匹配回收,bug修复

This commit is contained in:
super
2026-04-10 19:33:58 +08:00
parent 144d03965e
commit 03a85d5523
40 changed files with 913104 additions and 72 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.

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

@@ -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());
markMissingEntriesAsStale(activeCacheKeys, now);
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',