From 73d25fcbcbdc806515ffac5d936698197c45444e Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Wed, 20 May 2026 09:05:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=B8=80=E4=BA=9B=E6=9B=B4?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/.env | 9 +- app/amazon/similar_asin.py | 14 +- .../common/util/FailedStatusRowFilter.java | 114 + .../config/AppearancePatentProperties.java | 2 +- .../aiimage/config/SimilarAsinProperties.java | 23 +- .../aiimage/config/TaskFileJobConfig.java | 2 +- .../client/AppearancePatentCozeClient.java | 6 + .../dto/AppearancePatentResultRowDto.java | 10 + .../vo/AppearancePatentHistoryItemVo.java | 4 + .../service/AppearancePatentTaskService.java | 212 +- .../service/CozeCredentialPoolService.java | 17 + .../client/SimilarAsinCozeClient.java | 149 +- .../model/vo/SimilarAsinHistoryItemVo.java | 4 + .../service/SimilarAsinTaskService.java | 750 ++- .../TransientPayloadStorageService.java | 74 +- .../resources/application-local.example.yml | 4 +- .../src/main/resources/application.yml | 15 +- .../db/V52__rotate_coze_credential_tokens.sql | 28 + .../util/FailedStatusRowFilterTest.java | 68 + .../components/BrandAppearancePatentTab.vue | 25 +- .../brand/components/BrandQueryAsinTab.vue | 67 + .../brand/components/BrandSimilarAsinTab.vue | 306 +- frontend-vue/src/shared/api/java-modules.ts | 44 +- web_source/templates_backup/admin.html | 539 -- web_source/templates_backup/brand.html | 847 --- web_source/templates_backup/home.html | 352 - web_source/templates_backup/index.html | 5833 ----------------- web_source/templates_backup/login.html | 164 - 28 files changed, 1620 insertions(+), 8062 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/common/util/FailedStatusRowFilter.java create mode 100644 backend-java/src/main/resources/db/V52__rotate_coze_credential_tokens.sql create mode 100644 backend-java/src/test/java/com/nanri/aiimage/common/util/FailedStatusRowFilterTest.java delete mode 100644 web_source/templates_backup/admin.html delete mode 100644 web_source/templates_backup/brand.html delete mode 100644 web_source/templates_backup/home.html delete mode 100644 web_source/templates_backup/index.html delete mode 100644 web_source/templates_backup/login.html diff --git a/app/.env b/app/.env index 49ccb5d..7e54df9 100644 --- a/app/.env +++ b/app/.env @@ -3,7 +3,7 @@ workflow_id=7608812635877900322 mysql_host=47.110.241.161 mysql_user=aiimage -proxy_url=https://api.jikip.com/ip-get?num=1&minute=1&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8 +proxy_url=https://api.jikip.com/ip-get?num=1&minute=3&format=json&area=all&protocol=1&mode=2&key=t24g6gi44ubufd8 proxy_mode=2 zn_company=rongchuang123 @@ -11,9 +11,10 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot client_name=ShuFuAI - # java_api_base=http://47.111.163.154:18080 -# java_api_base=http://127.0.0.1:18080 -java_api_base=http://121.196.149.225:18080 +java_api_base=http://127.0.0.1:18080 +# java_api_base=http://121.196.149.225:18080 + + diff --git a/app/amazon/similar_asin.py b/app/amazon/similar_asin.py index 925956c..aaedbcb 100644 --- a/app/amazon/similar_asin.py +++ b/app/amazon/similar_asin.py @@ -484,7 +484,11 @@ class SimilarAsinTask(TaskBase): return_data = { 'image_url': "", - 'title': "" + 'title': "", + 'category' : '', + 'sku' : '', + 'success' : False, + 'similar_data' : [] } asin = value.get("asin") country = value.get("country") @@ -494,8 +498,9 @@ class SimilarAsinTask(TaskBase): self.log(f"抓取结果->{return_data}") break except Exception as e: - if "与页面的连接已断开" in str(e): - chrome = ChromeAmzone() + # if "与页面的连接已断开" in str(e): + chrome = ChromeAmzone() + self.log(f"{asin}抓取数据报错,{e}") if not isinstance(return_data, dict): return_data = {} if return_data.get("image_url"): @@ -564,7 +569,8 @@ class SimilarAsinTask(TaskBase): self.log(f"任务 {task_id} 已被暂停!") else: runing_task[task_id]["status"] = "completed" - self.log(f"任务 {task_id} 处理完成!") + + self.log(f"任务 {task_id} 处理完成!") except Exception as e: self.log(f"任务处理失败: {traceback.format_exc()}", "ERROR") diff --git a/backend-java/src/main/java/com/nanri/aiimage/common/util/FailedStatusRowFilter.java b/backend-java/src/main/java/com/nanri/aiimage/common/util/FailedStatusRowFilter.java new file mode 100644 index 0000000..b2e803b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/common/util/FailedStatusRowFilter.java @@ -0,0 +1,114 @@ +package com.nanri.aiimage.common.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; + +public final class FailedStatusRowFilter { + + private static final String STATUS_HEADER_CN = "\u72b6\u6001"; + private static final String FAILED_STATUS_ERROR = "\u9519\u8bef"; + private static final String FAILED_STATUS_FAILED_CN = "\u5931\u8d25"; + private static final String FAILED_STATUS_FAILED_EN = "failed"; + private static final String NO_MATCHED_ROWS_MESSAGE = + "\u72b6\u6001\u5217\u8fc7\u6ee4\u540e\u672a\u627e\u5230 \u9519\u8bef/\u5931\u8d25/FAILED \u6570\u636e"; + + private static final Set STATUS_HEADER_ALIASES = Set.of( + normalizeHeader(STATUS_HEADER_CN), + normalizeHeader("status") + ); + + private static final Set FAILED_STATUS_VALUES = Set.of( + normalizeValue(FAILED_STATUS_ERROR), + normalizeValue(FAILED_STATUS_FAILED_CN), + normalizeValue(FAILED_STATUS_FAILED_EN) + ); + + private FailedStatusRowFilter() { + } + + public static int findStatusColumnIndex(List headers) { + if (headers == null || headers.isEmpty()) { + return -1; + } + for (int i = 0; i < headers.size(); i++) { + if (STATUS_HEADER_ALIASES.contains(normalizeHeader(headers.get(i)))) { + return i; + } + } + return -1; + } + + public static boolean matchesFailedStatus(String value) { + return FAILED_STATUS_VALUES.contains(normalizeValue(value)); + } + + public static boolean isBlankStatus(String value) { + return normalizeValue(value).isBlank(); + } + + public static String noMatchedRowsMessage() { + return NO_MATCHED_ROWS_MESSAGE; + } + + /** + * 暴露表头归一化逻辑给其它模块复用,避免每个模块各自维护一份规则。 + * 处理:去 BOM、全角空格转半角、首尾 trim、连续空白合一、转小写, + * 并剥除常见分隔符(含中文全角括号、方括号、冒号)。 + */ + public static String canonicalizeHeader(String value) { + return normalizeHeader(value); + } + + public static FilterResult retainFailedRows(List rows, + boolean statusFilteringEnabled, + Function statusReader) { + return retainRows(rows, statusFilteringEnabled, statusReader, FailedStatusRowFilter::matchesFailedStatus); + } + + public static FilterResult retainRows(List rows, + boolean statusFilteringEnabled, + Function statusReader, + Predicate statusMatcher) { + List safeRows = rows == null ? List.of() : rows; + if (!statusFilteringEnabled) { + return new FilterResult<>(false, safeRows, 0); + } + List filteredRows = new ArrayList<>(); + int filteredCount = 0; + for (T row : safeRows) { + String status = statusReader == null ? "" : statusReader.apply(row); + boolean matched = statusMatcher != null && statusMatcher.test(status); + if (matched) { + filteredRows.add(row); + } else { + filteredCount++; + } + } + return new FilterResult<>(true, filteredRows, filteredCount); + } + + private static String normalizeHeader(String value) { + // 扩展:兼容中文全角括号 ()、中文方括号 【】、中文冒号 :,避免 + // "状态(结果)"、"标题维度(商标)" 等全角括号表头被识别失败。 + return normalizeValue(value).replaceAll("[\\s_\\-()\\[\\]{}::()【】]+", ""); + } + + private static String normalizeValue(String value) { + if (value == null) { + return ""; + } + return value + .replace(String.valueOf((char) 0xFEFF), "") + .replace((char) 0x3000, ' ') + .trim() + .replaceAll("\\s+", " ") + .toLowerCase(Locale.ROOT); + } + + public record FilterResult(boolean filterApplied, List rows, int filteredCount) { + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java index f8fb85e..a8a676f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java @@ -21,7 +21,7 @@ public class AppearancePatentProperties { private int cozeReadTimeoutMillis = 60000; private int cozePollIntervalMillis = 30000; private int cozePollTimeoutMillis = 600000; - private int staleTimeoutMinutes = 20; + private int staleTimeoutMinutes = 30; private String staleFinalizeCron = "0 */2 * * * *"; @Data diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java index 8fad3e8..783813f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java @@ -15,15 +15,32 @@ public class SimilarAsinProperties { private String cozeWorkflowId = "7635328462404583478"; private String cozeToken = ""; private List cozeCredentials = new ArrayList<>(); - private int cozeCredentialStripeSize = 5; - private int cozeBatchSize = 50; + private int cozeCredentialStripeSize = 1; + private int cozeBatchSize = 10; private int cozeConnectTimeoutMillis = 10000; private int cozeReadTimeoutMillis = 60000; private int cozePollIntervalMillis = 30000; private int cozePollTimeoutMillis = 600000; - private int staleTimeoutMinutes = 20; + private int staleTimeoutMinutes = 30; private String staleFinalizeCron = "0 */2 * * * *"; + /** + * 是否在 Coze 请求 parameters 中附带 api_key 字段。 + * 默认 true:线上 Coze 工作流将该字段视为必填,缺失会得到 4000 + * "Missing required parameters";前端传入的 api_key 必须透传到 coze。 + * 仅在工作流明确不再需要 api_key 时,可通过环境变量 + * AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY=false 关闭。 + */ + private boolean cozeIncludeLegacyApiKey = true; + + /** + * 是否使用旧的 item 字段顺序 {asin, sku, url, target_urls, title}。 + * 默认 false:当前实现使用 {asin, url, target_urls, title, sku}。 + * 出现兼容问题时可通过 AIIMAGE_SIMILAR_ASIN_COZE_USE_LEGACY_ITEM_ORDER=true + * 切回旧顺序进行回归对比。 + */ + private boolean cozeUseLegacyItemFieldOrder = false; + @Data public static class CozeCredential { private String name; diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/TaskFileJobConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/TaskFileJobConfig.java index dd893cf..f56fca1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/TaskFileJobConfig.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/TaskFileJobConfig.java @@ -40,7 +40,7 @@ public class TaskFileJobConfig { @Bean("cozeTaskExecutor") public TaskExecutor cozeTaskExecutor( ExecutorService cozeVirtualThreadExecutor, - @Value("${aiimage.coze-task.max-concurrent:8}") int maxConcurrent) { + @Value("${aiimage.coze-task.max-concurrent:12}") int maxConcurrent) { Semaphore semaphore = new Semaphore(Math.max(1, maxConcurrent)); return new ConcurrentTaskExecutor(command -> cozeVirtualThreadExecutor.execute(() -> { boolean acquired = false; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java index 56ef765..5a33543 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java @@ -607,6 +607,12 @@ public class AppearancePatentCozeClient { if (row.getError() == null || row.getError().isBlank()) { row.setError(failureMessage); } + if (row.getStatus() == null || row.getStatus().isBlank()) { + row.setStatus("FAILED"); + // 标记本次 FAILED 是 markFailed 合成的,仅在内存生命周期内生效(@JsonIgnore), + // 后续导出 / 重新上传时可据此与"用户真实失败"区分,避免重复触发 retry。 + row.setFailureSyntheticStatus(true); + } if (row.getTitleRisk() == null || row.getTitleRisk().isBlank()) { row.setTitleRisk(reviewMessage); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java index 0898cdb..7198252 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java @@ -1,6 +1,7 @@ package com.nanri.aiimage.modules.appearancepatent.model.dto; import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -76,4 +77,13 @@ public class AppearancePatentResultRowDto { @JsonAlias({"patent_reason", "patentReason", "patent reason"}) @Schema(description = "Coze patent reason", accessMode = Schema.AccessMode.READ_ONLY) private String patentReason; + + /** + * 仅内存生命周期标记,标识当前 status 是 markFailed 时合成出来的(而不是用户/Python 真实回传)。 + * 不入库、不参与 chunk 序列化(@JsonIgnore),用于导出 / 重新上传判定时区分"系统合成 FAILED"与"用户真正失败", + * 避免用户拿结果簿原样再上传时被反复识别为失败行重新触发 retry。 + */ + @JsonIgnore + @Schema(hidden = true) + private boolean failureSyntheticStatus; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java index 85756f6..4cf8ffc 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java @@ -34,4 +34,8 @@ public class AppearancePatentHistoryItemVo { private Integer rowCount; @Schema(description = "历史记录创建时间,ISO 本地时间字符串。", example = "2026-04-26T10:30:00") private String createdAt; + @Schema(description = "任务开始时间,ISO 本地时间字符串。当前等价于 biz_file_task.created_at(用户点解析创建任务时刻)。", example = "2026-04-26T10:00:00") + private String startedAt; + @Schema(description = "任务结束时间,ISO 本地时间字符串。SUCCESS/FAILED 时回填,未结束为空。", example = "2026-04-26T10:10:00") + private String finishedAt; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java index 1f3f019..af01b8b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java @@ -8,6 +8,7 @@ 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.common.util.FailedStatusRowFilter; import com.nanri.aiimage.config.AppearancePatentProperties; import com.nanri.aiimage.config.InstanceMetadata; import com.nanri.aiimage.config.StorageProperties; @@ -99,6 +100,7 @@ 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_EMPTY_RESULT_MESSAGE = "Coze returned empty result rows"; private static final String COZE_STATUS_SUBMITTED = "SUBMITTED"; private static final String COZE_STATUS_RUNNING = "RUNNING"; private static final String COZE_STATUS_DONE = "DONE"; @@ -345,8 +347,7 @@ public class AppearancePatentTaskService { .thenComparing(FileResultEntity::getId, Comparator.nullsLast(Comparator.reverseOrder()))); for (FileResultEntity row : sortedRows) { FileTaskEntity task = taskMap.get(row.getTaskId()); - String taskStatus = task == null ? null : task.getStatus(); - vo.getItems().add(toHistoryItem(row, taskStatus, jobMap.get(row.getId()))); + vo.getItems().add(toHistoryItem(row, task, jobMap.get(row.getId()))); } return vo; } @@ -408,7 +409,7 @@ public class AppearancePatentTaskService { detail.setTask(toTaskItem(task)); FileResultEntity resultRow = resultByTaskId.get(taskId); if (resultRow != null) { - detail.getItems().add(toHistoryItem(resultRow, task.getStatus(), jobMap.get(resultRow.getId()))); + detail.getItems().add(toHistoryItem(resultRow, task, jobMap.get(resultRow.getId()))); } vo.getItems().add(detail); } @@ -805,8 +806,20 @@ public class AppearancePatentTaskService { long activeAssembleJobs = taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE); log.info("[appearance-patent] stale recovery probe taskId={} uploadComplete={} pendingCozeStates={} activeAssembleJobs={} persistedRows={}", taskId, uploadComplete, pendingCozeStates, activeAssembleJobs, hasPersistedResultRows(taskId)); - if (uploadComplete && (pendingCozeStates > 0 || activeAssembleJobs > 0)) { + if (isJavaSideProcessing(taskId)) { + // 防止 Coze 永远 pending 时 stale-recovery 永久 defer: + // 超过 stale-timeout-minutes × 4 仍未推进的 RUNNING 任务,强制走 finalize 链路。 + long deferCeilingMinutes = Math.max(1, properties.getStaleTimeoutMinutes()) * 4L; + LocalDateTime updatedAt = task.getUpdatedAt(); + if (updatedAt != null + && Duration.between(updatedAt, LocalDateTime.now()).toMinutes() >= deferCeilingMinutes) { + log.warn("[appearance-patent] stale recovery defer ceiling exceeded, forcing finalize taskId={} updatedAt={} ceilingMinutes={} pendingCozeStates={} activeAssembleJobs={}", + taskId, updatedAt, deferCeilingMinutes, pendingCozeStates, activeAssembleJobs); + return false; + } touchJavaSideTaskActivity(taskId); + log.info("[appearance-patent] stale recovery deferred because Java-side processing is still active taskId={} uploadComplete={} pendingCozeStates={} activeAssembleJobs={}", + taskId, uploadComplete, pendingCozeStates, activeAssembleJobs); return true; } if (!hasPersistedResultRows(taskId)) { @@ -1610,6 +1623,7 @@ public class AppearancePatentTaskService { if (batchRows == null || batchRows.isEmpty()) { return false; } + taskFileJobService.touchRunning(job.getId()); String batchScopeKey = buildCozeBatchScopeKey(task.getId(), batchRows); String batchScopeHash = DigestUtil.sha256Hex(batchScopeKey); TaskScopeStateEntity existing = taskScopeStateMapper.selectOne(new LambdaQueryWrapper() @@ -1626,7 +1640,7 @@ public class AppearancePatentTaskService { AppearancePatentCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled( batchRows, prompt, apiKey, credential, true); if (submit.immediateData() != null && !submit.immediateData().isBlank()) { - List cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData()); + List cozeRows = mergeUsableCozeRows(batchRows, submit.immediateData()); mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); return false; } @@ -1844,15 +1858,25 @@ public class AppearancePatentTaskService { if (batchRows.isEmpty() && failureMessage.isBlank()) { failureMessage = "Coze batch payload missing"; } + List cozeRows = List.of(); + if (failureMessage.isBlank()) { + try { + // 显式把 workflow status 传进 mergeUsableCozeRows: + // 业务侧 SUCCESS 但 payload 空时不再走 retry,而是直接 markFailed 落地。 + cozeRows = mergeUsableCozeRows(batchRows, poll.resolvedPayloadText(), poll.status()); + } catch (Exception ex) { + failureMessage = firstNonBlank(ex.getMessage(), COZE_EMPTY_RESULT_MESSAGE); + } + } if (!failureMessage.isBlank() && splitRetryFailedCozeBatchState(state, context, batchRows, failureMessage)) { return; } if (!failureMessage.isBlank() && retryFailedCozeBatchState(state, context, batchRows, failureMessage)) { return; } - List cozeRows = failureMessage.isBlank() - ? cozeClient.mergeRowsFromDataText(batchRows, poll.resolvedPayloadText()) - : cozeClient.markRowsFailed(batchRows, failureMessage); + if (!failureMessage.isBlank()) { + cozeRows = cozeClient.markRowsFailed(batchRows, failureMessage); + } FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId()); if (task != null) { Map> allRowsByBaseId = loadAllRowsByBaseId(task); @@ -1919,7 +1943,7 @@ public class AppearancePatentTaskService { Map> allRowsByBaseId = loadAllRowsByBaseId(task); if (submit.immediateData() != null && !submit.immediateData().isBlank()) { List cozeRows = - cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData()); + mergeUsableCozeRows(batchRows, submit.immediateData()); mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); markCozeStateTerminal(state, COZE_STATUS_DONE, null); maybeFinalizeCozeJobLocked(state.getTaskId(), context); @@ -2061,7 +2085,7 @@ public class AppearancePatentTaskService { cozeClient.credentialByName(context.credentialName()), false); if (submit.immediateData() != null && !submit.immediateData().isBlank()) { List cozeRows = - cozeClient.mergeRowsFromDataText(partRows, submit.immediateData()); + mergeUsableCozeRows(partRows, submit.immediateData()); mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); submittedAny = true; } else if (submit.executeId() != null && !submit.executeId().isBlank()) { @@ -2141,6 +2165,8 @@ public class AppearancePatentTaskService { String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT); return normalized.contains("timeout") || normalized.contains("timed out") + || normalized.contains("without output") + || normalized.contains("empty result") || normalized.contains("out of limit") || normalized.contains("execution limit") || normalized.contains("720712008") @@ -2156,6 +2182,8 @@ public class AppearancePatentTaskService { || normalized.contains("retry later") || normalized.contains("timeout") || normalized.contains("timed out") + || normalized.contains("without output") + || normalized.contains("empty result") || normalized.contains("out of limit") || normalized.contains("execution limit") || normalized.contains("702093018") @@ -2585,7 +2613,7 @@ public class AppearancePatentTaskService { Map> allRowsByBaseId = loadAllRowsByBaseId(task); if (submit.immediateData() != null && !submit.immediateData().isBlank()) { List cozeRows = - cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData()); + mergeUsableCozeRows(batchRows, submit.immediateData()); mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); markCozeStateTerminal(state, COZE_STATUS_DONE, null); maybeFinalizeCozeJobLocked(state.getTaskId(), context); @@ -3191,7 +3219,7 @@ public class AppearancePatentTaskService { row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getAppearanceRisk())); row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getPatentRisk())); row.createCell(col++).setCellValue(resultRow == null ? "未送检" : userFacingConclusion(resultRow)); - row.createCell(col).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getStatus(), "")); + row.createCell(col).setCellValue(resultRow == null ? "" : userFacingStatus(resultRow)); } writeReasonSheet(workbook, headerStyle, rowsToWrite, resultMap); workbook.write(fos); @@ -3260,6 +3288,59 @@ public class AppearancePatentTaskService { || !normalize(row.getTitleReason()).isBlank(); } + private List mergeUsableCozeRows(List batchRows, + String payloadText) throws Exception { + // 旧签名保留:未显式传 workflow status 的调用方默认按 "业务侧 SUCCESS" 对待 + // (submit immediateData 同步路径本身就意味着 coze workflow 业务侧已成功返回)。 + return mergeUsableCozeRows(batchRows, payloadText, null); + } + + /** + * 合并 Coze 回包: + *
    + *
  • workflow 业务侧 SUCCESS(status=SUCCESS 或 immediate 同步返回)但解析后无任何风险维度结果, + * 直接 {@link AppearancePatentCozeClient#markRowsFailed(List, String)} 落地,不再抛异常, + * 避免业务空结果被重复扔进 retry/split-retry 死循环。
  • + *
  • workflow 业务侧非 SUCCESS(poll status=FAILED/CANCELED 等系统侧失败)保持原行为, + * 抛 {@link IllegalStateException},让外层 retry/split-retry 链路处理。
  • + *
+ */ + private List mergeUsableCozeRows(List batchRows, + String payloadText, + String workflowStatus) throws Exception { + List cozeRows = cozeClient.mergeRowsFromDataText(batchRows, payloadText); + if (isUnusableCozePayload(cozeRows)) { + if (isBusinessSuccessStatus(workflowStatus)) { + log.warn("[appearance-patent] coze business success but empty payload, markFailed batchRows={} status={}", + batchRows == null ? 0 : batchRows.size(), workflowStatus); + return cozeClient.markRowsFailed(batchRows, "Coze 业务返回空结果"); + } + throw new IllegalStateException(COZE_EMPTY_RESULT_MESSAGE); + } + return cozeRows; + } + + private boolean isBusinessSuccessStatus(String status) { + // submit 同步路径没有 status,但回了 data 字段就视为业务 SUCCESS。 + if (status == null || status.isBlank()) { + return true; + } + String normalized = status.trim().toUpperCase(Locale.ROOT); + return normalized.contains("SUCCESS"); + } + + private boolean isUnusableCozePayload(List cozeRows) { + if (cozeRows == null || cozeRows.isEmpty()) { + return true; + } + boolean hasUsableOutcome = cozeRows.stream().anyMatch(row -> hasResolvedCozeFields(row) || hasReasonFields(row)); + if (hasUsableOutcome) { + return false; + } + return cozeRows.stream().allMatch(row -> isFailedCozeStatusValue(row == null ? null : row.getStatus()) + || normalize(row == null ? null : row.getStatus()).isBlank()); + } + private ParsedWorkbook parseWorkbook(File input, AppearancePatentSourceFileDto source) { DataFormatter formatter = new DataFormatter(); try (FileInputStream fis = new FileInputStream(input); Workbook workbook = WorkbookFactory.create(fis)) { @@ -3269,6 +3350,7 @@ public class AppearancePatentTaskService { throw new BusinessException("Excel 表头为空"); } Map headerMap = buildHeaderMap(header, formatter); + List headers = readHeaders(header, formatter); int idCol = findRequiredHeader(headerMap, "id"); int asinCol = findRequiredHeader(headerMap, "asin"); int countryCol = findRequiredHeader(headerMap, "国家", "country"); @@ -3279,9 +3361,12 @@ public class AppearancePatentTaskService { int titleCol = findOptionalHeaderExact(headerMap, "标题", "title", "listing title", "product title", "商品标题", "商品名称", "产品名称"); - List allRows = new ArrayList<>(); + int statusCol = FailedStatusRowFilter.findStatusColumnIndex(headers); + + List parsedRows = new ArrayList<>(); int total = 0; int dropped = 0; + int validRows = 0; String currentBlockBaseId = ""; String currentGroupKey = ""; for (int i = 1; i <= sheet.getLastRowNum(); i++) { @@ -3300,6 +3385,7 @@ public class AppearancePatentTaskService { dropped++; continue; } + validRows++; AppearancePatentParsedRowVo vo = new AppearancePatentParsedRowVo(); vo.setSourceFileKey(source.getFileKey()); vo.setSourceFilename(firstNonBlank(source.getOriginalFilename(), input.getName())); @@ -3318,13 +3404,32 @@ public class AppearancePatentTaskService { vo.setPrice(priceCol >= 0 ? cell(row, priceCol, formatter) : ""); vo.setUrl(urlCol >= 0 ? cell(row, urlCol, formatter) : ""); vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : ""); - allRows.add(vo); + parsedRows.add(new ParsedAppearanceRow(vo, statusCol >= 0 ? cell(row, statusCol, formatter) : "")); } + List allRows = parsedRows.stream() + .map(ParsedAppearanceRow::row) + .toList(); hydratePromptFields(allRows); + boolean includeBlankStatusRows = statusCol >= 0 && isAppearanceResultWorkbook(headers); + FailedStatusRowFilter.FilterResult filteredRows = + FailedStatusRowFilter.retainRows( + parsedRows, + statusCol >= 0, + ParsedAppearanceRow::sourceStatus, + status -> FailedStatusRowFilter.matchesFailedStatus(status) + || (includeBlankStatusRows && FailedStatusRowFilter.isBlankStatus(status)) + ); + dropped += filteredRows.filteredCount(); + allRows = filteredRows.rows().stream() + .map(ParsedAppearanceRow::row) + .toList(); + if (statusCol >= 0 && validRows > 0 && allRows.isEmpty()) { + throw new BusinessException(FailedStatusRowFilter.noMatchedRowsMessage()); + } if (allRows.isEmpty()) { throw new BusinessException("no valid appearance patent rows"); } - return new ParsedWorkbook(total, dropped, List.of(), allRows); + return new ParsedWorkbook(total, dropped, headers, allRows); } catch (BusinessException ex) { throw ex; } catch (Exception ex) { @@ -3356,6 +3461,41 @@ public class AppearancePatentTaskService { } } + private List readHeaders(Row header, DataFormatter formatter) { + List headers = new ArrayList<>(); + for (int i = 0; i < header.getLastCellNum(); i++) { + String val = normalize(formatter.formatCellValue(header.getCell(i))); + headers.add(val.isBlank() ? "列" + (i + 1) : val); + } + return headers; + } + + private boolean isAppearanceResultWorkbook(List headers) { + return hasHeader(headers, "状态") + && (hasHeader(headers, "结论") + || hasHeader(headers, "标题维度(商标)") + || hasHeader(headers, "外观维度(外观设计专利)") + || hasHeader(headers, "专利维度(发明/实用新型专利)")); + } + + private boolean hasHeader(List headers, String candidate) { + if (headers == null || headers.isEmpty()) { + return false; + } + // 复用 FailedStatusRowFilter.canonicalizeHeader,统一归一化规则, + // 避免专利模块自己维护一份只去半角括号的实现导致全角括号表头识别失败。 + String normalizedCandidate = FailedStatusRowFilter.canonicalizeHeader(candidate); + if (normalizedCandidate.isBlank()) { + return false; + } + for (String header : headers) { + if (normalizedCandidate.equals(FailedStatusRowFilter.canonicalizeHeader(header))) { + return true; + } + } + return false; + } + private boolean hasPromptFields(AppearancePatentParsedRowVo row) { return row != null && (!normalize(row.getTitle()).isBlank() || !normalize(row.getUrl()).isBlank()); } @@ -3521,7 +3661,7 @@ public class AppearancePatentTaskService { return vo; } - private AppearancePatentHistoryItemVo toHistoryItem(FileResultEntity row, String taskStatus, TaskFileJobEntity job) { + private AppearancePatentHistoryItemVo toHistoryItem(FileResultEntity row, FileTaskEntity task, TaskFileJobEntity job) { AppearancePatentHistoryItemVo vo = new AppearancePatentHistoryItemVo(); vo.setResultId(row.getId()); vo.setTaskId(row.getTaskId()); @@ -3529,11 +3669,14 @@ public class AppearancePatentTaskService { vo.setResultFilename(row.getResultFilename()); vo.setDownloadUrl(null); attachFileJobState(vo, row, job); - vo.setTaskStatus(taskStatus); + vo.setTaskStatus(task == null ? null : task.getStatus()); vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1); vo.setError(row.getErrorMessage()); vo.setRowCount(row.getRowCount()); vo.setCreatedAt(fmt(row.getCreatedAt())); + // 任务开始时间复用 biz_file_task.created_at;缺 task 时回退到 result.createdAt 兜底 + vo.setStartedAt(fmt(task == null ? row.getCreatedAt() : task.getCreatedAt())); + vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt())); return vo; } @@ -3880,6 +4023,9 @@ public class AppearancePatentTaskService { if (row != null && isTechnicalCozeFailure(row.getError())) { return "待人工复核"; } + if (row != null && isFailedCozeStatusValue(row.getStatus())) { + return "待人工复核"; + } return firstNonBlank(value, ""); } @@ -3894,9 +4040,30 @@ public class AppearancePatentTaskService { if (isTechnicalCozeFailure(row.getError())) { return "待人工复核"; } + if (isFailedCozeStatusValue(row.getStatus())) { + return "待人工复核"; + } return firstNonBlank(row.getConclusion(), ""); } + /** + * 导出最终 xlsx 时的状态展示: + * - 如果 status 是 markFailed 合成的(failureSyntheticStatus=true), + * 且 row 实际并没有任何风险维度结果,导出层降级为"待人工复核",避免用户原样 + * 重新上传时该行被反复识别为失败行重新触发 Coze。 + * - 真正业务侧失败 / Python 真实回传 FAILED 仍按原值显示。 + */ + private String userFacingStatus(AppearancePatentResultRowDto row) { + if (row == null) { + return ""; + } + String original = firstNonBlank(row.getStatus(), ""); + if (!row.isFailureSyntheticStatus()) { + return original; + } + return "待人工复核"; + } + private boolean isTechnicalCozeFailure(String value) { String normalized = normalize(value).toLowerCase(Locale.ROOT); return normalized.contains("coze") @@ -3906,6 +4073,14 @@ public class AppearancePatentTaskService { || normalized.contains("timeout"); } + private boolean isFailedCozeStatusValue(String status) { + String normalized = normalize(status).toLowerCase(Locale.ROOT); + return normalized.contains("fail") + || normalized.contains("error") + || normalized.contains("cancel") + || normalized.contains("失败"); + } + private String safeFileStem(String filename) { String name = filename == null || filename.isBlank() ? "appearance-patent" : filename; int idx = name.lastIndexOf('.'); @@ -4012,4 +4187,7 @@ public class AppearancePatentTaskService { private record ParsedWorkbook(int totalRows, int droppedRows, List headers, List allRows) { } + + private record ParsedAppearanceRow(AppearancePatentParsedRowVo row, String sourceStatus) { + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/coze/service/CozeCredentialPoolService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/coze/service/CozeCredentialPoolService.java index 28071aa..f79a63c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/coze/service/CozeCredentialPoolService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/coze/service/CozeCredentialPoolService.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service; import java.time.Duration; import java.util.List; import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; @Slf4j @Service @@ -19,6 +20,12 @@ public class CozeCredentialPoolService { private static final Duration INFLIGHT_TTL = Duration.ofMinutes(30); + /** + * 每个 moduleType 只 WARN 一次,避免高频日志噪音。 + * key = moduleType,value = 仅作占位,仅用 putIfAbsent 语义判断"是否已经 WARN 过"。 + */ + private final ConcurrentHashMap stripeWarnedModules = new ConcurrentHashMap<>(); + private final CozeCredentialMapper cozeCredentialMapper; private final StringRedisTemplate stringRedisTemplate; @@ -63,6 +70,16 @@ public class CozeCredentialPoolService { return null; } int safeStripeSize = Math.max(1, stripeSize); + // 自检:当 stripe < credentials.size() 时凭据轮换会偏向首张凭据, + // 通常说明 stripeSize 配置错(推荐 stripe == credentials.size())。 + // 仅 WARN 一次,避免日志噪音;不阻断启动,留运维自行调整。 + if (safeStripeSize < credentials.size() && moduleType != null && !moduleType.isBlank()) { + if (stripeWarnedModules.putIfAbsent(moduleType, Boolean.TRUE) == null) { + log.warn("[coze-credential] stripeSize({}) < credentials.size({}) for moduleType={}, " + + "round-robin will be biased; consider setting stripeSize == credentials.size()", + safeStripeSize, credentials.size(), moduleType); + } + } long cursor = nextCursor(moduleType); int index = (int) ((Math.max(0L, cursor) / safeStripeSize) % credentials.size()); return credentials.get(index); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java index a468dcb..3133d8d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java @@ -231,9 +231,6 @@ public class SimilarAsinCozeClient { Map body = new LinkedHashMap<>(); body.put("workflow_id", credential.workflowId()); body.put("parameters", parameters); - if (apiKey != null && !apiKey.isBlank()) { - body.put("api_key", apiKey.trim()); - } body.put("is_async", Boolean.TRUE); log.info("[similar-asin] coze request credential={} url={} body={}", credential.name(), @@ -351,16 +348,19 @@ public class SimilarAsinCozeClient { } private Map buildParameters(List rows, String prompt, String apiKey) { - List asins = rows.stream().map(row -> nonBlank(row.getAsin(), "")).toList(); - List skus = rows.stream().map(row -> nonBlank(row.getSku(), "")).toList(); - List titles = rows.stream().map(row -> nonBlank(row.getTitle(), row.getAsin())).toList(); - List urls = rows.stream().map(row -> nonBlank(row.getUrl(), "")).toList(); - List> urlLists = rows.stream().map(SimilarAsinResultRowDto::getUrls).toList(); + List asins = rows.stream().map(row -> safeText(row.getAsin())).toList(); + List titles = rows.stream().map(row -> safeText(firstNonBlank(row.getTitle(), row.getAsin()))).toList(); + List skus = rows.stream().map(row -> safeText(row.getSku())).toList(); + List urls = rows.stream().map(this::primaryImageUrl).toList(); + List> urlLists = rows.stream().map(this::imageUrls).toList(); Map parameters = new LinkedHashMap<>(); - parameters.put("items", buildItemObjects(asins, skus, titles, urls, urlLists)); + parameters.put("items", buildItemObjects(asins, titles, skus, urls, urlLists)); parameters.put("prompt", prompt == null ? "" : prompt); - if (apiKey != null && !apiKey.isBlank()) { + // legacy flag:当线上 coze 工作流回退到老契约时,把环境变量 + // AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY=true 即可重新塞 api_key。 + boolean includeLegacyApiKey = properties.isCozeIncludeLegacyApiKey(); + if (includeLegacyApiKey && apiKey != null && !apiKey.isBlank()) { parameters.put("api_key", apiKey.trim()); } return parameters; @@ -397,18 +397,31 @@ public class SimilarAsinCozeClient { } private List> buildItemObjects(List asins, - List skus, List titles, + List skus, List urls, List> urlLists) { + // legacy flag:保留切回旧字段顺序 {asin, sku, url, target_urls, title} 的开关, + // 默认 false 使用当前顺序 {asin, url, target_urls, title, sku}。 + boolean useLegacyOrder = properties.isCozeUseLegacyItemFieldOrder(); List> items = new ArrayList<>(asins.size()); for (int i = 0; i < asins.size(); i++) { + String url = urls.get(i); + List imageUrls = urlLists.get(i); Map item = new LinkedHashMap<>(); - item.put("asin", asins.get(i)); - item.put("sku", skus.get(i)); - item.put("title", titles.get(i)); - item.put("url", urls.get(i)); - item.put("target_urls", urlLists.get(i)); + if (useLegacyOrder) { + item.put("asin", asins.get(i)); + item.put("sku", skus.get(i)); + item.put("url", url); + item.put("target_urls", imageUrls); + item.put("title", titles.get(i)); + } else { + item.put("asin", asins.get(i)); + item.put("url", url); + item.put("target_urls", imageUrls); + item.put("title", titles.get(i)); + item.put("sku", skus.get(i)); + } items.add(item); } return items; @@ -568,6 +581,7 @@ public class SimilarAsinCozeClient { return false; } return !normalize(result.rowToken()).isBlank() + || !normalize(result.groupKey()).isBlank() || !normalize(result.rowId()).isBlank() || !normalize(result.asin()).isBlank(); } @@ -602,6 +616,48 @@ public class SimilarAsinCozeClient { row.setTitleReason(result.titleReason()); row.setAppearanceReason(result.appearanceReason()); row.setPatentReason(result.patentReason()); + normalizeRequiredBusinessFields(row); + } + + private void normalizeRequiredBusinessFields(SimilarAsinResultRowDto row) { + if (row == null || !hasBusinessCozeResult(row)) { + return; + } + boolean filledStock = false; + boolean filledSimilarity = false; + if (normalize(row.getIsStock()).isBlank()) { + row.setIsStock("\u672a\u77e5"); + filledStock = true; + } + if (normalize(row.getSimilarity()).isBlank()) { + row.setSimilarity("0%"); + filledSimilarity = true; + } + // \u5728 error \u5b57\u6bb5\u8ffd\u52a0 marker\uff0c\u5bfc\u51fa/BI \u4fa7\u53ef\u636e\u6b64\u533a\u5206"\u4e1a\u52a1\u771f\u5b9e\u7a7a"\u4e0e"\u4ee3\u7801\u515c\u5e95\u586b\u7684"\u3002 + // \u4ec5\u5728\u539f\u503c\u4e3a\u7a7a\u65f6\u586b\u5165\u515c\u5e95\uff0c\u6240\u4ee5\u4e24\u4e2a marker \u5404\u81ea\u72ec\u7acb\u5224\u5b9a\uff0c\u4e0d\u4f1a\u91cd\u590d\u8ffd\u52a0\u3002 + if (filledStock || filledSimilarity) { + StringBuilder marker = new StringBuilder(); + if (filledStock) { + marker.append("|coze-default-stock"); + } + if (filledSimilarity) { + marker.append("|coze-default-similarity"); + } + String existingError = row.getError(); + String markerText = marker.toString(); + if (existingError == null || existingError.isBlank()) { + row.setError(markerText.startsWith("|") ? markerText.substring(1) : markerText); + } else if (!existingError.contains(markerText.trim())) { + row.setError(existingError + markerText); + } + } + } + + private boolean hasBusinessCozeResult(SimilarAsinResultRowDto row) { + return !normalize(row.getIsConform()).isBlank() + || !normalize(row.getReason()).isBlank() + || !normalize(row.getCategory()).isBlank() + || !normalize(row.getStatus()).isBlank(); } private RestClient restClient() { @@ -1056,6 +1112,65 @@ public class SimilarAsinCozeClient { return preferred == null || preferred.isBlank() ? fallback : preferred.trim(); } + private String safeText(String value) { + return value == null ? "" : value.trim(); + } + + private List safeUrls(List urls) { + if (urls == null || urls.isEmpty()) { + return List.of(); + } + return urls.stream() + .map(this::safeText) + .filter(value -> !value.isBlank()) + .toList(); + } + + private void putIfPresent(Map item, String key, String value) { + String normalized = safeText(value); + if (!normalized.isBlank()) { + item.put(key, normalized); + } + } + + private void putIfPresent(Map item, String key, List values) { + if (values != null && !values.isEmpty()) { + item.put(key, values); + } + } + + private String primaryImageUrl(SimilarAsinResultRowDto row) { + if (row == null) { + return ""; + } + String url = safeText(row.getUrl()); + if (!url.isBlank()) { + return url; + } + List urls = safeUrls(row.getUrls()); + return urls.isEmpty() ? "" : urls.get(0); + } + + private List imageUrls(SimilarAsinResultRowDto row) { + if (row == null) { + return List.of(); + } + List urls = safeUrls(row.getUrls()); + if (!urls.isEmpty()) { + return urls; + } + String primaryUrl = primaryImageUrl(row); + return primaryUrl.isBlank() ? List.of() : List.of(primaryUrl); + } + + private String cozeGroupKey(SimilarAsinResultRowDto row) { + if (row == null) { + return ""; + } + String groupKey = safeText(row.getGroupKey()); + return groupKey.isBlank() ? rowKey(row) : groupKey; + } + private String normalize(String value) { return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim(); } @@ -1164,7 +1279,7 @@ public class SimilarAsinCozeClient { public boolean isFailed() { String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT); - return normalized.contains("FAILED") || normalized.contains("ERROR") || normalized.contains("CANCEL"); + return normalized.contains("FAIL") || normalized.contains("ERROR") || normalized.contains("CANCEL"); } public boolean isFinished() { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinHistoryItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinHistoryItemVo.java index f310d32..d920458 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinHistoryItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinHistoryItemVo.java @@ -34,4 +34,8 @@ public class SimilarAsinHistoryItemVo { private Integer rowCount; @Schema(description = "历史记录创建时间,ISO 本地时间字符串。", example = "2026-04-26T10:30:00") private String createdAt; + @Schema(description = "任务开始时间,ISO 本地时间字符串。当前等价于 biz_file_task.created_at(用户点解析创建任务时刻)。", example = "2026-04-26T10:00:00") + private String startedAt; + @Schema(description = "任务结束时间,ISO 本地时间字符串。SUCCESS/FAILED 时回填,未结束为空。", example = "2026-04-26T10:10:00") + private String finishedAt; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index 8e31a87..83ab112 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -8,6 +8,7 @@ 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.common.util.FailedStatusRowFilter; import com.nanri.aiimage.config.InstanceMetadata; import com.nanri.aiimage.config.SimilarAsinProperties; import com.nanri.aiimage.config.StorageProperties; @@ -88,6 +89,7 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.function.Supplier; +import java.util.regex.Pattern; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -105,6 +107,10 @@ public class SimilarAsinTaskService { private static final String COZE_STATUS_RUNNING = "RUNNING"; private static final String COZE_STATUS_DONE = "DONE"; private static final String COZE_STATUS_FAILED = "FAILED"; + /** orphan-coze-rows: 跨 chunk merge 时未匹配到任何 chunk 的回流行兜底落库前缀。 */ + private static final String ORPHAN_SCOPE_KEY_PREFIX = "orphan-coze-rows:"; + /** orphan 行兜底状态,写入时直接置为 DONE,避免被 stale-recovery 扫描到。 */ + private static final String ORPHAN_COZE_ROW_ERROR = "orphan-coze-row(no submitted chunk)"; private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final String CONTENT_TYPE_ZIP = "application/zip"; private static final int RESULT_ROWS_READ_RETRY_LIMIT = 3; @@ -117,9 +123,19 @@ public class SimilarAsinTaskService { private static final long COZE_SUBMIT_MIN_INTERVAL_MILLIS = 30000L; private static final int MAX_COZE_SUBMIT_RETRY_COUNT = 5; private static final int PARSE_RESPONSE_PREVIEW_LIMIT = 100; + /** + * 仅命中确定性输入失败: + * - "fields cannot be extracted from null values"(Coze 工作流空输入兜底) + * - 错误码 720712000(Coze 显式输入校验失败) + * 注意:曾经裸串匹配 "null values" 会把 "got null values during merge" 等 + * 非确定性场景误判成 deterministic,导致行被直接 markFailed 而不进 retry。 + */ + private static final Pattern DETERMINISTIC_INPUT_FAILURE_PATTERN = Pattern.compile( + "fields\\s+cannot\\s+be\\s+extracted\\s+from\\s+null\\s+values" + + "|\\b720712000\\b"); + private static final List RESULT_HEADERS = List.of( "id", - "sku", "asin", "国家", "是否有货", @@ -388,13 +404,16 @@ public class SimilarAsinTaskService { .eq(FileResultEntity::getUserId, userId) .orderByDesc(FileResultEntity::getCreatedAt) .last("limit " + safeLimit)); - Map statusMap = new LinkedHashMap<>(); + Map taskMap = new LinkedHashMap<>(); List taskIds = rows.stream().map(FileResultEntity::getTaskId).filter(Objects::nonNull).distinct().toList(); if (!taskIds.isEmpty()) { for (FileTaskEntity task : fileTaskMapper.selectList(new LambdaQueryWrapper() - .select(FileTaskEntity::getId, FileTaskEntity::getStatus) + .select(FileTaskEntity::getId, + FileTaskEntity::getStatus, + FileTaskEntity::getCreatedAt, + FileTaskEntity::getFinishedAt) .in(FileTaskEntity::getId, taskIds))) { - statusMap.put(task.getId(), task.getStatus()); + taskMap.put(task.getId(), task); } } Map jobMap = taskFileJobService.findAssembleJobsByResultIds(MODULE_TYPE, rows.stream() @@ -402,11 +421,12 @@ public class SimilarAsinTaskService { .filter(Objects::nonNull) .toList()); for (FileResultEntity row : rows) { - String taskStatus = statusMap.get(row.getTaskId()); + FileTaskEntity task = taskMap.get(row.getTaskId()); + String taskStatus = task == null ? null : task.getStatus(); if (STATUS_PENDING.equals(taskStatus)) { continue; } - vo.getItems().add(toHistoryItem(row, taskStatus, jobMap.get(row.getId()))); + vo.getItems().add(toHistoryItem(row, task, jobMap.get(row.getId()))); } return vo; } @@ -468,7 +488,7 @@ public class SimilarAsinTaskService { detail.setTask(toTaskItem(task)); FileResultEntity resultRow = resultByTaskId.get(taskId); if (resultRow != null) { - detail.getItems().add(toHistoryItem(resultRow, task.getStatus(), jobMap.get(resultRow.getId()))); + detail.getItems().add(toHistoryItem(resultRow, task, jobMap.get(resultRow.getId()))); } vo.getItems().add(detail); } @@ -878,8 +898,20 @@ public class SimilarAsinTaskService { boolean uploadComplete = isResultSubmissionComplete(taskId); long pendingCozeStates = countPendingCozeStates(taskId); long activeAssembleJobs = taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE); - if (uploadComplete && (pendingCozeStates > 0 || activeAssembleJobs > 0)) { + if (isJavaSideProcessing(taskId)) { + // 防止 Coze 永远 pending 时 stale-recovery 永久 defer: + // 超过 stale-timeout-minutes × 4 仍未推进的 RUNNING 任务,强制走 finalize 链路。 + long deferCeilingMinutes = Math.max(1, properties.getStaleTimeoutMinutes()) * 4L; + LocalDateTime updatedAt = task.getUpdatedAt(); + if (updatedAt != null + && Duration.between(updatedAt, LocalDateTime.now()).toMinutes() >= deferCeilingMinutes) { + log.warn("[similar-asin] stale recovery defer ceiling exceeded, forcing finalize taskId={} updatedAt={} ceilingMinutes={} pendingCozeStates={} activeAssembleJobs={}", + taskId, updatedAt, deferCeilingMinutes, pendingCozeStates, activeAssembleJobs); + return false; + } touchJavaSideTaskActivity(taskId); + log.info("[similar-asin] stale recovery deferred because Java-side processing is still active taskId={} uploadComplete={} pendingCozeStates={} activeAssembleJobs={}", + taskId, uploadComplete, pendingCozeStates, activeAssembleJobs); return true; } if (!hasPersistedResultRows(taskId)) { @@ -1143,6 +1175,26 @@ public class SimilarAsinTaskService { return pendingRows; } + private List collectPendingCozeCandidates(List chunks, + Map> allRowsByBaseId) { + if (chunks == null || chunks.isEmpty()) { + return List.of(); + } + List candidates = new ArrayList<>(); + for (TaskChunkEntity chunk : chunks) { + Map persistedRows = readChunkRows(chunk); + if (persistedRows.isEmpty()) { + continue; + } + List unresolvedRows = + enrichRowsForCoze(collectPendingCozeRows(persistedRows.values()), allRowsByBaseId); + for (SimilarAsinResultRowDto row : unresolvedRows) { + candidates.add(new CozeCandidate(chunk.getScopeHash(), chunk.getChunkIndex(), row)); + } + } + return candidates; + } + private List buildParsedGroups(List rows) { List groups = new ArrayList<>(); for (List siblings : groupRowsByBaseId(rows).values()) { @@ -1403,7 +1455,8 @@ public class SimilarAsinTaskService { .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) .orderByAsc(TaskChunkEntity::getChunkIndex)); Map> allRowsByBaseId = loadAllRowsByBaseId(task); - int cozeWorkUnits = countCozeWorkUnits(chunks, Math.max(1, properties.getCozeBatchSize())); + int batchSize = Math.max(1, properties.getCozeBatchSize()); + int cozeWorkUnits = countCozeWorkUnits(chunks, batchSize); int totalProgressUnits = Math.max(3, cozeWorkUnits + 3); if (countPendingCozeStates(task.getId()) > 0) { taskFileJobService.touchRunning(job.getId()); @@ -1411,12 +1464,6 @@ public class SimilarAsinTaskService { saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result"); return false; } - if (STATUS_RUNNING.equals(task.getStatus()) && !isResultSubmissionComplete(task.getId())) { - taskFileJobService.touchRunning(job.getId()); - touchJavaSideTaskActivity(task.getId()); - saveFileBuildProgress(task, job, totalProgressUnits, Math.max(1, cozeWorkUnits), "Waiting for Python upload"); - return false; - } saveFileBuildProgress(task, job, totalProgressUnits, 0, "Submitting Coze"); boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId); if (pendingCoze) { @@ -1425,6 +1472,13 @@ public class SimilarAsinTaskService { saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result"); return false; } + if (STATUS_RUNNING.equals(task.getStatus()) && !isResultSubmissionComplete(task.getId())) { + taskFileJobService.touchRunning(job.getId()); + touchJavaSideTaskActivity(task.getId()); + saveFileBuildProgress(task, job, totalProgressUnits, Math.max(1, cozeWorkUnits), + "Waiting for Python upload, submitting Coze every " + batchSize + " rows"); + return false; + } completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits); return true; } @@ -1442,11 +1496,35 @@ public class SimilarAsinTaskService { return; } log.info("[similar-asin] coze poll picked pending states count={}", states.size()); + Map> stateIdsByTaskId = new LinkedHashMap<>(); for (TaskScopeStateEntity state : states) { - if (state == null || state.getId() == null || !isCozeStateOwnedByCurrentInstance(state)) { + if (state == null || state.getId() == null || state.getTaskId() == null || !isCozeStateOwnedByCurrentInstance(state)) { continue; } - cozeTaskExecutor.execute(() -> pollPendingCozeState(state.getId())); + stateIdsByTaskId.computeIfAbsent(state.getTaskId(), key -> new ArrayList<>()).add(state.getId()); + } + for (Map.Entry> entry : stateIdsByTaskId.entrySet()) { + Long taskId = entry.getKey(); + List stateIds = new ArrayList<>(entry.getValue()); + cozeTaskExecutor.execute(() -> pollPendingCozeStatesForTask(taskId, stateIds)); + } + } + } + + private void pollPendingCozeStatesForTask(Long taskId, List stateIds) { + if (taskId == null || stateIds == null || stateIds.isEmpty()) { + return; + } + TaskDistributedLockService.LockHandle taskLockHandle = acquireTaskLock(taskId, 0L); + if (taskLockHandle == null) { + return; + } + try (taskLockHandle) { + for (Long stateId : stateIds) { + if (stateId == null) { + continue; + } + pollPendingCozeStateLocked(stateId); } } } @@ -1467,42 +1545,185 @@ public class SimilarAsinTaskService { String prompt = readAiPrompt(task); String apiKey = readApiKey(task); int batchSize = Math.max(1, properties.getCozeBatchSize()); - boolean pending = false; - for (TaskChunkEntity chunk : chunks) { - Map persistedRows = readChunkRows(chunk); - if (persistedRows.isEmpty()) { - continue; - } - List unresolvedRows = collectPendingCozeRows(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 batchRows = - unresolvedRows.subList(i, Math.min(i + batchSize, unresolvedRows.size())); - pending |= submitCozeBatch(task, result, job, chunk, batchRows, batchIndex, batchTotal, prompt, apiKey, allRowsByBaseId); - batchIndex++; + List candidates = collectPendingCozeCandidates(chunks, allRowsByBaseId); + if (candidates.isEmpty()) { + return countPendingCozeStates(task.getId()) > 0; + } + List missingImageCandidates = candidates.stream() + .filter(candidate -> candidate != null && candidate.row() != null && !candidate.row().hasImageUrl()) + .toList(); + if (!missingImageCandidates.isEmpty()) { + mergeCozeRowsIntoChunk(task, + null, + null, + cozeClient.markRowsFailed(missingImageCandidates.stream().map(CozeCandidate::row).toList(), + "image url missing, skip Coze"), + allRowsByBaseId); + log.warn("[similar-asin] skip coze rows without image url taskId={} jobId={} rows={}", + task.getId(), job.getId(), missingImageCandidates.size()); + } + List readyCandidates = candidates.stream() + .filter(candidate -> candidate != null && candidate.row() != null && candidate.row().hasImageUrl()) + .toList(); + boolean flushRemainder = isResultSubmissionComplete(task.getId()); + // 防止 Python 端长时间慢回传时零头永久挂着:job.updatedAt 距今 ≥ 10 分钟则强制 flush。 + if (!flushRemainder && readyCandidates.size() > 0) { + LocalDateTime jobUpdatedAt = job.getUpdatedAt(); + long pendingFlushMinutes = 10L; + if (jobUpdatedAt != null + && Duration.between(jobUpdatedAt, LocalDateTime.now()).toMinutes() >= pendingFlushMinutes) { + flushRemainder = true; + log.warn("[similar-asin] coze batch flush triggered by stale timer taskId={} jobId={} pendingRows={} batchSize={} jobUpdatedAt={} flushAfterMinutes={}", + task.getId(), job.getId(), readyCandidates.size(), batchSize, jobUpdatedAt, pendingFlushMinutes); } } + int submitLimit = (readyCandidates.size() / batchSize) * batchSize; + if (flushRemainder && submitLimit < readyCandidates.size()) { + submitLimit = readyCandidates.size(); + } + if (submitLimit <= 0) { + log.info("[similar-asin] coze batch waiting for more rows taskId={} jobId={} pendingRows={} batchSize={} finalUpload={}", + task.getId(), job.getId(), readyCandidates.size(), batchSize, flushRemainder); + return countPendingCozeStates(task.getId()) > 0; + } + boolean pending = false; + int batchTotal = Math.max(1, (submitLimit + batchSize - 1) / batchSize); + int batchIndex = 1; + for (int i = 0; i < submitLimit; i += batchSize) { + List batchCandidates = readyCandidates.subList(i, Math.min(i + batchSize, submitLimit)); + pending |= submitCozeBatch(task, result, job, batchCandidates, batchIndex, batchTotal, prompt, apiKey, allRowsByBaseId); + batchIndex++; + } return pending || countPendingCozeStates(task.getId()) > 0; } + private List enrichRowsForCoze(List rows, + Map> allRowsByBaseId) { + if (rows == null || rows.isEmpty()) { + return List.of(); + } + List enrichedRows = new ArrayList<>(rows.size()); + for (SimilarAsinResultRowDto row : rows) { + enrichedRows.add(enrichRowForCoze(row, allRowsByBaseId)); + } + return enrichedRows; + } + + private SimilarAsinResultRowDto enrichRowForCoze(SimilarAsinResultRowDto row, + Map> allRowsByBaseId) { + if (row == null || allRowsByBaseId == null || allRowsByBaseId.isEmpty()) { + return row; + } + SimilarAsinParsedRowVo parsedRow = findParsedRow(row, allRowsByBaseId); + if (parsedRow == null) { + return row; + } + if (normalize(row.getUrl()).isBlank()) { + String parsedUrl = firstNonBlank(parsedRow.getUrl(), readValueByHeader(parsedRow, + "url", "rul", "image", "img", "pic", "picture", "link")); + if (!normalize(parsedUrl).isBlank()) { + row.setUrl(parsedUrl); + } + } + if (normalize(row.getTitle()).isBlank()) { + row.setTitle(parsedRow.getTitle()); + } + if (normalize(row.getSku()).isBlank()) { + row.setSku(parsedRow.getSku()); + } + if (normalize(row.getPrice()).isBlank()) { + row.setPrice(parsedRow.getPrice()); + } + if (normalize(row.getCountry()).isBlank()) { + row.setCountry(parsedRow.getCountry()); + } + if (normalize(row.getId()).isBlank()) { + row.setId(firstNonBlank(parsedRow.getDisplayId(), parsedRow.getSourceId())); + } + if (normalize(row.getRowToken()).isBlank()) { + row.setRowToken(parsedRow.getRowToken()); + } + if (normalize(row.getGroupKey()).isBlank()) { + row.setGroupKey(parsedRow.getGroupKey()); + } + return row; + } + + private SimilarAsinParsedRowVo findParsedRow(SimilarAsinResultRowDto row, + Map> allRowsByBaseId) { + if (row == null || allRowsByBaseId == null || allRowsByBaseId.isEmpty()) { + return null; + } + String groupKey = normalize(row.getGroupKey()); + List candidates = !groupKey.isBlank() + ? allRowsByBaseId.getOrDefault(groupKey, List.of()) + : List.of(); + SimilarAsinParsedRowVo matched = findParsedRowInCandidates(row, candidates); + if (matched != null) { + return matched; + } + for (List rows : allRowsByBaseId.values()) { + matched = findParsedRowInCandidates(row, rows); + if (matched != null) { + return matched; + } + } + return null; + } + + private SimilarAsinParsedRowVo findParsedRowInCandidates(SimilarAsinResultRowDto row, + List candidates) { + if (row == null || candidates == null || candidates.isEmpty()) { + return null; + } + String rowToken = normalize(row.getRowToken()); + String resultKey = rowKey(row); + for (SimilarAsinParsedRowVo candidate : candidates) { + if (candidate == null) { + continue; + } + if (!rowToken.isBlank() && rowToken.equals(normalize(candidate.getRowToken()))) { + return candidate; + } + if (!resultKey.isBlank() && resultKey.equals(rowKey(candidate))) { + return candidate; + } + } + String asin = normalize(row.getAsin()).toUpperCase(Locale.ROOT); + String country = normalize(row.getCountry()); + if (asin.isBlank()) { + return candidates.getFirst(); + } + for (SimilarAsinParsedRowVo candidate : candidates) { + if (asin.equals(normalize(candidate.getAsin()).toUpperCase(Locale.ROOT)) + && (country.isBlank() || country.equals(normalize(candidate.getCountry())))) { + return candidate; + } + } + return candidates.getFirst(); + } + private boolean submitCozeBatch(FileTaskEntity task, FileResultEntity result, TaskFileJobEntity job, - TaskChunkEntity chunk, - List batchRows, + List batchCandidates, int batchIndex, int batchTotal, String prompt, String apiKey, Map> allRowsByBaseId) { - if (batchRows == null || batchRows.isEmpty()) { + if (batchCandidates == null || batchCandidates.isEmpty()) { return false; } - String batchScopeKey = buildCozeBatchScopeKey(job.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), batchIndex); + List batchRows = batchCandidates.stream() + .map(CozeCandidate::row) + .filter(Objects::nonNull) + .toList(); + if (batchRows.isEmpty()) { + return false; + } + taskFileJobService.touchRunning(job.getId()); + String batchScopeKey = buildCozeBatchScopeKey(task.getId(), batchRows); String batchScopeHash = DigestUtil.sha256Hex(batchScopeKey); TaskScopeStateEntity existing = taskScopeStateMapper.selectOne(new LambdaQueryWrapper() .eq(TaskScopeStateEntity::getTaskId, task.getId()) @@ -1523,37 +1744,37 @@ public class SimilarAsinTaskService { if (!emptyResultMessage.isBlank()) { throw new IllegalStateException(emptyResultMessage); } - mergeCozeRowsIntoChunk(task, chunk.getScopeHash(), chunk.getChunkIndex(), cozeRows, allRowsByBaseId); + mergeCozeRowsIntoChunk(task, null, null, cozeRows, allRowsByBaseId); return false; } if (submit.executeId() == null || submit.executeId().isBlank()) { mergeCozeRowsIntoChunk(task, - chunk.getScopeHash(), - chunk.getChunkIndex(), + null, + null, cozeClient.markRowsFailed(batchRows, "Coze async execute_id missing"), allRowsByBaseId); return false; } - saveCozeBatchState(task, result, job, chunk, batchRows, batchScopeKey, batchScopeHash, + saveCozeBatchState(task, result, job, batchRows, batchScopeKey, batchScopeHash, batchIndex, batchTotal, submit.executeId(), submit.credentialName()); - log.info("[similar-asin] coze async submitted taskId={} jobId={} chunk={} batch={}/{} credential={} executeId={}", - task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal, + log.info("[similar-asin] coze async submitted taskId={} jobId={} rows={} batch={}/{} credential={} executeId={}", + task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, submit.credentialName(), submit.executeId()); return true; } catch (Exception ex) { String message = firstNonBlank(ex.getMessage(), "Coze submit failed"); - log.warn("[similar-asin] coze async submit failed taskId={} jobId={} chunk={} batch={}/{} err={}", - task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal, message); + log.warn("[similar-asin] coze async submit failed taskId={} jobId={} rows={} batch={}/{} err={}", + task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, message); if (isCozeThrottleLockTimeout(message)) { - savePendingCozeBatchState(task, result, job, chunk, batchRows, batchScopeKey, batchScopeHash, + savePendingCozeBatchState(task, result, job, batchRows, batchScopeKey, batchScopeHash, batchIndex, batchTotal, message, credential.name()); taskFileJobService.touchRunning(job.getId()); touchJavaSideTaskActivity(task.getId()); return true; } mergeCozeRowsIntoChunk(task, - chunk.getScopeHash(), - chunk.getChunkIndex(), + null, + null, cozeClient.markRowsFailed(batchRows, message), allRowsByBaseId); return false; @@ -1567,7 +1788,6 @@ public class SimilarAsinTaskService { private void savePendingCozeBatchState(FileTaskEntity task, FileResultEntity result, TaskFileJobEntity job, - TaskChunkEntity chunk, List batchRows, String batchScopeKey, String batchScopeHash, @@ -1579,8 +1799,8 @@ public class SimilarAsinTaskService { CozeBatchContext context = new CozeBatchContext( job.getId(), result.getId(), - chunk.getScopeHash(), - chunk.getChunkIndex(), + null, + null, batchIndex, batchTotal, currentInstanceId(), @@ -1610,8 +1830,8 @@ public class SimilarAsinTaskService { try { taskScopeStateMapper.insert(state); touchJavaSideTaskActivity(task.getId()); - log.info("[similar-asin] coze async submit queued by throttle taskId={} jobId={} chunk={} rows={} batch={}/{}", - task.getId(), job.getId(), chunk.getChunkIndex(), batchRows.size(), batchIndex, batchTotal); + log.info("[similar-asin] coze async submit queued by throttle taskId={} jobId={} rows={} batch={}/{}", + task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal); } catch (DuplicateKeyException ex) { transientPayloadStorageService.deletePayloadIfPresent(storedBatchPayload); log.info("[similar-asin] duplicate pending coze batch state ignored taskId={} scope={}", @@ -1622,7 +1842,6 @@ public class SimilarAsinTaskService { private void saveCozeBatchState(FileTaskEntity task, FileResultEntity result, TaskFileJobEntity job, - TaskChunkEntity chunk, List batchRows, String batchScopeKey, String batchScopeHash, @@ -1634,8 +1853,8 @@ public class SimilarAsinTaskService { CozeBatchContext context = new CozeBatchContext( job.getId(), result.getId(), - chunk.getScopeHash(), - chunk.getChunkIndex(), + null, + null, batchIndex, batchTotal, currentInstanceId(), @@ -1798,6 +2017,9 @@ public class SimilarAsinTaskService { if (state == null || context == null || batchRows == null || batchRows.isEmpty()) { return false; } + if (isDeterministicCozeInputFailure(failureMessage)) { + return false; + } if (!isRetryableCozeFailure(failureMessage) || cozeSubmitRetryCount(context) >= MAX_COZE_SUBMIT_RETRY_COUNT) { return false; } @@ -1931,6 +2153,9 @@ public class SimilarAsinTaskService { if (state == null || context == null || batchRows == null || batchRows.size() <= 1) { return false; } + if (isDeterministicCozeInputFailure(failureMessage)) { + return false; + } if (!shouldSplitCozeBatchForRetry(failureMessage) || cozeSubmitRetryCount(context) >= MAX_COZE_SUBMIT_RETRY_COUNT) { return false; } @@ -1955,7 +2180,7 @@ public class SimilarAsinTaskService { if (submit.immediateData() != null && !submit.immediateData().isBlank()) { List cozeRows = cozeClient.mergeRowsFromDataText(partRows, submit.immediateData()); - mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId); + mergeCozeRowsIntoChunk(task, null, null, cozeRows, allRowsByBaseId); submittedAny = true; } else if (submit.executeId() != null && !submit.executeId().isBlank()) { saveSplitRetryCozeBatchState(state, context, partRows, partIndex, partitions.size(), retryCount, @@ -2069,13 +2294,7 @@ public class SimilarAsinTaskService { markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze pending submit task missing"); return; } - TaskChunkEntity currentChunk = taskChunkMapper.selectOne(new LambdaQueryWrapper() - .eq(TaskChunkEntity::getTaskId, task.getId()) - .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) - .eq(TaskChunkEntity::getScopeHash, context.chunkScopeHash()) - .eq(TaskChunkEntity::getChunkIndex, context.chunkIndex()) - .last("limit 1")); - Map currentRows = readChunkRows(currentChunk); + Map currentRows = loadSubmittedRows(task.getId()); List currentBatchRows = batchRows.stream() .map(row -> currentRows.get(rowKey(row))) .filter(Objects::nonNull) @@ -2101,7 +2320,7 @@ public class SimilarAsinTaskService { if (!emptyResultMessage.isBlank()) { throw new IllegalStateException(emptyResultMessage); } - mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId); + mergeCozeRowsIntoChunk(task, null, null, cozeRows, allRowsByBaseId); markCozeStateTerminal(state, COZE_STATUS_DONE, null); maybeFinalizeCozeJobLocked(state.getTaskId(), context); return; @@ -2109,8 +2328,8 @@ public class SimilarAsinTaskService { if (submit.executeId() == null || submit.executeId().isBlank()) { markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze async execute_id missing"); mergeCozeRowsIntoChunk(task, - context.chunkScopeHash(), - context.chunkIndex(), + null, + null, cozeClient.markRowsFailed(batchRows, "Coze async execute_id missing"), allRowsByBaseId); maybeFinalizeCozeJobLocked(state.getTaskId(), context); @@ -2142,8 +2361,8 @@ public class SimilarAsinTaskService { Map> allRowsByBaseId = loadAllRowsByBaseId(task); String finalMessage = message + " after " + nextAttemptCount + " submit attempts"; mergeCozeRowsIntoChunk(task, - context.chunkScopeHash(), - context.chunkIndex(), + null, + null, cozeClient.markRowsFailed(batchRows, finalMessage), allRowsByBaseId); markCozeStateTerminal(state, COZE_STATUS_FAILED, finalMessage); @@ -2169,6 +2388,9 @@ public class SimilarAsinTaskService { } private boolean isRetryableCozeFailure(String failureMessage) { + if (isDeterministicCozeInputFailure(failureMessage)) { + return false; + } String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT); return normalized.contains("rate limit") || normalized.contains("too many") @@ -2188,6 +2410,14 @@ public class SimilarAsinTaskService { || normalized.contains("\u8c03\u7528\u8d85\u65f6"); } + private boolean isDeterministicCozeInputFailure(String failureMessage) { + String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT); + if (normalized.isBlank()) { + return false; + } + return DETERMINISTIC_INPUT_FAILURE_PATTERN.matcher(normalized).find(); + } + private String emptyCozeResultMessage(List cozeRows, int expectedRows) { if (cozeRows == null || cozeRows.isEmpty()) { return expectedRows > 0 ? "Coze async workflow returned empty result rows" : ""; @@ -2357,13 +2587,87 @@ public class SimilarAsinTaskService { if (task == null || cozeRows == null || cozeRows.isEmpty()) { return; } - Map mergedRows = new LinkedHashMap<>(); + List chunks = loadSubmittedChunks(task.getId()); + if (chunks.isEmpty()) { + return; + } + Map> rowsByChunk = new LinkedHashMap<>(); + Map chunkByKey = new LinkedHashMap<>(); + for (TaskChunkEntity chunk : chunks) { + String chunkKey = chunkStorageKey(chunk.getScopeHash(), chunk.getChunkIndex()); + rowsByChunk.put(chunkKey, readChunkRows(chunk)); + chunkByKey.put(chunkKey, chunk); + } + Map> mergeRowsByChunk = new LinkedHashMap<>(); + List orphanRows = new ArrayList<>(); for (SimilarAsinResultRowDto resultRow : cozeRows) { for (SimilarAsinResultRowDto expandedRow : expandRows(List.of(resultRow), allRowsByBaseId)) { - mergedRows.put(rowKey(expandedRow), expandedRow); + String rowKey = rowKey(expandedRow); + if (rowKey.isBlank()) { + continue; + } + boolean matched = false; + for (Map.Entry> entry : rowsByChunk.entrySet()) { + if (entry.getValue().containsKey(rowKey)) { + mergeRowsByChunk.computeIfAbsent(entry.getKey(), ignored -> new LinkedHashMap<>()) + .put(rowKey, expandedRow); + matched = true; + } + } + if (!matched && chunkScopeHash != null && !chunkScopeHash.isBlank() && chunkIndex != null) { + String fallbackKey = chunkStorageKey(chunkScopeHash, chunkIndex); + if (chunkByKey.containsKey(fallbackKey)) { + mergeRowsByChunk.computeIfAbsent(fallbackKey, ignored -> new LinkedHashMap<>()) + .put(rowKey, expandedRow); + matched = true; + } + } + if (!matched) { + orphanRows.add(expandedRow); + log.error("[similar-asin] coze row has no submitted chunk taskId={} rowKey={} asin={} country={}", + task.getId(), rowKey, expandedRow.getAsin(), expandedRow.getCountry()); + } } } - mergeChunkPayload(task.getId(), chunkScopeHash, chunkIndex, new ArrayList<>(mergedRows.values())); + if (!orphanRows.isEmpty()) { + persistOrphanCozeRows(task.getId(), orphanRows); + } + for (Map.Entry> entry : mergeRowsByChunk.entrySet()) { + TaskChunkEntity chunk = chunkByKey.get(entry.getKey()); + if (chunk == null || entry.getValue().isEmpty()) { + continue; + } + mergeChunkPayload(task.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), new ArrayList<>(entry.getValue().values())); + } + } + + private List loadSubmittedChunks(Long taskId) { + if (taskId == null || taskId <= 0) { + return List.of(); + } + List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .orderByAsc(TaskChunkEntity::getChunkIndex)); + if (chunks == null || chunks.isEmpty()) { + return List.of(); + } + return chunks.stream() + .filter(Objects::nonNull) + .filter(chunk -> !readChunkRows(chunk).isEmpty()) + .toList(); + } + + private Map loadSubmittedRows(Long taskId) { + Map rows = new LinkedHashMap<>(); + for (TaskChunkEntity chunk : loadSubmittedChunks(taskId)) { + rows.putAll(readChunkRows(chunk)); + } + return rows; + } + + private String chunkStorageKey(String scopeHash, Integer chunkIndex) { + return firstNonBlank(scopeHash, "") + ":" + (chunkIndex == null ? 0 : chunkIndex); } private List readCozeBatchRows(TaskScopeStateEntity state) { @@ -2483,11 +2787,25 @@ public class SimilarAsinTaskService { .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 String buildCozeBatchScopeKey(Long taskId, List batchRows) { + StringBuilder rowKeys = new StringBuilder(); + if (batchRows != null) { + for (SimilarAsinResultRowDto row : batchRows) { + String key = rowKey(row); + if (!key.isBlank()) { + if (!rowKeys.isEmpty()) { + rowKeys.append('|'); + } + rowKeys.append(key); + } + } + } + return "coze:task:" + taskId + ":rows:" + DigestUtil.sha256Hex(rowKeys.toString()); + } + + private record CozeCandidate(String chunkScopeHash, + Integer chunkIndex, + SimilarAsinResultRowDto row) { } private String buildTaskOwnerScopeKey(FileTaskEntity task) { @@ -2563,12 +2881,9 @@ public class SimilarAsinTaskService { if (persistedRows.isEmpty()) { continue; } - int unresolved = collectPendingCozeRows(persistedRows.values()).size(); - if (unresolved > 0) { - total += Math.max(1, (unresolved + batchSize - 1) / batchSize); - } + total += collectPendingCozeRows(persistedRows.values()).size(); } - return total; + return total <= 0 ? 0 : Math.max(1, (total + batchSize - 1) / batchSize); } private void saveFileBuildProgress(FileTaskEntity task, @@ -2873,6 +3188,21 @@ public class SimilarAsinTaskService { for (TaskChunkEntity chunk : chunks) { result.putAll(readChunkRows(chunk)); } + // 合并 orphan 行(跨 chunk merge 兜底),仅当 chunk 中没有同 rowKey 时才补上,避免覆盖正常结果。 + Map orphans = loadOrphanCozeRows(taskId); + if (!orphans.isEmpty()) { + int added = 0; + for (Map.Entry entry : orphans.entrySet()) { + if (!result.containsKey(entry.getKey())) { + result.put(entry.getKey(), entry.getValue()); + added++; + } + } + if (added > 0) { + log.warn("[similar-asin] merged orphan coze rows into result taskId={} added={} totalOrphan={}", + taskId, added, orphans.size()); + } + } return result; } @@ -2937,14 +3267,13 @@ public class SimilarAsinTaskService { Row row = sheet.createRow(rowIndex++); int col = 0; row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getDisplayId(), parsedRow.getSourceId())); - row.createCell(col++).setCellValue(firstNonBlank(resultRow == null ? parsedRow.getSku() : firstNonBlank(resultRow.getSku(), parsedRow.getSku()), "")); row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getAsin(), "")); row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getCountry(), "")); - row.createCell(col++).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getIsStock(), "")); - row.createCell(col++).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getSimilarity(), "")); - row.createCell(col++).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getIsConform(), "")); - row.createCell(col++).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getReason(), "")); - row.createCell(col++).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getCategory(), "")); + row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingRequiredCozeCellValue(resultRow, resultRow.getIsStock(), "\u672a\u77e5")); + row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingRequiredCozeCellValue(resultRow, resultRow.getSimilarity(), "0%")); + row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getIsConform())); + row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getReason())); + row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getCategory())); row.createCell(col).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getStatus(), "")); } workbook.write(fos); @@ -2978,6 +3307,8 @@ public class SimilarAsinTaskService { int idCol = findRequiredHeader(headerMap, "id"); int asinCol = findRequiredHeader(headerMap, "asin"); int countryCol = findRequiredHeader(headerMap, "国家", "country"); + // 兼容旧 xlsx:RESULT_HEADERS 已不再写出 sku 列,但仍要支持旧版结果簿重新上传, + // 因此这里仍按可选列读取并保留到 DTO,enrichRowForCoze 仍可使用,仅导出阶段不再写出。 int skuCol = findOptionalHeaderExact(headerMap, "sku", "seller sku", "seller_sku", "msku", "货号"); int priceCol = findOptionalHeaderExact(headerMap, "价格", "price"); int urlCol = findOptionalHeaderExact(headerMap, @@ -2986,9 +3317,13 @@ public class SimilarAsinTaskService { int titleCol = findOptionalHeaderExact(headerMap, "标题", "title", "listing title", "product title", "商品标题", "商品名称", "产品名称"); + int statusCol = FailedStatusRowFilter.findStatusColumnIndex(headers); + String statusHeader = statusCol >= 0 && statusCol < headers.size() ? headers.get(statusCol) : null; + List allRows = new ArrayList<>(); int total = 0; int dropped = 0; + int validRows = 0; String currentBlockBaseId = ""; String currentGroupKey = ""; for (int i = 1; i <= sheet.getLastRowNum(); i++) { @@ -3007,6 +3342,7 @@ public class SimilarAsinTaskService { dropped++; continue; } + validRows++; SimilarAsinParsedRowVo vo = new SimilarAsinParsedRowVo(); vo.setSourceFileKey(source.getFileKey()); vo.setSourceFilename(firstNonBlank(source.getOriginalFilename(), input.getName())); @@ -3029,6 +3365,20 @@ public class SimilarAsinTaskService { vo.setValues(readRowValues(row, headers, formatter)); allRows.add(vo); } + boolean includeBlankStatusRows = statusCol >= 0 && isSimilarAsinResultWorkbook(headers); + FailedStatusRowFilter.FilterResult filteredRows = + FailedStatusRowFilter.retainRows( + allRows, + statusCol >= 0, + rowVo -> statusHeader == null || rowVo.getValues() == null ? "" : rowVo.getValues().get(statusHeader), + status -> FailedStatusRowFilter.matchesFailedStatus(status) + || (includeBlankStatusRows && FailedStatusRowFilter.isBlankStatus(status)) + ); + dropped += filteredRows.filteredCount(); + allRows = new ArrayList<>(filteredRows.rows()); + if (statusCol >= 0 && validRows > 0 && allRows.isEmpty()) { + throw new BusinessException(FailedStatusRowFilter.noMatchedRowsMessage()); + } if (allRows.isEmpty()) { throw new BusinessException("no valid similar ASIN rows"); } @@ -3229,7 +3579,7 @@ public class SimilarAsinTaskService { return vo; } - private SimilarAsinHistoryItemVo toHistoryItem(FileResultEntity row, String taskStatus, TaskFileJobEntity job) { + private SimilarAsinHistoryItemVo toHistoryItem(FileResultEntity row, FileTaskEntity task, TaskFileJobEntity job) { SimilarAsinHistoryItemVo vo = new SimilarAsinHistoryItemVo(); vo.setResultId(row.getId()); vo.setTaskId(row.getTaskId()); @@ -3237,11 +3587,14 @@ public class SimilarAsinTaskService { vo.setResultFilename(row.getResultFilename()); vo.setDownloadUrl(null); attachFileJobState(vo, row, job); - vo.setTaskStatus(taskStatus); + vo.setTaskStatus(task == null ? null : task.getStatus()); vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1); vo.setError(row.getErrorMessage()); vo.setRowCount(row.getRowCount()); vo.setCreatedAt(fmt(row.getCreatedAt())); + // 任务开始时间复用 biz_file_task.created_at;缺 task 时回退到 result.createdAt 兜底 + vo.setStartedAt(fmt(task == null ? row.getCreatedAt() : task.getCreatedAt())); + vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt())); return vo; } @@ -3272,29 +3625,29 @@ public class SimilarAsinTaskService { } int cozeCompleted = countCompletedCozeStates(taskId); int cozePending = countPendingCozeStates(taskId); + boolean uploadComplete = isResultSubmissionComplete(taskId); TaskProgressSnapshotEntity snapshot = taskProgressSnapshotService.find(taskId, MODULE_TYPE); - if (job != null && STATUS_RUNNING.equals(job.getStatus()) && cozePending > 0) { - int total = Math.max(1, cozeCompleted + cozePending); + if (cozeCompleted + cozePending > 0) { + int total = calculateCozeDisplayTotal(snapshot, cozeCompleted + cozePending); + if (!uploadComplete) { + total = Math.max(total, cozeCompleted + cozePending + 1); + } int current = Math.max(0, Math.min(cozeCompleted, total)); vo.setFileProgressCurrent(current); vo.setFileProgressTotal(total); int percent = calculateDisplayProgressPercent(current, total, job, snapshot == null ? null : snapshot.getUpdatedAt()); percent = Math.max(percent, calculateSnapshotDisplayPercent(snapshot, job)); - vo.setFileProgressPercent(Math.min(99, percent)); - vo.setFileProgressMessage("Coze 已完成 " + current + "/" + total + ",等待回流"); + vo.setFileProgressPercent(Math.min(uploadComplete ? 99 : 98, percent)); + if (!uploadComplete) { + vo.setFileProgressMessage(buildUploadingCozeProgressMessage(current, total, cozePending)); + return; + } + vo.setFileProgressMessage(cozePending > 0 + ? buildCozeProgressMessage(current, total, cozePending) + : "Coze 已回流 " + current + "/" + total + " 批次,正在生成结果文件"); return; } if (snapshot == null) { - if (job != null && STATUS_RUNNING.equals(job.getStatus()) && cozeCompleted + cozePending > 0) { - int total = Math.max(1, cozeCompleted + cozePending); - int current = Math.max(0, Math.min(cozeCompleted, total)); - vo.setFileProgressCurrent(current); - vo.setFileProgressTotal(total); - vo.setFileProgressPercent(Math.min(99, calculateDisplayProgressPercent(current, total, job, job.getUpdatedAt()))); - vo.setFileProgressMessage(cozePending > 0 - ? "Coze 已完成 " + current + "/" + total + ",等待回流" - : "Coze 处理中"); - } return; } int total = snapshot.getTotalCount() == null ? 0 : snapshot.getTotalCount(); @@ -3311,6 +3664,36 @@ public class SimilarAsinTaskService { vo.setFileProgressPercent(percent); vo.setFileProgressMessage(snapshot.getMessage()); } + + private int calculateCozeDisplayTotal(TaskProgressSnapshotEntity snapshot, int observedCozeStates) { + int observed = Math.max(0, observedCozeStates); + // 小任务 totalCount <= 3 时直接使用 observed,避免 totalCount - 3 → 0 引起进度条 0/0。 + if (snapshot == null || snapshot.getTotalCount() == null || snapshot.getTotalCount() <= 3) { + return Math.max(1, observed); + } + int expectedFromSnapshot = Math.max(1, snapshot.getTotalCount() - 3); + return Math.max(1, Math.max(observed, expectedFromSnapshot)); + } + + private String buildCozeProgressMessage(int completed, int total, int pending) { + int safeTotal = Math.max(1, total); + int safeCompleted = Math.max(0, Math.min(completed, safeTotal)); + int submitted = Math.max(safeCompleted, Math.min(safeTotal, safeCompleted + Math.max(0, pending))); + if (safeCompleted <= 0 && submitted > 0) { + return "Coze 已提交 " + submitted + "/" + safeTotal + " 批次,等待回流"; + } + return "Coze 已回流 " + safeCompleted + "/" + safeTotal + " 批次,等待结果文件"; + } + private String buildUploadingCozeProgressMessage(int completed, int total, int pending) { + int safeTotal = Math.max(1, total); + int safeCompleted = Math.max(0, Math.min(completed, safeTotal)); + int submitted = Math.max(safeCompleted, Math.min(safeTotal, safeCompleted + Math.max(0, pending))); + if (pending > 0) { + return "Python 仍在回传,Coze 已提交 " + submitted + "/" + safeTotal + " 批次"; + } + return "Python 仍在回传,Coze 已回流 " + safeCompleted + "/" + safeTotal + " 批次"; + } + private String fmt(LocalDateTime t) { return t == null ? null : t.toString(); } @@ -3443,6 +3826,114 @@ public class SimilarAsinTaskService { return rows; } + /** + * 跨 chunk merge 时把没有匹配到任何 chunk 的回流行兜底落到 task_scope_state, + * 避免数据被静默丢弃;assembleResult 阶段会在 {@link #loadPersistedResultRows(Long)} + * 中读回这些 orphan 行并合入最终 xlsx。 + * 使用 best-effort 策略:transient storage 出问题时仅记录 warn,不打断主流程。 + */ + private void persistOrphanCozeRows(Long taskId, List orphanRows) { + if (taskId == null || taskId <= 0 || orphanRows == null || orphanRows.isEmpty()) { + return; + } + try { + // 给每条 orphan 行写入 marker,后续导出时可识别。 + for (SimilarAsinResultRowDto row : orphanRows) { + if (row == null) { + continue; + } + String existingError = row.getError(); + String marker = ORPHAN_COZE_ROW_ERROR; + if (existingError == null || existingError.isBlank()) { + row.setError(marker); + } else if (!existingError.contains(marker)) { + row.setError(existingError + " | " + marker); + } + } + String orphanScopeKey = ORPHAN_SCOPE_KEY_PREFIX + UUID.randomUUID(); + String orphanScopeHash = DigestUtil.sha256Hex(orphanScopeKey); + String payloadJson = writeJson(orphanRows, "serialize orphan coze rows failed"); + String storedPayload = transientPayloadStorageService.storeParsedPayloadEntry( + MODULE_TYPE, taskId, orphanScopeHash, "orphan-" + UUID.randomUUID(), payloadJson, true); + LocalDateTime now = LocalDateTime.now(); + TaskScopeStateEntity state = new TaskScopeStateEntity(); + state.setTaskId(taskId); + state.setModuleType(MODULE_TYPE); + state.setScopeKey(orphanScopeKey); + state.setScopeHash(orphanScopeHash); + state.setParsedPayloadJson(storedPayload); + state.setStateJson("{\"phase\":\"ORPHAN_COZE_ROWS\"}"); + // cozeStatus 直接置 DONE,避免被 listOwnedPendingCozeStates 扫描到。 + state.setCozeStatus(COZE_STATUS_DONE); + state.setCozeSubmittedAt(now); + state.setCozeCompletedAt(now); + state.setCozeAttemptCount(0); + state.setCozeError(ORPHAN_COZE_ROW_ERROR); + state.setChunkTotal(orphanRows.size()); + state.setReceivedChunkCount(orphanRows.size()); + state.setCompleted(1); + state.setLastChunkAt(now); + state.setCreatedAt(now); + state.setUpdatedAt(now); + try { + taskScopeStateMapper.insert(state); + log.warn("[similar-asin] orphan coze rows persisted taskId={} rows={} scopeKey={}", + taskId, orphanRows.size(), orphanScopeKey); + } catch (DuplicateKeyException ex) { + transientPayloadStorageService.deletePayloadIfPresent(storedPayload); + log.info("[similar-asin] duplicate orphan scope state ignored taskId={} scopeKey={}", + taskId, orphanScopeKey); + } + } catch (Exception ex) { + log.warn("[similar-asin] persist orphan coze rows failed taskId={} rows={} err={}", + taskId, orphanRows.size(), ex.getMessage()); + } + } + + /** + * 读回 {@link #persistOrphanCozeRows(Long, List)} 落库的 orphan 行集合。 + * 仅 assembleResult 阶段调用,与 chunk 行合并时使用 putIfAbsent 语义: + * 同一 rowKey 下 chunk 已有结果时优先用 chunk 行,避免覆盖正常路径数据。 + */ + private Map loadOrphanCozeRows(Long taskId) { + Map rows = new LinkedHashMap<>(); + if (taskId == null || taskId <= 0) { + return rows; + } + List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .likeRight(TaskScopeStateEntity::getScopeKey, ORPHAN_SCOPE_KEY_PREFIX)); + if (states == null || states.isEmpty()) { + return rows; + } + for (TaskScopeStateEntity state : states) { + if (state == null || state.getParsedPayloadJson() == null || state.getParsedPayloadJson().isBlank()) { + continue; + } + try { + String payloadJson = transientPayloadStorageService.resolvePayload( + state.getParsedPayloadJson(), "read similar ASIN orphan rows failed"); + JsonNode array = objectMapper.readTree(payloadJson); + if (!array.isArray()) { + continue; + } + for (JsonNode node : array) { + SimilarAsinResultRowDto row = objectMapper.treeToValue(node, SimilarAsinResultRowDto.class); + String key = rowKey(row); + if (key.isBlank()) { + continue; + } + rows.putIfAbsent(key, row); + } + } catch (Exception ex) { + log.warn("[similar-asin] read orphan coze rows failed taskId={} stateId={} err={}", + taskId, state.getId(), ex.getMessage()); + } + } + return rows; + } + private String rowKey(SimilarAsinParsedRowVo row) { if (row == null) { return ""; @@ -3515,6 +4006,29 @@ public class SimilarAsinTaskService { return ""; } + private boolean isSimilarAsinResultWorkbook(List headers) { + return hasHeader(headers, "状态") + && (hasHeader(headers, "是否有货") + || hasHeader(headers, "相似度") + || hasHeader(headers, "是否符合类目") + || hasHeader(headers, "不符合理由") + || hasHeader(headers, "产品类目")); + } + + private boolean hasHeader(List headers, String candidate) { + if (headers == null || headers.isEmpty()) { + return false; + } + String normalizedCandidate = normalize(candidate).replaceAll("[\\s_\\-()\\[\\]{}:]+", ""); + for (String header : headers) { + String normalizedHeader = normalize(header).replaceAll("[\\s_\\-()\\[\\]{}:]+", ""); + if (normalizedCandidate.equals(normalizedHeader)) { + return true; + } + } + return false; + } + private String userFacingCozeCellValue(SimilarAsinResultRowDto row, String value) { String normalizedValue = normalize(value); if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) { @@ -3526,6 +4040,24 @@ public class SimilarAsinTaskService { return firstNonBlank(value, ""); } + private String userFacingRequiredCozeCellValue(SimilarAsinResultRowDto row, String value, String fallback) { + String cellValue = userFacingCozeCellValue(row, value); + if (!normalize(cellValue).isBlank()) { + return cellValue; + } + return hasBusinessCozeResult(row) ? fallback : ""; + } + + private boolean hasBusinessCozeResult(SimilarAsinResultRowDto row) { + if (row == null) { + return false; + } + return hasUsableCozeField(row.getIsConform()) + || hasUsableCozeField(row.getReason()) + || hasUsableCozeField(row.getCategory()) + || hasUsableCozeField(row.getStatus()); + } + private String userFacingConclusion(SimilarAsinResultRowDto row) { if (row == null) { return ""; @@ -3542,6 +4074,14 @@ public class SimilarAsinTaskService { private boolean isTechnicalCozeFailure(String value) { String normalized = normalize(value).toLowerCase(Locale.ROOT); + if (normalized.isBlank()) { + return false; + } + // 确定性输入失败统一通过 DETERMINISTIC_INPUT_FAILURE_PATTERN 判定, + // 其它技术性失败(coze 关键词、超时、节点超限等)保持原有子串匹配。 + if (DETERMINISTIC_INPUT_FAILURE_PATTERN.matcher(normalized).find()) { + return true; + } return normalized.contains("coze") || normalized.contains("结果不完整") || normalized.contains("工作流节点执行超限") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadStorageService.java index 0e5de2e..b665cdf 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadStorageService.java @@ -1,6 +1,7 @@ package com.nanri.aiimage.modules.task.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.config.InstanceMetadata; import com.nanri.aiimage.config.StorageProperties; import com.nanri.aiimage.config.TransientStorageProperties; import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService; @@ -28,6 +29,7 @@ import java.util.zip.GZIPOutputStream; public class TransientPayloadStorageService { private static final String LOCAL_POINTER_PREFIX = "local:"; + private static final String LOCAL_INSTANCE_TAG = "i/"; 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"; @@ -37,6 +39,7 @@ public class TransientPayloadStorageService { private final RustfsObjectStorageService rustfsObjectStorageService; private final OssStorageService ossStorageService; private final ObjectMapper objectMapper; + private final InstanceMetadata instanceMetadata; public boolean isWriteEnabled() { return properties.isEnabled() @@ -94,7 +97,15 @@ public class TransientPayloadStorageService { } try { if (pointer.startsWith(LOCAL_POINTER_PREFIX)) { - return decodeStoredPayload(readLocalPayload(pointer.substring(LOCAL_POINTER_PREFIX.length()))); + String localKey = pointer.substring(LOCAL_POINTER_PREFIX.length()); + String ownerInstance = extractLocalInstanceId(localKey); + if (ownerInstance != null && !ownerInstance.equals(instanceMetadata.getInstanceId())) { + // 跨实例消费 local 指针时立即报错,避免悄悄读出空内容造成下游错位。 + throw new IllegalStateException( + "transient payload only exists on instance=" + ownerInstance + + " current=" + instanceMetadata.getInstanceId()); + } + return decodeStoredPayload(readLocalPayload(stripLocalInstanceId(localKey))); } if (pointer.startsWith(RUSTFS_POINTER_PREFIX)) { return decodeStoredPayload(rustfsObjectStorageService.readObjectAsString(pointer.substring(RUSTFS_POINTER_PREFIX.length()))); @@ -114,7 +125,15 @@ public class TransientPayloadStorageService { return; } if (pointer.startsWith(LOCAL_POINTER_PREFIX)) { - deleteLocalPayload(pointer.substring(LOCAL_POINTER_PREFIX.length())); + String localKey = pointer.substring(LOCAL_POINTER_PREFIX.length()); + String ownerInstance = extractLocalInstanceId(localKey); + if (ownerInstance != null && !ownerInstance.equals(instanceMetadata.getInstanceId())) { + // 跨实例不能直接删另一台机器上的本地文件,留给该实例的清理任务处理。 + log.info("[transient-payload] skip cross-instance local delete pointer={} owner={} current={}", + pointer, ownerInstance, instanceMetadata.getInstanceId()); + return; + } + deleteLocalPayload(stripLocalInstanceId(localKey)); return; } if (pointer.startsWith(RUSTFS_POINTER_PREFIX)) { @@ -191,9 +210,9 @@ public class TransientPayloadStorageService { try { pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadText(objectKey, storedContent, verifyAfterUpload); } catch (Exception ex) { - log.warn("[transient-payload] rustfs upload failed objectKey={} err={}", - objectKey, ex.getMessage()); - throw ex; + // 升级为 ERROR:rustfs 失败后只能落到本地,多实例下其他节点读不到,必须能告警。 + log.error("[transient-payload] rustfs upload failed, fallback to local store instanceId={} objectKey={} err={}", + instanceMetadata.getInstanceId(), objectKey, ex.getMessage()); } } if (pointer == null) { @@ -226,7 +245,9 @@ public class TransientPayloadStorageService { StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); - return LOCAL_POINTER_PREFIX + objectKey; + // 在 local 指针上携带 instanceId 段,跨实例 resolve 时能直接判定来源、避免错读。 + String instanceId = sanitizeInstanceId(instanceMetadata.getInstanceId()); + return LOCAL_POINTER_PREFIX + LOCAL_INSTANCE_TAG + instanceId + "/" + objectKey; } catch (Exception ex) { log.warn("[transient-payload] local store failed objectKey={} err={}", objectKey, ex.getMessage()); return null; @@ -242,6 +263,47 @@ public class TransientPayloadStorageService { } } + /** + * 从 local 指针中解析出归属实例 ID。指针形式: + * - 新格式:{@code i//} + * - 老格式:{@code }(无 instanceId 段,返回 null 表示未携带,向后兼容旧任务)。 + */ + private String extractLocalInstanceId(String localKey) { + if (localKey == null || !localKey.startsWith(LOCAL_INSTANCE_TAG)) { + return null; + } + String remainder = localKey.substring(LOCAL_INSTANCE_TAG.length()); + int slash = remainder.indexOf('/'); + if (slash <= 0) { + return null; + } + return remainder.substring(0, slash); + } + + /** + * 去掉 local 指针中的 instanceId 段,得到真正的 objectKey。 + */ + private String stripLocalInstanceId(String localKey) { + if (localKey == null || !localKey.startsWith(LOCAL_INSTANCE_TAG)) { + return localKey; + } + String remainder = localKey.substring(LOCAL_INSTANCE_TAG.length()); + int slash = remainder.indexOf('/'); + if (slash < 0) { + return remainder; + } + return remainder.substring(slash + 1); + } + + private String sanitizeInstanceId(String value) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isBlank()) { + return "unknown"; + } + // 仅允许字母、数字、下划线、横线和点,避免出现路径分隔符破坏 objectKey 解析。 + return normalized.replaceAll("[^A-Za-z0-9_.\\-]", "_"); + } + private String encodeStoredPayload(String content) { try { byte[] raw = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8); diff --git a/backend-java/src/main/resources/application-local.example.yml b/backend-java/src/main/resources/application-local.example.yml index ed5f89e..50e2d9e 100644 --- a/backend-java/src/main/resources/application-local.example.yml +++ b/backend-java/src/main/resources/application-local.example.yml @@ -37,7 +37,7 @@ AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID=7632683471312355338 AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN= AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE=50 AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS=60000 -AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES=20 +AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES=30 AIIMAGE_SIMILAR_ASIN_COZE_BASE_URL=https://api.coze.cn AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH=/v1/workflow/run @@ -45,7 +45,7 @@ AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID=7635328462404583478 AIIMAGE_SIMILAR_ASIN_COZE_TOKEN= AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE=50 AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS=60000 -AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES=20 +AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES=30 AIIMAGE_ROCKETMQ_NAME_SERVER=121.196.149.225:9876 AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED=true diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index b0c99e4..ccbf2a6 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -148,31 +148,34 @@ aiimage: stuck-timeout-minutes: ${AIIMAGE_RESULT_FILE_JOB_STUCK_TIMEOUT_MINUTES:30} batch-size: ${AIIMAGE_RESULT_FILE_JOB_BATCH_SIZE:20} coze-task: - max-concurrent: ${AIIMAGE_COZE_TASK_MAX_CONCURRENT:8} + max-concurrent: ${AIIMAGE_COZE_TASK_MAX_CONCURRENT:12} appearance-patent: coze-base-url: ${AIIMAGE_APPEARANCE_PATENT_COZE_BASE_URL:https://api.coze.cn} coze-workflow-path: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH:/v1/workflow/run} coze-workflow-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7639685157562089513} - coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:Bearer sat_CztofPRhIKFKeaPZBIRxocfckqhsdCZZ45NPhkf7WOZjbX36vnVSfVV30T6u2rSX} + coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:} coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:20} coze-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_CONNECT_TIMEOUT_MILLIS:10000} coze-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS:60000} coze-poll-interval-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_INTERVAL_MILLIS:30000} coze-poll-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_TIMEOUT_MILLIS:600000} - stale-timeout-minutes: ${AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES:20} + stale-timeout-minutes: ${AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES:30} 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:7639708860686024756} - coze-token: ${AIIMAGE_SIMILAR_ASIN_COZE_TOKEN:Bearer sat_vqmb97BiXbMW3XVyG9htWQKjZ2F55CaW2FRrT9bDdPha7a3hxmXnMNCE1XNHdCVU} - coze-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE:20} + coze-token: ${AIIMAGE_SIMILAR_ASIN_COZE_TOKEN:} + coze-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE:10} + coze-credential-stripe-size: ${AIIMAGE_SIMILAR_ASIN_COZE_CREDENTIAL_STRIPE_SIZE:1} coze-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_CONNECT_TIMEOUT_MILLIS:10000} coze-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS:60000} coze-poll-interval-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_INTERVAL_MILLIS:30000} coze-poll-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_TIMEOUT_MILLIS:600000} - stale-timeout-minutes: ${AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES:20} + stale-timeout-minutes: ${AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES:30} stale-finalize-cron: ${AIIMAGE_SIMILAR_ASIN_STALE_FINALIZE_CRON:0 */2 * * * *} + coze-include-legacy-api-key: ${AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY:true} + coze-use-legacy-item-field-order: ${AIIMAGE_SIMILAR_ASIN_COZE_USE_LEGACY_ITEM_ORDER:false} security: shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key} internal-token: ${AIIMAGE_INTERNAL_TOKEN:} diff --git a/backend-java/src/main/resources/db/V52__rotate_coze_credential_tokens.sql b/backend-java/src/main/resources/db/V52__rotate_coze_credential_tokens.sql new file mode 100644 index 0000000..9bddefc --- /dev/null +++ b/backend-java/src/main/resources/db/V52__rotate_coze_credential_tokens.sql @@ -0,0 +1,28 @@ +-- ============================================================================= +-- V52 占位脚本:用于 coze credential token 轮换 +-- ============================================================================= +-- 背景: +-- V51 历史脚本中以明文形式写入了 4 个生产 coze token(已记录在 git 提交记录里)。 +-- 出于安全考虑,需要在 coze 平台立刻吊销这 4 个 token,并由运维带外用新 +-- token 覆盖 biz_coze_credential 表对应记录。 +-- +-- 本脚本的目的: +-- 1. 不在 git 里写入任何明文新 token,保证版本库中再也搜不到 sat_ 前缀的明文。 +-- 2. 仅占用 V52 版本号,让 Flyway 顺利推进到下一版本,不影响线上数据。 +-- 3. 通过 Flyway history 留下"V52 已飞、token 轮换由运维带外完成"的可审计痕迹。 +-- +-- 运维操作 (带外执行,不要写进 git): +-- UPDATE biz_coze_credential SET token = '' +-- WHERE module_type = 'APPEARANCE_PATENT' AND credential_name = 'appearance-1'; +-- UPDATE biz_coze_credential SET token = '' +-- WHERE module_type = 'APPEARANCE_PATENT' AND credential_name = 'appearance-2'; +-- UPDATE biz_coze_credential SET token = '' +-- WHERE module_type = 'SIMILAR_ASIN' AND credential_name = 'similar-1'; +-- UPDATE biz_coze_credential SET token = '' +-- WHERE module_type = 'SIMILAR_ASIN' AND credential_name = 'similar-2'; +-- +-- application.yml 中两个 coze-token 默认值已同步清空, +-- AIIMAGE_*_COZE_TOKEN 环境变量为空时 SimilarAsinCozeClient.hasConfiguredCredential() +-- 会返回 false,并打印 "coze token not configured, skip async coze"。 +-- ============================================================================= +SELECT 1; diff --git a/backend-java/src/test/java/com/nanri/aiimage/common/util/FailedStatusRowFilterTest.java b/backend-java/src/test/java/com/nanri/aiimage/common/util/FailedStatusRowFilterTest.java new file mode 100644 index 0000000..14027ad --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/common/util/FailedStatusRowFilterTest.java @@ -0,0 +1,68 @@ +package com.nanri.aiimage.common.util; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FailedStatusRowFilterTest { + + @Test + void findsStatusColumnByChineseOrEnglishAlias() { + assertEquals(2, FailedStatusRowFilter.findStatusColumnIndex(List.of("id", "asin", "\u72b6\u6001"))); + assertEquals(1, FailedStatusRowFilter.findStatusColumnIndex(List.of("id", " Status "))); + assertEquals(-1, FailedStatusRowFilter.findStatusColumnIndex(List.of("id", "asin", "country"))); + } + + @Test + void matchesConfiguredFailedStatusValues() { + assertTrue(FailedStatusRowFilter.matchesFailedStatus("\u9519\u8bef")); + assertTrue(FailedStatusRowFilter.matchesFailedStatus("\u5931\u8d25")); + assertTrue(FailedStatusRowFilter.matchesFailedStatus("FAILED")); + assertTrue(FailedStatusRowFilter.matchesFailedStatus(" failed ")); + assertTrue(FailedStatusRowFilter.isBlankStatus(" ")); + assertFalse(FailedStatusRowFilter.matchesFailedStatus("\u6210\u529f")); + } + + @Test + void retainsOnlyFailedRowsWhenStatusFilteringEnabled() { + List rows = List.of("\u9519\u8bef", "\u6210\u529f", "FAILED", "\u5904\u7406\u4e2d"); + FailedStatusRowFilter.FilterResult result = + FailedStatusRowFilter.retainFailedRows(rows, true, value -> value); + + assertTrue(result.filterApplied()); + assertEquals(List.of("\u9519\u8bef", "FAILED"), result.rows()); + assertEquals(2, result.filteredCount()); + } + + @Test + void keepsOriginalRowsWhenStatusFilteringDisabled() { + List rows = List.of("\u9519\u8bef", "\u6210\u529f"); + FailedStatusRowFilter.FilterResult result = + FailedStatusRowFilter.retainFailedRows(rows, false, value -> value); + + assertFalse(result.filterApplied()); + assertEquals(rows, result.rows()); + assertEquals(0, result.filteredCount()); + } + + @Test + void canRetainFailedAndBlankRowsTogether() { + List rows = List.of("\u9519\u8bef", "", "\u6210\u529f", "FAILED"); + FailedStatusRowFilter.FilterResult result = + FailedStatusRowFilter.retainRows( + rows, + true, + value -> value, + value -> FailedStatusRowFilter.matchesFailedStatus(value) + || FailedStatusRowFilter.isBlankStatus(value) + ); + + assertTrue(result.filterApplied()); + assertEquals(List.of("\u9519\u8bef", "", "FAILED"), result.rows()); + assertEquals(1, result.filteredCount()); + } +} diff --git a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue index ebdd2cd..9ed6306 100644 --- a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue @@ -19,7 +19,7 @@
新增条件要求
- - 0 字符 -
- -
-
上传图片(最多8张)
-
支持JPG PNG BMP JPEG
- -
-
-
点击上传图片 (0/8)
-
支持多选/拖拽上传
-
-
-
-
-
上传视频(只支持1个)
-
支持 MP4 等视频格式
- -
-
-
点击上传视频
-
仅支持1个视频
-
- -
- - - - -
-
-
生成数量
-
- - - - - -
-
- -
-
提示词模式
-
- - -
-
● 统一提示词:所有图片使用相同的提示词生成;分批提示词:每行一个提示词
-
- -
-
统一提示词
-
- - 0字符 -
-
- - - -
-
画幅比例
-
- - - - - - - - - -
-
● 图片编辑模式建议使用auto自动比例
-
- -
-
分辨率(RESOLUTION)
-
- - -
-
- -
-
上传图片(最多8张)
-
支持JPG PNG BMP JPEG
-
-
- - - - -
-
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - - -
- -
-
-
- -
-
生成或恢复到工作台的图片将显示在此处,点击可查看大图
-
-
- -
右侧显示所有图片竖向拼接后的效果,点击可查看大图并保存
-
- -
- - -
- - -
-
-
- 设置 - -
-
-
-
API Key
-
用于生成接口鉴权,必须设置后才能进行生成。仅保存在本机,不会上传。
-
- -
-
-
-
自动保存位置
-
设置后,生图完成后将自动保存副本到此文件夹。
-
- - - -
-
-
-
软件更新
-
- 当前版本: v1.0.0 - -
-
-
- -
-
-
- -
-
- - -
-
-
选择参与拼接的图片及顺序(拖拽调整顺序,× 移出拼接)
-
-
点击下方图片可加入拼接顺序:
-
-
- - -
-
-
- - - - - - -
-
-
- -
查看大图
-
-
- - - -
- -
-
- - -
-
-
- -
- -
-
-
- - - 画笔: 20px - - - -
-
- *提示: 红色区域将被重新绘制
- *提示: 点击图片添加标记,右键删除标记 -
-
- - -
- -
-
- - -
-
-
-
Just the Right Capacity, Easily Holds Daily Essentials.
-
Phone, long wallet, keys, lipstick—all fit in without looking bulky.
-
- -
-
-
- - -
- - -
-
- - -
- -
-
-
- - - - diff --git a/web_source/templates_backup/login.html b/web_source/templates_backup/login.html deleted file mode 100644 index 4548ab1..0000000 --- a/web_source/templates_backup/login.html +++ /dev/null @@ -1,164 +0,0 @@ - - - - - - 登录 - 数富AI - - - -
- 数富AI -
- - - - -