完成查询asin开发
This commit is contained in:
+374
@@ -0,0 +1,374 @@
|
||||
package com.nanri.aiimage.modules.queryasin.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCreateTaskRequest;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinSubmitResultRequest;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinTaskBatchRequest;
|
||||
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinCreateTaskVo;
|
||||
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinHistoryVo;
|
||||
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinResolveService;
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
|
||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
|
||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.ExampleObject;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
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;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/query-asin")
|
||||
@Tag(
|
||||
name = "查询ASIN",
|
||||
description = "查询ASIN模块:管理备选店铺、批量匹配紫鸟店铺、创建任务、接收 Python 分片结果、自动收尾生成 Excel 并提供下载。涉及查询和删除的接口需要携带 user_id。")
|
||||
public class QueryAsinTaskController {
|
||||
|
||||
private final QueryAsinResolveService queryAsinResolveService;
|
||||
private final QueryAsinTaskService queryAsinTaskService;
|
||||
|
||||
@GetMapping("/candidates")
|
||||
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在查询ASIN模块中已保存的备选店铺。")
|
||||
public ApiResponse<List<ProductRiskCandidateVo>> listCandidates(
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(queryAsinResolveService.listCandidates(userId));
|
||||
}
|
||||
|
||||
@PostMapping("/candidates")
|
||||
@Operation(summary = "新增备选店铺", description = "向查询ASIN备选区新增一条店铺记录,供后续匹配和创建任务使用。")
|
||||
public ApiResponse<ProductRiskCandidateVo> addCandidate(
|
||||
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||
description = "新增备选店铺请求,需传入用户 ID 和店铺名。",
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = "application/json",
|
||||
schema = @Schema(implementation = ProductRiskCandidateAddRequest.class),
|
||||
examples = @ExampleObject(
|
||||
name = "新增店铺示例",
|
||||
value = """
|
||||
{
|
||||
"user_id": 1,
|
||||
"shop_name": "郭亚芳"
|
||||
}
|
||||
""")))
|
||||
@Valid @RequestBody ProductRiskCandidateAddRequest request) {
|
||||
return ApiResponse.success(queryAsinResolveService.addCandidate(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/candidates/{id}")
|
||||
@Operation(summary = "删除备选店铺", description = "删除当前用户名下的一条备选店铺记录。")
|
||||
public ApiResponse<Void> deleteCandidate(
|
||||
@Parameter(description = "备选店铺记录主键", example = "10")
|
||||
@PathVariable Long id,
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
queryAsinResolveService.deleteCandidate(userId, id);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/match-shops")
|
||||
@Operation(summary = "批量匹配店铺", description = "根据店铺名批量匹配紫鸟店铺索引,返回是否命中、店铺 ID、平台、公司和匹配状态。")
|
||||
public ApiResponse<ProductRiskMatchShopsVo> matchShops(
|
||||
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||
description = "批量匹配请求,传入 user_id 和待匹配的店铺名称列表。",
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = "application/json",
|
||||
schema = @Schema(implementation = ProductRiskMatchShopsRequest.class),
|
||||
examples = @ExampleObject(
|
||||
name = "匹配店铺示例",
|
||||
value = """
|
||||
{
|
||||
"user_id": 1,
|
||||
"shop_names": [
|
||||
"郭亚芳",
|
||||
"示例店铺A"
|
||||
]
|
||||
}
|
||||
""")))
|
||||
@Valid @RequestBody ProductRiskMatchShopsRequest request) {
|
||||
return ApiResponse.success(queryAsinResolveService.matchShops(request));
|
||||
}
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
@Operation(summary = "查询统计看板", description = "返回备选店铺数、已处理任务数、成功任务数和失败任务数。")
|
||||
public ApiResponse<ProductRiskDashboardVo> dashboard(
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(queryAsinTaskService.dashboard(userId));
|
||||
}
|
||||
|
||||
@GetMapping("/history")
|
||||
@Operation(summary = "查询任务记录", description = "返回当前用户在查询ASIN模块中的当前任务和历史任务记录。")
|
||||
public ApiResponse<QueryAsinHistoryVo> history(
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(queryAsinTaskService.listHistory(userId));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/progress/batch")
|
||||
@Operation(summary = "批量查询查询ASIN任务进度", description = "仅返回任务状态和店铺结果摘要,用于前端轮询降载。")
|
||||
public ApiResponse<QueryAsinTaskBatchVo> taskProgressBatch(@Valid @RequestBody QueryAsinTaskBatchRequest request) {
|
||||
return ApiResponse.success(queryAsinTaskService.getTaskProgressBatch(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks")
|
||||
@Operation(summary = "创建查询ASIN任务", description = "根据已匹配的店铺创建任务和占位结果记录,后续由 Python 端处理并回传结果。")
|
||||
public ApiResponse<QueryAsinCreateTaskVo> createTask(
|
||||
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||
description = "创建任务请求。items 中每一项代表一个待处理店铺,需携带默认模板结构。",
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = "application/json",
|
||||
schema = @Schema(implementation = QueryAsinCreateTaskRequest.class),
|
||||
examples = @ExampleObject(
|
||||
name = "创建任务示例",
|
||||
value = """
|
||||
{
|
||||
"user_id": 1,
|
||||
"items": [
|
||||
{
|
||||
"shopName": "郭亚芳",
|
||||
"matched": true,
|
||||
"shopId": "27730548558377",
|
||||
"platform": "亚马逊",
|
||||
"companyName": "示例公司",
|
||||
"matchStatus": "MATCHED",
|
||||
"matchMessage": "索引已命中",
|
||||
"countrySections": [
|
||||
{
|
||||
"country": "德国",
|
||||
"rows": [
|
||||
{
|
||||
"status": "全部",
|
||||
"quantity": "",
|
||||
"deleteQuantity": "",
|
||||
"processStatus": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cartRatios": [
|
||||
{
|
||||
"country": "德国",
|
||||
"ratio": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""")))
|
||||
@Valid @RequestBody QueryAsinCreateTaskRequest request) {
|
||||
return ApiResponse.success(queryAsinTaskService.createTask(request));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/result")
|
||||
@Operation(
|
||||
summary = "提交查询ASIN结果",
|
||||
description = "供 Python 端回传处理结果。支持按店铺分片多次提交,服务端会合并分片、按店铺完成状态自动收尾,并在任务结束后生成 Excel。")
|
||||
public ApiResponse<Void> submitResult(
|
||||
@Parameter(description = "任务主键,必须是运行中的任务", example = "3089")
|
||||
@PathVariable Long taskId,
|
||||
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||
description = "查询ASIN结果回传请求。shops 表示本次提交的店铺结果分片列表。shopDone=true 表示该店铺已全部提交完成。",
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = "application/json",
|
||||
schema = @Schema(implementation = QueryAsinSubmitResultRequest.class),
|
||||
examples = {
|
||||
@ExampleObject(
|
||||
name = "成功分片示例",
|
||||
summary = "一个店铺分片回传,包含状态数据和购物车比例数据",
|
||||
value = """
|
||||
{
|
||||
"shops": [
|
||||
{
|
||||
"shopName": "郭亚芳",
|
||||
"submissionId": "query-asin:3089:郭亚芳:1711111111111",
|
||||
"chunkIndex": 1,
|
||||
"chunkTotal": 2,
|
||||
"shopDone": false,
|
||||
"countrySections": [
|
||||
{
|
||||
"country": "德国",
|
||||
"rows": [
|
||||
{
|
||||
"status": "正常",
|
||||
"quantity": "12",
|
||||
"deleteQuantity": "3",
|
||||
"processStatus": "处理中"
|
||||
},
|
||||
{
|
||||
"status": "下架",
|
||||
"quantity": "2",
|
||||
"deleteQuantity": "2",
|
||||
"processStatus": "已完成"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"country": "英国",
|
||||
"rows": [
|
||||
{
|
||||
"status": "正常",
|
||||
"quantity": "5",
|
||||
"deleteQuantity": "1",
|
||||
"processStatus": "处理中"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cartRatios": [
|
||||
{
|
||||
"country": "德国",
|
||||
"ratio": "25%"
|
||||
},
|
||||
{
|
||||
"country": "英国",
|
||||
"ratio": "18%"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""),
|
||||
@ExampleObject(
|
||||
name = "店铺完成示例",
|
||||
summary = "最后一片提交完成后,将 shopDone 置为 true",
|
||||
value = """
|
||||
{
|
||||
"shops": [
|
||||
{
|
||||
"shopName": "郭亚芳",
|
||||
"submissionId": "query-asin:3089:郭亚芳:1711111111111",
|
||||
"chunkIndex": 2,
|
||||
"chunkTotal": 2,
|
||||
"shopDone": true,
|
||||
"countrySections": [
|
||||
{
|
||||
"country": "法国",
|
||||
"rows": [
|
||||
{
|
||||
"status": "正常",
|
||||
"quantity": "4",
|
||||
"deleteQuantity": "0",
|
||||
"processStatus": "已完成"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cartRatios": [
|
||||
{
|
||||
"country": "法国",
|
||||
"ratio": "12%"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""),
|
||||
@ExampleObject(
|
||||
name = "失败示例",
|
||||
summary = "店铺处理失败时直接回传错误信息",
|
||||
value = """
|
||||
{
|
||||
"shops": [
|
||||
{
|
||||
"shopName": "郭亚芳",
|
||||
"shopDone": true,
|
||||
"error": "紫鸟页面加载超时,未能完成删除数据采集"
|
||||
}
|
||||
]
|
||||
}
|
||||
""")
|
||||
}))
|
||||
@Valid @RequestBody QueryAsinSubmitResultRequest request,
|
||||
jakarta.servlet.http.HttpServletResponse response) {
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
queryAsinTaskService.submitResult(taskId, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/results/{resultId}/download")
|
||||
@Operation(summary = "下载结果文件", description = "按结果记录下载服务端已生成并上传到 OSS 的查询ASIN Excel 文件。")
|
||||
public void downloadResult(
|
||||
@Parameter(description = "结果记录主键", example = "4599")
|
||||
@PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId,
|
||||
jakarta.servlet.http.HttpServletResponse response) {
|
||||
String url = queryAsinTaskService.resolveResultDownloadUrl(resultId, userId);
|
||||
String filename = queryAsinTaskService.resolveResultDownloadFilename(resultId, userId);
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果");
|
||||
}
|
||||
try {
|
||||
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
response.setContentType("application/octet-stream");
|
||||
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename);
|
||||
try (InputStream in = URI.create(url).toURL().openStream()) {
|
||||
byte[] buffer = new byte[65536];
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
response.getOutputStream().write(buffer, 0, read);
|
||||
}
|
||||
response.getOutputStream().flush();
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/tasks/{taskId}")
|
||||
@Operation(summary = "删除任务", description = "删除整条查询ASIN任务以及该任务下关联的所有结果记录。")
|
||||
public ApiResponse<Void> deleteTask(
|
||||
@Parameter(description = "任务主键", example = "3089")
|
||||
@PathVariable Long taskId,
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
queryAsinTaskService.deleteTask(taskId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@DeleteMapping("/history/{resultId}")
|
||||
@Operation(summary = "删除单条历史记录", description = "删除一条查询ASIN结果记录,并同步重算其所属任务状态。")
|
||||
public ApiResponse<Void> deleteHistory(
|
||||
@Parameter(description = "结果记录主键", example = "4599")
|
||||
@PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
queryAsinTaskService.deleteHistory(resultId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.nanri.aiimage.modules.queryasin.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.queryasin.model.entity.QueryAsinShopCandidateEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface QueryAsinShopCandidateMapper extends BaseMapper<QueryAsinShopCandidateEntity> {
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.nanri.aiimage.modules.queryasin.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "购物车比例数据")
|
||||
public class QueryAsinCartRatioDto {
|
||||
|
||||
@Schema(description = "国家名称", example = "德国")
|
||||
private String country;
|
||||
|
||||
@Schema(description = "购物车比例", example = "25%")
|
||||
private String ratio;
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.nanri.aiimage.modules.queryasin.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "查询 ASIN 国家维度 ASIN 列表")
|
||||
public class QueryAsinCountryAsinsDto {
|
||||
|
||||
@Schema(description = "国家编码,支持 DE、UK、FR、IT、ES", example = "DE")
|
||||
private String country;
|
||||
|
||||
@Schema(description = "该国家需要查询的 ASIN 列表")
|
||||
private List<String> asins = new ArrayList<>();
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.nanri.aiimage.modules.queryasin.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "国家状态数据中的单行记录")
|
||||
public class QueryAsinCountryMetricRowDto {
|
||||
|
||||
@Schema(description = "商品状态", example = "正常")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "该状态下的商品数量", example = "12")
|
||||
private String quantity;
|
||||
|
||||
@JsonProperty("deleteQuantity")
|
||||
@Schema(description = "已删除数量", example = "3")
|
||||
private String deleteQuantity;
|
||||
|
||||
@JsonProperty("processStatus")
|
||||
@Schema(description = "处理结果或处理状态", example = "处理中")
|
||||
private String processStatus;
|
||||
}
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.nanri.aiimage.modules.queryasin.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "单个国家的状态数据分组")
|
||||
public class QueryAsinCountrySectionDto {
|
||||
|
||||
@Schema(description = "国家名称", example = "德国")
|
||||
private String country;
|
||||
|
||||
@Schema(description = "该国家下的多行状态数据")
|
||||
private List<QueryAsinCountryMetricRowDto> rows = new ArrayList<>();
|
||||
}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.nanri.aiimage.modules.queryasin.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 jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "创建查询ASIN任务请求")
|
||||
public class QueryAsinCreateTaskRequest {
|
||||
|
||||
@NotNull
|
||||
@JsonProperty("user_id")
|
||||
@Schema(description = "当前用户 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long userId;
|
||||
|
||||
@Valid
|
||||
@NotEmpty
|
||||
@Schema(description = "待创建的店铺任务列表,通常由已匹配成功的店铺组成", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<QueryAsinTaskItemDto> items = new ArrayList<>();
|
||||
}
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.nanri.aiimage.modules.queryasin.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "单个店铺的查询ASIN结果分片")
|
||||
public class QueryAsinShopPayloadDto {
|
||||
|
||||
@JsonProperty("shopName")
|
||||
@Schema(description = "店铺名称,服务端按店铺名称聚合分片", example = "郭亚芳")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "店铺处理失败时的错误信息;有值时该店铺会直接标记失败", example = "紫鸟页面加载超时,未能完成删除数据采集")
|
||||
private String error;
|
||||
|
||||
@JsonProperty("countrySections")
|
||||
@Schema(description = "各国家的状态数据列表。同一国家可分多片提交,服务端会按顺序合并")
|
||||
private List<QueryAsinCountrySectionDto> countrySections = new ArrayList<>();
|
||||
|
||||
@JsonProperty("cartRatios")
|
||||
@Schema(description = "购物车比例数据列表,通常只在店铺首行展示")
|
||||
private List<QueryAsinCartRatioDto> cartRatios = new ArrayList<>();
|
||||
|
||||
@JsonProperty("queryAsins")
|
||||
@Schema(description = "Python 回传的查询 ASIN 结果分片;如果 Python 仅透传后台 ASIN,也按该字段提交")
|
||||
private List<QueryAsinCountryAsinsDto> queryAsins = new ArrayList<>();
|
||||
|
||||
@JsonProperty("shopDone")
|
||||
@Schema(description = "该店铺是否已全部提交完成。最后一片应传 true", example = "true")
|
||||
private Boolean shopDone;
|
||||
|
||||
@JsonProperty("submissionId")
|
||||
@Schema(description = "本次店铺提交批次标识,便于问题排查与日志跟踪", example = "query-asin:3089:郭亚芳:1711111111111")
|
||||
private String submissionId;
|
||||
|
||||
@JsonProperty("chunkIndex")
|
||||
@Schema(description = "当前分片序号,建议从 1 开始", example = "1")
|
||||
private Integer chunkIndex;
|
||||
|
||||
@JsonProperty("chunkTotal")
|
||||
@Schema(description = "当前店铺总分片数", example = "2")
|
||||
private Integer chunkTotal;
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.nanri.aiimage.modules.queryasin.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "查询ASIN结果回传请求")
|
||||
public class QueryAsinSubmitResultRequest {
|
||||
|
||||
@Valid
|
||||
@NotEmpty
|
||||
@Schema(description = "本次提交的店铺结果分片列表,支持一次提交多个店铺", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<QueryAsinShopPayloadDto> shops = new ArrayList<>();
|
||||
}
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.nanri.aiimage.modules.queryasin.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class QueryAsinTaskBatchRequest {
|
||||
|
||||
@NotEmpty(message = "taskIds 不能为空")
|
||||
private List<Long> taskIds = new ArrayList<>();
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.nanri.aiimage.modules.queryasin.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "查询ASIN任务中的单个店铺项")
|
||||
public class QueryAsinTaskItemDto {
|
||||
|
||||
@JsonProperty("shopName")
|
||||
@Schema(description = "店铺名称", example = "郭亚芳")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "该店铺是否已完成紫鸟匹配,创建任务时必须为 true", example = "true")
|
||||
private boolean matched;
|
||||
|
||||
@JsonProperty("shopId")
|
||||
@Schema(description = "匹配到的紫鸟店铺 ID", example = "27730548558377")
|
||||
private String shopId;
|
||||
|
||||
@Schema(description = "店铺所属平台", example = "亚马逊")
|
||||
private String platform;
|
||||
|
||||
@JsonProperty("companyName")
|
||||
@Schema(description = "店铺所属公司名称", example = "示例公司")
|
||||
private String companyName;
|
||||
|
||||
@JsonProperty("matchStatus")
|
||||
@Schema(description = "匹配状态,例如 MATCHED、PENDING、CONFLICT、INDEX_STALE", example = "MATCHED")
|
||||
private String matchStatus;
|
||||
|
||||
@JsonProperty("matchMessage")
|
||||
@Schema(description = "匹配状态说明", example = "索引已命中")
|
||||
private String matchMessage;
|
||||
|
||||
@Valid
|
||||
@JsonProperty("queryAsins")
|
||||
@Schema(description = "后台查询 ASIN 维护表中的国家 ASIN 列表;查询ASIN任务按全表返回,不按店铺过滤")
|
||||
private List<QueryAsinCountryAsinsDto> queryAsins = new ArrayList<>();
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.nanri.aiimage.modules.queryasin.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_query_asin_shop_candidate")
|
||||
public class QueryAsinShopCandidateEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String shopName;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.queryasin.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class QueryAsinCreateTaskVo {
|
||||
|
||||
private Long taskId;
|
||||
private List<QueryAsinResultItemVo> items = new ArrayList<>();
|
||||
}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.nanri.aiimage.modules.queryasin.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class QueryAsinHistoryVo {
|
||||
|
||||
private List<QueryAsinResultItemVo> items = new ArrayList<>();
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.nanri.aiimage.modules.queryasin.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCartRatioDto;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountrySectionDto;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class QueryAsinResultItemVo {
|
||||
|
||||
private Long resultId;
|
||||
private Long taskId;
|
||||
|
||||
@JsonProperty("shopName")
|
||||
private String shopName;
|
||||
|
||||
@JsonProperty("shopId")
|
||||
private String shopId;
|
||||
|
||||
private String platform;
|
||||
|
||||
@JsonProperty("companyName")
|
||||
private String companyName;
|
||||
|
||||
private boolean matched;
|
||||
|
||||
@JsonProperty("matchStatus")
|
||||
private String matchStatus;
|
||||
|
||||
@JsonProperty("matchMessage")
|
||||
private String matchMessage;
|
||||
|
||||
@JsonProperty("taskStatus")
|
||||
private String taskStatus;
|
||||
|
||||
private Boolean success;
|
||||
private String error;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime finishedAt;
|
||||
private String outputFilename;
|
||||
private String downloadUrl;
|
||||
|
||||
@JsonProperty("countrySections")
|
||||
private List<QueryAsinCountrySectionDto> countrySections = new ArrayList<>();
|
||||
|
||||
@JsonProperty("cartRatios")
|
||||
private List<QueryAsinCartRatioDto> cartRatios = new ArrayList<>();
|
||||
|
||||
@JsonProperty("queryAsins")
|
||||
private List<QueryAsinCountryAsinsDto> queryAsins = new ArrayList<>();
|
||||
}
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.nanri.aiimage.modules.queryasin.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class QueryAsinTaskBatchVo {
|
||||
|
||||
private List<QueryAsinResultItemVo> items = new ArrayList<>();
|
||||
|
||||
private List<Long> missingTaskIds = new ArrayList<>();
|
||||
}
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package com.nanri.aiimage.modules.queryasin.service;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
|
||||
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinResultItemVo;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class QueryAsinExcelAssemblyService {
|
||||
|
||||
private static final List<String> COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES");
|
||||
private static final String[] HEADER = {"店铺名", "德国", "英国", "法国", "意大利", "西班牙"};
|
||||
|
||||
public void writeWorkbook(File outputXlsx, List<QueryAsinResultItemVo> items) {
|
||||
SXSSFWorkbook workbook = new SXSSFWorkbook(200);
|
||||
workbook.setCompressTempFiles(true);
|
||||
try (FileOutputStream outputStream = new FileOutputStream(outputXlsx)) {
|
||||
Sheet sheet = workbook.createSheet("查询ASIN结果");
|
||||
Row headerRow = sheet.createRow(0);
|
||||
for (int columnIndex = 0; columnIndex < HEADER.length; columnIndex++) {
|
||||
headerRow.createCell(columnIndex).setCellValue(HEADER[columnIndex]);
|
||||
sheet.setColumnWidth(columnIndex, (columnIndex == 0 ? 22 : 18) * 256);
|
||||
}
|
||||
|
||||
int rowIndex = 1;
|
||||
for (QueryAsinResultItemVo item : items) {
|
||||
if (item == null || Boolean.FALSE.equals(item.getSuccess())) {
|
||||
continue;
|
||||
}
|
||||
Map<String, List<String>> asinsByCountry = asinsByCountry(item.getQueryAsins());
|
||||
int maxRows = maxRows(asinsByCountry);
|
||||
for (int i = 0; i < maxRows; i++) {
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
row.createCell(0).setCellValue(i == 0 ? safe(item.getShopName()) : "");
|
||||
for (int c = 0; c < COUNTRIES.size(); c++) {
|
||||
List<String> asins = asinsByCountry.get(COUNTRIES.get(c));
|
||||
row.createCell(c + 1).setCellValue(asins != null && i < asins.size() ? safe(asins.get(i)) : "");
|
||||
}
|
||||
}
|
||||
rowIndex++;
|
||||
}
|
||||
workbook.write(outputStream);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[query-asin] write workbook failed: {}", ex.getMessage());
|
||||
throw new BusinessException("generate query-asin excel failed: " + ex.getMessage());
|
||||
} finally {
|
||||
try {
|
||||
workbook.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
workbook.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public int countRows(List<QueryAsinResultItemVo> items) {
|
||||
int count = 0;
|
||||
if (items == null) {
|
||||
return 0;
|
||||
}
|
||||
for (QueryAsinResultItemVo item : items) {
|
||||
if (item != null && !Boolean.FALSE.equals(item.getSuccess())) {
|
||||
count += maxRows(asinsByCountry(item.getQueryAsins()));
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private Map<String, List<String>> asinsByCountry(List<QueryAsinCountryAsinsDto> queryAsins) {
|
||||
Map<String, List<String>> map = new LinkedHashMap<>();
|
||||
for (String country : COUNTRIES) {
|
||||
map.put(country, List.of());
|
||||
}
|
||||
if (queryAsins == null) {
|
||||
return map;
|
||||
}
|
||||
for (QueryAsinCountryAsinsDto item : queryAsins) {
|
||||
if (item == null || item.getCountry() == null) {
|
||||
continue;
|
||||
}
|
||||
String country = item.getCountry().trim().toUpperCase();
|
||||
if (map.containsKey(country)) {
|
||||
map.put(country, item.getAsins() == null ? List.of() : item.getAsins());
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private int maxRows(Map<String, List<String>> asinsByCountry) {
|
||||
int max = 1;
|
||||
for (List<String> asins : asinsByCountry.values()) {
|
||||
if (asins != null && asins.size() > max) {
|
||||
max = asins.size();
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
private String safe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package com.nanri.aiimage.modules.queryasin.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
|
||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskShopQueueItemVo;
|
||||
import com.nanri.aiimage.modules.queryasin.mapper.QueryAsinShopCandidateMapper;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
|
||||
import com.nanri.aiimage.modules.queryasin.model.entity.QueryAsinShopCandidateEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.mapper.QueryAsinMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.QueryAsinEntity;
|
||||
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class QueryAsinResolveService {
|
||||
|
||||
private final QueryAsinShopCandidateMapper candidateMapper;
|
||||
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||
private final QueryAsinMapper queryAsinMapper;
|
||||
|
||||
public List<ProductRiskCandidateVo> listCandidates(Long userId) {
|
||||
validateUserId(userId);
|
||||
List<QueryAsinShopCandidateEntity> rows = candidateMapper.selectList(
|
||||
new LambdaQueryWrapper<QueryAsinShopCandidateEntity>()
|
||||
.eq(QueryAsinShopCandidateEntity::getUserId, userId)
|
||||
.orderByDesc(QueryAsinShopCandidateEntity::getId));
|
||||
List<ProductRiskCandidateVo> list = new ArrayList<>();
|
||||
for (QueryAsinShopCandidateEntity row : rows) {
|
||||
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
|
||||
vo.setId(row.getId());
|
||||
vo.setShopName(row.getShopName());
|
||||
vo.setCreatedAt(row.getCreatedAt());
|
||||
list.add(vo);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ProductRiskCandidateVo addCandidate(ProductRiskCandidateAddRequest request) {
|
||||
validateUserId(request.getUserId());
|
||||
String normalized = ziniaoShopSwitchService.normalizeShopName(request.getShopName());
|
||||
if (normalized.isBlank()) {
|
||||
throw new BusinessException("店铺名不能为空");
|
||||
}
|
||||
ZiniaoShopMatchResultVo indexHit = ziniaoShopSwitchService.findIndexedStoreByName(normalized, false);
|
||||
if (indexHit == null || !indexHit.isMatched()) {
|
||||
String hint = indexHit != null && indexHit.getMatchMessage() != null && !indexHit.getMatchMessage().isBlank()
|
||||
? indexHit.getMatchMessage()
|
||||
: "店铺索引未命中,无法加入备选区";
|
||||
throw new BusinessException(hint);
|
||||
}
|
||||
if (ZiniaoShopIndexService.MATCH_STATUS_CONFLICT.equals(indexHit.getMatchStatus())) {
|
||||
throw new BusinessException(indexHit.getMatchMessage() != null ? indexHit.getMatchMessage() : "存在多个同名店铺,请人工确认");
|
||||
}
|
||||
QueryAsinShopCandidateEntity existing = candidateMapper.selectOne(
|
||||
new LambdaQueryWrapper<QueryAsinShopCandidateEntity>()
|
||||
.eq(QueryAsinShopCandidateEntity::getUserId, request.getUserId())
|
||||
.eq(QueryAsinShopCandidateEntity::getShopName, normalized)
|
||||
.last("limit 1"));
|
||||
if (existing != null) {
|
||||
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
|
||||
vo.setId(existing.getId());
|
||||
vo.setShopName(existing.getShopName());
|
||||
vo.setCreatedAt(existing.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
QueryAsinShopCandidateEntity entity = new QueryAsinShopCandidateEntity();
|
||||
entity.setUserId(request.getUserId());
|
||||
entity.setShopName(normalized);
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
candidateMapper.insert(entity);
|
||||
|
||||
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setShopName(entity.getShopName());
|
||||
vo.setCreatedAt(entity.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteCandidate(Long userId, Long id) {
|
||||
validateUserId(userId);
|
||||
if (id == null || id <= 0) {
|
||||
throw new BusinessException("id 不合法");
|
||||
}
|
||||
QueryAsinShopCandidateEntity row = candidateMapper.selectById(id);
|
||||
if (row == null || !userId.equals(row.getUserId())) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
candidateMapper.deleteById(id);
|
||||
}
|
||||
|
||||
public ProductRiskMatchShopsVo matchShops(ProductRiskMatchShopsRequest request) {
|
||||
validateUserId(request.getUserId());
|
||||
LinkedHashSet<String> ordered = new LinkedHashSet<>();
|
||||
for (String raw : request.getShopNames()) {
|
||||
String normalized = ziniaoShopSwitchService.normalizeShopName(raw);
|
||||
if (!normalized.isBlank()) {
|
||||
ordered.add(normalized);
|
||||
}
|
||||
}
|
||||
if (ordered.isEmpty()) {
|
||||
throw new BusinessException("shop_names 无有效店铺名");
|
||||
}
|
||||
ProductRiskMatchShopsVo vo = new ProductRiskMatchShopsVo();
|
||||
for (String shopName : ordered) {
|
||||
vo.getItems().add(matchOneShop(request.getUserId(), shopName));
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
public List<QueryAsinCountryAsinsDto> loadQueryAsinsForShop(Long userId, String shopName) {
|
||||
validateUserId(userId);
|
||||
String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(shopName);
|
||||
if (normalizedShopName.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<QueryAsinEntity> rows = queryAsinMapper.selectList(
|
||||
new LambdaQueryWrapper<QueryAsinEntity>().orderByAsc(QueryAsinEntity::getId));
|
||||
return toCountryAsins(rows);
|
||||
}
|
||||
|
||||
public long countCandidates(Long userId) {
|
||||
validateUserId(userId);
|
||||
Long count = candidateMapper.selectCount(new LambdaQueryWrapper<QueryAsinShopCandidateEntity>()
|
||||
.eq(QueryAsinShopCandidateEntity::getUserId, userId));
|
||||
return count == null ? 0L : count;
|
||||
}
|
||||
|
||||
private ProductRiskShopQueueItemVo matchOneShop(Long userId, String shopName) {
|
||||
ProductRiskShopQueueItemVo item = new ProductRiskShopQueueItemVo();
|
||||
item.setShopName(shopName);
|
||||
try {
|
||||
ZiniaoShopMatchResultVo matched = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false);
|
||||
if (matched == null) {
|
||||
item.setMatched(false);
|
||||
item.setMatchStatus("PENDING");
|
||||
item.setMatchMessage("匹配结果为空");
|
||||
return item;
|
||||
}
|
||||
item.setMatched(matched.isMatched());
|
||||
item.setShopId(matched.getShopId());
|
||||
item.setPlatform(matched.getPlatform());
|
||||
item.setCompanyName(matched.getCompanyName());
|
||||
item.setMatchedUserId(matched.getMatchedUserId());
|
||||
item.setMatchStatus(matched.getMatchStatus());
|
||||
item.setMatchMessage(matched.getMatchMessage());
|
||||
item.setOpenStoreUrl(matched.getOpenStoreUrl());
|
||||
item.setQueryAsins(loadQueryAsinsForShop(userId, shopName));
|
||||
if (item.isMatched() && item.getQueryAsins().isEmpty()) {
|
||||
item.setMatchMessage("紫鸟已匹配,但后台查询 ASIN 表暂无数据");
|
||||
}
|
||||
} catch (BusinessException ex) {
|
||||
item.setMatched(false);
|
||||
item.setMatchStatus("PENDING");
|
||||
item.setMatchMessage(ex.getMessage());
|
||||
} catch (Exception ex) {
|
||||
item.setMatched(false);
|
||||
item.setMatchStatus("PENDING");
|
||||
item.setMatchMessage(Objects.toString(ex.getMessage(), "匹配异常"));
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
private List<QueryAsinCountryAsinsDto> toCountryAsins(List<QueryAsinEntity> rows) {
|
||||
Map<String, LinkedHashSet<String>> asinsByCountry = new LinkedHashMap<>();
|
||||
for (String country : List.of("DE", "UK", "FR", "IT", "ES")) {
|
||||
asinsByCountry.put(country, new LinkedHashSet<>());
|
||||
}
|
||||
if (rows != null) {
|
||||
for (QueryAsinEntity row : rows) {
|
||||
addAsin(asinsByCountry, "DE", row.getAsinDe());
|
||||
addAsin(asinsByCountry, "UK", row.getAsinUk());
|
||||
addAsin(asinsByCountry, "FR", row.getAsinFr());
|
||||
addAsin(asinsByCountry, "IT", row.getAsinIt());
|
||||
addAsin(asinsByCountry, "ES", row.getAsinEs());
|
||||
}
|
||||
}
|
||||
List<QueryAsinCountryAsinsDto> out = new ArrayList<>();
|
||||
for (Map.Entry<String, LinkedHashSet<String>> entry : asinsByCountry.entrySet()) {
|
||||
if (entry.getValue().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
QueryAsinCountryAsinsDto dto = new QueryAsinCountryAsinsDto();
|
||||
dto.setCountry(entry.getKey());
|
||||
dto.setAsins(new ArrayList<>(entry.getValue()));
|
||||
out.add(dto);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void addAsin(Map<String, LinkedHashSet<String>> map, String country, String asin) {
|
||||
String normalized = asin == null ? "" : asin.trim().toUpperCase();
|
||||
if (!normalized.isEmpty()) {
|
||||
map.get(country).add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateUserId(Long userId) {
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
}
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package com.nanri.aiimage.modules.queryasin.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinShopPayloadDto;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class QueryAsinTaskCacheService {
|
||||
|
||||
private static final String MODULE_TYPE = "QUERY_ASIN";
|
||||
private static final long PAYLOAD_TTL_HOURS = 24;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TaskPressureProperties taskPressureProperties;
|
||||
private final TaskScopePayloadStorageService taskScopePayloadStorageService;
|
||||
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
|
||||
|
||||
public QueryAsinShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
|
||||
return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, QueryAsinShopPayloadDto.class);
|
||||
}
|
||||
|
||||
public void saveShopMergedPayload(Long taskId, String shopKey, QueryAsinShopPayloadDto payload) {
|
||||
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank() || payload == null) {
|
||||
return;
|
||||
}
|
||||
taskScopePayloadStorageService.saveScopePayload(taskId, MODULE_TYPE, shopKey, payload);
|
||||
touchTaskHeartbeat(taskId);
|
||||
}
|
||||
|
||||
public void removeShopMergedPayload(Long taskId, String shopKey) {
|
||||
taskScopePayloadStorageService.removeScopePayload(taskId, MODULE_TYPE, shopKey);
|
||||
}
|
||||
|
||||
public Map<String, QueryAsinShopPayloadDto> getAllShopMergedPayload(Long taskId) {
|
||||
return taskScopePayloadStorageService.getAllScopePayload(taskId, MODULE_TYPE, QueryAsinShopPayloadDto.class);
|
||||
}
|
||||
|
||||
public boolean hasAnyShopMergedPayload(Long taskId) {
|
||||
return taskScopePayloadStorageService.hasAnyScopePayload(taskId, MODULE_TYPE);
|
||||
}
|
||||
|
||||
public long countShopMergedPayload(Long taskId) {
|
||||
return taskScopePayloadStorageService.countScopePayload(taskId, MODULE_TYPE);
|
||||
}
|
||||
|
||||
public long getTaskHeartbeatMillis(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return 0L;
|
||||
}
|
||||
String raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId));
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return 0L;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(raw);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
public void touchTaskHeartbeat(Long taskId) {
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
buildTaskHeartbeatKey(taskId),
|
||||
String.valueOf(Instant.now().toEpochMilli()),
|
||||
Duration.ofHours(PAYLOAD_TTL_HOURS));
|
||||
}
|
||||
|
||||
public void deleteTaskCache(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
taskEntityLocalCache.remove(taskId);
|
||||
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
|
||||
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
|
||||
taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE);
|
||||
}
|
||||
|
||||
public void saveTaskCache(FileTaskEntity task) {
|
||||
if (task == null || task.getId() == null) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
|
||||
now,
|
||||
objectMapper.convertValue(task, FileTaskEntity.class)
|
||||
));
|
||||
try {
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
buildTaskEntityKey(task.getId()),
|
||||
objectMapper.writeValueAsString(task),
|
||||
Duration.ofHours(PAYLOAD_TTL_HOURS)
|
||||
);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public Map<Long, FileTaskEntity> getTaskCacheBatch(java.util.List<Long> taskIds) {
|
||||
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
java.util.List<Long> normalized = taskIds.stream()
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (normalized.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
java.util.List<Long> missingIds = new ArrayList<>();
|
||||
for (Long taskId : normalized) {
|
||||
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
|
||||
if (isLocalCacheFresh(cached, now)) {
|
||||
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
|
||||
} else {
|
||||
missingIds.add(taskId);
|
||||
}
|
||||
}
|
||||
if (missingIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
java.util.List<String> keys = missingIds.stream().map(this::buildTaskEntityKey).toList();
|
||||
java.util.List<String> values = stringRedisTemplate.opsForValue().multiGet(keys);
|
||||
for (int i = 0; i < missingIds.size(); i++) {
|
||||
Long taskId = missingIds.get(i);
|
||||
String val = values != null && i < values.size() ? values.get(i) : null;
|
||||
if (val == null || val.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
FileTaskEntity task = objectMapper.readValue(val, FileTaskEntity.class);
|
||||
result.put(taskId, task);
|
||||
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String buildTaskHeartbeatKey(Long taskId) {
|
||||
return "query-asin:task:heartbeat:" + taskId;
|
||||
}
|
||||
|
||||
private String buildTaskEntityKey(Long taskId) {
|
||||
return "query-asin:task:entity:" + taskId;
|
||||
}
|
||||
|
||||
private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
|
||||
return cached != null
|
||||
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
|
||||
}
|
||||
|
||||
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
|
||||
}
|
||||
|
||||
+960
@@ -0,0 +1,960 @@
|
||||
package com.nanri.aiimage.modules.queryasin.service;
|
||||
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCartRatioDto;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryMetricRowDto;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountrySectionDto;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCreateTaskRequest;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinShopPayloadDto;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinSubmitResultRequest;
|
||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinTaskItemDto;
|
||||
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinCreateTaskVo;
|
||||
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinHistoryVo;
|
||||
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinResultItemVo;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class QueryAsinTaskService {
|
||||
|
||||
private static final String MODULE_TYPE = "QUERY_ASIN";
|
||||
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
private static final String INTERRUPTED_MESSAGE = "Python 在该店铺结果提交完成前中断";
|
||||
|
||||
private final FileTaskMapper fileTaskMapper;
|
||||
private final FileResultMapper fileResultMapper;
|
||||
private final QueryAsinResolveService queryAsinResolveService;
|
||||
private final QueryAsinExcelAssemblyService excelAssemblyService;
|
||||
private final QueryAsinTaskCacheService taskCacheService;
|
||||
private final OssStorageService ossStorageService;
|
||||
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TaskPressureProperties taskPressureProperties;
|
||||
|
||||
private FileTaskEntity loadTaskForExecution(Long taskId) {
|
||||
Map<Long, FileTaskEntity> cachedTasks = taskCacheService.getTaskCacheBatch(List.of(taskId));
|
||||
FileTaskEntity cached = cachedTasks.get(taskId);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
FileTaskEntity dbTask = fileTaskMapper.selectById(taskId);
|
||||
if (dbTask != null && MODULE_TYPE.equals(dbTask.getModuleType())) {
|
||||
taskCacheService.saveTaskCache(dbTask);
|
||||
}
|
||||
return dbTask;
|
||||
}
|
||||
|
||||
private Map<Long, FileTaskEntity> loadTaskMapByIds(List<Long> taskIds) {
|
||||
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<Long> normalizedTaskIds = taskIds.stream()
|
||||
.filter(taskId -> taskId != null && taskId > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (normalizedTaskIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
Map<Long, FileTaskEntity> cachedTasks = taskCacheService.getTaskCacheBatch(normalizedTaskIds);
|
||||
result.putAll(cachedTasks);
|
||||
List<Long> missingTaskIds = normalizedTaskIds.stream()
|
||||
.filter(taskId -> !cachedTasks.containsKey(taskId))
|
||||
.toList();
|
||||
if (!missingTaskIds.isEmpty()) {
|
||||
for (FileTaskEntity dbTask : selectTasksByIdsInBatches(missingTaskIds)) {
|
||||
if (dbTask == null || !MODULE_TYPE.equals(dbTask.getModuleType())) {
|
||||
continue;
|
||||
}
|
||||
result.put(dbTask.getId(), dbTask);
|
||||
if ("RUNNING".equals(dbTask.getStatus())) {
|
||||
taskCacheService.saveTaskCache(dbTask);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<FileTaskEntity> selectTasksByIdsInBatches(List<Long> taskIds) {
|
||||
List<FileTaskEntity> tasks = new ArrayList<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return tasks;
|
||||
}
|
||||
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
|
||||
for (int start = 0; start < taskIds.size(); start += batchSize) {
|
||||
int end = Math.min(start + batchSize, taskIds.size());
|
||||
tasks.addAll(fileTaskMapper.selectBatchIds(taskIds.subList(start, end)));
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
public ProductRiskDashboardVo dashboard(Long userId) {
|
||||
validateUserId(userId);
|
||||
ProductRiskDashboardVo vo = new ProductRiskDashboardVo();
|
||||
vo.setCandidateCount(queryAsinResolveService.countCandidates(userId));
|
||||
vo.setProcessedTaskCount(countTasks(userId, List.of("SUCCESS", "FAILED")));
|
||||
vo.setSuccessTaskCount(countTasks(userId, List.of("SUCCESS")));
|
||||
vo.setFailedTaskCount(countTasks(userId, List.of("FAILED")));
|
||||
return vo;
|
||||
}
|
||||
|
||||
public QueryAsinHistoryVo listHistory(Long userId) {
|
||||
validateUserId(userId);
|
||||
QueryAsinHistoryVo vo = new QueryAsinHistoryVo();
|
||||
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileResultEntity::getUserId, userId)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||
.last("limit 100"));
|
||||
if (entities.isEmpty()) {
|
||||
vo.setItems(List.of());
|
||||
return vo;
|
||||
}
|
||||
|
||||
Map<Long, FileTaskEntity> taskMap = loadTaskMap(entities);
|
||||
Map<Long, Map<Long, QueryAsinResultItemVo>> snapshotMap = buildSnapshotMap(taskMap);
|
||||
List<QueryAsinResultItemVo> items = new ArrayList<>();
|
||||
for (FileResultEntity entity : entities) {
|
||||
FileTaskEntity task = taskMap.get(entity.getTaskId());
|
||||
QueryAsinResultItemVo snapshot = snapshotMap.getOrDefault(entity.getTaskId(), Map.of()).get(entity.getId());
|
||||
items.add(toHistoryItem(entity, task, snapshot));
|
||||
}
|
||||
vo.setItems(items);
|
||||
return vo;
|
||||
}
|
||||
|
||||
public QueryAsinTaskBatchVo getTaskProgressBatch(List<Long> taskIds) {
|
||||
QueryAsinTaskBatchVo batch = new QueryAsinTaskBatchVo();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return batch;
|
||||
}
|
||||
|
||||
List<Long> normalizedTaskIds = taskIds.stream()
|
||||
.filter(taskId -> taskId != null && taskId > 0)
|
||||
.distinct()
|
||||
.limit(50)
|
||||
.toList();
|
||||
if (normalizedTaskIds.isEmpty()) {
|
||||
return batch;
|
||||
}
|
||||
|
||||
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(normalizedTaskIds);
|
||||
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.in(FileResultEntity::getTaskId, normalizedTaskIds)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
Map<Long, List<FileResultEntity>> rowsByTaskId = new LinkedHashMap<>();
|
||||
for (FileResultEntity row : rows) {
|
||||
rowsByTaskId.computeIfAbsent(row.getTaskId(), ignored -> new ArrayList<>()).add(row);
|
||||
}
|
||||
|
||||
for (Long taskId : normalizedTaskIds) {
|
||||
FileTaskEntity task = taskMap.get(taskId);
|
||||
if (task == null) {
|
||||
batch.getMissingTaskIds().add(taskId);
|
||||
continue;
|
||||
}
|
||||
List<FileResultEntity> taskRows = rowsByTaskId.get(taskId);
|
||||
if (taskRows == null || taskRows.isEmpty()) {
|
||||
batch.getMissingTaskIds().add(taskId);
|
||||
continue;
|
||||
}
|
||||
List<QueryAsinResultItemVo> snapshots = buildSnapshotFromDb(task, taskRows);
|
||||
batch.getItems().addAll(snapshots);
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
public String resolveResultDownloadUrl(Long resultId, Long userId) {
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
if (blank(entity.getResultFileUrl())) {
|
||||
throw new BusinessException("暂无可下载文件");
|
||||
}
|
||||
return ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl());
|
||||
}
|
||||
|
||||
public String resolveResultDownloadFilename(Long resultId, Long userId) {
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
return !blank(entity.getResultFilename())
|
||||
? entity.getResultFilename()
|
||||
: safeFileStem(entity.getSourceFilename()) + ".xlsx";
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public QueryAsinCreateTaskVo createTask(QueryAsinCreateTaskRequest request) {
|
||||
validateUserId(request.getUserId());
|
||||
if (request.getItems() == null || request.getItems().isEmpty()) {
|
||||
throw new BusinessException("items 不能为空");
|
||||
}
|
||||
|
||||
List<QueryAsinTaskItemDto> uniqueItems = dedupeItems(request.getItems());
|
||||
if (uniqueItems.isEmpty()) {
|
||||
throw new BusinessException("items 不能为空");
|
||||
}
|
||||
for (QueryAsinTaskItemDto item : uniqueItems) {
|
||||
if (item == null || !item.isMatched()) {
|
||||
throw new BusinessException("存在未匹配店铺,无法创建任务");
|
||||
}
|
||||
if (item.getQueryAsins() == null || item.getQueryAsins().isEmpty()) {
|
||||
item.setQueryAsins(queryAsinResolveService.loadQueryAsinsForShop(request.getUserId(), item.getShopName()));
|
||||
}
|
||||
if (item.getQueryAsins() == null || item.getQueryAsins().isEmpty()) {
|
||||
throw new BusinessException("后台查询 ASIN 表暂无数据,无法创建任务");
|
||||
}
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
|
||||
task.setModuleType(MODULE_TYPE);
|
||||
task.setTaskMode("PYTHON_QUEUE");
|
||||
task.setStatus("RUNNING");
|
||||
task.setSourceFileCount(uniqueItems.size());
|
||||
task.setSuccessFileCount(0);
|
||||
task.setFailedFileCount(0);
|
||||
task.setCreatedBy("user:" + request.getUserId());
|
||||
task.setUserId(request.getUserId());
|
||||
task.setCreatedAt(now);
|
||||
task.setUpdatedAt(now);
|
||||
fileTaskMapper.insert(task);
|
||||
taskCacheService.saveTaskCache(task);
|
||||
taskCacheService.touchTaskHeartbeat(task.getId());
|
||||
|
||||
List<QueryAsinResultItemVo> snapshots = new ArrayList<>();
|
||||
for (QueryAsinTaskItemDto item : uniqueItems) {
|
||||
String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
|
||||
if (blank(normalizedShopName)) {
|
||||
throw new BusinessException("任务数据已失效,请刷新后重试");
|
||||
}
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setTaskId(task.getId());
|
||||
result.setModuleType(MODULE_TYPE);
|
||||
result.setSourceFilename(normalizedShopName);
|
||||
result.setSourceFileUrl(item.getShopId());
|
||||
result.setUserId(request.getUserId());
|
||||
result.setSuccess(null);
|
||||
result.setCreatedAt(now);
|
||||
fileResultMapper.insert(result);
|
||||
snapshots.add(toSnapshotVo(result, item, task.getStatus(), null));
|
||||
}
|
||||
persistTaskJson(task, uniqueItems, snapshots);
|
||||
|
||||
QueryAsinCreateTaskVo vo = new QueryAsinCreateTaskVo();
|
||||
vo.setTaskId(task.getId());
|
||||
vo.setItems(snapshots);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void submitResult(Long taskId, QueryAsinSubmitResultRequest request) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
throw new BusinessException("taskId 不合法");
|
||||
}
|
||||
if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
|
||||
throw new BusinessException("shops 不能为空");
|
||||
}
|
||||
|
||||
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
||||
throw new BusinessException("任务已结束,拒绝重复提交");
|
||||
}
|
||||
|
||||
taskCacheService.touchTaskHeartbeat(taskId);
|
||||
List<FileResultEntity> resultRows = listTaskRows(taskId);
|
||||
List<QueryAsinResultItemVo> snapshots = buildSnapshotFromDb(task, resultRows);
|
||||
Map<Long, QueryAsinResultItemVo> snapshotByResultId = indexSnapshotByResultId(snapshots);
|
||||
Map<String, QueryAsinShopPayloadDto> payloadByShop = normalizePayloadByShop(request.getShops());
|
||||
|
||||
for (FileResultEntity row : resultRows) {
|
||||
String shopKey = row.getSourceFilename();
|
||||
QueryAsinShopPayloadDto incoming = payloadByShop.get(shopKey);
|
||||
if (incoming == null) {
|
||||
continue;
|
||||
}
|
||||
QueryAsinShopPayloadDto mergedPayload = mergeShopPayload(taskId, shopKey, incoming);
|
||||
QueryAsinResultItemVo snapshot = snapshotByResultId.get(row.getId());
|
||||
mergePayloadIntoSnapshot(snapshot, mergedPayload);
|
||||
|
||||
if (!blank(mergedPayload.getError())) {
|
||||
markResultFailed(row, mergedPayload.getError());
|
||||
applyFailureToSnapshot(snapshot, mergedPayload.getError());
|
||||
taskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(mergedPayload.getShopDone())) {
|
||||
markResultSuccess(row);
|
||||
applySuccessToSnapshot(snapshot);
|
||||
taskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
}
|
||||
}
|
||||
|
||||
persistSnapshotJson(task, snapshots);
|
||||
fileTaskMapper.updateById(task);
|
||||
tryFinalizeTask(taskId, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return false;
|
||||
}
|
||||
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
return false;
|
||||
}
|
||||
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
List<FileResultEntity> rows = listTaskRows(taskId);
|
||||
if (rows.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Map<String, QueryAsinShopPayloadDto> cachedPayloads = taskCacheService.getAllShopMergedPayload(taskId);
|
||||
List<QueryAsinResultItemVo> snapshots = buildSnapshotFromDb(task, rows);
|
||||
Map<Long, QueryAsinResultItemVo> snapshotByResultId = indexSnapshotByResultId(snapshots);
|
||||
boolean changed = false;
|
||||
|
||||
for (FileResultEntity row : rows) {
|
||||
if (isResultFinished(row)) {
|
||||
continue;
|
||||
}
|
||||
QueryAsinResultItemVo snapshot = snapshotByResultId.get(row.getId());
|
||||
QueryAsinShopPayloadDto cached = cachedPayloads.get(row.getSourceFilename());
|
||||
if (cached == null) {
|
||||
if (fromCompensation) {
|
||||
markResultFailed(row, INTERRUPTED_MESSAGE);
|
||||
applyFailureToSnapshot(snapshot, INTERRUPTED_MESSAGE);
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
mergePayloadIntoSnapshot(snapshot, cached);
|
||||
|
||||
if (!blank(cached.getError())) {
|
||||
markResultFailed(row, cached.getError());
|
||||
applyFailureToSnapshot(snapshot, cached.getError());
|
||||
taskCacheService.removeShopMergedPayload(taskId, row.getSourceFilename());
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(cached.getShopDone()) || (fromCompensation && hasAnyPayloadData(cached))) {
|
||||
markResultSuccess(row);
|
||||
applySuccessToSnapshot(snapshot);
|
||||
taskCacheService.removeShopMergedPayload(taskId, row.getSourceFilename());
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fromCompensation) {
|
||||
markResultFailed(row, INTERRUPTED_MESSAGE);
|
||||
applyFailureToSnapshot(snapshot, INTERRUPTED_MESSAGE);
|
||||
taskCacheService.removeShopMergedPayload(taskId, row.getSourceFilename());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
List<FileResultEntity> latestRows = listTaskRows(taskId);
|
||||
updateTaskStatusFromRows(task, latestRows);
|
||||
if (latestRows.stream().allMatch(this::isResultFinished)) {
|
||||
finalizeTaskWorkbook(task, latestRows, snapshots);
|
||||
return true;
|
||||
}
|
||||
|
||||
persistSnapshotJson(task, snapshots);
|
||||
fileTaskMapper.updateById(task);
|
||||
return changed;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteTask(Long taskId, Long userId) {
|
||||
validateUserId(userId);
|
||||
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
|
||||
fileTaskMapper.deleteById(taskId);
|
||||
taskCacheService.deleteTaskCache(taskId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteHistory(Long resultId, Long userId) {
|
||||
validateUserId(userId);
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
Long taskId = entity.getTaskId();
|
||||
fileResultMapper.deleteById(resultId);
|
||||
reconcileTaskAfterResultRemoval(taskId);
|
||||
}
|
||||
|
||||
private void reconcileTaskAfterResultRemoval(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
return;
|
||||
}
|
||||
List<FileResultEntity> rows = listTaskRows(taskId);
|
||||
if (rows.isEmpty()) {
|
||||
fileTaskMapper.deleteById(taskId);
|
||||
taskCacheService.deleteTaskCache(taskId);
|
||||
return;
|
||||
}
|
||||
updateTaskStatusFromRows(task, rows);
|
||||
persistSnapshotJson(task, buildSnapshotFromDb(task, rows));
|
||||
fileTaskMapper.updateById(task);
|
||||
}
|
||||
|
||||
private long countTasks(Long userId, List<String> statuses) {
|
||||
Long count = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileTaskEntity::getUserId, userId)
|
||||
.in(FileTaskEntity::getStatus, statuses));
|
||||
return count == null ? 0L : count;
|
||||
}
|
||||
|
||||
private List<FileResultEntity> listTaskRows(Long taskId) {
|
||||
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
}
|
||||
|
||||
private Map<String, QueryAsinShopPayloadDto> normalizePayloadByShop(List<QueryAsinShopPayloadDto> shops) {
|
||||
Map<String, QueryAsinShopPayloadDto> payloadByShop = new LinkedHashMap<>();
|
||||
for (QueryAsinShopPayloadDto item : shops) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String shopKey = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
|
||||
if (blank(shopKey)) {
|
||||
continue;
|
||||
}
|
||||
item.setShopName(shopKey);
|
||||
payloadByShop.put(shopKey, item);
|
||||
}
|
||||
return payloadByShop;
|
||||
}
|
||||
|
||||
private Map<Long, FileTaskEntity> loadTaskMap(List<FileResultEntity> entities) {
|
||||
List<Long> taskIds = entities.stream()
|
||||
.map(FileResultEntity::getTaskId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
|
||||
if (taskIds.isEmpty()) {
|
||||
return taskMap;
|
||||
}
|
||||
for (FileTaskEntity task : selectTasksByIdsInBatches(taskIds)) {
|
||||
if (task != null && MODULE_TYPE.equals(task.getModuleType())) {
|
||||
taskMap.put(task.getId(), task);
|
||||
}
|
||||
}
|
||||
return taskMap;
|
||||
}
|
||||
|
||||
private Map<Long, Map<Long, QueryAsinResultItemVo>> buildSnapshotMap(Map<Long, FileTaskEntity> taskMap) {
|
||||
Map<Long, Map<Long, QueryAsinResultItemVo>> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<Long, FileTaskEntity> entry : taskMap.entrySet()) {
|
||||
out.put(entry.getKey(), indexSnapshotByResultId(parseTaskSnapshots(entry.getValue().getResultJson())));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private QueryAsinResultItemVo toHistoryItem(FileResultEntity entity, FileTaskEntity task, QueryAsinResultItemVo snapshot) {
|
||||
QueryAsinResultItemVo item = snapshot != null ? snapshot : new QueryAsinResultItemVo();
|
||||
item.setResultId(entity.getId());
|
||||
item.setTaskId(entity.getTaskId());
|
||||
item.setShopName(firstNonBlank(item.getShopName(), entity.getSourceFilename()));
|
||||
item.setShopId(firstNonBlank(item.getShopId(), entity.getSourceFileUrl()));
|
||||
item.setTaskStatus(task != null ? task.getStatus() : item.getTaskStatus());
|
||||
item.setSuccess(entity.getSuccess() == null ? item.getSuccess() : entity.getSuccess() == 1);
|
||||
item.setError(!blank(entity.getErrorMessage()) ? entity.getErrorMessage() : item.getError());
|
||||
item.setCreatedAt(entity.getCreatedAt());
|
||||
item.setFinishedAt(task != null ? task.getFinishedAt() : item.getFinishedAt());
|
||||
item.setOutputFilename(firstNonBlank(item.getOutputFilename(), entity.getResultFilename()));
|
||||
item.setDownloadUrl(blank(entity.getResultFileUrl())
|
||||
? item.getDownloadUrl()
|
||||
: ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
|
||||
if (item.getCountrySections() == null) {
|
||||
item.setCountrySections(new ArrayList<>());
|
||||
}
|
||||
if (item.getCartRatios() == null) {
|
||||
item.setCartRatios(new ArrayList<>());
|
||||
}
|
||||
if (item.getQueryAsins() == null) {
|
||||
item.setQueryAsins(new ArrayList<>());
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
private List<QueryAsinTaskItemDto> dedupeItems(List<QueryAsinTaskItemDto> items) {
|
||||
LinkedHashMap<String, QueryAsinTaskItemDto> map = new LinkedHashMap<>();
|
||||
for (QueryAsinTaskItemDto item : items) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
|
||||
if (blank(normalizedShopName)) {
|
||||
continue;
|
||||
}
|
||||
item.setShopName(normalizedShopName);
|
||||
String key = normalizedShopName + "::" + Objects.toString(item.getShopId(), "");
|
||||
map.put(key, item);
|
||||
}
|
||||
return new ArrayList<>(map.values());
|
||||
}
|
||||
|
||||
private QueryAsinResultItemVo toSnapshotVo(FileResultEntity result, QueryAsinTaskItemDto item, String taskStatus, LocalDateTime finishedAt) {
|
||||
QueryAsinResultItemVo vo = new QueryAsinResultItemVo();
|
||||
vo.setResultId(result.getId());
|
||||
vo.setTaskId(result.getTaskId());
|
||||
vo.setShopName(item.getShopName());
|
||||
vo.setShopId(item.getShopId());
|
||||
vo.setPlatform(item.getPlatform());
|
||||
vo.setCompanyName(item.getCompanyName());
|
||||
vo.setMatched(item.isMatched());
|
||||
vo.setMatchStatus(item.getMatchStatus());
|
||||
vo.setMatchMessage(item.getMatchMessage());
|
||||
vo.setTaskStatus(taskStatus);
|
||||
vo.setSuccess(result.getSuccess() == null ? null : result.getSuccess() == 1);
|
||||
vo.setError(result.getErrorMessage());
|
||||
vo.setCreatedAt(result.getCreatedAt());
|
||||
vo.setFinishedAt(finishedAt);
|
||||
vo.setCountrySections(new ArrayList<>());
|
||||
vo.setCartRatios(new ArrayList<>());
|
||||
vo.setQueryAsins(copyCountryAsins(item.getQueryAsins()));
|
||||
vo.setOutputFilename(result.getResultFilename());
|
||||
vo.setDownloadUrl(null);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private void persistTaskJson(FileTaskEntity task, List<QueryAsinTaskItemDto> requestItems, List<QueryAsinResultItemVo> snapshots) {
|
||||
try {
|
||||
task.setRequestJson(objectMapper.writeValueAsString(requestItems));
|
||||
task.setResultJson(objectMapper.writeValueAsString(snapshots));
|
||||
fileTaskMapper.updateById(task);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("查询ASIN任务快照保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
private List<QueryAsinResultItemVo> buildSnapshotFromDb(FileTaskEntity task, List<FileResultEntity> rows) {
|
||||
List<QueryAsinResultItemVo> existing = parseTaskSnapshots(task.getResultJson());
|
||||
Map<Long, QueryAsinResultItemVo> snapshotByResultId = indexSnapshotByResultId(existing);
|
||||
List<QueryAsinResultItemVo> list = new ArrayList<>();
|
||||
for (FileResultEntity row : rows) {
|
||||
list.add(toHistoryItem(row, task, snapshotByResultId.get(row.getId())));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private Map<Long, QueryAsinResultItemVo> indexSnapshotByResultId(List<QueryAsinResultItemVo> snapshots) {
|
||||
Map<Long, QueryAsinResultItemVo> map = new LinkedHashMap<>();
|
||||
if (snapshots == null) {
|
||||
return map;
|
||||
}
|
||||
for (QueryAsinResultItemVo snapshot : snapshots) {
|
||||
if (snapshot != null && snapshot.getResultId() != null) {
|
||||
map.put(snapshot.getResultId(), snapshot);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private void updateTaskStatusFromRows(FileTaskEntity task, List<FileResultEntity> rows) {
|
||||
long successCount = rows.stream().filter(row -> Integer.valueOf(1).equals(row.getSuccess())).count();
|
||||
long failedCount = rows.stream().filter(row -> Integer.valueOf(0).equals(row.getSuccess())).count();
|
||||
long pendingCount = rows.stream().filter(row -> !isResultFinished(row)).count();
|
||||
task.setSuccessFileCount((int) successCount);
|
||||
task.setFailedFileCount((int) failedCount);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
if (pendingCount > 0) {
|
||||
task.setStatus("RUNNING");
|
||||
task.setFinishedAt(null);
|
||||
task.setErrorMessage(null);
|
||||
return;
|
||||
}
|
||||
task.setStatus(failedCount > 0 ? "FAILED" : "SUCCESS");
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
if (failedCount > 0) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
for (FileResultEntity row : rows) {
|
||||
if (!blank(row.getErrorMessage())) {
|
||||
errors.add(row.getSourceFilename() + ": " + row.getErrorMessage());
|
||||
}
|
||||
}
|
||||
task.setErrorMessage(String.join("; ", errors));
|
||||
} else {
|
||||
task.setErrorMessage(null);
|
||||
}
|
||||
}
|
||||
|
||||
private QueryAsinShopPayloadDto mergeShopPayload(Long taskId, String shopKey, QueryAsinShopPayloadDto incoming) {
|
||||
QueryAsinShopPayloadDto merged = taskCacheService.getShopMergedPayload(taskId, shopKey);
|
||||
if (merged == null) {
|
||||
merged = new QueryAsinShopPayloadDto();
|
||||
merged.setShopName(shopKey);
|
||||
merged.setCountrySections(new ArrayList<>());
|
||||
merged.setCartRatios(new ArrayList<>());
|
||||
merged.setQueryAsins(new ArrayList<>());
|
||||
}
|
||||
|
||||
merged.setShopName(firstNonBlank(incoming.getShopName(), merged.getShopName()));
|
||||
merged.setSubmissionId(firstNonBlank(incoming.getSubmissionId(), merged.getSubmissionId()));
|
||||
merged.setChunkIndex(incoming.getChunkIndex());
|
||||
merged.setChunkTotal(incoming.getChunkTotal());
|
||||
|
||||
if (!blank(incoming.getError())) {
|
||||
merged.setError(incoming.getError().trim());
|
||||
merged.setShopDone(Boolean.TRUE);
|
||||
taskCacheService.saveShopMergedPayload(taskId, shopKey, merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
merged.setCountrySections(mergeCountrySections(merged.getCountrySections(), incoming.getCountrySections()));
|
||||
merged.setCartRatios(mergeCartRatios(merged.getCartRatios(), incoming.getCartRatios()));
|
||||
merged.setQueryAsins(mergeCountryAsins(merged.getQueryAsins(), incoming.getQueryAsins()));
|
||||
if (Boolean.TRUE.equals(incoming.getShopDone())) {
|
||||
merged.setShopDone(Boolean.TRUE);
|
||||
}
|
||||
taskCacheService.saveShopMergedPayload(taskId, shopKey, merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
private void mergePayloadIntoSnapshot(QueryAsinResultItemVo snapshot, QueryAsinShopPayloadDto payload) {
|
||||
if (snapshot == null || payload == null) {
|
||||
return;
|
||||
}
|
||||
snapshot.setShopName(firstNonBlank(payload.getShopName(), snapshot.getShopName()));
|
||||
snapshot.setCountrySections(copyCountrySections(payload.getCountrySections()));
|
||||
snapshot.setCartRatios(copyCartRatios(payload.getCartRatios()));
|
||||
if (payload.getQueryAsins() != null && !payload.getQueryAsins().isEmpty()) {
|
||||
snapshot.setQueryAsins(copyCountryAsins(payload.getQueryAsins()));
|
||||
}
|
||||
if (!blank(payload.getError())) {
|
||||
snapshot.setError(payload.getError().trim());
|
||||
}
|
||||
}
|
||||
|
||||
private void applySuccessToSnapshot(QueryAsinResultItemVo snapshot) {
|
||||
if (snapshot == null) {
|
||||
return;
|
||||
}
|
||||
snapshot.setSuccess(Boolean.TRUE);
|
||||
snapshot.setError(null);
|
||||
}
|
||||
|
||||
private void applyFailureToSnapshot(QueryAsinResultItemVo snapshot, String error) {
|
||||
if (snapshot == null) {
|
||||
return;
|
||||
}
|
||||
snapshot.setSuccess(Boolean.FALSE);
|
||||
snapshot.setError(blankToNull(error));
|
||||
}
|
||||
|
||||
private List<QueryAsinCountrySectionDto> mergeCountrySections(List<QueryAsinCountrySectionDto> base, List<QueryAsinCountrySectionDto> incoming) {
|
||||
Map<String, QueryAsinCountrySectionDto> map = new LinkedHashMap<>();
|
||||
for (QueryAsinCountrySectionDto section : copyCountrySections(base)) {
|
||||
map.put(section.getCountry(), section);
|
||||
}
|
||||
for (QueryAsinCountrySectionDto section : copyCountrySections(incoming)) {
|
||||
QueryAsinCountrySectionDto existing = map.get(section.getCountry());
|
||||
if (existing == null) {
|
||||
map.put(section.getCountry(), section);
|
||||
continue;
|
||||
}
|
||||
List<QueryAsinCountryMetricRowDto> mergedRows = existing.getRows() == null
|
||||
? new ArrayList<>()
|
||||
: new ArrayList<>(existing.getRows());
|
||||
if (section.getRows() != null) {
|
||||
mergedRows.addAll(section.getRows());
|
||||
}
|
||||
existing.setRows(mergedRows);
|
||||
}
|
||||
return new ArrayList<>(map.values());
|
||||
}
|
||||
|
||||
private List<QueryAsinCartRatioDto> mergeCartRatios(List<QueryAsinCartRatioDto> base, List<QueryAsinCartRatioDto> incoming) {
|
||||
Map<String, QueryAsinCartRatioDto> map = new LinkedHashMap<>();
|
||||
for (QueryAsinCartRatioDto ratio : copyCartRatios(base)) {
|
||||
map.put(ratio.getCountry(), ratio);
|
||||
}
|
||||
for (QueryAsinCartRatioDto ratio : copyCartRatios(incoming)) {
|
||||
if (!blank(ratio.getCountry())) {
|
||||
map.put(ratio.getCountry(), ratio);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(map.values());
|
||||
}
|
||||
|
||||
private List<QueryAsinCountryAsinsDto> mergeCountryAsins(List<QueryAsinCountryAsinsDto> base, List<QueryAsinCountryAsinsDto> incoming) {
|
||||
Map<String, QueryAsinCountryAsinsDto> map = new LinkedHashMap<>();
|
||||
for (QueryAsinCountryAsinsDto item : copyCountryAsins(base)) {
|
||||
map.put(item.getCountry(), item);
|
||||
}
|
||||
for (QueryAsinCountryAsinsDto item : copyCountryAsins(incoming)) {
|
||||
if (blank(item.getCountry())) {
|
||||
continue;
|
||||
}
|
||||
QueryAsinCountryAsinsDto existing = map.get(item.getCountry());
|
||||
if (existing == null) {
|
||||
map.put(item.getCountry(), item);
|
||||
continue;
|
||||
}
|
||||
List<String> merged = new ArrayList<>(existing.getAsins() == null ? List.of() : existing.getAsins());
|
||||
for (String asin : item.getAsins() == null ? List.<String>of() : item.getAsins()) {
|
||||
String normalized = asin == null ? "" : asin.trim().toUpperCase();
|
||||
if (!normalized.isEmpty() && !merged.contains(normalized)) {
|
||||
merged.add(normalized);
|
||||
}
|
||||
}
|
||||
existing.setAsins(merged);
|
||||
}
|
||||
return new ArrayList<>(map.values());
|
||||
}
|
||||
|
||||
private boolean hasAnyPayloadData(QueryAsinShopPayloadDto payload) {
|
||||
if (payload == null) {
|
||||
return false;
|
||||
}
|
||||
if (payload.getQueryAsins() != null) {
|
||||
for (QueryAsinCountryAsinsDto item : payload.getQueryAsins()) {
|
||||
if (item != null && item.getAsins() != null && !item.getAsins().isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (payload.getCountrySections() != null) {
|
||||
for (QueryAsinCountrySectionDto section : payload.getCountrySections()) {
|
||||
if (section == null || section.getRows() == null) {
|
||||
continue;
|
||||
}
|
||||
for (QueryAsinCountryMetricRowDto row : section.getRows()) {
|
||||
if (row != null && (!blank(row.getStatus())
|
||||
|| !blank(row.getQuantity())
|
||||
|| !blank(row.getDeleteQuantity())
|
||||
|| !blank(row.getProcessStatus()))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (payload.getCartRatios() != null) {
|
||||
for (QueryAsinCartRatioDto ratio : payload.getCartRatios()) {
|
||||
if (ratio != null && (!blank(ratio.getCountry()) || !blank(ratio.getRatio()))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void finalizeTaskWorkbook(FileTaskEntity task, List<FileResultEntity> rows, List<QueryAsinResultItemVo> snapshots) {
|
||||
List<QueryAsinResultItemVo> successItems = snapshots.stream()
|
||||
.filter(item -> Boolean.TRUE.equals(item.getSuccess()))
|
||||
.toList();
|
||||
if (!successItems.isEmpty()) {
|
||||
File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "query-asin-result", String.valueOf(task.getId())));
|
||||
String filename = safeFileStem("查询ASIN-" + task.getId()) + ".xlsx";
|
||||
File xlsx = FileUtil.file(workRoot, filename);
|
||||
try {
|
||||
excelAssemblyService.writeWorkbook(xlsx, successItems);
|
||||
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
|
||||
long fileSize = xlsx.length();
|
||||
int rowCount = excelAssemblyService.countRows(successItems);
|
||||
for (FileResultEntity row : rows) {
|
||||
if (Integer.valueOf(1).equals(row.getSuccess())) {
|
||||
row.setResultFilename(filename);
|
||||
row.setResultFileUrl(objectKey);
|
||||
row.setResultFileSize(fileSize);
|
||||
row.setResultContentType(CONTENT_TYPE_XLSX);
|
||||
row.setRowCount(rowCount);
|
||||
fileResultMapper.updateById(row);
|
||||
}
|
||||
}
|
||||
snapshots = buildSnapshotFromDb(task, rows);
|
||||
} finally {
|
||||
FileUtil.del(xlsx);
|
||||
}
|
||||
}
|
||||
|
||||
persistSnapshotJson(task, snapshots);
|
||||
fileTaskMapper.updateById(task);
|
||||
taskCacheService.deleteTaskCache(task.getId());
|
||||
}
|
||||
|
||||
private void markResultSuccess(FileResultEntity row) {
|
||||
row.setSuccess(1);
|
||||
row.setErrorMessage(null);
|
||||
fileResultMapper.updateById(row);
|
||||
}
|
||||
|
||||
private void markResultFailed(FileResultEntity row, String message) {
|
||||
row.setSuccess(0);
|
||||
row.setErrorMessage(blankToNull(message));
|
||||
row.setResultFilename(null);
|
||||
row.setResultFileUrl(null);
|
||||
row.setResultFileSize(0L);
|
||||
row.setResultContentType(null);
|
||||
row.setRowCount(0);
|
||||
fileResultMapper.updateById(row);
|
||||
}
|
||||
|
||||
private boolean isResultFinished(FileResultEntity row) {
|
||||
return Integer.valueOf(1).equals(row.getSuccess()) || Integer.valueOf(0).equals(row.getSuccess());
|
||||
}
|
||||
|
||||
private List<QueryAsinResultItemVo> parseTaskSnapshots(String json) {
|
||||
if (blank(json)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<List<QueryAsinResultItemVo>>() {});
|
||||
} catch (Exception ex) {
|
||||
log.warn("[query-asin] parse task snapshot failed: {}", ex.getMessage());
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
private void persistSnapshotJson(FileTaskEntity task, List<QueryAsinResultItemVo> snapshots) {
|
||||
try {
|
||||
task.setResultJson(objectMapper.writeValueAsString(snapshots == null ? List.of() : snapshots));
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("查询ASIN任务快照保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
private List<QueryAsinCountrySectionDto> copyCountrySections(List<QueryAsinCountrySectionDto> sections) {
|
||||
List<QueryAsinCountrySectionDto> copy = new ArrayList<>();
|
||||
if (sections == null) {
|
||||
return copy;
|
||||
}
|
||||
for (QueryAsinCountrySectionDto section : sections) {
|
||||
if (section == null) {
|
||||
continue;
|
||||
}
|
||||
QueryAsinCountrySectionDto item = new QueryAsinCountrySectionDto();
|
||||
item.setCountry(section.getCountry());
|
||||
item.setRows(section.getRows() == null ? new ArrayList<>() : new ArrayList<>(section.getRows()));
|
||||
copy.add(item);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
private List<QueryAsinCartRatioDto> copyCartRatios(List<QueryAsinCartRatioDto> ratios) {
|
||||
List<QueryAsinCartRatioDto> copy = new ArrayList<>();
|
||||
if (ratios == null) {
|
||||
return copy;
|
||||
}
|
||||
for (QueryAsinCartRatioDto ratio : ratios) {
|
||||
if (ratio == null) {
|
||||
continue;
|
||||
}
|
||||
QueryAsinCartRatioDto item = new QueryAsinCartRatioDto();
|
||||
item.setCountry(ratio.getCountry());
|
||||
item.setRatio(ratio.getRatio());
|
||||
copy.add(item);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
private List<QueryAsinCountryAsinsDto> copyCountryAsins(List<QueryAsinCountryAsinsDto> items) {
|
||||
List<QueryAsinCountryAsinsDto> copy = new ArrayList<>();
|
||||
if (items == null) {
|
||||
return copy;
|
||||
}
|
||||
for (QueryAsinCountryAsinsDto source : items) {
|
||||
if (source == null || blank(source.getCountry())) {
|
||||
continue;
|
||||
}
|
||||
QueryAsinCountryAsinsDto item = new QueryAsinCountryAsinsDto();
|
||||
item.setCountry(source.getCountry().trim().toUpperCase());
|
||||
List<String> asins = new ArrayList<>();
|
||||
if (source.getAsins() != null) {
|
||||
for (String asin : source.getAsins()) {
|
||||
String normalized = asin == null ? "" : asin.trim().toUpperCase();
|
||||
if (!normalized.isEmpty() && !asins.contains(normalized)) {
|
||||
asins.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
item.setAsins(asins);
|
||||
copy.add(item);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String first, String second) {
|
||||
return !blank(first) ? first : second;
|
||||
}
|
||||
|
||||
private String safeFileStem(String value) {
|
||||
String raw = value == null ? "result" : value.trim();
|
||||
String safe = raw.replaceAll("[\\\\/:*?\"<>|]+", "_");
|
||||
return safe.isBlank() ? "result" : safe;
|
||||
}
|
||||
|
||||
private boolean blank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private String blankToNull(String value) {
|
||||
return blank(value) ? null : value.trim();
|
||||
}
|
||||
|
||||
private void validateUserId(Long userId) {
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user