匹配回收,bug修复

This commit is contained in:
super
2026-04-10 19:33:58 +08:00
parent ccaec4a984
commit 86fd9475e4
50 changed files with 913120 additions and 88 deletions

View File

@@ -12,8 +12,8 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot
client_name=ShuFuAI
# java_api_base=http://127.0.0.1:18080
java_api_base=http://127.0.0.1:18080
# java_api_base=http://8.136.19.173:18081
java_api_base=http://8.136.19.173:18080
# java_api_base=http://8.136.19.173:18080

Binary file not shown.

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

@@ -19,6 +19,7 @@ import threading
import time
import requests
import subprocess
import atexit
from amazon.del_brand import kill_process
@@ -39,6 +40,21 @@ print(f"""
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
APP_URL = "http://127.0.0.1:5123"
PORT = 5123
_shutdown_started = False
def cleanup_before_exit():
global _shutdown_started
if _shutdown_started:
return
_shutdown_started = True
try:
kill_process('v6')
except Exception as e:
print(f"【退出前】关闭紫鸟浏览器进程异常: {str(e)}")
atexit.register(cleanup_before_exit)
def generate_images(params):
@@ -57,6 +73,11 @@ class WindowAPI:
window.events.restored += lambda _: setattr(self, '_is_maximized', False)
def close(self):
try:
self._window.destroy()
except:
pass
return
try:
kill_process('v6')
except Exception as e:
@@ -369,6 +390,7 @@ def start_task_monitor():
def on_window_closing():
return
"""窗口关闭事件处理器,执行与 close() 方法相同的清理逻辑"""
# 先关闭窗口,避免看起来卡着
try:
@@ -419,7 +441,7 @@ def main():
window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder,
api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del,api.save_file_from_url_new)
webview.start(
debug=False,
debug=True,
storage_path=cache_path,
private_mode=False
)

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>格式转换 - 数富AI</title>
<script type="module" crossorigin src="/assets/convert.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-V_UIajJm.js">
<link rel="modulepreload" crossorigin href="/assets/brand-1nUVsWrm.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-Q4glvYu5.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-Cq_E2BnJ.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DZ5_PGnw.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BbIq-QPS.css">
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
</head>
<body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据去重 - 数富AI</title>
<script type="module" crossorigin src="/assets/dedupe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-V_UIajJm.js">
<link rel="modulepreload" crossorigin href="/assets/brand-1nUVsWrm.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-Q4glvYu5.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-Cq_E2BnJ.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DZ5_PGnw.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BbIq-QPS.css">
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
</head>
<body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>删除品牌 - 数富AI</title>
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-V_UIajJm.js">
<link rel="modulepreload" crossorigin href="/assets/brand-1nUVsWrm.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-Q4glvYu5.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-Cq_E2BnJ.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DZ5_PGnw.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BbIq-QPS.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-Dl7T0pmm.css">
</head>
<body>

View File

@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据拆分 - 数富AI</title>
<script type="module" crossorigin src="/assets/split.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-V_UIajJm.js">
<link rel="modulepreload" crossorigin href="/assets/brand-1nUVsWrm.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-Q4glvYu5.css">
<link rel="modulepreload" crossorigin href="/assets/pywebview-Cq_E2BnJ.js">
<link rel="modulepreload" crossorigin href="/assets/brand-DZ5_PGnw.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-BbIq-QPS.css">
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
</head>
<body>

View File

@@ -1,56 +0,0 @@
#!/usr/bin/env python3
import json
import sys
import time
from urllib import request, error
def build_chunk(file_url: str, chunk_index: int, chunk_total: int, total_lines: int):
return {
"fileUrl": file_url,
"originalFilename": "demo.xlsx",
"relativePath": None,
"mainSheetName": "Sheet1",
"chunkIndex": chunk_index,
"chunkTotal": chunk_total,
"totalLines": total_lines,
"keptRows": [f"brand-{chunk_index}"],
"invalidBrands": [],
"queryFailedBrands": []
}
def post_json(url: str, payload: dict):
data = json.dumps(payload).encode("utf-8")
req = request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
with request.urlopen(req, timeout=60) as resp:
return resp.status, resp.read().decode("utf-8", errors="ignore")
def main():
if len(sys.argv) < 4:
print("usage: python brand_chunk_load_test.py <base_url> <task_id> <chunk_total> [file_url]")
sys.exit(1)
base_url = sys.argv[1].rstrip("/")
task_id = int(sys.argv[2])
chunk_total = int(sys.argv[3])
file_url = sys.argv[4] if len(sys.argv) > 4 else "https://example.com/demo.xlsx"
submit_url = f"{base_url}/api/brand/tasks/{task_id}/result"
started = time.time()
for i in range(1, chunk_total + 1):
payload = {
"strategy": "Terms",
"files": [build_chunk(file_url, i, chunk_total, chunk_total)]
}
status, body = post_json(submit_url, payload)
if i == 1 or i == chunk_total or i % 100 == 0:
print(f"chunk {i}/{chunk_total} status={status} elapsed={time.time() - started:.2f}s")
if status < 200 or status >= 300:
print(body)
sys.exit(2)
print(f"done {chunk_total} chunks in {time.time() - started:.2f}s")
if __name__ == "__main__":
main()

View File

@@ -19,4 +19,5 @@ public class DeleteBrandProgressProperties {
* 与删除品牌共用同一定时调度。
*/
private long productRiskStaleTimeoutMinutes = 10;
private long productRiskInitialTimeoutMinutes = 10;
}

View File

@@ -0,0 +1,37 @@
package com.nanri.aiimage.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.InetAddress;
@Component
public class InstanceMetadata {
private final String instanceId;
private final String hostname;
public InstanceMetadata(@Value("${aiimage.instance-id:}") String configuredInstanceId) {
this.hostname = resolveHostname();
this.instanceId = configuredInstanceId == null || configuredInstanceId.isBlank()
? this.hostname
: configuredInstanceId.trim();
}
public String getInstanceId() {
return instanceId;
}
public String getHostname() {
return hostname;
}
private static String resolveHostname() {
try {
return InetAddress.getLocalHost().getHostName();
} catch (Exception ignored) {
String envHostname = System.getenv("HOSTNAME");
return (envHostname == null || envHostname.isBlank()) ? "unknown-host" : envHostname;
}
}
}

View File

@@ -0,0 +1,81 @@
package com.nanri.aiimage.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Optional;
@Component
public class RequestTraceFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(RequestTraceFilter.class);
private final InstanceMetadata instanceMetadata;
public RequestTraceFilter(InstanceMetadata instanceMetadata) {
this.instanceMetadata = instanceMetadata;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
long start = System.currentTimeMillis();
String remoteAddr = firstNonBlank(
request.getHeader("X-Forwarded-For"),
request.getHeader("X-Real-IP"),
request.getRemoteAddr()
);
String forwardedProto = request.getHeader("X-Forwarded-Proto");
String forwardedHost = firstNonBlank(request.getHeader("X-Forwarded-Host"), request.getHeader("Host"));
String forwardedPort = request.getHeader("X-Forwarded-Port");
String requestId = firstNonBlank(
request.getHeader("X-Request-Id"),
request.getHeader("X-Amzn-Trace-Id"),
request.getHeader("Traceparent")
);
response.setHeader("X-AIIMAGE-Instance", instanceMetadata.getInstanceId());
response.setHeader("X-AIIMAGE-Host", instanceMetadata.getHostname());
try {
filterChain.doFilter(request, response);
} finally {
long costMs = System.currentTimeMillis() - start;
log.info(
"request-trace instance={} host={} method={} uri={} status={} remote={} forwardedHost={} forwardedProto={} forwardedPort={} requestId={} userAgent={} costMs={}",
instanceMetadata.getInstanceId(),
instanceMetadata.getHostname(),
request.getMethod(),
request.getRequestURI(),
response.getStatus(),
remoteAddr,
blankToDash(forwardedHost),
blankToDash(forwardedProto),
blankToDash(forwardedPort),
blankToDash(requestId),
blankToDash(request.getHeader("User-Agent")),
costMs
);
}
}
private static String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
private static String blankToDash(String value) {
return Optional.ofNullable(value).filter(v -> !v.isBlank()).orElse("-");
}
}

View File

@@ -0,0 +1,53 @@
package com.nanri.aiimage.modules.debug.controller;
import com.nanri.aiimage.config.InstanceMetadata;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
@RestController
@RequestMapping("/debug")
public class DebugRequestInfoController {
private final InstanceMetadata instanceMetadata;
public DebugRequestInfoController(InstanceMetadata instanceMetadata) {
this.instanceMetadata = instanceMetadata;
}
@GetMapping("/request-info")
public Map<String, Object> requestInfo(
HttpServletRequest request,
@RequestHeader(value = "X-Forwarded-For", required = false) String forwardedFor,
@RequestHeader(value = "X-Real-IP", required = false) String realIp,
@RequestHeader(value = "X-Forwarded-Proto", required = false) String forwardedProto,
@RequestHeader(value = "X-Forwarded-Host", required = false) String forwardedHost,
@RequestHeader(value = "X-Forwarded-Port", required = false) String forwardedPort,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestHeader(value = "X-Amzn-Trace-Id", required = false) String amazonTraceId,
@RequestHeader(value = "Traceparent", required = false) String traceparent
) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("instanceId", instanceMetadata.getInstanceId());
result.put("hostname", instanceMetadata.getHostname());
result.put("serverName", request.getServerName());
result.put("serverPort", request.getServerPort());
result.put("method", request.getMethod());
result.put("uri", request.getRequestURI());
result.put("remoteAddr", request.getRemoteAddr());
result.put("xForwardedFor", forwardedFor);
result.put("xRealIp", realIp);
result.put("xForwardedProto", forwardedProto);
result.put("xForwardedHost", forwardedHost);
result.put("xForwardedPort", forwardedPort);
result.put("xRequestId", requestId);
result.put("xAmznTraceId", amazonTraceId);
result.put("traceparent", traceparent);
return result;
}
}

View File

@@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.deletebrand.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.nanri.aiimage.config.DeleteBrandProgressProperties;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskCacheService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
@@ -31,6 +32,7 @@ public class DeleteBrandStaleTaskService {
private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
private final DeleteBrandRunService deleteBrandRunService;
private final ProductRiskTaskService productRiskTaskService;
private final ProductRiskTaskCacheService productRiskTaskCacheService;
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
@Scheduled(cron = "${aiimage.delete-brand-progress.stale-check-cron:0 */2 * * * *}")
@@ -115,7 +117,10 @@ public class DeleteBrandStaleTaskService {
private ProductRiskStaleCheckStats failStaleProductRiskResolveTasks() {
ProductRiskStaleCheckStats stats = new ProductRiskStaleCheckStats();
long minutes = Math.max(1L, deleteBrandProgressProperties.getProductRiskStaleTimeoutMinutes());
LocalDateTime threshold = LocalDateTime.now().minusMinutes(minutes);
long initialMinutes = Math.max(minutes, deleteBrandProgressProperties.getProductRiskInitialTimeoutMinutes());
LocalDateTime now = LocalDateTime.now();
LocalDateTime threshold = now.minusMinutes(minutes);
LocalDateTime initialThreshold = now.minusMinutes(initialMinutes);
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_PRODUCT_RISK)
.eq(FileTaskEntity::getStatus, "RUNNING")
@@ -125,9 +130,19 @@ public class DeleteBrandStaleTaskService {
if (runningTasks.isEmpty()) {
return stats;
}
log.info("[stale-check] product-risk candidates={} threshold={} timeoutMinutes={}",
runningTasks.size(), threshold, minutes);
log.info("[stale-check] product-risk candidates={} threshold={} timeoutMinutes={} initialThreshold={} initialTimeoutMinutes={}",
runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes);
for (FileTaskEntity task : runningTasks) {
boolean hasUploadedPayload = productRiskTaskCacheService.hasAnyShopMergedPayload(task.getId());
boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0)
|| (task.getFailedFileCount() != null && task.getFailedFileCount() > 0);
boolean hasStartedProgress = hasUploadedPayload || hasFinishedRows;
if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) {
stats.skippedTaskCount++;
log.info("[stale-check] product-risk skip initial-grace taskId={} createdAt={} initialThreshold={}",
task.getId(), task.getCreatedAt(), initialThreshold);
continue;
}
try {
if (productRiskTaskService.tryFinalizeTask(task.getId(), true)) {
stats.finalizedTaskCount++;

View File

@@ -8,6 +8,7 @@ import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@@ -27,4 +28,8 @@ public class ProductRiskCreateTaskRequest {
+ "店名会去重;每店生成一条 biz_file_result含 resultId",
requiredMode = Schema.RequiredMode.REQUIRED)
private List<ProductRiskShopQueueItemVo> items = new ArrayList<>();
@JsonProperty("schedule_time")
@Schema(description = "可选,定时执行时间,必须晚于当前时间", example = "2026-04-09T18:30:00")
private LocalDateTime scheduleTime;
}

View File

@@ -57,4 +57,8 @@ public class ProductRiskResultItemVo {
@JsonProperty("downloadUrl")
@Schema(description = "预签名下载 URL列表里可能带直链下载也可用 GET /results/{resultId}/download")
private String downloadUrl;
@JsonProperty("scheduledAt")
@Schema(description = "定时执行时间,未设置时为空")
private String scheduledAt;
}

View File

@@ -33,4 +33,8 @@ public class ProductRiskTaskItemVo {
@JsonProperty("finishedAt")
@Schema(description = "结束时间RUNNING 时通常为空")
private String finishedAt;
@JsonProperty("scheduledAt")
@Schema(description = "定时执行时间,未设置时为空")
private String scheduledAt;
}

View File

@@ -73,6 +73,14 @@ public class ProductRiskTaskCacheService {
}
}
public boolean hasAnyShopMergedPayload(Long taskId) {
if (taskId == null || taskId <= 0) {
return false;
}
Long size = stringRedisTemplate.opsForHash().size(buildShopPayloadKey(taskId));
return size != null && size > 0;
}
public void deleteTaskCache(Long taskId) {
if (taskId == null || taskId <= 0) {
return;

View File

@@ -0,0 +1,150 @@
package com.nanri.aiimage.modules.shopmatch.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCountryPreferenceSaveRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCreateTaskRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskTaskBatchRequest;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCountryPreferenceVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCreateTaskVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskHistoryVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskTaskBatchVo;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchSubmitResultRequest;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchResolveService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
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.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.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/shop-match")
public class ShopMatchController {
private final ShopMatchResolveService shopMatchResolveService;
private final ShopMatchTaskService shopMatchTaskService;
@GetMapping("/candidates")
public ApiResponse<List<ProductRiskCandidateVo>> listCandidates(@RequestParam("user_id") Long userId) {
return ApiResponse.success(shopMatchResolveService.listCandidates(userId));
}
@PostMapping("/candidates")
public ApiResponse<ProductRiskCandidateVo> addCandidate(@Valid @RequestBody ProductRiskCandidateAddRequest request) {
return ApiResponse.success(shopMatchResolveService.addCandidate(request));
}
@DeleteMapping("/candidates/{id}")
public ApiResponse<Void> deleteCandidate(@PathVariable Long id, @RequestParam("user_id") Long userId) {
shopMatchResolveService.deleteCandidate(userId, id);
return ApiResponse.success(null);
}
@GetMapping("/country-preference")
public ApiResponse<ProductRiskCountryPreferenceVo> getCountryPreference(@RequestParam("user_id") Long userId) {
return ApiResponse.success(shopMatchResolveService.getCountryPreference(userId));
}
@PutMapping("/country-preference")
public ApiResponse<ProductRiskCountryPreferenceVo> saveCountryPreference(@Valid @RequestBody ProductRiskCountryPreferenceSaveRequest request) {
return ApiResponse.success(shopMatchResolveService.saveCountryPreference(request));
}
@PostMapping("/match-shops")
public ApiResponse<ProductRiskMatchShopsVo> matchShops(@Valid @RequestBody ProductRiskMatchShopsRequest request) {
return ApiResponse.success(shopMatchResolveService.matchShops(request));
}
@GetMapping("/dashboard")
public ApiResponse<ProductRiskDashboardVo> dashboard(@RequestParam("user_id") Long userId) {
return ApiResponse.success(shopMatchTaskService.dashboard(userId));
}
@GetMapping("/history")
public ApiResponse<ProductRiskHistoryVo> history(@RequestParam("user_id") Long userId) {
return ApiResponse.success(shopMatchTaskService.listHistory(userId));
}
@DeleteMapping("/history/{resultId}")
public ApiResponse<Void> deleteHistory(@PathVariable Long resultId, @RequestParam("user_id") Long userId) {
shopMatchTaskService.deleteHistory(resultId, userId);
return ApiResponse.success(null);
}
@PostMapping("/tasks")
public ApiResponse<ProductRiskCreateTaskVo> createTask(@Valid @RequestBody ProductRiskCreateTaskRequest request) {
return ApiResponse.success(shopMatchTaskService.createTask(request));
}
@DeleteMapping("/tasks/{taskId}")
public ApiResponse<Void> deleteTask(@PathVariable Long taskId, @RequestParam("user_id") Long userId) {
shopMatchTaskService.deleteTask(taskId, userId);
return ApiResponse.success(null);
}
@PostMapping("/tasks/{taskId}/activate")
public ApiResponse<Void> activateTask(@PathVariable Long taskId, @RequestParam("user_id") Long userId) {
shopMatchTaskService.activateTask(taskId, userId);
return ApiResponse.success(null);
}
@PostMapping("/tasks/batch")
public ApiResponse<ProductRiskTaskBatchVo> tasksBatch(@Valid @RequestBody ProductRiskTaskBatchRequest request) {
return ApiResponse.success(shopMatchTaskService.getTaskDetailsBatch(request.getTaskIds()));
}
@PostMapping("/tasks/{taskId}/result")
public ApiResponse<Void> submitResult(@PathVariable Long taskId, @Valid @RequestBody ShopMatchSubmitResultRequest request) {
shopMatchTaskService.submitResult(taskId, request);
return ApiResponse.success(null);
}
@GetMapping("/results/{resultId}/download")
public void downloadResult(
@PathVariable Long resultId,
@RequestParam("user_id") Long userId,
jakarta.servlet.http.HttpServletResponse response) {
String url = shopMatchTaskService.resolveResultDownloadUrl(resultId, userId);
String filename = shopMatchTaskService.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, "下载失败");
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
package com.nanri.aiimage.modules.shopmatch.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "匹配店铺单行结果")
public class ShopMatchRowDto {
@Schema(description = "ASIN")
private String asin;
@Schema(description = "状态")
private String status;
@JsonProperty("done")
@Schema(description = "该店铺是否已完成回传")
private Boolean done;
}

View File

@@ -0,0 +1,25 @@
package com.nanri.aiimage.modules.shopmatch.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Data
@Schema(description = "Python 回传的单店铺结果")
public class ShopMatchShopPayloadDto {
@JsonProperty("shopName")
@Schema(description = "店铺名称")
private String shopName;
@Schema(description = "失败时的错误信息")
private String error;
@Schema(description = "按国家分组的 ASIN/状态 列表")
private Map<ProductRiskCountryCode, List<ShopMatchRowDto>> countries = new LinkedHashMap<>();
}

View File

@@ -0,0 +1,18 @@
package com.nanri.aiimage.modules.shopmatch.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 = "匹配店铺结果回传")
public class ShopMatchSubmitResultRequest {
@NotEmpty(message = "shops 不能为空")
@Valid
private List<ShopMatchShopPayloadDto> shops = new ArrayList<>();
}

View File

@@ -0,0 +1,18 @@
package com.nanri.aiimage.modules.shopmatch.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_shop_match_country_pref")
public class ShopMatchCountryPrefEntity {
@TableId(type = IdType.INPUT)
private Long userId;
private String countryCodesJson;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,19 @@
package com.nanri.aiimage.modules.shopmatch.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_shop_match_shop_candidate")
public class ShopMatchShopCandidateEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String shopName;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,79 @@
package com.nanri.aiimage.modules.shopmatch.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchRowDto;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
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 ShopMatchExcelAssemblyService {
private static final String[] HEADER = {"ASIN", "状态"};
public void writeWorkbook(File outputXlsx, Map<String, List<ShopMatchRowDto>> countries) {
Map<String, List<ShopMatchRowDto>> safe = countries == null ? Map.of() : countries;
try (XSSFWorkbook wb = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(outputXlsx)) {
for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) {
String sheetName = code.getSheetName();
Sheet sheet = wb.createSheet(sheetName);
Row header = sheet.createRow(0);
header.createCell(0).setCellValue(HEADER[0]);
header.createCell(1).setCellValue(HEADER[1]);
List<ShopMatchRowDto> rows = safe.getOrDefault(sheetName, List.of());
int rowIndex = 1;
for (ShopMatchRowDto item : rows) {
if (item == null) {
continue;
}
Row row = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(item.getAsin() == null ? "" : item.getAsin());
row.createCell(1).setCellValue(item.getStatus() == null ? "" : item.getStatus());
}
sheet.autoSizeColumn(0);
sheet.autoSizeColumn(1);
}
wb.write(fos);
} catch (Exception ex) {
log.warn("[shop-match] write workbook failed: {}", ex.getMessage());
throw new BusinessException("生成 Excel 失败: " + ex.getMessage());
}
}
public int countRows(Map<String, List<ShopMatchRowDto>> countries) {
if (countries == null || countries.isEmpty()) {
return 0;
}
int count = 0;
for (List<ShopMatchRowDto> rows : countries.values()) {
if (rows != null) {
count += rows.size();
}
}
return count;
}
public Map<String, List<ShopMatchRowDto>> normalizeCountriesMap(Map<ProductRiskCountryCode, List<ShopMatchRowDto>> raw) {
if (raw == null || raw.isEmpty()) {
return new LinkedHashMap<>();
}
Map<String, List<ShopMatchRowDto>> out = new LinkedHashMap<>();
for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) {
List<ShopMatchRowDto> rows = raw.get(code);
if (rows != null) {
out.put(code.getSheetName(), rows);
}
}
return out;
}
}

View File

@@ -0,0 +1,274 @@
package com.nanri.aiimage.modules.shopmatch.service;
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.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCountryPreferenceSaveRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCountryPreferenceVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskShopQueueItemVo;
import com.nanri.aiimage.modules.shopmatch.mapper.ShopMatchCountryPrefMapper;
import com.nanri.aiimage.modules.shopmatch.mapper.ShopMatchShopCandidateMapper;
import com.nanri.aiimage.modules.shopmatch.model.entity.ShopMatchCountryPrefEntity;
import com.nanri.aiimage.modules.shopmatch.model.entity.ShopMatchShopCandidateEntity;
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.LinkedHashSet;
import java.util.List;
import java.util.Objects;
@Service
@RequiredArgsConstructor
public class ShopMatchResolveService {
public static final List<String> DEFAULT_COUNTRY_PREFERENCE_ORDER = List.of("DE", "UK", "FR", "IT", "ES");
private final ShopMatchShopCandidateMapper candidateMapper;
private final ShopMatchCountryPrefMapper countryPrefMapper;
private final ObjectMapper objectMapper;
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
public List<ProductRiskCandidateVo> listCandidates(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
List<ShopMatchShopCandidateEntity> rows = candidateMapper.selectList(
new LambdaQueryWrapper<ShopMatchShopCandidateEntity>()
.eq(ShopMatchShopCandidateEntity::getUserId, userId)
.orderByDesc(ShopMatchShopCandidateEntity::getId));
List<ProductRiskCandidateVo> list = new ArrayList<>();
for (ShopMatchShopCandidateEntity 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) {
if (request.getUserId() == null || request.getUserId() <= 0) {
throw new BusinessException("user_id 不合法");
}
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() : "存在多个同名店铺,请人工确认");
}
ShopMatchShopCandidateEntity existing = candidateMapper.selectOne(
new LambdaQueryWrapper<ShopMatchShopCandidateEntity>()
.eq(ShopMatchShopCandidateEntity::getUserId, request.getUserId())
.eq(ShopMatchShopCandidateEntity::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;
}
ShopMatchShopCandidateEntity entity = new ShopMatchShopCandidateEntity();
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) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
if (id == null || id <= 0) {
throw new BusinessException("id 不合法");
}
ShopMatchShopCandidateEntity row = candidateMapper.selectById(id);
if (row == null || !userId.equals(row.getUserId())) {
throw new BusinessException("记录不存在");
}
candidateMapper.deleteById(id);
}
public ProductRiskMatchShopsVo matchShops(ProductRiskMatchShopsRequest request) {
if (request.getUserId() == null || request.getUserId() <= 0) {
throw new BusinessException("user_id 不合法");
}
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(shopName));
}
return vo;
}
public ProductRiskCountryPreferenceVo getCountryPreference(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
ShopMatchCountryPrefEntity row = countryPrefMapper.selectById(userId);
ProductRiskCountryPreferenceVo vo = new ProductRiskCountryPreferenceVo();
if (row == null || row.getCountryCodesJson() == null || row.getCountryCodesJson().isBlank()) {
vo.getCountryCodes().addAll(DEFAULT_COUNTRY_PREFERENCE_ORDER);
return vo;
}
try {
List<String> parsed = objectMapper.readValue(row.getCountryCodesJson(), new TypeReference<List<String>>() {});
vo.getCountryCodes().addAll(sanitizeStoredCodes(parsed));
} catch (Exception ex) {
vo.getCountryCodes().addAll(DEFAULT_COUNTRY_PREFERENCE_ORDER);
}
return vo;
}
@Transactional
public ProductRiskCountryPreferenceVo saveCountryPreference(ProductRiskCountryPreferenceSaveRequest request) {
if (request.getUserId() == null || request.getUserId() <= 0) {
throw new BusinessException("user_id 不合法");
}
List<String> normalized = validateCountryCodesForSave(request.getCountryCodes());
String json;
try {
json = objectMapper.writeValueAsString(normalized);
} catch (Exception ex) {
throw new BusinessException("保存偏好失败");
}
ShopMatchCountryPrefEntity row = countryPrefMapper.selectById(request.getUserId());
if (row == null) {
row = new ShopMatchCountryPrefEntity();
row.setUserId(request.getUserId());
row.setCountryCodesJson(json);
countryPrefMapper.insert(row);
} else {
row.setCountryCodesJson(json);
countryPrefMapper.updateById(row);
}
ProductRiskCountryPreferenceVo vo = new ProductRiskCountryPreferenceVo();
vo.getCountryCodes().addAll(normalized);
return vo;
}
private ProductRiskShopQueueItemVo matchOneShop(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());
} 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 static List<String> sanitizeStoredCodes(List<String> raw) {
List<String> parsed = parseValidCountryCodes(raw);
return parsed.isEmpty() ? new ArrayList<>(DEFAULT_COUNTRY_PREFERENCE_ORDER) : parsed;
}
private static List<String> validateCountryCodesForSave(List<String> raw) {
if (raw == null || raw.isEmpty()) {
throw new BusinessException("country_codes 至少选择 1 个国家");
}
LinkedHashSet<String> seen = new LinkedHashSet<>();
List<String> out = new ArrayList<>();
for (String item : raw) {
if (item == null || item.isBlank()) {
throw new BusinessException("country_codes 含空值");
}
String upper = item.trim().toUpperCase();
try {
ProductRiskCountryCode.valueOf(upper);
} catch (IllegalArgumentException ex) {
throw new BusinessException("非法国家代码: " + item);
}
if (!seen.add(upper)) {
throw new BusinessException("country_codes 存在重复: " + upper);
}
out.add(upper);
}
return out;
}
private static List<String> parseValidCountryCodes(List<String> raw) {
if (raw == null || raw.isEmpty()) {
return new ArrayList<>();
}
LinkedHashSet<String> seen = new LinkedHashSet<>();
List<String> out = new ArrayList<>();
for (String item : raw) {
if (item == null || item.isBlank()) {
continue;
}
String upper = item.trim().toUpperCase();
try {
ProductRiskCountryCode.valueOf(upper);
} catch (IllegalArgumentException ex) {
continue;
}
if (seen.add(upper)) {
out.add(upper);
}
}
return out;
}
}

View File

@@ -0,0 +1,87 @@
package com.nanri.aiimage.modules.shopmatch.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchShopPayloadDto;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class ShopMatchTaskCacheService {
private static final long PAYLOAD_TTL_HOURS = 24;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
public ShopMatchShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
return null;
}
Object raw = stringRedisTemplate.opsForHash().get(buildShopPayloadKey(taskId), shopKey);
if (!(raw instanceof String json) || json.isBlank()) {
return null;
}
try {
return objectMapper.readValue(json, ShopMatchShopPayloadDto.class);
} catch (Exception ex) {
throw new BusinessException("读取匹配店铺缓存失败");
}
}
public void saveShopMergedPayload(Long taskId, String shopKey, ShopMatchShopPayloadDto payload) {
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank() || payload == null) {
return;
}
try {
String key = buildShopPayloadKey(taskId);
stringRedisTemplate.opsForHash().put(key, shopKey, objectMapper.writeValueAsString(payload));
stringRedisTemplate.expire(key, Duration.ofHours(PAYLOAD_TTL_HOURS));
} catch (Exception ex) {
throw new BusinessException("暂存匹配店铺结果失败");
}
}
public void removeShopMergedPayload(Long taskId, String shopKey) {
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
return;
}
stringRedisTemplate.opsForHash().delete(buildShopPayloadKey(taskId), shopKey);
}
public Map<String, ShopMatchShopPayloadDto> getAllShopMergedPayload(Long taskId) {
try {
Map<Object, Object> raw = stringRedisTemplate.opsForHash().entries(buildShopPayloadKey(taskId));
if (raw == null || raw.isEmpty()) {
return Map.of();
}
LinkedHashMap<String, ShopMatchShopPayloadDto> out = new LinkedHashMap<>();
for (Map.Entry<Object, Object> entry : raw.entrySet()) {
if (!(entry.getKey() instanceof String key) || !(entry.getValue() instanceof String val) || val.isBlank()) {
continue;
}
out.put(key, objectMapper.readValue(val, ShopMatchShopPayloadDto.class));
}
return out;
} catch (Exception ex) {
throw new BusinessException("读取匹配店铺缓存失败");
}
}
public void deleteTaskCache(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
stringRedisTemplate.delete(buildShopPayloadKey(taskId));
}
private String buildShopPayloadKey(Long taskId) {
return "shop-match:task:shop-payload:" + taskId;
}
}

View File

@@ -0,0 +1,718 @@
package com.nanri.aiimage.modules.shopmatch.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.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCreateTaskRequest;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCreateTaskVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskHistoryVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskResultItemVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskShopQueueItemVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskTaskBatchVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskTaskDetailVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskTaskItemVo;
import com.nanri.aiimage.modules.shopmatch.mapper.ShopMatchShopCandidateMapper;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchRowDto;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchShopPayloadDto;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchSubmitResultRequest;
import com.nanri.aiimage.modules.shopmatch.model.entity.ShopMatchShopCandidateEntity;
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.Locale;
import java.util.Map;
@Service
@RequiredArgsConstructor
@Slf4j
public class ShopMatchTaskService {
private static final String MODULE_TYPE = "SHOP_MATCH";
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper;
private final ShopMatchShopCandidateMapper candidateMapper;
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
private final ShopMatchExcelAssemblyService excelAssemblyService;
private final OssStorageService ossStorageService;
private final ObjectMapper objectMapper;
private final ShopMatchTaskCacheService shopMatchTaskCacheService;
public ProductRiskDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
ProductRiskDashboardVo vo = new ProductRiskDashboardVo();
vo.setCandidateCount(candidateMapper.selectCount(new LambdaQueryWrapper<ShopMatchShopCandidateEntity>()
.eq(ShopMatchShopCandidateEntity::getUserId, userId)));
Long processed = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getUserId, userId)
.in(FileTaskEntity::getStatus, List.of("SUCCESS", "FAILED")));
Long success = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getUserId, userId)
.eq(FileTaskEntity::getStatus, "SUCCESS"));
Long failed = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getUserId, userId)
.eq(FileTaskEntity::getStatus, "FAILED"));
vo.setProcessedTaskCount(processed == null ? 0L : processed);
vo.setSuccessTaskCount(success == null ? 0L : success);
vo.setFailedTaskCount(failed == null ? 0L : failed);
return vo;
}
public ProductRiskHistoryVo listHistory(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
ProductRiskHistoryVo vo = new ProductRiskHistoryVo();
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.eq(FileResultEntity::getUserId, userId)
.orderByDesc(FileResultEntity::getCreatedAt)
.last("limit 100"));
Map<Long, String> statusByTaskId = new LinkedHashMap<>();
List<Long> taskIds = entities.stream()
.map(FileResultEntity::getTaskId)
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (!taskIds.isEmpty()) {
for (FileTaskEntity task : fileTaskMapper.selectBatchIds(taskIds)) {
if (task != null) {
statusByTaskId.put(task.getId(), task.getStatus());
}
}
}
List<ProductRiskResultItemVo> items = new ArrayList<>();
for (FileResultEntity entity : entities) {
items.add(toHistoryItemVo(entity, statusByTaskId.get(entity.getTaskId())));
}
vo.setItems(items);
return vo;
}
@Transactional
public void deleteHistory(Long resultId, Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
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);
if (taskId != null && taskId > 0) {
reconcileTaskAfterResultRemoval(taskId);
}
}
@Transactional
public void deleteTask(Long taskId, Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
FileTaskEntity task = fileTaskMapper.selectById(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);
shopMatchTaskCacheService.deleteTaskCache(taskId);
}
private void reconcileTaskAfterResultRemoval(Long taskId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return;
}
List<FileResultEntity> latest = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
if (latest.isEmpty()) {
fileTaskMapper.deleteById(taskId);
shopMatchTaskCacheService.deleteTaskCache(taskId);
return;
}
updateTaskStatusFromLatestRows(task, latest);
}
public ProductRiskTaskBatchVo getTaskDetailsBatch(List<Long> taskIds) {
ProductRiskTaskBatchVo batch = new ProductRiskTaskBatchVo();
if (taskIds == null || taskIds.isEmpty()) {
return batch;
}
for (Long taskId : taskIds) {
if (taskId == null || taskId <= 0) {
continue;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
batch.getMissingTaskIds().add(taskId);
continue;
}
batch.getItems().add(buildTaskDetail(task));
}
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 (entity.getResultFileUrl() == null || entity.getResultFileUrl().isBlank()) {
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 entity.getResultFilename() != null && !entity.getResultFilename().isBlank()
? entity.getResultFilename()
: safeFileStem(entity.getSourceFilename()) + ".xlsx";
}
@Transactional
public ProductRiskCreateTaskVo createTask(ProductRiskCreateTaskRequest request) {
if (request.getUserId() == null || request.getUserId() <= 0) {
throw new BusinessException("user_id 不合法");
}
if (request.getItems() == null || request.getItems().isEmpty()) {
throw new BusinessException("items 不能为空");
}
List<ProductRiskShopQueueItemVo> uniqueItems = dedupeQueueItems(request.getItems());
if (uniqueItems.isEmpty()) {
throw new BusinessException("items 不能为空");
}
for (ProductRiskShopQueueItemVo item : uniqueItems) {
if (item == null || !item.isMatched()) {
throw new BusinessException("存在未匹配店铺,无法创建任务");
}
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime scheduledAt = request.getScheduleTime();
if (scheduledAt != null && !scheduledAt.isAfter(now)) {
throw new BusinessException("schedule_time 必须晚于当前时间");
}
FileTaskEntity task = new FileTaskEntity();
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
task.setModuleType(MODULE_TYPE);
task.setTaskMode("PYTHON_QUEUE");
task.setStatus(scheduledAt != null ? "SCHEDULED" : "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);
task.setScheduledAt(scheduledAt);
fileTaskMapper.insert(task);
List<ProductRiskResultItemVo> snapshot = new ArrayList<>();
for (ProductRiskShopQueueItemVo item : uniqueItems) {
String normalized = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (normalized.isBlank()) {
throw new BusinessException("店铺名无效");
}
FileResultEntity result = new FileResultEntity();
result.setTaskId(task.getId());
result.setModuleType(MODULE_TYPE);
result.setSourceFilename(normalized);
result.setSourceFileUrl(item.getShopId());
result.setUserId(request.getUserId());
result.setSuccess(0);
result.setCreatedAt(LocalDateTime.now());
fileResultMapper.insert(result);
snapshot.add(toSnapshotVo(result, item, task.getStatus()));
}
try {
task.setRequestJson(objectMapper.writeValueAsString(request));
task.setResultJson(objectMapper.writeValueAsString(snapshot));
} catch (Exception ex) {
throw new BusinessException("序列化任务数据失败");
}
fileTaskMapper.updateById(task);
ProductRiskCreateTaskVo vo = new ProductRiskCreateTaskVo();
vo.setTaskId(task.getId());
vo.setItems(snapshot);
return vo;
}
@Transactional
public void activateTask(Long taskId, Long userId) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法");
}
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
throw new BusinessException("任务已结束");
}
if ("RUNNING".equals(task.getStatus())) {
return;
}
if (!"SCHEDULED".equals(task.getStatus())) {
throw new BusinessException("当前任务状态不允许激活");
}
if (task.getScheduledAt() != null && LocalDateTime.now().isBefore(task.getScheduledAt())) {
throw new BusinessException("未到定时执行时间");
}
task.setStatus("RUNNING");
task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.updateById(task);
}
@Transactional
public void submitResult(Long taskId, ShopMatchSubmitResultRequest 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 = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
throw new BusinessException("任务已结束,拒绝重复提交");
}
List<FileResultEntity> resultRows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
Map<String, ShopMatchShopPayloadDto> payloadByShop = new LinkedHashMap<>();
for (ShopMatchShopPayloadDto item : request.getShops()) {
if (item == null) {
continue;
}
String key = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (!key.isBlank()) {
payloadByShop.put(key, item);
}
}
File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "shop-match-result", String.valueOf(taskId)));
boolean changed = false;
for (FileResultEntity result : resultRows) {
String shopKey = result.getSourceFilename();
ShopMatchShopPayloadDto incoming = payloadByShop.get(shopKey);
if (incoming == null) {
continue;
}
changed = true;
ShopMatchShopPayloadDto merged = mergeShopPayload(taskId, shopKey, incoming);
if (incoming.getError() != null && !incoming.getError().isBlank()) {
markResultFailed(result, incoming.getError().trim());
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
continue;
}
if (!isShopPayloadCompleted(merged)) {
continue;
}
if (countPayloadRows(merged) <= 0) {
markResultFailed(result, "未收到可组装的结果数据");
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
continue;
}
try {
assembleShopResult(result, shopKey, merged, workRoot);
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
} catch (Exception ex) {
markResultFailed(result, ex.getMessage() == null ? "assemble failed" : ex.getMessage());
shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey);
}
}
if (!changed) {
return;
}
List<FileResultEntity> latest = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
updateTaskStatusFromLatestRows(task, latest);
}
private void assembleShopResult(FileResultEntity result, String shopKey, ShopMatchShopPayloadDto payload, File workRoot) {
Map<String, List<ShopMatchRowDto>> countries = excelAssemblyService.normalizeCountriesMap(payload.getCountries());
String displayName = payload.getShopName() != null && !payload.getShopName().isBlank() ? payload.getShopName().trim() : shopKey;
String stem = safeFileStem(displayName);
File xlsx = FileUtil.file(workRoot, stem + ".xlsx");
try {
excelAssemblyService.writeWorkbook(xlsx, countries);
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
result.setResultFilename(stem + ".xlsx");
result.setResultFileUrl(objectKey);
result.setResultFileSize(xlsx.length());
result.setResultContentType(CONTENT_TYPE_XLSX);
result.setRowCount(excelAssemblyService.countRows(countries));
result.setSuccess(1);
result.setErrorMessage(null);
fileResultMapper.updateById(result);
} finally {
FileUtil.del(xlsx);
}
}
private void markResultFailed(FileResultEntity result, String message) {
result.setSuccess(0);
result.setErrorMessage(message);
result.setResultFilename(null);
result.setResultFileUrl(null);
result.setResultFileSize(0L);
result.setRowCount(0);
result.setResultContentType(null);
fileResultMapper.updateById(result);
}
private void updateTaskStatusFromLatestRows(FileTaskEntity task, List<FileResultEntity> latest) {
int ok = 0;
int fail = 0;
List<String> errors = new ArrayList<>();
boolean allDone = true;
for (FileResultEntity row : latest) {
boolean success = row.getSuccess() != null && row.getSuccess() == 1;
boolean failed = row.getErrorMessage() != null && !row.getErrorMessage().isBlank();
if (success) {
ok++;
} else if (failed) {
fail++;
errors.add(row.getSourceFilename() + ": " + row.getErrorMessage());
} else {
allDone = false;
}
}
task.setSuccessFileCount(ok);
task.setFailedFileCount(fail);
task.setUpdatedAt(LocalDateTime.now());
if (!allDone) {
task.setStatus("RUNNING");
task.setErrorMessage(null);
task.setFinishedAt(null);
} else if (ok > 0 && fail == 0) {
task.setStatus("SUCCESS");
task.setErrorMessage(null);
task.setFinishedAt(LocalDateTime.now());
} else if (ok > 0) {
task.setStatus("SUCCESS");
task.setErrorMessage(String.join("; ", errors));
task.setFinishedAt(LocalDateTime.now());
} else {
task.setStatus("FAILED");
task.setErrorMessage(errors.isEmpty() ? "all shops failed" : String.join("; ", errors));
task.setFinishedAt(LocalDateTime.now());
}
try {
task.setResultJson(objectMapper.writeValueAsString(buildSnapshotFromDb(task.getId(), task.getStatus())));
} catch (Exception ex) {
log.warn("[shop-match] compact result json failed: {}", ex.getMessage());
}
fileTaskMapper.updateById(task);
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
shopMatchTaskCacheService.deleteTaskCache(task.getId());
}
}
private ProductRiskTaskDetailVo buildTaskDetail(FileTaskEntity task) {
ProductRiskTaskDetailVo detail = new ProductRiskTaskDetailVo();
detail.setTask(toTaskItemVo(task));
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, task.getId())
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
for (FileResultEntity row : rows) {
detail.getItems().add(toHistoryItemVo(row, task.getStatus()));
}
return detail;
}
private List<ProductRiskResultItemVo> buildSnapshotFromDb(Long taskId, String taskStatus) {
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
List<ProductRiskResultItemVo> items = new ArrayList<>();
for (FileResultEntity row : rows) {
items.add(toHistoryItemVo(row, taskStatus));
}
return items;
}
private ProductRiskTaskItemVo toTaskItemVo(FileTaskEntity task) {
ProductRiskTaskItemVo vo = new ProductRiskTaskItemVo();
vo.setId(task.getId());
vo.setTaskNo(task.getTaskNo());
vo.setStatus(task.getStatus());
vo.setErrorMessage(task.getErrorMessage());
vo.setCreatedAt(fmt(task.getCreatedAt()));
vo.setUpdatedAt(fmt(task.getUpdatedAt()));
vo.setFinishedAt(fmt(task.getFinishedAt()));
vo.setScheduledAt(fmt(task.getScheduledAt()));
return vo;
}
private ProductRiskResultItemVo toSnapshotVo(FileResultEntity result, ProductRiskShopQueueItemVo item, String taskStatus) {
ProductRiskResultItemVo vo = new ProductRiskResultItemVo();
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.setScheduledAt(fmt(result.getTaskId() == null ? null : loadScheduledAt(result.getTaskId())));
vo.setSuccess(false);
return vo;
}
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) {
ProductRiskResultItemVo vo = new ProductRiskResultItemVo();
vo.setResultId(entity.getId());
vo.setTaskId(entity.getTaskId());
vo.setShopName(entity.getSourceFilename());
vo.setShopId(entity.getSourceFileUrl());
vo.setTaskStatus(taskStatus);
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
vo.setError(entity.getErrorMessage());
vo.setOutputFilename(entity.getResultFilename());
vo.setDownloadUrl(entity.getResultFileUrl() == null || entity.getResultFileUrl().isBlank()
? null
: ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
vo.setMatched(true);
if (entity.getTaskId() != null) {
vo.setScheduledAt(fmt(loadScheduledAt(entity.getTaskId())));
}
if (entity.getTaskId() != null) {
mergeQueueFieldsFromRequest(vo, entity.getTaskId());
}
return vo;
}
private LocalDateTime loadScheduledAt(Long taskId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
return task == null ? null : task.getScheduledAt();
}
private void mergeQueueFieldsFromRequest(ProductRiskResultItemVo vo, Long taskId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) {
return;
}
try {
ProductRiskCreateTaskRequest request = objectMapper.readValue(task.getRequestJson(), ProductRiskCreateTaskRequest.class);
String key = ziniaoShopSwitchService.normalizeShopName(vo.getShopName());
for (ProductRiskShopQueueItemVo item : request.getItems()) {
if (item == null) {
continue;
}
if (key.equals(ziniaoShopSwitchService.normalizeShopName(item.getShopName()))) {
vo.setShopName(item.getShopName());
vo.setShopId(item.getShopId());
vo.setPlatform(item.getPlatform());
vo.setCompanyName(item.getCompanyName());
vo.setMatchStatus(item.getMatchStatus());
vo.setMatchMessage(item.getMatchMessage());
break;
}
}
} catch (Exception ignored) {
}
}
private static String fmt(LocalDateTime time) {
return time == null ? null : time.toString();
}
private List<ProductRiskShopQueueItemVo> dedupeQueueItems(List<ProductRiskShopQueueItemVo> raw) {
Map<String, ProductRiskShopQueueItemVo> byNorm = new LinkedHashMap<>();
for (ProductRiskShopQueueItemVo item : raw) {
if (item == null) {
continue;
}
String key = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (!key.isBlank()) {
byNorm.putIfAbsent(key, item);
}
}
return new ArrayList<>(byNorm.values());
}
private static String safeFileStem(String shopName) {
String value = shopName == null ? "shop" : shopName.trim();
if (value.isBlank()) {
value = "shop";
}
return value.replaceAll("[\\\\/:*?\"<>|]", "_");
}
private ShopMatchShopPayloadDto mergeShopPayload(Long taskId, String shopKey, ShopMatchShopPayloadDto incoming) {
ShopMatchShopPayloadDto merged = shopMatchTaskCacheService.getShopMergedPayload(taskId, shopKey);
if (merged == null) {
merged = new ShopMatchShopPayloadDto();
}
if (incoming.getShopName() != null && !incoming.getShopName().isBlank()) {
merged.setShopName(incoming.getShopName().trim());
} else if (merged.getShopName() == null || merged.getShopName().isBlank()) {
merged.setShopName(shopKey);
}
if (incoming.getError() != null && !incoming.getError().isBlank()) {
merged.setError(incoming.getError().trim());
}
Map<com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode, List<ShopMatchRowDto>> nextCountries =
merged.getCountries() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(merged.getCountries());
if (incoming.getCountries() != null && !incoming.getCountries().isEmpty()) {
for (Map.Entry<com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode, List<ShopMatchRowDto>> entry : incoming.getCountries().entrySet()) {
if (entry.getKey() == null || entry.getValue() == null || entry.getValue().isEmpty()) {
continue;
}
nextCountries.put(entry.getKey(), mergeCountryRows(nextCountries.get(entry.getKey()), entry.getValue()));
}
}
merged.setCountries(nextCountries);
shopMatchTaskCacheService.saveShopMergedPayload(taskId, shopKey, merged);
return merged;
}
private List<ShopMatchRowDto> mergeCountryRows(List<ShopMatchRowDto> existingRows, List<ShopMatchRowDto> incomingRows) {
LinkedHashMap<String, ShopMatchRowDto> byKey = new LinkedHashMap<>();
if (existingRows != null) {
for (ShopMatchRowDto row : existingRows) {
if (row != null) {
byKey.put(buildRowKey(row), cloneRow(row));
}
}
}
for (ShopMatchRowDto row : incomingRows) {
if (row == null) {
continue;
}
if (Boolean.TRUE.equals(row.getDone()) && isEmptyBusinessRow(row) && !byKey.isEmpty()) {
for (Map.Entry<String, ShopMatchRowDto> entry : byKey.entrySet()) {
ShopMatchRowDto merged = mergeRow(entry.getValue(), row);
merged.setDone(Boolean.TRUE);
entry.setValue(merged);
}
continue;
}
String key = buildRowKey(row);
byKey.put(key, mergeRow(byKey.get(key), row));
}
return new ArrayList<>(byKey.values());
}
private String buildRowKey(ShopMatchRowDto row) {
String asin = row == null || row.getAsin() == null ? "" : row.getAsin().trim().toUpperCase(Locale.ROOT);
return asin.isBlank() ? "row:" + java.util.UUID.randomUUID() : "asin:" + asin;
}
private ShopMatchRowDto mergeRow(ShopMatchRowDto previous, ShopMatchRowDto incoming) {
if (previous == null) {
return cloneRow(incoming);
}
ShopMatchRowDto merged = cloneRow(previous);
if (incoming.getAsin() != null && !incoming.getAsin().isBlank()) {
merged.setAsin(incoming.getAsin());
}
if (incoming.getStatus() != null && !incoming.getStatus().isBlank()) {
merged.setStatus(incoming.getStatus());
}
merged.setDone(incoming.getDone() != null ? incoming.getDone() : merged.getDone());
return merged;
}
private ShopMatchRowDto cloneRow(ShopMatchRowDto row) {
ShopMatchRowDto out = new ShopMatchRowDto();
if (row != null) {
out.setAsin(row.getAsin());
out.setStatus(row.getStatus());
out.setDone(row.getDone());
}
return out;
}
private boolean isShopPayloadCompleted(ShopMatchShopPayloadDto payload) {
return countPayloadRows(payload) > 0 && payloadHasAnyDoneTrue(payload);
}
private int countPayloadRows(ShopMatchShopPayloadDto payload) {
if (payload == null || payload.getCountries() == null || payload.getCountries().isEmpty()) {
return 0;
}
int count = 0;
for (List<ShopMatchRowDto> rows : payload.getCountries().values()) {
if (rows == null) {
continue;
}
for (ShopMatchRowDto row : rows) {
if (row != null && !isEmptyBusinessRow(row)) {
count++;
}
}
}
return count;
}
private boolean payloadHasAnyDoneTrue(ShopMatchShopPayloadDto payload) {
if (payload == null || payload.getCountries() == null) {
return false;
}
for (List<ShopMatchRowDto> rows : payload.getCountries().values()) {
if (rows == null) {
continue;
}
for (ShopMatchRowDto row : rows) {
if (row != null && Boolean.TRUE.equals(row.getDone())) {
return true;
}
}
}
return false;
}
private boolean isEmptyBusinessRow(ShopMatchRowDto row) {
return row == null || row.getAsin() == null || row.getAsin().trim().isEmpty();
}
}

View File

@@ -28,4 +28,5 @@ public class FileTaskEntity {
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime finishedAt;
private LocalDateTime scheduledAt;
}

View File

@@ -280,13 +280,14 @@ public class ZiniaoShopIndexService {
roundEntries.add(entryToStore);
}
Map<String, ZiniaoShopIndexEntryDto> byShopId = new LinkedHashMap<>();
Map<String, ZiniaoShopIndexEntryDto> byNameOnly = new LinkedHashMap<>();
Map<String, ZiniaoShopIndexEntryDto> byNameAlias = new LinkedHashMap<>();
for (ZiniaoShopIndexEntryDto e : roundEntries) {
String sid = e.getShopId();
if (sid != null && !sid.isBlank()) {
byShopId.merge(sid, e, this::mergeDuplicateShopIndexEntries);
} else if (e.getNormalizedShopName() != null && !e.getNormalizedShopName().isBlank()) {
byNameOnly.merge(e.getNormalizedShopName(), e, this::mergeDuplicateShopIndexEntries);
}
if (e.getNormalizedShopName() != null && !e.getNormalizedShopName().isBlank()) {
byNameAlias.merge(e.getNormalizedShopName(), e, this::mergeDuplicateShopIndexEntries);
}
}
Set<String> activeCacheKeys = new HashSet<>();
@@ -295,14 +296,20 @@ public class ZiniaoShopIndexService {
activeCacheKeys.add(key);
ziniaoMemoryStoreService.put(ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY, key, e, resolveEntryTtl());
}
for (ZiniaoShopIndexEntryDto e : byNameOnly.values()) {
for (ZiniaoShopIndexEntryDto e : byNameAlias.values()) {
String key = SHOP_ENTRY_KEY_NAME_PREFIX + e.getNormalizedShopName();
activeCacheKeys.add(key);
ziniaoMemoryStoreService.put(ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY, key, e, resolveEntryTtl());
}
log.info("[ziniao-index] refresh persist shopIndex uniqueShopId={} nameOnly={} groupedNames={} activeRows={}",
byShopId.size(), byNameOnly.size(), grouped.size(), activeCacheKeys.size());
log.info("[ziniao-index] refresh persist shopIndex uniqueShopId={} nameAlias={} groupedNames={} activeRows={}",
byShopId.size(), byNameAlias.size(), grouped.size(), activeCacheKeys.size());
boolean fullRefresh = apiKeyAccounts.size() >= allApiKeyAccounts.size();
if (fullRefresh) {
markMissingEntriesAsStale(activeCacheKeys, now);
} else {
log.info("[ziniao-index] skip stale marking for partial refresh processedApiKeys={}/{} nextOffset={}",
apiKeyAccounts.size(), allApiKeyAccounts.size(), nextOffset);
}
cursor.setStatus("SUCCESS");
cursor.setMessage(allInvalidUserIds.isEmpty()

View File

@@ -12,7 +12,7 @@ spring:
max-request-size: 500MB
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AIIMAGE_DB_URL:jdbc:mysql://127.0.0.1:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false}
url: ${AIIMAGE_DB_URL:jdbc:mysql://127.0.0.1:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
username: ${AIIMAGE_DB_USERNAME:root}
password: ${AIIMAGE_DB_PASSWORD:change-me}
hikari:
@@ -60,6 +60,7 @@ knife4j:
language: zh_cn
aiimage:
instance-id: ${AIIMAGE_INSTANCE_ID:}
oss:
region: ${AIIMAGE_OSS_REGION:cn-hangzhou}
endpoint: ${AIIMAGE_OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com}
@@ -82,10 +83,11 @@ aiimage:
stale-check-cron: ${AIIMAGE_DELETE_BRAND_STALE_CHECK_CRON:*/30 * * * * *}
finalize-check-cron: ${AIIMAGE_DELETE_BRAND_FINALIZE_CHECK_CRON:30 */2 * * * *}
product-risk-stale-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_STALE_TIMEOUT_MINUTES:10}
product-risk-initial-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_INITIAL_TIMEOUT_MINUTES:10}
module-cleanup:
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *}
module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE}
module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE,SHOP_MATCH}
security:
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS biz_shop_match_shop_candidate (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
user_id BIGINT NOT NULL COMMENT '用户 ID',
shop_name VARCHAR(255) NOT NULL COMMENT '店铺名称(已规范化)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY uk_user_shop (user_id, shop_name),
KEY idx_user_id (user_id)
) COMMENT='匹配店铺-用户备选店铺';

View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS biz_shop_match_country_pref (
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID',
country_codes_json VARCHAR(256) NOT NULL COMMENT 'JSON 数组,按处理顺序保存国家代码',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) COMMENT='匹配店铺-用户国家偏好';

View File

@@ -0,0 +1,2 @@
ALTER TABLE biz_file_task
ADD COLUMN scheduled_at DATETIME NULL COMMENT '定时执行时间' AFTER finished_at;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
<!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/shop-match-main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@
<script setup lang="ts">
const props = defineProps<{
active: 'brand' | 'dedupe' | 'convert' | 'split' | 'delete-brand' | 'product-risk'
active: 'brand' | 'dedupe' | 'convert' | 'split' | 'delete-brand' | 'product-risk' | 'shop-match'
}>()
const active = props.active
@@ -58,6 +58,7 @@ const navGroups = [
items: [
{ key: 'delete-brand', label: '删除指定ASIN和品牌', href: '/new_web_source/delete-brand.html' },
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' },
{ key: 'shop-match', label: '匹配店铺', href: '/new_web_source/shop-match.html' },
],
},
] as const

View File

@@ -624,6 +624,7 @@ export interface ProductRiskHistoryItem {
error?: string;
outputFilename?: string;
downloadUrl?: string;
scheduledAt?: string;
}
export interface ProductRiskHistoryVo {
@@ -638,6 +639,7 @@ export interface ProductRiskTaskSummary {
createdAt?: string;
updatedAt?: string;
finishedAt?: string;
scheduledAt?: string;
}
export interface ProductRiskTaskDetailVo {
@@ -659,6 +661,16 @@ export interface ProductRiskCreateTaskVo {
items: ProductRiskHistoryItem[];
}
export type ShopMatchCandidateVo = ProductRiskCandidateVo;
export type ShopMatchCountryPreferenceVo = ProductRiskCountryPreferenceVo;
export type ShopMatchShopQueueItem = ProductRiskShopQueueItem;
export type ShopMatchDashboardVo = ProductRiskDashboardVo;
export type ShopMatchHistoryItem = ProductRiskHistoryItem;
export type ShopMatchHistoryVo = ProductRiskHistoryVo;
export type ShopMatchTaskDetailVo = ProductRiskTaskDetailVo;
export type ShopMatchTaskBatchVo = ProductRiskTaskBatchVo;
export type ShopMatchCreateTaskVo = ProductRiskCreateTaskVo;
export function getProductRiskDashboard() {
return unwrapJavaResponse(
get<JavaApiResponse<ProductRiskDashboardVo>>(
@@ -741,6 +753,148 @@ export function getProductRiskResultDownloadUrl(resultId: number) {
);
}
export function listShopMatchCandidates() {
return unwrapJavaResponse(
get<JavaApiResponse<ShopMatchCandidateVo[]>>(
`${JAVA_API_PREFIX}/shop-match/candidates`,
{
params: { user_id: getCurrentUserId() },
},
),
);
}
export function addShopMatchCandidate(shopName: string) {
return unwrapJavaResponse(
post<
JavaApiResponse<ShopMatchCandidateVo>,
{ user_id: number; shop_name: string }
>(`${JAVA_API_PREFIX}/shop-match/candidates`, {
user_id: getCurrentUserId(),
shop_name: shopName,
}),
);
}
export function deleteShopMatchCandidate(id: number) {
return unwrapJavaResponse(
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/shop-match/candidates/${id}`, {
params: { user_id: getCurrentUserId() },
}),
);
}
export function getShopMatchCountryPreference() {
return unwrapJavaResponse(
get<JavaApiResponse<ShopMatchCountryPreferenceVo>>(
`${JAVA_API_PREFIX}/shop-match/country-preference`,
{
params: { user_id: getCurrentUserId() },
},
),
);
}
export function putShopMatchCountryPreference(countryCodes: string[]) {
return unwrapJavaResponse(
put<
JavaApiResponse<ShopMatchCountryPreferenceVo>,
{ user_id: number; country_codes: string[] }
>(`${JAVA_API_PREFIX}/shop-match/country-preference`, {
user_id: getCurrentUserId(),
country_codes: countryCodes,
}),
);
}
export function matchShopMatchShops(shopNames: string[]) {
return unwrapJavaResponse(
post<
JavaApiResponse<ProductRiskMatchShopsVo>,
{ user_id: number; shop_names: string[] }
>(`${JAVA_API_PREFIX}/shop-match/match-shops`, {
user_id: getCurrentUserId(),
shop_names: shopNames,
}),
);
}
export function getShopMatchDashboard() {
return unwrapJavaResponse(
get<JavaApiResponse<ShopMatchDashboardVo>>(
`${JAVA_API_PREFIX}/shop-match/dashboard`,
{
params: { user_id: getCurrentUserId() },
},
),
);
}
export function getShopMatchHistory() {
return unwrapJavaResponse(
get<JavaApiResponse<ShopMatchHistoryVo>>(
`${JAVA_API_PREFIX}/shop-match/history`,
{
params: { user_id: getCurrentUserId() },
},
),
);
}
export function deleteShopMatchHistory(resultId: number) {
return unwrapJavaResponse(
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/shop-match/history/${resultId}`, {
params: { user_id: getCurrentUserId() },
}),
);
}
export function createShopMatchTask(
items: ShopMatchShopQueueItem[],
scheduleTime?: string,
) {
return unwrapJavaResponse(
post<
JavaApiResponse<ShopMatchCreateTaskVo>,
{ user_id: number; items: ShopMatchShopQueueItem[]; schedule_time?: string }
>(`${JAVA_API_PREFIX}/shop-match/tasks`, {
user_id: getCurrentUserId(),
items,
schedule_time: scheduleTime,
}),
);
}
export function deleteShopMatchTask(taskId: number) {
return unwrapJavaResponse(
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/shop-match/tasks/${taskId}`, {
params: { user_id: getCurrentUserId() },
}),
);
}
export function activateShopMatchTask(taskId: number) {
return unwrapJavaResponse(
post<JavaApiResponse<null>, undefined>(
`${JAVA_API_PREFIX}/shop-match/tasks/${taskId}/activate?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
undefined,
),
);
}
export function getShopMatchTasksBatch(taskIds: number[]) {
return unwrapJavaResponse(
post<JavaApiResponse<ShopMatchTaskBatchVo>, { taskIds: number[] }>(
`${JAVA_API_PREFIX}/shop-match/tasks/batch`,
{ taskIds },
),
);
}
export function getShopMatchResultDownloadUrl(resultId: number) {
return getJavaDownloadUrl(`/shop-match/results/${resultId}/download`);
}
export function getJavaDownloadUrl(path: string) {
let raw =
path.startsWith("http://") || path.startsWith("https://")

View File

@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandShopMatchTab from '@/pages/brand/components/BrandShopMatchTab.vue'
createApp(BrandShopMatchTab).use(ElementPlus).mount('#app')

View File

@@ -47,6 +47,7 @@ export default defineConfig({
split: resolve(__dirname, 'split.html'),
'delete-brand': resolve(__dirname, 'delete-brand.html'),
'product-risk': resolve(__dirname, 'product-risk.html'),
'shop-match': resolve(__dirname, 'shop-match.html'),
},
output: {
entryFileNames: 'assets/[name].js',