完成查询asin开发

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -433,7 +433,7 @@ def main():
webview.start( webview.start(
debug=True, debug=True,
storage_path=cache_path, storage_path=cache_path,
private_mode=True private_mode=False
) )

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>格式转换 - 数富AI</title> <title>格式转换 - 数富AI</title>
<script type="module" crossorigin src="/assets/convert.js"></script> <script type="module" crossorigin src="/assets/convert.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-9YBa--7x.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CRmRvyGD.js"> <link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.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/convert-7wWJ02Tw.css"> <link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
</head> </head>
<body> <body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据去重 - 数富AI</title> <title>数据去重 - 数富AI</title>
<script type="module" crossorigin src="/assets/dedupe.js"></script> <script type="module" crossorigin src="/assets/dedupe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-9YBa--7x.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CRmRvyGD.js"> <link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.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/dedupe-BpNHwt51.css"> <link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
</head> </head>
<body> <body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>删除品牌 - 数富AI</title> <title>删除品牌 - 数富AI</title>
<script type="module" crossorigin src="/assets/delete-brand.js"></script> <script type="module" crossorigin src="/assets/delete-brand.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-9YBa--7x.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CRmRvyGD.js"> <link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.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/delete-brand-CWLpe7lu.css"> <link rel="stylesheet" crossorigin href="/assets/delete-brand-CWLpe7lu.css">
</head> </head>
<body> <body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据拆分 - 数富AI</title> <title>数据拆分 - 数富AI</title>
<script type="module" crossorigin src="/assets/split.js"></script> <script type="module" crossorigin src="/assets/split.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-9YBa--7x.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-CeWJDVeG.js">
<link rel="modulepreload" crossorigin href="/assets/brand-CRmRvyGD.js"> <link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.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/split-CRUIYKS6.css"> <link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
</head> </head>
<body> <body>

View File

@@ -413,7 +413,8 @@
<a class="nav-item" href="/new_web_source/product-risk.html">商品风险解决</a> <a class="nav-item" href="/new_web_source/product-risk.html">商品风险解决</a>
<a class="nav-item" href="/new_web_source/shop-match.html">定时匹配</a> <a class="nav-item" href="/new_web_source/shop-match.html">定时匹配</a>
<a class="nav-item" href="/new_web_source/price-track.html">跟价</a> <a class="nav-item" href="/new_web_source/price-track.html">跟价</a>
<span class="nav-item disabled">巡店删除</span> <a class="nav-item" href="/new_web_source/patrol-delete.html">巡店删除</a>
<a class="nav-item" href="/new_web_source/query-asin.html">查询ASIN</a>
<span class="nav-item disabled">取款</span> <span class="nav-item disabled">取款</span>
<span class="nav-item disabled">店铺状态查询</span> <span class="nav-item disabled">店铺状态查询</span>
</div> </div>

View File

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

View File

@@ -28,6 +28,7 @@ public class PermissionMenuSchemaInitializer {
new DefaultAdminMenu("店铺密钥管理", "admin_shop_keys", "shop-keys", 40), new DefaultAdminMenu("店铺密钥管理", "admin_shop_keys", "shop-keys", 40),
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50), new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50),
new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin", 60), 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_history", "history", 70),
new DefaultAdminMenu("版本管理", "admin_version", "version", 80) new DefaultAdminMenu("版本管理", "admin_version", "version", 80)
); );

View File

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

View File

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

View File

@@ -0,0 +1,374 @@
package com.nanri.aiimage.modules.queryasin.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCreateTaskRequest;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinSubmitResultRequest;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinTaskBatchRequest;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinCreateTaskVo;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinHistoryVo;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinTaskBatchVo;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinResolveService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/query-asin")
@Tag(
name = "查询ASIN",
description = "查询ASIN模块管理备选店铺、批量匹配紫鸟店铺、创建任务、接收 Python 分片结果、自动收尾生成 Excel 并提供下载。涉及查询和删除的接口需要携带 user_id。")
public class QueryAsinTaskController {
private final QueryAsinResolveService queryAsinResolveService;
private final QueryAsinTaskService queryAsinTaskService;
@GetMapping("/candidates")
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在查询ASIN模块中已保存的备选店铺。")
public ApiResponse<List<ProductRiskCandidateVo>> listCandidates(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(queryAsinResolveService.listCandidates(userId));
}
@PostMapping("/candidates")
@Operation(summary = "新增备选店铺", description = "向查询ASIN备选区新增一条店铺记录供后续匹配和创建任务使用。")
public ApiResponse<ProductRiskCandidateVo> addCandidate(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "新增备选店铺请求,需传入用户 ID 和店铺名。",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ProductRiskCandidateAddRequest.class),
examples = @ExampleObject(
name = "新增店铺示例",
value = """
{
"user_id": 1,
"shop_name": "郭亚芳"
}
""")))
@Valid @RequestBody ProductRiskCandidateAddRequest request) {
return ApiResponse.success(queryAsinResolveService.addCandidate(request));
}
@DeleteMapping("/candidates/{id}")
@Operation(summary = "删除备选店铺", description = "删除当前用户名下的一条备选店铺记录。")
public ApiResponse<Void> deleteCandidate(
@Parameter(description = "备选店铺记录主键", example = "10")
@PathVariable Long id,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
queryAsinResolveService.deleteCandidate(userId, id);
return ApiResponse.success(null);
}
@PostMapping("/match-shops")
@Operation(summary = "批量匹配店铺", description = "根据店铺名批量匹配紫鸟店铺索引,返回是否命中、店铺 ID、平台、公司和匹配状态。")
public ApiResponse<ProductRiskMatchShopsVo> matchShops(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "批量匹配请求,传入 user_id 和待匹配的店铺名称列表。",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ProductRiskMatchShopsRequest.class),
examples = @ExampleObject(
name = "匹配店铺示例",
value = """
{
"user_id": 1,
"shop_names": [
"郭亚芳",
"示例店铺A"
]
}
""")))
@Valid @RequestBody ProductRiskMatchShopsRequest request) {
return ApiResponse.success(queryAsinResolveService.matchShops(request));
}
@GetMapping("/dashboard")
@Operation(summary = "查询统计看板", description = "返回备选店铺数、已处理任务数、成功任务数和失败任务数。")
public ApiResponse<ProductRiskDashboardVo> dashboard(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(queryAsinTaskService.dashboard(userId));
}
@GetMapping("/history")
@Operation(summary = "查询任务记录", description = "返回当前用户在查询ASIN模块中的当前任务和历史任务记录。")
public ApiResponse<QueryAsinHistoryVo> history(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(queryAsinTaskService.listHistory(userId));
}
@PostMapping("/tasks/progress/batch")
@Operation(summary = "批量查询查询ASIN任务进度", description = "仅返回任务状态和店铺结果摘要,用于前端轮询降载。")
public ApiResponse<QueryAsinTaskBatchVo> taskProgressBatch(@Valid @RequestBody QueryAsinTaskBatchRequest request) {
return ApiResponse.success(queryAsinTaskService.getTaskProgressBatch(request.getTaskIds()));
}
@PostMapping("/tasks")
@Operation(summary = "创建查询ASIN任务", description = "根据已匹配的店铺创建任务和占位结果记录,后续由 Python 端处理并回传结果。")
public ApiResponse<QueryAsinCreateTaskVo> createTask(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "创建任务请求。items 中每一项代表一个待处理店铺,需携带默认模板结构。",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = QueryAsinCreateTaskRequest.class),
examples = @ExampleObject(
name = "创建任务示例",
value = """
{
"user_id": 1,
"items": [
{
"shopName": "郭亚芳",
"matched": true,
"shopId": "27730548558377",
"platform": "亚马逊",
"companyName": "示例公司",
"matchStatus": "MATCHED",
"matchMessage": "索引已命中",
"countrySections": [
{
"country": "德国",
"rows": [
{
"status": "全部",
"quantity": "",
"deleteQuantity": "",
"processStatus": ""
}
]
}
],
"cartRatios": [
{
"country": "德国",
"ratio": ""
}
]
}
]
}
""")))
@Valid @RequestBody QueryAsinCreateTaskRequest request) {
return ApiResponse.success(queryAsinTaskService.createTask(request));
}
@PostMapping("/tasks/{taskId}/result")
@Operation(
summary = "提交查询ASIN结果",
description = "供 Python 端回传处理结果。支持按店铺分片多次提交,服务端会合并分片、按店铺完成状态自动收尾,并在任务结束后生成 Excel。")
public ApiResponse<Void> submitResult(
@Parameter(description = "任务主键,必须是运行中的任务", example = "3089")
@PathVariable Long taskId,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "查询ASIN结果回传请求。shops 表示本次提交的店铺结果分片列表。shopDone=true 表示该店铺已全部提交完成。",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = QueryAsinSubmitResultRequest.class),
examples = {
@ExampleObject(
name = "成功分片示例",
summary = "一个店铺分片回传,包含状态数据和购物车比例数据",
value = """
{
"shops": [
{
"shopName": "郭亚芳",
"submissionId": "query-asin:3089:郭亚芳:1711111111111",
"chunkIndex": 1,
"chunkTotal": 2,
"shopDone": false,
"countrySections": [
{
"country": "德国",
"rows": [
{
"status": "正常",
"quantity": "12",
"deleteQuantity": "3",
"processStatus": "处理中"
},
{
"status": "下架",
"quantity": "2",
"deleteQuantity": "2",
"processStatus": "已完成"
}
]
},
{
"country": "英国",
"rows": [
{
"status": "正常",
"quantity": "5",
"deleteQuantity": "1",
"processStatus": "处理中"
}
]
}
],
"cartRatios": [
{
"country": "德国",
"ratio": "25%"
},
{
"country": "英国",
"ratio": "18%"
}
]
}
]
}
"""),
@ExampleObject(
name = "店铺完成示例",
summary = "最后一片提交完成后,将 shopDone 置为 true",
value = """
{
"shops": [
{
"shopName": "郭亚芳",
"submissionId": "query-asin:3089:郭亚芳:1711111111111",
"chunkIndex": 2,
"chunkTotal": 2,
"shopDone": true,
"countrySections": [
{
"country": "法国",
"rows": [
{
"status": "正常",
"quantity": "4",
"deleteQuantity": "0",
"processStatus": "已完成"
}
]
}
],
"cartRatios": [
{
"country": "法国",
"ratio": "12%"
}
]
}
]
}
"""),
@ExampleObject(
name = "失败示例",
summary = "店铺处理失败时直接回传错误信息",
value = """
{
"shops": [
{
"shopName": "郭亚芳",
"shopDone": true,
"error": "紫鸟页面加载超时,未能完成删除数据采集"
}
]
}
""")
}))
@Valid @RequestBody QueryAsinSubmitResultRequest request,
jakarta.servlet.http.HttpServletResponse response) {
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("application/json;charset=UTF-8");
queryAsinTaskService.submitResult(taskId, request);
return ApiResponse.success(null);
}
@GetMapping("/results/{resultId}/download")
@Operation(summary = "下载结果文件", description = "按结果记录下载服务端已生成并上传到 OSS 的查询ASIN Excel 文件。")
public void downloadResult(
@Parameter(description = "结果记录主键", example = "4599")
@PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId,
jakarta.servlet.http.HttpServletResponse response) {
String url = queryAsinTaskService.resolveResultDownloadUrl(resultId, userId);
String filename = queryAsinTaskService.resolveResultDownloadFilename(resultId, userId);
if (url == null || url.isBlank()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果");
}
try {
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
response.setContentType("application/octet-stream");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename);
try (InputStream in = URI.create(url).toURL().openStream()) {
byte[] buffer = new byte[65536];
int read;
while ((read = in.read(buffer)) != -1) {
response.getOutputStream().write(buffer, 0, read);
}
response.getOutputStream().flush();
}
} catch (Exception ex) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
}
}
@DeleteMapping("/tasks/{taskId}")
@Operation(summary = "删除任务", description = "删除整条查询ASIN任务以及该任务下关联的所有结果记录。")
public ApiResponse<Void> deleteTask(
@Parameter(description = "任务主键", example = "3089")
@PathVariable Long taskId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
queryAsinTaskService.deleteTask(taskId, userId);
return ApiResponse.success(null);
}
@DeleteMapping("/history/{resultId}")
@Operation(summary = "删除单条历史记录", description = "删除一条查询ASIN结果记录并同步重算其所属任务状态。")
public ApiResponse<Void> deleteHistory(
@Parameter(description = "结果记录主键", example = "4599")
@PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
queryAsinTaskService.deleteHistory(resultId, userId);
return ApiResponse.success(null);
}
}

