匹配回收,bug修复
This commit is contained in:
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 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.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.config.DeleteBrandProgressProperties;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskCacheService;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
@@ -31,6 +32,7 @@ public class DeleteBrandStaleTaskService {
|
||||
private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
|
||||
private final DeleteBrandRunService deleteBrandRunService;
|
||||
private final ProductRiskTaskService productRiskTaskService;
|
||||
private final ProductRiskTaskCacheService productRiskTaskCacheService;
|
||||
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
|
||||
|
||||
@Scheduled(cron = "${aiimage.delete-brand-progress.stale-check-cron:0 */2 * * * *}")
|
||||
@@ -115,7 +117,10 @@ public class DeleteBrandStaleTaskService {
|
||||
private ProductRiskStaleCheckStats failStaleProductRiskResolveTasks() {
|
||||
ProductRiskStaleCheckStats stats = new ProductRiskStaleCheckStats();
|
||||
long minutes = Math.max(1L, deleteBrandProgressProperties.getProductRiskStaleTimeoutMinutes());
|
||||
LocalDateTime threshold = LocalDateTime.now().minusMinutes(minutes);
|
||||
long initialMinutes = Math.max(minutes, deleteBrandProgressProperties.getProductRiskInitialTimeoutMinutes());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime threshold = now.minusMinutes(minutes);
|
||||
LocalDateTime initialThreshold = now.minusMinutes(initialMinutes);
|
||||
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_PRODUCT_RISK)
|
||||
.eq(FileTaskEntity::getStatus, "RUNNING")
|
||||
@@ -125,9 +130,19 @@ public class DeleteBrandStaleTaskService {
|
||||
if (runningTasks.isEmpty()) {
|
||||
return stats;
|
||||
}
|
||||
log.info("[stale-check] product-risk candidates={} threshold={} timeoutMinutes={}",
|
||||
runningTasks.size(), threshold, minutes);
|
||||
log.info("[stale-check] product-risk candidates={} threshold={} timeoutMinutes={} initialThreshold={} initialTimeoutMinutes={}",
|
||||
runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes);
|
||||
for (FileTaskEntity task : runningTasks) {
|
||||
boolean hasUploadedPayload = productRiskTaskCacheService.hasAnyShopMergedPayload(task.getId());
|
||||
boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0)
|
||||
|| (task.getFailedFileCount() != null && task.getFailedFileCount() > 0);
|
||||
boolean hasStartedProgress = hasUploadedPayload || hasFinishedRows;
|
||||
if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) {
|
||||
stats.skippedTaskCount++;
|
||||
log.info("[stale-check] product-risk skip initial-grace taskId={} createdAt={} initialThreshold={}",
|
||||
task.getId(), task.getCreatedAt(), initialThreshold);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (productRiskTaskService.tryFinalizeTask(task.getId(), true)) {
|
||||
stats.finalizedTaskCount++;
|
||||
|
||||
@@ -8,6 +8,7 @@ import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -27,4 +28,8 @@ public class ProductRiskCreateTaskRequest {
|
||||
+ "店名会去重;每店生成一条 biz_file_result(含 resultId)。",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<ProductRiskShopQueueItemVo> items = new ArrayList<>();
|
||||
|
||||
@JsonProperty("schedule_time")
|
||||
@Schema(description = "可选,定时执行时间,必须晚于当前时间", example = "2026-04-09T18:30:00")
|
||||
private LocalDateTime scheduleTime;
|
||||
}
|
||||
|
||||
@@ -57,4 +57,8 @@ public class ProductRiskResultItemVo {
|
||||
@JsonProperty("downloadUrl")
|
||||
@Schema(description = "预签名下载 URL(列表里可能带;直链下载也可用 GET /results/{resultId}/download)")
|
||||
private String downloadUrl;
|
||||
|
||||
@JsonProperty("scheduledAt")
|
||||
@Schema(description = "定时执行时间,未设置时为空")
|
||||
private String scheduledAt;
|
||||
}
|
||||
|
||||
@@ -33,4 +33,8 @@ public class ProductRiskTaskItemVo {
|
||||
@JsonProperty("finishedAt")
|
||||
@Schema(description = "结束时间;RUNNING 时通常为空")
|
||||
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) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
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 updatedAt;
|
||||
private LocalDateTime finishedAt;
|
||||
private LocalDateTime scheduledAt;
|
||||
}
|
||||
|
||||
@@ -280,13 +280,14 @@ public class ZiniaoShopIndexService {
|
||||
roundEntries.add(entryToStore);
|
||||
}
|
||||
Map<String, ZiniaoShopIndexEntryDto> byShopId = new LinkedHashMap<>();
|
||||
Map<String, ZiniaoShopIndexEntryDto> byNameOnly = new LinkedHashMap<>();
|
||||
Map<String, ZiniaoShopIndexEntryDto> byNameAlias = new LinkedHashMap<>();
|
||||
for (ZiniaoShopIndexEntryDto e : roundEntries) {
|
||||
String sid = e.getShopId();
|
||||
if (sid != null && !sid.isBlank()) {
|
||||
byShopId.merge(sid, e, this::mergeDuplicateShopIndexEntries);
|
||||
} else if (e.getNormalizedShopName() != null && !e.getNormalizedShopName().isBlank()) {
|
||||
byNameOnly.merge(e.getNormalizedShopName(), e, this::mergeDuplicateShopIndexEntries);
|
||||
}
|
||||
if (e.getNormalizedShopName() != null && !e.getNormalizedShopName().isBlank()) {
|
||||
byNameAlias.merge(e.getNormalizedShopName(), e, this::mergeDuplicateShopIndexEntries);
|
||||
}
|
||||
}
|
||||
Set<String> activeCacheKeys = new HashSet<>();
|
||||
@@ -295,14 +296,20 @@ public class ZiniaoShopIndexService {
|
||||
activeCacheKeys.add(key);
|
||||
ziniaoMemoryStoreService.put(ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY, key, e, resolveEntryTtl());
|
||||
}
|
||||
for (ZiniaoShopIndexEntryDto e : byNameOnly.values()) {
|
||||
for (ZiniaoShopIndexEntryDto e : byNameAlias.values()) {
|
||||
String key = SHOP_ENTRY_KEY_NAME_PREFIX + e.getNormalizedShopName();
|
||||
activeCacheKeys.add(key);
|
||||
ziniaoMemoryStoreService.put(ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY, key, e, resolveEntryTtl());
|
||||
}
|
||||
log.info("[ziniao-index] refresh persist shopIndex uniqueShopId={} nameOnly={} groupedNames={} activeRows={}",
|
||||
byShopId.size(), byNameOnly.size(), grouped.size(), activeCacheKeys.size());
|
||||
markMissingEntriesAsStale(activeCacheKeys, now);
|
||||
log.info("[ziniao-index] refresh persist shopIndex uniqueShopId={} nameAlias={} groupedNames={} activeRows={}",
|
||||
byShopId.size(), byNameAlias.size(), grouped.size(), activeCacheKeys.size());
|
||||
boolean fullRefresh = apiKeyAccounts.size() >= allApiKeyAccounts.size();
|
||||
if (fullRefresh) {
|
||||
markMissingEntriesAsStale(activeCacheKeys, now);
|
||||
} else {
|
||||
log.info("[ziniao-index] skip stale marking for partial refresh processedApiKeys={}/{} nextOffset={}",
|
||||
apiKeyAccounts.size(), allApiKeyAccounts.size(), nextOffset);
|
||||
}
|
||||
|
||||
cursor.setStatus("SUCCESS");
|
||||
cursor.setMessage(allInvalidUserIds.isEmpty()
|
||||
|
||||
@@ -12,7 +12,7 @@ spring:
|
||||
max-request-size: 500MB
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${AIIMAGE_DB_URL:jdbc:mysql://127.0.0.1:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false}
|
||||
url: ${AIIMAGE_DB_URL:jdbc:mysql://127.0.0.1:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
|
||||
username: ${AIIMAGE_DB_USERNAME:root}
|
||||
password: ${AIIMAGE_DB_PASSWORD:change-me}
|
||||
hikari:
|
||||
@@ -60,6 +60,7 @@ knife4j:
|
||||
language: zh_cn
|
||||
|
||||
aiimage:
|
||||
instance-id: ${AIIMAGE_INSTANCE_ID:}
|
||||
oss:
|
||||
region: ${AIIMAGE_OSS_REGION:cn-hangzhou}
|
||||
endpoint: ${AIIMAGE_OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com}
|
||||
@@ -82,10 +83,11 @@ aiimage:
|
||||
stale-check-cron: ${AIIMAGE_DELETE_BRAND_STALE_CHECK_CRON:*/30 * * * * *}
|
||||
finalize-check-cron: ${AIIMAGE_DELETE_BRAND_FINALIZE_CHECK_CRON:30 */2 * * * *}
|
||||
product-risk-stale-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_STALE_TIMEOUT_MINUTES:10}
|
||||
product-risk-initial-timeout-minutes: ${AIIMAGE_PRODUCT_RISK_INITIAL_TIMEOUT_MINUTES:10}
|
||||
module-cleanup:
|
||||
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
|
||||
cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *}
|
||||
module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE}
|
||||
module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE,SHOP_MATCH}
|
||||
security:
|
||||
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
|
||||
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user