完成查询asin开发

This commit is contained in:
super
2026-04-23 20:48:57 +08:00
parent c0fdea6570
commit c502afb588
105 changed files with 6027 additions and 6535 deletions

View File

@@ -11,5 +11,5 @@ import java.util.List;
public class ModuleCleanupProperties {
private boolean enabled = true;
private String cron = "0 0 0 * * *";
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK", "SHOP_MATCH", "PATROL_DELETE"));
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK", "SHOP_MATCH", "PATROL_DELETE", "QUERY_ASIN"));
}

View File

@@ -28,6 +28,7 @@ public class PermissionMenuSchemaInitializer {
new DefaultAdminMenu("店铺密钥管理", "admin_shop_keys", "shop-keys", 40),
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50),
new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin", 60),
new DefaultAdminMenu("查询ASIN", "admin_query_asin", "query-asin", 65),
new DefaultAdminMenu("查看生成记录", "admin_history", "history", 70),
new DefaultAdminMenu("版本管理", "admin_version", "version", 80)
);

View File

@@ -1128,9 +1128,8 @@ public class PriceTrackTaskService {
if (targetAsin == null || targetAsin.isBlank()) {
continue;
}
boolean removed = skipPriceAsinService.removeByShopCountryAndAsin(shopKey, countryCode, targetAsin);
log.info("[price-track] skip asin delete signal taskShop={} country={} asin={} removed={} reason={}",
shopKey, countryCode, targetAsin, removed, row.getDeleteReason());
log.info("[price-track] skip asin delete signal ignored taskShop={} country={} asin={} reason={}",
shopKey, countryCode, targetAsin, row.getDeleteReason());
}
}
}

View File

@@ -1,9 +1,13 @@
package com.nanri.aiimage.modules.productrisk.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 与 Python 队列约定字段对齐,供前端原样推入 enqueue_json。
*/
@@ -22,7 +26,7 @@ public class ProductRiskShopQueueItemVo {
@Schema(description = "紫鸟侧店铺 ID字符串未命中时可能为空")
private String shopId;
@Schema(description = "平台,如 亚马逊", example = "亚马逊")
@Schema(description = "平台,如亚马逊", example = "亚马逊")
private String platform;
@JsonProperty("companyName")
@@ -38,11 +42,14 @@ public class ProductRiskShopQueueItemVo {
private Long matchedUserId;
@JsonProperty("matchStatus")
@Schema(
description = "匹配状态码:如 MATCHED、PENDING、CONFLICT、INDEX_STALE 等,以后端为准。")
@Schema(description = "匹配状态码:如 MATCHED、PENDING、CONFLICT、INDEX_STALE 等,以后端为准")
private String matchStatus;
@JsonProperty("matchMessage")
@Schema(description = "人类可读说明,供前端展示")
private String matchMessage;
@JsonProperty("queryAsins")
@Schema(description = "查询 ASIN 模块返回的后台维护 ASIN 数据;按全表聚合返回,不按当前店铺过滤")
private List<QueryAsinCountryAsinsDto> queryAsins = new ArrayList<>();
}

View File

@@ -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);
}
}

View File

@@ -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> {
}

View File

@@ -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;
}

View File

@@ -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<>();
}

View File

@@ -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;
}

View File

@@ -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<>();
}

View File

@@ -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<>();
}

View File

@@ -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;
}

View File

@@ -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<>();
}

View File

@@ -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<>();
}

View File

@@ -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<>();
}

View File

@@ -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;
}

View File

@@ -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<>();
}

View File

@@ -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<>();
}

View File

@@ -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<>();
}

View File

@@ -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<>();
}

View File

@@ -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;
}
}

View File

@@ -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 不合法");
}
}
}

View File

@@ -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) {}
}

View File

@@ -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 不合法");
}
}
}

View File