View File

@@ -0,0 +1,10 @@
package com.nanri.aiimage.modules.queryasin.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.queryasin.model.entity.QueryAsinShopCandidateEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface QueryAsinShopCandidateMapper extends BaseMapper<QueryAsinShopCandidateEntity> {
}

View File

@@ -0,0 +1,16 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "购物车比例数据")
public class QueryAsinCartRatioDto {
@Schema(description = "国家名称", example = "德国")
private String country;
@Schema(description = "购物车比例", example = "25%")
private String ratio;
}

View File

@@ -0,0 +1,18 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "查询 ASIN 国家维度 ASIN 列表")
public class QueryAsinCountryAsinsDto {
@Schema(description = "国家编码,支持 DE、UK、FR、IT、ES", example = "DE")
private String country;
@Schema(description = "该国家需要查询的 ASIN 列表")
private List<String> asins = new ArrayList<>();
}

View File

@@ -0,0 +1,25 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "国家状态数据中的单行记录")
public class QueryAsinCountryMetricRowDto {
@Schema(description = "商品状态", example = "正常")
private String status;
@Schema(description = "该状态下的商品数量", example = "12")
private String quantity;
@JsonProperty("deleteQuantity")
@Schema(description = "已删除数量", example = "3")
private String deleteQuantity;
@JsonProperty("processStatus")
@Schema(description = "处理结果或处理状态", example = "处理中")
private String processStatus;
}

View File

@@ -0,0 +1,19 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "单个国家的状态数据分组")
public class QueryAsinCountrySectionDto {
@Schema(description = "国家名称", example = "德国")
private String country;
@Schema(description = "该国家下的多行状态数据")
private List<QueryAsinCountryMetricRowDto> rows = new ArrayList<>();
}

View File

@@ -0,0 +1,27 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "创建查询ASIN任务请求")
public class QueryAsinCreateTaskRequest {
@NotNull
@JsonProperty("user_id")
@Schema(description = "当前用户 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
private Long userId;
@Valid
@NotEmpty
@Schema(description = "待创建的店铺任务列表,通常由已匹配成功的店铺组成", requiredMode = Schema.RequiredMode.REQUIRED)
private List<QueryAsinTaskItemDto> items = new ArrayList<>();
}

View File

@@ -0,0 +1,49 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "单个店铺的查询ASIN结果分片")
public class QueryAsinShopPayloadDto {
@JsonProperty("shopName")
@Schema(description = "店铺名称,服务端按店铺名称聚合分片", example = "郭亚芳")
private String shopName;
@Schema(description = "店铺处理失败时的错误信息;有值时该店铺会直接标记失败", example = "紫鸟页面加载超时,未能完成删除数据采集")
private String error;
@JsonProperty("countrySections")
@Schema(description = "各国家的状态数据列表。同一国家可分多片提交,服务端会按顺序合并")
private List<QueryAsinCountrySectionDto> countrySections = new ArrayList<>();
@JsonProperty("cartRatios")
@Schema(description = "购物车比例数据列表,通常只在店铺首行展示")
private List<QueryAsinCartRatioDto> cartRatios = new ArrayList<>();
@JsonProperty("queryAsins")
@Schema(description = "Python 回传的查询 ASIN 结果分片;如果 Python 仅透传后台 ASIN也按该字段提交")
private List<QueryAsinCountryAsinsDto> queryAsins = new ArrayList<>();
@JsonProperty("shopDone")
@Schema(description = "该店铺是否已全部提交完成。最后一片应传 true", example = "true")
private Boolean shopDone;
@JsonProperty("submissionId")
@Schema(description = "本次店铺提交批次标识,便于问题排查与日志跟踪", example = "query-asin:3089:郭亚芳:1711111111111")
private String submissionId;
@JsonProperty("chunkIndex")
@Schema(description = "当前分片序号,建议从 1 开始", example = "1")
private Integer chunkIndex;
@JsonProperty("chunkTotal")
@Schema(description = "当前店铺总分片数", example = "2")
private Integer chunkTotal;
}

View File

@@ -0,0 +1,20 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "查询ASIN结果回传请求")
public class QueryAsinSubmitResultRequest {
@Valid
@NotEmpty
@Schema(description = "本次提交的店铺结果分片列表,支持一次提交多个店铺", requiredMode = Schema.RequiredMode.REQUIRED)
private List<QueryAsinShopPayloadDto> shops = new ArrayList<>();
}

View File

@@ -0,0 +1,15 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class QueryAsinTaskBatchRequest {
@NotEmpty(message = "taskIds 不能为空")
private List<Long> taskIds = new ArrayList<>();
}

View File

@@ -0,0 +1,45 @@
package com.nanri.aiimage.modules.queryasin.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "查询ASIN任务中的单个店铺项")
public class QueryAsinTaskItemDto {
@JsonProperty("shopName")
@Schema(description = "店铺名称", example = "郭亚芳")
private String shopName;
@Schema(description = "该店铺是否已完成紫鸟匹配,创建任务时必须为 true", example = "true")
private boolean matched;
@JsonProperty("shopId")
@Schema(description = "匹配到的紫鸟店铺 ID", example = "27730548558377")
private String shopId;
@Schema(description = "店铺所属平台", example = "亚马逊")
private String platform;
@JsonProperty("companyName")
@Schema(description = "店铺所属公司名称", example = "示例公司")
private String companyName;
@JsonProperty("matchStatus")
@Schema(description = "匹配状态,例如 MATCHED、PENDING、CONFLICT、INDEX_STALE", example = "MATCHED")
private String matchStatus;
@JsonProperty("matchMessage")
@Schema(description = "匹配状态说明", example = "索引已命中")
private String matchMessage;
@Valid
@JsonProperty("queryAsins")
@Schema(description = "后台查询 ASIN 维护表中的国家 ASIN 列表查询ASIN任务按全表返回不按店铺过滤")
private List<QueryAsinCountryAsinsDto> queryAsins = new ArrayList<>();
}

View File

