更新商品风险处理

This commit is contained in:
super
2026-04-05 22:04:21 +08:00
parent 380dd9f206
commit 486e690204
45 changed files with 3783 additions and 12 deletions

View File

@@ -13,4 +13,9 @@ public class DeleteBrandProgressProperties {
* 定时补偿 finalize用于“分片其实已齐但最后一次提交断开导致没触发 finalize”的场景。
*/
private String finalizeCheckCron = "30 */2 * * * *";
/**
* 商品风险PRODUCT_RISK_RESOLVERUNNING 超时自动失败:与删除品牌共用同一定时调度。
*/
private long productRiskStaleTimeoutHours = 48;
}

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"));
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE"));
}

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.deletebrand.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.nanri.aiimage.config.DeleteBrandProgressProperties;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
@@ -13,11 +14,15 @@ import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Map;
/**
* 删除品牌任务的超时回收与 finalize 补偿并与删除品牌共用同一套定时调度对商品风险PRODUCT_RISK_RESOLVE长时间无回传做失败收尾。
*/
@Service
@RequiredArgsConstructor
public class DeleteBrandStaleTaskService {
private static final String MODULE_TYPE = "DELETE_BRAND";
private static final String MODULE_TYPE_DELETE_BRAND = "DELETE_BRAND";
private static final String MODULE_TYPE_PRODUCT_RISK = "PRODUCT_RISK_RESOLVE";
private final FileTaskMapper fileTaskMapper;
private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
@@ -26,11 +31,16 @@ public class DeleteBrandStaleTaskService {
@Scheduled(cron = "${aiimage.delete-brand-progress.stale-check-cron:0 */2 * * * *}")
public void failStaleRunningTasks() {
failStaleDeleteBrandTasks();
failStaleProductRiskResolveTasks();
}
private void failStaleDeleteBrandTasks() {
LocalDateTime threshold = LocalDateTime.now().minusMinutes(deleteBrandProgressProperties.getHeartbeatTimeoutMinutes());
// 先按 DB updatedAt 粗筛,避免全表扫描
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_DELETE_BRAND)
.eq(FileTaskEntity::getStatus, "RUNNING")
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 200"));
@@ -74,9 +84,9 @@ public class DeleteBrandStaleTaskService {
}
}
int updated = fileTaskMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<FileTaskEntity>()
int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getId, task.getId())
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_DELETE_BRAND)
.eq(FileTaskEntity::getStatus, "RUNNING")
.set(FileTaskEntity::getStatus, "FAILED")
.set(FileTaskEntity::getErrorMessage, "结果回传长时间无响应,任务已自动失败")
@@ -89,10 +99,30 @@ public class DeleteBrandStaleTaskService {
}
}
private void failStaleProductRiskResolveTasks() {
long hours = Math.max(1L, deleteBrandProgressProperties.getProductRiskStaleTimeoutHours());
LocalDateTime threshold = LocalDateTime.now().minusHours(hours);
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_PRODUCT_RISK)
.eq(FileTaskEntity::getStatus, "RUNNING")
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 200"));
for (FileTaskEntity task : runningTasks) {
fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getId, task.getId())
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_PRODUCT_RISK)
.eq(FileTaskEntity::getStatus, "RUNNING")
.set(FileTaskEntity::getStatus, "FAILED")
.set(FileTaskEntity::getErrorMessage, "长时间未收到 Python 结果,任务已自动失败")
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())
.set(FileTaskEntity::getFinishedAt, LocalDateTime.now()));
}
}
@Scheduled(cron = "${aiimage.delete-brand-progress.finalize-check-cron:30 */2 * * * *}")
public void finalizeCompletedRunningTasks() {
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_DELETE_BRAND)
.eq(FileTaskEntity::getStatus, "RUNNING")
.orderByAsc(FileTaskEntity::getUpdatedAt)
.last("limit 100"));

View File

