异常记录检测修改
This commit is contained in:
+105
-2
@@ -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;
|
||||
|
||||
+2
-5
@@ -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;
|
||||
|
||||
+8
@@ -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 = "图片地址为空")
|
||||
|
||||
+493
-19
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user