@@ -0,0 +1,20 @@
package com.nanri.aiimage.modules.queryasin.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_query_asin_shop_candidate")
public class QueryAsinShopCandidateEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String shopName;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,14 @@
package com.nanri.aiimage.modules.queryasin.model.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class QueryAsinCreateTaskVo {
private Long taskId;
private List<QueryAsinResultItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,13 @@
package com.nanri.aiimage.modules.queryasin.model.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class QueryAsinHistoryVo {
private List<QueryAsinResultItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,57 @@
package com.nanri.aiimage.modules.queryasin.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCartRatioDto;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountrySectionDto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Data
public class QueryAsinResultItemVo {
private Long resultId;
private Long taskId;
@JsonProperty("shopName")
private String shopName;
@JsonProperty("shopId")
private String shopId;
private String platform;
@JsonProperty("companyName")
private String companyName;
private boolean matched;
@JsonProperty("matchStatus")
private String matchStatus;
@JsonProperty("matchMessage")
private String matchMessage;
@JsonProperty("taskStatus")
private String taskStatus;
private Boolean success;
private String error;
private LocalDateTime createdAt;
private LocalDateTime finishedAt;
private String outputFilename;
private String downloadUrl;
@JsonProperty("countrySections")
private List<QueryAsinCountrySectionDto> countrySections = new ArrayList<>();
@JsonProperty("cartRatios")
private List<QueryAsinCartRatioDto> cartRatios = new ArrayList<>();
@JsonProperty("queryAsins")
private List<QueryAsinCountryAsinsDto> queryAsins = new ArrayList<>();
}

View File

@@ -0,0 +1,15 @@
package com.nanri.aiimage.modules.queryasin.model.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class QueryAsinTaskBatchVo {
private List<QueryAsinResultItemVo> items = new ArrayList<>();
private List<Long> missingTaskIds = new ArrayList<>();
}

View File

@@ -0,0 +1,112 @@
package com.nanri.aiimage.modules.queryasin.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinResultItemVo;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.FileOutputStream;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
@Slf4j
public class QueryAsinExcelAssemblyService {
private static final List<String> COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES");
private static final String[] HEADER = {"店铺名", "德国", "英国", "法国", "意大利", "西班牙"};
public void writeWorkbook(File outputXlsx, List<QueryAsinResultItemVo> items) {
SXSSFWorkbook workbook = new SXSSFWorkbook(200);
workbook.setCompressTempFiles(true);
try (FileOutputStream outputStream = new FileOutputStream(outputXlsx)) {
Sheet sheet = workbook.createSheet("查询ASIN结果");
Row headerRow = sheet.createRow(0);
for (int columnIndex = 0; columnIndex < HEADER.length; columnIndex++) {
headerRow.createCell(columnIndex).setCellValue(HEADER[columnIndex]);
sheet.setColumnWidth(columnIndex, (columnIndex == 0 ? 22 : 18) * 256);
}
int rowIndex = 1;
for (QueryAsinResultItemVo item : items) {
if (item == null || Boolean.FALSE.equals(item.getSuccess())) {
continue;
}
Map<String, List<String>> asinsByCountry = asinsByCountry(item.getQueryAsins());
int maxRows = maxRows(asinsByCountry);
for (int i = 0; i < maxRows; i++) {
Row row = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(i == 0 ? safe(item.getShopName()) : "");
for (int c = 0; c < COUNTRIES.size(); c++) {
List<String> asins = asinsByCountry.get(COUNTRIES.get(c));
row.createCell(c + 1).setCellValue(asins != null && i < asins.size() ? safe(asins.get(i)) : "");
}
}
rowIndex++;
}
workbook.write(outputStream);
} catch (Exception ex) {
log.warn("[query-asin] write workbook failed: {}", ex.getMessage());
throw new BusinessException("generate query-asin excel failed: " + ex.getMessage());
} finally {
try {
workbook.close();
} catch (Exception ignored) {
}
workbook.dispose();
}
}
public int countRows(List<QueryAsinResultItemVo> items) {
int count = 0;
if (items == null) {
return 0;
}
for (QueryAsinResultItemVo item : items) {
if (item != null && !Boolean.FALSE.equals(item.getSuccess())) {
count += maxRows(asinsByCountry(item.getQueryAsins()));
}
}
return count;
}
private Map<String, List<String>> asinsByCountry(List<QueryAsinCountryAsinsDto> queryAsins) {
Map<String, List<String>> map = new LinkedHashMap<>();
for (String country : COUNTRIES) {
map.put(country, List.of());
}
if (queryAsins == null) {
return map;
}
for (QueryAsinCountryAsinsDto item : queryAsins) {
if (item == null || item.getCountry() == null) {
continue;
}
String country = item.getCountry().trim().toUpperCase();
if (map.containsKey(country)) {
map.put(country, item.getAsins() == null ? List.of() : item.getAsins());
}
}
return map;
}
private int maxRows(Map<String, List<String>> asinsByCountry) {
int max = 1;
for (List<String> asins : asinsByCountry.values()) {
if (asins != null && asins.size() > max) {
max = asins.size();
}
}
return max;
}
private String safe(String value) {
return value == null ? "" : value;
}
}

View File

@@ -0,0 +1,221 @@
package com.nanri.aiimage.modules.queryasin.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskShopQueueItemVo;
import com.nanri.aiimage.modules.queryasin.mapper.QueryAsinShopCandidateMapper;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
import com.nanri.aiimage.modules.queryasin.model.entity.QueryAsinShopCandidateEntity;
import com.nanri.aiimage.modules.shopkey.mapper.QueryAsinMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.QueryAsinEntity;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
@RequiredArgsConstructor
public class QueryAsinResolveService {
private final QueryAsinShopCandidateMapper candidateMapper;
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
private final QueryAsinMapper queryAsinMapper;
public List<ProductRiskCandidateVo> listCandidates(Long userId) {
validateUserId(userId);
List<QueryAsinShopCandidateEntity> rows = candidateMapper.selectList(
new LambdaQueryWrapper<QueryAsinShopCandidateEntity>()
.eq(QueryAsinShopCandidateEntity::getUserId, userId)
.orderByDesc(QueryAsinShopCandidateEntity::getId));
List<ProductRiskCandidateVo> list = new ArrayList<>();
for (QueryAsinShopCandidateEntity row : rows) {
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
vo.setId(row.getId());
vo.setShopName(row.getShopName());
vo.setCreatedAt(row.getCreatedAt());
list.add(vo);
}
return list;
}
@Transactional
public ProductRiskCandidateVo addCandidate(ProductRiskCandidateAddRequest request) {
validateUserId(request.getUserId());
String normalized = ziniaoShopSwitchService.normalizeShopName(request.getShopName());
if (normalized.isBlank()) {
throw new BusinessException("店铺名不能为空");
}
ZiniaoShopMatchResultVo indexHit = ziniaoShopSwitchService.findIndexedStoreByName(normalized, false);
if (indexHit == null || !indexHit.isMatched()) {
String hint = indexHit != null && indexHit.getMatchMessage() != null && !indexHit.getMatchMessage().isBlank()
? indexHit.getMatchMessage()
: "店铺索引未命中,无法加入备选区";
throw new BusinessException(hint);
}
if (ZiniaoShopIndexService.MATCH_STATUS_CONFLICT.equals(indexHit.getMatchStatus())) {
throw new BusinessException(indexHit.getMatchMessage() != null ? indexHit.getMatchMessage() : "存在多个同名店铺,请人工确认");
}
QueryAsinShopCandidateEntity existing = candidateMapper.selectOne(
new LambdaQueryWrapper<QueryAsinShopCandidateEntity>()
.eq(QueryAsinShopCandidateEntity::getUserId, request.getUserId())
.eq(QueryAsinShopCandidateEntity::getShopName, normalized)
.last("limit 1"));
if (existing != null) {
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
vo.setId(existing.getId());
vo.setShopName(existing.getShopName());
vo.setCreatedAt(existing.getCreatedAt());
return vo;
}
QueryAsinShopCandidateEntity entity = new QueryAsinShopCandidateEntity();
entity.setUserId(request.getUserId());
entity.setShopName(normalized);
entity.setCreatedAt(LocalDateTime.now());
candidateMapper.insert(entity);
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
vo.setId(entity.getId());
vo.setShopName(entity.getShopName());
vo.setCreatedAt(entity.getCreatedAt());
return vo;
}
@Transactional
public void deleteCandidate(Long userId, Long id) {
validateUserId(userId);
if (id == null || id <= 0) {
throw new BusinessException("id 不合法");
}
QueryAsinShopCandidateEntity row = candidateMapper.selectById(id);
if (row == null || !userId.equals(row.getUserId())) {
throw new BusinessException("记录不存在");
}
candidateMapper.deleteById(id);
}
public ProductRiskMatchShopsVo matchShops(ProductRiskMatchShopsRequest request) {
validateUserId(request.getUserId());
LinkedHashSet<String> ordered = new LinkedHashSet<>();
for (String raw : request.getShopNames()) {
String normalized = ziniaoShopSwitchService.normalizeShopName(raw);
if (!normalized.isBlank()) {
ordered.add(normalized);
}
}
if (ordered.isEmpty()) {
throw new BusinessException("shop_names 无有效店铺名");
}
ProductRiskMatchShopsVo vo = new ProductRiskMatchShopsVo();
for (String shopName : ordered) {
vo.getItems().add(matchOneShop(request.getUserId(), shopName));
}
return vo;
}
public List<QueryAsinCountryAsinsDto> loadQueryAsinsForShop(Long userId, String shopName) {
validateUserId(userId);
String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(shopName);
if (normalizedShopName.isBlank()) {
return List.of();
}
List<QueryAsinEntity> rows = queryAsinMapper.selectList(
new LambdaQueryWrapper<QueryAsinEntity>().orderByAsc(QueryAsinEntity::getId));
return toCountryAsins(rows);
}
public long countCandidates(Long userId) {
validateUserId(userId);
Long count = candidateMapper.selectCount(new LambdaQueryWrapper<QueryAsinShopCandidateEntity>()
.eq(QueryAsinShopCandidateEntity::getUserId, userId));
return count == null ? 0L : count;
}
private ProductRiskShopQueueItemVo matchOneShop(Long userId, String shopName) {
ProductRiskShopQueueItemVo item = new ProductRiskShopQueueItemVo();
item.setShopName(shopName);
try {
ZiniaoShopMatchResultVo matched = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false);
if (matched == null) {
item.setMatched(false);
item.setMatchStatus("PENDING");
item.setMatchMessage("匹配结果为空");
return item;
}
item.setMatched(matched.isMatched());
item.setShopId(matched.getShopId());
item.setPlatform(matched.getPlatform());
item.setCompanyName(matched.getCompanyName());
item.setMatchedUserId(matched.getMatchedUserId());
item.setMatchStatus(matched.getMatchStatus());
item.setMatchMessage(matched.getMatchMessage());
item.setOpenStoreUrl(matched.getOpenStoreUrl());
item.setQueryAsins(loadQueryAsinsForShop(userId, shopName));
if (item.isMatched() && item.getQueryAsins().isEmpty()) {
item.setMatchMessage("紫鸟已匹配,但后台查询 ASIN 表暂无数据");
}
} catch (BusinessException ex) {
item.setMatched(false);
item.setMatchStatus("PENDING");
item.setMatchMessage(ex.getMessage());
} catch (Exception ex) {
item.setMatched(false);
item.setMatchStatus("PENDING");
item.setMatchMessage(Objects.toString(ex.getMessage(), "匹配异常"));
}
return item;
}
private List<QueryAsinCountryAsinsDto> toCountryAsins(List<QueryAsinEntity> rows) {
Map<String, LinkedHashSet<String>> asinsByCountry = new LinkedHashMap<>();
for (String country : List.of("DE", "UK", "FR", "IT", "ES")) {
asinsByCountry.put(country, new LinkedHashSet<>());
}
if (rows != null) {
for (QueryAsinEntity row : rows) {
addAsin(asinsByCountry, "DE", row.getAsinDe());
addAsin(asinsByCountry, "UK", row.getAsinUk());
addAsin(asinsByCountry, "FR", row.getAsinFr());
addAsin(asinsByCountry, "IT", row.getAsinIt());
addAsin(asinsByCountry, "ES", row.getAsinEs());
}
}
List<QueryAsinCountryAsinsDto> out = new ArrayList<>();
for (Map.Entry<String, LinkedHashSet<String>> entry : asinsByCountry.entrySet()) {
if (entry.getValue().isEmpty()) {
continue;
}
QueryAsinCountryAsinsDto dto = new QueryAsinCountryAsinsDto();
dto.setCountry(entry.getKey());
dto.setAsins(new ArrayList<>(entry.getValue()));
out.add(dto);
}
return out;
}
private void addAsin(Map<String, LinkedHashSet<String>> map, String country, String asin) {
String normalized = asin == null ? "" : asin.trim().toUpperCase();
if (!normalized.isEmpty()) {
map.get(country).add(normalized);
}
}
private void validateUserId(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
}
}

View File

@@ -0,0 +1,168 @@
package com.nanri.aiimage.modules.queryasin.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinShopPayloadDto;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
public class QueryAsinTaskCacheService {
private static final String MODULE_TYPE = "QUERY_ASIN";
private static final long PAYLOAD_TTL_HOURS = 24;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private final TaskScopePayloadStorageService taskScopePayloadStorageService;
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
public QueryAsinShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, QueryAsinShopPayloadDto.class);
}
public void saveShopMergedPayload(Long taskId, String shopKey, QueryAsinShopPayloadDto payload) {
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank() || payload == null) {
return;
}
taskScopePayloadStorageService.saveScopePayload(taskId, MODULE_TYPE, shopKey, payload);
touchTaskHeartbeat(taskId);
}
public void removeShopMergedPayload(Long taskId, String shopKey) {
taskScopePayloadStorageService.removeScopePayload(taskId, MODULE_TYPE, shopKey);
}
public Map<String, QueryAsinShopPayloadDto> getAllShopMergedPayload(Long taskId) {
return taskScopePayloadStorageService.getAllScopePayload(taskId, MODULE_TYPE, QueryAsinShopPayloadDto.class);
}
public boolean hasAnyShopMergedPayload(Long taskId) {
return taskScopePayloadStorageService.hasAnyScopePayload(taskId, MODULE_TYPE);
}
public long countShopMergedPayload(Long taskId) {
return taskScopePayloadStorageService.countScopePayload(taskId, MODULE_TYPE);
}
public long getTaskHeartbeatMillis(Long taskId) {
if (taskId == null || taskId <= 0) {
return 0L;
}
String raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId));
if (raw == null || raw.isBlank()) {
return 0L;
}
try {
return Long.parseLong(raw);
} catch (NumberFormatException ignored) {
return 0L;
}
}
public void touchTaskHeartbeat(Long taskId) {
stringRedisTemplate.opsForValue().set(
buildTaskHeartbeatKey(taskId),
String.valueOf(Instant.now().toEpochMilli()),
Duration.ofHours(PAYLOAD_TTL_HOURS));
}
public void deleteTaskCache(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
taskEntityLocalCache.remove(taskId);
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE);
}
public void saveTaskCache(FileTaskEntity task) {
if (task == null || task.getId() == null) {
return;
}
long now = System.currentTimeMillis();
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
now,
objectMapper.convertValue(task, FileTaskEntity.class)
));
try {
stringRedisTemplate.opsForValue().set(
buildTaskEntityKey(task.getId()),
objectMapper.writeValueAsString(task),
Duration.ofHours(PAYLOAD_TTL_HOURS)
);
} catch (Exception ignored) {
}
}
public Map<Long, FileTaskEntity> getTaskCacheBatch(java.util.List<Long> taskIds) {
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
return result;
}
java.util.List<Long> normalized = taskIds.stream()
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (normalized.isEmpty()) {
return result;
}
long now = System.currentTimeMillis();
java.util.List<Long> missingIds = new ArrayList<>();
for (Long taskId : normalized) {
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
if (isLocalCacheFresh(cached, now)) {
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
} else {
missingIds.add(taskId);
}
}
if (missingIds.isEmpty()) {
return result;
}
java.util.List<String> keys = missingIds.stream().map(this::buildTaskEntityKey).toList();
java.util.List<String> values = stringRedisTemplate.opsForValue().multiGet(keys);
for (int i = 0; i < missingIds.size(); i++) {
Long taskId = missingIds.get(i);
String val = values != null && i < values.size() ? values.get(i) : null;
if (val == null || val.isBlank()) {
continue;
}
try {
FileTaskEntity task = objectMapper.readValue(val, FileTaskEntity.class);
result.put(taskId, task);
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
} catch (Exception ignored) {
}
}
return result;
}
private String buildTaskHeartbeatKey(Long taskId) {
return "query-asin:task:heartbeat:" + taskId;
}
private String buildTaskEntityKey(Long taskId) {
return "query-asin:task:entity:" + taskId;
}
private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
return cached != null
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
}
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
}

