更新处理相关内容
This commit is contained in:
547002
2026_05_06.log
Normal file
547002
2026_05_06.log
Normal file
File diff suppressed because one or more lines are too long
82604
2026_05_07.log
Normal file
82604
2026_05_07.log
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/appearance-patent-D0I8HQmx.css
Normal file
1
app/assets/appearance-patent-D0I8HQmx.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{bb as r}from"./pywebview-CcblbV5J.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{bc as r}from"./pywebview-BdjYDsam.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"version": "1.0.56"}
|
{"version": "1.0.13"}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,10 +11,10 @@ public class AppearancePatentProperties {
|
|||||||
private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}";
|
private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}";
|
||||||
private String cozeWorkflowId = "7632683471312355338";
|
private String cozeWorkflowId = "7632683471312355338";
|
||||||
private String cozeToken = "";
|
private String cozeToken = "";
|
||||||
private int cozeBatchSize = 10;
|
private int cozeBatchSize = 50;
|
||||||
private int cozeConnectTimeoutMillis = 10000;
|
private int cozeConnectTimeoutMillis = 10000;
|
||||||
private int cozeReadTimeoutMillis = 60000;
|
private int cozeReadTimeoutMillis = 60000;
|
||||||
private int cozePollIntervalMillis = 5000;
|
private int cozePollIntervalMillis = 30000;
|
||||||
private int cozePollTimeoutMillis = 600000;
|
private int cozePollTimeoutMillis = 600000;
|
||||||
private int staleTimeoutMinutes = 20;
|
private int staleTimeoutMinutes = 20;
|
||||||
private String staleFinalizeCron = "0 */2 * * * *";
|
private String staleFinalizeCron = "0 */2 * * * *";
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ public class SimilarAsinProperties {
|
|||||||
private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}";
|
private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}";
|
||||||
private String cozeWorkflowId = "7632683471312355338";
|
private String cozeWorkflowId = "7632683471312355338";
|
||||||
private String cozeToken = "";
|
private String cozeToken = "";
|
||||||
private int cozeBatchSize = 10;
|
private int cozeBatchSize = 50;
|
||||||
private int cozeConnectTimeoutMillis = 10000;
|
private int cozeConnectTimeoutMillis = 10000;
|
||||||
private int cozeReadTimeoutMillis = 60000;
|
private int cozeReadTimeoutMillis = 60000;
|
||||||
private int cozePollIntervalMillis = 5000;
|
private int cozePollIntervalMillis = 30000;
|
||||||
private int cozePollTimeoutMillis = 600000;
|
private int cozePollTimeoutMillis = 600000;
|
||||||
private int staleTimeoutMinutes = 20;
|
private int staleTimeoutMinutes = 20;
|
||||||
private String staleFinalizeCron = "0 */2 * * * *";
|
private String staleFinalizeCron = "0 */2 * * * *";
|
||||||
|
|||||||
@@ -734,7 +734,10 @@ public class AppearancePatentCozeClient {
|
|||||||
private String resolveFailureMessage(JsonNode root) {
|
private String resolveFailureMessage(JsonNode root) {
|
||||||
JsonNode dataNode = root.path("data");
|
JsonNode dataNode = root.path("data");
|
||||||
String message = text(firstNonNull(dataNode.get("error_message"), firstNonNull(dataNode.get("msg"), root.get("msg"))));
|
String message = text(firstNonNull(dataNode.get("error_message"), firstNonNull(dataNode.get("msg"), root.get("msg"))));
|
||||||
return message == null ? "" : message;
|
if (message != null && !message.isBlank()) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return firstNonBlank(findTextByFieldName(root, "error_message", "error", "msg"), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
private String extractExecuteId(JsonNode root) {
|
private String extractExecuteId(JsonNode root) {
|
||||||
@@ -978,7 +981,7 @@ public class AppearancePatentCozeClient {
|
|||||||
|
|
||||||
public boolean isFailed() {
|
public boolean isFailed() {
|
||||||
String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT);
|
String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT);
|
||||||
return normalized.contains("FAILED") || normalized.contains("ERROR") || normalized.contains("CANCEL");
|
return normalized.contains("FAIL") || normalized.contains("ERROR") || normalized.contains("CANCEL");
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isFinished() {
|
public boolean isFinished() {
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ public class AppearancePatentTaskService {
|
|||||||
private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5);
|
private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5);
|
||||||
private static final long TASK_LOCK_WAIT_MILLIS = 10000L;
|
private static final long TASK_LOCK_WAIT_MILLIS = 10000L;
|
||||||
private static final long TASK_LOCK_RETRY_DELAY_MILLIS = 200L;
|
private static final long TASK_LOCK_RETRY_DELAY_MILLIS = 200L;
|
||||||
|
private static final Duration COZE_SUBMIT_LOCK_TTL = Duration.ofMinutes(2);
|
||||||
|
private static final long COZE_SUBMIT_LOCK_WAIT_MILLIS = 180000L;
|
||||||
|
private static final long COZE_SUBMIT_LOCK_RETRY_DELAY_MILLIS = 500L;
|
||||||
|
private static final long COZE_SUBMIT_MIN_INTERVAL_MILLIS = 30000L;
|
||||||
|
private static final int MAX_COZE_SUBMIT_RETRY_COUNT = 5;
|
||||||
private static final List<String> RESULT_HEADERS = List.of(
|
private static final List<String> RESULT_HEADERS = List.of(
|
||||||
"id",
|
"id",
|
||||||
"asin",
|
"asin",
|
||||||
@@ -419,6 +424,7 @@ public class AppearancePatentTaskService {
|
|||||||
completeSubmittedChunk(context);
|
completeSubmittedChunk(context);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
submitCozeForSubmittedChunk(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||||
@@ -496,12 +502,15 @@ public class AppearancePatentTaskService {
|
|||||||
taskScopeStateMapper.updateById(scope);
|
taskScopeStateMapper.updateById(scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SubmitContext context = new SubmitContext(task, scopeKey, scopeHash, chunkIndex,
|
||||||
|
Boolean.TRUE.equals(request.getDone()), request.getError());
|
||||||
if (Boolean.TRUE.equals(request.getDone()) || request.getError() != null && !request.getError().isBlank()) {
|
if (Boolean.TRUE.equals(request.getDone()) || request.getError() != null && !request.getError().isBlank()) {
|
||||||
finalizeTask(task, request.getError(), allRowCount(task), true);
|
finalizeTask(task, request.getError(), allRowCount(task), true);
|
||||||
} else {
|
} else {
|
||||||
task.setUpdatedAt(LocalDateTime.now());
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
fileTaskMapper.updateById(task);
|
fileTaskMapper.updateById(task);
|
||||||
}
|
}
|
||||||
|
submitCozeForSubmittedChunk(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -659,7 +668,7 @@ public class AppearancePatentTaskService {
|
|||||||
upsertScopeState(taskId, scopeKey, scopeHash, chunkTotal, request.getError(), done, false);
|
upsertScopeState(taskId, scopeKey, scopeHash, chunkTotal, request.getError(), done, false);
|
||||||
task.setUpdatedAt(LocalDateTime.now());
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
fileTaskMapper.updateById(task);
|
fileTaskMapper.updateById(task);
|
||||||
return new SubmitContext(task, scopeKey, scopeHash, done, request.getError());
|
return new SubmitContext(task, scopeKey, scopeHash, chunkIndex, done, request.getError());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void completeSubmittedChunk(SubmitContext context) {
|
private void completeSubmittedChunk(SubmitContext context) {
|
||||||
@@ -681,6 +690,39 @@ public class AppearancePatentTaskService {
|
|||||||
fileTaskMapper.updateById(task);
|
fileTaskMapper.updateById(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void submitCozeForSubmittedChunk(SubmitContext context) {
|
||||||
|
if (context == null || context.task() == null || context.task().getId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
FileTaskEntity task = fileTaskMapper.selectById(context.task().getId());
|
||||||
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || STATUS_SUCCESS.equals(task.getStatus())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<TaskChunkEntity> chunks = loadSubmittedChunks(task.getId());
|
||||||
|
if (chunks.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
FileResultEntity result = findOrCreateResultRecordForAssembly(task, allRowCount(task));
|
||||||
|
if (result == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TaskFileJobEntity job = taskFileJobService.enqueueAssembleResult(
|
||||||
|
task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task.getId()));
|
||||||
|
if (job == null || "SUCCESS".equals(job.getStatus())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||||
|
boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId);
|
||||||
|
saveCozePipelineProgress(task, job);
|
||||||
|
if (pendingCoze) {
|
||||||
|
taskFileJobService.touchRunning(job.getId());
|
||||||
|
touchJavaSideTaskActivity(task.getId());
|
||||||
|
} else if (isResultSubmissionComplete(task.getId())) {
|
||||||
|
maybeFinalizeCozeJobLocked(task.getId(), new CozeBatchContext(
|
||||||
|
job.getId(), result.getId(), null, null, 1, 1, currentInstanceId(), 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void finalizeStaleTask(Long taskId, String error) {
|
private void finalizeStaleTask(Long taskId, String error) {
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !STATUS_RUNNING.equals(task.getStatus())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !STATUS_RUNNING.equals(task.getStatus())) {
|
||||||
@@ -1074,13 +1116,19 @@ public class AppearancePatentTaskService {
|
|||||||
private void finalizeTask(FileTaskEntity task, String error, int rowCount, boolean assembleWorkbook) {
|
private void finalizeTask(FileTaskEntity task, String error, int rowCount, boolean assembleWorkbook) {
|
||||||
String finalError = error;
|
String finalError = error;
|
||||||
FileResultEntity result = null;
|
FileResultEntity result = null;
|
||||||
|
boolean hasPersistedResultRows = hasPersistedResultRows(task.getId());
|
||||||
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
.eq(FileResultEntity::getTaskId, task.getId())
|
.eq(FileResultEntity::getTaskId, task.getId())
|
||||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||||
.last("limit 1"));
|
.last("limit 1"));
|
||||||
if (!rows.isEmpty()) {
|
if (!rows.isEmpty()) {
|
||||||
result = rows.getFirst();
|
result = rows.getFirst();
|
||||||
if (assembleWorkbook && shouldAssembleSynchronously()) {
|
} else if (assembleWorkbook && hasPersistedResultRows) {
|
||||||
|
result = createResultRecordForAssembly(task, rowCount);
|
||||||
|
}
|
||||||
|
if (result != null) {
|
||||||
|
boolean shouldAssembleResult = assembleWorkbook && (finalError == null || finalError.isBlank() || hasPersistedResultRows);
|
||||||
|
if (shouldAssembleResult && shouldAssembleSynchronously()) {
|
||||||
try {
|
try {
|
||||||
assembleResultWorkbook(task, result);
|
assembleResultWorkbook(task, result);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
@@ -1102,20 +1150,69 @@ public class AppearancePatentTaskService {
|
|||||||
result.setSuccess(failed ? 0 : 1);
|
result.setSuccess(failed ? 0 : 1);
|
||||||
result.setErrorMessage(finalError);
|
result.setErrorMessage(finalError);
|
||||||
result.setRowCount(rowCount > 0 ? rowCount : result.getRowCount());
|
result.setRowCount(rowCount > 0 ? rowCount : result.getRowCount());
|
||||||
if (!failed && assembleWorkbook) {
|
boolean shouldAssembleResult = assembleWorkbook && (!failed || hasPersistedResultRows);
|
||||||
|
if (shouldAssembleResult) {
|
||||||
result.setResultFilename(safeFileStem(result.getSourceFilename()) + "-result.xlsx");
|
result.setResultFilename(safeFileStem(result.getSourceFilename()) + "-result.xlsx");
|
||||||
result.setResultFileUrl(null);
|
result.setResultFileUrl(null);
|
||||||
result.setResultFileSize(0L);
|
result.setResultFileSize(0L);
|
||||||
result.setResultContentType(CONTENT_TYPE_XLSX);
|
result.setResultContentType(CONTENT_TYPE_XLSX);
|
||||||
}
|
}
|
||||||
fileResultMapper.updateById(result);
|
fileResultMapper.updateById(result);
|
||||||
if (!failed && assembleWorkbook) {
|
if (shouldAssembleResult) {
|
||||||
taskFileJobService.enqueueAssembleResult(task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task.getId()));
|
enqueueResultAssembly(task, result, true);
|
||||||
|
} else if (assembleWorkbook && failed) {
|
||||||
|
log.warn("[appearance-patent] skip failed task workbook assembly because no persisted result rows taskId={}", task.getId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
taskCacheService.deleteTaskCache(task.getId());
|
taskCacheService.deleteTaskCache(task.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private FileResultEntity findOrCreateResultRecordForAssembly(FileTaskEntity task, int rowCount) {
|
||||||
|
if (task == null || task.getId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
|
.eq(FileResultEntity::getTaskId, task.getId())
|
||||||
|
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||||
|
.last("limit 1"));
|
||||||
|
if (rows != null && !rows.isEmpty()) {
|
||||||
|
return rows.getFirst();
|
||||||
|
}
|
||||||
|
return createResultRecordForAssembly(task, rowCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskFileJobEntity enqueueResultAssembly(FileTaskEntity task, FileResultEntity result, boolean dispatchWhenIdle) {
|
||||||
|
if (task == null || task.getId() == null || result == null || result.getId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TaskFileJobEntity job = taskFileJobService.enqueueAssembleResult(
|
||||||
|
task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task.getId()));
|
||||||
|
if (dispatchWhenIdle
|
||||||
|
&& job != null
|
||||||
|
&& "RUNNING".equals(job.getStatus())
|
||||||
|
&& countPendingCozeStates(task.getId()) == 0) {
|
||||||
|
taskFileJobService.requeue(job.getId(), "Python upload finished, assembling xlsx");
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileResultEntity createResultRecordForAssembly(FileTaskEntity task, int rowCount) {
|
||||||
|
AppearancePatentParsedPayloadDto payload = readParsedPayload(task);
|
||||||
|
List<AppearancePatentSourceFileDto> sourceFiles = payload.getSourceFiles() == null ? List.of() : payload.getSourceFiles();
|
||||||
|
FileResultEntity result = new FileResultEntity();
|
||||||
|
result.setTaskId(task.getId());
|
||||||
|
result.setModuleType(MODULE_TYPE);
|
||||||
|
result.setSourceFilename(buildAggregateSourceFilenameLabel(sourceFiles));
|
||||||
|
result.setSourceFileUrl(buildAggregateScopeKey(sourceFiles));
|
||||||
|
result.setRowCount(rowCount > 0 ? rowCount : (payload.getAllItems() == null ? 0 : payload.getAllItems().size()));
|
||||||
|
result.setUserId(task.getUserId());
|
||||||
|
result.setCreatedAt(LocalDateTime.now());
|
||||||
|
fileResultMapper.insert(result);
|
||||||
|
log.warn("[appearance-patent] recreated missing result record for workbook assembly taskId={} resultId={}",
|
||||||
|
task.getId(), result.getId());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean shouldAssembleSynchronously() {
|
private boolean shouldAssembleSynchronously() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1158,29 +1255,41 @@ public class AppearancePatentTaskService {
|
|||||||
if (countPendingCozeStates(task.getId()) > 0) {
|
if (countPendingCozeStates(task.getId()) > 0) {
|
||||||
taskFileJobService.touchRunning(job.getId());
|
taskFileJobService.touchRunning(job.getId());
|
||||||
touchJavaSideTaskActivity(task.getId());
|
touchJavaSideTaskActivity(task.getId());
|
||||||
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result");
|
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze 已提交,等待结果回流");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
saveFileBuildProgress(task, job, totalProgressUnits, 0, "正在提交 Coze");
|
||||||
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||||
saveFileBuildProgress(task, job, totalProgressUnits, 0, "Submitting Coze");
|
|
||||||
boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId);
|
boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId);
|
||||||
if (pendingCoze) {
|
if (pendingCoze) {
|
||||||
taskFileJobService.touchRunning(job.getId());
|
taskFileJobService.touchRunning(job.getId());
|
||||||
touchJavaSideTaskActivity(task.getId());
|
touchJavaSideTaskActivity(task.getId());
|
||||||
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result");
|
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze 已提交,等待结果回流");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (STATUS_RUNNING.equals(task.getStatus()) && !isResultSubmissionComplete(task.getId())) {
|
||||||
|
taskFileJobService.touchRunning(job.getId());
|
||||||
|
touchJavaSideTaskActivity(task.getId());
|
||||||
|
saveFileBuildProgress(task, job, totalProgressUnits, Math.max(1, cozeWorkUnits), "等待 Python 继续回传数据");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits);
|
completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(fixedDelayString = "${aiimage.appearance-patent.coze-poll-delay-ms:5000}")
|
@Scheduled(fixedDelayString = "${aiimage.appearance-patent.coze-poll-delay-ms:30000}")
|
||||||
public void pollPendingCozeJobs() {
|
public void pollPendingCozeJobs() {
|
||||||
List<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
List<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
||||||
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
|
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
|
||||||
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
|
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))
|
||||||
.isNotNull(TaskScopeStateEntity::getCozeExecuteId)
|
.isNotNull(TaskScopeStateEntity::getCozeExecuteId)
|
||||||
.orderByAsc(TaskScopeStateEntity::getUpdatedAt)
|
.and(wrapper -> wrapper
|
||||||
|
.apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) IS NULL")
|
||||||
|
.or()
|
||||||
|
.apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) = ''")
|
||||||
|
.or()
|
||||||
|
.apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) = {0}", currentInstanceId()))
|
||||||
|
.orderByDesc(TaskScopeStateEntity::getUpdatedAt)
|
||||||
.last("limit 50"));
|
.last("limit 50"));
|
||||||
if (states == null || states.isEmpty()) {
|
if (states == null || states.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@@ -1205,6 +1314,64 @@ public class AppearancePatentTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<TaskChunkEntity> loadSubmittedChunks(Long taskId) {
|
||||||
|
if (taskId == null || taskId <= 0) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
|
||||||
|
.eq(TaskChunkEntity::getTaskId, taskId)
|
||||||
|
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
|
||||||
|
.orderByAsc(TaskChunkEntity::getScopeHash)
|
||||||
|
.orderByAsc(TaskChunkEntity::getChunkIndex));
|
||||||
|
return chunks == null ? List.of() : chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CozeCandidate> collectPendingCozeCandidates(FileTaskEntity task, List<TaskChunkEntity> chunks) {
|
||||||
|
if (task == null || task.getId() == null || chunks == null || chunks.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Set<String> queuedRowKeys = new LinkedHashSet<>(loadSubmittedCozeRowKeys(task.getId()));
|
||||||
|
List<CozeCandidate> candidates = new ArrayList<>();
|
||||||
|
for (TaskChunkEntity chunk : chunks) {
|
||||||
|
Map<String, AppearancePatentResultRowDto> persistedRows = readChunkRows(chunk);
|
||||||
|
if (persistedRows.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (AppearancePatentResultRowDto row : pickGroupRepresentativesForCoze(persistedRows.values())) {
|
||||||
|
String key = rowKey(row);
|
||||||
|
if (key.isBlank() || !queuedRowKeys.add(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.add(new CozeCandidate(chunk.getScopeHash(), chunk.getChunkIndex(), row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> loadSubmittedCozeRowKeys(Long taskId) {
|
||||||
|
Set<String> keys = new LinkedHashSet<>();
|
||||||
|
if (taskId == null || taskId <= 0) {
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
List<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
||||||
|
.select(TaskScopeStateEntity::getId, TaskScopeStateEntity::getTaskId, TaskScopeStateEntity::getParsedPayloadJson)
|
||||||
|
.eq(TaskScopeStateEntity::getTaskId, taskId)
|
||||||
|
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
|
||||||
|
.isNotNull(TaskScopeStateEntity::getCozeStatus));
|
||||||
|
if (states == null || states.isEmpty()) {
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
for (TaskScopeStateEntity state : states) {
|
||||||
|
for (AppearancePatentResultRowDto row : readCozeBatchRows(state)) {
|
||||||
|
String key = rowKey(row);
|
||||||
|
if (!key.isBlank()) {
|
||||||
|
keys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean submitCozeBatches(FileTaskEntity task,
|
private boolean submitCozeBatches(FileTaskEntity task,
|
||||||
FileResultEntity result,
|
FileResultEntity result,
|
||||||
TaskFileJobEntity job,
|
TaskFileJobEntity job,
|
||||||
@@ -1221,24 +1388,27 @@ public class AppearancePatentTaskService {
|
|||||||
String prompt = readAiPrompt(task);
|
String prompt = readAiPrompt(task);
|
||||||
String apiKey = readApiKey(task);
|
String apiKey = readApiKey(task);
|
||||||
int batchSize = Math.max(1, properties.getCozeBatchSize());
|
int batchSize = Math.max(1, properties.getCozeBatchSize());
|
||||||
|
List<CozeCandidate> candidates = collectPendingCozeCandidates(task, chunks);
|
||||||
|
boolean flushRemainder = isResultSubmissionComplete(task.getId());
|
||||||
|
int submitLimit = (candidates.size() / batchSize) * batchSize;
|
||||||
|
if (flushRemainder && submitLimit < candidates.size()) {
|
||||||
|
submitLimit = candidates.size();
|
||||||
|
}
|
||||||
|
if (submitLimit <= 0) {
|
||||||
|
log.info("[appearance-patent] coze batch waiting for more rows taskId={} jobId={} pendingRows={} batchSize={} finalUpload={}",
|
||||||
|
task.getId(), job.getId(), candidates.size(), batchSize, flushRemainder);
|
||||||
|
return countPendingCozeStates(task.getId()) > 0;
|
||||||
|
}
|
||||||
boolean pending = false;
|
boolean pending = false;
|
||||||
for (TaskChunkEntity chunk : chunks) {
|
int batchTotal = Math.max(1, (submitLimit + batchSize - 1) / batchSize);
|
||||||
Map<String, AppearancePatentResultRowDto> persistedRows = readChunkRows(chunk);
|
int batchIndex = 1;
|
||||||
if (persistedRows.isEmpty()) {
|
for (int i = 0; i < submitLimit; i += batchSize) {
|
||||||
continue;
|
List<CozeCandidate> batchCandidates = candidates.subList(i, Math.min(i + batchSize, submitLimit));
|
||||||
}
|
List<AppearancePatentResultRowDto> batchRows = batchCandidates.stream()
|
||||||
List<AppearancePatentResultRowDto> unresolvedRows = pickGroupRepresentativesForCoze(persistedRows.values());
|
.map(CozeCandidate::row)
|
||||||
if (unresolvedRows.isEmpty()) {
|
.toList();
|
||||||
continue;
|
pending |= submitCozeBatch(task, result, job, batchRows, batchIndex, batchTotal, prompt, apiKey, allRowsByBaseId);
|
||||||
}
|
batchIndex++;
|
||||||
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, apiKey, allRowsByBaseId);
|
|
||||||
batchIndex++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return pending || countPendingCozeStates(task.getId()) > 0;
|
return pending || countPendingCozeStates(task.getId()) > 0;
|
||||||
}
|
}
|
||||||
@@ -1246,7 +1416,6 @@ public class AppearancePatentTaskService {
|
|||||||
private boolean submitCozeBatch(FileTaskEntity task,
|
private boolean submitCozeBatch(FileTaskEntity task,
|
||||||
FileResultEntity result,
|
FileResultEntity result,
|
||||||
TaskFileJobEntity job,
|
TaskFileJobEntity job,
|
||||||
TaskChunkEntity chunk,
|
|
||||||
List<AppearancePatentResultRowDto> batchRows,
|
List<AppearancePatentResultRowDto> batchRows,
|
||||||
int batchIndex,
|
int batchIndex,
|
||||||
int batchTotal,
|
int batchTotal,
|
||||||
@@ -1256,7 +1425,7 @@ public class AppearancePatentTaskService {
|
|||||||
if (batchRows == null || batchRows.isEmpty()) {
|
if (batchRows == null || batchRows.isEmpty()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
String batchScopeKey = buildCozeBatchScopeKey(job.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), batchIndex);
|
String batchScopeKey = buildCozeBatchScopeKey(task.getId(), batchRows);
|
||||||
String batchScopeHash = DigestUtil.sha256Hex(batchScopeKey);
|
String batchScopeHash = DigestUtil.sha256Hex(batchScopeKey);
|
||||||
TaskScopeStateEntity existing = taskScopeStateMapper.selectOne(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
TaskScopeStateEntity existing = taskScopeStateMapper.selectOne(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
||||||
.eq(TaskScopeStateEntity::getTaskId, task.getId())
|
.eq(TaskScopeStateEntity::getTaskId, task.getId())
|
||||||
@@ -1268,34 +1437,28 @@ public class AppearancePatentTaskService {
|
|||||||
|| COZE_STATUS_RUNNING.equals(existing.getCozeStatus());
|
|| COZE_STATUS_RUNNING.equals(existing.getCozeStatus());
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
AppearancePatentCozeClient.CozeSubmitResponse submit = cozeClient.submitWorkflow(batchRows, prompt, apiKey);
|
AppearancePatentCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(batchRows, prompt, apiKey);
|
||||||
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
||||||
List<AppearancePatentResultRowDto> cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData());
|
List<AppearancePatentResultRowDto> cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData());
|
||||||
mergeCozeRowsIntoChunk(task, chunk.getScopeHash(), chunk.getChunkIndex(), cozeRows, allRowsByBaseId);
|
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (submit.executeId() == null || submit.executeId().isBlank()) {
|
if (submit.executeId() == null || submit.executeId().isBlank()) {
|
||||||
mergeCozeRowsIntoChunk(task,
|
mergeCozeRowsIntoSubmittedChunks(task,
|
||||||
chunk.getScopeHash(),
|
|
||||||
chunk.getChunkIndex(),
|
|
||||||
cozeClient.markRowsFailed(batchRows, "Coze async execute_id missing"),
|
cozeClient.markRowsFailed(batchRows, "Coze async execute_id missing"),
|
||||||
allRowsByBaseId);
|
allRowsByBaseId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
saveCozeBatchState(task, result, job, chunk, batchRows, batchScopeKey, batchScopeHash,
|
saveCozeBatchState(task, result, job, batchRows, batchScopeKey, batchScopeHash,
|
||||||
batchIndex, batchTotal, submit.executeId());
|
batchIndex, batchTotal, submit.executeId());
|
||||||
log.info("[appearance-patent] coze async submitted taskId={} jobId={} chunk={} batch={}/{} executeId={}",
|
log.info("[appearance-patent] coze async submitted taskId={} jobId={} rows={} batch={}/{} executeId={}",
|
||||||
task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal, submit.executeId());
|
task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, submit.executeId());
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
String message = firstNonBlank(ex.getMessage(), "Coze submit failed");
|
String message = firstNonBlank(ex.getMessage(), "Coze submit failed");
|
||||||
log.warn("[appearance-patent] coze async submit failed taskId={} jobId={} chunk={} batch={}/{} err={}",
|
log.warn("[appearance-patent] coze async submit failed taskId={} jobId={} rows={} batch={}/{} err={}",
|
||||||
task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal, message);
|
task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, message);
|
||||||
mergeCozeRowsIntoChunk(task,
|
mergeCozeRowsIntoSubmittedChunks(task, cozeClient.markRowsFailed(batchRows, message), allRowsByBaseId);
|
||||||
chunk.getScopeHash(),
|
|
||||||
chunk.getChunkIndex(),
|
|
||||||
cozeClient.markRowsFailed(batchRows, message),
|
|
||||||
allRowsByBaseId);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1303,7 +1466,6 @@ public class AppearancePatentTaskService {
|
|||||||
private void saveCozeBatchState(FileTaskEntity task,
|
private void saveCozeBatchState(FileTaskEntity task,
|
||||||
FileResultEntity result,
|
FileResultEntity result,
|
||||||
TaskFileJobEntity job,
|
TaskFileJobEntity job,
|
||||||
TaskChunkEntity chunk,
|
|
||||||
List<AppearancePatentResultRowDto> batchRows,
|
List<AppearancePatentResultRowDto> batchRows,
|
||||||
String batchScopeKey,
|
String batchScopeKey,
|
||||||
String batchScopeHash,
|
String batchScopeHash,
|
||||||
@@ -1314,11 +1476,12 @@ public class AppearancePatentTaskService {
|
|||||||
CozeBatchContext context = new CozeBatchContext(
|
CozeBatchContext context = new CozeBatchContext(
|
||||||
job.getId(),
|
job.getId(),
|
||||||
result.getId(),
|
result.getId(),
|
||||||
chunk.getScopeHash(),
|
null,
|
||||||
chunk.getChunkIndex(),
|
null,
|
||||||
batchIndex,
|
batchIndex,
|
||||||
batchTotal,
|
batchTotal,
|
||||||
currentInstanceId()
|
currentInstanceId(),
|
||||||
|
0
|
||||||
);
|
);
|
||||||
String batchPayload = writeJson(batchRows, "serialize coze batch payload failed");
|
String batchPayload = writeJson(batchRows, "serialize coze batch payload failed");
|
||||||
String storedBatchPayload = storeSharedCozeBatchPayload(task.getId(), batchScopeHash, batchPayload);
|
String storedBatchPayload = storeSharedCozeBatchPayload(task.getId(), batchScopeHash, batchPayload);
|
||||||
@@ -1393,10 +1556,10 @@ public class AppearancePatentTaskService {
|
|||||||
markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze batch context missing");
|
markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze batch context missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isOwnerCurrent(context.ownerInstanceId())) {
|
if (!isOwnerCurrent(context.ownerInstanceId())) {
|
||||||
log.info("[appearance-patent] coze poll skipped after context refresh because owner is another instance taskId={} stateId={} owner={} current={}",
|
log.info("[appearance-patent] coze poll skipped after context refresh because owner is another instance taskId={} stateId={} owner={} current={}",
|
||||||
state.getTaskId(), state.getId(), context.ownerInstanceId(), currentInstanceId());
|
state.getTaskId(), state.getId(), context.ownerInstanceId(), currentInstanceId());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
taskFileJobService.touchRunning(context.jobId());
|
taskFileJobService.touchRunning(context.jobId());
|
||||||
log.info("[appearance-patent] coze poll start taskId={} stateId={} executeId={} jobId={} chunk={} batch={}/{}",
|
log.info("[appearance-patent] coze poll start taskId={} stateId={} executeId={} jobId={} chunk={} batch={}/{}",
|
||||||
@@ -1421,17 +1584,27 @@ public class AppearancePatentTaskService {
|
|||||||
if (batchRows.isEmpty() && failureMessage.isBlank()) {
|
if (batchRows.isEmpty() && failureMessage.isBlank()) {
|
||||||
failureMessage = "Coze batch payload missing";
|
failureMessage = "Coze batch payload missing";
|
||||||
}
|
}
|
||||||
|
if (!failureMessage.isBlank() && splitRetryFailedCozeBatchState(state, context, batchRows, failureMessage)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!failureMessage.isBlank() && retryFailedCozeBatchState(state, context, batchRows, failureMessage)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
List<AppearancePatentResultRowDto> cozeRows = failureMessage.isBlank()
|
List<AppearancePatentResultRowDto> cozeRows = failureMessage.isBlank()
|
||||||
? cozeClient.mergeRowsFromDataText(batchRows, poll.resolvedPayloadText())
|
? cozeClient.mergeRowsFromDataText(batchRows, poll.resolvedPayloadText())
|
||||||
: cozeClient.markRowsFailed(batchRows, failureMessage);
|
: cozeClient.markRowsFailed(batchRows, failureMessage);
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId());
|
FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId());
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||||
mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
|
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId);
|
||||||
}
|
}
|
||||||
markCozeStateTerminal(state,
|
markCozeStateTerminal(state,
|
||||||
failureMessage.isBlank() ? COZE_STATUS_DONE : COZE_STATUS_FAILED,
|
failureMessage.isBlank() ? COZE_STATUS_DONE : COZE_STATUS_FAILED,
|
||||||
failureMessage.isBlank() ? null : failureMessage);
|
failureMessage.isBlank() ? null : failureMessage);
|
||||||
|
if (task != null) {
|
||||||
|
TaskFileJobEntity progressJob = taskFileJobService.findAssembleJob(state.getTaskId(), MODULE_TYPE, context.resultId());
|
||||||
|
saveCozePipelineProgress(task, progressJob);
|
||||||
|
}
|
||||||
log.info("[appearance-patent] coze poll completed taskId={} stateId={} executeId={} status={} batchRows={} mergedRows={} failure={}",
|
log.info("[appearance-patent] coze poll completed taskId={} stateId={} executeId={} status={} batchRows={} mergedRows={} failure={}",
|
||||||
state.getTaskId(), state.getId(), state.getCozeExecuteId(),
|
state.getTaskId(), state.getId(), state.getCozeExecuteId(),
|
||||||
failureMessage.isBlank() ? COZE_STATUS_DONE : COZE_STATUS_FAILED,
|
failureMessage.isBlank() ? COZE_STATUS_DONE : COZE_STATUS_FAILED,
|
||||||
@@ -1447,9 +1620,7 @@ public class AppearancePatentTaskService {
|
|||||||
FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId());
|
FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId());
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||||
mergeCozeRowsIntoChunk(task,
|
mergeCozeRowsIntoSubmittedChunks(task,
|
||||||
context.chunkScopeHash(),
|
|
||||||
context.chunkIndex(),
|
|
||||||
cozeClient.markRowsFailed(batchRows, message),
|
cozeClient.markRowsFailed(batchRows, message),
|
||||||
allRowsByBaseId);
|
allRowsByBaseId);
|
||||||
}
|
}
|
||||||
@@ -1467,6 +1638,240 @@ public class AppearancePatentTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean retryFailedCozeBatchState(TaskScopeStateEntity state,
|
||||||
|
CozeBatchContext context,
|
||||||
|
List<AppearancePatentResultRowDto> batchRows,
|
||||||
|
String failureMessage) {
|
||||||
|
if (state == null || context == null || batchRows == null || batchRows.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isRetryableCozeFailure(failureMessage) || cozeSubmitRetryCount(context) >= MAX_COZE_SUBMIT_RETRY_COUNT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId());
|
||||||
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
AppearancePatentCozeClient.CozeSubmitResponse submit =
|
||||||
|
submitCozeWorkflowThrottled(batchRows, readAiPrompt(task), readApiKey(task));
|
||||||
|
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||||
|
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
||||||
|
List<AppearancePatentResultRowDto> cozeRows =
|
||||||
|
cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData());
|
||||||
|
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId);
|
||||||
|
markCozeStateTerminal(state, COZE_STATUS_DONE, null);
|
||||||
|
maybeFinalizeCozeJobLocked(state.getTaskId(), context);
|
||||||
|
log.info("[appearance-patent] coze retry returned immediate result taskId={} stateId={} chunk={} batch={}/{}",
|
||||||
|
state.getTaskId(), state.getId(), context.chunkIndex(), context.batchIndex(), context.batchTotal());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (submit.executeId() == null || submit.executeId().isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
CozeBatchContext retryContext = withCozeSubmitRetryCount(context, cozeSubmitRetryCount(context) + 1);
|
||||||
|
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::getCozeExecuteId, submit.executeId())
|
||||||
|
.set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_SUBMITTED)
|
||||||
|
.set(TaskScopeStateEntity::getCozeSubmittedAt, now)
|
||||||
|
.set(TaskScopeStateEntity::getCozeLastPolledAt, null)
|
||||||
|
.set(TaskScopeStateEntity::getCozeCompletedAt, null)
|
||||||
|
.set(TaskScopeStateEntity::getCozeAttemptCount, 0)
|
||||||
|
.set(TaskScopeStateEntity::getCozeError, "retry after failure: " + firstNonBlank(failureMessage, "unknown"))
|
||||||
|
.set(TaskScopeStateEntity::getStateJson, writeJson(retryContext, "serialize coze batch retry context failed"))
|
||||||
|
.set(TaskScopeStateEntity::getCompleted, 0)
|
||||||
|
.set(TaskScopeStateEntity::getUpdatedAt, now));
|
||||||
|
if (updated > 0) {
|
||||||
|
taskFileJobService.touchRunning(context.jobId());
|
||||||
|
touchJavaSideTaskActivity(state.getTaskId());
|
||||||
|
log.info("[appearance-patent] coze retry submitted taskId={} stateId={} oldExecuteId={} newExecuteId={} chunk={} batch={}/{} retry={}/{} failure={}",
|
||||||
|
state.getTaskId(), state.getId(), state.getCozeExecuteId(), submit.executeId(),
|
||||||
|
context.chunkIndex(), context.batchIndex(), context.batchTotal(),
|
||||||
|
retryContext.submitRetryCount(), MAX_COZE_SUBMIT_RETRY_COUNT, failureMessage);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[appearance-patent] coze retry submit failed taskId={} stateId={} executeId={} err={}",
|
||||||
|
state.getTaskId(), state.getId(), state.getCozeExecuteId(), firstNonBlank(ex.getMessage(), "Coze retry failed"));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppearancePatentCozeClient.CozeSubmitResponse submitCozeWorkflowThrottled(
|
||||||
|
List<AppearancePatentResultRowDto> rows,
|
||||||
|
String prompt,
|
||||||
|
String apiKey) throws Exception {
|
||||||
|
DistributedJobLockService.LockHandle lockHandle = acquireCozeSubmitLock();
|
||||||
|
if (lockHandle == null) {
|
||||||
|
throw new IllegalStateException("Coze submit throttle lock timeout");
|
||||||
|
}
|
||||||
|
try (lockHandle) {
|
||||||
|
AppearancePatentCozeClient.CozeSubmitResponse response = cozeClient.submitWorkflow(rows, prompt, apiKey);
|
||||||
|
sleepQuietly(COZE_SUBMIT_MIN_INTERVAL_MILLIS);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DistributedJobLockService.LockHandle acquireCozeSubmitLock() {
|
||||||
|
long deadline = System.currentTimeMillis() + COZE_SUBMIT_LOCK_WAIT_MILLIS;
|
||||||
|
while (System.currentTimeMillis() <= deadline) {
|
||||||
|
DistributedJobLockService.LockHandle lockHandle =
|
||||||
|
distributedJobLockService.tryLock("appearance-patent:coze-submit", COZE_SUBMIT_LOCK_TTL);
|
||||||
|
if (lockHandle != null) {
|
||||||
|
return lockHandle;
|
||||||
|
}
|
||||||
|
sleepQuietly(COZE_SUBMIT_LOCK_RETRY_DELAY_MILLIS);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sleepQuietly(long millis) {
|
||||||
|
if (millis <= 0L) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(millis);
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean splitRetryFailedCozeBatchState(TaskScopeStateEntity state,
|
||||||
|
CozeBatchContext context,
|
||||||
|
List<AppearancePatentResultRowDto> batchRows,
|
||||||
|
String failureMessage) {
|
||||||
|
if (state == null || context == null || batchRows == null || batchRows.size() <= 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!shouldSplitCozeBatchForRetry(failureMessage) || cozeSubmitRetryCount(context) >= MAX_COZE_SUBMIT_RETRY_COUNT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId());
|
||||||
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int middle = Math.max(1, batchRows.size() / 2);
|
||||||
|
List<List<AppearancePatentResultRowDto>> partitions = List.<List<AppearancePatentResultRowDto>>of(
|
||||||
|
new ArrayList<>(batchRows.subList(0, middle)),
|
||||||
|
new ArrayList<>(batchRows.subList(middle, batchRows.size()))
|
||||||
|
).stream().filter(rows -> rows != null && !rows.isEmpty()).toList();
|
||||||
|
int retryCount = cozeSubmitRetryCount(context) + 1;
|
||||||
|
boolean submittedAny = false;
|
||||||
|
try {
|
||||||
|
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||||
|
int partIndex = 1;
|
||||||
|
for (List<AppearancePatentResultRowDto> partRows : partitions) {
|
||||||
|
AppearancePatentCozeClient.CozeSubmitResponse submit =
|
||||||
|
submitCozeWorkflowThrottled(partRows, readAiPrompt(task), readApiKey(task));
|
||||||
|
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
||||||
|
List<AppearancePatentResultRowDto> cozeRows =
|
||||||
|
cozeClient.mergeRowsFromDataText(partRows, submit.immediateData());
|
||||||
|
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId);
|
||||||
|
submittedAny = true;
|
||||||
|
} else if (submit.executeId() != null && !submit.executeId().isBlank()) {
|
||||||
|
saveSplitRetryCozeBatchState(state, context, partRows, partIndex, partitions.size(), retryCount, submit.executeId());
|
||||||
|
submittedAny = true;
|
||||||
|
}
|
||||||
|
partIndex++;
|
||||||
|
}
|
||||||
|
if (submittedAny) {
|
||||||
|
markCozeStateTerminal(state, COZE_STATUS_DONE, "split retry submitted after failure: " + firstNonBlank(failureMessage, "unknown"));
|
||||||
|
taskFileJobService.touchRunning(context.jobId());
|
||||||
|
touchJavaSideTaskActivity(state.getTaskId());
|
||||||
|
maybeFinalizeCozeJobLocked(state.getTaskId(), context);
|
||||||
|
log.info("[appearance-patent] coze split retry submitted taskId={} stateId={} chunk={} batch={}/{} parts={} retry={}/{} failure={}",
|
||||||
|
state.getTaskId(), state.getId(), context.chunkIndex(), context.batchIndex(), context.batchTotal(),
|
||||||
|
partitions.size(), retryCount, MAX_COZE_SUBMIT_RETRY_COUNT, failureMessage);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[appearance-patent] coze split retry submit failed taskId={} stateId={} executeId={} err={}",
|
||||||
|
state.getTaskId(), state.getId(), state.getCozeExecuteId(), firstNonBlank(ex.getMessage(), "Coze split retry failed"));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveSplitRetryCozeBatchState(TaskScopeStateEntity parent,
|
||||||
|
CozeBatchContext parentContext,
|
||||||
|
List<AppearancePatentResultRowDto> batchRows,
|
||||||
|
int partIndex,
|
||||||
|
int partTotal,
|
||||||
|
int retryCount,
|
||||||
|
String executeId) {
|
||||||
|
String scopeKey = parent.getScopeKey() + ":split:" + retryCount + ":" + partIndex;
|
||||||
|
String scopeHash = DigestUtil.sha256Hex(scopeKey);
|
||||||
|
CozeBatchContext context = new CozeBatchContext(
|
||||||
|
parentContext.jobId(),
|
||||||
|
parentContext.resultId(),
|
||||||
|
parentContext.chunkScopeHash(),
|
||||||
|
parentContext.chunkIndex(),
|
||||||
|
partIndex,
|
||||||
|
partTotal,
|
||||||
|
parentContext.ownerInstanceId(),
|
||||||
|
retryCount
|
||||||
|
);
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
String batchPayload = writeJson(batchRows, "serialize split coze batch payload failed");
|
||||||
|
String storedBatchPayload = storeSharedCozeBatchPayload(parent.getTaskId(), scopeHash, batchPayload);
|
||||||
|
TaskScopeStateEntity state = new TaskScopeStateEntity();
|
||||||
|
state.setTaskId(parent.getTaskId());
|
||||||
|
state.setModuleType(MODULE_TYPE);
|
||||||
|
state.setScopeKey(scopeKey);
|
||||||
|
state.setScopeHash(scopeHash);
|
||||||
|
state.setParsedPayloadJson(storedBatchPayload);
|
||||||
|
state.setStateJson(writeJson(context, "serialize split coze batch context failed"));
|
||||||
|
state.setCozeExecuteId(executeId);
|
||||||
|
state.setCozeStatus(COZE_STATUS_SUBMITTED);
|
||||||
|
state.setCozeSubmittedAt(now);
|
||||||
|
state.setCozeAttemptCount(0);
|
||||||
|
state.setChunkTotal(partTotal);
|
||||||
|
state.setReceivedChunkCount(partIndex);
|
||||||
|
state.setCompleted(0);
|
||||||
|
state.setCreatedAt(now);
|
||||||
|
state.setUpdatedAt(now);
|
||||||
|
try {
|
||||||
|
taskScopeStateMapper.insert(state);
|
||||||
|
} catch (DuplicateKeyException ex) {
|
||||||
|
transientPayloadStorageService.deletePayloadIfPresent(storedBatchPayload);
|
||||||
|
log.info("[appearance-patent] duplicate split coze batch state ignored taskId={} scope={}",
|
||||||
|
parent.getTaskId(), scopeKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldSplitCozeBatchForRetry(String failureMessage) {
|
||||||
|
String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT);
|
||||||
|
return normalized.contains("timeout")
|
||||||
|
|| normalized.contains("timed out")
|
||||||
|
|| normalized.contains("out of limit")
|
||||||
|
|| normalized.contains("execution limit")
|
||||||
|
|| normalized.contains("720712008")
|
||||||
|
|| normalized.contains("720701002")
|
||||||
|
|| normalized.contains("\u5de5\u4f5c\u6d41\u8282\u70b9\u6267\u884c\u8d85\u9650")
|
||||||
|
|| normalized.contains("\u8c03\u7528\u8d85\u65f6");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isRetryableCozeFailure(String failureMessage) {
|
||||||
|
String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT);
|
||||||
|
return normalized.contains("rate limit")
|
||||||
|
|| normalized.contains("too many")
|
||||||
|
|| normalized.contains("retry later")
|
||||||
|
|| normalized.contains("timeout")
|
||||||
|
|| normalized.contains("timed out")
|
||||||
|
|| normalized.contains("out of limit")
|
||||||
|
|| normalized.contains("execution limit")
|
||||||
|
|| normalized.contains("702093018")
|
||||||
|
|| normalized.contains("720712008")
|
||||||
|
|| normalized.contains("720701002")
|
||||||
|
|| normalized.contains("plugin limit")
|
||||||
|
|| normalized.contains("\u9650\u6d41")
|
||||||
|
|| normalized.contains("\u7a0d\u540e\u91cd\u8bd5")
|
||||||
|
|| normalized.contains("\u5de5\u4f5c\u6d41\u8282\u70b9\u6267\u884c\u8d85\u9650")
|
||||||
|
|| normalized.contains("\u8c03\u7528\u8d85\u65f6");
|
||||||
|
}
|
||||||
|
|
||||||
private void updateCozeStateRunning(TaskScopeStateEntity state, String error) {
|
private void updateCozeStateRunning(TaskScopeStateEntity state, String error) {
|
||||||
int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
|
int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
|
||||||
.eq(TaskScopeStateEntity::getId, state.getId())
|
.eq(TaskScopeStateEntity::getId, state.getId())
|
||||||
@@ -1546,7 +1951,13 @@ public class AppearancePatentTaskService {
|
|||||||
if (job == null || "SUCCESS".equals(job.getStatus())) {
|
if (job == null || "SUCCESS".equals(job.getStatus())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
boolean requeued = taskFileJobService.requeue(job.getId(), "Coze results ready, assembling xlsx");
|
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||||
|
if (task != null && STATUS_RUNNING.equals(task.getStatus()) && !isResultSubmissionComplete(taskId)) {
|
||||||
|
taskFileJobService.touchRunning(job.getId());
|
||||||
|
touchJavaSideTaskActivity(taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean requeued = taskFileJobService.requeue(job.getId(), "Coze 结果已回流,正在组装 xlsx");
|
||||||
if (requeued) {
|
if (requeued) {
|
||||||
log.info("[appearance-patent] coze async results ready, result file job requeued taskId={} jobId={} resultId={}",
|
log.info("[appearance-patent] coze async results ready, result file job requeued taskId={} jobId={} resultId={}",
|
||||||
taskId, job.getId(), context.resultId());
|
taskId, job.getId(), context.resultId());
|
||||||
@@ -1570,17 +1981,22 @@ public class AppearancePatentTaskService {
|
|||||||
throw new BusinessException("Coze 结果仍在处理中,暂不能生成结果文件");
|
throw new BusinessException("Coze 结果仍在处理中,暂不能生成结果文件");
|
||||||
}
|
}
|
||||||
int assembleProgress = Math.max(1, Math.min(totalProgressUnits - 2, cozeWorkUnits));
|
int assembleProgress = Math.max(1, Math.min(totalProgressUnits - 2, cozeWorkUnits));
|
||||||
saveFileBuildProgress(task, job, totalProgressUnits, assembleProgress, "Assembling xlsx");
|
saveFileBuildProgress(task, job, totalProgressUnits, assembleProgress, "正在组装 xlsx");
|
||||||
assembleResultWorkbook(task, result);
|
assembleResultWorkbook(task, result);
|
||||||
saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits - 1, "Uploading result file");
|
saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits - 1, "正在上传结果文件");
|
||||||
fileResultMapper.updateById(result);
|
fileResultMapper.updateById(result);
|
||||||
task.setStatus(STATUS_SUCCESS);
|
boolean taskAlreadyFailed = STATUS_FAILED.equals(task.getStatus())
|
||||||
task.setErrorMessage(null);
|
|| (task.getErrorMessage() != null && !task.getErrorMessage().isBlank());
|
||||||
|
String existingError = task.getErrorMessage();
|
||||||
|
task.setStatus(taskAlreadyFailed ? STATUS_FAILED : STATUS_SUCCESS);
|
||||||
|
task.setSuccessFileCount(taskAlreadyFailed ? 0 : 1);
|
||||||
|
task.setFailedFileCount(taskAlreadyFailed ? 1 : 0);
|
||||||
|
task.setErrorMessage(taskAlreadyFailed ? existingError : null);
|
||||||
task.setUpdatedAt(LocalDateTime.now());
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
task.setFinishedAt(LocalDateTime.now());
|
task.setFinishedAt(LocalDateTime.now());
|
||||||
fileTaskMapper.updateById(task);
|
fileTaskMapper.updateById(task);
|
||||||
taskCacheService.deleteTaskCache(task.getId());
|
taskCacheService.deleteTaskCache(task.getId());
|
||||||
saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits, "Result file generated");
|
saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits, "结果文件已生成");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void mergeCozeRowsIntoChunk(FileTaskEntity task,
|
private void mergeCozeRowsIntoChunk(FileTaskEntity task,
|
||||||
@@ -1588,16 +2004,74 @@ public class AppearancePatentTaskService {
|
|||||||
Integer chunkIndex,
|
Integer chunkIndex,
|
||||||
List<AppearancePatentResultRowDto> cozeRows,
|
List<AppearancePatentResultRowDto> cozeRows,
|
||||||
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId) {
|
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId) {
|
||||||
|
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId, chunkScopeHash, chunkIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void mergeCozeRowsIntoSubmittedChunks(FileTaskEntity task,
|
||||||
|
List<AppearancePatentResultRowDto> cozeRows,
|
||||||
|
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId) {
|
||||||
|
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void mergeCozeRowsIntoSubmittedChunks(FileTaskEntity task,
|
||||||
|
List<AppearancePatentResultRowDto> cozeRows,
|
||||||
|
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId,
|
||||||
|
String fallbackScopeHash,
|
||||||
|
Integer fallbackChunkIndex) {
|
||||||
if (task == null || cozeRows == null || cozeRows.isEmpty()) {
|
if (task == null || cozeRows == null || cozeRows.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Map<String, AppearancePatentResultRowDto> mergedRows = new LinkedHashMap<>();
|
List<TaskChunkEntity> chunks = loadSubmittedChunks(task.getId());
|
||||||
|
if (chunks.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Map<String, AppearancePatentResultRowDto>> rowsByChunk = new LinkedHashMap<>();
|
||||||
|
Map<String, TaskChunkEntity> chunkByKey = new LinkedHashMap<>();
|
||||||
|
for (TaskChunkEntity chunk : chunks) {
|
||||||
|
String chunkKey = chunkStorageKey(chunk.getScopeHash(), chunk.getChunkIndex());
|
||||||
|
rowsByChunk.put(chunkKey, readChunkRows(chunk));
|
||||||
|
chunkByKey.put(chunkKey, chunk);
|
||||||
|
}
|
||||||
|
Map<String, Map<String, AppearancePatentResultRowDto>> mergeRowsByChunk = new LinkedHashMap<>();
|
||||||
for (AppearancePatentResultRowDto resultRow : cozeRows) {
|
for (AppearancePatentResultRowDto resultRow : cozeRows) {
|
||||||
for (AppearancePatentResultRowDto expandedRow : expandRows(List.of(resultRow), allRowsByBaseId)) {
|
for (AppearancePatentResultRowDto expandedRow : expandRows(List.of(resultRow), allRowsByBaseId)) {
|
||||||
mergedRows.put(rowKey(expandedRow), expandedRow);
|
String rowKey = rowKey(expandedRow);
|
||||||
|
if (rowKey.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boolean matched = false;
|
||||||
|
for (Map.Entry<String, Map<String, AppearancePatentResultRowDto>> entry : rowsByChunk.entrySet()) {
|
||||||
|
if (entry.getValue().containsKey(rowKey)) {
|
||||||
|
mergeRowsByChunk.computeIfAbsent(entry.getKey(), ignored -> new LinkedHashMap<>())
|
||||||
|
.put(rowKey, expandedRow);
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched && fallbackScopeHash != null && fallbackChunkIndex != null) {
|
||||||
|
String fallbackKey = chunkStorageKey(fallbackScopeHash, fallbackChunkIndex);
|
||||||
|
if (chunkByKey.containsKey(fallbackKey)) {
|
||||||
|
mergeRowsByChunk.computeIfAbsent(fallbackKey, ignored -> new LinkedHashMap<>())
|
||||||
|
.put(rowKey, expandedRow);
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched) {
|
||||||
|
log.warn("[appearance-patent] coze row has no submitted chunk taskId={} rowKey={}",
|
||||||
|
task.getId(), rowKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mergeChunkPayload(task.getId(), chunkScopeHash, chunkIndex, new ArrayList<>(mergedRows.values()));
|
for (Map.Entry<String, Map<String, AppearancePatentResultRowDto>> entry : mergeRowsByChunk.entrySet()) {
|
||||||
|
TaskChunkEntity chunk = chunkByKey.get(entry.getKey());
|
||||||
|
if (chunk == null || entry.getValue().isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mergeChunkPayload(task.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), new ArrayList<>(entry.getValue().values()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String chunkStorageKey(String scopeHash, Integer chunkIndex) {
|
||||||
|
return firstNonBlank(scopeHash, "") + ":" + (chunkIndex == null ? 0 : chunkIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<AppearancePatentResultRowDto> readCozeBatchRows(TaskScopeStateEntity state) {
|
private List<AppearancePatentResultRowDto> readCozeBatchRows(TaskScopeStateEntity state) {
|
||||||
@@ -1636,6 +2110,23 @@ public class AppearancePatentTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int cozeSubmitRetryCount(CozeBatchContext context) {
|
||||||
|
return context == null || context.submitRetryCount() == null ? 0 : context.submitRetryCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CozeBatchContext withCozeSubmitRetryCount(CozeBatchContext context, int submitRetryCount) {
|
||||||
|
return new CozeBatchContext(
|
||||||
|
context.jobId(),
|
||||||
|
context.resultId(),
|
||||||
|
context.chunkScopeHash(),
|
||||||
|
context.chunkIndex(),
|
||||||
|
context.batchIndex(),
|
||||||
|
context.batchTotal(),
|
||||||
|
context.ownerInstanceId(),
|
||||||
|
submitRetryCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isCozeStateTimedOut(TaskScopeStateEntity state) {
|
private boolean isCozeStateTimedOut(TaskScopeStateEntity state) {
|
||||||
if (state == null || state.getCozeSubmittedAt() == null) {
|
if (state == null || state.getCozeSubmittedAt() == null) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1670,6 +2161,30 @@ public class AppearancePatentTaskService {
|
|||||||
return count == null ? 0 : count.intValue();
|
return count == null ? 0 : count.intValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int countAllCozeStates(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)
|
||||||
|
.isNotNull(TaskScopeStateEntity::getCozeStatus));
|
||||||
|
return count == null ? 0 : count.intValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isResultSubmissionComplete(Long taskId) {
|
||||||
|
if (taskId == null || taskId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
||||||
|
.eq(TaskScopeStateEntity::getTaskId, taskId)
|
||||||
|
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
|
||||||
|
.isNull(TaskScopeStateEntity::getCozeStatus)
|
||||||
|
.isNotNull(TaskScopeStateEntity::getLastChunkAt)
|
||||||
|
.eq(TaskScopeStateEntity::getCompleted, 1));
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isJavaSideProcessing(Long taskId) {
|
private boolean isJavaSideProcessing(Long taskId) {
|
||||||
return countPendingCozeStates(taskId) > 0
|
return countPendingCozeStates(taskId) > 0
|
||||||
|| taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE) > 0;
|
|| taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE) > 0;
|
||||||
@@ -1686,11 +2201,20 @@ public class AppearancePatentTaskService {
|
|||||||
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now()));
|
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildCozeBatchScopeKey(Long jobId, String chunkScopeHash, Integer chunkIndex, int batchIndex) {
|
private String buildCozeBatchScopeKey(Long taskId, List<AppearancePatentResultRowDto> batchRows) {
|
||||||
return "coze:job:" + jobId
|
StringBuilder rowKeys = new StringBuilder();
|
||||||
+ ":chunk:" + (chunkIndex == null ? 0 : chunkIndex)
|
if (batchRows != null) {
|
||||||
+ ":" + firstNonBlank(chunkScopeHash, "unknown")
|
for (AppearancePatentResultRowDto row : batchRows) {
|
||||||
+ ":batch:" + batchIndex;
|
String key = rowKey(row);
|
||||||
|
if (!key.isBlank()) {
|
||||||
|
if (!rowKeys.isEmpty()) {
|
||||||
|
rowKeys.append('|');
|
||||||
|
}
|
||||||
|
rowKeys.append(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "coze:task:" + taskId + ":rows:" + DigestUtil.sha256Hex(rowKeys.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildTaskOwnerScopeKey(Long taskId) {
|
private String buildTaskOwnerScopeKey(Long taskId) {
|
||||||
@@ -1811,6 +2335,45 @@ public class AppearancePatentTaskService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void saveCozePipelineProgress(FileTaskEntity task, TaskFileJobEntity job) {
|
||||||
|
if (task == null || task.getId() == null || job == null || job.getId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int totalCoze = countAllCozeStates(task.getId());
|
||||||
|
int completedCoze = countCompletedCozeStates(task.getId());
|
||||||
|
int pendingCoze = countPendingCozeStates(task.getId());
|
||||||
|
boolean uploadComplete = isResultSubmissionComplete(task.getId());
|
||||||
|
if (totalCoze <= 0) {
|
||||||
|
int receivedChunks = countTaskChunks(task.getId());
|
||||||
|
int uploadedProgress = Math.max(1, receivedChunks);
|
||||||
|
saveFileBuildProgress(task, job, Math.max(uploadedProgress + 1, 2), uploadedProgress,
|
||||||
|
"正在接收 Python 数据,累计 50 条后提交 Coze");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int total = Math.max(3, totalCoze + 3);
|
||||||
|
int completed = Math.max(0, Math.min(completedCoze, total - 1));
|
||||||
|
String message;
|
||||||
|
if (pendingCoze > 0) {
|
||||||
|
message = "已提交 Coze " + totalCoze + " 批,已完成 " + completedCoze + " 批,等待结果回流";
|
||||||
|
} else if (!uploadComplete) {
|
||||||
|
message = "Coze 已完成 " + completedCoze + " 批,等待 Python 继续回传数据";
|
||||||
|
} else {
|
||||||
|
message = "Coze 结果已回流,正在组装 xlsx";
|
||||||
|
completed = Math.max(completed, Math.min(total - 2, completedCoze));
|
||||||
|
}
|
||||||
|
saveFileBuildProgress(task, job, total, completed, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int countTaskChunks(Long taskId) {
|
||||||
|
if (taskId == null || taskId <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
Long count = taskChunkMapper.selectCount(new LambdaQueryWrapper<TaskChunkEntity>()
|
||||||
|
.eq(TaskChunkEntity::getTaskId, taskId)
|
||||||
|
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE));
|
||||||
|
return count == null ? 0 : count.intValue();
|
||||||
|
}
|
||||||
|
|
||||||
public void cleanupResultFileJob(TaskFileJobEntity job) {
|
public void cleanupResultFileJob(TaskFileJobEntity job) {
|
||||||
if (job == null || job.getTaskId() == null) {
|
if (job == null || job.getTaskId() == null) {
|
||||||
return;
|
return;
|
||||||
@@ -1891,6 +2454,10 @@ public class AppearancePatentTaskService {
|
|||||||
if (receivedRows == null || receivedRows.isEmpty()) {
|
if (receivedRows == null || receivedRows.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||||
|
if (task != null && STATUS_FAILED.equals(task.getStatus())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
int expectedRows = 0;
|
int expectedRows = 0;
|
||||||
int missingRows = 0;
|
int missingRows = 0;
|
||||||
List<String> sampleAsins = new ArrayList<>();
|
List<String> sampleAsins = new ArrayList<>();
|
||||||
@@ -1907,7 +2474,8 @@ public class AppearancePatentTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (expectedRows > 0 && missingRows > 0) {
|
boolean enforceCompleteCozeCoverage = false;
|
||||||
|
if (enforceCompleteCozeCoverage && expectedRows > 0 && missingRows > 0) {
|
||||||
log.warn("[appearance-patent] incomplete coze coverage taskId={} expectedRows={} missingRows={} samples={}",
|
log.warn("[appearance-patent] incomplete coze coverage taskId={} expectedRows={} missingRows={} samples={}",
|
||||||
taskId, expectedRows, missingRows, sampleAsins);
|
taskId, expectedRows, missingRows, sampleAsins);
|
||||||
throw new BusinessException("Coze 结果不完整:缺少 " + missingRows + "/" + expectedRows + " 条检测结果,请等待重试或重新运行任务");
|
throw new BusinessException("Coze 结果不完整:缺少 " + missingRows + "/" + expectedRows + " 条检测结果,请等待重试或重新运行任务");
|
||||||
@@ -1933,6 +2501,16 @@ public class AppearancePatentTaskService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean hasPersistedResultRows(Long taskId) {
|
||||||
|
if (taskId == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Long count = taskChunkMapper.selectCount(new LambdaQueryWrapper<TaskChunkEntity>()
|
||||||
|
.eq(TaskChunkEntity::getTaskId, taskId)
|
||||||
|
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE));
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, AppearancePatentResultRowDto> loadPersistedResultRowsWithRetry(Long taskId, int expectedParsedRows) {
|
private Map<String, AppearancePatentResultRowDto> loadPersistedResultRowsWithRetry(Long taskId, int expectedParsedRows) {
|
||||||
Map<String, AppearancePatentResultRowDto> result = loadPersistedResultRows(taskId);
|
Map<String, AppearancePatentResultRowDto> result = loadPersistedResultRows(taskId);
|
||||||
if (expectedParsedRows <= 0 || !result.isEmpty()) {
|
if (expectedParsedRows <= 0 || !result.isEmpty()) {
|
||||||
@@ -2770,6 +3348,7 @@ public class AppearancePatentTaskService {
|
|||||||
private record SubmitContext(FileTaskEntity task,
|
private record SubmitContext(FileTaskEntity task,
|
||||||
String scopeKey,
|
String scopeKey,
|
||||||
String scopeHash,
|
String scopeHash,
|
||||||
|
Integer chunkIndex,
|
||||||
boolean forceFlush,
|
boolean forceFlush,
|
||||||
String error) {
|
String error) {
|
||||||
}
|
}
|
||||||
@@ -2780,7 +3359,13 @@ public class AppearancePatentTaskService {
|
|||||||
Integer chunkIndex,
|
Integer chunkIndex,
|
||||||
Integer batchIndex,
|
Integer batchIndex,
|
||||||
Integer batchTotal,
|
Integer batchTotal,
|
||||||
String ownerInstanceId) {
|
String ownerInstanceId,
|
||||||
|
Integer submitRetryCount) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CozeCandidate(String chunkScopeHash,
|
||||||
|
Integer chunkIndex,
|
||||||
|
AppearancePatentResultRowDto row) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private record ParsedWorkbook(int totalRows, int droppedRows, List<String> headers, List<AppearancePatentParsedRowVo> allRows) {
|
private record ParsedWorkbook(int totalRows, int droppedRows, List<String> headers, List<AppearancePatentParsedRowVo> allRows) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public class PermissionMenuSchemaInitializer {
|
|||||||
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50),
|
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50),
|
||||||
new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin", 60),
|
new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin", 60),
|
||||||
new DefaultAdminMenu("查询ASIN", "admin_query_asin", "query-asin", 65),
|
new DefaultAdminMenu("查询ASIN", "admin_query_asin", "query-asin", 65),
|
||||||
|
new DefaultAdminMenu("商品类目", "admin_product_categories", "product-categories", 66),
|
||||||
new DefaultAdminMenu("查看生成记录", "admin_history", "history", 70),
|
new DefaultAdminMenu("查看生成记录", "admin_history", "history", 70),
|
||||||
new DefaultAdminMenu("版本管理", "admin_version", "version", 80)
|
new DefaultAdminMenu("版本管理", "admin_version", "version", 80)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.nanri.aiimage.modules.productcategory.controller;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.common.api.ApiResponse;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.model.dto.ProductCategorySaveRequest;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.service.ProductCategoryService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RequestMapping("/api/admin")
|
||||||
|
@Tag(name = "商品类目管理", description = "维护商品类目树")
|
||||||
|
public class ProductCategoryController {
|
||||||
|
|
||||||
|
private final ProductCategoryService productCategoryService;
|
||||||
|
|
||||||
|
@GetMapping("/product-categories")
|
||||||
|
@Operation(summary = "查询商品类目树")
|
||||||
|
public ApiResponse<ProductCategoryListVo> list() {
|
||||||
|
return ApiResponse.success(productCategoryService.list());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/product-category")
|
||||||
|
@Operation(summary = "新增商品类目")
|
||||||
|
public ApiResponse<ProductCategoryItemVo> create(@Valid @RequestBody ProductCategorySaveRequest request) {
|
||||||
|
return ApiResponse.success("创建成功", productCategoryService.create(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/product-category/{id}")
|
||||||
|
@Operation(summary = "更新商品类目")
|
||||||
|
public ApiResponse<ProductCategoryItemVo> update(@PathVariable Long id,
|
||||||
|
@Valid @RequestBody ProductCategorySaveRequest request) {
|
||||||
|
return ApiResponse.success("保存成功", productCategoryService.update(id, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/product-category/{id}")
|
||||||
|
@Operation(summary = "删除商品类目")
|
||||||
|
public ApiResponse<Void> delete(@PathVariable Long id) {
|
||||||
|
productCategoryService.delete(id);
|
||||||
|
return ApiResponse.success("删除成功", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.nanri.aiimage.modules.productcategory.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ProductCategoryMapper extends BaseMapper<ProductCategoryEntity> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.nanri.aiimage.modules.productcategory.model.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "商品类目保存请求")
|
||||||
|
public class ProductCategorySaveRequest {
|
||||||
|
|
||||||
|
@JsonAlias("parent_id")
|
||||||
|
@Schema(description = "父级类目 ID,空表示顶级")
|
||||||
|
private Long parentId;
|
||||||
|
|
||||||
|
@NotBlank(message = "类目名称不能为空")
|
||||||
|
@Schema(description = "类目名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@JsonAlias("sort_order")
|
||||||
|
@Schema(description = "排序值")
|
||||||
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
@Schema(description = "备注")
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.nanri.aiimage.modules.productcategory.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("biz_product_category")
|
||||||
|
public class ProductCategoryEntity {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
private Long parentId;
|
||||||
|
private String name;
|
||||||
|
private String categoryKey;
|
||||||
|
private Integer sortOrder;
|
||||||
|
private String description;
|
||||||
|
private Boolean isBuiltin;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.nanri.aiimage.modules.productcategory.model.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ProductCategoryItemVo {
|
||||||
|
private Long id;
|
||||||
|
private Long parentId;
|
||||||
|
private String name;
|
||||||
|
private String categoryKey;
|
||||||
|
private Integer sortOrder;
|
||||||
|
private String description;
|
||||||
|
private Boolean isBuiltin;
|
||||||
|
private Integer childCount;
|
||||||
|
private Integer level;
|
||||||
|
private String path;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private List<ProductCategoryItemVo> children = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.nanri.aiimage.modules.productcategory.model.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ProductCategoryListVo {
|
||||||
|
private List<ProductCategoryItemVo> tree;
|
||||||
|
private List<ProductCategoryItemVo> items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package com.nanri.aiimage.modules.productcategory.service;
|
||||||
|
|
||||||
|
import cn.hutool.crypto.digest.DigestUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.mapper.ProductCategoryMapper;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.model.dto.ProductCategorySaveRequest;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo;
|
||||||
|
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ProductCategoryService {
|
||||||
|
|
||||||
|
private final ProductCategoryMapper productCategoryMapper;
|
||||||
|
|
||||||
|
public ProductCategoryListVo list() {
|
||||||
|
List<ProductCategoryEntity> rows = productCategoryMapper.selectList(new LambdaQueryWrapper<ProductCategoryEntity>()
|
||||||
|
.orderByAsc(ProductCategoryEntity::getParentId)
|
||||||
|
.orderByAsc(ProductCategoryEntity::getSortOrder)
|
||||||
|
.orderByAsc(ProductCategoryEntity::getId));
|
||||||
|
return buildListVo(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ProductCategoryItemVo create(ProductCategorySaveRequest request) {
|
||||||
|
String name = normalizeRequired(request.getName(), "类目名称不能为空");
|
||||||
|
Long parentId = normalizeParentId(request.getParentId());
|
||||||
|
ensureParentExists(parentId, null);
|
||||||
|
ensureSiblingNameUnique(parentId, name, null);
|
||||||
|
|
||||||
|
ProductCategoryEntity entity = new ProductCategoryEntity();
|
||||||
|
entity.setParentId(parentId);
|
||||||
|
entity.setName(name);
|
||||||
|
entity.setCategoryKey(generateCategoryKey(parentId, name));
|
||||||
|
entity.setSortOrder(resolveSortOrder(request.getSortOrder()));
|
||||||
|
entity.setDescription(normalizeDescription(request.getDescription()));
|
||||||
|
entity.setIsBuiltin(false);
|
||||||
|
productCategoryMapper.insert(entity);
|
||||||
|
return findItem(entity.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ProductCategoryItemVo update(Long id, ProductCategorySaveRequest request) {
|
||||||
|
ProductCategoryEntity entity = getById(id);
|
||||||
|
String name = normalizeRequired(request.getName(), "类目名称不能为空");
|
||||||
|
Long parentId = normalizeParentId(request.getParentId());
|
||||||
|
ensureParentExists(parentId, id);
|
||||||
|
ensureSiblingNameUnique(parentId, name, id);
|
||||||
|
|
||||||
|
entity.setParentId(parentId);
|
||||||
|
entity.setName(name);
|
||||||
|
entity.setSortOrder(resolveSortOrder(request.getSortOrder()));
|
||||||
|
entity.setDescription(normalizeDescription(request.getDescription()));
|
||||||
|
productCategoryMapper.updateById(entity);
|
||||||
|
return findItem(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
ProductCategoryEntity entity = getById(id);
|
||||||
|
Long childCount = productCategoryMapper.selectCount(new LambdaQueryWrapper<ProductCategoryEntity>()
|
||||||
|
.eq(ProductCategoryEntity::getParentId, entity.getId()));
|
||||||
|
if (childCount != null && childCount > 0) {
|
||||||
|
throw new BusinessException("请先删除该类目下的子类目");
|
||||||
|
}
|
||||||
|
productCategoryMapper.deleteById(entity.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductCategoryItemVo findItem(Long id) {
|
||||||
|
ProductCategoryListVo listVo = list();
|
||||||
|
return listVo.getItems().stream()
|
||||||
|
.filter(item -> id.equals(item.getId()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new BusinessException("类目不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductCategoryListVo buildListVo(List<ProductCategoryEntity> rows) {
|
||||||
|
Map<Long, Integer> childCountById = new HashMap<>();
|
||||||
|
Map<Long, List<ProductCategoryEntity>> byParent = new HashMap<>();
|
||||||
|
for (ProductCategoryEntity row : rows) {
|
||||||
|
byParent.computeIfAbsent(row.getParentId(), ignored -> new ArrayList<>()).add(row);
|
||||||
|
if (row.getParentId() != null) {
|
||||||
|
childCountById.merge(row.getParentId(), 1, Integer::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byParent.values().forEach(list -> list.sort(Comparator
|
||||||
|
.comparing(ProductCategoryEntity::getSortOrder, Comparator.nullsLast(Integer::compareTo))
|
||||||
|
.thenComparing(ProductCategoryEntity::getId, Comparator.nullsLast(Long::compareTo))));
|
||||||
|
|
||||||
|
List<ProductCategoryItemVo> flat = new ArrayList<>();
|
||||||
|
List<ProductCategoryItemVo> tree = buildChildren(null, 0, "", byParent, childCountById, flat);
|
||||||
|
ProductCategoryListVo vo = new ProductCategoryListVo();
|
||||||
|
vo.setTree(tree);
|
||||||
|
vo.setItems(flat);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ProductCategoryItemVo> buildChildren(
|
||||||
|
Long parentId,
|
||||||
|
int level,
|
||||||
|
String parentPath,
|
||||||
|
Map<Long, List<ProductCategoryEntity>> byParent,
|
||||||
|
Map<Long, Integer> childCountById,
|
||||||
|
List<ProductCategoryItemVo> flat) {
|
||||||
|
List<ProductCategoryItemVo> result = new ArrayList<>();
|
||||||
|
for (ProductCategoryEntity entity : byParent.getOrDefault(parentId, List.of())) {
|
||||||
|
ProductCategoryItemVo item = toItemVo(entity, childCountById.getOrDefault(entity.getId(), 0));
|
||||||
|
item.setLevel(level);
|
||||||
|
item.setPath(parentPath.isBlank() ? item.getName() : parentPath + " / " + item.getName());
|
||||||
|
flat.add(copyWithoutChildren(item));
|
||||||
|
item.setChildren(buildChildren(entity.getId(), level + 1, item.getPath(), byParent, childCountById, flat));
|
||||||
|
result.add(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductCategoryItemVo toItemVo(ProductCategoryEntity entity, int childCount) {
|
||||||
|
ProductCategoryItemVo vo = new ProductCategoryItemVo();
|
||||||
|
vo.setId(entity.getId());
|
||||||
|
vo.setParentId(entity.getParentId());
|
||||||
|
vo.setName(entity.getName());
|
||||||
|
vo.setCategoryKey(entity.getCategoryKey());
|
||||||
|
vo.setSortOrder(entity.getSortOrder());
|
||||||
|
vo.setDescription(entity.getDescription());
|
||||||
|
vo.setIsBuiltin(Boolean.TRUE.equals(entity.getIsBuiltin()));
|
||||||
|
vo.setChildCount(childCount);
|
||||||
|
vo.setCreatedAt(entity.getCreatedAt());
|
||||||
|
vo.setUpdatedAt(entity.getUpdatedAt());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductCategoryItemVo copyWithoutChildren(ProductCategoryItemVo source) {
|
||||||
|
ProductCategoryItemVo vo = new ProductCategoryItemVo();
|
||||||
|
vo.setId(source.getId());
|
||||||
|
vo.setParentId(source.getParentId());
|
||||||
|
vo.setName(source.getName());
|
||||||
|
vo.setCategoryKey(source.getCategoryKey());
|
||||||
|
vo.setSortOrder(source.getSortOrder());
|
||||||
|
vo.setDescription(source.getDescription());
|
||||||
|
vo.setIsBuiltin(source.getIsBuiltin());
|
||||||
|
vo.setChildCount(source.getChildCount());
|
||||||
|
vo.setLevel(source.getLevel());
|
||||||
|
vo.setPath(source.getPath());
|
||||||
|
vo.setCreatedAt(source.getCreatedAt());
|
||||||
|
vo.setUpdatedAt(source.getUpdatedAt());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductCategoryEntity getById(Long id) {
|
||||||
|
ProductCategoryEntity entity = productCategoryMapper.selectById(id);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new BusinessException("类目不存在");
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureParentExists(Long parentId, Long currentId) {
|
||||||
|
if (parentId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentId != null && currentId.equals(parentId)) {
|
||||||
|
throw new BusinessException("父级类目不能选择自己");
|
||||||
|
}
|
||||||
|
ProductCategoryEntity parent = productCategoryMapper.selectById(parentId);
|
||||||
|
if (parent == null) {
|
||||||
|
throw new BusinessException("父级类目不存在");
|
||||||
|
}
|
||||||
|
Set<Long> visited = new HashSet<>();
|
||||||
|
Long cursor = parent.getParentId();
|
||||||
|
while (cursor != null) {
|
||||||
|
if (!visited.add(cursor)) {
|
||||||
|
throw new BusinessException("类目层级存在循环");
|
||||||
|
}
|
||||||
|
if (currentId != null && currentId.equals(cursor)) {
|
||||||
|
throw new BusinessException("父级类目不能选择自己的子级");
|
||||||
|
}
|
||||||
|
ProductCategoryEntity row = productCategoryMapper.selectById(cursor);
|
||||||
|
cursor = row == null ? null : row.getParentId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureSiblingNameUnique(Long parentId, String name, Long excludeId) {
|
||||||
|
LambdaQueryWrapper<ProductCategoryEntity> query = new LambdaQueryWrapper<ProductCategoryEntity>()
|
||||||
|
.eq(ProductCategoryEntity::getName, name);
|
||||||
|
if (parentId == null) {
|
||||||
|
query.isNull(ProductCategoryEntity::getParentId);
|
||||||
|
} else {
|
||||||
|
query.eq(ProductCategoryEntity::getParentId, parentId);
|
||||||
|
}
|
||||||
|
if (excludeId != null) {
|
||||||
|
query.ne(ProductCategoryEntity::getId, excludeId);
|
||||||
|
}
|
||||||
|
Long count = productCategoryMapper.selectCount(query);
|
||||||
|
if (count != null && count > 0) {
|
||||||
|
throw new BusinessException("同级类目名称已存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRequired(String value, String message) {
|
||||||
|
String normalized = value == null ? "" : value.trim();
|
||||||
|
if (normalized.isBlank()) {
|
||||||
|
throw new BusinessException(message);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long normalizeParentId(Long value) {
|
||||||
|
return value == null || value <= 0 ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer resolveSortOrder(Integer value) {
|
||||||
|
return value == null ? 100 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeDescription(String value) {
|
||||||
|
String normalized = value == null ? "" : value.trim();
|
||||||
|
return normalized.length() > 512 ? normalized.substring(0, 512) : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateCategoryKey(Long parentId, String name) {
|
||||||
|
String source = (parentId == null ? "root" : String.valueOf(parentId)) + ":" + name;
|
||||||
|
return "custom_" + DigestUtil.sha1Hex(source).substring(0, 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
package com.nanri.aiimage.modules.productrisk.model.vo;
|
package com.nanri.aiimage.modules.productrisk.model.vo;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinDetailDto;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Schema(description = "单店结果项:列表接口与任务详情中共用,对应 biz_file_result 一行 + 任务/请求衍生字段")
|
@Schema(description = "单店结果项:列表接口与任务详情中共用,对应 biz_file_result 一行 + 任务/请求衍生字段")
|
||||||
public class ProductRiskResultItemVo {
|
public class ProductRiskResultItemVo {
|
||||||
@@ -65,4 +71,12 @@ public class ProductRiskResultItemVo {
|
|||||||
@JsonProperty("scheduledAt")
|
@JsonProperty("scheduledAt")
|
||||||
@Schema(description = "定时执行时间,未设置时为空")
|
@Schema(description = "定时执行时间,未设置时为空")
|
||||||
private String scheduledAt;
|
private String scheduledAt;
|
||||||
|
|
||||||
|
@JsonAlias("skip_asins_by_country")
|
||||||
|
@JsonProperty("skipAsinsByCountry")
|
||||||
|
private Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
@JsonAlias("skip_asin_details_by_country")
|
||||||
|
@JsonProperty("skipAsinDetailsByCountry")
|
||||||
|
private Map<String, List<SkipPriceAsinDetailDto>> skipAsinDetailsByCountry = new LinkedHashMap<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package com.nanri.aiimage.modules.productrisk.model.vo;
|
package com.nanri.aiimage.modules.productrisk.model.vo;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
|
import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
|
||||||
|
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinDetailDto;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 与 Python 队列约定字段对齐,供前端原样推入 enqueue_json。
|
* 与 Python 队列约定字段对齐,供前端原样推入 enqueue_json。
|
||||||
@@ -52,4 +56,12 @@ public class ProductRiskShopQueueItemVo {
|
|||||||
@JsonProperty("queryAsins")
|
@JsonProperty("queryAsins")
|
||||||
@Schema(description = "查询 ASIN 模块返回的后台维护 ASIN 数据;按全表聚合返回,不按当前店铺过滤")
|
@Schema(description = "查询 ASIN 模块返回的后台维护 ASIN 数据;按全表聚合返回,不按当前店铺过滤")
|
||||||
private List<QueryAsinCountryAsinsDto> queryAsins = new ArrayList<>();
|
private List<QueryAsinCountryAsinsDto> queryAsins = new ArrayList<>();
|
||||||
|
|
||||||
|
@JsonAlias("skip_asins_by_country")
|
||||||
|
@JsonProperty("skipAsinsByCountry")
|
||||||
|
private Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
@JsonAlias("skip_asin_details_by_country")
|
||||||
|
@JsonProperty("skipAsinDetailsByCountry")
|
||||||
|
private Map<String, List<SkipPriceAsinDetailDto>> skipAsinDetailsByCountry = new LinkedHashMap<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopkey.model.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "跳过 ASIN 明细")
|
||||||
|
public class SkipPriceAsinDetailDto {
|
||||||
|
|
||||||
|
@Schema(description = "ASIN")
|
||||||
|
private String asin;
|
||||||
|
|
||||||
|
@JsonProperty("minimumPrice")
|
||||||
|
@Schema(description = "最低价,未配置时为空字符串")
|
||||||
|
private String minimumPrice;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.nanri.aiimage.common.exception.BusinessException;
|
|||||||
import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper;
|
import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper;
|
||||||
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
|
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
|
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
|
||||||
|
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinDetailDto;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
|
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.entity.SkipPriceAsinEntity;
|
import com.nanri.aiimage.modules.shopkey.model.entity.SkipPriceAsinEntity;
|
||||||
@@ -833,6 +834,72 @@ public class SkipPriceAsinService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<String, Map<String, List<SkipPriceAsinDetailDto>>> listSkipAsinDetailsByShopAndCountries(
|
||||||
|
List<String> shopNames, List<String> countryCodes) {
|
||||||
|
if (shopNames == null || shopNames.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
LinkedHashSet<String> normalizedShopNames = new LinkedHashSet<>();
|
||||||
|
for (String shopName : shopNames) {
|
||||||
|
String normalized = normalizeBlank(shopName);
|
||||||
|
if (!normalized.isEmpty()) {
|
||||||
|
normalizedShopNames.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normalizedShopNames.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
LinkedHashSet<String> normalizedCountries = new LinkedHashSet<>();
|
||||||
|
if (countryCodes != null) {
|
||||||
|
for (String countryCode : countryCodes) {
|
||||||
|
String normalized = normalizeBlank(countryCode).toUpperCase(Locale.ROOT);
|
||||||
|
if (SUPPORTED_COUNTRIES.contains(normalized)) {
|
||||||
|
normalizedCountries.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normalizedCountries.isEmpty()) {
|
||||||
|
normalizedCountries.addAll(SUPPORTED_COUNTRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper.selectList(new LambdaQueryWrapper<SkipPriceAsinEntity>()
|
||||||
|
.in(SkipPriceAsinEntity::getShopName, normalizedShopNames)
|
||||||
|
.orderByDesc(SkipPriceAsinEntity::getId));
|
||||||
|
Map<String, Map<String, LinkedHashMap<String, SkipPriceAsinDetailDto>>> grouped = new LinkedHashMap<>();
|
||||||
|
for (SkipPriceAsinEntity row : rows) {
|
||||||
|
String shopName = normalizeBlank(row.getShopName());
|
||||||
|
if (shopName.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Map<String, LinkedHashMap<String, SkipPriceAsinDetailDto>> countryMap =
|
||||||
|
grouped.computeIfAbsent(shopName, ignored -> new LinkedHashMap<>());
|
||||||
|
for (String country : normalizedCountries) {
|
||||||
|
String asin = getCountryAsin(row, country);
|
||||||
|
if (asin.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
SkipPriceAsinDetailDto detail = new SkipPriceAsinDetailDto();
|
||||||
|
detail.setAsin(asin);
|
||||||
|
BigDecimal minimumPrice = getCountryMinimumPrice(row, country);
|
||||||
|
detail.setMinimumPrice(minimumPrice == null ? "" : minimumPrice.toPlainString());
|
||||||
|
countryMap.computeIfAbsent(country, ignored -> new LinkedHashMap<>()).putIfAbsent(asin, detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Map<String, List<SkipPriceAsinDetailDto>>> result = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, Map<String, LinkedHashMap<String, SkipPriceAsinDetailDto>>> shopEntry : grouped.entrySet()) {
|
||||||
|
Map<String, List<SkipPriceAsinDetailDto>> countryResult = new LinkedHashMap<>();
|
||||||
|
for (String country : normalizedCountries) {
|
||||||
|
LinkedHashMap<String, SkipPriceAsinDetailDto> details = shopEntry.getValue().get(country);
|
||||||
|
if (details != null && !details.isEmpty()) {
|
||||||
|
countryResult.put(country, List.copyOf(details.values()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.put(shopEntry.getKey(), countryResult);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public Map<String, List<String>> listAllSkipAsinsByCountry() {
|
public Map<String, List<String>> listAllSkipAsinsByCountry() {
|
||||||
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper.selectList(
|
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper.selectList(
|
||||||
new LambdaQueryWrapper<SkipPriceAsinEntity>()
|
new LambdaQueryWrapper<SkipPriceAsinEntity>()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.nanri.aiimage.modules.shopmatch.model.dto;
|
package com.nanri.aiimage.modules.shopmatch.model.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -11,6 +12,11 @@ public class ShopMatchRowDto {
|
|||||||
@Schema(description = "ASIN")
|
@Schema(description = "ASIN")
|
||||||
private String asin;
|
private String asin;
|
||||||
|
|
||||||
|
@JsonAlias("minimum_price")
|
||||||
|
@JsonProperty("minimumPrice")
|
||||||
|
@Schema(description = "最低价")
|
||||||
|
private String minimumPrice;
|
||||||
|
|
||||||
@Schema(description = "状态")
|
@Schema(description = "状态")
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.nanri.aiimage.modules.shopmatch.model.dto;
|
package com.nanri.aiimage.modules.shopmatch.model.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
|
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
|
||||||
|
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinDetailDto;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@@ -22,4 +24,14 @@ public class ShopMatchShopPayloadDto {
|
|||||||
|
|
||||||
@Schema(description = "按国家分组的 ASIN/状态 列表")
|
@Schema(description = "按国家分组的 ASIN/状态 列表")
|
||||||
private Map<ProductRiskCountryCode, List<ShopMatchRowDto>> countries = new LinkedHashMap<>();
|
private Map<ProductRiskCountryCode, List<ShopMatchRowDto>> countries = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
@JsonAlias("skip_asin_details_by_country")
|
||||||
|
@JsonProperty("skipAsinDetailsByCountry")
|
||||||
|
@Schema(description = "Python 回传的跳过 ASIN 明细,按国家携带 asin 和最低价")
|
||||||
|
private Map<ProductRiskCountryCode, List<SkipPriceAsinDetailDto>> skipAsinDetailsByCountry = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
@JsonAlias("skip_asins_by_country")
|
||||||
|
@JsonProperty("skipAsinsByCountry")
|
||||||
|
@Schema(description = "Python 回传的跳过 ASIN 列表,兼容旧格式")
|
||||||
|
private Map<ProductRiskCountryCode, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ public class ShopMatchExcelAssemblyService {
|
|||||||
String sheetName = code.getSheetName();
|
String sheetName = code.getSheetName();
|
||||||
Sheet sheet = workbook.createSheet(sheetName);
|
Sheet sheet = workbook.createSheet(sheetName);
|
||||||
Row header = sheet.createRow(0);
|
Row header = sheet.createRow(0);
|
||||||
header.createCell(0).setCellValue(HEADER[0]);
|
header.createCell(0).setCellValue("ASIN");
|
||||||
header.createCell(1).setCellValue(HEADER[1]);
|
header.createCell(1).setCellValue("最低价");
|
||||||
|
header.createCell(2).setCellValue("状态");
|
||||||
List<ShopMatchRowDto> rows = safe.getOrDefault(sheetName, List.of());
|
List<ShopMatchRowDto> rows = safe.getOrDefault(sheetName, List.of());
|
||||||
int rowIndex = 1;
|
int rowIndex = 1;
|
||||||
for (ShopMatchRowDto item : rows) {
|
for (ShopMatchRowDto item : rows) {
|
||||||
@@ -40,10 +41,12 @@ public class ShopMatchExcelAssemblyService {
|
|||||||
}
|
}
|
||||||
Row row = sheet.createRow(rowIndex++);
|
Row row = sheet.createRow(rowIndex++);
|
||||||
row.createCell(0).setCellValue(item.getAsin() == null ? "" : item.getAsin());
|
row.createCell(0).setCellValue(item.getAsin() == null ? "" : item.getAsin());
|
||||||
row.createCell(1).setCellValue(item.getStatus() == null ? "" : item.getStatus());
|
row.createCell(1).setCellValue(item.getMinimumPrice() == null ? "" : item.getMinimumPrice());
|
||||||
|
row.createCell(2).setCellValue(item.getStatus() == null ? "" : item.getStatus());
|
||||||
}
|
}
|
||||||
sheet.setColumnWidth(0, 20 * 256);
|
sheet.setColumnWidth(0, 20 * 256);
|
||||||
sheet.setColumnWidth(1, 18 * 256);
|
sheet.setColumnWidth(1, 18 * 256);
|
||||||
|
sheet.setColumnWidth(2, 18 * 256);
|
||||||
}
|
}
|
||||||
workbook.write(fos);
|
workbook.write(fos);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchShopPayloadDto;
|
|||||||
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchStageCompleteRequest;
|
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchStageCompleteRequest;
|
||||||
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchSubmitResultRequest;
|
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchSubmitResultRequest;
|
||||||
import com.nanri.aiimage.modules.shopmatch.model.entity.ShopMatchShopCandidateEntity;
|
import com.nanri.aiimage.modules.shopmatch.model.entity.ShopMatchShopCandidateEntity;
|
||||||
|
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinDetailDto;
|
||||||
|
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
|
||||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
@@ -69,6 +71,7 @@ public class ShopMatchTaskService {
|
|||||||
private final TaskResultPayloadService taskResultPayloadService;
|
private final TaskResultPayloadService taskResultPayloadService;
|
||||||
private final TaskFileJobService taskFileJobService;
|
private final TaskFileJobService taskFileJobService;
|
||||||
private final TaskDistributedLockService taskDistributedLockService;
|
private final TaskDistributedLockService taskDistributedLockService;
|
||||||
|
private final SkipPriceAsinService skipPriceAsinService;
|
||||||
|
|
||||||
private FileTaskEntity loadTaskForExecution(Long taskId) {
|
private FileTaskEntity loadTaskForExecution(Long taskId) {
|
||||||
Map<Long, FileTaskEntity> cachedTasks = shopMatchTaskCacheService.getTaskCacheBatch(List.of(taskId));
|
Map<Long, FileTaskEntity> cachedTasks = shopMatchTaskCacheService.getTaskCacheBatch(List.of(taskId));
|
||||||
@@ -381,6 +384,7 @@ public class ShopMatchTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> countryCodes = normalizeCountryCodes(request.getCountryCodes());
|
List<String> countryCodes = normalizeCountryCodes(request.getCountryCodes());
|
||||||
|
enrichItemsWithSkipAsins(uniqueItems, countryCodes);
|
||||||
List<LocalDateTime> scheduleTimes = normalizeScheduleTimes(request.getScheduleTimes());
|
List<LocalDateTime> scheduleTimes = normalizeScheduleTimes(request.getScheduleTimes());
|
||||||
LocalDateTime now = now();
|
LocalDateTime now = now();
|
||||||
|
|
||||||
@@ -962,6 +966,8 @@ public class ShopMatchTaskService {
|
|||||||
vo.setMatched(item.isMatched());
|
vo.setMatched(item.isMatched());
|
||||||
vo.setMatchStatus(item.getMatchStatus());
|
vo.setMatchStatus(item.getMatchStatus());
|
||||||
vo.setMatchMessage(item.getMatchMessage());
|
vo.setMatchMessage(item.getMatchMessage());
|
||||||
|
vo.setSkipAsinsByCountry(item.getSkipAsinsByCountry());
|
||||||
|
vo.setSkipAsinDetailsByCountry(item.getSkipAsinDetailsByCountry());
|
||||||
vo.setTaskStatus(taskStatus);
|
vo.setTaskStatus(taskStatus);
|
||||||
vo.setSuccess(false);
|
vo.setSuccess(false);
|
||||||
vo.setError(null);
|
vo.setError(null);
|
||||||
@@ -1078,6 +1084,8 @@ public class ShopMatchTaskService {
|
|||||||
vo.setCompanyName(item.getCompanyName());
|
vo.setCompanyName(item.getCompanyName());
|
||||||
vo.setMatchStatus(item.getMatchStatus());
|
vo.setMatchStatus(item.getMatchStatus());
|
||||||
vo.setMatchMessage(item.getMatchMessage());
|
vo.setMatchMessage(item.getMatchMessage());
|
||||||
|
vo.setSkipAsinsByCountry(item.getSkipAsinsByCountry());
|
||||||
|
vo.setSkipAsinDetailsByCountry(item.getSkipAsinDetailsByCountry());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1164,6 +1172,36 @@ public class ShopMatchTaskService {
|
|||||||
return new ArrayList<>(byNorm.values());
|
return new ArrayList<>(byNorm.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void enrichItemsWithSkipAsins(List<ProductRiskShopQueueItemVo> items, List<String> countryCodes) {
|
||||||
|
List<String> shopNames = items.stream()
|
||||||
|
.map(ProductRiskShopQueueItemVo::getShopName)
|
||||||
|
.filter(name -> name != null && !name.isBlank())
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
Map<String, Map<String, List<SkipPriceAsinDetailDto>>> detailsByShop =
|
||||||
|
skipPriceAsinService.listSkipAsinDetailsByShopAndCountries(shopNames, countryCodes);
|
||||||
|
for (ProductRiskShopQueueItemVo item : items) {
|
||||||
|
Map<String, List<SkipPriceAsinDetailDto>> detailMap =
|
||||||
|
detailsByShop.getOrDefault(item.getShopName(), Map.of());
|
||||||
|
Map<String, List<SkipPriceAsinDetailDto>> normalizedDetails = new LinkedHashMap<>();
|
||||||
|
Map<String, List<String>> asinsByCountry = new LinkedHashMap<>();
|
||||||
|
for (String countryCode : countryCodes) {
|
||||||
|
List<SkipPriceAsinDetailDto> details = detailMap.getOrDefault(countryCode, List.of());
|
||||||
|
if (details.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalizedDetails.put(countryCode, details);
|
||||||
|
asinsByCountry.put(countryCode, details.stream()
|
||||||
|
.map(SkipPriceAsinDetailDto::getAsin)
|
||||||
|
.filter(asin -> asin != null && !asin.isBlank())
|
||||||
|
.distinct()
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
item.setSkipAsinDetailsByCountry(normalizedDetails);
|
||||||
|
item.setSkipAsinsByCountry(asinsByCountry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private List<String> normalizeCountryCodes(List<String> raw) {
|
private List<String> normalizeCountryCodes(List<String> raw) {
|
||||||
if (raw == null || raw.isEmpty()) {
|
if (raw == null || raw.isEmpty()) {
|
||||||
throw new BusinessException("country_codes 不能为空");
|
throw new BusinessException("country_codes 不能为空");
|
||||||
@@ -1234,6 +1272,7 @@ public class ShopMatchTaskService {
|
|||||||
if (incoming.getError() != null && !incoming.getError().isBlank()) {
|
if (incoming.getError() != null && !incoming.getError().isBlank()) {
|
||||||
merged.setError(incoming.getError().trim());
|
merged.setError(incoming.getError().trim());
|
||||||
}
|
}
|
||||||
|
mergeSkipAsinPayload(merged, incoming);
|
||||||
Map<com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode, List<ShopMatchRowDto>> nextCountries =
|
Map<com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode, List<ShopMatchRowDto>> nextCountries =
|
||||||
merged.getCountries() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(merged.getCountries());
|
merged.getCountries() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(merged.getCountries());
|
||||||
if (incoming.getCountries() != null && !incoming.getCountries().isEmpty()) {
|
if (incoming.getCountries() != null && !incoming.getCountries().isEmpty()) {
|
||||||
@@ -1245,10 +1284,131 @@ public class ShopMatchTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
merged.setCountries(nextCountries);
|
merged.setCountries(nextCountries);
|
||||||
|
appendSkipAsinRows(merged);
|
||||||
shopMatchTaskCacheService.saveShopMergedPayload(taskId, shopKey, merged);
|
shopMatchTaskCacheService.saveShopMergedPayload(taskId, shopKey, merged);
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void mergeSkipAsinPayload(ShopMatchShopPayloadDto merged, ShopMatchShopPayloadDto incoming) {
|
||||||
|
Map<ProductRiskCountryCode, List<SkipPriceAsinDetailDto>> nextDetails =
|
||||||
|
merged.getSkipAsinDetailsByCountry() == null ? new LinkedHashMap<>()
|
||||||
|
: new LinkedHashMap<>(merged.getSkipAsinDetailsByCountry());
|
||||||
|
if (incoming.getSkipAsinDetailsByCountry() != null && !incoming.getSkipAsinDetailsByCountry().isEmpty()) {
|
||||||
|
for (Map.Entry<ProductRiskCountryCode, List<SkipPriceAsinDetailDto>> entry : incoming.getSkipAsinDetailsByCountry().entrySet()) {
|
||||||
|
if (entry.getKey() == null || entry.getValue() == null || entry.getValue().isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nextDetails.put(entry.getKey(), mergeSkipAsinDetails(nextDetails.get(entry.getKey()), entry.getValue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<ProductRiskCountryCode, List<String>> nextAsins =
|
||||||
|
merged.getSkipAsinsByCountry() == null ? new LinkedHashMap<>()
|
||||||
|
: new LinkedHashMap<>(merged.getSkipAsinsByCountry());
|
||||||
|
if (incoming.getSkipAsinsByCountry() != null && !incoming.getSkipAsinsByCountry().isEmpty()) {
|
||||||
|
for (Map.Entry<ProductRiskCountryCode, List<String>> entry : incoming.getSkipAsinsByCountry().entrySet()) {
|
||||||
|
if (entry.getKey() == null || entry.getValue() == null || entry.getValue().isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nextAsins.put(entry.getKey(), mergeSkipAsins(nextAsins.get(entry.getKey()), entry.getValue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.setSkipAsinDetailsByCountry(nextDetails);
|
||||||
|
merged.setSkipAsinsByCountry(nextAsins);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SkipPriceAsinDetailDto> mergeSkipAsinDetails(List<SkipPriceAsinDetailDto> existing,
|
||||||
|
List<SkipPriceAsinDetailDto> incoming) {
|
||||||
|
LinkedHashMap<String, SkipPriceAsinDetailDto> byAsin = new LinkedHashMap<>();
|
||||||
|
if (existing != null) {
|
||||||
|
for (SkipPriceAsinDetailDto detail : existing) {
|
||||||
|
putSkipAsinDetail(byAsin, detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (incoming != null) {
|
||||||
|
for (SkipPriceAsinDetailDto detail : incoming) {
|
||||||
|
putSkipAsinDetail(byAsin, detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(byAsin.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void putSkipAsinDetail(Map<String, SkipPriceAsinDetailDto> byAsin, SkipPriceAsinDetailDto detail) {
|
||||||
|
String asin = normalizeAsin(detail == null ? null : detail.getAsin());
|
||||||
|
if (asin.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SkipPriceAsinDetailDto normalized = new SkipPriceAsinDetailDto();
|
||||||
|
normalized.setAsin(asin);
|
||||||
|
normalized.setMinimumPrice(detail.getMinimumPrice() == null ? "" : detail.getMinimumPrice().trim());
|
||||||
|
byAsin.put(asin, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> mergeSkipAsins(List<String> existing, List<String> incoming) {
|
||||||
|
LinkedHashSet<String> asins = new LinkedHashSet<>();
|
||||||
|
if (existing != null) {
|
||||||
|
existing.stream().map(this::normalizeAsin).filter(asin -> !asin.isBlank()).forEach(asins::add);
|
||||||
|
}
|
||||||
|
if (incoming != null) {
|
||||||
|
incoming.stream().map(this::normalizeAsin).filter(asin -> !asin.isBlank()).forEach(asins::add);
|
||||||
|
}
|
||||||
|
return new ArrayList<>(asins);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendSkipAsinRows(ShopMatchShopPayloadDto payload) {
|
||||||
|
Map<ProductRiskCountryCode, List<ShopMatchRowDto>> countries =
|
||||||
|
payload.getCountries() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(payload.getCountries());
|
||||||
|
Map<ProductRiskCountryCode, List<SkipPriceAsinDetailDto>> detailsByCountry =
|
||||||
|
payload.getSkipAsinDetailsByCountry() == null ? Map.of() : payload.getSkipAsinDetailsByCountry();
|
||||||
|
Map<ProductRiskCountryCode, List<String>> asinsByCountry =
|
||||||
|
payload.getSkipAsinsByCountry() == null ? Map.of() : payload.getSkipAsinsByCountry();
|
||||||
|
|
||||||
|
LinkedHashSet<ProductRiskCountryCode> countryCodes = new LinkedHashSet<>();
|
||||||
|
countryCodes.addAll(detailsByCountry.keySet());
|
||||||
|
countryCodes.addAll(asinsByCountry.keySet());
|
||||||
|
for (ProductRiskCountryCode countryCode : countryCodes) {
|
||||||
|
if (countryCode == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
LinkedHashMap<String, ShopMatchRowDto> rowsByAsin = new LinkedHashMap<>();
|
||||||
|
List<ShopMatchRowDto> existingRows = countries.get(countryCode);
|
||||||
|
if (existingRows != null) {
|
||||||
|
for (ShopMatchRowDto row : existingRows) {
|
||||||
|
if (row == null || isEmptyBusinessRow(row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rowsByAsin.put(normalizeAsin(row.getAsin()), cloneRow(row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (SkipPriceAsinDetailDto detail : detailsByCountry.getOrDefault(countryCode, List.of())) {
|
||||||
|
upsertSkipAsinRow(rowsByAsin, detail == null ? null : detail.getAsin(),
|
||||||
|
detail == null ? null : detail.getMinimumPrice());
|
||||||
|
}
|
||||||
|
for (String asin : asinsByCountry.getOrDefault(countryCode, List.of())) {
|
||||||
|
upsertSkipAsinRow(rowsByAsin, asin, null);
|
||||||
|
}
|
||||||
|
countries.put(countryCode, new ArrayList<>(rowsByAsin.values()));
|
||||||
|
}
|
||||||
|
payload.setCountries(countries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void upsertSkipAsinRow(Map<String, ShopMatchRowDto> rowsByAsin, String asin, String minimumPrice) {
|
||||||
|
String normalizedAsin = normalizeAsin(asin);
|
||||||
|
if (normalizedAsin.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ShopMatchRowDto row = rowsByAsin.get(normalizedAsin);
|
||||||
|
if (row == null) {
|
||||||
|
row = new ShopMatchRowDto();
|
||||||
|
row.setAsin(normalizedAsin);
|
||||||
|
rowsByAsin.put(normalizedAsin, row);
|
||||||
|
}
|
||||||
|
if (minimumPrice != null && !minimumPrice.isBlank()) {
|
||||||
|
row.setMinimumPrice(minimumPrice.trim());
|
||||||
|
}
|
||||||
|
row.setStatus("跳过");
|
||||||
|
}
|
||||||
|
|
||||||
private List<ShopMatchRowDto> mergeCountryRows(List<ShopMatchRowDto> existingRows, List<ShopMatchRowDto> incomingRows) {
|
private List<ShopMatchRowDto> mergeCountryRows(List<ShopMatchRowDto> existingRows, List<ShopMatchRowDto> incomingRows) {
|
||||||
LinkedHashMap<String, ShopMatchRowDto> byKey = new LinkedHashMap<>();
|
LinkedHashMap<String, ShopMatchRowDto> byKey = new LinkedHashMap<>();
|
||||||
if (existingRows != null) {
|
if (existingRows != null) {
|
||||||
@@ -1289,6 +1449,9 @@ public class ShopMatchTaskService {
|
|||||||
if (incoming.getAsin() != null && !incoming.getAsin().isBlank()) {
|
if (incoming.getAsin() != null && !incoming.getAsin().isBlank()) {
|
||||||
merged.setAsin(incoming.getAsin());
|
merged.setAsin(incoming.getAsin());
|
||||||
}
|
}
|
||||||
|
if (incoming.getMinimumPrice() != null && !incoming.getMinimumPrice().isBlank()) {
|
||||||
|
merged.setMinimumPrice(incoming.getMinimumPrice());
|
||||||
|
}
|
||||||
if (incoming.getStatus() != null && !incoming.getStatus().isBlank()) {
|
if (incoming.getStatus() != null && !incoming.getStatus().isBlank()) {
|
||||||
merged.setStatus(incoming.getStatus());
|
merged.setStatus(incoming.getStatus());
|
||||||
}
|
}
|
||||||
@@ -1300,12 +1463,17 @@ public class ShopMatchTaskService {
|
|||||||
ShopMatchRowDto out = new ShopMatchRowDto();
|
ShopMatchRowDto out = new ShopMatchRowDto();
|
||||||
if (row != null) {
|
if (row != null) {
|
||||||
out.setAsin(row.getAsin());
|
out.setAsin(row.getAsin());
|
||||||
|
out.setMinimumPrice(row.getMinimumPrice());
|
||||||
out.setStatus(row.getStatus());
|
out.setStatus(row.getStatus());
|
||||||
out.setDone(row.getDone());
|
out.setDone(row.getDone());
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalizeAsin(String asin) {
|
||||||
|
return asin == null ? "" : asin.trim().toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isShopPayloadCompleted(ShopMatchShopPayloadDto payload) {
|
private boolean isShopPayloadCompleted(ShopMatchShopPayloadDto payload) {
|
||||||
return countPayloadRows(payload) > 0 && payloadHasAnyDoneTrue(payload);
|
return countPayloadRows(payload) > 0 && payloadHasAnyDoneTrue(payload);
|
||||||
}
|
}
|
||||||
@@ -1329,9 +1497,12 @@ public class ShopMatchTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean payloadHasAnyDoneTrue(ShopMatchShopPayloadDto payload) {
|
private boolean payloadHasAnyDoneTrue(ShopMatchShopPayloadDto payload) {
|
||||||
if (payload == null || payload.getCountries() == null) {
|
if (payload == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (payload.getCountries() == null || payload.getCountries().isEmpty()) {
|
||||||
|
return hasSkipAsinPayload(payload);
|
||||||
|
}
|
||||||
for (List<ShopMatchRowDto> rows : payload.getCountries().values()) {
|
for (List<ShopMatchRowDto> rows : payload.getCountries().values()) {
|
||||||
if (rows == null) {
|
if (rows == null) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1345,6 +1516,27 @@ public class ShopMatchTaskService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean hasSkipAsinPayload(ShopMatchShopPayloadDto payload) {
|
||||||
|
if (payload == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (payload.getSkipAsinDetailsByCountry() != null) {
|
||||||
|
for (List<SkipPriceAsinDetailDto> details : payload.getSkipAsinDetailsByCountry().values()) {
|
||||||
|
if (details != null && details.stream().anyMatch(detail -> detail != null && !normalizeAsin(detail.getAsin()).isBlank())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload.getSkipAsinsByCountry() != null) {
|
||||||
|
for (List<String> asins : payload.getSkipAsinsByCountry().values()) {
|
||||||
|
if (asins != null && asins.stream().anyMatch(asin -> !normalizeAsin(asin).isBlank())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isEmptyBusinessRow(ShopMatchRowDto row) {
|
private boolean isEmptyBusinessRow(ShopMatchRowDto row) {
|
||||||
return row == null || row.getAsin() == null || row.getAsin().trim().isEmpty();
|
return row == null || row.getAsin() == null || row.getAsin().trim().isEmpty();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -572,6 +572,11 @@ public class SimilarAsinCozeClient {
|
|||||||
|| message.contains("Read timed out")
|
|| message.contains("Read timed out")
|
||||||
|| message.contains("Connection reset")
|
|| message.contains("Connection reset")
|
||||||
|| message.contains("I/O error on POST request")
|
|| message.contains("I/O error on POST request")
|
||||||
|
|| message.contains("429")
|
||||||
|
|| message.toLowerCase(Locale.ROOT).contains("rate limit")
|
||||||
|
|| message.toLowerCase(Locale.ROOT).contains("retry later")
|
||||||
|
|| message.contains("\u9650\u6d41")
|
||||||
|
|| message.contains("\u7a0d\u540e\u91cd\u8bd5")
|
||||||
|| message.toLowerCase(Locale.ROOT).contains("timeout");
|
|| message.toLowerCase(Locale.ROOT).contains("timeout");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,7 +784,9 @@ public class SimilarAsinCozeClient {
|
|||||||
|
|
||||||
private String resolveFailureMessage(JsonNode root) {
|
private String resolveFailureMessage(JsonNode root) {
|
||||||
JsonNode dataNode = root.path("data");
|
JsonNode dataNode = root.path("data");
|
||||||
String message = text(firstNonNull(dataNode.get("error_message"), firstNonNull(dataNode.get("msg"), root.get("msg"))));
|
String message = firstNonBlank(
|
||||||
|
findTextByFieldName(dataNode, "error_message", "error", "msg"),
|
||||||
|
findTextByFieldName(root, "error_message", "error", "msg"));
|
||||||
return message == null ? "" : message;
|
return message == null ? "" : message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ public class SimilarAsinFilterConditionEntity {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private Long userId;
|
private Long userId;
|
||||||
private String conditionText;
|
private String conditionText;
|
||||||
|
private String conditionHash;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ public class SimilarAsinTaskService {
|
|||||||
private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L;
|
private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L;
|
||||||
private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5);
|
private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5);
|
||||||
private static final long TASK_LOCK_WAIT_MILLIS = 10000L;
|
private static final long TASK_LOCK_WAIT_MILLIS = 10000L;
|
||||||
|
private static final Duration COZE_SUBMIT_LOCK_TTL = Duration.ofMinutes(2);
|
||||||
|
private static final long COZE_SUBMIT_LOCK_WAIT_MILLIS = 180000L;
|
||||||
|
private static final long COZE_SUBMIT_LOCK_RETRY_DELAY_MILLIS = 500L;
|
||||||
|
private static final long COZE_SUBMIT_MIN_INTERVAL_MILLIS = 30000L;
|
||||||
|
private static final int MAX_COZE_SUBMIT_RETRY_COUNT = 5;
|
||||||
private static final int PARSE_RESPONSE_PREVIEW_LIMIT = 100;
|
private static final int PARSE_RESPONSE_PREVIEW_LIMIT = 100;
|
||||||
private static final List<String> RESULT_HEADERS = List.of(
|
private static final List<String> RESULT_HEADERS = List.of(
|
||||||
"id",
|
"id",
|
||||||
@@ -159,9 +164,11 @@ public class SimilarAsinTaskService {
|
|||||||
if (text.isBlank()) {
|
if (text.isBlank()) {
|
||||||
throw new BusinessException("筛选条件不能为空");
|
throw new BusinessException("筛选条件不能为空");
|
||||||
}
|
}
|
||||||
|
String conditionHash = DigestUtil.sha256Hex(text);
|
||||||
SimilarAsinFilterConditionEntity existing = filterConditionMapper.selectOne(
|
SimilarAsinFilterConditionEntity existing = filterConditionMapper.selectOne(
|
||||||
new LambdaQueryWrapper<SimilarAsinFilterConditionEntity>()
|
new LambdaQueryWrapper<SimilarAsinFilterConditionEntity>()
|
||||||
.eq(SimilarAsinFilterConditionEntity::getUserId, request.getUserId())
|
.eq(SimilarAsinFilterConditionEntity::getUserId, request.getUserId())
|
||||||
|
.eq(SimilarAsinFilterConditionEntity::getConditionHash, conditionHash)
|
||||||
.eq(SimilarAsinFilterConditionEntity::getConditionText, text)
|
.eq(SimilarAsinFilterConditionEntity::getConditionText, text)
|
||||||
.last("limit 1"));
|
.last("limit 1"));
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
@@ -170,8 +177,22 @@ public class SimilarAsinTaskService {
|
|||||||
SimilarAsinFilterConditionEntity entity = new SimilarAsinFilterConditionEntity();
|
SimilarAsinFilterConditionEntity entity = new SimilarAsinFilterConditionEntity();
|
||||||
entity.setUserId(request.getUserId());
|
entity.setUserId(request.getUserId());
|
||||||
entity.setConditionText(text);
|
entity.setConditionText(text);
|
||||||
|
entity.setConditionHash(conditionHash);
|
||||||
entity.setCreatedAt(LocalDateTime.now());
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
filterConditionMapper.insert(entity);
|
try {
|
||||||
|
filterConditionMapper.insert(entity);
|
||||||
|
} catch (DuplicateKeyException ex) {
|
||||||
|
existing = filterConditionMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<SimilarAsinFilterConditionEntity>()
|
||||||
|
.eq(SimilarAsinFilterConditionEntity::getUserId, request.getUserId())
|
||||||
|
.eq(SimilarAsinFilterConditionEntity::getConditionHash, conditionHash)
|
||||||
|
.eq(SimilarAsinFilterConditionEntity::getConditionText, text)
|
||||||
|
.last("limit 1"));
|
||||||
|
if (existing != null) {
|
||||||
|
return toFilterConditionVo(existing);
|
||||||
|
}
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
return toFilterConditionVo(entity);
|
return toFilterConditionVo(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +482,7 @@ public class SimilarAsinTaskService {
|
|||||||
completeSubmittedChunk(context);
|
completeSubmittedChunk(context);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
submitCozeForSubmittedChunk(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||||
@@ -538,12 +560,15 @@ public class SimilarAsinTaskService {
|
|||||||
taskScopeStateMapper.updateById(scope);
|
taskScopeStateMapper.updateById(scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SubmitContext context = new SubmitContext(task, scopeKey, scopeHash, chunkIndex,
|
||||||
|
Boolean.TRUE.equals(request.getDone()), request.getError());
|
||||||
if (Boolean.TRUE.equals(request.getDone()) || request.getError() != null && !request.getError().isBlank()) {
|
if (Boolean.TRUE.equals(request.getDone()) || request.getError() != null && !request.getError().isBlank()) {
|
||||||
finalizeTask(task, request.getError(), allRowCount(task), true);
|
finalizeTask(task, request.getError(), allRowCount(task), true);
|
||||||
} else {
|
} else {
|
||||||
task.setUpdatedAt(LocalDateTime.now());
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
fileTaskMapper.updateById(task);
|
fileTaskMapper.updateById(task);
|
||||||
}
|
}
|
||||||
|
submitCozeForSubmittedChunk(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -706,7 +731,7 @@ public class SimilarAsinTaskService {
|
|||||||
upsertScopeState(taskId, scopeKey, scopeHash, chunkTotal, request.getError(), done, false);
|
upsertScopeState(taskId, scopeKey, scopeHash, chunkTotal, request.getError(), done, false);
|
||||||
task.setUpdatedAt(LocalDateTime.now());
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
fileTaskMapper.updateById(task);
|
fileTaskMapper.updateById(task);
|
||||||
return new SubmitContext(task, scopeKey, scopeHash, done, request.getError());
|
return new SubmitContext(task, scopeKey, scopeHash, chunkIndex, done, request.getError());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void completeSubmittedChunk(SubmitContext context) {
|
private void completeSubmittedChunk(SubmitContext context) {
|
||||||
@@ -728,6 +753,43 @@ public class SimilarAsinTaskService {
|
|||||||
fileTaskMapper.updateById(task);
|
fileTaskMapper.updateById(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void submitCozeForSubmittedChunk(SubmitContext context) {
|
||||||
|
if (context == null || context.task() == null || context.task().getId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
FileTaskEntity task = fileTaskMapper.selectById(context.task().getId());
|
||||||
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || STATUS_SUCCESS.equals(task.getStatus())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TaskChunkEntity chunk = taskChunkMapper.selectOne(new LambdaQueryWrapper<TaskChunkEntity>()
|
||||||
|
.eq(TaskChunkEntity::getTaskId, task.getId())
|
||||||
|
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE)
|
||||||
|
.eq(TaskChunkEntity::getScopeHash, context.scopeHash())
|
||||||
|
.eq(TaskChunkEntity::getChunkIndex, context.chunkIndex())
|
||||||
|
.last("limit 1"));
|
||||||
|
if (chunk == null || readChunkRows(chunk).isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
FileResultEntity result = findOrCreateResultRecordForAssembly(task, allRowCount(task));
|
||||||
|
if (result == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TaskFileJobEntity job = taskFileJobService.enqueueAssembleResult(
|
||||||
|
task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task));
|
||||||
|
if (job == null || "SUCCESS".equals(job.getStatus())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||||
|
boolean pendingCoze = submitCozeBatches(task, result, job, List.of(chunk), allRowsByBaseId);
|
||||||
|
if (pendingCoze) {
|
||||||
|
taskFileJobService.touchRunning(job.getId());
|
||||||
|
touchJavaSideTaskActivity(task.getId());
|
||||||
|
} else if (isResultSubmissionComplete(task.getId())) {
|
||||||
|
maybeFinalizeCozeJobLocked(task.getId(), new CozeBatchContext(
|
||||||
|
job.getId(), result.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), 1, 1, currentInstanceId(), 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void finalizeStaleTask(Long taskId, String error) {
|
private void finalizeStaleTask(Long taskId, String error) {
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !STATUS_RUNNING.equals(task.getStatus())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !STATUS_RUNNING.equals(task.getStatus())) {
|
||||||
@@ -1127,13 +1189,19 @@ public class SimilarAsinTaskService {
|
|||||||
private void finalizeTask(FileTaskEntity task, String error, int rowCount, boolean assembleWorkbook) {
|
private void finalizeTask(FileTaskEntity task, String error, int rowCount, boolean assembleWorkbook) {
|
||||||
String finalError = error;
|
String finalError = error;
|
||||||
FileResultEntity result = null;
|
FileResultEntity result = null;
|
||||||
|
boolean hasPersistedResultRows = hasPersistedResultRows(task.getId());
|
||||||
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
.eq(FileResultEntity::getTaskId, task.getId())
|
.eq(FileResultEntity::getTaskId, task.getId())
|
||||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||||
.last("limit 1"));
|
.last("limit 1"));
|
||||||
if (!rows.isEmpty()) {
|
if (!rows.isEmpty()) {
|
||||||
result = rows.getFirst();
|
result = rows.getFirst();
|
||||||
if (assembleWorkbook && shouldAssembleSynchronously()) {
|
} else if (assembleWorkbook && hasPersistedResultRows) {
|
||||||
|
result = createResultRecordForAssembly(task, rowCount);
|
||||||
|
}
|
||||||
|
if (result != null) {
|
||||||
|
boolean shouldAssembleResult = assembleWorkbook && (finalError == null || finalError.isBlank() || hasPersistedResultRows);
|
||||||
|
if (shouldAssembleResult && shouldAssembleSynchronously()) {
|
||||||
try {
|
try {
|
||||||
assembleResultWorkbook(task, result);
|
assembleResultWorkbook(task, result);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
@@ -1155,20 +1223,67 @@ public class SimilarAsinTaskService {
|
|||||||
result.setSuccess(failed ? 0 : 1);
|
result.setSuccess(failed ? 0 : 1);
|
||||||
result.setErrorMessage(finalError);
|
result.setErrorMessage(finalError);
|
||||||
result.setRowCount(rowCount > 0 ? rowCount : result.getRowCount());
|
result.setRowCount(rowCount > 0 ? rowCount : result.getRowCount());
|
||||||
if (!failed && assembleWorkbook) {
|
boolean shouldAssembleResult = assembleWorkbook && (!failed || hasPersistedResultRows);
|
||||||
|
if (shouldAssembleResult) {
|
||||||
result.setResultFilename(safeFileStem(result.getSourceFilename()) + "-result.xlsx");
|
result.setResultFilename(safeFileStem(result.getSourceFilename()) + "-result.xlsx");
|
||||||
result.setResultFileUrl(null);
|
result.setResultFileUrl(null);
|
||||||
result.setResultFileSize(0L);
|
result.setResultFileSize(0L);
|
||||||
result.setResultContentType(CONTENT_TYPE_XLSX);
|
result.setResultContentType(CONTENT_TYPE_XLSX);
|
||||||
}
|
}
|
||||||
fileResultMapper.updateById(result);
|
fileResultMapper.updateById(result);
|
||||||
if (!failed && assembleWorkbook) {
|
if (shouldAssembleResult) {
|
||||||
taskFileJobService.enqueueAssembleResult(task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task));
|
enqueueResultAssembly(task, result, true);
|
||||||
|
} else if (assembleWorkbook && failed) {
|
||||||
|
log.warn("[similar-asin] skip failed task workbook assembly because no persisted result rows taskId={}", task.getId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
taskCacheService.deleteTaskCache(task.getId());
|
taskCacheService.deleteTaskCache(task.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private FileResultEntity findOrCreateResultRecordForAssembly(FileTaskEntity task, int rowCount) {
|
||||||
|
if (task == null || task.getId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
|
.eq(FileResultEntity::getTaskId, task.getId())
|
||||||
|
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||||
|
.last("limit 1"));
|
||||||
|
if (rows != null && !rows.isEmpty()) {
|
||||||
|
return rows.getFirst();
|
||||||
|
}
|
||||||
|
return createResultRecordForAssembly(task, rowCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskFileJobEntity enqueueResultAssembly(FileTaskEntity task, FileResultEntity result, boolean dispatchWhenIdle) {
|
||||||
|
if (task == null || task.getId() == null || result == null || result.getId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TaskFileJobEntity job = taskFileJobService.enqueueAssembleResult(
|
||||||
|
task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task));
|
||||||
|
if (dispatchWhenIdle
|
||||||
|
&& job != null
|
||||||
|
&& "RUNNING".equals(job.getStatus())
|
||||||
|
&& countPendingCozeStates(task.getId()) == 0) {
|
||||||
|
taskFileJobService.requeue(job.getId(), "Python upload finished, assembling xlsx");
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileResultEntity createResultRecordForAssembly(FileTaskEntity task, int rowCount) {
|
||||||
|
SimilarAsinParsedPayloadDto payload = readParsedPayload(task);
|
||||||
|
List<SimilarAsinSourceFileDto> sourceFiles = payload.getSourceFiles() == null ? List.of() : payload.getSourceFiles();
|
||||||
|
FileResultEntity result = new FileResultEntity();
|
||||||
|
result.setTaskId(task.getId());
|
||||||
|
result.setModuleType(MODULE_TYPE);
|
||||||
|
result.setSourceFilename(buildAggregateSourceFilenameLabel(sourceFiles));
|
||||||
|
result.setSourceFileUrl(buildAggregateScopeKey(sourceFiles));
|
||||||
|
result.setRowCount(rowCount);
|
||||||
|
result.setUserId(task.getUserId());
|
||||||
|
result.setCreatedAt(LocalDateTime.now());
|
||||||
|
fileResultMapper.insert(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean shouldAssembleSynchronously() {
|
private boolean shouldAssembleSynchronously() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1197,6 +1312,12 @@ public class SimilarAsinTaskService {
|
|||||||
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||||
int cozeWorkUnits = countCozeWorkUnits(chunks, Math.max(1, properties.getCozeBatchSize()));
|
int cozeWorkUnits = countCozeWorkUnits(chunks, Math.max(1, properties.getCozeBatchSize()));
|
||||||
int totalProgressUnits = Math.max(3, cozeWorkUnits + 3);
|
int totalProgressUnits = Math.max(3, cozeWorkUnits + 3);
|
||||||
|
if (countPendingCozeStates(task.getId()) > 0) {
|
||||||
|
taskFileJobService.touchRunning(job.getId());
|
||||||
|
touchJavaSideTaskActivity(task.getId());
|
||||||
|
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
saveFileBuildProgress(task, job, totalProgressUnits, 0, "Submitting Coze");
|
saveFileBuildProgress(task, job, totalProgressUnits, 0, "Submitting Coze");
|
||||||
boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId);
|
boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId);
|
||||||
if (pendingCoze) {
|
if (pendingCoze) {
|
||||||
@@ -1205,11 +1326,17 @@ public class SimilarAsinTaskService {
|
|||||||
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result");
|
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (STATUS_RUNNING.equals(task.getStatus()) && !isResultSubmissionComplete(task.getId())) {
|
||||||
|
taskFileJobService.touchRunning(job.getId());
|
||||||
|
touchJavaSideTaskActivity(task.getId());
|
||||||
|
saveFileBuildProgress(task, job, totalProgressUnits, Math.max(1, cozeWorkUnits), "Waiting for Python upload");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits);
|
completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(fixedDelayString = "${aiimage.similar-asin.coze-poll-delay-ms:5000}")
|
@Scheduled(fixedDelayString = "${aiimage.similar-asin.coze-poll-delay-ms:30000}")
|
||||||
public void pollPendingCozeJobs() {
|
public void pollPendingCozeJobs() {
|
||||||
DistributedJobLockService.LockHandle lockHandle =
|
DistributedJobLockService.LockHandle lockHandle =
|
||||||
distributedJobLockService.tryLock("similar-asin:coze-poll", Duration.ofMinutes(1));
|
distributedJobLockService.tryLock("similar-asin:coze-poll", Duration.ofMinutes(1));
|
||||||
@@ -1299,7 +1426,7 @@ public class SimilarAsinTaskService {
|
|||||||
|| COZE_STATUS_RUNNING.equals(existing.getCozeStatus());
|
|| COZE_STATUS_RUNNING.equals(existing.getCozeStatus());
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
SimilarAsinCozeClient.CozeSubmitResponse submit = cozeClient.submitWorkflow(batchRows, prompt, apiKey);
|
SimilarAsinCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(batchRows, prompt, apiKey);
|
||||||
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
||||||
List<SimilarAsinResultRowDto> cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData());
|
List<SimilarAsinResultRowDto> cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData());
|
||||||
mergeCozeRowsIntoChunk(task, chunk.getScopeHash(), chunk.getChunkIndex(), cozeRows, allRowsByBaseId);
|
mergeCozeRowsIntoChunk(task, chunk.getScopeHash(), chunk.getChunkIndex(), cozeRows, allRowsByBaseId);
|
||||||
@@ -1349,7 +1476,8 @@ public class SimilarAsinTaskService {
|
|||||||
chunk.getChunkIndex(),
|
chunk.getChunkIndex(),
|
||||||
batchIndex,
|
batchIndex,
|
||||||
batchTotal,
|
batchTotal,
|
||||||
currentInstanceId()
|
currentInstanceId(),
|
||||||
|
0
|
||||||
);
|
);
|
||||||
String batchPayload = writeJson(batchRows, "serialize coze batch payload failed");
|
String batchPayload = writeJson(batchRows, "serialize coze batch payload failed");
|
||||||
String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast(
|
String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
@@ -1442,6 +1570,12 @@ public class SimilarAsinTaskService {
|
|||||||
if (batchRows.isEmpty() && failureMessage.isBlank()) {
|
if (batchRows.isEmpty() && failureMessage.isBlank()) {
|
||||||
failureMessage = "Coze batch payload missing";
|
failureMessage = "Coze batch payload missing";
|
||||||
}
|
}
|
||||||
|
if (!failureMessage.isBlank() && splitRetryFailedCozeBatchState(state, context, batchRows, failureMessage)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!failureMessage.isBlank() && retryFailedCozeBatchState(state, context, batchRows, failureMessage)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
List<SimilarAsinResultRowDto> cozeRows = failureMessage.isBlank()
|
List<SimilarAsinResultRowDto> cozeRows = failureMessage.isBlank()
|
||||||
? cozeClient.mergeRowsFromDataText(batchRows, poll.resolvedPayloadText())
|
? cozeClient.mergeRowsFromDataText(batchRows, poll.resolvedPayloadText())
|
||||||
: cozeClient.markRowsFailed(batchRows, failureMessage);
|
: cozeClient.markRowsFailed(batchRows, failureMessage);
|
||||||
@@ -1477,6 +1611,241 @@ public class SimilarAsinTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean retryFailedCozeBatchState(TaskScopeStateEntity state,
|
||||||
|
CozeBatchContext context,
|
||||||
|
List<SimilarAsinResultRowDto> batchRows,
|
||||||
|
String failureMessage) {
|
||||||
|
if (state == null || context == null || batchRows == null || batchRows.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isRetryableCozeFailure(failureMessage) || cozeSubmitRetryCount(context) >= MAX_COZE_SUBMIT_RETRY_COUNT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId());
|
||||||
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
SimilarAsinCozeClient.CozeSubmitResponse submit =
|
||||||
|
submitCozeWorkflowThrottled(batchRows, readAiPrompt(task), readApiKey(task));
|
||||||
|
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||||
|
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows =
|
||||||
|
cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData());
|
||||||
|
mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
|
||||||
|
markCozeStateTerminal(state, COZE_STATUS_DONE, null);
|
||||||
|
maybeFinalizeCozeJob(state.getTaskId(), context);
|
||||||
|
log.info("[similar-asin] coze retry returned immediate result taskId={} stateId={} chunk={} batch={}/{}",
|
||||||
|
state.getTaskId(), state.getId(), context.chunkIndex(), context.batchIndex(), context.batchTotal());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (submit.executeId() == null || submit.executeId().isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
CozeBatchContext retryContext = withCozeSubmitRetryCount(context, cozeSubmitRetryCount(context) + 1);
|
||||||
|
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::getCozeExecuteId, submit.executeId())
|
||||||
|
.set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_SUBMITTED)
|
||||||
|
.set(TaskScopeStateEntity::getCozeSubmittedAt, now)
|
||||||
|
.set(TaskScopeStateEntity::getCozeLastPolledAt, null)
|
||||||
|
.set(TaskScopeStateEntity::getCozeCompletedAt, null)
|
||||||
|
.set(TaskScopeStateEntity::getCozeAttemptCount, 0)
|
||||||
|
.set(TaskScopeStateEntity::getCozeError, "retry after failure: " + firstNonBlank(failureMessage, "unknown"))
|
||||||
|
.set(TaskScopeStateEntity::getStateJson, writeJson(retryContext, "serialize coze batch retry context failed"))
|
||||||
|
.set(TaskScopeStateEntity::getCompleted, 0)
|
||||||
|
.set(TaskScopeStateEntity::getUpdatedAt, now));
|
||||||
|
if (updated > 0) {
|
||||||
|
taskFileJobService.touchRunning(context.jobId());
|
||||||
|
touchJavaSideTaskActivity(state.getTaskId());
|
||||||
|
log.info("[similar-asin] coze retry submitted taskId={} stateId={} oldExecuteId={} newExecuteId={} chunk={} batch={}/{} retry={}/{} failure={}",
|
||||||
|
state.getTaskId(), state.getId(), state.getCozeExecuteId(), submit.executeId(),
|
||||||
|
context.chunkIndex(), context.batchIndex(), context.batchTotal(),
|
||||||
|
retryContext.submitRetryCount(), MAX_COZE_SUBMIT_RETRY_COUNT, failureMessage);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[similar-asin] coze retry submit failed taskId={} stateId={} executeId={} err={}",
|
||||||
|
state.getTaskId(), state.getId(), state.getCozeExecuteId(), firstNonBlank(ex.getMessage(), "Coze retry failed"));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimilarAsinCozeClient.CozeSubmitResponse submitCozeWorkflowThrottled(
|
||||||
|
List<SimilarAsinResultRowDto> rows,
|
||||||
|
String prompt,
|
||||||
|
String apiKey) throws Exception {
|
||||||
|
DistributedJobLockService.LockHandle lockHandle = acquireCozeSubmitLock();
|
||||||
|
if (lockHandle == null) {
|
||||||
|
throw new IllegalStateException("Coze submit throttle lock timeout");
|
||||||
|
}
|
||||||
|
try (lockHandle) {
|
||||||
|
SimilarAsinCozeClient.CozeSubmitResponse response = cozeClient.submitWorkflow(rows, prompt, apiKey);
|
||||||
|
sleepQuietly(COZE_SUBMIT_MIN_INTERVAL_MILLIS);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DistributedJobLockService.LockHandle acquireCozeSubmitLock() {
|
||||||
|
long deadline = System.currentTimeMillis() + COZE_SUBMIT_LOCK_WAIT_MILLIS;
|
||||||
|
while (System.currentTimeMillis() <= deadline) {
|
||||||
|
DistributedJobLockService.LockHandle lockHandle =
|
||||||
|
distributedJobLockService.tryLock("similar-asin:coze-submit", COZE_SUBMIT_LOCK_TTL);
|
||||||
|
if (lockHandle != null) {
|
||||||
|
return lockHandle;
|
||||||
|
}
|
||||||
|
sleepQuietly(COZE_SUBMIT_LOCK_RETRY_DELAY_MILLIS);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sleepQuietly(long millis) {
|
||||||
|
if (millis <= 0L) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(millis);
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean splitRetryFailedCozeBatchState(TaskScopeStateEntity state,
|
||||||
|
CozeBatchContext context,
|
||||||
|
List<SimilarAsinResultRowDto> batchRows,
|
||||||
|
String failureMessage) {
|
||||||
|
if (state == null || context == null || batchRows == null || batchRows.size() <= 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!shouldSplitCozeBatchForRetry(failureMessage) || cozeSubmitRetryCount(context) >= MAX_COZE_SUBMIT_RETRY_COUNT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId());
|
||||||
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int middle = Math.max(1, batchRows.size() / 2);
|
||||||
|
List<List<SimilarAsinResultRowDto>> partitions = List.<List<SimilarAsinResultRowDto>>of(
|
||||||
|
new ArrayList<>(batchRows.subList(0, middle)),
|
||||||
|
new ArrayList<>(batchRows.subList(middle, batchRows.size()))
|
||||||
|
).stream().filter(rows -> rows != null && !rows.isEmpty()).toList();
|
||||||
|
int retryCount = cozeSubmitRetryCount(context) + 1;
|
||||||
|
boolean submittedAny = false;
|
||||||
|
try {
|
||||||
|
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
|
||||||
|
int partIndex = 1;
|
||||||
|
for (List<SimilarAsinResultRowDto> partRows : partitions) {
|
||||||
|
SimilarAsinCozeClient.CozeSubmitResponse submit =
|
||||||
|
submitCozeWorkflowThrottled(partRows, readAiPrompt(task), readApiKey(task));
|
||||||
|
if (submit.immediateData() != null && !submit.immediateData().isBlank()) {
|
||||||
|
List<SimilarAsinResultRowDto> cozeRows =
|
||||||
|
cozeClient.mergeRowsFromDataText(partRows, submit.immediateData());
|
||||||
|
mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
|
||||||
|
submittedAny = true;
|
||||||
|
} else if (submit.executeId() != null && !submit.executeId().isBlank()) {
|
||||||
|
saveSplitRetryCozeBatchState(state, context, partRows, partIndex, partitions.size(), retryCount, submit.executeId());
|
||||||
|
submittedAny = true;
|
||||||
|
}
|
||||||
|
partIndex++;
|
||||||
|
}
|
||||||
|
if (submittedAny) {
|
||||||
|
markCozeStateTerminal(state, COZE_STATUS_DONE, "split retry submitted after failure: " + firstNonBlank(failureMessage, "unknown"));
|
||||||
|
taskFileJobService.touchRunning(context.jobId());
|
||||||
|
touchJavaSideTaskActivity(state.getTaskId());
|
||||||
|
maybeFinalizeCozeJob(state.getTaskId(), context);
|
||||||
|
log.info("[similar-asin] coze split retry submitted taskId={} stateId={} chunk={} batch={}/{} parts={} retry={}/{} failure={}",
|
||||||
|
state.getTaskId(), state.getId(), context.chunkIndex(), context.batchIndex(), context.batchTotal(),
|
||||||
|
partitions.size(), retryCount, MAX_COZE_SUBMIT_RETRY_COUNT, failureMessage);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[similar-asin] coze split retry submit failed taskId={} stateId={} executeId={} err={}",
|
||||||
|
state.getTaskId(), state.getId(), state.getCozeExecuteId(), firstNonBlank(ex.getMessage(), "Coze split retry failed"));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveSplitRetryCozeBatchState(TaskScopeStateEntity parent,
|
||||||
|
CozeBatchContext parentContext,
|
||||||
|
List<SimilarAsinResultRowDto> batchRows,
|
||||||
|
int partIndex,
|
||||||
|
int partTotal,
|
||||||
|
int retryCount,
|
||||||
|
String executeId) {
|
||||||
|
String scopeKey = parent.getScopeKey() + ":split:" + retryCount + ":" + partIndex;
|
||||||
|
String scopeHash = DigestUtil.sha256Hex(scopeKey);
|
||||||
|
CozeBatchContext context = new CozeBatchContext(
|
||||||
|
parentContext.jobId(),
|
||||||
|
parentContext.resultId(),
|
||||||
|
parentContext.chunkScopeHash(),
|
||||||
|
parentContext.chunkIndex(),
|
||||||
|
partIndex,
|
||||||
|
partTotal,
|
||||||
|
parentContext.ownerInstanceId(),
|
||||||
|
retryCount
|
||||||
|
);
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
String batchPayload = writeJson(batchRows, "serialize split coze batch payload failed");
|
||||||
|
String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast(
|
||||||
|
MODULE_TYPE, parent.getTaskId(), scopeHash, batchPayload, true);
|
||||||
|
TaskScopeStateEntity state = new TaskScopeStateEntity();
|
||||||
|
state.setTaskId(parent.getTaskId());
|
||||||
|
state.setModuleType(MODULE_TYPE);
|
||||||
|
state.setScopeKey(scopeKey);
|
||||||
|
state.setScopeHash(scopeHash);
|
||||||
|
state.setParsedPayloadJson(storedBatchPayload);
|
||||||
|
state.setStateJson(writeJson(context, "serialize split coze batch context failed"));
|
||||||
|
state.setCozeExecuteId(executeId);
|
||||||
|
state.setCozeStatus(COZE_STATUS_SUBMITTED);
|
||||||
|
state.setCozeSubmittedAt(now);
|
||||||
|
state.setCozeAttemptCount(0);
|
||||||
|
state.setChunkTotal(partTotal);
|
||||||
|
state.setReceivedChunkCount(partIndex);
|
||||||
|
state.setCompleted(0);
|
||||||
|
state.setCreatedAt(now);
|
||||||
|
state.setUpdatedAt(now);
|
||||||
|
try {
|
||||||
|
taskScopeStateMapper.insert(state);
|
||||||
|
} catch (DuplicateKeyException ex) {
|
||||||
|
transientPayloadStorageService.deletePayloadIfPresent(storedBatchPayload);
|
||||||
|
log.info("[similar-asin] duplicate split coze batch state ignored taskId={} scope={}",
|
||||||
|
parent.getTaskId(), scopeKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldSplitCozeBatchForRetry(String failureMessage) {
|
||||||
|
String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT);
|
||||||
|
return normalized.contains("timeout")
|
||||||
|
|| normalized.contains("timed out")
|
||||||
|
|| normalized.contains("out of limit")
|
||||||
|
|| normalized.contains("execution limit")
|
||||||
|
|| normalized.contains("720712008")
|
||||||
|
|| normalized.contains("720701002")
|
||||||
|
|| normalized.contains("\u5de5\u4f5c\u6d41\u8282\u70b9\u6267\u884c\u8d85\u9650")
|
||||||
|
|| normalized.contains("\u8c03\u7528\u8d85\u65f6");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isRetryableCozeFailure(String failureMessage) {
|
||||||
|
String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT);
|
||||||
|
return normalized.contains("rate limit")
|
||||||
|
|| normalized.contains("too many")
|
||||||
|
|| normalized.contains("retry later")
|
||||||
|
|| normalized.contains("timeout")
|
||||||
|
|| normalized.contains("timed out")
|
||||||
|
|| normalized.contains("out of limit")
|
||||||
|
|| normalized.contains("execution limit")
|
||||||
|
|| normalized.contains("702093018")
|
||||||
|
|| normalized.contains("720712008")
|
||||||
|
|| normalized.contains("720701002")
|
||||||
|
|| normalized.contains("plugin limit")
|
||||||
|
|| normalized.contains("\u9650\u6d41")
|
||||||
|
|| normalized.contains("\u7a0d\u540e\u91cd\u8bd5")
|
||||||
|
|| normalized.contains("\u5de5\u4f5c\u6d41\u8282\u70b9\u6267\u884c\u8d85\u9650")
|
||||||
|
|| normalized.contains("\u8c03\u7528\u8d85\u65f6");
|
||||||
|
}
|
||||||
|
|
||||||
private void updateCozeStateRunning(TaskScopeStateEntity state, String error) {
|
private void updateCozeStateRunning(TaskScopeStateEntity state, String error) {
|
||||||
int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
|
int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
|
||||||
.eq(TaskScopeStateEntity::getId, state.getId())
|
.eq(TaskScopeStateEntity::getId, state.getId())
|
||||||
@@ -1538,22 +1907,7 @@ public class SimilarAsinTaskService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try (lockHandle) {
|
try (lockHandle) {
|
||||||
if (countPendingCozeStates(taskId) > 0) {
|
maybeFinalizeCozeJobLocked(taskId, context);
|
||||||
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("[similar-asin] coze async job finalized taskId={} jobId={} resultId={} resultFileUrl={}",
|
|
||||||
taskId, job.getId(), result.getId(), result.getResultFileUrl());
|
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
TaskFileJobEntity job = taskFileJobService.findAssembleJob(taskId, MODULE_TYPE, context.resultId());
|
TaskFileJobEntity job = taskFileJobService.findAssembleJob(taskId, MODULE_TYPE, context.resultId());
|
||||||
if (job != null) {
|
if (job != null) {
|
||||||
@@ -1564,6 +1918,37 @@ public class SimilarAsinTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void maybeFinalizeCozeJobLocked(Long taskId, CozeBatchContext context) {
|
||||||
|
if (taskId == null || context == null || countPendingCozeStates(taskId) > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DistributedJobLockService.LockHandle lockHandle =
|
||||||
|
distributedJobLockService.tryLock("similar-asin:coze-finalize:" + taskId, Duration.ofMinutes(5));
|
||||||
|
if (lockHandle == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (lockHandle) {
|
||||||
|
if (countPendingCozeStates(taskId) > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TaskFileJobEntity job = taskFileJobService.findAssembleJob(taskId, MODULE_TYPE, context.resultId());
|
||||||
|
if (job == null || "SUCCESS".equals(job.getStatus())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||||
|
if (task != null && STATUS_RUNNING.equals(task.getStatus()) && !isResultSubmissionComplete(taskId)) {
|
||||||
|
taskFileJobService.touchRunning(job.getId());
|
||||||
|
touchJavaSideTaskActivity(taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean requeued = taskFileJobService.requeue(job.getId(), "Coze results ready, assembling xlsx");
|
||||||
|
if (requeued) {
|
||||||
|
log.info("[similar-asin] coze async results ready, result file job requeued taskId={} jobId={} resultId={}",
|
||||||
|
taskId, job.getId(), context.resultId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private TaskDistributedLockService.LockHandle acquireTaskLock(Long taskId, long waitMillis) {
|
private TaskDistributedLockService.LockHandle acquireTaskLock(Long taskId, long waitMillis) {
|
||||||
TaskDistributedLockService.LockHandle lockHandle = taskDistributedLockService.acquire(MODULE_TYPE, taskId, TASK_LOCK_TTL, waitMillis);
|
TaskDistributedLockService.LockHandle lockHandle = taskDistributedLockService.acquire(MODULE_TYPE, taskId, TASK_LOCK_TTL, waitMillis);
|
||||||
if (lockHandle == null) {
|
if (lockHandle == null) {
|
||||||
@@ -1593,8 +1978,13 @@ public class SimilarAsinTaskService {
|
|||||||
assembleResultWorkbook(task, result);
|
assembleResultWorkbook(task, result);
|
||||||
saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits - 1, "Uploading result file");
|
saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits - 1, "Uploading result file");
|
||||||
fileResultMapper.updateById(result);
|
fileResultMapper.updateById(result);
|
||||||
task.setStatus(STATUS_SUCCESS);
|
boolean taskAlreadyFailed = STATUS_FAILED.equals(task.getStatus())
|
||||||
task.setErrorMessage(null);
|
|| (task.getErrorMessage() != null && !task.getErrorMessage().isBlank());
|
||||||
|
String existingError = task.getErrorMessage();
|
||||||
|
task.setStatus(taskAlreadyFailed ? STATUS_FAILED : STATUS_SUCCESS);
|
||||||
|
task.setSuccessFileCount(taskAlreadyFailed ? 0 : 1);
|
||||||
|
task.setFailedFileCount(taskAlreadyFailed ? 1 : 0);
|
||||||
|
task.setErrorMessage(taskAlreadyFailed ? existingError : null);
|
||||||
task.setUpdatedAt(LocalDateTime.now());
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
task.setFinishedAt(LocalDateTime.now());
|
task.setFinishedAt(LocalDateTime.now());
|
||||||
fileTaskMapper.updateById(task);
|
fileTaskMapper.updateById(task);
|
||||||
@@ -1655,6 +2045,23 @@ public class SimilarAsinTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int cozeSubmitRetryCount(CozeBatchContext context) {
|
||||||
|
return context == null || context.submitRetryCount() == null ? 0 : context.submitRetryCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CozeBatchContext withCozeSubmitRetryCount(CozeBatchContext context, int submitRetryCount) {
|
||||||
|
return new CozeBatchContext(
|
||||||
|
context.jobId(),
|
||||||
|
context.resultId(),
|
||||||
|
context.chunkScopeHash(),
|
||||||
|
context.chunkIndex(),
|
||||||
|
context.batchIndex(),
|
||||||
|
context.batchTotal(),
|
||||||
|
context.ownerInstanceId(),
|
||||||
|
submitRetryCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isCozeStateTimedOut(TaskScopeStateEntity state) {
|
private boolean isCozeStateTimedOut(TaskScopeStateEntity state) {
|
||||||
if (state == null || state.getCozeSubmittedAt() == null) {
|
if (state == null || state.getCozeSubmittedAt() == null) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1689,6 +2096,19 @@ public class SimilarAsinTaskService {
|
|||||||
return count == null ? 0 : count.intValue();
|
return count == null ? 0 : count.intValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isResultSubmissionComplete(Long taskId) {
|
||||||
|
if (taskId == null || taskId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
||||||
|
.eq(TaskScopeStateEntity::getTaskId, taskId)
|
||||||
|
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
|
||||||
|
.isNull(TaskScopeStateEntity::getCozeStatus)
|
||||||
|
.isNotNull(TaskScopeStateEntity::getLastChunkAt)
|
||||||
|
.eq(TaskScopeStateEntity::getCompleted, 1));
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isJavaSideProcessing(Long taskId) {
|
private boolean isJavaSideProcessing(Long taskId) {
|
||||||
return countPendingCozeStates(taskId) > 0
|
return countPendingCozeStates(taskId) > 0
|
||||||
|| taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE) > 0;
|
|| taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE) > 0;
|
||||||
@@ -1878,6 +2298,16 @@ public class SimilarAsinTaskService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean hasPersistedResultRows(Long taskId) {
|
||||||
|
if (taskId == null || taskId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Long count = taskChunkMapper.selectCount(new LambdaQueryWrapper<TaskChunkEntity>()
|
||||||
|
.eq(TaskChunkEntity::getTaskId, taskId)
|
||||||
|
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE));
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, SimilarAsinResultRowDto> loadPersistedResultRowsWithRetry(Long taskId, int expectedParsedRows) {
|
private Map<String, SimilarAsinResultRowDto> loadPersistedResultRowsWithRetry(Long taskId, int expectedParsedRows) {
|
||||||
Map<String, SimilarAsinResultRowDto> result = loadPersistedResultRows(taskId);
|
Map<String, SimilarAsinResultRowDto> result = loadPersistedResultRows(taskId);
|
||||||
if (expectedParsedRows <= 0 || !result.isEmpty()) {
|
if (expectedParsedRows <= 0 || !result.isEmpty()) {
|
||||||
@@ -2620,6 +3050,7 @@ public class SimilarAsinTaskService {
|
|||||||
private record SubmitContext(FileTaskEntity task,
|
private record SubmitContext(FileTaskEntity task,
|
||||||
String scopeKey,
|
String scopeKey,
|
||||||
String scopeHash,
|
String scopeHash,
|
||||||
|
Integer chunkIndex,
|
||||||
boolean forceFlush,
|
boolean forceFlush,
|
||||||
String error) {
|
String error) {
|
||||||
}
|
}
|
||||||
@@ -2630,7 +3061,8 @@ public class SimilarAsinTaskService {
|
|||||||
Integer chunkIndex,
|
Integer chunkIndex,
|
||||||
Integer batchIndex,
|
Integer batchIndex,
|
||||||
Integer batchTotal,
|
Integer batchTotal,
|
||||||
String ownerInstanceId) {
|
String ownerInstanceId,
|
||||||
|
Integer submitRetryCount) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private record ParsedWorkbook(int totalRows, int droppedRows, List<String> headers, List<SimilarAsinParsedRowVo> allRows) {
|
private record ParsedWorkbook(int totalRows, int droppedRows, List<String> headers, List<SimilarAsinParsedRowVo> allRows) {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ AIIMAGE_APPEARANCE_PATENT_COZE_BASE_URL=https://api.coze.cn
|
|||||||
AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH=/v1/workflow/run
|
AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH=/v1/workflow/run
|
||||||
AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID=7632683471312355338
|
AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID=7632683471312355338
|
||||||
AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN=
|
AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN=
|
||||||
AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE=10
|
AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE=50
|
||||||
AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS=60000
|
AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS=60000
|
||||||
AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES=20
|
AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES=20
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ AIIMAGE_SIMILAR_ASIN_COZE_BASE_URL=https://api.coze.cn
|
|||||||
AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH=/v1/workflow/run
|
AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH=/v1/workflow/run
|
||||||
AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID=7632683471312355338
|
AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID=7632683471312355338
|
||||||
AIIMAGE_SIMILAR_ASIN_COZE_TOKEN=
|
AIIMAGE_SIMILAR_ASIN_COZE_TOKEN=
|
||||||
AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE=10
|
AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE=50
|
||||||
AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS=60000
|
AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS=60000
|
||||||
AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES=20
|
AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES=20
|
||||||
|
|
||||||
|
|||||||
@@ -147,10 +147,10 @@ aiimage:
|
|||||||
coze-workflow-path: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH:/v1/workflow/run}
|
coze-workflow-path: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH:/v1/workflow/run}
|
||||||
coze-workflow-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7632683471312355338}
|
coze-workflow-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7632683471312355338}
|
||||||
coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:Bearer sat_gsInckdsN0qqhnZSK7K4Zevw6neJCHmYHyIbm85oGgJIGEmSdkH5OFctSKLsAHvT}
|
coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:Bearer sat_gsInckdsN0qqhnZSK7K4Zevw6neJCHmYHyIbm85oGgJIGEmSdkH5OFctSKLsAHvT}
|
||||||
coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:10}
|
coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:50}
|
||||||
coze-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_CONNECT_TIMEOUT_MILLIS:10000}
|
coze-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_CONNECT_TIMEOUT_MILLIS:10000}
|
||||||
coze-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS:60000}
|
coze-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS:60000}
|
||||||
coze-poll-interval-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_INTERVAL_MILLIS:5000}
|
coze-poll-interval-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_INTERVAL_MILLIS:30000}
|
||||||
coze-poll-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_TIMEOUT_MILLIS:600000}
|
coze-poll-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_TIMEOUT_MILLIS:600000}
|
||||||
stale-timeout-minutes: ${AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES:20}
|
stale-timeout-minutes: ${AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES:20}
|
||||||
stale-finalize-cron: ${AIIMAGE_APPEARANCE_PATENT_STALE_FINALIZE_CRON:0 */2 * * * *}
|
stale-finalize-cron: ${AIIMAGE_APPEARANCE_PATENT_STALE_FINALIZE_CRON:0 */2 * * * *}
|
||||||
@@ -159,10 +159,10 @@ aiimage:
|
|||||||
coze-workflow-path: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH:/v1/workflow/run}
|
coze-workflow-path: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH:/v1/workflow/run}
|
||||||
coze-workflow-id: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID:7632683471312355338}
|
coze-workflow-id: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID:7632683471312355338}
|
||||||
coze-token: ${AIIMAGE_SIMILAR_ASIN_COZE_TOKEN:Bearer sat_gsInckdsN0qqhnZSK7K4Zevw6neJCHmYHyIbm85oGgJIGEmSdkH5OFctSKLsAHvT}
|
coze-token: ${AIIMAGE_SIMILAR_ASIN_COZE_TOKEN:Bearer sat_gsInckdsN0qqhnZSK7K4Zevw6neJCHmYHyIbm85oGgJIGEmSdkH5OFctSKLsAHvT}
|
||||||
coze-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE:10}
|
coze-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE:50}
|
||||||
coze-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_CONNECT_TIMEOUT_MILLIS:10000}
|
coze-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_CONNECT_TIMEOUT_MILLIS:10000}
|
||||||
coze-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS:60000}
|
coze-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS:60000}
|
||||||
coze-poll-interval-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_INTERVAL_MILLIS:5000}
|
coze-poll-interval-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_INTERVAL_MILLIS:30000}
|
||||||
coze-poll-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_TIMEOUT_MILLIS:600000}
|
coze-poll-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_TIMEOUT_MILLIS:600000}
|
||||||
stale-timeout-minutes: ${AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES:20}
|
stale-timeout-minutes: ${AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES:20}
|
||||||
stale-finalize-cron: ${AIIMAGE_SIMILAR_ASIN_STALE_FINALIZE_CRON:0 */2 * * * *}
|
stale-finalize-cron: ${AIIMAGE_SIMILAR_ASIN_STALE_FINALIZE_CRON:0 */2 * * * *}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
SET @db_name = DATABASE();
|
||||||
|
|
||||||
|
SET @idx_exists := (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = @db_name
|
||||||
|
AND TABLE_NAME = 'biz_similar_asin_filter_condition'
|
||||||
|
AND INDEX_NAME = 'uk_similar_asin_filter_condition_user_text'
|
||||||
|
);
|
||||||
|
SET @sql := IF(@idx_exists > 0,
|
||||||
|
'ALTER TABLE biz_similar_asin_filter_condition DROP INDEX uk_similar_asin_filter_condition_user_text',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @condition_text_type := (
|
||||||
|
SELECT DATA_TYPE
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @db_name
|
||||||
|
AND TABLE_NAME = 'biz_similar_asin_filter_condition'
|
||||||
|
AND COLUMN_NAME = 'condition_text'
|
||||||
|
);
|
||||||
|
SET @sql := IF(@condition_text_type <> 'mediumtext',
|
||||||
|
'ALTER TABLE biz_similar_asin_filter_condition MODIFY condition_text MEDIUMTEXT NOT NULL',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @condition_hash_exists := (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @db_name
|
||||||
|
AND TABLE_NAME = 'biz_similar_asin_filter_condition'
|
||||||
|
AND COLUMN_NAME = 'condition_hash'
|
||||||
|
);
|
||||||
|
SET @sql := IF(@condition_hash_exists = 0,
|
||||||
|
'ALTER TABLE biz_similar_asin_filter_condition ADD COLUMN condition_hash CHAR(64) NULL AFTER condition_text',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
UPDATE biz_similar_asin_filter_condition
|
||||||
|
SET condition_hash = SHA2(condition_text, 256)
|
||||||
|
WHERE condition_hash IS NULL OR condition_hash = '';
|
||||||
|
|
||||||
|
ALTER TABLE biz_similar_asin_filter_condition
|
||||||
|
MODIFY condition_hash CHAR(64) NOT NULL;
|
||||||
|
|
||||||
|
SET @idx_exists := (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = @db_name
|
||||||
|
AND TABLE_NAME = 'biz_similar_asin_filter_condition'
|
||||||
|
AND INDEX_NAME = 'uk_similar_asin_filter_condition_user_hash'
|
||||||
|
);
|
||||||
|
SET @sql := IF(@idx_exists = 0,
|
||||||
|
'ALTER TABLE biz_similar_asin_filter_condition ADD UNIQUE KEY uk_similar_asin_filter_condition_user_hash (user_id, condition_hash)',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `sort_order`)
|
||||||
|
SELECT '商品类目', 'admin_product_categories', 'admin', 'product-categories', 66
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM `columns` WHERE `column_key` = 'admin_product_categories'
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE `columns`
|
||||||
|
SET `name` = '商品类目',
|
||||||
|
`menu_type` = 'admin',
|
||||||
|
`route_path` = 'product-categories',
|
||||||
|
`sort_order` = 66
|
||||||
|
WHERE `column_key` = 'admin_product_categories';
|
||||||
91
backend-java/src/main/resources/db/V50__product_category.sql
Normal file
91
backend-java/src/main/resources/db/V50__product_category.sql
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS biz_product_category (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
|
||||||
|
parent_id BIGINT NULL COMMENT '父级类目ID',
|
||||||
|
name VARCHAR(128) NOT NULL COMMENT '类目名称',
|
||||||
|
category_key VARCHAR(128) NOT NULL COMMENT '类目标识',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
|
||||||
|
description VARCHAR(512) NOT NULL DEFAULT '' COMMENT '备注',
|
||||||
|
is_builtin TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否内置',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
UNIQUE KEY uk_product_category_key (category_key),
|
||||||
|
KEY idx_product_category_parent_sort (parent_id, sort_order, id)
|
||||||
|
) COMMENT='商品类目树';
|
||||||
|
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT NULL, '护肤品', 'skincare', 10, 1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'skincare');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT NULL, '彩妆', 'makeup', 20, 1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'makeup');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT NULL, '洗护', 'haircare', 30, 1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'haircare');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT NULL, '功效类', 'functional', 40, 1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'functional');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT NULL, '香氛', 'fragrance', 50, 1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'fragrance');
|
||||||
|
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '面霜', 'skincare_cream', 10, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'skincare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'skincare_cream');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '精华', 'skincare_essence', 20, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'skincare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'skincare_essence');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '面膜', 'skincare_mask', 30, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'skincare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'skincare_mask');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '爽肤水', 'skincare_toner', 40, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'skincare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'skincare_toner');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '身体乳', 'skincare_body_lotion', 50, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'skincare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'skincare_body_lotion');
|
||||||
|
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '口红', 'makeup_lipstick', 10, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'makeup' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'makeup_lipstick');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '粉底', 'makeup_foundation', 20, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'makeup' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'makeup_foundation');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '眼影', 'makeup_eyeshadow', 30, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'makeup' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'makeup_eyeshadow');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '睫毛', 'makeup_mascara', 40, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'makeup' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'makeup_mascara');
|
||||||
|
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '洗发水', 'haircare_shampoo', 10, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'haircare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'haircare_shampoo');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '护发素', 'haircare_conditioner', 20, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'haircare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'haircare_conditioner');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '染发剂', 'haircare_hair_dye', 30, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'haircare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'haircare_hair_dye');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '烫发膏', 'haircare_perm_cream', 40, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'haircare' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'haircare_perm_cream');
|
||||||
|
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '防晒', 'functional_sunscreen', 10, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'functional' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'functional_sunscreen');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '祛斑', 'functional_spot_removal', 20, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'functional' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'functional_spot_removal');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '美白', 'functional_whitening', 30, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'functional' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'functional_whitening');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, is_builtin)
|
||||||
|
SELECT p.id, '祛痘产品', 'functional_acne_care', 40, 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'functional' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'functional_acne_care');
|
||||||
|
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, description, is_builtin)
|
||||||
|
SELECT p.id, '香水', 'fragrance_perfume', 10, '', 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'fragrance' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'fragrance_perfume');
|
||||||
|
INSERT INTO biz_product_category (parent_id, name, category_key, sort_order, description, is_builtin)
|
||||||
|
SELECT p.id, '香薰精油(高酒精属于危险品)', 'fragrance_essential_oil_hazardous', 20, '高酒精属于危险品', 1 FROM biz_product_category p
|
||||||
|
WHERE p.category_key = 'fragrance' AND NOT EXISTS (SELECT 1 FROM biz_product_category WHERE category_key = 'fragrance_essential_oil_hazardous');
|
||||||
@@ -48,6 +48,8 @@ admin_api = Blueprint('admin_api', __name__, url_prefix='/api/admin')
|
|||||||
_backend_java_session_local = threading.local()
|
_backend_java_session_local = threading.local()
|
||||||
_column_sort_schema_checked = False
|
_column_sort_schema_checked = False
|
||||||
_column_sort_schema_lock = threading.Lock()
|
_column_sort_schema_lock = threading.Lock()
|
||||||
|
_product_category_schema_checked = False
|
||||||
|
_product_category_schema_lock = threading.Lock()
|
||||||
|
|
||||||
ADMIN_MENU_ACCESS_CONFIG = {
|
ADMIN_MENU_ACCESS_CONFIG = {
|
||||||
'dedupe-total-data': {
|
'dedupe-total-data': {
|
||||||
@@ -70,6 +72,11 @@ ADMIN_MENU_ACCESS_CONFIG = {
|
|||||||
'route_path': 'query-asin',
|
'route_path': 'query-asin',
|
||||||
'error': '无权访问查询 ASIN 模块',
|
'error': '无权访问查询 ASIN 模块',
|
||||||
},
|
},
|
||||||
|
'product-categories': {
|
||||||
|
'column_key': 'admin_product_categories',
|
||||||
|
'route_path': 'product-categories',
|
||||||
|
'error': '无权访问商品类目模块',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ADMIN_MENU_ACCESS_CONFIG.update({
|
ADMIN_MENU_ACCESS_CONFIG.update({
|
||||||
@@ -300,6 +307,92 @@ def _get_next_local_column_sort_order():
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
PRODUCT_CATEGORY_DEFAULT_TREE = [
|
||||||
|
{
|
||||||
|
'name': '护肤品',
|
||||||
|
'key': 'skincare',
|
||||||
|
'children': [
|
||||||
|
('面霜', 'skincare_cream'),
|
||||||
|
('精华', 'skincare_essence'),
|
||||||
|
('面膜', 'skincare_mask'),
|
||||||
|
('爽肤水', 'skincare_toner'),
|
||||||
|
('身体乳', 'skincare_body_lotion'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '彩妆',
|
||||||
|
'key': 'makeup',
|
||||||
|
'children': [
|
||||||
|
('口红', 'makeup_lipstick'),
|
||||||
|
('粉底', 'makeup_foundation'),
|
||||||
|
('眼影', 'makeup_eyeshadow'),
|
||||||
|
('睫毛', 'makeup_mascara'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '洗护',
|
||||||
|
'key': 'haircare',
|
||||||
|
'children': [
|
||||||
|
('洗发水', 'haircare_shampoo'),
|
||||||
|
('护发素', 'haircare_conditioner'),
|
||||||
|
('染发剂', 'haircare_hair_dye'),
|
||||||
|
('烫发膏', 'haircare_perm_cream'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '功效类',
|
||||||
|
'key': 'functional',
|
||||||
|
'children': [
|
||||||
|
('防晒', 'functional_sunscreen'),
|
||||||
|
('祛斑', 'functional_spot_removal'),
|
||||||
|
('美白', 'functional_whitening'),
|
||||||
|
('祛痘产品', 'functional_acne_care'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '香氛',
|
||||||
|
'key': 'fragrance',
|
||||||
|
'children': [
|
||||||
|
('香水', 'fragrance_perfume'),
|
||||||
|
('香薰精油(高酒精属于危险品)', 'fragrance_essential_oil_hazardous'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_local_admin_menu_item(name, column_key, route_path, sort_order):
|
||||||
|
_ensure_column_sort_schema()
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order)
|
||||||
|
VALUES (%s, %s, 'admin', %s, %s)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
menu_type = 'admin',
|
||||||
|
route_path = VALUES(route_path),
|
||||||
|
sort_order = VALUES(sort_order)
|
||||||
|
""",
|
||||||
|
(name, column_key, route_path, int(sort_order)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_product_category_schema():
|
||||||
|
global _product_category_schema_checked
|
||||||
|
if _product_category_schema_checked:
|
||||||
|
return
|
||||||
|
with _product_category_schema_lock:
|
||||||
|
if _product_category_schema_checked:
|
||||||
|
return
|
||||||
|
_ensure_local_admin_menu_item('商品类目', 'admin_product_categories', 'product-categories', 66)
|
||||||
|
_product_category_schema_checked = True
|
||||||
|
|
||||||
|
|
||||||
def _swap_local_column_sort_order(column_id, target_id):
|
def _swap_local_column_sort_order(column_id, target_id):
|
||||||
_ensure_column_sort_schema()
|
_ensure_column_sort_schema()
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
@@ -440,6 +533,20 @@ def _ensure_backend_menu_access(*menu_names):
|
|||||||
return role, current_row, (jsonify({'success': False, 'error': error_message}), 403)
|
return role, current_row, (jsonify({'success': False, 'error': error_message}), 403)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_product_category_access():
|
||||||
|
role, current_row, items, denied = _load_current_backend_menu_items()
|
||||||
|
if denied:
|
||||||
|
return role, current_row, denied
|
||||||
|
if role == 'super_admin':
|
||||||
|
return role, current_row, None
|
||||||
|
for item in items or []:
|
||||||
|
if (item.get('column_key') or '').strip() == 'admin_product_categories':
|
||||||
|
return role, current_row, None
|
||||||
|
if (item.get('route_path') or '').strip() == 'product-categories':
|
||||||
|
return role, current_row, None
|
||||||
|
return role, current_row, (jsonify({'success': False, 'error': '无权访问商品类目模块'}), 403)
|
||||||
|
|
||||||
|
|
||||||
def _get_column_ids_by_route_paths(route_paths, menu_type='admin'):
|
def _get_column_ids_by_route_paths(route_paths, menu_type='admin'):
|
||||||
normalized_paths = [(path or '').strip() for path in (route_paths or []) if (path or '').strip()]
|
normalized_paths = [(path or '').strip() for path in (route_paths or []) if (path or '').strip()]
|
||||||
if not normalized_paths:
|
if not normalized_paths:
|
||||||
@@ -496,6 +603,7 @@ def get_admin_current_user():
|
|||||||
@admin_api.route('/current-user/menus')
|
@admin_api.route('/current-user/menus')
|
||||||
@login_required
|
@login_required
|
||||||
def get_admin_current_user_menus():
|
def get_admin_current_user_menus():
|
||||||
|
_ensure_product_category_schema()
|
||||||
_, _, items, denied = _load_current_backend_menu_items()
|
_, _, items, denied = _load_current_backend_menu_items()
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
@@ -874,6 +982,7 @@ def list_columns():
|
|||||||
_, _, denied = _ensure_admin_menu_access('columns', 'users')
|
_, _, denied = _ensure_admin_menu_access('columns', 'users')
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
|
_ensure_product_category_schema()
|
||||||
menu_type = (request.args.get('menu_type') or '').strip()
|
menu_type = (request.args.get('menu_type') or '').strip()
|
||||||
local_rows = _load_local_columns(menu_type or None)
|
local_rows = _load_local_columns(menu_type or None)
|
||||||
items = [
|
items = [
|
||||||
@@ -1072,6 +1181,117 @@ def _set_user_column_permissions(cur, user_id, column_ids):
|
|||||||
|
|
||||||
# ---------- 版本管理(web_config) ----------
|
# ---------- 版本管理(web_config) ----------
|
||||||
|
|
||||||
|
# ---------- 商品类目 ----------
|
||||||
|
|
||||||
|
|
||||||
|
def _format_product_category_item(item, include_children=True):
|
||||||
|
formatted = {
|
||||||
|
'id': item.get('id'),
|
||||||
|
'parent_id': item.get('parentId'),
|
||||||
|
'name': item.get('name') or '',
|
||||||
|
'category_key': item.get('categoryKey') or '',
|
||||||
|
'sort_order': item.get('sortOrder') if item.get('sortOrder') is not None else 0,
|
||||||
|
'description': item.get('description') or '',
|
||||||
|
'is_builtin': bool(item.get('isBuiltin')),
|
||||||
|
'child_count': item.get('childCount') if item.get('childCount') is not None else 0,
|
||||||
|
'level': item.get('level') if item.get('level') is not None else 0,
|
||||||
|
'path': item.get('path') or '',
|
||||||
|
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||||
|
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
|
||||||
|
}
|
||||||
|
if include_children:
|
||||||
|
formatted['children'] = [_format_product_category_item(child) for child in (item.get('children') or [])]
|
||||||
|
return formatted
|
||||||
|
|
||||||
|
|
||||||
|
def _build_product_category_payload_for_java(data):
|
||||||
|
sort_order = data.get('sort_order') if 'sort_order' in data else data.get('sortOrder')
|
||||||
|
parent_id = data.get('parent_id') if 'parent_id' in data else data.get('parentId')
|
||||||
|
if parent_id == '':
|
||||||
|
parent_id = None
|
||||||
|
if sort_order == '':
|
||||||
|
sort_order = None
|
||||||
|
return {
|
||||||
|
'parentId': parent_id,
|
||||||
|
'name': (data.get('name') or '').strip(),
|
||||||
|
'sortOrder': sort_order,
|
||||||
|
'description': (data.get('description') or '').strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api.route('/product-categories')
|
||||||
|
@login_required
|
||||||
|
def list_product_categories():
|
||||||
|
_ensure_product_category_schema()
|
||||||
|
_, _, denied = _ensure_product_category_access()
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
result, error_response, status = _proxy_backend_java('GET', '/api/admin/product-categories')
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response, status
|
||||||
|
payload = result.get('data') or {}
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'tree': [_format_product_category_item(item) for item in (payload.get('tree') or [])],
|
||||||
|
'items': [_format_product_category_item(item, include_children=False) for item in (payload.get('items') or [])],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api.route('/product-category', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def create_product_category():
|
||||||
|
_ensure_product_category_schema()
|
||||||
|
_, _, denied = _ensure_product_category_access()
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
result, error_response, status = _proxy_backend_java(
|
||||||
|
'POST',
|
||||||
|
'/api/admin/product-category',
|
||||||
|
json_data=_build_product_category_payload_for_java(request.get_json() or {}),
|
||||||
|
)
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response, status
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'msg': result.get('message') or '创建成功',
|
||||||
|
'item': _format_product_category_item(result.get('data') or {}, include_children=False),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api.route('/product-category/<int:item_id>', methods=['PUT'])
|
||||||
|
@login_required
|
||||||
|
def update_product_category(item_id):
|
||||||
|
_ensure_product_category_schema()
|
||||||
|
_, _, denied = _ensure_product_category_access()
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
result, error_response, status = _proxy_backend_java(
|
||||||
|
'PUT',
|
||||||
|
f'/api/admin/product-category/{item_id}',
|
||||||
|
json_data=_build_product_category_payload_for_java(request.get_json() or {}),
|
||||||
|
)
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response, status
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'msg': result.get('message') or '保存成功',
|
||||||
|
'item': _format_product_category_item(result.get('data') or {}, include_children=False),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api.route('/product-category/<int:item_id>', methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
def delete_product_category(item_id):
|
||||||
|
_ensure_product_category_schema()
|
||||||
|
_, _, denied = _ensure_product_category_access()
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
result, error_response, status = _proxy_backend_java('DELETE', f'/api/admin/product-category/{item_id}')
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response, status
|
||||||
|
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
|
||||||
|
|
||||||
|
|
||||||
@admin_api.route('/versions')
|
@admin_api.route('/versions')
|
||||||
@admin_required
|
@admin_required
|
||||||
def list_versions():
|
def list_versions():
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ bucket_path = "nanri-image/"
|
|||||||
file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
|
file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
|
||||||
|
|
||||||
import os
|
import os
|
||||||
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
|
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/')
|
||||||
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/')
|
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/')
|
||||||
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://47.111.163.154:18080').rstrip('/')
|
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://47.111.163.154:18080').rstrip('/')
|
||||||
# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://121.196.149.225:18080').rstrip('/')
|
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://121.196.149.225:18080').rstrip('/')
|
||||||
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
|
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
|
||||||
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
|
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
|
||||||
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"
|
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"
|
||||||
|
|||||||
3523
backend/static/admin.js
Normal file
3523
backend/static/admin.js
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@
|
|||||||
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
|
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="loading-msg">Python 回传字段按 asin、国家、url、标题提交;后端接收分片后按 10 条一批调用 Coze,结果区展示真实 Coze 批次进度。</p>
|
<p class="loading-msg">Python 回传字段按 asin、国家、url、标题提交;后端接收分片后累计 50 条调用 Coze,当前任务区展示 Coze 回流和结果组装进度。</p>
|
||||||
|
|
||||||
<div v-if="visibleTaskSummary" class="parse-card">
|
<div v-if="visibleTaskSummary" class="parse-card">
|
||||||
<div>任务 ID:{{ visibleTaskSummary.taskId }}</div>
|
<div>任务 ID:{{ visibleTaskSummary.taskId }}</div>
|
||||||
@@ -75,6 +75,19 @@
|
|||||||
<span class="id">{{ item.sourceFilename || '外观专利检测' }}</span>
|
<span class="id">{{ item.sourceFilename || '外观专利检测' }}</span>
|
||||||
<div class="files">任务 ID:{{ item.taskId }}</div>
|
<div class="files">任务 ID:{{ item.taskId }}</div>
|
||||||
<div class="files">行数:{{ item.rowCount ?? '-' }}</div>
|
<div class="files">行数:{{ item.rowCount ?? '-' }}</div>
|
||||||
|
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
|
||||||
|
<div v-if="showFileProgress(item)" class="file-progress">
|
||||||
|
<div class="file-progress-meta">
|
||||||
|
<span>{{ displayFileProgressMessage(item, '处理中') }}</span>
|
||||||
|
<span>{{ fileProgressPercent(item) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-progress-track">
|
||||||
|
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="file-progress-count">
|
||||||
|
{{ item.fileProgressCurrent || 0 }}/{{ item.fileProgressTotal || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-right">
|
<div class="task-right">
|
||||||
<span class="status running">{{ statusText(item) }}</span>
|
<span class="status running">{{ statusText(item) }}</span>
|
||||||
@@ -100,7 +113,7 @@
|
|||||||
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
|
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
|
||||||
<div v-if="showFileProgress(item)" class="file-progress">
|
<div v-if="showFileProgress(item)" class="file-progress">
|
||||||
<div class="file-progress-meta">
|
<div class="file-progress-meta">
|
||||||
<span>{{ item.fileProgressMessage || '结果生成中' }}</span>
|
<span>{{ displayFileProgressMessage(item, '结果生成中') }}</span>
|
||||||
<span>{{ fileProgressPercent(item) }}%</span>
|
<span>{{ fileProgressPercent(item) }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-progress-track">
|
<div class="file-progress-track">
|
||||||
@@ -201,8 +214,14 @@ const pendingParseItem = computed<AppearancePatentHistoryItem | null>(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
const currentItems = computed(() => {
|
const currentItems = computed(() => {
|
||||||
const runningItems = historyItems.value.filter((i) => i.taskId != null && pollingTaskIds.value.includes(i.taskId))
|
const activeIds = new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value])
|
||||||
return pendingParseItem.value ? [pendingParseItem.value, ...runningItems] : runningItems
|
const activeItems = historyItems.value.filter((item) => {
|
||||||
|
const status = normalizeTaskStatus(item)
|
||||||
|
return item.taskId != null && (activeIds.has(item.taskId) || status === 'RUNNING' || status === 'PENDING')
|
||||||
|
})
|
||||||
|
if (!pendingParseItem.value) return activeItems
|
||||||
|
const exists = activeItems.some((item) => item.taskId === pendingParseItem.value?.taskId)
|
||||||
|
return exists ? activeItems : [pendingParseItem.value, ...activeItems]
|
||||||
})
|
})
|
||||||
const historyOnlyItems = computed(() =>
|
const historyOnlyItems = computed(() =>
|
||||||
historyItems.value.filter((i) => {
|
historyItems.value.filter((i) => {
|
||||||
@@ -594,6 +613,16 @@ function seedPendingFileTasksFromHistory() {
|
|||||||
if (pendingFileTaskIds.value.length) ensurePolling()
|
if (pendingFileTaskIds.value.length) ensurePolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seedRunningTasksFromHistory() {
|
||||||
|
const ids = historyItems.value
|
||||||
|
.filter((item) => item.taskId != null && normalizeTaskStatus(item) === 'RUNNING')
|
||||||
|
.map((item) => item.taskId as number)
|
||||||
|
if (!ids.length) return
|
||||||
|
pollingTaskIds.value = Array.from(new Set([...pollingTaskIds.value, ...ids]))
|
||||||
|
savePollingIds()
|
||||||
|
ensurePolling()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
dashboard.value = await getAppearancePatentDashboard()
|
dashboard.value = await getAppearancePatentDashboard()
|
||||||
}
|
}
|
||||||
@@ -653,6 +682,9 @@ function pendingResultHint(item: AppearancePatentHistoryItem) {
|
|||||||
if (isResultBuildFailed(item)) {
|
if (isResultBuildFailed(item)) {
|
||||||
return item.fileError || '结果文件生成失败,请稍后重试或检查后端日志'
|
return item.fileError || '结果文件生成失败,请稍后重试或检查后端日志'
|
||||||
}
|
}
|
||||||
|
if (showFileProgress(item)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
if (!isResultPreparing(item)) return ''
|
if (!isResultPreparing(item)) return ''
|
||||||
const fileStatus = (item.fileStatus || '').toUpperCase()
|
const fileStatus = (item.fileStatus || '').toUpperCase()
|
||||||
if (fileStatus === 'RUNNING') {
|
if (fileStatus === 'RUNNING') {
|
||||||
@@ -674,8 +706,37 @@ function fileProgressPercent(item: AppearancePatentHistoryItem) {
|
|||||||
return Math.max(0, Math.min(100, Math.round(percent)))
|
return Math.max(0, Math.min(100, Math.round(percent)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function displayFileProgressMessage(item: AppearancePatentHistoryItem, fallback: string) {
|
||||||
|
const message = (item.fileProgressMessage || '').trim()
|
||||||
|
if (!message) return fallback
|
||||||
|
return translateFileProgressMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateFileProgressMessage(message: string) {
|
||||||
|
const submittedMatch = message.match(/^Coze submitted (\d+) batches, completed (\d+), waiting for results$/)
|
||||||
|
if (submittedMatch) {
|
||||||
|
return `已提交 Coze ${submittedMatch[1]} 批,已完成 ${submittedMatch[2]} 批,等待结果回流`
|
||||||
|
}
|
||||||
|
const completedMatch = message.match(/^Coze completed (\d+) batches, waiting for remaining Python data$/)
|
||||||
|
if (completedMatch) {
|
||||||
|
return `Coze 已完成 ${completedMatch[1]} 批,等待 Python 继续回传数据`
|
||||||
|
}
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'Receiving Python data, waiting for 50 rows before Coze': '正在接收 Python 数据,累计 50 条后提交 Coze',
|
||||||
|
'Submitting Coze': '正在提交 Coze',
|
||||||
|
'Coze submitted, waiting for result': 'Coze 已提交,等待结果回流',
|
||||||
|
'Waiting for Python upload': '等待 Python 继续回传数据',
|
||||||
|
'Coze results ready, assembling xlsx': 'Coze 结果已回流,正在组装 xlsx',
|
||||||
|
'Assembling xlsx': '正在组装 xlsx',
|
||||||
|
'Uploading result file': '正在上传结果文件',
|
||||||
|
'Result file generated': '结果文件已生成',
|
||||||
|
}
|
||||||
|
return translations[message] || message
|
||||||
|
}
|
||||||
|
|
||||||
function showFileProgress(item: AppearancePatentHistoryItem) {
|
function showFileProgress(item: AppearancePatentHistoryItem) {
|
||||||
return isResultPreparing(item) && (item.fileProgressTotal || 0) > 0
|
const status = normalizeTaskStatus(item)
|
||||||
|
return (status === 'RUNNING' || isResultPreparing(item)) && (item.fileProgressTotal || 0) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadResult(item: AppearancePatentHistoryItem) {
|
async function downloadResult(item: AppearancePatentHistoryItem) {
|
||||||
@@ -717,6 +778,7 @@ onMounted(async () => {
|
|||||||
loadDashboard().catch(() => undefined),
|
loadDashboard().catch(() => undefined),
|
||||||
loadHistory().catch(() => undefined),
|
loadHistory().catch(() => undefined),
|
||||||
])
|
])
|
||||||
|
seedRunningTasksFromHistory()
|
||||||
seedPendingFileTasksFromHistory()
|
seedPendingFileTasksFromHistory()
|
||||||
if (pollingTaskIds.value.length || pendingFileTaskIds.value.length) ensurePolling()
|
if (pollingTaskIds.value.length || pendingFileTaskIds.value.length) ensurePolling()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -291,7 +291,9 @@ function sanitizeSchedulePickerValues(values: string[], ensureOne: boolean) {
|
|||||||
if (!deduped.length && ensureOne) return [getDefaultScheduleValue()]
|
if (!deduped.length && ensureOne) return [getDefaultScheduleValue()]
|
||||||
return deduped
|
return deduped
|
||||||
}
|
}
|
||||||
function buildQueuePayload(taskId: number, item: Pick<ShopMatchHistoryItem, 'shopName' | 'shopId' | 'platform' | 'companyName' | 'matchStatus' | 'matchMessage'>, countryCodes: string[], stageIndex?: number, finalStage = true) { return { type: 'shop-match-run', ts: Date.now(), data: { taskId, user_id: currentUserId(), items: [{ shopName: item.shopName, shopId: item.shopId, platform: item.platform, companyName: item.companyName, matched: true, matchStatus: item.matchStatus, matchMessage: item.matchMessage }], country_codes: [...countryCodes], risk_listing_filter: shopMatchListingFilter.value, stage_index: stageIndex, final_stage: finalStage } } }
|
function readSkipAsinsByCountry(item: ShopMatchHistoryItem) { return item.skipAsinsByCountry || item.skip_asins_by_country || {} }
|
||||||
|
function readSkipAsinDetailsByCountry(item: ShopMatchHistoryItem) { return item.skipAsinDetailsByCountry || item.skip_asin_details_by_country || {} }
|
||||||
|
function buildQueuePayload(taskId: number, item: Pick<ShopMatchHistoryItem, 'shopName' | 'shopId' | 'platform' | 'companyName' | 'matchStatus' | 'matchMessage' | 'skipAsinsByCountry' | 'skip_asins_by_country' | 'skipAsinDetailsByCountry' | 'skip_asin_details_by_country'>, countryCodes: string[], stageIndex?: number, finalStage = true) { const skipAsinsByCountry = readSkipAsinsByCountry(item as ShopMatchHistoryItem); const skipAsinDetailsByCountry = readSkipAsinDetailsByCountry(item as ShopMatchHistoryItem); return { type: 'shop-match-run', ts: Date.now(), data: { taskId, user_id: currentUserId(), items: [{ shopName: item.shopName, shopId: item.shopId, platform: item.platform, companyName: item.companyName, matched: true, matchStatus: item.matchStatus, matchMessage: item.matchMessage, skip_asins_by_country: skipAsinsByCountry, skipAsinsByCountry, skip_asin_details_by_country: skipAsinDetailsByCountry, skipAsinDetailsByCountry }], country_codes: [...countryCodes], skip_asins_by_country: skipAsinsByCountry, skipAsinsByCountry, skip_asin_details_by_country: skipAsinDetailsByCountry, skipAsinDetailsByCountry, risk_listing_filter: shopMatchListingFilter.value, stage_index: stageIndex, final_stage: finalStage } } }
|
||||||
function nextScheduleStage(taskId: number) { const task = taskSnapshots.value[taskId]?.task; const stages = task?.scheduleStages || []; const currentStageIndex = task?.currentStageIndex; if (typeof currentStageIndex !== 'number') return null; const stage = stages.find((item) => item.stageIndex === currentStageIndex); if (!stage?.scheduledAt) return null; return { stageIndex: currentStageIndex, scheduledAt: stage.scheduledAt, finalStage: currentStageIndex === stages.length - 1 } }
|
function nextScheduleStage(taskId: number) { const task = taskSnapshots.value[taskId]?.task; const stages = task?.scheduleStages || []; const currentStageIndex = task?.currentStageIndex; if (typeof currentStageIndex !== 'number') return null; const stage = stages.find((item) => item.stageIndex === currentStageIndex); if (!stage?.scheduledAt) return null; return { stageIndex: currentStageIndex, scheduledAt: stage.scheduledAt, finalStage: currentStageIndex === stages.length - 1 } }
|
||||||
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await withTransientRetry(() => activateShopMatchTask(taskId, stage.stageIndex), (attempt, maxAttempts) => { queuePushResult.value = `任务 ${taskId} 等待后端恢复后再激活执行(${attempt}/${maxAttempts})...` }); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; if (snapshot?.task) taskSnapshots.value = { ...taskSnapshots.value, [taskId]: { ...snapshot, task: { ...snapshot.task, status: 'RUNNING', activeStageIndex: stage.stageIndex } } }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId} 已推送第 ${stage.stageIndex + 1} 轮`; ensurePolling(true) }
|
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await withTransientRetry(() => activateShopMatchTask(taskId, stage.stageIndex), (attempt, maxAttempts) => { queuePushResult.value = `任务 ${taskId} 等待后端恢复后再激活执行(${attempt}/${maxAttempts})...` }); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; if (snapshot?.task) taskSnapshots.value = { ...taskSnapshots.value, [taskId]: { ...snapshot, task: { ...snapshot.task, status: 'RUNNING', activeStageIndex: stage.stageIndex } } }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId} 已推送第 ${stage.stageIndex + 1} 轮`; ensurePolling(true) }
|
||||||
function scheduledTimestamp(value?: string) { const date = parseScheduleDateTime(value); return date ? date.getTime() : Number.NaN }
|
function scheduledTimestamp(value?: string) { const date = parseScheduleDateTime(value); return date ? date.getTime() : Number.NaN }
|
||||||
|
|||||||
@@ -631,6 +631,28 @@ export interface ProductRiskShopQueueItem {
|
|||||||
matchStatus?: string;
|
matchStatus?: string;
|
||||||
matchMessage?: string;
|
matchMessage?: string;
|
||||||
queryAsins?: QueryAsinCountryAsins[];
|
queryAsins?: QueryAsinCountryAsins[];
|
||||||
|
skipAsinsByCountry?: Record<string, string[]>;
|
||||||
|
skip_asins_by_country?: Record<string, string[]>;
|
||||||
|
skipAsinDetailsByCountry?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
|
||||||
|
skip_asin_details_by_country?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShopMatchResultRow {
|
||||||
|
asin?: string;
|
||||||
|
minimumPrice?: string;
|
||||||
|
minimum_price?: string;
|
||||||
|
status?: string;
|
||||||
|
done?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShopMatchSubmitShopPayload {
|
||||||
|
shopName?: string;
|
||||||
|
error?: string;
|
||||||
|
countries?: Record<string, ShopMatchResultRow[]>;
|
||||||
|
skipAsinsByCountry?: Record<string, string[]>;
|
||||||
|
skip_asins_by_country?: Record<string, string[]>;
|
||||||
|
skipAsinDetailsByCountry?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
|
||||||
|
skip_asin_details_by_country?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductRiskMatchShopsVo {
|
export interface ProductRiskMatchShopsVo {
|
||||||
@@ -737,6 +759,10 @@ export interface ProductRiskHistoryItem {
|
|||||||
fileError?: string;
|
fileError?: string;
|
||||||
fileReady?: boolean;
|
fileReady?: boolean;
|
||||||
scheduledAt?: string;
|
scheduledAt?: string;
|
||||||
|
skipAsinsByCountry?: Record<string, string[]>;
|
||||||
|
skip_asins_by_country?: Record<string, string[]>;
|
||||||
|
skipAsinDetailsByCountry?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
|
||||||
|
skip_asin_details_by_country?: Record<string, Array<{ asin?: string; minimumPrice?: string }>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductRiskHistoryVo {
|
export interface ProductRiskHistoryVo {
|
||||||
|
|||||||
Reference in New Issue
Block a user