后台管理跳过asin、后台相关数据做数据隔离

This commit is contained in:
super
2026-04-14 10:04:50 +08:00
parent 951a353881
commit 025ca6d4fd
70 changed files with 1675 additions and 381 deletions

View File

@@ -13,7 +13,7 @@ client_name=ShuFuAI
# java_api_base=http://47.111.163.154:18080
# java_api_base=http://127.0.0.1:18080
java_api_base=http://8.136.19.173:18080
java_api_base=http://127.0.0.1:18080
# java_api_base=http://8.136.19.173:18080

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>格式转换 - 数富AI</title>
<script type="module" crossorigin src="/assets/convert.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-D808cNhy.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DRn3N4FA.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BLoeiUcF.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
</head>
<body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据去重 - 数富AI</title>
<script type="module" crossorigin src="/assets/dedupe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-D808cNhy.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DRn3N4FA.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BLoeiUcF.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
</head>
<body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>删除品牌 - 数富AI</title>
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-D808cNhy.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DRn3N4FA.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BLoeiUcF.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-DfRLfrtm.css">
</head>
<body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据拆分 - 数富AI</title>
<script type="module" crossorigin src="/assets/split.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-D808cNhy.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DRn3N4FA.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BLoeiUcF.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
</head>
<body>

Binary file not shown.

Binary file not shown.

View File

@@ -21,6 +21,12 @@ public class DeleteBrandProgressProperties {
private long productRiskStaleTimeoutMinutes = 10;
private long productRiskInitialTimeoutMinutes = 10;
/**
* Price track RUNNING timeout auto-finalize/fail threshold in minutes.
*/
private long priceTrackStaleTimeoutMinutes = 10;
private long priceTrackInitialTimeoutMinutes = 10;
/**
* Shop match RUNNING timeout auto-finalize/fail threshold in minutes.
*/

View File

@@ -11,5 +11,5 @@ import java.util.List;
public class ModuleCleanupProperties {
private boolean enabled = true;
private String cron = "0 0 0 * * *";
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE"));
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK"));
}

View File

@@ -6,6 +6,8 @@ import com.nanri.aiimage.common.service.DistributedJobLockService;
import com.nanri.aiimage.config.DeleteBrandProgressProperties;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskCacheService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskCacheService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskCacheService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
@@ -28,6 +30,7 @@ public class DeleteBrandStaleTaskService {
private static final String MODULE_TYPE_DELETE_BRAND = "DELETE_BRAND";
private static final String MODULE_TYPE_PRODUCT_RISK = "PRODUCT_RISK_RESOLVE";
private static final String MODULE_TYPE_PRICE_TRACK = "PRICE_TRACK";
private static final String MODULE_TYPE_SHOP_MATCH = "SHOP_MATCH";
private static final Duration STALE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
private static final Duration FINALIZE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
@@ -37,6 +40,8 @@ public class DeleteBrandStaleTaskService {
private final DeleteBrandRunService deleteBrandRunService;
private final ProductRiskTaskService productRiskTaskService;
private final ProductRiskTaskCacheService productRiskTaskCacheService;
private final PriceTrackTaskService priceTrackTaskService;
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
private final ShopMatchTaskService shopMatchTaskService;
private final ShopMatchTaskCacheService shopMatchTaskCacheService;
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
@@ -55,6 +60,7 @@ public class DeleteBrandStaleTaskService {
log.info("[stale-check] scan started thread={}", Thread.currentThread().getName());
failStaleDeleteBrandTasks();
ProductRiskStaleCheckStats stats = failStaleProductRiskResolveTasks();
PriceTrackStaleCheckStats priceTrackStats = failStalePriceTrackTasks();
ShopMatchStaleCheckStats shopMatchStats = failStaleShopMatchTasks();
log.info("[stale-check] product-risk summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
stats.scannedTaskCount,
@@ -63,6 +69,13 @@ public class DeleteBrandStaleTaskService {
stats.skippedTaskCount,
System.currentTimeMillis() - startedAt,
Thread.currentThread().getName());
log.info("[stale-check] price-track summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
priceTrackStats.scannedTaskCount,
priceTrackStats.finalizedTaskCount,
priceTrackStats.failedTaskCount,
priceTrackStats.skippedTaskCount,
System.currentTimeMillis() - startedAt,
Thread.currentThread().getName());
log.info("[stale-check] shop-match summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
shopMatchStats.scannedTaskCount,
shopMatchStats.finalizedTaskCount,
@@ -196,6 +209,67 @@ public class DeleteBrandStaleTaskService {
return stats;
}
private PriceTrackStaleCheckStats failStalePriceTrackTasks() {
PriceTrackStaleCheckStats stats = new PriceTrackStaleCheckStats();
long minutes = Math.max(1L, deleteBrandProgressProperties.getPriceTrackStaleTimeoutMinutes());
long initialMinutes = Math.max(minutes, deleteBrandProgressProperties.getPriceTrackInitialTimeoutMinutes());
long staleTimeoutMillis = Duration.ofMinutes(minutes).toMillis();
long nowMillis = System.currentTimeMillis();
LocalDateTime now = LocalDateTime.now();
LocalDateTime threshold = now.minusMinutes(minutes);
LocalDateTime initialThreshold = now.minusMinutes(initialMinutes);
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_PRICE_TRACK)
.eq(FileTaskEntity::getStatus, "RUNNING")
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 200"));
stats.scannedTaskCount = runningTasks.size();
if (runningTasks.isEmpty()) {
return stats;
}
log.info("[stale-check] price-track candidates={} threshold={} timeoutMinutes={} initialThreshold={} initialTimeoutMinutes={}",
runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes);
for (FileTaskEntity task : runningTasks) {
long lastHeartbeatMillis = priceTrackTaskCacheService.getTaskHeartbeatMillis(task.getId());
boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0)
|| (task.getFailedFileCount() != null && task.getFailedFileCount() > 0);
boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasFinishedRows;
if (lastHeartbeatMillis > 0L && nowMillis - lastHeartbeatMillis < staleTimeoutMillis) {
stats.skippedTaskCount++;
continue;
}
if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) {
stats.skippedTaskCount++;
continue;
}
try {
if (priceTrackTaskService.tryFinalizeTask(task.getId(), true)) {
stats.finalizedTaskCount++;
log.info("[stale-check] price-track finalized taskId={}", task.getId());
continue;
}
} catch (Exception ex) {
log.warn("[stale-check] price-track finalize threw taskId={} msg={}", task.getId(), ex.getMessage());
}
int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getId, task.getId())
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_PRICE_TRACK)
.eq(FileTaskEntity::getStatus, "RUNNING")
.set(FileTaskEntity::getStatus, "FAILED")
.set(FileTaskEntity::getErrorMessage, "长时间未收到 Python 结果回传,任务已自动失败")
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())
.set(FileTaskEntity::getFinishedAt, LocalDateTime.now()));
if (updated > 0) {
stats.failedTaskCount++;
priceTrackTaskCacheService.deleteTaskCache(task.getId());
log.warn("[stale-check] price-track failed taskId={} reason=timeout", task.getId());
} else {
stats.skippedTaskCount++;
}
}
return stats;
}
private ShopMatchStaleCheckStats failStaleShopMatchTasks() {
ShopMatchStaleCheckStats stats = new ShopMatchStaleCheckStats();
long minutes = Math.max(1L, deleteBrandProgressProperties.getShopMatchStaleTimeoutMinutes());
@@ -291,4 +365,11 @@ public class DeleteBrandStaleTaskService {
private int failedTaskCount;
private int skippedTaskCount;
}
private static final class PriceTrackStaleCheckStats {
private int scannedTaskCount;
private int finalizedTaskCount;
private int failedTaskCount;
private int skippedTaskCount;
}
}

View File

