完成查询asin开发
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -433,7 +433,7 @@ def main():
|
||||
webview.start(
|
||||
debug=True,
|
||||
storage_path=cache_path,
|
||||
private_mode=True
|
||||
private_mode=False
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 不合法");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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 不合法");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.shopkey.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class QueryAsinImportStartVo {
|
||||
|
||||
private String importId;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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";
|
||||
|
||||
28
backend-java/src/main/resources/db/V31__query_asin.sql
Normal file
28
backend-java/src/main/resources/db/V31__query_asin.sql
Normal 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';
|
||||
@@ -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;
|
||||
@@ -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 前台备选店铺';
|
||||
Binary file not shown.
@@ -60,6 +60,11 @@ ADMIN_MENU_ACCESS_CONFIG = {
|
||||
'route_path': 'skip-price-asin',
|
||||
'error': '无权访问跳过跟价 ASIN 模块',
|
||||
},
|
||||
'query-asin': {
|
||||
'column_key': 'admin_query_asin',
|
||||
'route_path': 'query-asin',
|
||||
'error': '无权访问查询 ASIN 模块',
|
||||
},
|
||||
}
|
||||
|
||||
ADMIN_MENU_ACCESS_CONFIG.update({
|
||||
@@ -98,10 +103,10 @@ def _safe_version_key(version):
|
||||
return s or 'unknown'
|
||||
|
||||
|
||||
def _proxy_backend_java(method, path, *, params=None, json_data=None, files=None, data=None):
|
||||
def _proxy_backend_java(method, path, *, params=None, json_data=None, files=None, data=None, timeout=10):
|
||||
url = f"{backend_java_base_url}{path}"
|
||||
try:
|
||||
resp = requests.request(method=method, url=url, params=params, json=json_data, files=files, data=data, timeout=10)
|
||||
resp = requests.request(method=method, url=url, params=params, json=json_data, files=files, data=data, timeout=timeout)
|
||||
except requests.RequestException:
|
||||
return None, jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
|
||||
try:
|
||||
@@ -469,7 +474,7 @@ def list_users():
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要登录'}), 403
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
_, _, denied = _ensure_backend_menu_access('users', 'history', 'shop-manage', 'skip-price-asin')
|
||||
_, _, denied = _ensure_backend_menu_access('users', 'history', 'shop-manage', 'skip-price-asin', 'query-asin')
|
||||
if denied:
|
||||
return denied
|
||||
page_size = min(999, max(5, int(request.args.get('page_size', 15))))
|
||||
@@ -1436,7 +1441,7 @@ def delete_dedupe_total_data(item_id):
|
||||
@admin_api.route('/shop-manages')
|
||||
@login_required
|
||||
def list_shop_manages():
|
||||
role, current_row, denied = _ensure_backend_menu_access('shop-manage')
|
||||
role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin')
|
||||
if denied:
|
||||
return denied
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
@@ -1600,7 +1605,7 @@ def delete_shop_manage(item_id):
|
||||
@admin_api.route('/shop-manage-groups')
|
||||
@login_required
|
||||
def list_shop_manage_groups():
|
||||
role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin')
|
||||
role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin')
|
||||
if denied:
|
||||
return denied
|
||||
params = {}
|
||||
@@ -1638,7 +1643,7 @@ def list_shop_manage_groups():
|
||||
@admin_api.route('/shop-manage-group', methods=['POST'])
|
||||
@login_required
|
||||
def create_shop_manage_group():
|
||||
role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin')
|
||||
role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin')
|
||||
if denied:
|
||||
return denied
|
||||
data = request.get_json() or {}
|
||||
@@ -1683,7 +1688,7 @@ def create_shop_manage_group():
|
||||
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_shop_manage_group(item_id):
|
||||
role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin')
|
||||
role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin')
|
||||
if denied:
|
||||
return denied
|
||||
data = request.get_json() or {}
|
||||
@@ -1876,7 +1881,7 @@ def delete_skip_price_asin_country(item_id, country):
|
||||
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_shop_manage_group(item_id):
|
||||
role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin')
|
||||
role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin')
|
||||
if denied:
|
||||
return denied
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
@@ -1937,3 +1942,269 @@ def update_skip_price_asin_country(item_id, country):
|
||||
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
def _format_query_asin_item(item):
|
||||
return {
|
||||
'id': item.get('id'),
|
||||
'group_id': item.get('groupId'),
|
||||
'group_name': item.get('groupName') or '',
|
||||
'shop_name': item.get('shopName') or '',
|
||||
'asin_de': item.get('asinDe') or '',
|
||||
'asin_uk': item.get('asinUk') or '',
|
||||
'asin_fr': item.get('asinFr') or '',
|
||||
'asin_it': item.get('asinIt') or '',
|
||||
'asin_es': item.get('asinEs') or '',
|
||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
|
||||
}
|
||||
|
||||
|
||||
@admin_api.route('/query-asins')
|
||||
@login_required
|
||||
def list_query_asins():
|
||||
role, current_row, denied = _ensure_backend_menu_access('query-asin')
|
||||
if denied:
|
||||
return denied
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
|
||||
group_id_raw = (request.args.get('group_id') or '').strip()
|
||||
shop_name = (request.args.get('shop_name') or '').strip()
|
||||
asin = (request.args.get('asin') or '').strip()
|
||||
params = {'page': page, 'pageSize': page_size}
|
||||
if current_row and current_row.get('id'):
|
||||
params['operatorId'] = current_row.get('id')
|
||||
params['superAdmin'] = 'true' if role == 'super_admin' else 'false'
|
||||
if group_id_raw:
|
||||
try:
|
||||
group_id = int(group_id_raw)
|
||||
if group_id > 0:
|
||||
params['groupId'] = group_id
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if shop_name:
|
||||
params['shopName'] = shop_name
|
||||
if asin:
|
||||
params['asin'] = asin
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'GET',
|
||||
'/api/admin/query-asins',
|
||||
params=params,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
payload = result.get('data') or {}
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'items': [_format_query_asin_item(item) for item in (payload.get('items') or [])],
|
||||
'total': payload.get('total') or 0,
|
||||
'page': payload.get('page') or page,
|
||||
'page_size': payload.get('pageSize') or page_size,
|
||||
})
|
||||
|
||||
|
||||
def _format_query_asin_import_progress(progress):
|
||||
return {
|
||||
'status': progress.get('status') or 'pending',
|
||||
'total_rows': progress.get('totalRows') or 0,
|
||||
'processed_rows': progress.get('processedRows') or 0,
|
||||
'asin_count': progress.get('asinCount') or 0,
|
||||
'inserted_count': progress.get('insertedCount') or 0,
|
||||
'deleted_count': progress.get('deletedCount') or 0,
|
||||
'skipped_count': progress.get('skippedCount') or 0,
|
||||
'error_message': progress.get('errorMessage') or '',
|
||||
}
|
||||
|
||||
|
||||
@admin_api.route('/query-asins/import/<import_id>')
|
||||
@login_required
|
||||
def query_asin_import_progress(import_id):
|
||||
role, current_row, denied = _ensure_backend_menu_access('query-asin')
|
||||
if denied:
|
||||
return denied
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'GET',
|
||||
f'/api/admin/query-asins/import/{import_id}',
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'progress': _format_query_asin_import_progress(result.get('data') or {}),
|
||||
})
|
||||
|
||||
|
||||
@admin_api.route('/query-asins/import', methods=['POST'])
|
||||
@login_required
|
||||
def import_query_asins():
|
||||
role, current_row, denied = _ensure_backend_menu_access('query-asin')
|
||||
if denied:
|
||||
return denied
|
||||
file_storage = request.files.get('file')
|
||||
if not file_storage or file_storage.filename == '':
|
||||
return jsonify({'success': False, 'error': '请选择 Excel 文件'})
|
||||
group_id_raw = (request.form.get('group_id') or '').strip()
|
||||
params = {
|
||||
'operatorId': current_row.get('id') if current_row else None,
|
||||
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||
}
|
||||
if group_id_raw:
|
||||
params['groupId'] = group_id_raw
|
||||
files = {
|
||||
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
|
||||
}
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'POST',
|
||||
'/api/admin/query-asins/import',
|
||||
params=params,
|
||||
files=files,
|
||||
timeout=60,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
summary = result.get('data') or {}
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'msg': result.get('message') or '开始导入',
|
||||
'import_id': summary.get('importId') or '',
|
||||
})
|
||||
|
||||
|
||||
@admin_api.route('/query-asins/delete-import/<import_id>')
|
||||
@login_required
|
||||
def query_asin_delete_import_progress(import_id):
|
||||
role, current_row, denied = _ensure_backend_menu_access('query-asin')
|
||||
if denied:
|
||||
return denied
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'GET',
|
||||
f'/api/admin/query-asins/delete-import/{import_id}',
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'progress': _format_query_asin_import_progress(result.get('data') or {}),
|
||||
})
|
||||
|
||||
|
||||
@admin_api.route('/query-asins/delete-import', methods=['POST'])
|
||||
@login_required
|
||||
def delete_import_query_asins():
|
||||
role, current_row, denied = _ensure_backend_menu_access('query-asin')
|
||||
if denied:
|
||||
return denied
|
||||
file_storage = request.files.get('file')
|
||||
if not file_storage or file_storage.filename == '':
|
||||
return jsonify({'success': False, 'error': '请选择 Excel 文件'})
|
||||
group_id_raw = (request.form.get('group_id') or '').strip()
|
||||
params = {
|
||||
'operatorId': current_row.get('id') if current_row else None,
|
||||
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||
}
|
||||
if group_id_raw:
|
||||
params['groupId'] = group_id_raw
|
||||
files = {
|
||||
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
|
||||
}
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'POST',
|
||||
'/api/admin/query-asins/delete-import',
|
||||
params=params,
|
||||
files=files,
|
||||
timeout=60,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
summary = result.get('data') or {}
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'msg': result.get('message') or '开始删除',
|
||||
'import_id': summary.get('importId') or '',
|
||||
})
|
||||
|
||||
|
||||
@admin_api.route('/query-asin', methods=['POST'])
|
||||
@login_required
|
||||
def create_query_asin():
|
||||
role, current_row, denied = _ensure_backend_menu_access('query-asin')
|
||||
if denied:
|
||||
return denied
|
||||
data = request.get_json() or {}
|
||||
asin_mappings = data.get('asin_mappings') or {}
|
||||
fallback_asin = (data.get('asin') or '').strip()
|
||||
if not fallback_asin and isinstance(asin_mappings, dict):
|
||||
for value in asin_mappings.values():
|
||||
fallback_asin = (value or '').strip()
|
||||
if fallback_asin:
|
||||
break
|
||||
payload = {
|
||||
'groupId': data.get('group_id'),
|
||||
'shopName': (data.get('shop_name') or '').strip(),
|
||||
'countries': data.get('countries') or [],
|
||||
'asin': fallback_asin,
|
||||
'asinMappings': asin_mappings,
|
||||
}
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'POST',
|
||||
'/api/admin/query-asins',
|
||||
params={
|
||||
'operatorId': current_row.get('id') if current_row else None,
|
||||
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||
},
|
||||
json_data=payload,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'msg': result.get('message') or '保存成功',
|
||||
'item': _format_query_asin_item(result.get('data') or {}),
|
||||
})
|
||||
|
||||
|
||||
@admin_api.route('/query-asin/<int:item_id>/country/<country>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_query_asin_country(item_id, country):
|
||||
role, current_row, denied = _ensure_backend_menu_access('query-asin')
|
||||
if denied:
|
||||
return denied
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'DELETE',
|
||||
f'/api/admin/query-asins/{item_id}/countries/{country}',
|
||||
params={
|
||||
'operatorId': current_row.get('id') if current_row else None,
|
||||
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||
},
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
|
||||
|
||||
|
||||
@admin_api.route('/query-asin/<int:item_id>/country/<country>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_query_asin_country(item_id, country):
|
||||
role, current_row, denied = _ensure_backend_menu_access('query-asin')
|
||||
if denied:
|
||||
return denied
|
||||
data = request.get_json() or {}
|
||||
payload = {
|
||||
'asin': (data.get('asin') or '').strip(),
|
||||
}
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'PUT',
|
||||
f'/api/admin/query-asins/{item_id}/countries/{country}',
|
||||
params={
|
||||
'operatorId': current_row.get('id') if current_row else None,
|
||||
'superAdmin': 'true' if role == 'super_admin' else 'false',
|
||||
},
|
||||
json_data=payload,
|
||||
)
|
||||
if error_response is not None:
|
||||
return error_response, status
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'msg': result.get('message') or '保存成功',
|
||||
'item': _format_query_asin_item(result.get('data') or {}),
|
||||
})
|
||||
|
||||
@@ -413,6 +413,7 @@
|
||||
<div class="tab" data-tab="shop-keys">店铺密钥管理</div>
|
||||
<div class="tab" data-tab="shop-manage">店铺管理</div>
|
||||
<div class="tab" data-tab="skip-price-asin">跳过跟价 ASIN</div>
|
||||
<div class="tab" data-tab="query-asin">查询 ASIN</div>
|
||||
<div class="tab" data-tab="history">查看生成记录</div>
|
||||
<div class="tab" data-tab="version">版本管理</div>
|
||||
</div>
|
||||
@@ -817,6 +818,120 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="panel-query-asin" class="tab-panel">
|
||||
<div class="form-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">新增查询 ASIN</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="min-width:220px;">
|
||||
<label>分组</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<select id="queryAsinGroupSelect" style="min-width:150px;">
|
||||
<option value="">请选择分组</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary" id="btnManageQueryAsinGroups" type="button">管理分组</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="min-width:340px;">
|
||||
<label>店铺名</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input type="text" id="queryAsinShopName" placeholder="请输入店铺名称" style="flex:1;min-width:180px;">
|
||||
<button class="btn btn-secondary" id="btnChooseQueryAsinShop" type="button">选择店铺</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="min-width:220px;">
|
||||
<label>国家</label>
|
||||
<select id="queryAsinCountries" multiple size="5" style="min-height:98px;">
|
||||
<option value="DE">德国</option>
|
||||
<option value="UK">英国</option>
|
||||
<option value="FR">法国</option>
|
||||
<option value="IT">意大利</option>
|
||||
<option value="ES">西班牙</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="min-width:320px;flex:1;">
|
||||
<label>ASIN</label>
|
||||
<div id="queryAsinInputs" style="display:flex;flex-direction:column;gap:8px;">
|
||||
<div style="color:#999;font-size:13px;">请选择国家后输入对应 ASIN</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" id="btnCreateQueryAsin" style="align-self:flex-end;margin-bottom:40px;">新增 ASIN</button>
|
||||
</div>
|
||||
<p class="msg" id="msgQueryAsin"></p>
|
||||
<div class="form-row" style="align-items:flex-start;gap:16px;margin-top:18px;border-top:1px solid #f0f0f0;padding-top:16px;">
|
||||
<div style="flex:1;min-width:320px;">
|
||||
<h3 style="margin-bottom:12px;font-size:14px;">导入添加</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="min-width:320px;">
|
||||
<label>上传 Excel(列:分组、店铺名、德国/英国/法国/意大利/西班牙)</label>
|
||||
<input type="file" id="queryAsinImportFile" accept=".xlsx,.xls">
|
||||
</div>
|
||||
<button class="btn" id="btnImportQueryAsin" type="button">上传并添加</button>
|
||||
</div>
|
||||
<p class="msg" id="msgQueryAsinImport"></p>
|
||||
<div class="progress-wrap" id="queryAsinImportProgressWrap" style="display:none;">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="queryAsinImportProgressFill"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="queryAsinImportProgressText"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:320px;">
|
||||
<h3 style="margin-bottom:12px;font-size:14px;">导入删除</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="min-width:320px;">
|
||||
<label>上传 Excel(匹配分组 + 店铺名 + 国家 ASIN 后删除)</label>
|
||||
<input type="file" id="queryAsinDeleteImportFile" accept=".xlsx,.xls">
|
||||
</div>
|
||||
<button class="btn btn-danger" id="btnDeleteImportQueryAsin" type="button">上传并删除</button>
|
||||
</div>
|
||||
<p class="msg" id="msgQueryAsinDeleteImport"></p>
|
||||
<div class="progress-wrap" id="queryAsinDeleteImportProgressWrap" style="display:none;">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="queryAsinDeleteImportProgressFill"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="queryAsinDeleteImportProgressText"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">店铺列表</h3>
|
||||
<div class="form-row" style="margin-bottom:12px;">
|
||||
<div class="form-group" style="min-width:180px;">
|
||||
<label>分组</label>
|
||||
<select id="queryAsinFilterGroupId">
|
||||
<option value="">全部分组</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="min-width:220px;">
|
||||
<label>店铺名</label>
|
||||
<input type="text" id="queryAsinFilterShopName" placeholder="请输入店铺名">
|
||||
</div>
|
||||
<div class="form-group" style="min-width:220px;">
|
||||
<label>ASIN</label>
|
||||
<input type="text" id="queryAsinFilterAsin" placeholder="请输入 ASIN">
|
||||
</div>
|
||||
<button class="btn" id="btnSearchQueryAsin">查询</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>序号</th>
|
||||
<th>分组</th>
|
||||
<th>店铺名</th>
|
||||
<th>德国</th>
|
||||
<th>英国</th>
|
||||
<th>法国</th>
|
||||
<th>意大利</th>
|
||||
<th>西班牙</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="queryAsinListBody"></tbody>
|
||||
</table>
|
||||
<div class="pagination" id="queryAsinPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="panel-history" class="tab-panel">
|
||||
<div class="form-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">筛选条件</h3>
|
||||
@@ -1063,6 +1178,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-mask" id="chooseQueryAsinShopModal">
|
||||
<div class="modal" style="max-width:900px;width:90%;max-height:80vh;display:flex;flex-direction:column;">
|
||||
<h3>选择店铺</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="min-width:180px;">
|
||||
<label>分组</label>
|
||||
<select id="chooseQueryAsinShopGroupId">
|
||||
<option value="">全部分组</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="min-width:220px;">
|
||||
<label>店铺名</label>
|
||||
<input type="text" id="chooseQueryAsinShopKeyword" placeholder="请输入店铺名">
|
||||
</div>
|
||||
<button class="btn" id="btnSearchChooseQueryAsinShop" type="button">查询</button>
|
||||
</div>
|
||||
<div style="flex:1;min-height:0;overflow:auto;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>序号</th>
|
||||
<th>分组</th>
|
||||
<th>店铺名</th>
|
||||
<th>店铺商城名</th>
|
||||
<th>账号</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="chooseQueryAsinShopListBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="chooseQueryAsinShopPagination"></div>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button class="btn btn-secondary" id="btnCloseChooseQueryAsinShopModal" type="button">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-mask" id="editQueryAsinModal">
|
||||
<div class="modal">
|
||||
<h3>编辑查询 ASIN</h3>
|
||||
<input type="hidden" id="editQueryAsinId">
|
||||
<input type="hidden" id="editQueryAsinCountry">
|
||||
<div class="form-group">
|
||||
<label>店铺名</label>
|
||||
<input type="text" id="editQueryAsinShopName" readonly style="background:#f5f5f5;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>国家</label>
|
||||
<input type="text" id="editQueryAsinCountryLabel" readonly style="background:#f5f5f5;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>ASIN</label>
|
||||
<input type="text" id="editQueryAsinValue" placeholder="请输入 ASIN">
|
||||
</div>
|
||||
<p class="msg" id="msgEditQueryAsin"></p>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;">
|
||||
<button class="btn" id="btnSaveQueryAsin">保存</button>
|
||||
<button class="btn btn-secondary" id="btnCloseEditQueryAsin">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-mask" id="editShopManageModal">
|
||||
<div class="modal">
|
||||
<h3>编辑店铺</h3>
|
||||
@@ -1134,6 +1312,7 @@
|
||||
'shop-keys': 'panel-shop-keys',
|
||||
'shop-manage': 'panel-shop-manage',
|
||||
'skip-price-asin': 'panel-skip-price-asin',
|
||||
'query-asin': 'panel-query-asin',
|
||||
'history': 'panel-history',
|
||||
'version': 'panel-version'
|
||||
};
|
||||
@@ -1144,6 +1323,7 @@
|
||||
else if (tabName === 'shop-keys') loadShopKeys(1);
|
||||
else if (tabName === 'shop-manage') loadShopManage(1);
|
||||
else if (tabName === 'skip-price-asin') loadSkipPriceAsin(1);
|
||||
else if (tabName === 'query-asin') loadQueryAsin(1);
|
||||
else if (tabName === 'history') loadHistory(1);
|
||||
else if (tabName === 'version') loadVersions();
|
||||
}
|
||||
@@ -2304,10 +2484,16 @@
|
||||
var skipCreateSel = document.getElementById('skipPriceAsinGroupSelect');
|
||||
var skipFilterSel = document.getElementById('skipPriceAsinFilterGroupId');
|
||||
var chooseSkipShopSel = document.getElementById('chooseSkipPriceAsinShopGroupId');
|
||||
var queryCreateSel = document.getElementById('queryAsinGroupSelect');
|
||||
var queryFilterSel = document.getElementById('queryAsinFilterGroupId');
|
||||
var chooseQueryShopSel = document.getElementById('chooseQueryAsinShopGroupId');
|
||||
var selectedFilterId = filterSel ? filterSel.value : '';
|
||||
var selectedSkipCreateId = skipCreateSel ? skipCreateSel.value : '';
|
||||
var selectedSkipFilterId = skipFilterSel ? skipFilterSel.value : '';
|
||||
var selectedChooseSkipShopId = chooseSkipShopSel ? chooseSkipShopSel.value : '';
|
||||
var selectedQueryCreateId = queryCreateSel ? queryCreateSel.value : '';
|
||||
var selectedQueryFilterId = queryFilterSel ? queryFilterSel.value : '';
|
||||
var selectedChooseQueryShopId = chooseQueryShopSel ? chooseQueryShopSel.value : '';
|
||||
var createOpts = ['<option value="">请选择分组</option>'];
|
||||
var filterOpts = ['<option value="">全部分组</option>'];
|
||||
shopManageGroups.forEach(function (g) {
|
||||
@@ -2318,15 +2504,21 @@
|
||||
createSel.innerHTML = createOpts.join('');
|
||||
editSel.innerHTML = createOpts.join('');
|
||||
if (skipCreateSel) skipCreateSel.innerHTML = createOpts.join('');
|
||||
if (queryCreateSel) queryCreateSel.innerHTML = createOpts.join('');
|
||||
if (filterSel) filterSel.innerHTML = filterOpts.join('');
|
||||
if (skipFilterSel) skipFilterSel.innerHTML = filterOpts.join('');
|
||||
if (chooseSkipShopSel) chooseSkipShopSel.innerHTML = filterOpts.join('');
|
||||
if (queryFilterSel) queryFilterSel.innerHTML = filterOpts.join('');
|
||||
if (chooseQueryShopSel) chooseQueryShopSel.innerHTML = filterOpts.join('');
|
||||
if (selectedCreateId != null) createSel.value = String(selectedCreateId);
|
||||
if (selectedEditId != null) editSel.value = String(selectedEditId);
|
||||
if (filterSel && selectedFilterId) filterSel.value = selectedFilterId;
|
||||
if (skipCreateSel && selectedSkipCreateId) skipCreateSel.value = selectedSkipCreateId;
|
||||
if (skipFilterSel && selectedSkipFilterId) skipFilterSel.value = selectedSkipFilterId;
|
||||
if (chooseSkipShopSel && selectedChooseSkipShopId) chooseSkipShopSel.value = selectedChooseSkipShopId;
|
||||
if (queryCreateSel && selectedQueryCreateId) queryCreateSel.value = selectedQueryCreateId;
|
||||
if (queryFilterSel && selectedQueryFilterId) queryFilterSel.value = selectedQueryFilterId;
|
||||
if (chooseQueryShopSel && selectedChooseQueryShopId) chooseQueryShopSel.value = selectedChooseQueryShopId;
|
||||
}
|
||||
|
||||
function loadShopManageGroups(selectedCreateId, selectedEditId) {
|
||||
@@ -2361,7 +2553,7 @@
|
||||
|
||||
function updateShopManageGroupButtonsAccess() {
|
||||
var canManageGroups = !!currentUserId;
|
||||
['btnManageShopGroups', 'btnManageShopGroupsFromEdit', 'btnManageSkipPriceAsinGroups'].forEach(function (id) {
|
||||
['btnManageShopGroups', 'btnManageShopGroupsFromEdit', 'btnManageSkipPriceAsinGroups', 'btnManageQueryAsinGroups'].forEach(function (id) {
|
||||
var btn = document.getElementById(id);
|
||||
if (btn) btn.style.display = canManageGroups ? '' : 'none';
|
||||
});
|
||||
@@ -2447,6 +2639,10 @@
|
||||
setShopManageGroupGrantRoutes(['skip-price-asin']);
|
||||
openShopManageGroupModal();
|
||||
};
|
||||
document.getElementById('btnManageQueryAsinGroups').onclick = function () {
|
||||
setShopManageGroupGrantRoutes(['query-asin']);
|
||||
openShopManageGroupModal();
|
||||
};
|
||||
document.getElementById('btnSearchShopManage').onclick = function () {
|
||||
loadShopManage(1);
|
||||
};
|
||||
@@ -2990,6 +3186,515 @@
|
||||
};
|
||||
setupSkipPriceAsinShopPicker();
|
||||
renderSkipPriceAsinInputs();
|
||||
|
||||
// ========== 查询 ASIN ==========
|
||||
var queryAsinPage = 1, queryAsinPageSize = 15;
|
||||
var chooseQueryAsinShopPage = 1, chooseQueryAsinShopPageSize = 10;
|
||||
var queryAsinCountryColumns = [
|
||||
{ code: 'DE', field: 'asin_de', label: '德国' },
|
||||
{ code: 'UK', field: 'asin_uk', label: '英国' },
|
||||
{ code: 'FR', field: 'asin_fr', label: '法国' },
|
||||
{ code: 'IT', field: 'asin_it', label: '意大利' },
|
||||
{ code: 'ES', field: 'asin_es', label: '西班牙' }
|
||||
];
|
||||
function getQueryAsinCountryLabel(countryCode) {
|
||||
var country = queryAsinCountryColumns.find(function (item) { return item.code === countryCode; });
|
||||
return country ? country.label : countryCode;
|
||||
}
|
||||
function getSelectedQueryAsinCountries() {
|
||||
return Array.from(document.getElementById('queryAsinCountries').selectedOptions).map(function (option) {
|
||||
return option.value;
|
||||
});
|
||||
}
|
||||
function renderQueryAsinInputs() {
|
||||
var container = document.getElementById('queryAsinInputs');
|
||||
var selectedCountries = getSelectedQueryAsinCountries();
|
||||
var existingValues = {};
|
||||
container.querySelectorAll('[data-query-asin-country-input]').forEach(function (input) {
|
||||
existingValues[input.getAttribute('data-query-asin-country-input')] = input.value;
|
||||
});
|
||||
if (!selectedCountries.length) {
|
||||
container.innerHTML = '<div style="color:#999;font-size:13px;">请选择国家后输入 ASIN</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = selectedCountries.map(function (countryCode) {
|
||||
var label = getQueryAsinCountryLabel(countryCode);
|
||||
var value = existingValues[countryCode] || '';
|
||||
return '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">' +
|
||||
'<span style="min-width:56px;color:#555;">' + label + '</span>' +
|
||||
'<input type="text" data-query-asin-country-input="' + countryCode + '" value="' + value.replace(/"/g, '"') + '" placeholder="请输入' + label + ' ASIN" style="flex:1;min-width:180px;">' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
function collectQueryAsinMappings(countries) {
|
||||
var asinMappings = {};
|
||||
for (var i = 0; i < countries.length; i++) {
|
||||
var countryCode = countries[i];
|
||||
var input = document.querySelector('[data-query-asin-country-input="' + countryCode + '"]');
|
||||
var asin = input ? (input.value || '').trim().toUpperCase() : '';
|
||||
if (!asin) {
|
||||
throw new Error(getQueryAsinCountryLabel(countryCode) + ' ASIN 不能为空');
|
||||
}
|
||||
asinMappings[countryCode] = asin;
|
||||
}
|
||||
return asinMappings;
|
||||
}
|
||||
function buildChooseQueryAsinShopQuery(page) {
|
||||
var query = 'page=' + (page || 1) + '&page_size=' + chooseQueryAsinShopPageSize;
|
||||
var groupId = (document.getElementById('chooseQueryAsinShopGroupId').value || '').trim();
|
||||
var shopName = (document.getElementById('chooseQueryAsinShopKeyword').value || '').trim();
|
||||
if (groupId) query += '&group_id=' + encodeURIComponent(groupId);
|
||||
if (shopName) query += '&shop_name=' + encodeURIComponent(shopName);
|
||||
return query;
|
||||
}
|
||||
function loadChooseQueryAsinShops(page) {
|
||||
chooseQueryAsinShopPage = page || 1;
|
||||
fetch('/api/admin/shop-manages?' + buildChooseQueryAsinShopQuery(chooseQueryAsinShopPage))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
var tbody = document.getElementById('chooseQueryAsinShopListBody');
|
||||
if (!res.success) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
var items = res.items || [];
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">暂无店铺</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = items.map(function (item, index) {
|
||||
var rowNo = (chooseQueryAsinShopPage - 1) * chooseQueryAsinShopPageSize + index + 1;
|
||||
return '<tr><td>' + rowNo + '</td><td>' + (item.group_name || '') + '</td><td>' + (item.shop_name || '') + '</td><td>' + (item.mall_name || '') + '</td><td>' + (item.username || '') + '</td><td>' +
|
||||
'<button class="btn btn-sm" type="button" data-choose-query-asin-shop="' + item.id + '" data-shop-name="' + (item.shop_name || '').replace(/"/g, '"') + '" data-group-id="' + (item.group_id || '') + '">选择</button>' +
|
||||
'</td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
renderPagination('chooseQueryAsinShopPagination', res.total, res.page, res.page_size, loadChooseQueryAsinShops);
|
||||
bindChooseQueryAsinShopActions();
|
||||
})
|
||||
.catch(function () {
|
||||
document.getElementById('chooseQueryAsinShopListBody').innerHTML = '<tr><td colspan="6" class="empty-tip">请求失败</td></tr>';
|
||||
});
|
||||
}
|
||||
function bindChooseQueryAsinShopActions() {
|
||||
document.querySelectorAll('[data-choose-query-asin-shop]').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
document.getElementById('queryAsinShopName').value = (btn.dataset.shopName || '').replace(/"/g, '"');
|
||||
if (btn.dataset.groupId) {
|
||||
document.getElementById('queryAsinGroupSelect').value = btn.dataset.groupId;
|
||||
}
|
||||
document.getElementById('chooseQueryAsinShopModal').classList.remove('show');
|
||||
};
|
||||
});
|
||||
}
|
||||
function openChooseQueryAsinShopModal() {
|
||||
var currentGroupId = (document.getElementById('queryAsinGroupSelect').value || '').trim();
|
||||
if (currentGroupId) {
|
||||
document.getElementById('chooseQueryAsinShopGroupId').value = currentGroupId;
|
||||
}
|
||||
document.getElementById('chooseQueryAsinShopKeyword').value = (document.getElementById('queryAsinShopName').value || '').trim();
|
||||
document.getElementById('chooseQueryAsinShopModal').classList.add('show');
|
||||
loadChooseQueryAsinShops(1);
|
||||
}
|
||||
function buildQueryAsinQuery(page) {
|
||||
var query = 'page=' + (page || 1) + '&page_size=' + queryAsinPageSize;
|
||||
var groupId = (document.getElementById('queryAsinFilterGroupId').value || '').trim();
|
||||
var shopName = (document.getElementById('queryAsinFilterShopName').value || '').trim();
|
||||
var asin = (document.getElementById('queryAsinFilterAsin').value || '').trim();
|
||||
if (groupId) query += '&group_id=' + encodeURIComponent(groupId);
|
||||
if (shopName) query += '&shop_name=' + encodeURIComponent(shopName);
|
||||
if (asin) query += '&asin=' + encodeURIComponent(asin);
|
||||
return query;
|
||||
}
|
||||
function renderQueryAsinCell(item, country) {
|
||||
var asinValue = item[country.field] || '';
|
||||
var infoHtml = asinValue ? '<span>' + asinValue + '</span>' : '<span style="color:#999;">-</span>';
|
||||
var deleteHtml = asinValue
|
||||
? ('<button class="btn btn-sm btn-danger" data-query-asin-delete="' + item.id + '" data-country="' + country.code + '" data-shop-name="' + (item.shop_name || '').replace(/"/g, '"') + '">删除</button>')
|
||||
: '';
|
||||
return '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">' +
|
||||
infoHtml +
|
||||
'<button class="btn btn-sm" data-query-asin-edit="' + item.id + '" data-country="' + country.code + '" data-asin="' + asinValue.replace(/"/g, '"') + '" data-shop-name="' + (item.shop_name || '').replace(/"/g, '"') + '">编辑</button>' +
|
||||
deleteHtml +
|
||||
'</div>';
|
||||
}
|
||||
function openEditQueryAsinModal(itemId, countryCode, shopName, asinValue) {
|
||||
document.getElementById('editQueryAsinId').value = itemId || '';
|
||||
document.getElementById('editQueryAsinCountry').value = countryCode || '';
|
||||
document.getElementById('editQueryAsinShopName').value = shopName || '';
|
||||
document.getElementById('editQueryAsinCountryLabel').value = getQueryAsinCountryLabel(countryCode || '');
|
||||
document.getElementById('editQueryAsinValue').value = asinValue || '';
|
||||
document.getElementById('msgEditQueryAsin').textContent = '';
|
||||
document.getElementById('msgEditQueryAsin').className = 'msg';
|
||||
document.getElementById('editQueryAsinModal').classList.add('show');
|
||||
}
|
||||
function bindQueryAsinActions() {
|
||||
document.querySelectorAll('[data-query-asin-edit]').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
openEditQueryAsinModal(
|
||||
btn.dataset.queryAsinEdit,
|
||||
btn.dataset.country || '',
|
||||
(btn.dataset.shopName || '').replace(/"/g, '"'),
|
||||
(btn.dataset.asin || '').replace(/"/g, '"')
|
||||
);
|
||||
};
|
||||
});
|
||||
document.querySelectorAll('[data-query-asin-delete]').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
var shopName = (btn.dataset.shopName || '').replace(/"/g, '"');
|
||||
var countryCode = btn.dataset.country || '';
|
||||
if (!confirm('确定删除店铺“' + shopName + '”在 ' + countryCode + ' 的 ASIN 吗?')) return;
|
||||
fetch('/api/admin/query-asin/' + btn.dataset.queryAsinDelete + '/country/' + countryCode, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res.success) loadQueryAsin(queryAsinPage);
|
||||
else alert(res.error || '删除失败');
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
function loadQueryAsin(page) {
|
||||
queryAsinPage = page || 1;
|
||||
fetch('/api/admin/query-asins?' + buildQueryAsinQuery(queryAsinPage))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
var tbody = document.getElementById('queryAsinListBody');
|
||||
if (!res.success) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
var items = res.items || [];
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="empty-tip">暂无数据</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = items.map(function (item, index) {
|
||||
var rowNo = (queryAsinPage - 1) * queryAsinPageSize + index + 1;
|
||||
return '<tr><td>' + rowNo + '</td><td>' + (item.group_name || '') + '</td><td>' + (item.shop_name || '') + '</td>' +
|
||||
queryAsinCountryColumns.map(function (country) {
|
||||
return '<td>' + renderQueryAsinCell(item, country) + '</td>';
|
||||
}).join('') +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
renderPagination('queryAsinPagination', res.total, res.page, res.page_size, loadQueryAsin);
|
||||
bindQueryAsinActions();
|
||||
})
|
||||
.catch(function () {
|
||||
document.getElementById('queryAsinListBody').innerHTML = '<tr><td colspan="8" class="empty-tip">请求失败</td></tr>';
|
||||
});
|
||||
}
|
||||
var queryAsinImportPollTimer = null;
|
||||
var queryAsinDeleteImportPollTimer = null;
|
||||
function stopQueryAsinImportProgress() {
|
||||
if (queryAsinImportPollTimer) {
|
||||
clearInterval(queryAsinImportPollTimer);
|
||||
queryAsinImportPollTimer = null;
|
||||
}
|
||||
}
|
||||
function stopQueryAsinDeleteImportProgress() {
|
||||
if (queryAsinDeleteImportPollTimer) {
|
||||
clearInterval(queryAsinDeleteImportPollTimer);
|
||||
queryAsinDeleteImportPollTimer = null;
|
||||
}
|
||||
}
|
||||
function setQueryAsinImportProgress(percent, text) {
|
||||
var wrap = document.getElementById('queryAsinImportProgressWrap');
|
||||
var fill = document.getElementById('queryAsinImportProgressFill');
|
||||
var textEl = document.getElementById('queryAsinImportProgressText');
|
||||
wrap.style.display = 'block';
|
||||
fill.style.width = Math.max(0, Math.min(100, percent || 0)) + '%';
|
||||
textEl.textContent = text || '';
|
||||
}
|
||||
function setQueryAsinDeleteImportProgress(percent, text) {
|
||||
var wrap = document.getElementById('queryAsinDeleteImportProgressWrap');
|
||||
var fill = document.getElementById('queryAsinDeleteImportProgressFill');
|
||||
var textEl = document.getElementById('queryAsinDeleteImportProgressText');
|
||||
wrap.style.display = 'block';
|
||||
fill.style.width = Math.max(0, Math.min(100, percent || 0)) + '%';
|
||||
textEl.textContent = text || '';
|
||||
}
|
||||
function pollQueryAsinImport(importId) {
|
||||
stopQueryAsinImportProgress();
|
||||
function tick() {
|
||||
fetch('/api/admin/query-asins/import/' + encodeURIComponent(importId))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.success) {
|
||||
stopQueryAsinImportProgress();
|
||||
document.getElementById('msgQueryAsinImport').textContent = res.error || '查询导入进度失败';
|
||||
document.getElementById('msgQueryAsinImport').className = 'msg err';
|
||||
return;
|
||||
}
|
||||
var progress = res.progress || {};
|
||||
var totalRows = progress.total_rows || 0;
|
||||
var processedRows = progress.processed_rows || 0;
|
||||
var percent = totalRows > 0 ? Math.round(processedRows * 100 / totalRows) : 0;
|
||||
setQueryAsinImportProgress(percent, '处理中:已处理 ' + processedRows + ' / ' + totalRows + ',添加 ' + (progress.inserted_count || 0) + ',跳过 ' + (progress.skipped_count || 0));
|
||||
if (progress.status === 'success') {
|
||||
stopQueryAsinImportProgress();
|
||||
document.getElementById('msgQueryAsinImport').textContent = '导入成功:总行数 ' + totalRows + ',ASIN 数量 ' + (progress.asin_count || 0) + ',添加 ' + (progress.inserted_count || 0) + ',跳过 ' + (progress.skipped_count || 0);
|
||||
document.getElementById('msgQueryAsinImport').className = 'msg ok';
|
||||
loadQueryAsin(1);
|
||||
} else if (progress.status === 'failed') {
|
||||
stopQueryAsinImportProgress();
|
||||
document.getElementById('msgQueryAsinImport').textContent = progress.error_message || '导入失败';
|
||||
document.getElementById('msgQueryAsinImport').className = 'msg err';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
stopQueryAsinImportProgress();
|
||||
document.getElementById('msgQueryAsinImport').textContent = '查询导入进度失败';
|
||||
document.getElementById('msgQueryAsinImport').className = 'msg err';
|
||||
});
|
||||
}
|
||||
tick();
|
||||
queryAsinImportPollTimer = setInterval(tick, 1000);
|
||||
}
|
||||
function pollQueryAsinDeleteImport(importId) {
|
||||
stopQueryAsinDeleteImportProgress();
|
||||
function tick() {
|
||||
fetch('/api/admin/query-asins/delete-import/' + encodeURIComponent(importId))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.success) {
|
||||
stopQueryAsinDeleteImportProgress();
|
||||
document.getElementById('msgQueryAsinDeleteImport').textContent = res.error || '查询删除进度失败';
|
||||
document.getElementById('msgQueryAsinDeleteImport').className = 'msg err';
|
||||
return;
|
||||
}
|
||||
var progress = res.progress || {};
|
||||
var totalRows = progress.total_rows || 0;
|
||||
var processedRows = progress.processed_rows || 0;
|
||||
var percent = totalRows > 0 ? Math.round(processedRows * 100 / totalRows) : 0;
|
||||
setQueryAsinDeleteImportProgress(percent, '处理中:已处理 ' + processedRows + ' / ' + totalRows + ',删除 ' + (progress.deleted_count || 0) + ',跳过 ' + (progress.skipped_count || 0));
|
||||
if (progress.status === 'success') {
|
||||
stopQueryAsinDeleteImportProgress();
|
||||
document.getElementById('msgQueryAsinDeleteImport').textContent = '删除成功:总行数 ' + totalRows + ',ASIN 数量 ' + (progress.asin_count || 0) + ',删除 ' + (progress.deleted_count || 0) + ',跳过 ' + (progress.skipped_count || 0);
|
||||
document.getElementById('msgQueryAsinDeleteImport').className = 'msg ok';
|
||||
loadQueryAsin(1);
|
||||
} else if (progress.status === 'failed') {
|
||||
stopQueryAsinDeleteImportProgress();
|
||||
document.getElementById('msgQueryAsinDeleteImport').textContent = progress.error_message || '删除失败';
|
||||
document.getElementById('msgQueryAsinDeleteImport').className = 'msg err';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
stopQueryAsinDeleteImportProgress();
|
||||
document.getElementById('msgQueryAsinDeleteImport').textContent = '查询删除进度失败';
|
||||
document.getElementById('msgQueryAsinDeleteImport').className = 'msg err';
|
||||
});
|
||||
}
|
||||
tick();
|
||||
queryAsinDeleteImportPollTimer = setInterval(tick, 1000);
|
||||
}
|
||||
function uploadQueryAsinImport(deleteMode) {
|
||||
var fileInput = document.getElementById(deleteMode ? 'queryAsinDeleteImportFile' : 'queryAsinImportFile');
|
||||
var msgEl = document.getElementById(deleteMode ? 'msgQueryAsinDeleteImport' : 'msgQueryAsinImport');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
if (deleteMode) {
|
||||
stopQueryAsinDeleteImportProgress();
|
||||
document.getElementById('queryAsinDeleteImportProgressWrap').style.display = 'none';
|
||||
} else {
|
||||
stopQueryAsinImportProgress();
|
||||
document.getElementById('queryAsinImportProgressWrap').style.display = 'none';
|
||||
}
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
msgEl.textContent = '请选择 Excel 文件';
|
||||
msgEl.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
var file = fileInput.files[0];
|
||||
var lowerName = (file.name || '').toLowerCase();
|
||||
if (!(lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls'))) {
|
||||
msgEl.textContent = '仅支持 .xlsx 或 .xls 文件';
|
||||
msgEl.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
if (deleteMode && !confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?')) {
|
||||
return;
|
||||
}
|
||||
var formData = new FormData();
|
||||
formData.append('file', file);
|
||||
var groupId = (document.getElementById('queryAsinGroupSelect').value || '').trim();
|
||||
if (groupId) formData.append('group_id', groupId);
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', deleteMode ? '/api/admin/query-asins/delete-import' : '/api/admin/query-asins/import', true);
|
||||
xhr.upload.onprogress = function (event) {
|
||||
if (event.lengthComputable) {
|
||||
var percent = Math.round(event.loaded * 100 / event.total);
|
||||
if (deleteMode) setQueryAsinDeleteImportProgress(percent, '上传中:' + percent + '%');
|
||||
else setQueryAsinImportProgress(percent, '上传中:' + percent + '%');
|
||||
}
|
||||
};
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState !== 4) return;
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
msgEl.textContent = '请求失败';
|
||||
msgEl.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
var res;
|
||||
try { res = JSON.parse(xhr.responseText || '{}'); } catch (e) { res = { success: false, error: '返回格式错误' }; }
|
||||
if (!res.success) {
|
||||
msgEl.textContent = res.error || (deleteMode ? '删除失败' : '导入失败');
|
||||
msgEl.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
if (deleteMode) {
|
||||
setQueryAsinDeleteImportProgress(100, '上传完成,后端处理中...');
|
||||
pollQueryAsinDeleteImport(res.import_id);
|
||||
} else {
|
||||
setQueryAsinImportProgress(100, '上传完成,后端处理中...');
|
||||
pollQueryAsinImport(res.import_id);
|
||||
}
|
||||
fileInput.value = '';
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
msgEl.textContent = '请求失败';
|
||||
msgEl.className = 'msg err';
|
||||
};
|
||||
xhr.send(formData);
|
||||
}
|
||||
document.getElementById('btnChooseQueryAsinShop').onclick = openChooseQueryAsinShopModal;
|
||||
document.getElementById('btnSearchChooseQueryAsinShop').onclick = function () {
|
||||
loadChooseQueryAsinShops(1);
|
||||
};
|
||||
document.getElementById('chooseQueryAsinShopGroupId').onchange = function () {
|
||||
loadChooseQueryAsinShops(1);
|
||||
};
|
||||
document.getElementById('chooseQueryAsinShopKeyword').addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
loadChooseQueryAsinShops(1);
|
||||
}
|
||||
});
|
||||
document.getElementById('btnCloseChooseQueryAsinShopModal').onclick = function () {
|
||||
document.getElementById('chooseQueryAsinShopModal').classList.remove('show');
|
||||
};
|
||||
document.getElementById('btnCloseEditQueryAsin').onclick = function () {
|
||||
document.getElementById('editQueryAsinModal').classList.remove('show');
|
||||
};
|
||||
document.getElementById('btnSaveQueryAsin').onclick = function () {
|
||||
var itemId = (document.getElementById('editQueryAsinId').value || '').trim();
|
||||
var countryCode = (document.getElementById('editQueryAsinCountry').value || '').trim();
|
||||
var asin = (document.getElementById('editQueryAsinValue').value || '').trim().toUpperCase();
|
||||
var msgEl = document.getElementById('msgEditQueryAsin');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
if (!itemId || !countryCode) {
|
||||
msgEl.textContent = '缺少编辑记录';
|
||||
msgEl.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
if (!asin) {
|
||||
msgEl.textContent = 'ASIN 不能为空';
|
||||
msgEl.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
fetch('/api/admin/query-asin/' + itemId + '/country/' + countryCode, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ asin: asin })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.success) {
|
||||
msgEl.textContent = res.error || '保存失败';
|
||||
msgEl.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
document.getElementById('editQueryAsinModal').classList.remove('show');
|
||||
loadQueryAsin(queryAsinPage);
|
||||
})
|
||||
.catch(function () {
|
||||
msgEl.textContent = '请求失败';
|
||||
msgEl.className = 'msg err';
|
||||
});
|
||||
};
|
||||
document.getElementById('queryAsinCountries').addEventListener('change', renderQueryAsinInputs);
|
||||
document.getElementById('btnSearchQueryAsin').onclick = function () {
|
||||
loadQueryAsin(1);
|
||||
};
|
||||
document.getElementById('queryAsinFilterShopName').addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
loadQueryAsin(1);
|
||||
}
|
||||
});
|
||||
document.getElementById('queryAsinFilterAsin').addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
loadQueryAsin(1);
|
||||
}
|
||||
});
|
||||
document.getElementById('btnCreateQueryAsin').onclick = function () {
|
||||
var groupId = (document.getElementById('queryAsinGroupSelect').value || '').trim();
|
||||
var shopName = (document.getElementById('queryAsinShopName').value || '').trim();
|
||||
var countries = getSelectedQueryAsinCountries();
|
||||
var msgEl = document.getElementById('msgQueryAsin');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
if (!groupId || !shopName || !countries.length) {
|
||||
msgEl.textContent = '请完整填写分组、店铺名、国家和 ASIN';
|
||||
msgEl.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
var asinMappings = {};
|
||||
try {
|
||||
asinMappings = collectQueryAsinMappings(countries);
|
||||
} catch (err) {
|
||||
msgEl.textContent = err.message || '请输入 ASIN';
|
||||
msgEl.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
var fallbackAsin = '';
|
||||
Object.keys(asinMappings).some(function (countryCode) {
|
||||
fallbackAsin = asinMappings[countryCode] || '';
|
||||
return !!fallbackAsin;
|
||||
});
|
||||
fetch('/api/admin/query-asin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
group_id: Number(groupId),
|
||||
shop_name: shopName,
|
||||
countries: countries,
|
||||
asin: fallbackAsin,
|
||||
asin_mappings: asinMappings
|
||||
})
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.success) {
|
||||
msgEl.textContent = res.error || '保存失败';
|
||||
msgEl.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
document.getElementById('queryAsinGroupSelect').value = '';
|
||||
document.getElementById('queryAsinShopName').value = '';
|
||||
Array.from(document.getElementById('queryAsinCountries').options).forEach(function (option) {
|
||||
option.selected = false;
|
||||
});
|
||||
renderQueryAsinInputs();
|
||||
msgEl.textContent = res.msg || '保存成功';
|
||||
msgEl.className = 'msg ok';
|
||||
loadQueryAsin(1);
|
||||
})
|
||||
.catch(function () {
|
||||
msgEl.textContent = '请求失败';
|
||||
msgEl.className = 'msg err';
|
||||
});
|
||||
};
|
||||
document.getElementById('btnImportQueryAsin').onclick = function () {
|
||||
uploadQueryAsinImport(false);
|
||||
};
|
||||
document.getElementById('btnDeleteImportQueryAsin').onclick = function () {
|
||||
uploadQueryAsinImport(true);
|
||||
};
|
||||
renderQueryAsinInputs();
|
||||
|
||||
function loadVersions() {
|
||||
fetch('/api/admin/versions')
|
||||
.then(function (r) { return r.json(); })
|
||||
|
||||
13
frontend-vue/query-asin.html
Normal file
13
frontend-vue/query-asin.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>巡店删除 - 数富AI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/query-asin-main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -620,6 +620,11 @@ function clearActiveQueueTask() {
|
||||
saveQueueState();
|
||||
}
|
||||
|
||||
function isRecordMissingError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error || "");
|
||||
return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message);
|
||||
}
|
||||
|
||||
function removeMatchedRowLocally(row: PatrolDeleteShopQueueItem) {
|
||||
matchedItems.value = matchedItems.value.filter(
|
||||
(item) => rowKeyForMatch(item) !== rowKeyForMatch(row),
|
||||
@@ -627,6 +632,14 @@ function removeMatchedRowLocally(row: PatrolDeleteShopQueueItem) {
|
||||
saveMatchedItems();
|
||||
}
|
||||
|
||||
function removeHistoryItemLocally(item: PatrolDeleteHistoryItem) {
|
||||
historyItems.value = historyItems.value.filter((row) => {
|
||||
if (item.resultId != null && row.resultId === item.resultId) return false;
|
||||
if (item.taskId != null && row.taskId === item.taskId) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeQueueItems(
|
||||
base: PatrolDeleteShopQueueItem[],
|
||||
incoming: PatrolDeleteShopQueueItem[],
|
||||
@@ -1013,6 +1026,15 @@ async function deleteTaskRecord(item: PatrolDeleteHistoryItem) {
|
||||
await Promise.all([loadCandidates(), refreshTaskViews()]);
|
||||
ElMessage.success("已删除");
|
||||
} catch (error) {
|
||||
if (isRecordMissingError(error)) {
|
||||
if (item.taskId && activeTaskId.value === item.taskId) {
|
||||
clearActiveQueueTask();
|
||||
}
|
||||
removeHistoryItemLocally(item);
|
||||
await loadCandidates();
|
||||
ElMessage.success("后端记录已不存在,已同步移除本地记录");
|
||||
return;
|
||||
}
|
||||
ElMessage.error(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -818,6 +818,11 @@ function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | nul
|
||||
return `${(row.shopName || '').trim()}\u0001${row.shopId ?? ''}`
|
||||
}
|
||||
|
||||
function isRecordMissingError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error || '')
|
||||
return /记录不存在|不存在|已删除|not\s*found|404/i.test(message)
|
||||
}
|
||||
|
||||
function buildSkipAsinDeletePolicy() {
|
||||
const enabled = statusModeEnabled.value && !asinModeEnabled.value
|
||||
return {
|
||||
@@ -843,6 +848,14 @@ function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: numb
|
||||
saveMatchedItemsToStorage()
|
||||
}
|
||||
|
||||
function removeHistoryItemLocally(item: PriceTrackHistoryItem) {
|
||||
historyItems.value = historyItems.value.filter((row) => {
|
||||
if (item.resultId != null && row.resultId === item.resultId) return false
|
||||
if (item.taskId != null && row.taskId === item.taskId) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
async function removeMatchedRow(row: PriceTrackShopQueueItem) {
|
||||
const backup = [...matchedItems.value]
|
||||
removeMatchedRowsLocally([row])
|
||||
@@ -860,6 +873,13 @@ async function removeMatchedRow(row: PriceTrackShopQueueItem) {
|
||||
}
|
||||
ElMessage.success('已从匹配结果中移除')
|
||||
} catch (e) {
|
||||
if (isRecordMissingError(e)) {
|
||||
await loadHistory()
|
||||
await loadDashboard()
|
||||
syncPollingIdsWithHistory()
|
||||
ElMessage.success('后端记录已不存在,已同步移除本地记录')
|
||||
return
|
||||
}
|
||||
matchedItems.value = backup
|
||||
saveMatchedItemsToStorage()
|
||||
ElMessage.error(e instanceof Error ? e.message : '同步后端失败')
|
||||
@@ -1587,6 +1607,15 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
|
||||
syncPollingIdsWithHistory()
|
||||
ElMessage.success('已删除')
|
||||
} catch (e) {
|
||||
if (isRecordMissingError(e)) {
|
||||
if (item.taskId != null) removePollingTask(item.taskId)
|
||||
removeMatchedRowsLocally([item])
|
||||
removeHistoryItemLocally(item)
|
||||
await loadDashboard()
|
||||
syncPollingIdsWithHistory()
|
||||
ElMessage.success('后端记录已不存在,已同步移除本地记录')
|
||||
return
|
||||
}
|
||||
ElMessage.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,6 +469,11 @@ function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | nul
|
||||
return `${name}\u0001${id}`
|
||||
}
|
||||
|
||||
function isRecordMissingError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error || '')
|
||||
return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message)
|
||||
}
|
||||
|
||||
function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
|
||||
if (!rows.length) return
|
||||
const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
|
||||
@@ -476,6 +481,14 @@ function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: numb
|
||||
saveMatchedItemsToStorage()
|
||||
}
|
||||
|
||||
function removeHistoryItemLocally(item: ProductRiskHistoryItem) {
|
||||
historyItems.value = historyItems.value.filter((row) => {
|
||||
if (item.resultId != null && row.resultId === item.resultId) return false
|
||||
if (item.taskId != null && row.taskId === item.taskId) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
async function removeMatchedRow(row: ProductRiskShopQueueItem) {
|
||||
const key = rowKeyForMatch(row)
|
||||
const backup = [...matchedItems.value]
|
||||
@@ -495,6 +508,13 @@ async function removeMatchedRow(row: ProductRiskShopQueueItem) {
|
||||
}
|
||||
ElMessage.success('已从匹配结果中移除')
|
||||
} catch (e) {
|
||||
if (isRecordMissingError(e)) {
|
||||
await loadHistory()
|
||||
await loadDashboard()
|
||||
syncPollingIdsWithHistory()
|
||||
ElMessage.success('后端记录已不存在,已同步移除本地记录')
|
||||
return
|
||||
}
|
||||
matchedItems.value = backup
|
||||
saveMatchedItemsToStorage()
|
||||
ElMessage.error(e instanceof Error ? e.message : '同步后端失败')
|
||||
@@ -530,6 +550,17 @@ async function deleteTaskRecord(item: ProductRiskHistoryItem) {
|
||||
syncPollingIdsWithHistory()
|
||||
ElMessage.success('已删除')
|
||||
} catch (e) {
|
||||
if (isRecordMissingError(e)) {
|
||||
if (item.taskId != null && item.taskId > 0) {
|
||||
removePollingTask(item.taskId)
|
||||
}
|
||||
removeMatchedRowsLocally([item])
|
||||
removeHistoryItemLocally(item)
|
||||
await loadDashboard()
|
||||
syncPollingIdsWithHistory()
|
||||
ElMessage.success('后端记录已不存在,已同步移除本地记录')
|
||||
return
|
||||
}
|
||||
ElMessage.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
1167
frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue
Normal file
1167
frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -191,7 +191,7 @@ function loadTaskSnapshotsFromStorage() { try { const raw = window.localStorage.
|
||||
function saveTaskSnapshotsToStorage() { setStorageJson(taskSnapshotsStorageKey(), taskSnapshots.value, Object.keys(taskSnapshots.value).length === 0) }
|
||||
function loadMatchedItemsFromStorage() { try { const raw = window.localStorage.getItem(matchedItemsStorageKey()); matchedItems.value = raw ? JSON.parse(raw) : [] } catch { matchedItems.value = [] } }
|
||||
function saveMatchedItemsToStorage() { setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0) }
|
||||
function isTaskMissingError(error: unknown) { const message = error instanceof Error ? error.message : String(error || ''); return message.includes('任务不存在') }
|
||||
function isTaskMissingError(error: unknown) { const message = error instanceof Error ? error.message : String(error || ''); return /记录不存在|任务不存在|不存在|已删除|not\s*found|404/i.test(message) }
|
||||
function syncPollingIdsWithTaskState() { const nextIds = pollingTaskIds.value.filter((taskId) => (taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status) === 'RUNNING'); if (nextIds.length === pollingTaskIds.value.length) return; pollingTaskIds.value = nextIds; savePollingIds() }
|
||||
function addPollingTask(taskId: number) { if (!pollingTaskIds.value.includes(taskId)) { pollingTaskIds.value = [...pollingTaskIds.value, taskId]; savePollingIds() } }
|
||||
function clearDispatchTimer(taskId: number) { const timer = dispatchTimers.get(taskId); if (timer) { window.clearTimeout(timer); dispatchTimers.delete(taskId) } }
|
||||
@@ -334,10 +334,15 @@ async function deleteTaskRecord(item: ShopMatchHistoryItem) {
|
||||
await Promise.allSettled([loadHistory(), loadCandidates(), loadDashboard()])
|
||||
ElMessage.success('删除成功')
|
||||
} catch (error) {
|
||||
if (taskId > 0 && isTaskMissingError(error)) {
|
||||
if (isTaskMissingError(error)) {
|
||||
if (taskId > 0) {
|
||||
removeTaskLocally(taskId)
|
||||
} else if (resultId > 0) {
|
||||
historyItems.value = historyItems.value.filter((row) => Number(row.resultId || 0) !== resultId)
|
||||
}
|
||||
removeMatchedRowsLocally([item])
|
||||
await Promise.allSettled([loadHistory(), loadCandidates(), loadDashboard()])
|
||||
ElMessage.success('任务已在服务端删除,本地记录已同步清理')
|
||||
ElMessage.success('后端记录已不存在,本地记录已同步清理')
|
||||
return
|
||||
}
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
|
||||
@@ -53,6 +53,7 @@ type ActiveNavKey =
|
||||
| 'shop-match'
|
||||
| 'pricing'
|
||||
| 'patrol-delete'
|
||||
| 'query-asin'
|
||||
|
||||
type NavItem = {
|
||||
key: string
|
||||
@@ -95,6 +96,7 @@ const navGroups: ReadonlyArray<NavGroup> = [
|
||||
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' },
|
||||
{ key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html' },
|
||||
{ key: 'patrol-delete', label: '巡店删除', href: '/new_web_source/patrol-delete.html' },
|
||||
{ key: 'query-asin', label: '查询ASIN', href: '/new_web_source/query-asin.html' },
|
||||
{ key: 'withdraw', label: '取款' },
|
||||
{ key: 'shop-status', label: '店铺状态查询' },
|
||||
],
|
||||
|
||||
13
frontend-vue/src/query-asin-main.ts
Normal file
13
frontend-vue/src/query-asin-main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import BrandQueryAsinTab from '@/pages/brand/components/BrandQueryAsinTab.vue'
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
createApp(BrandQueryAsinTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
|
||||
@@ -581,6 +581,7 @@ export interface ProductRiskShopQueueItem {
|
||||
matchedUserId?: number;
|
||||
matchStatus?: string;
|
||||
matchMessage?: string;
|
||||
queryAsins?: QueryAsinCountryAsins[];
|
||||
}
|
||||
|
||||
export interface ProductRiskMatchShopsVo {
|
||||
@@ -1189,6 +1190,165 @@ export function deletePatrolDeleteHistory(resultId: number) {
|
||||
);
|
||||
}
|
||||
|
||||
export type QueryAsinCandidateVo = ProductRiskCandidateVo;
|
||||
export type QueryAsinDashboardVo = ProductRiskDashboardVo;
|
||||
export type QueryAsinShopQueueItem = ProductRiskShopQueueItem;
|
||||
|
||||
export interface QueryAsinCountryAsins {
|
||||
country: string;
|
||||
asins: string[];
|
||||
}
|
||||
|
||||
export interface QueryAsinTaskItem {
|
||||
shopName?: string;
|
||||
matched?: boolean;
|
||||
shopId?: string;
|
||||
platform?: string;
|
||||
companyName?: string;
|
||||
matchStatus?: string;
|
||||
matchMessage?: string;
|
||||
queryAsins: QueryAsinCountryAsins[];
|
||||
}
|
||||
|
||||
export interface QueryAsinHistoryItem {
|
||||
resultId?: number;
|
||||
taskId?: number;
|
||||
shopName?: string;
|
||||
shopId?: string;
|
||||
platform?: string;
|
||||
companyName?: string;
|
||||
matched?: boolean;
|
||||
matchStatus?: string;
|
||||
matchMessage?: string;
|
||||
taskStatus?: string;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
createdAt?: string;
|
||||
finishedAt?: string;
|
||||
outputFilename?: string;
|
||||
downloadUrl?: string;
|
||||
queryAsins: QueryAsinCountryAsins[];
|
||||
}
|
||||
|
||||
export interface QueryAsinHistoryVo {
|
||||
items: QueryAsinHistoryItem[];
|
||||
}
|
||||
|
||||
export interface QueryAsinTaskBatchVo {
|
||||
items: QueryAsinHistoryItem[];
|
||||
missingTaskIds: number[];
|
||||
}
|
||||
|
||||
export interface QueryAsinCreateTaskVo {
|
||||
taskId: number;
|
||||
items: QueryAsinHistoryItem[];
|
||||
}
|
||||
|
||||
export function listQueryAsinCandidates() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<QueryAsinCandidateVo[]>>(`${JAVA_API_PREFIX}/query-asin/candidates`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function addQueryAsinCandidate(shopName: string) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<QueryAsinCandidateVo>, { user_id: number; shop_name: string }>(
|
||||
`${JAVA_API_PREFIX}/query-asin/candidates`,
|
||||
{ user_id: getCurrentUserId(), shop_name: shopName },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteQueryAsinCandidate(id: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/query-asin/candidates/${id}`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function matchQueryAsinShops(shopNames: string[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ProductRiskMatchShopsVo>, { user_id: number; shop_names: string[] }>(
|
||||
`${JAVA_API_PREFIX}/query-asin/match-shops`,
|
||||
{ user_id: getCurrentUserId(), shop_names: shopNames },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getQueryAsinDashboard() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<QueryAsinDashboardVo>>(`${JAVA_API_PREFIX}/query-asin/dashboard`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getQueryAsinHistory() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<QueryAsinHistoryVo>>(`${JAVA_API_PREFIX}/query-asin/history`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getQueryAsinTaskProgressBatch(taskIds: number[]) {
|
||||
return postTaskProgressBatch<QueryAsinTaskBatchVo>(
|
||||
`${JAVA_API_PREFIX}/query-asin/tasks/progress/batch`,
|
||||
taskIds,
|
||||
);
|
||||
}
|
||||
|
||||
export function createQueryAsinTask(items: QueryAsinTaskItem[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<QueryAsinCreateTaskVo>, { user_id: number; items: QueryAsinTaskItem[] }>(
|
||||
`${JAVA_API_PREFIX}/query-asin/tasks`,
|
||||
{ user_id: getCurrentUserId(), items },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function submitQueryAsinTaskResult(
|
||||
taskId: number,
|
||||
payload: {
|
||||
shops: Array<{
|
||||
shopName: string;
|
||||
error?: string;
|
||||
queryAsins: QueryAsinCountryAsins[];
|
||||
shopDone?: boolean;
|
||||
submissionId?: string;
|
||||
chunkIndex?: number;
|
||||
chunkTotal?: number;
|
||||
}>;
|
||||
},
|
||||
) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<null>, typeof payload>(`${JAVA_API_PREFIX}/query-asin/tasks/${taskId}/result`, payload),
|
||||
);
|
||||
}
|
||||
|
||||
export function getQueryAsinResultDownloadUrl(resultId: number) {
|
||||
return getJavaDownloadUrl(`/query-asin/results/${resultId}/download`);
|
||||
}
|
||||
|
||||
export function deleteQueryAsinTask(taskId: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/query-asin/tasks/${taskId}`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteQueryAsinHistory(resultId: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/query-asin/history/${resultId}`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 跟价 ==========
|
||||
|
||||
export interface PriceTrackCandidateVo {
|
||||
|
||||
@@ -50,6 +50,7 @@ export default defineConfig({
|
||||
'shop-match': resolve(__dirname, 'shop-match.html'),
|
||||
'price-track': resolve(__dirname, 'price-track.html'),
|
||||
'patrol-delete': resolve(__dirname, 'patrol-delete.html'),
|
||||
'query-asin': resolve(__dirname, 'query-asin.html'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'assets/[name].js',
|
||||
|
||||
1
new_web_source/assets/brand-UU-ckLq6.js
Normal file
1
new_web_source/assets/brand-UU-ckLq6.js
Normal file
@@ -0,0 +1 @@
|
||||
import{aO as r}from"./pywebview-CeWJDVeG.js";const n="";function a(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{a as e};
|
||||
1
new_web_source/assets/convert-7wWJ02Tw.css
Normal file
1
new_web_source/assets/convert-7wWJ02Tw.css
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/convert.js
Normal file
1
new_web_source/assets/convert.js
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/dedupe-BpNHwt51.css
Normal file
1
new_web_source/assets/dedupe-BpNHwt51.css
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/dedupe.js
Normal file
1
new_web_source/assets/dedupe.js
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/delete-brand-CWLpe7lu.css
Normal file
1
new_web_source/assets/delete-brand-CWLpe7lu.css
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/delete-brand.js
Normal file
1
new_web_source/assets/delete-brand.js
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/el-input-CezJelDw.css
Normal file
1
new_web_source/assets/el-input-CezJelDw.css
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/listingFilters-BpGOU_pJ.js
Normal file
1
new_web_source/assets/listingFilters-BpGOU_pJ.js
Normal file
@@ -0,0 +1 @@
|
||||
const e=[{value:"SearchSuppressed",label:"在搜索结果中禁止显示"},{value:"ApprovalRequired",label:"需要批准"},{value:"Active",label:"在售"},{value:"DetailPageRemoved",label:"详情页面已删除"}];export{e as L};
|
||||
1
new_web_source/assets/listingFilters-CK58rX4v.css
Normal file
1
new_web_source/assets/listingFilters-CK58rX4v.css
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/patrol-delete-BPu1AAPM.css
Normal file
1
new_web_source/assets/patrol-delete-BPu1AAPM.css
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/patrol-delete.js
Normal file
1
new_web_source/assets/patrol-delete.js
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/price-track-85NbckiJ.css
Normal file
1
new_web_source/assets/price-track-85NbckiJ.css
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/price-track-RLJUgMl_.css
Normal file
1
new_web_source/assets/price-track-RLJUgMl_.css
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/price-track.js
Normal file
1
new_web_source/assets/price-track.js
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/product-risk-cfL3Ch5-.css
Normal file
1
new_web_source/assets/product-risk-cfL3Ch5-.css
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/product-risk.js
Normal file
1
new_web_source/assets/product-risk.js
Normal file
File diff suppressed because one or more lines are too long
55
new_web_source/assets/pywebview-CeWJDVeG.js
Normal file
55
new_web_source/assets/pywebview-CeWJDVeG.js
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/pywebview-ij4pgMq8.css
Normal file
1
new_web_source/assets/pywebview-ij4pgMq8.css
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/query-asin-B5e5m-vs.css
Normal file
1
new_web_source/assets/query-asin-B5e5m-vs.css
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/query-asin-Ba06RE4W.css
Normal file
1
new_web_source/assets/query-asin-Ba06RE4W.css
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/query-asin-CcqwgsLE.css
Normal file
1
new_web_source/assets/query-asin-CcqwgsLE.css
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/query-asin.js
Normal file
1
new_web_source/assets/query-asin.js
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/shop-match-Banz7DWL.css
Normal file
1
new_web_source/assets/shop-match-Banz7DWL.css
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/shop-match.js
Normal file
1
new_web_source/assets/shop-match.js
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/split-CRUIYKS6.css
Normal file
1
new_web_source/assets/split-CRUIYKS6.css
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/split.js
Normal file
1
new_web_source/assets/split.js
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/zh-cn-DyKmB71i.js
Normal file
1
new_web_source/assets/zh-cn-DyKmB71i.js
Normal file
File diff suppressed because one or more lines are too long
@@ -5,9 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>格式转换 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/convert.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据去重 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>删除品牌 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-DfRLfrtm.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-CWLpe7lu.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>巡店删除 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/patrol-delete.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DuyK2jB1.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/zh-cn-Z9PBnl-n.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D74jYk6P.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/patrol-delete-DIvFZUTh.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/zh-cn-DyKmB71i.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/patrol-delete-BPu1AAPM.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/el-input-CezJelDw.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>跟价 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/price-track.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DuyK2jB1.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-CKgrwtni.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D74jYk6P.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/price-track-BccJT4Qr.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/price-track-RLJUgMl_.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/el-input-CezJelDw.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>商品风险解决 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/product-risk.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/listingFilters-BpGOU_pJ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/listingFilters-CK58rX4v.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/product-risk-CG-1Iq4P.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/product-risk-cfL3Ch5-.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/el-input-CezJelDw.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
18
new_web_source/query-asin.html
Normal file
18
new_web_source/query-asin.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>巡店删除 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/query-asin.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/zh-cn-DyKmB71i.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/query-asin-B5e5m-vs.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/el-input-CezJelDw.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>匹配店铺 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/shop-match.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DuyK2jB1.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/zh-cn-Z9PBnl-n.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/zh-cn-DyKmB71i.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/listingFilters-BpGOU_pJ.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D74jYk6P.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/listingFilters-CK58rX4v.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/shop-match-DNFA8ceR.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/shop-match-Banz7DWL.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/el-input-CezJelDw.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据拆分 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/split.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-DiP0HdY6.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-D-mMH8F6.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-ij4pgMq8.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user