@@ -0,0 +1,351 @@
package com.nanri.aiimage.modules.productrisk.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCountryPreferenceSaveRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCreateTaskRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskSubmitResultRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskTaskBatchRequest;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCountryPreferenceVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCreateTaskVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskHistoryVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskPendingDeleteVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskTaskBatchVo;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskResolveService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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 org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
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/product-risk-resolve")
@Tag(
name = "商品风险解决",
description = "亚马逊商品风险处理备选店铺、紫鸟匹配、创建任务、Python 队列与回传、生成 zip。"
+ "查询类接口需带 Query 参数 user_id与登录用户一致")
public class ProductRiskResolveController {
private final ProductRiskResolveService productRiskResolveService;
private final ProductRiskTaskService productRiskTaskService;
@GetMapping("/candidates")
@Operation(
summary = "查询备选店铺列表",
description = "返回当前用户在「商品风险」模块中已保存的备选店铺,按创建时间倒序。用于前端勾选后再调用匹配接口。")
public ApiResponse<List<ProductRiskCandidateVo>> listCandidates(
@Parameter(
name = "user_id",
description = "当前用户 ID仅返回该用户下的备选店铺",
required = true,
in = ParameterIn.QUERY,
example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(productRiskResolveService.listCandidates(userId));
}
@PostMapping("/candidates")
@Operation(
summary = "新增备选店铺",
description = "将店铺名写入用户备选表;店铺名会规范化去重。仅用于前端勾选与匹配流程,与任务/结果表无强绑定。")
public ApiResponse<ProductRiskCandidateVo> addCandidate(@Valid @RequestBody ProductRiskCandidateAddRequest request) {
return ApiResponse.success(productRiskResolveService.addCandidate(request));
}
@DeleteMapping("/candidates/{id}")
@Operation(
summary = "删除备选店铺",
description = "按主键删除一条备选记录;`id` 必须属于当前 `user_id`,否则报错。")
public ApiResponse<Void> deleteCandidate(
@Parameter(description = "备选店铺记录主键biz_product_risk_shop_candidate.id", example = "10")
@PathVariable Long id,
@Parameter(
name = "user_id",
description = "当前用户 ID用于校验数据归属",
required = true,
in = ParameterIn.QUERY,
example = "1")
@RequestParam("user_id") Long userId) {
productRiskResolveService.deleteCandidate(userId, id);
return ApiResponse.success(null);
}
@GetMapping("/country-preference")
@Operation(
summary = "查询用户五国处理顺序",
description = "返回当前用户已保存的国家代码列表顺序即处理顺序未保存时返回默认DE、UK、FR、IT、ES全选")
public ApiResponse<ProductRiskCountryPreferenceVo> getCountryPreference(
@Parameter(
name = "user_id",
description = "当前用户 ID",
required = true,
in = ParameterIn.QUERY,
example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(productRiskResolveService.getCountryPreference(userId));
}
@PutMapping("/country-preference")
@Operation(
summary = "保存用户五国处理顺序",
description = "写入 biz_product_risk_country_prefcountry_codes 为 ProductRiskCountryCode 枚举名,至少 1 个、无重复。")
public ApiResponse<ProductRiskCountryPreferenceVo> saveCountryPreference(
@Valid @RequestBody ProductRiskCountryPreferenceSaveRequest request) {
return ApiResponse.success(productRiskResolveService.saveCountryPreference(request));
}
@PostMapping("/match-shops")
@Operation(
summary = "批量匹配店铺(紫鸟索引)",
description = "按店名查紫鸟索引,返回 shopId、platform、companyName、matchStatus 等items 可作队列 data.items。"
+ "未命中时 matched=false不可创建任务。")
public ApiResponse<ProductRiskMatchShopsVo> matchShops(@Valid @RequestBody ProductRiskMatchShopsRequest request) {
return ApiResponse.success(productRiskResolveService.matchShops(request));
}
@GetMapping("/dashboard")
@Operation(
summary = "统计看板",
description = "汇总当前用户备选店铺数量商品风险模块下已结束任务数SUCCESS/FAILED其中成功任务数、失败任务数。")
public ApiResponse<ProductRiskDashboardVo> dashboard(
@Parameter(
name = "user_id",
description = "当前用户 ID",
required = true,
in = ParameterIn.QUERY,
example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(productRiskTaskService.dashboard(userId));
}
@GetMapping("/history")
@Operation(
summary = "处理记录列表(含进行中与已结束)",
description = "返回当前用户商品风险模块下各店结果行biz_file_result按创建时间倒序最多 100 条;"
+ "含 resultId、taskId、店名、任务状态、success、错误、输出文件名等。")
public ApiResponse<ProductRiskHistoryVo> history(
@Parameter(
name = "user_id",
description = "当前用户 ID",
required = true,
in = ParameterIn.QUERY,
example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(productRiskTaskService.listHistory(userId));
}
@DeleteMapping("/history/{resultId}")
@Operation(
summary = "删除单条店铺结果记录",
description = "删除一条 biz_file_result须为本模块且归属 user_id随后重算父任务无子结果则删任务。")
public ApiResponse<Void> deleteHistory(
@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) {
productRiskTaskService.deleteHistory(resultId, userId);
return ApiResponse.success(null);
}
@PostMapping("/tasks")
@Operation(
summary = "创建商品风险处理任务",
description = "推 Python 队列前调用:落库 RUNNING 任务及各店 biz_file_result响应 taskId 写入队列 data.taskId"
+ "items 含各店 resultId。请求 items 须全为 matched=true店名会去重。")
public ApiResponse<ProductRiskCreateTaskVo> createTask(@Valid @RequestBody ProductRiskCreateTaskRequest request) {
return ApiResponse.success(productRiskTaskService.createTask(request));
}
@DeleteMapping("/tasks/{taskId}")
@Operation(
summary = "删除整条任务",
description = "删除该任务及下属全部结果行RUNNING/SUCCESS/FAILED 均可user_id 须与任务归属一致。")
public ApiResponse<Void> deleteTask(
@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) {
productRiskTaskService.deleteTask(taskId, userId);
return ApiResponse.success(null);
}
@DeleteMapping("/pending-shop-result")
@Operation(
summary = "按店铺名删除运行中任务下的一条结果",
description = "仅在 RUNNING 任务下按规范化店名删一条 biz_file_result 并重算任务;无匹配时 removed=false。"
+ "用于前端从匹配列表移除店铺时同步后端占位。")
public ApiResponse<ProductRiskPendingDeleteVo> deletePendingShopResult(
@Parameter(
name = "user_id",
description = "当前用户 ID",
required = true,
in = ParameterIn.QUERY,
example = "1")
@RequestParam("user_id") Long userId,
@Parameter(
name = "shop_name",
description = "店铺名称(与匹配/创建任务时一致;服务端会按紫鸟规则规范化后再与库中 source_filename 比对)",
required = true,
in = ParameterIn.QUERY,
example = "某某店铺")
@RequestParam("shop_name") String shopName) {
return ApiResponse.success(productRiskTaskService.deletePendingShopResult(userId, shopName));
}
@PostMapping("/tasks/batch")
@Operation(
summary = "批量查询任务详情(轮询)",
description = "按 taskIds 返回任务概要及各店结果;无效 id 记入 missingTaskIds。供前端轮询 RUNNING 至结束。")
public ApiResponse<ProductRiskTaskBatchVo> tasksBatch(@Valid @RequestBody ProductRiskTaskBatchRequest request) {
return ApiResponse.success(productRiskTaskService.getTaskDetailsBatch(request.getTaskIds()));
}
@PostMapping("/tasks/{taskId}/result")
@Operation(
summary = "Python 回传处理结果(可多次、可分批)",
description = "Python 调用;同一 taskId 可多次,未出现在 shops 中的店保持待处理。"
+ "有 error 则标店失败;否则按 countries键为五国枚举 DE/FR/ES/IT/UK生成 xlsx/zip 上传 OSS"
+ "Excel 内仍为中文工作表名。全部店终态后任务 SUCCESS/FAILED。")
public ApiResponse<Void> submitResult(
@Parameter(description = "任务主键,须为 RUNNING 状态", example = "200")
@PathVariable Long taskId,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "按店铺回传:成功时 `countries` 的 key 为枚举 ProductRiskCountryCodeJSON 与枚举名一致DE、FR、ES、IT、UK"
+ "失败时填 `error`,可不传 `countries`",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ProductRiskSubmitResultRequest.class),
examples = {
@ExampleObject(
name = "成功(单店,五国含样例行)",
summary = "DE、UK 各一行,其余国可为空数组",
value = """
{
"shops": [
{
"shopName": "模拟旗舰店A",
"countries": {
"DE": [
{
"shopName": "模拟旗舰店A",
"productAsinSku": "B0DEMO1234",
"status": "风险待解除",
"done": "",
"removeAsin": "B0OLD5678",
"removeStatus": "排队中"
}
],
"FR": [],
"ES": [],
"IT": [],
"UK": [
{
"shopName": "模拟旗舰店A",
"productAsinSku": "B0UKDEMO99",
"status": "正常",
"done": "",
"removeAsin": "",
"removeStatus": ""
}
]
}
}
]
}
"""),
@ExampleObject(
name = "失败(单店仅 error",
summary = "该店标记失败,不生成 zip",
value = """
{
"shops": [
{
"shopName": "模拟旗舰店B",
"error": "紫鸟会话超时,未能导出表格"
}
]
}
""")
}))
@Valid @RequestBody ProductRiskSubmitResultRequest request) {
productRiskTaskService.submitResult(taskId, request);
return ApiResponse.success(null);
}
@GetMapping("/results/{resultId}/download")
@Operation(
summary = "流式下载单个店铺的 zip 结果",
description = "从 OSS 拉取 zip 以附件写入响应体(非 JSON须归属 user_id无文件则 404。")
public void downloadResult(
@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 = productRiskTaskService.resolveResultDownloadUrl(resultId, userId);
String filename = productRiskTaskService.resolveResultDownloadFilename(resultId, userId);
if (url == null || url.isBlank()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果");
}
try {
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
response.setContentType("application/octet-stream");
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];
int read;
while ((read = in.read(buffer)) != -1) {
response.getOutputStream().write(buffer, 0, read);
}
response.getOutputStream().flush();
}
} catch (Exception ex) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
}
}
}

View File

@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.productrisk.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.productrisk.model.entity.ProductRiskCountryPrefEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ProductRiskCountryPrefMapper extends BaseMapper<ProductRiskCountryPrefEntity> {
}

View File

@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.productrisk.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.productrisk.model.entity.ProductRiskShopCandidateEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ProductRiskShopCandidateMapper extends BaseMapper<ProductRiskShopCandidateEntity> {
}

View File