View File

@@ -0,0 +1,960 @@
package com.nanri.aiimage.modules.queryasin.service;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCartRatioDto;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryMetricRowDto;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountrySectionDto;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCreateTaskRequest;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinShopPayloadDto;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinSubmitResultRequest;
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinTaskItemDto;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinTaskBatchVo;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinCreateTaskVo;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinHistoryVo;
import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinResultItemVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
@RequiredArgsConstructor
@Slf4j
public class QueryAsinTaskService {
private static final String MODULE_TYPE = "QUERY_ASIN";
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final String INTERRUPTED_MESSAGE = "Python 在该店铺结果提交完成前中断";
private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper;
private final QueryAsinResolveService queryAsinResolveService;
private final QueryAsinExcelAssemblyService excelAssemblyService;
private final QueryAsinTaskCacheService taskCacheService;
private final OssStorageService ossStorageService;
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private FileTaskEntity loadTaskForExecution(Long taskId) {
Map<Long, FileTaskEntity> cachedTasks = taskCacheService.getTaskCacheBatch(List.of(taskId));
FileTaskEntity cached = cachedTasks.get(taskId);
if (cached != null) {
return cached;
}
FileTaskEntity dbTask = fileTaskMapper.selectById(taskId);
if (dbTask != null && MODULE_TYPE.equals(dbTask.getModuleType())) {
taskCacheService.saveTaskCache(dbTask);
}
return dbTask;
}
private Map<Long, FileTaskEntity> loadTaskMapByIds(List<Long> taskIds) {
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
return result;
}
List<Long> normalizedTaskIds = taskIds.stream()
.filter(taskId -> taskId != null && taskId > 0)
.distinct()
.toList();
if (normalizedTaskIds.isEmpty()) {
return result;
}
Map<Long, FileTaskEntity> cachedTasks = taskCacheService.getTaskCacheBatch(normalizedTaskIds);
result.putAll(cachedTasks);
List<Long> missingTaskIds = normalizedTaskIds.stream()
.filter(taskId -> !cachedTasks.containsKey(taskId))
.toList();
if (!missingTaskIds.isEmpty()) {
for (FileTaskEntity dbTask : selectTasksByIdsInBatches(missingTaskIds)) {
if (dbTask == null || !MODULE_TYPE.equals(dbTask.getModuleType())) {
continue;
}
result.put(dbTask.getId(), dbTask);
if ("RUNNING".equals(dbTask.getStatus())) {
taskCacheService.saveTaskCache(dbTask);
}
}
}
return result;
}
private List<FileTaskEntity> selectTasksByIdsInBatches(List<Long> taskIds) {
List<FileTaskEntity> tasks = new ArrayList<>();
if (taskIds == null || taskIds.isEmpty()) {
return tasks;
}
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
for (int start = 0; start < taskIds.size(); start += batchSize) {
int end = Math.min(start + batchSize, taskIds.size());
tasks.addAll(fileTaskMapper.selectBatchIds(taskIds.subList(start, end)));
}
return tasks;
}
public ProductRiskDashboardVo dashboard(Long userId) {
validateUserId(userId);
ProductRiskDashboardVo vo = new ProductRiskDashboardVo();
vo.setCandidateCount(queryAsinResolveService.countCandidates(userId));
vo.setProcessedTaskCount(countTasks(userId, List.of("SUCCESS", "FAILED")));
vo.setSuccessTaskCount(countTasks(userId, List.of("SUCCESS")));
vo.setFailedTaskCount(countTasks(userId, List.of("FAILED")));
return vo;
}
public QueryAsinHistoryVo listHistory(Long userId) {
validateUserId(userId);
QueryAsinHistoryVo vo = new QueryAsinHistoryVo();
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.eq(FileResultEntity::getUserId, userId)
.orderByDesc(FileResultEntity::getCreatedAt)
.last("limit 100"));
if (entities.isEmpty()) {
vo.setItems(List.of());
return vo;
}
Map<Long, FileTaskEntity> taskMap = loadTaskMap(entities);
Map<Long, Map<Long, QueryAsinResultItemVo>> snapshotMap = buildSnapshotMap(taskMap);
List<QueryAsinResultItemVo> items = new ArrayList<>();
for (FileResultEntity entity : entities) {
FileTaskEntity task = taskMap.get(entity.getTaskId());
QueryAsinResultItemVo snapshot = snapshotMap.getOrDefault(entity.getTaskId(), Map.of()).get(entity.getId());
items.add(toHistoryItem(entity, task, snapshot));
}
vo.setItems(items);
return vo;
}
public QueryAsinTaskBatchVo getTaskProgressBatch(List<Long> taskIds) {
QueryAsinTaskBatchVo batch = new QueryAsinTaskBatchVo();
if (taskIds == null || taskIds.isEmpty()) {
return batch;
}
List<Long> normalizedTaskIds = taskIds.stream()
.filter(taskId -> taskId != null && taskId > 0)
.distinct()
.limit(50)
.toList();
if (normalizedTaskIds.isEmpty()) {
return batch;
}
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(normalizedTaskIds);
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.in(FileResultEntity::getTaskId, normalizedTaskIds)
.orderByAsc(FileResultEntity::getId));
Map<Long, List<FileResultEntity>> rowsByTaskId = new LinkedHashMap<>();
for (FileResultEntity row : rows) {
rowsByTaskId.computeIfAbsent(row.getTaskId(), ignored -> new ArrayList<>()).add(row);
}
for (Long taskId : normalizedTaskIds) {
FileTaskEntity task = taskMap.get(taskId);
if (task == null) {
batch.getMissingTaskIds().add(taskId);
continue;
}
List<FileResultEntity> taskRows = rowsByTaskId.get(taskId);
if (taskRows == null || taskRows.isEmpty()) {
batch.getMissingTaskIds().add(taskId);
continue;
}
List<QueryAsinResultItemVo> snapshots = buildSnapshotFromDb(task, taskRows);
batch.getItems().addAll(snapshots);
}
return batch;
}
public String resolveResultDownloadUrl(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
}
if (blank(entity.getResultFileUrl())) {
throw new BusinessException("暂无可下载文件");
}
return ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl());
}
public String resolveResultDownloadFilename(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
}
return !blank(entity.getResultFilename())
? entity.getResultFilename()
: safeFileStem(entity.getSourceFilename()) + ".xlsx";
}
@Transactional
public QueryAsinCreateTaskVo createTask(QueryAsinCreateTaskRequest request) {
validateUserId(request.getUserId());
if (request.getItems() == null || request.getItems().isEmpty()) {
throw new BusinessException("items 不能为空");
}
List<QueryAsinTaskItemDto> uniqueItems = dedupeItems(request.getItems());
if (uniqueItems.isEmpty()) {
throw new BusinessException("items 不能为空");
}
for (QueryAsinTaskItemDto item : uniqueItems) {
if (item == null || !item.isMatched()) {
throw new BusinessException("存在未匹配店铺,无法创建任务");
}
if (item.getQueryAsins() == null || item.getQueryAsins().isEmpty()) {
item.setQueryAsins(queryAsinResolveService.loadQueryAsinsForShop(request.getUserId(), item.getShopName()));
}
if (item.getQueryAsins() == null || item.getQueryAsins().isEmpty()) {
throw new BusinessException("后台查询 ASIN 表暂无数据,无法创建任务");
}
}
LocalDateTime now = LocalDateTime.now();
FileTaskEntity task = new FileTaskEntity();
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
task.setModuleType(MODULE_TYPE);
task.setTaskMode("PYTHON_QUEUE");
task.setStatus("RUNNING");
task.setSourceFileCount(uniqueItems.size());
task.setSuccessFileCount(0);
task.setFailedFileCount(0);
task.setCreatedBy("user:" + request.getUserId());
task.setUserId(request.getUserId());
task.setCreatedAt(now);
task.setUpdatedAt(now);
fileTaskMapper.insert(task);
taskCacheService.saveTaskCache(task);
taskCacheService.touchTaskHeartbeat(task.getId());
List<QueryAsinResultItemVo> snapshots = new ArrayList<>();
for (QueryAsinTaskItemDto item : uniqueItems) {
String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (blank(normalizedShopName)) {
throw new BusinessException("任务数据已失效,请刷新后重试");
}
FileResultEntity result = new FileResultEntity();
result.setTaskId(task.getId());
result.setModuleType(MODULE_TYPE);
result.setSourceFilename(normalizedShopName);
result.setSourceFileUrl(item.getShopId());
result.setUserId(request.getUserId());
result.setSuccess(null);
result.setCreatedAt(now);
fileResultMapper.insert(result);
snapshots.add(toSnapshotVo(result, item, task.getStatus(), null));
}
persistTaskJson(task, uniqueItems, snapshots);
QueryAsinCreateTaskVo vo = new QueryAsinCreateTaskVo();
vo.setTaskId(task.getId());
vo.setItems(snapshots);
return vo;
}
@Transactional
public void submitResult(Long taskId, QueryAsinSubmitResultRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法");
}
if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
throw new BusinessException("shops 不能为空");
}
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
throw new BusinessException("任务已结束,拒绝重复提交");
}
taskCacheService.touchTaskHeartbeat(taskId);
List<FileResultEntity> resultRows = listTaskRows(taskId);
List<QueryAsinResultItemVo> snapshots = buildSnapshotFromDb(task, resultRows);
Map<Long, QueryAsinResultItemVo> snapshotByResultId = indexSnapshotByResultId(snapshots);
Map<String, QueryAsinShopPayloadDto> payloadByShop = normalizePayloadByShop(request.getShops());
for (FileResultEntity row : resultRows) {
String shopKey = row.getSourceFilename();
QueryAsinShopPayloadDto incoming = payloadByShop.get(shopKey);
if (incoming == null) {
continue;
}
QueryAsinShopPayloadDto mergedPayload = mergeShopPayload(taskId, shopKey, incoming);
QueryAsinResultItemVo snapshot = snapshotByResultId.get(row.getId());
mergePayloadIntoSnapshot(snapshot, mergedPayload);
if (!blank(mergedPayload.getError())) {
markResultFailed(row, mergedPayload.getError());
applyFailureToSnapshot(snapshot, mergedPayload.getError());
taskCacheService.removeShopMergedPayload(taskId, shopKey);
continue;
}
if (Boolean.TRUE.equals(mergedPayload.getShopDone())) {
markResultSuccess(row);
applySuccessToSnapshot(snapshot);
taskCacheService.removeShopMergedPayload(taskId, shopKey);
}
}
persistSnapshotJson(task, snapshots);
fileTaskMapper.updateById(task);
tryFinalizeTask(taskId, false);
}
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) {
return false;
}
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return false;
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
return true;
}
List<FileResultEntity> rows = listTaskRows(taskId);
if (rows.isEmpty()) {
return false;
}
Map<String, QueryAsinShopPayloadDto> cachedPayloads = taskCacheService.getAllShopMergedPayload(taskId);
List<QueryAsinResultItemVo> snapshots = buildSnapshotFromDb(task, rows);
Map<Long, QueryAsinResultItemVo> snapshotByResultId = indexSnapshotByResultId(snapshots);
boolean changed = false;
for (FileResultEntity row : rows) {
if (isResultFinished(row)) {
continue;
}
QueryAsinResultItemVo snapshot = snapshotByResultId.get(row.getId());
QueryAsinShopPayloadDto cached = cachedPayloads.get(row.getSourceFilename());
if (cached == null) {
if (fromCompensation) {
markResultFailed(row, INTERRUPTED_MESSAGE);
applyFailureToSnapshot(snapshot, INTERRUPTED_MESSAGE);
changed = true;
}
continue;
}
mergePayloadIntoSnapshot(snapshot, cached);
if (!blank(cached.getError())) {
markResultFailed(row, cached.getError());
applyFailureToSnapshot(snapshot, cached.getError());
taskCacheService.removeShopMergedPayload(taskId, row.getSourceFilename());
changed = true;
continue;
}
if (Boolean.TRUE.equals(cached.getShopDone()) || (fromCompensation && hasAnyPayloadData(cached))) {
markResultSuccess(row);
applySuccessToSnapshot(snapshot);
taskCacheService.removeShopMergedPayload(taskId, row.getSourceFilename());
changed = true;
continue;
}
if (fromCompensation) {
markResultFailed(row, INTERRUPTED_MESSAGE);
applyFailureToSnapshot(snapshot, INTERRUPTED_MESSAGE);
taskCacheService.removeShopMergedPayload(taskId, row.getSourceFilename());
changed = true;
}
}
List<FileResultEntity> latestRows = listTaskRows(taskId);
updateTaskStatusFromRows(task, latestRows);
if (latestRows.stream().allMatch(this::isResultFinished)) {
finalizeTaskWorkbook(task, latestRows, snapshots);
return true;
}
persistSnapshotJson(task, snapshots);
fileTaskMapper.updateById(task);
return changed;
}
@Transactional
public void deleteTask(Long taskId, Long userId) {
validateUserId(userId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
}
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
fileTaskMapper.deleteById(taskId);
taskCacheService.deleteTaskCache(taskId);
}
@Transactional
public void deleteHistory(Long resultId, Long userId) {
validateUserId(userId);
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
}
Long taskId = entity.getTaskId();
fileResultMapper.deleteById(resultId);
reconcileTaskAfterResultRemoval(taskId);
}
private void reconcileTaskAfterResultRemoval(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return;
}
List<FileResultEntity> rows = listTaskRows(taskId);
if (rows.isEmpty()) {
fileTaskMapper.deleteById(taskId);
taskCacheService.deleteTaskCache(taskId);
return;
}
updateTaskStatusFromRows(task, rows);
persistSnapshotJson(task, buildSnapshotFromDb(task, rows));
fileTaskMapper.updateById(task);
}
private long countTasks(Long userId, List<String> statuses) {
Long count = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getUserId, userId)
.in(FileTaskEntity::getStatus, statuses));
return count == null ? 0L : count;
}
private List<FileResultEntity> listTaskRows(Long taskId) {
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
}
private Map<String, QueryAsinShopPayloadDto> normalizePayloadByShop(List<QueryAsinShopPayloadDto> shops) {
Map<String, QueryAsinShopPayloadDto> payloadByShop = new LinkedHashMap<>();
for (QueryAsinShopPayloadDto item : shops) {
if (item == null) {
continue;
}
String shopKey = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (blank(shopKey)) {
continue;
}
item.setShopName(shopKey);
payloadByShop.put(shopKey, item);
}
return payloadByShop;
}
private Map<Long, FileTaskEntity> loadTaskMap(List<FileResultEntity> entities) {
List<Long> taskIds = entities.stream()
.map(FileResultEntity::getTaskId)
.filter(id -> id != null && id > 0)
.distinct()
.toList();
Map<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
if (taskIds.isEmpty()) {
return taskMap;
}
for (FileTaskEntity task : selectTasksByIdsInBatches(taskIds)) {
if (task != null && MODULE_TYPE.equals(task.getModuleType())) {
taskMap.put(task.getId(), task);
}
}
return taskMap;
}
private Map<Long, Map<Long, QueryAsinResultItemVo>> buildSnapshotMap(Map<Long, FileTaskEntity> taskMap) {
Map<Long, Map<Long, QueryAsinResultItemVo>> out = new LinkedHashMap<>();
for (Map.Entry<Long, FileTaskEntity> entry : taskMap.entrySet()) {
out.put(entry.getKey(), indexSnapshotByResultId(parseTaskSnapshots(entry.getValue().getResultJson())));
}
return out;
}
private QueryAsinResultItemVo toHistoryItem(FileResultEntity entity, FileTaskEntity task, QueryAsinResultItemVo snapshot) {
QueryAsinResultItemVo item = snapshot != null ? snapshot : new QueryAsinResultItemVo();
item.setResultId(entity.getId());
item.setTaskId(entity.getTaskId());
item.setShopName(firstNonBlank(item.getShopName(), entity.getSourceFilename()));
item.setShopId(firstNonBlank(item.getShopId(), entity.getSourceFileUrl()));
item.setTaskStatus(task != null ? task.getStatus() : item.getTaskStatus());
item.setSuccess(entity.getSuccess() == null ? item.getSuccess() : entity.getSuccess() == 1);
item.setError(!blank(entity.getErrorMessage()) ? entity.getErrorMessage() : item.getError());
item.setCreatedAt(entity.getCreatedAt());
item.setFinishedAt(task != null ? task.getFinishedAt() : item.getFinishedAt());
item.setOutputFilename(firstNonBlank(item.getOutputFilename(), entity.getResultFilename()));
item.setDownloadUrl(blank(entity.getResultFileUrl())
? item.getDownloadUrl()
: ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
if (item.getCountrySections() == null) {
item.setCountrySections(new ArrayList<>());
}
if (item.getCartRatios() == null) {
item.setCartRatios(new ArrayList<>());
}
if (item.getQueryAsins() == null) {
item.setQueryAsins(new ArrayList<>());
}
return item;
}
private List<QueryAsinTaskItemDto> dedupeItems(List<QueryAsinTaskItemDto> items) {
LinkedHashMap<String, QueryAsinTaskItemDto> map = new LinkedHashMap<>();
for (QueryAsinTaskItemDto item : items) {
if (item == null) {
continue;
}
String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (blank(normalizedShopName)) {
continue;
}
item.setShopName(normalizedShopName);
String key = normalizedShopName + "::" + Objects.toString(item.getShopId(), "");
map.put(key, item);
}
return new ArrayList<>(map.values());
}
private QueryAsinResultItemVo toSnapshotVo(FileResultEntity result, QueryAsinTaskItemDto item, String taskStatus, LocalDateTime finishedAt) {
QueryAsinResultItemVo vo = new QueryAsinResultItemVo();
vo.setResultId(result.getId());
vo.setTaskId(result.getTaskId());
vo.setShopName(item.getShopName());
vo.setShopId(item.getShopId());
vo.setPlatform(item.getPlatform());
vo.setCompanyName(item.getCompanyName());
vo.setMatched(item.isMatched());
vo.setMatchStatus(item.getMatchStatus());
vo.setMatchMessage(item.getMatchMessage());
vo.setTaskStatus(taskStatus);
vo.setSuccess(result.getSuccess() == null ? null : result.getSuccess() == 1);
vo.setError(result.getErrorMessage());
vo.setCreatedAt(result.getCreatedAt());
vo.setFinishedAt(finishedAt);
vo.setCountrySections(new ArrayList<>());
vo.setCartRatios(new ArrayList<>());
vo.setQueryAsins(copyCountryAsins(item.getQueryAsins()));
vo.setOutputFilename(result.getResultFilename());
vo.setDownloadUrl(null);
return vo;
}
private void persistTaskJson(FileTaskEntity task, List<QueryAsinTaskItemDto> requestItems, List<QueryAsinResultItemVo> snapshots) {
try {
task.setRequestJson(objectMapper.writeValueAsString(requestItems));
task.setResultJson(objectMapper.writeValueAsString(snapshots));
fileTaskMapper.updateById(task);
} catch (Exception ex) {
throw new BusinessException("查询ASIN任务快照保存失败");
}
}
private List<QueryAsinResultItemVo> buildSnapshotFromDb(FileTaskEntity task, List<FileResultEntity> rows) {
List<QueryAsinResultItemVo> existing = parseTaskSnapshots(task.getResultJson());
Map<Long, QueryAsinResultItemVo> snapshotByResultId = indexSnapshotByResultId(existing);
List<QueryAsinResultItemVo> list = new ArrayList<>();
for (FileResultEntity row : rows) {
list.add(toHistoryItem(row, task, snapshotByResultId.get(row.getId())));
}
return list;
}
private Map<Long, QueryAsinResultItemVo> indexSnapshotByResultId(List<QueryAsinResultItemVo> snapshots) {
Map<Long, QueryAsinResultItemVo> map = new LinkedHashMap<>();
if (snapshots == null) {
return map;
}
for (QueryAsinResultItemVo snapshot : snapshots) {
if (snapshot != null && snapshot.getResultId() != null) {
map.put(snapshot.getResultId(), snapshot);
}
}
return map;
}
private void updateTaskStatusFromRows(FileTaskEntity task, List<FileResultEntity> rows) {
long successCount = rows.stream().filter(row -> Integer.valueOf(1).equals(row.getSuccess())).count();
long failedCount = rows.stream().filter(row -> Integer.valueOf(0).equals(row.getSuccess())).count();
long pendingCount = rows.stream().filter(row -> !isResultFinished(row)).count();
task.setSuccessFileCount((int) successCount);
task.setFailedFileCount((int) failedCount);
task.setUpdatedAt(LocalDateTime.now());
if (pendingCount > 0) {
task.setStatus("RUNNING");
task.setFinishedAt(null);
task.setErrorMessage(null);
return;
}
task.setStatus(failedCount > 0 ? "FAILED" : "SUCCESS");
task.setFinishedAt(LocalDateTime.now());
if (failedCount > 0) {
List<String> errors = new ArrayList<>();
for (FileResultEntity row : rows) {
if (!blank(row.getErrorMessage())) {
errors.add(row.getSourceFilename() + ": " + row.getErrorMessage());
}
}
task.setErrorMessage(String.join("; ", errors));
} else {
task.setErrorMessage(null);
}
}
private QueryAsinShopPayloadDto mergeShopPayload(Long taskId, String shopKey, QueryAsinShopPayloadDto incoming) {
QueryAsinShopPayloadDto merged = taskCacheService.getShopMergedPayload(taskId, shopKey);
if (merged == null) {
merged = new QueryAsinShopPayloadDto();
merged.setShopName(shopKey);
merged.setCountrySections(new ArrayList<>());
merged.setCartRatios(new ArrayList<>());
merged.setQueryAsins(new ArrayList<>());
}
merged.setShopName(firstNonBlank(incoming.getShopName(), merged.getShopName()));
merged.setSubmissionId(firstNonBlank(incoming.getSubmissionId(), merged.getSubmissionId()));
merged.setChunkIndex(incoming.getChunkIndex());
merged.setChunkTotal(incoming.getChunkTotal());
if (!blank(incoming.getError())) {
merged.setError(incoming.getError().trim());
merged.setShopDone(Boolean.TRUE);
taskCacheService.saveShopMergedPayload(taskId, shopKey, merged);
return merged;
}
merged.setCountrySections(mergeCountrySections(merged.getCountrySections(), incoming.getCountrySections()));
merged.setCartRatios(mergeCartRatios(merged.getCartRatios(), incoming.getCartRatios()));
merged.setQueryAsins(mergeCountryAsins(merged.getQueryAsins(), incoming.getQueryAsins()));
if (Boolean.TRUE.equals(incoming.getShopDone())) {
merged.setShopDone(Boolean.TRUE);
}
taskCacheService.saveShopMergedPayload(taskId, shopKey, merged);
return merged;
}
private void mergePayloadIntoSnapshot(QueryAsinResultItemVo snapshot, QueryAsinShopPayloadDto payload) {
if (snapshot == null || payload == null) {
return;
}
snapshot.setShopName(firstNonBlank(payload.getShopName(), snapshot.getShopName()));
snapshot.setCountrySections(copyCountrySections(payload.getCountrySections()));
snapshot.setCartRatios(copyCartRatios(payload.getCartRatios()));
if (payload.getQueryAsins() != null && !payload.getQueryAsins().isEmpty()) {
snapshot.setQueryAsins(copyCountryAsins(payload.getQueryAsins()));
}
if (!blank(payload.getError())) {
snapshot.setError(payload.getError().trim());
}
}
private void applySuccessToSnapshot(QueryAsinResultItemVo snapshot) {
if (snapshot == null) {
return;
}
snapshot.setSuccess(Boolean.TRUE);
snapshot.setError(null);
}
private void applyFailureToSnapshot(QueryAsinResultItemVo snapshot, String error) {
if (snapshot == null) {
return;
}
snapshot.setSuccess(Boolean.FALSE);
snapshot.setError(blankToNull(error));
}
private List<QueryAsinCountrySectionDto> mergeCountrySections(List<QueryAsinCountrySectionDto> base, List<QueryAsinCountrySectionDto> incoming) {
Map<String, QueryAsinCountrySectionDto> map = new LinkedHashMap<>();
for (QueryAsinCountrySectionDto section : copyCountrySections(base)) {
map.put(section.getCountry(), section);
}
for (QueryAsinCountrySectionDto section : copyCountrySections(incoming)) {
QueryAsinCountrySectionDto existing = map.get(section.getCountry());
if (existing == null) {
map.put(section.getCountry(), section);
continue;
}
List<QueryAsinCountryMetricRowDto> mergedRows = existing.getRows() == null
? new ArrayList<>()
: new ArrayList<>(existing.getRows());
if (section.getRows() != null) {
mergedRows.addAll(section.getRows());
}
existing.setRows(mergedRows);
}
return new ArrayList<>(map.values());
}
private List<QueryAsinCartRatioDto> mergeCartRatios(List<QueryAsinCartRatioDto> base, List<QueryAsinCartRatioDto> incoming) {
Map<String, QueryAsinCartRatioDto> map = new LinkedHashMap<>();
for (QueryAsinCartRatioDto ratio : copyCartRatios(base)) {
map.put(ratio.getCountry(), ratio);
}
for (QueryAsinCartRatioDto ratio : copyCartRatios(incoming)) {
if (!blank(ratio.getCountry())) {
map.put(ratio.getCountry(), ratio);
}
}
return new ArrayList<>(map.values());
}
private List<QueryAsinCountryAsinsDto> mergeCountryAsins(List<QueryAsinCountryAsinsDto> base, List<QueryAsinCountryAsinsDto> incoming) {
Map<String, QueryAsinCountryAsinsDto> map = new LinkedHashMap<>();
for (QueryAsinCountryAsinsDto item : copyCountryAsins(base)) {
map.put(item.getCountry(), item);
}
for (QueryAsinCountryAsinsDto item : copyCountryAsins(incoming)) {
if (blank(item.getCountry())) {
continue;
}
QueryAsinCountryAsinsDto existing = map.get(item.getCountry());
if (existing == null) {
map.put(item.getCountry(), item);
continue;
}
List<String> merged = new ArrayList<>(existing.getAsins() == null ? List.of() : existing.getAsins());
for (String asin : item.getAsins() == null ? List.<String>of() : item.getAsins()) {
String normalized = asin == null ? "" : asin.trim().toUpperCase();
if (!normalized.isEmpty() && !merged.contains(normalized)) {
merged.add(normalized);
}
}
existing.setAsins(merged);
}
return new ArrayList<>(map.values());
}
private boolean hasAnyPayloadData(QueryAsinShopPayloadDto payload) {
if (payload == null) {
return false;
}
if (payload.getQueryAsins() != null) {
for (QueryAsinCountryAsinsDto item : payload.getQueryAsins()) {
if (item != null && item.getAsins() != null && !item.getAsins().isEmpty()) {
return true;
}
}
}
if (payload.getCountrySections() != null) {
for (QueryAsinCountrySectionDto section : payload.getCountrySections()) {
if (section == null || section.getRows() == null) {
continue;
}
for (QueryAsinCountryMetricRowDto row : section.getRows()) {
if (row != null && (!blank(row.getStatus())
|| !blank(row.getQuantity())
|| !blank(row.getDeleteQuantity())
|| !blank(row.getProcessStatus()))) {
return true;
}
}
}
}
if (payload.getCartRatios() != null) {
for (QueryAsinCartRatioDto ratio : payload.getCartRatios()) {
if (ratio != null && (!blank(ratio.getCountry()) || !blank(ratio.getRatio()))) {
return true;
}
}
}
return false;
}
private void finalizeTaskWorkbook(FileTaskEntity task, List<FileResultEntity> rows, List<QueryAsinResultItemVo> snapshots) {
List<QueryAsinResultItemVo> successItems = snapshots.stream()
.filter(item -> Boolean.TRUE.equals(item.getSuccess()))
.toList();
if (!successItems.isEmpty()) {
File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "query-asin-result", String.valueOf(task.getId())));
String filename = safeFileStem("查询ASIN-" + task.getId()) + ".xlsx";
File xlsx = FileUtil.file(workRoot, filename);
try {
excelAssemblyService.writeWorkbook(xlsx, successItems);
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
long fileSize = xlsx.length();
int rowCount = excelAssemblyService.countRows(successItems);
for (FileResultEntity row : rows) {
if (Integer.valueOf(1).equals(row.getSuccess())) {
row.setResultFilename(filename);
row.setResultFileUrl(objectKey);
row.setResultFileSize(fileSize);
row.setResultContentType(CONTENT_TYPE_XLSX);
row.setRowCount(rowCount);
fileResultMapper.updateById(row);
}
}
snapshots = buildSnapshotFromDb(task, rows);
} finally {
FileUtil.del(xlsx);
}
}
persistSnapshotJson(task, snapshots);
fileTaskMapper.updateById(task);
taskCacheService.deleteTaskCache(task.getId());
}
private void markResultSuccess(FileResultEntity row) {
row.setSuccess(1);
row.setErrorMessage(null);
fileResultMapper.updateById(row);
}
private void markResultFailed(FileResultEntity row, String message) {
row.setSuccess(0);
row.setErrorMessage(blankToNull(message));
row.setResultFilename(null);
row.setResultFileUrl(null);
row.setResultFileSize(0L);
row.setResultContentType(null);
row.setRowCount(0);
fileResultMapper.updateById(row);
}
private boolean isResultFinished(FileResultEntity row) {
return Integer.valueOf(1).equals(row.getSuccess()) || Integer.valueOf(0).equals(row.getSuccess());
}
private List<QueryAsinResultItemVo> parseTaskSnapshots(String json) {
if (blank(json)) {
return new ArrayList<>();
}
try {
return objectMapper.readValue(json, new TypeReference<List<QueryAsinResultItemVo>>() {});
} catch (Exception ex) {
log.warn("[query-asin] parse task snapshot failed: {}", ex.getMessage());
return new ArrayList<>();
}
}
private void persistSnapshotJson(FileTaskEntity task, List<QueryAsinResultItemVo> snapshots) {
try {
task.setResultJson(objectMapper.writeValueAsString(snapshots == null ? List.of() : snapshots));
} catch (Exception ex) {
throw new BusinessException("查询ASIN任务快照保存失败");
}
}
private List<QueryAsinCountrySectionDto> copyCountrySections(List<QueryAsinCountrySectionDto> sections) {
List<QueryAsinCountrySectionDto> copy = new ArrayList<>();
if (sections == null) {
return copy;
}
for (QueryAsinCountrySectionDto section : sections) {
if (section == null) {
continue;
}
QueryAsinCountrySectionDto item = new QueryAsinCountrySectionDto();
item.setCountry(section.getCountry());
item.setRows(section.getRows() == null ? new ArrayList<>() : new ArrayList<>(section.getRows()));
copy.add(item);
}
return copy;
}
private List<QueryAsinCartRatioDto> copyCartRatios(List<QueryAsinCartRatioDto> ratios) {
List<QueryAsinCartRatioDto> copy = new ArrayList<>();
if (ratios == null) {
return copy;
}
for (QueryAsinCartRatioDto ratio : ratios) {
if (ratio == null) {
continue;
}
QueryAsinCartRatioDto item = new QueryAsinCartRatioDto();
item.setCountry(ratio.getCountry());
item.setRatio(ratio.getRatio());
copy.add(item);
}
return copy;
}
private List<QueryAsinCountryAsinsDto> copyCountryAsins(List<QueryAsinCountryAsinsDto> items) {
List<QueryAsinCountryAsinsDto> copy = new ArrayList<>();
if (items == null) {
return copy;
}
for (QueryAsinCountryAsinsDto source : items) {
if (source == null || blank(source.getCountry())) {
continue;
}
QueryAsinCountryAsinsDto item = new QueryAsinCountryAsinsDto();
item.setCountry(source.getCountry().trim().toUpperCase());
List<String> asins = new ArrayList<>();
if (source.getAsins() != null) {
for (String asin : source.getAsins()) {
String normalized = asin == null ? "" : asin.trim().toUpperCase();
if (!normalized.isEmpty() && !asins.contains(normalized)) {
asins.add(normalized);
}
}
}
item.setAsins(asins);
copy.add(item);
}
return copy;
}
private String firstNonBlank(String first, String second) {
return !blank(first) ? first : second;
}
private String safeFileStem(String value) {
String raw = value == null ? "result" : value.trim();
String safe = raw.replaceAll("[\\\\/:*?\"<>|]+", "_");
return safe.isBlank() ? "result" : safe;
}
private boolean blank(String value) {
return value == null || value.isBlank();
}
private String blankToNull(String value) {
return blank(value) ? null : value.trim();
}
private void validateUserId(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -60,6 +60,11 @@ ADMIN_MENU_ACCESS_CONFIG = {
'route_path': 'skip-price-asin', 'route_path': 'skip-price-asin',
'error': '无权访问跳过跟价 ASIN 模块', 'error': '无权访问跳过跟价 ASIN 模块',
}, },
'query-asin': {
'column_key': 'admin_query_asin',
'route_path': 'query-asin',
'error': '无权访问查询 ASIN 模块',
},
} }
ADMIN_MENU_ACCESS_CONFIG.update({ ADMIN_MENU_ACCESS_CONFIG.update({
@@ -98,10 +103,10 @@ def _safe_version_key(version):
return s or 'unknown' 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}" url = f"{backend_java_base_url}{path}"
try: 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: except requests.RequestException:
return None, jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502 return None, jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
try: try:
@@ -469,7 +474,7 @@ def list_users():
if not role: if not role:
return jsonify({'success': False, 'error': '需要登录'}), 403 return jsonify({'success': False, 'error': '需要登录'}), 403
page = max(1, int(request.args.get('page', 1))) 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: if denied:
return denied return denied
page_size = min(999, max(5, int(request.args.get('page_size', 15)))) 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') @admin_api.route('/shop-manages')
@login_required @login_required
def list_shop_manages(): 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: if denied:
return denied return denied
page = max(1, int(request.args.get('page', 1))) 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') @admin_api.route('/shop-manage-groups')
@login_required @login_required
def list_shop_manage_groups(): 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: if denied:
return denied return denied
params = {} params = {}
@@ -1638,7 +1643,7 @@ def list_shop_manage_groups():
@admin_api.route('/shop-manage-group', methods=['POST']) @admin_api.route('/shop-manage-group', methods=['POST'])
@login_required @login_required
def create_shop_manage_group(): 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: if denied:
return denied return denied
data = request.get_json() or {} 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']) @admin_api.route('/shop-manage-group/<int:item_id>', methods=['PUT'])
@login_required @login_required
def update_shop_manage_group(item_id): 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: if denied:
return denied return denied
data = request.get_json() or {} 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']) @admin_api.route('/shop-manage-group/<int:item_id>', methods=['DELETE'])
@login_required @login_required
def delete_shop_manage_group(item_id): 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: if denied:
return denied return denied
result, error_response, status = _proxy_backend_java( 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], '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 {}),
})

View File

@@ -413,6 +413,7 @@
<div class="tab" data-tab="shop-keys">店铺密钥管理</div> <div class="tab" data-tab="shop-keys">店铺密钥管理</div>
<div class="tab" data-tab="shop-manage">店铺管理</div> <div class="tab" data-tab="shop-manage">店铺管理</div>
<div class="tab" data-tab="skip-price-asin">跳过跟价 ASIN</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="history">查看生成记录</div>
<div class="tab" data-tab="version">版本管理</div> <div class="tab" data-tab="version">版本管理</div>
</div> </div>
@@ -817,6 +818,120 @@
</div> </div>
</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 id="panel-history" class="tab-panel">
<div class="form-box"> <div class="form-box">
<h3 style="margin-bottom:16px;font-size:15px;">筛选条件</h3> <h3 style="margin-bottom:16px;font-size:15px;">筛选条件</h3>
@@ -1063,6 +1178,69 @@
</div> </div>
</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-mask" id="editShopManageModal">
<div class="modal"> <div class="modal">
<h3>编辑店铺</h3> <h3>编辑店铺</h3>
@@ -1134,6 +1312,7 @@
'shop-keys': 'panel-shop-keys', 'shop-keys': 'panel-shop-keys',
'shop-manage': 'panel-shop-manage', 'shop-manage': 'panel-shop-manage',
'skip-price-asin': 'panel-skip-price-asin', 'skip-price-asin': 'panel-skip-price-asin',
'query-asin': 'panel-query-asin',
'history': 'panel-history', 'history': 'panel-history',
'version': 'panel-version' 'version': 'panel-version'
}; };
@@ -1144,6 +1323,7 @@
else if (tabName === 'shop-keys') loadShopKeys(1); else if (tabName === 'shop-keys') loadShopKeys(1);
else if (tabName === 'shop-manage') loadShopManage(1); else if (tabName === 'shop-manage') loadShopManage(1);
else if (tabName === 'skip-price-asin') loadSkipPriceAsin(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 === 'history') loadHistory(1);
else if (tabName === 'version') loadVersions(); else if (tabName === 'version') loadVersions();
} }
@@ -2304,10 +2484,16 @@
var skipCreateSel = document.getElementById('skipPriceAsinGroupSelect'); var skipCreateSel = document.getElementById('skipPriceAsinGroupSelect');
var skipFilterSel = document.getElementById('skipPriceAsinFilterGroupId'); var skipFilterSel = document.getElementById('skipPriceAsinFilterGroupId');
var chooseSkipShopSel = document.getElementById('chooseSkipPriceAsinShopGroupId'); 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 selectedFilterId = filterSel ? filterSel.value : '';
var selectedSkipCreateId = skipCreateSel ? skipCreateSel.value : ''; var selectedSkipCreateId = skipCreateSel ? skipCreateSel.value : '';
var selectedSkipFilterId = skipFilterSel ? skipFilterSel.value : ''; var selectedSkipFilterId = skipFilterSel ? skipFilterSel.value : '';
var selectedChooseSkipShopId = chooseSkipShopSel ? chooseSkipShopSel.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 createOpts = ['<option value="">请选择分组</option>'];
var filterOpts = ['<option value="">全部分组</option>']; var filterOpts = ['<option value="">全部分组</option>'];
shopManageGroups.forEach(function (g) { shopManageGroups.forEach(function (g) {
@@ -2318,15 +2504,21 @@
createSel.innerHTML = createOpts.join(''); createSel.innerHTML = createOpts.join('');
editSel.innerHTML = createOpts.join(''); editSel.innerHTML = createOpts.join('');
if (skipCreateSel) skipCreateSel.innerHTML = createOpts.join(''); if (skipCreateSel) skipCreateSel.innerHTML = createOpts.join('');
if (queryCreateSel) queryCreateSel.innerHTML = createOpts.join('');
if (filterSel) filterSel.innerHTML = filterOpts.join(''); if (filterSel) filterSel.innerHTML = filterOpts.join('');
if (skipFilterSel) skipFilterSel.innerHTML = filterOpts.join(''); if (skipFilterSel) skipFilterSel.innerHTML = filterOpts.join('');
if (chooseSkipShopSel) chooseSkipShopSel.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 (selectedCreateId != null) createSel.value = String(selectedCreateId);
if (selectedEditId != null) editSel.value = String(selectedEditId); if (selectedEditId != null) editSel.value = String(selectedEditId);
if (filterSel && selectedFilterId) filterSel.value = selectedFilterId; if (filterSel && selectedFilterId) filterSel.value = selectedFilterId;
if (skipCreateSel && selectedSkipCreateId) skipCreateSel.value = selectedSkipCreateId; if (skipCreateSel && selectedSkipCreateId) skipCreateSel.value = selectedSkipCreateId;
if (skipFilterSel && selectedSkipFilterId) skipFilterSel.value = selectedSkipFilterId; if (skipFilterSel && selectedSkipFilterId) skipFilterSel.value = selectedSkipFilterId;
if (chooseSkipShopSel && selectedChooseSkipShopId) chooseSkipShopSel.value = selectedChooseSkipShopId; 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) { function loadShopManageGroups(selectedCreateId, selectedEditId) {
@@ -2361,7 +2553,7 @@
function updateShopManageGroupButtonsAccess() { function updateShopManageGroupButtonsAccess() {
var canManageGroups = !!currentUserId; var canManageGroups = !!currentUserId;
['btnManageShopGroups', 'btnManageShopGroupsFromEdit', 'btnManageSkipPriceAsinGroups'].forEach(function (id) { ['btnManageShopGroups', 'btnManageShopGroupsFromEdit', 'btnManageSkipPriceAsinGroups', 'btnManageQueryAsinGroups'].forEach(function (id) {
var btn = document.getElementById(id); var btn = document.getElementById(id);
if (btn) btn.style.display = canManageGroups ? '' : 'none'; if (btn) btn.style.display = canManageGroups ? '' : 'none';
}); });
@@ -2447,6 +2639,10 @@
setShopManageGroupGrantRoutes(['skip-price-asin']); setShopManageGroupGrantRoutes(['skip-price-asin']);
openShopManageGroupModal(); openShopManageGroupModal();
}; };
document.getElementById('btnManageQueryAsinGroups').onclick = function () {
setShopManageGroupGrantRoutes(['query-asin']);
openShopManageGroupModal();
};
document.getElementById('btnSearchShopManage').onclick = function () { document.getElementById('btnSearchShopManage').onclick = function () {
loadShopManage(1); loadShopManage(1);
}; };
@@ -2990,6 +3186,515 @@
}; };
setupSkipPriceAsinShopPicker(); setupSkipPriceAsinShopPicker();
renderSkipPriceAsinInputs(); 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, '&quot;') + '" 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, '&quot;') + '" 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(/&quot;/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, '&quot;') + '">删除</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, '&quot;') + '" data-shop-name="' + (item.shop_name || '').replace(/"/g, '&quot;') + '">编辑</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(/&quot;/g, '"'),
(btn.dataset.asin || '').replace(/&quot;/g, '"')
);
};
});
document.querySelectorAll('[data-query-asin-delete]').forEach(function (btn) {
btn.onclick = function () {
var shopName = (btn.dataset.shopName || '').replace(/&quot;/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() { function loadVersions() {
fetch('/api/admin/versions') fetch('/api/admin/versions')
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })

View 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>

View File

@@ -620,6 +620,11 @@ function clearActiveQueueTask() {
saveQueueState(); 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) { function removeMatchedRowLocally(row: PatrolDeleteShopQueueItem) {
matchedItems.value = matchedItems.value.filter( matchedItems.value = matchedItems.value.filter(
(item) => rowKeyForMatch(item) !== rowKeyForMatch(row), (item) => rowKeyForMatch(item) !== rowKeyForMatch(row),
@@ -627,6 +632,14 @@ function removeMatchedRowLocally(row: PatrolDeleteShopQueueItem) {
saveMatchedItems(); 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( function mergeQueueItems(
base: PatrolDeleteShopQueueItem[], base: PatrolDeleteShopQueueItem[],
incoming: PatrolDeleteShopQueueItem[], incoming: PatrolDeleteShopQueueItem[],
@@ -1013,6 +1026,15 @@ async function deleteTaskRecord(item: PatrolDeleteHistoryItem) {
await Promise.all([loadCandidates(), refreshTaskViews()]); await Promise.all([loadCandidates(), refreshTaskViews()]);
ElMessage.success("已删除"); ElMessage.success("已删除");
} catch (error) { } 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 : "删除失败"); ElMessage.error(error instanceof Error ? error.message : "删除失败");
} }
} }

View File

@@ -818,6 +818,11 @@ function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | nul
return `${(row.shopName || '').trim()}\u0001${row.shopId ?? ''}` 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() { function buildSkipAsinDeletePolicy() {
const enabled = statusModeEnabled.value && !asinModeEnabled.value const enabled = statusModeEnabled.value && !asinModeEnabled.value
return { return {
@@ -843,6 +848,14 @@ function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: numb
saveMatchedItemsToStorage() 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) { async function removeMatchedRow(row: PriceTrackShopQueueItem) {
const backup = [...matchedItems.value] const backup = [...matchedItems.value]
removeMatchedRowsLocally([row]) removeMatchedRowsLocally([row])
@@ -860,6 +873,13 @@ async function removeMatchedRow(row: PriceTrackShopQueueItem) {
} }
ElMessage.success('已从匹配结果中移除') ElMessage.success('已从匹配结果中移除')
} catch (e) { } catch (e) {
if (isRecordMissingError(e)) {
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
ElMessage.success('后端记录已不存在,已同步移除本地记录')
return
}
matchedItems.value = backup matchedItems.value = backup
saveMatchedItemsToStorage() saveMatchedItemsToStorage()
ElMessage.error(e instanceof Error ? e.message : '同步后端失败') ElMessage.error(e instanceof Error ? e.message : '同步后端失败')
@@ -1587,6 +1607,15 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
syncPollingIdsWithHistory() syncPollingIdsWithHistory()
ElMessage.success('已删除') ElMessage.success('已删除')
} catch (e) { } 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 : '删除失败') ElMessage.error(e instanceof Error ? e.message : '删除失败')
} }
} }

View File

@@ -469,6 +469,11 @@ function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | nul
return `${name}\u0001${id}` 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 }>) { function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
if (!rows.length) return if (!rows.length) return
const keys = new Set(rows.map((row) => rowKeyForMatch(row))) const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
@@ -476,6 +481,14 @@ function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: numb
saveMatchedItemsToStorage() 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) { async function removeMatchedRow(row: ProductRiskShopQueueItem) {
const key = rowKeyForMatch(row) const key = rowKeyForMatch(row)
const backup = [...matchedItems.value] const backup = [...matchedItems.value]
@@ -495,6 +508,13 @@ async function removeMatchedRow(row: ProductRiskShopQueueItem) {
} }
ElMessage.success('已从匹配结果中移除') ElMessage.success('已从匹配结果中移除')
} catch (e) { } catch (e) {
if (isRecordMissingError(e)) {
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
ElMessage.success('后端记录已不存在,已同步移除本地记录')
return
}
matchedItems.value = backup matchedItems.value = backup
saveMatchedItemsToStorage() saveMatchedItemsToStorage()
ElMessage.error(e instanceof Error ? e.message : '同步后端失败') ElMessage.error(e instanceof Error ? e.message : '同步后端失败')
@@ -530,6 +550,17 @@ async function deleteTaskRecord(item: ProductRiskHistoryItem) {
syncPollingIdsWithHistory() syncPollingIdsWithHistory()
ElMessage.success('已删除') ElMessage.success('已删除')
} catch (e) { } 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 : '删除失败') ElMessage.error(e instanceof Error ? e.message : '删除失败')
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -191,7 +191,7 @@ function loadTaskSnapshotsFromStorage() { try { const raw = window.localStorage.
function saveTaskSnapshotsToStorage() { setStorageJson(taskSnapshotsStorageKey(), taskSnapshots.value, Object.keys(taskSnapshots.value).length === 0) } 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 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 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 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 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) } } 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()]) await Promise.allSettled([loadHistory(), loadCandidates(), loadDashboard()])
ElMessage.success('删除成功') ElMessage.success('删除成功')
} catch (error) { } catch (error) {
if (taskId > 0 && isTaskMissingError(error)) { if (isTaskMissingError(error)) {
if (taskId > 0) {
removeTaskLocally(taskId) 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()]) await Promise.allSettled([loadHistory(), loadCandidates(), loadDashboard()])
ElMessage.success('任务已在服务端删除,本地记录已同步清理') ElMessage.success('后端记录已不存在,本地记录已同步清理')
return return
} }
ElMessage.error(error instanceof Error ? error.message : '删除失败') ElMessage.error(error instanceof Error ? error.message : '删除失败')

