完成匹配、跟价、权限部分
This commit is contained in:
+14
-7
@@ -47,7 +47,8 @@ import java.util.List;
|
||||
@RequestMapping("/api/price-track")
|
||||
@Tag(
|
||||
name = "跟价",
|
||||
description = "亚马逊跟价处理模块:包含备选店铺、紫鸟匹配、任务创建、Python 回传、任务轮询和结果下载接口。查询类接口需要携带 Query 参数 user_id。")
|
||||
description = "亚马逊跟价处理模块,包含备选店铺、紫鸟匹配、任务创建、Python 回传、任务轮询和结果下载接口。查询类接口需携带 query 参数 user_id。"
|
||||
)
|
||||
public class PriceTrackController {
|
||||
|
||||
private final PriceTrackService priceTrackService;
|
||||
@@ -62,13 +63,13 @@ public class PriceTrackController {
|
||||
}
|
||||
|
||||
@PostMapping("/candidates")
|
||||
@Operation(summary = "新增备选店铺", description = "把店铺名称写入当前用户的备选店铺列表,供后续匹配和创建任务使用。")
|
||||
@Operation(summary = "新增备选店铺", description = "新增一条备选店铺记录,供后续匹配和创建任务使用。")
|
||||
public ApiResponse<PriceTrackCandidateVo> addCandidate(@Valid @RequestBody PriceTrackCandidateAddRequest request) {
|
||||
return ApiResponse.success(priceTrackService.addCandidate(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/candidates/{id}")
|
||||
@Operation(summary = "删除备选店铺", description = "删除一条备选店铺记录,id 必须属于当前 user_id。")
|
||||
@Operation(summary = "删除备选店铺", description = "删除一条备选店铺记录,记录必须属于当前 user_id。")
|
||||
public ApiResponse<Void> deleteCandidate(
|
||||
@Parameter(description = "备选店铺记录主键", example = "10") @PathVariable Long id,
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@@ -107,7 +108,7 @@ public class PriceTrackController {
|
||||
}
|
||||
|
||||
@GetMapping("/history")
|
||||
@Operation(summary = "处理记录列表", description = "返回当前用户跟价模块下各店铺结果行,包含运行中和历史记录。")
|
||||
@Operation(summary = "处理记录列表", description = "返回当前用户跟价模块下各店铺结果记录,包含运行中和历史记录。")
|
||||
public ApiResponse<PriceTrackHistoryVo> history(
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
@@ -115,7 +116,7 @@ public class PriceTrackController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/history/{resultId}")
|
||||
@Operation(summary = "删除单条结果记录", description = "删除一条 biz_file_result,并同步重算父任务状态。")
|
||||
@Operation(summary = "删除单条结果记录", description = "删除一条 biz_file_result 结果记录,并同步重算父任务状态。")
|
||||
public ApiResponse<Void> deleteHistory(
|
||||
@Parameter(description = "结果行主键 biz_file_result.id", example = "1001") @PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@@ -131,7 +132,7 @@ public class PriceTrackController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/tasks/{taskId}")
|
||||
@Operation(summary = "删除整条任务", description = "删除该任务以及其下属全部结果行,RUNNING、SUCCESS、FAILED 均可。")
|
||||
@Operation(summary = "删除整条任务", description = "删除该任务以及其下全部结果行,RUNNING、SUCCESS、FAILED 均可删除。")
|
||||
public ApiResponse<Void> deleteTask(
|
||||
@Parameter(description = "任务主键 biz_file_task.id", example = "200") @PathVariable Long taskId,
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@@ -151,11 +152,17 @@ public class PriceTrackController {
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/batch")
|
||||
@Operation(summary = "批量查询任务详情", description = "按 taskIds 返回任务概要和各店铺结果,供前端轮询使用。")
|
||||
@Operation(summary = "批量查询任务详情", description = "按 taskIds 返回任务概要和各店铺结果,供前端查询使用。")
|
||||
public ApiResponse<PriceTrackTaskBatchVo> tasksBatch(@Valid @RequestBody PriceTrackTaskBatchRequest request) {
|
||||
return ApiResponse.success(priceTrackTaskService.getTaskDetailsBatch(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/progress/batch")
|
||||
@Operation(summary = "批量查询任务进度摘要", description = "仅返回任务状态、时间和错误等轻量信息,供前端轮询降载。")
|
||||
public ApiResponse<PriceTrackTaskBatchVo> taskProgressBatch(@Valid @RequestBody PriceTrackTaskBatchRequest request) {
|
||||
return ApiResponse.success(priceTrackTaskService.getTaskProgressBatch(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/result")
|
||||
@Operation(summary = "Python 回传处理结果", description = "同一 taskId 可多次回传。成功时按 countries 生成结果文件,所有店铺进入终态后任务会变为 SUCCESS 或 FAILED。")
|
||||
public ApiResponse<Void> submitResult(
|
||||
|
||||
+12
-5
@@ -8,13 +8,20 @@ import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "批量匹配店铺请求体:按店铺名查询紫鸟索引,返回是否命中及店铺元数据")
|
||||
@Schema(description = "Price track match shops request")
|
||||
public class PriceTrackMatchShopsRequest {
|
||||
@NotNull(message = "user_id不能为空")
|
||||
@Schema(description = "当前用户 ID,用于审计和数据归属", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
|
||||
@NotNull(message = "userId is required")
|
||||
@Schema(description = "Current user id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long userId;
|
||||
|
||||
@NotEmpty(message = "店铺列表不能为空")
|
||||
@Schema(description = "待匹配的店铺名称列表,至少 1 个;返回体 items 与列表顺序对应", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"店铺甲\",\"店铺乙\"]")
|
||||
@NotEmpty(message = "shopNames cannot be empty")
|
||||
@Schema(description = "Shop names to match", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<String> shopNames;
|
||||
|
||||
@Schema(description = "Selected ASIN file local paths")
|
||||
private List<String> asinFiles;
|
||||
|
||||
@Schema(description = "Selected country codes")
|
||||
private List<String> countryCodes;
|
||||
}
|
||||
|
||||
+42
-12
@@ -7,43 +7,73 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Python 回传跟价结果请求体:按店铺提交各国家 ASIN 跟价结果或错误信息")
|
||||
@Schema(description = "Python submit payload for price track task results")
|
||||
public class PriceTrackSubmitResultRequest {
|
||||
@Schema(description = "本次回传涉及的店铺列表,至少 1 个,店名需要能和任务结果行匹配", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(description = "Shop result list", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<ShopResult> shops;
|
||||
|
||||
@Data
|
||||
@Schema(description = "单个店铺回传结果:成功时提供 countries,失败时可只提供 error")
|
||||
@Schema(description = "Per-shop submit result")
|
||||
public static class ShopResult {
|
||||
@Schema(description = "店铺名称,用于和任务内结果行匹配", example = "测试店铺A")
|
||||
@Schema(description = "Shop name used to match task rows", example = "Test Shop A")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "按国家分组的结果行,key 为国家代码,例如 DE、UK、FR、IT、ES")
|
||||
@Schema(description = "Rows grouped by country code, for example DE/UK/FR/IT/ES")
|
||||
private Map<String, List<AsinResult>> countries;
|
||||
|
||||
@Schema(description = "失败时的错误信息;非空则该店铺记为失败")
|
||||
@Schema(description = "Failure message")
|
||||
private String error;
|
||||
|
||||
@Schema(description = "是否成功;false 按失败处理,true 或 null 通常表示结合 countries 判定")
|
||||
@Schema(description = "Whether the shop finished successfully")
|
||||
private Boolean success;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Schema(description = "单条 ASIN 跟价结果")
|
||||
@Schema(description = "Per-ASIN price track result row")
|
||||
public static class AsinResult {
|
||||
@Schema(description = "Shop mall name", example = "Amazon.de")
|
||||
private String shopMallName;
|
||||
|
||||
@Schema(description = "ASIN", example = "B0ABCDE123")
|
||||
private String asin;
|
||||
|
||||
@Schema(description = "当前店铺价格", example = "19.99")
|
||||
@Schema(description = "Price", example = "19.99")
|
||||
private String price;
|
||||
|
||||
@Schema(description = "Recommended price", example = "18.99")
|
||||
private String recommendedPrice;
|
||||
|
||||
@Schema(description = "Lowest price", example = "18.50")
|
||||
private String minimumPrice;
|
||||
|
||||
@Schema(description = "First place", example = "Competitor A")
|
||||
private String firstPlace;
|
||||
|
||||
@Schema(description = "Second place", example = "Competitor B")
|
||||
private String secondPlace;
|
||||
|
||||
@Schema(description = "Buy box shop name", example = "Shop A")
|
||||
private String cartShopName;
|
||||
|
||||
@Schema(description = "Price change status", example = "UPDATED")
|
||||
private String priceChangeStatus;
|
||||
|
||||
@Schema(description = "Modify count", example = "2")
|
||||
private String modifyCount;
|
||||
|
||||
@Schema(description = "Status", example = "SUCCESS")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "Legacy current price", example = "19.99")
|
||||
private String currentPrice;
|
||||
|
||||
@Schema(description = "竞争对手价格", example = "18.50")
|
||||
@Schema(description = "Legacy competitor price", example = "18.50")
|
||||
private String competitorPrice;
|
||||
|
||||
@Schema(description = "建议动作或跟价动作", example = "LOWER_PRICE")
|
||||
@Schema(description = "Legacy action", example = "LOWER_PRICE")
|
||||
private String action;
|
||||
|
||||
@Schema(description = "该行是否已处理完成")
|
||||
@Schema(description = "Legacy done flag")
|
||||
private Boolean done;
|
||||
}
|
||||
}
|
||||
|
||||
+10
-3
@@ -4,13 +4,20 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Schema(description = "创建任务响应:包含任务 id 和各店铺的初始结果快照")
|
||||
@Schema(description = "price track create task response")
|
||||
public class PriceTrackCreateTaskVo {
|
||||
@Schema(description = "新建任务主键 biz_file_task.id,前端推送队列时需要回传该值", example = "200")
|
||||
@Schema(description = "task id", example = "200")
|
||||
private Long taskId;
|
||||
|
||||
@Schema(description = "各店铺一条初始结果快照,包含 resultId、taskId、店铺信息和初始任务状态")
|
||||
@Schema(description = "initial task items")
|
||||
private List<PriceTrackResultItemVo> items;
|
||||
|
||||
@Schema(description = "all skip asins grouped by country")
|
||||
private Map<String, List<String>> skipAsinsByCountry;
|
||||
|
||||
@Schema(description = "parsed asin rows by country")
|
||||
private Map<String, List<Map<String, String>>> asinRowsByCountry;
|
||||
}
|
||||
|
||||
+22
-10
@@ -5,35 +5,47 @@ import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Schema(description = "批量匹配店铺响应")
|
||||
@Schema(description = "Price track shop match response")
|
||||
public class PriceTrackMatchShopsVo {
|
||||
@Schema(description = "逐店返回的匹配结果,顺序与请求 shopNames 对应")
|
||||
|
||||
@Schema(description = "Match results in the same order as requested shop names")
|
||||
private List<PriceTrackShopQueueItem> items = new ArrayList<>();
|
||||
|
||||
@Schema(description = "All skip ASINs grouped by country code")
|
||||
private Map<String, List<String>> skipAsinsByCountry;
|
||||
|
||||
@Schema(description = "Parsed asin rows grouped by country code")
|
||||
private Map<String, List<Map<String, String>>> asinRowsByCountry;
|
||||
|
||||
@Data
|
||||
@Schema(description = "单店匹配结果,同时也是创建任务时 items 的结构")
|
||||
@Schema(description = "Single matched shop item")
|
||||
public static class PriceTrackShopQueueItem {
|
||||
@Schema(description = "店铺名称", example = "某某店铺")
|
||||
|
||||
@Schema(description = "Shop name", example = "Demo Shop")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "紫鸟店铺 ID,未命中时可能为空")
|
||||
@Schema(description = "Matched shop id")
|
||||
private Object shopId;
|
||||
|
||||
@Schema(description = "平台,例如亚马逊", example = "亚马逊")
|
||||
@Schema(description = "Platform", example = "Amazon")
|
||||
private String platform;
|
||||
|
||||
@Schema(description = "公司名称或主体标识")
|
||||
@Schema(description = "Company name")
|
||||
private String companyName;
|
||||
|
||||
@Schema(description = "是否命中紫鸟索引;false 时不可用于创建任务")
|
||||
@Schema(description = "Whether the shop matched")
|
||||
private Boolean matched;
|
||||
|
||||
@Schema(description = "匹配状态码,例如 MATCHED、PENDING、CONFLICT、INDEX_STALE")
|
||||
@Schema(description = "Match status, such as MATCHED or PENDING")
|
||||
private String matchStatus;
|
||||
|
||||
@Schema(description = "人类可读说明,供前端展示")
|
||||
@Schema(description = "Readable match message")
|
||||
private String matchMessage;
|
||||
|
||||
@Schema(description = "All skip ASINs grouped by country code")
|
||||
private Map<String, List<String>> skipAsins;
|
||||
}
|
||||
}
|
||||
|
||||
+44
-10
@@ -19,7 +19,19 @@ import java.util.Map;
|
||||
@Slf4j
|
||||
public class PriceTrackExcelAssemblyService {
|
||||
|
||||
private static final String[] HEADER = {"ASIN", "当前店铺价格", "竞争对手价格", "建议动作", "是否完成"};
|
||||
private static final String[] RESULT_HEADER = {
|
||||
"\u5e97\u94fa\u5546\u57ce\u540d\u79f0",
|
||||
"ASIN",
|
||||
"\u4ef7\u683c",
|
||||
"\u63a8\u8350\u4ef7",
|
||||
"\u6700\u4f4e\u4ef7",
|
||||
"\u7b2c\u4e00\u540d",
|
||||
"\u7b2c\u4e8c\u540d",
|
||||
"\u8d2d\u7269\u8f66\u5e97\u94fa\u540d",
|
||||
"\u6539\u4ef7\u60c5\u51b5",
|
||||
"\u4fee\u6539\u6b21\u6570",
|
||||
"\u72b6\u6001"
|
||||
};
|
||||
|
||||
public void writeWorkbook(File outputXlsx, Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> countries) {
|
||||
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> safe = countries == null ? Map.of() : countries;
|
||||
@@ -28,8 +40,8 @@ public class PriceTrackExcelAssemblyService {
|
||||
String sheetName = code.name();
|
||||
Sheet sheet = wb.createSheet(sheetName);
|
||||
Row headerRow = sheet.createRow(0);
|
||||
for (int i = 0; i < HEADER.length; i++) {
|
||||
headerRow.createCell(i).setCellValue(HEADER[i]);
|
||||
for (int i = 0; i < RESULT_HEADER.length; i++) {
|
||||
headerRow.createCell(i).setCellValue(RESULT_HEADER[i]);
|
||||
}
|
||||
List<PriceTrackSubmitResultRequest.AsinResult> rows = safe.getOrDefault(sheetName, List.of());
|
||||
int rowIdx = 1;
|
||||
@@ -38,20 +50,26 @@ public class PriceTrackExcelAssemblyService {
|
||||
continue;
|
||||
}
|
||||
Row row = sheet.createRow(rowIdx++);
|
||||
row.createCell(0).setCellValue(item.getAsin() == null ? "" : item.getAsin());
|
||||
row.createCell(1).setCellValue(item.getCurrentPrice() == null ? "" : item.getCurrentPrice());
|
||||
row.createCell(2).setCellValue(item.getCompetitorPrice() == null ? "" : item.getCompetitorPrice());
|
||||
row.createCell(3).setCellValue(item.getAction() == null ? "" : item.getAction());
|
||||
row.createCell(4).setCellValue(item.getDone() == null ? "" : String.valueOf(item.getDone()));
|
||||
row.createCell(0).setCellValue(valueOf(item.getShopMallName()));
|
||||
row.createCell(1).setCellValue(valueOf(item.getAsin()));
|
||||
row.createCell(2).setCellValue(firstNonBlank(item.getPrice(), item.getCurrentPrice()));
|
||||
row.createCell(3).setCellValue(valueOf(item.getRecommendedPrice()));
|
||||
row.createCell(4).setCellValue(firstNonBlank(item.getMinimumPrice(), item.getCompetitorPrice()));
|
||||
row.createCell(5).setCellValue(valueOf(item.getFirstPlace()));
|
||||
row.createCell(6).setCellValue(valueOf(item.getSecondPlace()));
|
||||
row.createCell(7).setCellValue(valueOf(item.getCartShopName()));
|
||||
row.createCell(8).setCellValue(firstNonBlank(item.getPriceChangeStatus(), item.getAction()));
|
||||
row.createCell(9).setCellValue(valueOf(item.getModifyCount()));
|
||||
row.createCell(10).setCellValue(firstNonBlank(item.getStatus(), item.getDone() == null ? null : String.valueOf(item.getDone())));
|
||||
}
|
||||
for (int i = 0; i < HEADER.length; i++) {
|
||||
for (int i = 0; i < RESULT_HEADER.length; i++) {
|
||||
sheet.autoSizeColumn(i);
|
||||
}
|
||||
}
|
||||
wb.write(fos);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[price-track] write workbook failed: {}", ex.getMessage());
|
||||
throw new BusinessException("生成 Excel 失败: " + ex.getMessage());
|
||||
throw new BusinessException("鐢熸垚 Excel 澶辫触: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,4 +100,20 @@ public class PriceTrackExcelAssemblyService {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String valueOf(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String... values) {
|
||||
if (values == null || values.length == 0) {
|
||||
return "";
|
||||
}
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -24,8 +24,10 @@ 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
|
||||
@@ -40,6 +42,7 @@ public class PriceTrackService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||
private final SkipPriceAsinService skipPriceAsinService;
|
||||
private final PriceTrackTaskService priceTrackTaskService;
|
||||
|
||||
public List<PriceTrackCandidateVo> listCandidates(Long userId) {
|
||||
if (userId == null || userId <= 0) {
|
||||
@@ -139,9 +142,22 @@ public class PriceTrackService {
|
||||
throw new BusinessException("shop_names 没有有效店铺名");
|
||||
}
|
||||
|
||||
boolean asinMode = request.getAsinFiles() != null && !request.getAsinFiles().isEmpty();
|
||||
Map<String, List<String>> skipAsinsByCountry = asinMode
|
||||
? new LinkedHashMap<>()
|
||||
: skipPriceAsinService.listAllSkipAsinsByCountry();
|
||||
Map<String, List<Map<String, String>>> asinRowsByCountry =
|
||||
!asinMode
|
||||
? new LinkedHashMap<>()
|
||||
: priceTrackTaskService.parseMatchAsinRowsByCountry(
|
||||
request.getAsinFiles(),
|
||||
request.getCountryCodes());
|
||||
|
||||
PriceTrackMatchShopsVo vo = new PriceTrackMatchShopsVo();
|
||||
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
||||
vo.setAsinRowsByCountry(asinRowsByCountry);
|
||||
for (String shopName : ordered) {
|
||||
vo.getItems().add(matchOneShop(shopName));
|
||||
vo.getItems().add(matchOneShop(shopName, skipAsinsByCountry));
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
@@ -198,9 +214,13 @@ public class PriceTrackService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
private PriceTrackMatchShopsVo.PriceTrackShopQueueItem matchOneShop(String shopName) {
|
||||
private PriceTrackMatchShopsVo.PriceTrackShopQueueItem matchOneShop(
|
||||
String shopName,
|
||||
Map<String, List<String>> skipAsins
|
||||
) {
|
||||
PriceTrackMatchShopsVo.PriceTrackShopQueueItem item = new PriceTrackMatchShopsVo.PriceTrackShopQueueItem();
|
||||
item.setShopName(shopName);
|
||||
item.setSkipAsins(skipAsins == null ? new LinkedHashMap<>() : new LinkedHashMap<>(skipAsins));
|
||||
try {
|
||||
ZiniaoShopMatchResultVo match = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false);
|
||||
if (match == null) {
|
||||
|
||||
+252
-9
@@ -4,7 +4,9 @@ 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.common.util.ExcelStreamReader;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
|
||||
import com.nanri.aiimage.modules.pricetrack.mapper.PriceTrackShopCandidateMapper;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
|
||||
@@ -30,12 +32,19 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -179,8 +188,33 @@ public class PriceTrackTaskService {
|
||||
return batch;
|
||||
}
|
||||
|
||||
public PriceTrackTaskBatchVo getTaskProgressBatch(List<Long> taskIds) {
|
||||
PriceTrackTaskBatchVo batch = new PriceTrackTaskBatchVo();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return batch;
|
||||
}
|
||||
for (Long taskId : taskIds) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
continue;
|
||||
}
|
||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
batch.getMissingTaskIds().add(taskId);
|
||||
continue;
|
||||
}
|
||||
PriceTrackTaskDetailVo detail = new PriceTrackTaskDetailVo();
|
||||
detail.setTask(toTaskItemVo(task));
|
||||
batch.getItems().add(detail);
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PriceTrackCreateTaskVo createTask(PriceTrackCreateTaskRequest request) {
|
||||
if (request.isStatusMode() == request.isAsinMode()) throw new BusinessException("跟价模式必须二选一");
|
||||
if (request.isAsinMode() && (request.getAsinFiles() == null || request.getAsinFiles().isEmpty())) {
|
||||
throw new BusinessException("自定义 ASIN 模式必须上传 ASIN 文件");
|
||||
}
|
||||
if (request.getUserId() == null || request.getUserId() <= 0) throw new BusinessException("user_id 不合法");
|
||||
if (request.getItems() == null || request.getItems().isEmpty()) throw new BusinessException("items 不能为空");
|
||||
|
||||
@@ -195,13 +229,14 @@ public class PriceTrackTaskService {
|
||||
}
|
||||
|
||||
// 汇总店铺名并查询需要跳过的 ASIN
|
||||
List<String> shopNames = uniqueItems.stream()
|
||||
.map(PriceTrackMatchShopsVo.PriceTrackShopQueueItem::getShopName)
|
||||
.filter(s -> s != null && !s.isBlank())
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<String, Map<String, String>> skipAsinsByShop = skipPriceAsinService.listSkipAsinsByShops(shopNames);
|
||||
log.info("[price-track] createTask skipAsins shops={}", skipAsinsByShop.keySet());
|
||||
Map<String, List<String>> skipAsinsByCountry = request.isAsinMode()
|
||||
? new LinkedHashMap<>()
|
||||
: skipPriceAsinService.listAllSkipAsinsByCountry();
|
||||
Map<String, List<Map<String, String>>> asinRowsByCountry = request.isAsinMode()
|
||||
? parseAsinRowsByCountry(request.getAsinFiles(), request.getCountryCodes())
|
||||
: new LinkedHashMap<>();
|
||||
log.info("[price-track] createTask skipAsins countries={} asinMode={}",
|
||||
skipAsinsByCountry.keySet(), request.isAsinMode());
|
||||
|
||||
// 创建任务记录
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
@@ -242,7 +277,8 @@ public class PriceTrackTaskService {
|
||||
ctx.put("statusMode", request.isStatusMode());
|
||||
ctx.put("asinMode", request.isAsinMode());
|
||||
ctx.put("countryCodes", request.getCountryCodes());
|
||||
ctx.put("skipAsinsByShop", skipAsinsByShop);
|
||||
ctx.put("skipAsinsByCountry", skipAsinsByCountry);
|
||||
ctx.put("asinRowsByCountry", asinRowsByCountry);
|
||||
ctx.put("items", uniqueItems);
|
||||
task.setRequestJson(objectMapper.writeValueAsString(ctx));
|
||||
task.setResultJson(objectMapper.writeValueAsString(snapshot));
|
||||
@@ -257,6 +293,8 @@ public class PriceTrackTaskService {
|
||||
PriceTrackCreateTaskVo vo = new PriceTrackCreateTaskVo();
|
||||
vo.setTaskId(task.getId());
|
||||
vo.setItems(snapshot);
|
||||
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
||||
vo.setAsinRowsByCountry(asinRowsByCountry);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@@ -272,7 +310,7 @@ public class PriceTrackTaskService {
|
||||
}
|
||||
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
||||
log.warn("[price-track] submitResult rejected taskId={} status={}", taskId, task.getStatus());
|
||||
throw new BusinessException("任务已结束,拒绝重复提交");
|
||||
throw new BusinessException(40901, "任务已结束,拒绝重复提交");
|
||||
}
|
||||
|
||||
priceTrackTaskCacheService.touchTaskHeartbeat(taskId);
|
||||
@@ -452,6 +490,211 @@ public class PriceTrackTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, List<Map<String, String>>> parseMatchAsinRowsByCountry(List<String> asinFiles, List<String> countryCodes) {
|
||||
return parseAsinRowsByCountry(asinFiles, countryCodes);
|
||||
}
|
||||
|
||||
private Map<String, List<Map<String, String>>> parseAsinRowsByCountry(List<String> asinFiles, List<String> countryCodes) {
|
||||
Map<String, List<Map<String, String>>> merged = new LinkedHashMap<>();
|
||||
if (asinFiles == null || asinFiles.isEmpty()) {
|
||||
return merged;
|
||||
}
|
||||
for (String rawPath : asinFiles) {
|
||||
if (rawPath == null || rawPath.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
File file = new File(rawPath);
|
||||
if (!file.isFile()) {
|
||||
throw new BusinessException("ASIN file not found: " + rawPath);
|
||||
}
|
||||
String lowerName = file.getName().toLowerCase(Locale.ROOT);
|
||||
if (lowerName.endsWith(".csv")) {
|
||||
mergeCountryRows(merged, parseCsvAsinRows(file, countryCodes));
|
||||
continue;
|
||||
}
|
||||
if (lowerName.endsWith(".xlsx") || lowerName.endsWith(".xls")) {
|
||||
mergeCountryRows(merged, parseWorkbookAsinRows(file, countryCodes));
|
||||
continue;
|
||||
}
|
||||
throw new BusinessException("Unsupported ASIN file type: " + file.getName());
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
private Map<String, List<Map<String, String>>> parseWorkbookAsinRows(File file, List<String> countryCodes) {
|
||||
Map<String, List<Map<String, String>>> out = new LinkedHashMap<>();
|
||||
Map<String, Integer>[] headerIndexHolder = new Map[]{Map.of()};
|
||||
String[] countryCodeHolder = new String[]{null};
|
||||
int[] sheetCount = new int[]{0};
|
||||
try {
|
||||
ExcelStreamReader.readAllSheets(file, new ExcelStreamReader.SheetRowHandler() {
|
||||
@Override
|
||||
public void onHeader(String sheetName, Integer sheetNo, Map<Integer, String> headerMap) {
|
||||
sheetCount[0]++;
|
||||
countryCodeHolder[0] = resolveCountryCode(sheetName, countryCodes, false);
|
||||
headerIndexHolder[0] = buildHeaderIndex(headerMap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRow(String sheetName, Integer sheetNo, int rowIndex, Map<Integer, String> headerMap, Map<Integer, String> rowMap) {
|
||||
String countryCode = countryCodeHolder[0];
|
||||
if (countryCode == null && sheetCount[0] == 1 && countryCodes != null && countryCodes.size() == 1) {
|
||||
countryCode = countryCodes.get(0);
|
||||
}
|
||||
if (countryCode == null) {
|
||||
return;
|
||||
}
|
||||
if (headerIndexHolder[0].isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, String> item = extractAsinRow(rowMap, headerIndexHolder[0]);
|
||||
if (!item.isEmpty()) {
|
||||
out.computeIfAbsent(countryCode, key -> new ArrayList<>()).add(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("Failed to parse ASIN workbook");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, List<Map<String, String>>> parseCsvAsinRows(File file, List<String> countryCodes) {
|
||||
if (countryCodes == null || countryCodes.size() != 1) {
|
||||
throw new BusinessException("CSV ASIN file requires exactly one selected country");
|
||||
}
|
||||
Map<String, List<Map<String, String>>> out = new LinkedHashMap<>();
|
||||
List<Map<String, String>> rows = out.computeIfAbsent(countryCodes.get(0), key -> new ArrayList<>());
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
|
||||
String headerLine = reader.readLine();
|
||||
if (headerLine == null) {
|
||||
return out;
|
||||
}
|
||||
List<String> headers = splitCsvLine(headerLine);
|
||||
Map<String, Integer> headerIndex = buildHeaderIndex(headers);
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
List<String> values = splitCsvLine(line);
|
||||
Map<String, String> item = extractAsinRow(values, headerIndex);
|
||||
if (!item.isEmpty()) {
|
||||
rows.add(item);
|
||||
}
|
||||
}
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("Failed to parse ASIN csv");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void mergeCountryRows(Map<String, List<Map<String, String>>> target, Map<String, List<Map<String, String>>> incoming) {
|
||||
for (Map.Entry<String, List<Map<String, String>>> entry : incoming.entrySet()) {
|
||||
target.computeIfAbsent(entry.getKey(), key -> new ArrayList<>()).addAll(entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveCountryCode(String sheetName, List<String> countryCodes, boolean singleSheetFallback) {
|
||||
String normalized = normalizeHeaderText(sheetName).toUpperCase(Locale.ROOT);
|
||||
for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) {
|
||||
if (code.name().equals(normalized) || normalizeHeaderText(code.getSheetName()).equals(normalizeHeaderText(sheetName))) {
|
||||
return code.name();
|
||||
}
|
||||
}
|
||||
if (singleSheetFallback && countryCodes != null && countryCodes.size() == 1) {
|
||||
return countryCodes.get(0);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<String, Integer> buildHeaderIndex(Map<Integer, String> headerRow) {
|
||||
List<String> headers = new ArrayList<>();
|
||||
int maxIndex = headerRow.keySet().stream().mapToInt(Integer::intValue).max().orElse(-1);
|
||||
for (int i = 0; i <= maxIndex; i++) {
|
||||
headers.add(normalizeCellText(headerRow.get(i)));
|
||||
}
|
||||
return buildHeaderIndex(headers);
|
||||
}
|
||||
|
||||
private Map<String, Integer> buildHeaderIndex(List<String> headers) {
|
||||
Map<String, Integer> index = new LinkedHashMap<>();
|
||||
for (int i = 0; i < headers.size(); i++) {
|
||||
String key = mapHeaderKey(headers.get(i));
|
||||
if (key != null && !index.containsKey(key)) {
|
||||
index.put(key, i);
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
private Map<String, String> extractAsinRow(Map<Integer, String> row, Map<String, Integer> headerIndex) {
|
||||
Map<String, String> item = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Integer> entry : headerIndex.entrySet()) {
|
||||
item.put(entry.getKey(), normalizeCellText(row.get(entry.getValue())));
|
||||
}
|
||||
return isEmptyAsinRow(item) ? Map.of() : item;
|
||||
}
|
||||
|
||||
private Map<String, String> extractAsinRow(List<String> values, Map<String, Integer> headerIndex) {
|
||||
Map<String, String> item = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Integer> entry : headerIndex.entrySet()) {
|
||||
int idx = entry.getValue();
|
||||
item.put(entry.getKey(), idx >= 0 && idx < values.size() ? normalizeCellText(values.get(idx)) : "");
|
||||
}
|
||||
return isEmptyAsinRow(item) ? Map.of() : item;
|
||||
}
|
||||
|
||||
private boolean isEmptyAsinRow(Map<String, String> row) {
|
||||
return row.values().stream().allMatch(value -> value == null || value.isBlank());
|
||||
}
|
||||
|
||||
private String mapHeaderKey(String rawHeader) {
|
||||
String header = normalizeHeaderText(rawHeader);
|
||||
if (header.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return switch (header) {
|
||||
case "店铺商城名称", "shopmallname" -> "shopMallName";
|
||||
case "asin" -> "asin";
|
||||
case "价格", "price" -> "price";
|
||||
case "推荐价", "recommendedprice" -> "recommendedPrice";
|
||||
case "最低价", "minimumprice" -> "minimumPrice";
|
||||
case "第一名", "firstplace" -> "firstPlace";
|
||||
case "第二名", "secondplace" -> "secondPlace";
|
||||
case "购物车店铺名", "cartshopname" -> "cartShopName";
|
||||
case "改价情况", "pricechangestatus" -> "priceChangeStatus";
|
||||
case "修改次数", "modifycount" -> "modifyCount";
|
||||
case "状态", "status" -> "status";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private String normalizeCellText(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("\ufeff", "")
|
||||
.replace("\u3000", " ")
|
||||
.replace("\r", " ")
|
||||
.replace("\n", " ")
|
||||
.trim()
|
||||
.replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private String normalizeHeaderText(String value) {
|
||||
return normalizeCellText(value)
|
||||
.replaceAll("\\s+", "")
|
||||
.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private List<String> splitCsvLine(String line) {
|
||||
return Arrays.stream((line == null ? "" : line).split(",", -1))
|
||||
.map(String::trim)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> dedupeQueueItems(List<PriceTrackMatchShopsVo.PriceTrackShopQueueItem> raw) {
|
||||
Map<String, PriceTrackMatchShopsVo.PriceTrackShopQueueItem> byNorm = new LinkedHashMap<>();
|
||||
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : raw) {
|
||||
|
||||
Reference in New Issue
Block a user