@@ -0,0 +1,22 @@
package com.nanri.aiimage.modules.productrisk.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
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 ProductRiskCandidateAddRequest {
@NotNull(message = "user_id 不能为空")
@JsonProperty("user_id")
@Schema(description = "当前用户 ID数据按用户隔离", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long userId;
@NotBlank(message = "店铺名不能为空")
@JsonProperty("shop_name")
@Schema(description = "店铺名称(展示用原文;入库前会规范化去重)", requiredMode = Schema.RequiredMode.REQUIRED, example = "测试店铺A")
private String shopName;
}

View File

@@ -0,0 +1,27 @@
package com.nanri.aiimage.modules.productrisk.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "保存用户五国处理顺序:至少 1 个、最多 5 个,代码须为 DE、FR、ES、IT、UK无重复")
public class ProductRiskCountryPreferenceSaveRequest {
@NotNull(message = "user_id 不能为空")
@JsonProperty("user_id")
@Schema(description = "当前用户 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long userId;
@NotEmpty(message = "country_codes 不能为空")
@Size(max = 5, message = "country_codes 最多 5 项")
@JsonProperty("country_codes")
@Schema(description = "国家代码列表(与 ProductRiskCountryCode 枚举名一致),顺序即队列处理顺序", requiredMode = Schema.RequiredMode.REQUIRED)
private List<String> countryCodes = new ArrayList<>();
}

View File

@@ -0,0 +1,30 @@
package com.nanri.aiimage.modules.productrisk.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskShopQueueItemVo;
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.ArrayList;
import java.util.List;
@Data
@Schema(description = "创建商品风险处理任务请求体:在推 Python 队列前调用,落库任务与各店结果占位行")
public class ProductRiskCreateTaskRequest {
@NotNull(message = "user_id 不能为空")
@JsonProperty("user_id")
@Schema(description = "任务归属用户 ID写入 biz_file_task.user_id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long userId;
@NotEmpty(message = "items 不能为空")
@Valid
@Schema(
description = "已匹配店铺列表(结构同 /match-shops 的 items 元素),每项须 matched=true"
+ "店名会去重;每店生成一条 biz_file_result含 resultId",
requiredMode = Schema.RequiredMode.REQUIRED)
private List<ProductRiskShopQueueItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,27 @@
package com.nanri.aiimage.modules.productrisk.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
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 ProductRiskMatchShopsRequest {
@NotNull(message = "user_id 不能为空")
@JsonProperty("user_id")
@Schema(description = "当前用户 ID审计/归属;匹配逻辑主要依赖店名)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long userId;
@NotEmpty(message = "shop_names 不能为空")
@JsonProperty("shop_names")
@Schema(
description = "待匹配的店铺名称列表,至少 1 个;服务端按列表顺序逐项查索引,返回体 `items` 与列表顺序一一对应",
requiredMode = Schema.RequiredMode.REQUIRED,
example = "[\"店铺甲\",\"店铺乙\"]")
private List<String> shopNames;
}

View File

@@ -0,0 +1,33 @@
package com.nanri.aiimage.modules.productrisk.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "单国 sheet 内一行数据,与商品风险处理 Excel 模板列一致")
public class ProductRiskRowDto {
@JsonProperty("shopName")
@Schema(description = "店铺名(行内冗余字段,可与 sheet 维度一致)")
private String shopName;
@JsonProperty("productAsinSku")
@Schema(description = "商品 ASIN/SKU 等业务主键列")
private String productAsinSku;
@Schema(description = "状态列")
private String status;
@JsonProperty("done")
@Schema(description = "是否完成等标记列")
private String done;
@JsonProperty("removeAsin")
@Schema(description = "待移除 ASIN 等")
private String removeAsin;
@JsonProperty("removeStatus")
@Schema(description = "移除状态")
private String removeStatus;
}

View File

@@ -0,0 +1,33 @@
package com.nanri.aiimage.modules.productrisk.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Data
@Schema(description = "Python 回传的单个店铺:成功时带五国表格数据;失败时仅填 error")
public class ProductRiskShopPayloadDto {
@JsonProperty("shopName")
@Schema(description = "店铺名称,用于与任务内结果行匹配(会按紫鸟规则规范化后比对)", example = "测试店铺")
private String shopName;
/**
* 若不为空,表示该店铺处理失败,跳过后续 xlsx 生成。
*/
@Schema(description = "非空时表示该店处理失败,写入 errorMessage不生成 zip为空则须配合 countries 生成文件")
private String error;
/**
* key 为 {@link ProductRiskCountryCode} 的 JSON 名DE、FR、ES、IT、UK写盘时映射为中文工作表。
*/
@Schema(
description = "五国数据JSON 对象的键为枚举 ProductRiskCountryCodeDE、FR、ES、IT、UK"
+ "值为该国 sheet 的行列表;省略的键表示该国无数据行。")
private Map<ProductRiskCountryCode, List<ProductRiskRowDto>> countries = new LinkedHashMap<>();
}

View File

@@ -0,0 +1,23 @@
package com.nanri.aiimage.modules.productrisk.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(
description = "Python 回传处理结果请求体:按店铺提交五国 sheet 数据或错误信息(`countries` 的键为枚举 ProductRiskCountryCodeDE、FR、ES、IT、UK"
+ "可多次调用、可只包含部分店铺")
public class ProductRiskSubmitResultRequest {
@NotEmpty(message = "shops 不能为空")
@Valid
@Schema(
description = "本次回传涉及的店铺列表,至少 1 个;店名须能与任务下 biz_file_result.source_filename规范化店名对应",
requiredMode = Schema.RequiredMode.REQUIRED)
private List<ProductRiskShopPayloadDto> shops = new ArrayList<>();
}

View File

@@ -0,0 +1,20 @@
package com.nanri.aiimage.modules.productrisk.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 ProductRiskTaskBatchRequest {
@NotEmpty(message = "taskIds 不能为空")
@Schema(
description = "待查询的任务主键列表biz_file_task.id不可为空无效或非本模块 id 会出现在响应 `missingTaskIds`",
requiredMode = Schema.RequiredMode.REQUIRED,
example = "[200,201]")
private List<Long> taskIds = new ArrayList<>();
}

View File

@@ -0,0 +1,18 @@
package com.nanri.aiimage.modules.productrisk.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_product_risk_country_pref")
public class ProductRiskCountryPrefEntity {
@TableId(type = IdType.INPUT)
private Long userId;
private String countryCodesJson;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,19 @@
package com.nanri.aiimage.modules.productrisk.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_product_risk_shop_candidate")
public class ProductRiskShopCandidateEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String shopName;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,29 @@
package com.nanri.aiimage.modules.productrisk.model.enums;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* 商品风险回传五国:请求 JSON 中 {@code countries} 的键须为该枚举名(大写);生成 Excel 时映射为中文工作表名。
*/
@Schema(
description = "五国站点代码,用作 countries 的 JSON 键(与枚举名一致):"
+ "DE→德国、FR→法国、ES→西班牙、IT→意大利、UK→英国",
enumAsRef = true)
public enum ProductRiskCountryCode {
DE("德国"),
FR("法国"),
ES("西班牙"),
IT("意大利"),
UK("英国");
private final String sheetName;
ProductRiskCountryCode(String sheetName) {
this.sheetName = sheetName;
}
public String getSheetName() {
return sheetName;
}
}

View File

@@ -0,0 +1,23 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Schema(description = "备选店铺列表单项")
public class ProductRiskCandidateVo {
@Schema(description = "记录主键,删除备选时作为路径参数 id")
private Long id;
@JsonProperty("shop_name")
@Schema(description = "店铺名称JSON 字段名 shop_name", example = "测试店铺")
private String shopName;
@JsonProperty("created_at")
@Schema(description = "创建时间JSON 字段名 created_at")
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "用户五国处理偏好按顺序的国家代码列表ProductRiskCountryCode 名,如 DE、UK")
public class ProductRiskCountryPreferenceVo {
@JsonProperty("country_codes")
@Schema(description = "已选国家代码,顺序即处理顺序;未持久化时服务端返回默认德国→英国→法国→意大利→西班牙(全选)")
private List<String> countryCodes = new ArrayList<>();
}

View File

@@ -0,0 +1,18 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "创建任务响应:含任务 id 与各店初始快照(含 resultId用于推队列与前端展示")
public class ProductRiskCreateTaskVo {
@Schema(description = "新建任务主键 biz_file_task.id须写入队列消息 data.taskId", example = "200")
private Long taskId;
@Schema(description = "各店一条结果快照,含 resultId、taskId、店名及初始 taskStatus=RUNNING 等")
private List<ProductRiskResultItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,26 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "商品风险模块统计看板(当前用户维度)")
public class ProductRiskDashboardVo {
@JsonProperty("candidateCount")
@Schema(description = "备选店铺条数biz_product_risk_shop_candidate")
private long candidateCount;
@JsonProperty("processedTaskCount")
@Schema(description = "已结束任务数:状态为 SUCCESS 或 FAILED 的商品风险任务总数")
private long processedTaskCount;
@JsonProperty("successTaskCount")
@Schema(description = "成功结束的任务数(状态 SUCCESS")
private long successTaskCount;
@JsonProperty("failedTaskCount")
@Schema(description = "失败结束的任务数(状态 FAILED")
private long failedTaskCount;
}

View File

@@ -0,0 +1,15 @@
package com.nanri.aiimage.modules.productrisk.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 ProductRiskHistoryVo {
@Schema(description = "按店铺拆分的结果项列表,默认最多返回 100 条,按创建时间倒序")
private List<ProductRiskResultItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,15 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "批量匹配响应体items 与请求 shop_names 顺序一致")
public class ProductRiskMatchShopsVo {
@Schema(description = "各店匹配结果列表,一项对应请求中的一个店名")
private List<ProductRiskShopQueueItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,12 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "DELETE /pending-shop-result 响应:告知是否在库中删除了运行中任务下的一条店铺结果")
public class ProductRiskPendingDeleteVo {
@Schema(description = "true已删除一条匹配到的 biz_file_result 并已重算父任务false无匹配记录未创建任务或任务已结束或店名无对应行")
private boolean removed;
}

View File

@@ -0,0 +1,60 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "单店结果项:列表接口与任务详情中共用,对应 biz_file_result 一行 + 任务/请求衍生字段")
public class ProductRiskResultItemVo {
@Schema(description = "结果行主键 biz_file_result.id删除单条、下载 zip 时使用", example = "1001")
private Long resultId;
@Schema(description = "所属任务 id biz_file_task.id", example = "200")
private Long taskId;
@JsonProperty("shopName")
@Schema(description = "店铺名称(列表接口中可能与库内规范化名一致;详情中会尽量合并创建任务时的展示名)")
private String shopName;
@JsonProperty("shopId")
@Schema(description = "店铺 ID紫鸟")
private String shopId;
@Schema(description = "平台")
private String platform;
@JsonProperty("companyName")
@Schema(description = "公司名称")
private String companyName;
@Schema(description = "创建任务时是否已匹配(列表中一般为 true")
private boolean matched;
@JsonProperty("matchStatus")
@Schema(description = "匹配状态码,含义同 ProductRiskShopQueueItemVo.matchStatus")
private String matchStatus;
@JsonProperty("matchMessage")
@Schema(description = "匹配说明文案")
private String matchMessage;
@JsonProperty("taskStatus")
@Schema(description = "所属任务当前状态RUNNING / SUCCESS / FAILED与各店行一致")
private String taskStatus;
@Schema(description = "该店是否已成功生成结果文件null 表示未出终态时可不按布尔处理")
private Boolean success;
@Schema(description = "该店失败时的错误信息")
private String error;
@JsonProperty("outputFilename")
@Schema(description = "生成的 zip 文件名(成功后有值)")
private String outputFilename;
@JsonProperty("downloadUrl")
@Schema(description = "预签名下载 URL列表里可能带直链下载也可用 GET /results/{resultId}/download")
private String downloadUrl;
}

View File

@@ -0,0 +1,48 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 与 Python 队列约定字段对齐,供前端原样推入 enqueue_json。
*/
@Data
@Schema(description = "单店匹配结果(队列项):与 /match-shops 及创建任务请求 items 元素结构一致,可嵌入队列 JSON data.items")
public class ProductRiskShopQueueItemVo {
@JsonProperty("shopName")
@Schema(description = "店铺名称", example = "某某店")
private String shopName;
@Schema(description = "是否命中紫鸟索引false 时不可用于创建任务")
private boolean matched;
@JsonProperty("shopId")
@Schema(description = "紫鸟侧店铺 ID字符串未命中时可能为空")
private String shopId;
@Schema(description = "平台,如 亚马逊", example = "亚马逊")
private String platform;
@JsonProperty("companyName")
@Schema(description = "公司名称或主体标识")
private String companyName;
@JsonProperty("openStoreUrl")
@Schema(description = "打开店铺的地址(由 Python 侧消费)")
private String openStoreUrl;
@JsonProperty("matchedUserId")
@Schema(description = "索引关联到的用户 ID若有")
private Long matchedUserId;
@JsonProperty("matchStatus")
@Schema(
description = "匹配状态码:如 MATCHED、PENDING、CONFLICT、INDEX_STALE 等,以后端为准。")
private String matchStatus;
@JsonProperty("matchMessage")
@Schema(description = "人类可读说明,供前端展示")
private String matchMessage;
}

View File

@@ -0,0 +1,18 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "批量任务查询响应:每个 id 一条详情;无效 id 记入 missingTaskIds")
public class ProductRiskTaskBatchVo {
@Schema(description = "与请求 taskIds 顺序不一定一致;仅包含存在且模块类型为商品风险的任务")
private List<ProductRiskTaskDetailVo> items = new ArrayList<>();
@Schema(description = "请求中存在但数据库不存在或非商品风险模块的 taskId 列表")
private List<Long> missingTaskIds = new ArrayList<>();
}

View File

@@ -0,0 +1,18 @@
package com.nanri.aiimage.modules.productrisk.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 ProductRiskTaskDetailVo {
@Schema(description = "任务级字段id、状态、错误信息、时间等")
private ProductRiskTaskItemVo task = new ProductRiskTaskItemVo();
@Schema(description = "该任务下每个店铺一条结果记录,顺序一般按结果表 id 升序")
private List<ProductRiskResultItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,36 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "商品风险任务概要biz_file_task 核心字段)")
public class ProductRiskTaskItemVo {
@Schema(description = "任务主键", example = "200")
private Long id;
@JsonProperty("taskNo")
@Schema(description = "业务单号,如 PRODUCT_RISK_RESOLVE-雪花 id")
private String taskNo;
@Schema(description = "任务状态RUNNING 执行中SUCCESS 已结束且至少一店成功FAILED 已结束且全部失败或整体失败")
private String status;
@JsonProperty("errorMessage")
@Schema(description = "失败或部分失败时的汇总错误说明;成功且无附加信息时可能为空")
private String errorMessage;
@JsonProperty("createdAt")
@Schema(description = "创建时间(字符串格式,服务端 LocalDateTime.toString()")
private String createdAt;
@JsonProperty("updatedAt")
@Schema(description = "最后更新时间")
private String updatedAt;
@JsonProperty("finishedAt")
@Schema(description = "结束时间RUNNING 时通常为空")
private String finishedAt;
}

View File

@@ -0,0 +1,110 @@
package com.nanri.aiimage.modules.productrisk.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskRowDto;
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
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 ProductRiskExcelAssemblyService {
private static final List<String> SHEET_ORDER = java.util.Arrays.stream(ProductRiskCountryCode.values())
.map(ProductRiskCountryCode::getSheetName)
.toList();
private static final String[] HEADER = {
"店铺名",
"商品ASIN/SKU",
"状态",
"完成",
"移除ASIN",
"移除状态"
};
public void writeWorkbook(File outputXlsx, String shopDisplayName, Map<String, List<ProductRiskRowDto>> countries) {
Map<String, List<ProductRiskRowDto>> safe = countries == null ? Map.of() : countries;
try (XSSFWorkbook wb = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(outputXlsx)) {
for (String sheetName : SHEET_ORDER) {
Sheet sheet = wb.createSheet(sheetName);
Row headerRow = sheet.createRow(0);
for (int c = 0; c < HEADER.length; c++) {
headerRow.createCell(c).setCellValue(HEADER[c]);
}
List<ProductRiskRowDto> rows = safe.getOrDefault(sheetName, List.of());
int rowIdx = 1;
for (ProductRiskRowDto dto : rows) {
Row row = sheet.createRow(rowIdx++);
createTextCell(row, 0, firstNonBlank(dto == null ? null : dto.getShopName(), shopDisplayName));
createTextCell(row, 1, dto == null ? null : dto.getProductAsinSku());
createTextCell(row, 2, dto == null ? null : dto.getStatus());
createTextCell(row, 3, dto == null ? null : dto.getDone());
createTextCell(row, 4, dto == null ? null : dto.getRemoveAsin());
createTextCell(row, 5, dto == null ? null : dto.getRemoveStatus());
}
for (int i = 0; i < HEADER.length; i++) {
sheet.autoSizeColumn(i);
}
}
wb.write(fos);
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
log.warn("[product-risk] write workbook failed: {}", ex.getMessage());
throw new BusinessException("生成 Excel 失败: " + ex.getMessage());
}
}
public int countRows(Map<String, List<ProductRiskRowDto>> countries) {
if (countries == null || countries.isEmpty()) {
return 0;
}
int n = 0;
for (List<ProductRiskRowDto> list : countries.values()) {
if (list != null) {
n += list.size();
}
}
return n;
}
/**
* 将接口中的 {@link ProductRiskCountryCode} 键转为 Excel 中文工作表名 → 行列表。
*/
public Map<String, List<ProductRiskRowDto>> normalizeCountriesMap(Map<ProductRiskCountryCode, List<ProductRiskRowDto>> raw) {
if (raw == null || raw.isEmpty()) {
return new LinkedHashMap<>();
}
Map<String, List<ProductRiskRowDto>> out = new LinkedHashMap<>();
for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) {
List<ProductRiskRowDto> rows = raw.get(code);
if (rows != null) {
out.put(code.getSheetName(), rows);
}
}
return out;
}
private static void createTextCell(Row row, int index, String value) {
Cell cell = row.createCell(index);
cell.setCellValue(value == null ? "" : value);
}
private static String firstNonBlank(String primary, String fallback) {
if (primary != null && !primary.isBlank()) {
return primary;
}
return fallback == null ? "" : fallback;
}
}