@@ -2,16 +2,19 @@ package com.nanri.aiimage.modules.pricetrack.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCandidateAddRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackMatchShopsRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackTaskBatchRequest;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCandidateVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCountryPreferenceVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCreateTaskVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackDashboardVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackHistoryVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackPendingDeleteVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskBatchVo;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import io.swagger.v3.oas.annotations.Operation;
@@ -20,127 +23,164 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.net.URLEncoder;
import java.io.InputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/price-track")
@Tag(name = "跟价", description = "亚马逊跟价处理备选店铺、紫鸟匹配、创建任务、Python队列与回传。")
@Tag(
name = "跟价",
description = "亚马逊跟价处理模块包含备选店铺、紫鸟匹配、任务创建、Python 回传、任务轮询和结果下载接口。查询类接口需要携带 Query 参数 user_id。")
public class PriceTrackController {
private final PriceTrackService priceTrackService;
private final PriceTrackTaskService priceTrackTaskService;
@GetMapping("/candidates")
@Operation(summary = "查询备选店铺列表")
public ApiResponse<java.util.List<PriceTrackCandidateVo>> listCandidates(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY)
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在跟价模块中保存的备选店铺。")
public ApiResponse<List<PriceTrackCandidateVo>> listCandidates(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(priceTrackService.listCandidates(userId));
}
@PostMapping("/candidates")
@Operation(summary = "新增备选店铺")
@Operation(summary = "新增备选店铺", description = "把店铺名称写入当前用户的备选店铺列表,供后续匹配和创建任务使用。")
public ApiResponse<PriceTrackCandidateVo> addCandidate(@Valid @RequestBody PriceTrackCandidateAddRequest request) {
return ApiResponse.success(priceTrackService.addCandidate(request));
}
@DeleteMapping("/candidates/{id}")
@Operation(summary = "删除备选店铺")
@Operation(summary = "删除备选店铺", description = "删除一条备选店铺记录id 必须属于当前 user_id。")
public ApiResponse<Void> deleteCandidate(
@PathVariable Long id,
@Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) {
@Parameter(description = "备选店铺记录主键", example = "10") @PathVariable Long id,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
priceTrackService.deleteCandidate(userId, id);
return ApiResponse.success(null);
}
@GetMapping("/country-preference")
@Operation(summary = "查询国家处理顺序")
@Operation(summary = "查询国家处理顺序", description = "返回当前用户保存的国家代码列表,顺序即跟价任务处理顺序。")
public ApiResponse<PriceTrackCountryPreferenceVo> getCountryPreference(
@Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) {
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(priceTrackService.getCountryPreference(userId));
}
@PutMapping("/country-preference")
@Operation(summary = "保存国家处理顺序")
@Operation(summary = "保存国家处理顺序", description = "保存当前用户在跟价模块中的国家勾选和处理顺序。")
public ApiResponse<PriceTrackCountryPreferenceVo> saveCountryPreference(
@Valid @RequestBody PriceTrackCountryPreferenceSaveRequest request) {
return ApiResponse.success(priceTrackService.saveCountryPreference(request));
}
@PostMapping("/match-shops")
@Operation(summary = "批量匹配店铺")
@Operation(summary = "批量匹配店铺", description = "按店铺名查询紫鸟索引,返回 shopId、platform、companyName、matchStatus 等信息。")
public ApiResponse<PriceTrackMatchShopsVo> matchShops(@Valid @RequestBody PriceTrackMatchShopsRequest request) {
return ApiResponse.success(priceTrackService.matchShops(request));
}
@GetMapping("/dashboard")
@Operation(summary = "统计看板")
@Operation(summary = "统计看板", description = "返回备选店铺数、已结束任务数、成功任务数和失败任务数。")
public ApiResponse<PriceTrackDashboardVo> dashboard(
@Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) {
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(priceTrackTaskService.dashboard(userId));
}
@GetMapping("/history")
@Operation(summary = "处理记录列表")
@Operation(summary = "处理记录列表", description = "返回当前用户跟价模块下各店铺结果行,包含运行中和历史记录。")
public ApiResponse<PriceTrackHistoryVo> history(
@Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) {
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(priceTrackTaskService.listHistory(userId));
}
@DeleteMapping("/history/{resultId}")
@Operation(summary = "删除单条结果记录")
@Operation(summary = "删除单条结果记录", description = "删除一条 biz_file_result并同步重算父任务状态。")
public ApiResponse<Void> deleteHistory(
@PathVariable Long resultId,
@Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) {
@Parameter(description = "结果行主键 biz_file_result.id", example = "1001") @PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
priceTrackTaskService.deleteHistory(resultId, userId);
return ApiResponse.success(null);
}
@PostMapping("/tasks")
@Operation(summary = "创建跟价处理任务")
@Operation(summary = "创建跟价任务", description = "落库 RUNNING 任务和各店铺占位结果,返回 taskId 和初始 items 快照。")
public ApiResponse<PriceTrackCreateTaskVo> createTask(@Valid @RequestBody PriceTrackCreateTaskRequest request) {
return ApiResponse.success(priceTrackTaskService.createTask(request));
}
@DeleteMapping("/tasks/{taskId}")
@Operation(summary = "删除整条任务")
@Operation(summary = "删除整条任务", description = "删除该任务以及其下属全部结果行RUNNING、SUCCESS、FAILED 均可。")
public ApiResponse<Void> deleteTask(
@PathVariable Long taskId,
@Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) {
@Parameter(description = "任务主键 biz_file_task.id", example = "200") @PathVariable Long taskId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
priceTrackTaskService.deleteTask(taskId, userId);
return ApiResponse.success(null);
}
@DeleteMapping("/pending-shop-result")
@Operation(summary = "按店铺名删除运行中结果", description = "仅在 RUNNING 任务下按规范化店名删除一条 biz_file_result并同步重算任务状态。")
public ApiResponse<PriceTrackPendingDeleteVo> deletePendingShopResult(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId,
@Parameter(name = "shop_name", description = "店铺名称", required = true, in = ParameterIn.QUERY, example = "某某店铺")
@RequestParam("shop_name") String shopName) {
return ApiResponse.success(priceTrackTaskService.deletePendingShopResult(userId, shopName));
}
@PostMapping("/tasks/batch")
@Operation(summary = "批量查询任务详情", description = "按 taskIds 返回任务概要和各店铺结果,供前端轮询使用。")
public ApiResponse<PriceTrackTaskBatchVo> tasksBatch(@Valid @RequestBody PriceTrackTaskBatchRequest request) {
return ApiResponse.success(priceTrackTaskService.getTaskDetailsBatch(request.getTaskIds()));
}
@PostMapping("/tasks/{taskId}/result")
@Operation(summary = "Python回传处理结果")
public ApiResponse<Void> submitResult(@PathVariable Long taskId,
@Operation(summary = "Python 回传处理结果", description = "同一 taskId 可多次回传。成功时按 countries 生成结果文件,所有店铺进入终态后任务会变为 SUCCESS 或 FAILED。")
public ApiResponse<Void> submitResult(
@Parameter(description = "任务主键,必须处于 RUNNING 状态", example = "200") @PathVariable Long taskId,
@Valid @RequestBody PriceTrackSubmitResultRequest request) {
priceTrackTaskService.submitResult(taskId, request);
return ApiResponse.success(null);
}
@GetMapping("/results/{resultId}/download")
@Operation(summary = "下载结果文件")
@Operation(summary = "下载结果文件", description = "按结果记录下载后端生成的跟价 Excel 文件。")
public void downloadResult(
@PathVariable Long resultId,
@Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId,
@Parameter(description = "结果记录主键 biz_file_result.id", example = "1001") @PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId,
jakarta.servlet.http.HttpServletResponse response) {
String url = priceTrackTaskService.resolveResultDownloadUrl(resultId, userId);
String filename = priceTrackTaskService.resolveResultDownloadFilename(resultId, userId);
if (url == null || url.isBlank()) {
throw new ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "暂无可下载结果");
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果");
}
try {
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
response.setContentType("application/octet-stream");
response.setHeader(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename);
try (InputStream in = URI.create(url).toURL().openStream()) {
byte[] buffer = new byte[65536];
@@ -151,7 +191,7 @@ public class PriceTrackController {
response.getOutputStream().flush();
}
} catch (Exception ex) {
throw new ResponseStatusException(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
}
}
}

View File

@@ -1,14 +1,18 @@
package com.nanri.aiimage.modules.pricetrack.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "新增备选店铺请求体:写入用户维度的待处理店铺列表,供前端勾选与匹配")
public class PriceTrackCandidateAddRequest {
@NotNull(message = "user_id不能为空")
@Schema(description = "当前用户 ID数据按用户隔离", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long userId;
@NotBlank(message = "店铺名不能为空")
@NotBlank(message = "店铺名不能为空")
@Schema(description = "店铺名称,入库前会先做规范化和去重", requiredMode = Schema.RequiredMode.REQUIRED, example = "测试店铺A")
private String shopName;
}

View File

@@ -1,15 +1,20 @@
package com.nanri.aiimage.modules.pricetrack.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "保存用户五国处理顺序:代码仅支持 DE、UK、FR、IT、ES且不能重复")
public class PriceTrackCountryPreferenceSaveRequest {
@NotNull(message = "user_id不能为空")
@Schema(description = "当前用户 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long userId;
@NotEmpty(message = "国家列表不能为空")
@Schema(description = "国家代码列表,顺序即任务处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]")
private List<String> countryCodes;
}

View File

@@ -1,35 +1,36 @@
package com.nanri.aiimage.modules.pricetrack.model.dto;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
@Schema(description = "创建跟价任务请求体:在推送 Python 队列前调用,用于落库任务和店铺结果占位记录")
public class PriceTrackCreateTaskRequest {
@NotNull(message = "user_id不能为空")
@Schema(description = "任务所属用户 ID会写入 biz_file_task.user_id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long userId;
/** 跟价模式status_mode=按商品状态(全量), asin_mode=按指定ASIN(自定义) */
@Schema(description = "是否启用按商品状态处理模式")
private boolean statusMode;
@Schema(description = "是否启用按指定 ASIN 文件处理模式")
private boolean asinMode;
/** 匹配好的店铺队列项 */
@NotEmpty(message = "店铺列表不能为空")
private List<Map<String, Object>> items;
@Valid
@Schema(description = "已匹配的店铺列表,每项都应为 matched=true服务端会按规范化店名去重", requiredMode = Schema.RequiredMode.REQUIRED)
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items;
/** ASIN文档路径asinMode时使用 */
@Schema(description = "ASIN 文件路径列表asinMode=true 时由前端上传或选择后传入", example = "[\"/tmp/a.xlsx\",\"/tmp/b.csv\"]")
private List<String> asinFiles;
/** 国家代码列表 */
@NotEmpty(message = "国家列表不能为空")
@Schema(description = "国家代码列表,顺序即跟价处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]")
private List<String> countryCodes;
/** 价格区间 */
private Double minPrice;
private Double maxPrice;
/** 价差比例(% */
private Double priceDiffPercent;
}

View File

@@ -1,15 +1,20 @@
package com.nanri.aiimage.modules.pricetrack.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "批量匹配店铺请求体:按店铺名查询紫鸟索引,返回是否命中及店铺元数据")
public class PriceTrackMatchShopsRequest {
@NotNull(message = "user_id不能为空")
@Schema(description = "当前用户 ID用于审计和数据归属", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long userId;
@NotEmpty(message = "店铺列表不能为空")
@Schema(description = "待匹配的店铺名称列表,至少 1 个;返回体 items 与列表顺序对应", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"店铺甲\",\"店铺乙\"]")
private List<String> shopNames;
}

View File

@@ -1,27 +1,49 @@
package com.nanri.aiimage.modules.pricetrack.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
@Schema(description = "Python 回传跟价结果请求体:按店铺提交各国家 ASIN 跟价结果或错误信息")
public class PriceTrackSubmitResultRequest {
@Schema(description = "本次回传涉及的店铺列表,至少 1 个,店名需要能和任务结果行匹配", requiredMode = Schema.RequiredMode.REQUIRED)
private List<ShopResult> shops;
@Data
@Schema(description = "单个店铺回传结果:成功时提供 countries失败时可只提供 error")
public static class ShopResult {
@Schema(description = "店铺名称,用于和任务内结果行匹配", example = "测试店铺A")
private String shopName;
@Schema(description = "按国家分组的结果行key 为国家代码,例如 DE、UK、FR、IT、ES")
private Map<String, List<AsinResult>> countries;
@Schema(description = "失败时的错误信息;非空则该店铺记为失败")
private String error;
@Schema(description = "是否成功false 按失败处理true 或 null 通常表示结合 countries 判定")
private Boolean success;
}
@Data
@Schema(description = "单条 ASIN 跟价结果")
public static class AsinResult {
@Schema(description = "ASIN", example = "B0ABCDE123")
private String asin;
@Schema(description = "当前店铺价格", example = "19.99")
private String currentPrice;
@Schema(description = "竞争对手价格", example = "18.50")
private String competitorPrice;
@Schema(description = "建议动作或跟价动作", example = "LOWER_PRICE")
private String action;
@Schema(description = "该行是否已处理完成")
private Boolean done;
}
}

View File

@@ -0,0 +1,16 @@
package com.nanri.aiimage.modules.pricetrack.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "批量查询任务详情请求体:一次拉取多个 taskId 的概要和下属店铺结果,供轮询使用")
public class PriceTrackTaskBatchRequest {
@NotEmpty(message = "taskIds不能为空")
@Schema(description = "待查询的任务主键列表,无效或非本模块 taskId 会出现在 missingTaskIds", requiredMode = Schema.RequiredMode.REQUIRED, example = "[200,201]")
private List<Long> taskIds = new ArrayList<>();
}

View File

@@ -1,11 +1,16 @@
package com.nanri.aiimage.modules.pricetrack.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain = true)
@Schema(description = "备选店铺列表单项")
public class PriceTrackCandidateVo {
@Schema(description = "记录主键,删除备选店铺时作为路径参数", example = "10")
private Long id;
@Schema(description = "店铺名称", example = "测试店铺A")
private String shopName;
}

View File

@@ -1,10 +1,16 @@
package com.nanri.aiimage.modules.pricetrack.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "用户五国处理偏好:按顺序返回国家代码列表,例如 DE、UK、FR、IT、ES")
public class PriceTrackCountryPreferenceVo {
@Schema(description = "当前用户 ID", example = "1")
private Long userId;
@Schema(description = "已选国家代码列表;若用户未保存,则服务端返回默认顺序 DE、UK、FR、IT、ES")
private List<String> countryCodes;
}

View File

@@ -1,11 +1,16 @@
package com.nanri.aiimage.modules.pricetrack.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
@Schema(description = "创建任务响应:包含任务 id 和各店铺的初始结果快照")
public class PriceTrackCreateTaskVo {
@Schema(description = "新建任务主键 biz_file_task.id前端推送队列时需要回传该值", example = "200")
private Long taskId;
private List<Map<String, Object>> items;
@Schema(description = "各店铺一条初始结果快照,包含 resultId、taskId、店铺信息和初始任务状态")
private List<PriceTrackResultItemVo> items;
}

View File

@@ -1,13 +1,22 @@
package com.nanri.aiimage.modules.pricetrack.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain = true)
@Schema(description = "跟价模块统计看板,按当前用户维度汇总")
public class PriceTrackDashboardVo {
private int candidateCount;
private int processedTaskCount;
private int successTaskCount;
private int failedTaskCount;
@Schema(description = "备选店铺数量")
private Long candidateCount;
@Schema(description = "已结束任务数,状态为 SUCCESS 或 FAILED")
private Long processedTaskCount;
@Schema(description = "成功结束的任务数")
private Long successTaskCount;
@Schema(description = "失败结束的任务数")
private Long failedTaskCount;
}

View File

@@ -1,9 +1,13 @@
package com.nanri.aiimage.modules.pricetrack.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "跟价结果列表响应items 中每项对应一个店铺结果行")
public class PriceTrackHistoryVo {
@Schema(description = "结果列表,包含运行中和历史记录")
private List<PriceTrackResultItemVo> items;
}

View File

@@ -1,20 +1,39 @@
package com.nanri.aiimage.modules.pricetrack.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "批量匹配店铺响应")
public class PriceTrackMatchShopsVo {
private List<PriceTrackShopQueueItem> items;
@Schema(description = "逐店返回的匹配结果,顺序与请求 shopNames 对应")
private List<PriceTrackShopQueueItem> items = new ArrayList<>();
@Data
@Schema(description = "单店匹配结果,同时也是创建任务时 items 的结构")
public static class PriceTrackShopQueueItem {
@Schema(description = "店铺名称", example = "某某店铺")
private String shopName;
private Long shopId;
@Schema(description = "紫鸟店铺 ID未命中时可能为空")
private Object shopId;
@Schema(description = "平台,例如亚马逊", example = "亚马逊")
private String platform;
@Schema(description = "公司名称或主体标识")
private String companyName;
@Schema(description = "是否命中紫鸟索引false 时不可用于创建任务")
private Boolean matched;
@Schema(description = "匹配状态码,例如 MATCHED、PENDING、CONFLICT、INDEX_STALE")
private String matchStatus;
@Schema(description = "人类可读说明,供前端展示")
private String matchMessage;
}
}

View File

@@ -0,0 +1,11 @@
package com.nanri.aiimage.modules.pricetrack.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "按店铺名删除运行中结果的响应")
public class PriceTrackPendingDeleteVo {
@Schema(description = "是否实际删除到一条运行中的结果记录")
private boolean removed;
}

View File

@@ -1,23 +1,52 @@
package com.nanri.aiimage.modules.pricetrack.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain = true)
@Schema(description = "单店结果项:列表接口与任务详情共用,对应 biz_file_result 一行以及请求衍生字段")
public class PriceTrackResultItemVo {
@Schema(description = "结果行主键 biz_file_result.id", example = "1001")
private Long resultId;
@Schema(description = "所属任务主键 biz_file_task.id", example = "200")
private Long taskId;
@Schema(description = "店铺名称,详情页会尽量恢复创建任务时的展示名")
private String shopName;
@Schema(description = "店铺 ID紫鸟")
private Object shopId;
@Schema(description = "平台")
private String platform;
@Schema(description = "公司名称")
private String companyName;
@Schema(description = "创建任务时是否已匹配")
private Boolean matched;
@Schema(description = "匹配状态码,含义同 PriceTrackShopQueueItem.matchStatus")
private String matchStatus;
@Schema(description = "匹配说明")
private String matchMessage;
@Schema(description = "所属任务当前状态RUNNING、SUCCESS、FAILED")
private String taskStatus;
@Schema(description = "该店铺是否成功生成结果文件null 或 false 可视为未完成或失败")
private Boolean success;
@Schema(description = "失败时的错误信息")
private String error;
@Schema(description = "生成的 xlsx 文件名")
private String outputFilename;
@Schema(description = "预签名下载 URL列表可能带回也可通过下载接口获取")
private String downloadUrl;
}

View File

@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.pricetrack.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "批量任务查询响应:每个 taskId 返回一条详情;无效 id 放到 missingTaskIds")
public class PriceTrackTaskBatchVo {
@Schema(description = "存在且属于跟价模块的任务详情列表")
private List<PriceTrackTaskDetailVo> items = new ArrayList<>();
@Schema(description = "请求中不存在或不属于跟价模块的 taskId 列表")
private List<Long> missingTaskIds = new ArrayList<>();
}

View File

@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.pricetrack.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "单个任务详情:任务级概要和下属店铺结果列表")
public class PriceTrackTaskDetailVo {
@Schema(description = "任务级字段,例如状态、错误信息、时间等")
private PriceTrackTaskItemVo task = new PriceTrackTaskItemVo();
@Schema(description = "该任务下每个店铺对应一条结果记录")
private List<PriceTrackResultItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,29 @@
package com.nanri.aiimage.modules.pricetrack.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "跟价任务概要")
public class PriceTrackTaskItemVo {
@Schema(description = "任务主键", example = "200")
private Long id;
@Schema(description = "任务编号")
private String taskNo;
@Schema(description = "任务状态RUNNING、SUCCESS、FAILED")
private String status;
@Schema(description = "任务错误信息;部分成功或失败时可能有值")
private String errorMessage;
@Schema(description = "创建时间")
private String createdAt;
@Schema(description = "更新时间")
private String updatedAt;
@Schema(description = "完成时间;未结束时为空")
private String finishedAt;
}

View File

@@ -0,0 +1,85 @@
package com.nanri.aiimage.modules.pricetrack.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.FileOutputStream;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
@Slf4j
public class PriceTrackExcelAssemblyService {
private static final String[] HEADER = {"ASIN", "当前店铺价格", "竞争对手价格", "建议动作", "是否完成"};
public void writeWorkbook(File outputXlsx, Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> countries) {
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> safe = countries == null ? Map.of() : countries;
try (XSSFWorkbook wb = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(outputXlsx)) {
for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) {
String sheetName = code.name();
Sheet sheet = wb.createSheet(sheetName);
Row headerRow = sheet.createRow(0);
for (int i = 0; i < HEADER.length; i++) {
headerRow.createCell(i).setCellValue(HEADER[i]);
}
List<PriceTrackSubmitResultRequest.AsinResult> rows = safe.getOrDefault(sheetName, List.of());
int rowIdx = 1;
for (PriceTrackSubmitResultRequest.AsinResult item : rows) {
if (item == null) {
continue;
}
Row row = sheet.createRow(rowIdx++);
row.createCell(0).setCellValue(item.getAsin() == null ? "" : item.getAsin());
row.createCell(1).setCellValue(item.getCurrentPrice() == null ? "" : item.getCurrentPrice());
row.createCell(2).setCellValue(item.getCompetitorPrice() == null ? "" : item.getCompetitorPrice());
row.createCell(3).setCellValue(item.getAction() == null ? "" : item.getAction());
row.createCell(4).setCellValue(item.getDone() == null ? "" : String.valueOf(item.getDone()));
}
for (int i = 0; i < HEADER.length; i++) {
sheet.autoSizeColumn(i);
}
}
wb.write(fos);
} catch (Exception ex) {
log.warn("[price-track] write workbook failed: {}", ex.getMessage());
throw new BusinessException("生成 Excel 失败: " + ex.getMessage());
}
}
public int countRows(Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> countries) {
if (countries == null || countries.isEmpty()) {
return 0;
}
int count = 0;
for (List<PriceTrackSubmitResultRequest.AsinResult> rows : countries.values()) {
if (rows != null) {
count += rows.size();
}
}
return count;
}
public Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> normalizeCountriesMap(
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> raw) {
if (raw == null || raw.isEmpty()) {
return new LinkedHashMap<>();
}
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> out = new LinkedHashMap<>();
for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) {
List<PriceTrackSubmitResultRequest.AsinResult> rows = raw.get(code.name());
if (rows != null) {
out.put(code.name(), rows);
}
}
return out;
}
}