@@ -0,0 +1,125 @@
package com.nanri.aiimage.modules.shopkey.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.shopkey.model.dto.QueryAsinCountryUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.QueryAsinCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportStartVo;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinPageVo;
import com.nanri.aiimage.modules.shopkey.service.QueryAsinService;
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.PutMapping;
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.multipart.MultipartFile;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/query-asins")
@Tag(name = "查询 ASIN", description = "维护店铺按国家查询的 ASIN")
public class QueryAsinController {
private final QueryAsinService queryAsinService;
@GetMapping
@Operation(summary = "分页查询 ASIN")
public ApiResponse<QueryAsinPageVo> page(
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
@Parameter(description = "ASIN") @RequestParam(required = false) String asin,
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success(queryAsinService.page(
page,
pageSize,
groupId,
shopName,
asin,
operatorId,
Boolean.TRUE.equals(superAdmin)));
}
@PostMapping
@Operation(summary = "新增或覆盖查询 ASIN")
public ApiResponse<QueryAsinItemVo> create(
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody QueryAsinCreateRequest request) {
return ApiResponse.success("保存成功",
queryAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@PostMapping("/import")
@Operation(summary = "导入添加查询 ASIN")
public ApiResponse<QueryAsinImportStartVo> importExcel(
@Parameter(description = "xlsx/xls 文件", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "Excel 未提供分组时使用的分组ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("开始导入",
queryAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@GetMapping("/import/{importId}")
@Operation(summary = "查询导入添加进度")
public ApiResponse<QueryAsinImportProgressVo> importProgress(@PathVariable String importId) {
return ApiResponse.success(queryAsinService.getImportProgress(importId));
}
@PostMapping("/delete-import")
@Operation(summary = "导入删除查询 ASIN")
public ApiResponse<QueryAsinImportStartVo> deleteImportExcel(
@Parameter(description = "xlsx/xls 文件", required = true) @RequestParam("file") MultipartFile file,
@Parameter(description = "Excel 未提供分组时使用的分组ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success("开始删除",
queryAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
}
@GetMapping("/delete-import/{importId}")
@Operation(summary = "查询导入删除进度")
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(@PathVariable String importId) {
return ApiResponse.success(queryAsinService.getDeleteImportProgress(importId));
}
@PutMapping("/{id}/countries/{country}")
@Operation(summary = "编辑指定国家的查询 ASIN")
public ApiResponse<QueryAsinItemVo> updateCountry(
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
@Parameter(description = "国家编码", required = true) @PathVariable String country,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin,
@Valid @RequestBody QueryAsinCountryUpdateRequest request) {
return ApiResponse.success("保存成功", queryAsinService.updateCountry(
id,
country,
request.getAsin(),
operatorId,
Boolean.TRUE.equals(superAdmin)));
}
@DeleteMapping("/{id}/countries/{country}")
@Operation(summary = "删除指定国家的查询 ASIN")
public ApiResponse<Void> deleteCountry(
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
@Parameter(description = "国家编码", required = true) @PathVariable String country,
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
queryAsinService.deleteCountry(id, country, operatorId, Boolean.TRUE.equals(superAdmin));
return ApiResponse.success("删除成功", null);
}
}

View File

@@ -0,0 +1,55 @@
package com.nanri.aiimage.modules.shopkey.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.QueryAsinEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Insert;
import java.util.Collection;
import java.util.List;
@Mapper
public interface QueryAsinMapper extends BaseMapper<QueryAsinEntity> {
@Select("""
<script>
SELECT id, group_id, shop_name, asin_de, asin_uk, asin_fr, asin_it, asin_es, created_at, updated_at
FROM biz_query_asin
WHERE group_id IN
<foreach collection="groupIds" item="groupId" open="(" separator="," close=")">
#{groupId}
</foreach>
AND shop_name IN
<foreach collection="shopNames" item="shopName" open="(" separator="," close=")">
#{shopName}
</foreach>
</script>
""")
List<QueryAsinEntity> selectByGroupIdsAndShopNames(@Param("groupIds") Collection<Long> groupIds,
@Param("shopNames") Collection<String> shopNames);
@Select("""
<script>
SELECT id, group_id, shop_name, asin_de, asin_uk, asin_fr, asin_it, asin_es, created_at, updated_at
FROM biz_query_asin
WHERE shop_name IN
<foreach collection="shopNames" item="shopName" open="(" separator="," close=")">
#{shopName}
</foreach>
</script>
""")
List<QueryAsinEntity> selectByShopNames(@Param("shopNames") Collection<String> shopNames);
@Insert("""
<script>
INSERT INTO biz_query_asin (group_id, shop_name, asin_de, asin_uk, asin_fr, asin_it, asin_es)
VALUES
<foreach collection="rows" item="row" separator=",">
(#{row.groupId}, #{row.shopName}, #{row.asinDe}, #{row.asinUk}, #{row.asinFr}, #{row.asinIt}, #{row.asinEs})
</foreach>
</script>
""")
int insertBatch(@Param("rows") List<QueryAsinEntity> rows);
}

View File

@@ -0,0 +1,13 @@
package com.nanri.aiimage.modules.shopkey.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class QueryAsinCountryUpdateRequest {
@Schema(description = "ASIN", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "ASIN 不能为空")
private String asin;
}

View File

@@ -0,0 +1,32 @@
package com.nanri.aiimage.modules.shopkey.model.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class QueryAsinCreateRequest {
@NotNull(message = "分组不能为空")
private Long groupId;
@NotBlank(message = "店铺名不能为空")
private String shopName;
@NotEmpty(message = "请至少选择一个国家")
private List<String> countries;
/**
* 兼容旧请求:选中的国家共用同一个 ASIN。
*/
private String asin;
/**
* 新请求:每个国家对应独立 ASIN例如 {"DE":"B0...", "UK":"B0..."}。
*/
private Map<String, String> asinMappings;
}

View File

@@ -0,0 +1,44 @@
package com.nanri.aiimage.modules.shopkey.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_query_asin")
public class QueryAsinEntity {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("group_id")
private Long groupId;
@TableField("shop_name")
private String shopName;
@TableField("asin_de")
private String asinDe;
@TableField("asin_uk")
private String asinUk;
@TableField("asin_fr")
private String asinFr;
@TableField("asin_it")
private String asinIt;
@TableField("asin_es")
private String asinEs;
@TableField("created_at")
private LocalDateTime createdAt;
@TableField("updated_at")
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,16 @@
package com.nanri.aiimage.modules.shopkey.model.vo;
import lombok.Data;
@Data
public class QueryAsinImportProgressVo {
private String status;
private Integer totalRows;
private Integer processedRows;
private Integer asinCount;
private Integer insertedCount;
private Integer deletedCount;
private Integer skippedCount;
private String errorMessage;
}

View File

@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.shopkey.model.vo;
import lombok.Data;
@Data
public class QueryAsinImportStartVo {
private String importId;
}

View File

@@ -0,0 +1,21 @@
package com.nanri.aiimage.modules.shopkey.model.vo;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class QueryAsinItemVo {
private Long id;
private Long groupId;
private String groupName;
private String shopName;
private String asinDe;
private String asinUk;
private String asinFr;
private String asinIt;
private String asinEs;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,15 @@
package com.nanri.aiimage.modules.shopkey.model.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class QueryAsinPageVo {
private List<QueryAsinItemVo> items = new ArrayList<>();
private Long total = 0L;
private Long page = 1L;
private Long pageSize = 15L;
}

View File

@@ -0,0 +1,828 @@
package com.nanri.aiimage.modules.shopkey.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.hutool.core.util.IdUtil;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.util.ExcelStreamReader;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.shopkey.mapper.QueryAsinMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.QueryAsinCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.entity.QueryAsinEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportStartVo;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinItemVo;
import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinPageVo;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageGroupItemVo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashMap;
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.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
public class QueryAsinService {
private static final List<String> SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES");
private static final String KEY_SEPARATOR = Character.toString((char) 1);
private static final String UTF8_BOM = Character.toString(0xFEFF);
private final QueryAsinMapper queryAsinMapper;
private final ShopManageGroupService shopManageGroupService;
private final TaskPressureProperties taskPressureProperties;
private final Map<String, QueryAsinImportProgressVo> importProgressMap = new ConcurrentHashMap<>();
private final Map<String, QueryAsinImportProgressVo> deleteImportProgressMap = new ConcurrentHashMap<>();
public QueryAsinPageVo page(long page, long pageSize, Long groupId, String shopName, String asin,
Long operatorId, boolean superAdmin) {
long safePage = Math.max(page, 1);
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
String safeShopName = normalizeBlank(shopName);
String safeAsin = normalizeBlank(asin);
Set<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
LambdaQueryWrapper<QueryAsinEntity> query = new LambdaQueryWrapper<QueryAsinEntity>()
.eq(groupId != null && groupId > 0, QueryAsinEntity::getGroupId, groupId)
.like(!safeShopName.isEmpty(), QueryAsinEntity::getShopName, safeShopName)
.and(!safeAsin.isEmpty(), wrapper -> wrapper
.like(QueryAsinEntity::getAsinDe, safeAsin)
.or().like(QueryAsinEntity::getAsinUk, safeAsin)
.or().like(QueryAsinEntity::getAsinFr, safeAsin)
.or().like(QueryAsinEntity::getAsinIt, safeAsin)
.or().like(QueryAsinEntity::getAsinEs, safeAsin))
.orderByDesc(QueryAsinEntity::getId);
if (!superAdmin) {
if (accessibleGroupIds.isEmpty()) {
query.eq(QueryAsinEntity::getId, -1L);
} else {
query.in(QueryAsinEntity::getGroupId, accessibleGroupIds);
}
}
Long total = queryAsinMapper.selectCount(query);
List<QueryAsinEntity> rows = queryAsinMapper
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize));
Map<Long, String> groupNameById = buildGroupNameMap(rows.stream()
.map(QueryAsinEntity::getGroupId)
.filter(id -> id != null && id > 0)
.distinct()
.toList());
QueryAsinPageVo vo = new QueryAsinPageVo();
vo.setItems(rows.stream().map(row -> toItemVo(row, groupNameById.get(row.getGroupId()))).toList());
vo.setTotal(total);
vo.setPage(safePage);
vo.setPageSize(safePageSize);
return vo;
}
@Transactional
public QueryAsinItemVo createOrUpdate(QueryAsinCreateRequest request, Long operatorId, boolean superAdmin) {
ShopManageGroupEntity group = shopManageGroupService.getAccessibleById(request.getGroupId(), operatorId, superAdmin);
String shopName = normalizeRequired(request.getShopName(), "店铺名不能为空");
Set<String> countries = normalizeCountries(request.getCountries());
Map<String, String> countryAsinMap = normalizeCountryAsinMap(countries, request);
QueryAsinEntity entity = newImportEntity(group.getId(), shopName);
for (Map.Entry<String, String> entry : countryAsinMap.entrySet()) {
setCountryAsin(entity, entry.getKey(), entry.getValue());
}
List<QueryAsinEntity> existingRows = queryAsinMapper.selectByGroupIdsAndShopNames(
List.of(group.getId()), List.of(shopName));
for (QueryAsinEntity existing : existingRows) {
if (rowSignature(existing).equals(rowSignature(entity))) {
return toItemVo(existing, group.getGroupName());
}
}
queryAsinMapper.insert(entity);
return toItemVo(getById(entity.getId()), group.getGroupName());
}
@Transactional
public void deleteCountry(Long id, String country, Long operatorId, boolean superAdmin) {
QueryAsinEntity entity = getById(id);
validateGroupAccess(entity, operatorId, superAdmin);
setCountryAsin(entity, normalizeCountry(country), "");
if (isEmptyRow(entity)) {
queryAsinMapper.deleteById(entity.getId());
return;
}
queryAsinMapper.updateById(entity);
}
@Transactional
public QueryAsinItemVo updateCountry(Long id, String country, String asin, Long operatorId, boolean superAdmin) {
QueryAsinEntity entity = getById(id);
validateGroupAccess(entity, operatorId, superAdmin);
setCountryAsin(entity, normalizeCountry(country), normalizeAsin(asin));
queryAsinMapper.updateById(entity);
QueryAsinEntity saved = getById(entity.getId());
String groupName = "";
try {
ShopManageGroupEntity group = shopManageGroupService.getById(saved.getGroupId());
groupName = group.getGroupName();
} catch (Exception ignored) {
groupName = "";
}
return toItemVo(saved, groupName);
}
public QueryAsinImportStartVo startImport(MultipartFile file, Long fallbackGroupId,
Long operatorId, boolean superAdmin) {
return startImportTask(file, fallbackGroupId, operatorId, superAdmin, false);
}
public QueryAsinImportProgressVo getImportProgress(String importId) {
QueryAsinImportProgressVo progress = importProgressMap.get(importId);
if (progress == null) {
throw new BusinessException("导入任务不存在");
}
return progress;
}
public QueryAsinImportStartVo startDeleteImport(MultipartFile file, Long fallbackGroupId,
Long operatorId, boolean superAdmin) {
return startImportTask(file, fallbackGroupId, operatorId, superAdmin, true);
}
public QueryAsinImportProgressVo getDeleteImportProgress(String importId) {
QueryAsinImportProgressVo progress = deleteImportProgressMap.get(importId);
if (progress == null) {
throw new BusinessException("删除任务不存在");
}
return progress;
}
private QueryAsinImportStartVo startImportTask(MultipartFile file, Long fallbackGroupId, Long operatorId,
boolean superAdmin, boolean deleteMode) {
if (file == null || file.isEmpty()) {
throw new BusinessException("请上传 xlsx 或 xls 文件");
}
String filename = file.getOriginalFilename();
validateExcelFilename(filename);
if (fallbackGroupId != null && fallbackGroupId > 0) {
shopManageGroupService.getAccessibleById(fallbackGroupId, operatorId, superAdmin);
}
String importId = IdUtil.fastSimpleUUID();
QueryAsinImportProgressVo progress = newImportProgress();
Map<String, QueryAsinImportProgressVo> progressMap = deleteMode ? deleteImportProgressMap : importProgressMap;
progressMap.put(importId, progress);
try {
File tempFile = saveMultipartToTempFile(file, deleteMode ? "query-asin-delete-" : "query-asin-import-");
Thread.ofVirtual().start(() -> runImportTask(importId, tempFile, filename, fallbackGroupId,
operatorId, superAdmin, deleteMode));
} catch (Exception ex) {
progressMap.remove(importId);
throw new BusinessException("读取上传文件失败");
}
QueryAsinImportStartVo vo = new QueryAsinImportStartVo();
vo.setImportId(importId);
return vo;
}
private QueryAsinImportProgressVo newImportProgress() {
QueryAsinImportProgressVo progress = new QueryAsinImportProgressVo();
progress.setStatus("pending");
progress.setTotalRows(0);
progress.setProcessedRows(0);
progress.setAsinCount(0);
progress.setInsertedCount(0);
progress.setDeletedCount(0);
progress.setSkippedCount(0);
return progress;
}
private void runImportTask(String importId, File tempFile, String filename, Long fallbackGroupId,
Long operatorId, boolean superAdmin, boolean deleteMode) {
Map<String, QueryAsinImportProgressVo> progressMap = deleteMode ? deleteImportProgressMap : importProgressMap;
QueryAsinImportProgressVo progress = progressMap.get(importId);
if (progress == null) {
deleteQuietly(tempFile);
return;
}
progress.setStatus("running");
progress.setErrorMessage(null);
try {
processImportFile(tempFile, filename, fallbackGroupId, operatorId, superAdmin, deleteMode, progress);
progress.setStatus("success");
} catch (Exception ex) {
progress.setStatus("failed");
progress.setErrorMessage(ex instanceof BusinessException ? ex.getMessage()
: (deleteMode ? "导入删除 Excel 失败" : "导入添加 Excel 失败"));
} finally {
deleteQuietly(tempFile);
}
}
private void processImportFile(File tempFile, String filename, Long fallbackGroupId, Long operatorId,
boolean superAdmin, boolean deleteMode, QueryAsinImportProgressVo progress) throws Exception {
validateExcelFilename(filename);
ImportContext context = new ImportContext(fallbackGroupId, operatorId, superAdmin, deleteMode, progress);
ExcelStreamReader.readFirstSheet(tempFile, new ExcelStreamReader.SheetRowHandler() {
@Override
public void onHeader(String sheetName, Integer sheetNo, Map<Integer, String> headerMap) {
context.setHeaderMap(headerMap);
}
@Override
public void onRow(String sheetName, Integer sheetNo, int rowIndex,
Map<Integer, String> headerMap, Map<Integer, String> rowMap) {
context.consumeRow(rowIndex, headerMap, rowMap);
}
});
context.flush();
if (!context.hasHeader()) {
throw new BusinessException("Excel 表头为空");
}
}
private File saveMultipartToTempFile(MultipartFile file, String prefix) throws Exception {
String suffix = ".xlsx";
String name = file.getOriginalFilename();
if (name != null) {
int idx = name.lastIndexOf('.');
if (idx >= 0) {
suffix = name.substring(idx);
}
}
File tempFile = File.createTempFile(prefix, suffix);
file.transferTo(tempFile);
return tempFile;
}
private void deleteQuietly(File file) {
if (file == null) {
return;
}
try {
Files.deleteIfExists(file.toPath());
} catch (Exception ignored) {
}
}
private void validateExcelFilename(String filename) {
String lowerFilename = filename == null ? "" : filename.toLowerCase(Locale.ROOT);
if (!(lowerFilename.endsWith(".xlsx") || lowerFilename.endsWith(".xls"))) {
throw new BusinessException("仅支持 .xlsx 或 .xls 文件");
}
}
private QueryAsinEntity getById(Long id) {
QueryAsinEntity entity = queryAsinMapper.selectById(id);
if (entity == null) {
throw new BusinessException("记录不存在");
}
return entity;
}
private void validateGroupAccess(QueryAsinEntity entity, Long operatorId, boolean superAdmin) {
shopManageGroupService.getAccessibleById(entity.getGroupId(), operatorId, superAdmin);
}
private QueryAsinItemVo toItemVo(QueryAsinEntity entity, String groupName) {
QueryAsinItemVo vo = new QueryAsinItemVo();
vo.setId(entity.getId());
vo.setGroupId(entity.getGroupId());
vo.setGroupName(groupName == null ? "" : groupName);
vo.setShopName(entity.getShopName());
vo.setAsinDe(blankToEmpty(entity.getAsinDe()));
vo.setAsinUk(blankToEmpty(entity.getAsinUk()));
vo.setAsinFr(blankToEmpty(entity.getAsinFr()));
vo.setAsinIt(blankToEmpty(entity.getAsinIt()));
vo.setAsinEs(blankToEmpty(entity.getAsinEs()));
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
private Map<Long, String> buildGroupNameMap(List<Long> groupIds) {
if (groupIds == null || groupIds.isEmpty()) {
return Map.of();
}
LinkedHashMap<Long, String> map = new LinkedHashMap<>();
for (Long groupId : groupIds) {
try {
ShopManageGroupEntity group = shopManageGroupService.getById(groupId);
map.put(groupId, group.getGroupName());
} catch (Exception ignored) {
map.put(groupId, "");
}
}
return map;
}
private Set<String> normalizeCountries(List<String> countries) {
LinkedHashSet<String> normalized = new LinkedHashSet<>();
if (countries != null) {
for (String country : countries) {
normalized.add(normalizeCountry(country));
}
}
if (normalized.isEmpty()) {
throw new BusinessException("请至少选择一个国家");
}
return normalized;
}
private Map<String, String> normalizeCountryAsinMap(Set<String> countries, QueryAsinCreateRequest request) {
LinkedHashMap<String, String> normalized = new LinkedHashMap<>();
Map<String, String> requestMappings = request.getAsinMappings();
if (requestMappings != null && !requestMappings.isEmpty()) {
for (String country : countries) {
String asin = requestMappings.get(country);
if (asin == null) {
asin = requestMappings.get(country.toLowerCase(Locale.ROOT));
}
normalized.put(country, normalizeAsin(asin));
}
return normalized;
}
String asin = normalizeAsin(request.getAsin());
for (String country : countries) {
normalized.put(country, asin);
}
return normalized;
}
private String normalizeCountry(String country) {
String normalized = normalizeBlank(country).toUpperCase(Locale.ROOT);
if (!SUPPORTED_COUNTRIES.contains(normalized)) {
throw new BusinessException("国家参数不支持: " + country);
}
return normalized;
}
private String normalizeAsin(String asin) {
String normalized = normalizeRequired(asin, "ASIN 不能为空").toUpperCase(Locale.ROOT);
if (normalized.length() > 64) {
throw new BusinessException("ASIN 长度不能超过 64");
}
return normalized;
}
private String normalizeRequired(String value, String message) {
String normalized = normalizeBlank(value);
if (normalized.isEmpty()) {
throw new BusinessException(message);
}
return normalized;
}
private String normalizeBlank(String value) {
return value == null ? "" : value.trim();
}
private String blankToEmpty(String value) {
return value == null ? "" : value;
}
private void setCountryAsin(QueryAsinEntity entity, String country, String asin) {
String value = normalizeBlank(asin);
switch (country) {
case "DE" -> entity.setAsinDe(value);
case "UK" -> entity.setAsinUk(value);
case "FR" -> entity.setAsinFr(value);
case "IT" -> entity.setAsinIt(value);
case "ES" -> entity.setAsinEs(value);
default -> throw new BusinessException("国家参数不支持: " + country);
}
}
private boolean isEmptyRow(QueryAsinEntity entity) {
return normalizeBlank(entity.getAsinDe()).isEmpty()
&& normalizeBlank(entity.getAsinUk()).isEmpty()
&& normalizeBlank(entity.getAsinFr()).isEmpty()
&& normalizeBlank(entity.getAsinIt()).isEmpty()
&& normalizeBlank(entity.getAsinEs()).isEmpty();
}
private String getCountryAsin(QueryAsinEntity entity, String country) {
return switch (country) {
case "DE" -> normalizeBlank(entity.getAsinDe());
case "UK" -> normalizeBlank(entity.getAsinUk());
case "FR" -> normalizeBlank(entity.getAsinFr());
case "IT" -> normalizeBlank(entity.getAsinIt());
case "ES" -> normalizeBlank(entity.getAsinEs());
default -> "";
};
}
private QueryAsinEntity newImportEntity(Long groupId, String shopName) {
QueryAsinEntity entity = new QueryAsinEntity();
entity.setGroupId(groupId);
entity.setShopName(shopName);
entity.setAsinDe("");
entity.setAsinUk("");
entity.setAsinFr("");
entity.setAsinIt("");
entity.setAsinEs("");
return entity;
}
private String importKey(Long groupId, String shopName) {
return groupId + KEY_SEPARATOR + shopName;
}
private String rowSignature(QueryAsinEntity entity) {
return importKey(entity.getGroupId(), normalizeBlank(entity.getShopName()))
+ KEY_SEPARATOR + normalizeBlank(entity.getAsinDe()).toUpperCase(Locale.ROOT)
+ KEY_SEPARATOR + normalizeBlank(entity.getAsinUk()).toUpperCase(Locale.ROOT)
+ KEY_SEPARATOR + normalizeBlank(entity.getAsinFr()).toUpperCase(Locale.ROOT)
+ KEY_SEPARATOR + normalizeBlank(entity.getAsinIt()).toUpperCase(Locale.ROOT)
+ KEY_SEPARATOR + normalizeBlank(entity.getAsinEs()).toUpperCase(Locale.ROOT);
}
private String normalizeExcelText(String value) {
return value == null ? "" : value.replace(UTF8_BOM, "").trim();
}
private String normalizeHeaderKey(String value) {
return normalizeExcelText(value).toLowerCase(Locale.ROOT)
.replace(" ", "")
.replace("_", "")
.replace("-", "");
}
private final class ImportContext {
private final Long fallbackGroupId;
private final Long operatorId;
private final boolean superAdmin;
private final boolean deleteMode;
private final QueryAsinImportProgressVo progress;
private final int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
private final List<QueryAsinEntity> pendingRows = new ArrayList<>();
private final LinkedHashSet<String> acceptedRowSignatures = new LinkedHashSet<>();
private final Map<String, Long> groupIdByNameOrId = new HashMap<>();
private final Map<String, Integer> headerIndexByKey = new HashMap<>();
private boolean groupsLoaded;
private boolean headerLoaded;
private int pendingCellCount;
private int asinCount;
private int insertedCount;
private int deletedCount;
private int skippedCount;
private ImportContext(Long fallbackGroupId, Long operatorId, boolean superAdmin,
boolean deleteMode, QueryAsinImportProgressVo progress) {
this.fallbackGroupId = fallbackGroupId;
this.operatorId = operatorId;
this.superAdmin = superAdmin;
this.deleteMode = deleteMode;
this.progress = progress;
}
private boolean hasHeader() {
return headerLoaded;
}
private void setHeaderMap(Map<Integer, String> headerMap) {
headerIndexByKey.clear();
if (headerMap != null) {
for (Map.Entry<Integer, String> entry : headerMap.entrySet()) {
String key = normalizeHeaderKey(entry.getValue());
if (!key.isEmpty()) {
headerIndexByKey.putIfAbsent(key, entry.getKey());
}
}
}
headerLoaded = true;
if (findGroupIndex() == null && (fallbackGroupId == null || fallbackGroupId <= 0)) {
throw new BusinessException("Excel 缺少分组列,请在页面选择分组或提供分组列");
}
if (findShopIndex() == null) {
throw new BusinessException("Excel 缺少店铺名列");
}
if (SUPPORTED_COUNTRIES.stream().noneMatch(country -> findCountryIndex(country) != null)) {
throw new BusinessException("Excel 至少需要包含一个国家 ASIN 列");
}
}
private void consumeRow(int rowIndex, Map<Integer, String> headerMap, Map<Integer, String> rowMap) {
if (!headerLoaded) {
setHeaderMap(headerMap);
}
progress.setTotalRows(Math.max(progress.getTotalRows() == null ? 0 : progress.getTotalRows(), rowIndex));
progress.setProcessedRows(rowIndex);
Long groupId = resolveGroupId(rowMap);
String shopName = cell(rowMap, findShopIndex());
if (groupId == null || shopName.isEmpty()) {
skippedCount++;
updateProgress();
return;
}
QueryAsinEntity row = newImportEntity(groupId, shopName);
int rowAsinCount = 0;
int rowAcceptedCount = 0;
for (String country : SUPPORTED_COUNTRIES) {
Integer index = findCountryIndex(country);
if (index == null) {
continue;
}
String asin = normalizeBlank(cell(rowMap, index)).toUpperCase(Locale.ROOT);
if (asin.isEmpty()) {
continue;
}
if (asin.length() > 64) {
skippedCount++;
continue;
}
asinCount++;
rowAsinCount++;
setCountryAsin(row, country, asin);
rowAcceptedCount++;
}
if (rowAsinCount == 0) {
skippedCount++;
}
if (rowAcceptedCount > 0) {
String signature = rowSignature(row);
if (acceptedRowSignatures.add(signature)) {
pendingRows.add(row);
pendingCellCount += rowAcceptedCount;
} else {
skippedCount += rowAcceptedCount;
}
}
if (pendingRows.size() >= batchSize) {
flush();
}
updateProgress();
}
private void flush() {
if (pendingRows.isEmpty()) {
return;
}
if (deleteMode) {
flushDelete();
} else {
flushInsert();
}
pendingRows.clear();
pendingCellCount = 0;
updateProgress();
}
private void flushInsert() {
List<QueryAsinEntity> targets = new ArrayList<>(pendingRows);
LinkedHashSet<Long> groupIds = new LinkedHashSet<>();
LinkedHashSet<String> shopNames = new LinkedHashSet<>();
for (QueryAsinEntity target : targets) {
groupIds.add(target.getGroupId());
shopNames.add(target.getShopName());
}
Map<String, List<QueryAsinEntity>> existingByKey = new LinkedHashMap<>();
LinkedHashSet<String> existingSignatures = new LinkedHashSet<>();
if (!groupIds.isEmpty() && !shopNames.isEmpty()) {
for (QueryAsinEntity existing : queryAsinMapper.selectByGroupIdsAndShopNames(groupIds, shopNames)) {
existingByKey.computeIfAbsent(importKey(existing.getGroupId(), existing.getShopName()),
ignored -> new ArrayList<>()).add(existing);
existingSignatures.add(rowSignature(existing));
}
}
List<QueryAsinEntity> insertRows = new ArrayList<>();
for (QueryAsinEntity target : targets) {
int cellCount = countCountryCells(target);
String signature = rowSignature(target);
if (existingSignatures.contains(signature)) {
skippedCount += cellCount;
continue;
}
QueryAsinEntity compatible = findCompatibleInsertCandidate(
target,
existingByKey.get(importKey(target.getGroupId(), target.getShopName())));
if (compatible != null) {
int addedCells = mergeMissingCountryAsins(compatible, target);
if (addedCells > 0) {
queryAsinMapper.updateById(compatible);
insertedCount += addedCells;
}
skippedCount += cellCount - addedCells;
existingSignatures.add(rowSignature(compatible));
continue;
}
insertRows.add(target);
insertedCount += cellCount;
existingSignatures.add(signature);
existingByKey.computeIfAbsent(importKey(target.getGroupId(), target.getShopName()),
ignored -> new ArrayList<>()).add(target);
}
if (!insertRows.isEmpty()) {
queryAsinMapper.insertBatch(insertRows);
}
}
private QueryAsinEntity findCompatibleInsertCandidate(QueryAsinEntity target, List<QueryAsinEntity> candidates) {
if (candidates == null || candidates.isEmpty()) {
return null;
}
QueryAsinEntity best = null;
int bestOverlap = -1;
for (QueryAsinEntity candidate : candidates) {
int overlap = 0;
boolean conflict = false;
for (String country : SUPPORTED_COUNTRIES) {
String targetAsin = getCountryAsin(target, country);
String candidateAsin = getCountryAsin(candidate, country);
if (targetAsin.isEmpty() || candidateAsin.isEmpty()) {
continue;
}
if (!targetAsin.equals(candidateAsin)) {
conflict = true;
break;
}
overlap++;
}
if (!conflict && overlap > bestOverlap) {
best = candidate;
bestOverlap = overlap;
}
}
return bestOverlap > 0 ? best : null;
}
private int mergeMissingCountryAsins(QueryAsinEntity existing, QueryAsinEntity target) {
int addedCells = 0;
for (String country : SUPPORTED_COUNTRIES) {
String targetAsin = getCountryAsin(target, country);
if (targetAsin.isEmpty() || !getCountryAsin(existing, country).isEmpty()) {
continue;
}
setCountryAsin(existing, country, targetAsin);
addedCells++;
}
return addedCells;
}
private void flushDelete() {
List<QueryAsinEntity> targets = new ArrayList<>(pendingRows);
LinkedHashSet<Long> groupIds = new LinkedHashSet<>();
LinkedHashSet<String> shopNames = new LinkedHashSet<>();
for (QueryAsinEntity target : targets) {
groupIds.add(target.getGroupId());
shopNames.add(target.getShopName());
}
Map<String, List<QueryAsinEntity>> existingByKey = new LinkedHashMap<>();
if (!groupIds.isEmpty() && !shopNames.isEmpty()) {
for (QueryAsinEntity existing : queryAsinMapper.selectByGroupIdsAndShopNames(groupIds, shopNames)) {
existingByKey.computeIfAbsent(importKey(existing.getGroupId(), existing.getShopName()),
ignored -> new ArrayList<>()).add(existing);
}
}
for (QueryAsinEntity target : targets) {
List<QueryAsinEntity> candidates = existingByKey.get(importKey(target.getGroupId(), target.getShopName()));
if (candidates == null || candidates.isEmpty()) {
skippedCount += countCountryCells(target);
continue;
}
QueryAsinEntity existing = findBestDeleteCandidate(target, candidates);
if (existing == null) {
skippedCount += countCountryCells(target);
continue;
}
boolean changed = false;
for (String country : SUPPORTED_COUNTRIES) {
String asin = getCountryAsin(target, country);
if (asin.isEmpty()) {
continue;
}
if (asin.equals(getCountryAsin(existing, country))) {
setCountryAsin(existing, country, "");
deletedCount++;
changed = true;
} else {
skippedCount++;
}
}
if (!changed) {
continue;
}
if (isEmptyRow(existing)) {
queryAsinMapper.deleteById(existing.getId());
} else {
queryAsinMapper.updateById(existing);
}
}
}
private QueryAsinEntity findBestDeleteCandidate(QueryAsinEntity target, List<QueryAsinEntity> candidates) {
for (QueryAsinEntity candidate : candidates) {
boolean allMatch = true;
for (String country : SUPPORTED_COUNTRIES) {
String asin = getCountryAsin(target, country);
if (!asin.isEmpty() && !asin.equals(getCountryAsin(candidate, country))) {
allMatch = false;
break;
}
}
if (allMatch) {
return candidate;
}
}
return null;
}
private int countCountryCells(QueryAsinEntity entity) {
int count = 0;
for (String country : SUPPORTED_COUNTRIES) {
if (!getCountryAsin(entity, country).isEmpty()) {
count++;
}
}
return count;
}
private Long resolveGroupId(Map<Integer, String> rowMap) {
Integer groupIndex = findGroupIndex();
String groupText = cell(rowMap, groupIndex);
if (groupText.isEmpty()) {
return fallbackGroupId != null && fallbackGroupId > 0 ? fallbackGroupId : null;
}
loadGroups();
Long id = groupIdByNameOrId.get(groupText);
if (id == null) {
id = groupIdByNameOrId.get(normalizeHeaderKey(groupText));
}
return id;
}
private void loadGroups() {
if (groupsLoaded) {
return;
}
List<ShopManageGroupItemVo> groups = shopManageGroupService.listAccessible(operatorId, superAdmin);
for (ShopManageGroupItemVo group : groups) {
if (group.getId() == null || group.getId() <= 0) {
continue;
}
groupIdByNameOrId.put(String.valueOf(group.getId()), group.getId());
groupIdByNameOrId.put(normalizeHeaderKey(group.getGroupName()), group.getId());
}
groupsLoaded = true;
}
private Integer findGroupIndex() {
return firstHeader("分组id", "分组", "groupid", "group");
}
private Integer findShopIndex() {
return firstHeader("店铺名", "店铺名称", "shopname", "shop");
}
private Integer findCountryIndex(String country) {
return switch (country) {
case "DE" -> firstHeader("德国", "", "de", "asinde");
case "UK" -> firstHeader("英国", "", "uk", "asinuk");
case "FR" -> firstHeader("法国", "", "fr", "asinfr");
case "IT" -> firstHeader("意大利", "", "it", "asinit");
case "ES" -> firstHeader("西班牙", "西", "es", "asines");
default -> null;
};
}
private Integer firstHeader(String... names) {
for (String name : names) {
Integer index = headerIndexByKey.get(normalizeHeaderKey(name));
if (index != null) {
return index;
}
}
return null;
}
private String cell(Map<Integer, String> rowMap, Integer index) {
if (rowMap == null || index == null) {
return "";
}
return normalizeExcelText(rowMap.get(index));
}
private void updateProgress() {
progress.setAsinCount(asinCount);
progress.setInsertedCount(insertedCount);
progress.setDeletedCount(deletedCount);
progress.setSkippedCount(skippedCount);
}
}
}

View File

@@ -4,12 +4,14 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.shopkey.mapper.QueryAsinMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMemberMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.entity.QueryAsinEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupMemberEntity;
@@ -35,6 +37,7 @@ public class ShopManageGroupService {
private final ShopManageGroupMapper groupMapper;
private final ShopManageGroupMemberMapper groupMemberMapper;
private final ShopManageMapper shopManageMapper;
private final QueryAsinMapper queryAsinMapper;
private final SkipPriceAsinMapper skipPriceAsinMapper;
private final AdminUserMapper adminUserMapper;
@@ -128,6 +131,11 @@ public class ShopManageGroupService {
if (skipPriceAsinCount != null && skipPriceAsinCount > 0) {
throw new BusinessException("该分组下存在跳过跟价 ASIN无法删除");
}
Long queryAsinCount = queryAsinMapper.selectCount(new LambdaQueryWrapper<QueryAsinEntity>()
.eq(QueryAsinEntity::getGroupId, entity.getId()));
if (queryAsinCount != null && queryAsinCount > 0) {
throw new BusinessException("该分组下存在查询 ASIN无法删除");
}
groupMemberMapper.delete(new LambdaQueryWrapper<ShopManageGroupMemberEntity>()
.eq(ShopManageGroupMemberEntity::getGroupId, entity.getId()));
groupMapper.deleteById(entity.getId());

View File

@@ -34,7 +34,8 @@ public class TaskScopePayloadStorageService {
"SHOP_MATCH",
"PRODUCT_RISK_RESOLVE",
"PRICE_TRACK",
"PATROL_DELETE"
"PATROL_DELETE",
"QUERY_ASIN"
));
private static final String OSS_POINTER_PREFIX = "oss:";
private static final String LOCAL_BUFFER_DIR = "task-scope-buffer";

View File

@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS biz_query_asin (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
group_id BIGINT NOT NULL COMMENT '分组ID',
shop_name VARCHAR(128) NOT NULL COMMENT '店铺名',
asin_de VARCHAR(64) NOT NULL DEFAULT '' COMMENT '德国 ASIN',
asin_uk VARCHAR(64) NOT NULL DEFAULT '' COMMENT '英国 ASIN',
asin_fr VARCHAR(64) NOT NULL DEFAULT '' COMMENT '法国 ASIN',
asin_it VARCHAR(64) NOT NULL DEFAULT '' COMMENT '意大利 ASIN',
asin_es VARCHAR(64) NOT NULL DEFAULT '' COMMENT '西班牙 ASIN',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
KEY idx_group_shop_name (group_id, shop_name),
KEY idx_shop_name (shop_name),
KEY idx_created_at (created_at)
) COMMENT='查询 ASIN';
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `sort_order`)
SELECT '查询ASIN', 'admin_query_asin', 'admin', 'query-asin', 65
WHERE NOT EXISTS (
SELECT 1 FROM `columns` WHERE `column_key` = 'admin_query_asin'
);
UPDATE `columns`
SET `name` = '查询ASIN',
`menu_type` = 'admin',
`route_path` = 'query-asin',
`sort_order` = 65
WHERE `column_key` = 'admin_query_asin';

View File

@@ -0,0 +1,32 @@
SET @schema_name = DATABASE();
SET @sql = IF(
EXISTS (
SELECT 1
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @schema_name
AND TABLE_NAME = 'biz_query_asin'
AND INDEX_NAME = 'uk_group_shop_name'
AND NON_UNIQUE = 0
),
'ALTER TABLE biz_query_asin DROP INDEX uk_group_shop_name',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = IF(
EXISTS (
SELECT 1
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @schema_name
AND TABLE_NAME = 'biz_query_asin'
AND INDEX_NAME = 'idx_group_shop_name'
),
'SELECT 1',
'ALTER TABLE biz_query_asin ADD KEY idx_group_shop_name (group_id, shop_name)'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS biz_query_asin_shop_candidate (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
user_id BIGINT NOT NULL COMMENT '用户ID',
shop_name VARCHAR(128) NOT NULL COMMENT '店铺名',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY uk_user_shop_name (user_id, shop_name),
KEY idx_user_id (user_id)
) COMMENT='查询 ASIN 前台备选店铺';