后台管理跳过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

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,8 +251,8 @@ public class PriceTrackTaskService {
}
fileTaskMapper.updateById(task);
// TODO: Python侧定时轮询 RUNNING 状态任务, pickup 后开始处理
// Java侧通过 submitResult 接口接收Python回传结果
// TODO: Python 侧轮询 RUNNING 任务pickup 后开始处理
// Java 侧通过 submitResult 接口接收 Python 回传结果
PriceTrackCreateTaskVo vo = new PriceTrackCreateTaskVo();
vo.setTaskId(task.getId());
@@ -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());
fileTaskMapper.updateById(task);
cleanupTaskCacheIfTerminal(taskId, task.getStatus());
return true;
}
try {
task.setResultJson(objectMapper.writeValueAsString(buildSnapshotFromDb(taskId, task.getStatus())));
} catch (Exception ex) {
log.warn("[price-track] compact result json failed: {}", ex.getMessage());
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);
}
fileTaskMapper.updateById(task);
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 = "分组 ID") @RequestParam(required = false) Long groupId,
@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", 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", 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;