上架需求增加

This commit is contained in:
supernijia
2026-07-25 09:58:29 +08:00
parent f1b7a7aae8
commit 8ab36b1aaf
38 changed files with 4326 additions and 10 deletions
@@ -17,17 +17,19 @@ public class OpenApiConfig {
.info(new Info()
.title("AI Image Backend API")
.description("""
文件处理后端接口文档
数富 AI Java 后端接口文档,包含文件处理、任务调度、店铺匹配、紫鸟接入和管理后台接口
当前迁移范围:仅迁移数据去重(dedupe)、格式转换(convert)、数据拆分(split)三块处理逻辑到 Java;
Python 桌面端壳、pywebview 调用方式、前端页面交互保持不变。
任务型模块通常按以下链路联调:
1. 前端或 Python 桌面端调用 /api/files/upload 上传源文件;
2. 调用模块的解析或创建任务接口,由 Java 解析数据并持久化任务;
3. 前端激活任务并派发给 Python,Python 按分页接口拉取任务明细;
4. 执行期间调用 /api/tasks/{taskId}/heartbeat 上报心跳和处理进度;
5. Python 将处理结果回传模块接口,Java 异步生成 XLSX/ZIP 并上传 OSS
6. 任务详情或历史接口返回公开 OSS 下载直链。
建议联调顺序:
1. 先启动 Java 后端;
2. 在 Python 桌面端中选择文件并执行 dedupe/convert/split
3. Python 壳会先调用 /api/files/upload 上传临时文件;
4. 再调用对应的 /api/dedupe/run、/api/convert/run、/api/split/run
5. Java 生成结果文件后,Python 壳再通过下载接口取回并保存到用户本地目录。
跨语言请求中的 user_id、task_ids 等字段使用 snake_case;响应字段默认使用 camelCase。
上架模块的 Python 分页、结果回传和任务级进度接口优先使用 taskId/fileId 定位,user_id 仅作为可选的旧客户端归属校验;创建批次、总览和历史接口仍按用户维度调用。
本地启动说明:
- 默认配置读取 application.yml
@@ -41,4 +43,5 @@ public class OpenApiConfig {
.description("Knife4j 文档")
.url("/doc.html"));
}
}
@@ -40,7 +40,7 @@ public class FileUploadController {
该接口不是给浏览器页面直接使用的最终业务接口,而是给 Python 桌面端壳做文件中转:
- 桌面端先选本地文件;
- 再调用本接口上传到 Java 临时目录;
- 然后把 fileKey 传给 dedupe/convert/split 执行接口
- 然后把 fileKey 传给对应任务接口;上架模块继续调用 /api/publish/parse,其他模块按各自创建接口处理
- 处理完成后,桌面端再调用下载接口把结果文件保存回用户本地目录。
""")
@ApiResponses({
@@ -51,8 +51,11 @@ public class FileUploadController {
public ApiResponse<UploadFileVo> upload(
@Parameter(name = "file", description = "待上传的 Excel 或业务源文件", required = true, in = ParameterIn.QUERY)
MultipartFile file,
@Parameter(description = "文件夹上传时的相对路径,单文件上传可不传", example = "英国/郭亚庆.xlsx")
@RequestParam(required = false) String relativePath,
@Parameter(description = "是否同时上传到公开 OSS;上架源文件通常保持 false", example = "false")
@RequestParam(defaultValue = "false") boolean uploadToOss,
@Parameter(description = "上传到 OSS 时使用的模块类型,例如 PUBLISH", example = "PUBLISH")
@RequestParam(defaultValue = "COMMON") String moduleType) throws Exception {
UploadFileVo vo = localFileStorageService.saveTempFile(file, relativePath);
if (uploadToOss) {
@@ -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 和 filesuser_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);
}
}
@@ -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> {
}
@@ -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);
}
@@ -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<>();
}
@@ -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 = "文件级失败原因。非空时文件标记为 FAILEDrows/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<>();
}
@@ -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;
}
@@ -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;
}
@@ -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<>();
}
@@ -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<>();
}
@@ -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;
}
@@ -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;
}
@@ -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<>();
}
@@ -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;
}
@@ -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<>();
}
@@ -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<>();
}
@@ -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<>();
}
@@ -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;
}
@@ -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<>();
}
@@ -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;
}
@@ -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;
}
@@ -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) {
}
}
@@ -10,6 +10,7 @@ import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandTaskCacheService
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskCacheService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskCacheService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskCacheService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskCacheService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskCacheService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskCacheService;
@@ -48,6 +49,7 @@ public class TaskHeartbeatService {
private final FileTaskMapper fileTaskMapper;
private final BrandCrawlTaskMapper brandCrawlTaskMapper;
private final ProductRiskTaskCacheService productRiskTaskCacheService;
private final PublishTaskService publishTaskService;
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
private final ShopMatchTaskCacheService shopMatchTaskCacheService;
private final PatrolDeleteTaskCacheService patrolDeleteTaskCacheService;
@@ -159,6 +161,9 @@ public class TaskHeartbeatService {
case MODULE_PRODUCT_RISK -> {
productRiskTaskCacheService.touchTaskHeartbeat(taskId);
}
case PublishTaskService.MODULE_TYPE -> {
publishTaskService.touchHeartbeat(taskId, request);
}
case MODULE_PRICE_TRACK -> {
priceTrackTaskCacheService.touchTaskHeartbeat(taskId);
}
@@ -9,6 +9,7 @@ import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
@@ -41,6 +42,7 @@ public class TaskResultFileJobWorker {
private final ShopMatchTaskService shopMatchTaskService;
private final PriceTrackTaskService priceTrackTaskService;
private final ProductRiskTaskService productRiskTaskService;
private final PublishTaskService publishTaskService;
private final QueryAsinTaskService queryAsinTaskService;
private final WithdrawTaskService withdrawTaskService;
private final PatrolDeleteTaskService patrolDeleteTaskService;
@@ -261,6 +263,10 @@ public class TaskResultFileJobWorker {
productRiskTaskService.processResultFileJob(job);
return true;
}
if (PublishTaskService.MODULE_TYPE.equals(moduleType)) {
publishTaskService.processResultFileJob(job);
return true;
}
if ("QUERY_ASIN".equals(moduleType)) {
queryAsinTaskService.processResultFileJob(job);
return true;
@@ -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';
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -8,6 +8,7 @@ import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
@@ -25,6 +26,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -39,6 +41,7 @@ class TaskResultFileJobWorkerTest {
@Mock private ShopMatchTaskService shopMatchTaskService;
@Mock private PriceTrackTaskService priceTrackTaskService;
@Mock private ProductRiskTaskService productRiskTaskService;
@Mock private PublishTaskService publishTaskService;
@Mock private QueryAsinTaskService queryAsinTaskService;
@Mock private WithdrawTaskService withdrawTaskService;
@Mock private PatrolDeleteTaskService patrolDeleteTaskService;
@@ -79,4 +82,34 @@ class TaskResultFileJobWorkerTest {
order.verify(lock).close();
order.verify(withdrawTaskService).tryFinalizeTask(taskId, false);
}
@Test
void publishFileSuccessDelegatesAndDoesNotRunGenericPayloadCleanup() {
long jobId = 13641L;
long taskId = 20141L;
long resultId = 22929L;
TaskFileJobEntity job = new TaskFileJobEntity();
job.setId(jobId);
job.setTaskId(taskId);
job.setResultId(resultId);
job.setModuleType(PublishTaskService.MODULE_TYPE);
job.setScopeKey("publish:20141");
FileResultEntity result = new FileResultEntity();
result.setResultFileUrl("result/publish/20141.xlsx");
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(taskFileJobService.markRunning(jobId)).thenReturn(true);
when(taskDistributedLockService.acquire(
PublishTaskService.MODULE_TYPE,
taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
when(fileResultMapper.selectById(resultId)).thenReturn(result);
worker.process(job);
verify(publishTaskService).processResultFileJob(job);
verify(taskFileJobService).markSuccess(job, "result/publish/20141.xlsx");
verifyNoInteractions(taskResultPayloadService);
}
}
+18
View File
@@ -0,0 +1,18 @@
> crawler-plugin-frontend-vue@0.0.1 dev
> vite --host --port 5173 --port 5174 --strictPort
VITE v7.3.1 ready in 956 ms
➜ Local: http://localhost:5174/
➜ Network: http://192.168.31.112:5174/
 ➜ press h + enter to show help
18:07:40 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue, /src/pages/brand/components/BrandPublishTab.vue?vue&type=style&index=0&scoped=812decd4&lang.css
18:07:40 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue, /src/pages/brand/components/BrandPublishTab.vue?vue&type=style&index=0&scoped=812decd4&lang.css
18:11:38 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue, /src/pages/brand/components/BrandPublishTab.vue?vue&type=style&index=0&scoped=812decd4&lang.css
18:19:19 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue
18:23:14 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue
18:47:36 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue
18:51:56 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue
21:15:16 [vite] (client) hmr update /src/pages/brand/components/BrandPublishTab.vue
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>上架 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/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 =
| 'brand'
| 'publish'
| 'appearance-patent'
| 'similar-asin'
| 'dedupe'
@@ -106,6 +107,7 @@ const navGroups: ReadonlyArray<NavGroup> = [
columnKey: 'brand_operation_tools',
label: '运营工具',
items: [
{ key: 'publish', label: '上架', href: '/new_web_source/publish.html' },
{ key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' },
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' },
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' },
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandPublishTab from '@/pages/brand/components/BrandPublishTab.vue'
createApp(BrandPublishTab).use(ElementPlus).mount('#app')
+263
View File
@@ -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) {
let raw =
path.startsWith("http://") || path.startsWith("https://")
+1
View File
@@ -42,6 +42,7 @@ export default defineConfig({
cssCodeSplit: true,
rollupOptions: {
input: {
publish: resolve(__dirname, 'publish.html'),
dedupe: resolve(__dirname, 'dedupe.html'),
convert: resolve(__dirname, 'convert.html'),
split: resolve(__dirname, 'split.html'),