提交一些更改

This commit is contained in:
super
2026-05-20 09:05:28 +08:00
parent a2ebfc0b81
commit 73d25fcbcb
28 changed files with 1620 additions and 8062 deletions
@@ -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 业务侧 SUCCESSstatus=SUCCESS 或 immediate 同步返回)但解析后无任何风险维度结果,
* 直接 {@link AppearancePatentCozeClient#markRowsFailed(List, String)} 落地,<b>不再抛异常</b>
* 避免业务空结果被重复扔进 retry/split-retry 死循环。</li>
* <li>workflow 业务侧非 SUCCESSpoll 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) {
}
}