View File

@@ -0,0 +1,284 @@
package com.nanri.aiimage.modules.productrisk.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.productrisk.mapper.ProductRiskCountryPrefMapper;
import com.nanri.aiimage.modules.productrisk.mapper.ProductRiskShopCandidateMapper;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCountryPreferenceSaveRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
import com.nanri.aiimage.modules.productrisk.model.entity.ProductRiskCountryPrefEntity;
import com.nanri.aiimage.modules.productrisk.model.entity.ProductRiskShopCandidateEntity;
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCountryPreferenceVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskShopQueueItemVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
@Service
@RequiredArgsConstructor
public class ProductRiskResolveService {
/**
* 默认处理顺序:德国 → 英国 → 法国 → 意大利 → 西班牙(与前端一致)。
*/
public static final List<String> DEFAULT_COUNTRY_PREFERENCE_ORDER = List.of("DE", "UK", "FR", "IT", "ES");
private final ProductRiskShopCandidateMapper candidateMapper;
private final ProductRiskCountryPrefMapper countryPrefMapper;
private final ObjectMapper objectMapper;
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
public List<ProductRiskCandidateVo> listCandidates(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
List<ProductRiskShopCandidateEntity> rows = candidateMapper.selectList(
new LambdaQueryWrapper<ProductRiskShopCandidateEntity>()
.eq(ProductRiskShopCandidateEntity::getUserId, userId)
.orderByDesc(ProductRiskShopCandidateEntity::getId));
List<ProductRiskCandidateVo> list = new ArrayList<>();
for (ProductRiskShopCandidateEntity row : rows) {
if (row == null) {
continue;
}
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
vo.setId(row.getId());
vo.setShopName(row.getShopName());
vo.setCreatedAt(row.getCreatedAt());
list.add(vo);
}
return list;
}
@Transactional
public ProductRiskCandidateVo addCandidate(ProductRiskCandidateAddRequest request) {
if (request.getUserId() == null || request.getUserId() <= 0) {
throw new BusinessException("user_id 不合法");
}
String normalized = ziniaoShopSwitchService.normalizeShopName(request.getShopName());
if (normalized.isBlank()) {
throw new BusinessException("店铺名不能为空");
}
ZiniaoShopMatchResultVo indexHit = ziniaoShopSwitchService.findIndexedStoreByName(normalized, false);
if (indexHit == null || !indexHit.isMatched()) {
String hint = indexHit != null && indexHit.getMatchMessage() != null && !indexHit.getMatchMessage().isBlank()
? indexHit.getMatchMessage()
: "店铺索引未命中,无法加入备选";
throw new BusinessException(hint);
}
if (ZiniaoShopIndexService.MATCH_STATUS_CONFLICT.equals(indexHit.getMatchStatus())) {
throw new BusinessException(indexHit.getMatchMessage() != null ? indexHit.getMatchMessage() : "存在多个同名店铺,请人工确认");
}
ProductRiskShopCandidateEntity existing = candidateMapper.selectOne(
new LambdaQueryWrapper<ProductRiskShopCandidateEntity>()
.eq(ProductRiskShopCandidateEntity::getUserId, request.getUserId())
.eq(ProductRiskShopCandidateEntity::getShopName, normalized)
.last("LIMIT 1"));
if (existing != null) {
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
vo.setId(existing.getId());
vo.setShopName(existing.getShopName());
vo.setCreatedAt(existing.getCreatedAt());
return vo;
}
ProductRiskShopCandidateEntity entity = new ProductRiskShopCandidateEntity();
entity.setUserId(request.getUserId());
entity.setShopName(normalized);
entity.setCreatedAt(LocalDateTime.now());
candidateMapper.insert(entity);
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
vo.setId(entity.getId());
vo.setShopName(entity.getShopName());
vo.setCreatedAt(entity.getCreatedAt());
return vo;
}
@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 不合法");
}
ProductRiskShopCandidateEntity row = candidateMapper.selectById(id);
if (row == null || !userId.equals(row.getUserId())) {
throw new BusinessException("记录不存在");
}
candidateMapper.deleteById(id);
}
public ProductRiskMatchShopsVo matchShops(ProductRiskMatchShopsRequest request) {
if (request.getUserId() == null || request.getUserId() <= 0) {
throw new BusinessException("user_id 不合法");
}
LinkedHashSet<String> ordered = new LinkedHashSet<>();
for (String raw : request.getShopNames()) {
String n = ziniaoShopSwitchService.normalizeShopName(raw);
if (!n.isBlank()) {
ordered.add(n);
}
}
if (ordered.isEmpty()) {
throw new BusinessException("shop_names 无有效店铺名");
}
ProductRiskMatchShopsVo vo = new ProductRiskMatchShopsVo();
for (String shopName : ordered) {
vo.getItems().add(matchOneShop(shopName));
}
return vo;
}
public ProductRiskCountryPreferenceVo getCountryPreference(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
ProductRiskCountryPrefEntity row = countryPrefMapper.selectById(userId);
ProductRiskCountryPreferenceVo vo = new ProductRiskCountryPreferenceVo();
if (row == null || row.getCountryCodesJson() == null || row.getCountryCodesJson().isBlank()) {
vo.getCountryCodes().addAll(DEFAULT_COUNTRY_PREFERENCE_ORDER);
return vo;
}
try {
List<String> parsed = objectMapper.readValue(row.getCountryCodesJson(), new TypeReference<List<String>>() {
});
vo.getCountryCodes().addAll(sanitizeStoredCodes(parsed));
} catch (Exception ex) {
vo.getCountryCodes().addAll(DEFAULT_COUNTRY_PREFERENCE_ORDER);
}
return vo;
}
@Transactional
public ProductRiskCountryPreferenceVo saveCountryPreference(ProductRiskCountryPreferenceSaveRequest request) {
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("保存偏好失败");
}
ProductRiskCountryPrefEntity row = countryPrefMapper.selectById(request.getUserId());
if (row == null) {
row = new ProductRiskCountryPrefEntity();
row.setUserId(request.getUserId());
row.setCountryCodesJson(json);
countryPrefMapper.insert(row);
} else {
row.setCountryCodesJson(json);
countryPrefMapper.updateById(row);
}
ProductRiskCountryPreferenceVo vo = new ProductRiskCountryPreferenceVo();
vo.getCountryCodes().addAll(normalized);
return vo;
}
private static List<String> sanitizeStoredCodes(List<String> raw) {
List<String> parsed = parseValidCountryCodes(raw);
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 个国家");
}
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();
try {
ProductRiskCountryCode.valueOf(u);
} catch (IllegalArgumentException ex) {
throw new BusinessException("非法国家代码: " + s);
}
if (!seen.add(u)) {
throw new BusinessException("country_codes 存在重复: " + u);
}
out.add(u);
}
if (out.size() > ProductRiskCountryCode.values().length) {
throw new BusinessException("country_codes 最多 5 项");
}
return out;
}
/**
* 仅保留合法枚举名,去重并保持顺序;非法项静默丢弃(用于读取库内数据)。
*/
private static List<String> parseValidCountryCodes(List<String> raw) {
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();
try {
ProductRiskCountryCode.valueOf(u);
} catch (IllegalArgumentException ex) {
continue;
}
if (seen.add(u)) {
out.add(u);
}
}
return out;
}
private ProductRiskShopQueueItemVo matchOneShop(String shopName) {
ProductRiskShopQueueItemVo item = new ProductRiskShopQueueItemVo();
item.setShopName(shopName);
try {
ZiniaoShopMatchResultVo m = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false);
if (m == null) {
item.setMatched(false);
item.setMatchStatus("PENDING");
item.setMatchMessage("匹配结果为空");
return item;
}
item.setMatched(m.isMatched());
item.setShopId(m.getShopId());
item.setPlatform(m.getPlatform());
item.setCompanyName(m.getCompanyName());
item.setMatchedUserId(m.getMatchedUserId());
item.setMatchStatus(m.getMatchStatus());
item.setMatchMessage(m.getMatchMessage());
} catch (BusinessException ex) {
item.setMatched(false);
item.setMatchStatus("PENDING");
item.setMatchMessage(ex.getMessage());
} catch (Exception ex) {
item.setMatched(false);
item.setMatchStatus("PENDING");
item.setMatchMessage(Objects.toString(ex.getMessage(), "匹配异常"));
}
return item;
}
}

