更新商品风险处理
This commit is contained in:
4
app/.env
4
app/.env
@@ -12,7 +12,7 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot
|
||||
client_name=ShuFuAI
|
||||
|
||||
|
||||
# java_api_base=http://127.0.0.1:18080
|
||||
java_api_base=http://8.136.19.173:18080
|
||||
java_api_base=http://127.0.0.1:18080
|
||||
# java_api_base=http://8.136.19.173:18080
|
||||
|
||||
|
||||
|
||||
@@ -61,6 +61,11 @@
|
||||
<artifactId>easyexcel</artifactId>
|
||||
<version>${easyexcel.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>5.2.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.aliyun.oss</groupId>
|
||||
<artifactId>aliyun-sdk-oss</artifactId>
|
||||
|
||||
@@ -13,4 +13,9 @@ public class DeleteBrandProgressProperties {
|
||||
* 定时补偿 finalize:用于“分片其实已齐,但最后一次提交断开导致没触发 finalize”的场景。
|
||||
*/
|
||||
private String finalizeCheckCron = "30 */2 * * * *";
|
||||
|
||||
/**
|
||||
* 商品风险(PRODUCT_RISK_RESOLVE)RUNNING 超时自动失败:与删除品牌共用同一定时调度。
|
||||
*/
|
||||
private long productRiskStaleTimeoutHours = 48;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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_pref;country_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 为枚举 ProductRiskCountryCode(JSON 与枚举名一致: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, "下载失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 对象的键为枚举 ProductRiskCountryCode(DE、FR、ES、IT、UK),"
|
||||
+ "值为该国 sheet 的行列表;省略的键表示该国无数据行。")
|
||||
private Map<ProductRiskCountryCode, List<ProductRiskRowDto>> countries = new LinkedHashMap<>();
|
||||
}
|
||||
@@ -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` 的键为枚举 ProductRiskCountryCode:DE、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<>();
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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("[\\\\/:*?\"<>|]", "_");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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='商品风险-用户五国处理顺序与勾选(子集)';
|
||||
@@ -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='商品风险解决-用户备选店铺';
|
||||
12
frontend-vue/product-risk.html
Normal file
12
frontend-vue/product-risk.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>商品风险解决 - 数富AI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/product-risk-main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1487
frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue
Normal file
1487
frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
active: 'brand' | 'dedupe' | 'convert' | 'split' | 'delete-brand'
|
||||
active: 'brand' | 'dedupe' | 'convert' | 'split' | 'delete-brand' | 'product-risk'
|
||||
}>()
|
||||
|
||||
const active = props.active
|
||||
@@ -57,6 +57,7 @@ const navGroups = [
|
||||
label: '运营工具',
|
||||
items: [
|
||||
{ key: 'delete-brand', label: '删除指定ASIN和品牌', href: '/new_web_source/delete-brand.html' },
|
||||
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' },
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
7
frontend-vue/src/product-risk-main.ts
Normal file
7
frontend-vue/src/product-risk-main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import BrandProductRiskTab from '@/pages/brand/components/BrandProductRiskTab.vue'
|
||||
|
||||
createApp(BrandProductRiskTab).use(ElementPlus).mount('#app')
|
||||
@@ -1,4 +1,4 @@
|
||||
import { del, get, http, post, type JavaApiResponse, unwrapJavaResponse } from '@/shared/api/http'
|
||||
import { del, get, http, post, put, type JavaApiResponse, unwrapJavaResponse } from '@/shared/api/http'
|
||||
|
||||
const JAVA_API_PREFIX = '/newApi/api'
|
||||
|
||||
@@ -398,6 +398,206 @@ export function submitDeleteBrandResult(taskId: number, request: DeleteBrandSubm
|
||||
return unwrapJavaResponse(post<JavaApiResponse<null>, DeleteBrandSubmitResultRequest>(`${JAVA_API_PREFIX}/delete-brand/tasks/${taskId}/result`, request))
|
||||
}
|
||||
|
||||
/** 商品风险解决:备选店铺与匹配(推队列由前端 pywebview 完成) */
|
||||
export interface ProductRiskCandidateVo {
|
||||
id: number
|
||||
shop_name: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface ProductRiskShopQueueItem {
|
||||
shopName: string
|
||||
matched: boolean
|
||||
shopId?: string
|
||||
platform?: string
|
||||
companyName?: string
|
||||
openStoreUrl?: string
|
||||
matchedUserId?: number
|
||||
matchStatus?: string
|
||||
matchMessage?: string
|
||||
}
|
||||
|
||||
export interface ProductRiskMatchShopsVo {
|
||||
items: ProductRiskShopQueueItem[]
|
||||
}
|
||||
|
||||
export function listProductRiskCandidates() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<ProductRiskCandidateVo[]>>(`${JAVA_API_PREFIX}/product-risk-resolve/candidates`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function addProductRiskCandidate(shopName: string) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ProductRiskCandidateVo>, { user_id: number; shop_name: string }>(
|
||||
`${JAVA_API_PREFIX}/product-risk-resolve/candidates`,
|
||||
{ user_id: getCurrentUserId(), shop_name: shopName },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteProductRiskCandidate(id: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/product-risk-resolve/candidates/${id}`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export interface ProductRiskCountryPreferenceVo {
|
||||
country_codes: string[]
|
||||
}
|
||||
|
||||
export function getProductRiskCountryPreference() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<ProductRiskCountryPreferenceVo>>(`${JAVA_API_PREFIX}/product-risk-resolve/country-preference`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function putProductRiskCountryPreference(countryCodes: string[]) {
|
||||
return unwrapJavaResponse(
|
||||
put<
|
||||
JavaApiResponse<ProductRiskCountryPreferenceVo>,
|
||||
{ user_id: number; country_codes: string[] }
|
||||
>(`${JAVA_API_PREFIX}/product-risk-resolve/country-preference`, {
|
||||
user_id: getCurrentUserId(),
|
||||
country_codes: countryCodes,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function matchProductRiskShops(shopNames: string[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ProductRiskMatchShopsVo>, { user_id: number; shop_names: string[] }>(
|
||||
`${JAVA_API_PREFIX}/product-risk-resolve/match-shops`,
|
||||
{ user_id: getCurrentUserId(), shop_names: shopNames },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export interface ProductRiskDashboardVo {
|
||||
candidateCount: number
|
||||
processedTaskCount: number
|
||||
successTaskCount: number
|
||||
failedTaskCount: number
|
||||
}
|
||||
|
||||
export interface ProductRiskHistoryItem {
|
||||
resultId?: number
|
||||
taskId?: number
|
||||
shopName?: string
|
||||
shopId?: string
|
||||
platform?: string
|
||||
companyName?: string
|
||||
matched?: boolean
|
||||
matchStatus?: string
|
||||
matchMessage?: string
|
||||
taskStatus?: string
|
||||
success?: boolean
|
||||
error?: string
|
||||
outputFilename?: string
|
||||
downloadUrl?: string
|
||||
}
|
||||
|
||||
export interface ProductRiskHistoryVo {
|
||||
items: ProductRiskHistoryItem[]
|
||||
}
|
||||
|
||||
export interface ProductRiskTaskSummary {
|
||||
id?: number
|
||||
taskNo?: string
|
||||
status?: string
|
||||
errorMessage?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
finishedAt?: string
|
||||
}
|
||||
|
||||
export interface ProductRiskTaskDetailVo {
|
||||
task?: ProductRiskTaskSummary
|
||||
items?: ProductRiskHistoryItem[]
|
||||
}
|
||||
|
||||
export interface ProductRiskTaskBatchVo {
|
||||
items: ProductRiskTaskDetailVo[]
|
||||
missingTaskIds?: number[]
|
||||
}
|
||||
|
||||
export interface ProductRiskPendingDeleteVo {
|
||||
removed: boolean
|
||||
}
|
||||
|
||||
export interface ProductRiskCreateTaskVo {
|
||||
taskId: number
|
||||
items: ProductRiskHistoryItem[]
|
||||
}
|
||||
|
||||
export function getProductRiskDashboard() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<ProductRiskDashboardVo>>(`${JAVA_API_PREFIX}/product-risk-resolve/dashboard`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function getProductRiskHistory() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<ProductRiskHistoryVo>>(`${JAVA_API_PREFIX}/product-risk-resolve/history`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteProductRiskHistory(resultId: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/product-risk-resolve/history/${resultId}`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function createProductRiskTask(items: ProductRiskShopQueueItem[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ProductRiskCreateTaskVo>, { user_id: number; items: ProductRiskShopQueueItem[] }>(
|
||||
`${JAVA_API_PREFIX}/product-risk-resolve/tasks`,
|
||||
{ user_id: getCurrentUserId(), items },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteProductRiskTask(taskId: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/product-risk-resolve/tasks/${taskId}`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function deletePendingProductRiskShopResult(shopName: string) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<ProductRiskPendingDeleteVo>>(`${JAVA_API_PREFIX}/product-risk-resolve/pending-shop-result`, {
|
||||
params: { user_id: getCurrentUserId(), shop_name: shopName },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function getProductRiskTasksBatch(taskIds: number[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ProductRiskTaskBatchVo>, { taskIds: number[] }>(
|
||||
`${JAVA_API_PREFIX}/product-risk-resolve/tasks/batch`,
|
||||
{ taskIds },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function getProductRiskResultDownloadUrl(resultId: number) {
|
||||
return getJavaDownloadUrl(`/product-risk-resolve/results/${resultId}/download`)
|
||||
}
|
||||
|
||||
export function getJavaDownloadUrl(path: string) {
|
||||
let raw = path.startsWith('http://') || path.startsWith('https://') ? path : `${JAVA_API_PREFIX}${path}`
|
||||
// 核心修复:pywebview 的 save_file_from_url 需要完整带 schema 的 URL
|
||||
|
||||
@@ -46,6 +46,7 @@ export default defineConfig({
|
||||
convert: resolve(__dirname, 'convert.html'),
|
||||
split: resolve(__dirname, 'split.html'),
|
||||
'delete-brand': resolve(__dirname, 'delete-brand.html'),
|
||||
'product-risk': resolve(__dirname, 'product-risk.html'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'assets/[name].js',
|
||||
|
||||
Reference in New Issue
Block a user