上架需求增加
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
+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.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);
|
||||
}
|
||||
|
||||
+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.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;
|
||||
|
||||
Reference in New Issue
Block a user