提交一些更改
This commit is contained in:
@@ -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<String> STATUS_HEADER_ALIASES = Set.of(
|
||||
normalizeHeader(STATUS_HEADER_CN),
|
||||
normalizeHeader("status")
|
||||
);
|
||||
|
||||
private static final Set<String> 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<String> 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 <T> FilterResult<T> retainFailedRows(List<T> rows,
|
||||
boolean statusFilteringEnabled,
|
||||
Function<T, String> statusReader) {
|
||||
return retainRows(rows, statusFilteringEnabled, statusReader, FailedStatusRowFilter::matchesFailedStatus);
|
||||
}
|
||||
|
||||
public static <T> FilterResult<T> retainRows(List<T> rows,
|
||||
boolean statusFilteringEnabled,
|
||||
Function<T, String> statusReader,
|
||||
Predicate<String> statusMatcher) {
|
||||
List<T> safeRows = rows == null ? List.of() : rows;
|
||||
if (!statusFilteringEnabled) {
|
||||
return new FilterResult<>(false, safeRows, 0);
|
||||
}
|
||||
List<T> 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<T>(boolean filterApplied, List<T> rows, int filteredCount) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -15,15 +15,32 @@ public class SimilarAsinProperties {
|
||||
private String cozeWorkflowId = "7635328462404583478";
|
||||
private String cozeToken = "";
|
||||
private List<CozeCredential> 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<TaskScopeStateEntity>()
|
||||
@@ -1626,7 +1640,7 @@ public class AppearancePatentTaskService {
|
||||
AppearancePatentCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(
|
||||
batchRows, prompt, apiKey, credential, true);
|
||||
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
||||
List<AppearancePatentResultRowDto> cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData());
|
||||
List<AppearancePatentResultRowDto> 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<AppearancePatentResultRowDto> 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<AppearancePatentResultRowDto> 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<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||
@@ -1919,7 +1943,7 @@ public class AppearancePatentTaskService {
|
||||
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
||||
List<AppearancePatentResultRowDto> 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<AppearancePatentResultRowDto> 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<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
||||
List<AppearancePatentResultRowDto> 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<AppearancePatentResultRowDto> mergeUsableCozeRows(List<AppearancePatentResultRowDto> batchRows,
|
||||
String payloadText) throws Exception {
|
||||
// 旧签名保留:未显式传 workflow status 的调用方默认按 "业务侧 SUCCESS" 对待
|
||||
// (submit immediateData 同步路径本身就意味着 coze workflow 业务侧已成功返回)。
|
||||
return mergeUsableCozeRows(batchRows, payloadText, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 Coze 回包:
|
||||
* <ul>
|
||||
* <li>workflow 业务侧 SUCCESS(status=SUCCESS 或 immediate 同步返回)但解析后无任何风险维度结果,
|
||||
* 直接 {@link AppearancePatentCozeClient#markRowsFailed(List, String)} 落地,<b>不再抛异常</b>,
|
||||
* 避免业务空结果被重复扔进 retry/split-retry 死循环。</li>
|
||||
* <li>workflow 业务侧非 SUCCESS(poll status=FAILED/CANCELED 等系统侧失败)保持原行为,
|
||||
* 抛 {@link IllegalStateException},让外层 retry/split-retry 链路处理。</li>
|
||||
* </ul>
|
||||
*/
|
||||
private List<AppearancePatentResultRowDto> mergeUsableCozeRows(List<AppearancePatentResultRowDto> batchRows,
|
||||
String payloadText,
|
||||
String workflowStatus) throws Exception {
|
||||
List<AppearancePatentResultRowDto> 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<AppearancePatentResultRowDto> 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<String, Integer> headerMap = buildHeaderMap(header, formatter);
|
||||
List<String> 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<AppearancePatentParsedRowVo> allRows = new ArrayList<>();
|
||||
int statusCol = FailedStatusRowFilter.findStatusColumnIndex(headers);
|
||||
|
||||
List<ParsedAppearanceRow> 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<AppearancePatentParsedRowVo> allRows = parsedRows.stream()
|
||||
.map(ParsedAppearanceRow::row)
|
||||
.toList();
|
||||
hydratePromptFields(allRows);
|
||||
boolean includeBlankStatusRows = statusCol >= 0 && isAppearanceResultWorkbook(headers);
|
||||
FailedStatusRowFilter.FilterResult<ParsedAppearanceRow> 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<String> readHeaders(Row header, DataFormatter formatter) {
|
||||
List<String> 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<String> headers) {
|
||||
return hasHeader(headers, "状态")
|
||||
&& (hasHeader(headers, "结论")
|
||||
|| hasHeader(headers, "标题维度(商标)")
|
||||
|| hasHeader(headers, "外观维度(外观设计专利)")
|
||||
|| hasHeader(headers, "专利维度(发明/实用新型专利)"));
|
||||
}
|
||||
|
||||
private boolean hasHeader(List<String> 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<String> headers, List<AppearancePatentParsedRowVo> allRows) {
|
||||
}
|
||||
|
||||
private record ParsedAppearanceRow(AppearancePatentParsedRowVo row, String sourceStatus) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Boolean> 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);
|
||||
|
||||
@@ -231,9 +231,6 @@ public class SimilarAsinCozeClient {
|
||||
Map<String, Object> 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<String, Object> buildParameters(List<SimilarAsinResultRowDto> rows, String prompt, String apiKey) {
|
||||
List<String> asins = rows.stream().map(row -> nonBlank(row.getAsin(), "")).toList();
|
||||
List<String> skus = rows.stream().map(row -> nonBlank(row.getSku(), "")).toList();
|
||||
List<String> titles = rows.stream().map(row -> nonBlank(row.getTitle(), row.getAsin())).toList();
|
||||
List<String> urls = rows.stream().map(row -> nonBlank(row.getUrl(), "")).toList();
|
||||
List<List<String>> urlLists = rows.stream().map(SimilarAsinResultRowDto::getUrls).toList();
|
||||
List<String> asins = rows.stream().map(row -> safeText(row.getAsin())).toList();
|
||||
List<String> titles = rows.stream().map(row -> safeText(firstNonBlank(row.getTitle(), row.getAsin()))).toList();
|
||||
List<String> skus = rows.stream().map(row -> safeText(row.getSku())).toList();
|
||||
List<String> urls = rows.stream().map(this::primaryImageUrl).toList();
|
||||
List<List<String>> urlLists = rows.stream().map(this::imageUrls).toList();
|
||||
|
||||
Map<String, Object> 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<Map<String, Object>> buildItemObjects(List<String> asins,
|
||||
List<String> skus,
|
||||
List<String> titles,
|
||||
List<String> skus,
|
||||
List<String> urls,
|
||||
List<List<String>> urlLists) {
|
||||
// legacy flag:保留切回旧字段顺序 {asin, sku, url, target_urls, title} 的开关,
|
||||
// 默认 false 使用当前顺序 {asin, url, target_urls, title, sku}。
|
||||
boolean useLegacyOrder = properties.isCozeUseLegacyItemFieldOrder();
|
||||
List<Map<String, Object>> items = new ArrayList<>(asins.size());
|
||||
for (int i = 0; i < asins.size(); i++) {
|
||||
String url = urls.get(i);
|
||||
List<String> imageUrls = urlLists.get(i);
|
||||
Map<String, Object> 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<String> safeUrls(List<String> urls) {
|
||||
if (urls == null || urls.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return urls.stream()
|
||||
.map(this::safeText)
|
||||
.filter(value -> !value.isBlank())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void putIfPresent(Map<String, Object> item, String key, String value) {
|
||||
String normalized = safeText(value);
|
||||
if (!normalized.isBlank()) {
|
||||
item.put(key, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
private void putIfPresent(Map<String, Object> item, String key, List<String> 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<String> urls = safeUrls(row.getUrls());
|
||||
return urls.isEmpty() ? "" : urls.get(0);
|
||||
}
|
||||
|
||||
private List<String> imageUrls(SimilarAsinResultRowDto row) {
|
||||
if (row == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> 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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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/<instanceId>/<objectKey>}
|
||||
* - 老格式:{@code <objectKey>}(无 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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 = '<NEW_TOKEN_HERE>'
|
||||
-- WHERE module_type = 'APPEARANCE_PATENT' AND credential_name = 'appearance-1';
|
||||
-- UPDATE biz_coze_credential SET token = '<NEW_TOKEN_HERE>'
|
||||
-- WHERE module_type = 'APPEARANCE_PATENT' AND credential_name = 'appearance-2';
|
||||
-- UPDATE biz_coze_credential SET token = '<NEW_TOKEN_HERE>'
|
||||
-- WHERE module_type = 'SIMILAR_ASIN' AND credential_name = 'similar-1';
|
||||
-- UPDATE biz_coze_credential SET token = '<NEW_TOKEN_HERE>'
|
||||
-- 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;
|
||||
@@ -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<String> rows = List.of("\u9519\u8bef", "\u6210\u529f", "FAILED", "\u5904\u7406\u4e2d");
|
||||
FailedStatusRowFilter.FilterResult<String> 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<String> rows = List.of("\u9519\u8bef", "\u6210\u529f");
|
||||
FailedStatusRowFilter.FilterResult<String> result =
|
||||
FailedStatusRowFilter.retainFailedRows(rows, false, value -> value);
|
||||
|
||||
assertFalse(result.filterApplied());
|
||||
assertEquals(rows, result.rows());
|
||||
assertEquals(0, result.filteredCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canRetainFailedAndBlankRowsTogether() {
|
||||
List<String> rows = List.of("\u9519\u8bef", "", "\u6210\u529f", "FAILED");
|
||||
FailedStatusRowFilter.FilterResult<String> 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user