异常记录检测修改

This commit is contained in:
super
2026-05-04 19:04:55 +08:00
parent f46c231323
commit 111f30d150
70 changed files with 7182 additions and 252 deletions
@@ -44,6 +44,42 @@ public class AppearancePatentCozeClient {
}
}
public CozeSubmitResponse submitWorkflow(List<AppearancePatentResultRowDto> rows, String prompt) throws Exception {
JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt));
ensureSuccess(submitRoot);
return new CozeSubmitResponse(
extractExecuteId(submitRoot),
extractResultDataText(submitRoot),
writeJson(submitRoot)
);
}
public CozePollResponse pollWorkflow(String executeId) throws Exception {
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId));
ensureSuccess(pollRoot);
String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT);
String dataText = extractResultDataText(pollRoot);
String outputText = dataText.isBlank() ? extractWorkflowOutputText(pollRoot) : "";
String failureMessage = isFailedWorkflowStatus(status)
? firstNonBlank(resolveFailureMessage(pollRoot), "Coze async workflow failed")
: "";
return new CozePollResponse(executeId, status, dataText, outputText, failureMessage, writeJson(pollRoot));
}
public List<AppearancePatentResultRowDto> mergeRowsFromDataText(List<AppearancePatentResultRowDto> rows, String dataText) throws Exception {
if (dataText == null || dataText.isBlank()) {
return rows == null ? List.of() : rows.stream().map(this::copy).toList();
}
return mergeRows(rows, parseResults(wrapDataPayload(dataText)));
}
public List<AppearancePatentResultRowDto> markRowsFailed(List<AppearancePatentResultRowDto> rows, String failureMessage) {
if (rows == null || rows.isEmpty()) {
return List.of();
}
return rows.stream().map(this::copy).map(row -> markFailed(row, failureMessage)).toList();
}
private List<AppearancePatentResultRowDto> inspectWithFallback(List<AppearancePatentResultRowDto> rows, String prompt) {
try {
if (rows.size() == 1) {
@@ -140,6 +176,7 @@ public class AppearancePatentCozeClient {
long deadline = System.currentTimeMillis() + Math.max(10000, properties.getCozePollTimeoutMillis());
while (System.currentTimeMillis() < deadline) {
ensureNotInterrupted();
JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId));
ensureSuccess(pollRoot);
@@ -430,9 +467,21 @@ public class AppearancePatentCozeClient {
}
private AppearancePatentResultRowDto markFailed(AppearancePatentResultRowDto row, String failureMessage) {
String reviewMessage = failureMessage == null || failureMessage.isBlank()
? "Coze 检测失败,待人工复核"
: "Coze 检测失败,待人工复核:" + failureMessage;
if (row.getError() == null || row.getError().isBlank()) {
row.setError(failureMessage);
}
if (row.getTitleRisk() == null || row.getTitleRisk().isBlank()) {
row.setTitleRisk(reviewMessage);
}
if (row.getAppearanceRisk() == null || row.getAppearanceRisk().isBlank()) {
row.setAppearanceRisk(reviewMessage);
}
if (row.getPatentRisk() == null || row.getPatentRisk().isBlank()) {
row.setPatentRisk(reviewMessage);
}
if (row.getConclusion() == null || row.getConclusion().isBlank()) {
row.setConclusion(failureMessage);
}
@@ -515,15 +564,22 @@ public class AppearancePatentCozeClient {
return "";
}
if (!outputNode.isTextual()) {
return outputNode.toString();
String discovered = discoverEmbeddedData(outputNode);
return discovered.isBlank() && isResultDataPayload(outputNode) ? outputNode.toString() : discovered;
}
String output = normalize(outputNode.asText(""));
if (output.isBlank()) {
return "";
}
if (looksLikeResultDataPayload(output)) {
return output;
}
JsonNode parsedOutput = parseJsonOrMissing(output);
String nestedOutput = text(firstNonNull(parsedOutput.get("Output"), parsedOutput.get("output")));
return nestedOutput == null || nestedOutput.isBlank() ? output : nestedOutput;
if (nestedOutput != null && !nestedOutput.isBlank() && looksLikeResultDataPayload(nestedOutput)) {
return nestedOutput;
}
return discoverEmbeddedData(parsedOutput);
}
private String discoverEmbeddedData(JsonNode node) {
@@ -749,6 +805,7 @@ public class AppearancePatentCozeClient {
private void sleepBeforeRetry(int attemptIndex) {
long delayMillis = Math.max(1, attemptIndex) * 1500L;
ensureNotInterrupted();
sleepQuietly(delayMillis);
}
@@ -757,6 +814,13 @@ public class AppearancePatentCozeClient {
Thread.sleep(delayMillis);
} catch (InterruptedException interruptedException) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Coze workflow interrupted", interruptedException);
}
}
private void ensureNotInterrupted() {
if (Thread.currentThread().isInterrupted()) {
throw new IllegalStateException("Coze workflow interrupted");
}
}
@@ -855,6 +919,45 @@ public class AppearancePatentCozeClient {
) {
}
public record CozeSubmitResponse(
String executeId,
String immediateData,
String rawResponse
) {
}
public record CozePollResponse(
String executeId,
String status,
String dataText,
String outputText,
String failureMessage,
String rawResponse
) {
public boolean hasPayload() {
return dataText != null && !dataText.isBlank() || outputText != null && !outputText.isBlank();
}
public String resolvedPayloadText() {
return dataText != null && !dataText.isBlank() ? dataText : outputText;
}
public boolean isFailed() {
String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT);
return normalized.contains("FAILED") || normalized.contains("ERROR") || normalized.contains("CANCEL");
}
public boolean isFinished() {
String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT);
return isFailed()
|| normalized.contains("SUCCESS")
|| normalized.contains("SUCCEED")
|| normalized.contains("FINISH")
|| normalized.contains("DONE")
|| normalized.contains("COMPLET");
}
}
private static final class PartialCozeResultException extends RuntimeException {
private final int resolvedCount;
@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.appearancepatent.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParseRequest;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentSubmitResultRequest;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentTaskBatchRequest;
@@ -14,7 +15,6 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -28,7 +28,6 @@ import org.springframework.web.server.ResponseStatusException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@RestController
@@ -129,10 +128,8 @@ public class AppearancePatentController {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果");
}
try {
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
response.setContentType("application/octet-stream");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename);
DownloadHeaderUtil.setAttachment(response, filename);
try (InputStream in = URI.create(url).toURL().openStream()) {
byte[] buffer = new byte[65536];
int read;
@@ -29,9 +29,17 @@ public class AppearancePatentResultRowDto {
private String country;
@Schema(description = "商品主图或待检测图片 URL。Java 调用 Coze 时会放入 url_list。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
@JsonAlias({
"imageUrl", "image_url", "imgUrl", "img_url", "pictureUrl", "picture_url",
"mainImage", "main_image", "mainImageUrl", "main_image_url",
"productImage", "product_image", "productImageUrl", "product_image_url",
"image", "img", "pic", "picture", "link", "imageLink", "image_link",
"图片链接", "商品图片", "商品主图", "主图", "主图链接"
})
private String url;
@Schema(description = "商品标题。Java 调用 Coze 时会放入 title_list;为空时会回退使用 ASIN。", example = "Women Floral Dress Summer Casual")
@JsonAlias({"productTitle", "product_title", "itemTitle", "item_title", "商品标题", "商品名称", "标题"})
private String title;
@Schema(description = "单行错误信息。通常用于记录 Python 单行处理异常;Coze 失败时后端会尽量保留原始行,不强行写入风险结果。", example = "图片地址为空")
@@ -3,9 +3,11 @@ package com.nanri.aiimage.modules.appearancepatent.service;
import cn.hutool.core.util.IdUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.service.DistributedJobLockService;
import com.nanri.aiimage.config.AppearancePatentProperties;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.modules.appearancepatent.client.AppearancePatentCozeClient;
@@ -50,7 +52,10 @@ import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
@@ -73,6 +78,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
@Service
@@ -85,6 +91,10 @@ public class AppearancePatentTaskService {
private static final String STATUS_RUNNING = "RUNNING";
private static final String STATUS_SUCCESS = "SUCCESS";
private static final String STATUS_FAILED = "FAILED";
private static final String COZE_STATUS_SUBMITTED = "SUBMITTED";
private static final String COZE_STATUS_RUNNING = "RUNNING";
private static final String COZE_STATUS_DONE = "DONE";
private static final String COZE_STATUS_FAILED = "FAILED";
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final int RESULT_ROWS_READ_RETRY_LIMIT = 3;
private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L;
@@ -114,6 +124,10 @@ public class AppearancePatentTaskService {
private final TaskProgressSnapshotService taskProgressSnapshotService;
private final TransientPayloadStorageService transientPayloadStorageService;
private final PlatformTransactionManager transactionManager;
private final DistributedJobLockService distributedJobLockService;
@Autowired
@Qualifier("cozeTaskExecutor")
private TaskExecutor cozeTaskExecutor;
public AppearancePatentParseVo parseAndCreateTask(AppearancePatentParseRequest request) {
long startedAt = System.nanoTime();
@@ -514,6 +528,10 @@ public class AppearancePatentTaskService {
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 50"));
for (FileTaskEntity task : tasks) {
if (isJavaSideProcessing(task.getId())) {
touchJavaSideTaskActivity(task.getId());
continue;
}
long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId());
if (heartbeatMillis > thresholdMillis) {
continue;
@@ -533,6 +551,10 @@ public class AppearancePatentTaskService {
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 50"));
for (FileTaskEntity task : tasks) {
if (isJavaSideProcessing(task.getId())) {
touchJavaSideTaskActivity(task.getId());
continue;
}
long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId());
if (heartbeatMillis > thresholdMillis) {
continue;
@@ -1004,7 +1026,7 @@ public class AppearancePatentTaskService {
assembleResultWorkbook(task, result);
} catch (Exception ex) {
log.warn("[appearance-patent] assemble result workbook failed taskId={} err={}", task.getId(), ex.getMessage());
finalError = firstNonBlank(finalError, "生成外观专利检测结果失败");
finalError = firstNonBlank(finalError, "Generate appearance patent result failed");
}
}
}
@@ -1038,39 +1060,478 @@ public class AppearancePatentTaskService {
return false;
}
public void processResultFileJob(TaskFileJobEntity job) {
public boolean processResultFileJob(TaskFileJobEntity job) {
if (job == null || job.getTaskId() == null || job.getResultId() == null) {
throw new BusinessException("结果文件任务参数不完整");
throw new BusinessException("result file job arguments are incomplete");
}
FileTaskEntity task = fileTaskMapper.selectById(job.getTaskId());
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
throw new BusinessException("task not found");
}
FileResultEntity result = fileResultMapper.selectById(job.getResultId());
if (result == null || !MODULE_TYPE.equals(result.getModuleType())) {
throw new BusinessException("结果记录不存在");
throw new BusinessException("result record not found");
}
List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
.eq(TaskChunkEntity::getTaskId, task.getId())
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
.orderByAsc(TaskChunkEntity::getChunkIndex));
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
int cozeWorkUnits = countCozeWorkUnits(chunks, Math.max(1, properties.getCozeBatchSize()));
int totalProgressUnits = Math.max(3, cozeWorkUnits + 3);
int[] completedProgressUnits = {0};
saveFileBuildProgress(task, job, totalProgressUnits, completedProgressUnits[0], "正在请求 Coze");
Runnable progressHook = () -> {
saveFileBuildProgress(task, job, totalProgressUnits, 0, "Submitting Coze");
boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId);
if (pendingCoze) {
taskFileJobService.touchRunning(job.getId());
completedProgressUnits[0] = Math.min(totalProgressUnits - 2, completedProgressUnits[0] + 1);
saveFileBuildProgress(task, job, totalProgressUnits, completedProgressUnits[0], "正在请求 Coze");
};
applyCozeToPersistedChunks(task, progressHook);
completedProgressUnits[0] = Math.max(completedProgressUnits[0], totalProgressUnits - 2);
saveFileBuildProgress(task, job, totalProgressUnits, completedProgressUnits[0], "正在组装 xlsx");
touchJavaSideTaskActivity(task.getId());
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result");
return false;
}
completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits);
return true;
}
@Scheduled(fixedDelayString = "${aiimage.appearance-patent.coze-poll-delay-ms:5000}")
public void pollPendingCozeJobs() {
DistributedJobLockService.LockHandle lockHandle =
distributedJobLockService.tryLock("appearance-patent:coze-poll", Duration.ofMinutes(1));
if (lockHandle == null) {
return;
}
try (lockHandle) {
List<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
.isNotNull(TaskScopeStateEntity::getCozeExecuteId)
.orderByAsc(TaskScopeStateEntity::getUpdatedAt)
.last("limit 50"));
if (states == null || states.isEmpty()) {
return;
}
log.info("[appearance-patent] coze poll picked pending states count={}", states.size());
for (TaskScopeStateEntity state : states) {
if (state == null || state.getId() == null) {
continue;
}
cozeTaskExecutor.execute(() -> pollPendingCozeState(state.getId()));
}
}
}
private boolean submitCozeBatches(FileTaskEntity task,
FileResultEntity result,
TaskFileJobEntity job,
List<TaskChunkEntity> chunks,
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId) {
if (chunks == null || chunks.isEmpty()) {
return countPendingCozeStates(task.getId()) > 0;
}
if (properties.getCozeToken() == null || properties.getCozeToken().isBlank()) {
log.warn("[appearance-patent] coze token not configured, skip async coze taskId={} jobId={}",
task.getId(), job.getId());
return false;
}
String prompt = readAiPrompt(task);
int batchSize = Math.max(1, properties.getCozeBatchSize());
boolean pending = false;
for (TaskChunkEntity chunk : chunks) {
Map<String, AppearancePatentResultRowDto> persistedRows = readChunkRows(chunk);
if (persistedRows.isEmpty()) {
continue;
}
List<AppearancePatentResultRowDto> unresolvedRows = pickGroupRepresentativesForCoze(persistedRows.values());
if (unresolvedRows.isEmpty()) {
continue;
}
int batchTotal = Math.max(1, (unresolvedRows.size() + batchSize - 1) / batchSize);
int batchIndex = 1;
for (int i = 0; i < unresolvedRows.size(); i += batchSize) {
List<AppearancePatentResultRowDto> batchRows =
unresolvedRows.subList(i, Math.min(i + batchSize, unresolvedRows.size()));
pending |= submitCozeBatch(task, result, job, chunk, batchRows, batchIndex, batchTotal, prompt, allRowsByBaseId);
batchIndex++;
}
}
return pending || countPendingCozeStates(task.getId()) > 0;
}
private boolean submitCozeBatch(FileTaskEntity task,
FileResultEntity result,
TaskFileJobEntity job,
TaskChunkEntity chunk,
List<AppearancePatentResultRowDto> batchRows,
int batchIndex,
int batchTotal,
String prompt,
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId) {
if (batchRows == null || batchRows.isEmpty()) {
return false;
}
String batchScopeKey = buildCozeBatchScopeKey(job.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), batchIndex);
String batchScopeHash = DigestUtil.sha256Hex(batchScopeKey);
TaskScopeStateEntity existing = taskScopeStateMapper.selectOne(new LambdaQueryWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getTaskId, task.getId())
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
.eq(TaskScopeStateEntity::getScopeHash, batchScopeHash)
.last("limit 1"));
if (existing != null) {
return COZE_STATUS_SUBMITTED.equals(existing.getCozeStatus())
|| COZE_STATUS_RUNNING.equals(existing.getCozeStatus());
}
try {
AppearancePatentCozeClient.CozeSubmitResponse submit = cozeClient.submitWorkflow(batchRows, prompt);
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
List<AppearancePatentResultRowDto> cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData());
mergeCozeRowsIntoChunk(task, chunk.getScopeHash(), chunk.getChunkIndex(), cozeRows, allRowsByBaseId);
return false;
}
if (submit.executeId() == null || submit.executeId().isBlank()) {
mergeCozeRowsIntoChunk(task,
chunk.getScopeHash(),
chunk.getChunkIndex(),
cozeClient.markRowsFailed(batchRows, "Coze async execute_id missing"),
allRowsByBaseId);
return false;
}
saveCozeBatchState(task, result, job, chunk, batchRows, batchScopeKey, batchScopeHash,
batchIndex, batchTotal, submit.executeId());
log.info("[appearance-patent] coze async submitted taskId={} jobId={} chunk={} batch={}/{} executeId={}",
task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal, submit.executeId());
return true;
} catch (Exception ex) {
String message = firstNonBlank(ex.getMessage(), "Coze submit failed");
log.warn("[appearance-patent] coze async submit failed taskId={} jobId={} chunk={} batch={}/{} err={}",
task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal, message);
mergeCozeRowsIntoChunk(task,
chunk.getScopeHash(),
chunk.getChunkIndex(),
cozeClient.markRowsFailed(batchRows, message),
allRowsByBaseId);
return false;
}
}
private void saveCozeBatchState(FileTaskEntity task,
FileResultEntity result,
TaskFileJobEntity job,
TaskChunkEntity chunk,
List<AppearancePatentResultRowDto> batchRows,
String batchScopeKey,
String batchScopeHash,
int batchIndex,
int batchTotal,
String executeId) {
LocalDateTime now = LocalDateTime.now();
CozeBatchContext context = new CozeBatchContext(
job.getId(),
result.getId(),
chunk.getScopeHash(),
chunk.getChunkIndex(),
batchIndex,
batchTotal
);
String batchPayload = writeJson(batchRows, "serialize coze batch payload failed");
String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast(
MODULE_TYPE, task.getId(), batchScopeHash, batchPayload, true);
TaskScopeStateEntity state = new TaskScopeStateEntity();
state.setTaskId(task.getId());
state.setModuleType(MODULE_TYPE);
state.setScopeKey(batchScopeKey);
state.setScopeHash(batchScopeHash);
state.setParsedPayloadJson(storedBatchPayload);
state.setStateJson(writeJson(context, "serialize coze batch context failed"));
state.setCozeExecuteId(executeId);
state.setCozeStatus(COZE_STATUS_SUBMITTED);
state.setCozeSubmittedAt(now);
state.setCozeAttemptCount(0);
state.setChunkTotal(batchTotal);
state.setReceivedChunkCount(batchIndex);
state.setCompleted(0);
state.setCreatedAt(now);
state.setUpdatedAt(now);
try {
taskScopeStateMapper.insert(state);
touchJavaSideTaskActivity(task.getId());
} catch (DuplicateKeyException ex) {
transientPayloadStorageService.deletePayloadIfPresent(storedBatchPayload);
log.info("[appearance-patent] duplicate coze batch state ignored taskId={} scope={}",
task.getId(), batchScopeKey);
}
}
private void pollPendingCozeState(Long stateId) {
TaskScopeStateEntity state = taskScopeStateMapper.selectById(stateId);
if (state == null || state.getCozeExecuteId() == null || state.getCozeExecuteId().isBlank()) {
return;
}
if (!(COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) {
return;
}
if (!tryClaimCozeStateForPoll(state)) {
return;
}
CozeBatchContext context = readCozeBatchContext(state);
if (context == null || context.jobId() == null || context.resultId() == null) {
markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze batch context missing");
return;
}
taskFileJobService.touchRunning(context.jobId());
try {
AppearancePatentCozeClient.CozePollResponse poll = cozeClient.pollWorkflow(state.getCozeExecuteId());
if (!poll.hasPayload() && !poll.isFinished() && !isCozeStateTimedOut(state)) {
updateCozeStateRunning(state, null);
return;
}
String failureMessage = poll.isFailed()
? firstNonBlank(poll.failureMessage(), "Coze async workflow failed")
: "";
if (!poll.hasPayload() && failureMessage.isBlank()) {
failureMessage = isCozeStateTimedOut(state)
? "Coze async workflow poll timeout"
: "Coze async workflow completed without output";
}
List<AppearancePatentResultRowDto> batchRows = readCozeBatchRows(state);
List<AppearancePatentResultRowDto> cozeRows = failureMessage.isBlank()
? cozeClient.mergeRowsFromDataText(batchRows, poll.resolvedPayloadText())
: cozeClient.markRowsFailed(batchRows, failureMessage);
FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId());
if (task != null) {
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
}
markCozeStateTerminal(state,
failureMessage.isBlank() ? COZE_STATUS_DONE : COZE_STATUS_FAILED,
failureMessage.isBlank() ? null : failureMessage);
maybeFinalizeCozeJob(state.getTaskId(), context);
} catch (Exception ex) {
String message = firstNonBlank(ex.getMessage(), "Coze poll failed");
if (isCozeStateTimedOut(state)) {
List<AppearancePatentResultRowDto> batchRows = readCozeBatchRows(state);
FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId());
if (task != null) {
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
mergeCozeRowsIntoChunk(task,
context.chunkScopeHash(),
context.chunkIndex(),
cozeClient.markRowsFailed(batchRows, message),
allRowsByBaseId);
}
markCozeStateTerminal(state, COZE_STATUS_FAILED, message);
maybeFinalizeCozeJob(state.getTaskId(), context);
return;
}
log.warn("[appearance-patent] coze poll failed taskId={} stateId={} executeId={} err={}",
state.getTaskId(), state.getId(), state.getCozeExecuteId(), message);
updateCozeStateRunning(state, message);
}
}
private void updateCozeStateRunning(TaskScopeStateEntity state, String error) {
int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getId, state.getId())
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
.set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_RUNNING)
.set(TaskScopeStateEntity::getCozeLastPolledAt, LocalDateTime.now())
.set(TaskScopeStateEntity::getCozeAttemptCount, cozeAttemptCount(state) + 1)
.set(TaskScopeStateEntity::getCozeError, error)
.set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now()));
if (updated > 0) {
touchJavaSideTaskActivity(state.getTaskId());
}
}
private boolean tryClaimCozeStateForPoll(TaskScopeStateEntity state) {
if (state == null || state.getId() == null) {
return false;
}
LocalDateTime now = LocalDateTime.now();
long intervalMillis = Math.max(200L, properties.getCozePollIntervalMillis());
if (state.getCozeLastPolledAt() != null
&& Duration.between(state.getCozeLastPolledAt(), now).toMillis() < intervalMillis) {
return false;
}
return taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getId, state.getId())
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
.and(wrapper -> wrapper
.isNull(TaskScopeStateEntity::getCozeLastPolledAt)
.or()
.le(TaskScopeStateEntity::getCozeLastPolledAt, now.minus(Duration.ofMillis(intervalMillis))))
.set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_RUNNING)
.set(TaskScopeStateEntity::getCozeLastPolledAt, now)
.set(TaskScopeStateEntity::getUpdatedAt, now)) > 0;
}
private void markCozeStateTerminal(TaskScopeStateEntity state, String status, String error) {
taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getId, state.getId())
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
.set(TaskScopeStateEntity::getCozeStatus, status)
.set(TaskScopeStateEntity::getCozeCompletedAt, LocalDateTime.now())
.set(TaskScopeStateEntity::getCozeLastPolledAt, LocalDateTime.now())
.set(TaskScopeStateEntity::getCozeAttemptCount, cozeAttemptCount(state) + 1)
.set(TaskScopeStateEntity::getCozeError, error)
.set(TaskScopeStateEntity::getCompleted, 1)
.set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now()));
}
private void maybeFinalizeCozeJob(Long taskId, CozeBatchContext context) {
if (taskId == null || context == null || countPendingCozeStates(taskId) > 0) {
return;
}
DistributedJobLockService.LockHandle lockHandle =
distributedJobLockService.tryLock("appearance-patent:coze-finalize:" + taskId, Duration.ofMinutes(5));
if (lockHandle == null) {
return;
}
try (lockHandle) {
if (countPendingCozeStates(taskId) > 0) {
return;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileResultEntity result = fileResultMapper.selectById(context.resultId());
TaskFileJobEntity job = taskFileJobService.findAssembleJob(taskId, MODULE_TYPE, context.resultId());
if (task == null || result == null || job == null || "SUCCESS".equals(job.getStatus())) {
return;
}
int cozeWorkUnits = countCompletedCozeStates(taskId);
int totalProgressUnits = Math.max(3, cozeWorkUnits + 3);
completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits);
taskFileJobService.markSuccess(job, result.getResultFileUrl());
cleanupResultFileJob(job);
log.info("[appearance-patent] coze async job finalized taskId={} jobId={} resultId={} resultFileUrl={}",
taskId, job.getId(), result.getId(), result.getResultFileUrl());
} catch (Exception ex) {
TaskFileJobEntity job = taskFileJobService.findAssembleJob(taskId, MODULE_TYPE, context.resultId());
if (job != null) {
taskFileJobService.markFailed(job, firstNonBlank(ex.getMessage(), "appearance patent result file build failed"));
}
log.warn("[appearance-patent] coze async finalize failed taskId={} resultId={} err={}",
taskId, context.resultId(), ex.getMessage(), ex);
}
}
private void completeCozeFileJob(FileTaskEntity task,
FileResultEntity result,
TaskFileJobEntity job,
int totalProgressUnits,
int cozeWorkUnits) {
int assembleProgress = Math.max(1, Math.min(totalProgressUnits - 2, cozeWorkUnits));
saveFileBuildProgress(task, job, totalProgressUnits, assembleProgress, "Assembling xlsx");
assembleResultWorkbook(task, result);
completedProgressUnits[0] = totalProgressUnits - 1;
saveFileBuildProgress(task, job, totalProgressUnits, completedProgressUnits[0], "正在上传结果文件");
saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits - 1, "Uploading result file");
fileResultMapper.updateById(result);
saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits, "结果文件已生成");
saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits, "Result file generated");
}
private void mergeCozeRowsIntoChunk(FileTaskEntity task,
String chunkScopeHash,
Integer chunkIndex,
List<AppearancePatentResultRowDto> cozeRows,
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId) {
if (task == null || cozeRows == null || cozeRows.isEmpty()) {
return;
}
Map<String, AppearancePatentResultRowDto> mergedRows = new LinkedHashMap<>();
for (AppearancePatentResultRowDto resultRow : cozeRows) {
for (AppearancePatentResultRowDto expandedRow : expandRows(List.of(resultRow), allRowsByBaseId)) {
mergedRows.put(rowKey(expandedRow), expandedRow);
}
}
mergeChunkPayload(task.getId(), chunkScopeHash, chunkIndex, new ArrayList<>(mergedRows.values()));
}
private List<AppearancePatentResultRowDto> readCozeBatchRows(TaskScopeStateEntity state) {
if (state == null || state.getParsedPayloadJson() == null || state.getParsedPayloadJson().isBlank()) {
return List.of();
}
try {
String payloadJson = transientPayloadStorageService.resolvePayload(
state.getParsedPayloadJson(), "read appearance patent coze batch failed");
JsonNode array = objectMapper.readTree(payloadJson);
if (!array.isArray()) {
return List.of();
}
List<AppearancePatentResultRowDto> rows = new ArrayList<>();
for (JsonNode node : array) {
rows.add(objectMapper.treeToValue(node, AppearancePatentResultRowDto.class));
}
return rows;
} catch (Exception ex) {
log.warn("[appearance-patent] read coze batch failed taskId={} stateId={} err={}",
state.getTaskId(), state.getId(), ex.getMessage());
return List.of();
}
}
private CozeBatchContext readCozeBatchContext(TaskScopeStateEntity state) {
if (state == null || state.getStateJson() == null || state.getStateJson().isBlank()) {
return null;
}
try {
return objectMapper.readValue(state.getStateJson(), CozeBatchContext.class);
} catch (Exception ex) {
log.warn("[appearance-patent] read coze batch context failed taskId={} stateId={} err={}",
state.getTaskId(), state.getId(), ex.getMessage());
return null;
}
}
private boolean isCozeStateTimedOut(TaskScopeStateEntity state) {
if (state == null || state.getCozeSubmittedAt() == null) {
return false;
}
long timeoutMillis = Math.max(10000L, properties.getCozePollTimeoutMillis());
return Duration.between(state.getCozeSubmittedAt(), LocalDateTime.now()).toMillis() >= timeoutMillis;
}
private int cozeAttemptCount(TaskScopeStateEntity state) {
return state == null || state.getCozeAttemptCount() == null ? 0 : state.getCozeAttemptCount();
}
private int countPendingCozeStates(Long taskId) {
if (taskId == null || taskId <= 0) {
return 0;
}
Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getTaskId, taskId)
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING)));
return count == null ? 0 : count.intValue();
}
private int countCompletedCozeStates(Long taskId) {
if (taskId == null || taskId <= 0) {
return 0;
}
Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getTaskId, taskId)
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_DONE, COZE_STATUS_FAILED)));
return count == null ? 0 : count.intValue();
}
private boolean isJavaSideProcessing(Long taskId) {
return countPendingCozeStates(taskId) > 0
|| taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE) > 0;
}
private void touchJavaSideTaskActivity(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getId, taskId)
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getStatus, STATUS_RUNNING)
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now()));
}
private String buildCozeBatchScopeKey(Long jobId, String chunkScopeHash, Integer chunkIndex, int batchIndex) {
return "coze:job:" + jobId
+ ":chunk:" + (chunkIndex == null ? 0 : chunkIndex)
+ ":" + firstNonBlank(chunkScopeHash, "unknown")
+ ":batch:" + batchIndex;
}
private int countCozeWorkUnits(List<TaskChunkEntity> chunks, int batchSize) {
@@ -1146,7 +1607,12 @@ public class AppearancePatentTaskService {
throw new BusinessException("创建结果目录失败");
}
String filename = safeFileStem(result.getSourceFilename()) + "-result.xlsx";
File xlsx = new File(outputDir, filename);
String tempFilename = safeFileStem(result.getSourceFilename())
+ "-" + task.getId()
+ "-" + result.getId()
+ "-" + UUID.randomUUID()
+ "-result.xlsx";
File xlsx = new File(outputDir, tempFilename);
try {
writeResultWorkbook(xlsx, parsed, resultMap);
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
@@ -1691,7 +2157,7 @@ public class AppearancePatentTaskService {
}
private String storeParsedPayload(Long taskId, String scopeHash, String parsedPayloadJson) {
return transientPayloadStorageService.storeParsedPayloadFast(MODULE_TYPE, taskId, scopeHash, parsedPayloadJson, true);
return transientPayloadStorageService.storeParsedPayloadFast(MODULE_TYPE, taskId, scopeHash, parsedPayloadJson, false);
}
private long elapsedMs(long start, long end) {
@@ -1919,6 +2385,14 @@ public class AppearancePatentTaskService {
String error) {
}
private record CozeBatchContext(Long jobId,
Long resultId,
String chunkScopeHash,
Integer chunkIndex,
Integer batchIndex,
Integer batchTotal) {
}
private record ParsedWorkbook(int totalRows, int droppedRows, List<String> headers, List<AppearancePatentParsedRowVo> allRows) {
}
}