View File

@@ -0,0 +1,634 @@
package com.nanri.aiimage.modules.productrisk.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.productrisk.mapper.ProductRiskShopCandidateMapper;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCreateTaskRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskRowDto;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskShopPayloadDto;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskSubmitResultRequest;
import com.nanri.aiimage.modules.productrisk.model.entity.ProductRiskShopCandidateEntity;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCreateTaskVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskHistoryVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskResultItemVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskTaskBatchVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskTaskDetailVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskPendingDeleteVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskTaskItemVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskShopQueueItemVo;
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;
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductRiskTaskService {
private static final String MODULE_TYPE = "PRODUCT_RISK_RESOLVE";
private static final String CONTENT_TYPE_ZIP = "application/zip";
private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper;
private final ProductRiskShopCandidateMapper candidateMapper;
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
private final ProductRiskExcelAssemblyService excelAssemblyService;
private final OssStorageService ossStorageService;
private final ObjectMapper objectMapper;
public ProductRiskDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
ProductRiskDashboardVo vo = new ProductRiskDashboardVo();
vo.setCandidateCount(candidateMapper.selectCount(new LambdaQueryWrapper<ProductRiskShopCandidateEntity>()
.eq(ProductRiskShopCandidateEntity::getUserId, userId)));
Long processed = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getUserId, userId)
.in(FileTaskEntity::getStatus, List.of("SUCCESS", "FAILED")));
Long success = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getUserId, userId)
.eq(FileTaskEntity::getStatus, "SUCCESS"));
Long failed = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getUserId, userId)
.eq(FileTaskEntity::getStatus, "FAILED"));
vo.setProcessedTaskCount(processed == null ? 0L : processed);
vo.setSuccessTaskCount(success == null ? 0L : success);
vo.setFailedTaskCount(failed == null ? 0L : failed);
return vo;
}
public ProductRiskHistoryVo listHistory(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
ProductRiskHistoryVo vo = new ProductRiskHistoryVo();
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.eq(FileResultEntity::getUserId, userId)
.orderByDesc(FileResultEntity::getCreatedAt)
.last("limit 100"));
Map<Long, String> statusByTaskId = new LinkedHashMap<>();
List<Long> taskIds = entities.stream()
.map(FileResultEntity::getTaskId)
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (!taskIds.isEmpty()) {
List<FileTaskEntity> tasks = fileTaskMapper.selectBatchIds(taskIds);
for (FileTaskEntity t : tasks) {
if (t != null) {
statusByTaskId.put(t.getId(), t.getStatus());
}
}
}
List<ProductRiskResultItemVo> items = new ArrayList<>();
for (FileResultEntity entity : entities) {
items.add(toHistoryItemVo(entity, statusByTaskId.get(entity.getTaskId())));
}
vo.setItems(items);
return vo;
}
@Transactional
public void deleteHistory(Long resultId, Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
}
Long taskId = entity.getTaskId();
fileResultMapper.deleteById(resultId);
if (taskId != null && taskId > 0) {
reconcileTaskAfterResultRemoval(taskId);
}
}
/**
* 删除整条商品风险任务及其下所有店铺结果(运行中或已结束均可,需归属当前用户)。
*/
@Transactional
public void deleteTask(Long taskId, Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
}
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
fileTaskMapper.deleteById(taskId);
}
/**
* 从用户当前「运行中」的商品风险任务里,按规范化店铺名删除一条结果(用于前端移除匹配列表后同步后端)。
* 若无对应记录则 removed=false。
*/
@Transactional
public ProductRiskPendingDeleteVo 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"));
ProductRiskPendingDeleteVo vo = new ProductRiskPendingDeleteVo();
vo.setRemoved(false);
for (FileResultEntity fr : candidates) {
if (fr.getTaskId() == null) {
continue;
}
FileTaskEntity t = fileTaskMapper.selectById(fr.getTaskId());
if (t == null || !MODULE_TYPE.equals(t.getModuleType()) || !userId.equals(t.getUserId())) {
continue;
}
if (!"RUNNING".equals(t.getStatus())) {
continue;
}
Long tid = fr.getTaskId();
fileResultMapper.deleteById(fr.getId());
reconcileTaskAfterResultRemoval(tid);
vo.setRemoved(true);
return vo;
}
return vo;
}
/**
* 删除一条 file_result 后重算父任务状态;若无剩余结果则删除任务。
*/
private void reconcileTaskAfterResultRemoval(Long taskId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return;
}
List<FileResultEntity> latest = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
if (latest.isEmpty()) {
fileTaskMapper.deleteById(taskId);
return;
}
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(taskId, task.getStatus())));
} catch (Exception ex) {
log.warn("[product-risk] compact result json failed: {}", ex.getMessage());
}
fileTaskMapper.updateById(task);
}
public ProductRiskTaskBatchVo getTaskDetailsBatch(List<Long> taskIds) {
ProductRiskTaskBatchVo batch = new ProductRiskTaskBatchVo();
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;
}
public String resolveResultDownloadUrl(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
}
if (entity.getResultFileUrl() == null || entity.getResultFileUrl().isBlank()) {
throw new BusinessException("暂无可下载文件");
}
return ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl());
}
public String resolveResultDownloadFilename(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
}
if (entity.getResultFilename() != null && !entity.getResultFilename().isBlank()) {
return entity.getResultFilename();
}
return safeFileStem(entity.getSourceFilename()) + ".zip";
}
@Transactional
public ProductRiskCreateTaskVo createTask(ProductRiskCreateTaskRequest request) {
if (request.getUserId() == null || request.getUserId() <= 0) {
throw new BusinessException("user_id 不合法");
}
if (request.getItems() == null || request.getItems().isEmpty()) {
throw new BusinessException("items 不能为空");
}
List<ProductRiskShopQueueItemVo> uniqueItems = dedupeQueueItems(request.getItems());
if (uniqueItems.isEmpty()) {
throw new BusinessException("items 不能为空");
}
for (ProductRiskShopQueueItemVo item : uniqueItems) {
if (item == null || !item.isMatched()) {
String name = item == null ? "" : Objects.toString(item.getShopName(), "");
throw new BusinessException("存在未匹配店铺,无法创建任务: " + name);
}
}
FileTaskEntity task = new FileTaskEntity();
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
task.setModuleType(MODULE_TYPE);
task.setTaskMode("PYTHON_QUEUE");
task.setStatus("RUNNING");
task.setSourceFileCount(uniqueItems.size());
task.setSuccessFileCount(0);
task.setFailedFileCount(0);
task.setCreatedBy("user:" + request.getUserId());
task.setUserId(request.getUserId());
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.insert(task);
List<ProductRiskResultItemVo> snapshot = new ArrayList<>();
for (ProductRiskShopQueueItemVo item : uniqueItems) {
String norm = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (norm.isBlank()) {
throw new BusinessException("店铺名无效");
}
FileResultEntity fr = new FileResultEntity();
fr.setTaskId(task.getId());
fr.setModuleType(MODULE_TYPE);
fr.setSourceFilename(norm);
fr.setSourceFileUrl(item.getShopId());
fr.setUserId(request.getUserId());
fr.setSuccess(0);
fr.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(fr);
snapshot.add(toSnapshotVo(fr, item, task.getStatus()));
}
try {
ProductRiskCreateTaskRequest persisted = new ProductRiskCreateTaskRequest();
persisted.setUserId(request.getUserId());
persisted.setItems(uniqueItems);
task.setRequestJson(objectMapper.writeValueAsString(persisted));
task.setResultJson(objectMapper.writeValueAsString(snapshot));
} catch (Exception ex) {
throw new BusinessException("序列化任务数据失败");
}
fileTaskMapper.updateById(task);
ProductRiskCreateTaskVo vo = new ProductRiskCreateTaskVo();
vo.setTaskId(task.getId());
vo.setItems(snapshot);
return vo;
}
@Transactional
public void submitResult(Long taskId, ProductRiskSubmitResultRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法");
}
if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
throw new BusinessException("shops 不能为空");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
throw new BusinessException("任务已结束,拒绝重复提交");
}
List<FileResultEntity> resultRows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
Map<String, ProductRiskShopPayloadDto> payloadByShop = new LinkedHashMap<>();
for (ProductRiskShopPayloadDto p : request.getShops()) {
if (p == null) {
continue;
}
String key = ziniaoShopSwitchService.normalizeShopName(p.getShopName());
if (!key.isBlank()) {
payloadByShop.put(key, p);
}
}
File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "product-risk-result", String.valueOf(taskId)));
List<String> batchErrors = new ArrayList<>();
for (FileResultEntity fr : resultRows) {
String shopKey = fr.getSourceFilename();
ProductRiskShopPayloadDto payload = shopKey == null ? null : payloadByShop.get(shopKey);
if (payload == null) {
// 与删除品牌一致:支持队列逐条处理、分次回传,未在本次 payload 中的店铺保持待处理
continue;
}
if (payload.getError() != null && !payload.getError().isBlank()) {
markResultFailed(fr, payload.getError());
batchErrors.add(shopKey + ": " + payload.getError());
continue;
}
try {
Map<String, List<ProductRiskRowDto>> countries = excelAssemblyService.normalizeCountriesMap(payload.getCountries());
String displayName = payload.getShopName() != null && !payload.getShopName().isBlank()
? payload.getShopName().trim()
: shopKey;
String stem = safeFileStem(displayName);
File xlsx = FileUtil.file(workRoot, stem + ".xlsx");
excelAssemblyService.writeWorkbook(xlsx, displayName, countries);
File zip = FileUtil.file(workRoot, stem + ".zip");
ZipUtil.zip(zip, false, xlsx);
String objectKey = ossStorageService.uploadResultFile(zip, MODULE_TYPE);
fr.setResultFilename(stem + ".zip");
fr.setResultFileUrl(objectKey);
fr.setResultFileSize(zip.length());
fr.setResultContentType(CONTENT_TYPE_ZIP);
fr.setRowCount(excelAssemblyService.countRows(countries));
fr.setSuccess(1);
fr.setErrorMessage(null);
fileResultMapper.updateById(fr);
FileUtil.del(xlsx);
FileUtil.del(zip);
} catch (Exception ex) {
log.warn("[product-risk] shop assemble failed taskId={} shop={} msg={}", taskId, shopKey, ex.getMessage());
markResultFailed(fr, ex.getMessage() == null ? "组装失败" : ex.getMessage());
batchErrors.add(shopKey + ": " + ex.getMessage());
}
}
List<FileResultEntity> latest = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
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.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.isEmpty() ? batchErrors : 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(taskId, task.getStatus())));
} catch (Exception ex) {
log.warn("[product-risk] compact result json failed: {}", ex.getMessage());
}
fileTaskMapper.updateById(task);
}
private void markResultFailed(FileResultEntity fr, String message) {
fr.setSuccess(0);
fr.setErrorMessage(message);
fr.setResultFilename(null);
fr.setResultFileUrl(null);
fr.setResultFileSize(0L);
fileResultMapper.updateById(fr);
}
private ProductRiskTaskDetailVo buildTaskDetail(FileTaskEntity task) {
ProductRiskTaskDetailVo detail = new ProductRiskTaskDetailVo();
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 List<ProductRiskResultItemVo> buildSnapshotFromDb(Long taskId, String taskStatus) {
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
List<ProductRiskResultItemVo> list = new ArrayList<>();
for (FileResultEntity fr : rows) {
list.add(toHistoryItemVo(fr, taskStatus));
}
return list;
}
private ProductRiskTaskItemVo toTaskItemVo(FileTaskEntity task) {
ProductRiskTaskItemVo vo = new ProductRiskTaskItemVo();
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 ProductRiskResultItemVo toSnapshotVo(FileResultEntity fr, ProductRiskShopQueueItemVo item, String taskStatus) {
ProductRiskResultItemVo vo = new ProductRiskResultItemVo();
vo.setResultId(fr.getId());
vo.setTaskId(fr.getTaskId());
vo.setShopName(item.getShopName());
vo.setShopId(item.getShopId());
vo.setPlatform(item.getPlatform());
vo.setCompanyName(item.getCompanyName());
vo.setMatched(item.isMatched());
vo.setMatchStatus(item.getMatchStatus());
vo.setMatchMessage(item.getMatchMessage());
vo.setTaskStatus(taskStatus);
vo.setSuccess(false);
vo.setError(null);
vo.setOutputFilename(null);
vo.setDownloadUrl(null);
return vo;
}
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) {
ProductRiskResultItemVo vo = new ProductRiskResultItemVo();
vo.setResultId(entity.getId());
vo.setTaskId(entity.getTaskId());
vo.setShopName(entity.getSourceFilename());
vo.setShopId(entity.getSourceFileUrl());
vo.setTaskStatus(taskStatus);
boolean ok = entity.getSuccess() != null && entity.getSuccess() == 1;
vo.setSuccess(ok);
vo.setError(entity.getErrorMessage());
vo.setOutputFilename(entity.getResultFilename());
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 void mergeQueueFieldsFromRequest(ProductRiskResultItemVo vo, Long taskId) {
FileTaskEntity t = fileTaskMapper.selectById(taskId);
if (t == null || t.getRequestJson() == null || t.getRequestJson().isBlank()) {
return;
}
try {
ProductRiskCreateTaskRequest req = objectMapper.readValue(t.getRequestJson(), ProductRiskCreateTaskRequest.class);
if (req.getItems() == null) {
return;
}
String key = ziniaoShopSwitchService.normalizeShopName(vo.getShopName());
for (ProductRiskShopQueueItemVo item : req.getItems()) {
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 static String fmt(LocalDateTime t) {
return t == null ? null : t.toString();
}
private List<ProductRiskShopQueueItemVo> dedupeQueueItems(List<ProductRiskShopQueueItemVo> raw) {
Map<String, ProductRiskShopQueueItemVo> byNorm = new LinkedHashMap<>();
for (ProductRiskShopQueueItemVo item : raw) {
if (item == null) {
continue;
}
String k = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (k.isBlank()) {
continue;
}
byNorm.putIfAbsent(k, item);
}
return new ArrayList<>(byNorm.values());
}
private static String safeFileStem(String shopName) {
String s = shopName == null ? "shop" : shopName.trim();
if (s.isBlank()) {
s = "shop";
}
return s.replaceAll("[\\\\/:*?\"<>|]", "_");
}
}

View File

@@ -74,7 +74,10 @@ public class ZiniaoShopIndexService {
return cursor;
}
public ZiniaoShopMatchResultVo findIndexedStoreByName(String targetShopName) {
/**
* @param buildOpenStoreUrl 是否拼接紫鸟打开店铺 URL商品风险等由 Python 侧自行拼地址时应传 false避免多余鉴权调用且不因链接失败误判匹配状态。
*/
public ZiniaoShopMatchResultVo findIndexedStoreByName(String targetShopName, boolean buildOpenStoreUrl) {
String normalizedShopName = normalizeShopName(targetShopName);
if (normalizedShopName.isBlank()) {
return pendingResult("店铺名为空,无法匹配索引");
@@ -98,6 +101,20 @@ public class ZiniaoShopIndexService {
return pendingResult("店铺索引信息不完整,请等待后台刷新");
}
if (!buildOpenStoreUrl) {
ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo();
vo.setMatched(true);
vo.setShopId(entry.getShopId());
vo.setShopName(entry.getShopName());
vo.setCompanyName(entry.getCompanyName());
vo.setPlatform(entry.getPlatform());
vo.setMatchedUserId(entry.getMatchedUserId());
vo.setOpenStoreUrl(null);
vo.setMatchStatus(isFresh(entry) ? MATCH_STATUS_MATCHED : MATCH_STATUS_STALE);
vo.setMatchMessage(isFresh(entry) ? null : "店铺索引已过保鲜期,请等待后台刷新");
return vo;
}
String apiKey = findApiKeyByHash(entry.getApiKeyHash());
if (apiKey == null) {
return pendingResult("店铺索引所依赖的紫鸟 key 已失效,请等待后台刷新");
@@ -134,6 +151,10 @@ public class ZiniaoShopIndexService {
return vo;
}
public ZiniaoShopMatchResultVo findIndexedStoreByName(String targetShopName) {
return findIndexedStoreByName(targetShopName, true);
}
public void refreshShopIndex() {
long now = Instant.now().toEpochMilli();
ZiniaoShopIndexRefreshCursorDto previousCursor = ziniaoTransientCacheService

View File

@@ -34,6 +34,10 @@ public class ZiniaoShopSwitchService {
return ziniaoShopIndexService.findIndexedStoreByName(targetShopName);
}
public ZiniaoShopMatchResultVo findIndexedStoreByName(String targetShopName, boolean buildOpenStoreUrl) {
return ziniaoShopIndexService.findIndexedStoreByName(targetShopName, buildOpenStoreUrl);
}
public ZiniaoShopMatchResultVo emptyMatchResult() {
ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo();
vo.setMatched(false);

View File

@@ -81,10 +81,11 @@ aiimage:
heartbeat-timeout-minutes: ${AIIMAGE_DELETE_BRAND_HEARTBEAT_TIMEOUT_MINUTES:15}
stale-check-cron: ${AIIMAGE_DELETE_BRAND_STALE_CHECK_CRON:0 */2 * * * *}
finalize-check-cron: ${AIIMAGE_DELETE_BRAND_FINALIZE_CHECK_CRON:30 */2 * * * *}
product-risk-stale-timeout-hours: ${AIIMAGE_PRODUCT_RISK_STALE_TIMEOUT_HOURS:48}
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}
module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE}
ziniao:
enabled: ${AIIMAGE_ZINIAO_ENABLED:false}
base-url: ${AIIMAGE_ZINIAO_BASE_URL:https://sbappstoreapi.ziniao.com/openapi-router}

View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS biz_product_risk_country_pref (
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID',
country_codes_json VARCHAR(256) NOT NULL COMMENT 'JSON 数组:五国代码按处理顺序,如 ["DE","UK","FR"]',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) COMMENT='商品风险-用户五国处理顺序与勾选(子集)';

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS biz_product_risk_shop_candidate (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
user_id BIGINT NOT NULL COMMENT '用户 ID',
shop_name VARCHAR(255) NOT NULL COMMENT '店铺名(已规范化)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY uk_user_shop (user_id, shop_name),
KEY idx_user_id (user_id)
) COMMENT='商品风险解决-用户备选店铺';