From 8ab36b1aaf6b642730ff3263e33433bf458ce71d Mon Sep 17 00:00:00 2001 From: supernijia Date: Sat, 25 Jul 2026 09:58:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E6=9E=B6=E9=9C=80=E6=B1=82=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nanri/aiimage/config/OpenApiConfig.java | 21 +- .../file/controller/FileUploadController.java | 5 +- .../publish/controller/PublishController.java | 158 +++ .../publish/mapper/PublishFileMapper.java | 9 + .../publish/mapper/PublishItemMapper.java | 28 + .../model/dto/PublishParseRequest.java | 38 + .../model/dto/PublishResultFileDto.java | 39 + .../publish/model/dto/PublishRowDto.java | 46 + .../model/dto/PublishSourceFileDto.java | 24 + .../model/dto/PublishSubmitResultRequest.java | 23 + .../model/dto/PublishTaskBatchRequest.java | 22 + .../model/entity/PublishFileEntity.java | 34 + .../model/entity/PublishItemEntity.java | 28 + .../publish/model/vo/PublishDashboardVo.java | 22 + .../publish/model/vo/PublishFileVo.java | 53 + .../publish/model/vo/PublishHistoryVo.java | 16 + .../publish/model/vo/PublishItemsPageVo.java | 45 + .../publish/model/vo/PublishParseVo.java | 24 + .../publish/model/vo/PublishResultVo.java | 27 + .../publish/model/vo/PublishTaskBatchVo.java | 16 + .../publish/model/vo/PublishTaskDetailVo.java | 18 + .../publish/model/vo/PublishTaskVo.java | 37 + .../publish/service/PublishTaskService.java | 1219 +++++++++++++++++ .../service/PublishWorkbookService.java | 351 +++++ .../task/service/TaskHeartbeatService.java | 5 + .../task/service/TaskResultFileJobWorker.java | 6 + .../main/resources/db/V77__publish_task.sql | 58 + .../service/PublishTaskServiceTest.java | 354 +++++ .../service/PublishWorkbookServiceTest.java | 153 +++ .../service/TaskHeartbeatServiceTest.java | 89 ++ .../service/TaskResultFileJobWorkerTest.java | 33 + frontend-vue/dev-5174.log | 18 + frontend-vue/publish.html | 12 + .../brand/components/BrandPublishTab.vue | 1032 ++++++++++++++ .../pages/brand/components/BrandTopBar.vue | 2 + frontend-vue/src/publish-main.ts | 7 + frontend-vue/src/shared/api/java-modules.ts | 263 ++++ frontend-vue/vite.config.ts | 1 + 38 files changed, 4326 insertions(+), 10 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/mapper/PublishFileMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/mapper/PublishItemMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishParseRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishResultFileDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishRowDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishSourceFileDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishSubmitResultRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishTaskBatchRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/entity/PublishFileEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/entity/PublishItemEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishDashboardVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishFileVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishHistoryVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishItemsPageVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishParseVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishResultVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishTaskBatchVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishTaskDetailVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishTaskVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishTaskService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishWorkbookService.java create mode 100644 backend-java/src/main/resources/db/V77__publish_task.sql create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishTaskServiceTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishWorkbookServiceTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatServiceTest.java create mode 100644 frontend-vue/dev-5174.log create mode 100644 frontend-vue/publish.html create mode 100644 frontend-vue/src/pages/brand/components/BrandPublishTab.vue create mode 100644 frontend-vue/src/publish-main.ts diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/OpenApiConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/OpenApiConfig.java index 6c6adf89..469b2f58 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/OpenApiConfig.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/OpenApiConfig.java @@ -17,17 +17,19 @@ public class OpenApiConfig { .info(new Info() .title("AI Image Backend API") .description(""" - 文件处理后端接口文档。 + 数富 AI Java 后端接口文档,包含文件处理、任务调度、店铺匹配、紫鸟接入和管理后台接口。 - 当前迁移范围:仅迁移数据去重(dedupe)、格式转换(convert)、数据拆分(split)三块处理逻辑到 Java; - Python 桌面端壳、pywebview 调用方式、前端页面交互保持不变。 + 任务型模块通常按以下链路联调: + 1. 前端或 Python 桌面端调用 /api/files/upload 上传源文件; + 2. 调用模块的解析或创建任务接口,由 Java 解析数据并持久化任务; + 3. 前端激活任务并派发给 Python,Python 按分页接口拉取任务明细; + 4. 执行期间调用 /api/tasks/{taskId}/heartbeat 上报心跳和处理进度; + 5. Python 将处理结果回传模块接口,Java 异步生成 XLSX/ZIP 并上传 OSS; + 6. 任务详情或历史接口返回公开 OSS 下载直链。 - 建议联调顺序: - 1. 先启动 Java 后端; - 2. 在 Python 桌面端中选择文件并执行 dedupe/convert/split; - 3. Python 壳会先调用 /api/files/upload 上传临时文件; - 4. 再调用对应的 /api/dedupe/run、/api/convert/run、/api/split/run; - 5. Java 生成结果文件后,Python 壳再通过下载接口取回并保存到用户本地目录。 + 跨语言请求中的 user_id、task_ids 等字段使用 snake_case;响应字段默认使用 camelCase。 + + 上架模块的 Python 分页、结果回传和任务级进度接口优先使用 taskId/fileId 定位,user_id 仅作为可选的旧客户端归属校验;创建批次、总览和历史接口仍按用户维度调用。 本地启动说明: - 默认配置读取 application.yml; @@ -41,4 +43,5 @@ public class OpenApiConfig { .description("Knife4j 文档") .url("/doc.html")); } + } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java index 46c503b5..75cb302c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java @@ -40,7 +40,7 @@ public class FileUploadController { 该接口不是给浏览器页面直接使用的最终业务接口,而是给 Python 桌面端壳做文件中转: - 桌面端先选本地文件; - 再调用本接口上传到 Java 临时目录; - - 然后把 fileKey 传给 dedupe/convert/split 执行接口; + - 然后把 fileKey 传给对应任务接口;上架模块继续调用 /api/publish/parse,其他模块按各自创建接口处理; - 处理完成后,桌面端再调用下载接口把结果文件保存回用户本地目录。 """) @ApiResponses({ @@ -51,8 +51,11 @@ public class FileUploadController { public ApiResponse upload( @Parameter(name = "file", description = "待上传的 Excel 或业务源文件", required = true, in = ParameterIn.QUERY) MultipartFile file, + @Parameter(description = "文件夹上传时的相对路径,单文件上传可不传", example = "英国/郭亚庆.xlsx") @RequestParam(required = false) String relativePath, + @Parameter(description = "是否同时上传到公开 OSS;上架源文件通常保持 false", example = "false") @RequestParam(defaultValue = "false") boolean uploadToOss, + @Parameter(description = "上传到 OSS 时使用的模块类型,例如 PUBLISH", example = "PUBLISH") @RequestParam(defaultValue = "COMMON") String moduleType) throws Exception { UploadFileVo vo = localFileStorageService.saveTempFile(file, relativePath); if (uploadToOss) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java new file mode 100644 index 00000000..cfac2f58 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java @@ -0,0 +1,158 @@ +package com.nanri.aiimage.modules.publish.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.publish.model.dto.PublishParseRequest; +import com.nanri.aiimage.modules.publish.model.dto.PublishSubmitResultRequest; +import com.nanri.aiimage.modules.publish.model.dto.PublishTaskBatchRequest; +import com.nanri.aiimage.modules.publish.model.vo.PublishDashboardVo; +import com.nanri.aiimage.modules.publish.model.vo.PublishHistoryVo; +import com.nanri.aiimage.modules.publish.model.vo.PublishItemsPageVo; +import com.nanri.aiimage.modules.publish.model.vo.PublishParseVo; +import com.nanri.aiimage.modules.publish.model.vo.PublishTaskBatchVo; +import com.nanri.aiimage.modules.publish.model.vo.PublishTaskDetailVo; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/publish") +@Tag(name = "上架", description = "上架 Excel 多文件任务接口。前端创建批次并严格串行派发文件;Python 按页拉取数据、发送统一任务心跳并回传当前店铺完整结果。") +public class PublishController { + + private final PublishTaskService publishTaskService; + + @PostMapping("/parse") + @Operation( + summary = "匹配店铺、解析 Excel 并创建多文件批次任务", + description = "按文件名(去扩展名)先匹配已管理店铺,再查询紫鸟索引;解析每个非空 Sheet。表头必须依次为 id、ASIN、国家、品牌、价格、状态、同步状态、同步国家。匹配并解析成功的文件为 PENDING,失败文件为 FAILED;只要存在可处理文件,任务为 PENDING,否则任务为 FAILED。") + public ApiResponse parse(@Valid @RequestBody PublishParseRequest request) { + return ApiResponse.success(publishTaskService.parseAndCreateTask(request)); + } + + @PostMapping("/tasks/{taskId}/activate") + @Operation(summary = "激活上架任务", description = "按 taskId 激活上架任务,将状态从 PENDING 修改为 RUNNING。user_id 可省略;如传入则校验任务归属。文件仍需通过单文件激活接口逐个激活。") + public ApiResponse activateTask( + @Parameter(description = "上架任务 ID", required = true, example = "9001") + @PathVariable Long taskId, + @Parameter(description = "可选的任务所属用户 ID;省略时由 taskId 反查", required = false, example = "1") + @RequestParam(value = "user_id", required = false) Long userId) { + publishTaskService.activateTask(taskId, userId); + return ApiResponse.success(null); + } + + @PostMapping("/tasks/{taskId}/files/{fileId}/activate") + @Operation( + summary = "激活任务中的单个文件", + description = "前端派发 Python 队列前调用。taskId 和 fileId 用于定位文件,user_id 可省略;如传入则校验任务归属。同一任务同一时间只允许一个 RUNNING 文件;重复激活当前 RUNNING 文件为幂等操作。") + public ApiResponse activateFile( + @Parameter(description = "上架任务 ID", required = true, example = "9001") + @PathVariable Long taskId, + @Parameter(description = "任务内文件 ID", required = true, example = "9101") + @PathVariable Long fileId, + @Parameter(description = "可选的任务所属用户 ID;省略时由 taskId 反查", required = false, example = "1") + @RequestParam(value = "user_id", required = false) Long userId) { + publishTaskService.activateFile(taskId, fileId, userId); + return ApiResponse.success(null); + } + + @GetMapping("/tasks/{taskId}/items") + @Operation( + summary = "按文件分页获取上架原始数据,供 Python 拉取", + description = "Python 只需提供 taskId、file_id、page 和 page_size;后端按 taskId 反查任务所属用户并返回原 Excel 行序数据,同时携带店铺信息、发布国家和同步国家。user_id 仅作为旧客户端的可选归属校验。页码从 1 开始,每页最多 200 行。") + public ApiResponse items( + @Parameter(description = "上架任务 ID", required = true, example = "9001") + @PathVariable Long taskId, + @Parameter(description = "可选的任务所属用户 ID;省略时由 taskId 反查", required = false, example = "1") + @RequestParam(value = "user_id", required = false) Long userId, + @Parameter(description = "任务内文件 ID", required = true, example = "9101") + @RequestParam("file_id") Long fileId, + @Parameter(description = "页码,从 1 开始", example = "1") + @RequestParam(value = "page", defaultValue = "1") Integer page, + @Parameter(description = "每页条数,默认 50,最大 200", example = "50") + @RequestParam(value = "page_size", defaultValue = "50") Integer pageSize) { + return ApiResponse.success(publishTaskService.getItemsPage(taskId, userId, fileId, page, pageSize)); + } + + @GetMapping("/tasks/{taskId}") + @Operation(summary = "获取上架任务详情", description = "按 taskId 返回任务汇总、每个文件的处理进度和结果文件状态;user_id 可省略,结果完成后包含公开 OSS 下载直链。") + public ApiResponse task( + @Parameter(description = "上架任务 ID", required = true, example = "9001") + @PathVariable Long taskId, + @Parameter(description = "可选的任务所属用户 ID;省略时由 taskId 反查", required = false, example = "1") + @RequestParam(value = "user_id", required = false) Long userId) { + return ApiResponse.success(publishTaskService.getTaskDetail(taskId, userId)); + } + + @PostMapping("/tasks/progress/batch") + @Operation( + summary = "批量获取任务、文件进度和最终结果", + description = "按 task_ids 批量返回任务、文件进度和最终结果。task_ids 会过滤空值和非正数、去重并最多处理前 50 个;user_id 可省略,省略时按任务 ID 查询,传入时仅返回该用户的任务,未找到的 ID 放入 missingTaskIds。") + public ApiResponse progressBatch( + @Valid @RequestBody PublishTaskBatchRequest request) { + return ApiResponse.success(publishTaskService.getTaskProgress(request.getUserId(), request.getTaskIds())); + } + + @PostMapping("/tasks/{taskId}/result") + @Operation( + summary = "Python 按文件回传当前店铺完整数据", + description = "请求只需 taskId 和 files,user_id 为兼容旧客户端的可选字段;后端按 taskId 反查任务所属用户,传入 user_id 时会校验归属。成功回传必须包含该文件全部原始行,可使用 rows 数组,或在 rows 为空时使用 countries 按国家分组;行数少于原始数据、包含 null 行或八列全空白对象时会被拒绝且不会覆盖已解析数据。error 非空时将该文件标记为 FAILED。全部文件进入终态后,只要至少一个文件成功就异步组装结果;全部失败则不生成结果文件。") + public ApiResponse submitResult( + @Parameter(description = "上架任务 ID", required = true, example = "9001") + @PathVariable Long taskId, + @Valid @RequestBody PublishSubmitResultRequest request) { + publishTaskService.submitResult(taskId, request); + return ApiResponse.success(null); + } + + @GetMapping("/dashboard") + @Operation(summary = "获取上架任务总览", description = "返回当前用户各状态任务数量和最近任务详情。") + public ApiResponse dashboard( + @Parameter(description = "当前用户 ID", required = true, example = "1") + @RequestParam("user_id") Long userId) { + return ApiResponse.success(publishTaskService.dashboard(userId)); + } + + @GetMapping("/history") + @Operation(summary = "获取上架任务历史", description = "按创建时间倒序返回当前用户的任务详情,最多返回 100 条。") + public ApiResponse history( + @Parameter(description = "当前用户 ID", required = true, example = "1") + @RequestParam("user_id") Long userId, + @Parameter(description = "返回数量上限,默认 50,最大 100", example = "50") + @RequestParam(value = "limit", defaultValue = "50") Integer limit) { + return ApiResponse.success(publishTaskService.history(userId, limit)); + } + + @DeleteMapping("/tasks/{taskId}") + @Operation(summary = "删除上架任务", description = "按 taskId 删除任务、文件明细、解析行、结果记录和关联结果文件 Job。user_id 可省略;如传入则校验任务归属。") + public ApiResponse deleteTask( + @Parameter(description = "上架任务 ID", required = true, example = "9001") + @PathVariable Long taskId, + @Parameter(description = "可选的任务所属用户 ID;省略时由 taskId 反查", required = false, example = "1") + @RequestParam(value = "user_id", required = false) Long userId) { + publishTaskService.deleteTask(taskId, userId); + return ApiResponse.success(null); + } + + @DeleteMapping("/history/{resultId}") + @Operation(summary = "按结果记录删除上架历史", description = "通过结果记录定位并删除对应任务;会校验结果记录属于当前用户和上架模块。") + public ApiResponse deleteHistory( + @Parameter(description = "上架结果记录 ID", required = true, example = "9201") + @PathVariable Long resultId, + @Parameter(description = "结果所属用户 ID", required = true, example = "1") + @RequestParam("user_id") Long userId) { + publishTaskService.deleteHistory(resultId, userId); + return ApiResponse.success(null); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/mapper/PublishFileMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/mapper/PublishFileMapper.java new file mode 100644 index 00000000..bf1baf85 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/mapper/PublishFileMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.publish.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.publish.model.entity.PublishFileEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PublishFileMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/mapper/PublishItemMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/mapper/PublishItemMapper.java new file mode 100644 index 00000000..26815da6 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/mapper/PublishItemMapper.java @@ -0,0 +1,28 @@ +package com.nanri.aiimage.modules.publish.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.publish.model.entity.PublishItemEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface PublishItemMapper extends BaseMapper { + + @Insert(""" + + """) + int insertBatch(@Param("rows") List rows); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishParseRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishParseRequest.java new file mode 100644 index 00000000..9ceb007a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishParseRequest.java @@ -0,0 +1,38 @@ +package com.nanri.aiimage.modules.publish.model.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "创建上架批次的请求。files 中每个文件独立匹配店铺,批次内由前端严格串行执行。") +public class PublishParseRequest { + @NotNull(message = "user_id cannot be null") + @JsonProperty("user_id") + @Schema(description = "当前登录用户 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + private Long userId; + + @Valid + @NotEmpty(message = "files cannot be empty") + @Schema(description = "已通过 /api/files/upload 上传的 Excel 文件列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List files = new ArrayList<>(); + + @JsonProperty("publish_country") + @JsonAlias("publishCountry") + @NotBlank(message = "publish_country cannot be blank") + @Schema(description = "发布国家代码,不能为空;后端按原值保存并派发", example = "DE", requiredMode = Schema.RequiredMode.REQUIRED) + private String publishCountry; + + @JsonProperty("sync_countries") + @JsonAlias("syncCountries") + @Schema(description = "同步国家代码列表;后端按原值保存并派发,当前不自动去重或剔除 publish_country", example = "[\"UK\",\"FR\"]") + private List syncCountries = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishResultFileDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishResultFileDto.java new file mode 100644 index 00000000..e7cf7400 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishResultFileDto.java @@ -0,0 +1,39 @@ +package com.nanri.aiimage.modules.publish.model.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import lombok.Data; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Data +@Schema(description = "单个文件的 Python 处理结果。fileId、fileKey、sourceFilename 至少提供一个用于定位文件。成功时必须提交完整 rows 或 countries;失败时填写 error。同一次请求中,不同定位字段指向同一任务文件也视为重复提交。") +public class PublishResultFileDto { + @JsonAlias("file_id") + @Schema(description = "任务内文件 ID;同时兼容 file_id,优先使用该字段定位", example = "9101") + private Long fileId; + + @JsonAlias("file_key") + @Schema(description = "源文件标识;同时兼容 file_key,可在 fileId 缺失时定位文件", example = "uploads/20260724/uuid/郭亚庆.xlsx") + private String fileKey; + + @JsonAlias("source_filename") + @Schema(description = "原始文件名;同时兼容 source_filename,可在 fileId/fileKey 缺失时定位文件", example = "郭亚庆.xlsx") + private String sourceFilename; + + @Schema(description = "文件级失败原因。非空时文件标记为 FAILED,rows/countries 不会覆盖原始数据", example = "打开店铺失败") + private String error; + + @Valid + @JsonAlias({"items", "data"}) + @Schema(description = "当前店铺的完整结果行;同时兼容 items/data。成功时行数不能少于原始 Excel 数据行数,且不能包含 null 行或八列全空白对象") + private List rows = new ArrayList<>(); + + @Valid + @Schema(description = "按国家名称或代码分组的完整结果,可替代 rows。仅当 rows 为空时读取;缺少国家字段的行会使用当前 Map key;分组内不能包含 null 行或八列全空白对象") + private Map> countries = new LinkedHashMap<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishRowDto.java new file mode 100644 index 00000000..0632375e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishRowDto.java @@ -0,0 +1,46 @@ +package com.nanri.aiimage.modules.publish.model.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "上架 Excel 的一行数据。JSON canonical 字段如下,同时兼容注解中列出的中文或 snake_case 别名。") +public class PublishRowDto { + @JsonProperty("id") + @JsonAlias({"sourceId", "source_id"}) + @Schema(description = "Excel 的 id 列;同时兼容 sourceId/source_id", example = "3551") + private String sourceId; + + @JsonProperty("ASIN") + @JsonAlias("asin") + @Schema(description = "Excel 的 ASIN 列;同时兼容 asin", example = "B0GJDJ3JT3") + private String asin; + + @JsonAlias("国家") + @Schema(description = "Excel 的国家列;同时兼容中文键 国家", example = "英国") + private String country; + + @JsonAlias("品牌") + @Schema(description = "Excel 的品牌列;同时兼容中文键 品牌", example = "Zewurtuw") + private String brand; + + @JsonAlias({"价格", "price_value"}) + @Schema(description = "Excel 的价格列;同时兼容中文键 价格 和 price_value", example = "50") + private String price; + + @JsonAlias({"状态", "status_value"}) + @Schema(description = "发布处理状态;同时兼容中文键 状态 和 status_value", example = "成功") + private String status; + + @JsonProperty("syncStatus") + @JsonAlias({"同步状态", "sync_status"}) + @Schema(description = "同步处理状态;同时兼容中文键 同步状态 和 sync_status", example = "成功") + private String syncStatus; + + @JsonProperty("syncCountries") + @JsonAlias({"同步国家", "sync_countries"}) + @Schema(description = "已同步国家文本;同时兼容中文键 同步国家 和 sync_countries", example = "德国,法国") + private String syncCountries; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishSourceFileDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishSourceFileDto.java new file mode 100644 index 00000000..676e8c24 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishSourceFileDto.java @@ -0,0 +1,24 @@ +package com.nanri.aiimage.modules.publish.model.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "单个上架源文件。fileKey 来自公共文件上传接口,originalFilename 用于提取并匹配店铺名。") +public class PublishSourceFileDto { + @NotBlank(message = "fileKey cannot be blank") + @JsonAlias("file_key") + @Schema(description = "公共上传接口返回的临时文件标识;同时兼容 file_key", example = "uploads/20260724/uuid/郭亚庆.xlsx", requiredMode = Schema.RequiredMode.REQUIRED) + private String fileKey; + + @NotBlank(message = "originalFilename cannot be blank") + @JsonAlias({"original_filename", "sourceFilename", "source_filename"}) + @Schema(description = "原始文件名,去掉扩展名后作为店铺名匹配;同时兼容 original_filename/sourceFilename/source_filename", example = "郭亚庆.xlsx", requiredMode = Schema.RequiredMode.REQUIRED) + private String originalFilename; + + @JsonAlias("relative_path") + @Schema(description = "选择文件夹时的相对路径;单文件上传可不传,同时兼容 relative_path", example = "英国/郭亚庆.xlsx") + private String relativePath; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishSubmitResultRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishSubmitResultRequest.java new file mode 100644 index 00000000..9ea57f71 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishSubmitResultRequest.java @@ -0,0 +1,23 @@ +package com.nanri.aiimage.modules.publish.model.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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 回传上架结果的请求。同一次请求可提交一个或多个文件;任何结果项只要最终定位到同一任务文件,就会被判定为重复提交。") +public class PublishSubmitResultRequest { + @JsonProperty("user_id") + @Schema(description = "可选的任务所属用户 ID;省略时由 taskId 反查任务所属用户,传入时必须与任务创建用户一致", example = "1", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private Long userId; + + @Valid + @NotEmpty(message = "files cannot be empty") + @Schema(description = "文件处理结果列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List files = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishTaskBatchRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishTaskBatchRequest.java new file mode 100644 index 00000000..bbf063f5 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/dto/PublishTaskBatchRequest.java @@ -0,0 +1,22 @@ +package com.nanri.aiimage.modules.publish.model.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +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 = "批量轮询上架任务进度的请求") +public class PublishTaskBatchRequest { + @JsonProperty("user_id") + @Schema(description = "可选的当前登录用户 ID;省略时仅按 task_ids 查询上架任务,传入时仅返回该用户的任务", example = "1", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private Long userId; + + @NotEmpty(message = "task_ids cannot be empty") + @JsonProperty("task_ids") + @Schema(description = "需要查询的上架任务 ID 列表;后端过滤空值和非正数、去重,并最多处理前 50 个", example = "[9001,9002]", requiredMode = Schema.RequiredMode.REQUIRED) + private List taskIds = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/entity/PublishFileEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/entity/PublishFileEntity.java new file mode 100644 index 00000000..3d339a14 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/entity/PublishFileEntity.java @@ -0,0 +1,34 @@ +package com.nanri.aiimage.modules.publish.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_publish_file") +public class PublishFileEntity { + @TableId(type = IdType.AUTO) + private Long id; + private Long taskId; + private String fileKey; + private String sourceFilename; + private String relativePath; + private String shopName; + private Integer matched; + private String shopId; + private Long matchedUserId; + private String platform; + private String companyName; + private String matchStatus; + private String matchMessage; + private String status; + private Integer totalRows; + private Integer processedRows; + private String errorMessage; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime finishedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/entity/PublishItemEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/entity/PublishItemEntity.java new file mode 100644 index 00000000..31da2b77 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/entity/PublishItemEntity.java @@ -0,0 +1,28 @@ +package com.nanri.aiimage.modules.publish.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_publish_item") +public class PublishItemEntity { + @TableId(type = IdType.AUTO) + private Long id; + private Long taskId; + private Long fileId; + private Integer rowIndex; + private String sourceId; + private String asin; + private String country; + private String brand; + private String priceValue; + private String statusValue; + private String syncStatus; + private String syncCountries; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishDashboardVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishDashboardVo.java new file mode 100644 index 00000000..0e901606 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishDashboardVo.java @@ -0,0 +1,22 @@ +package com.nanri.aiimage.modules.publish.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 PublishDashboardVo { + @Schema(description = "待派发任务数量", example = "1") + private Long pendingCount; + @Schema(description = "处理中任务数量", example = "1") + private Long runningCount; + @Schema(description = "成功任务数量", example = "10") + private Long successCount; + @Schema(description = "失败任务数量", example = "0") + private Long failedCount; + @Schema(description = "最近 10 个任务详情") + private List recent = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishFileVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishFileVo.java new file mode 100644 index 00000000..51baaf57 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishFileVo.java @@ -0,0 +1,53 @@ +package com.nanri.aiimage.modules.publish.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "上架任务中的单个源文件及其处理进度") +public class PublishFileVo { + @Schema(description = "任务内文件 ID", example = "9101") + private Long fileId; + @Schema(description = "公共上传接口返回的文件标识") + private String fileKey; + @Schema(description = "原始文件名", example = "郭亚庆.xlsx") + private String sourceFilename; + @Schema(description = "从文件名提取并匹配的店铺名", example = "郭亚庆") + private String shopName; + @Schema(description = "匹配到的紫鸟店铺 ID") + private String shopId; + @Schema(description = "匹配到的店铺所属用户 ID", example = "1") + private Long matchedUserId; + @Schema(description = "店铺平台", example = "AMAZON") + private String platform; + @Schema(description = "紫鸟公司名称") + private String companyName; + @Schema(description = "店铺是否可用于派发", example = "true") + private boolean matched; + @Schema(description = "公共店铺匹配状态", example = "MATCHED", allowableValues = {"MATCHED", "PENDING", "CONFLICT", "INDEX_STALE"}) + private String matchStatus; + @Schema(description = "店铺匹配说明") + private String matchMessage; + @Schema(description = "文件状态", example = "RUNNING", allowableValues = {"PENDING", "RUNNING", "SUCCESS", "FAILED"}) + private String status; + @Schema(description = "当前文件数据总行数", example = "682") + private Integer totalRows; + @Schema(description = "当前文件已处理行数", example = "341") + private Integer processedRows; + @Schema(description = "文件完成百分比,范围 0-100", example = "50") + private Integer percent; + @Schema(description = "与 percent 相同,供公共进度组件使用", example = "50") + private Integer progressPercent; + @Schema(description = "心跳上报的当前处理数量;未上报时使用 processedRows", example = "341") + private Integer progressCurrent; + @Schema(description = "心跳上报的总处理数量;未上报时使用 totalRows", example = "682") + private Integer progressTotal; + @Schema(description = "文件失败原因的进度展示副本;无错误时为空") + private String progressMessage; + @Schema(description = "Python 分页拉取建议页大小", example = "50") + private Integer pageSize; + @Schema(description = "按建议页大小计算的总页数", example = "14") + private Integer totalPages; + @Schema(description = "文件失败原因") + private String errorMessage; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishHistoryVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishHistoryVo.java new file mode 100644 index 00000000..f6daa290 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishHistoryVo.java @@ -0,0 +1,16 @@ +package com.nanri.aiimage.modules.publish.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 PublishHistoryVo { + @Schema(description = "当前用户的上架任务总数", example = "12") + private Long total; + @Schema(description = "按创建时间倒序返回的任务详情") + private List items = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishItemsPageVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishItemsPageVo.java new file mode 100644 index 00000000..6ac17683 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishItemsPageVo.java @@ -0,0 +1,45 @@ +package com.nanri.aiimage.modules.publish.model.vo; + +import com.nanri.aiimage.modules.publish.model.dto.PublishRowDto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "Python 按文件分页拉取的上架数据") +public class PublishItemsPageVo { + @Schema(description = "上架任务 ID", example = "9001") + private Long taskId; + @Schema(description = "任务内文件 ID", example = "9101") + private Long fileId; + @Schema(description = "任务状态", example = "RUNNING", allowableValues = {"PENDING", "RUNNING", "SUCCESS", "FAILED"}) + private String taskStatus; + @Schema(description = "文件状态", example = "RUNNING", allowableValues = {"PENDING", "RUNNING", "SUCCESS", "FAILED"}) + private String fileStatus; + @Schema(description = "店铺名称", example = "郭亚庆") + private String shopName; + @Schema(description = "紫鸟店铺 ID") + private String shopId; + @Schema(description = "店铺平台", example = "AMAZON") + private String platform; + @Schema(description = "紫鸟公司名称") + private String companyName; + @Schema(description = "前端选择的发布国家代码", example = "DE") + private String publishCountry; + @Schema(description = "前端选择的同步国家代码列表", example = "[\"UK\",\"FR\"]") + private List syncCountries = new ArrayList<>(); + @Schema(description = "当前页码,从 1 开始", example = "1") + private Integer page; + @Schema(description = "实际页大小,最大 200", example = "50") + private Integer pageSize; + @Schema(description = "当前页返回行数", example = "50") + private Integer count; + @Schema(description = "当前文件数据总行数", example = "682") + private Long total; + @Schema(description = "总页数", example = "14") + private Integer totalPages; + @Schema(description = "按原 Excel 行序返回的八列数据") + private List items = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishParseVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishParseVo.java new file mode 100644 index 00000000..6d01db3a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishParseVo.java @@ -0,0 +1,24 @@ +package com.nanri.aiimage.modules.publish.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "上架 Excel 解析和批次创建结果") +public class PublishParseVo { + @Schema(description = "新建的上架任务 ID", example = "9001") + private Long taskId; + @Schema(description = "任务编号,格式为 PUBLISH-<雪花 ID>", example = "PUBLISH-1958489276579999744") + private String taskNo; + @Schema(description = "用户本次提交的原始文件数量", example = "2") + private Integer sourceFileCount; + @Schema(description = "所有可处理文件的解析数据总行数", example = "1364") + private Integer totalRows; + @Schema(description = "Python 分页拉取建议页大小", example = "50") + private Integer pageSize; + @Schema(description = "每个源文件的店铺匹配和解析结果") + private List files = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishResultVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishResultVo.java new file mode 100644 index 00000000..f11f6179 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishResultVo.java @@ -0,0 +1,27 @@ +package com.nanri.aiimage.modules.publish.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "结果文件及异步组装状态;任务刚创建时也会返回未就绪的结果记录") +public class PublishResultVo { + @Schema(description = "结果记录 ID", example = "9201") + private Long resultId; + @Schema(description = "结果文件名。按原始源文件数判定:单文件任务生成 XLSX,多文件任务生成 ZIP;ZIP 仅包含处理成功的文件,无成功文件时不生成结果", example = "PUBLISH-1958489276579999744_上架结果.zip") + private String resultFilename; + @Schema(description = "结果准备完成后的公开 OSS 直链,不包含过期签名参数") + private String downloadUrl; + @Schema(description = "结果文件是否已生成、上传 OSS 并可下载", example = "true") + private Boolean fileReady; + @Schema(description = "异步结果文件 Job ID", example = "9301") + private Long fileJobId; + @Schema(description = "异步结果文件 Job 状态", example = "SUCCESS", allowableValues = {"PENDING", "RUNNING", "SUCCESS", "FAILED"}) + private String fileJobStatus; + @Schema(description = "异步结果文件 Job 已重试次数", example = "0") + private Integer fileJobRetryCount; + @Schema(description = "异步结果文件 Job 失败原因") + private String fileJobError; + @Schema(description = "结果记录失败原因") + private String errorMessage; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishTaskBatchVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishTaskBatchVo.java new file mode 100644 index 00000000..af8d572d --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishTaskBatchVo.java @@ -0,0 +1,16 @@ +package com.nanri.aiimage.modules.publish.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 PublishTaskBatchVo { + @Schema(description = "当前用户可访问的任务详情,顺序与请求中的 task_ids 一致") + private List items = new ArrayList<>(); + @Schema(description = "未找到或不属于当前用户的任务 ID") + private List missingTaskIds = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishTaskDetailVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishTaskDetailVo.java new file mode 100644 index 00000000..1dd3cf3f --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishTaskDetailVo.java @@ -0,0 +1,18 @@ +package com.nanri.aiimage.modules.publish.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 PublishTaskDetailVo { + @Schema(description = "任务汇总状态和整体进度") + private PublishTaskVo task; + @Schema(description = "任务中的源文件列表") + private List files = new ArrayList<>(); + @Schema(description = "结果文件及异步组装状态;任务刚创建时也会返回未就绪的结果记录") + private PublishResultVo result; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishTaskVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishTaskVo.java new file mode 100644 index 00000000..71406f88 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/vo/PublishTaskVo.java @@ -0,0 +1,37 @@ +package com.nanri.aiimage.modules.publish.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "任务汇总状态和整体进度") +public class PublishTaskVo { + @Schema(description = "任务 ID", example = "9001") + private Long id; + @Schema(description = "任务编号,格式为 PUBLISH-<雪花 ID>", example = "PUBLISH-1958489276579999744") + private String taskNo; + @Schema(description = "任务状态。全部文件进入终态后,只要至少一个文件成功且结果组装完成,任务即为 SUCCESS;全部失败或结果组装失败时为 FAILED", example = "RUNNING", allowableValues = {"PENDING", "RUNNING", "SUCCESS", "FAILED"}) + private String status; + @Schema(description = "原始文件数量", example = "2") + private Integer sourceFileCount; + @Schema(description = "成功文件数量", example = "1") + private Integer successFileCount; + @Schema(description = "失败文件数量", example = "0") + private Integer failedFileCount; + @Schema(description = "已进入终态的文件数量", example = "1") + private Integer completedFileCount; + @Schema(description = "全部文件数据总行数", example = "1364") + private Integer totalRows; + @Schema(description = "全部文件已处理行数", example = "682") + private Integer processedRows; + @Schema(description = "任务完成百分比,范围 0-100", example = "50") + private Integer percent; + @Schema(description = "任务失败或部分失败说明") + private String errorMessage; + @Schema(description = "任务创建时间,格式 yyyy-MM-dd HH:mm:ss", example = "2026-07-24 19:30:00") + private String createdAt; + @Schema(description = "任务最后更新时间,格式 yyyy-MM-dd HH:mm:ss", example = "2026-07-24 19:35:00") + private String updatedAt; + @Schema(description = "任务结束时间;未结束时为空", example = "2026-07-24 19:40:00") + private String finishedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishTaskService.java new file mode 100644 index 00000000..5c8cbb72 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishTaskService.java @@ -0,0 +1,1219 @@ +package com.nanri.aiimage.modules.publish.service; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.IdUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.file.service.LocalFileStorageService; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.publish.mapper.PublishFileMapper; +import com.nanri.aiimage.modules.publish.mapper.PublishItemMapper; +import com.nanri.aiimage.modules.publish.model.dto.PublishParseRequest; +import com.nanri.aiimage.modules.publish.model.dto.PublishResultFileDto; +import com.nanri.aiimage.modules.publish.model.dto.PublishRowDto; +import com.nanri.aiimage.modules.publish.model.dto.PublishSourceFileDto; +import com.nanri.aiimage.modules.publish.model.dto.PublishSubmitResultRequest; +import com.nanri.aiimage.modules.publish.model.entity.PublishFileEntity; +import com.nanri.aiimage.modules.publish.model.entity.PublishItemEntity; +import com.nanri.aiimage.modules.publish.model.vo.PublishDashboardVo; +import com.nanri.aiimage.modules.publish.model.vo.PublishFileVo; +import com.nanri.aiimage.modules.publish.model.vo.PublishHistoryVo; +import com.nanri.aiimage.modules.publish.model.vo.PublishItemsPageVo; +import com.nanri.aiimage.modules.publish.model.vo.PublishParseVo; +import com.nanri.aiimage.modules.publish.model.vo.PublishResultVo; +import com.nanri.aiimage.modules.publish.model.vo.PublishTaskBatchVo; +import com.nanri.aiimage.modules.publish.model.vo.PublishTaskDetailVo; +import com.nanri.aiimage.modules.publish.model.vo.PublishTaskVo; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.model.dto.TaskHeartbeatRequest; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.task.service.TaskDistributedLockService; +import com.nanri.aiimage.modules.task.service.TaskFileJobService; +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 lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import java.io.File; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PublishTaskService { + + public static final String MODULE_TYPE = "PUBLISH"; + public static final int DEFAULT_PAGE_SIZE = 50; + private static final int MAX_PAGE_SIZE = 200; + private static final int INSERT_BATCH_SIZE = 500; + private static final String STATUS_PENDING = "PENDING"; + private static final String STATUS_RUNNING = "RUNNING"; + private static final String STATUS_SUCCESS = "SUCCESS"; + private static final String STATUS_FAILED = "FAILED"; + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final LocalFileStorageService localFileStorageService; + private final ZiniaoShopSwitchService ziniaoShopSwitchService; + private final PublishWorkbookService workbookService; + private final PublishFileMapper publishFileMapper; + private final PublishItemMapper publishItemMapper; + private final FileTaskMapper fileTaskMapper; + private final FileResultMapper fileResultMapper; + private final TaskFileJobService taskFileJobService; + private final TaskDistributedLockService taskDistributedLockService; + private final OssStorageService ossStorageService; + private final ObjectMapper objectMapper; + private final TransactionTemplate transactionTemplate; + + @Value("${aiimage.publish.stale-timeout-minutes:30}") + private int staleTimeoutMinutes; + + public PublishParseVo parseAndCreateTask(PublishParseRequest request) { + validateParseRequest(request); + List preparedFiles = new ArrayList<>(); + Set fileKeys = new LinkedHashSet<>(); + for (PublishSourceFileDto source : request.getFiles()) { + if (!fileKeys.add(source.getFileKey().trim())) { + throw new BusinessException("上传文件重复: " + source.getOriginalFilename()); + } + preparedFiles.add(prepareFile(source)); + } + + PersistedTask persisted = transactionTemplate.execute(status -> persistTask(request, preparedFiles)); + if (persisted == null) { + throw new BusinessException("创建上架任务失败"); + } + PublishParseVo response = new PublishParseVo(); + response.setTaskId(persisted.task().getId()); + response.setTaskNo(persisted.task().getTaskNo()); + response.setSourceFileCount(persisted.task().getSourceFileCount()); + response.setTotalRows(persisted.files().stream().mapToInt(row -> safeInt(row.getTotalRows())).sum()); + response.setPageSize(DEFAULT_PAGE_SIZE); + response.setFiles(persisted.files().stream().map(this::toFileVo).toList()); + return response; + } + + public void activateTask(Long taskId, Long userId) { + FileTaskEntity task = requireTask(taskId, userId); + if (STATUS_RUNNING.equals(task.getStatus())) { + return; + } + if (!STATUS_PENDING.equals(task.getStatus())) { + throw new BusinessException("任务当前状态不可激活: " + task.getStatus()); + } + int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper() + .eq(FileTaskEntity::getId, taskId) + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .eq(FileTaskEntity::getStatus, STATUS_PENDING) + .set(FileTaskEntity::getStatus, STATUS_RUNNING) + .set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())); + if (updated <= 0) { + throw new BusinessException("任务激活失败,请刷新后重试"); + } + } + + public void activateFile(Long taskId, Long fileId, Long userId) { + try (TaskDistributedLockService.LockHandle lock = + taskDistributedLockService.acquire(MODULE_TYPE, taskId)) { + if (lock == null) { + throw new BusinessException("任务正在处理中,请稍后重试"); + } + FileTaskEntity task = requireTask(taskId, userId); + PublishFileEntity file = requireFile(taskId, fileId); + if (STATUS_RUNNING.equals(file.getStatus())) { + return; + } + if (!STATUS_PENDING.equals(file.getStatus())) { + throw new BusinessException("文件当前状态不可激活: " + file.getStatus()); + } + PublishFileEntity runningFile = publishFileMapper.selectOne( + new LambdaQueryWrapper() + .eq(PublishFileEntity::getTaskId, taskId) + .eq(PublishFileEntity::getStatus, STATUS_RUNNING) + .ne(PublishFileEntity::getId, fileId) + .orderByAsc(PublishFileEntity::getId) + .last("limit 1")); + if (runningFile != null) { + throw new BusinessException("同一任务已有文件正在执行: " + runningFile.getSourceFilename()); + } + int updated = publishFileMapper.update(null, new LambdaUpdateWrapper() + .eq(PublishFileEntity::getId, fileId) + .eq(PublishFileEntity::getTaskId, taskId) + .eq(PublishFileEntity::getStatus, STATUS_PENDING) + .set(PublishFileEntity::getStatus, STATUS_RUNNING) + .set(PublishFileEntity::getUpdatedAt, LocalDateTime.now()) + .set(PublishFileEntity::getErrorMessage, null)); + if (updated <= 0) { + throw new BusinessException("文件激活失败,请刷新后重试"); + } + if (STATUS_PENDING.equals(task.getStatus())) { + fileTaskMapper.update(null, new LambdaUpdateWrapper() + .eq(FileTaskEntity::getId, taskId) + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .eq(FileTaskEntity::getStatus, STATUS_PENDING) + .set(FileTaskEntity::getStatus, STATUS_RUNNING) + .set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())); + } + } + } + + public PublishItemsPageVo getItemsPage(Long taskId, + Long userId, + Long fileId, + Integer page, + Integer pageSize) { + FileTaskEntity task = requireTask(taskId, userId); + PublishFileEntity file = requireFile(taskId, fileId); + int safePage = page == null || page <= 0 ? 1 : page; + int safePageSize = pageSize == null || pageSize <= 0 + ? DEFAULT_PAGE_SIZE : Math.min(pageSize, MAX_PAGE_SIZE); + long total = Objects.requireNonNullElse(publishItemMapper.selectCount( + new LambdaQueryWrapper() + .eq(PublishItemEntity::getTaskId, taskId) + .eq(PublishItemEntity::getFileId, fileId)), 0L); + int totalPages = total == 0 ? 0 : (int) ((total + safePageSize - 1) / safePageSize); + long offset = (long) (safePage - 1) * safePageSize; + List entities = publishItemMapper.selectList( + new LambdaQueryWrapper() + .eq(PublishItemEntity::getTaskId, taskId) + .eq(PublishItemEntity::getFileId, fileId) + .orderByAsc(PublishItemEntity::getRowIndex) + .last("limit " + safePageSize + " offset " + Math.max(0L, offset))); + + TaskOptions options = readTaskOptions(task); + PublishItemsPageVo response = new PublishItemsPageVo(); + response.setTaskId(taskId); + response.setFileId(fileId); + response.setTaskStatus(task.getStatus()); + response.setFileStatus(file.getStatus()); + response.setShopName(file.getShopName()); + response.setShopId(file.getShopId()); + response.setPlatform(file.getPlatform()); + response.setCompanyName(file.getCompanyName()); + response.setPublishCountry(options.publishCountry()); + response.setSyncCountries(options.syncCountries()); + response.setPage(safePage); + response.setPageSize(safePageSize); + response.setTotal(total); + response.setTotalPages(totalPages); + response.setItems(entities.stream().map(this::toRowDto).toList()); + response.setCount(response.getItems().size()); + return response; + } + + public void submitResult(Long taskId, PublishSubmitResultRequest request) { + if (request == null || request.getFiles() == null || request.getFiles().isEmpty()) { + throw new BusinessException("files cannot be empty"); + } + if (request.getUserId() != null && request.getUserId() <= 0) { + throw new BusinessException("user_id 不合法"); + } + try (TaskDistributedLockService.LockHandle lock = + taskDistributedLockService.acquire(MODULE_TYPE, taskId)) { + if (lock == null) { + throw new BusinessException("task lock is busy"); + } + transactionTemplate.executeWithoutResult(status -> submitResultLocked(taskId, request)); + } + } + + public PublishTaskDetailVo getTaskDetail(Long taskId, Long userId) { + FileTaskEntity task = requireTask(taskId, userId); + return loadTaskDetails(List.of(task)).getFirst(); + } + + public PublishTaskBatchVo getTaskProgress(Long userId, List taskIds) { + if (userId != null && userId <= 0) { + throw new BusinessException("user_id 不合法"); + } + PublishTaskBatchVo response = new PublishTaskBatchVo(); + List normalized = normalizeTaskIds(taskIds); + if (normalized.isEmpty()) { + return response; + } + LambdaQueryWrapper taskQuery = new LambdaQueryWrapper() + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .in(FileTaskEntity::getId, normalized); + if (userId != null) { + taskQuery.eq(FileTaskEntity::getUserId, userId); + } + List tasks = fileTaskMapper.selectList(taskQuery); + Map taskById = tasks.stream() + .collect(Collectors.toMap(FileTaskEntity::getId, Function.identity(), (left, right) -> left, + LinkedHashMap::new)); + List ordered = new ArrayList<>(); + for (Long taskId : normalized) { + FileTaskEntity task = taskById.get(taskId); + if (task == null) { + response.getMissingTaskIds().add(taskId); + } else { + ordered.add(task); + } + } + response.setItems(loadTaskDetails(ordered)); + return response; + } + + public PublishHistoryVo history(Long userId, Integer limit) { + validateUserId(userId); + int safeLimit = limit == null || limit <= 0 ? 50 : Math.min(limit, 100); + Long total = fileTaskMapper.selectCount(new LambdaQueryWrapper() + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .eq(FileTaskEntity::getUserId, userId)); + List tasks = fileTaskMapper.selectList(new LambdaQueryWrapper() + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .eq(FileTaskEntity::getUserId, userId) + .orderByDesc(FileTaskEntity::getCreatedAt) + .last("limit " + safeLimit)); + PublishHistoryVo response = new PublishHistoryVo(); + response.setTotal(Objects.requireNonNullElse(total, 0L)); + response.setItems(loadTaskDetails(tasks)); + return response; + } + + public PublishDashboardVo dashboard(Long userId) { + validateUserId(userId); + PublishDashboardVo response = new PublishDashboardVo(); + response.setPendingCount(countTasks(userId, STATUS_PENDING)); + response.setRunningCount(countTasks(userId, STATUS_RUNNING)); + response.setSuccessCount(countTasks(userId, STATUS_SUCCESS)); + response.setFailedCount(countTasks(userId, STATUS_FAILED)); + response.setRecent(history(userId, 10).getItems()); + return response; + } + + public void touchHeartbeat(Long taskId, TaskHeartbeatRequest request) { + if (taskId == null || taskId <= 0) { + return; + } + LocalDateTime now = LocalDateTime.now(); + fileTaskMapper.update(null, new LambdaUpdateWrapper() + .eq(FileTaskEntity::getId, taskId) + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .eq(FileTaskEntity::getStatus, STATUS_RUNNING) + .set(FileTaskEntity::getUpdatedAt, now)); + if (request == null || (request.getCurrent() == null && request.getTotal() == null)) { + return; + } + PublishFileEntity active = publishFileMapper.selectOne(new LambdaQueryWrapper() + .eq(PublishFileEntity::getTaskId, taskId) + .eq(PublishFileEntity::getStatus, STATUS_RUNNING) + .orderByAsc(PublishFileEntity::getId) + .last("limit 1")); + if (active == null) { + return; + } + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .eq(PublishFileEntity::getId, active.getId()) + .eq(PublishFileEntity::getStatus, STATUS_RUNNING) + .set(PublishFileEntity::getUpdatedAt, now); + if (request.getTotal() != null && request.getTotal() >= 0) { + update.set(PublishFileEntity::getTotalRows, request.getTotal()); + } + if (request.getCurrent() != null && request.getCurrent() >= 0) { + int current = request.getCurrent(); + if (request.getTotal() != null && request.getTotal() >= 0) { + current = Math.min(current, request.getTotal()); + } + update.set(PublishFileEntity::getProcessedRows, current); + } + publishFileMapper.update(null, update); + } + + @Scheduled(fixedDelayString = "${aiimage.publish.stale-scan-delay-ms:60000}") + public void failStaleTasks() { + LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(5, staleTimeoutMinutes)); + List staleTasks = fileTaskMapper.selectList(new LambdaQueryWrapper() + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .eq(FileTaskEntity::getStatus, STATUS_RUNNING) + .lt(FileTaskEntity::getUpdatedAt, threshold) + .orderByAsc(FileTaskEntity::getUpdatedAt) + .last("limit 100")); + for (FileTaskEntity candidate : staleTasks) { + if (taskFileJobService.countUnfinishedAssembleJobs(candidate.getId(), MODULE_TYPE) > 0L) { + continue; + } + try (TaskDistributedLockService.LockHandle lock = + taskDistributedLockService.acquire(MODULE_TYPE, candidate.getId(), 0L)) { + if (lock == null) { + continue; + } + transactionTemplate.executeWithoutResult(status -> failStaleTaskLocked(candidate.getId(), threshold)); + } catch (Exception ex) { + log.warn("[publish] stale task cleanup failed taskId={} msg={}", + candidate.getId(), ex.getMessage()); + } + } + } + + public void processResultFileJob(TaskFileJobEntity job) { + if (job == null || job.getTaskId() == null || job.getResultId() == null) { + throw new BusinessException("result file job arguments are incomplete"); + } + FileTaskEntity task = fileTaskMapper.selectById(job.getTaskId()); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("task not found"); + } + FileResultEntity result = fileResultMapper.selectById(job.getResultId()); + if (result == null || !MODULE_TYPE.equals(result.getModuleType()) + || !task.getId().equals(result.getTaskId())) { + throw new BusinessException("result record not found"); + } + + File workDirectory = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), + "publish-result", String.valueOf(task.getId()), String.valueOf(job.getId()))); + try { + List files = listTaskFiles(task.getId()); + List successfulFiles = files.stream() + .filter(file -> STATUS_SUCCESS.equals(file.getStatus())) + .toList(); + if (successfulFiles.isEmpty()) { + markTaskAndResultFailed(task, result, "没有成功文件,无法生成结果"); + throw new BusinessException("no successful publish files"); + } + + List inputs = new ArrayList<>(); + int rowCount = 0; + for (PublishFileEntity file : successfulFiles) { + List items = publishItemMapper.selectList( + new LambdaQueryWrapper() + .eq(PublishItemEntity::getTaskId, task.getId()) + .eq(PublishItemEntity::getFileId, file.getId()) + .orderByAsc(PublishItemEntity::getRowIndex)); + List rows = items.stream().map(this::toRowDto).toList(); + rowCount += rows.size(); + inputs.add(new PublishWorkbookService.WorkbookInput( + file.getSourceFilename(), file.getShopName(), rows)); + } + + PublishWorkbookService.PackagedResult packaged = workbookService.packageTaskResult( + workDirectory, task.getTaskNo(), safeInt(task.getSourceFileCount()), inputs); + String objectKey = ossStorageService.uploadResultFile(packaged.file(), MODULE_TYPE); + result.setResultFilename(packaged.filename()); + result.setResultFileUrl(objectKey); + result.setResultFileSize(packaged.file().length()); + result.setResultContentType(packaged.contentType()); + result.setRowCount(rowCount); + result.setSuccess(1); + result.setErrorMessage(null); + fileResultMapper.updateById(result); + + int failedCount = (int) files.stream().filter(file -> STATUS_FAILED.equals(file.getStatus())).count(); + task.setStatus(STATUS_SUCCESS); + task.setSuccessFileCount(successfulFiles.size()); + task.setFailedFileCount(failedCount); + task.setErrorMessage(null); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + } catch (Exception ex) { + handleAssemblyFailure(job, task, result, ex); + if (ex instanceof BusinessException businessException) { + throw businessException; + } + throw new BusinessException("生成上架结果失败: " + safeMessage(ex)); + } finally { + try { + FileUtil.del(workDirectory); + } catch (Exception ignored) { + } + } + } + + @Transactional + public void deleteTask(Long taskId, Long userId) { + FileTaskEntity task = requireTask(taskId, userId); + List results = fileResultMapper.selectList(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + for (FileResultEntity result : results) { + if (result.getResultFileUrl() != null && !result.getResultFileUrl().isBlank()) { + try { + ossStorageService.deleteObject(result.getResultFileUrl()); + } catch (Exception ex) { + log.warn("[publish] OSS cleanup failed taskId={} resultId={} msg={}", + taskId, result.getId(), ex.getMessage()); + } + } + } + taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE); + publishItemMapper.delete(new LambdaQueryWrapper() + .eq(PublishItemEntity::getTaskId, taskId)); + publishFileMapper.delete(new LambdaQueryWrapper() + .eq(PublishFileEntity::getTaskId, taskId)); + fileResultMapper.delete(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + fileTaskMapper.deleteById(task.getId()); + } + + public void deleteHistory(Long resultId, Long userId) { + validateUserId(userId); + FileResultEntity result = fileResultMapper.selectById(resultId); + if (result == null || !MODULE_TYPE.equals(result.getModuleType())) { + throw new BusinessException("结果不存在"); + } + FileTaskEntity task = requireTask(result.getTaskId(), userId); + deleteTask(task.getId(), userId); + } + + private PreparedFile prepareFile(PublishSourceFileDto source) { + PreparedFile prepared = new PreparedFile(source); + prepared.shopName = FileUtil.mainName(source.getOriginalFilename()).trim(); + if (prepared.shopName.isBlank()) { + prepared.fail("文件名无法解析出店铺名"); + return prepared; + } + try { + ZiniaoShopMatchResultVo match = ziniaoShopSwitchService.findIndexedStoreByName(prepared.shopName, false); + prepared.applyMatch(match); + if (!isUsableMatch(match)) { + prepared.fail(firstNonBlank(prepared.matchMessage, "店铺未匹配到紫鸟索引")); + return prepared; + } + } catch (Exception ex) { + prepared.matchStatus = ZiniaoShopIndexService.MATCH_STATUS_PENDING; + prepared.matchMessage = safeMessage(ex); + prepared.fail(prepared.matchMessage); + return prepared; + } + + try { + File localFile = localFileStorageService.findLocalSourceFile(source.getFileKey()); + if (localFile == null || !localFile.exists()) { + throw new BusinessException("上传文件不存在,请重新上传"); + } + prepared.rows = workbookService.parse(localFile).rows(); + prepared.status = STATUS_PENDING; + prepared.errorMessage = null; + } catch (Exception ex) { + prepared.fail(safeMessage(ex)); + } + return prepared; + } + + private void failStaleTaskLocked(Long taskId, LocalDateTime threshold) { + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType()) + || !STATUS_RUNNING.equals(task.getStatus()) + || task.getUpdatedAt() == null || !task.getUpdatedAt().isBefore(threshold)) { + return; + } + if (taskFileJobService.countUnfinishedAssembleJobs(taskId, MODULE_TYPE) > 0L) { + return; + } + LocalDateTime now = LocalDateTime.now(); + String error = "任务心跳超时"; + publishFileMapper.update(null, new LambdaUpdateWrapper() + .eq(PublishFileEntity::getTaskId, taskId) + .in(PublishFileEntity::getStatus, List.of(STATUS_PENDING, STATUS_RUNNING)) + .set(PublishFileEntity::getStatus, STATUS_FAILED) + .set(PublishFileEntity::getErrorMessage, error) + .set(PublishFileEntity::getUpdatedAt, now) + .set(PublishFileEntity::getFinishedAt, now)); + List files = listTaskFiles(taskId); + int successCount = (int) files.stream().filter(file -> STATUS_SUCCESS.equals(file.getStatus())).count(); + task.setSuccessFileCount(successCount); + task.setFailedFileCount(Math.max(0, files.size() - successCount)); + FileResultEntity result = ensureTaskResult(task); + if (successCount <= 0) { + markTaskAndResultFailed(task, result, error); + return; + } + task.setStatus(STATUS_RUNNING); + task.setErrorMessage(null); + task.setUpdatedAt(now); + task.setFinishedAt(null); + fileTaskMapper.updateById(task); + result.setSuccess(0); + result.setErrorMessage(null); + fileResultMapper.updateById(result); + taskFileJobService.enqueueAssembleResult(taskId, MODULE_TYPE, result.getId(), "task:" + taskId); + } + + private PersistedTask persistTask(PublishParseRequest request, List preparedFiles) { + LocalDateTime now = LocalDateTime.now(); + int failedFiles = (int) preparedFiles.stream().filter(file -> STATUS_FAILED.equals(file.status)).count(); + int processableFiles = preparedFiles.size() - failedFiles; + + FileTaskEntity task = new FileTaskEntity(); + task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr()); + task.setModuleType(MODULE_TYPE); + task.setTaskMode("PYTHON_QUEUE"); + task.setStatus(processableFiles > 0 ? STATUS_PENDING : STATUS_FAILED); + task.setSourceFileCount(preparedFiles.size()); + task.setSuccessFileCount(0); + task.setFailedFileCount(failedFiles); + task.setRequestJson(writeJson(request, "序列化上架任务失败")); + task.setResultJson("{}"); + task.setErrorMessage(processableFiles > 0 ? null : "全部文件解析或店铺匹配失败"); + task.setCreatedBy("user:" + request.getUserId()); + task.setUserId(request.getUserId()); + task.setCreatedAt(now); + task.setUpdatedAt(now); + task.setFinishedAt(processableFiles > 0 ? null : now); + fileTaskMapper.insert(task); + + List savedFiles = new ArrayList<>(); + int totalRows = 0; + for (PreparedFile prepared : preparedFiles) { + PublishFileEntity file = prepared.toEntity(task.getId(), now); + publishFileMapper.insert(file); + savedFiles.add(file); + insertRows(task.getId(), file.getId(), prepared.rows); + totalRows += prepared.rows.size(); + } + + FileResultEntity result = new FileResultEntity(); + result.setTaskId(task.getId()); + result.setModuleType(MODULE_TYPE); + result.setSourceFilename(aggregateSourceFilename(preparedFiles)); + result.setSourceFileUrl(preparedFiles.isEmpty() ? null : preparedFiles.getFirst().source.getFileKey()); + result.setResultContentType(preparedFiles.size() == 1 + ? PublishWorkbookService.XLSX_CONTENT_TYPE : PublishWorkbookService.ZIP_CONTENT_TYPE); + result.setRowCount(totalRows); + result.setSuccess(0); + result.setErrorMessage(processableFiles > 0 ? null : task.getErrorMessage()); + result.setUserId(request.getUserId()); + result.setCreatedAt(now); + fileResultMapper.insert(result); + return new PersistedTask(task, result, savedFiles); + } + + private void submitResultLocked(Long taskId, PublishSubmitResultRequest request) { + FileTaskEntity task = requireTask(taskId, request.getUserId()); + if (STATUS_SUCCESS.equals(task.getStatus())) { + return; + } + if (STATUS_FAILED.equals(task.getStatus())) { + throw new BusinessException("任务已失败,拒绝继续回传"); + } + if (STATUS_PENDING.equals(task.getStatus())) { + task.setStatus(STATUS_RUNNING); + } + + Set submittedFileIds = new LinkedHashSet<>(); + for (PublishResultFileDto incoming : request.getFiles()) { + PublishFileEntity file = findCallbackFile(taskId, incoming); + if (!submittedFileIds.add(file.getId())) { + throw new BusinessException("同一文件不能在一次请求中重复提交"); + } + if (STATUS_SUCCESS.equals(file.getStatus()) || STATUS_FAILED.equals(file.getStatus())) { + continue; + } + if (incoming.getError() != null && !incoming.getError().isBlank()) { + file.setStatus(STATUS_FAILED); + file.setProcessedRows(0); + file.setErrorMessage(incoming.getError().trim()); + } else { + List rows = flattenRows(incoming); + validateCompleteResultRows(taskId, file.getId(), rows); + replaceRows(taskId, file.getId(), rows); + file.setStatus(STATUS_SUCCESS); + file.setTotalRows(rows.size()); + file.setProcessedRows(rows.size()); + file.setErrorMessage(null); + } + file.setUpdatedAt(LocalDateTime.now()); + file.setFinishedAt(LocalDateTime.now()); + publishFileMapper.updateById(file); + } + + List files = listTaskFiles(taskId); + int successCount = (int) files.stream().filter(file -> STATUS_SUCCESS.equals(file.getStatus())).count(); + int failedCount = (int) files.stream().filter(file -> STATUS_FAILED.equals(file.getStatus())).count(); + int terminalCount = successCount + failedCount; + task.setSuccessFileCount(successCount); + task.setFailedFileCount(failedCount); + task.setUpdatedAt(LocalDateTime.now()); + + FileResultEntity result = ensureTaskResult(task); + if (terminalCount < files.size()) { + task.setStatus(STATUS_RUNNING); + fileTaskMapper.updateById(task); + return; + } + if (successCount <= 0) { + markTaskAndResultFailed(task, result, "全部文件处理失败"); + return; + } + + task.setStatus(STATUS_RUNNING); + task.setErrorMessage(null); + task.setFinishedAt(null); + fileTaskMapper.updateById(task); + taskFileJobService.enqueueAssembleResult(taskId, MODULE_TYPE, result.getId(), "task:" + taskId); + } + + private List loadTaskDetails(List tasks) { + if (tasks == null || tasks.isEmpty()) { + return List.of(); + } + List taskIds = tasks.stream().map(FileTaskEntity::getId).toList(); + List files = publishFileMapper.selectList(new LambdaQueryWrapper() + .in(PublishFileEntity::getTaskId, taskIds) + .orderByAsc(PublishFileEntity::getId)); + Map> filesByTask = files.stream() + .collect(Collectors.groupingBy(PublishFileEntity::getTaskId, LinkedHashMap::new, Collectors.toList())); + List results = fileResultMapper.selectList(new LambdaQueryWrapper() + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .in(FileResultEntity::getTaskId, taskIds) + .orderByAsc(FileResultEntity::getId)); + Map resultByTask = new LinkedHashMap<>(); + for (FileResultEntity result : results) { + resultByTask.putIfAbsent(result.getTaskId(), result); + } + Map jobs = taskFileJobService.findAssembleJobsByResultIds(MODULE_TYPE, + results.stream().map(FileResultEntity::getId).filter(Objects::nonNull).toList()); + + List details = new ArrayList<>(); + for (FileTaskEntity task : tasks) { + List taskFiles = filesByTask.getOrDefault(task.getId(), List.of()); + FileResultEntity result = resultByTask.get(task.getId()); + PublishTaskDetailVo detail = new PublishTaskDetailVo(); + detail.setTask(toTaskVo(task, taskFiles)); + detail.setFiles(taskFiles.stream().map(this::toFileVo).toList()); + detail.setResult(toResultVo(result, result == null ? null : jobs.get(result.getId()))); + details.add(detail); + } + return details; + } + + private PublishTaskVo toTaskVo(FileTaskEntity task, List files) { + int totalRows = files.stream().mapToInt(file -> safeInt(file.getTotalRows())).sum(); + int processedRows = files.stream().mapToInt(file -> safeInt(file.getProcessedRows())).sum(); + int completedFiles = (int) files.stream().filter(file -> isTerminal(file.getStatus())).count(); + int progressSum = files.stream().mapToInt(this::filePercent).sum(); + int percent = files.isEmpty() ? 0 : (int) Math.round(progressSum * 1.0 / files.size()); + if (isTerminal(task.getStatus())) { + percent = 100; + } + PublishTaskVo response = new PublishTaskVo(); + response.setId(task.getId()); + response.setTaskNo(task.getTaskNo()); + response.setStatus(task.getStatus()); + response.setSourceFileCount(task.getSourceFileCount()); + response.setSuccessFileCount(task.getSuccessFileCount()); + response.setFailedFileCount(task.getFailedFileCount()); + response.setCompletedFileCount(completedFiles); + response.setTotalRows(totalRows); + response.setProcessedRows(processedRows); + response.setPercent(percent); + response.setErrorMessage(task.getErrorMessage()); + response.setCreatedAt(formatTime(task.getCreatedAt())); + response.setUpdatedAt(formatTime(task.getUpdatedAt())); + response.setFinishedAt(formatTime(task.getFinishedAt())); + return response; + } + + private PublishFileVo toFileVo(PublishFileEntity file) { + int percent = filePercent(file); + PublishFileVo response = new PublishFileVo(); + response.setFileId(file.getId()); + response.setFileKey(file.getFileKey()); + response.setSourceFilename(file.getSourceFilename()); + response.setShopName(file.getShopName()); + response.setShopId(file.getShopId()); + response.setMatchedUserId(file.getMatchedUserId()); + response.setPlatform(file.getPlatform()); + response.setCompanyName(file.getCompanyName()); + response.setMatched(Integer.valueOf(1).equals(file.getMatched())); + response.setMatchStatus(file.getMatchStatus()); + response.setMatchMessage(file.getMatchMessage()); + response.setStatus(file.getStatus()); + response.setTotalRows(safeInt(file.getTotalRows())); + response.setProcessedRows(safeInt(file.getProcessedRows())); + response.setPercent(percent); + response.setProgressPercent(percent); + response.setProgressCurrent(safeInt(file.getProcessedRows())); + response.setProgressTotal(safeInt(file.getTotalRows())); + response.setProgressMessage(file.getErrorMessage()); + response.setPageSize(DEFAULT_PAGE_SIZE); + response.setTotalPages(file.getTotalRows() == null || file.getTotalRows() <= 0 + ? 0 : (file.getTotalRows() + DEFAULT_PAGE_SIZE - 1) / DEFAULT_PAGE_SIZE); + response.setErrorMessage(file.getErrorMessage()); + return response; + } + + private PublishResultVo toResultVo(FileResultEntity result, TaskFileJobEntity job) { + if (result == null) { + return null; + } + boolean ready = Integer.valueOf(1).equals(result.getSuccess()) + && result.getResultFileUrl() != null && !result.getResultFileUrl().isBlank(); + PublishResultVo response = new PublishResultVo(); + response.setResultId(result.getId()); + response.setResultFilename(result.getResultFilename()); + response.setDownloadUrl(ready ? ossStorageService.generateFreshDownloadUrl(result.getResultFileUrl()) : null); + response.setFileReady(ready); + response.setErrorMessage(result.getErrorMessage()); + if (job != null) { + response.setFileJobId(job.getId()); + response.setFileJobStatus(job.getStatus()); + response.setFileJobRetryCount(job.getRetryCount()); + response.setFileJobError(job.getErrorMessage()); + } + return response; + } + + private void validateParseRequest(PublishParseRequest request) { + if (request == null) { + throw new BusinessException("请求不能为空"); + } + validateUserId(request.getUserId()); + if (request.getFiles() == null || request.getFiles().isEmpty()) { + throw new BusinessException("请先上传 Excel 文件"); + } + if (request.getPublishCountry() == null || request.getPublishCountry().isBlank()) { + throw new BusinessException("publish_country cannot be blank"); + } + } + + private FileTaskEntity requireTask(Long taskId, Long userId) { + if (taskId == null || taskId <= 0) { + throw new BusinessException("taskId 不合法"); + } + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType()) + || (userId != null && !userId.equals(task.getUserId()))) { + throw new BusinessException("任务不存在"); + } + return task; + } + + private PublishFileEntity requireFile(Long taskId, Long fileId) { + if (fileId == null || fileId <= 0) { + throw new BusinessException("file_id 不合法"); + } + PublishFileEntity file = publishFileMapper.selectById(fileId); + if (file == null || !taskId.equals(file.getTaskId())) { + throw new BusinessException("任务文件不存在"); + } + return file; + } + + private PublishFileEntity findCallbackFile(Long taskId, PublishResultFileDto incoming) { + if (incoming == null) { + throw new BusinessException("回传文件不能为空"); + } + if (incoming.getFileId() != null && incoming.getFileId() > 0) { + return requireFile(taskId, incoming.getFileId()); + } + LambdaQueryWrapper query = new LambdaQueryWrapper() + .eq(PublishFileEntity::getTaskId, taskId); + if (incoming.getFileKey() != null && !incoming.getFileKey().isBlank()) { + query.eq(PublishFileEntity::getFileKey, incoming.getFileKey().trim()); + } else if (incoming.getSourceFilename() != null && !incoming.getSourceFilename().isBlank()) { + query.eq(PublishFileEntity::getSourceFilename, incoming.getSourceFilename().trim()); + } else { + throw new BusinessException("回传文件缺少 fileId/fileKey/sourceFilename"); + } + PublishFileEntity file = publishFileMapper.selectOne(query.last("limit 1")); + if (file == null) { + throw new BusinessException("任务文件不存在"); + } + return file; + } + + private List flattenRows(PublishResultFileDto incoming) { + if (incoming.getRows() != null && !incoming.getRows().isEmpty()) { + return incoming.getRows().stream().map(this::copyRequiredRow).toList(); + } + List rows = new ArrayList<>(); + if (incoming.getCountries() == null) { + return rows; + } + for (Map.Entry> entry : incoming.getCountries().entrySet()) { + if (entry.getValue() == null) { + continue; + } + for (PublishRowDto source : entry.getValue()) { + PublishRowDto row = copyRequiredRow(source); + if (row.getCountry() == null || row.getCountry().isBlank()) { + row.setCountry(entry.getKey()); + } + rows.add(row); + } + } + return rows; + } + + private PublishRowDto copyRequiredRow(PublishRowDto source) { + if (source == null) { + throw new BusinessException("回传数据不能包含空行"); + } + PublishRowDto row = copyRow(source); + if (isBlankRow(row)) { + throw new BusinessException("回传数据不能包含空白对象行"); + } + return row; + } + + private void validateCompleteResultRows(Long taskId, Long fileId, List rows) { + long originalCount = Objects.requireNonNullElse(publishItemMapper.selectCount( + new LambdaQueryWrapper() + .eq(PublishItemEntity::getTaskId, taskId) + .eq(PublishItemEntity::getFileId, fileId)), 0L); + int submittedCount = rows == null ? 0 : rows.size(); + if (originalCount > 0L && submittedCount == 0) { + throw new BusinessException("回传数据不能为空,原始数据共 " + originalCount + " 行"); + } + if (originalCount > 0L && submittedCount < originalCount) { + throw new BusinessException("回传数据不完整,应至少包含 " + originalCount + + " 行,实际 " + submittedCount + " 行"); + } + } + + private boolean isBlankRow(PublishRowDto row) { + return row == null + || ((row.getSourceId() == null || row.getSourceId().isBlank()) + && (row.getAsin() == null || row.getAsin().isBlank()) + && (row.getCountry() == null || row.getCountry().isBlank()) + && (row.getBrand() == null || row.getBrand().isBlank()) + && (row.getPrice() == null || row.getPrice().isBlank()) + && (row.getStatus() == null || row.getStatus().isBlank()) + && (row.getSyncStatus() == null || row.getSyncStatus().isBlank()) + && (row.getSyncCountries() == null || row.getSyncCountries().isBlank())); + } + + private PublishRowDto copyRow(PublishRowDto source) { + PublishRowDto target = new PublishRowDto(); + target.setSourceId(source.getSourceId()); + target.setAsin(source.getAsin()); + target.setCountry(source.getCountry()); + target.setBrand(source.getBrand()); + target.setPrice(source.getPrice()); + target.setStatus(source.getStatus()); + target.setSyncStatus(source.getSyncStatus()); + target.setSyncCountries(source.getSyncCountries()); + return target; + } + + private void replaceRows(Long taskId, Long fileId, List rows) { + publishItemMapper.delete(new LambdaQueryWrapper() + .eq(PublishItemEntity::getTaskId, taskId) + .eq(PublishItemEntity::getFileId, fileId)); + insertRows(taskId, fileId, rows); + } + + private void insertRows(Long taskId, Long fileId, List rows) { + if (rows == null || rows.isEmpty()) { + return; + } + LocalDateTime now = LocalDateTime.now(); + List batch = new ArrayList<>(Math.min(rows.size(), INSERT_BATCH_SIZE)); + int rowIndex = 0; + for (PublishRowDto row : rows) { + if (row == null) { + continue; + } + PublishItemEntity entity = new PublishItemEntity(); + entity.setTaskId(taskId); + entity.setFileId(fileId); + entity.setRowIndex(++rowIndex); + entity.setSourceId(row.getSourceId()); + entity.setAsin(row.getAsin()); + entity.setCountry(row.getCountry()); + entity.setBrand(row.getBrand()); + entity.setPriceValue(row.getPrice()); + entity.setStatusValue(row.getStatus()); + entity.setSyncStatus(row.getSyncStatus()); + entity.setSyncCountries(row.getSyncCountries()); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + batch.add(entity); + if (batch.size() >= INSERT_BATCH_SIZE) { + publishItemMapper.insertBatch(batch); + batch.clear(); + } + } + if (!batch.isEmpty()) { + publishItemMapper.insertBatch(batch); + } + } + + private PublishRowDto toRowDto(PublishItemEntity entity) { + PublishRowDto row = new PublishRowDto(); + row.setSourceId(entity.getSourceId()); + row.setAsin(entity.getAsin()); + row.setCountry(entity.getCountry()); + row.setBrand(entity.getBrand()); + row.setPrice(entity.getPriceValue()); + row.setStatus(entity.getStatusValue()); + row.setSyncStatus(entity.getSyncStatus()); + row.setSyncCountries(entity.getSyncCountries()); + return row; + } + + private FileResultEntity ensureTaskResult(FileTaskEntity task) { + FileResultEntity result = fileResultMapper.selectOne(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, task.getId()) + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .orderByAsc(FileResultEntity::getId) + .last("limit 1")); + if (result != null) { + return result; + } + result = new FileResultEntity(); + result.setTaskId(task.getId()); + result.setModuleType(MODULE_TYPE); + result.setSourceFilename(task.getTaskNo()); + result.setResultContentType(safeInt(task.getSourceFileCount()) == 1 + ? PublishWorkbookService.XLSX_CONTENT_TYPE : PublishWorkbookService.ZIP_CONTENT_TYPE); + result.setSuccess(0); + result.setUserId(task.getUserId()); + result.setCreatedAt(LocalDateTime.now()); + fileResultMapper.insert(result); + return result; + } + + private void markTaskAndResultFailed(FileTaskEntity task, FileResultEntity result, String error) { + LocalDateTime now = LocalDateTime.now(); + task.setStatus(STATUS_FAILED); + task.setErrorMessage(error); + task.setUpdatedAt(now); + task.setFinishedAt(now); + fileTaskMapper.updateById(task); + result.setSuccess(0); + result.setErrorMessage(error); + fileResultMapper.updateById(result); + } + + private void handleAssemblyFailure(TaskFileJobEntity job, + FileTaskEntity task, + FileResultEntity result, + Exception error) { + String message = safeMessage(error); + TaskFileJobEntity latest = taskFileJobService.findById(job.getId()); + int retryCount = latest == null ? safeInt(job.getRetryCount()) : safeInt(latest.getRetryCount()); + boolean terminalAttempt = retryCount >= TaskFileJobService.MAX_RETRY_COUNT - 1; + task.setErrorMessage(message); + task.setUpdatedAt(LocalDateTime.now()); + if (terminalAttempt) { + task.setStatus(STATUS_FAILED); + task.setFinishedAt(LocalDateTime.now()); + } + fileTaskMapper.updateById(task); + result.setSuccess(0); + result.setErrorMessage(message); + fileResultMapper.updateById(result); + } + + private List listTaskFiles(Long taskId) { + return publishFileMapper.selectList(new LambdaQueryWrapper() + .eq(PublishFileEntity::getTaskId, taskId) + .orderByAsc(PublishFileEntity::getId)); + } + + private TaskOptions readTaskOptions(FileTaskEntity task) { + if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) { + return new TaskOptions(null, List.of()); + } + try { + JsonNode root = objectMapper.readTree(task.getRequestJson()); + String publishCountry = text(root, "publish_country", "publishCountry"); + JsonNode sync = root.has("sync_countries") ? root.get("sync_countries") : root.get("syncCountries"); + List syncCountries = new ArrayList<>(); + if (sync != null && sync.isArray()) { + sync.forEach(node -> { + if (node != null && !node.asText("").isBlank()) { + syncCountries.add(node.asText()); + } + }); + } + return new TaskOptions(publishCountry, syncCountries); + } catch (Exception ex) { + log.warn("[publish] failed to parse task options taskId={} msg={}", + task.getId(), safeMessage(ex)); + return new TaskOptions(null, List.of()); + } + } + + private String text(JsonNode root, String... names) { + for (String name : names) { + JsonNode node = root.get(name); + if (node != null && !node.isNull() && !node.asText("").isBlank()) { + return node.asText(); + } + } + return null; + } + + private boolean isUsableMatch(ZiniaoShopMatchResultVo match) { + if (match == null || match.getShopId() == null || match.getShopId().isBlank()) { + return false; + } + return match.isMatched() + || ZiniaoShopIndexService.MATCH_STATUS_MATCHED.equals(match.getMatchStatus()) + || ZiniaoShopIndexService.MATCH_STATUS_STALE.equals(match.getMatchStatus()); + } + + private int filePercent(PublishFileEntity file) { + if (file == null) { + return 0; + } + if (isTerminal(file.getStatus())) { + return 100; + } + int total = safeInt(file.getTotalRows()); + if (total <= 0) { + return 0; + } + return Math.min(100, Math.max(0, + (int) Math.round(safeInt(file.getProcessedRows()) * 100.0 / total))); + } + + private boolean isTerminal(String status) { + return STATUS_SUCCESS.equals(status) || STATUS_FAILED.equals(status); + } + + private long countTasks(Long userId, String status) { + return Objects.requireNonNullElse(fileTaskMapper.selectCount(new LambdaQueryWrapper() + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .eq(FileTaskEntity::getUserId, userId) + .eq(FileTaskEntity::getStatus, status)), 0L); + } + + private List normalizeTaskIds(List taskIds) { + if (taskIds == null) { + return List.of(); + } + return taskIds.stream().filter(id -> id != null && id > 0).distinct().limit(50).toList(); + } + + private void validateUserId(Long userId) { + if (userId == null || userId <= 0) { + throw new BusinessException("user_id 不合法"); + } + } + + private String aggregateSourceFilename(List files) { + if (files.isEmpty()) { + return ""; + } + String first = files.getFirst().source.getOriginalFilename(); + return files.size() == 1 ? first : first + " 等 " + files.size() + " 个文件"; + } + + private String writeJson(Object value, String errorMessage) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new BusinessException(errorMessage); + } + } + + private String formatTime(LocalDateTime value) { + return value == null ? null : value.format(TIME_FORMATTER); + } + + private int safeInt(Integer value) { + return value == null ? 0 : Math.max(0, value); + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return ""; + } + + private String safeMessage(Exception ex) { + return ex == null || ex.getMessage() == null || ex.getMessage().isBlank() + ? "未知错误" : ex.getMessage(); + } + + private final class PreparedFile { + private final PublishSourceFileDto source; + private String shopName; + private boolean matched; + private String shopId; + private Long matchedUserId; + private String platform; + private String companyName; + private String matchStatus; + private String matchMessage; + private String status = STATUS_FAILED; + private String errorMessage; + private List rows = List.of(); + + private PreparedFile(PublishSourceFileDto source) { + this.source = source; + } + + private void applyMatch(ZiniaoShopMatchResultVo match) { + if (match == null) { + return; + } + matched = isUsableMatch(match); + shopId = match.getShopId(); + matchedUserId = match.getMatchedUserId(); + platform = match.getPlatform(); + companyName = match.getCompanyName(); + matchStatus = match.getMatchStatus(); + matchMessage = match.getMatchMessage(); + } + + private void fail(String message) { + status = STATUS_FAILED; + errorMessage = firstNonBlank(message, "文件处理失败"); + } + + private PublishFileEntity toEntity(Long taskId, LocalDateTime now) { + PublishFileEntity entity = new PublishFileEntity(); + entity.setTaskId(taskId); + entity.setFileKey(source.getFileKey().trim()); + entity.setSourceFilename(source.getOriginalFilename().trim()); + entity.setRelativePath(source.getRelativePath()); + entity.setShopName(shopName); + entity.setMatched(matched ? 1 : 0); + entity.setShopId(shopId); + entity.setMatchedUserId(matchedUserId); + entity.setPlatform(platform); + entity.setCompanyName(companyName); + entity.setMatchStatus(matchStatus); + entity.setMatchMessage(matchMessage); + entity.setStatus(status); + entity.setTotalRows(rows.size()); + entity.setProcessedRows(0); + entity.setErrorMessage(errorMessage); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + entity.setFinishedAt(STATUS_FAILED.equals(status) ? now : null); + return entity; + } + } + + private record PersistedTask(FileTaskEntity task, + FileResultEntity result, + List files) { + } + + private record TaskOptions(String publishCountry, List syncCountries) { + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishWorkbookService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishWorkbookService.java new file mode 100644 index 00000000..88d600c3 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishWorkbookService.java @@ -0,0 +1,351 @@ +package com.nanri.aiimage.modules.publish.service; + +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.common.util.ExcelStreamReader; +import com.nanri.aiimage.modules.publish.model.dto.PublishRowDto; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.WorkbookUtil; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.FileOutputStream; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +@Service +public class PublishWorkbookService { + + public static final List HEADERS = List.of( + "id", "ASIN", "国家", "品牌", "价格", "状态", "同步状态", "同步国家"); + public static final String XLSX_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + public static final String ZIP_CONTENT_TYPE = "application/zip"; + + public ParsedWorkbook parse(File inputFile) { + List rows = new ArrayList<>(); + Set validatedSheets = new LinkedHashSet<>(); + try { + ExcelStreamReader.readAllSheets(inputFile, new ExcelStreamReader.SheetRowHandler() { + @Override + public void onHeader(String sheetName, Integer sheetNo, Map headerMap) { + boolean emptyHeader = headerMap == null || headerMap.values().stream() + .allMatch(value -> normalize(value).isBlank()); + if (emptyHeader) { + return; + } + validateHeaders(headerMap); + validatedSheets.add(sheetNo); + } + + @Override + public void onRow(String sheetName, + Integer sheetNo, + int rowIndex, + Map headerMap, + Map rowMap) { + boolean emptyRow = rowMap == null || rowMap.values().stream() + .allMatch(value -> normalize(value).isBlank()); + if (emptyRow) { + return; + } + if (!validatedSheets.contains(sheetNo)) { + throw new BusinessException("工作表 " + sheetName + " 缺少严格的上架表头"); + } + List values = new ArrayList<>(HEADERS.size()); + boolean nonEmpty = false; + for (int columnIndex = 0; columnIndex < HEADERS.size(); columnIndex++) { + String value = normalize(rowMap.get(columnIndex)); + values.add(value); + nonEmpty = nonEmpty || !value.isBlank(); + } + if (nonEmpty) { + rows.add(toRow(values)); + } + } + }); + if (validatedSheets.isEmpty()) { + throw new BusinessException("Excel 表头为空"); + } + return new ParsedWorkbook(rows); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException("解析上架 Excel 失败: " + safeMessage(ex)); + } + } + + public File writeWorkbook(File outputFile, List rows) { + File parent = outputFile.getParentFile(); + if (parent != null) { + parent.mkdirs(); + } + SXSSFWorkbook workbook = new SXSSFWorkbook(200); + workbook.setCompressTempFiles(true); + try (FileOutputStream output = new FileOutputStream(outputFile)) { + CellStyle headerStyle = createHeaderStyle(workbook); + Map> rowsByCountry = groupByCountry(rows); + Set usedSheetNames = new LinkedHashSet<>(); + for (Map.Entry> entry : rowsByCountry.entrySet()) { + String sheetName = uniqueSheetName(entry.getKey(), usedSheetNames); + Sheet sheet = workbook.createSheet(sheetName); + writeSheet(sheet, entry.getValue(), headerStyle); + } + workbook.write(output); + return outputFile; + } catch (Exception ex) { + throw new BusinessException("生成上架结果 Excel 失败: " + safeMessage(ex)); + } finally { + workbook.dispose(); + try { + workbook.close(); + } catch (Exception ignored) { + } + } + } + + public PackagedResult packageTaskResult(File workDirectory, + String taskNo, + int sourceFileCount, + List successfulFiles) { + if (successfulFiles == null || successfulFiles.isEmpty()) { + throw new BusinessException("没有可生成的成功文件"); + } + workDirectory.mkdirs(); + List workbooks = new ArrayList<>(); + Set usedFilenames = new LinkedHashSet<>(); + for (WorkbookInput input : successfulFiles) { + String desired = safeFileStem(firstNonBlank(input.sourceFilename(), input.shopName(), "上架结果")) + + "_上架结果.xlsx"; + String filename = uniqueFilename(desired, usedFilenames); + File workbook = new File(workDirectory, filename); + writeWorkbook(workbook, input.rows()); + workbooks.add(workbook); + } + + if (sourceFileCount == 1) { + if (workbooks.size() != 1) { + throw new BusinessException("单文件任务结果数量不一致"); + } + File workbook = workbooks.getFirst(); + return new PackagedResult(workbook, workbook.getName(), XLSX_CONTENT_TYPE); + } + + String zipFilename = safeFileStem(firstNonBlank(taskNo, "publish")) + "_上架结果.zip"; + File zipFile = new File(workDirectory, zipFilename); + try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(zipFile))) { + for (File workbook : workbooks) { + zip.putNextEntry(new ZipEntry(workbook.getName())); + Files.copy(workbook.toPath(), zip); + zip.closeEntry(); + } + } catch (Exception ex) { + throw new BusinessException("打包上架结果失败: " + safeMessage(ex)); + } + return new PackagedResult(zipFile, zipFilename, ZIP_CONTENT_TYPE); + } + + private void validateHeaders(Map headerMap) { + if (headerMap == null || headerMap.isEmpty()) { + throw new BusinessException("Excel 表头为空"); + } + for (int index = 0; index < HEADERS.size(); index++) { + String actual = normalize(headerMap.get(index)); + String expected = HEADERS.get(index); + if (!expected.equals(actual)) { + throw new BusinessException("Excel 表头不匹配,第 " + (index + 1) + + " 列应为 " + expected + ",实际为 " + actual); + } + } + for (Map.Entry entry : headerMap.entrySet()) { + if (entry.getKey() != null && entry.getKey() >= HEADERS.size() && !normalize(entry.getValue()).isBlank()) { + throw new BusinessException("Excel 表头必须严格为: " + String.join("/", HEADERS)); + } + } + } + + private PublishRowDto toRow(List values) { + PublishRowDto row = new PublishRowDto(); + row.setSourceId(values.get(0)); + row.setAsin(values.get(1)); + row.setCountry(values.get(2)); + row.setBrand(values.get(3)); + row.setPrice(values.get(4)); + row.setStatus(values.get(5)); + row.setSyncStatus(values.get(6)); + row.setSyncCountries(values.get(7)); + return row; + } + + private Map> groupByCountry(List rows) { + Map> grouped = new LinkedHashMap<>(); + if (rows != null) { + for (PublishRowDto row : rows) { + if (row == null) { + continue; + } + String country = countrySheetName(row.getCountry()); + grouped.computeIfAbsent(country, ignored -> new ArrayList<>()).add(row); + } + } + if (grouped.isEmpty()) { + grouped.put("空数据", List.of()); + } + return grouped; + } + + private void writeSheet(Sheet sheet, List rows, CellStyle headerStyle) { + Row header = sheet.createRow(0); + for (int index = 0; index < HEADERS.size(); index++) { + Cell cell = header.createCell(index); + cell.setCellValue(HEADERS.get(index)); + cell.setCellStyle(headerStyle); + } + int rowIndex = 1; + for (PublishRowDto value : rows) { + Row row = sheet.createRow(rowIndex++); + setText(row, 0, value.getSourceId()); + setText(row, 1, value.getAsin()); + setText(row, 2, value.getCountry()); + setText(row, 3, value.getBrand()); + setPrice(row, 4, value.getPrice()); + setText(row, 5, value.getStatus()); + setText(row, 6, value.getSyncStatus()); + setText(row, 7, value.getSyncCountries()); + } + int[] widths = {14, 18, 12, 24, 12, 14, 14, 28}; + for (int index = 0; index < widths.length; index++) { + sheet.setColumnWidth(index, widths[index] * 256); + } + sheet.createFreezePane(0, 1); + } + + private CellStyle createHeaderStyle(Workbook workbook) { + Font font = workbook.createFont(); + font.setBold(true); + CellStyle style = workbook.createCellStyle(); + style.setFont(font); + return style; + } + + private void setText(Row row, int columnIndex, String value) { + row.createCell(columnIndex).setCellValue(value == null ? "" : value); + } + + private void setPrice(Row row, int columnIndex, String value) { + String normalized = normalize(value); + if (normalized.isBlank()) { + setText(row, columnIndex, ""); + return; + } + try { + row.createCell(columnIndex).setCellValue(new BigDecimal(normalized).doubleValue()); + } catch (NumberFormatException ignored) { + setText(row, columnIndex, value); + } + } + + private String countrySheetName(String value) { + String normalized = normalize(value); + String upper = normalized.toUpperCase(Locale.ROOT); + return switch (upper) { + case "DE", "GERMANY", "德国" -> "德国"; + case "UK", "GB", "UNITED KINGDOM", "英国" -> "英国"; + case "FR", "FRANCE", "法国" -> "法国"; + case "IT", "ITALY", "意大利" -> "意大利"; + case "ES", "SPAIN", "西班牙" -> "西班牙"; + default -> firstNonBlank(normalized, "未分国家"); + }; + } + + private String uniqueSheetName(String rawName, Set usedNames) { + String base = WorkbookUtil.createSafeSheetName(firstNonBlank(rawName, "未分国家")); + if (base == null || base.isBlank()) { + base = "未分国家"; + } + base = base.length() <= 31 ? base : base.substring(0, 31); + String candidate = base; + int suffix = 2; + while (!usedNames.add(candidate.toLowerCase(Locale.ROOT))) { + String suffixText = "_" + suffix++; + candidate = base.substring(0, Math.min(base.length(), 31 - suffixText.length())) + suffixText; + } + return candidate; + } + + private String uniqueFilename(String desired, Set usedNames) { + String candidate = desired; + int suffix = 2; + while (!usedNames.add(candidate.toLowerCase(Locale.ROOT))) { + int dot = desired.lastIndexOf('.'); + String stem = dot > 0 ? desired.substring(0, dot) : desired; + String extension = dot > 0 ? desired.substring(dot) : ""; + candidate = stem + "_" + suffix++ + extension; + } + return candidate; + } + + private String safeFileStem(String filename) { + String value = firstNonBlank(filename, "publish").replace('\\', '/'); + int slash = value.lastIndexOf('/'); + if (slash >= 0) { + value = value.substring(slash + 1); + } + int dot = value.lastIndexOf('.'); + if (dot > 0) { + value = value.substring(0, dot); + } + value = value.replaceAll("[\\\\/:*?\"<>|\\r\\n]", "_").trim(); + return value.isBlank() ? "publish" : value; + } + + private String normalize(String value) { + if (value == null) { + return ""; + } + return value.replace(String.valueOf((char) 0xFEFF), "") + .replace((char) 0x3000, ' ') + .replace("\r\n", " ") + .replace("\r", " ") + .replace("\n", " ") + .replace("\t", " ") + .trim() + .replaceAll("\\s+", " "); + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return ""; + } + + private static String safeMessage(Exception ex) { + return ex.getMessage() == null || ex.getMessage().isBlank() ? ex.getClass().getSimpleName() : ex.getMessage(); + } + + public record ParsedWorkbook(List rows) { + } + + public record WorkbookInput(String sourceFilename, String shopName, List rows) { + } + + public record PackagedResult(File file, String filename, String contentType) { + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java index 85d98e0b..9715042d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java @@ -10,6 +10,7 @@ import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandTaskCacheService import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskCacheService; import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskCacheService; import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskCacheService; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskCacheService; import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskCacheService; import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskCacheService; @@ -48,6 +49,7 @@ public class TaskHeartbeatService { private final FileTaskMapper fileTaskMapper; private final BrandCrawlTaskMapper brandCrawlTaskMapper; private final ProductRiskTaskCacheService productRiskTaskCacheService; + private final PublishTaskService publishTaskService; private final PriceTrackTaskCacheService priceTrackTaskCacheService; private final ShopMatchTaskCacheService shopMatchTaskCacheService; private final PatrolDeleteTaskCacheService patrolDeleteTaskCacheService; @@ -159,6 +161,9 @@ public class TaskHeartbeatService { case MODULE_PRODUCT_RISK -> { productRiskTaskCacheService.touchTaskHeartbeat(taskId); } + case PublishTaskService.MODULE_TYPE -> { + publishTaskService.touchHeartbeat(taskId, request); + } case MODULE_PRICE_TRACK -> { priceTrackTaskCacheService.touchTaskHeartbeat(taskId); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java index a804720f..de7f2cb2 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java @@ -9,6 +9,7 @@ import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; @@ -41,6 +42,7 @@ public class TaskResultFileJobWorker { private final ShopMatchTaskService shopMatchTaskService; private final PriceTrackTaskService priceTrackTaskService; private final ProductRiskTaskService productRiskTaskService; + private final PublishTaskService publishTaskService; private final QueryAsinTaskService queryAsinTaskService; private final WithdrawTaskService withdrawTaskService; private final PatrolDeleteTaskService patrolDeleteTaskService; @@ -261,6 +263,10 @@ public class TaskResultFileJobWorker { productRiskTaskService.processResultFileJob(job); return true; } + if (PublishTaskService.MODULE_TYPE.equals(moduleType)) { + publishTaskService.processResultFileJob(job); + return true; + } if ("QUERY_ASIN".equals(moduleType)) { queryAsinTaskService.processResultFileJob(job); return true; diff --git a/backend-java/src/main/resources/db/V77__publish_task.sql b/backend-java/src/main/resources/db/V77__publish_task.sql new file mode 100644 index 00000000..c90420da --- /dev/null +++ b/backend-java/src/main/resources/db/V77__publish_task.sql @@ -0,0 +1,58 @@ +CREATE TABLE IF NOT EXISTS biz_publish_file ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'primary key', + task_id BIGINT NOT NULL COMMENT 'biz_file_task.id', + file_key VARCHAR(255) NOT NULL COMMENT 'uploaded temporary file key', + source_filename VARCHAR(255) NOT NULL COMMENT 'original source filename', + relative_path VARCHAR(1000) NULL COMMENT 'relative upload path', + shop_name VARCHAR(255) NOT NULL COMMENT 'shop name resolved from filename', + matched TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'whether the shop matched the Ziniao index', + shop_id VARCHAR(128) NULL COMMENT 'Ziniao shop id', + matched_user_id BIGINT NULL COMMENT 'user id owning the matched Ziniao shop', + platform VARCHAR(128) NULL COMMENT 'shop platform', + company_name VARCHAR(255) NULL COMMENT 'Ziniao company name', + match_status VARCHAR(32) NULL COMMENT 'MATCHED/PENDING/CONFLICT/INDEX_STALE', + match_message VARCHAR(1000) NULL COMMENT 'shop match detail', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/RUNNING/SUCCESS/FAILED', + total_rows INT NOT NULL DEFAULT 0 COMMENT 'current full row count', + processed_rows INT NOT NULL DEFAULT 0 COMMENT 'reported processed row count', + error_message VARCHAR(1000) NULL COMMENT 'parse or processing error', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'updated time', + finished_at DATETIME NULL COMMENT 'finished time', + UNIQUE KEY uk_publish_task_file_key (task_id, file_key), + KEY idx_publish_file_task_status (task_id, status), + KEY idx_publish_file_task_id (task_id, id) +) COMMENT='publish task source file'; + +CREATE TABLE IF NOT EXISTS biz_publish_item ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'primary key', + task_id BIGINT NOT NULL COMMENT 'biz_file_task.id', + file_id BIGINT NOT NULL COMMENT 'biz_publish_file.id', + row_index INT NOT NULL COMMENT 'row order starting from 1', + source_id VARCHAR(128) NULL COMMENT 'source id column', + asin VARCHAR(128) NULL COMMENT 'ASIN column', + country VARCHAR(128) NULL COMMENT 'country column', + brand VARCHAR(255) NULL COMMENT 'brand column', + price_value VARCHAR(128) NULL COMMENT 'price column formatted value', + status_value VARCHAR(128) NULL COMMENT 'status column', + sync_status VARCHAR(128) NULL COMMENT 'sync status column', + sync_countries VARCHAR(1000) NULL COMMENT 'sync countries column', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'updated time', + UNIQUE KEY uk_publish_file_row (file_id, row_index), + KEY idx_publish_item_task_file (task_id, file_id, row_index), + KEY idx_publish_item_file_country (file_id, country, row_index) +) COMMENT='publish task full workbook row'; + +INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `sort_order`) +SELECT '上架', 'publish', 'app', 'publish', 112 +WHERE NOT EXISTS ( + SELECT 1 FROM `columns` WHERE `column_key` = 'publish' +); + +UPDATE `columns` +SET `name` = '上架', + `menu_type` = 'app', + `route_path` = 'publish', + `sort_order` = 112 +WHERE `column_key` = 'publish'; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishTaskServiceTest.java new file mode 100644 index 00000000..04345bae --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishTaskServiceTest.java @@ -0,0 +1,354 @@ +package com.nanri.aiimage.modules.publish.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.file.service.LocalFileStorageService; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.publish.mapper.PublishFileMapper; +import com.nanri.aiimage.modules.publish.mapper.PublishItemMapper; +import com.nanri.aiimage.modules.publish.model.dto.PublishResultFileDto; +import com.nanri.aiimage.modules.publish.model.dto.PublishRowDto; +import com.nanri.aiimage.modules.publish.model.dto.PublishSubmitResultRequest; +import com.nanri.aiimage.modules.publish.model.entity.PublishFileEntity; +import com.nanri.aiimage.modules.publish.model.entity.PublishItemEntity; +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.task.service.TaskDistributedLockService; +import com.nanri.aiimage.modules.task.service.TaskFileJobService; +import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PublishTaskServiceTest { + + @BeforeAll + static void initializeMybatisMetadata() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class); + TableInfoHelper.initTableInfo(assistant, FileResultEntity.class); + TableInfoHelper.initTableInfo(assistant, PublishFileEntity.class); + TableInfoHelper.initTableInfo(assistant, PublishItemEntity.class); + } + + @Mock private LocalFileStorageService localFileStorageService; + @Mock private ZiniaoShopSwitchService ziniaoShopSwitchService; + @Mock private PublishWorkbookService workbookService; + @Mock private PublishFileMapper publishFileMapper; + @Mock private PublishItemMapper publishItemMapper; + @Mock private FileTaskMapper fileTaskMapper; + @Mock private FileResultMapper fileResultMapper; + @Mock private TaskFileJobService taskFileJobService; + @Mock private TaskDistributedLockService taskDistributedLockService; + @Mock private OssStorageService ossStorageService; + @Mock private ObjectMapper objectMapper; + @Mock private TransactionTemplate transactionTemplate; + + @InjectMocks private PublishTaskService service; + + @BeforeEach + void executeTransactionsInline() { + lenient().doAnswer(invocation -> { + Consumer callback = invocation.getArgument(0); + callback.accept(null); + return null; + }).when(transactionTemplate).executeWithoutResult(any()); + } + + @Test + void resultCallbackRejectsAnotherUserBeforeReadingOrReplacingRows() { + long taskId = 101L; + FileTaskEntity task = task(taskId, 7L, "RUNNING"); + PublishSubmitResultRequest request = resultRequest(8L, 201L, List.of(row("1"))); + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + + when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock); + when(fileTaskMapper.selectById(taskId)).thenReturn(task); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.submitResult(taskId, request)); + + assertEquals("任务不存在", error.getMessage()); + verifyNoInteractions(publishItemMapper); + verify(publishFileMapper, never()).updateById(any(PublishFileEntity.class)); + verify(lock).close(); + } + + @Test + void resultCallbackRejectsIncompleteRowsWithoutDeletingOriginalData() { + long taskId = 102L; + long fileId = 202L; + FileTaskEntity task = task(taskId, 7L, "RUNNING"); + PublishFileEntity file = file(taskId, fileId, "RUNNING", "郭亚庆.xlsx"); + PublishSubmitResultRequest request = resultRequest(7L, fileId, List.of(row("1"))); + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + + when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock); + when(fileTaskMapper.selectById(taskId)).thenReturn(task); + when(publishFileMapper.selectById(fileId)).thenReturn(file); + when(publishItemMapper.selectCount(any())).thenReturn(2L); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.submitResult(taskId, request)); + + assertTrue(error.getMessage().contains("回传数据不完整")); + assertEquals("RUNNING", file.getStatus()); + verify(publishItemMapper, never()).delete(any()); + verify(publishItemMapper, never()).insertBatch(any()); + verify(publishFileMapper, never()).updateById(any(PublishFileEntity.class)); + verify(lock).close(); + } + + @Test + void resultCallbackRejectsBlankObjectWithoutDeletingOriginalData() { + long taskId = 103L; + long fileId = 203L; + FileTaskEntity task = task(taskId, 7L, "RUNNING"); + PublishFileEntity file = file(taskId, fileId, "RUNNING", "郭亚庆.xlsx"); + PublishSubmitResultRequest request = resultRequest(7L, fileId, List.of(new PublishRowDto())); + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + + when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock); + when(fileTaskMapper.selectById(taskId)).thenReturn(task); + when(publishFileMapper.selectById(fileId)).thenReturn(file); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.submitResult(taskId, request)); + + assertTrue(error.getMessage().contains("空白对象行")); + verify(publishItemMapper, never()).delete(any()); + verify(publishItemMapper, never()).insertBatch(any()); + verify(publishFileMapper, never()).updateById(any(PublishFileEntity.class)); + verify(lock).close(); + } + + @Test + void resultCallbackCanDeriveTaskOwnerWhenUserIdIsOmitted() { + long taskId = 108L; + long fileId = 208L; + long resultId = 308L; + FileTaskEntity task = task(taskId, 7L, "RUNNING"); + PublishFileEntity file = file(taskId, fileId, "RUNNING", "郭亚庆.xlsx"); + FileResultEntity result = new FileResultEntity(); + result.setId(resultId); + result.setTaskId(taskId); + result.setModuleType(PublishTaskService.MODULE_TYPE); + result.setSuccess(0); + PublishSubmitResultRequest request = resultRequest(7L, fileId, List.of(row("1"))); + request.setUserId(null); + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + + when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock); + when(fileTaskMapper.selectById(taskId)).thenReturn(task); + when(publishFileMapper.selectById(fileId)).thenReturn(file); + when(publishItemMapper.selectCount(any())).thenReturn(1L); + when(publishFileMapper.selectList(any())).thenReturn(List.of(file)); + when(fileResultMapper.selectOne(any())).thenReturn(result); + + service.submitResult(taskId, request); + + assertEquals("SUCCESS", file.getStatus()); + verify(taskFileJobService).enqueueAssembleResult( + taskId, PublishTaskService.MODULE_TYPE, resultId, "task:" + taskId); + verify(lock).close(); + } + + @Test + void activateFileRejectsSecondRunningFileWhileHoldingTaskLock() { + long taskId = 104L; + long fileId = 204L; + FileTaskEntity task = task(taskId, 7L, "RUNNING"); + PublishFileEntity target = file(taskId, fileId, "PENDING", "待执行.xlsx"); + PublishFileEntity running = file(taskId, 205L, "RUNNING", "执行中.xlsx"); + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + + when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock); + when(fileTaskMapper.selectById(taskId)).thenReturn(task); + when(publishFileMapper.selectById(fileId)).thenReturn(target); + when(publishFileMapper.selectOne(any())).thenReturn(running); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.activateFile(taskId, fileId, 7L)); + + assertTrue(error.getMessage().contains("已有文件正在执行")); + verify(publishFileMapper, never()).update(isNull(), any()); + verify(lock).close(); + } + + @Test + void activateFileIsIdempotentForTheSameRunningFile() { + long taskId = 105L; + long fileId = 205L; + FileTaskEntity task = task(taskId, 7L, "RUNNING"); + PublishFileEntity running = file(taskId, fileId, "RUNNING", "执行中.xlsx"); + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + + when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock); + when(fileTaskMapper.selectById(taskId)).thenReturn(task); + when(publishFileMapper.selectById(fileId)).thenReturn(running); + + service.activateFile(taskId, fileId, 7L); + + verify(publishFileMapper, never()).selectOne(any()); + verify(publishFileMapper, never()).update(isNull(), any()); + verify(lock).close(); + } + + @Test + void staleTaskWithSuccessfulFileFailsRemainderAndEnqueuesAssembly() { + long taskId = 106L; + long resultId = 306L; + FileTaskEntity task = task(taskId, 7L, "RUNNING"); + task.setSourceFileCount(2); + task.setUpdatedAt(LocalDateTime.now().minusHours(1)); + task.setErrorMessage("旧错误"); + PublishFileEntity success = file(taskId, 206L, "SUCCESS", "成功.xlsx"); + PublishFileEntity failed = file(taskId, 207L, "FAILED", "超时.xlsx"); + FileResultEntity result = new FileResultEntity(); + result.setId(resultId); + result.setTaskId(taskId); + result.setModuleType(PublishTaskService.MODULE_TYPE); + result.setSuccess(0); + result.setErrorMessage("旧错误"); + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + + when(fileTaskMapper.selectList(any())).thenReturn(List.of(task)); + when(taskFileJobService.countUnfinishedAssembleJobs(taskId, PublishTaskService.MODULE_TYPE)) + .thenReturn(0L); + when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId, 0L)) + .thenReturn(lock); + when(fileTaskMapper.selectById(taskId)).thenReturn(task); + when(publishFileMapper.selectList(any())).thenReturn(List.of(success, failed)); + when(fileResultMapper.selectOne(any())).thenReturn(result); + + service.failStaleTasks(); + + assertEquals("RUNNING", task.getStatus()); + assertEquals(1, task.getSuccessFileCount()); + assertEquals(1, task.getFailedFileCount()); + assertNull(task.getErrorMessage()); + assertNull(task.getFinishedAt()); + assertEquals(0, result.getSuccess()); + assertNull(result.getErrorMessage()); + assertNotNull(task.getUpdatedAt()); + verify(taskFileJobService, times(2)) + .countUnfinishedAssembleJobs(taskId, PublishTaskService.MODULE_TYPE); + verify(taskFileJobService).enqueueAssembleResult( + taskId, PublishTaskService.MODULE_TYPE, resultId, "task:" + taskId); + verify(fileTaskMapper).updateById(task); + verify(fileResultMapper).updateById(result); + verify(lock).close(); + } + + @Test + void staleTaskWithoutSuccessfulFilesFailsTaskAndResult() { + long taskId = 107L; + long resultId = 307L; + FileTaskEntity task = task(taskId, 7L, "RUNNING"); + task.setSourceFileCount(1); + task.setUpdatedAt(LocalDateTime.now().minusHours(1)); + PublishFileEntity failed = file(taskId, 208L, "FAILED", "超时.xlsx"); + FileResultEntity result = new FileResultEntity(); + result.setId(resultId); + result.setTaskId(taskId); + result.setModuleType(PublishTaskService.MODULE_TYPE); + result.setSuccess(0); + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + + when(fileTaskMapper.selectList(any())).thenReturn(List.of(task)); + when(taskFileJobService.countUnfinishedAssembleJobs(taskId, PublishTaskService.MODULE_TYPE)) + .thenReturn(0L); + when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId, 0L)) + .thenReturn(lock); + when(fileTaskMapper.selectById(taskId)).thenReturn(task); + when(publishFileMapper.selectList(any())).thenReturn(List.of(failed)); + when(fileResultMapper.selectOne(any())).thenReturn(result); + + service.failStaleTasks(); + + assertEquals("FAILED", task.getStatus()); + assertEquals(0, task.getSuccessFileCount()); + assertEquals(1, task.getFailedFileCount()); + assertEquals("任务心跳超时", task.getErrorMessage()); + assertNotNull(task.getFinishedAt()); + assertEquals(0, result.getSuccess()); + assertEquals("任务心跳超时", result.getErrorMessage()); + verify(taskFileJobService, never()).enqueueAssembleResult( + taskId, PublishTaskService.MODULE_TYPE, resultId, "task:" + taskId); + verify(fileTaskMapper).updateById(task); + verify(fileResultMapper).updateById(result); + verify(lock).close(); + } + + private FileTaskEntity task(long taskId, long userId, String status) { + FileTaskEntity task = new FileTaskEntity(); + task.setId(taskId); + task.setUserId(userId); + task.setModuleType(PublishTaskService.MODULE_TYPE); + task.setStatus(status); + return task; + } + + private PublishFileEntity file(long taskId, + long fileId, + String status, + String sourceFilename) { + PublishFileEntity file = new PublishFileEntity(); + file.setId(fileId); + file.setTaskId(taskId); + file.setStatus(status); + file.setSourceFilename(sourceFilename); + return file; + } + + private PublishSubmitResultRequest resultRequest(long userId, + long fileId, + List rows) { + PublishResultFileDto file = new PublishResultFileDto(); + file.setFileId(fileId); + file.setRows(rows); + PublishSubmitResultRequest request = new PublishSubmitResultRequest(); + request.setUserId(userId); + request.setFiles(List.of(file)); + return request; + } + + private PublishRowDto row(String sourceId) { + PublishRowDto row = new PublishRowDto(); + row.setSourceId(sourceId); + row.setAsin("B001"); + row.setCountry("德国"); + return row; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishWorkbookServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishWorkbookServiceTest.java new file mode 100644 index 00000000..f959e619 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishWorkbookServiceTest.java @@ -0,0 +1,153 @@ +package com.nanri.aiimage.modules.publish.service; + +import cn.hutool.core.io.FileUtil; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.publish.model.dto.PublishRowDto; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipFile; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PublishWorkbookServiceTest { + + private final PublishWorkbookService service = new PublishWorkbookService(); + + @Test + void parsesOnlyTheExactEightHeadersAcrossNonEmptySheets() throws Exception { + Path directory = Files.createTempDirectory("publish-parse-"); + try { + File valid = directory.resolve("valid.xlsx").toFile(); + try (Workbook workbook = new XSSFWorkbook(); + FileOutputStream output = new FileOutputStream(valid)) { + writeSourceSheet(workbook.createSheet("英国数据"), "英国", "B001"); + workbook.createSheet("空白页"); + writeSourceSheet(workbook.createSheet("德国数据"), "DE", "B002"); + workbook.write(output); + } + + PublishWorkbookService.ParsedWorkbook parsed = service.parse(valid); + assertEquals(2, parsed.rows().size()); + assertEquals("B001", parsed.rows().get(0).getAsin()); + assertEquals("DE", parsed.rows().get(1).getCountry()); + + File invalid = directory.resolve("invalid.xlsx").toFile(); + try (Workbook workbook = new XSSFWorkbook(); + FileOutputStream output = new FileOutputStream(invalid)) { + Sheet sheet = workbook.createSheet("错误表头"); + Row header = sheet.createRow(0); + List headers = new ArrayList<>(PublishWorkbookService.HEADERS); + headers.set(1, "Asin"); + for (int index = 0; index < headers.size(); index++) { + header.createCell(index).setCellValue(headers.get(index)); + } + workbook.write(output); + } + assertThrows(BusinessException.class, () -> service.parse(invalid)); + } finally { + FileUtil.del(directory.toFile()); + } + } + + @Test + void writesOneSheetPerNormalizedCountryWithExactHeaders() throws Exception { + Path directory = Files.createTempDirectory("publish-sheets-"); + try { + File output = directory.resolve("result.xlsx").toFile(); + service.writeWorkbook(output, List.of( + row("1", "B001", "UK", "19.99"), + row("2", "B002", "德国", "not-a-number"), + row("3", "B003", "GB", "20"))); + + try (FileInputStream input = new FileInputStream(output); + Workbook workbook = new XSSFWorkbook(input)) { + assertEquals(2, workbook.getNumberOfSheets()); + assertEquals(Set.of("英国", "德国"), + Set.of(workbook.getSheetName(0), workbook.getSheetName(1))); + Sheet uk = workbook.getSheet("英国"); + assertNotNull(uk); + for (int index = 0; index < PublishWorkbookService.HEADERS.size(); index++) { + assertEquals(PublishWorkbookService.HEADERS.get(index), + uk.getRow(0).getCell(index).getStringCellValue()); + } + assertEquals(CellType.NUMERIC, uk.getRow(1).getCell(4).getCellType()); + assertEquals(19.99D, uk.getRow(1).getCell(4).getNumericCellValue(), 0.0001D); + assertEquals("not-a-number", + workbook.getSheet("德国").getRow(1).getCell(4).getStringCellValue()); + } + } finally { + FileUtil.del(directory.toFile()); + } + } + + @Test + void packagingDecisionUsesOriginalSourceFileCount() throws Exception { + Path directory = Files.createTempDirectory("publish-package-"); + try { + List oneSuccess = List.of( + new PublishWorkbookService.WorkbookInput( + "郭亚庆.xlsx", "郭亚庆", List.of(row("1", "B001", "英国", "50")))); + + PublishWorkbookService.PackagedResult single = service.packageTaskResult( + directory.resolve("single").toFile(), "PUBLISH-1", 1, oneSuccess); + assertTrue(single.filename().endsWith(".xlsx")); + assertEquals(PublishWorkbookService.XLSX_CONTENT_TYPE, single.contentType()); + + PublishWorkbookService.PackagedResult multiWithOneSuccess = service.packageTaskResult( + directory.resolve("multi").toFile(), "PUBLISH-2", 2, oneSuccess); + assertTrue(multiWithOneSuccess.filename().endsWith(".zip")); + assertEquals(PublishWorkbookService.ZIP_CONTENT_TYPE, multiWithOneSuccess.contentType()); + try (ZipFile zip = new ZipFile(multiWithOneSuccess.file())) { + assertEquals(1, zip.size()); + assertTrue(zip.entries().nextElement().getName().endsWith(".xlsx")); + } + } finally { + FileUtil.del(directory.toFile()); + } + } + + private void writeSourceSheet(Sheet sheet, String country, String asin) { + Row header = sheet.createRow(0); + for (int index = 0; index < PublishWorkbookService.HEADERS.size(); index++) { + header.createCell(index).setCellValue(PublishWorkbookService.HEADERS.get(index)); + } + Row data = sheet.createRow(1); + data.createCell(0).setCellValue("1"); + data.createCell(1).setCellValue(asin); + data.createCell(2).setCellValue(country); + data.createCell(3).setCellValue("Brand"); + data.createCell(4).setCellValue(50); + data.createCell(5).setCellValue("成功"); + data.createCell(6).setCellValue("成功"); + data.createCell(7).setCellValue("德国,法国"); + } + + private PublishRowDto row(String id, String asin, String country, String price) { + PublishRowDto row = new PublishRowDto(); + row.setSourceId(id); + row.setAsin(asin); + row.setCountry(country); + row.setBrand("Brand"); + row.setPrice(price); + row.setStatus("成功"); + row.setSyncStatus("成功"); + row.setSyncCountries("德国,法国"); + return row; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatServiceTest.java new file mode 100644 index 00000000..3724e4de --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatServiceTest.java @@ -0,0 +1,89 @@ +package com.nanri.aiimage.modules.task.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskCacheService; +import com.nanri.aiimage.modules.brand.mapper.BrandCrawlTaskMapper; +import com.nanri.aiimage.modules.brand.service.BrandTaskProgressCacheService; +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandTaskCacheService; +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskCacheService; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskCacheService; +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskCacheService; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskCacheService; +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskCacheService; +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskCacheService; +import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.model.dto.TaskHeartbeatRequest; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.model.vo.TaskHeartbeatVo; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskCacheService; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TaskHeartbeatServiceTest { + + @BeforeAll + static void initializeMybatisMetadata() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + FileTaskEntity.class); + } + + @Mock private FileTaskMapper fileTaskMapper; + @Mock private BrandCrawlTaskMapper brandCrawlTaskMapper; + @Mock private ProductRiskTaskCacheService productRiskTaskCacheService; + @Mock private PublishTaskService publishTaskService; + @Mock private PriceTrackTaskCacheService priceTrackTaskCacheService; + @Mock private ShopMatchTaskCacheService shopMatchTaskCacheService; + @Mock private PatrolDeleteTaskCacheService patrolDeleteTaskCacheService; + @Mock private QueryAsinTaskCacheService queryAsinTaskCacheService; + @Mock private WithdrawTaskCacheService withdrawTaskCacheService; + @Mock private AppearancePatentTaskCacheService appearancePatentTaskCacheService; + @Mock private SimilarAsinTaskCacheService similarAsinTaskCacheService; + @Mock private DeleteBrandTaskCacheService deleteBrandTaskCacheService; + @Mock private BrandTaskProgressCacheService brandTaskProgressCacheService; + + @InjectMocks private TaskHeartbeatService service; + + @Test + @SuppressWarnings("unchecked") + void publishHeartbeatTouchesGenericTaskAndModuleProgress() { + long taskId = 20142L; + FileTaskEntity task = new FileTaskEntity(); + task.setId(taskId); + task.setModuleType(PublishTaskService.MODULE_TYPE); + task.setStatus("RUNNING"); + + TaskHeartbeatRequest request = new TaskHeartbeatRequest(); + request.setPhase("dispatching"); + request.setCurrent(20); + request.setTotal(100); + + when(fileTaskMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(task); + when(brandCrawlTaskMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + when(fileTaskMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1); + + TaskHeartbeatVo result = service.heartbeat(taskId, request); + + assertTrue(result.isAlive()); + assertEquals(PublishTaskService.MODULE_TYPE, result.getModuleType()); + verify(fileTaskMapper).update(isNull(), any(LambdaUpdateWrapper.class)); + verify(publishTaskService).touchHeartbeat(taskId, request); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerTest.java index 1f257475..d54b6e24 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerTest.java @@ -8,6 +8,7 @@ import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; @@ -25,6 +26,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -39,6 +41,7 @@ class TaskResultFileJobWorkerTest { @Mock private ShopMatchTaskService shopMatchTaskService; @Mock private PriceTrackTaskService priceTrackTaskService; @Mock private ProductRiskTaskService productRiskTaskService; + @Mock private PublishTaskService publishTaskService; @Mock private QueryAsinTaskService queryAsinTaskService; @Mock private WithdrawTaskService withdrawTaskService; @Mock private PatrolDeleteTaskService patrolDeleteTaskService; @@ -79,4 +82,34 @@ class TaskResultFileJobWorkerTest { order.verify(lock).close(); order.verify(withdrawTaskService).tryFinalizeTask(taskId, false); } + + @Test + void publishFileSuccessDelegatesAndDoesNotRunGenericPayloadCleanup() { + long jobId = 13641L; + long taskId = 20141L; + long resultId = 22929L; + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setId(jobId); + job.setTaskId(taskId); + job.setResultId(resultId); + job.setModuleType(PublishTaskService.MODULE_TYPE); + job.setScopeKey("publish:20141"); + + FileResultEntity result = new FileResultEntity(); + result.setResultFileUrl("result/publish/20141.xlsx"); + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + + when(taskFileJobService.markRunning(jobId)).thenReturn(true); + when(taskDistributedLockService.acquire( + PublishTaskService.MODULE_TYPE, + taskId, + TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock); + when(fileResultMapper.selectById(resultId)).thenReturn(result); + + worker.process(job); + + verify(publishTaskService).processResultFileJob(job); + verify(taskFileJobService).markSuccess(job, "result/publish/20141.xlsx"); + verifyNoInteractions(taskResultPayloadService); + } } diff --git a/frontend-vue/dev-5174.log b/frontend-vue/dev-5174.log new file mode 100644 index 00000000..9b50c505 --- /dev/null +++ b/frontend-vue/dev-5174.log @@ -0,0 +1,18 @@ + +> crawler-plugin-frontend-vue@0.0.1 dev +> vite --host --port 5173 --port 5174 --strictPort + + + VITE v7.3.1 ready in 956 ms + + ➜ Local: http://localhost:5174/ + ➜ Network: http://192.168.31.112:5174/ + ➜ press h + enter to show help +18:07:40 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue, /src/pages/brand/components/BrandPublishTab.vue?vue&type=style&index=0&scoped=812decd4&lang.css +18:07:40 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue, /src/pages/brand/components/BrandPublishTab.vue?vue&type=style&index=0&scoped=812decd4&lang.css +18:11:38 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue, /src/pages/brand/components/BrandPublishTab.vue?vue&type=style&index=0&scoped=812decd4&lang.css +18:19:19 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue +18:23:14 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue +18:47:36 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue +18:51:56 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue +21:15:16 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue diff --git a/frontend-vue/publish.html b/frontend-vue/publish.html new file mode 100644 index 00000000..2ba0d46e --- /dev/null +++ b/frontend-vue/publish.html @@ -0,0 +1,12 @@ + + + + + + 上架 - 数富AI + + +
+ + + diff --git a/frontend-vue/src/pages/brand/components/BrandPublishTab.vue b/frontend-vue/src/pages/brand/components/BrandPublishTab.vue new file mode 100644 index 00000000..7ff8314a --- /dev/null +++ b/frontend-vue/src/pages/brand/components/BrandPublishTab.vue @@ -0,0 +1,1032 @@ + + + + + diff --git a/frontend-vue/src/pages/brand/components/BrandTopBar.vue b/frontend-vue/src/pages/brand/components/BrandTopBar.vue index f0bcd639..536b205f 100644 --- a/frontend-vue/src/pages/brand/components/BrandTopBar.vue +++ b/frontend-vue/src/pages/brand/components/BrandTopBar.vue @@ -40,6 +40,7 @@ import DownloadProgressPanel from '@/shared/components/DownloadProgressPanel.vue type ActiveNavKey = | 'brand' + | 'publish' | 'appearance-patent' | 'similar-asin' | 'dedupe' @@ -106,6 +107,7 @@ const navGroups: ReadonlyArray = [ columnKey: 'brand_operation_tools', label: '运营工具', items: [ + { key: 'publish', label: '上架', href: '/new_web_source/publish.html' }, { key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' }, { key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' }, { key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' }, diff --git a/frontend-vue/src/publish-main.ts b/frontend-vue/src/publish-main.ts new file mode 100644 index 00000000..8890001f --- /dev/null +++ b/frontend-vue/src/publish-main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import '@/styles/main.css' +import BrandPublishTab from '@/pages/brand/components/BrandPublishTab.vue' + +createApp(BrandPublishTab).use(ElementPlus).mount('#app') diff --git a/frontend-vue/src/shared/api/java-modules.ts b/frontend-vue/src/shared/api/java-modules.ts index fcdd3c98..6f95d33c 100644 --- a/frontend-vue/src/shared/api/java-modules.ts +++ b/frontend-vue/src/shared/api/java-modules.ts @@ -3061,6 +3061,269 @@ export function putCollectDataCountryPreference(countryCodes: string[]) { ); } +// ========== 上架 ========== + +export interface PublishSourceFile { + fileKey: string; + originalFilename?: string; + relativePath?: string; +} + +export interface PublishParseRequest { + user_id: number; + files: PublishSourceFile[]; + publish_country: string; + sync_countries: string[]; +} + +export interface PublishFileItem { + fileId: number; + fileKey?: string; + sourceFilename?: string; + shopName?: string; + shopId?: string; + matchedUserId?: number; + platform?: string; + companyName?: string; + matched?: boolean; + matchStatus?: string; + matchMessage?: string; + status?: string; + error?: string; + errorMessage?: string; + totalRows?: number; + processedRows?: number; + progressCurrent?: number; + progressTotal?: number; + progressPercent?: number; + percent?: number; + progressMessage?: string; + pageSize?: number; + totalPages?: number; + pageUrl?: string; +} + +export interface PublishTaskResult { + resultId?: number; + downloadUrl?: string; + resultFilename?: string; + fileReady?: boolean; + fileJobId?: number; + fileJobStatus?: string; + fileJobRetryCount?: number; + fileJobError?: string; + errorMessage?: string; +} + +export interface PublishTaskSummary { + id: number; + taskNo?: string; + status?: string; + sourceFileCount?: number; + totalRows?: number; + successFileCount?: number; + failedFileCount?: number; + completedFileCount?: number; + errorMessage?: string; + processedRows?: number; + percent?: number; + createdAt?: string; + updatedAt?: string; + startedAt?: string; + finishedAt?: string; + downloadUrl?: string; + resultFilename?: string; +} + +export interface PublishTaskDetailVo { + task: PublishTaskSummary; + files: PublishFileItem[]; + result?: PublishTaskResult; +} + +export interface PublishParseVo { + taskId: number; + taskNo?: string; + sourceFileCount?: number; + totalRows?: number; + pageSize?: number; + files: PublishFileItem[]; + result?: PublishTaskResult; +} + +export interface PublishTaskBatchVo { + items: PublishTaskDetailVo[]; + missingTaskIds?: number[]; +} + +export interface PublishHistoryVo { + total?: number; + items: PublishTaskDetailVo[]; +} + +export interface PublishDashboardVo { + pendingCount: number; + runningCount: number; + successCount: number; + failedCount: number; + recent?: PublishTaskDetailVo[]; +} + +export interface PublishItemVo { + id?: number; + rowIndex?: number; + fileId?: number; + fileKey?: string; + sourceFilename?: string; + values?: Record; + data?: Record; +} + +export interface PublishItemsPageVo { + taskId?: number; + fileId?: number; + taskStatus?: string; + fileStatus?: string; + shopName?: string; + shopId?: string; + platform?: string; + companyName?: string; + publishCountry?: string; + syncCountries?: string[]; + page: number; + pageSize: number; + total: number; + totalPages: number; + count?: number; + items: PublishItemVo[]; +} + +export interface PublishTaskResultFileRequest { + fileId: number; + fileKey?: string; + sourceFilename?: string; + error?: string; + rows: unknown[]; +} + +export interface PublishTaskResultRequest { + user_id?: number; + files: PublishTaskResultFileRequest[]; +} + +export function parsePublish( + request: Omit | PublishParseRequest, +) { + return unwrapJavaResponse( + post, PublishParseRequest>( + `${JAVA_API_PREFIX}/publish/parse`, + { ...request, user_id: getCurrentUserId() }, + { timeout: 180000 }, + ), + ); +} + +export function activatePublishTask(taskId: number) { + return unwrapJavaResponse( + post, undefined>( + `${JAVA_API_PREFIX}/publish/tasks/${taskId}/activate`, + undefined, + ), + ); +} + +export function activatePublishFile(taskId: number, fileId: number) { + return unwrapJavaResponse( + post, undefined>( + `${JAVA_API_PREFIX}/publish/tasks/${taskId}/files/${fileId}/activate`, + undefined, + ), + ); +} + +export function getPublishItemsPage( + taskId: number, + fileId: number, + page: number = 1, + pageSize: number = 100, +) { + return unwrapJavaResponse( + get>( + `${JAVA_API_PREFIX}/publish/tasks/${taskId}/items`, + { + params: { + file_id: fileId, + page, + page_size: pageSize, + }, + }, + ), + ); +} + +export function getPublishItemsPageUrl( + taskId: number, + fileId: number, + pageSize: number = 100, + page: number = 1, +) { + let raw = `${JAVA_API_PREFIX}/publish/tasks/${taskId}/items`; + if (typeof window !== "undefined") { + raw = `${window.location.origin}${raw}`; + } + const params = new URLSearchParams({ + file_id: String(fileId), + page: String(page), + page_size: String(pageSize), + }); + return `${raw}?${params.toString()}`; +} + +export function getPublishTaskProgressBatch(taskIds: number[]) { + const normalizedTaskIds = normalizeTaskIds(taskIds); + if (!normalizedTaskIds.length) { + return Promise.resolve({ items: [], missingTaskIds: [] } as PublishTaskBatchVo); + } + return unwrapJavaResponse( + post< + JavaApiResponse, + { task_ids: number[] } + >(`${JAVA_API_PREFIX}/publish/tasks/progress/batch`, { + task_ids: normalizedTaskIds, + }), + ); +} + +export function submitPublishTaskResult( + taskId: number, + request: PublishTaskResultRequest, +) { + return unwrapJavaResponse( + post, PublishTaskResultRequest>( + `${JAVA_API_PREFIX}/publish/tasks/${taskId}/result`, + { ...request }, + ), + ); +} + +export function getPublishDashboard() { + return unwrapJavaResponse( + get>( + `${JAVA_API_PREFIX}/publish/dashboard`, + { params: { user_id: getCurrentUserId() } }, + ), + ); +} + +export function getPublishHistory() { + return unwrapJavaResponse( + get>( + `${JAVA_API_PREFIX}/publish/history`, + { params: { user_id: getCurrentUserId() } }, + ), + ); +} + export function getJavaDownloadUrl(path: string) { let raw = path.startsWith("http://") || path.startsWith("https://") diff --git a/frontend-vue/vite.config.ts b/frontend-vue/vite.config.ts index 268767f7..f3655bd7 100644 --- a/frontend-vue/vite.config.ts +++ b/frontend-vue/vite.config.ts @@ -42,6 +42,7 @@ export default defineConfig({ cssCodeSplit: true, rollupOptions: { input: { + publish: resolve(__dirname, 'publish.html'), dedupe: resolve(__dirname, 'dedupe.html'), convert: resolve(__dirname, 'convert.html'), split: resolve(__dirname, 'split.html'),