View File

@@ -14,7 +14,6 @@ import com.nanri.aiimage.modules.pricetrack.model.entity.PriceTrackShopCandidate
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCandidateVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCountryPreferenceVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackShopQueueItem;
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
@@ -52,7 +51,9 @@ public class PriceTrackService {
.orderByDesc(PriceTrackShopCandidateEntity::getId));
List<PriceTrackCandidateVo> list = new ArrayList<>();
for (PriceTrackShopCandidateEntity row : rows) {
if (row == null) continue;
if (row == null) {
continue;
}
PriceTrackCandidateVo vo = new PriceTrackCandidateVo();
vo.setId(row.getId());
vo.setShopName(row.getShopName());
@@ -68,20 +69,22 @@ public class PriceTrackService {
}
String normalized = ziniaoShopSwitchService.normalizeShopName(request.getShopName());
if (normalized.isBlank()) {
throw new BusinessException("店铺名不能为空");
throw new BusinessException("店铺名不能为空");
}
// 店铺需在紫鸟索引中命中才能加入备选
ZiniaoShopMatchResultVo indexHit = ziniaoShopSwitchService.findIndexedStoreByName(normalized, false);
if (indexHit == null || !indexHit.isMatched()) {
String hint = (indexHit != null && indexHit.getMatchMessage() != null)
? indexHit.getMatchMessage() : "店铺索引未命中,无法加入备选";
String hint = indexHit != null && indexHit.getMatchMessage() != null
? indexHit.getMatchMessage()
: "店铺未命中紫鸟索引,无法加入备选列表";
throw new BusinessException(hint);
}
if (ZiniaoShopIndexService.MATCH_STATUS_CONFLICT.equals(indexHit.getMatchStatus())) {
throw new BusinessException(indexHit.getMatchMessage() != null
? indexHit.getMatchMessage() : "存在多个同名店铺,请人工确认");
? indexHit.getMatchMessage()
: "存在多个同名店铺,请先人工确认");
}
// 查重
PriceTrackShopCandidateEntity existing = candidateMapper.selectOne(
new LambdaQueryWrapper<PriceTrackShopCandidateEntity>()
.eq(PriceTrackShopCandidateEntity::getUserId, request.getUserId())
@@ -93,11 +96,13 @@ public class PriceTrackService {
vo.setShopName(existing.getShopName());
return vo;
}
PriceTrackShopCandidateEntity entity = new PriceTrackShopCandidateEntity();
entity.setUserId(request.getUserId());
entity.setShopName(normalized);
entity.setCreatedAt(LocalDateTime.now());
candidateMapper.insert(entity);
PriceTrackCandidateVo vo = new PriceTrackCandidateVo();
vo.setId(entity.getId());
vo.setShopName(entity.getShopName());
@@ -106,8 +111,12 @@ public class PriceTrackService {
@Transactional
public void deleteCandidate(Long userId, Long id) {
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
if (id == null || id <= 0) throw new BusinessException("id 不合法");
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
if (id == null || id <= 0) {
throw new BusinessException("id 不合法");
}
PriceTrackShopCandidateEntity row = candidateMapper.selectById(id);
if (row == null || !userId.equals(row.getUserId())) {
throw new BusinessException("记录不存在");
@@ -121,12 +130,15 @@ public class PriceTrackService {
}
LinkedHashSet<String> ordered = new LinkedHashSet<>();
for (String raw : request.getShopNames()) {
String n = ziniaoShopSwitchService.normalizeShopName(raw);
if (!n.isBlank()) ordered.add(n);
String normalized = ziniaoShopSwitchService.normalizeShopName(raw);
if (!normalized.isBlank()) {
ordered.add(normalized);
}
}
if (ordered.isEmpty()) {
throw new BusinessException("shop_names 有效店铺名");
throw new BusinessException("shop_names 没有有效店铺名");
}
PriceTrackMatchShopsVo vo = new PriceTrackMatchShopsVo();
for (String shopName : ordered) {
vo.getItems().add(matchOneShop(shopName));
@@ -135,7 +147,9 @@ public class PriceTrackService {
}
public PriceTrackCountryPreferenceVo getCountryPreference(Long userId) {
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
PriceTrackCountryPrefEntity row = countryPrefMapper.selectById(userId);
PriceTrackCountryPreferenceVo vo = new PriceTrackCountryPreferenceVo();
if (row == null || row.getCountryCodesJson() == null || row.getCountryCodesJson().isBlank()) {
@@ -156,14 +170,17 @@ public class PriceTrackService {
@Transactional
public PriceTrackCountryPreferenceVo saveCountryPreference(PriceTrackCountryPreferenceSaveRequest request) {
if (request.getUserId() == null || request.getUserId() <= 0) throw new BusinessException("user_id 不合法");
if (request.getUserId() == null || request.getUserId() <= 0) {
throw new BusinessException("user_id 不合法");
}
List<String> normalized = validateCountryCodesForSave(request.getCountryCodes());
String json;
try {
json = objectMapper.writeValueAsString(normalized);
} catch (Exception ex) {
throw new BusinessException("保存偏好失败");
throw new BusinessException("保存国家偏好失败");
}
PriceTrackCountryPrefEntity row = countryPrefMapper.selectById(request.getUserId());
if (row == null) {
row = new PriceTrackCountryPrefEntity();
@@ -174,29 +191,30 @@ public class PriceTrackService {
row.setCountryCodesJson(json);
countryPrefMapper.updateById(row);
}
PriceTrackCountryPreferenceVo vo = new PriceTrackCountryPreferenceVo();
vo.setUserId(request.getUserId());
vo.setCountryCodes(normalized);
return vo;
}
private PriceTrackShopQueueItem matchOneShop(String shopName) {
PriceTrackShopQueueItem item = new PriceTrackShopQueueItem();
private PriceTrackMatchShopsVo.PriceTrackShopQueueItem matchOneShop(String shopName) {
PriceTrackMatchShopsVo.PriceTrackShopQueueItem item = new PriceTrackMatchShopsVo.PriceTrackShopQueueItem();
item.setShopName(shopName);
try {
ZiniaoShopMatchResultVo m = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false);
if (m == null) {
ZiniaoShopMatchResultVo match = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false);
if (match == null) {
item.setMatched(false);
item.setMatchStatus("PENDING");
item.setMatchMessage("匹配结果为空");
item.setMatchMessage("未查询到匹配结果");
return item;
}
item.setMatched(m.isMatched());
item.setShopId(m.getShopId());
item.setPlatform(m.getPlatform());
item.setCompanyName(m.getCompanyName());
item.setMatchStatus(m.getMatchStatus());
item.setMatchMessage(m.getMatchMessage());
item.setMatched(match.isMatched());
item.setShopId(match.getShopId());
item.setPlatform(match.getPlatform());
item.setCompanyName(match.getCompanyName());
item.setMatchStatus(match.getMatchStatus());
item.setMatchMessage(match.getMatchMessage());
} catch (BusinessException ex) {
item.setMatched(false);
item.setMatchStatus("PENDING");
@@ -211,34 +229,54 @@ public class PriceTrackService {
private static List<String> sanitizeStoredCodes(List<String> raw) {
List<String> parsed = parseValidCountryCodes(raw);
if (parsed.isEmpty()) return new ArrayList<>(DEFAULT_COUNTRY_PREFERENCE_ORDER);
if (parsed.isEmpty()) {
return new ArrayList<>(DEFAULT_COUNTRY_PREFERENCE_ORDER);
}
return parsed;
}
private static List<String> validateCountryCodesForSave(List<String> raw) {
if (raw == null || raw.isEmpty()) throw new BusinessException("country_codes 至少选择 1 个国家");
if (raw == null || raw.isEmpty()) {
throw new BusinessException("country_codes 至少选择 1 个国家");
}
LinkedHashSet<String> seen = new LinkedHashSet<>();
List<String> out = new ArrayList<>();
for (String s : raw) {
if (s == null || s.isBlank()) throw new BusinessException("country_codes 含空项");
String u = s.trim().toUpperCase();
if (!SUPPORTED_COUNTRIES.contains(u)) throw new BusinessException("非法国家代码: " + s);
if (!seen.add(u)) throw new BusinessException("country_codes 存在重复: " + u);
out.add(u);
for (String code : raw) {
if (code == null || code.isBlank()) {
throw new BusinessException("country_codes 不能包含空值");
}
String normalized = code.trim().toUpperCase();
if (!SUPPORTED_COUNTRIES.contains(normalized)) {
throw new BusinessException("非法国家代码: " + code);
}
if (!seen.add(normalized)) {
throw new BusinessException("country_codes 存在重复值: " + normalized);
}
out.add(normalized);
}
if (out.isEmpty()) {
throw new BusinessException("country_codes 至少选择 1 个国家");
}
if (out.isEmpty()) throw new BusinessException("country_codes 至少选择 1 个国家");
return out;
}
private static List<String> parseValidCountryCodes(List<String> raw) {
if (raw == null || raw.isEmpty()) return new ArrayList<>();
if (raw == null || raw.isEmpty()) {
return new ArrayList<>();
}
LinkedHashSet<String> seen = new LinkedHashSet<>();
List<String> out = new ArrayList<>();
for (String s : raw) {
if (s == null || s.isBlank()) continue;
String u = s.trim().toUpperCase();
if (!SUPPORTED_COUNTRIES.contains(u)) continue;
if (seen.add(u)) out.add(u);
for (String code : raw) {
if (code == null || code.isBlank()) {
continue;
}
String normalized = code.trim().toUpperCase();
if (!SUPPORTED_COUNTRIES.contains(normalized)) {
continue;
}
if (seen.add(normalized)) {
out.add(normalized);
}
}
return out;
}

View File

@@ -0,0 +1,53 @@
package com.nanri.aiimage.modules.pricetrack.service;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
@Service
@RequiredArgsConstructor
public class PriceTrackTaskCacheService {
private static final long HEARTBEAT_TTL_HOURS = 24;
private final StringRedisTemplate stringRedisTemplate;
public void touchTaskHeartbeat(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
stringRedisTemplate.opsForValue().set(
buildTaskHeartbeatKey(taskId),
String.valueOf(Instant.now().toEpochMilli()),
Duration.ofHours(HEARTBEAT_TTL_HOURS));
}
public long getTaskHeartbeatMillis(Long taskId) {
if (taskId == null || taskId <= 0) {
return 0L;
}
String raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId));
if (raw == null || raw.isBlank()) {
return 0L;
}
try {
return Long.parseLong(raw);
} catch (NumberFormatException ignored) {
return 0L;
}
}
public void deleteTaskCache(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
}
private String buildTaskHeartbeatKey(Long taskId) {
return "price-track:task:heartbeat:" + taskId;
}
}

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.pricetrack.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
@@ -11,28 +12,28 @@ import com.nanri.aiimage.modules.pricetrack.model.entity.PriceTrackShopCandidate
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCreateTaskVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackDashboardVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackHistoryVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackPendingDeleteVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackResultItemVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackShopQueueItem;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskBatchVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskDetailVo;
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskItemVo;
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.ZipUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
@@ -42,7 +43,7 @@ import java.util.Objects;
public class PriceTrackTaskService {
private static final String MODULE_TYPE = "PRICE_TRACK";
private static final String CONTENT_TYPE_ZIP = "application/zip";
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper;
@@ -51,6 +52,8 @@ public class PriceTrackTaskService {
private final OssStorageService ossStorageService;
private final ObjectMapper objectMapper;
private final SkipPriceAsinService skipPriceAsinService;
private final PriceTrackExcelAssemblyService excelAssemblyService;
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
public PriceTrackDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
@@ -129,6 +132,51 @@ public class PriceTrackTaskService {
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
fileTaskMapper.deleteById(taskId);
priceTrackTaskCacheService.deleteTaskCache(taskId);
}
@Transactional
public PriceTrackPendingDeleteVo deletePendingShopResult(Long userId, String shopName) {
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
if (shopName == null || shopName.isBlank()) throw new BusinessException("shop_name 涓嶈兘涓虹┖");
String norm = ziniaoShopSwitchService.normalizeShopName(shopName);
if (norm.isBlank()) throw new BusinessException("店铺名称无效");
List<FileResultEntity> candidates = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getUserId, userId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.eq(FileResultEntity::getSourceFilename, norm)
.orderByDesc(FileResultEntity::getId)
.last("limit 30"));
PriceTrackPendingDeleteVo vo = new PriceTrackPendingDeleteVo();
vo.setRemoved(false);
for (FileResultEntity fr : candidates) {
if (fr.getTaskId() == null) continue;
FileTaskEntity task = fileTaskMapper.selectById(fr.getTaskId());
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) continue;
if (!"RUNNING".equals(task.getStatus())) continue;
fileResultMapper.deleteById(fr.getId());
reconcileTaskAfterResultRemoval(fr.getTaskId());
vo.setRemoved(true);
return vo;
}
return vo;
}
public PriceTrackTaskBatchVo getTaskDetailsBatch(List<Long> taskIds) {
PriceTrackTaskBatchVo batch = new PriceTrackTaskBatchVo();
if (taskIds == null || taskIds.isEmpty()) return batch;
for (Long taskId : taskIds) {
if (taskId == null || taskId <= 0) continue;
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
batch.getMissingTaskIds().add(taskId);
continue;
}
batch.getItems().add(buildTaskDetail(task));
}
return batch;
}
@Transactional
@@ -137,18 +185,18 @@ public class PriceTrackTaskService {
if (request.getItems() == null || request.getItems().isEmpty()) throw new BusinessException("items 不能为空");
// 去重并校验匹配状态
List<PriceTrackShopQueueItem> uniqueItems = dedupeQueueItems(request.getItems());
List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> uniqueItems = dedupeQueueItems(request.getItems());
if (uniqueItems.isEmpty()) throw new BusinessException("items 不能为空");
for (PriceTrackShopQueueItem item : uniqueItems) {
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : uniqueItems) {
if (item == null || !Boolean.TRUE.equals(item.getMatched())) {
String name = item == null ? "" : Objects.toString(item.getShopName(), "");
throw new BusinessException("存在未匹配店铺,无法创建任务: " + name);
}
}
// 收集店铺名查询跳过ASIN
// 汇总店铺名查询需要跳过的 ASIN
List<String> shopNames = uniqueItems.stream()
.map(PriceTrackShopQueueItem::getShopName)
.map(PriceTrackMatchShopsVo.PriceTrackShopQueueItem::getShopName)
.filter(s -> s != null && !s.isBlank())
.distinct()
.toList();
@@ -169,11 +217,12 @@ public class PriceTrackTaskService {
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.insert(task);
priceTrackTaskCacheService.touchTaskHeartbeat(task.getId());
List<PriceTrackResultItemVo> snapshot = new ArrayList<>();
for (PriceTrackShopQueueItem item : uniqueItems) {
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : uniqueItems) {
String norm = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (norm.isBlank()) throw new BusinessException("店铺名无效");
if (norm.isBlank()) throw new BusinessException("店铺名无效");
FileResultEntity fr = new FileResultEntity();
fr.setTaskId(task.getId());
fr.setModuleType(MODULE_TYPE);
@@ -186,16 +235,13 @@ public class PriceTrackTaskService {
snapshot.add(toSnapshotVo(fr, item, task.getStatus()));
}
// 保存请求上下文含skipASIN用于Python后续查询
// 保存请求上下文快照,供 Python 消费和前端轮询展示使用
try {
Map<String, Object> ctx = new LinkedHashMap<>();
ctx.put("userId", request.getUserId());
ctx.put("statusMode", request.isStatusMode());
ctx.put("asinMode", request.isAsinMode());
ctx.put("countryCodes", request.getCountryCodes());
ctx.put("minPrice", request.getMinPrice());
ctx.put("maxPrice", request.getMaxPrice());
ctx.put("priceDiffPercent", request.getPriceDiffPercent());
ctx.put("skipAsinsByShop", skipAsinsByShop);
ctx.put("items", uniqueItems);
task.setRequestJson(objectMapper.writeValueAsString(ctx));
@@ -205,7 +251,7 @@ public class PriceTrackTaskService {
}
fileTaskMapper.updateById(task);
// TODO: Python侧定时轮询 RUNNING 状态任务, pickup 后开始处理
// TODO: Python 侧轮询 RUNNING 任务pickup 后开始处理
// Java 侧通过 submitResult 接口接收 Python 回传结果
PriceTrackCreateTaskVo vo = new PriceTrackCreateTaskVo();
@@ -229,6 +275,7 @@ public class PriceTrackTaskService {
throw new BusinessException("任务已结束,拒绝重复提交");
}
priceTrackTaskCacheService.touchTaskHeartbeat(taskId);
List<FileResultEntity> resultRows = fileResultMapper.selectList(
new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
@@ -242,38 +289,26 @@ public class PriceTrackTaskService {
if (!key.isBlank()) payloadByShop.put(key, sr);
}
int ok = 0, fail = 0;
List<String> allErrors = new ArrayList<>();
boolean allDone = true;
for (FileResultEntity fr : resultRows) {
String shopKey = fr.getSourceFilename();
PriceTrackSubmitResultRequest.ShopResult payload = shopKey == null ? null : payloadByShop.get(shopKey);
if (payload == null) {
// 未匹配到payload的店铺保持待处理
allDone = false;
// 当前批次没有该店铺的 payload保持待处理
continue;
}
if (payload.getError() != null && !payload.getError().isBlank()) {
markResultFailed(fr, payload.getError());
fail++;
allErrors.add(shopKey + ": " + payload.getError());
continue;
}
if (Boolean.FALSE.equals(payload.getSuccess())) {
markResultFailed(fr, "处理失败");
fail++;
allErrors.add(shopKey + ": 处理失败");
markResultFailed(fr, "店铺处理失败");
continue;
}
// 成功
fr.setSuccess(1);
fr.setErrorMessage(null);
fr.setResultFilename(shopKey + "-price-track.xlsx");
fr.setResultFileSize(0L);
fr.setRowCount(countAsinResults(payload));
fileResultMapper.updateById(fr);
ok++;
try {
assembleShopResult(fr, shopKey, payload);
} catch (Exception ex) {
markResultFailed(fr, ex.getMessage() == null ? "组装结果失败" : ex.getMessage());
}
}
List<FileResultEntity> latest = fileResultMapper.selectList(
@@ -282,33 +317,57 @@ public class PriceTrackTaskService {
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
allDone = latest.stream().noneMatch(fr ->
fr.getSuccess() == null || fr.getSuccess() == 0);
updateTaskStatusFromLatestRows(task, latest);
}
task.setSuccessFileCount(ok);
task.setFailedFileCount(fail);
task.setUpdatedAt(LocalDateTime.now());
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) return false;
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) return false;
if (!"RUNNING".equals(task.getStatus())) {
log.warn("[price-track] stale finalize skipped taskId={} status={} fromCompensation={}",
taskId, task.getStatus(), fromCompensation);
cleanupTaskCacheIfTerminal(taskId, task.getStatus());
return false;
}
if (!allDone) {
task.setStatus("RUNNING");
} else if (ok > 0 && fail == 0) {
task.setStatus("SUCCESS");
task.setFinishedAt(LocalDateTime.now());
} else if (ok > 0) {
task.setStatus("SUCCESS");
task.setErrorMessage(String.join("", allErrors));
task.setFinishedAt(LocalDateTime.now());
} else {
List<FileResultEntity> latest = fileResultMapper.selectList(
new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
if (latest.isEmpty()) {
task.setStatus("FAILED");
task.setErrorMessage(String.join("", allErrors));
task.setErrorMessage("任务明细缺失,无法自动收尾");
task.setUpdatedAt(LocalDateTime.now());
task.setFinishedAt(LocalDateTime.now());
}
try {
task.setResultJson(objectMapper.writeValueAsString(buildSnapshotFromDb(taskId, task.getStatus())));
} catch (Exception ex) {
log.warn("[price-track] compact result json failed: {}", ex.getMessage());
}
fileTaskMapper.updateById(task);
cleanupTaskCacheIfTerminal(taskId, task.getStatus());
return true;
}
boolean changed = false;
for (FileResultEntity fr : latest) {
boolean success = fr.getSuccess() != null && fr.getSuccess() == 1;
boolean failed = fr.getErrorMessage() != null && !fr.getErrorMessage().isBlank();
if (success || failed) continue;
markResultFailed(fr, "Python interrupted before this shop finished uploading results");
changed = true;
log.warn("[price-track] stale finalize failed taskId={} shop={} fromCompensation={}",
taskId, fr.getSourceFilename(), fromCompensation);
}
if (!changed) {
return false;
}
List<FileResultEntity> refreshed = fileResultMapper.selectList(
new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
updateTaskStatusFromLatestRows(task, refreshed);
return true;
}
public String resolveResultDownloadUrl(Long resultId, Long userId) {
@@ -345,21 +404,10 @@ public class PriceTrackTaskService {
.orderByAsc(FileResultEntity::getId));
if (latest.isEmpty()) {
fileTaskMapper.deleteById(taskId);
cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY");
return;
}
int ok = 0, fail = 0;
for (FileResultEntity fr : latest) {
if (Boolean.TRUE.equals(fr.getSuccess())) ok++;
else if (fr.getErrorMessage() != null && !fr.getErrorMessage().isBlank()) fail++;
}
task.setSuccessFileCount(ok);
task.setFailedFileCount(fail);
task.setUpdatedAt(LocalDateTime.now());
boolean allDone = latest.stream().noneMatch(fr -> fr.getSuccess() == null || fr.getSuccess() == 0);
if (!allDone) return;
task.setStatus(ok > 0 ? "SUCCESS" : "FAILED");
task.setFinishedAt(LocalDateTime.now());
fileTaskMapper.updateById(task);
updateTaskStatusFromLatestRows(task, latest);
}
private void markResultFailed(FileResultEntity fr, String message) {
@@ -369,12 +417,44 @@ public class PriceTrackTaskService {
fr.setResultFileUrl(null);
fr.setResultFileSize(0L);
fr.setRowCount(0);
fr.setResultContentType(null);
fileResultMapper.updateById(fr);
}
private List<PriceTrackShopQueueItem> dedupeQueueItems(List<PriceTrackShopQueueItem> raw) {
Map<String, PriceTrackShopQueueItem> byNorm = new LinkedHashMap<>();
for (PriceTrackShopQueueItem item : raw) {
private void assembleShopResult(FileResultEntity result,
String shopKey,
PriceTrackSubmitResultRequest.ShopResult payload) {
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> countries =
excelAssemblyService.normalizeCountriesMap(payload.getCountries());
if (excelAssemblyService.countRows(countries) <= 0) {
throw new BusinessException("未收到可用结果数据");
}
String displayName = payload.getShopName() != null && !payload.getShopName().isBlank()
? payload.getShopName().trim()
: shopKey;
String stem = safeFileStem(displayName);
java.io.File workRoot = cn.hutool.core.io.FileUtil.mkdir(
cn.hutool.core.io.FileUtil.file(System.getProperty("java.io.tmpdir"), "price-track-result", String.valueOf(result.getTaskId())));
java.io.File xlsx = cn.hutool.core.io.FileUtil.file(workRoot, stem + ".xlsx");
try {
excelAssemblyService.writeWorkbook(xlsx, countries);
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
result.setSuccess(1);
result.setErrorMessage(null);
result.setResultFilename(stem + ".xlsx");
result.setResultFileUrl(objectKey);
result.setResultFileSize(xlsx.length());
result.setResultContentType(CONTENT_TYPE_XLSX);
result.setRowCount(excelAssemblyService.countRows(countries));
fileResultMapper.updateById(result);
} finally {
cn.hutool.core.io.FileUtil.del(xlsx);
}
}
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> dedupeQueueItems(List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> raw) {
Map<String, PriceTrackMatchShopsVo.PriceTrackShopQueueItem> byNorm = new LinkedHashMap<>();
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : raw) {
if (item == null) continue;
String k = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (k.isBlank()) continue;
@@ -383,7 +463,7 @@ public class PriceTrackTaskService {
return new ArrayList<>(byNorm.values());
}
private PriceTrackResultItemVo toSnapshotVo(FileResultEntity fr, PriceTrackShopQueueItem item, String taskStatus) {
private PriceTrackResultItemVo toSnapshotVo(FileResultEntity fr, PriceTrackMatchShopsVo.PriceTrackShopQueueItem item, String taskStatus) {
PriceTrackResultItemVo vo = new PriceTrackResultItemVo();
vo.setResultId(fr.getId());
vo.setTaskId(fr.getTaskId());
@@ -416,9 +496,65 @@ public class PriceTrackTaskService {
vo.setDownloadUrl(entity.getResultFileUrl() == null || entity.getResultFileUrl().isBlank()
? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
vo.setMatched(true);
if (entity.getTaskId() != null) {
mergeQueueFieldsFromRequest(vo, entity.getTaskId());
}
return vo;
}
private PriceTrackTaskDetailVo buildTaskDetail(FileTaskEntity task) {
PriceTrackTaskDetailVo detail = new PriceTrackTaskDetailVo();
detail.setTask(toTaskItemVo(task));
List<FileResultEntity> rows = fileResultMapper.selectList(
new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, task.getId())
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
for (FileResultEntity fr : rows) {
detail.getItems().add(toHistoryItemVo(fr, task.getStatus()));
}
return detail;
}
private PriceTrackTaskItemVo toTaskItemVo(FileTaskEntity task) {
PriceTrackTaskItemVo vo = new PriceTrackTaskItemVo();
vo.setId(task.getId());
vo.setTaskNo(task.getTaskNo());
vo.setStatus(task.getStatus());
vo.setErrorMessage(task.getErrorMessage());
vo.setCreatedAt(fmt(task.getCreatedAt()));
vo.setUpdatedAt(fmt(task.getUpdatedAt()));
vo.setFinishedAt(fmt(task.getFinishedAt()));
return vo;
}
private void mergeQueueFieldsFromRequest(PriceTrackResultItemVo vo, Long taskId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) return;
try {
Map<String, Object> payload = objectMapper.readValue(task.getRequestJson(), new TypeReference<Map<String, Object>>() {});
Object rawItems = payload.get("items");
if (rawItems == null) return;
List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> items = objectMapper.convertValue(
rawItems,
new TypeReference<List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem>>() {});
String key = ziniaoShopSwitchService.normalizeShopName(vo.getShopName());
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : items) {
if (item == null) continue;
if (key.equals(ziniaoShopSwitchService.normalizeShopName(item.getShopName()))) {
vo.setShopName(item.getShopName());
vo.setShopId(item.getShopId());
vo.setPlatform(item.getPlatform());
vo.setCompanyName(item.getCompanyName());
vo.setMatchStatus(item.getMatchStatus());
vo.setMatchMessage(item.getMatchMessage());
break;
}
}
} catch (Exception ignored) {
}
}
private List<PriceTrackResultItemVo> buildSnapshotFromDb(Long taskId, String taskStatus) {
List<FileResultEntity> rows = fileResultMapper.selectList(
new LambdaQueryWrapper<FileResultEntity>()
@@ -430,17 +566,70 @@ public class PriceTrackTaskService {
return list;
}
private int countAsinResults(PriceTrackSubmitResultRequest.ShopResult payload) {
if (payload == null || payload.getCountries() == null) return 0;
return payload.getCountries().values().stream()
.filter(l -> l != null)
.mapToInt(List::size)
.sum();
}
private static String safeFileStem(String shopName) {
String s = shopName == null ? "shop" : shopName.trim();
if (s.isBlank()) s = "shop";
return s.replaceAll("[\\\\/:*?\"<>|]", "_");
}
private static String fmt(LocalDateTime t) {
return t == null ? null : t.toString();
}
private void updateTaskStatusFromLatestRows(FileTaskEntity task, List<FileResultEntity> latest) {
String oldStatus = task.getStatus();
int ok = 0;
int fail = 0;
List<String> allErrors = new ArrayList<>();
boolean allDone = true;
for (FileResultEntity fr : latest) {
boolean success = fr.getSuccess() != null && fr.getSuccess() == 1;
boolean failed = fr.getErrorMessage() != null && !fr.getErrorMessage().isBlank();
if (success) {
ok++;
} else if (failed) {
fail++;
allErrors.add(fr.getSourceFilename() + ": " + fr.getErrorMessage());
} else {
allDone = false;
}
}
task.setSourceFileCount(latest.size());
task.setSuccessFileCount(ok);
task.setFailedFileCount(fail);
task.setUpdatedAt(LocalDateTime.now());
if (!allDone) {
task.setStatus("RUNNING");
task.setErrorMessage(null);
task.setFinishedAt(null);
} else if (ok > 0 && fail == 0) {
task.setStatus("SUCCESS");
task.setErrorMessage(null);
task.setFinishedAt(LocalDateTime.now());
} else if (ok > 0) {
task.setStatus("SUCCESS");
task.setErrorMessage(String.join("; ", allErrors));
task.setFinishedAt(LocalDateTime.now());
} else {
task.setStatus("FAILED");
task.setErrorMessage(allErrors.isEmpty() ? "全部店铺处理失败" : String.join("; ", allErrors));
task.setFinishedAt(LocalDateTime.now());
}
try {
task.setResultJson(objectMapper.writeValueAsString(buildSnapshotFromDb(task.getId(), task.getStatus())));
} catch (Exception ex) {
log.warn("[price-track] compact result json failed: {}", ex.getMessage());
}
fileTaskMapper.updateById(task);
log.warn("[price-track] status updated from latest rows taskId={} oldStatus={} newStatus={} ok={} fail={} allDone={} errorMessage={}",
task.getId(), oldStatus, task.getStatus(), ok, fail, allDone, task.getErrorMessage());
cleanupTaskCacheIfTerminal(task.getId(), task.getStatus());
}
private void cleanupTaskCacheIfTerminal(Long taskId, String taskStatus) {
if (!"SUCCESS".equals(taskStatus) && !"FAILED".equals(taskStatus) && !"DELETE_EMPTY".equals(taskStatus)) {
return;
}
priceTrackTaskCacheService.deleteTaskCache(taskId);
}
}

View File

@@ -2,15 +2,15 @@ package com.nanri.aiimage.modules.shopkey.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageCredentialVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageGroupItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManagePageVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageGroupItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageCredentialVo;
import com.nanri.aiimage.modules.shopkey.service.ShopManageService;
import com.nanri.aiimage.modules.shopkey.service.ShopManageGroupService;
import com.nanri.aiimage.modules.shopkey.service.ShopManageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
@@ -34,7 +34,7 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/shop-manages")
@Tag(name = "店铺管理", description = "维护店铺信息,支持增删改查。")
@Tag(name = "店铺管理", description = "维护店铺信息,支持查询、新建、修改、删除")
public class ShopManageController {
@Value("${aiimage.security.internal-token:}")
@@ -44,68 +44,89 @@ public class ShopManageController {
private final ShopManageGroupService shopManageGroupService;
@GetMapping
@Operation(summary = "分页查询店铺", description = "分页查询店铺列表。")
@Operation(summary = "分页查询店铺", description = "支持按分组、店铺名称、创建人进行筛选")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = ShopManagePageVo.class)))
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "查询成功",
content = @Content(schema = @Schema(implementation = ShopManagePageVo.class)))
})
public ApiResponse<ShopManagePageVo> page(
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
@Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName) {
return ApiResponse.success(shopManageService.page(page, pageSize, groupId, shopName));
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
@Parameter(description = "创建人用户 ID普通管理员只传自己") @RequestParam(required = false) Long createdById) {
return ApiResponse.success(shopManageService.page(page, pageSize, groupId, shopName, createdById));
}
@PostMapping
@Operation(summary = "新增店铺", description = "新增一条店铺记录。")
public ApiResponse<ShopManageItemVo> create(@Valid @RequestBody ShopManageCreateRequest request) {
return ApiResponse.success("创建成功", shopManageService.create(request));
@Operation(summary = "新增店铺", description = "创建店铺记录,并写入创建人")
public ApiResponse<ShopManageItemVo> create(
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody ShopManageCreateRequest request) {
return ApiResponse.success("创建成功",
shopManageService.create(request, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@PutMapping("/{id}")
@Operation(summary = "更新店铺", description = "按 ID 更新一条店铺记录。")
@Operation(summary = "更新店铺", description = "按 ID 更新店铺,普通管理员只能修改自己创建的数据")
public ApiResponse<ShopManageItemVo> update(
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody ShopManageUpdateRequest request) {
return ApiResponse.success("更新成功", shopManageService.update(id, request));
return ApiResponse.success("更新成功",
shopManageService.update(id, operatorId, Boolean.TRUE.equals(superAdmin), request));
}
@DeleteMapping("/{id}")
@Operation(summary = "删除店铺", description = "按 ID 删除一条店铺记录。")
public ApiResponse<Void> delete(@Parameter(description = "主键ID", required = true) @PathVariable Long id) {
shopManageService.delete(id);
@Operation(summary = "删除店铺", description = "按 ID 删除店铺,普通管理员只能删除自己创建的数据")
public ApiResponse<Void> delete(
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
shopManageService.delete(id, operatorId, Boolean.TRUE.equals(superAdmin));
return ApiResponse.success("删除成功", null);
}
@GetMapping("/groups")
@Operation(summary = "查询分组列表", description = "用于店铺管理下拉选择。")
public ApiResponse<java.util.List<ShopManageGroupItemVo>> listGroups() {
return ApiResponse.success(shopManageGroupService.list());
@Operation(summary = "查询分组列表", description = "普通管理员只返回自己创建的分组,超级管理员返回全部")
public ApiResponse<java.util.List<ShopManageGroupItemVo>> listGroups(
@Parameter(description = "创建人用户 ID普通管理员只传自己") @RequestParam(required = false) Long createdById) {
return ApiResponse.success(shopManageGroupService.list(createdById));
}
@PostMapping("/groups")
@Operation(summary = "新增分组", description = "新增一条店铺分组。")
@Operation(summary = "新增分组", description = "创建分组并写入创建人")
public ApiResponse<ShopManageGroupItemVo> createGroup(@Valid @RequestBody ShopManageGroupCreateRequest request) {
return ApiResponse.success("创建成功", shopManageGroupService.create(request));
}
@PutMapping("/groups/{id}")
@Operation(summary = "更新分组", description = "按 ID 更新分组名称。")
@Operation(summary = "更新分组", description = "普通管理员只能修改自己创建的分组")
public ApiResponse<ShopManageGroupItemVo> updateGroup(
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody ShopManageGroupUpdateRequest request) {
return ApiResponse.success("更新成功", shopManageGroupService.update(id, request));
return ApiResponse.success("更新成功",
shopManageGroupService.update(id, operatorId, Boolean.TRUE.equals(superAdmin), request));
}
@DeleteMapping("/groups/{id}")
@Operation(summary = "删除分组", description = "删除分组(分组下有店铺时不允许删除)。")
public ApiResponse<Void> deleteGroup(@Parameter(description = "主键ID", required = true) @PathVariable Long id) {
shopManageGroupService.delete(id);
@Operation(summary = "删除分组", description = "普通管理员只能删除自己创建的分组")
public ApiResponse<Void> deleteGroup(
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
@Parameter(description = "当前操作人用户 ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
shopManageGroupService.delete(id, operatorId, Boolean.TRUE.equals(superAdmin));
return ApiResponse.success("删除成功", null);
}
@GetMapping("/credential")
@Operation(summary = "按店铺名获取明文凭据(内部)", description = "仅供 Python 自动化内部调用,需传 X-Internal-Token")
@Operation(summary = "按店铺名获取明文凭据(内部)", description = "仅供 Python 自动化内部调用,需传 X-Internal-Token")
public ApiResponse<ShopManageCredentialVo> getCredentialByShopName(
@RequestParam("shopName") String shopName,
@RequestHeader(value = "X-Internal-Token", required = false) String token) {

View File

@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.shopkey.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCountryUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinPageVo;
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
@@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -44,6 +46,15 @@ public class SkipPriceAsinController {
return ApiResponse.success("保存成功", skipPriceAsinService.createOrUpdate(request));
}
@PutMapping("/{id}/countries/{country}")
@Operation(summary = "编辑指定国家的跳过跟价 ASIN")
public ApiResponse<SkipPriceAsinItemVo> updateCountry(
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
@Parameter(description = "国家编码", required = true) @PathVariable String country,
@Valid @RequestBody SkipPriceAsinCountryUpdateRequest request) {
return ApiResponse.success("保存成功", skipPriceAsinService.updateCountry(id, country, request.getAsin()));
}
@DeleteMapping("/{id}/countries/{country}")
@Operation(summary = "删除指定国家的跳过跟价 ASIN")
public ApiResponse<Void> deleteCountry(

View File

@@ -1,5 +1,6 @@
package com.nanri.aiimage.modules.shopkey.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@@ -7,18 +8,27 @@ import lombok.Data;
@Data
public class ShopManageCreateRequest {
@Schema(description = "分组 ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "分组不能为空")
private Long groupId;
@NotBlank(message = "店铺名不能为空")
@Schema(description = "店铺名", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "店铺名称不能为空")
private String shopName;
@NotBlank(message = "店铺商城名不能为空")
@Schema(description = "商城名", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "商城名称不能为空")
private String mallName;
@Schema(description = "登录账号", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "账号不能为空")
private String account;
@Schema(description = "登录密码", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "密码不能为空")
private String password;
@Schema(description = "创建人用户 ID由后台代理透传", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "创建人不能为空")
private Long createdById;
}

View File

@@ -1,11 +1,18 @@
package com.nanri.aiimage.modules.shopkey.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class ShopManageGroupCreateRequest {
@Schema(description = "分组名称", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "分组名称不能为空")
private String groupName;
@Schema(description = "创建人用户 ID由后台代理透传", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "创建人不能为空")
private Long createdById;
}

View File

@@ -1,11 +1,13 @@
package com.nanri.aiimage.modules.shopkey.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class ShopManageGroupUpdateRequest {
@Schema(description = "分组名称", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "分组名称不能为空")
private String groupName;
}

View File

@@ -1,5 +1,6 @@
package com.nanri.aiimage.modules.shopkey.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@@ -7,18 +8,23 @@ import lombok.Data;
@Data
public class ShopManageUpdateRequest {
@Schema(description = "分组 ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "分组不能为空")
private Long groupId;
@NotBlank(message = "店铺名不能为空")
@Schema(description = "店铺名", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "店铺名称不能为空")
private String shopName;
@NotBlank(message = "店铺商城名不能为空")
@Schema(description = "商城名", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "商城名称不能为空")
private String mallName;
@Schema(description = "登录账号", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "账号不能为空")
private String account;
@Schema(description = "登录密码", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "密码不能为空")
private String password;
}

View File

@@ -0,0 +1,13 @@
package com.nanri.aiimage.modules.shopkey.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class SkipPriceAsinCountryUpdateRequest {
@Schema(description = "ASIN", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "ASIN 不能为空")
private String asin;
}

View File

@@ -17,6 +17,8 @@ public class ShopManageEntity {
private Long groupId;
private String groupName;
private String shopName;
@TableField("created_by_id")
private Long createdById;
@TableField("mall_name")
private String mallName;
private String account;

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.shopkey.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@@ -14,6 +15,8 @@ public class ShopManageGroupEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String groupName;
@TableField("created_by_id")
private Long createdById;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -6,8 +6,8 @@ import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageGroupItemVo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -23,7 +23,13 @@ public class ShopManageGroupService {
private final ShopManageMapper shopManageMapper;
public List<ShopManageGroupItemVo> list() {
return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>().orderByAsc(ShopManageGroupEntity::getId))
return list(null);
}
public List<ShopManageGroupItemVo> list(Long createdById) {
return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.eq(createdById != null && createdById > 0, ShopManageGroupEntity::getCreatedById, createdById)
.orderByAsc(ShopManageGroupEntity::getId))
.stream()
.map(this::toVo)
.toList();
@@ -31,34 +37,45 @@ public class ShopManageGroupService {
@Transactional
public ShopManageGroupItemVo create(ShopManageGroupCreateRequest request) {
Long createdById = normalizePositiveId(request.getCreatedById(), "创建人不能为空");
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
ensureGroupNameUnique(groupName, null);
ensureGroupNameUnique(groupName, null, createdById, false);
ShopManageGroupEntity entity = new ShopManageGroupEntity();
entity.setGroupName(groupName);
entity.setCreatedById(createdById);
groupMapper.insert(entity);
return toVo(getById(entity.getId()));
}
@Transactional
public ShopManageGroupItemVo update(Long id, ShopManageGroupUpdateRequest request) {
public ShopManageGroupItemVo update(Long id, Long operatorId, boolean superAdmin, ShopManageGroupUpdateRequest request) {
ShopManageGroupEntity entity = getById(id);
validateOwnership(entity, operatorId, superAdmin);
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
ensureGroupNameUnique(groupName, id);
ensureGroupNameUnique(groupName, id, operatorId, superAdmin);
entity.setGroupName(groupName);
groupMapper.updateById(entity);
return toVo(getById(id));
}
@Transactional
public void delete(Long id) {
public void delete(Long id, Long operatorId, boolean superAdmin) {
ShopManageGroupEntity entity = getById(id);
Long useCount = shopManageMapper.selectCount(new LambdaQueryWrapper<ShopManageEntity>().eq(ShopManageEntity::getGroupId, entity.getId()));
validateOwnership(entity, operatorId, superAdmin);
Long useCount = shopManageMapper.selectCount(new LambdaQueryWrapper<ShopManageEntity>()
.eq(ShopManageEntity::getGroupId, entity.getId()));
if (useCount != null && useCount > 0) {
throw new BusinessException("该分组下存在店铺,无法删除");
}
groupMapper.deleteById(entity.getId());
}
public ShopManageGroupEntity getAccessibleById(Long id, Long operatorId, boolean superAdmin) {
ShopManageGroupEntity entity = getById(id);
validateOwnership(entity, operatorId, superAdmin);
return entity;
}
public ShopManageGroupEntity getById(Long id) {
ShopManageGroupEntity entity = groupMapper.selectById(id);
if (entity == null) {
@@ -67,9 +84,12 @@ public class ShopManageGroupService {
return entity;
}
private void ensureGroupNameUnique(String groupName, Long excludeId) {
private void ensureGroupNameUnique(String groupName, Long excludeId, Long operatorId, boolean superAdmin) {
LambdaQueryWrapper<ShopManageGroupEntity> query = new LambdaQueryWrapper<ShopManageGroupEntity>()
.eq(ShopManageGroupEntity::getGroupName, groupName);
if (!superAdmin) {
query.eq(ShopManageGroupEntity::getCreatedById, normalizePositiveId(operatorId, "操作人不能为空"));
}
if (excludeId != null) {
query.ne(ShopManageGroupEntity::getId, excludeId);
}
@@ -79,6 +99,16 @@ public class ShopManageGroupService {
}
}
private void validateOwnership(ShopManageGroupEntity entity, Long operatorId, boolean superAdmin) {
if (superAdmin) {
return;
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
if (entity.getCreatedById() == null || !entity.getCreatedById().equals(normalizedOperatorId)) {
throw new BusinessException("只能操作自己创建的分组数据");
}
}
private String normalizeRequired(String value, String message) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty()) {
@@ -87,6 +117,13 @@ public class ShopManageGroupService {
return normalized;
}
private Long normalizePositiveId(Long value, String message) {
if (value == null || value <= 0) {
throw new BusinessException(message);
}
return value;
}
private ShopManageGroupItemVo toVo(ShopManageGroupEntity entity) {
ShopManageGroupItemVo vo = new ShopManageGroupItemVo();
vo.setId(entity.getId());

View File

@@ -8,9 +8,9 @@ import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageCredentialVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManagePageVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageCredentialVo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -28,16 +28,21 @@ public class ShopManageService {
private final ShopCredentialCryptoService shopCredentialCryptoService;
public ShopManagePageVo page(long page, long pageSize) {
return page(page, pageSize, null, null);
return page(page, pageSize, null, null, null);
}
public ShopManagePageVo page(long page, long pageSize, Long groupId, String shopName) {
return page(page, pageSize, groupId, shopName, null);
}
public ShopManagePageVo page(long page, long pageSize, Long groupId, String shopName, Long createdById) {
long safePage = Math.max(page, 1);
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
String safeShopName = shopName == null ? "" : shopName.trim();
LambdaQueryWrapper<ShopManageEntity> query = new LambdaQueryWrapper<ShopManageEntity>()
.eq(groupId != null && groupId > 0, ShopManageEntity::getGroupId, groupId)
.eq(createdById != null && createdById > 0, ShopManageEntity::getCreatedById, createdById)
.like(!safeShopName.isEmpty(), ShopManageEntity::getShopName, safeShopName)
.orderByDesc(ShopManageEntity::getId);
Long total = shopManageMapper.selectCount(query);
@@ -62,16 +67,18 @@ public class ShopManageService {
}
@Transactional
public ShopManageItemVo create(ShopManageCreateRequest request) {
ShopManageGroupEntity group = shopManageGroupService.getById(request.getGroupId());
String shopName = normalizeRequired(request.getShopName(), "店铺名不能为空");
String mallName = normalizeRequired(request.getMallName(), "店铺商城名不能为空");
public ShopManageItemVo create(ShopManageCreateRequest request, Long operatorId, boolean superAdmin) {
Long createdById = normalizePositiveId(request.getCreatedById(), "创建人不能为空");
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);
String shopName = normalizeRequired(request.getShopName(), "店铺名不能为空");
String mallName = normalizeRequired(request.getMallName(), "商城名称不能为空");
ensureShopNameUnique(shopName, null);
ShopManageEntity entity = new ShopManageEntity();
entity.setGroupId(group.getId());
entity.setGroupName(group.getGroupName());
entity.setShopName(shopName);
entity.setCreatedById(createdById);
entity.setMallName(mallName);
entity.setAccount(normalizeRequired(request.getAccount(), "账号不能为空"));
entity.setPassword(shopCredentialCryptoService.encrypt(normalizeRequired(request.getPassword(), "密码不能为空")));
@@ -81,11 +88,12 @@ public class ShopManageService {
}
@Transactional
public ShopManageItemVo update(Long id, ShopManageUpdateRequest request) {
public ShopManageItemVo update(Long id, Long operatorId, boolean superAdmin, ShopManageUpdateRequest request) {
ShopManageEntity entity = getById(id);
ShopManageGroupEntity group = shopManageGroupService.getById(request.getGroupId());
String shopName = normalizeRequired(request.getShopName(), "店铺名不能为空");
String mallName = normalizeRequired(request.getMallName(), "店铺商城名不能为空");
validateOwnership(entity, operatorId, superAdmin);
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);
String shopName = normalizeRequired(request.getShopName(), "店铺名不能为空");
String mallName = normalizeRequired(request.getMallName(), "商城名称不能为空");
ensureShopNameUnique(shopName, id);
entity.setGroupId(group.getId());
@@ -100,13 +108,14 @@ public class ShopManageService {
}
@Transactional
public void delete(Long id) {
public void delete(Long id, Long operatorId, boolean superAdmin) {
ShopManageEntity entity = getById(id);
validateOwnership(entity, operatorId, superAdmin);
shopManageMapper.deleteById(entity.getId());
}
public ShopManageCredentialVo getCredentialByShopName(String shopName) {
String normalizedShopName = normalizeRequired(shopName, "店铺名不能为空");
String normalizedShopName = normalizeRequired(shopName, "店铺名不能为空");
ShopManageEntity entity = shopManageMapper.selectOne(new LambdaQueryWrapper<ShopManageEntity>()
.eq(ShopManageEntity::getShopName, normalizedShopName)
.last("limit 1"));
@@ -137,6 +146,16 @@ public class ShopManageService {
return entity;
}
private void validateOwnership(ShopManageEntity entity, Long operatorId, boolean superAdmin) {
if (superAdmin) {
return;
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
if (entity.getCreatedById() == null || !entity.getCreatedById().equals(normalizedOperatorId)) {
throw new BusinessException("只能操作自己创建的店铺数据");
}
}
private String normalizeRequired(String value, String message) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty()) {
@@ -145,6 +164,13 @@ public class ShopManageService {
return normalized;
}
private Long normalizePositiveId(Long value, String message) {
if (value == null || value <= 0) {
throw new BusinessException(message);
}
return value;
}
private void ensureShopNameUnique(String shopName, Long excludeId) {
LambdaQueryWrapper<ShopManageEntity> query = new LambdaQueryWrapper<ShopManageEntity>()
.eq(ShopManageEntity::getShopName, shopName);
@@ -153,7 +179,7 @@ public class ShopManageService {
}
Long count = shopManageMapper.selectCount(query);
if (count != null && count > 0) {
throw new BusinessException("店铺名已存在");
throw new BusinessException("店铺名已存在");
}
}
@@ -164,8 +190,8 @@ public class ShopManageService {
LinkedHashMap<Long, String> map = new LinkedHashMap<>();
for (Long groupId : groupIds) {
try {
ShopManageGroupEntity g = shopManageGroupService.getById(groupId);
map.put(groupId, g.getGroupName());
ShopManageGroupEntity group = shopManageGroupService.getById(groupId);
map.put(groupId, group.getGroupName());
} catch (Exception ignored) {
map.put(groupId, "");
}

View File

@@ -105,6 +105,24 @@ public class SkipPriceAsinService {
skipPriceAsinMapper.updateById(entity);
}
@Transactional
public SkipPriceAsinItemVo updateCountry(Long id, String country, String asin) {
SkipPriceAsinEntity entity = getById(id);
String normalizedCountry = normalizeCountry(country);
String normalizedAsin = normalizeAsin(asin);
setCountryAsin(entity, normalizedCountry, normalizedAsin);
skipPriceAsinMapper.updateById(entity);
SkipPriceAsinEntity saved = getById(entity.getId());
String groupName = "";
try {
ShopManageGroupEntity group = shopManageGroupService.getById(saved.getGroupId());
groupName = group.getGroupName();
} catch (Exception ignored) {
groupName = "";
}
return toItemVo(saved, groupName);
}
private SkipPriceAsinEntity getById(Long id) {
SkipPriceAsinEntity entity = skipPriceAsinMapper.selectById(id);
if (entity == null) {

View File

@@ -84,12 +84,14 @@ aiimage:
finalize-check-cron: ${AIIMAGE_DELETE_BRAND_FINALIZE_CHECK_CRON:30 */2 * * * *}
product-risk-stale-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_STALE_TIMEOUT_MINUTES:10}
product-risk-initial-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_INITIAL_TIMEOUT_MINUTES:10}
price-track-stale-timeout-minutes: ${AIIMAGE_PRICE_TRACK_STALE_TIMEOUT_MINUTES:10}
price-track-initial-timeout-minutes: ${AIIMAGE_PRICE_TRACK_INITIAL_TIMEOUT_MINUTES:10}
shop-match-stale-timeout-minutes: ${AIIMAGE_SHOP_MATCH_STALE_TIMEOUT_MINUTES:10}
shop-match-initial-timeout-minutes: ${AIIMAGE_SHOP_MATCH_INITIAL_TIMEOUT_MINUTES:10}
module-cleanup:
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *}
module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE,SHOP_MATCH}
module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE,PRICE_TRACK,SHOP_MATCH}
security:
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}

View File

@@ -0,0 +1,33 @@
SET @db_name = DATABASE();
SET @created_by_id_col_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_shop_manage'
AND COLUMN_NAME = 'created_by_id'
);
SET @sql_add_created_by_id_col := IF(@created_by_id_col_exists = 0,
'ALTER TABLE biz_shop_manage ADD COLUMN created_by_id BIGINT NULL COMMENT ''创建人用户ID'' AFTER shop_name',
'SELECT 1'
);
PREPARE stmt_add_created_by_id_col FROM @sql_add_created_by_id_col;
EXECUTE stmt_add_created_by_id_col;
DEALLOCATE PREPARE stmt_add_created_by_id_col;
SET @created_by_id_idx_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_shop_manage'
AND INDEX_NAME = 'idx_created_by_id'
);
SET @sql_add_created_by_id_idx := IF(@created_by_id_idx_exists = 0,
'ALTER TABLE biz_shop_manage ADD INDEX idx_created_by_id (created_by_id)',
'SELECT 1'
);
PREPARE stmt_add_created_by_id_idx FROM @sql_add_created_by_id_idx;
EXECUTE stmt_add_created_by_id_idx;
DEALLOCATE PREPARE stmt_add_created_by_id_idx;

View File

@@ -0,0 +1,33 @@
SET @db_name = DATABASE();
SET @created_by_id_col_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_shop_manage_group'
AND COLUMN_NAME = 'created_by_id'
);
SET @sql_add_created_by_id_col := IF(@created_by_id_col_exists = 0,
'ALTER TABLE biz_shop_manage_group ADD COLUMN created_by_id BIGINT NULL COMMENT ''创建人用户ID'' AFTER group_name',
'SELECT 1'
);
PREPARE stmt_add_created_by_id_col FROM @sql_add_created_by_id_col;
EXECUTE stmt_add_created_by_id_col;
DEALLOCATE PREPARE stmt_add_created_by_id_col;
SET @created_by_id_idx_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = 'biz_shop_manage_group'
AND INDEX_NAME = 'idx_created_by_id'
);
SET @sql_add_created_by_id_idx := IF(@created_by_id_idx_exists = 0,
'ALTER TABLE biz_shop_manage_group ADD INDEX idx_created_by_id (created_by_id)',
'SELECT 1'
);
PREPARE stmt_add_created_by_id_idx FROM @sql_add_created_by_id_idx;
EXECUTE stmt_add_created_by_id_idx;
DEALLOCATE PREPARE stmt_add_created_by_id_idx;

View File

@@ -32,7 +32,7 @@ def _ensure_dedupe_total_data_access():
return _ensure_admin_menu_access('dedupe-total-data')
role, current_row = get_current_admin_role()
if not _can_access_dedupe_total_data(role, current_row):
return None, None, (jsonify({'success': False, 'error': '无权访问数据去重总数据'}), 403)
return None, None, (jsonify({'success': False, 'error': '无权访问数据去重总数据'}), 403)
return role, current_row, None
from ali_oss import upload_file as oss_upload_file
@@ -48,23 +48,23 @@ ADMIN_MENU_ACCESS_CONFIG = {
'dedupe-total-data': {
'column_key': 'admin_dedupe_total_data',
'route_path': 'dedupe-total-data',
'error': '鏃犳潈闄愯闂暟鎹幓閲嶆€绘暟鎹?',
'error': '无权访问数据去重汇总数据',
},
'shop-manage': {
'column_key': 'admin_shop_manage',
'route_path': 'shop-manage',
'error': '鏃犳潈闄愯闂簵閾虹鐞嗘ā鍧?',
'error': '无权访问店铺管理模块',
},
'skip-price-asin': {
'column_key': 'admin_skip_price_asin',
'route_path': 'skip-price-asin',
'error': '鏃犳潈闄愯闂烦杩囪窡浠稟SIN 妯″潡',
'error': '无权访问跳过跟价 ASIN 模块',
},
}
def _safe_version_key(version):
"""将版本号转为安全的 OSS 对象名部分"""
"""将版本号转为安全的 OSS 对象名片段"""
s = (version or '').strip()
s = re.sub(r'[^\w.\-]', '_', s)
return s or 'unknown'
@@ -138,7 +138,7 @@ def _ensure_admin_menu_access(*menu_names):
if role == 'super_admin':
return role, current_row, None
if role != 'admin' or not current_row:
return role, current_row, (jsonify({'success': False, 'error': '闇€瑕佺鐞嗗憳鏉冮檺'}), 403)
return role, current_row, (jsonify({'success': False, 'error': '需要管理员权限'}), 403)
key_set, route_set = _get_current_admin_permission_sets()
for menu_name in menu_names:
config = ADMIN_MENU_ACCESS_CONFIG.get(menu_name) or {}
@@ -148,7 +148,7 @@ def _ensure_admin_menu_access(*menu_names):
return role, current_row, None
fallback_menu = menu_names[0] if menu_names else ''
fallback_config = ADMIN_MENU_ACCESS_CONFIG.get(fallback_menu) or {}
error_message = fallback_config.get('error') or '鏃犳潈闄愯闂綋鍓嶆ā鍧?'
error_message = fallback_config.get('error') or '无权访问当前模块'
return role, current_row, (jsonify({'success': False, 'error': error_message}), 403)
@@ -157,7 +157,7 @@ def _ensure_admin_menu_access(*menu_names):
@admin_api.route('/users')
@admin_required
def list_users():
"""分页获取用户列表支持用户名模糊搜索、指定管理员所属普通用户筛选"""
"""分页获取用户列表支持用户名模糊搜索和按管理员归属筛选普通用户"""
role, current_row = get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
@@ -998,7 +998,7 @@ def delete_dedupe_total_data(item_id):
@admin_api.route('/shop-manages')
@admin_required
def list_shop_manages():
_, _, denied = _ensure_admin_menu_access('shop-manage')
role, current_row, denied = _ensure_admin_menu_access('shop-manage')
if denied:
return denied
page = max(1, int(request.args.get('page', 1)))
@@ -1007,6 +1007,8 @@ def list_shop_manages():
shop_name = (request.args.get('shop_name') or '').strip()
params = {'page': page, 'pageSize': page_size}
if role != 'super_admin' and current_row and current_row.get('id'):
params['createdById'] = current_row.get('id')
if group_id_raw:
try:
group_id = int(group_id_raw)
@@ -1051,7 +1053,7 @@ def list_shop_manages():
@admin_api.route('/shop-manage', methods=['POST'])
@admin_required
def create_shop_manage():
_, _, denied = _ensure_admin_menu_access('shop-manage')
role, current_row, denied = _ensure_admin_menu_access('shop-manage')
if denied:
return denied
data = request.get_json() or {}
@@ -1061,10 +1063,15 @@ def create_shop_manage():
'mallName': (data.get('mall_name') or '').strip(),
'account': (data.get('account') or '').strip(),
'password': (data.get('password') or '').strip(),
'createdById': current_row.get('id') if current_row else None,
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/shop-manages',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload,
)
if error_response is not None:
@@ -1090,7 +1097,7 @@ def create_shop_manage():
@admin_api.route('/shop-manage/<int:item_id>', methods=['PUT'])
@admin_required
def update_shop_manage(item_id):
_, _, denied = _ensure_admin_menu_access('shop-manage')
role, current_row, denied = _ensure_admin_menu_access('shop-manage')
if denied:
return denied
data = request.get_json() or {}
@@ -1104,6 +1111,10 @@ def update_shop_manage(item_id):
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/shop-manages/{item_id}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload,
)
if error_response is not None:
@@ -1128,12 +1139,16 @@ def update_shop_manage(item_id):
@admin_api.route('/shop-manage/<int:item_id>', methods=['DELETE'])
@admin_required
def delete_shop_manage(item_id):
_, _, denied = _ensure_admin_menu_access('shop-manage')
role, current_row, denied = _ensure_admin_menu_access('shop-manage')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/shop-manages/{item_id}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
)
if error_response is not None:
return error_response, status
@@ -1146,12 +1161,13 @@ def delete_shop_manage(item_id):
@admin_api.route('/shop-manage-groups')
@admin_required
def list_shop_manage_groups():
_, _, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
role, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/shop-manages/groups',
params={'createdById': current_row.get('id')} if role != 'super_admin' and current_row and current_row.get('id') else None,
)
if error_response is not None:
return error_response, status
@@ -1170,11 +1186,14 @@ def list_shop_manage_groups():
@admin_api.route('/shop-manage-group', methods=['POST'])
@admin_required
def create_shop_manage_group():
_, _, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
_, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
if denied:
return denied
data = request.get_json() or {}
payload = {'groupName': (data.get('group_name') or '').strip()}
payload = {
'groupName': (data.get('group_name') or '').strip(),
'createdById': current_row.get('id') if current_row else None,
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/shop-manages/groups',
@@ -1198,7 +1217,7 @@ def create_shop_manage_group():
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['PUT'])
@admin_required
def update_shop_manage_group(item_id):
_, _, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
role, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
if denied:
return denied
data = request.get_json() or {}
@@ -1206,6 +1225,10 @@ def update_shop_manage_group(item_id):
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/shop-manages/groups/{item_id}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload,
)
if error_response is not None:
@@ -1302,7 +1325,7 @@ def create_skip_price_asin():
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '淇濆瓨鎴愬姛',
'msg': result.get('message') or '保存成功',
'item': {
'id': item.get('id'),
'group_id': item.get('groupId'),
@@ -1331,19 +1354,58 @@ def delete_skip_price_asin_country(item_id, country):
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': result.get('message') or '鍒犻櫎鎴愬姛'})
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['DELETE'])
@admin_required
def delete_shop_manage_group(item_id):
_, _, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
role, current_row, denied = _ensure_admin_menu_access('shop-manage', 'skip-price-asin')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/shop-manages/groups/{item_id}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
@admin_api.route('/skip-price-asin/<int:item_id>/country/<country>', methods=['PUT'])
@admin_required
def update_skip_price_asin_country(item_id, country):
_, _, denied = _ensure_admin_menu_access('skip-price-asin')
if denied:
return denied
data = request.get_json() or {}
payload = {'asin': (data.get('asin') or '').strip()}
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
json_data=payload,
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '保存成功',
'item': {
'id': item.get('id'),
'group_id': item.get('groupId'),
'group_name': item.get('groupName') or '',
'shop_name': item.get('shopName') or '',
'asin_de': item.get('asinDe') or '',
'asin_uk': item.get('asinUk') or '',
'asin_fr': item.get('asinFr') or '',
'asin_it': item.get('asinIt') or '',
'asin_es': item.get('asinEs') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
},
})

View File

@@ -23,8 +23,8 @@ bucket_path = "nanri-image/"
file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
import os
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/')
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/')
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"

View File

@@ -1995,10 +1995,35 @@
if (!value) return '<span style="color:#999;">-</span>';
return '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">' +
'<span>' + value + '</span>' +
'<button class="btn btn-sm" data-skip-price-asin-edit="' + item.id + '" data-country="' + country.code + '" data-asin="' + value.replace(/"/g, '&quot;') + '" data-shop-name="' + (item.shop_name || '').replace(/"/g, '&quot;') + '">编辑</button>' +
'<button class="btn btn-sm btn-danger" data-skip-price-asin-delete="' + item.id + '" data-country="' + country.code + '" data-shop-name="' + (item.shop_name || '').replace(/"/g, '&quot;') + '">删除</button>' +
'</div>';
}
function bindSkipPriceAsinActions() {
document.querySelectorAll('[data-skip-price-asin-edit]').forEach(function(btn) {
btn.onclick = function() {
var shopName = (btn.dataset.shopName || '').replace(/&quot;/g, '"');
var countryCode = btn.dataset.country || '';
var currentAsin = (btn.dataset.asin || '').replace(/&quot;/g, '"');
var nextAsin = prompt('请输入店铺“' + shopName + '”在 ' + countryCode + ' 的 ASIN', currentAsin);
if (nextAsin === null) return;
nextAsin = (nextAsin || '').trim();
if (!nextAsin) {
alert('ASIN 不能为空');
return;
}
fetch('/api/admin/skip-price-asin/' + btn.dataset.skipPriceAsinEdit + '/country/' + countryCode, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asin: nextAsin })
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.success) loadSkipPriceAsin(skipPriceAsinPage);
else alert(res.error || '保存失败');
});
};
});
document.querySelectorAll('[data-skip-price-asin-delete]').forEach(function(btn) {
btn.onclick = function() {
var shopName = (btn.dataset.shopName || '').replace(/&quot;/g, '"');

View File

@@ -90,26 +90,6 @@
</div>
<div v-if="countryPrefSaving" class="country-pref-status">保存中</div>
<!-- 价格监控设置 -->
<div class="section-title">价格监控设置</div>
<div class="price-filter-zone">
<div class="price-input-row">
<span class="price-label">最低价</span>
<el-input v-model.number="minPrice" type="number" clearable placeholder="可不填" class="price-input" />
<span class="price-unit">EUR</span>
</div>
<div class="price-input-row">
<span class="price-label">最高价</span>
<el-input v-model.number="maxPrice" type="number" clearable placeholder="可不填" class="price-input" />
<span class="price-unit">EUR</span>
</div>
<div class="price-input-row">
<span class="price-label">价差比例</span>
<el-input v-model.number="priceDiffPercent" type="number" clearable placeholder="如 5 表示 5%" class="price-input" />
<span class="price-unit">%</span>
</div>
</div>
<!-- 状态指示 -->
<div v-if="statusModeEnabled" class="mode-status-bar">
<span class="mode-status-dot active"></span>
@@ -257,6 +237,7 @@ import {
getPriceTrackDashboard,
getPriceTrackHistory,
getPriceTrackResultDownloadUrl,
getPriceTrackTasksBatch,
listPriceTrackCandidates,
matchPriceTrackShops,
putPriceTrackCountryPreference,
@@ -264,6 +245,7 @@ import {
type PriceTrackDashboardVo,
type PriceTrackHistoryItem,
type PriceTrackShopQueueItem,
type PriceTrackTaskDetailVo,
} from '@/shared/api/java-modules'
// ========== 类型定义 ==========
@@ -293,11 +275,6 @@ const asinModeEnabled = ref(false) // 按指定ASIN文档自定义
// 上传文件
const asinFiles = ref<string[]>([]) // ASIN文档
// 价格监控
const minPrice = ref<number | undefined>(undefined)
const maxPrice = ref<number | undefined>(undefined)
const priceDiffPercent = ref<number | undefined>(undefined)
// 是否有有效模式
const hasValidMode = computed(() => statusModeEnabled.value || asinModeEnabled.value)
@@ -317,10 +294,113 @@ const dashboard = ref<PriceTrackDashboardVo>({
const historyItems = ref<PriceTrackHistoryItem[]>([])
const pollingTaskIds = ref<number[]>([])
const taskDetails = ref<Record<number, string>>({})
const taskSnapshots = ref<Record<number, any>>({})
const taskSnapshots = ref<Record<number, PriceTrackTaskDetailVo>>({})
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
function uidForStorage() {
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
}
function sessionKey() {
return `price-track:tasks:${uidForStorage()}`
}
function taskStatusStorageKey() {
return `price-track:task-status:${uidForStorage()}`
}
function taskSnapshotsStorageKey() {
return `price-track:tasks-batch-snapshot:${uidForStorage()}`
}
function matchedItemsStorageKey() {
return `price-track:matched-items:${uidForStorage()}`
}
function setStorageJson(key: string, value: unknown, shouldRemove: boolean) {
if (typeof window === 'undefined') return
try {
if (shouldRemove) window.localStorage.removeItem(key)
else window.localStorage.setItem(key, JSON.stringify(value))
} catch {
/* quota */
}
}
function loadPollingIdsFromStorage() {
try {
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(sessionKey()) : null
if (!raw) {
pollingTaskIds.value = []
return
}
const parsed = JSON.parse(raw) as unknown
pollingTaskIds.value = Array.isArray(parsed) ? parsed.filter((n): n is number => typeof n === 'number' && n > 0) : []
} catch {
pollingTaskIds.value = []
}
}
function savePollingIds() {
setStorageJson(sessionKey(), pollingTaskIds.value, pollingTaskIds.value.length === 0)
}
function loadTaskDetailsFromStorage() {
try {
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(taskStatusStorageKey()) : null
if (!raw) return
const parsed = JSON.parse(raw) as Record<string, unknown>
const out: Record<number, string> = {}
for (const [k, v] of Object.entries(parsed)) {
const id = Number(k)
if (id > 0 && typeof v === 'string') out[id] = v
}
taskDetails.value = out
} catch {
/* ignore */
}
}
function saveTaskDetailsToStorage() {
setStorageJson(taskStatusStorageKey(), taskDetails.value, Object.keys(taskDetails.value).length === 0)
}
function loadTaskSnapshotsFromStorage() {
try {
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(taskSnapshotsStorageKey()) : null
if (!raw) return
const parsed = JSON.parse(raw) as Record<string, unknown>
const out: Record<number, PriceTrackTaskDetailVo> = {}
for (const [k, v] of Object.entries(parsed)) {
const id = Number(k)
if (id > 0 && v && typeof v === 'object') out[id] = v as PriceTrackTaskDetailVo
}
taskSnapshots.value = out
} catch {
/* ignore */
}
}
function saveTaskSnapshotsToStorage() {
setStorageJson(taskSnapshotsStorageKey(), taskSnapshots.value, Object.keys(taskSnapshots.value).length === 0)
}
function loadMatchedItemsFromStorage() {
try {
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(matchedItemsStorageKey()) : null
if (!raw) return
const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed)) matchedItems.value = parsed as PriceTrackShopQueueItem[]
} catch {
/* ignore */
}
}
function saveMatchedItemsToStorage() {
setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0)
}
// ========== 文件上传 ==========
async function selectAsinFile() {
const api = getPywebviewApi()
@@ -463,6 +543,15 @@ async function loadHistory() {
}
}
function syncPollingIdsWithHistory() {
for (const taskId of [...pollingTaskIds.value]) {
const any = historyItems.value.some((r) => r.taskId === taskId)
if (!any && !taskSnapshots.value[taskId]) {
removePollingTask(taskId)
}
}
}
function onSelectionChange(rows: PriceTrackCandidateVo[]) {
selectedCandidates.value = rows || []
}
@@ -491,6 +580,7 @@ async function removeCandidate(row: PriceTrackCandidateVo) {
await deletePriceTrackCandidate(row.id)
await loadCandidates()
matchedItems.value = []
saveMatchedItemsToStorage()
ElMessage.success('已删除')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '删除失败')
@@ -519,6 +609,7 @@ async function runMatch() {
const res = await matchPriceTrackShops(names)
const batch = res.items || []
matchedItems.value = [...matchedItems.value, ...batch.filter((n) => !matchedItems.value.some((e) => e.shopName === n.shopName))]
saveMatchedItemsToStorage()
ElMessage.success(`匹配 ${batch.length} 个店铺`)
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '匹配失败')
@@ -549,11 +640,32 @@ function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: numb
if (!rows.length) return
const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
matchedItems.value = matchedItems.value.filter((row) => !keys.has(rowKeyForMatch(row)))
saveMatchedItemsToStorage()
}
async function removeMatchedRow(row: PriceTrackShopQueueItem) {
removeMatchedRowsLocally([row])
const key = rowKeyForMatch(row)
const backup = [...matchedItems.value]
matchedItems.value = matchedItems.value.filter((r) => rowKeyForMatch(r) !== key)
saveMatchedItemsToStorage()
const name = (row.shopName || '').trim()
if (!name) {
ElMessage.success('已从匹配结果中移除')
return
}
try {
const res = await deletePendingPriceTrackShopResult(name)
if (res?.removed) {
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
}
ElMessage.success('已从匹配结果中移除')
} catch (e) {
matchedItems.value = backup
saveMatchedItemsToStorage()
ElMessage.error(e instanceof Error ? e.message : '同步后端失败')
}
}
async function pushToPythonQueue() {
@@ -582,11 +694,21 @@ async function pushToPythonQueue() {
items: matchedRows as unknown as Record<string, unknown>[],
asinFiles: asinFiles.value,
countryCodes: [...orderedCountryCodes.value],
minPrice: minPrice.value,
maxPrice: maxPrice.value,
priceDiffPercent: priceDiffPercent.value,
}
const taskVo = await createPriceTrackTask(taskReq)
taskSnapshots.value = {
...taskSnapshots.value,
[taskVo.taskId]: {
task: { id: taskVo.taskId, status: 'RUNNING' },
items: taskVo.items,
},
}
taskDetails.value = {
...taskDetails.value,
[taskVo.taskId]: 'RUNNING',
}
saveTaskSnapshotsToStorage()
saveTaskDetailsToStorage()
// 2. 推送 taskId 给 PythonPython 会从 Java 拉取完整任务上下文
const queuePayload = {
@@ -605,6 +727,7 @@ async function pushToPythonQueue() {
if (pushResult?.success) {
queuePushResult.value = `任务 ${taskVo.taskId} 已入队,等待执行完成...`
addPollingTask(taskVo.taskId)
scheduleNextPoll(true)
ElMessage.success('已推送到 Python 队列')
} else {
queuePushResult.value = `推送失败:${pushResult?.error || '未知错误'}`
@@ -627,24 +750,37 @@ async function refreshTaskBatch() {
const ids = pollingTaskIds.value.filter((id) => id > 0)
if (!ids.length) return
try {
await loadHistory()
// 检查是否有任务结束,停止轮询
const terminalIds = ids.filter((id) => isTaskTerminal(id))
for (const id of terminalIds) {
removePollingTask(id)
const batch = await getPriceTrackTasksBatch(ids)
let changed = false
const nextSnapshots = { ...taskSnapshots.value }
for (const missingId of batch.missingTaskIds || []) {
removePollingTask(missingId)
delete nextSnapshots[missingId]
}
for (const detail of batch.items || []) {
const taskId = detail.task?.id
const status = detail.task?.status
if (typeof taskId !== 'number' || taskId <= 0) continue
nextSnapshots[taskId] = detail
if (status) {
taskDetails.value[taskId] = status
if (status === 'SUCCESS' || status === 'FAILED') {
removePollingTask(taskId)
changed = true
}
}
}
taskSnapshots.value = nextSnapshots
saveTaskSnapshotsToStorage()
saveTaskDetailsToStorage()
await loadHistory()
syncPollingIdsWithHistory()
if (changed) await loadDashboard()
} catch {
/* polling noise */
}
}
function isTaskTerminal(taskId: number) {
const rows = historyItems.value.filter((r) => r.taskId === taskId)
if (!rows.length) return false
// 全部行都有终态才停止
return rows.every((r) => r.taskStatus === 'SUCCESS' || r.taskStatus === 'FAILED')
}
function scheduleNextPoll(immediate = false) {
if (pollTimer.value) {
if (!immediate) return
@@ -670,17 +806,33 @@ function stopPolling() {
function addPollingTask(taskId: number) {
if (!pollingTaskIds.value.includes(taskId)) {
pollingTaskIds.value = [...pollingTaskIds.value, taskId]
savePollingIds()
}
}
function removePollingTask(taskId: number) {
pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId)
savePollingIds()
delete taskDetails.value[taskId]
saveTaskDetailsToStorage()
if (taskSnapshots.value[taskId]) {
const next = { ...taskSnapshots.value }
delete next[taskId]
taskSnapshots.value = next
saveTaskSnapshotsToStorage()
}
}
const currentSectionItems = computed(() =>
historyItems.value.filter((row) => row.taskId && pollingTaskIds.value.includes(row.taskId) && !isTaskTerminalById(row.taskId))
)
const currentSectionItems = computed(() => {
const out = historyItems.value.filter((row) => row.taskId && pollingTaskIds.value.includes(row.taskId) && !isTaskTerminalById(row.taskId))
const existingTaskIds = new Set(out.map((row) => row.taskId).filter((id): id is number => typeof id === 'number'))
for (const taskId of pollingTaskIds.value) {
if (existingTaskIds.has(taskId)) continue
const first = taskSnapshots.value[taskId]?.items?.[0]
if (first) out.push(first)
}
return out
})
const historySectionItems = computed(() =>
historyItems.value.filter((row) => !row.taskId || !pollingTaskIds.value.includes(row.taskId) || isTaskTerminalById(row.taskId))
@@ -690,7 +842,7 @@ const hasVisibleList = computed(() => currentSectionItems.value.length > 0 || hi
function taskStatusOf(taskId?: number) {
if (!taskId) return ''
return taskDetails.value[taskId] || ''
return taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status || ''
}
function isTaskTerminalById(taskId?: number) {
@@ -729,7 +881,7 @@ function canDownload(item: PriceTrackHistoryItem) {
async function downloadResult(item: PriceTrackHistoryItem) {
if (!item.resultId) return
const api = getPywebviewApi()
const filename = item.outputFilename || `${item.shopName || 'result'}.zip`
const filename = item.outputFilename || `${item.shopName || 'result'}.xlsx`
if (!api?.save_file_from_url_new) {
ElMessage.error('当前客户端未提供 save_file_from_url_new无法下载文件')
return
@@ -747,10 +899,14 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
try {
if (item.resultId != null) {
await deletePriceTrackHistory(item.resultId)
if (item.taskId != null) removePollingTask(item.taskId)
} else if (item.taskId != null) {
await deletePriceTrackTask(item.taskId)
}
if (item.taskId != null) removePollingTask(item.taskId)
removeMatchedRowsLocally([item])
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
ElMessage.success('已删除')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '删除失败')
@@ -759,6 +915,10 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
// ========== 生命周期 ==========
onMounted(() => {
loadPollingIdsFromStorage()
loadTaskDetailsFromStorage()
loadTaskSnapshotsFromStorage()
loadMatchedItemsFromStorage()
loadCandidates().catch(() => undefined)
loadCountryPreference().catch(() => undefined)
loadHistory().catch(() => undefined)
@@ -1085,49 +1245,12 @@ onUnmounted(() => {
color: #ccc;
}
.price-filter-zone {
border: 1px dashed #3a3a3a;
border-radius: 10px;
padding: 14px;
background: #252525;
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.price-input-row {
display: flex;
align-items: center;
gap: 10px;
}
.price-label {
font-size: 12px;
color: #aaa;
width: 60px;
flex-shrink: 0;
}
.price-input {
flex: 1;
}
.price-unit {
font-size: 12px;
color: #888;
width: 30px;
flex-shrink: 0;
}
.price-input-row :deep(.el-input__wrapper) {
background-color: #2a2a2a;
box-shadow: 0 0 0 1px #3a3a3a inset;
}
.price-input-row :deep(.el-input__inner) {
color: #ccc;
}
.empty-candidates {
color: #666;

View File

@@ -215,6 +215,7 @@ async function loadCandidates() { candidates.value = await listShopMatchCandidat
async function loadDashboard() { dashboard.value = await getShopMatchDashboard() }
async function loadHistory() { const data = await getShopMatchHistory(); historyItems.value = data.items || [] }
async function loadCountryPreference() { try { const data = await getShopMatchCountryPreference(); if (countryPrefUserTouched.value) return; const codes = (data.country_codes || []).filter((item) => COUNTRY_OPTIONS.some((option) => option.code === item)); if (codes.length) orderedCountryCodes.value = codes } catch {} }
async function refreshTaskViewsBestEffort() { await Promise.allSettled([loadHistory(), loadDashboard()]) }
function scheduleSaveCountryPreference() { if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer); countryPrefSaveTimer = setTimeout(() => { countryPrefSaveTimer = null; void saveCountryPreference() }, 450) }
async function saveCountryPreference() { if (!orderedCountryCodes.value.length) return; countryPrefSaving.value = true; try { await putShopMatchCountryPreference(orderedCountryCodes.value) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存国家配置失败') } finally { countryPrefSaving.value = false } }
function onSelectionChange(rows: ShopMatchCandidateVo[]) { selectedCandidates.value = rows }
@@ -252,7 +253,7 @@ function getPollIntervalMs() { return document.visibilityState === 'visible' ? 4
function scheduleNextPoll(immediate = false) { if (pollTimer.value) { if (!immediate) return; window.clearTimeout(pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (pollingTaskIds.value.length) pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } }
async function waitForTaskTerminal(taskId: number) { while (true) { const batch = await getShopMatchTasksBatch([taskId]); if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await loadHistory(); await loadDashboard(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { taskSnapshots.value = { ...taskSnapshots.value, [taskId]: detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await loadHistory(); await loadDashboard(); restoreScheduledDispatches(); return status } await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) } }
async function waitForTaskTerminal(taskId: number) { while (true) { const batch = await getShopMatchTasksBatch([taskId]); if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { taskSnapshots.value = { ...taskSnapshots.value, [taskId]: detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) }) } }
function resolvedTaskStatus(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); if (!taskId) return item.taskStatus || ''; return taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status || item.taskStatus || '' }
function taskSnapshotOf(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); return taskId ? taskSnapshots.value[taskId] : undefined }
function currentTaskStageText(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (!stages.length) return ''; const total = stages.length; const activeIndex = typeof task?.activeStageIndex === 'number' ? task.activeStageIndex : undefined; const currentIndex = typeof task?.currentStageIndex === 'number' ? task.currentStageIndex : undefined; if (typeof activeIndex === 'number') return `执行进度: 第 ${activeIndex + 1}/${total} 次执行中`; if (typeof currentIndex === 'number') return `执行进度: 等待第 ${currentIndex + 1}/${total}`; return `执行进度: 共 ${total}`}

View File

@@ -2,6 +2,7 @@ export const LISTING_FILTER_OPTIONS = [
{ value: 'SearchSuppressed', label: '在搜索结果中禁止显示' },
{ value: 'ApprovalRequired', label: '需要批准' },
{ value: 'Active', label: '在售' },
{ value: 'DetailPageRemoved', label: '详情页面已删除' },
] as const
export type ListingFilterValue = (typeof LISTING_FILTER_OPTIONS)[number]['value']

View File

@@ -972,9 +972,38 @@ export interface PriceTrackHistoryVo {
export interface PriceTrackCreateTaskVo {
taskId: number;
items: PriceTrackShopQueueItem[];
items: PriceTrackHistoryItem[];
}
export interface PriceTrackTaskSummary {
id?: number;
taskNo?: string;
status?: string;
errorMessage?: string;
createdAt?: string;
updatedAt?: string;
finishedAt?: string;
}
export interface PriceTrackTaskDetailVo {
task?: PriceTrackTaskSummary;
items?: PriceTrackHistoryItem[];
}
export interface PriceTrackTaskBatchVo {
items: PriceTrackTaskDetailVo[];
missingTaskIds?: number[];
}
export interface PriceTrackPendingDeleteVo {
removed: boolean;
}
export type PriceTrackCreateTaskPayload = Omit<
PriceTrackCreateTaskRequest,
"userId"
>;
export function listPriceTrackCandidates() {
return unwrapJavaResponse(
get<JavaApiResponse<PriceTrackCandidateVo[]>>(
@@ -985,9 +1014,9 @@ export function listPriceTrackCandidates() {
export function addPriceTrackCandidate(shopName: string) {
return unwrapJavaResponse(
post<JavaApiResponse<PriceTrackCandidateVo>, { user_id: number; shopName: string }>(
post<JavaApiResponse<PriceTrackCandidateVo>, { userId: number; shopName: string }>(
`${JAVA_API_PREFIX}/price-track/candidates`,
{ user_id: getCurrentUserId(), shopName },
{ userId: getCurrentUserId(), shopName },
),
);
}
@@ -1010,18 +1039,18 @@ export function getPriceTrackCountryPreference() {
export function putPriceTrackCountryPreference(countryCodes: string[]) {
return unwrapJavaResponse(
put<JavaApiResponse<PriceTrackCountryPreferenceVo>, { user_id: number; countryCodes: string[] }>(
put<JavaApiResponse<PriceTrackCountryPreferenceVo>, { userId: number; countryCodes: string[] }>(
`${JAVA_API_PREFIX}/price-track/country-preference`,
{ user_id: getCurrentUserId(), countryCodes },
{ userId: getCurrentUserId(), countryCodes },
),
);
}
export function matchPriceTrackShops(shopNames: string[]) {
return unwrapJavaResponse(
post<JavaApiResponse<PriceTrackMatchShopsVo>, { user_id: number; shopNames: string[] }>(
post<JavaApiResponse<PriceTrackMatchShopsVo>, { userId: number; shopNames: string[] }>(
`${JAVA_API_PREFIX}/price-track/match-shops`,
{ user_id: getCurrentUserId(), shopNames },
{ userId: getCurrentUserId(), shopNames },
),
);
}
@@ -1057,16 +1086,15 @@ export interface PriceTrackCreateTaskRequest {
items: PriceTrackShopQueueItem[];
asinFiles: string[];
countryCodes: string[];
minPrice?: number;
maxPrice?: number;
priceDiffPercent?: number;
}
export function createPriceTrackTask(request: PriceTrackCreateTaskRequest) {
export function createPriceTrackTask(
request: PriceTrackCreateTaskPayload | PriceTrackCreateTaskRequest,
) {
return unwrapJavaResponse(
post<JavaApiResponse<PriceTrackCreateTaskVo>, PriceTrackCreateTaskRequest>(
`${JAVA_API_PREFIX}/price-track/tasks`,
{ ...request, user_id: getCurrentUserId() },
{ ...request, userId: getCurrentUserId() },
),
);
}
@@ -1079,13 +1107,22 @@ export function deletePriceTrackTask(taskId: number) {
);
}
export function getPriceTrackTasksBatch(taskIds: number[]) {
return unwrapJavaResponse(
post<JavaApiResponse<PriceTrackTaskBatchVo>, { taskIds: number[] }>(
`${JAVA_API_PREFIX}/price-track/tasks/batch`,
{ taskIds },
),
);
}
export function getPriceTrackResultDownloadUrl(resultId: number) {
return getJavaDownloadUrl(`/price-track/results/${resultId}/download`);
}
export function deletePendingPriceTrackShopResult(shopName: string) {
return unwrapJavaResponse(
del<JavaApiResponse<null>>(
del<JavaApiResponse<PriceTrackPendingDeleteVo>>(
`${JAVA_API_PREFIX}/price-track/pending-shop-result?user_id=${encodeURIComponent(String(getCurrentUserId()))}&shop_name=${encodeURIComponent(shopName)}`,
),
);

View File

@@ -11,6 +11,15 @@ export interface PywebviewApi {
minimize?: () => Promise<void>;
maximize?: () => Promise<void>;
toggle_maximize?: () => Promise<void>;
select_files?: (options?: {
filters?: Array<{
name?: string;
extensions?: string;
}>;
multiple?: boolean;
}) => Promise<{
paths?: string[];
} | null>;
save_image?: (
urlOrData: string,
filename?: string,

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>格式转换 - 数富AI</title>
<script type="module" crossorigin src="/assets/convert.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据去重 - 数富AI</title>
<script type="module" crossorigin src="/assets/dedupe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>删除品牌 - 数富AI</title>
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-DfRLfrtm.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>跟价 - 数富AI</title>
<script type="module" crossorigin src="/assets/price-track.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
<link rel="stylesheet" crossorigin href="/assets/price-track-BO5PtL0W.css">
<link rel="stylesheet" crossorigin href="/assets/el-input-CezJelDw.css">
</head>
<body>
<div id="app"></div>
</body>

View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>商品风险解决 - 数富AI</title>
<script type="module" crossorigin src="/assets/product-risk.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
<link rel="modulepreload" crossorigin href="/assets/listingFilters-BpGOU_pJ.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
<link rel="stylesheet" crossorigin href="/assets/listingFilters-CK58rX4v.css">
<link rel="stylesheet" crossorigin href="/assets/product-risk-CG-1Iq4P.css">
<link rel="stylesheet" crossorigin href="/assets/el-input-CezJelDw.css">
</head>
<body>
<div id="app"></div>
</body>

View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>匹配店铺 - 数富AI</title>
<script type="module" crossorigin src="/assets/shop-match.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
<link rel="modulepreload" crossorigin href="/assets/listingFilters-BpGOU_pJ.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
<link rel="stylesheet" crossorigin href="/assets/listingFilters-CK58rX4v.css">
<link rel="stylesheet" crossorigin href="/assets/shop-match-MsxXMG_G.css">
<link rel="stylesheet" crossorigin href="/assets/el-input-CezJelDw.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

16
new_web_source/split.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据拆分 - 数富AI</title>
<script type="module" crossorigin src="/assets/split.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
</head>
<body>
<div id="app"></div>
</body>
</html>