异常记录检测修改
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
package com.nanri.aiimage.common.util;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class DownloadHeaderUtil {
|
||||
|
||||
private static final String DEFAULT_FILENAME = "download";
|
||||
|
||||
private DownloadHeaderUtil() {
|
||||
}
|
||||
|
||||
public static void setAttachment(HttpServletResponse response, String filename) {
|
||||
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, contentDisposition(filename));
|
||||
}
|
||||
|
||||
public static String contentDisposition(String filename) {
|
||||
String normalized = normalizeFilename(filename);
|
||||
String encodedFilename = URLEncoder.encode(normalized, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
return "attachment; filename=\"" + asciiFallback(normalized) + "\"; filename*=UTF-8''" + encodedFilename;
|
||||
}
|
||||
|
||||
private static String normalizeFilename(String filename) {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
return DEFAULT_FILENAME;
|
||||
}
|
||||
return filename.replace("\r", "").replace("\n", "").trim();
|
||||
}
|
||||
|
||||
private static String asciiFallback(String filename) {
|
||||
String normalized = normalizeFilename(filename);
|
||||
String extension = extensionOf(normalized);
|
||||
String base = extension.isEmpty() ? normalized : normalized.substring(0, normalized.length() - extension.length() - 1);
|
||||
String sanitizedBase = sanitizeAscii(base);
|
||||
String sanitizedExtension = sanitizeExtension(extension);
|
||||
if (sanitizedBase.isBlank()) {
|
||||
sanitizedBase = DEFAULT_FILENAME;
|
||||
}
|
||||
String fallback = sanitizedExtension.isBlank() ? sanitizedBase : sanitizedBase + "." + sanitizedExtension;
|
||||
return fallback.length() <= 120 ? fallback : shorten(fallback, sanitizedExtension);
|
||||
}
|
||||
|
||||
private static String extensionOf(String filename) {
|
||||
int dotIndex = filename.lastIndexOf('.');
|
||||
if (dotIndex < 0 || dotIndex == filename.length() - 1) {
|
||||
return "";
|
||||
}
|
||||
return filename.substring(dotIndex + 1);
|
||||
}
|
||||
|
||||
private static String sanitizeAscii(String value) {
|
||||
return value.replaceAll("[^A-Za-z0-9._-]", "_")
|
||||
.replaceAll("_+", "_")
|
||||
.replaceAll("^[_\\.]+|[_\\.]+$", "");
|
||||
}
|
||||
|
||||
private static String sanitizeExtension(String extension) {
|
||||
return extension.replaceAll("[^A-Za-z0-9]", "");
|
||||
}
|
||||
|
||||
private static String shorten(String fallback, String extension) {
|
||||
int maxBaseLength = extension.isBlank() ? 120 : Math.max(1, 119 - extension.length());
|
||||
String base = extension.isBlank() ? fallback : fallback.substring(0, fallback.length() - extension.length() - 1);
|
||||
base = base.substring(0, Math.min(base.length(), maxBaseLength)).replaceAll("[_\\.]+$", "");
|
||||
if (base.isBlank()) {
|
||||
base = DEFAULT_FILENAME;
|
||||
}
|
||||
return extension.isBlank() ? base : base + "." + extension;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, AppearancePatentProperties.class})
|
||||
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class})
|
||||
public class PropertiesConfig {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.nanri.aiimage.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "aiimage.similar-asin")
|
||||
public class SimilarAsinProperties {
|
||||
private String cozeBaseUrl = "https://api.coze.cn";
|
||||
private String cozeWorkflowPath = "/v1/workflow/run";
|
||||
private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}";
|
||||
private String cozeWorkflowId = "7632683471312355338";
|
||||
private String cozeToken = "";
|
||||
private int cozeBatchSize = 10;
|
||||
private int cozeConnectTimeoutMillis = 10000;
|
||||
private int cozeReadTimeoutMillis = 60000;
|
||||
private int cozePollIntervalMillis = 2000;
|
||||
private int cozePollTimeoutMillis = 120000;
|
||||
private int staleTimeoutMinutes = 20;
|
||||
private String staleFinalizeCron = "0 */2 * * * *";
|
||||
}
|
||||
@@ -11,4 +11,5 @@ public class StorageProperties {
|
||||
private String cleanupCron = "0 0 * * * *";
|
||||
private long sourceRetentionHours = 24;
|
||||
private long resultRetentionHours = 24;
|
||||
private long transientPayloadRetentionHours = 72;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.scheduling.concurrent.ConcurrentTaskExecutor;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
@Configuration
|
||||
public class TaskFileJobConfig {
|
||||
|
||||
@@ -24,4 +28,16 @@ public class TaskFileJobConfig {
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "shutdown")
|
||||
public ExecutorService cozeVirtualThreadExecutor() {
|
||||
return Executors.newThreadPerTaskExecutor(Thread.ofVirtual()
|
||||
.name("coze-task-", 0)
|
||||
.factory());
|
||||
}
|
||||
|
||||
@Bean("cozeTaskExecutor")
|
||||
public TaskExecutor cozeTaskExecutor(ExecutorService cozeVirtualThreadExecutor) {
|
||||
return new ConcurrentTaskExecutor(cozeVirtualThreadExecutor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,42 @@ public class AppearancePatentCozeClient {
|
||||
}
|
||||
}
|
||||
|
||||
public CozeSubmitResponse submitWorkflow(List<AppearancePatentResultRowDto> rows, String prompt) throws Exception {
|
||||
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt));
|
||||
ensureSuccess(submitRoot);
|
||||
return new CozeSubmitResponse(
|
||||
extractExecuteId(submitRoot),
|
||||
extractResultDataText(submitRoot),
|
||||
writeJson(submitRoot)
|
||||
);
|
||||
}
|
||||
|
||||
public CozePollResponse pollWorkflow(String executeId) throws Exception {
|
||||
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId));
|
||||
ensureSuccess(pollRoot);
|
||||
String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT);
|
||||
String dataText = extractResultDataText(pollRoot);
|
||||
String outputText = dataText.isBlank() ? extractWorkflowOutputText(pollRoot) : "";
|
||||
String failureMessage = isFailedWorkflowStatus(status)
|
||||
? firstNonBlank(resolveFailureMessage(pollRoot), "Coze async workflow failed")
|
||||
: "";
|
||||
return new CozePollResponse(executeId, status, dataText, outputText, failureMessage, writeJson(pollRoot));
|
||||
}
|
||||
|
||||
public List<AppearancePatentResultRowDto> mergeRowsFromDataText(List<AppearancePatentResultRowDto> rows, String dataText) throws Exception {
|
||||
if (dataText == null || dataText.isBlank()) {
|
||||
return rows == null ? List.of() : rows.stream().map(this::copy).toList();
|
||||
}
|
||||
return mergeRows(rows, parseResults(wrapDataPayload(dataText)));
|
||||
}
|
||||
|
||||
public List<AppearancePatentResultRowDto> markRowsFailed(List<AppearancePatentResultRowDto> rows, String failureMessage) {
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return rows.stream().map(this::copy).map(row -> markFailed(row, failureMessage)).toList();
|
||||
}
|
||||
|
||||
private List<AppearancePatentResultRowDto> inspectWithFallback(List<AppearancePatentResultRowDto> rows, String prompt) {
|
||||
try {
|
||||
if (rows.size() == 1) {
|
||||
@@ -140,6 +176,7 @@ public class AppearancePatentCozeClient {
|
||||
|
||||
long deadline = System.currentTimeMillis() + Math.max(10000, properties.getCozePollTimeoutMillis());
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
ensureNotInterrupted();
|
||||
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId));
|
||||
ensureSuccess(pollRoot);
|
||||
|
||||
@@ -430,9 +467,21 @@ public class AppearancePatentCozeClient {
|
||||
}
|
||||
|
||||
private AppearancePatentResultRowDto markFailed(AppearancePatentResultRowDto row, String failureMessage) {
|
||||
String reviewMessage = failureMessage == null || failureMessage.isBlank()
|
||||
? "Coze 检测失败,待人工复核"
|
||||
: "Coze 检测失败,待人工复核:" + failureMessage;
|
||||
if (row.getError() == null || row.getError().isBlank()) {
|
||||
row.setError(failureMessage);
|
||||
}
|
||||
if (row.getTitleRisk() == null || row.getTitleRisk().isBlank()) {
|
||||
row.setTitleRisk(reviewMessage);
|
||||
}
|
||||
if (row.getAppearanceRisk() == null || row.getAppearanceRisk().isBlank()) {
|
||||
row.setAppearanceRisk(reviewMessage);
|
||||
}
|
||||
if (row.getPatentRisk() == null || row.getPatentRisk().isBlank()) {
|
||||
row.setPatentRisk(reviewMessage);
|
||||
}
|
||||
if (row.getConclusion() == null || row.getConclusion().isBlank()) {
|
||||
row.setConclusion(failureMessage);
|
||||
}
|
||||
@@ -515,15 +564,22 @@ public class AppearancePatentCozeClient {
|
||||
return "";
|
||||
}
|
||||
if (!outputNode.isTextual()) {
|
||||
return outputNode.toString();
|
||||
String discovered = discoverEmbeddedData(outputNode);
|
||||
return discovered.isBlank() && isResultDataPayload(outputNode) ? outputNode.toString() : discovered;
|
||||
}
|
||||
String output = normalize(outputNode.asText(""));
|
||||
if (output.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
if (looksLikeResultDataPayload(output)) {
|
||||
return output;
|
||||
}
|
||||
JsonNode parsedOutput = parseJsonOrMissing(output);
|
||||
String nestedOutput = text(firstNonNull(parsedOutput.get("Output"), parsedOutput.get("output")));
|
||||
return nestedOutput == null || nestedOutput.isBlank() ? output : nestedOutput;
|
||||
if (nestedOutput != null && !nestedOutput.isBlank() && looksLikeResultDataPayload(nestedOutput)) {
|
||||
return nestedOutput;
|
||||
}
|
||||
return discoverEmbeddedData(parsedOutput);
|
||||
}
|
||||
|
||||
private String discoverEmbeddedData(JsonNode node) {
|
||||
@@ -749,6 +805,7 @@ public class AppearancePatentCozeClient {
|
||||
|
||||
private void sleepBeforeRetry(int attemptIndex) {
|
||||
long delayMillis = Math.max(1, attemptIndex) * 1500L;
|
||||
ensureNotInterrupted();
|
||||
sleepQuietly(delayMillis);
|
||||
}
|
||||
|
||||
@@ -757,6 +814,13 @@ public class AppearancePatentCozeClient {
|
||||
Thread.sleep(delayMillis);
|
||||
} catch (InterruptedException interruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Coze workflow interrupted", interruptedException);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureNotInterrupted() {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
throw new IllegalStateException("Coze workflow interrupted");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,6 +919,45 @@ public class AppearancePatentCozeClient {
|
||||
) {
|
||||
}
|
||||
|
||||
public record CozeSubmitResponse(
|
||||
String executeId,
|
||||
String immediateData,
|
||||
String rawResponse
|
||||
) {
|
||||
}
|
||||
|
||||
public record CozePollResponse(
|
||||
String executeId,
|
||||
String status,
|
||||
String dataText,
|
||||
String outputText,
|
||||
String failureMessage,
|
||||
String rawResponse
|
||||
) {
|
||||
public boolean hasPayload() {
|
||||
return dataText != null && !dataText.isBlank() || outputText != null && !outputText.isBlank();
|
||||
}
|
||||
|
||||
public String resolvedPayloadText() {
|
||||
return dataText != null && !dataText.isBlank() ? dataText : outputText;
|
||||
}
|
||||
|
||||
public boolean isFailed() {
|
||||
String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT);
|
||||
return normalized.contains("FAILED") || normalized.contains("ERROR") || normalized.contains("CANCEL");
|
||||
}
|
||||
|
||||
public boolean isFinished() {
|
||||
String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT);
|
||||
return isFailed()
|
||||
|| normalized.contains("SUCCESS")
|
||||
|| normalized.contains("SUCCEED")
|
||||
|| normalized.contains("FINISH")
|
||||
|| normalized.contains("DONE")
|
||||
|| normalized.contains("COMPLET");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PartialCozeResultException extends RuntimeException {
|
||||
|
||||
private final int resolvedCount;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.appearancepatent.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
||||
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParseRequest;
|
||||
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentSubmitResultRequest;
|
||||
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentTaskBatchRequest;
|
||||
@@ -14,7 +15,6 @@ import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -28,7 +28,6 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@@ -129,10 +128,8 @@ public class AppearancePatentController {
|
||||
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);
|
||||
DownloadHeaderUtil.setAttachment(response, filename);
|
||||
try (InputStream in = URI.create(url).toURL().openStream()) {
|
||||
byte[] buffer = new byte[65536];
|
||||
int read;
|
||||
|
||||
@@ -29,9 +29,17 @@ public class AppearancePatentResultRowDto {
|
||||
private String country;
|
||||
|
||||
@Schema(description = "商品主图或待检测图片 URL。Java 调用 Coze 时会放入 url_list。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
|
||||
@JsonAlias({
|
||||
"imageUrl", "image_url", "imgUrl", "img_url", "pictureUrl", "picture_url",
|
||||
"mainImage", "main_image", "mainImageUrl", "main_image_url",
|
||||
"productImage", "product_image", "productImageUrl", "product_image_url",
|
||||
"image", "img", "pic", "picture", "link", "imageLink", "image_link",
|
||||
"图片链接", "商品图片", "商品主图", "主图", "主图链接"
|
||||
})
|
||||
private String url;
|
||||
|
||||
@Schema(description = "商品标题。Java 调用 Coze 时会放入 title_list;为空时会回退使用 ASIN。", example = "Women Floral Dress Summer Casual")
|
||||
@JsonAlias({"productTitle", "product_title", "itemTitle", "item_title", "商品标题", "商品名称", "标题"})
|
||||
private String title;
|
||||
|
||||
@Schema(description = "单行错误信息。通常用于记录 Python 单行处理异常;Coze 失败时后端会尽量保留原始行,不强行写入风险结果。", example = "图片地址为空")
|
||||
|
||||
@@ -3,9 +3,11 @@ package com.nanri.aiimage.modules.appearancepatent.service;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.service.DistributedJobLockService;
|
||||
import com.nanri.aiimage.config.AppearancePatentProperties;
|
||||
import com.nanri.aiimage.config.StorageProperties;
|
||||
import com.nanri.aiimage.modules.appearancepatent.client.AppearancePatentCozeClient;
|
||||
@@ -50,7 +52,10 @@ import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
@@ -73,6 +78,7 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@Service
|
||||
@@ -85,6 +91,10 @@ public class AppearancePatentTaskService {
|
||||
private static final String STATUS_RUNNING = "RUNNING";
|
||||
private static final String STATUS_SUCCESS = "SUCCESS";
|
||||
private static final String STATUS_FAILED = "FAILED";
|
||||
private static final String COZE_STATUS_SUBMITTED = "SUBMITTED";
|
||||
private static final String COZE_STATUS_RUNNING = "RUNNING";
|
||||
private static final String COZE_STATUS_DONE = "DONE";
|
||||
private static final String COZE_STATUS_FAILED = "FAILED";
|
||||
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
private static final int RESULT_ROWS_READ_RETRY_LIMIT = 3;
|
||||
private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L;
|
||||
@@ -114,6 +124,10 @@ public class AppearancePatentTaskService {
|
||||
private final TaskProgressSnapshotService taskProgressSnapshotService;
|
||||
private final TransientPayloadStorageService transientPayloadStorageService;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
private final DistributedJobLockService distributedJobLockService;
|
||||
@Autowired
|
||||
@Qualifier("cozeTaskExecutor")
|
||||
private TaskExecutor cozeTaskExecutor;
|
||||
|
||||
public AppearancePatentParseVo parseAndCreateTask(AppearancePatentParseRequest request) {
|
||||
long startedAt = System.nanoTime();
|
||||
@@ -514,6 +528,10 @@ public class AppearancePatentTaskService {
|
||||
.lt(FileTaskEntity::getUpdatedAt, threshold)
|
||||
.last("limit 50"));
|
||||
for (FileTaskEntity task : tasks) {
|
||||
if (isJavaSideProcessing(task.getId())) {
|
||||
touchJavaSideTaskActivity(task.getId());
|
||||
continue;
|
||||
}
|
||||
long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId());
|
||||
if (heartbeatMillis > thresholdMillis) {
|
||||
continue;
|
||||
@@ -533,6 +551,10 @@ public class AppearancePatentTaskService {
|
||||
.lt(FileTaskEntity::getUpdatedAt, threshold)
|
||||
.last("limit 50"));
|
||||
for (FileTaskEntity task : tasks) {
|
||||
if (isJavaSideProcessing(task.getId())) {
|
||||
touchJavaSideTaskActivity(task.getId());
|
||||
continue;
|
||||
}
|
||||
long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId());
|
||||
if (heartbeatMillis > thresholdMillis) {
|
||||
continue;
|
||||
@@ -1004,7 +1026,7 @@ public class AppearancePatentTaskService {
|
||||
assembleResultWorkbook(task, result);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[appearance-patent] assemble result workbook failed taskId={} err={}", task.getId(), ex.getMessage());
|
||||
finalError = firstNonBlank(finalError, "生成外观专利检测结果失败");
|
||||
finalError = firstNonBlank(finalError, "Generate appearance patent result failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1038,39 +1060,478 @@ public class AppearancePatentTaskService {
|
||||
return false;
|
||||
}
|
||||
|
||||
public void processResultFileJob(TaskFileJobEntity job) {
|
||||
public boolean processResultFileJob(TaskFileJobEntity job) {
|
||||
if (job == null || job.getTaskId() == null || job.getResultId() == null) {
|
||||
throw new BusinessException("结果文件任务参数不完整");
|
||||
throw new BusinessException("result file job arguments are incomplete");
|
||||
}
|
||||
FileTaskEntity task = fileTaskMapper.selectById(job.getTaskId());
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
throw new BusinessException("任务不存在");
|
||||
throw new BusinessException("task not found");
|
||||
}
|
||||
FileResultEntity result = fileResultMapper.selectById(job.getResultId());
|
||||
if (result == null || !MODULE_TYPE.equals(result.getModuleType())) {
|
||||
throw new BusinessException("结果记录不存在");
|
||||
throw new BusinessException("result record not found");
|
||||
}
|
||||
List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
|
||||
.eq(TaskChunkEntity::getTaskId, task.getId())
|
||||
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(TaskChunkEntity::getChunkIndex));
|
||||
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||
int cozeWorkUnits = countCozeWorkUnits(chunks, Math.max(1, properties.getCozeBatchSize()));
|
||||
int totalProgressUnits = Math.max(3, cozeWorkUnits + 3);
|
||||
int[] completedProgressUnits = {0};
|
||||
saveFileBuildProgress(task, job, totalProgressUnits, completedProgressUnits[0], "正在请求 Coze");
|
||||
Runnable progressHook = () -> {
|
||||
saveFileBuildProgress(task, job, totalProgressUnits, 0, "Submitting Coze");
|
||||
boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId);
|
||||
if (pendingCoze) {
|
||||
taskFileJobService.touchRunning(job.getId());
|
||||
completedProgressUnits[0] = Math.min(totalProgressUnits - 2, completedProgressUnits[0] + 1);
|
||||
saveFileBuildProgress(task, job, totalProgressUnits, completedProgressUnits[0], "正在请求 Coze");
|
||||
};
|
||||
applyCozeToPersistedChunks(task, progressHook);
|
||||
completedProgressUnits[0] = Math.max(completedProgressUnits[0], totalProgressUnits - 2);
|
||||
saveFileBuildProgress(task, job, totalProgressUnits, completedProgressUnits[0], "正在组装 xlsx");
|
||||
touchJavaSideTaskActivity(task.getId());
|
||||
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result");
|
||||
return false;
|
||||
}
|
||||
completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${aiimage.appearance-patent.coze-poll-delay-ms:5000}")
|
||||
public void pollPendingCozeJobs() {
|
||||
DistributedJobLockService.LockHandle lockHandle =
|
||||
distributedJobLockService.tryLock("appearance-patent:coze-poll", Duration.ofMinutes(1));
|
||||
if (lockHandle == null) {
|
||||
return;
|
||||
}
|
||||
try (lockHandle) {
|
||||
List<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
||||
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
|
||||
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
|
||||
.isNotNull(TaskScopeStateEntity::getCozeExecuteId)
|
||||
.orderByAsc(TaskScopeStateEntity::getUpdatedAt)
|
||||
.last("limit 50"));
|
||||
if (states == null || states.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
log.info("[appearance-patent] coze poll picked pending states count={}", states.size());
|
||||
for (TaskScopeStateEntity state : states) {
|
||||
if (state == null || state.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
cozeTaskExecutor.execute(() -> pollPendingCozeState(state.getId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean submitCozeBatches(FileTaskEntity task,
|
||||
FileResultEntity result,
|
||||
TaskFileJobEntity job,
|
||||
List<TaskChunkEntity> chunks,
|
||||
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId) {
|
||||
if (chunks == null || chunks.isEmpty()) {
|
||||
return countPendingCozeStates(task.getId()) > 0;
|
||||
}
|
||||
if (properties.getCozeToken() == null || properties.getCozeToken().isBlank()) {
|
||||
log.warn("[appearance-patent] coze token not configured, skip async coze taskId={} jobId={}",
|
||||
task.getId(), job.getId());
|
||||
return false;
|
||||
}
|
||||
String prompt = readAiPrompt(task);
|
||||
int batchSize = Math.max(1, properties.getCozeBatchSize());
|
||||
boolean pending = false;
|
||||
for (TaskChunkEntity chunk : chunks) {
|
||||
Map<String, AppearancePatentResultRowDto> persistedRows = readChunkRows(chunk);
|
||||
if (persistedRows.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
List<AppearancePatentResultRowDto> unresolvedRows = pickGroupRepresentativesForCoze(persistedRows.values());
|
||||
if (unresolvedRows.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
int batchTotal = Math.max(1, (unresolvedRows.size() + batchSize - 1) / batchSize);
|
||||
int batchIndex = 1;
|
||||
for (int i = 0; i < unresolvedRows.size(); i += batchSize) {
|
||||
List<AppearancePatentResultRowDto> batchRows =
|
||||
unresolvedRows.subList(i, Math.min(i + batchSize, unresolvedRows.size()));
|
||||
pending |= submitCozeBatch(task, result, job, chunk, batchRows, batchIndex, batchTotal, prompt, allRowsByBaseId);
|
||||
batchIndex++;
|
||||
}
|
||||
}
|
||||
return pending || countPendingCozeStates(task.getId()) > 0;
|
||||
}
|
||||
|
||||
private boolean submitCozeBatch(FileTaskEntity task,
|
||||
FileResultEntity result,
|
||||
TaskFileJobEntity job,
|
||||
TaskChunkEntity chunk,
|
||||
List<AppearancePatentResultRowDto> batchRows,
|
||||
int batchIndex,
|
||||
int batchTotal,
|
||||
String prompt,
|
||||
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId) {
|
||||
if (batchRows == null || batchRows.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String batchScopeKey = buildCozeBatchScopeKey(job.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), batchIndex);
|
||||
String batchScopeHash = DigestUtil.sha256Hex(batchScopeKey);
|
||||
TaskScopeStateEntity existing = taskScopeStateMapper.selectOne(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
||||
.eq(TaskScopeStateEntity::getTaskId, task.getId())
|
||||
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(TaskScopeStateEntity::getScopeHash, batchScopeHash)
|
||||
.last("limit 1"));
|
||||
if (existing != null) {
|
||||
return COZE_STATUS_SUBMITTED.equals(existing.getCozeStatus())
|
||||
|| COZE_STATUS_RUNNING.equals(existing.getCozeStatus());
|
||||
}
|
||||
try {
|
||||
AppearancePatentCozeClient.CozeSubmitResponse submit = cozeClient.submitWorkflow(batchRows, prompt);
|
||||
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
||||
List<AppearancePatentResultRowDto> cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData());
|
||||
mergeCozeRowsIntoChunk(task, chunk.getScopeHash(), chunk.getChunkIndex(), cozeRows, allRowsByBaseId);
|
||||
return false;
|
||||
}
|
||||
if (submit.executeId() == null || submit.executeId().isBlank()) {
|
||||
mergeCozeRowsIntoChunk(task,
|
||||
chunk.getScopeHash(),
|
||||
chunk.getChunkIndex(),
|
||||
cozeClient.markRowsFailed(batchRows, "Coze async execute_id missing"),
|
||||
allRowsByBaseId);
|
||||
return false;
|
||||
}
|
||||
saveCozeBatchState(task, result, job, chunk, batchRows, batchScopeKey, batchScopeHash,
|
||||
batchIndex, batchTotal, submit.executeId());
|
||||
log.info("[appearance-patent] coze async submitted taskId={} jobId={} chunk={} batch={}/{} executeId={}",
|
||||
task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal, submit.executeId());
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
String message = firstNonBlank(ex.getMessage(), "Coze submit failed");
|
||||
log.warn("[appearance-patent] coze async submit failed taskId={} jobId={} chunk={} batch={}/{} err={}",
|
||||
task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal, message);
|
||||
mergeCozeRowsIntoChunk(task,
|
||||
chunk.getScopeHash(),
|
||||
chunk.getChunkIndex(),
|
||||
cozeClient.markRowsFailed(batchRows, message),
|
||||
allRowsByBaseId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void saveCozeBatchState(FileTaskEntity task,
|
||||
FileResultEntity result,
|
||||
TaskFileJobEntity job,
|
||||
TaskChunkEntity chunk,
|
||||
List<AppearancePatentResultRowDto> batchRows,
|
||||
String batchScopeKey,
|
||||
String batchScopeHash,
|
||||
int batchIndex,
|
||||
int batchTotal,
|
||||
String executeId) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
CozeBatchContext context = new CozeBatchContext(
|
||||
job.getId(),
|
||||
result.getId(),
|
||||
chunk.getScopeHash(),
|
||||
chunk.getChunkIndex(),
|
||||
batchIndex,
|
||||
batchTotal
|
||||
);
|
||||
String batchPayload = writeJson(batchRows, "serialize coze batch payload failed");
|
||||
String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast(
|
||||
MODULE_TYPE, task.getId(), batchScopeHash, batchPayload, true);
|
||||
TaskScopeStateEntity state = new TaskScopeStateEntity();
|
||||
state.setTaskId(task.getId());
|
||||
state.setModuleType(MODULE_TYPE);
|
||||
state.setScopeKey(batchScopeKey);
|
||||
state.setScopeHash(batchScopeHash);
|
||||
state.setParsedPayloadJson(storedBatchPayload);
|
||||
state.setStateJson(writeJson(context, "serialize coze batch context failed"));
|
||||
state.setCozeExecuteId(executeId);
|
||||
state.setCozeStatus(COZE_STATUS_SUBMITTED);
|
||||
state.setCozeSubmittedAt(now);
|
||||
state.setCozeAttemptCount(0);
|
||||
state.setChunkTotal(batchTotal);
|
||||
state.setReceivedChunkCount(batchIndex);
|
||||
state.setCompleted(0);
|
||||
state.setCreatedAt(now);
|
||||
state.setUpdatedAt(now);
|
||||
try {
|
||||
taskScopeStateMapper.insert(state);
|
||||
touchJavaSideTaskActivity(task.getId());
|
||||
} catch (DuplicateKeyException ex) {
|
||||
transientPayloadStorageService.deletePayloadIfPresent(storedBatchPayload);
|
||||
log.info("[appearance-patent] duplicate coze batch state ignored taskId={} scope={}",
|
||||
task.getId(), batchScopeKey);
|
||||
}
|
||||
}
|
||||
|
||||
private void pollPendingCozeState(Long stateId) {
|
||||
TaskScopeStateEntity state = taskScopeStateMapper.selectById(stateId);
|
||||
if (state == null || state.getCozeExecuteId() == null || state.getCozeExecuteId().isBlank()) {
|
||||
return;
|
||||
}
|
||||
if (!(COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) {
|
||||
return;
|
||||
}
|
||||
if (!tryClaimCozeStateForPoll(state)) {
|
||||
return;
|
||||
}
|
||||
CozeBatchContext context = readCozeBatchContext(state);
|
||||
if (context == null || context.jobId() == null || context.resultId() == null) {
|
||||
markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze batch context missing");
|
||||
return;
|
||||
}
|
||||
taskFileJobService.touchRunning(context.jobId());
|
||||
try {
|
||||
AppearancePatentCozeClient.CozePollResponse poll = cozeClient.pollWorkflow(state.getCozeExecuteId());
|
||||
if (!poll.hasPayload() && !poll.isFinished() && !isCozeStateTimedOut(state)) {
|
||||
updateCozeStateRunning(state, null);
|
||||
return;
|
||||
}
|
||||
String failureMessage = poll.isFailed()
|
||||
? firstNonBlank(poll.failureMessage(), "Coze async workflow failed")
|
||||
: "";
|
||||
if (!poll.hasPayload() && failureMessage.isBlank()) {
|
||||
failureMessage = isCozeStateTimedOut(state)
|
||||
? "Coze async workflow poll timeout"
|
||||
: "Coze async workflow completed without output";
|
||||
}
|
||||
List<AppearancePatentResultRowDto> batchRows = readCozeBatchRows(state);
|
||||
List<AppearancePatentResultRowDto> cozeRows = failureMessage.isBlank()
|
||||
? cozeClient.mergeRowsFromDataText(batchRows, poll.resolvedPayloadText())
|
||||
: cozeClient.markRowsFailed(batchRows, failureMessage);
|
||||
FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId());
|
||||
if (task != null) {
|
||||
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||
mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
|
||||
}
|
||||
markCozeStateTerminal(state,
|
||||
failureMessage.isBlank() ? COZE_STATUS_DONE : COZE_STATUS_FAILED,
|
||||
failureMessage.isBlank() ? null : failureMessage);
|
||||
maybeFinalizeCozeJob(state.getTaskId(), context);
|
||||
} catch (Exception ex) {
|
||||
String message = firstNonBlank(ex.getMessage(), "Coze poll failed");
|
||||
if (isCozeStateTimedOut(state)) {
|
||||
List<AppearancePatentResultRowDto> batchRows = readCozeBatchRows(state);
|
||||
FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId());
|
||||
if (task != null) {
|
||||
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||
mergeCozeRowsIntoChunk(task,
|
||||
context.chunkScopeHash(),
|
||||
context.chunkIndex(),
|
||||
cozeClient.markRowsFailed(batchRows, message),
|
||||
allRowsByBaseId);
|
||||
}
|
||||
markCozeStateTerminal(state, COZE_STATUS_FAILED, message);
|
||||
maybeFinalizeCozeJob(state.getTaskId(), context);
|
||||
return;
|
||||
}
|
||||
log.warn("[appearance-patent] coze poll failed taskId={} stateId={} executeId={} err={}",
|
||||
state.getTaskId(), state.getId(), state.getCozeExecuteId(), message);
|
||||
updateCozeStateRunning(state, message);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateCozeStateRunning(TaskScopeStateEntity state, String error) {
|
||||
int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
|
||||
.eq(TaskScopeStateEntity::getId, state.getId())
|
||||
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
|
||||
.set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_RUNNING)
|
||||
.set(TaskScopeStateEntity::getCozeLastPolledAt, LocalDateTime.now())
|
||||
.set(TaskScopeStateEntity::getCozeAttemptCount, cozeAttemptCount(state) + 1)
|
||||
.set(TaskScopeStateEntity::getCozeError, error)
|
||||
.set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now()));
|
||||
if (updated > 0) {
|
||||
touchJavaSideTaskActivity(state.getTaskId());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean tryClaimCozeStateForPoll(TaskScopeStateEntity state) {
|
||||
if (state == null || state.getId() == null) {
|
||||
return false;
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
long intervalMillis = Math.max(200L, properties.getCozePollIntervalMillis());
|
||||
if (state.getCozeLastPolledAt() != null
|
||||
&& Duration.between(state.getCozeLastPolledAt(), now).toMillis() < intervalMillis) {
|
||||
return false;
|
||||
}
|
||||
return taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
|
||||
.eq(TaskScopeStateEntity::getId, state.getId())
|
||||
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
|
||||
.and(wrapper -> wrapper
|
||||
.isNull(TaskScopeStateEntity::getCozeLastPolledAt)
|
||||
.or()
|
||||
.le(TaskScopeStateEntity::getCozeLastPolledAt, now.minus(Duration.ofMillis(intervalMillis))))
|
||||
.set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_RUNNING)
|
||||
.set(TaskScopeStateEntity::getCozeLastPolledAt, now)
|
||||
.set(TaskScopeStateEntity::getUpdatedAt, now)) > 0;
|
||||
}
|
||||
|
||||
private void markCozeStateTerminal(TaskScopeStateEntity state, String status, String error) {
|
||||
taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
|
||||
.eq(TaskScopeStateEntity::getId, state.getId())
|
||||
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
|
||||
.set(TaskScopeStateEntity::getCozeStatus, status)
|
||||
.set(TaskScopeStateEntity::getCozeCompletedAt, LocalDateTime.now())
|
||||
.set(TaskScopeStateEntity::getCozeLastPolledAt, LocalDateTime.now())
|
||||
.set(TaskScopeStateEntity::getCozeAttemptCount, cozeAttemptCount(state) + 1)
|
||||
.set(TaskScopeStateEntity::getCozeError, error)
|
||||
.set(TaskScopeStateEntity::getCompleted, 1)
|
||||
.set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
private void maybeFinalizeCozeJob(Long taskId, CozeBatchContext context) {
|
||||
if (taskId == null || context == null || countPendingCozeStates(taskId) > 0) {
|
||||
return;
|
||||
}
|
||||
DistributedJobLockService.LockHandle lockHandle =
|
||||
distributedJobLockService.tryLock("appearance-patent:coze-finalize:" + taskId, Duration.ofMinutes(5));
|
||||
if (lockHandle == null) {
|
||||
return;
|
||||
}
|
||||
try (lockHandle) {
|
||||
if (countPendingCozeStates(taskId) > 0) {
|
||||
return;
|
||||
}
|
||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||
FileResultEntity result = fileResultMapper.selectById(context.resultId());
|
||||
TaskFileJobEntity job = taskFileJobService.findAssembleJob(taskId, MODULE_TYPE, context.resultId());
|
||||
if (task == null || result == null || job == null || "SUCCESS".equals(job.getStatus())) {
|
||||
return;
|
||||
}
|
||||
int cozeWorkUnits = countCompletedCozeStates(taskId);
|
||||
int totalProgressUnits = Math.max(3, cozeWorkUnits + 3);
|
||||
completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits);
|
||||
taskFileJobService.markSuccess(job, result.getResultFileUrl());
|
||||
cleanupResultFileJob(job);
|
||||
log.info("[appearance-patent] coze async job finalized taskId={} jobId={} resultId={} resultFileUrl={}",
|
||||
taskId, job.getId(), result.getId(), result.getResultFileUrl());
|
||||
} catch (Exception ex) {
|
||||
TaskFileJobEntity job = taskFileJobService.findAssembleJob(taskId, MODULE_TYPE, context.resultId());
|
||||
if (job != null) {
|
||||
taskFileJobService.markFailed(job, firstNonBlank(ex.getMessage(), "appearance patent result file build failed"));
|
||||
}
|
||||
log.warn("[appearance-patent] coze async finalize failed taskId={} resultId={} err={}",
|
||||
taskId, context.resultId(), ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void completeCozeFileJob(FileTaskEntity task,
|
||||
FileResultEntity result,
|
||||
TaskFileJobEntity job,
|
||||
int totalProgressUnits,
|
||||
int cozeWorkUnits) {
|
||||
int assembleProgress = Math.max(1, Math.min(totalProgressUnits - 2, cozeWorkUnits));
|
||||
saveFileBuildProgress(task, job, totalProgressUnits, assembleProgress, "Assembling xlsx");
|
||||
assembleResultWorkbook(task, result);
|
||||
completedProgressUnits[0] = totalProgressUnits - 1;
|
||||
saveFileBuildProgress(task, job, totalProgressUnits, completedProgressUnits[0], "正在上传结果文件");
|
||||
saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits - 1, "Uploading result file");
|
||||
fileResultMapper.updateById(result);
|
||||
saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits, "结果文件已生成");
|
||||
saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits, "Result file generated");
|
||||
}
|
||||
|
||||
private void mergeCozeRowsIntoChunk(FileTaskEntity task,
|
||||
String chunkScopeHash,
|
||||
Integer chunkIndex,
|
||||
List<AppearancePatentResultRowDto> cozeRows,
|
||||
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId) {
|
||||
if (task == null || cozeRows == null || cozeRows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, AppearancePatentResultRowDto> mergedRows = new LinkedHashMap<>();
|
||||
for (AppearancePatentResultRowDto resultRow : cozeRows) {
|
||||
for (AppearancePatentResultRowDto expandedRow : expandRows(List.of(resultRow), allRowsByBaseId)) {
|
||||
mergedRows.put(rowKey(expandedRow), expandedRow);
|
||||
}
|
||||
}
|
||||
mergeChunkPayload(task.getId(), chunkScopeHash, chunkIndex, new ArrayList<>(mergedRows.values()));
|
||||
}
|
||||
|
||||
private List<AppearancePatentResultRowDto> readCozeBatchRows(TaskScopeStateEntity state) {
|
||||
if (state == null || state.getParsedPayloadJson() == null || state.getParsedPayloadJson().isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
String payloadJson = transientPayloadStorageService.resolvePayload(
|
||||
state.getParsedPayloadJson(), "read appearance patent coze batch failed");
|
||||
JsonNode array = objectMapper.readTree(payloadJson);
|
||||
if (!array.isArray()) {
|
||||
return List.of();
|
||||
}
|
||||
List<AppearancePatentResultRowDto> rows = new ArrayList<>();
|
||||
for (JsonNode node : array) {
|
||||
rows.add(objectMapper.treeToValue(node, AppearancePatentResultRowDto.class));
|
||||
}
|
||||
return rows;
|
||||
} catch (Exception ex) {
|
||||
log.warn("[appearance-patent] read coze batch failed taskId={} stateId={} err={}",
|
||||
state.getTaskId(), state.getId(), ex.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private CozeBatchContext readCozeBatchContext(TaskScopeStateEntity state) {
|
||||
if (state == null || state.getStateJson() == null || state.getStateJson().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(state.getStateJson(), CozeBatchContext.class);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[appearance-patent] read coze batch context failed taskId={} stateId={} err={}",
|
||||
state.getTaskId(), state.getId(), ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCozeStateTimedOut(TaskScopeStateEntity state) {
|
||||
if (state == null || state.getCozeSubmittedAt() == null) {
|
||||
return false;
|
||||
}
|
||||
long timeoutMillis = Math.max(10000L, properties.getCozePollTimeoutMillis());
|
||||
return Duration.between(state.getCozeSubmittedAt(), LocalDateTime.now()).toMillis() >= timeoutMillis;
|
||||
}
|
||||
|
||||
private int cozeAttemptCount(TaskScopeStateEntity state) {
|
||||
return state == null || state.getCozeAttemptCount() == null ? 0 : state.getCozeAttemptCount();
|
||||
}
|
||||
|
||||
private int countPendingCozeStates(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
||||
.eq(TaskScopeStateEntity::getTaskId, taskId)
|
||||
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
|
||||
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING)));
|
||||
return count == null ? 0 : count.intValue();
|
||||
}
|
||||
|
||||
private int countCompletedCozeStates(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
||||
.eq(TaskScopeStateEntity::getTaskId, taskId)
|
||||
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
|
||||
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_DONE, COZE_STATUS_FAILED)));
|
||||
return count == null ? 0 : count.intValue();
|
||||
}
|
||||
|
||||
private boolean isJavaSideProcessing(Long taskId) {
|
||||
return countPendingCozeStates(taskId) > 0
|
||||
|| taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE) > 0;
|
||||
}
|
||||
|
||||
private void touchJavaSideTaskActivity(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
|
||||
.eq(FileTaskEntity::getId, taskId)
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileTaskEntity::getStatus, STATUS_RUNNING)
|
||||
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
private String buildCozeBatchScopeKey(Long jobId, String chunkScopeHash, Integer chunkIndex, int batchIndex) {
|
||||
return "coze:job:" + jobId
|
||||
+ ":chunk:" + (chunkIndex == null ? 0 : chunkIndex)
|
||||
+ ":" + firstNonBlank(chunkScopeHash, "unknown")
|
||||
+ ":batch:" + batchIndex;
|
||||
}
|
||||
|
||||
private int countCozeWorkUnits(List<TaskChunkEntity> chunks, int batchSize) {
|
||||
@@ -1146,7 +1607,12 @@ public class AppearancePatentTaskService {
|
||||
throw new BusinessException("创建结果目录失败");
|
||||
}
|
||||
String filename = safeFileStem(result.getSourceFilename()) + "-result.xlsx";
|
||||
File xlsx = new File(outputDir, filename);
|
||||
String tempFilename = safeFileStem(result.getSourceFilename())
|
||||
+ "-" + task.getId()
|
||||
+ "-" + result.getId()
|
||||
+ "-" + UUID.randomUUID()
|
||||
+ "-result.xlsx";
|
||||
File xlsx = new File(outputDir, tempFilename);
|
||||
try {
|
||||
writeResultWorkbook(xlsx, parsed, resultMap);
|
||||
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
|
||||
@@ -1691,7 +2157,7 @@ public class AppearancePatentTaskService {
|
||||
}
|
||||
|
||||
private String storeParsedPayload(Long taskId, String scopeHash, String parsedPayloadJson) {
|
||||
return transientPayloadStorageService.storeParsedPayloadFast(MODULE_TYPE, taskId, scopeHash, parsedPayloadJson, true);
|
||||
return transientPayloadStorageService.storeParsedPayloadFast(MODULE_TYPE, taskId, scopeHash, parsedPayloadJson, false);
|
||||
}
|
||||
|
||||
private long elapsedMs(long start, long end) {
|
||||
@@ -1919,6 +2385,14 @@ public class AppearancePatentTaskService {
|
||||
String error) {
|
||||
}
|
||||
|
||||
private record CozeBatchContext(Long jobId,
|
||||
Long resultId,
|
||||
String chunkScopeHash,
|
||||
Integer chunkIndex,
|
||||
Integer batchIndex,
|
||||
Integer batchTotal) {
|
||||
}
|
||||
|
||||
private record ParsedWorkbook(int totalRows, int droppedRows, List<String> headers, List<AppearancePatentParsedRowVo> allRows) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.brand.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
||||
import com.nanri.aiimage.modules.brand.model.dto.BrandCrawlResultRequest;
|
||||
import com.nanri.aiimage.modules.brand.model.dto.BrandTaskCreateRequest;
|
||||
import com.nanri.aiimage.modules.brand.model.vo.BrandCrawlPayloadVo;
|
||||
@@ -17,7 +18,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -30,8 +30,6 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@@ -213,10 +211,8 @@ public class BrandTaskController {
|
||||
if (filename.isBlank()) {
|
||||
filename = "brand_task_" + taskId + ".zip";
|
||||
}
|
||||
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
response.setContentType("application/zip");
|
||||
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename);
|
||||
DownloadHeaderUtil.setAttachment(response, filename);
|
||||
// 后端代理拉取 OSS 文件并流式写出
|
||||
try (InputStream in = URI.create(ossUrl).toURL().openStream()) {
|
||||
byte[] buffer = new byte[65536];
|
||||
|
||||
@@ -14,10 +14,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -28,9 +25,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.File;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.net.URI;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@@ -100,15 +95,14 @@ public class ConvertRunController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "返回结果文件流"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在")
|
||||
})
|
||||
public ResponseEntity<Resource> download(
|
||||
public ResponseEntity<Void> download(
|
||||
@Parameter(description = "格式转换结果记录 ID", required = true) @PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
|
||||
@RequestParam("user_id") Long userId) {
|
||||
File resultFile = convertRunService.getResultFile(resultId, userId);
|
||||
String encodedFilename = URLEncoder.encode(resultFile.getName(), StandardCharsets.UTF_8).replaceAll("\\+", "%20");
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename)
|
||||
.body(new FileSystemResource(resultFile));
|
||||
String downloadUrl = convertRunService.getResultDownloadUrl(resultId, userId);
|
||||
return ResponseEntity.status(302)
|
||||
.location(URI.create(downloadUrl))
|
||||
.header(HttpHeaders.CACHE_CONTROL, "no-store")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -43,6 +44,7 @@ public class ConvertRunService {
|
||||
|
||||
private static final String MODULE_TYPE = "CONVERT";
|
||||
private static final String TEMPLATE_CODE_FIVE_COUNTRIES = "uk_offer";
|
||||
private static final DateTimeFormatter ZIP_NAME_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
private static final List<String> FIVE_COUNTRY_OUTPUT_FILES = List.of(
|
||||
"英国.txt",
|
||||
"法国.txt",
|
||||
@@ -76,10 +78,12 @@ public class ConvertRunService {
|
||||
fileTaskMapper.insert(task);
|
||||
|
||||
boolean folderMode = request.getArchiveName() != null && !request.getArchiveName().isBlank();
|
||||
boolean archiveMode = folderMode || request.getFiles().size() > 1;
|
||||
List<ConvertResultItemVo> items = new ArrayList<>();
|
||||
int successCount = 0;
|
||||
int failedCount = 0;
|
||||
List<ConvertArchiveEntry> archiveEntries = new ArrayList<>();
|
||||
List<String> successSourceNames = new ArrayList<>();
|
||||
|
||||
for (UploadedSourceFileDto sourceFile : request.getFiles()) {
|
||||
try {
|
||||
@@ -92,10 +96,11 @@ public class ConvertRunService {
|
||||
? inputFile.getName()
|
||||
: sourceFile.getOriginalFilename();
|
||||
List<GeneratedConvertFile> generatedFiles = generateOutputFiles(inputFile, template);
|
||||
if (folderMode) {
|
||||
if (archiveMode) {
|
||||
for (GeneratedConvertFile generatedFile : generatedFiles) {
|
||||
archiveEntries.add(new ConvertArchiveEntry(sourceFile.getRelativePath(), inputName, generatedFile));
|
||||
}
|
||||
successSourceNames.add(inputName);
|
||||
successCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -153,12 +158,13 @@ public class ConvertRunService {
|
||||
}
|
||||
}
|
||||
|
||||
if (folderMode && !archiveEntries.isEmpty()) {
|
||||
File zipFile = packageFolderConvertResultsAsZip(request.getArchiveName(), archiveEntries);
|
||||
if (archiveMode && !archiveEntries.isEmpty()) {
|
||||
String archiveName = resolveArchiveName(request.getArchiveName(), successSourceNames);
|
||||
File zipFile = packageFolderConvertResultsAsZip(archiveName, archiveEntries);
|
||||
String ossObjectKey = ossStorageService.uploadResultFile(zipFile, MODULE_TYPE);
|
||||
// 只存 objectKey
|
||||
ConvertResultItemVo item = new ConvertResultItemVo();
|
||||
item.setSourceFilename(request.getArchiveName());
|
||||
item.setSourceFilename(folderMode ? request.getArchiveName() : "Current convert task");
|
||||
item.setOutputFilename(zipFile.getName());
|
||||
item.setSuccess(true);
|
||||
item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey));
|
||||
@@ -166,7 +172,7 @@ public class ConvertRunService {
|
||||
FileResultEntity resultEntity = new FileResultEntity();
|
||||
resultEntity.setTaskId(task.getId());
|
||||
resultEntity.setModuleType(MODULE_TYPE);
|
||||
resultEntity.setSourceFilename(request.getArchiveName());
|
||||
resultEntity.setSourceFilename(item.getSourceFilename());
|
||||
resultEntity.setResultFilename(zipFile.getName());
|
||||
resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey
|
||||
resultEntity.setResultFileSize(zipFile.length());
|
||||
@@ -205,7 +211,7 @@ public class ConvertRunService {
|
||||
vo.setResultId(entity.getId());
|
||||
vo.setSourceFilename(entity.getSourceFilename());
|
||||
vo.setOutputFilename(entity.getResultFilename());
|
||||
vo.setDownloadUrl(null);
|
||||
vo.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
|
||||
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
|
||||
vo.setError(entity.getErrorMessage());
|
||||
return vo;
|
||||
@@ -221,17 +227,12 @@ public class ConvertRunService {
|
||||
fileResultMapper.deleteById(resultId);
|
||||
}
|
||||
|
||||
public File getResultFile(Long resultId, Long userId) {
|
||||
public String getResultDownloadUrl(Long resultId, Long userId) {
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || entity.getResultFileUrl() == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||
throw new BusinessException("Convert result file does not exist.");
|
||||
}
|
||||
|
||||
File file = new File(entity.getResultFileUrl());
|
||||
if (!file.exists()) {
|
||||
throw new BusinessException("Convert result file does not exist.");
|
||||
}
|
||||
return file;
|
||||
return ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl());
|
||||
}
|
||||
|
||||
private List<GeneratedConvertFile> generateOutputFiles(File inputFile, ConvertTemplateEntity templateEntity) throws IOException {
|
||||
@@ -410,6 +411,16 @@ public class ConvertRunService {
|
||||
return zipFile;
|
||||
}
|
||||
|
||||
private String resolveArchiveName(String archiveName, List<String> successSourceNames) {
|
||||
if (archiveName != null && !archiveName.isBlank()) {
|
||||
return FileUtil.mainName(archiveName.trim());
|
||||
}
|
||||
if (successSourceNames.size() == 1) {
|
||||
return FileUtil.mainName(successSourceNames.getFirst());
|
||||
}
|
||||
return "convert-" + LocalDateTime.now().format(ZIP_NAME_DATE_FORMATTER);
|
||||
}
|
||||
|
||||
private String buildFolderZipEntry(String relativePath, String inputName, String outputFilename, boolean includeSourceStemFolder) {
|
||||
String normalizedRelativePath = relativePath == null ? "" : relativePath.replace('\\', '/');
|
||||
String relativeDir = normalizedRelativePath.contains("/")
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.deletebrand.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandRunRequest;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandSubmitResultRequest;
|
||||
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandTaskBatchRequest;
|
||||
@@ -19,7 +20,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -33,7 +33,6 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
|
||||
@@ -134,11 +133,8 @@ public class DeleteBrandRunController {
|
||||
}
|
||||
|
||||
try {
|
||||
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
String asciiFilename = buildAsciiDownloadFilename(filename, taskId);
|
||||
response.setContentType("application/octet-stream");
|
||||
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + asciiFilename + "\"; filename*=UTF-8''" + encodedFilename);
|
||||
DownloadHeaderUtil.setAttachment(response, filename);
|
||||
try (InputStream in = URI.create(url).toURL().openStream()) {
|
||||
byte[] buffer = new byte[65536];
|
||||
int read;
|
||||
@@ -152,23 +148,37 @@ public class DeleteBrandRunController {
|
||||
}
|
||||
}
|
||||
|
||||
private String buildAsciiDownloadFilename(String filename, Long taskId) {
|
||||
String sanitized = filename == null ? "" : filename.replaceAll("[^A-Za-z0-9._-]", "_");
|
||||
sanitized = sanitized.replaceAll("_+", "_");
|
||||
sanitized = sanitized.replaceAll("^[_\\.]+|[_\\.]+$", "");
|
||||
if (!sanitized.isBlank() && sanitized.contains(".")) {
|
||||
return sanitized;
|
||||
@GetMapping("/results/{resultId}/download")
|
||||
@Operation(summary = "Download one delete-brand result")
|
||||
public void downloadResult(
|
||||
@PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", description = "褰撳墠鐧诲綍鐢ㄦ埛 ID", required = true, in = ParameterIn.QUERY)
|
||||
@RequestParam("user_id") Long userId,
|
||||
jakarta.servlet.http.HttpServletResponse response) {
|
||||
String url = deleteBrandRunService.resolveResultDownloadUrl(resultId, userId);
|
||||
String filename = deleteBrandRunService.resolveResultDownloadFilename(resultId, userId);
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "缁撴灉鏃犲彲涓嬭浇");
|
||||
}
|
||||
String ext = "xlsx";
|
||||
if (filename != null) {
|
||||
int dotIndex = filename.lastIndexOf('.');
|
||||
if (dotIndex >= 0 && dotIndex < filename.length() - 1) {
|
||||
String rawExt = filename.substring(dotIndex + 1).replaceAll("[^A-Za-z0-9]", "");
|
||||
if (!rawExt.isBlank()) {
|
||||
ext = rawExt;
|
||||
if (filename == null || filename.isBlank()) {
|
||||
String rawPath = URI.create(url).getPath();
|
||||
filename = rawPath == null ? "delete-brand_" + resultId + ".xlsx" : rawPath.substring(rawPath.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
try {
|
||||
response.setContentType("application/octet-stream");
|
||||
DownloadHeaderUtil.setAttachment(response, filename);
|
||||
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, "涓嬭浇澶辫触");
|
||||
}
|
||||
return "delete-brand_" + taskId + "." + ext;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -84,6 +84,20 @@ public class DeleteBrandRunService {
|
||||
private final TaskPressureProperties taskPressureProperties;
|
||||
private final TaskFileJobService taskFileJobService;
|
||||
|
||||
private boolean isUsableIndexedMatch(ZiniaoShopMatchResultVo matchResult) {
|
||||
if (matchResult == null) {
|
||||
return false;
|
||||
}
|
||||
String shopId = matchResult.getShopId();
|
||||
if (shopId == null || shopId.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String status = matchResult.getMatchStatus();
|
||||
return matchResult.isMatched()
|
||||
|| ZiniaoShopIndexService.MATCH_STATUS_MATCHED.equals(status)
|
||||
|| ZiniaoShopIndexService.MATCH_STATUS_STALE.equals(status);
|
||||
}
|
||||
|
||||
public DeleteBrandRunVo run(DeleteBrandRunRequest request) {
|
||||
if (request.getUserId() == null || request.getUserId() <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
@@ -136,8 +150,8 @@ public class DeleteBrandRunService {
|
||||
ignored -> ziniaoShopSwitchService.findIndexedStoreByName(item.getShopName(), false));
|
||||
item.setMatchStatus(matchResult.getMatchStatus());
|
||||
item.setMatchMessage(matchResult.getMatchMessage());
|
||||
item.setMatched(matchResult.isMatched());
|
||||
if (matchResult.isMatched()) {
|
||||
item.setMatched(isUsableIndexedMatch(matchResult));
|
||||
if (item.isMatched()) {
|
||||
item.setShopId(matchResult.getShopId());
|
||||
item.setCompanyName(matchResult.getCompanyName());
|
||||
item.setPlatform(matchResult.getPlatform());
|
||||
@@ -236,35 +250,79 @@ public class DeleteBrandRunService {
|
||||
}
|
||||
|
||||
public DeleteBrandHistoryVo listHistory(Long userId) {
|
||||
long startedAt = System.nanoTime();
|
||||
DeleteBrandHistoryVo vo = new DeleteBrandHistoryVo();
|
||||
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
List<FileResultEntity> createdRows = fileResultMapper.selectList(historyResultQuery()
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileResultEntity::getUserId, userId)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||
.last("limit 100"));
|
||||
.last("limit 500"));
|
||||
List<FileTaskEntity> recentTouchedTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.select(FileTaskEntity::getId,
|
||||
FileTaskEntity::getStatus,
|
||||
FileTaskEntity::getUpdatedAt,
|
||||
FileTaskEntity::getFinishedAt)
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileTaskEntity::getUserId, userId)
|
||||
.orderByDesc(FileTaskEntity::getUpdatedAt)
|
||||
.last("limit 200"));
|
||||
Map<Long, FileResultEntity> entityById = new LinkedHashMap<>();
|
||||
for (FileResultEntity entity : createdRows) {
|
||||
if (entity != null && entity.getId() != null) {
|
||||
entityById.put(entity.getId(), entity);
|
||||
}
|
||||
}
|
||||
for (FileResultEntity entity : selectHistoryResultRowsByTaskIdsInBatches(userId, recentTouchedTasks.stream()
|
||||
.map(FileTaskEntity::getId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList())) {
|
||||
if (entity != null && entity.getId() != null) {
|
||||
entityById.putIfAbsent(entity.getId(), entity);
|
||||
}
|
||||
}
|
||||
List<FileResultEntity> entities = new ArrayList<>(entityById.values());
|
||||
long resultRowsLoadedAt = System.nanoTime();
|
||||
if (entities.isEmpty()) {
|
||||
vo.setItems(List.of());
|
||||
log.info("[delete-brand] history timing userId={} rows=0 totalMs={} resultQueryMs={} taskQueryMs=0 jobQueryMs=0 buildMs=0",
|
||||
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
|
||||
return vo;
|
||||
}
|
||||
|
||||
// 补充信息
|
||||
Map<Long, List<DeleteBrandResultItemVo>> itemsByTaskId = new LinkedHashMap<>();
|
||||
Map<Long, FileTaskEntity> taskById = new LinkedHashMap<>();
|
||||
Map<Long, String> statusByTaskId = new LinkedHashMap<>();
|
||||
List<Long> taskIds = entities.stream()
|
||||
.map(FileResultEntity::getTaskId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
for (FileTaskEntity task : recentTouchedTasks) {
|
||||
if (task != null) {
|
||||
taskById.put(task.getId(), task);
|
||||
statusByTaskId.put(task.getId(), task.getStatus());
|
||||
}
|
||||
}
|
||||
if (!taskIds.isEmpty()) {
|
||||
List<FileTaskEntity> tasks = selectTasksByIdsInBatches(taskIds);
|
||||
List<FileTaskEntity> tasks = selectTaskHistoryFieldsByIdsInBatches(taskIds);
|
||||
for (FileTaskEntity task : tasks) {
|
||||
try {
|
||||
if (task != null) {
|
||||
taskById.put(task.getId(), task);
|
||||
statusByTaskId.put(task.getId(), task.getStatus());
|
||||
if (task.getResultJson() != null && !task.getResultJson().isBlank()) {
|
||||
List<DeleteBrandResultItemVo> taskItems = objectMapper.readValue(task.getResultJson(), new TypeReference<List<DeleteBrandResultItemVo>>() {
|
||||
});
|
||||
itemsByTaskId.put(task.getId(), taskItems);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
long tasksLoadedAt = System.nanoTime();
|
||||
Map<Long, TaskFileJobEntity> jobMap = taskFileJobService.findAssembleJobsByResultIds(MODULE_TYPE, entities.stream()
|
||||
.map(FileResultEntity::getId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList());
|
||||
long jobsLoadedAt = System.nanoTime();
|
||||
entities = sortDeleteBrandHistoryEntities(entities, taskById, jobMap).stream()
|
||||
.limit(100)
|
||||
.toList();
|
||||
|
||||
vo.setItems(entities.stream().map(entity -> {
|
||||
DeleteBrandResultItemVo item = new DeleteBrandResultItemVo();
|
||||
@@ -275,7 +333,7 @@ public class DeleteBrandRunService {
|
||||
item.setShopName(entity.getSourceFilename() == null ? null : FileUtil.mainName(entity.getSourceFilename()));
|
||||
item.setOutputFilename(entity.getResultFilename());
|
||||
item.setDownloadUrl(null);
|
||||
attachFileJobState(item, entity);
|
||||
attachFileJobState(item, entity, jobMap.get(entity.getId()));
|
||||
item.setTotalRows(entity.getRowCount());
|
||||
item.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
|
||||
item.setError(entity.getErrorMessage());
|
||||
@@ -284,7 +342,7 @@ public class DeleteBrandRunService {
|
||||
// 真实补充
|
||||
if (entity.getTaskId() != null) {
|
||||
item.setTaskStatus(statusByTaskId.get(entity.getTaskId()));
|
||||
List<DeleteBrandResultItemVo> taskItems = itemsByTaskId.get(entity.getTaskId());
|
||||
List<DeleteBrandResultItemVo> taskItems = null;
|
||||
if (taskItems != null && entity.getSourceFilename() != null && !entity.getSourceFilename().isBlank()) {
|
||||
for (DeleteBrandResultItemVo candidate : taskItems) {
|
||||
if (candidate == null) {
|
||||
@@ -320,12 +378,42 @@ public class DeleteBrandRunService {
|
||||
}
|
||||
return item;
|
||||
}).toList());
|
||||
long finishedAt = System.nanoTime();
|
||||
log.info("[delete-brand] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
||||
userId,
|
||||
entities.size(),
|
||||
statusByTaskId.size(),
|
||||
jobMap.size(),
|
||||
elapsedMs(startedAt, finishedAt),
|
||||
elapsedMs(startedAt, resultRowsLoadedAt),
|
||||
elapsedMs(resultRowsLoadedAt, tasksLoadedAt),
|
||||
elapsedMs(tasksLoadedAt, jobsLoadedAt),
|
||||
elapsedMs(jobsLoadedAt, finishedAt));
|
||||
|
||||
return vo;
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<FileResultEntity> historyResultQuery() {
|
||||
return new LambdaQueryWrapper<FileResultEntity>()
|
||||
.select(FileResultEntity::getId,
|
||||
FileResultEntity::getTaskId,
|
||||
FileResultEntity::getModuleType,
|
||||
FileResultEntity::getSourceFilename,
|
||||
FileResultEntity::getSourceFileUrl,
|
||||
FileResultEntity::getResultFilename,
|
||||
FileResultEntity::getResultFileUrl,
|
||||
FileResultEntity::getRowCount,
|
||||
FileResultEntity::getSuccess,
|
||||
FileResultEntity::getErrorMessage,
|
||||
FileResultEntity::getUserId,
|
||||
FileResultEntity::getCreatedAt);
|
||||
}
|
||||
|
||||
private void attachFileJobState(DeleteBrandResultItemVo item, FileResultEntity entity) {
|
||||
TaskFileJobEntity job = taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId());
|
||||
attachFileJobState(item, entity, taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId()));
|
||||
}
|
||||
|
||||
private void attachFileJobState(DeleteBrandResultItemVo item, FileResultEntity entity, TaskFileJobEntity job) {
|
||||
item.setFileReady(entity.getResultFileUrl() != null && !entity.getResultFileUrl().isBlank());
|
||||
if (job == null) {
|
||||
item.setFileStatus(Boolean.TRUE.equals(item.getFileReady()) ? "SUCCESS" : null);
|
||||
@@ -336,6 +424,51 @@ public class DeleteBrandRunService {
|
||||
item.setFileError(job.getErrorMessage());
|
||||
}
|
||||
|
||||
private List<FileResultEntity> sortDeleteBrandHistoryEntities(
|
||||
List<FileResultEntity> entities,
|
||||
Map<Long, FileTaskEntity> taskById,
|
||||
Map<Long, TaskFileJobEntity> jobByResultId
|
||||
) {
|
||||
if (entities == null || entities.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<FileResultEntity> sorted = new ArrayList<>(entities);
|
||||
sorted.sort(Comparator
|
||||
.comparing((FileResultEntity entity) -> resolveHistorySortTime(entity, taskById, jobByResultId),
|
||||
Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(FileResultEntity::getId, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private LocalDateTime resolveHistorySortTime(
|
||||
FileResultEntity entity,
|
||||
Map<Long, FileTaskEntity> taskById,
|
||||
Map<Long, TaskFileJobEntity> jobByResultId
|
||||
) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
TaskFileJobEntity job = jobByResultId == null ? null : jobByResultId.get(entity.getId());
|
||||
if (job != null) {
|
||||
if (job.getFinishedAt() != null) {
|
||||
return job.getFinishedAt();
|
||||
}
|
||||
if (job.getUpdatedAt() != null) {
|
||||
return job.getUpdatedAt();
|
||||
}
|
||||
}
|
||||
FileTaskEntity task = taskById == null ? null : taskById.get(entity.getTaskId());
|
||||
if (task != null) {
|
||||
if (task.getFinishedAt() != null) {
|
||||
return task.getFinishedAt();
|
||||
}
|
||||
if (task.getUpdatedAt() != null) {
|
||||
return task.getUpdatedAt();
|
||||
}
|
||||
}
|
||||
return entity.getCreatedAt();
|
||||
}
|
||||
|
||||
public DeleteBrandTaskDeletionStatusVo getTaskDeletionStatus(Long taskId, Long userId) {
|
||||
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
|
||||
@@ -617,6 +750,13 @@ public class DeleteBrandRunService {
|
||||
}
|
||||
|
||||
java.util.Map<Long, java.util.Map<Object, Object>> progressByTaskId = deleteBrandTaskCacheService.getProgressBatch(normalizedTaskIds);
|
||||
Map<Long, List<FileResultEntity>> resultRowsByTaskId = findProgressResultRowsByTaskIds(normalizedTaskIds);
|
||||
Map<Long, TaskFileJobEntity> jobMap = taskFileJobService.findAssembleJobsByResultIds(MODULE_TYPE, resultRowsByTaskId.values().stream()
|
||||
.flatMap(List::stream)
|
||||
.map(FileResultEntity::getId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList());
|
||||
|
||||
for (Long taskId : normalizedTaskIds) {
|
||||
FileTaskEntity task = taskById.get(taskId);
|
||||
@@ -624,7 +764,8 @@ public class DeleteBrandRunService {
|
||||
vo.getMissingTaskIds().add(taskId);
|
||||
continue;
|
||||
}
|
||||
vo.getItems().add(buildTaskProgressDetail(task, progressByTaskId.get(taskId)));
|
||||
vo.getItems().add(buildTaskProgressDetail(task, progressByTaskId.get(taskId),
|
||||
resultRowsByTaskId.getOrDefault(taskId, List.of()), jobMap));
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
@@ -634,6 +775,13 @@ public class DeleteBrandRunService {
|
||||
}
|
||||
|
||||
private DeleteBrandTaskDetailVo buildTaskProgressDetail(FileTaskEntity task, java.util.Map<Object, Object> progress) {
|
||||
return buildTaskProgressDetail(task, progress, List.of(), Map.of());
|
||||
}
|
||||
|
||||
private DeleteBrandTaskDetailVo buildTaskProgressDetail(FileTaskEntity task,
|
||||
java.util.Map<Object, Object> progress,
|
||||
List<FileResultEntity> resultRows,
|
||||
Map<Long, TaskFileJobEntity> jobMap) {
|
||||
task = refreshIndexedMatchesIfNeeded(task);
|
||||
DeleteBrandTaskItemVo taskVo = new DeleteBrandTaskItemVo();
|
||||
taskVo.setId(task.getId());
|
||||
@@ -657,10 +805,39 @@ public class DeleteBrandRunService {
|
||||
DeleteBrandTaskDetailVo detail = new DeleteBrandTaskDetailVo();
|
||||
detail.setTask(taskVo);
|
||||
detail.setLine_progress(buildLineProgress(task.getId(), progress));
|
||||
detail.setFileProgress(java.util.List.of());
|
||||
List<DeleteBrandResultItemVo> taskItems = buildProgressResultItems(task, resultRows, jobMap);
|
||||
detail.setItems(taskItems);
|
||||
detail.setFileProgress(buildFileProgress(task, taskItems, detail.getLine_progress()));
|
||||
return detail;
|
||||
}
|
||||
|
||||
private List<DeleteBrandResultItemVo> buildProgressResultItems(FileTaskEntity task,
|
||||
List<FileResultEntity> resultRows,
|
||||
Map<Long, TaskFileJobEntity> jobMap) {
|
||||
if (task == null || resultRows == null || resultRows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return resultRows.stream()
|
||||
.filter(entity -> entity != null && entity.getId() != null)
|
||||
.map(entity -> {
|
||||
DeleteBrandResultItemVo item = new DeleteBrandResultItemVo();
|
||||
item.setResultId(entity.getId());
|
||||
item.setTaskId(entity.getTaskId());
|
||||
item.setFileKey(entity.getSourceFileUrl());
|
||||
item.setSourceFilename(entity.getSourceFilename());
|
||||
item.setShopName(entity.getSourceFilename() == null ? null : FileUtil.mainName(entity.getSourceFilename()));
|
||||
item.setOutputFilename(entity.getResultFilename());
|
||||
item.setDownloadUrl(null);
|
||||
item.setTotalRows(entity.getRowCount());
|
||||
item.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
|
||||
item.setError(entity.getErrorMessage());
|
||||
item.setTaskStatus(task.getStatus());
|
||||
attachFileJobState(item, entity, jobMap == null ? null : jobMap.get(entity.getId()));
|
||||
return item;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private DeleteBrandTaskDetailVo buildTaskDetail(FileTaskEntity task, java.util.Map<Object, Object> progress) {
|
||||
task = refreshIndexedMatchesIfNeeded(task);
|
||||
DeleteBrandTaskItemVo taskVo = new DeleteBrandTaskItemVo();
|
||||
@@ -837,7 +1014,7 @@ public class DeleteBrandRunService {
|
||||
String oldOpenStoreUrl = item.getOpenStoreUrl();
|
||||
String oldMessage = item.getMatchMessage();
|
||||
|
||||
item.setMatched(refreshed.isMatched());
|
||||
item.setMatched(isUsableIndexedMatch(refreshed));
|
||||
item.setMatchStatus(refreshed.getMatchStatus());
|
||||
item.setMatchMessage(refreshed.getMatchMessage());
|
||||
item.setShopId(refreshed.getShopId());
|
||||
@@ -1011,6 +1188,65 @@ public class DeleteBrandRunService {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private List<FileTaskEntity> selectTaskHistoryFieldsByIdsInBatches(List<Long> taskIds) {
|
||||
List<FileTaskEntity> tasks = new ArrayList<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return tasks;
|
||||
}
|
||||
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
|
||||
for (int start = 0; start < taskIds.size(); start += batchSize) {
|
||||
int end = Math.min(start + batchSize, taskIds.size());
|
||||
tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.select(FileTaskEntity::getId,
|
||||
FileTaskEntity::getStatus,
|
||||
FileTaskEntity::getUpdatedAt,
|
||||
FileTaskEntity::getFinishedAt)
|
||||
.in(FileTaskEntity::getId, taskIds.subList(start, end))));
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private List<FileResultEntity> selectHistoryResultRowsByTaskIdsInBatches(Long userId, List<Long> taskIds) {
|
||||
List<FileResultEntity> rows = new ArrayList<>();
|
||||
if (userId == null || taskIds == null || taskIds.isEmpty()) {
|
||||
return rows;
|
||||
}
|
||||
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
|
||||
for (int start = 0; start < taskIds.size(); start += batchSize) {
|
||||
int end = Math.min(start + batchSize, taskIds.size());
|
||||
rows.addAll(fileResultMapper.selectList(historyResultQuery()
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileResultEntity::getUserId, userId)
|
||||
.in(FileResultEntity::getTaskId, taskIds.subList(start, end))));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private Map<Long, List<FileResultEntity>> findProgressResultRowsByTaskIds(List<Long> taskIds) {
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, List<FileResultEntity>> rowsByTaskId = new LinkedHashMap<>();
|
||||
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
|
||||
for (int start = 0; start < taskIds.size(); start += batchSize) {
|
||||
int end = Math.min(start + batchSize, taskIds.size());
|
||||
List<FileResultEntity> rows = fileResultMapper.selectList(historyResultQuery()
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.in(FileResultEntity::getTaskId, taskIds.subList(start, end))
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
for (FileResultEntity row : rows) {
|
||||
if (row != null && row.getTaskId() != null) {
|
||||
rowsByTaskId.computeIfAbsent(row.getTaskId(), ignored -> new ArrayList<>()).add(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
return rowsByTaskId;
|
||||
}
|
||||
|
||||
private static long elapsedMs(long startInclusive, long endExclusive) {
|
||||
return Math.max(0L, (endExclusive - startInclusive) / 1_000_000L);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) {
|
||||
log.info("[DeleteBrand] Received submitResult for taskId: {}, files count: {}", taskId, request == null || request.getFiles() == null ? 0 : request.getFiles().size());
|
||||
@@ -1027,6 +1263,16 @@ public class DeleteBrandRunService {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
||||
log.warn("[DeleteBrand] submitResult rejected because task is terminal -> taskId: {}, status: {}, createdAt: {}, updatedAt: {}, finishedAt: {}, error: {}, sourceFiles: {}, successFiles: {}, failedFiles: {}",
|
||||
task.getId(),
|
||||
task.getStatus(),
|
||||
task.getCreatedAt(),
|
||||
task.getUpdatedAt(),
|
||||
task.getFinishedAt(),
|
||||
task.getErrorMessage(),
|
||||
task.getSourceFileCount(),
|
||||
task.getSuccessFileCount(),
|
||||
task.getFailedFileCount());
|
||||
throw new BusinessException("任务已结束,拒绝继续提交结果");
|
||||
}
|
||||
|
||||
@@ -1086,6 +1332,7 @@ public class DeleteBrandRunService {
|
||||
// 如果该文件已完成,立即更新成品计数的 Redis 提示,让后续 tryFinalizeTask 更准确
|
||||
if (isFileCompleted(fileDto)) {
|
||||
log.info("[DeleteBrand] File chunk transmission fully reached via chunkTotal for taskId: {}, file: {}", taskId, fileDto.getSourceFilename());
|
||||
enqueueCompletedFileAssembleJob(task, fileIdentity, parsedFile);
|
||||
shouldTryFinalize = true;
|
||||
// 这里暂不直接 increment,而是标记需要在 tryFinalizeTask 中重新计算
|
||||
// 为了保险,直接在 tryFinalizeTask 里重新遍历分片状态
|
||||
@@ -1098,6 +1345,55 @@ public class DeleteBrandRunService {
|
||||
}
|
||||
}
|
||||
|
||||
private void enqueueCompletedFileAssembleJob(FileTaskEntity task,
|
||||
String fileIdentity,
|
||||
DeleteBrandParsedFileCacheDto parsedFile) {
|
||||
if (task == null || task.getId() == null || parsedFile == null) {
|
||||
return;
|
||||
}
|
||||
FileResultEntity resultEntity = findResultEntity(task.getId(), fileIdentity, parsedFile);
|
||||
if (resultEntity == null) {
|
||||
log.warn("[DeleteBrand] completed file has no result row, skip early assemble -> taskId: {}, file: {}",
|
||||
task.getId(), parsedFile.getSourceFilename());
|
||||
return;
|
||||
}
|
||||
if (taskFileJobService.hasSuccessfulAssembleJob(task.getId(), MODULE_TYPE, resultEntity.getId())) {
|
||||
return;
|
||||
}
|
||||
String outputFilename = buildResultFilename(parsedFile);
|
||||
resultEntity.setResultFilename(outputFilename);
|
||||
resultEntity.setResultContentType(CONTENT_TYPE_XLSX);
|
||||
resultEntity.setRowCount(parsedFile.getTotalRows());
|
||||
resultEntity.setSuccess(1);
|
||||
resultEntity.setErrorMessage(null);
|
||||
if (resultEntity.getResultFileUrl() == null || resultEntity.getResultFileUrl().isBlank()) {
|
||||
resultEntity.setResultFileSize(0L);
|
||||
}
|
||||
fileResultMapper.updateById(resultEntity);
|
||||
taskFileJobService.enqueueAssembleResult(task.getId(), MODULE_TYPE, resultEntity.getId(), fileIdentity);
|
||||
log.info("[DeleteBrand] completed file early assemble job enqueued -> taskId: {}, resultId: {}, file: {}",
|
||||
task.getId(), resultEntity.getId(), parsedFile.getSourceFilename());
|
||||
}
|
||||
|
||||
private FileResultEntity findResultEntity(Long taskId,
|
||||
String fileIdentity,
|
||||
DeleteBrandParsedFileCacheDto parsedFile) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return null;
|
||||
}
|
||||
LambdaQueryWrapper<FileResultEntity> wrapper = new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileResultEntity::getTaskId, taskId);
|
||||
if (fileIdentity != null && !fileIdentity.isBlank()) {
|
||||
wrapper.eq(FileResultEntity::getSourceFileUrl, fileIdentity);
|
||||
} else if (parsedFile != null && parsedFile.getSourceFilename() != null && !parsedFile.getSourceFilename().isBlank()) {
|
||||
wrapper.eq(FileResultEntity::getSourceFilename, parsedFile.getSourceFilename());
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return fileResultMapper.selectOne(wrapper.last("limit 1"));
|
||||
}
|
||||
|
||||
public void tryFinalizeTask(Long taskId, boolean fromCompensation) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
@@ -1291,15 +1587,19 @@ public class DeleteBrandRunService {
|
||||
throw new BusinessException("结果记录不存在: " + parsedFile.getSourceFilename());
|
||||
}
|
||||
String outputFilename = buildResultFilename(parsedFile);
|
||||
boolean alreadyAssembled = taskFileJobService.hasSuccessfulAssembleJob(task.getId(), MODULE_TYPE, resultEntity.getId());
|
||||
resultEntity.setResultFilename(outputFilename);
|
||||
resultEntity.setResultFileUrl(null);
|
||||
resultEntity.setResultFileSize(0L);
|
||||
if (!alreadyAssembled && (resultEntity.getResultFileUrl() == null || resultEntity.getResultFileUrl().isBlank())) {
|
||||
resultEntity.setResultFileSize(0L);
|
||||
}
|
||||
resultEntity.setResultContentType(CONTENT_TYPE_XLSX);
|
||||
resultEntity.setRowCount(parsedFile.getTotalRows());
|
||||
resultEntity.setSuccess(1);
|
||||
resultEntity.setErrorMessage(null);
|
||||
fileResultMapper.updateById(resultEntity);
|
||||
taskFileJobService.enqueueAssembleResult(task.getId(), MODULE_TYPE, resultEntity.getId(), fileIdentity);
|
||||
if (!alreadyAssembled) {
|
||||
taskFileJobService.enqueueAssembleResult(task.getId(), MODULE_TYPE, resultEntity.getId(), fileIdentity);
|
||||
}
|
||||
|
||||
DeleteBrandResultItemVo item = new DeleteBrandResultItemVo();
|
||||
item.setResultId(resultEntity.getId());
|
||||
@@ -1343,6 +1643,7 @@ public class DeleteBrandRunService {
|
||||
"finished_files", String.valueOf(successCount),
|
||||
"updated_at", String.valueOf(System.currentTimeMillis())
|
||||
));
|
||||
cleanupCompletedTaskDataIfNoPendingFileJobs(task.getId());
|
||||
} catch (Exception ex) {
|
||||
task.setStatus("FAILED");
|
||||
task.setErrorMessage(ex.getMessage());
|
||||
@@ -1419,8 +1720,19 @@ public class DeleteBrandRunService {
|
||||
if (job == null || job.getTaskId() == null) {
|
||||
return;
|
||||
}
|
||||
if (taskFileJobService.countUnfinishedAssembleJobs(job.getTaskId(), MODULE_TYPE) == 0L) {
|
||||
deleteBrandTaskStorageService.deleteTaskData(job.getTaskId());
|
||||
cleanupCompletedTaskDataIfNoPendingFileJobs(job.getTaskId());
|
||||
}
|
||||
|
||||
private void cleanupCompletedTaskDataIfNoPendingFileJobs(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||
if (task == null || (!"SUCCESS".equals(task.getStatus()) && !"FAILED".equals(task.getStatus()))) {
|
||||
return;
|
||||
}
|
||||
if (taskFileJobService.countUnfinishedAssembleJobs(taskId, MODULE_TYPE) == 0L) {
|
||||
deleteBrandTaskStorageService.deleteTaskData(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1611,6 +1923,32 @@ public class DeleteBrandRunService {
|
||||
return entity == null ? null : entity.getResultFilename();
|
||||
}
|
||||
|
||||
public String resolveResultDownloadUrl(Long resultId, Long userId) {
|
||||
FileResultEntity entity = findDownloadableResult(resultId, userId);
|
||||
return entity == null || entity.getResultFileUrl() == null ? null : ossStorageService.getPublicUrl(entity.getResultFileUrl());
|
||||
}
|
||||
|
||||
public String resolveResultDownloadFilename(Long resultId, Long userId) {
|
||||
FileResultEntity entity = findDownloadableResult(resultId, userId);
|
||||
return entity == null ? null : entity.getResultFilename();
|
||||
}
|
||||
|
||||
private FileResultEntity findDownloadableResult(Long resultId, Long userId) {
|
||||
if (resultId == null || resultId <= 0 || userId == null || userId <= 0) {
|
||||
return null;
|
||||
}
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null
|
||||
|| !MODULE_TYPE.equals(entity.getModuleType())
|
||||
|| entity.getUserId() == null
|
||||
|| !entity.getUserId().equals(userId)
|
||||
|| entity.getResultFileUrl() == null
|
||||
|| entity.getResultFileUrl().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
private FileResultEntity findLatestSuccessfulResult(Long taskId) {
|
||||
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
|
||||
@@ -134,15 +134,17 @@ public class DeleteBrandStaleTaskService {
|
||||
}
|
||||
}
|
||||
boolean hasStartedProgress = lastHeartbeatAt > 0L;
|
||||
int completedScopeCount = 0;
|
||||
if (!hasStartedProgress) {
|
||||
hasStartedProgress = deleteBrandTaskStorageService.countCompletedScopes(task.getId()) > 0
|
||||
|| deleteBrandTaskStorageService.countParsedPayloadScopes(task.getId()) > 0;
|
||||
// Parsed payload is created by Java during the parse step. Only result chunks mean Python has started uploading.
|
||||
completedScopeCount = deleteBrandTaskStorageService.countCompletedScopes(task.getId());
|
||||
hasStartedProgress = completedScopeCount > 0;
|
||||
}
|
||||
|
||||
if (lastHeartbeatAt > 0L && nowMillis - lastHeartbeatAt < staleTimeoutMillis) {
|
||||
continue;
|
||||
}
|
||||
if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) {
|
||||
if (!hasStartedProgress && isWithinInitialGrace(task, initialThreshold)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -151,12 +153,24 @@ public class DeleteBrandStaleTaskService {
|
||||
FileTaskEntity refreshed = fileTaskMapper.selectById(task.getId());
|
||||
if (refreshed != null && !"RUNNING".equals(refreshed.getStatus())) {
|
||||
deleteBrandTaskCacheService.saveTaskCache(refreshed);
|
||||
log.info("[stale-check] delete-brand finalized before timeout-fail taskId={} status={} updatedAt={} finishedAt={}",
|
||||
refreshed.getId(), refreshed.getStatus(), refreshed.getUpdatedAt(), refreshed.getFinishedAt());
|
||||
continue;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("[stale-check] delete-brand finalize threw taskId={} msg={}", task.getId(), ex.getMessage());
|
||||
}
|
||||
|
||||
log.warn("[stale-check] delete-brand failing stale task -> taskId={} updatedAt={} createdAt={} lastHeartbeatAt={} timeoutMinutes={} initialTimeoutMinutes={} completedScopes={} hasStartedProgress={} initialThreshold={}",
|
||||
task.getId(),
|
||||
task.getUpdatedAt(),
|
||||
task.getCreatedAt(),
|
||||
lastHeartbeatAt,
|
||||
minutes,
|
||||
initialMinutes,
|
||||
completedScopeCount,
|
||||
hasStartedProgress,
|
||||
initialThreshold);
|
||||
int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
|
||||
.eq(FileTaskEntity::getId, task.getId())
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_DELETE_BRAND)
|
||||
@@ -168,6 +182,7 @@ public class DeleteBrandStaleTaskService {
|
||||
|
||||
if (updated > 0) {
|
||||
deleteBrandTaskCacheService.delete(task.getId());
|
||||
log.warn("[stale-check] delete-brand failed taskId={} reason=timeout", task.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,11 +219,11 @@ public class DeleteBrandStaleTaskService {
|
||||
task.getId(), lastPayloadHeartbeatMillis, minutes, task.getUpdatedAt());
|
||||
continue;
|
||||
}
|
||||
boolean hasStartedProgress = lastPayloadHeartbeatMillis > 0L || hasFinishedRows;
|
||||
boolean hasStartedProgress = hasFinishedRows;
|
||||
if (!hasStartedProgress) {
|
||||
hasStartedProgress = productRiskTaskCacheService.hasAnyShopMergedPayload(task.getId());
|
||||
}
|
||||
if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) {
|
||||
if (!hasStartedProgress && isWithinInitialGrace(task, initialThreshold)) {
|
||||
stats.skippedTaskCount++;
|
||||
log.info("[stale-check] product-risk skip initial-grace taskId={} createdAt={} initialThreshold={}",
|
||||
task.getId(), task.getCreatedAt(), initialThreshold);
|
||||
@@ -271,11 +286,11 @@ public class DeleteBrandStaleTaskService {
|
||||
stats.skippedTaskCount++;
|
||||
continue;
|
||||
}
|
||||
boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasFinishedRows;
|
||||
boolean hasStartedProgress = hasFinishedRows;
|
||||
if (!hasStartedProgress) {
|
||||
hasStartedProgress = priceTrackTaskCacheService.hasAnyShopMergedPayload(task.getId());
|
||||
}
|
||||
if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) {
|
||||
if (!hasStartedProgress && isWithinInitialGrace(task, initialThreshold)) {
|
||||
stats.skippedTaskCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -339,11 +354,11 @@ public class DeleteBrandStaleTaskService {
|
||||
task.getId(), lastHeartbeatMillis, minutes, task.getUpdatedAt());
|
||||
continue;
|
||||
}
|
||||
boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasFinishedRows;
|
||||
boolean hasStartedProgress = hasFinishedRows;
|
||||
if (!hasStartedProgress) {
|
||||
hasStartedProgress = shopMatchTaskCacheService.hasAnyShopMergedPayload(task.getId());
|
||||
}
|
||||
if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) {
|
||||
if (!hasStartedProgress && isWithinInitialGrace(task, initialThreshold)) {
|
||||
stats.skippedTaskCount++;
|
||||
log.info("[stale-check] shop-match skip initial-grace taskId={} createdAt={} initialThreshold={}",
|
||||
task.getId(), task.getCreatedAt(), initialThreshold);
|
||||
@@ -405,11 +420,11 @@ public class DeleteBrandStaleTaskService {
|
||||
stats.skippedTaskCount++;
|
||||
continue;
|
||||
}
|
||||
boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasFinishedRows;
|
||||
boolean hasStartedProgress = hasFinishedRows;
|
||||
if (!hasStartedProgress) {
|
||||
hasStartedProgress = patrolDeleteTaskCacheService.hasAnyShopMergedPayload(task.getId());
|
||||
}
|
||||
if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) {
|
||||
if (!hasStartedProgress && isWithinInitialGrace(task, initialThreshold)) {
|
||||
stats.skippedTaskCount++;
|
||||
continue;
|
||||
}
|
||||
@@ -440,6 +455,14 @@ public class DeleteBrandStaleTaskService {
|
||||
return stats;
|
||||
}
|
||||
|
||||
private boolean isWithinInitialGrace(FileTaskEntity task, LocalDateTime initialThreshold) {
|
||||
if (task == null || initialThreshold == null) {
|
||||
return false;
|
||||
}
|
||||
LocalDateTime baseline = task.getUpdatedAt() != null ? task.getUpdatedAt() : task.getCreatedAt();
|
||||
return baseline != null && baseline.isAfter(initialThreshold);
|
||||
}
|
||||
|
||||
@Scheduled(cron = "${aiimage.delete-brand-progress.finalize-check-cron:30 */2 * * * *}")
|
||||
public void finalizeCompletedRunningTasks() {
|
||||
DistributedJobLockService.LockHandle lockHandle =
|
||||
|
||||
@@ -19,6 +19,7 @@ import java.util.regex.Pattern;
|
||||
public class LocalTempCleanupService {
|
||||
|
||||
private static final Pattern ROOT_TEMP_FILE_PATTERN = Pattern.compile("^[a-fA-F0-9]{32}(\\.[^.]+)?$");
|
||||
private static final String TRANSIENT_PAYLOAD_DIR_NAME = "transient-payload";
|
||||
private static final List<String> RESULT_DIR_NAMES = List.of(
|
||||
"dedupe-result",
|
||||
"convert-result",
|
||||
@@ -39,11 +40,14 @@ public class LocalTempCleanupService {
|
||||
return;
|
||||
}
|
||||
|
||||
Instant sourceExpireBefore = Instant.now().minus(storageProperties.getSourceRetentionHours(), ChronoUnit.HOURS);
|
||||
Instant resultExpireBefore = Instant.now().minus(storageProperties.getResultRetentionHours(), ChronoUnit.HOURS);
|
||||
Instant now = Instant.now();
|
||||
Instant sourceExpireBefore = now.minus(storageProperties.getSourceRetentionHours(), ChronoUnit.HOURS);
|
||||
Instant resultExpireBefore = now.minus(storageProperties.getResultRetentionHours(), ChronoUnit.HOURS);
|
||||
Instant transientPayloadExpireBefore = now.minus(storageProperties.getTransientPayloadRetentionHours(), ChronoUnit.HOURS);
|
||||
|
||||
int deletedSourceCount = 0;
|
||||
int deletedResultCount = 0;
|
||||
int deletedTransientPayloadCount = 0;
|
||||
File[] children = tempDir.listFiles();
|
||||
if (children == null || children.length == 0) {
|
||||
return;
|
||||
@@ -60,14 +64,20 @@ public class LocalTempCleanupService {
|
||||
if (child.isDirectory() && RESULT_DIR_NAMES.contains(child.getName())) {
|
||||
deletedResultCount += deleteExpiredChildrenRecursively(child, resultExpireBefore);
|
||||
deleteEmptyDirectories(child, tempDir);
|
||||
continue;
|
||||
}
|
||||
if (child.isDirectory() && TRANSIENT_PAYLOAD_DIR_NAME.equals(child.getName())) {
|
||||
deletedTransientPayloadCount += deleteExpiredChildrenRecursively(child, transientPayloadExpireBefore);
|
||||
deleteEmptyDirectories(child, tempDir);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("清理本地临时文件失败: path={}", child.getAbsolutePath(), ex);
|
||||
log.warn("local temp cleanup failed: path={}", child.getAbsolutePath(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedSourceCount > 0 || deletedResultCount > 0) {
|
||||
log.info("本地临时目录清理完成: sourceDeleted={}, resultDeleted={}, tempDir={}", deletedSourceCount, deletedResultCount, tempDir.getAbsolutePath());
|
||||
if (deletedSourceCount > 0 || deletedResultCount > 0 || deletedTransientPayloadCount > 0) {
|
||||
log.info("local temp cleanup completed: sourceDeleted={}, resultDeleted={}, transientPayloadDeleted={}, tempDir={}",
|
||||
deletedSourceCount, deletedResultCount, deletedTransientPayloadCount, tempDir.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteConditionAddRequest;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCreateTaskRequest;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteSubmitResultRequest;
|
||||
@@ -25,7 +26,6 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -39,7 +39,6 @@ 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;
|
||||
|
||||
@@ -358,10 +357,8 @@ public class PatrolDeleteController {
|
||||
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);
|
||||
DownloadHeaderUtil.setAttachment(response, filename);
|
||||
try (InputStream in = URI.create(url).toURL().openStream()) {
|
||||
byte[] buffer = new byte[65536];
|
||||
int read;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCandidateAddRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest;
|
||||
@@ -28,7 +29,6 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -43,7 +43,6 @@ 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;
|
||||
|
||||
@@ -244,10 +243,8 @@ public class PriceTrackController {
|
||||
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);
|
||||
DownloadHeaderUtil.setAttachment(response, filename);
|
||||
try (InputStream in = URI.create(url).toURL().openStream()) {
|
||||
byte[] buffer = new byte[65536];
|
||||
int read;
|
||||
|
||||
@@ -166,14 +166,33 @@ public class PriceTrackTaskService {
|
||||
}
|
||||
|
||||
public PriceTrackHistoryVo listHistory(Long userId) {
|
||||
long startedAt = System.nanoTime();
|
||||
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
||||
PriceTrackHistoryVo vo = new PriceTrackHistoryVo();
|
||||
List<FileResultEntity> entities = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.select(FileResultEntity::getId,
|
||||
FileResultEntity::getTaskId,
|
||||
FileResultEntity::getModuleType,
|
||||
FileResultEntity::getSourceFilename,
|
||||
FileResultEntity::getSourceFileUrl,
|
||||
FileResultEntity::getResultFilename,
|
||||
FileResultEntity::getResultFileUrl,
|
||||
FileResultEntity::getSuccess,
|
||||
FileResultEntity::getErrorMessage,
|
||||
FileResultEntity::getUserId,
|
||||
FileResultEntity::getCreatedAt)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileResultEntity::getUserId, userId)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||
.last("limit 100"));
|
||||
long resultRowsLoadedAt = System.nanoTime();
|
||||
if (entities.isEmpty()) {
|
||||
vo.setItems(List.of());
|
||||
log.info("[price-track] history timing userId={} rows=0 totalMs={} resultQueryMs={} taskQueryMs=0 jobQueryMs=0 buildMs=0",
|
||||
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
|
||||
return vo;
|
||||
}
|
||||
Map<Long, String> statusByTaskId = new LinkedHashMap<>();
|
||||
List<Long> taskIds = entities.stream()
|
||||
.map(FileResultEntity::getTaskId)
|
||||
@@ -186,11 +205,29 @@ public class PriceTrackTaskService {
|
||||
if (t != null) statusByTaskId.put(t.getId(), t.getStatus());
|
||||
}
|
||||
}
|
||||
long tasksLoadedAt = System.nanoTime();
|
||||
Map<Long, TaskFileJobEntity> jobMap = taskFileJobService.findAssembleJobsByResultIds(MODULE_TYPE, entities.stream()
|
||||
.map(FileResultEntity::getId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList());
|
||||
long jobsLoadedAt = System.nanoTime();
|
||||
List<PriceTrackResultItemVo> items = new ArrayList<>();
|
||||
for (FileResultEntity entity : entities) {
|
||||
items.add(toHistoryItemVo(entity, statusByTaskId.get(entity.getTaskId())));
|
||||
items.add(toHistoryItemVo(entity, statusByTaskId.get(entity.getTaskId()), jobMap.get(entity.getId()), false));
|
||||
}
|
||||
vo.setItems(items);
|
||||
long finishedAt = System.nanoTime();
|
||||
log.info("[price-track] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
||||
userId,
|
||||
entities.size(),
|
||||
statusByTaskId.size(),
|
||||
jobMap.size(),
|
||||
elapsedMs(startedAt, finishedAt),
|
||||
elapsedMs(startedAt, resultRowsLoadedAt),
|
||||
elapsedMs(resultRowsLoadedAt, tasksLoadedAt),
|
||||
elapsedMs(tasksLoadedAt, jobsLoadedAt),
|
||||
elapsedMs(jobsLoadedAt, finishedAt));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@@ -952,6 +989,10 @@ public class PriceTrackTaskService {
|
||||
}
|
||||
|
||||
private PriceTrackResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) {
|
||||
return toHistoryItemVo(entity, taskStatus, null, true);
|
||||
}
|
||||
|
||||
private PriceTrackResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus, TaskFileJobEntity job, boolean mergeRequestFields) {
|
||||
PriceTrackResultItemVo vo = new PriceTrackResultItemVo();
|
||||
vo.setResultId(entity.getId());
|
||||
vo.setTaskId(entity.getTaskId());
|
||||
@@ -963,16 +1004,19 @@ public class PriceTrackTaskService {
|
||||
vo.setError(entity.getErrorMessage());
|
||||
vo.setOutputFilename(entity.getResultFilename());
|
||||
vo.setDownloadUrl(null);
|
||||
attachFileJobState(vo, entity);
|
||||
attachFileJobState(vo, entity, job);
|
||||
vo.setMatched(true);
|
||||
if (entity.getTaskId() != null) {
|
||||
if (mergeRequestFields && entity.getTaskId() != null) {
|
||||
mergeQueueFieldsFromRequest(vo, entity.getTaskId());
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private void attachFileJobState(PriceTrackResultItemVo vo, FileResultEntity entity) {
|
||||
TaskFileJobEntity job = taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId());
|
||||
attachFileJobState(vo, entity, taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId()));
|
||||
}
|
||||
|
||||
private void attachFileJobState(PriceTrackResultItemVo vo, FileResultEntity entity, TaskFileJobEntity job) {
|
||||
vo.setFileReady(entity.getResultFileUrl() != null && !entity.getResultFileUrl().isBlank());
|
||||
if (job == null) {
|
||||
vo.setFileStatus(Boolean.TRUE.equals(vo.getFileReady()) ? "SUCCESS" : null);
|
||||
@@ -1226,6 +1270,10 @@ public class PriceTrackTaskService {
|
||||
return t == null ? null : t.toString();
|
||||
}
|
||||
|
||||
private static long elapsedMs(long startInclusive, long endExclusive) {
|
||||
return Math.max(0L, (endExclusive - startInclusive) / 1_000_000L);
|
||||
}
|
||||
|
||||
private void updateTaskStatusFromLatestRows(FileTaskEntity task, List<FileResultEntity> latest) {
|
||||
String oldStatus = task.getStatus();
|
||||
int ok = 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.productrisk.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
||||
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;
|
||||
@@ -26,7 +27,6 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -41,7 +41,6 @@ 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;
|
||||
|
||||
@@ -250,10 +249,8 @@ public class ProductRiskResolveController {
|
||||
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);
|
||||
DownloadHeaderUtil.setAttachment(response, filename);
|
||||
try (InputStream in = URI.create(url).toURL().openStream()) {
|
||||
byte[] buffer = new byte[65536];
|
||||
int read;
|
||||
|
||||
@@ -165,15 +165,34 @@ public class ProductRiskTaskService {
|
||||
}
|
||||
|
||||
public ProductRiskHistoryVo listHistory(Long userId) {
|
||||
long startedAt = System.nanoTime();
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
}
|
||||
ProductRiskHistoryVo vo = new ProductRiskHistoryVo();
|
||||
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.select(FileResultEntity::getId,
|
||||
FileResultEntity::getTaskId,
|
||||
FileResultEntity::getModuleType,
|
||||
FileResultEntity::getSourceFilename,
|
||||
FileResultEntity::getSourceFileUrl,
|
||||
FileResultEntity::getResultFilename,
|
||||
FileResultEntity::getResultFileUrl,
|
||||
FileResultEntity::getSuccess,
|
||||
FileResultEntity::getErrorMessage,
|
||||
FileResultEntity::getUserId,
|
||||
FileResultEntity::getCreatedAt)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileResultEntity::getUserId, userId)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||
.last("limit 100"));
|
||||
long resultRowsLoadedAt = System.nanoTime();
|
||||
if (entities.isEmpty()) {
|
||||
vo.setItems(List.of());
|
||||
log.info("[product-risk] history timing userId={} rows=0 totalMs={} resultQueryMs={} taskQueryMs=0 jobQueryMs=0 buildMs=0",
|
||||
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
|
||||
return vo;
|
||||
}
|
||||
Map<Long, String> statusByTaskId = new LinkedHashMap<>();
|
||||
List<Long> taskIds = entities.stream()
|
||||
.map(FileResultEntity::getTaskId)
|
||||
@@ -188,11 +207,29 @@ public class ProductRiskTaskService {
|
||||
}
|
||||
}
|
||||
}
|
||||
long tasksLoadedAt = System.nanoTime();
|
||||
Map<Long, TaskFileJobEntity> jobMap = taskFileJobService.findAssembleJobsByResultIds(MODULE_TYPE, entities.stream()
|
||||
.map(FileResultEntity::getId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList());
|
||||
long jobsLoadedAt = System.nanoTime();
|
||||
List<ProductRiskResultItemVo> items = new ArrayList<>();
|
||||
for (FileResultEntity entity : entities) {
|
||||
items.add(toHistoryItemVo(entity, statusByTaskId.get(entity.getTaskId())));
|
||||
items.add(toHistoryItemVo(entity, statusByTaskId.get(entity.getTaskId()), jobMap.get(entity.getId()), false));
|
||||
}
|
||||
vo.setItems(items);
|
||||
long finishedAt = System.nanoTime();
|
||||
log.info("[product-risk] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
||||
userId,
|
||||
entities.size(),
|
||||
statusByTaskId.size(),
|
||||
jobMap.size(),
|
||||
elapsedMs(startedAt, finishedAt),
|
||||
elapsedMs(startedAt, resultRowsLoadedAt),
|
||||
elapsedMs(resultRowsLoadedAt, tasksLoadedAt),
|
||||
elapsedMs(tasksLoadedAt, jobsLoadedAt),
|
||||
elapsedMs(jobsLoadedAt, finishedAt));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@@ -967,6 +1004,10 @@ public class ProductRiskTaskService {
|
||||
}
|
||||
|
||||
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) {
|
||||
return toHistoryItemVo(entity, taskStatus, null, true);
|
||||
}
|
||||
|
||||
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus, TaskFileJobEntity job, boolean mergeRequestFields) {
|
||||
ProductRiskResultItemVo vo = new ProductRiskResultItemVo();
|
||||
vo.setResultId(entity.getId());
|
||||
vo.setTaskId(entity.getTaskId());
|
||||
@@ -978,16 +1019,19 @@ public class ProductRiskTaskService {
|
||||
vo.setError(entity.getErrorMessage());
|
||||
vo.setOutputFilename(entity.getResultFilename());
|
||||
vo.setDownloadUrl(null);
|
||||
attachFileJobState(vo, entity);
|
||||
attachFileJobState(vo, entity, job);
|
||||
vo.setMatched(true);
|
||||
if (entity.getTaskId() != null) {
|
||||
if (mergeRequestFields && entity.getTaskId() != null) {
|
||||
mergeQueueFieldsFromRequest(vo, entity.getTaskId());
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private void attachFileJobState(ProductRiskResultItemVo vo, FileResultEntity entity) {
|
||||
TaskFileJobEntity job = taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId());
|
||||
attachFileJobState(vo, entity, taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId()));
|
||||
}
|
||||
|
||||
private void attachFileJobState(ProductRiskResultItemVo vo, FileResultEntity entity, TaskFileJobEntity job) {
|
||||
vo.setFileReady(entity.getResultFileUrl() != null && !entity.getResultFileUrl().isBlank());
|
||||
if (job == null) {
|
||||
vo.setFileStatus(Boolean.TRUE.equals(vo.getFileReady()) ? "SUCCESS" : null);
|
||||
@@ -1031,6 +1075,10 @@ public class ProductRiskTaskService {
|
||||
return t == null ? null : t.toString();
|
||||
}
|
||||
|
||||
private static long elapsedMs(long startInclusive, long endExclusive) {
|
||||
return Math.max(0L, (endExclusive - startInclusive) / 1_000_000L);
|
||||
}
|
||||
|
||||
private List<ProductRiskShopQueueItemVo> dedupeQueueItems(List<ProductRiskShopQueueItemVo> raw) {
|
||||
Map<String, ProductRiskShopQueueItemVo> byNorm = new LinkedHashMap<>();
|
||||
for (ProductRiskShopQueueItemVo item : raw) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.queryasin.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
|
||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
|
||||
@@ -23,7 +24,6 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -37,7 +37,6 @@ 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;
|
||||
|
||||
@@ -269,10 +268,8 @@ public class QueryAsinTaskController {
|
||||
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);
|
||||
DownloadHeaderUtil.setAttachment(response, filename);
|
||||
try (InputStream in = URI.create(url).toURL().openStream()) {
|
||||
byte[] buffer = new byte[65536];
|
||||
int read;
|
||||
|
||||
@@ -135,27 +135,58 @@ public class QueryAsinTaskService {
|
||||
}
|
||||
|
||||
public QueryAsinHistoryVo listHistory(Long userId) {
|
||||
long startedAt = System.nanoTime();
|
||||
validateUserId(userId);
|
||||
QueryAsinHistoryVo vo = new QueryAsinHistoryVo();
|
||||
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.select(FileResultEntity::getId,
|
||||
FileResultEntity::getTaskId,
|
||||
FileResultEntity::getModuleType,
|
||||
FileResultEntity::getSourceFilename,
|
||||
FileResultEntity::getSourceFileUrl,
|
||||
FileResultEntity::getResultFilename,
|
||||
FileResultEntity::getResultFileUrl,
|
||||
FileResultEntity::getSuccess,
|
||||
FileResultEntity::getErrorMessage,
|
||||
FileResultEntity::getUserId,
|
||||
FileResultEntity::getCreatedAt)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileResultEntity::getUserId, userId)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||
.last("limit 100"));
|
||||
long resultRowsLoadedAt = System.nanoTime();
|
||||
if (entities.isEmpty()) {
|
||||
vo.setItems(List.of());
|
||||
log.info("[query-asin] history timing userId={} rows=0 totalMs={} resultQueryMs={} taskQueryMs=0 jobQueryMs=0 buildMs=0",
|
||||
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
|
||||
return vo;
|
||||
}
|
||||
|
||||
Map<Long, FileTaskEntity> taskMap = loadTaskMap(entities);
|
||||
Map<Long, Map<Long, QueryAsinResultItemVo>> snapshotMap = buildSnapshotMap(taskMap);
|
||||
Map<Long, FileTaskEntity> taskMap = loadHistoryTaskMap(entities);
|
||||
long tasksLoadedAt = System.nanoTime();
|
||||
Map<Long, TaskFileJobEntity> jobMap = taskFileJobService.findAssembleJobsByResultIds(MODULE_TYPE, entities.stream()
|
||||
.map(FileResultEntity::getId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList());
|
||||
long jobsLoadedAt = System.nanoTime();
|
||||
List<QueryAsinResultItemVo> items = new ArrayList<>();
|
||||
for (FileResultEntity entity : entities) {
|
||||
FileTaskEntity task = taskMap.get(entity.getTaskId());
|
||||
QueryAsinResultItemVo snapshot = snapshotMap.getOrDefault(entity.getTaskId(), Map.of()).get(entity.getId());
|
||||
items.add(toHistoryItem(entity, task, snapshot));
|
||||
items.add(toHistoryItem(entity, task, null, jobMap.get(entity.getId())));
|
||||
}
|
||||
vo.setItems(items);
|
||||
long finishedAt = System.nanoTime();
|
||||
log.info("[query-asin] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
||||
userId,
|
||||
entities.size(),
|
||||
taskMap.size(),
|
||||
jobMap.size(),
|
||||
elapsedMs(startedAt, finishedAt),
|
||||
elapsedMs(startedAt, resultRowsLoadedAt),
|
||||
elapsedMs(resultRowsLoadedAt, tasksLoadedAt),
|
||||
elapsedMs(tasksLoadedAt, jobsLoadedAt),
|
||||
elapsedMs(jobsLoadedAt, finishedAt));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@@ -510,6 +541,31 @@ public class QueryAsinTaskService {
|
||||
return taskMap;
|
||||
}
|
||||
|
||||
private Map<Long, FileTaskEntity> loadHistoryTaskMap(List<FileResultEntity> entities) {
|
||||
List<Long> taskIds = entities.stream()
|
||||
.map(FileResultEntity::getTaskId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
|
||||
if (taskIds.isEmpty()) {
|
||||
return taskMap;
|
||||
}
|
||||
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
|
||||
for (int start = 0; start < taskIds.size(); start += batchSize) {
|
||||
int end = Math.min(start + batchSize, taskIds.size());
|
||||
List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.select(FileTaskEntity::getId, FileTaskEntity::getStatus, FileTaskEntity::getFinishedAt)
|
||||
.in(FileTaskEntity::getId, taskIds.subList(start, end)));
|
||||
for (FileTaskEntity task : tasks) {
|
||||
if (task != null) {
|
||||
taskMap.put(task.getId(), task);
|
||||
}
|
||||
}
|
||||
}
|
||||
return taskMap;
|
||||
}
|
||||
|
||||
private Map<Long, Map<Long, QueryAsinResultItemVo>> buildSnapshotMap(Map<Long, FileTaskEntity> taskMap) {
|
||||
Map<Long, Map<Long, QueryAsinResultItemVo>> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<Long, FileTaskEntity> entry : taskMap.entrySet()) {
|
||||
@@ -523,6 +579,10 @@ public class QueryAsinTaskService {
|
||||
}
|
||||
|
||||
private QueryAsinResultItemVo toHistoryItem(FileResultEntity entity, FileTaskEntity task, QueryAsinResultItemVo snapshot) {
|
||||
return toHistoryItem(entity, task, snapshot, null);
|
||||
}
|
||||
|
||||
private QueryAsinResultItemVo toHistoryItem(FileResultEntity entity, FileTaskEntity task, QueryAsinResultItemVo snapshot, TaskFileJobEntity job) {
|
||||
QueryAsinResultItemVo item = snapshot != null ? snapshot : new QueryAsinResultItemVo();
|
||||
item.setResultId(entity.getId());
|
||||
item.setTaskId(entity.getTaskId());
|
||||
@@ -535,7 +595,7 @@ public class QueryAsinTaskService {
|
||||
item.setFinishedAt(task != null ? task.getFinishedAt() : item.getFinishedAt());
|
||||
item.setOutputFilename(firstNonBlank(item.getOutputFilename(), entity.getResultFilename()));
|
||||
item.setDownloadUrl(null);
|
||||
attachFileJobState(item, entity);
|
||||
attachFileJobState(item, entity, job);
|
||||
if (item.getCountryResults() == null) {
|
||||
item.setCountryResults(new ArrayList<>());
|
||||
}
|
||||
@@ -546,7 +606,10 @@ public class QueryAsinTaskService {
|
||||
}
|
||||
|
||||
private void attachFileJobState(QueryAsinResultItemVo item, FileResultEntity entity) {
|
||||
TaskFileJobEntity job = taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId());
|
||||
attachFileJobState(item, entity, taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId()));
|
||||
}
|
||||
|
||||
private void attachFileJobState(QueryAsinResultItemVo item, FileResultEntity entity, TaskFileJobEntity job) {
|
||||
item.setFileReady(!blank(entity.getResultFileUrl()));
|
||||
if (job == null) {
|
||||
item.setFileStatus(Boolean.TRUE.equals(item.getFileReady()) ? "SUCCESS" : null);
|
||||
@@ -1061,6 +1124,10 @@ public class QueryAsinTaskService {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static long elapsedMs(long startInclusive, long endExclusive) {
|
||||
return Math.max(0L, (endExclusive - startInclusive) / 1_000_000L);
|
||||
}
|
||||
|
||||
private String blankToNull(String value) {
|
||||
return blank(value) ? null : value.trim();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.shopmatch.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
||||
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;
|
||||
@@ -23,7 +24,6 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -38,7 +38,6 @@ 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;
|
||||
|
||||
@@ -193,10 +192,8 @@ public class ShopMatchController {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No downloadable result");
|
||||
}
|
||||
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);
|
||||
DownloadHeaderUtil.setAttachment(response, filename);
|
||||
try (InputStream in = URI.create(url).toURL().openStream()) {
|
||||
byte[] buffer = new byte[65536];
|
||||
int read;
|
||||
|
||||
@@ -173,15 +173,34 @@ public class ShopMatchTaskService {
|
||||
}
|
||||
|
||||
public ProductRiskHistoryVo listHistory(Long userId) {
|
||||
long startedAt = System.nanoTime();
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
}
|
||||
ProductRiskHistoryVo vo = new ProductRiskHistoryVo();
|
||||
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.select(FileResultEntity::getId,
|
||||
FileResultEntity::getTaskId,
|
||||
FileResultEntity::getModuleType,
|
||||
FileResultEntity::getSourceFilename,
|
||||
FileResultEntity::getSourceFileUrl,
|
||||
FileResultEntity::getResultFilename,
|
||||
FileResultEntity::getResultFileUrl,
|
||||
FileResultEntity::getSuccess,
|
||||
FileResultEntity::getErrorMessage,
|
||||
FileResultEntity::getUserId,
|
||||
FileResultEntity::getCreatedAt)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileResultEntity::getUserId, userId)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||
.last("limit 100"));
|
||||
long resultRowsLoadedAt = System.nanoTime();
|
||||
if (entities.isEmpty()) {
|
||||
vo.setItems(List.of());
|
||||
log.info("[shop-match] history timing userId={} rows=0 totalMs={} resultQueryMs={} taskQueryMs=0 jobQueryMs=0 buildMs=0",
|
||||
userId, elapsedMs(startedAt, resultRowsLoadedAt), elapsedMs(startedAt, resultRowsLoadedAt));
|
||||
return vo;
|
||||
}
|
||||
Map<Long, String> statusByTaskId = new LinkedHashMap<>();
|
||||
List<Long> taskIds = entities.stream()
|
||||
.map(FileResultEntity::getTaskId)
|
||||
@@ -195,11 +214,29 @@ public class ShopMatchTaskService {
|
||||
}
|
||||
}
|
||||
}
|
||||
long tasksLoadedAt = System.nanoTime();
|
||||
Map<Long, TaskFileJobEntity> jobMap = taskFileJobService.findAssembleJobsByResultIds(MODULE_TYPE, entities.stream()
|
||||
.map(FileResultEntity::getId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList());
|
||||
long jobsLoadedAt = System.nanoTime();
|
||||
List<ProductRiskResultItemVo> items = new ArrayList<>();
|
||||
for (FileResultEntity entity : entities) {
|
||||
items.add(toHistoryItemVo(entity, statusByTaskId.get(entity.getTaskId())));
|
||||
items.add(toHistoryItemVo(entity, statusByTaskId.get(entity.getTaskId()), jobMap.get(entity.getId()), false));
|
||||
}
|
||||
vo.setItems(items);
|
||||
long finishedAt = System.nanoTime();
|
||||
log.info("[shop-match] history timing userId={} rows={} tasks={} jobs={} totalMs={} resultQueryMs={} taskQueryMs={} jobQueryMs={} buildMs={}",
|
||||
userId,
|
||||
entities.size(),
|
||||
statusByTaskId.size(),
|
||||
jobMap.size(),
|
||||
elapsedMs(startedAt, finishedAt),
|
||||
elapsedMs(startedAt, resultRowsLoadedAt),
|
||||
elapsedMs(resultRowsLoadedAt, tasksLoadedAt),
|
||||
elapsedMs(tasksLoadedAt, jobsLoadedAt),
|
||||
elapsedMs(jobsLoadedAt, finishedAt));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@@ -867,6 +904,10 @@ public class ShopMatchTaskService {
|
||||
}
|
||||
|
||||
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) {
|
||||
return toHistoryItemVo(entity, taskStatus, null, true);
|
||||
}
|
||||
|
||||
private ProductRiskResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus, TaskFileJobEntity job, boolean mergeRequestFields) {
|
||||
ProductRiskResultItemVo vo = new ProductRiskResultItemVo();
|
||||
vo.setResultId(entity.getId());
|
||||
vo.setTaskId(entity.getTaskId());
|
||||
@@ -878,16 +919,19 @@ public class ShopMatchTaskService {
|
||||
vo.setError(entity.getErrorMessage());
|
||||
vo.setOutputFilename(entity.getResultFilename());
|
||||
vo.setDownloadUrl(null);
|
||||
attachFileJobState(vo, entity);
|
||||
attachFileJobState(vo, entity, job);
|
||||
vo.setMatched(true);
|
||||
if (entity.getTaskId() != null) {
|
||||
if (mergeRequestFields && entity.getTaskId() != null) {
|
||||
mergeQueueFieldsFromRequest(vo, entity.getTaskId());
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private void attachFileJobState(ProductRiskResultItemVo vo, FileResultEntity entity) {
|
||||
TaskFileJobEntity job = taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId());
|
||||
attachFileJobState(vo, entity, taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId()));
|
||||
}
|
||||
|
||||
private void attachFileJobState(ProductRiskResultItemVo vo, FileResultEntity entity, TaskFileJobEntity job) {
|
||||
vo.setFileReady(entity.getResultFileUrl() != null && !entity.getResultFileUrl().isBlank());
|
||||
if (job == null) {
|
||||
vo.setFileStatus(Boolean.TRUE.equals(vo.getFileReady()) ? "SUCCESS" : null);
|
||||
@@ -1031,6 +1075,10 @@ public class ShopMatchTaskService {
|
||||
return time == null ? null : time.toString();
|
||||
}
|
||||
|
||||
private static long elapsedMs(long startInclusive, long endExclusive) {
|
||||
return Math.max(0L, (endExclusive - startInclusive) / 1_000_000L);
|
||||
}
|
||||
|
||||
private LocalDateTime now() {
|
||||
return LocalDateTime.now(BUSINESS_ZONE);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,990 @@
|
||||
package com.nanri.aiimage.modules.similarasin.client;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StreamUtils;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SimilarAsinCozeClient {
|
||||
|
||||
private final SimilarAsinProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public List<SimilarAsinResultRowDto> inspect(List<SimilarAsinResultRowDto> rows, String prompt) {
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
if (properties.getCozeToken() == null || properties.getCozeToken().isBlank()) {
|
||||
log.warn("[similar-asin] coze token not configured, keep raw rows size={}", rows.size());
|
||||
return rows.stream().map(this::copy).toList();
|
||||
}
|
||||
try {
|
||||
return inspectWithFallback(rows, prompt);
|
||||
} catch (Exception ex) {
|
||||
String failureMessage = failureMessage(ex);
|
||||
log.warn("[similar-asin] coze batch failed size={} err={}", rows.size(), failureMessage);
|
||||
return rows.stream().map(this::copy).map(row -> markFailed(row, failureMessage)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
public CozeSubmitResponse submitWorkflow(List<SimilarAsinResultRowDto> rows, String prompt) throws Exception {
|
||||
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt));
|
||||
ensureSuccess(submitRoot);
|
||||
return new CozeSubmitResponse(
|
||||
extractExecuteId(submitRoot),
|
||||
extractResultDataText(submitRoot),
|
||||
writeJson(submitRoot)
|
||||
);
|
||||
}
|
||||
|
||||
public CozePollResponse pollWorkflow(String executeId) throws Exception {
|
||||
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId));
|
||||
ensureSuccess(pollRoot);
|
||||
String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT);
|
||||
String dataText = extractResultDataText(pollRoot);
|
||||
String outputText = dataText.isBlank() ? extractWorkflowOutputText(pollRoot) : "";
|
||||
String failureMessage = isFailedWorkflowStatus(status)
|
||||
? firstNonBlank(resolveFailureMessage(pollRoot), "Coze async workflow failed")
|
||||
: "";
|
||||
return new CozePollResponse(executeId, status, dataText, outputText, failureMessage, writeJson(pollRoot));
|
||||
}
|
||||
|
||||
public List<SimilarAsinResultRowDto> mergeRowsFromDataText(List<SimilarAsinResultRowDto> rows, String dataText) throws Exception {
|
||||
if (dataText == null || dataText.isBlank()) {
|
||||
return rows == null ? List.of() : rows.stream().map(this::copy).toList();
|
||||
}
|
||||
return mergeRows(rows, parseResults(wrapDataPayload(dataText)));
|
||||
}
|
||||
|
||||
public List<SimilarAsinResultRowDto> markRowsFailed(List<SimilarAsinResultRowDto> rows, String failureMessage) {
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return rows.stream().map(this::copy).map(row -> markFailed(row, failureMessage)).toList();
|
||||
}
|
||||
|
||||
private List<SimilarAsinResultRowDto> inspectWithFallback(List<SimilarAsinResultRowDto> rows, String prompt) {
|
||||
try {
|
||||
if (rows.size() == 1) {
|
||||
return inspectSingleRowWithRetry(rows, prompt);
|
||||
}
|
||||
InspectAttempt attempt = inspectOnce(rows, prompt);
|
||||
if (attempt.resolvedCount() < rows.size()) {
|
||||
throw new PartialCozeResultException(attempt.resolvedCount(), rows.size(), attempt.rawResultCount());
|
||||
}
|
||||
return attempt.mergedRows();
|
||||
} catch (Exception ex) {
|
||||
if (shouldSplitBatch(rows, ex)) {
|
||||
int middle = rows.size() / 2;
|
||||
log.warn("[similar-asin] coze batch fallback split size={} left={} right={} err={}",
|
||||
rows.size(), middle, rows.size() - middle, failureMessage(ex));
|
||||
List<SimilarAsinResultRowDto> merged = new ArrayList<>(rows.size());
|
||||
merged.addAll(inspectPartitionWithFailureFallback(rows.subList(0, middle), prompt));
|
||||
merged.addAll(inspectPartitionWithFailureFallback(rows.subList(middle, rows.size()), prompt));
|
||||
return merged;
|
||||
}
|
||||
throw propagate(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private List<SimilarAsinResultRowDto> inspectPartitionWithFailureFallback(List<SimilarAsinResultRowDto> rows, String prompt) {
|
||||
try {
|
||||
return inspectWithFallback(rows, prompt);
|
||||
} catch (Exception ex) {
|
||||
String failureMessage = failureMessage(ex);
|
||||
log.warn("[similar-asin] coze partition failed size={} err={}", rows.size(), failureMessage);
|
||||
return rows.stream().map(this::copy).map(row -> markFailed(row, failureMessage)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
private List<SimilarAsinResultRowDto> inspectSingleRowWithRetry(List<SimilarAsinResultRowDto> rows, String prompt) throws Exception {
|
||||
SimilarAsinResultRowDto row = rows.getFirst();
|
||||
PartialCozeResultException lastFailure = null;
|
||||
for (int attemptIndex = 1; attemptIndex <= 3; attemptIndex++) {
|
||||
try {
|
||||
InspectAttempt attempt = inspectOnce(rows, prompt);
|
||||
if (attempt.resolvedCount() == rows.size()) {
|
||||
return attempt.mergedRows();
|
||||
}
|
||||
log.warn("[similar-asin] coze single unresolved attempt={} rowId={} asin={} country={} title={} url={} raw={}",
|
||||
attemptIndex,
|
||||
row.getId(),
|
||||
row.getAsin(),
|
||||
row.getCountry(),
|
||||
abbreviate(row.getTitle(), 120),
|
||||
abbreviate(row.getUrl(), 120),
|
||||
abbreviate(attempt.raw(), 500));
|
||||
lastFailure = new PartialCozeResultException(attempt.resolvedCount(), rows.size(), attempt.rawResultCount());
|
||||
} catch (Exception ex) {
|
||||
if (attemptIndex >= 3 || !isRetryableBatchFailure(ex)) {
|
||||
throw ex;
|
||||
}
|
||||
log.warn("[similar-asin] coze single retryable failure attempt={} rowId={} asin={} country={} err={}",
|
||||
attemptIndex,
|
||||
row.getId(),
|
||||
row.getAsin(),
|
||||
row.getCountry(),
|
||||
failureMessage(ex));
|
||||
}
|
||||
if (attemptIndex < 3) {
|
||||
sleepBeforeRetry(attemptIndex);
|
||||
}
|
||||
}
|
||||
throw lastFailure == null ? new PartialCozeResultException(0, rows.size(), 0) : lastFailure;
|
||||
}
|
||||
|
||||
private InspectAttempt inspectOnce(List<SimilarAsinResultRowDto> rows, String prompt) throws Exception {
|
||||
String raw = runWorkflowAsyncAndWait(rows, prompt);
|
||||
List<CozeResult> results = parseResults(raw);
|
||||
if (rows.size() > 1 && !results.isEmpty() && results.stream().noneMatch(this::hasIdentity)) {
|
||||
throw new PartialCozeResultException(0, rows.size(), results.size());
|
||||
}
|
||||
List<SimilarAsinResultRowDto> merged = mergeRows(rows, results);
|
||||
return new InspectAttempt(raw, merged, resolvedCount(merged), results.size());
|
||||
}
|
||||
|
||||
private String runWorkflowAsyncAndWait(List<SimilarAsinResultRowDto> rows, String prompt) throws Exception {
|
||||
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt));
|
||||
ensureSuccess(submitRoot);
|
||||
|
||||
String immediateData = extractResultDataText(submitRoot);
|
||||
if (!immediateData.isBlank()) {
|
||||
return wrapDataPayload(immediateData);
|
||||
}
|
||||
|
||||
String executeId = extractExecuteId(submitRoot);
|
||||
if (executeId == null || executeId.isBlank()) {
|
||||
throw new IllegalStateException("Coze async execute_id missing");
|
||||
}
|
||||
|
||||
long deadline = System.currentTimeMillis() + Math.max(10000, properties.getCozePollTimeoutMillis());
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
ensureNotInterrupted();
|
||||
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId));
|
||||
ensureSuccess(pollRoot);
|
||||
|
||||
String dataText = extractResultDataText(pollRoot);
|
||||
if (!dataText.isBlank()) {
|
||||
return wrapDataPayload(dataText);
|
||||
}
|
||||
|
||||
String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT);
|
||||
if (isFailedWorkflowStatus(status)) {
|
||||
throw new IllegalStateException(firstNonBlank(resolveFailureMessage(pollRoot), "Coze async workflow failed"));
|
||||
}
|
||||
if (isSuccessfulWorkflowStatus(status)) {
|
||||
String outputText = extractWorkflowOutputText(pollRoot);
|
||||
if (!outputText.isBlank()) {
|
||||
return wrapDataPayload(outputText);
|
||||
}
|
||||
throw new IllegalStateException("Coze async workflow completed without output");
|
||||
}
|
||||
sleepQuietly(Math.max(200, properties.getCozePollIntervalMillis()));
|
||||
}
|
||||
|
||||
throw new IllegalStateException("Coze async workflow poll timeout");
|
||||
}
|
||||
|
||||
private String postWorkflow(List<SimilarAsinResultRowDto> rows, String prompt) {
|
||||
Map<String, Object> parameters = buildParameters(rows, prompt);
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("workflow_id", properties.getCozeWorkflowId());
|
||||
body.put("parameters", parameters);
|
||||
body.put("is_async", Boolean.TRUE);
|
||||
log.info("[similar-asin] coze request url={} body={}",
|
||||
joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()),
|
||||
writeJson(body));
|
||||
|
||||
RestClient.RequestBodySpec request = restClient().post()
|
||||
.uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()))
|
||||
.headers(headers -> {
|
||||
headers.setBearerAuth(stripBearer(properties.getCozeToken()));
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
});
|
||||
request.body(body);
|
||||
return request.exchange((clientRequest, clientResponse) -> {
|
||||
byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody());
|
||||
String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
|
||||
log.info("[similar-asin] coze submit response status={} body={}",
|
||||
clientResponse.getStatusCode(),
|
||||
responseText);
|
||||
return responseText;
|
||||
});
|
||||
}
|
||||
|
||||
private String getWorkflowHistory(String executeId) {
|
||||
String path = properties.getCozeWorkflowHistoryPath()
|
||||
.replace("{workflow_id}", properties.getCozeWorkflowId())
|
||||
.replace("{execute_id}", executeId);
|
||||
return restClient().get()
|
||||
.uri(joinUrl(properties.getCozeBaseUrl(), path))
|
||||
.headers(headers -> {
|
||||
headers.setBearerAuth(stripBearer(properties.getCozeToken()));
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
})
|
||||
.exchange((clientRequest, clientResponse) -> {
|
||||
byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody());
|
||||
String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
|
||||
log.info("[similar-asin] coze history response executeId={} status={} body={}",
|
||||
executeId,
|
||||
clientResponse.getStatusCode(),
|
||||
responseText);
|
||||
return responseText;
|
||||
});
|
||||
}
|
||||
|
||||
private Map<String, Object> buildParameters(List<SimilarAsinResultRowDto> rows, String prompt) {
|
||||
List<String> groupKeys = rows.stream().map(row -> nonBlank(row.getGroupKey(), rowKey(row))).toList();
|
||||
List<String> rowIds = rows.stream().map(row -> nonBlank(row.getId(), "")).toList();
|
||||
List<String> asins = rows.stream().map(row -> nonBlank(row.getAsin(), "")).toList();
|
||||
List<String> countries = rows.stream().map(row -> nonBlank(row.getCountry(), "")).toList();
|
||||
List<String> titles = rows.stream().map(row -> nonBlank(row.getTitle(), row.getAsin())).toList();
|
||||
List<String> urls = rows.stream().map(row -> nonBlank(row.getUrl(), "")).toList();
|
||||
List<List<String>> urlLists = rows.stream().map(SimilarAsinResultRowDto::getUrls).toList();
|
||||
|
||||
Map<String, Object> parameters = new LinkedHashMap<>();
|
||||
parameters.put("title_list", titles);
|
||||
parameters.put("url_list", urls);
|
||||
parameters.put("url_lists", urlLists);
|
||||
parameters.put("items", buildItemObjects(rows, groupKeys, rowIds, asins, countries, titles, urlLists));
|
||||
parameters.put("prompt", prompt == null ? "" : prompt);
|
||||
return parameters;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildItemObjects(List<SimilarAsinResultRowDto> rows,
|
||||
List<String> groupKeys,
|
||||
List<String> rowIds,
|
||||
List<String> asins,
|
||||
List<String> countries,
|
||||
List<String> titles,
|
||||
List<List<String>> urlLists) {
|
||||
List<Map<String, Object>> items = new ArrayList<>(rows.size());
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("group_key", groupKeys.get(i));
|
||||
item.put("row_id", rowIds.get(i));
|
||||
item.put("asin", asins.get(i));
|
||||
item.put("country", countries.get(i));
|
||||
item.put("title", titles.get(i));
|
||||
item.put("url", urlLists.get(i));
|
||||
item.put("urls", urlLists.get(i));
|
||||
items.add(item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private List<CozeResult> parseResults(String raw) throws Exception {
|
||||
JsonNode root = objectMapper.readTree(raw);
|
||||
ensureSuccess(root);
|
||||
String dataText = extractResultDataText(root);
|
||||
if (dataText.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
JsonNode dataRoot = objectMapper.readTree(dataText);
|
||||
JsonNode array = dataRoot.isArray() ? dataRoot : dataRoot.path("data");
|
||||
List<CozeResult> results = new ArrayList<>();
|
||||
if (array.isArray()) {
|
||||
for (JsonNode node : array) {
|
||||
JsonNode itemNode = resultItemNode(node);
|
||||
results.add(new CozeResult(
|
||||
text(firstNonNull(
|
||||
firstNonNull(node.get("group_key"), node.get("groupKey")),
|
||||
firstNonNull(itemNode.get("group_key"), itemNode.get("groupKey")))),
|
||||
text(firstNonNull(
|
||||
firstNonNull(node.get("row_id"), firstNonNull(node.get("rowId"), node.get("id"))),
|
||||
firstNonNull(itemNode.get("row_id"), firstNonNull(itemNode.get("rowId"), itemNode.get("id"))))),
|
||||
text(firstNonNull(node.get("asin"), itemNode.get("asin"))),
|
||||
text(firstNonNull(
|
||||
firstNonNull(node.get("country"), node.get("site")),
|
||||
firstNonNull(itemNode.get("country"), itemNode.get("site")))),
|
||||
text(firstNonNull(node.get("title"), firstNonNull(itemNode.get("title"), itemNode.get("title_risk")))),
|
||||
text(firstNonNull(node.get("appearance"),
|
||||
firstNonNull(node.get("appearance_risk"),
|
||||
firstNonNull(itemNode.get("appearance"), itemNode.get("appearance_risk"))))),
|
||||
text(firstNonNull(firstNonNull(node.get("patent"), node.get("patent ")),
|
||||
firstNonNull(node.get("patent_risk"),
|
||||
firstNonNull(firstNonNull(itemNode.get("patent"), itemNode.get("patent ")), itemNode.get("patent_risk"))))),
|
||||
text(firstNonNull(node.get("result"),
|
||||
firstNonNull(node.get("conclusion"),
|
||||
firstNonNull(itemNode.get("result"), itemNode.get("conclusion"))))),
|
||||
text(firstNonNull(node.get("title_reason"),
|
||||
firstNonNull(node.get("titleReason"),
|
||||
firstNonNull(itemNode.get("title_reason"), itemNode.get("titleReason"))))),
|
||||
text(firstNonNull(node.get("appearance_reason"),
|
||||
firstNonNull(node.get("appearanceReason"),
|
||||
firstNonNull(itemNode.get("appearance_reason"), itemNode.get("appearanceReason"))))),
|
||||
text(firstNonNull(node.get("patent_reason"),
|
||||
firstNonNull(firstNonNull(node.get("patentReason"), node.get("patent reason")),
|
||||
firstNonNull(itemNode.get("patent_reason"),
|
||||
firstNonNull(itemNode.get("patentReason"), itemNode.get("patent reason"))))))
|
||||
));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private JsonNode resultItemNode(JsonNode node) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return objectMapper.missingNode();
|
||||
}
|
||||
JsonNode item = firstNonNull(node.get("item"), node.get("items"));
|
||||
if (item == null || item.isMissingNode() || item.isNull()) {
|
||||
return objectMapper.missingNode();
|
||||
}
|
||||
if (item.isArray()) {
|
||||
return item.isEmpty() ? objectMapper.missingNode() : item.get(0);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
private List<SimilarAsinResultRowDto> mergeRows(List<SimilarAsinResultRowDto> rows, List<CozeResult> results) {
|
||||
Map<String, CozeResult> resultByGroupKey = new LinkedHashMap<>();
|
||||
Map<String, CozeResult> resultByCompositeKey = new LinkedHashMap<>();
|
||||
Map<String, CozeResult> resultByAsinCountry = new LinkedHashMap<>();
|
||||
Map<String, CozeResult> resultByAsin = new LinkedHashMap<>();
|
||||
Map<String, CozeResult> resultByRowId = new LinkedHashMap<>();
|
||||
for (CozeResult result : results) {
|
||||
String groupKey = normalize(result.groupKey());
|
||||
if (!groupKey.isBlank()) {
|
||||
resultByGroupKey.putIfAbsent(groupKey, result);
|
||||
}
|
||||
String compositeKey = rowKey(result.rowId(), result.asin(), result.country());
|
||||
if (!compositeKey.isBlank()) {
|
||||
resultByCompositeKey.putIfAbsent(compositeKey, result);
|
||||
}
|
||||
String asinCountryKey = asinCountryKey(result.asin(), result.country());
|
||||
if (!asinCountryKey.isBlank()) {
|
||||
resultByAsinCountry.putIfAbsent(asinCountryKey, result);
|
||||
}
|
||||
String asinKey = normalize(result.asin()).toUpperCase(Locale.ROOT);
|
||||
if (!asinKey.isBlank()) {
|
||||
resultByAsin.putIfAbsent(asinKey, result);
|
||||
}
|
||||
String rowIdKey = normalize(result.rowId());
|
||||
if (!rowIdKey.isBlank()) {
|
||||
resultByRowId.putIfAbsent(rowIdKey, result);
|
||||
}
|
||||
}
|
||||
|
||||
List<SimilarAsinResultRowDto> merged = new ArrayList<>(rows.size());
|
||||
boolean allowIndexFallback = results.size() == rows.size() && results.stream().noneMatch(this::hasIdentity);
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
SimilarAsinResultRowDto row = copy(rows.get(i));
|
||||
CozeResult result = resultByGroupKey.get(normalize(row.getGroupKey()));
|
||||
if (result == null) {
|
||||
result = resultByCompositeKey.get(rowKey(row.getId(), row.getAsin(), row.getCountry()));
|
||||
}
|
||||
if (result == null) {
|
||||
result = resultByAsinCountry.get(asinCountryKey(row.getAsin(), row.getCountry()));
|
||||
}
|
||||
if (result == null) {
|
||||
result = resultByAsin.get(normalize(row.getAsin()).toUpperCase(Locale.ROOT));
|
||||
}
|
||||
if (result == null) {
|
||||
result = resultByRowId.get(normalize(row.getId()));
|
||||
}
|
||||
if (result == null && allowIndexFallback && i < results.size()) {
|
||||
result = results.get(i);
|
||||
}
|
||||
applyResult(row, result);
|
||||
merged.add(row);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
private String asinCountryKey(String asin, String country) {
|
||||
String normalizedAsin = normalize(asin).toUpperCase(Locale.ROOT);
|
||||
if (normalizedAsin.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
return normalizedAsin + "::" + normalize(country);
|
||||
}
|
||||
|
||||
private boolean hasIdentity(CozeResult result) {
|
||||
if (result == null) {
|
||||
return false;
|
||||
}
|
||||
return !normalize(result.groupKey()).isBlank()
|
||||
|| !normalize(result.rowId()).isBlank()
|
||||
|| !normalize(result.asin()).isBlank();
|
||||
}
|
||||
|
||||
private void applyResult(SimilarAsinResultRowDto row, CozeResult result) {
|
||||
if (row == null || result == null) {
|
||||
return;
|
||||
}
|
||||
row.setTitleRisk(result.title());
|
||||
row.setAppearanceRisk(result.appearance());
|
||||
row.setPatentRisk(result.patent());
|
||||
row.setConclusion(result.result());
|
||||
row.setTitleReason(result.titleReason());
|
||||
row.setAppearanceReason(result.appearanceReason());
|
||||
row.setPatentReason(result.patentReason());
|
||||
}
|
||||
|
||||
private RestClient restClient() {
|
||||
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
||||
requestFactory.setConnectTimeout(properties.getCozeConnectTimeoutMillis());
|
||||
requestFactory.setReadTimeout(properties.getCozeReadTimeoutMillis());
|
||||
return RestClient.builder().requestFactory(requestFactory).build();
|
||||
}
|
||||
|
||||
private SimilarAsinResultRowDto copy(SimilarAsinResultRowDto source) {
|
||||
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||
row.setSourceFileKey(source.getSourceFileKey());
|
||||
row.setSourceFilename(source.getSourceFilename());
|
||||
row.setRowToken(source.getRowToken());
|
||||
row.setGroupKey(source.getGroupKey());
|
||||
row.setId(source.getId());
|
||||
row.setAsin(source.getAsin());
|
||||
row.setCountry(source.getCountry());
|
||||
row.setUrls(source.getUrls());
|
||||
row.setTitle(source.getTitle());
|
||||
row.setError(source.getError());
|
||||
row.setDone(source.getDone());
|
||||
row.setTitleRisk(source.getTitleRisk());
|
||||
row.setAppearanceRisk(source.getAppearanceRisk());
|
||||
row.setPatentRisk(source.getPatentRisk());
|
||||
row.setConclusion(source.getConclusion());
|
||||
row.setTitleReason(source.getTitleReason());
|
||||
row.setAppearanceReason(source.getAppearanceReason());
|
||||
row.setPatentReason(source.getPatentReason());
|
||||
return row;
|
||||
}
|
||||
|
||||
private SimilarAsinResultRowDto markFailed(SimilarAsinResultRowDto row, String failureMessage) {
|
||||
String reviewMessage = failureMessage == null || failureMessage.isBlank()
|
||||
? "Coze 检测失败,待人工复核"
|
||||
: "Coze 检测失败,待人工复核:" + failureMessage;
|
||||
if (row.getError() == null || row.getError().isBlank()) {
|
||||
row.setError(failureMessage);
|
||||
}
|
||||
if (row.getTitleRisk() == null || row.getTitleRisk().isBlank()) {
|
||||
row.setTitleRisk(reviewMessage);
|
||||
}
|
||||
if (row.getAppearanceRisk() == null || row.getAppearanceRisk().isBlank()) {
|
||||
row.setAppearanceRisk(reviewMessage);
|
||||
}
|
||||
if (row.getPatentRisk() == null || row.getPatentRisk().isBlank()) {
|
||||
row.setPatentRisk(reviewMessage);
|
||||
}
|
||||
if (row.getConclusion() == null || row.getConclusion().isBlank()) {
|
||||
row.setConclusion(failureMessage);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private boolean shouldSplitBatch(List<SimilarAsinResultRowDto> rows, Exception ex) {
|
||||
return rows != null && rows.size() > 1 && isRetryableBatchFailure(ex);
|
||||
}
|
||||
|
||||
private boolean isRetryableBatchFailure(Exception ex) {
|
||||
if (ex instanceof PartialCozeResultException) {
|
||||
return true;
|
||||
}
|
||||
String message = ex == null ? "" : nonBlank(ex.getMessage(), "");
|
||||
return message.contains("Workflow node execution limit exceeded")
|
||||
|| message.contains("Read timed out")
|
||||
|| message.contains("Connection reset")
|
||||
|| message.contains("I/O error on POST request")
|
||||
|| message.toLowerCase(Locale.ROOT).contains("timeout");
|
||||
}
|
||||
|
||||
private int resolvedCount(List<SimilarAsinResultRowDto> rows) {
|
||||
int resolved = 0;
|
||||
for (SimilarAsinResultRowDto row : rows) {
|
||||
if (hasResolvedCozeFields(row)) {
|
||||
resolved++;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private boolean hasResolvedCozeFields(SimilarAsinResultRowDto row) {
|
||||
if (row == null) {
|
||||
return false;
|
||||
}
|
||||
return !normalize(row.getTitleRisk()).isBlank()
|
||||
|| !normalize(row.getAppearanceRisk()).isBlank()
|
||||
|| !normalize(row.getPatentRisk()).isBlank()
|
||||
|| !normalize(row.getConclusion()).isBlank();
|
||||
}
|
||||
|
||||
private void ensureSuccess(JsonNode root) {
|
||||
if (root.path("code").asInt(-1) != 0) {
|
||||
throw new IllegalStateException(root.path("msg").asText("Coze response code is not 0"));
|
||||
}
|
||||
}
|
||||
|
||||
private String extractResultDataText(JsonNode root) {
|
||||
if (root == null || root.isMissingNode() || root.isNull()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
JsonNode dataNode = root.path("data");
|
||||
if (dataNode.isTextual()) {
|
||||
String value = dataNode.asText("");
|
||||
if (looksLikeResultDataPayload(value)) {
|
||||
return value;
|
||||
}
|
||||
return discoverEmbeddedData(parseJsonOrMissing(value));
|
||||
}
|
||||
if (dataNode.isObject()) {
|
||||
String nested = text(firstNonNull(dataNode.get("data"), firstNonNull(dataNode.get("output"), dataNode.get("result"))));
|
||||
if (nested != null && !nested.isBlank() && looksLikeResultDataPayload(nested)) {
|
||||
return nested;
|
||||
}
|
||||
JsonNode outputs = firstNonNull(dataNode.get("outputs"), dataNode.get("details"));
|
||||
String discovered = discoverEmbeddedData(outputs);
|
||||
if (!discovered.isBlank()) {
|
||||
return discovered;
|
||||
}
|
||||
}
|
||||
|
||||
return discoverEmbeddedData(root);
|
||||
}
|
||||
|
||||
private String extractWorkflowOutputText(JsonNode root) {
|
||||
JsonNode outputNode = findFirstField(root, "output");
|
||||
if (outputNode == null || outputNode.isNull() || outputNode.isMissingNode()) {
|
||||
return "";
|
||||
}
|
||||
if (!outputNode.isTextual()) {
|
||||
String discovered = discoverEmbeddedData(outputNode);
|
||||
return discovered.isBlank() && isResultDataPayload(outputNode) ? outputNode.toString() : discovered;
|
||||
}
|
||||
String output = normalize(outputNode.asText(""));
|
||||
if (output.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
if (looksLikeResultDataPayload(output)) {
|
||||
return output;
|
||||
}
|
||||
JsonNode parsedOutput = parseJsonOrMissing(output);
|
||||
String nestedOutput = text(firstNonNull(parsedOutput.get("Output"), parsedOutput.get("output")));
|
||||
if (nestedOutput != null && !nestedOutput.isBlank() && looksLikeResultDataPayload(nestedOutput)) {
|
||||
return nestedOutput;
|
||||
}
|
||||
return discoverEmbeddedData(parsedOutput);
|
||||
}
|
||||
|
||||
private String discoverEmbeddedData(JsonNode node) {
|
||||
if (node == null || node.isNull() || node.isMissingNode()) {
|
||||
return "";
|
||||
}
|
||||
if (node.isTextual()) {
|
||||
String value = node.asText("");
|
||||
if (looksLikeResultDataPayload(value)) {
|
||||
return value;
|
||||
}
|
||||
return discoverEmbeddedData(parseJsonOrMissing(value));
|
||||
}
|
||||
if (node.isArray()) {
|
||||
for (JsonNode child : node) {
|
||||
String discovered = discoverEmbeddedData(child);
|
||||
if (!discovered.isBlank()) {
|
||||
return discovered;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (node.isObject()) {
|
||||
if (isResultDataPayload(node)) {
|
||||
return node.toString();
|
||||
}
|
||||
for (java.util.Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
|
||||
Map.Entry<String, JsonNode> entry = it.next();
|
||||
String discovered = discoverEmbeddedData(entry.getValue());
|
||||
if (!discovered.isBlank()) {
|
||||
return discovered;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private boolean looksLikeResultDataPayload(String value) {
|
||||
JsonNode parsed = parseJsonOrMissing(value);
|
||||
return isResultDataPayload(parsed);
|
||||
}
|
||||
|
||||
private boolean isResultDataPayload(JsonNode node) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return false;
|
||||
}
|
||||
JsonNode array = node.path("data");
|
||||
if (!array.isArray()) {
|
||||
return false;
|
||||
}
|
||||
for (JsonNode item : array) {
|
||||
JsonNode itemNode = resultItemNode(item);
|
||||
if (hasResultFields(item) || hasResultFields(itemNode)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean hasResultFields(JsonNode item) {
|
||||
return item != null
|
||||
&& (item.has("appearance")
|
||||
|| item.has("appearance_risk")
|
||||
|| item.has("patent")
|
||||
|| item.has("patent ")
|
||||
|| item.has("patent_risk")
|
||||
|| item.has("result")
|
||||
|| item.has("conclusion")
|
||||
|| item.has("title_reason")
|
||||
|| item.has("titleReason")
|
||||
|| item.has("appearance_reason")
|
||||
|| item.has("appearanceReason")
|
||||
|| item.has("patent_reason")
|
||||
|| item.has("patentReason")
|
||||
|| item.has("patent reason"));
|
||||
}
|
||||
|
||||
private boolean isSuccessfulWorkflowStatus(String status) {
|
||||
String normalized = normalize(status).toUpperCase(Locale.ROOT);
|
||||
return normalized.equals("SUCCESS")
|
||||
|| normalized.equals("SUCCEEDED")
|
||||
|| normalized.equals("COMPLETED")
|
||||
|| normalized.equals("COMPLETE")
|
||||
|| normalized.equals("DONE");
|
||||
}
|
||||
|
||||
private boolean isFailedWorkflowStatus(String status) {
|
||||
String normalized = normalize(status).toUpperCase(Locale.ROOT);
|
||||
return normalized.contains("FAIL")
|
||||
|| normalized.contains("ERROR")
|
||||
|| normalized.contains("CANCEL");
|
||||
}
|
||||
|
||||
private JsonNode parseJsonOrMissing(String value) {
|
||||
String normalized = normalize(value);
|
||||
if (!(normalized.startsWith("{") || normalized.startsWith("["))) {
|
||||
return objectMapper.missingNode();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(normalized);
|
||||
} catch (Exception ignored) {
|
||||
return objectMapper.missingNode();
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveWorkflowStatus(JsonNode root) {
|
||||
JsonNode dataNode = root.path("data");
|
||||
JsonNode statusNode = firstNonNull(
|
||||
firstNonNull(dataNode.get("status"), dataNode.get("execute_status")),
|
||||
firstNonNull(root.get("status"), root.get("execute_status")));
|
||||
String status = text(statusNode);
|
||||
if (status != null && !status.isBlank()) {
|
||||
return status;
|
||||
}
|
||||
return findTextByFieldName(root, "execute_status", "status");
|
||||
}
|
||||
|
||||
private String resolveFailureMessage(JsonNode root) {
|
||||
JsonNode dataNode = root.path("data");
|
||||
String message = text(firstNonNull(dataNode.get("error_message"), firstNonNull(dataNode.get("msg"), root.get("msg"))));
|
||||
return message == null ? "" : message;
|
||||
}
|
||||
|
||||
private String extractExecuteId(JsonNode root) {
|
||||
return findTextByFieldName(root, "execute_id", "executeId");
|
||||
}
|
||||
|
||||
private JsonNode findFirstField(JsonNode node, String name) {
|
||||
if (node == null || node.isMissingNode() || node.isNull() || name == null || name.isBlank()) {
|
||||
return objectMapper.missingNode();
|
||||
}
|
||||
if (node.isTextual()) {
|
||||
return findFirstField(parseJsonOrMissing(node.asText("")), name);
|
||||
}
|
||||
if (node.isArray()) {
|
||||
for (JsonNode child : node) {
|
||||
JsonNode found = findFirstField(child, name);
|
||||
if (found != null && !found.isMissingNode() && !found.isNull()) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return objectMapper.missingNode();
|
||||
}
|
||||
if (node.isObject()) {
|
||||
JsonNode direct = node.get(name);
|
||||
if (direct != null && !direct.isMissingNode() && !direct.isNull()) {
|
||||
return direct;
|
||||
}
|
||||
for (java.util.Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
|
||||
Map.Entry<String, JsonNode> entry = it.next();
|
||||
JsonNode found = findFirstField(entry.getValue(), name);
|
||||
if (found != null && !found.isMissingNode() && !found.isNull()) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return objectMapper.missingNode();
|
||||
}
|
||||
|
||||
private String findTextByFieldName(JsonNode node, String... names) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return "";
|
||||
}
|
||||
if (node.isTextual()) {
|
||||
return findTextByFieldName(parseJsonOrMissing(node.asText("")), names);
|
||||
}
|
||||
if (node.isArray()) {
|
||||
for (JsonNode child : node) {
|
||||
String found = findTextByFieldName(child, names);
|
||||
if (!found.isBlank()) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (node.isObject()) {
|
||||
for (String name : names) {
|
||||
String value = text(node.get(name));
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
JsonNode dataNode = node.get("data");
|
||||
if (dataNode != null && dataNode.isTextual()) {
|
||||
String found = findTextByFieldName(parseJsonOrMissing(dataNode.asText("")), names);
|
||||
if (!found.isBlank()) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
for (java.util.Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
|
||||
Map.Entry<String, JsonNode> entry = it.next();
|
||||
String found = findTextByFieldName(entry.getValue(), names);
|
||||
if (!found.isBlank()) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String wrapDataPayload(String dataText) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("code", 0);
|
||||
payload.put("data", dataText);
|
||||
return writeJson(payload);
|
||||
}
|
||||
|
||||
private String writeJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("Failed to serialize Coze payload", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String abbreviate(String value, int maxLength) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.length() <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.substring(0, Math.max(0, maxLength - 3)) + "...";
|
||||
}
|
||||
|
||||
private void sleepBeforeRetry(int attemptIndex) {
|
||||
long delayMillis = Math.max(1, attemptIndex) * 1500L;
|
||||
ensureNotInterrupted();
|
||||
sleepQuietly(delayMillis);
|
||||
}
|
||||
|
||||
private void sleepQuietly(long delayMillis) {
|
||||
try {
|
||||
Thread.sleep(delayMillis);
|
||||
} catch (InterruptedException interruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Coze workflow interrupted", interruptedException);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureNotInterrupted() {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
throw new IllegalStateException("Coze workflow interrupted");
|
||||
}
|
||||
}
|
||||
|
||||
private RuntimeException propagate(Exception ex) {
|
||||
if (ex instanceof RuntimeException runtimeException) {
|
||||
return runtimeException;
|
||||
}
|
||||
return new IllegalStateException(nonBlank(ex.getMessage(), "Coze call failed"), ex);
|
||||
}
|
||||
|
||||
private JsonNode firstNonNull(JsonNode left, JsonNode right) {
|
||||
return left == null || left.isNull() ? right : left;
|
||||
}
|
||||
|
||||
private String text(JsonNode node) {
|
||||
return node == null || node.isNull() ? null : node.asText();
|
||||
}
|
||||
|
||||
private String nonBlank(String value, String fallback) {
|
||||
return value == null || value.isBlank() ? fallback : value;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String preferred, String fallback) {
|
||||
return preferred == null || preferred.isBlank() ? fallback : preferred.trim();
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim();
|
||||
}
|
||||
|
||||
private String rowKey(SimilarAsinResultRowDto row) {
|
||||
if (row == null) {
|
||||
return "";
|
||||
}
|
||||
return rowKey(row.getId(), row.getAsin(), row.getCountry());
|
||||
}
|
||||
|
||||
private String rowKey(String rowId, String asin, String country) {
|
||||
return normalize(rowId) + "::" + normalize(asin).toUpperCase(Locale.ROOT) + "::" + normalize(country);
|
||||
}
|
||||
|
||||
private String failureMessage(Exception ex) {
|
||||
if (ex instanceof PartialCozeResultException partial) {
|
||||
return "Coze result incomplete(" + partial.resolvedCount() + "/" + partial.expectedCount() + ")";
|
||||
}
|
||||
String message = ex == null ? null : ex.getMessage();
|
||||
if (message == null || message.isBlank()) {
|
||||
return "Coze call failed";
|
||||
}
|
||||
if (message.contains("Workflow node execution limit exceeded")) {
|
||||
return "Coze workflow node execution limit exceeded";
|
||||
}
|
||||
if (message.contains("Read timed out")) {
|
||||
return "Coze call timed out";
|
||||
}
|
||||
return "Coze call failed: " + message;
|
||||
}
|
||||
|
||||
private String stripBearer(String token) {
|
||||
String normalized = token == null ? "" : token.trim();
|
||||
return normalized.regionMatches(true, 0, "Bearer ", 0, 7) ? normalized.substring(7).trim() : normalized;
|
||||
}
|
||||
|
||||
private String joinUrl(String baseUrl, String path) {
|
||||
String base = baseUrl == null ? "" : baseUrl.trim();
|
||||
String suffix = path == null ? "" : path.trim();
|
||||
if (base.endsWith("/") && suffix.startsWith("/")) {
|
||||
return base + suffix.substring(1);
|
||||
}
|
||||
if (!base.endsWith("/") && !suffix.startsWith("/")) {
|
||||
return base + "/" + suffix;
|
||||
}
|
||||
return base + suffix;
|
||||
}
|
||||
|
||||
private record CozeResult(
|
||||
String groupKey,
|
||||
String rowId,
|
||||
String asin,
|
||||
String country,
|
||||
String title,
|
||||
String appearance,
|
||||
String patent,
|
||||
String result,
|
||||
String titleReason,
|
||||
String appearanceReason,
|
||||
String patentReason
|
||||
) {
|
||||
}
|
||||
|
||||
private record InspectAttempt(
|
||||
String raw,
|
||||
List<SimilarAsinResultRowDto> mergedRows,
|
||||
int resolvedCount,
|
||||
int rawResultCount
|
||||
) {
|
||||
}
|
||||
|
||||
public record CozeSubmitResponse(
|
||||
String executeId,
|
||||
String immediateData,
|
||||
String rawResponse
|
||||
) {
|
||||
}
|
||||
|
||||
public record CozePollResponse(
|
||||
String executeId,
|
||||
String status,
|
||||
String dataText,
|
||||
String outputText,
|
||||
String failureMessage,
|
||||
String rawResponse
|
||||
) {
|
||||
public boolean hasPayload() {
|
||||
return dataText != null && !dataText.isBlank() || outputText != null && !outputText.isBlank();
|
||||
}
|
||||
|
||||
public String resolvedPayloadText() {
|
||||
return dataText != null && !dataText.isBlank() ? dataText : outputText;
|
||||
}
|
||||
|
||||
public boolean isFailed() {
|
||||
String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT);
|
||||
return normalized.contains("FAILED") || normalized.contains("ERROR") || normalized.contains("CANCEL");
|
||||
}
|
||||
|
||||
public boolean isFinished() {
|
||||
String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT);
|
||||
return isFailed()
|
||||
|| normalized.contains("SUCCESS")
|
||||
|| normalized.contains("SUCCEED")
|
||||
|| normalized.contains("FINISH")
|
||||
|| normalized.contains("DONE")
|
||||
|| normalized.contains("COMPLET");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PartialCozeResultException extends RuntimeException {
|
||||
|
||||
private final int resolvedCount;
|
||||
private final int expectedCount;
|
||||
private final int rawResultCount;
|
||||
|
||||
private PartialCozeResultException(int resolvedCount, int expectedCount, int rawResultCount) {
|
||||
super("partial-result resolved=" + resolvedCount + "/" + expectedCount + " raw=" + rawResultCount);
|
||||
this.resolvedCount = resolvedCount;
|
||||
this.expectedCount = expectedCount;
|
||||
this.rawResultCount = rawResultCount;
|
||||
}
|
||||
|
||||
private int resolvedCount() {
|
||||
return resolvedCount;
|
||||
}
|
||||
|
||||
private int expectedCount() {
|
||||
return expectedCount;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private int rawResultCount() {
|
||||
return rawResultCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.nanri.aiimage.modules.similarasin.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSubmitResultRequest;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinTaskBatchRequest;
|
||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinDashboardVo;
|
||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinHistoryVo;
|
||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo;
|
||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/similar-asin")
|
||||
@Tag(name = "相似ASIN检测", description = "相似ASIN检测任务接口。前端上传 Excel 后由 Java 解析并创建任务;Python 回传商品数据;Java 负责攒批调用 Coze、补齐子行、生成最终 xlsx 并上传 OSS。")
|
||||
public class SimilarAsinController {
|
||||
|
||||
private final SimilarAsinTaskService service;
|
||||
|
||||
@PostMapping("/parse")
|
||||
@Operation(summary = "解析 Excel 并创建任务", description = "解析上传后的 Excel 文件,提取 id、ASIN、国家、URL、标题等字段。返回给前端的数据只包含整数 id 和 n_1 行;n_2、n_3 等子行会保存在 OSS 解析载荷中,用于最终结果补齐。创建后的任务状态为 PENDING,不会自动推送 Python。")
|
||||
public ApiResponse<SimilarAsinParseVo> parse(@Valid @RequestBody SimilarAsinParseRequest request) {
|
||||
return ApiResponse.success(service.parseAndCreateTask(request));
|
||||
}
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
@Operation(summary = "查询相似ASIN检测总览", description = "查询当前用户的运行中、成功、失败和已结束任务数量。页面进入时请求一次即可,不需要持续轮询。")
|
||||
public ApiResponse<SimilarAsinDashboardVo> dashboard(
|
||||
@Parameter(description = "当前用户 ID,用于隔离不同用户的任务和历史记录。", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(service.dashboard(userId));
|
||||
}
|
||||
|
||||
@GetMapping("/history")
|
||||
@Operation(summary = "查询相似ASIN检测历史", description = "查询当前用户最近的相似ASIN检测历史记录,包含源文件名、任务状态、行数、错误信息和最终 xlsx 下载地址。")
|
||||
public ApiResponse<SimilarAsinHistoryVo> history(
|
||||
@Parameter(description = "当前用户 ID,用于查询该用户自己的历史记录。", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId,
|
||||
@Parameter(description = "history limit, default 50, max 100", example = "50")
|
||||
@RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) {
|
||||
return ApiResponse.success(service.history(userId, limit));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/progress/batch")
|
||||
@Operation(summary = "批量查询任务进度", description = "前端只对活跃任务调用该接口,建议 6 秒一次。接口只返回轻量任务状态,不返回明细结果。")
|
||||
public ApiResponse<SimilarAsinTaskBatchVo> progress(@Valid @RequestBody SimilarAsinTaskBatchRequest request) {
|
||||
return ApiResponse.success(service.progressBatch(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/activate")
|
||||
@Operation(summary = "激活任务", description = "前端手动推送 Python 队列成功后调用,将任务从 PENDING 改为 RUNNING,并记录后端内部活跃时间。后续活跃时间由 Python 回传结果接口自动刷新,不需要单独心跳接口。")
|
||||
public ApiResponse<Void> activate(
|
||||
@Parameter(description = "相似ASIN检测任务 ID,即解析接口返回的 taskId。", required = true, example = "3938")
|
||||
@PathVariable Long taskId,
|
||||
@Parameter(description = "当前用户 ID,必须与创建任务的用户一致。", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
service.activateTask(taskId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/result")
|
||||
@Operation(summary = "提交 Python 回传结果", description = "Python 回传商品数据接口。items 可以是一条或多条;Java 先原样保存回传数据,再内部攒够 10 条调用 Coze。done=true 表示 Python 已完成全部回传,Java 会强制处理剩余不足 10 条的数据并生成最终 xlsx。")
|
||||
public ApiResponse<Void> result(
|
||||
@Parameter(description = "相似ASIN检测任务 ID。任务必须处于 RUNNING 状态。", required = true, example = "3938")
|
||||
@PathVariable Long taskId,
|
||||
@Valid @RequestBody SimilarAsinSubmitResultRequest request,
|
||||
jakarta.servlet.http.HttpServletResponse response) {
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
service.submitResult(taskId, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@DeleteMapping("/tasks/{taskId}")
|
||||
@Operation(summary = "删除任务", description = "删除当前用户的一条相似ASIN检测任务,同时清理任务结果、scope 状态和分片记录。")
|
||||
public ApiResponse<Void> deleteTask(
|
||||
@Parameter(description = "相似ASIN检测任务 ID。", required = true, example = "3938")
|
||||
@PathVariable Long taskId,
|
||||
@Parameter(description = "当前用户 ID,必须与创建任务的用户一致。", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
service.deleteTask(taskId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@DeleteMapping("/history/{resultId}")
|
||||
@Operation(summary = "删除历史记录", description = "删除当前用户的一条相似ASIN检测历史记录。只删除 biz_file_result 记录,不主动删除任务主记录。")
|
||||
public ApiResponse<Void> deleteHistory(
|
||||
@Parameter(description = "历史结果记录 ID,即 history 接口返回的 resultId。", required = true, example = "1001")
|
||||
@PathVariable Long resultId,
|
||||
@Parameter(description = "当前用户 ID。", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
service.deleteHistory(resultId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/results/{resultId}/download")
|
||||
@Operation(summary = "下载相似ASIN检测结果文件")
|
||||
public void downloadResult(
|
||||
@Parameter(description = "结果记录 ID", required = true, example = "1001")
|
||||
@PathVariable Long resultId,
|
||||
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId,
|
||||
jakarta.servlet.http.HttpServletResponse response) {
|
||||
String url = service.resolveResultDownloadUrl(resultId, userId);
|
||||
String filename = service.resolveResultDownloadFilename(resultId, userId);
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果");
|
||||
}
|
||||
try {
|
||||
response.setContentType("application/octet-stream");
|
||||
DownloadHeaderUtil.setAttachment(response, filename);
|
||||
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,28 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测解析请求")
|
||||
public class SimilarAsinParseRequest {
|
||||
@JsonProperty("user_id")
|
||||
@NotNull
|
||||
@Schema(description = "当前用户 ID。后端会把创建的任务、历史记录和结果文件归属到该用户。", example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long userId;
|
||||
|
||||
@NotEmpty
|
||||
@Schema(description = "已上传的 Excel 文件列表。支持多文件聚合解析;文件对象来自统一上传接口返回值。", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<SimilarAsinSourceFileDto> files;
|
||||
|
||||
@JsonProperty("ai_prompt")
|
||||
@JsonAlias({"aiPrompt", "prompt"})
|
||||
@Schema(description = "AI 提示词。非必填;为空时前端会使用默认提示词。后端会保存该提示词,并在调用 Coze workflow 时作为 prompt 参数传入。", example = "请排查这些亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。")
|
||||
private String aiPrompt;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.dto;
|
||||
|
||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedGroupVo;
|
||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测解析载荷")
|
||||
public class SimilarAsinParsedPayloadDto {
|
||||
@Schema(description = "AI 提示词")
|
||||
private String aiPrompt;
|
||||
|
||||
@Schema(description = "本次解析的源文件列表")
|
||||
private List<SimilarAsinSourceFileDto> sourceFiles = new ArrayList<>();
|
||||
|
||||
@Schema(description = "Excel 原始表头列表")
|
||||
private List<String> headers = new ArrayList<>();
|
||||
|
||||
@Schema(description = "兼容旧链路的平铺有效行,现为全部有效行")
|
||||
private List<SimilarAsinParsedRowVo> items = new ArrayList<>();
|
||||
|
||||
@Schema(description = "按相邻主 ID 块分组后的完整数据")
|
||||
private List<SimilarAsinParsedGroupVo> groups = new ArrayList<>();
|
||||
|
||||
@Schema(description = "完整有效行")
|
||||
private List<SimilarAsinParsedRowVo> allItems = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Python 回传的相似ASIN检测分组结果")
|
||||
public class SimilarAsinResultGroupDto {
|
||||
@Schema(description = "来源文件 key")
|
||||
private String sourceFileKey;
|
||||
|
||||
@Schema(description = "来源文件名")
|
||||
private String sourceFilename;
|
||||
|
||||
@Schema(description = "相邻主 ID 块的分组 key")
|
||||
private String groupKey;
|
||||
|
||||
@Schema(description = "主 ID,例如 1、2、10")
|
||||
private String baseId;
|
||||
|
||||
@Schema(description = "分组首条展示 ID")
|
||||
private String displayId;
|
||||
|
||||
@Schema(description = "分组内全部抓取结果")
|
||||
private List<SimilarAsinResultRowDto> items = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.fasterxml.jackson.annotation.JsonSetter;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.lang.reflect.Array;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测单行商品数据")
|
||||
public class SimilarAsinResultRowDto {
|
||||
private static final int MAX_IMAGE_URLS = 24;
|
||||
|
||||
@Schema(description = "来源文件 key。多文件回传时用于避免行结果串到别的文件。", example = "uploads/20260426/similar_asin_17.xlsx")
|
||||
private String sourceFileKey;
|
||||
|
||||
@Schema(description = "来源文件名。仅用于调试与追踪。", example = "17.xlsx")
|
||||
private String sourceFilename;
|
||||
|
||||
@Schema(description = "行级唯一 token。Python 应从解析结果原样透传。", example = "uploads/20260426/similar_asin_17.xlsx::row::2")
|
||||
private String rowToken;
|
||||
|
||||
@Schema(description = "主数据分组 key。Python 应从解析结果原样透传,用于把同组子行补回。", example = "uploads/20260426/similar_asin_17.xlsx::2@2")
|
||||
private String groupKey;
|
||||
|
||||
@Schema(description = "Excel 中的 id。代表行通常是整数 id 或 n_1,例如 2_1;最终生成 xlsx 时,2_2、2_3 会复用同组 2_1 的 Coze 检测结果。", example = "2_1")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "亚马逊 ASIN。后端会统一按大写处理和匹配。", example = "B0CJ8SNXXV")
|
||||
private String asin;
|
||||
|
||||
@Schema(description = "站点或国家。来自 Excel 的国家列,例如英国、德国、法国。", example = "英国")
|
||||
private String country;
|
||||
|
||||
@Schema(description = "兼容旧链路的首个图片 URL。新链路允许 url 传字符串或数组;数组会同步写入 urls。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "商品图片 URL 列表,最多保留 24 个非空地址。请求中也可以直接把 url 传成数组。", example = "[\"https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg\"]")
|
||||
private List<String> urls = new ArrayList<>();
|
||||
|
||||
@Schema(description = "商品标题。Java 调用 Coze 时会放入 title_list;为空时会回退使用 ASIN。", example = "Women Floral Dress Summer Casual")
|
||||
@JsonAlias({"productTitle", "product_title", "itemTitle", "item_title", "商品标题", "商品名称", "标题"})
|
||||
private String title;
|
||||
|
||||
@Schema(description = "单行错误信息。通常用于记录 Python 单行处理异常;Coze 失败时后端会尽量保留原始行,不强行写入风险结果。", example = "图片地址为空")
|
||||
private String error;
|
||||
|
||||
@Schema(description = "单行完成标记。当前主要使用请求体顶层 done 控制任务收尾,该字段仅作兼容。", example = "true")
|
||||
private Boolean done;
|
||||
|
||||
@Schema(description = "Java 调用 Coze 后生成的标题维度检测结果,对应最终 xlsx 的“标题维度(商标)”列。Python 回传请求中不要传该字段;即使传入,后端也会以 Java/Coze 处理结果为准。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String titleRisk;
|
||||
|
||||
@Schema(description = "Java 调用 Coze 后生成的外观维度检测结果,对应最终 xlsx 的“外观维度(外观设计专利)”列。Python 回传请求中不要传该字段。", example = "未发现明显外观设计专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String appearanceRisk;
|
||||
|
||||
@JsonAlias({"patent ", "patent"})
|
||||
@Schema(description = "Java 调用 Coze 后生成的专利维度检测结果,对应最终 xlsx 的“专利维度(发明/实用新型专利)”列。兼容 Coze 返回字段 patent 和 patent 后带空格的情况;Python 回传请求中不要传该字段。", example = "未发现明显发明或实用新型专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String patentRisk;
|
||||
|
||||
@Schema(description = "Java 调用 Coze 后生成的最终结论,对应最终 xlsx 的“结论”列。Python 回传请求中不要传该字段。", example = "未发现明显侵权风险", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String conclusion;
|
||||
|
||||
@JsonAlias({"title_reason", "titleReason"})
|
||||
@Schema(description = "Coze title reason", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String titleReason;
|
||||
|
||||
@JsonAlias({"appearance_reason", "appearanceReason"})
|
||||
@Schema(description = "Coze appearance reason", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String appearanceReason;
|
||||
|
||||
@JsonAlias({"patent_reason", "patentReason", "patent reason"})
|
||||
@Schema(description = "Coze patent reason", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String patentReason;
|
||||
|
||||
public String getUrl() {
|
||||
if (url != null && !url.isBlank()) {
|
||||
return url;
|
||||
}
|
||||
List<String> normalized = getUrls();
|
||||
return normalized.isEmpty() ? "" : normalized.get(0);
|
||||
}
|
||||
|
||||
@JsonSetter("url")
|
||||
@JsonAlias({
|
||||
"imageUrl", "image_url", "imgUrl", "img_url", "pictureUrl", "picture_url",
|
||||
"mainImage", "main_image", "mainImageUrl", "main_image_url",
|
||||
"productImage", "product_image", "productImageUrl", "product_image_url",
|
||||
"image", "img", "pic", "picture", "link", "imageLink", "image_link"
|
||||
})
|
||||
public void setUrl(Object value) {
|
||||
List<String> normalized = normalizeUrls(value);
|
||||
this.urls = normalized;
|
||||
this.url = normalized.isEmpty() ? "" : normalized.get(0);
|
||||
}
|
||||
|
||||
public List<String> getUrls() {
|
||||
List<String> normalized = normalizeUrls(urls);
|
||||
if (url != null && !url.isBlank() && normalized.stream().noneMatch(url.trim()::equals)) {
|
||||
List<String> combined = new ArrayList<>(normalized.size() + 1);
|
||||
combined.add(url.trim());
|
||||
combined.addAll(normalized);
|
||||
return limitUrls(combined);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@JsonSetter("urls")
|
||||
@JsonAlias({
|
||||
"imageUrls", "image_urls", "imgUrls", "img_urls", "pictureUrls", "picture_urls",
|
||||
"mainImages", "main_images", "mainImageUrls", "main_image_urls",
|
||||
"productImages", "product_images", "productImageUrls", "product_image_urls",
|
||||
"images", "imgs", "pics", "pictures", "links", "imageLinks", "image_links",
|
||||
"图片链接", "商品图片", "商品主图", "主图", "主图链接"
|
||||
})
|
||||
public void setUrls(Object urls) {
|
||||
List<String> normalized = normalizeUrls(urls);
|
||||
this.urls = normalized;
|
||||
this.url = normalized.isEmpty() ? "" : normalized.get(0);
|
||||
}
|
||||
|
||||
public boolean hasImageUrl() {
|
||||
return !getUrls().isEmpty();
|
||||
}
|
||||
|
||||
private static List<String> normalizeUrls(Object value) {
|
||||
List<String> result = new ArrayList<>();
|
||||
appendUrls(result, value);
|
||||
return limitUrls(result);
|
||||
}
|
||||
|
||||
private static void appendUrls(List<String> result, Object value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
if (value instanceof JsonNode node) {
|
||||
if (node.isArray()) {
|
||||
node.forEach(child -> appendUrls(result, child));
|
||||
return;
|
||||
}
|
||||
if (node.isNull() || node.isMissingNode()) {
|
||||
return;
|
||||
}
|
||||
appendUrls(result, node.asText(""));
|
||||
return;
|
||||
}
|
||||
if (value instanceof Collection<?> collection) {
|
||||
collection.forEach(item -> appendUrls(result, item));
|
||||
return;
|
||||
}
|
||||
Class<?> valueClass = value.getClass();
|
||||
if (valueClass.isArray()) {
|
||||
int length = Array.getLength(value);
|
||||
for (int i = 0; i < length; i++) {
|
||||
appendUrls(result, Array.get(value, i));
|
||||
}
|
||||
return;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
if (!text.isBlank()) {
|
||||
result.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> limitUrls(List<String> values) {
|
||||
Set<String> deduplicated = new LinkedHashSet<>();
|
||||
if (values != null) {
|
||||
for (String value : values) {
|
||||
if (value == null || value.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
deduplicated.add(value.trim());
|
||||
if (deduplicated.size() >= MAX_IMAGE_URLS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(deduplicated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测源文件信息")
|
||||
public class SimilarAsinSourceFileDto {
|
||||
@Schema(description = "上传接口返回的临时文件 key。后端根据该 key 查找本地临时 Excel 文件并解析。", example = "uploads/20260426/similar_asin_17.xlsx", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String fileKey;
|
||||
@Schema(description = "原始文件名。用于历史记录展示和最终结果文件命名。", example = "17.xlsx")
|
||||
private String originalFilename;
|
||||
@Schema(description = "相对目录路径。当前仅记录来源,相似ASIN检测不依赖该字段处理。", example = "xlsx/17.xlsx")
|
||||
private String relativePath;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Python 回传相似ASIN检测结果请求")
|
||||
public class SimilarAsinSubmitResultRequest {
|
||||
@Schema(description = "本次 Python 回传的提交批次标识", example = "similar-asin-3938")
|
||||
private String submissionId;
|
||||
|
||||
@Schema(description = "当前回传分片序号", example = "1")
|
||||
private Integer chunkIndex;
|
||||
|
||||
@Schema(description = "本任务预计总分片数", example = "36")
|
||||
private Integer chunkTotal;
|
||||
|
||||
@Schema(description = "是否为最后一次回传", example = "false")
|
||||
private Boolean done;
|
||||
|
||||
@Schema(description = "Python 侧任务级错误信息", example = "浏览器执行异常,任务提前结束")
|
||||
private String error;
|
||||
|
||||
@Schema(description = "本次回传的分组结果列表,推荐优先使用")
|
||||
private List<SimilarAsinResultGroupDto> groups = new ArrayList<>();
|
||||
|
||||
@Schema(description = "兼容旧链路的平铺结果列表")
|
||||
private List<SimilarAsinResultRowDto> items = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测批量进度查询请求")
|
||||
public class SimilarAsinTaskBatchRequest {
|
||||
@NotEmpty
|
||||
@Schema(description = "需要查询进度的任务 ID 列表。前端只传正在轮询的活跃任务;后端会批量查询,避免每个任务单独请求。", example = "[3938,3939]", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<Long> taskIds;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测总览统计")
|
||||
public class SimilarAsinDashboardVo {
|
||||
@Schema(description = "运行中任务数量。这里统计 RUNNING 状态任务。", example = "1")
|
||||
private Long pendingTaskCount;
|
||||
@Schema(description = "已结束任务数量,等于成功任务数加失败任务数。", example = "12")
|
||||
private Long processedTaskCount;
|
||||
@Schema(description = "成功任务数量。", example = "10")
|
||||
private Long successTaskCount;
|
||||
@Schema(description = "失败任务数量。", example = "2")
|
||||
private Long failedTaskCount;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测历史记录项")
|
||||
public class SimilarAsinHistoryItemVo {
|
||||
@Schema(description = "结果记录 ID。删除历史、下载结果时使用。", example = "1001")
|
||||
private Long resultId;
|
||||
@Schema(description = "任务 ID。", example = "3938")
|
||||
private Long taskId;
|
||||
@Schema(description = "源 Excel 文件名。", example = "17.xlsx")
|
||||
private String sourceFilename;
|
||||
@Schema(description = "最终结果文件名。任务完成并生成 xlsx 后返回。", example = "17-result.xlsx")
|
||||
private String resultFilename;
|
||||
@Schema(description = "最终结果文件下载地址。后端基于 OSS objectKey 生成的新鲜预签名 URL。", example = "https://bucket.oss-cn-hangzhou.aliyuncs.com/result/similar_asin/xxx/17-result.xlsx?Expires=...")
|
||||
private String downloadUrl;
|
||||
private Long fileJobId;
|
||||
private String fileStatus;
|
||||
private String fileError;
|
||||
private Boolean fileReady;
|
||||
private Integer fileProgressPercent;
|
||||
private Integer fileProgressCurrent;
|
||||
private Integer fileProgressTotal;
|
||||
private String fileProgressMessage;
|
||||
@Schema(description = "任务状态:PENDING=已解析待推送,RUNNING=执行中,SUCCESS=成功,FAILED=失败。", example = "SUCCESS")
|
||||
private String taskStatus;
|
||||
@Schema(description = "结果是否成功。true 表示任务完成并生成结果文件;false 表示失败或未完成。", example = "true")
|
||||
private Boolean success;
|
||||
@Schema(description = "错误信息。任务失败时返回,例如 Python 超时、结果文件生成失败等。", example = "Python interrupted before uploading final similar ASIN result")
|
||||
private String error;
|
||||
@Schema(description = "最终结果行数。包含解析阶段被过滤但最终需要补回的 2_2、2_3 等子行。", example = "716")
|
||||
private Integer rowCount;
|
||||
@Schema(description = "历史记录创建时间,ISO 本地时间字符串。", example = "2026-04-26T10:30:00")
|
||||
private String createdAt;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测历史记录列表")
|
||||
public class SimilarAsinHistoryVo {
|
||||
@Schema(description = "历史记录项列表,默认返回最近 100 条。")
|
||||
private List<SimilarAsinHistoryItemVo> items = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测解析结果")
|
||||
public class SimilarAsinParseVo {
|
||||
@Schema(description = "新创建的任务 ID", example = "3938")
|
||||
private Long taskId;
|
||||
|
||||
@Schema(description = "来源 Excel 文件名,多个文件时为聚合展示文案", example = "17.xlsx 等 2 个文件")
|
||||
private String sourceFilename;
|
||||
|
||||
@Schema(description = "参与本次解析的源文件数量", example = "2")
|
||||
private Integer sourceFileCount;
|
||||
|
||||
@Schema(description = "Excel 中检测到的非空数据总行数", example = "716")
|
||||
private Integer totalRows;
|
||||
|
||||
@Schema(description = "解析出的有效行数", example = "458")
|
||||
private Integer acceptedRows;
|
||||
|
||||
@Schema(description = "因缺少必要字段而被丢弃的行数", example = "12")
|
||||
private Integer droppedRows;
|
||||
|
||||
@Schema(description = "按相邻主 ID 块生成的分组数", example = "36")
|
||||
private Integer groupCount;
|
||||
|
||||
@Schema(description = "本任务最终使用的 AI 提示词")
|
||||
private String aiPrompt;
|
||||
|
||||
@Schema(description = "兼容旧前端的平铺有效行列表,现为全部有效行")
|
||||
private List<SimilarAsinParsedRowVo> items = new ArrayList<>();
|
||||
|
||||
@Schema(description = "返回前端和推送 Python 的分组列表")
|
||||
private List<SimilarAsinParsedGroupVo> groups = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测解析后的分组数据")
|
||||
public class SimilarAsinParsedGroupVo {
|
||||
@Schema(description = "来源文件 key")
|
||||
private String sourceFileKey;
|
||||
|
||||
@Schema(description = "来源文件名")
|
||||
private String sourceFilename;
|
||||
|
||||
@Schema(description = "相邻主 ID 块的分组 key")
|
||||
private String groupKey;
|
||||
|
||||
@Schema(description = "主 ID,例如 1、2、10")
|
||||
private String baseId;
|
||||
|
||||
@Schema(description = "分组首条展示 ID,例如 1 或 2_1")
|
||||
private String displayId;
|
||||
|
||||
@Schema(description = "分组内行数")
|
||||
private Integer itemCount;
|
||||
|
||||
@Schema(description = "分组内全部行,顺序与原 Excel 保持一致")
|
||||
private List<SimilarAsinParsedRowVo> items = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测解析出的单行数据")
|
||||
public class SimilarAsinParsedRowVo {
|
||||
@Schema(description = "来源文件 key。用于多文件场景下区分同名/同 ID 行。", example = "uploads/20260426/similar_asin_17.xlsx")
|
||||
private String sourceFileKey;
|
||||
|
||||
@Schema(description = "来源文件名。用于调试和结果追踪。", example = "17.xlsx")
|
||||
private String sourceFilename;
|
||||
|
||||
@Schema(description = "Excel 原始行号,从 1 开始。", example = "2")
|
||||
private Integer rowIndex;
|
||||
|
||||
@Schema(description = "Excel 中原始 id 值。", example = "2_1")
|
||||
private String sourceId;
|
||||
|
||||
@Schema(description = "前端展示和 Python 回传使用的 id。整数 id 原样保留,子数据第一条如 2_1 原样保留。", example = "2_1")
|
||||
private String displayId;
|
||||
|
||||
@Schema(description = "行级唯一 token。多文件、重复主数据时用于精确匹配回传结果。", example = "uploads/20260426/similar_asin_17.xlsx::row::2")
|
||||
private String rowToken;
|
||||
|
||||
@Schema(description = "同一主数据块的分组 key。用于把 2_1、2_2、2_3 等子行重新补回同一组结果。", example = "uploads/20260426/similar_asin_17.xlsx::2@2")
|
||||
private String groupKey;
|
||||
|
||||
@Schema(description = "亚马逊 ASIN。", example = "B0CJ8SNXXV")
|
||||
private String asin;
|
||||
|
||||
@Schema(description = "国家或站点。", example = "英国")
|
||||
private String country;
|
||||
|
||||
@Schema(description = "商品图片 URL 或商品 URL,供 Coze 检测使用。", example = "https://webstatic.aiproxy.vip/output/demo.jpg")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "商品标题。", example = "Women Floral Dress Summer Casual")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "该 Excel 行的原始列值映射。最终生成 xlsx 时可以从这里读取价格等字段。", example = "{\"id\":\"2_1\",\"asin\":\"B0CJ8SNXXV\",\"国家\":\"英国\",\"价格\":\"12.99\"}")
|
||||
private Map<String, String> values = new LinkedHashMap<>();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测批量进度响应")
|
||||
public class SimilarAsinTaskBatchVo {
|
||||
@Schema(description = "查询到的任务详情列表。顺序按请求 taskIds 处理。")
|
||||
private List<SimilarAsinTaskDetailVo> items = new ArrayList<>();
|
||||
@Schema(description = "未找到或不属于相似ASIN检测模块的任务 ID 列表。", example = "[99999]")
|
||||
private List<Long> missingTaskIds = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测任务进度详情")
|
||||
public class SimilarAsinTaskDetailVo {
|
||||
@Schema(description = "任务主记录轻量信息。")
|
||||
private SimilarAsinTaskItemVo task;
|
||||
@Schema(description = "预留的任务明细列表。当前进度接口主要返回任务轻量状态,不返回完整结果明细。")
|
||||
private List<SimilarAsinHistoryItemVo> items = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.nanri.aiimage.modules.similarasin.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测任务轻量信息")
|
||||
public class SimilarAsinTaskItemVo {
|
||||
@Schema(description = "任务 ID。", example = "3938")
|
||||
private Long id;
|
||||
@Schema(description = "任务编号,后端自动生成。", example = "SIMILAR_ASIN-1780000000000000000")
|
||||
private String taskNo;
|
||||
@Schema(description = "任务状态:PENDING=已解析待推送,RUNNING=执行中,SUCCESS=成功,FAILED=失败。", example = "RUNNING")
|
||||
private String status;
|
||||
@Schema(description = "任务级错误信息。失败时返回。", example = "生成相似ASIN检测结果失败")
|
||||
private String errorMessage;
|
||||
@Schema(description = "创建时间,ISO 本地时间字符串。", example = "2026-04-26T10:00:00")
|
||||
private String createdAt;
|
||||
@Schema(description = "最后更新时间,通常由 Python 回传结果或任务收尾更新。", example = "2026-04-26T10:05:00")
|
||||
private String updatedAt;
|
||||
@Schema(description = "完成时间。任务未结束时为空。", example = "2026-04-26T10:10:00")
|
||||
private String finishedAt;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.nanri.aiimage.modules.similarasin.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SimilarAsinTaskCacheService {
|
||||
|
||||
private static final long TTL_HOURS = 24;
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public void appendPendingRow(Long taskId, String scopeHash, Integer chunkIndex, SimilarAsinResultRowDto row) {
|
||||
if (taskId == null || taskId <= 0 || scopeHash == null || scopeHash.isBlank() || chunkIndex == null || row == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
stringRedisTemplate.opsForList().rightPush(
|
||||
pendingRowsKey(taskId),
|
||||
objectMapper.writeValueAsString(new PendingRow(scopeHash, chunkIndex, row))
|
||||
);
|
||||
stringRedisTemplate.expire(pendingRowsKey(taskId), Duration.ofHours(TTL_HOURS));
|
||||
touchTaskHeartbeat(taskId);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public long pendingRowCount(Long taskId) {
|
||||
Long size;
|
||||
try {
|
||||
size = stringRedisTemplate.opsForList().size(pendingRowsKey(taskId));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[similar-asin-cache] pending row count degraded taskId={} msg={}", taskId, ex.getMessage());
|
||||
return 0L;
|
||||
}
|
||||
return size == null ? 0L : size;
|
||||
}
|
||||
|
||||
public List<PendingRow> drainPendingRows(Long taskId, int limit) {
|
||||
if (taskId == null || taskId <= 0 || limit <= 0) {
|
||||
return List.of();
|
||||
}
|
||||
String key = pendingRowsKey(taskId);
|
||||
List<String> values;
|
||||
try {
|
||||
values = stringRedisTemplate.opsForList().range(key, 0, limit - 1L);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[similar-asin-cache] drain range degraded taskId={} msg={}", taskId, ex.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
if (values == null || values.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
stringRedisTemplate.opsForList().trim(key, values.size(), -1);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[similar-asin-cache] drain trim degraded taskId={} msg={}", taskId, ex.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
List<PendingRow> rows = new ArrayList<>();
|
||||
for (String value : values) {
|
||||
if (value == null || value.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
rows.add(objectMapper.readValue(value, PendingRow.class));
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
touchTaskHeartbeat(taskId);
|
||||
return rows;
|
||||
}
|
||||
|
||||
public void touchTaskHeartbeat(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
heartbeatKey(taskId),
|
||||
String.valueOf(Instant.now().toEpochMilli()),
|
||||
Duration.ofHours(TTL_HOURS)
|
||||
);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[similar-asin-cache] touch heartbeat degraded taskId={} msg={}", taskId, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public long getTaskHeartbeatMillis(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return 0L;
|
||||
}
|
||||
String raw;
|
||||
try {
|
||||
raw = stringRedisTemplate.opsForValue().get(heartbeatKey(taskId));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[similar-asin-cache] get heartbeat degraded taskId={} msg={}", taskId, ex.getMessage());
|
||||
return 0L;
|
||||
}
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return 0L;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(raw);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteTaskCache(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
stringRedisTemplate.delete(pendingRowsKey(taskId));
|
||||
stringRedisTemplate.delete(heartbeatKey(taskId));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[similar-asin-cache] delete degraded taskId={} msg={}", taskId, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String pendingRowsKey(Long taskId) {
|
||||
return "similar-asin:task:pending-rows:" + taskId;
|
||||
}
|
||||
|
||||
private String heartbeatKey(Long taskId) {
|
||||
return "similar-asin:task:heartbeat:" + taskId;
|
||||
}
|
||||
|
||||
public record PendingRow(String scopeHash, Integer chunkIndex, SimilarAsinResultRowDto row) {
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,9 +14,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -27,6 +25,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/split")
|
||||
@@ -93,14 +93,14 @@ public class SplitRunController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "返回结果文件流"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在")
|
||||
})
|
||||
public ResponseEntity<Resource> download(
|
||||
public ResponseEntity<Void> download(
|
||||
@Parameter(description = "数据拆分结果记录 ID", required = true) @PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
|
||||
@RequestParam("user_id") Long userId) {
|
||||
Resource resource = splitRunService.getResultFile(resultId, userId);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment")
|
||||
.body(resource);
|
||||
String downloadUrl = splitRunService.getResultDownloadUrl(resultId, userId);
|
||||
return ResponseEntity.status(302)
|
||||
.location(URI.create(downloadUrl))
|
||||
.header(HttpHeaders.CACHE_CONTROL, "no-store")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
@@ -173,7 +171,7 @@ public class SplitRunService {
|
||||
vo.setResultId(entity.getId());
|
||||
vo.setSourceFilename(entity.getSourceFilename());
|
||||
vo.setOutputFilename(entity.getResultFilename());
|
||||
vo.setDownloadUrl(null);
|
||||
vo.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
|
||||
vo.setRowCount(entity.getRowCount());
|
||||
vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
|
||||
vo.setError(entity.getErrorMessage());
|
||||
@@ -192,17 +190,12 @@ public class SplitRunService {
|
||||
fileResultMapper.deleteById(resultId);
|
||||
}
|
||||
|
||||
public Resource getResultFile(Long resultId, Long userId) {
|
||||
public String getResultDownloadUrl(Long resultId, Long userId) {
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || entity.getResultFileUrl() == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||
throw new BusinessException("Split result file does not exist.");
|
||||
}
|
||||
|
||||
File file = new File(entity.getResultFileUrl());
|
||||
if (!file.exists()) {
|
||||
throw new BusinessException("Split result file does not exist.");
|
||||
}
|
||||
return new FileSystemResource(file);
|
||||
return ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl());
|
||||
}
|
||||
|
||||
private List<SplitChunkResult> splitExcelByLegacyRules(
|
||||
|
||||
@@ -19,6 +19,13 @@ public class TaskScopeStateEntity {
|
||||
private String scopeHash;
|
||||
private String parsedPayloadJson;
|
||||
private String stateJson;
|
||||
private String cozeExecuteId;
|
||||
private String cozeStatus;
|
||||
private LocalDateTime cozeSubmittedAt;
|
||||
private LocalDateTime cozeLastPolledAt;
|
||||
private LocalDateTime cozeCompletedAt;
|
||||
private Integer cozeAttemptCount;
|
||||
private String cozeError;
|
||||
private Integer chunkTotal;
|
||||
private Integer receivedChunkCount;
|
||||
private Integer completed;
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -18,8 +19,9 @@ public class TaskFileJobLocalDispatcher {
|
||||
private final TaskFileJobMapper taskFileJobMapper;
|
||||
private final ObjectProvider<TaskResultFileJobWorker> taskResultFileJobWorkerProvider;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("taskFileJobDispatchExecutor")
|
||||
private final TaskExecutor taskFileJobDispatchExecutor;
|
||||
private TaskExecutor taskFileJobDispatchExecutor;
|
||||
|
||||
@Value("${aiimage.result-file-job.local-dispatch-enabled:true}")
|
||||
private boolean localDispatchEnabled;
|
||||
@@ -28,26 +30,32 @@ public class TaskFileJobLocalDispatcher {
|
||||
if (!localDispatchEnabled || jobId == null || jobId <= 0) {
|
||||
return false;
|
||||
}
|
||||
taskFileJobDispatchExecutor.execute(() -> {
|
||||
try {
|
||||
TaskFileJobEntity job = taskFileJobMapper.selectById(jobId);
|
||||
if (job == null) {
|
||||
log.warn("[task-file-job] local dispatch ignored, job not found jobId={} taskId={} moduleType={}",
|
||||
jobId, taskId, moduleType);
|
||||
return;
|
||||
try {
|
||||
taskFileJobDispatchExecutor.execute(() -> {
|
||||
try {
|
||||
TaskFileJobEntity job = taskFileJobMapper.selectById(jobId);
|
||||
if (job == null) {
|
||||
log.warn("[task-file-job] local dispatch ignored, job not found jobId={} taskId={} moduleType={}",
|
||||
jobId, taskId, moduleType);
|
||||
return;
|
||||
}
|
||||
TaskResultFileJobWorker taskResultFileJobWorker = taskResultFileJobWorkerProvider.getIfAvailable();
|
||||
if (taskResultFileJobWorker == null) {
|
||||
log.warn("[task-file-job] local dispatch ignored, worker unavailable jobId={} taskId={} moduleType={}",
|
||||
jobId, taskId, moduleType);
|
||||
return;
|
||||
}
|
||||
taskResultFileJobWorker.process(job);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[task-file-job] local dispatch failed jobId={} taskId={} moduleType={} msg={}",
|
||||
jobId, taskId, moduleType, ex.getMessage(), ex);
|
||||
}
|
||||
TaskResultFileJobWorker taskResultFileJobWorker = taskResultFileJobWorkerProvider.getIfAvailable();
|
||||
if (taskResultFileJobWorker == null) {
|
||||
log.warn("[task-file-job] local dispatch ignored, worker unavailable jobId={} taskId={} moduleType={}",
|
||||
jobId, taskId, moduleType);
|
||||
return;
|
||||
}
|
||||
taskResultFileJobWorker.process(job);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[task-file-job] local dispatch failed jobId={} taskId={} moduleType={} msg={}",
|
||||
jobId, taskId, moduleType, ex.getMessage(), ex);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("[task-file-job] local dispatch executor rejected jobId={} taskId={} moduleType={} msg={}",
|
||||
jobId, taskId, moduleType, ex.getMessage(), ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ public class TaskFileJobService {
|
||||
if (existing == null) {
|
||||
return null;
|
||||
}
|
||||
if ("SUCCESS".equals(existing.getStatus()) || "RUNNING".equals(existing.getStatus())) {
|
||||
if ("SUCCESS".equals(existing.getStatus()) || "RUNNING".equals(existing.getStatus()) || "PENDING".equals(existing.getStatus())) {
|
||||
return existing;
|
||||
}
|
||||
taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
|
||||
@@ -192,6 +192,11 @@ public class TaskFileJobService {
|
||||
return findJob(taskId, moduleType, resultId, JOB_TYPE_ASSEMBLE_RESULT);
|
||||
}
|
||||
|
||||
public boolean hasSuccessfulAssembleJob(Long taskId, String moduleType, Long resultId) {
|
||||
TaskFileJobEntity job = findAssembleJob(taskId, moduleType, resultId);
|
||||
return job != null && "SUCCESS".equals(job.getStatus());
|
||||
}
|
||||
|
||||
public Map<Long, TaskFileJobEntity> findAssembleJobsByResultIds(String moduleType, List<Long> resultIds) {
|
||||
List<Long> normalizedIds = resultIds == null ? List.of() : resultIds.stream()
|
||||
.filter(id -> id != null && id > 0)
|
||||
@@ -225,6 +230,18 @@ public class TaskFileJobService {
|
||||
return count == null ? 0L : count;
|
||||
}
|
||||
|
||||
public long countActiveAssembleJobs(Long taskId, String moduleType) {
|
||||
if (taskId == null || taskId <= 0 || moduleType == null || moduleType.isBlank()) {
|
||||
return 0L;
|
||||
}
|
||||
Long count = taskFileJobMapper.selectCount(new LambdaQueryWrapper<TaskFileJobEntity>()
|
||||
.eq(TaskFileJobEntity::getTaskId, taskId)
|
||||
.eq(TaskFileJobEntity::getModuleType, moduleType)
|
||||
.eq(TaskFileJobEntity::getJobType, JOB_TYPE_ASSEMBLE_RESULT)
|
||||
.in(TaskFileJobEntity::getStatus, List.of("PENDING", "RUNNING")));
|
||||
return count == null ? 0L : count;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteTaskJobs(Long taskId, String moduleType) {
|
||||
if (taskId == null || taskId <= 0 || moduleType == null || moduleType.isBlank()) {
|
||||
|
||||
@@ -8,12 +8,16 @@ import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -34,8 +38,12 @@ public class TaskResultFileJobWorker {
|
||||
private final QueryAsinTaskService queryAsinTaskService;
|
||||
private final PatrolDeleteTaskService patrolDeleteTaskService;
|
||||
private final AppearancePatentTaskService appearancePatentTaskService;
|
||||
private final SimilarAsinTaskService similarAsinTaskService;
|
||||
private final DeleteBrandRunService deleteBrandRunService;
|
||||
private final BrandTaskService brandTaskService;
|
||||
@Autowired
|
||||
@Qualifier("cozeTaskExecutor")
|
||||
private TaskExecutor cozeTaskExecutor;
|
||||
|
||||
@Value("${aiimage.result-file-job.local-worker-enabled:true}")
|
||||
private boolean localWorkerEnabled;
|
||||
@@ -80,9 +88,29 @@ public class TaskResultFileJobWorker {
|
||||
if (job == null || job.getId() == null || !taskFileJobService.markRunning(job.getId())) {
|
||||
return;
|
||||
}
|
||||
if ("APPEARANCE_PATENT".equals(job.getModuleType()) || "SIMILAR_ASIN".equals(job.getModuleType())) {
|
||||
try {
|
||||
cozeTaskExecutor.execute(() -> processInternal(job));
|
||||
return;
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("[task-file-job] coze module offload failed, fallback inline jobId={} taskId={} moduleType={} msg={}",
|
||||
job.getId(), job.getTaskId(), job.getModuleType(), ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
processInternal(job);
|
||||
}
|
||||
|
||||
private void processInternal(TaskFileJobEntity job) {
|
||||
long startedAt = System.currentTimeMillis();
|
||||
try {
|
||||
dispatch(job);
|
||||
boolean completed = dispatch(job);
|
||||
if (!completed) {
|
||||
taskFileJobService.touchRunning(job.getId());
|
||||
log.info("[task-file-job] process deferred jobId={} taskId={} moduleType={} resultId={} elapsedMs={}",
|
||||
job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(),
|
||||
System.currentTimeMillis() - startedAt);
|
||||
return;
|
||||
}
|
||||
String resultFileUrl = resolveResultFileUrl(job);
|
||||
taskFileJobService.markSuccess(job, resultFileUrl);
|
||||
cleanupAfterSuccess(job);
|
||||
@@ -108,39 +136,41 @@ public class TaskResultFileJobWorker {
|
||||
return result == null ? null : result.getResultFileUrl();
|
||||
}
|
||||
|
||||
private void dispatch(TaskFileJobEntity job) {
|
||||
private boolean dispatch(TaskFileJobEntity job) {
|
||||
String moduleType = job.getModuleType();
|
||||
if ("SHOP_MATCH".equals(moduleType)) {
|
||||
shopMatchTaskService.processResultFileJob(job);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if ("PRICE_TRACK".equals(moduleType)) {
|
||||
priceTrackTaskService.processResultFileJob(job);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if ("PRODUCT_RISK_RESOLVE".equals(moduleType)) {
|
||||
productRiskTaskService.processResultFileJob(job);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if ("QUERY_ASIN".equals(moduleType)) {
|
||||
queryAsinTaskService.processResultFileJob(job);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if ("PATROL_DELETE".equals(moduleType)) {
|
||||
patrolDeleteTaskService.processResultFileJob(job);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if ("APPEARANCE_PATENT".equals(moduleType)) {
|
||||
appearancePatentTaskService.processResultFileJob(job);
|
||||
return;
|
||||
return appearancePatentTaskService.processResultFileJob(job);
|
||||
}
|
||||
if ("SIMILAR_ASIN".equals(moduleType)) {
|
||||
return similarAsinTaskService.processResultFileJob(job);
|
||||
}
|
||||
if ("DELETE_BRAND".equals(moduleType)) {
|
||||
deleteBrandRunService.processResultFileJob(job);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if ("BRAND".equals(moduleType)) {
|
||||
brandTaskService.processResultFileJob(job);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
throw new IllegalArgumentException("unsupported result file job module: " + moduleType);
|
||||
}
|
||||
@@ -159,6 +189,10 @@ public class TaskResultFileJobWorker {
|
||||
appearancePatentTaskService.cleanupResultFileJob(job);
|
||||
return;
|
||||
}
|
||||
if ("SIMILAR_ASIN".equals(moduleType)) {
|
||||
similarAsinTaskService.cleanupResultFileJob(job);
|
||||
return;
|
||||
}
|
||||
if ("DELETE_BRAND".equals(moduleType)) {
|
||||
deleteBrandRunService.cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,39 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.StorageProperties;
|
||||
import com.nanri.aiimage.config.TransientStorageProperties;
|
||||
import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TransientPayloadStorageService {
|
||||
|
||||
private static final String LOCAL_POINTER_PREFIX = "local:";
|
||||
private static final String RUSTFS_POINTER_PREFIX = "rustfs:";
|
||||
private static final String OSS_POINTER_PREFIX = "oss:";
|
||||
private static final String LOCAL_PAYLOAD_DIR = "transient-payload";
|
||||
|
||||
private final TransientStorageProperties properties;
|
||||
private final StorageProperties storageProperties;
|
||||
private final RustfsObjectStorageService rustfsObjectStorageService;
|
||||
private final OssStorageService ossStorageService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public boolean isWriteEnabled() {
|
||||
return properties.isEnabled() && rustfsObjectStorageService.isConfigured();
|
||||
return properties.isEnabled();
|
||||
}
|
||||
|
||||
public String storeScopePayload(String moduleType, Long taskId, String scopeHash, String content, boolean encodeAsJsonString) {
|
||||
@@ -68,6 +78,9 @@ public class TransientPayloadStorageService {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
if (pointer.startsWith(LOCAL_POINTER_PREFIX)) {
|
||||
return readLocalPayload(pointer.substring(LOCAL_POINTER_PREFIX.length()));
|
||||
}
|
||||
if (pointer.startsWith(RUSTFS_POINTER_PREFIX)) {
|
||||
return rustfsObjectStorageService.readObjectAsString(pointer.substring(RUSTFS_POINTER_PREFIX.length()));
|
||||
}
|
||||
@@ -85,6 +98,10 @@ public class TransientPayloadStorageService {
|
||||
if (pointer == null) {
|
||||
return;
|
||||
}
|
||||
if (pointer.startsWith(LOCAL_POINTER_PREFIX)) {
|
||||
deleteLocalPayload(pointer.substring(LOCAL_POINTER_PREFIX.length()));
|
||||
return;
|
||||
}
|
||||
if (pointer.startsWith(RUSTFS_POINTER_PREFIX)) {
|
||||
rustfsObjectStorageService.deleteObject(pointer.substring(RUSTFS_POINTER_PREFIX.length()));
|
||||
return;
|
||||
@@ -107,19 +124,28 @@ public class TransientPayloadStorageService {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (isPointer(value)) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
String decoded = objectMapper.readValue(value, String.class);
|
||||
return isPointer(decoded) ? decoded : null;
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
String candidate = value.trim();
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (isPointer(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
try {
|
||||
String decoded = objectMapper.readValue(candidate, String.class);
|
||||
if (decoded == null || decoded.equals(candidate)) {
|
||||
return null;
|
||||
}
|
||||
candidate = decoded.trim();
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isPointer(String value) {
|
||||
return value != null && (value.startsWith(RUSTFS_POINTER_PREFIX) || value.startsWith(OSS_POINTER_PREFIX));
|
||||
return value != null && (value.startsWith(LOCAL_POINTER_PREFIX)
|
||||
|| value.startsWith(RUSTFS_POINTER_PREFIX)
|
||||
|| value.startsWith(OSS_POINTER_PREFIX));
|
||||
}
|
||||
|
||||
private String store(String category,
|
||||
@@ -144,7 +170,19 @@ public class TransientPayloadStorageService {
|
||||
return content;
|
||||
}
|
||||
String objectKey = buildObjectKey(category, moduleType, taskId, scopeHash, entryKey);
|
||||
String pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadText(objectKey, content, verifyAfterUpload);
|
||||
String pointer = storeLocal(objectKey, content);
|
||||
if (pointer == null && rustfsObjectStorageService.isConfigured()) {
|
||||
try {
|
||||
pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadText(objectKey, content, verifyAfterUpload);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[transient-payload] rustfs upload failed and local fallback unavailable objectKey={} err={}",
|
||||
objectKey, ex.getMessage());
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
if (pointer == null) {
|
||||
return content;
|
||||
}
|
||||
if (!encodeAsJsonString) {
|
||||
return pointer;
|
||||
}
|
||||
@@ -155,6 +193,61 @@ public class TransientPayloadStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
private String storeLocal(String objectKey, String content) {
|
||||
try {
|
||||
Path root = localPayloadRoot();
|
||||
Path target = root.resolve(objectKey).normalize();
|
||||
if (!target.startsWith(root)) {
|
||||
throw new IllegalArgumentException("invalid local payload key: " + objectKey);
|
||||
}
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.writeString(target,
|
||||
content == null ? "" : content,
|
||||
StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE);
|
||||
return LOCAL_POINTER_PREFIX + objectKey;
|
||||
} catch (Exception ex) {
|
||||
log.warn("[transient-payload] local store failed objectKey={} err={}", objectKey, ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String readLocalPayload(String objectKey) {
|
||||
try {
|
||||
Path target = resolveLocalPayloadPath(objectKey);
|
||||
return Files.readString(target, StandardCharsets.UTF_8);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("failed to read local transient payload", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteLocalPayload(String objectKey) {
|
||||
try {
|
||||
Files.deleteIfExists(resolveLocalPayloadPath(objectKey));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[transient-payload] delete local payload failed objectKey={} err={}", objectKey, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveLocalPayloadPath(String objectKey) {
|
||||
Path root = localPayloadRoot();
|
||||
Path target = root.resolve(objectKey == null ? "" : objectKey).normalize();
|
||||
if (!target.startsWith(root)) {
|
||||
throw new IllegalArgumentException("invalid local payload key: " + objectKey);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private Path localPayloadRoot() {
|
||||
String configuredRoot = storageProperties.getLocalTempDir();
|
||||
Path tempRoot = configuredRoot == null || configuredRoot.isBlank()
|
||||
? Path.of(System.getProperty("java.io.tmpdir"))
|
||||
: Path.of(configuredRoot);
|
||||
return tempRoot.resolve(LOCAL_PAYLOAD_DIR).normalize();
|
||||
}
|
||||
|
||||
private String buildObjectKey(String category, String moduleType, Long taskId, String scopeHash, String entryKey) {
|
||||
String normalizedCategory = normalize(category, "unknown");
|
||||
String normalizedModuleType = normalize(moduleType, "unknown");
|
||||
|
||||
@@ -22,4 +22,6 @@ public class ZiniaoShopIndexEntryDto {
|
||||
private List<Long> sampleUserIds = new ArrayList<>();
|
||||
private Long lastSeenAt;
|
||||
private Long lastRefreshedAt;
|
||||
private Long lastRefreshBlockedAt;
|
||||
private String refreshBlockedReason;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ public class ZiniaoShopIndexService {
|
||||
private static final Duration DEFAULT_CURSOR_TTL = Duration.ofHours(24);
|
||||
private static final int DEFAULT_REFRESH_BATCH_SIZE = 100;
|
||||
private static final int SHOP_INDEX_LIST_LIMIT = 10000;
|
||||
private static final String REFRESH_BLOCKED_REASON_IP_WHITELIST = "IP_WHITELIST";
|
||||
|
||||
private final ZiniaoMemoryStoreService ziniaoMemoryStoreService;
|
||||
private final ZiniaoTransientCacheService ziniaoTransientCacheService;
|
||||
@@ -339,6 +340,9 @@ public class ZiniaoShopIndexService {
|
||||
cursor.setStatus("FAILED");
|
||||
cursor.setMessage(ex.getMessage());
|
||||
cursor.setLastFinishedAt(now);
|
||||
if (isIpWhitelistRefreshFailure(ex)) {
|
||||
markExistingEntriesRefreshBlocked(now, REFRESH_BLOCKED_REASON_IP_WHITELIST, ex.getMessage());
|
||||
}
|
||||
ziniaoTransientCacheService.put(CACHE_TYPE_SHOP_INDEX_REFRESH_CURSOR, "global", cursor, DEFAULT_CURSOR_TTL);
|
||||
log.warn("[ziniao-index] refresh failed status=FAILED msg={}", ex.getMessage());
|
||||
if (ex instanceof BusinessException businessException) {
|
||||
@@ -559,6 +563,11 @@ public class ZiniaoShopIndexService {
|
||||
if (entry.getLastRefreshedAt() == null || entry.getLastRefreshedAt() <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (REFRESH_BLOCKED_REASON_IP_WHITELIST.equals(entry.getRefreshBlockedReason())
|
||||
&& entry.getLastRefreshBlockedAt() != null
|
||||
&& entry.getLastRefreshBlockedAt() >= entry.getLastRefreshedAt()) {
|
||||
return true;
|
||||
}
|
||||
ZiniaoShopIndexRefreshCursorDto cursor = getRefreshCursor();
|
||||
if (cursor == null || !"FAILED".equals(cursor.getStatus())) {
|
||||
return false;
|
||||
@@ -571,6 +580,49 @@ public class ZiniaoShopIndexService {
|
||||
return lastFinishedAt != null && lastFinishedAt >= entry.getLastRefreshedAt();
|
||||
}
|
||||
|
||||
private boolean isIpWhitelistRefreshFailure(Exception ex) {
|
||||
if (ex instanceof BusinessException businessException
|
||||
&& ziniaoAuthService.isIpWhitelistError(businessException)) {
|
||||
return true;
|
||||
}
|
||||
String message = ex == null ? null : ex.getMessage();
|
||||
return message != null && message.contains("白名单");
|
||||
}
|
||||
|
||||
private void markExistingEntriesRefreshBlocked(long now, String reason, String message) {
|
||||
List<ZiniaoMemoryStoreEntity> entities = ziniaoMemoryStoreService.listAliveEntitiesByType(
|
||||
ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY,
|
||||
SHOP_INDEX_LIST_LIMIT
|
||||
);
|
||||
if (entities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int updated = 0;
|
||||
for (ZiniaoMemoryStoreEntity entity : entities) {
|
||||
String rowKey = entity.getCacheKey();
|
||||
ZiniaoShopIndexEntryDto existingEntry;
|
||||
try {
|
||||
existingEntry = objectMapper.readValue(entity.getPayloadJson(), ZiniaoShopIndexEntryDto.class);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[ziniao-index] skip refresh-block mark corrupt shop_index row cacheKey={}", rowKey);
|
||||
continue;
|
||||
}
|
||||
if (existingEntry == null || !STATUS_ACTIVE.equals(existingEntry.getStatus())) {
|
||||
continue;
|
||||
}
|
||||
existingEntry.setLastRefreshBlockedAt(now);
|
||||
existingEntry.setRefreshBlockedReason(reason);
|
||||
if (message != null && !message.isBlank()) {
|
||||
existingEntry.setMessage(message);
|
||||
}
|
||||
ziniaoMemoryStoreService.put(ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY, rowKey, existingEntry, resolveEntryTtl());
|
||||
updated++;
|
||||
}
|
||||
if (updated > 0) {
|
||||
log.warn("[ziniao-index] refresh blocked reason={} existing active rows kept usable count={}", reason, updated);
|
||||
}
|
||||
}
|
||||
|
||||
private Duration resolveEntryTtl() {
|
||||
Integer ttlHours = ziniaoProperties.getShopIndexEntryTtlHours();
|
||||
if (ttlHours == null || ttlHours <= 0) {
|
||||
|
||||
@@ -34,6 +34,14 @@ AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE=10
|
||||
AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS=60000
|
||||
AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES=20
|
||||
|
||||
AIIMAGE_SIMILAR_ASIN_COZE_BASE_URL=https://api.coze.cn
|
||||
AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH=/v1/workflow/run
|
||||
AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID=7632683471312355338
|
||||
AIIMAGE_SIMILAR_ASIN_COZE_TOKEN=
|
||||
AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE=10
|
||||
AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS=60000
|
||||
AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES=20
|
||||
|
||||
AIIMAGE_ROCKETMQ_NAME_SERVER=121.196.149.225:9876
|
||||
AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED=true
|
||||
AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_ENABLED=true
|
||||
|
||||
@@ -94,6 +94,7 @@ aiimage:
|
||||
cleanup-cron: ${AIIMAGE_STORAGE_CLEANUP_CRON:0 0 * * * *}
|
||||
source-retention-hours: ${AIIMAGE_STORAGE_SOURCE_RETENTION_HOURS:24}
|
||||
result-retention-hours: ${AIIMAGE_STORAGE_RESULT_RETENTION_HOURS:24}
|
||||
transient-payload-retention-hours: ${AIIMAGE_STORAGE_TRANSIENT_PAYLOAD_RETENTION_HOURS:72}
|
||||
temp-dir:
|
||||
retention-hours: ${AIIMAGE_TEMP_DIR_RETENTION_HOURS:24}
|
||||
brand-progress:
|
||||
@@ -103,6 +104,7 @@ aiimage:
|
||||
stale-check-cron: ${AIIMAGE_BRAND_PROGRESS_STALE_CHECK_CRON:0 */2 * * * *}
|
||||
delete-brand-progress:
|
||||
heartbeat-timeout-minutes: ${AIIMAGE_DELETE_BRAND_HEARTBEAT_TIMEOUT_MINUTES:15}
|
||||
delete-brand-initial-timeout-minutes: ${AIIMAGE_DELETE_BRAND_INITIAL_TIMEOUT_MINUTES:20}
|
||||
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:20}
|
||||
@@ -148,6 +150,16 @@ aiimage:
|
||||
coze-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS:60000}
|
||||
stale-timeout-minutes: ${AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES:20}
|
||||
stale-finalize-cron: ${AIIMAGE_APPEARANCE_PATENT_STALE_FINALIZE_CRON:0 */2 * * * *}
|
||||
similar-asin:
|
||||
coze-base-url: ${AIIMAGE_SIMILAR_ASIN_COZE_BASE_URL:https://api.coze.cn}
|
||||
coze-workflow-path: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH:/v1/workflow/run}
|
||||
coze-workflow-id: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID:7632683471312355338}
|
||||
coze-token: ${AIIMAGE_SIMILAR_ASIN_COZE_TOKEN:Bearer sat_gsInckdsN0qqhnZSK7K4Zevw6neJCHmYHyIbm85oGgJIGEmSdkH5OFctSKLsAHvT}
|
||||
coze-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE:10}
|
||||
coze-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_CONNECT_TIMEOUT_MILLIS:10000}
|
||||
coze-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS:60000}
|
||||
stale-timeout-minutes: ${AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES:20}
|
||||
stale-finalize-cron: ${AIIMAGE_SIMILAR_ASIN_STALE_FINALIZE_CRON:0 */2 * * * *}
|
||||
security:
|
||||
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
|
||||
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
SET @coze_execute_id_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_task_scope_state'
|
||||
AND COLUMN_NAME = 'coze_execute_id'
|
||||
);
|
||||
SET @sql_add_coze_execute_id := IF(
|
||||
@coze_execute_id_exists = 0,
|
||||
'ALTER TABLE biz_task_scope_state ADD COLUMN coze_execute_id VARCHAR(128) NULL COMMENT ''Coze execute id'' AFTER state_json',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_coze_execute_id FROM @sql_add_coze_execute_id;
|
||||
EXECUTE stmt_add_coze_execute_id;
|
||||
DEALLOCATE PREPARE stmt_add_coze_execute_id;
|
||||
|
||||
SET @coze_status_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_task_scope_state'
|
||||
AND COLUMN_NAME = 'coze_status'
|
||||
);
|
||||
SET @sql_add_coze_status := IF(
|
||||
@coze_status_exists = 0,
|
||||
'ALTER TABLE biz_task_scope_state ADD COLUMN coze_status VARCHAR(32) NULL COMMENT ''Coze workflow status'' AFTER coze_execute_id',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_coze_status FROM @sql_add_coze_status;
|
||||
EXECUTE stmt_add_coze_status;
|
||||
DEALLOCATE PREPARE stmt_add_coze_status;
|
||||
|
||||
SET @coze_submitted_at_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_task_scope_state'
|
||||
AND COLUMN_NAME = 'coze_submitted_at'
|
||||
);
|
||||
SET @sql_add_coze_submitted_at := IF(
|
||||
@coze_submitted_at_exists = 0,
|
||||
'ALTER TABLE biz_task_scope_state ADD COLUMN coze_submitted_at DATETIME NULL COMMENT ''Coze submitted time'' AFTER coze_status',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_coze_submitted_at FROM @sql_add_coze_submitted_at;
|
||||
EXECUTE stmt_add_coze_submitted_at;
|
||||
DEALLOCATE PREPARE stmt_add_coze_submitted_at;
|
||||
|
||||
SET @coze_last_polled_at_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_task_scope_state'
|
||||
AND COLUMN_NAME = 'coze_last_polled_at'
|
||||
);
|
||||
SET @sql_add_coze_last_polled_at := IF(
|
||||
@coze_last_polled_at_exists = 0,
|
||||
'ALTER TABLE biz_task_scope_state ADD COLUMN coze_last_polled_at DATETIME NULL COMMENT ''Coze last polled time'' AFTER coze_submitted_at',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_coze_last_polled_at FROM @sql_add_coze_last_polled_at;
|
||||
EXECUTE stmt_add_coze_last_polled_at;
|
||||
DEALLOCATE PREPARE stmt_add_coze_last_polled_at;
|
||||
|
||||
SET @coze_completed_at_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_task_scope_state'
|
||||
AND COLUMN_NAME = 'coze_completed_at'
|
||||
);
|
||||
SET @sql_add_coze_completed_at := IF(
|
||||
@coze_completed_at_exists = 0,
|
||||
'ALTER TABLE biz_task_scope_state ADD COLUMN coze_completed_at DATETIME NULL COMMENT ''Coze completed time'' AFTER coze_last_polled_at',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_coze_completed_at FROM @sql_add_coze_completed_at;
|
||||
EXECUTE stmt_add_coze_completed_at;
|
||||
DEALLOCATE PREPARE stmt_add_coze_completed_at;
|
||||
|
||||
SET @coze_attempt_count_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_task_scope_state'
|
||||
AND COLUMN_NAME = 'coze_attempt_count'
|
||||
);
|
||||
SET @sql_add_coze_attempt_count := IF(
|
||||
@coze_attempt_count_exists = 0,
|
||||
'ALTER TABLE biz_task_scope_state ADD COLUMN coze_attempt_count INT NOT NULL DEFAULT 0 COMMENT ''Coze poll attempt count'' AFTER coze_completed_at',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_coze_attempt_count FROM @sql_add_coze_attempt_count;
|
||||
EXECUTE stmt_add_coze_attempt_count;
|
||||
DEALLOCATE PREPARE stmt_add_coze_attempt_count;
|
||||
|
||||
SET @coze_error_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_task_scope_state'
|
||||
AND COLUMN_NAME = 'coze_error'
|
||||
);
|
||||
SET @sql_add_coze_error := IF(
|
||||
@coze_error_exists = 0,
|
||||
'ALTER TABLE biz_task_scope_state ADD COLUMN coze_error VARCHAR(1000) NULL COMMENT ''Coze error message'' AFTER coze_attempt_count',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_coze_error FROM @sql_add_coze_error;
|
||||
EXECUTE stmt_add_coze_error;
|
||||
DEALLOCATE PREPARE stmt_add_coze_error;
|
||||
|
||||
SET @idx_task_coze_status_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_task_scope_state'
|
||||
AND INDEX_NAME = 'idx_task_coze_status'
|
||||
);
|
||||
SET @sql_add_idx_task_coze_status := IF(
|
||||
@idx_task_coze_status_exists = 0,
|
||||
'ALTER TABLE biz_task_scope_state ADD INDEX idx_task_coze_status (module_type, coze_status, updated_at)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_idx_task_coze_status FROM @sql_add_idx_task_coze_status;
|
||||
EXECUTE stmt_add_idx_task_coze_status;
|
||||
DEALLOCATE PREPARE stmt_add_idx_task_coze_status;
|
||||
@@ -0,0 +1,15 @@
|
||||
SET @idx_file_task_delete_brand_history_updated_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'biz_file_task'
|
||||
AND INDEX_NAME = 'idx_file_task_delete_brand_history_updated'
|
||||
);
|
||||
SET @sql_add_idx_file_task_delete_brand_history_updated := IF(
|
||||
@idx_file_task_delete_brand_history_updated_exists = 0,
|
||||
'ALTER TABLE biz_file_task ADD INDEX idx_file_task_delete_brand_history_updated (module_type, user_id, updated_at)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_add_idx_file_task_delete_brand_history_updated FROM @sql_add_idx_file_task_delete_brand_history_updated;
|
||||
EXECUTE stmt_add_idx_file_task_delete_brand_history_updated;
|
||||
DEALLOCATE PREPARE stmt_add_idx_file_task_delete_brand_history_updated;
|
||||
14
backend-java/src/main/resources/db/V45__similar_asin.sql
Normal file
14
backend-java/src/main/resources/db/V45__similar_asin.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Similar ASIN detection reuses the generic file task/result/chunk tables.
|
||||
-- Register the frontend app menu column so permission queries can expose the page.
|
||||
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `sort_order`)
|
||||
SELECT '相似ASIN检测', 'similar_asin', 'app', 'similar-asin', 112
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `columns` WHERE `column_key` = 'similar_asin'
|
||||
);
|
||||
|
||||
UPDATE `columns`
|
||||
SET `name` = '相似ASIN检测',
|
||||
`menu_type` = 'app',
|
||||
`route_path` = 'similar-asin',
|
||||
`sort_order` = 112
|
||||
WHERE `column_key` = 'similar_asin';
|
||||
Reference in New Issue
Block a user