匹配回收,bug修复
This commit is contained in:
4
app/.env
4
app/.env
@@ -12,8 +12,8 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot
|
|||||||
client_name=ShuFuAI
|
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:18081
|
||||||
java_api_base=http://8.136.19.173:18080
|
# java_api_base=http://8.136.19.173:18080
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
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
Binary file not shown.
24
app/main.py
24
app/main.py
@@ -19,6 +19,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import requests
|
import requests
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import atexit
|
||||||
|
|
||||||
from amazon.del_brand import kill_process
|
from amazon.del_brand import kill_process
|
||||||
|
|
||||||
@@ -39,6 +40,21 @@ print(f"""
|
|||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
APP_URL = "http://127.0.0.1:5123"
|
APP_URL = "http://127.0.0.1:5123"
|
||||||
PORT = 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):
|
def generate_images(params):
|
||||||
@@ -57,6 +73,11 @@ class WindowAPI:
|
|||||||
window.events.restored += lambda _: setattr(self, '_is_maximized', False)
|
window.events.restored += lambda _: setattr(self, '_is_maximized', False)
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
try:
|
||||||
|
self._window.destroy()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
kill_process('v6')
|
kill_process('v6')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -369,6 +390,7 @@ def start_task_monitor():
|
|||||||
|
|
||||||
|
|
||||||
def on_window_closing():
|
def on_window_closing():
|
||||||
|
return
|
||||||
"""窗口关闭事件处理器,执行与 close() 方法相同的清理逻辑"""
|
"""窗口关闭事件处理器,执行与 close() 方法相同的清理逻辑"""
|
||||||
# 先关闭窗口,避免看起来卡着
|
# 先关闭窗口,避免看起来卡着
|
||||||
try:
|
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,
|
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)
|
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(
|
webview.start(
|
||||||
debug=False,
|
debug=True,
|
||||||
storage_path=cache_path,
|
storage_path=cache_path,
|
||||||
private_mode=False
|
private_mode=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>格式转换 - 数富AI</title>
|
<title>格式转换 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/convert.js"></script>
|
<script type="module" crossorigin src="/assets/convert.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-V_UIajJm.js">
|
<link rel="modulepreload" crossorigin href="/assets/pywebview-Cq_E2BnJ.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-1nUVsWrm.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-DZ5_PGnw.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-Q4glvYu5.css">
|
<link rel="stylesheet" crossorigin href="/assets/pywebview-BbIq-QPS.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
|
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>数据去重 - 数富AI</title>
|
<title>数据去重 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-V_UIajJm.js">
|
<link rel="modulepreload" crossorigin href="/assets/pywebview-Cq_E2BnJ.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-1nUVsWrm.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-DZ5_PGnw.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-Q4glvYu5.css">
|
<link rel="stylesheet" crossorigin href="/assets/pywebview-BbIq-QPS.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
|
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>删除品牌 - 数富AI</title>
|
<title>删除品牌 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-V_UIajJm.js">
|
<link rel="modulepreload" crossorigin href="/assets/pywebview-Cq_E2BnJ.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-1nUVsWrm.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-DZ5_PGnw.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-Q4glvYu5.css">
|
<link rel="stylesheet" crossorigin href="/assets/pywebview-BbIq-QPS.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-Dl7T0pmm.css">
|
<link rel="stylesheet" crossorigin href="/assets/delete-brand-Dl7T0pmm.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>数据拆分 - 数富AI</title>
|
<title>数据拆分 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/split.js"></script>
|
<script type="module" crossorigin src="/assets/split.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-V_UIajJm.js">
|
<link rel="modulepreload" crossorigin href="/assets/pywebview-Cq_E2BnJ.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-1nUVsWrm.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-DZ5_PGnw.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-Q4glvYu5.css">
|
<link rel="stylesheet" crossorigin href="/assets/pywebview-BbIq-QPS.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
|
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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()
|
|
||||||
@@ -19,4 +19,5 @@ public class DeleteBrandProgressProperties {
|
|||||||
* 与删除品牌共用同一定时调度。
|
* 与删除品牌共用同一定时调度。
|
||||||
*/
|
*/
|
||||||
private long productRiskStaleTimeoutMinutes = 10;
|
private long productRiskStaleTimeoutMinutes = 10;
|
||||||
|
private long productRiskInitialTimeoutMinutes = 10;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("-");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
import com.nanri.aiimage.config.DeleteBrandProgressProperties;
|
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.productrisk.service.ProductRiskTaskService;
|
||||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
@@ -31,6 +32,7 @@ public class DeleteBrandStaleTaskService {
|
|||||||
private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
|
private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
|
||||||
private final DeleteBrandRunService deleteBrandRunService;
|
private final DeleteBrandRunService deleteBrandRunService;
|
||||||
private final ProductRiskTaskService productRiskTaskService;
|
private final ProductRiskTaskService productRiskTaskService;
|
||||||
|
private final ProductRiskTaskCacheService productRiskTaskCacheService;
|
||||||
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
|
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
|
||||||
|
|
||||||
@Scheduled(cron = "${aiimage.delete-brand-progress.stale-check-cron:0 */2 * * * *}")
|
@Scheduled(cron = "${aiimage.delete-brand-progress.stale-check-cron:0 */2 * * * *}")
|
||||||
@@ -115,7 +117,10 @@ public class DeleteBrandStaleTaskService {
|
|||||||
private ProductRiskStaleCheckStats failStaleProductRiskResolveTasks() {
|
private ProductRiskStaleCheckStats failStaleProductRiskResolveTasks() {
|
||||||
ProductRiskStaleCheckStats stats = new ProductRiskStaleCheckStats();
|
ProductRiskStaleCheckStats stats = new ProductRiskStaleCheckStats();
|
||||||
long minutes = Math.max(1L, deleteBrandProgressProperties.getProductRiskStaleTimeoutMinutes());
|
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>()
|
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_PRODUCT_RISK)
|
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_PRODUCT_RISK)
|
||||||
.eq(FileTaskEntity::getStatus, "RUNNING")
|
.eq(FileTaskEntity::getStatus, "RUNNING")
|
||||||
@@ -125,9 +130,19 @@ public class DeleteBrandStaleTaskService {
|
|||||||
if (runningTasks.isEmpty()) {
|
if (runningTasks.isEmpty()) {
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
log.info("[stale-check] product-risk candidates={} threshold={} timeoutMinutes={}",
|
log.info("[stale-check] product-risk candidates={} threshold={} timeoutMinutes={} initialThreshold={} initialTimeoutMinutes={}",
|
||||||
runningTasks.size(), threshold, minutes);
|
runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes);
|
||||||
for (FileTaskEntity task : runningTasks) {
|
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 {
|
try {
|
||||||
if (productRiskTaskService.tryFinalizeTask(task.getId(), true)) {
|
if (productRiskTaskService.tryFinalizeTask(task.getId(), true)) {
|
||||||
stats.finalizedTaskCount++;
|
stats.finalizedTaskCount++;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import jakarta.validation.constraints.NotEmpty;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -27,4 +28,8 @@ public class ProductRiskCreateTaskRequest {
|
|||||||
+ "店名会去重;每店生成一条 biz_file_result(含 resultId)。",
|
+ "店名会去重;每店生成一条 biz_file_result(含 resultId)。",
|
||||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private List<ProductRiskShopQueueItemVo> items = new ArrayList<>();
|
private List<ProductRiskShopQueueItemVo> items = new ArrayList<>();
|
||||||
|
|
||||||
|
@JsonProperty("schedule_time")
|
||||||
|
@Schema(description = "可选,定时执行时间,必须晚于当前时间", example = "2026-04-09T18:30:00")
|
||||||
|
private LocalDateTime scheduleTime;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,4 +57,8 @@ public class ProductRiskResultItemVo {
|
|||||||
@JsonProperty("downloadUrl")
|
@JsonProperty("downloadUrl")
|
||||||
@Schema(description = "预签名下载 URL(列表里可能带;直链下载也可用 GET /results/{resultId}/download)")
|
@Schema(description = "预签名下载 URL(列表里可能带;直链下载也可用 GET /results/{resultId}/download)")
|
||||||
private String downloadUrl;
|
private String downloadUrl;
|
||||||
|
|
||||||
|
@JsonProperty("scheduledAt")
|
||||||
|
@Schema(description = "定时执行时间,未设置时为空")
|
||||||
|
private String scheduledAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,8 @@ public class ProductRiskTaskItemVo {
|
|||||||
@JsonProperty("finishedAt")
|
@JsonProperty("finishedAt")
|
||||||
@Schema(description = "结束时间;RUNNING 时通常为空")
|
@Schema(description = "结束时间;RUNNING 时通常为空")
|
||||||
private String finishedAt;
|
private String finishedAt;
|
||||||
|
|
||||||
|
@JsonProperty("scheduledAt")
|
||||||
|
@Schema(description = "定时执行时间,未设置时为空")
|
||||||
|
private String scheduledAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
public void deleteTaskCache(Long taskId) {
|
||||||
if (taskId == null || taskId <= 0) {
|
if (taskId == null || taskId <= 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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, "下载失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<>();
|
||||||
|
}
|
||||||
@@ -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<>();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,4 +28,5 @@ public class FileTaskEntity {
|
|||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
private LocalDateTime finishedAt;
|
private LocalDateTime finishedAt;
|
||||||
|
private LocalDateTime scheduledAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,13 +280,14 @@ public class ZiniaoShopIndexService {
|
|||||||
roundEntries.add(entryToStore);
|
roundEntries.add(entryToStore);
|
||||||
}
|
}
|
||||||
Map<String, ZiniaoShopIndexEntryDto> byShopId = new LinkedHashMap<>();
|
Map<String, ZiniaoShopIndexEntryDto> byShopId = new LinkedHashMap<>();
|
||||||
Map<String, ZiniaoShopIndexEntryDto> byNameOnly = new LinkedHashMap<>();
|
Map<String, ZiniaoShopIndexEntryDto> byNameAlias = new LinkedHashMap<>();
|
||||||
for (ZiniaoShopIndexEntryDto e : roundEntries) {
|
for (ZiniaoShopIndexEntryDto e : roundEntries) {
|
||||||
String sid = e.getShopId();
|
String sid = e.getShopId();
|
||||||
if (sid != null && !sid.isBlank()) {
|
if (sid != null && !sid.isBlank()) {
|
||||||
byShopId.merge(sid, e, this::mergeDuplicateShopIndexEntries);
|
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<>();
|
Set<String> activeCacheKeys = new HashSet<>();
|
||||||
@@ -295,14 +296,20 @@ public class ZiniaoShopIndexService {
|
|||||||
activeCacheKeys.add(key);
|
activeCacheKeys.add(key);
|
||||||
ziniaoMemoryStoreService.put(ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY, key, e, resolveEntryTtl());
|
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();
|
String key = SHOP_ENTRY_KEY_NAME_PREFIX + e.getNormalizedShopName();
|
||||||
activeCacheKeys.add(key);
|
activeCacheKeys.add(key);
|
||||||
ziniaoMemoryStoreService.put(ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY, key, e, resolveEntryTtl());
|
ziniaoMemoryStoreService.put(ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY, key, e, resolveEntryTtl());
|
||||||
}
|
}
|
||||||
log.info("[ziniao-index] refresh persist shopIndex uniqueShopId={} nameOnly={} groupedNames={} activeRows={}",
|
log.info("[ziniao-index] refresh persist shopIndex uniqueShopId={} nameAlias={} groupedNames={} activeRows={}",
|
||||||
byShopId.size(), byNameOnly.size(), grouped.size(), activeCacheKeys.size());
|
byShopId.size(), byNameAlias.size(), grouped.size(), activeCacheKeys.size());
|
||||||
markMissingEntriesAsStale(activeCacheKeys, now);
|
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.setStatus("SUCCESS");
|
||||||
cursor.setMessage(allInvalidUserIds.isEmpty()
|
cursor.setMessage(allInvalidUserIds.isEmpty()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ spring:
|
|||||||
max-request-size: 500MB
|
max-request-size: 500MB
|
||||||
datasource:
|
datasource:
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
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}
|
username: ${AIIMAGE_DB_USERNAME:root}
|
||||||
password: ${AIIMAGE_DB_PASSWORD:change-me}
|
password: ${AIIMAGE_DB_PASSWORD:change-me}
|
||||||
hikari:
|
hikari:
|
||||||
@@ -60,6 +60,7 @@ knife4j:
|
|||||||
language: zh_cn
|
language: zh_cn
|
||||||
|
|
||||||
aiimage:
|
aiimage:
|
||||||
|
instance-id: ${AIIMAGE_INSTANCE_ID:}
|
||||||
oss:
|
oss:
|
||||||
region: ${AIIMAGE_OSS_REGION:cn-hangzhou}
|
region: ${AIIMAGE_OSS_REGION:cn-hangzhou}
|
||||||
endpoint: ${AIIMAGE_OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com}
|
endpoint: ${AIIMAGE_OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com}
|
||||||
@@ -82,10 +83,11 @@ aiimage:
|
|||||||
stale-check-cron: ${AIIMAGE_DELETE_BRAND_STALE_CHECK_CRON:*/30 * * * * *}
|
stale-check-cron: ${AIIMAGE_DELETE_BRAND_STALE_CHECK_CRON:*/30 * * * * *}
|
||||||
finalize-check-cron: ${AIIMAGE_DELETE_BRAND_FINALIZE_CHECK_CRON:30 */2 * * * *}
|
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-stale-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_STALE_TIMEOUT_MINUTES:10}
|
||||||
|
product-risk-initial-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_INITIAL_TIMEOUT_MINUTES:10}
|
||||||
module-cleanup:
|
module-cleanup:
|
||||||
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
|
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
|
||||||
cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *}
|
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:
|
security:
|
||||||
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
|
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
|
||||||
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}
|
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}
|
||||||
|
|||||||
@@ -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='匹配店铺-用户备选店铺';
|
||||||
@@ -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='匹配店铺-用户国家偏好';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE biz_file_task
|
||||||
|
ADD COLUMN scheduled_at DATETIME NULL COMMENT '定时执行时间' AFTER finished_at;
|
||||||
909650
backend-java/src/main/resources/db/aiimage.sql
Normal file
909650
backend-java/src/main/resources/db/aiimage.sql
Normal file
File diff suppressed because one or more lines are too long
12
frontend-vue/shop-match.html
Normal file
12
frontend-vue/shop-match.html
Normal 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>
|
||||||
1582
frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue
Normal file
1582
frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = defineProps<{
|
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
|
const active = props.active
|
||||||
@@ -58,6 +58,7 @@ const navGroups = [
|
|||||||
items: [
|
items: [
|
||||||
{ key: 'delete-brand', label: '删除指定ASIN和品牌', href: '/new_web_source/delete-brand.html' },
|
{ key: 'delete-brand', label: '删除指定ASIN和品牌', href: '/new_web_source/delete-brand.html' },
|
||||||
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.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
|
] as const
|
||||||
|
|||||||
@@ -624,6 +624,7 @@ export interface ProductRiskHistoryItem {
|
|||||||
error?: string;
|
error?: string;
|
||||||
outputFilename?: string;
|
outputFilename?: string;
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
|
scheduledAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductRiskHistoryVo {
|
export interface ProductRiskHistoryVo {
|
||||||
@@ -638,6 +639,7 @@ export interface ProductRiskTaskSummary {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
finishedAt?: string;
|
finishedAt?: string;
|
||||||
|
scheduledAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductRiskTaskDetailVo {
|
export interface ProductRiskTaskDetailVo {
|
||||||
@@ -659,6 +661,16 @@ export interface ProductRiskCreateTaskVo {
|
|||||||
items: ProductRiskHistoryItem[];
|
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() {
|
export function getProductRiskDashboard() {
|
||||||
return unwrapJavaResponse(
|
return unwrapJavaResponse(
|
||||||
get<JavaApiResponse<ProductRiskDashboardVo>>(
|
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) {
|
export function getJavaDownloadUrl(path: string) {
|
||||||
let raw =
|
let raw =
|
||||||
path.startsWith("http://") || path.startsWith("https://")
|
path.startsWith("http://") || path.startsWith("https://")
|
||||||
|
|||||||
7
frontend-vue/src/shop-match-main.ts
Normal file
7
frontend-vue/src/shop-match-main.ts
Normal 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')
|
||||||
@@ -47,6 +47,7 @@ export default defineConfig({
|
|||||||
split: resolve(__dirname, 'split.html'),
|
split: resolve(__dirname, 'split.html'),
|
||||||
'delete-brand': resolve(__dirname, 'delete-brand.html'),
|
'delete-brand': resolve(__dirname, 'delete-brand.html'),
|
||||||
'product-risk': resolve(__dirname, 'product-risk.html'),
|
'product-risk': resolve(__dirname, 'product-risk.html'),
|
||||||
|
'shop-match': resolve(__dirname, 'shop-match.html'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: 'assets/[name].js',
|
entryFileNames: 'assets/[name].js',
|
||||||
|
|||||||
Reference in New Issue
Block a user