上架需求增加
This commit is contained in:
@@ -17,17 +17,19 @@ public class OpenApiConfig {
|
|||||||
.info(new Info()
|
.info(new Info()
|
||||||
.title("AI Image Backend API")
|
.title("AI Image Backend API")
|
||||||
.description("""
|
.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 下载直链。
|
||||||
|
|
||||||
建议联调顺序:
|
跨语言请求中的 user_id、task_ids 等字段使用 snake_case;响应字段默认使用 camelCase。
|
||||||
1. 先启动 Java 后端;
|
|
||||||
2. 在 Python 桌面端中选择文件并执行 dedupe/convert/split;
|
上架模块的 Python 分页、结果回传和任务级进度接口优先使用 taskId/fileId 定位,user_id 仅作为可选的旧客户端归属校验;创建批次、总览和历史接口仍按用户维度调用。
|
||||||
3. Python 壳会先调用 /api/files/upload 上传临时文件;
|
|
||||||
4. 再调用对应的 /api/dedupe/run、/api/convert/run、/api/split/run;
|
|
||||||
5. Java 生成结果文件后,Python 壳再通过下载接口取回并保存到用户本地目录。
|
|
||||||
|
|
||||||
本地启动说明:
|
本地启动说明:
|
||||||
- 默认配置读取 application.yml;
|
- 默认配置读取 application.yml;
|
||||||
@@ -41,4 +43,5 @@ public class OpenApiConfig {
|
|||||||
.description("Knife4j 文档")
|
.description("Knife4j 文档")
|
||||||
.url("/doc.html"));
|
.url("/doc.html"));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -40,7 +40,7 @@ public class FileUploadController {
|
|||||||
该接口不是给浏览器页面直接使用的最终业务接口,而是给 Python 桌面端壳做文件中转:
|
该接口不是给浏览器页面直接使用的最终业务接口,而是给 Python 桌面端壳做文件中转:
|
||||||
- 桌面端先选本地文件;
|
- 桌面端先选本地文件;
|
||||||
- 再调用本接口上传到 Java 临时目录;
|
- 再调用本接口上传到 Java 临时目录;
|
||||||
- 然后把 fileKey 传给 dedupe/convert/split 执行接口;
|
- 然后把 fileKey 传给对应任务接口;上架模块继续调用 /api/publish/parse,其他模块按各自创建接口处理;
|
||||||
- 处理完成后,桌面端再调用下载接口把结果文件保存回用户本地目录。
|
- 处理完成后,桌面端再调用下载接口把结果文件保存回用户本地目录。
|
||||||
""")
|
""")
|
||||||
@ApiResponses({
|
@ApiResponses({
|
||||||
@@ -51,8 +51,11 @@ public class FileUploadController {
|
|||||||
public ApiResponse<UploadFileVo> upload(
|
public ApiResponse<UploadFileVo> upload(
|
||||||
@Parameter(name = "file", description = "待上传的 Excel 或业务源文件", required = true, in = ParameterIn.QUERY)
|
@Parameter(name = "file", description = "待上传的 Excel 或业务源文件", required = true, in = ParameterIn.QUERY)
|
||||||
MultipartFile file,
|
MultipartFile file,
|
||||||
|
@Parameter(description = "文件夹上传时的相对路径,单文件上传可不传", example = "英国/郭亚庆.xlsx")
|
||||||
@RequestParam(required = false) String relativePath,
|
@RequestParam(required = false) String relativePath,
|
||||||
|
@Parameter(description = "是否同时上传到公开 OSS;上架源文件通常保持 false", example = "false")
|
||||||
@RequestParam(defaultValue = "false") boolean uploadToOss,
|
@RequestParam(defaultValue = "false") boolean uploadToOss,
|
||||||
|
@Parameter(description = "上传到 OSS 时使用的模块类型,例如 PUBLISH", example = "PUBLISH")
|
||||||
@RequestParam(defaultValue = "COMMON") String moduleType) throws Exception {
|
@RequestParam(defaultValue = "COMMON") String moduleType) throws Exception {
|
||||||
UploadFileVo vo = localFileStorageService.saveTempFile(file, relativePath);
|
UploadFileVo vo = localFileStorageService.saveTempFile(file, relativePath);
|
||||||
if (uploadToOss) {
|
if (uploadToOss) {
|
||||||
|
|||||||
+158
@@ -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<PublishParseVo> 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<Void> 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<Void> 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<PublishItemsPageVo> 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<PublishTaskDetailVo> 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<PublishTaskBatchVo> 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<Void> 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<PublishDashboardVo> 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<PublishHistoryVo> 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<Void> 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<Void> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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<PublishFileEntity> {
|
||||||
|
}
|
||||||
+28
@@ -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<PublishItemEntity> {
|
||||||
|
|
||||||
|
@Insert("""
|
||||||
|
<script>
|
||||||
|
INSERT INTO biz_publish_item
|
||||||
|
(task_id, file_id, row_index, source_id, asin, country, brand, price_value,
|
||||||
|
status_value, sync_status, sync_countries, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
<foreach collection="rows" item="row" separator=",">
|
||||||
|
(#{row.taskId}, #{row.fileId}, #{row.rowIndex}, #{row.sourceId}, #{row.asin},
|
||||||
|
#{row.country}, #{row.brand}, #{row.priceValue}, #{row.statusValue},
|
||||||
|
#{row.syncStatus}, #{row.syncCountries}, #{row.createdAt}, #{row.updatedAt})
|
||||||
|
</foreach>
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
int insertBatch(@Param("rows") List<PublishItemEntity> rows);
|
||||||
|
}
|
||||||
+38
@@ -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<PublishSourceFileDto> 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<String> syncCountries = new ArrayList<>();
|
||||||
|
}
|
||||||
+39
@@ -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<PublishRowDto> rows = new ArrayList<>();
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@Schema(description = "按国家名称或代码分组的完整结果,可替代 rows。仅当 rows 为空时读取;缺少国家字段的行会使用当前 Map key;分组内不能包含 null 行或八列全空白对象")
|
||||||
|
private Map<String, List<PublishRowDto>> countries = new LinkedHashMap<>();
|
||||||
|
}
|
||||||
+46
@@ -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;
|
||||||
|
}
|
||||||
+24
@@ -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;
|
||||||
|
}
|
||||||
+23
@@ -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<PublishResultFileDto> files = new ArrayList<>();
|
||||||
|
}
|
||||||
+22
@@ -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<Long> taskIds = new ArrayList<>();
|
||||||
|
}
|
||||||
+34
@@ -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;
|
||||||
|
}
|
||||||
+28
@@ -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;
|
||||||
|
}
|
||||||
+22
@@ -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<PublishTaskDetailVo> recent = new ArrayList<>();
|
||||||
|
}
|
||||||
+53
@@ -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;
|
||||||
|
}
|
||||||
+16
@@ -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<PublishTaskDetailVo> items = new ArrayList<>();
|
||||||
|
}
|
||||||
+45
@@ -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<String> 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<PublishRowDto> items = new ArrayList<>();
|
||||||
|
}
|
||||||
+24
@@ -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<PublishFileVo> files = new ArrayList<>();
|
||||||
|
}
|
||||||
+27
@@ -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;
|
||||||
|
}
|
||||||
+16
@@ -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<PublishTaskDetailVo> items = new ArrayList<>();
|
||||||
|
@Schema(description = "未找到或不属于当前用户的任务 ID")
|
||||||
|
private List<Long> missingTaskIds = new ArrayList<>();
|
||||||
|
}
|
||||||
+18
@@ -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<PublishFileVo> files = new ArrayList<>();
|
||||||
|
@Schema(description = "结果文件及异步组装状态;任务刚创建时也会返回未就绪的结果记录")
|
||||||
|
private PublishResultVo result;
|
||||||
|
}
|
||||||
+37
@@ -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;
|
||||||
|
}
|
||||||
+1219
File diff suppressed because it is too large
Load Diff
+351
@@ -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<String> 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<PublishRowDto> rows = new ArrayList<>();
|
||||||
|
Set<Integer> validatedSheets = new LinkedHashSet<>();
|
||||||
|
try {
|
||||||
|
ExcelStreamReader.readAllSheets(inputFile, new ExcelStreamReader.SheetRowHandler() {
|
||||||
|
@Override
|
||||||
|
public void onHeader(String sheetName, Integer sheetNo, Map<Integer, String> 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<Integer, String> headerMap,
|
||||||
|
Map<Integer, String> 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<String> 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<PublishRowDto> 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<String, List<PublishRowDto>> rowsByCountry = groupByCountry(rows);
|
||||||
|
Set<String> usedSheetNames = new LinkedHashSet<>();
|
||||||
|
for (Map.Entry<String, List<PublishRowDto>> 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<WorkbookInput> successfulFiles) {
|
||||||
|
if (successfulFiles == null || successfulFiles.isEmpty()) {
|
||||||
|
throw new BusinessException("没有可生成的成功文件");
|
||||||
|
}
|
||||||
|
workDirectory.mkdirs();
|
||||||
|
List<File> workbooks = new ArrayList<>();
|
||||||
|
Set<String> 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<Integer, String> 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<Integer, String> 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<String> 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<String, List<PublishRowDto>> groupByCountry(List<PublishRowDto> rows) {
|
||||||
|
Map<String, List<PublishRowDto>> 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<PublishRowDto> 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<String> 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<String> 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<PublishRowDto> rows) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WorkbookInput(String sourceFilename, String shopName, List<PublishRowDto> rows) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PackagedResult(File file, String filename, String contentType) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -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.patroldelete.service.PatrolDeleteTaskCacheService;
|
||||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskCacheService;
|
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskCacheService;
|
||||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskCacheService;
|
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.queryasin.service.QueryAsinTaskCacheService;
|
||||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskCacheService;
|
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskCacheService;
|
||||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskCacheService;
|
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskCacheService;
|
||||||
@@ -48,6 +49,7 @@ public class TaskHeartbeatService {
|
|||||||
private final FileTaskMapper fileTaskMapper;
|
private final FileTaskMapper fileTaskMapper;
|
||||||
private final BrandCrawlTaskMapper brandCrawlTaskMapper;
|
private final BrandCrawlTaskMapper brandCrawlTaskMapper;
|
||||||
private final ProductRiskTaskCacheService productRiskTaskCacheService;
|
private final ProductRiskTaskCacheService productRiskTaskCacheService;
|
||||||
|
private final PublishTaskService publishTaskService;
|
||||||
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
|
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
|
||||||
private final ShopMatchTaskCacheService shopMatchTaskCacheService;
|
private final ShopMatchTaskCacheService shopMatchTaskCacheService;
|
||||||
private final PatrolDeleteTaskCacheService patrolDeleteTaskCacheService;
|
private final PatrolDeleteTaskCacheService patrolDeleteTaskCacheService;
|
||||||
@@ -159,6 +161,9 @@ public class TaskHeartbeatService {
|
|||||||
case MODULE_PRODUCT_RISK -> {
|
case MODULE_PRODUCT_RISK -> {
|
||||||
productRiskTaskCacheService.touchTaskHeartbeat(taskId);
|
productRiskTaskCacheService.touchTaskHeartbeat(taskId);
|
||||||
}
|
}
|
||||||
|
case PublishTaskService.MODULE_TYPE -> {
|
||||||
|
publishTaskService.touchHeartbeat(taskId, request);
|
||||||
|
}
|
||||||
case MODULE_PRICE_TRACK -> {
|
case MODULE_PRICE_TRACK -> {
|
||||||
priceTrackTaskCacheService.touchTaskHeartbeat(taskId);
|
priceTrackTaskCacheService.touchTaskHeartbeat(taskId);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -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.patroldelete.service.PatrolDeleteTaskService;
|
||||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
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.queryasin.service.QueryAsinTaskService;
|
||||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||||
@@ -41,6 +42,7 @@ public class TaskResultFileJobWorker {
|
|||||||
private final ShopMatchTaskService shopMatchTaskService;
|
private final ShopMatchTaskService shopMatchTaskService;
|
||||||
private final PriceTrackTaskService priceTrackTaskService;
|
private final PriceTrackTaskService priceTrackTaskService;
|
||||||
private final ProductRiskTaskService productRiskTaskService;
|
private final ProductRiskTaskService productRiskTaskService;
|
||||||
|
private final PublishTaskService publishTaskService;
|
||||||
private final QueryAsinTaskService queryAsinTaskService;
|
private final QueryAsinTaskService queryAsinTaskService;
|
||||||
private final WithdrawTaskService withdrawTaskService;
|
private final WithdrawTaskService withdrawTaskService;
|
||||||
private final PatrolDeleteTaskService patrolDeleteTaskService;
|
private final PatrolDeleteTaskService patrolDeleteTaskService;
|
||||||
@@ -261,6 +263,10 @@ public class TaskResultFileJobWorker {
|
|||||||
productRiskTaskService.processResultFileJob(job);
|
productRiskTaskService.processResultFileJob(job);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (PublishTaskService.MODULE_TYPE.equals(moduleType)) {
|
||||||
|
publishTaskService.processResultFileJob(job);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if ("QUERY_ASIN".equals(moduleType)) {
|
if ("QUERY_ASIN".equals(moduleType)) {
|
||||||
queryAsinTaskService.processResultFileJob(job);
|
queryAsinTaskService.processResultFileJob(job);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -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';
|
||||||
+354
@@ -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<TransactionStatus> 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<PublishRowDto> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
@@ -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<String> 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<PublishWorkbookService.WorkbookInput> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -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.patroldelete.service.PatrolDeleteTaskService;
|
||||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
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.queryasin.service.QueryAsinTaskService;
|
||||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
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.inOrder;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -39,6 +41,7 @@ class TaskResultFileJobWorkerTest {
|
|||||||
@Mock private ShopMatchTaskService shopMatchTaskService;
|
@Mock private ShopMatchTaskService shopMatchTaskService;
|
||||||
@Mock private PriceTrackTaskService priceTrackTaskService;
|
@Mock private PriceTrackTaskService priceTrackTaskService;
|
||||||
@Mock private ProductRiskTaskService productRiskTaskService;
|
@Mock private ProductRiskTaskService productRiskTaskService;
|
||||||
|
@Mock private PublishTaskService publishTaskService;
|
||||||
@Mock private QueryAsinTaskService queryAsinTaskService;
|
@Mock private QueryAsinTaskService queryAsinTaskService;
|
||||||
@Mock private WithdrawTaskService withdrawTaskService;
|
@Mock private WithdrawTaskService withdrawTaskService;
|
||||||
@Mock private PatrolDeleteTaskService patrolDeleteTaskService;
|
@Mock private PatrolDeleteTaskService patrolDeleteTaskService;
|
||||||
@@ -79,4 +82,34 @@ class TaskResultFileJobWorkerTest {
|
|||||||
order.verify(lock).close();
|
order.verify(lock).close();
|
||||||
order.verify(withdrawTaskService).tryFinalizeTask(taskId, false);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
> crawler-plugin-frontend-vue@0.0.1 dev
|
||||||
|
> vite --host --port 5173 --port 5174 --strictPort
|
||||||
|
|
||||||
|
|
||||||
|
[32m[1mVITE[22m v7.3.1[39m [2mready in [0m[1m956[22m[2m[0m ms[22m
|
||||||
|
|
||||||
|
[32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m5174[22m/[39m
|
||||||
|
[32m➜[39m [1mNetwork[22m: [36mhttp://192.168.31.112:[1m5174[22m/[39m
|
||||||
|
[2m[32m ➜[39m[22m[2m press [22m[1mh + enter[22m[2m to show help[22m
|
||||||
|
[2m18:07:40[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/brand/components/BrandPublishTab.vue, /src/pages/brand/components/BrandPublishTab.vue?vue&type=style&index=0&scoped=812decd4&lang.css[22m
|
||||||
|
[2m18:07:40[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/brand/components/BrandPublishTab.vue, /src/pages/brand/components/BrandPublishTab.vue?vue&type=style&index=0&scoped=812decd4&lang.css[22m
|
||||||
|
[2m18:11:38[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/brand/components/BrandPublishTab.vue, /src/pages/brand/components/BrandPublishTab.vue?vue&type=style&index=0&scoped=812decd4&lang.css[22m
|
||||||
|
[2m18:19:19[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/brand/components/BrandPublishTab.vue[22m
|
||||||
|
[2m18:23:14[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/brand/components/BrandPublishTab.vue[22m
|
||||||
|
[2m18:47:36[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/brand/components/BrandPublishTab.vue[22m
|
||||||
|
[2m18:51:56[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/brand/components/BrandPublishTab.vue[22m
|
||||||
|
[2m21:15:16[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/pages/brand/components/BrandPublishTab.vue[22m
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>上架 - 数富AI</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/publish-main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ import DownloadProgressPanel from '@/shared/components/DownloadProgressPanel.vue
|
|||||||
|
|
||||||
type ActiveNavKey =
|
type ActiveNavKey =
|
||||||
| 'brand'
|
| 'brand'
|
||||||
|
| 'publish'
|
||||||
| 'appearance-patent'
|
| 'appearance-patent'
|
||||||
| 'similar-asin'
|
| 'similar-asin'
|
||||||
| 'dedupe'
|
| 'dedupe'
|
||||||
@@ -106,6 +107,7 @@ const navGroups: ReadonlyArray<NavGroup> = [
|
|||||||
columnKey: 'brand_operation_tools',
|
columnKey: 'brand_operation_tools',
|
||||||
label: '运营工具',
|
label: '运营工具',
|
||||||
items: [
|
items: [
|
||||||
|
{ key: 'publish', label: '上架', href: '/new_web_source/publish.html' },
|
||||||
{ key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.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: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' },
|
||||||
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' },
|
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' },
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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<string, unknown>;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "user_id"> | PublishParseRequest,
|
||||||
|
) {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
post<JavaApiResponse<PublishParseVo>, PublishParseRequest>(
|
||||||
|
`${JAVA_API_PREFIX}/publish/parse`,
|
||||||
|
{ ...request, user_id: getCurrentUserId() },
|
||||||
|
{ timeout: 180000 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activatePublishTask(taskId: number) {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
post<JavaApiResponse<null>, undefined>(
|
||||||
|
`${JAVA_API_PREFIX}/publish/tasks/${taskId}/activate`,
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activatePublishFile(taskId: number, fileId: number) {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
post<JavaApiResponse<null>, 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<JavaApiResponse<PublishItemsPageVo>>(
|
||||||
|
`${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<PublishTaskBatchVo>,
|
||||||
|
{ task_ids: number[] }
|
||||||
|
>(`${JAVA_API_PREFIX}/publish/tasks/progress/batch`, {
|
||||||
|
task_ids: normalizedTaskIds,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitPublishTaskResult(
|
||||||
|
taskId: number,
|
||||||
|
request: PublishTaskResultRequest,
|
||||||
|
) {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
post<JavaApiResponse<null>, PublishTaskResultRequest>(
|
||||||
|
`${JAVA_API_PREFIX}/publish/tasks/${taskId}/result`,
|
||||||
|
{ ...request },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublishDashboard() {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
get<JavaApiResponse<PublishDashboardVo>>(
|
||||||
|
`${JAVA_API_PREFIX}/publish/dashboard`,
|
||||||
|
{ params: { user_id: getCurrentUserId() } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublishHistory() {
|
||||||
|
return unwrapJavaResponse(
|
||||||
|
get<JavaApiResponse<PublishHistoryVo>>(
|
||||||
|
`${JAVA_API_PREFIX}/publish/history`,
|
||||||
|
{ params: { user_id: getCurrentUserId() } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getJavaDownloadUrl(path: string) {
|
export function getJavaDownloadUrl(path: string) {
|
||||||
let raw =
|
let raw =
|
||||||
path.startsWith("http://") || path.startsWith("https://")
|
path.startsWith("http://") || path.startsWith("https://")
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export default defineConfig({
|
|||||||
cssCodeSplit: true,
|
cssCodeSplit: true,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
|
publish: resolve(__dirname, 'publish.html'),
|
||||||
dedupe: resolve(__dirname, 'dedupe.html'),
|
dedupe: resolve(__dirname, 'dedupe.html'),
|
||||||
convert: resolve(__dirname, 'convert.html'),
|
convert: resolve(__dirname, 'convert.html'),
|
||||||
split: resolve(__dirname, 'split.html'),
|
split: resolve(__dirname, 'split.html'),
|
||||||
|
|||||||
Reference in New Issue
Block a user