View File

@@ -53,6 +53,7 @@ type ActiveNavKey =
| 'shop-match' | 'shop-match'
| 'pricing' | 'pricing'
| 'patrol-delete' | 'patrol-delete'
| 'query-asin'
type NavItem = { type NavItem = {
key: string key: string
@@ -95,6 +96,7 @@ const navGroups: ReadonlyArray<NavGroup> = [
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' }, { key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' },
{ key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html' }, { key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html' },
{ key: 'patrol-delete', label: '巡店删除', href: '/new_web_source/patrol-delete.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: 'withdraw', label: '取款' },
{ key: 'shop-status', label: '店铺状态查询' }, { key: 'shop-status', label: '店铺状态查询' },
], ],

View 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')

View File

@@ -581,6 +581,7 @@ export interface ProductRiskShopQueueItem {
matchedUserId?: number; matchedUserId?: number;
matchStatus?: string; matchStatus?: string;
matchMessage?: string; matchMessage?: string;
queryAsins?: QueryAsinCountryAsins[];
} }
export interface ProductRiskMatchShopsVo { 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 { export interface PriceTrackCandidateVo {

View File

@@ -50,6 +50,7 @@ export default defineConfig({
'shop-match': resolve(__dirname, 'shop-match.html'), 'shop-match': resolve(__dirname, 'shop-match.html'),
'price-track': resolve(__dirname, 'price-track.html'), 'price-track': resolve(__dirname, 'price-track.html'),
'patrol-delete': resolve(__dirname, 'patrol-delete.html'), 'patrol-delete': resolve(__dirname, 'patrol-delete.html'),
'query-asin': resolve(__dirname, 'query-asin.html'),
}, },
output: { output: {
entryFileNames: 'assets/[name].js', entryFileNames: 'assets/[name].js',

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
const e=[{value:"SearchSuppressed",label:"在搜索结果中禁止显示"},{value:"ApprovalRequired",label:"需要批准"},{value:"Active",label:"在售"},{value:"DetailPageRemoved",label:"详情页面已删除"}];export{e as L};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>格式转换 - 数富AI</title> <title>格式转换 - 数富AI</title>
<script type="module" crossorigin src="/assets/convert.js"></script> <script type="module" crossorigin src="/assets/convert.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/brand-BATjYfEL.js"> <link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.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/convert-7wWJ02Tw.css"> <link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
</head> </head>
<body> <body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据去重 - 数富AI</title> <title>数据去重 - 数富AI</title>
<script type="module" crossorigin src="/assets/dedupe.js"></script> <script type="module" crossorigin src="/assets/dedupe.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/brand-BATjYfEL.js"> <link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.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/dedupe-BpNHwt51.css"> <link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
</head> </head>
<body> <body>

View File

@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>删除品牌 - 数富AI</title> <title>删除品牌 - 数富AI</title>
<script type="module" crossorigin src="/assets/delete-brand.js"></script> <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/pywebview-CeWJDVeG.js">
<link rel="modulepreload" crossorigin href="/assets/brand-BATjYfEL.js"> <link rel="modulepreload" crossorigin href="/assets/brand-UU-ckLq6.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/delete-brand-DfRLfrtm.css"> <link rel="stylesheet" crossorigin href="/assets/delete-brand-CWLpe7lu.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>巡店删除 - 数富AI</title> <title>巡店删除 - 数富AI</title>
<script type="module" crossorigin src="/assets/patrol-delete.js"></script> <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/pywebview-CeWJDVeG.js">
<link rel="modulepreload" crossorigin href="/assets/zh-cn-Z9PBnl-n.js"> <link rel="modulepreload" crossorigin href="/assets/zh-cn-DyKmB71i.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/patrol-delete-DIvFZUTh.css"> <link rel="stylesheet" crossorigin href="/assets/patrol-delete-BPu1AAPM.css">
<link rel="stylesheet" crossorigin href="/assets/el-input-CezJelDw.css"> <link rel="stylesheet" crossorigin href="/assets/el-input-CezJelDw.css">
</head> </head>
<body> <body>

Some files were not shown because too many files have changed in this diff Show More