From a4e4745921cc673095a12500bd4aa7795069e302 Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Sat, 9 May 2026 00:20:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=A4=84=E7=90=86=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/version.txt | 2 +- .../config/AppearancePatentProperties.java | 4 +- .../aiimage/config/SimilarAsinProperties.java | 4 +- .../client/AppearancePatentCozeClient.java | 7 +- .../service/AppearancePatentTaskService.java | 729 +++- .../PermissionMenuSchemaInitializer.java | 1 + .../controller/ProductCategoryController.java | 54 + .../mapper/ProductCategoryMapper.java | 9 + .../model/dto/ProductCategorySaveRequest.java | 26 + .../model/entity/ProductCategoryEntity.java | 24 + .../model/vo/ProductCategoryItemVo.java | 24 + .../model/vo/ProductCategoryListVo.java | 11 + .../service/ProductCategoryService.java | 237 ++ .../model/vo/ProductRiskResultItemVo.java | 14 + .../model/vo/ProductRiskShopQueueItemVo.java | 12 + .../model/dto/SkipPriceAsinDetailDto.java | 17 + .../shopkey/service/SkipPriceAsinService.java | 67 + .../shopmatch/model/dto/ShopMatchRowDto.java | 6 + .../model/dto/ShopMatchShopPayloadDto.java | 12 + .../ShopMatchExcelAssemblyService.java | 9 +- .../service/ShopMatchTaskService.java | 194 +- .../client/SimilarAsinCozeClient.java | 9 +- .../SimilarAsinFilterConditionEntity.java | 1 + .../service/SimilarAsinTaskService.java | 488 ++- .../resources/application-local.example.yml | 4 +- .../src/main/resources/application.yml | 8 +- ...48__similar_asin_filter_condition_text.sql | 68 + .../db/V49__admin_product_categories_menu.sql | 12 + .../resources/db/V50__product_category.sql | 91 + backend/blueprints/admin_api.py | 220 + backend/config.py | 4 +- backend/static/admin.js | 3523 +++++++++++++++++ backend/web_source/admin.html | 3462 +--------------- .../components/BrandAppearancePatentTab.vue | 72 +- .../brand/components/BrandShopMatchTab.vue | 4 +- frontend-vue/src/shared/api/java-modules.ts | 26 + 36 files changed, 5956 insertions(+), 3499 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/controller/ProductCategoryController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/mapper/ProductCategoryMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/dto/ProductCategorySaveRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/entity/ProductCategoryEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/vo/ProductCategoryItemVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/vo/ProductCategoryListVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/service/ProductCategoryService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinDetailDto.java create mode 100644 backend-java/src/main/resources/db/V48__similar_asin_filter_condition_text.sql create mode 100644 backend-java/src/main/resources/db/V49__admin_product_categories_menu.sql create mode 100644 backend-java/src/main/resources/db/V50__product_category.sql create mode 100644 backend/static/admin.js diff --git a/app/version.txt b/app/version.txt index a0374f4..52a718a 100644 --- a/app/version.txt +++ b/app/version.txt @@ -1 +1 @@ -{"version": "1.0.56"} \ No newline at end of file +{"version": "1.0.13"} \ No newline at end of file diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java index ad6b46b..78cfc2d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java @@ -11,10 +11,10 @@ public class AppearancePatentProperties { private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}"; private String cozeWorkflowId = "7632683471312355338"; private String cozeToken = ""; - private int cozeBatchSize = 10; + private int cozeBatchSize = 50; private int cozeConnectTimeoutMillis = 10000; private int cozeReadTimeoutMillis = 60000; - private int cozePollIntervalMillis = 5000; + private int cozePollIntervalMillis = 30000; private int cozePollTimeoutMillis = 600000; private int staleTimeoutMinutes = 20; private String staleFinalizeCron = "0 */2 * * * *"; diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java index c4c8adc..fb4de5e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java @@ -11,10 +11,10 @@ public class SimilarAsinProperties { private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}"; private String cozeWorkflowId = "7632683471312355338"; private String cozeToken = ""; - private int cozeBatchSize = 10; + private int cozeBatchSize = 50; private int cozeConnectTimeoutMillis = 10000; private int cozeReadTimeoutMillis = 60000; - private int cozePollIntervalMillis = 5000; + private int cozePollIntervalMillis = 30000; private int cozePollTimeoutMillis = 600000; private int staleTimeoutMinutes = 20; private String staleFinalizeCron = "0 */2 * * * *"; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java index abd4107..77fe94b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java @@ -734,7 +734,10 @@ public class AppearancePatentCozeClient { private String resolveFailureMessage(JsonNode root) { JsonNode dataNode = root.path("data"); 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) { @@ -978,7 +981,7 @@ public class AppearancePatentCozeClient { public boolean isFailed() { String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT); - return normalized.contains("FAILED") || normalized.contains("ERROR") || normalized.contains("CANCEL"); + return normalized.contains("FAIL") || normalized.contains("ERROR") || normalized.contains("CANCEL"); } public boolean isFinished() { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java index 58259bd..8700b81 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java @@ -105,6 +105,11 @@ public class AppearancePatentTaskService { 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_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 RESULT_HEADERS = List.of( "id", "asin", @@ -419,6 +424,7 @@ public class AppearancePatentTaskService { completeSubmittedChunk(context); return null; }); + submitCozeForSubmittedChunk(context); return; } FileTaskEntity task = fileTaskMapper.selectById(taskId); @@ -496,12 +502,15 @@ public class AppearancePatentTaskService { 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()) { finalizeTask(task, request.getError(), allRowCount(task), true); } else { task.setUpdatedAt(LocalDateTime.now()); fileTaskMapper.updateById(task); } + submitCozeForSubmittedChunk(context); } @Transactional @@ -659,7 +668,7 @@ public class AppearancePatentTaskService { upsertScopeState(taskId, scopeKey, scopeHash, chunkTotal, request.getError(), done, false); task.setUpdatedAt(LocalDateTime.now()); 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) { @@ -681,6 +690,39 @@ public class AppearancePatentTaskService { 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 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> 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) { FileTaskEntity task = fileTaskMapper.selectById(taskId); 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) { String finalError = error; FileResultEntity result = null; + boolean hasPersistedResultRows = hasPersistedResultRows(task.getId()); List rows = fileResultMapper.selectList(new LambdaQueryWrapper() .eq(FileResultEntity::getTaskId, task.getId()) .eq(FileResultEntity::getModuleType, MODULE_TYPE) .last("limit 1")); if (!rows.isEmpty()) { 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 { assembleResultWorkbook(task, result); } catch (Exception ex) { @@ -1102,20 +1150,69 @@ public class AppearancePatentTaskService { result.setSuccess(failed ? 0 : 1); result.setErrorMessage(finalError); 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.setResultFileUrl(null); result.setResultFileSize(0L); result.setResultContentType(CONTENT_TYPE_XLSX); } fileResultMapper.updateById(result); - if (!failed && assembleWorkbook) { - taskFileJobService.enqueueAssembleResult(task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task.getId())); + if (shouldAssembleResult) { + 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()); } + private FileResultEntity findOrCreateResultRecordForAssembly(FileTaskEntity task, int rowCount) { + if (task == null || task.getId() == null) { + return null; + } + List rows = fileResultMapper.selectList(new LambdaQueryWrapper() + .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 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() { return false; } @@ -1158,29 +1255,41 @@ public class AppearancePatentTaskService { if (countPendingCozeStates(task.getId()) > 0) { taskFileJobService.touchRunning(job.getId()); touchJavaSideTaskActivity(task.getId()); - saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result"); + saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze 已提交,等待结果回流"); return false; } + saveFileBuildProgress(task, job, totalProgressUnits, 0, "正在提交 Coze"); Map> allRowsByBaseId = loadAllRowsByBaseId(task); - saveFileBuildProgress(task, job, totalProgressUnits, 0, "Submitting Coze"); boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId); if (pendingCoze) { taskFileJobService.touchRunning(job.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; } completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits); 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() { List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) .in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING)) .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")); if (states == null || states.isEmpty()) { return; @@ -1205,6 +1314,64 @@ public class AppearancePatentTaskService { } } + private List loadSubmittedChunks(Long taskId) { + if (taskId == null || taskId <= 0) { + return List.of(); + } + List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .orderByAsc(TaskChunkEntity::getScopeHash) + .orderByAsc(TaskChunkEntity::getChunkIndex)); + return chunks == null ? List.of() : chunks; + } + + private List collectPendingCozeCandidates(FileTaskEntity task, List chunks) { + if (task == null || task.getId() == null || chunks == null || chunks.isEmpty()) { + return List.of(); + } + Set queuedRowKeys = new LinkedHashSet<>(loadSubmittedCozeRowKeys(task.getId())); + List candidates = new ArrayList<>(); + for (TaskChunkEntity chunk : chunks) { + Map 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 loadSubmittedCozeRowKeys(Long taskId) { + Set keys = new LinkedHashSet<>(); + if (taskId == null || taskId <= 0) { + return keys; + } + List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .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, FileResultEntity result, TaskFileJobEntity job, @@ -1221,24 +1388,27 @@ public class AppearancePatentTaskService { String prompt = readAiPrompt(task); String apiKey = readApiKey(task); int batchSize = Math.max(1, properties.getCozeBatchSize()); + List 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; - for (TaskChunkEntity chunk : chunks) { - Map persistedRows = readChunkRows(chunk); - if (persistedRows.isEmpty()) { - continue; - } - List unresolvedRows = pickGroupRepresentativesForCoze(persistedRows.values()); - if (unresolvedRows.isEmpty()) { - continue; - } - int batchTotal = Math.max(1, (unresolvedRows.size() + batchSize - 1) / batchSize); - int batchIndex = 1; - for (int i = 0; i < unresolvedRows.size(); i += batchSize) { - List batchRows = - unresolvedRows.subList(i, Math.min(i + batchSize, unresolvedRows.size())); - pending |= submitCozeBatch(task, result, job, chunk, batchRows, batchIndex, batchTotal, prompt, apiKey, allRowsByBaseId); - batchIndex++; - } + int batchTotal = Math.max(1, (submitLimit + batchSize - 1) / batchSize); + int batchIndex = 1; + for (int i = 0; i < submitLimit; i += batchSize) { + List batchCandidates = candidates.subList(i, Math.min(i + batchSize, submitLimit)); + List batchRows = batchCandidates.stream() + .map(CozeCandidate::row) + .toList(); + pending |= submitCozeBatch(task, result, job, batchRows, batchIndex, batchTotal, prompt, apiKey, allRowsByBaseId); + batchIndex++; } return pending || countPendingCozeStates(task.getId()) > 0; } @@ -1246,7 +1416,6 @@ public class AppearancePatentTaskService { private boolean submitCozeBatch(FileTaskEntity task, FileResultEntity result, TaskFileJobEntity job, - TaskChunkEntity chunk, List batchRows, int batchIndex, int batchTotal, @@ -1256,7 +1425,7 @@ public class AppearancePatentTaskService { if (batchRows == null || batchRows.isEmpty()) { return false; } - String batchScopeKey = buildCozeBatchScopeKey(job.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), batchIndex); + String batchScopeKey = buildCozeBatchScopeKey(task.getId(), batchRows); String batchScopeHash = DigestUtil.sha256Hex(batchScopeKey); TaskScopeStateEntity existing = taskScopeStateMapper.selectOne(new LambdaQueryWrapper() .eq(TaskScopeStateEntity::getTaskId, task.getId()) @@ -1268,34 +1437,28 @@ public class AppearancePatentTaskService { || COZE_STATUS_RUNNING.equals(existing.getCozeStatus()); } try { - AppearancePatentCozeClient.CozeSubmitResponse submit = cozeClient.submitWorkflow(batchRows, prompt, apiKey); + AppearancePatentCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(batchRows, prompt, apiKey); if (submit.immediateData() != null && !submit.immediateData().isBlank()) { List cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData()); - mergeCozeRowsIntoChunk(task, chunk.getScopeHash(), chunk.getChunkIndex(), cozeRows, allRowsByBaseId); + mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); return false; } if (submit.executeId() == null || submit.executeId().isBlank()) { - mergeCozeRowsIntoChunk(task, - chunk.getScopeHash(), - chunk.getChunkIndex(), + mergeCozeRowsIntoSubmittedChunks(task, cozeClient.markRowsFailed(batchRows, "Coze async execute_id missing"), allRowsByBaseId); return false; } - saveCozeBatchState(task, result, job, chunk, batchRows, batchScopeKey, batchScopeHash, + saveCozeBatchState(task, result, job, batchRows, batchScopeKey, batchScopeHash, batchIndex, batchTotal, submit.executeId()); - log.info("[appearance-patent] coze async submitted taskId={} jobId={} chunk={} batch={}/{} executeId={}", - task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal, submit.executeId()); + log.info("[appearance-patent] coze async submitted taskId={} jobId={} rows={} batch={}/{} executeId={}", + task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, submit.executeId()); return true; } catch (Exception ex) { String message = firstNonBlank(ex.getMessage(), "Coze submit failed"); - log.warn("[appearance-patent] coze async submit failed taskId={} jobId={} chunk={} batch={}/{} err={}", - task.getId(), job.getId(), chunk.getChunkIndex(), batchIndex, batchTotal, message); - mergeCozeRowsIntoChunk(task, - chunk.getScopeHash(), - chunk.getChunkIndex(), - cozeClient.markRowsFailed(batchRows, message), - allRowsByBaseId); + log.warn("[appearance-patent] coze async submit failed taskId={} jobId={} rows={} batch={}/{} err={}", + task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, message); + mergeCozeRowsIntoSubmittedChunks(task, cozeClient.markRowsFailed(batchRows, message), allRowsByBaseId); return false; } } @@ -1303,7 +1466,6 @@ public class AppearancePatentTaskService { private void saveCozeBatchState(FileTaskEntity task, FileResultEntity result, TaskFileJobEntity job, - TaskChunkEntity chunk, List batchRows, String batchScopeKey, String batchScopeHash, @@ -1314,11 +1476,12 @@ public class AppearancePatentTaskService { CozeBatchContext context = new CozeBatchContext( job.getId(), result.getId(), - chunk.getScopeHash(), - chunk.getChunkIndex(), + null, + null, batchIndex, batchTotal, - currentInstanceId() + currentInstanceId(), + 0 ); String batchPayload = writeJson(batchRows, "serialize coze batch payload failed"); String storedBatchPayload = storeSharedCozeBatchPayload(task.getId(), batchScopeHash, batchPayload); @@ -1393,10 +1556,10 @@ public class AppearancePatentTaskService { markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze batch context missing"); return; } - if (!isOwnerCurrent(context.ownerInstanceId())) { - 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()); - return; + if (!isOwnerCurrent(context.ownerInstanceId())) { + 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()); + return; } taskFileJobService.touchRunning(context.jobId()); 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()) { failureMessage = "Coze batch payload missing"; } + if (!failureMessage.isBlank() && splitRetryFailedCozeBatchState(state, context, batchRows, failureMessage)) { + return; + } + if (!failureMessage.isBlank() && retryFailedCozeBatchState(state, context, batchRows, failureMessage)) { + return; + } List cozeRows = failureMessage.isBlank() ? cozeClient.mergeRowsFromDataText(batchRows, poll.resolvedPayloadText()) : cozeClient.markRowsFailed(batchRows, failureMessage); FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId()); if (task != null) { Map> allRowsByBaseId = loadAllRowsByBaseId(task); - mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId); + mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); } markCozeStateTerminal(state, failureMessage.isBlank() ? COZE_STATUS_DONE : COZE_STATUS_FAILED, 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={}", state.getTaskId(), state.getId(), state.getCozeExecuteId(), failureMessage.isBlank() ? COZE_STATUS_DONE : COZE_STATUS_FAILED, @@ -1447,9 +1620,7 @@ public class AppearancePatentTaskService { FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId()); if (task != null) { Map> allRowsByBaseId = loadAllRowsByBaseId(task); - mergeCozeRowsIntoChunk(task, - context.chunkScopeHash(), - context.chunkIndex(), + mergeCozeRowsIntoSubmittedChunks(task, cozeClient.markRowsFailed(batchRows, message), allRowsByBaseId); } @@ -1467,6 +1638,240 @@ public class AppearancePatentTaskService { } } + private boolean retryFailedCozeBatchState(TaskScopeStateEntity state, + CozeBatchContext context, + List 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> allRowsByBaseId = loadAllRowsByBaseId(task); + if (submit.immediateData() != null && !submit.immediateData().isBlank()) { + List 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() + .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 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 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> partitions = List.>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> allRowsByBaseId = loadAllRowsByBaseId(task); + int partIndex = 1; + for (List partRows : partitions) { + AppearancePatentCozeClient.CozeSubmitResponse submit = + submitCozeWorkflowThrottled(partRows, readAiPrompt(task), readApiKey(task)); + if (submit.immediateData() != null && !submit.immediateData().isBlank()) { + List 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 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) { int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper() .eq(TaskScopeStateEntity::getId, state.getId()) @@ -1546,7 +1951,13 @@ public class AppearancePatentTaskService { if (job == null || "SUCCESS".equals(job.getStatus())) { 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) { log.info("[appearance-patent] coze async results ready, result file job requeued taskId={} jobId={} resultId={}", taskId, job.getId(), context.resultId()); @@ -1570,17 +1981,22 @@ public class AppearancePatentTaskService { throw new BusinessException("Coze 结果仍在处理中,暂不能生成结果文件"); } 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); - saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits - 1, "Uploading result file"); + saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits - 1, "正在上传结果文件"); fileResultMapper.updateById(result); - task.setStatus(STATUS_SUCCESS); - task.setErrorMessage(null); + boolean taskAlreadyFailed = STATUS_FAILED.equals(task.getStatus()) + || (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.setFinishedAt(LocalDateTime.now()); fileTaskMapper.updateById(task); taskCacheService.deleteTaskCache(task.getId()); - saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits, "Result file generated"); + saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits, "结果文件已生成"); } private void mergeCozeRowsIntoChunk(FileTaskEntity task, @@ -1588,16 +2004,74 @@ public class AppearancePatentTaskService { Integer chunkIndex, List cozeRows, Map> allRowsByBaseId) { + mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId, chunkScopeHash, chunkIndex); + } + + private void mergeCozeRowsIntoSubmittedChunks(FileTaskEntity task, + List cozeRows, + Map> allRowsByBaseId) { + mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId, null, null); + } + + private void mergeCozeRowsIntoSubmittedChunks(FileTaskEntity task, + List cozeRows, + Map> allRowsByBaseId, + String fallbackScopeHash, + Integer fallbackChunkIndex) { if (task == null || cozeRows == null || cozeRows.isEmpty()) { return; } - Map mergedRows = new LinkedHashMap<>(); + List chunks = loadSubmittedChunks(task.getId()); + if (chunks.isEmpty()) { + return; + } + Map> rowsByChunk = new LinkedHashMap<>(); + Map chunkByKey = new LinkedHashMap<>(); + for (TaskChunkEntity chunk : chunks) { + String chunkKey = chunkStorageKey(chunk.getScopeHash(), chunk.getChunkIndex()); + rowsByChunk.put(chunkKey, readChunkRows(chunk)); + chunkByKey.put(chunkKey, chunk); + } + Map> mergeRowsByChunk = new LinkedHashMap<>(); for (AppearancePatentResultRowDto resultRow : cozeRows) { 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> 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> 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 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) { if (state == null || state.getCozeSubmittedAt() == null) { return false; @@ -1670,6 +2161,30 @@ public class AppearancePatentTaskService { return count == null ? 0 : count.intValue(); } + private int countAllCozeStates(Long taskId) { + if (taskId == null || taskId <= 0) { + return 0; + } + Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper() + .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() + .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) { return countPendingCozeStates(taskId) > 0 || taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE) > 0; @@ -1686,11 +2201,20 @@ public class AppearancePatentTaskService { .set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())); } - private String buildCozeBatchScopeKey(Long jobId, String chunkScopeHash, Integer chunkIndex, int batchIndex) { - return "coze:job:" + jobId - + ":chunk:" + (chunkIndex == null ? 0 : chunkIndex) - + ":" + firstNonBlank(chunkScopeHash, "unknown") - + ":batch:" + batchIndex; + private String buildCozeBatchScopeKey(Long taskId, List batchRows) { + StringBuilder rowKeys = new StringBuilder(); + if (batchRows != null) { + for (AppearancePatentResultRowDto row : batchRows) { + 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) { @@ -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() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + return count == null ? 0 : count.intValue(); + } + public void cleanupResultFileJob(TaskFileJobEntity job) { if (job == null || job.getTaskId() == null) { return; @@ -1891,6 +2454,10 @@ public class AppearancePatentTaskService { if (receivedRows == null || receivedRows.isEmpty()) { return; } + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task != null && STATUS_FAILED.equals(task.getStatus())) { + return; + } int expectedRows = 0; int missingRows = 0; List 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={}", taskId, expectedRows, missingRows, sampleAsins); throw new BusinessException("Coze 结果不完整:缺少 " + missingRows + "/" + expectedRows + " 条检测结果,请等待重试或重新运行任务"); @@ -1933,6 +2501,16 @@ public class AppearancePatentTaskService { return result; } + private boolean hasPersistedResultRows(Long taskId) { + if (taskId == null) { + return false; + } + Long count = taskChunkMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + return count != null && count > 0; + } + private Map loadPersistedResultRowsWithRetry(Long taskId, int expectedParsedRows) { Map result = loadPersistedResultRows(taskId); if (expectedParsedRows <= 0 || !result.isEmpty()) { @@ -2770,6 +3348,7 @@ public class AppearancePatentTaskService { private record SubmitContext(FileTaskEntity task, String scopeKey, String scopeHash, + Integer chunkIndex, boolean forceFlush, String error) { } @@ -2780,7 +3359,13 @@ public class AppearancePatentTaskService { Integer chunkIndex, Integer batchIndex, 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 headers, List allRows) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java index c74e581..26e55c4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java @@ -33,6 +33,7 @@ public class PermissionMenuSchemaInitializer { new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50), new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin", 60), 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_version", "version", 80) ); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/controller/ProductCategoryController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/controller/ProductCategoryController.java new file mode 100644 index 0000000..910db73 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/controller/ProductCategoryController.java @@ -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 list() { + return ApiResponse.success(productCategoryService.list()); + } + + @PostMapping("/product-category") + @Operation(summary = "新增商品类目") + public ApiResponse create(@Valid @RequestBody ProductCategorySaveRequest request) { + return ApiResponse.success("创建成功", productCategoryService.create(request)); + } + + @PutMapping("/product-category/{id}") + @Operation(summary = "更新商品类目") + public ApiResponse update(@PathVariable Long id, + @Valid @RequestBody ProductCategorySaveRequest request) { + return ApiResponse.success("保存成功", productCategoryService.update(id, request)); + } + + @DeleteMapping("/product-category/{id}") + @Operation(summary = "删除商品类目") + public ApiResponse delete(@PathVariable Long id) { + productCategoryService.delete(id); + return ApiResponse.success("删除成功", null); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/mapper/ProductCategoryMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/mapper/ProductCategoryMapper.java new file mode 100644 index 0000000..a4b57be --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/mapper/ProductCategoryMapper.java @@ -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 { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/dto/ProductCategorySaveRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/dto/ProductCategorySaveRequest.java new file mode 100644 index 0000000..fa71545 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/dto/ProductCategorySaveRequest.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/entity/ProductCategoryEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/entity/ProductCategoryEntity.java new file mode 100644 index 0000000..7a61d1e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/entity/ProductCategoryEntity.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/vo/ProductCategoryItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/vo/ProductCategoryItemVo.java new file mode 100644 index 0000000..7cdb430 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/vo/ProductCategoryItemVo.java @@ -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 children = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/vo/ProductCategoryListVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/vo/ProductCategoryListVo.java new file mode 100644 index 0000000..98a2289 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/model/vo/ProductCategoryListVo.java @@ -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 tree; + private List items; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/service/ProductCategoryService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/service/ProductCategoryService.java new file mode 100644 index 0000000..bc072ef --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/service/ProductCategoryService.java @@ -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 rows = productCategoryMapper.selectList(new LambdaQueryWrapper() + .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() + .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 rows) { + Map childCountById = new HashMap<>(); + Map> 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 flat = new ArrayList<>(); + List tree = buildChildren(null, 0, "", byParent, childCountById, flat); + ProductCategoryListVo vo = new ProductCategoryListVo(); + vo.setTree(tree); + vo.setItems(flat); + return vo; + } + + private List buildChildren( + Long parentId, + int level, + String parentPath, + Map> byParent, + Map childCountById, + List flat) { + List 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 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 query = new LambdaQueryWrapper() + .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); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskResultItemVo.java index 2504c58..26085b8 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskResultItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskResultItemVo.java @@ -1,9 +1,15 @@ package com.nanri.aiimage.modules.productrisk.model.vo; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; +import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinDetailDto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + @Data @Schema(description = "单店结果项:列表接口与任务详情中共用,对应 biz_file_result 一行 + 任务/请求衍生字段") public class ProductRiskResultItemVo { @@ -65,4 +71,12 @@ public class ProductRiskResultItemVo { @JsonProperty("scheduledAt") @Schema(description = "定时执行时间,未设置时为空") private String scheduledAt; + + @JsonAlias("skip_asins_by_country") + @JsonProperty("skipAsinsByCountry") + private Map> skipAsinsByCountry = new LinkedHashMap<>(); + + @JsonAlias("skip_asin_details_by_country") + @JsonProperty("skipAsinDetailsByCountry") + private Map> skipAsinDetailsByCountry = new LinkedHashMap<>(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskShopQueueItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskShopQueueItemVo.java index 16eecf0..f45a519 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskShopQueueItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskShopQueueItemVo.java @@ -1,12 +1,16 @@ package com.nanri.aiimage.modules.productrisk.model.vo; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; 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 lombok.Data; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * 与 Python 队列约定字段对齐,供前端原样推入 enqueue_json。 @@ -52,4 +56,12 @@ public class ProductRiskShopQueueItemVo { @JsonProperty("queryAsins") @Schema(description = "查询 ASIN 模块返回的后台维护 ASIN 数据;按全表聚合返回,不按当前店铺过滤") private List queryAsins = new ArrayList<>(); + + @JsonAlias("skip_asins_by_country") + @JsonProperty("skipAsinsByCountry") + private Map> skipAsinsByCountry = new LinkedHashMap<>(); + + @JsonAlias("skip_asin_details_by_country") + @JsonProperty("skipAsinDetailsByCountry") + private Map> skipAsinDetailsByCountry = new LinkedHashMap<>(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinDetailDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinDetailDto.java new file mode 100644 index 0000000..a17e5cf --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinDetailDto.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java index 11c3bde..3f7475e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java @@ -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.ShopManageMapper; 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.ShopManageGroupEntity; import com.nanri.aiimage.modules.shopkey.model.entity.SkipPriceAsinEntity; @@ -833,6 +834,72 @@ public class SkipPriceAsinService { return result; } + public Map>> listSkipAsinDetailsByShopAndCountries( + List shopNames, List countryCodes) { + if (shopNames == null || shopNames.isEmpty()) { + return Map.of(); + } + LinkedHashSet normalizedShopNames = new LinkedHashSet<>(); + for (String shopName : shopNames) { + String normalized = normalizeBlank(shopName); + if (!normalized.isEmpty()) { + normalizedShopNames.add(normalized); + } + } + if (normalizedShopNames.isEmpty()) { + return Map.of(); + } + LinkedHashSet 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 rows = skipPriceAsinMapper.selectList(new LambdaQueryWrapper() + .in(SkipPriceAsinEntity::getShopName, normalizedShopNames) + .orderByDesc(SkipPriceAsinEntity::getId)); + Map>> grouped = new LinkedHashMap<>(); + for (SkipPriceAsinEntity row : rows) { + String shopName = normalizeBlank(row.getShopName()); + if (shopName.isEmpty()) { + continue; + } + Map> 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>> result = new LinkedHashMap<>(); + for (Map.Entry>> shopEntry : grouped.entrySet()) { + Map> countryResult = new LinkedHashMap<>(); + for (String country : normalizedCountries) { + LinkedHashMap 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> listAllSkipAsinsByCountry() { List rows = skipPriceAsinMapper.selectList( new LambdaQueryWrapper() diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchRowDto.java index 222cc17..cf2e9b4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchRowDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchRowDto.java @@ -1,5 +1,6 @@ package com.nanri.aiimage.modules.shopmatch.model.dto; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -11,6 +12,11 @@ public class ShopMatchRowDto { @Schema(description = "ASIN") private String asin; + @JsonAlias("minimum_price") + @JsonProperty("minimumPrice") + @Schema(description = "最低价") + private String minimumPrice; + @Schema(description = "状态") private String status; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchShopPayloadDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchShopPayloadDto.java index 9100f22..a7cf80d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchShopPayloadDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchShopPayloadDto.java @@ -1,7 +1,9 @@ package com.nanri.aiimage.modules.shopmatch.model.dto; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; 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 lombok.Data; @@ -22,4 +24,14 @@ public class ShopMatchShopPayloadDto { @Schema(description = "按国家分组的 ASIN/状态 列表") private Map> countries = new LinkedHashMap<>(); + + @JsonAlias("skip_asin_details_by_country") + @JsonProperty("skipAsinDetailsByCountry") + @Schema(description = "Python 回传的跳过 ASIN 明细,按国家携带 asin 和最低价") + private Map> skipAsinDetailsByCountry = new LinkedHashMap<>(); + + @JsonAlias("skip_asins_by_country") + @JsonProperty("skipAsinsByCountry") + @Schema(description = "Python 回传的跳过 ASIN 列表,兼容旧格式") + private Map> skipAsinsByCountry = new LinkedHashMap<>(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchExcelAssemblyService.java index ecb3e2d..f0d54f8 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchExcelAssemblyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchExcelAssemblyService.java @@ -30,8 +30,9 @@ public class ShopMatchExcelAssemblyService { String sheetName = code.getSheetName(); Sheet sheet = workbook.createSheet(sheetName); Row header = sheet.createRow(0); - header.createCell(0).setCellValue(HEADER[0]); - header.createCell(1).setCellValue(HEADER[1]); + header.createCell(0).setCellValue("ASIN"); + header.createCell(1).setCellValue("最低价"); + header.createCell(2).setCellValue("状态"); List rows = safe.getOrDefault(sheetName, List.of()); int rowIndex = 1; for (ShopMatchRowDto item : rows) { @@ -40,10 +41,12 @@ public class ShopMatchExcelAssemblyService { } Row row = sheet.createRow(rowIndex++); 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(1, 18 * 256); + sheet.setColumnWidth(2, 18 * 256); } workbook.write(fos); } catch (Exception ex) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java index 926d493..602787b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java @@ -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.ShopMatchSubmitResultRequest; 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.FileTaskMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; @@ -69,6 +71,7 @@ public class ShopMatchTaskService { private final TaskResultPayloadService taskResultPayloadService; private final TaskFileJobService taskFileJobService; private final TaskDistributedLockService taskDistributedLockService; + private final SkipPriceAsinService skipPriceAsinService; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = shopMatchTaskCacheService.getTaskCacheBatch(List.of(taskId)); @@ -381,6 +384,7 @@ public class ShopMatchTaskService { } List countryCodes = normalizeCountryCodes(request.getCountryCodes()); + enrichItemsWithSkipAsins(uniqueItems, countryCodes); List scheduleTimes = normalizeScheduleTimes(request.getScheduleTimes()); LocalDateTime now = now(); @@ -962,6 +966,8 @@ public class ShopMatchTaskService { vo.setMatched(item.isMatched()); vo.setMatchStatus(item.getMatchStatus()); vo.setMatchMessage(item.getMatchMessage()); + vo.setSkipAsinsByCountry(item.getSkipAsinsByCountry()); + vo.setSkipAsinDetailsByCountry(item.getSkipAsinDetailsByCountry()); vo.setTaskStatus(taskStatus); vo.setSuccess(false); vo.setError(null); @@ -1078,6 +1084,8 @@ public class ShopMatchTaskService { vo.setCompanyName(item.getCompanyName()); vo.setMatchStatus(item.getMatchStatus()); vo.setMatchMessage(item.getMatchMessage()); + vo.setSkipAsinsByCountry(item.getSkipAsinsByCountry()); + vo.setSkipAsinDetailsByCountry(item.getSkipAsinDetailsByCountry()); break; } } @@ -1164,6 +1172,36 @@ public class ShopMatchTaskService { return new ArrayList<>(byNorm.values()); } + private void enrichItemsWithSkipAsins(List items, List countryCodes) { + List shopNames = items.stream() + .map(ProductRiskShopQueueItemVo::getShopName) + .filter(name -> name != null && !name.isBlank()) + .distinct() + .toList(); + Map>> detailsByShop = + skipPriceAsinService.listSkipAsinDetailsByShopAndCountries(shopNames, countryCodes); + for (ProductRiskShopQueueItemVo item : items) { + Map> detailMap = + detailsByShop.getOrDefault(item.getShopName(), Map.of()); + Map> normalizedDetails = new LinkedHashMap<>(); + Map> asinsByCountry = new LinkedHashMap<>(); + for (String countryCode : countryCodes) { + List 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 normalizeCountryCodes(List raw) { if (raw == null || raw.isEmpty()) { throw new BusinessException("country_codes 不能为空"); @@ -1234,6 +1272,7 @@ public class ShopMatchTaskService { if (incoming.getError() != null && !incoming.getError().isBlank()) { merged.setError(incoming.getError().trim()); } + mergeSkipAsinPayload(merged, incoming); Map> nextCountries = merged.getCountries() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(merged.getCountries()); if (incoming.getCountries() != null && !incoming.getCountries().isEmpty()) { @@ -1245,10 +1284,131 @@ public class ShopMatchTaskService { } } merged.setCountries(nextCountries); + appendSkipAsinRows(merged); shopMatchTaskCacheService.saveShopMergedPayload(taskId, shopKey, merged); return merged; } + private void mergeSkipAsinPayload(ShopMatchShopPayloadDto merged, ShopMatchShopPayloadDto incoming) { + Map> nextDetails = + merged.getSkipAsinDetailsByCountry() == null ? new LinkedHashMap<>() + : new LinkedHashMap<>(merged.getSkipAsinDetailsByCountry()); + if (incoming.getSkipAsinDetailsByCountry() != null && !incoming.getSkipAsinDetailsByCountry().isEmpty()) { + for (Map.Entry> 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> nextAsins = + merged.getSkipAsinsByCountry() == null ? new LinkedHashMap<>() + : new LinkedHashMap<>(merged.getSkipAsinsByCountry()); + if (incoming.getSkipAsinsByCountry() != null && !incoming.getSkipAsinsByCountry().isEmpty()) { + for (Map.Entry> 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 mergeSkipAsinDetails(List existing, + List incoming) { + LinkedHashMap 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 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 mergeSkipAsins(List existing, List incoming) { + LinkedHashSet 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> countries = + payload.getCountries() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(payload.getCountries()); + Map> detailsByCountry = + payload.getSkipAsinDetailsByCountry() == null ? Map.of() : payload.getSkipAsinDetailsByCountry(); + Map> asinsByCountry = + payload.getSkipAsinsByCountry() == null ? Map.of() : payload.getSkipAsinsByCountry(); + + LinkedHashSet countryCodes = new LinkedHashSet<>(); + countryCodes.addAll(detailsByCountry.keySet()); + countryCodes.addAll(asinsByCountry.keySet()); + for (ProductRiskCountryCode countryCode : countryCodes) { + if (countryCode == null) { + continue; + } + LinkedHashMap rowsByAsin = new LinkedHashMap<>(); + List 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 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 mergeCountryRows(List existingRows, List incomingRows) { LinkedHashMap byKey = new LinkedHashMap<>(); if (existingRows != null) { @@ -1289,6 +1449,9 @@ public class ShopMatchTaskService { if (incoming.getAsin() != null && !incoming.getAsin().isBlank()) { merged.setAsin(incoming.getAsin()); } + if (incoming.getMinimumPrice() != null && !incoming.getMinimumPrice().isBlank()) { + merged.setMinimumPrice(incoming.getMinimumPrice()); + } if (incoming.getStatus() != null && !incoming.getStatus().isBlank()) { merged.setStatus(incoming.getStatus()); } @@ -1300,12 +1463,17 @@ public class ShopMatchTaskService { ShopMatchRowDto out = new ShopMatchRowDto(); if (row != null) { out.setAsin(row.getAsin()); + out.setMinimumPrice(row.getMinimumPrice()); out.setStatus(row.getStatus()); out.setDone(row.getDone()); } return out; } + private String normalizeAsin(String asin) { + return asin == null ? "" : asin.trim().toUpperCase(Locale.ROOT); + } + private boolean isShopPayloadCompleted(ShopMatchShopPayloadDto payload) { return countPayloadRows(payload) > 0 && payloadHasAnyDoneTrue(payload); } @@ -1329,9 +1497,12 @@ public class ShopMatchTaskService { } private boolean payloadHasAnyDoneTrue(ShopMatchShopPayloadDto payload) { - if (payload == null || payload.getCountries() == null) { + if (payload == null) { return false; } + if (payload.getCountries() == null || payload.getCountries().isEmpty()) { + return hasSkipAsinPayload(payload); + } for (List rows : payload.getCountries().values()) { if (rows == null) { continue; @@ -1345,6 +1516,27 @@ public class ShopMatchTaskService { return false; } + private boolean hasSkipAsinPayload(ShopMatchShopPayloadDto payload) { + if (payload == null) { + return false; + } + if (payload.getSkipAsinDetailsByCountry() != null) { + for (List 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 asins : payload.getSkipAsinsByCountry().values()) { + if (asins != null && asins.stream().anyMatch(asin -> !normalizeAsin(asin).isBlank())) { + return true; + } + } + } + return false; + } + private boolean isEmptyBusinessRow(ShopMatchRowDto row) { return row == null || row.getAsin() == null || row.getAsin().trim().isEmpty(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java index ed70cd3..8668fa9 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java @@ -572,6 +572,11 @@ public class SimilarAsinCozeClient { || message.contains("Read timed out") || message.contains("Connection reset") || 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"); } @@ -779,7 +784,9 @@ public class SimilarAsinCozeClient { private String resolveFailureMessage(JsonNode root) { 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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/entity/SimilarAsinFilterConditionEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/entity/SimilarAsinFilterConditionEntity.java index 39f4c93..f8bb706 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/entity/SimilarAsinFilterConditionEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/entity/SimilarAsinFilterConditionEntity.java @@ -15,5 +15,6 @@ public class SimilarAsinFilterConditionEntity { private Long id; private Long userId; private String conditionText; + private String conditionHash; private LocalDateTime createdAt; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index 65a64aa..4c07082 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -106,6 +106,11 @@ public class SimilarAsinTaskService { private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L; private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5); 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 List RESULT_HEADERS = List.of( "id", @@ -159,9 +164,11 @@ public class SimilarAsinTaskService { if (text.isBlank()) { throw new BusinessException("筛选条件不能为空"); } + String conditionHash = DigestUtil.sha256Hex(text); SimilarAsinFilterConditionEntity existing = filterConditionMapper.selectOne( new LambdaQueryWrapper() .eq(SimilarAsinFilterConditionEntity::getUserId, request.getUserId()) + .eq(SimilarAsinFilterConditionEntity::getConditionHash, conditionHash) .eq(SimilarAsinFilterConditionEntity::getConditionText, text) .last("limit 1")); if (existing != null) { @@ -170,8 +177,22 @@ public class SimilarAsinTaskService { SimilarAsinFilterConditionEntity entity = new SimilarAsinFilterConditionEntity(); entity.setUserId(request.getUserId()); entity.setConditionText(text); + entity.setConditionHash(conditionHash); entity.setCreatedAt(LocalDateTime.now()); - filterConditionMapper.insert(entity); + try { + filterConditionMapper.insert(entity); + } catch (DuplicateKeyException ex) { + existing = filterConditionMapper.selectOne( + new LambdaQueryWrapper() + .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); } @@ -461,6 +482,7 @@ public class SimilarAsinTaskService { completeSubmittedChunk(context); return null; }); + submitCozeForSubmittedChunk(context); return; } FileTaskEntity task = fileTaskMapper.selectById(taskId); @@ -538,12 +560,15 @@ public class SimilarAsinTaskService { 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()) { finalizeTask(task, request.getError(), allRowCount(task), true); } else { task.setUpdatedAt(LocalDateTime.now()); fileTaskMapper.updateById(task); } + submitCozeForSubmittedChunk(context); } @Transactional @@ -706,7 +731,7 @@ public class SimilarAsinTaskService { upsertScopeState(taskId, scopeKey, scopeHash, chunkTotal, request.getError(), done, false); task.setUpdatedAt(LocalDateTime.now()); 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) { @@ -728,6 +753,43 @@ public class SimilarAsinTaskService { 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() + .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> 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) { FileTaskEntity task = fileTaskMapper.selectById(taskId); 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) { String finalError = error; FileResultEntity result = null; + boolean hasPersistedResultRows = hasPersistedResultRows(task.getId()); List rows = fileResultMapper.selectList(new LambdaQueryWrapper() .eq(FileResultEntity::getTaskId, task.getId()) .eq(FileResultEntity::getModuleType, MODULE_TYPE) .last("limit 1")); if (!rows.isEmpty()) { 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 { assembleResultWorkbook(task, result); } catch (Exception ex) { @@ -1155,20 +1223,67 @@ public class SimilarAsinTaskService { result.setSuccess(failed ? 0 : 1); result.setErrorMessage(finalError); 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.setResultFileUrl(null); result.setResultFileSize(0L); result.setResultContentType(CONTENT_TYPE_XLSX); } fileResultMapper.updateById(result); - if (!failed && assembleWorkbook) { - taskFileJobService.enqueueAssembleResult(task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task)); + if (shouldAssembleResult) { + 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()); } + private FileResultEntity findOrCreateResultRecordForAssembly(FileTaskEntity task, int rowCount) { + if (task == null || task.getId() == null) { + return null; + } + List rows = fileResultMapper.selectList(new LambdaQueryWrapper() + .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 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() { return false; } @@ -1197,6 +1312,12 @@ public class SimilarAsinTaskService { Map> allRowsByBaseId = loadAllRowsByBaseId(task); int cozeWorkUnits = countCozeWorkUnits(chunks, Math.max(1, properties.getCozeBatchSize())); 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"); boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId); if (pendingCoze) { @@ -1205,11 +1326,17 @@ public class SimilarAsinTaskService { saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result"); 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); 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() { DistributedJobLockService.LockHandle lockHandle = distributedJobLockService.tryLock("similar-asin:coze-poll", Duration.ofMinutes(1)); @@ -1299,7 +1426,7 @@ public class SimilarAsinTaskService { || COZE_STATUS_RUNNING.equals(existing.getCozeStatus()); } try { - SimilarAsinCozeClient.CozeSubmitResponse submit = cozeClient.submitWorkflow(batchRows, prompt, apiKey); + SimilarAsinCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled(batchRows, prompt, apiKey); if (submit.immediateData() != null && !submit.immediateData().isBlank()) { List cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData()); mergeCozeRowsIntoChunk(task, chunk.getScopeHash(), chunk.getChunkIndex(), cozeRows, allRowsByBaseId); @@ -1349,7 +1476,8 @@ public class SimilarAsinTaskService { chunk.getChunkIndex(), batchIndex, batchTotal, - currentInstanceId() + currentInstanceId(), + 0 ); String batchPayload = writeJson(batchRows, "serialize coze batch payload failed"); String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast( @@ -1442,6 +1570,12 @@ public class SimilarAsinTaskService { if (batchRows.isEmpty() && failureMessage.isBlank()) { failureMessage = "Coze batch payload missing"; } + if (!failureMessage.isBlank() && splitRetryFailedCozeBatchState(state, context, batchRows, failureMessage)) { + return; + } + if (!failureMessage.isBlank() && retryFailedCozeBatchState(state, context, batchRows, failureMessage)) { + return; + } List cozeRows = failureMessage.isBlank() ? cozeClient.mergeRowsFromDataText(batchRows, poll.resolvedPayloadText()) : cozeClient.markRowsFailed(batchRows, failureMessage); @@ -1477,6 +1611,241 @@ public class SimilarAsinTaskService { } } + private boolean retryFailedCozeBatchState(TaskScopeStateEntity state, + CozeBatchContext context, + List 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> allRowsByBaseId = loadAllRowsByBaseId(task); + if (submit.immediateData() != null && !submit.immediateData().isBlank()) { + List 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() + .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 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 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> partitions = List.>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> allRowsByBaseId = loadAllRowsByBaseId(task); + int partIndex = 1; + for (List partRows : partitions) { + SimilarAsinCozeClient.CozeSubmitResponse submit = + submitCozeWorkflowThrottled(partRows, readAiPrompt(task), readApiKey(task)); + if (submit.immediateData() != null && !submit.immediateData().isBlank()) { + List 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 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) { int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper() .eq(TaskScopeStateEntity::getId, state.getId()) @@ -1538,22 +1907,7 @@ public class SimilarAsinTaskService { return; } try (lockHandle) { - if (countPendingCozeStates(taskId) > 0) { - return; - } - FileTaskEntity task = fileTaskMapper.selectById(taskId); - FileResultEntity result = fileResultMapper.selectById(context.resultId()); - TaskFileJobEntity job = taskFileJobService.findAssembleJob(taskId, MODULE_TYPE, context.resultId()); - if (task == null || result == null || job == null || "SUCCESS".equals(job.getStatus())) { - return; - } - int cozeWorkUnits = countCompletedCozeStates(taskId); - int totalProgressUnits = Math.max(3, cozeWorkUnits + 3); - completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits); - taskFileJobService.markSuccess(job, result.getResultFileUrl()); - cleanupResultFileJob(job); - log.info("[similar-asin] coze async job finalized taskId={} jobId={} resultId={} resultFileUrl={}", - taskId, job.getId(), result.getId(), result.getResultFileUrl()); + maybeFinalizeCozeJobLocked(taskId, context); } catch (Exception ex) { TaskFileJobEntity job = taskFileJobService.findAssembleJob(taskId, MODULE_TYPE, context.resultId()); 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) { TaskDistributedLockService.LockHandle lockHandle = taskDistributedLockService.acquire(MODULE_TYPE, taskId, TASK_LOCK_TTL, waitMillis); if (lockHandle == null) { @@ -1593,8 +1978,13 @@ public class SimilarAsinTaskService { assembleResultWorkbook(task, result); saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits - 1, "Uploading result file"); fileResultMapper.updateById(result); - task.setStatus(STATUS_SUCCESS); - task.setErrorMessage(null); + boolean taskAlreadyFailed = STATUS_FAILED.equals(task.getStatus()) + || (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.setFinishedAt(LocalDateTime.now()); 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) { if (state == null || state.getCozeSubmittedAt() == null) { return false; @@ -1689,6 +2096,19 @@ public class SimilarAsinTaskService { return count == null ? 0 : count.intValue(); } + private boolean isResultSubmissionComplete(Long taskId) { + if (taskId == null || taskId <= 0) { + return false; + } + Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper() + .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) { return countPendingCozeStates(taskId) > 0 || taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE) > 0; @@ -1878,6 +2298,16 @@ public class SimilarAsinTaskService { return result; } + private boolean hasPersistedResultRows(Long taskId) { + if (taskId == null || taskId <= 0) { + return false; + } + Long count = taskChunkMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + return count != null && count > 0; + } + private Map loadPersistedResultRowsWithRetry(Long taskId, int expectedParsedRows) { Map result = loadPersistedResultRows(taskId); if (expectedParsedRows <= 0 || !result.isEmpty()) { @@ -2620,6 +3050,7 @@ public class SimilarAsinTaskService { private record SubmitContext(FileTaskEntity task, String scopeKey, String scopeHash, + Integer chunkIndex, boolean forceFlush, String error) { } @@ -2630,7 +3061,8 @@ public class SimilarAsinTaskService { Integer chunkIndex, Integer batchIndex, Integer batchTotal, - String ownerInstanceId) { + String ownerInstanceId, + Integer submitRetryCount) { } private record ParsedWorkbook(int totalRows, int droppedRows, List headers, List allRows) { diff --git a/backend-java/src/main/resources/application-local.example.yml b/backend-java/src/main/resources/application-local.example.yml index 220a2bf..c004fba 100644 --- a/backend-java/src/main/resources/application-local.example.yml +++ b/backend-java/src/main/resources/application-local.example.yml @@ -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_ID=7632683471312355338 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_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_ID=7632683471312355338 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_STALE_TIMEOUT_MINUTES=20 diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 3c95300..098c13c 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -147,10 +147,10 @@ aiimage: coze-workflow-path: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH:/v1/workflow/run} coze-workflow-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7632683471312355338} 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-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} stale-timeout-minutes: ${AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES:20} 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-id: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID:7632683471312355338} 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-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} stale-timeout-minutes: ${AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES:20} stale-finalize-cron: ${AIIMAGE_SIMILAR_ASIN_STALE_FINALIZE_CRON:0 */2 * * * *} diff --git a/backend-java/src/main/resources/db/V48__similar_asin_filter_condition_text.sql b/backend-java/src/main/resources/db/V48__similar_asin_filter_condition_text.sql new file mode 100644 index 0000000..2c3d4f1 --- /dev/null +++ b/backend-java/src/main/resources/db/V48__similar_asin_filter_condition_text.sql @@ -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; diff --git a/backend-java/src/main/resources/db/V49__admin_product_categories_menu.sql b/backend-java/src/main/resources/db/V49__admin_product_categories_menu.sql new file mode 100644 index 0000000..fc7dc54 --- /dev/null +++ b/backend-java/src/main/resources/db/V49__admin_product_categories_menu.sql @@ -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'; diff --git a/backend-java/src/main/resources/db/V50__product_category.sql b/backend-java/src/main/resources/db/V50__product_category.sql new file mode 100644 index 0000000..4a8c38f --- /dev/null +++ b/backend-java/src/main/resources/db/V50__product_category.sql @@ -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'); diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index 960f22d..ab1ca43 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -48,6 +48,8 @@ admin_api = Blueprint('admin_api', __name__, url_prefix='/api/admin') _backend_java_session_local = threading.local() _column_sort_schema_checked = False _column_sort_schema_lock = threading.Lock() +_product_category_schema_checked = False +_product_category_schema_lock = threading.Lock() ADMIN_MENU_ACCESS_CONFIG = { 'dedupe-total-data': { @@ -70,6 +72,11 @@ ADMIN_MENU_ACCESS_CONFIG = { 'route_path': 'query-asin', 'error': '无权访问查询 ASIN 模块', }, + 'product-categories': { + 'column_key': 'admin_product_categories', + 'route_path': 'product-categories', + 'error': '无权访问商品类目模块', + }, } ADMIN_MENU_ACCESS_CONFIG.update({ @@ -300,6 +307,92 @@ def _get_next_local_column_sort_order(): 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): _ensure_column_sort_schema() 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) +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'): normalized_paths = [(path or '').strip() for path in (route_paths or []) if (path or '').strip()] if not normalized_paths: @@ -496,6 +603,7 @@ def get_admin_current_user(): @admin_api.route('/current-user/menus') @login_required def get_admin_current_user_menus(): + _ensure_product_category_schema() _, _, items, denied = _load_current_backend_menu_items() if denied: return denied @@ -874,6 +982,7 @@ def list_columns(): _, _, denied = _ensure_admin_menu_access('columns', 'users') if denied: return denied + _ensure_product_category_schema() menu_type = (request.args.get('menu_type') or '').strip() local_rows = _load_local_columns(menu_type or None) items = [ @@ -1072,6 +1181,117 @@ def _set_user_column_permissions(cur, user_id, column_ids): # ---------- 版本管理(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/', 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/', 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_required def list_versions(): diff --git a/backend/config.py b/backend/config.py index db46cef..2b6f25a 100644 --- a/backend/config.py +++ b/backend/config.py @@ -23,10 +23,10 @@ bucket_path = "nanri-image/" file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/" 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://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_SECRET'] = accessKeySecret os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6" diff --git a/backend/static/admin.js b/backend/static/admin.js new file mode 100644 index 0000000..ee792dd --- /dev/null +++ b/backend/static/admin.js @@ -0,0 +1,3523 @@ +(function () { + // 全局请求动画:拦截本页所有 fetch 和 XMLHttpRequest 请求。 + (function installRequestLoadingInterceptor() { + if (window.__adminRequestLoadingInstalled) return; + window.__adminRequestLoadingInstalled = true; + var activeRequests = 0; + var showTimer = null; + var hideTimer = null; + var showDelayMs = 180; + var loadingEl = document.getElementById('requestLoading'); + var loadingBarEl = document.getElementById('requestLoadingBar'); + + function showRequestLoading() { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + if (!loadingEl || !loadingBarEl) return; + loadingBarEl.classList.remove('finishing'); + loadingBarEl.classList.add('show'); + loadingEl.classList.add('show'); + } + + function hideRequestLoading() { + if (showTimer) { + clearTimeout(showTimer); + showTimer = null; + } + if (!loadingEl || !loadingBarEl) return; + loadingBarEl.classList.remove('show'); + loadingBarEl.classList.add('finishing'); + loadingEl.classList.remove('show'); + hideTimer = setTimeout(function () { + loadingBarEl.classList.remove('finishing'); + }, 220); + } + + function beginRequest() { + activeRequests += 1; + if (activeRequests === 1) { + showTimer = setTimeout(showRequestLoading, showDelayMs); + } + } + + function endRequest() { + activeRequests = Math.max(0, activeRequests - 1); + if (activeRequests === 0) { + hideRequestLoading(); + } + } + + if (window.fetch) { + var nativeFetch = window.fetch.bind(window); + window.fetch = function (input, init) { + var options = init || {}; + var skipLoading = !!options.__skipLoading; + if (skipLoading) { + options = Object.assign({}, options); + delete options.__skipLoading; + } else { + beginRequest(); + } + try { + return nativeFetch(input, options).finally(function () { + if (!skipLoading) endRequest(); + }); + } catch (err) { + if (!skipLoading) endRequest(); + throw err; + } + }; + } + + if (window.XMLHttpRequest) { + var nativeOpen = XMLHttpRequest.prototype.open; + var nativeSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function () { + this.__adminSkipLoading = false; + return nativeOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function () { + if (!this.__adminSkipLoading) { + beginRequest(); + this.addEventListener('loadend', endRequest, { once: true }); + } + try { + return nativeSend.apply(this, arguments); + } catch (err) { + if (!this.__adminSkipLoading) endRequest(); + throw err; + } + }; + } + })(); + + // Tab 切换 + var adminTabsEl = document.getElementById('adminTabs'); + if (adminTabsEl) adminTabsEl.innerHTML = ''; + var activeAdminTabName = ''; + var ADMIN_PANEL_MAP = { + 'users': 'panel-users', + 'columns': 'panel-columns', + 'dedupe-total-data': 'panel-dedupe-total-data', + 'shop-keys': 'panel-shop-keys', + 'shop-manage': 'panel-shop-manage', + 'skip-price-asin': 'panel-skip-price-asin', + 'query-asin': 'panel-query-asin', + 'product-categories': 'panel-product-categories', + 'history': 'panel-history', + 'version': 'panel-version' + }; + function runTabLoader(tabName) { + if (tabName === 'users') { loadUsers(1); loadColumnsForPermission(); } + else if (tabName === 'columns') loadColumns(); + else if (tabName === 'dedupe-total-data') loadDedupeTotalData(1); + else if (tabName === 'shop-keys') loadShopKeys(1); + else if (tabName === 'shop-manage') loadShopManage(1); + else if (tabName === 'skip-price-asin') loadSkipPriceAsin(1); + else if (tabName === 'query-asin') loadQueryAsin(1); + else if (tabName === 'product-categories') loadProductCategories(); + else if (tabName === 'history') loadHistory(1); + else if (tabName === 'version') loadVersions(); + } + function hideAllAdminPanels() { + document.querySelectorAll('.tab-panel').forEach(function (panel) { + panel.classList.remove('active'); + panel.style.display = 'none'; + }); + } + function getActiveAdminTabName() { + return activeAdminTabName; + } + function activateAdminTab(tabName) { + var panelId = ADMIN_PANEL_MAP[tabName]; + var panel = panelId ? document.getElementById(panelId) : null; + if (!panel) return; + activeAdminTabName = tabName; + document.querySelectorAll('#adminTabs .tab').forEach(function (tab) { + tab.classList.toggle('active', tab.dataset.tab === tabName); + }); + hideAllAdminPanels(); + panel.style.display = ''; + panel.classList.add('active'); + runTabLoader(tabName); + } + function renderAdminTabs(items) { + var knownItems = (items || []).filter(function (item) { + return !!ADMIN_PANEL_MAP[item.route_path]; + }); + if (!knownItems.length) { + activeAdminTabName = ''; + if (adminTabsEl) adminTabsEl.innerHTML = '
暂无可用菜单
'; + hideAllAdminPanels(); + return; + } + if (adminTabsEl) { + adminTabsEl.innerHTML = knownItems.map(function (item) { + return '
' + (item.name || item.route_path) + '
'; + }).join(''); + } + document.querySelectorAll('#adminTabs .tab').forEach(function (tab) { + tab.onclick = function () { + activateAdminTab(tab.dataset.tab); + }; + }); + var fallbackTab = activeAdminTabName && knownItems.some(function (item) { return item.route_path === activeAdminTabName; }) + ? activeAdminTabName + : knownItems[0].route_path; + activateAdminTab(fallbackTab); + } + function loadAdminMenus(preferredTab) { + if (preferredTab) activeAdminTabName = preferredTab; + fetch('/api/admin/current-user/menus') + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + if (adminTabsEl) adminTabsEl.innerHTML = '
菜单加载失败
'; + hideAllAdminPanels(); + return; + } + renderAdminTabs(res.items || []); + }) + .catch(function () { + if (adminTabsEl) adminTabsEl.innerHTML = '
菜单加载失败
'; + hideAllAdminPanels(); + }); + } + hideAllAdminPanels(); + + // ========== 用户管理 ========== + var userPage = 1, userPageSize = 15; + var currentUserId = null; + var currentUserRole = 'admin'; + var currentUserUsername = ''; + var adminsList = []; + function roleLabel(role) { + if (role === 'super_admin') return '超级管理员'; + if (role === 'admin') return '管理员'; + return '普通账号'; + } + function buildUserListQuery(page) { + var q = 'page=' + (page || 1) + '&page_size=' + userPageSize; + var kw = (document.getElementById('searchUsername').value || '').trim(); + if (kw) q += '&username=' + encodeURIComponent(kw); + var cby = document.getElementById('filterCreatedBy').value; + if (cby) q += '&created_by_id=' + encodeURIComponent(cby); + return q; + } + function loadUsers(page) { + userPage = page || 1; + fetch('/api/admin/users?' + buildUserListQuery(userPage)) + .then(function (r) { return r.json(); }) + .then(function (res) { + var tbody = document.getElementById('userListBody'); + if (!res.success) { + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + return; + } + currentUserId = res.current_user_id || null; + currentUserRole = res.current_user_role || 'admin'; + currentUserUsername = (res.current_user_username || '').trim(); + adminsList = res.admins || []; + var items = res.items || []; + if (items.length === 0) { + tbody.innerHTML = '暂无用户'; + } else { + tbody.innerHTML = items.map(function (u) { + return '' + u.id + '' + (u.username || '') + '' + + roleLabel(u.role || 'normal') + '' + (u.creator_username || '-') + '' + (u.created_at || '') + '' + + ' ' + + '' + + ''; + }).join(''); + } + renderPagination('userPagination', res.total, res.page, res.page_size, loadUsers); + bindUserActions(); + updateCreateFormByRole(); + updateUserFilterByRole(); + }) + .catch(function () { + document.getElementById('userListBody').innerHTML = '请求失败'; + }); + } + function updateUserFilterByRole() { + var grp = document.getElementById('filterCreatedByGroup'); + var sel = document.getElementById('filterCreatedBy'); + if (currentUserRole === 'super_admin') { + grp.style.display = 'block'; + var cur = sel.value; + sel.innerHTML = ''; + adminsList.forEach(function (a) { + var opt = document.createElement('option'); + opt.value = a.id; + opt.textContent = a.username; + sel.appendChild(opt); + }); + sel.value = cur || ''; + } else { + grp.style.display = 'none'; + } + } + + function updateDedupeTotalDataAccess() { + var tab = document.querySelector('.tab[data-tab="dedupe-total-data"]'); + var panel = document.getElementById('panel-dedupe-total-data'); + var canUse = currentUserRole === 'super_admin' || (currentUserRole === 'admin' && currentUserUsername === ''); + if (tab) tab.style.display = canUse ? '' : 'none'; + if (panel) panel.style.display = canUse ? '' : 'none'; + if (!canUse && tab && tab.classList.contains('active')) { + tab.classList.remove('active'); + if (panel) panel.classList.remove('active'); + var usersTab = document.querySelector('.tab[data-tab="users"]'); + var usersPanel = document.getElementById('panel-users'); + if (usersTab) usersTab.classList.add('active'); + if (usersPanel) usersPanel.classList.add('active'); + } + } + function updateShopManageAccess() { + var tab = document.querySelector('.tab[data-tab="shop-manage"]'); + var panel = document.getElementById('panel-shop-manage'); + if (!tab || !panel) return; + var canUse = currentUserRole === 'super_admin' || + !!currentUserAdminPermissionKeys['admin_shop_manage'] || + !!currentUserAdminPermissionRoutes['shop-manage']; + tab.style.display = canUse ? '' : 'none'; + panel.style.display = canUse ? '' : 'none'; + if (!canUse && tab.classList.contains('active')) { + tab.classList.remove('active'); + panel.classList.remove('active'); + var usersTab = document.querySelector('.tab[data-tab="users"]'); + var usersPanel = document.getElementById('panel-users'); + if (usersTab) usersTab.classList.add('active'); + if (usersPanel) usersPanel.classList.add('active'); + } + } + function loadCurrentUserAdminPermissions() { + currentUserAdminPermissionKeys = {}; + currentUserAdminPermissionRoutes = {}; + if (!currentUserId || currentUserRole === 'super_admin') { + updateShopManageAccess(); + return; + } + fetch('/api/admin/user/' + currentUserId + '/column-permissions') + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + updateShopManageAccess(); + return; + } + (res.items || []).forEach(function (item) { + var columnKey = (item.column_key || '').trim(); + var routePath = (item.route_path || '').trim(); + if (columnKey) currentUserAdminPermissionKeys[columnKey] = true; + if (routePath) currentUserAdminPermissionRoutes[routePath] = true; + }); + updateShopManageAccess(); + }) + .catch(function () { + updateShopManageAccess(); + }); + } + function applyTabAccess(tabName, canUse) { + var tab = document.querySelector('.tab[data-tab="' + tabName + '"]'); + var panel = document.getElementById('panel-' + tabName); + if (tab) tab.style.display = canUse ? '' : 'none'; + if (panel) panel.style.display = canUse ? '' : 'none'; + if (!canUse && tab && tab.classList.contains('active')) { + tab.classList.remove('active'); + if (panel) panel.classList.remove('active'); + var usersTab = document.querySelector('.tab[data-tab="users"]'); + var usersPanel = document.getElementById('panel-users'); + if (usersTab) usersTab.classList.add('active'); + if (usersPanel) usersPanel.classList.add('active'); + } + } + function hasAdminTabAccess(tabName) { + var config = ADMIN_TAB_ACCESS_CONFIG[tabName]; + if (!config) return true; + if (currentUserRole === 'super_admin') return true; + if (config.superAdminOnly) return false; + return !!currentUserAdminPermissionKeys[config.columnKey] || + !!currentUserAdminPermissionRoutes[config.routePath]; + } + function refreshAdminTabAccess() { + Object.keys(ADMIN_TAB_ACCESS_CONFIG).forEach(function (tabName) { + applyTabAccess(tabName, hasAdminTabAccess(tabName)); + }); + } + function loadCurrentUserAdminPermissions() { + currentUserAdminPermissionKeys = {}; + currentUserAdminPermissionRoutes = {}; + if (!currentUserId || currentUserRole === 'super_admin') { + refreshAdminTabAccess(); + return; + } + fetch('/api/admin/user/' + currentUserId + '/column-permissions') + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + refreshAdminTabAccess(); + return; + } + (res.items || []).forEach(function (item) { + var columnKey = (item.column_key || '').trim(); + var routePath = (item.route_path || '').trim(); + if (columnKey) currentUserAdminPermissionKeys[columnKey] = true; + if (routePath) currentUserAdminPermissionRoutes[routePath] = true; + }); + refreshAdminTabAccess(); + }) + .catch(function () { + refreshAdminTabAccess(); + }); + } + var allColumnsList = []; + function loadColumnsForPermission() { + fetch('/api/admin/columns') + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) return; + allColumnsList = res.items || []; + renderColumnPermissionWrap('createColumnPermissionWrap'); + renderColumnPermissionWrap('editColumnPermissionWrap'); + var selCreate = document.getElementById('createColumnPermissionWrap'); + var selEdit = document.getElementById('editColumnPermissionWrap'); + if (selCreate && !selCreate._colCardsBound) { + selCreate._colCardsBound = true; + selCreate.onchange = function () { renderColumnCards('createColumnPermissionWrap'); }; + } + if (selEdit && !selEdit._colCardsBound) { + selEdit._colCardsBound = true; + selEdit.onchange = function () { renderColumnCards('editColumnPermissionWrap'); }; + } + }) + .catch(function () { }); + } + var columnCardsContainerMap = { createColumnPermissionWrap: 'createColumnCards', editColumnPermissionWrap: 'editColumnCards' }; + function renderColumnCards(selectId) { + var sel = document.getElementById(selectId); + var cardsId = columnCardsContainerMap[selectId]; + var cardsEl = cardsId ? document.getElementById(cardsId) : null; + if (!sel || sel.tagName !== 'SELECT' || !cardsEl) return; + cardsEl.innerHTML = ''; + for (var i = 0; i < sel.options.length; i++) { + var opt = sel.options[i]; + if (opt.disabled || !opt.selected) continue; + var card = document.createElement('span'); + card.className = 'column-permission-card'; + card.textContent = opt.textContent; + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'col-card-remove'; + btn.setAttribute('aria-label', '移除'); + btn.textContent = '×'; + (function (option, seldId) { + btn.onclick = function () { + option.selected = false; + renderColumnCards(seldId); + }; + })(opt, selectId); + card.appendChild(btn); + cardsEl.appendChild(card); + } + } + function renderColumnPermissionWrap(wrapId) { + var sel = document.getElementById(wrapId); + if (!sel || sel.tagName !== 'SELECT') return; + sel.innerHTML = ''; + allColumnsList.forEach(function (c) { + var opt = document.createElement('option'); + opt.value = c.id; + opt.textContent = c.name + ' (' + c.column_key + ')'; + sel.appendChild(opt); + }); + if (allColumnsList.length === 0) { + var opt = document.createElement('option'); + opt.disabled = true; + opt.textContent = '暂无菜单,请先在“栏目权限配置”中新增菜单'; + sel.appendChild(opt); + } + renderColumnCards(wrapId); + } + function getSelectedColumnIds(wrapId) { + var sel = document.getElementById(wrapId); + if (!sel || sel.tagName !== 'SELECT') return []; + var ids = []; + for (var i = 0; i < sel.options.length; i++) { + if (sel.options[i].selected) { + var v = parseInt(sel.options[i].value, 10); + if (!isNaN(v)) ids.push(v); + } + } + return ids; + } + function setColumnPermissionCheckboxes(wrapId, columnIds) { + var sel = document.getElementById(wrapId); + if (!sel || sel.tagName !== 'SELECT') return; + var set = {}; + (columnIds || []).forEach(function (id) { set[id] = true; }); + for (var i = 0; i < sel.options.length; i++) { + var opt = sel.options[i]; + if (opt.disabled) continue; + opt.selected = set[parseInt(opt.value, 10)] || false; + } + renderColumnCards(wrapId); + } + function updateCreateFormByRole() { + var roleSel = document.getElementById('createRole'); + var optAdmin = document.getElementById('optAdmin'); + var formCreatedBy = document.getElementById('formGroupCreatedBy'); + var selCreatedBy = document.getElementById('createCreatedBy'); + if (currentUserRole === 'super_admin') { + if (optAdmin) optAdmin.style.display = ''; + formCreatedBy.style.display = (roleSel.value === 'normal') ? 'block' : 'none'; + selCreatedBy.innerHTML = ''; + adminsList.forEach(function (a) { + var opt = document.createElement('option'); + opt.value = a.id; + opt.textContent = a.username; + selCreatedBy.appendChild(opt); + }); + } else { + if (optAdmin) optAdmin.style.display = 'none'; + roleSel.value = 'normal'; + formCreatedBy.style.display = 'none'; + } + } + function bindUserActions() { + document.querySelectorAll('[data-edit]').forEach(function (btn) { + btn.onclick = function () { + var raw = (btn.getAttribute('data-user') || '{}').replace(/"/g, '"'); + var u; + try { u = JSON.parse(raw); } catch (e) { u = {}; } + document.getElementById('editUserId').value = u.id || ''; + document.getElementById('editUsername').value = u.username || ''; + document.getElementById('editPassword').value = ''; + var editRole = document.getElementById('editRole'); + var editFormGroupRole = document.getElementById('editFormGroupRole'); + var editFormGroupCreator = document.getElementById('editFormGroupCreator'); + var editCreatorName = document.getElementById('editCreatorName'); + editFormGroupRole.style.display = (currentUserRole === 'super_admin' && u.role !== 'super_admin') ? 'block' : 'none'; + editFormGroupCreator.style.display = (u.role === 'normal' && u.creator_username) ? 'block' : 'none'; + editCreatorName.value = u.creator_username || ''; + if (u.role !== 'super_admin') { editRole.value = u.role || 'normal'; } + document.getElementById('msgEdit').textContent = ''; + if (allColumnsList.length) { + renderColumnPermissionWrap('editColumnPermissionWrap'); + fetch('/api/admin/user/' + (u.id) + '/columns') + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success && res.column_ids) setColumnPermissionCheckboxes('editColumnPermissionWrap', res.column_ids); + }); + } + document.getElementById('editUserModal').classList.add('show'); + }; + }); + document.querySelectorAll('[data-delete]').forEach(function (btn) { + btn.onclick = function () { + if (!confirm('确定删除用户 "' + (btn.dataset.name || '') + '" 吗?')) return; + fetch('/api/admin/user/' + btn.dataset.delete, { method: 'DELETE' }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { loadUsers(userPage); } + else { alert(res.error || '删除失败'); } + }); + }; + }); + } + + document.getElementById('btnSearchUsers').onclick = function () { loadUsers(1); }; + document.getElementById('createRole').onchange = function () { updateCreateFormByRole(); }; + document.getElementById('btnCreate').onclick = function () { + var username = (document.getElementById('username').value || '').trim(); + var password = document.getElementById('password').value || ''; + var role = document.getElementById('createRole').value || 'normal'; + var createdById = document.getElementById('createCreatedBy').value ? parseInt(document.getElementById('createCreatedBy').value, 10) : null; + var msgEl = document.getElementById('msgCreate'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!username || username.length < 2) { + msgEl.textContent = '用户名至少 2 个字符'; + msgEl.classList.add('err'); + return; + } + if (!password || password.length < 6) { + msgEl.textContent = '密码至少 6 个字符'; + msgEl.classList.add('err'); + return; + } + var body = { username: username, password: password, role: role }; + if (role === 'normal' && currentUserRole === 'super_admin' && createdById) body.created_by_id = createdById; + body.column_ids = getSelectedColumnIds('createColumnPermissionWrap'); + fetch('/api/admin/user', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { + msgEl.textContent = res.msg || '创建成功'; + msgEl.classList.add('ok'); + document.getElementById('username').value = ''; + document.getElementById('password').value = ''; + loadUsers(1); + } else { + msgEl.textContent = res.error || '创建失败'; + msgEl.classList.add('err'); + } + }) + .catch(function () { + msgEl.textContent = '请求失败'; + msgEl.classList.add('err'); + }); + }; + + document.getElementById('btnSaveUser').onclick = function () { + var uid = document.getElementById('editUserId').value; + var password = document.getElementById('editPassword').value; + var editRoleEl = document.getElementById('editRole'); + var msgEl = document.getElementById('msgEdit'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + var body = {}; + if (password) body.password = password; + if (currentUserRole === 'super_admin' && editRoleEl && editRoleEl.offsetParent !== null) + body.role = editRoleEl.value || 'normal'; + body.column_ids = getSelectedColumnIds('editColumnPermissionWrap'); + fetch('/api/admin/user/' + uid, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { + msgEl.textContent = res.msg || '保存成功'; + msgEl.classList.add('ok'); + document.getElementById('editUserModal').classList.remove('show'); + loadUsers(userPage); + if (String(uid || '') === String(currentUserId || '')) { + loadAdminMenus(getActiveAdminTabName()); + } + } else { + msgEl.textContent = res.error || '保存失败'; + msgEl.classList.add('err'); + } + }); + }; + document.getElementById('btnCloseEdit').onclick = function () { + document.getElementById('editUserModal').classList.remove('show'); + }; + + // ========== 生成记录 ========== + var historyPage = 1, historyPageSize = 15; + function toSqlDatetime(val) { + if (!val) return ''; + return val.replace('T', ' '); + } + function buildHistoryQuery(page) { + var q = 'page=' + (page || 1) + '&page_size=' + historyPageSize; + var uid = document.getElementById('filterUser').value; + var start = toSqlDatetime(document.getElementById('filterTimeStart').value); + var end = toSqlDatetime(document.getElementById('filterTimeEnd').value); + if (uid) q += '&user_id=' + uid; + if (start) q += '&time_start=' + encodeURIComponent(start); + if (end) q += '&time_end=' + encodeURIComponent(end); + return q; + } + function loadHistory(page) { + historyPage = page || 1; + fetch('/api/admin/history?' + buildHistoryQuery(historyPage)) + .then(function (r) { return r.json(); }) + .then(function (res) { + var tbody = document.getElementById('historyListBody'); + if (!res.success) { + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + return; + } + var items = res.items || []; + if (items.length === 0) { + tbody.innerHTML = '暂无记录'; + } else { + tbody.innerHTML = items.map(function (h) { + var urls = (h.result_urls || []); + var thumbUrls = (h.long_image_url ? [h.long_image_url] : []).concat(urls); + var thumbs = thumbUrls.slice(0, 3).map(function (url) { + return ''; + }).join(''); + return '' + h.id + '' + (h.username || '-') + '' + + (h.panel_type || '-') + '' + (h.created_at || '') + '' + + '
' + (thumbs || '-') + '
'; + }).join(''); + } + renderPagination('historyPagination', res.total, res.page, res.page_size, loadHistory); + }) + .catch(function () { + document.getElementById('historyListBody').innerHTML = '请求失败'; + }); + } + var shopManageAllUsers = []; + function escapeHtml(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + var dropdownMultiSelects = {}; + function initDropdownMultiSelect(selectId, placeholder) { + var select = document.getElementById(selectId); + if (!select || dropdownMultiSelects[selectId]) return; + + select.classList.add('multi-select-native'); + var wrapper = document.createElement('div'); + wrapper.className = 'multi-select-dropdown'; + wrapper.setAttribute('data-multi-select-id', selectId); + + var trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.className = 'multi-select-trigger'; + trigger.textContent = placeholder || '请选择'; + + var panel = document.createElement('div'); + panel.className = 'multi-select-panel'; + + select.parentNode.insertBefore(wrapper, select); + wrapper.appendChild(trigger); + wrapper.appendChild(panel); + wrapper.appendChild(select); + + function selectedOptions() { + return Array.from(select.options).filter(function (option) { return option.selected; }); + } + + function updateSummary() { + var selected = selectedOptions(); + if (!selected.length) { + trigger.textContent = placeholder || '请选择'; + trigger.title = ''; + return; + } + var labels = selected.map(function (option) { return option.textContent || option.value; }); + trigger.textContent = labels.join('、'); + trigger.title = labels.join('、'); + } + + function renderOptions() { + panel.innerHTML = Array.from(select.options).map(function (option, index) { + var optionId = selectId + '_multi_' + index; + return ''; + }).join(''); + panel.querySelectorAll('[data-multi-option-index]').forEach(function (checkbox) { + checkbox.onchange = function () { + var option = select.options[Number(checkbox.getAttribute('data-multi-option-index'))]; + if (!option) return; + option.selected = checkbox.checked; + updateSummary(); + select.dispatchEvent(new Event('change', { bubbles: true })); + }; + }); + } + + trigger.onclick = function (event) { + event.stopPropagation(); + document.querySelectorAll('.multi-select-dropdown.open').forEach(function (item) { + if (item !== wrapper) item.classList.remove('open'); + }); + wrapper.classList.toggle('open'); + }; + panel.onclick = function (event) { + event.stopPropagation(); + }; + select.addEventListener('change', function () { + panel.querySelectorAll('[data-multi-option-index]').forEach(function (checkbox) { + var option = select.options[Number(checkbox.getAttribute('data-multi-option-index'))]; + checkbox.checked = !!(option && option.selected); + }); + updateSummary(); + }); + + renderOptions(); + updateSummary(); + dropdownMultiSelects[selectId] = { + refresh: function () { + renderOptions(); + updateSummary(); + } + }; + } + + function refreshDropdownMultiSelect(selectId) { + if (dropdownMultiSelects[selectId]) { + dropdownMultiSelects[selectId].refresh(); + } + } + + document.addEventListener('click', function () { + document.querySelectorAll('.multi-select-dropdown.open').forEach(function (item) { + item.classList.remove('open'); + }); + }); + function getEligibleShopManageGroupUsers(leaderUserId) { + var canViewAllMembers = currentUserRole === 'super_admin'; + return shopManageAllUsers.filter(function (u) { + if (String(u.id || '') === String(leaderUserId || '')) return false; + if (canViewAllMembers) return true; + return String(u.role || '') === 'normal' && + String(u.created_by_id || '') === String(leaderUserId || ''); + }); + } + function refreshShopManageGroupMemberSelect(leaderUserId, selectedUserIds) { + var sel = document.getElementById('shopManageGroupMemberSelect'); + var helpEl = document.getElementById('shopManageGroupMemberHelp'); + if (!sel) return; + var selectedMap = {}; + (selectedUserIds || []).forEach(function (id) { + selectedMap[String(id)] = true; + }); + var eligibleUsers = getEligibleShopManageGroupUsers(leaderUserId); + var options = eligibleUsers.map(function (u) { + return ''; + }); + if (options.length) { + sel.innerHTML = options.join(''); + if (helpEl) { + helpEl.textContent = '可添加当前组长创建的普通员工账号,按住 Ctrl 或 Command 可多选。'; + } + return; + } + sel.innerHTML = ''; + if (helpEl) { + helpEl.textContent = currentUserRole === 'normal' + ? '普通账号没有下属普通员工时,这里会为空;当前账号只能作为组长使用。' + : '当前组长名下暂无可添加的普通员工账号。'; + } + } + function setShopManageGroupLeader(leaderUserId, leaderUsername, selectedUserIds) { + var leaderIdEl = document.getElementById('shopManageGroupLeaderUserId'); + var leaderNameEl = document.getElementById('shopManageGroupLeaderName'); + if (leaderIdEl) leaderIdEl.value = leaderUserId ? String(leaderUserId) : ''; + if (leaderNameEl) leaderNameEl.value = leaderUsername || ''; + refreshShopManageGroupMemberSelect(leaderUserId, selectedUserIds || []); + } + function getSelectedShopManageGroupMemberIds() { + var sel = document.getElementById('shopManageGroupMemberSelect'); + if (!sel) return []; + return Array.prototype.slice.call(sel.options || []) + .filter(function (opt) { return !!opt.selected; }) + .map(function (opt) { return Number(opt.value); }) + .filter(function (id) { return !!id; }); + } + function loadUserOptions() { + return fetch('/api/admin/users?page=1&page_size=999') + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + throw new Error(res.error || '加载用户失败'); + } + var items = res.items || []; + shopManageAllUsers = items.map(function (u) { + return { + id: u.id, + username: u.username || '', + role: u.role || 'normal', + created_by_id: u.created_by_id || null + }; + }); + setShopManageGroupLeader( + document.getElementById('shopManageGroupLeaderUserId') ? document.getElementById('shopManageGroupLeaderUserId').value : '', + document.getElementById('shopManageGroupLeaderName') ? document.getElementById('shopManageGroupLeaderName').value : '', + getSelectedShopManageGroupMemberIds() + ); + var sel = document.getElementById('filterUser'); + var cur = sel.value; + sel.innerHTML = ''; + items.forEach(function (u) { + var opt = document.createElement('option'); + opt.value = u.id; + opt.textContent = u.username + ' (' + roleLabel(u.role || 'normal') + ')'; + sel.appendChild(opt); + }); + sel.value = cur || ''; + }) + .catch(function () { + shopManageAllUsers = []; + var sel = document.getElementById('filterUser'); + if (sel) { + sel.innerHTML = ''; + } + refreshShopManageGroupMemberSelect( + document.getElementById('shopManageGroupLeaderUserId') ? document.getElementById('shopManageGroupLeaderUserId').value : '', + [] + ); + }); + } + document.getElementById('btnFilterHistory').onclick = function () { loadHistory(1); }; + + // ========== 数据去重总数据 ========== + var dedupeTotalDataPage = 1, dedupeTotalDataPageSize = 15; + function buildDedupeTotalDataQuery(page) { + var q = 'page=' + (page || 1) + '&page_size=' + dedupeTotalDataPageSize; + var keyword = (document.getElementById('searchDedupeTotalData').value || '').trim(); + if (keyword) q += '&keyword=' + encodeURIComponent(keyword); + return q; + } + function loadDedupeTotalData(page) { + dedupeTotalDataPage = page || 1; + fetch('/api/admin/dedupe-total-data?' + buildDedupeTotalDataQuery(dedupeTotalDataPage)) + .then(function (r) { return r.json(); }) + .then(function (res) { + var tbody = document.getElementById('dedupeTotalDataListBody'); + if (!res.success) { + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + return; + } + var items = res.items || []; + if (items.length === 0) { + tbody.innerHTML = '暂无总数据'; + } else { + tbody.innerHTML = items.map(function (item) { + return '' + item.id + '' + (item.data_value || '') + '' + (item.created_at || '') + '' + + ' ' + + '' + + ''; + }).join(''); + } + renderPagination('dedupeTotalDataPagination', res.total, res.page, res.page_size, loadDedupeTotalData); + bindDedupeTotalDataActions(); + }) + .catch(function () { + document.getElementById('dedupeTotalDataListBody').innerHTML = '请求失败'; + }); + } + function bindDedupeTotalDataActions() { + document.querySelectorAll('[data-dedupe-total-edit]').forEach(function (btn) { + btn.onclick = function () { + document.getElementById('editDedupeTotalDataId').value = btn.dataset.dedupeTotalEdit || ''; + document.getElementById('editDedupeTotalDataValue').value = (btn.dataset.value || '').replace(/"/g, '"'); + document.getElementById('msgEditDedupeTotalData').textContent = ''; + document.getElementById('msgEditDedupeTotalData').className = 'msg'; + document.getElementById('editDedupeTotalDataModal').classList.add('show'); + }; + }); + document.querySelectorAll('[data-dedupe-total-delete]').forEach(function (btn) { + btn.onclick = function () { + var value = (btn.dataset.value || '').replace(/"/g, '"'); + if (!confirm('确定删除总数据“' + value + '”吗?')) return; + fetch('/api/admin/dedupe-total-data/' + btn.dataset.dedupeTotalDelete, { method: 'DELETE' }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { loadDedupeTotalData(dedupeTotalDataPage); } + else { alert(res.error || '删除失败'); } + }); + }; + }); + } + document.getElementById('btnSearchDedupeTotalData').onclick = function () { loadDedupeTotalData(1); }; + var dedupeImportPollTimer = null; + var dedupeDeleteImportPollTimer = null; + function stopDedupeImportProgress() { + if (dedupeImportPollTimer) { + clearInterval(dedupeImportPollTimer); + dedupeImportPollTimer = null; + } + } + function stopDedupeDeleteImportProgress() { + if (dedupeDeleteImportPollTimer) { + clearInterval(dedupeDeleteImportPollTimer); + dedupeDeleteImportPollTimer = null; + } + } + function setDedupeImportProgress(percent, text) { + var wrap = document.getElementById('dedupeTotalDataProgressWrap'); + var fill = document.getElementById('dedupeTotalDataProgressFill'); + var textEl = document.getElementById('dedupeTotalDataProgressText'); + wrap.style.display = 'block'; + fill.style.width = Math.max(0, Math.min(100, percent || 0)) + '%'; + textEl.textContent = text || ''; + } + function setDedupeDeleteImportProgress(percent, text) { + var wrap = document.getElementById('dedupeTotalDataDeleteProgressWrap'); + var fill = document.getElementById('dedupeTotalDataDeleteProgressFill'); + var textEl = document.getElementById('dedupeTotalDataDeleteProgressText'); + wrap.style.display = 'block'; + fill.style.width = Math.max(0, Math.min(100, percent || 0)) + '%'; + textEl.textContent = text || ''; + } + function pollDedupeImport(importId) { + stopDedupeImportProgress(); + function tick() { + fetch('/api/admin/dedupe-total-data/import/' + encodeURIComponent(importId)) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + stopDedupeImportProgress(); + document.getElementById('msgDedupeTotalData').textContent = res.error || '查询导入进度失败'; + document.getElementById('msgDedupeTotalData').className = 'msg err'; + return; + } + var progress = res.progress || {}; + var totalRows = progress.total_rows || 0; + var processedRows = progress.processed_rows || 0; + var percent = totalRows > 0 ? Math.round(processedRows * 100 / totalRows) : 0; + setDedupeImportProgress(percent, '处理中:已处理 ' + processedRows + ' / ' + totalRows + ',新增 ' + (progress.inserted_count || 0) + ',跳过 ' + (progress.skipped_count || 0)); + if (progress.status === 'success') { + stopDedupeImportProgress(); + document.getElementById('msgDedupeTotalData').textContent = '导入成功:总行数 ' + (progress.total_rows || 0) + ',ASIN 数量 ' + (progress.asin_count || 0) + ',新增 ' + (progress.inserted_count || 0) + ',跳过 ' + (progress.skipped_count || 0); + document.getElementById('msgDedupeTotalData').className = 'msg ok'; + loadDedupeTotalData(1); + } else if (progress.status === 'failed') { + stopDedupeImportProgress(); + document.getElementById('msgDedupeTotalData').textContent = progress.error_message || '导入失败'; + document.getElementById('msgDedupeTotalData').className = 'msg err'; + } + }) + .catch(function () { + stopDedupeImportProgress(); + document.getElementById('msgDedupeTotalData').textContent = '查询导入进度失败'; + document.getElementById('msgDedupeTotalData').className = 'msg err'; + }); + } + tick(); + dedupeImportPollTimer = setInterval(tick, 1000); + } + function pollDedupeDeleteImport(importId) { + stopDedupeDeleteImportProgress(); + function tick() { + fetch('/api/admin/dedupe-total-data/delete-import/' + encodeURIComponent(importId)) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + stopDedupeDeleteImportProgress(); + document.getElementById('msgDeleteDedupeTotalData').textContent = res.error || '查询删除进度失败'; + document.getElementById('msgDeleteDedupeTotalData').className = 'msg err'; + return; + } + var progress = res.progress || {}; + var totalRows = progress.total_rows || 0; + var processedRows = progress.processed_rows || 0; + var percent = totalRows > 0 ? Math.round(processedRows * 100 / totalRows) : 0; + setDedupeDeleteImportProgress(percent, '处理中:已处理 ' + processedRows + ' / ' + totalRows + ',删除 ' + (progress.deleted_count || 0) + ',跳过 ' + (progress.skipped_count || 0)); + if (progress.status === 'success') { + stopDedupeDeleteImportProgress(); + document.getElementById('msgDeleteDedupeTotalData').textContent = '删除成功:总行数 ' + (progress.total_rows || 0) + ',ASIN 数量 ' + (progress.asin_count || 0) + ',删除 ' + (progress.deleted_count || 0) + ',跳过 ' + (progress.skipped_count || 0); + document.getElementById('msgDeleteDedupeTotalData').className = 'msg ok'; + loadDedupeTotalData(1); + } else if (progress.status === 'failed') { + stopDedupeDeleteImportProgress(); + document.getElementById('msgDeleteDedupeTotalData').textContent = progress.error_message || '删除失败'; + document.getElementById('msgDeleteDedupeTotalData').className = 'msg err'; + } + }) + .catch(function () { + stopDedupeDeleteImportProgress(); + document.getElementById('msgDeleteDedupeTotalData').textContent = '查询删除进度失败'; + document.getElementById('msgDeleteDedupeTotalData').className = 'msg err'; + }); + } + tick(); + dedupeDeleteImportPollTimer = setInterval(tick, 1000); + } + + document.getElementById('btnAddDedupeTotalData').onclick = function () { + var fileInput = document.getElementById('dedupeTotalDataFile'); + var msgEl = document.getElementById('msgDedupeTotalData'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + stopDedupeImportProgress(); + document.getElementById('dedupeTotalDataProgressWrap').style.display = 'none'; + if (!fileInput.files || fileInput.files.length === 0) { + msgEl.textContent = '请选择 Excel 文件'; + msgEl.classList.add('err'); + return; + } + var file = fileInput.files[0]; + var lowerName = (file.name || '').toLowerCase(); + if (!(lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls'))) { + msgEl.textContent = '仅支持 .xlsx 或 .xls 文件'; + msgEl.classList.add('err'); + return; + } + var formData = new FormData(); + formData.append('file', file); + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/admin/dedupe-total-data/import', true); + xhr.upload.onprogress = function (event) { + if (event.lengthComputable) { + var percent = Math.round(event.loaded * 100 / event.total); + setDedupeImportProgress(percent, '上传中:' + percent + '%'); + } + }; + xhr.onreadystatechange = function () { + if (xhr.readyState !== 4) return; + if (xhr.status < 200 || xhr.status >= 300) { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + return; + } + var res; + try { res = JSON.parse(xhr.responseText || '{}'); } catch (e) { res = { success: false, error: '返回格式错误' }; } + if (!res.success) { + msgEl.textContent = res.error || '导入失败'; + msgEl.className = 'msg err'; + return; + } + setDedupeImportProgress(100, '上传完成,后端处理中...'); + pollDedupeImport(res.import_id); + fileInput.value = ''; + }; + xhr.onerror = function () { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + }; + xhr.send(formData); + }; + document.getElementById('btnDeleteImportDedupeTotalData').onclick = function () { + var fileInput = document.getElementById('dedupeTotalDataDeleteFile'); + var msgEl = document.getElementById('msgDeleteDedupeTotalData'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + stopDedupeDeleteImportProgress(); + document.getElementById('dedupeTotalDataDeleteProgressWrap').style.display = 'none'; + if (!fileInput.files || fileInput.files.length === 0) { + msgEl.textContent = '请选择 Excel 文件'; + msgEl.classList.add('err'); + return; + } + var file = fileInput.files[0]; + var lowerName = (file.name || '').toLowerCase(); + if (!(lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls'))) { + msgEl.textContent = '仅支持 .xlsx 或 .xls 文件'; + msgEl.classList.add('err'); + return; + } + if (!confirm('确定按 Excel 中的 ASIN 批量删除匹配的总数据吗?')) { + return; + } + var formData = new FormData(); + formData.append('file', file); + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/admin/dedupe-total-data/delete-import', true); + xhr.upload.onprogress = function (event) { + if (event.lengthComputable) { + var percent = Math.round(event.loaded * 100 / event.total); + setDedupeDeleteImportProgress(percent, '上传中:' + percent + '%'); + } + }; + xhr.onreadystatechange = function () { + if (xhr.readyState !== 4) return; + if (xhr.status < 200 || xhr.status >= 300) { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + return; + } + var res; + try { res = JSON.parse(xhr.responseText || '{}'); } catch (e) { res = { success: false, error: '返回格式错误' }; } + if (!res.success) { + msgEl.textContent = res.error || '删除失败'; + msgEl.className = 'msg err'; + return; + } + setDedupeDeleteImportProgress(100, '上传完成,后端处理中...'); + pollDedupeDeleteImport(res.import_id); + fileInput.value = ''; + }; + xhr.onerror = function () { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + }; + xhr.send(formData); + }; + document.getElementById('btnSaveDedupeTotalData').onclick = function () { + var itemId = document.getElementById('editDedupeTotalDataId').value; + var value = (document.getElementById('editDedupeTotalDataValue').value || '').trim(); + var msgEl = document.getElementById('msgEditDedupeTotalData'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!value) { + msgEl.textContent = '请填写总数据值'; + msgEl.classList.add('err'); + return; + } + fetch('/api/admin/dedupe-total-data/' + itemId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data_value: value }) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { + document.getElementById('editDedupeTotalDataModal').classList.remove('show'); + loadDedupeTotalData(dedupeTotalDataPage); + } else { + msgEl.textContent = res.error || '保存失败'; + msgEl.classList.add('err'); + } + }); + }; + document.getElementById('btnCloseEditDedupeTotalData').onclick = function () { + document.getElementById('editDedupeTotalDataModal').classList.remove('show'); + }; + + // ========== 店铺密钥管理 ========== + var shopKeyPage = 1, shopKeyPageSize = 15; + function buildShopKeyQuery(page) { + return 'page=' + (page || 1) + '&page_size=' + shopKeyPageSize; + } + function loadShopKeys(page) { + shopKeyPage = page || 1; + fetch('/api/admin/shop-keys?' + buildShopKeyQuery(shopKeyPage)) + .then(function (r) { return r.json(); }) + .then(function (res) { + var tbody = document.getElementById('shopKeyListBody'); + if (!res.success) { + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + return; + } + var items = res.items || []; + if (items.length === 0) { + tbody.innerHTML = '暂无店铺密钥'; + } else { + tbody.innerHTML = items.map(function (item, index) { + var rowNo = (shopKeyPage - 1) * shopKeyPageSize + index + 1; + return '' + rowNo + '' + (item.ziniao_account_name || '') + '' + (item.ziniao_token || '') + '' + (item.created_at || '') + '' + (item.updated_at || '') + '' + + ' ' + + '' + + ''; + }).join(''); + } + renderPagination('shopKeyPagination', res.total, res.page, res.page_size, loadShopKeys); + bindShopKeyActions(); + }) + .catch(function () { + document.getElementById('shopKeyListBody').innerHTML = '请求失败'; + }); + } + function bindShopKeyActions() { + document.querySelectorAll('[data-shop-key-edit]').forEach(function (btn) { + btn.onclick = function () { + var item = {}; + try { item = JSON.parse((btn.dataset.shopKey || '').replace(/"/g, '"')); } catch (e) { item = {}; } + document.getElementById('editShopKeyId').value = item.id || ''; + document.getElementById('editShopKeyZiniaoAccountName').value = item.ziniao_account_name || ''; + document.getElementById('editShopKeyZiniaoToken').value = item.ziniao_token || ''; + document.getElementById('msgEditShopKey').textContent = ''; + document.getElementById('msgEditShopKey').className = 'msg'; + document.getElementById('editShopKeyModal').classList.add('show'); + }; + }); + document.querySelectorAll('[data-shop-key-delete]').forEach(function (btn) { + btn.onclick = function () { + var name = (btn.dataset.ziniaoAccountName || '').replace(/"/g, '"'); + if (!confirm('确定删除店铺密钥“' + name + '”吗?')) return; + fetch('/api/admin/shop-key/' + btn.dataset.shopKeyDelete, { method: 'DELETE' }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { loadShopKeys(shopKeyPage); } + else { alert(res.error || '删除失败'); } + }); + }; + }); + } + document.getElementById('btnCreateShopKey').onclick = function () { + var ziniaoAccountName = (document.getElementById('shopKeyZiniaoAccountName').value || '').trim(); + var ziniaoToken = (document.getElementById('shopKeyZiniaoToken').value || '').trim(); + var msgEl = document.getElementById('msgShopKey'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!ziniaoAccountName || !ziniaoToken) { + msgEl.textContent = '请完整填写紫鸟账号名称、紫鸟令牌'; + msgEl.classList.add('err'); + return; + } + fetch('/api/admin/shop-key', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ziniao_account_name: ziniaoAccountName, + ziniao_token: ziniaoToken + }) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { + document.getElementById('shopKeyZiniaoAccountName').value = ''; + document.getElementById('shopKeyZiniaoToken').value = ''; + msgEl.textContent = res.msg || '创建成功'; + msgEl.className = 'msg ok'; + loadShopKeys(1); + } else { + msgEl.textContent = res.error || '创建失败'; + msgEl.className = 'msg err'; + } + }) + .catch(function () { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + }); + }; + document.getElementById('btnSaveShopKey').onclick = function () { + var itemId = document.getElementById('editShopKeyId').value; + var ziniaoAccountName = (document.getElementById('editShopKeyZiniaoAccountName').value || '').trim(); + var ziniaoToken = (document.getElementById('editShopKeyZiniaoToken').value || '').trim(); + var msgEl = document.getElementById('msgEditShopKey'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!ziniaoAccountName || !ziniaoToken) { + msgEl.textContent = '请完整填写紫鸟账号名称、紫鸟令牌'; + msgEl.classList.add('err'); + return; + } + fetch('/api/admin/shop-key/' + itemId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ziniao_account_name: ziniaoAccountName, + ziniao_token: ziniaoToken + }) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { + document.getElementById('editShopKeyModal').classList.remove('show'); + loadShopKeys(shopKeyPage); + } else { + msgEl.textContent = res.error || '保存失败'; + msgEl.classList.add('err'); + } + }) + .catch(function () { + msgEl.textContent = '请求失败'; + msgEl.classList.add('err'); + }); + }; + document.getElementById('btnCloseEditShopKey').onclick = function () { + document.getElementById('editShopKeyModal').classList.remove('show'); + }; + + // ========== 店铺管理 ========== + var shopManagePage = 1, shopManagePageSize = 15; + var shopManageGroups = []; + var shopManageGroupsLoadedAt = 0; + var currentShopManageGroupGrantRoutes = []; + + function buildShopManageQuery(page) { + var query = 'page=' + (page || 1) + '&page_size=' + shopManagePageSize; + var groupId = (document.getElementById('shopManageFilterGroupId').value || '').trim(); + var shopName = (document.getElementById('shopManageFilterShopName').value || '').trim(); + if (groupId) query += '&group_id=' + encodeURIComponent(groupId); + if (shopName) query += '&shop_name=' + encodeURIComponent(shopName); + return query; + } + + function loadShopManage(page) { + shopManagePage = page || 1; + fetch('/api/admin/shop-manages?' + buildShopManageQuery(shopManagePage)) + .then(function (r) { return r.json(); }) + .then(function (res) { + var tbody = document.getElementById('shopManageListBody'); + if (!res.success) { + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + return; + } + var items = res.items || []; + if (items.length === 0) { + tbody.innerHTML = '暂无店铺'; + } else { + tbody.innerHTML = items.map(function (item, index) { + var rowNo = (shopManagePage - 1) * shopManagePageSize + index + 1; + return '' + rowNo + '' + (item.group_name || '') + '' + (item.shop_name || '') + '' + (item.mall_name || '') + '' + (item.account || '') + '' + (item.password || '') + '' + (item.created_at || '') + '' + (item.updated_at || '') + '' + + ' ' + + '' + + ''; + }).join(''); + } + renderPagination('shopManagePagination', res.total, res.page, res.page_size, loadShopManage); + bindShopManageActions(); + }) + .catch(function () { + document.getElementById('shopManageListBody').innerHTML = '请求失败'; + }); + } + + function bindShopManageActions() { + document.querySelectorAll('[data-shop-manage-edit]').forEach(function (btn) { + btn.onclick = function () { + var item = {}; + try { item = JSON.parse((btn.dataset.shopManage || '').replace(/"/g, '"')); } catch (e) { item = {}; } + document.getElementById('editShopManageId').value = item.id || ''; + document.getElementById('editShopManageShopName').value = item.shop_name || ''; + document.getElementById('editShopManageMallName').value = item.mall_name || ''; + document.getElementById('editShopManageAccount').value = item.account || ''; + document.getElementById('editShopManagePassword').value = item.password || ''; + document.getElementById('msgEditShopManage').textContent = ''; + document.getElementById('msgEditShopManage').className = 'msg'; + loadShopManageGroups(null, item.group_id).then(function () { + document.getElementById('editShopManageModal').classList.add('show'); + }); + }; + }); + document.querySelectorAll('[data-shop-manage-delete]').forEach(function (btn) { + btn.onclick = function () { + var name = (btn.dataset.shopManageName || '').replace(/"/g, '"'); + if (!confirm('确定删除店铺“' + name + '”吗?')) return; + fetch('/api/admin/shop-manage/' + btn.dataset.shopManageDelete, { method: 'DELETE' }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { loadShopManage(shopManagePage); } + else { alert(res.error || '删除失败'); } + }); + }; + }); + } + + function refreshShopGroupSelects(selectedCreateId, selectedEditId) { + var createSel = document.getElementById('shopManageGroupSelect'); + var editSel = document.getElementById('editShopManageGroupSelect'); + var filterSel = document.getElementById('shopManageFilterGroupId'); + var skipCreateSel = document.getElementById('skipPriceAsinGroupSelect'); + var skipFilterSel = document.getElementById('skipPriceAsinFilterGroupId'); + var chooseSkipShopSel = document.getElementById('chooseSkipPriceAsinShopGroupId'); + var queryCreateSel = document.getElementById('queryAsinGroupSelect'); + var queryFilterSel = document.getElementById('queryAsinFilterGroupId'); + var chooseQueryShopSel = document.getElementById('chooseQueryAsinShopGroupId'); + var selectedFilterId = filterSel ? filterSel.value : ''; + var selectedSkipCreateId = skipCreateSel ? skipCreateSel.value : ''; + var selectedSkipFilterId = skipFilterSel ? skipFilterSel.value : ''; + var selectedChooseSkipShopId = chooseSkipShopSel ? chooseSkipShopSel.value : ''; + var selectedQueryCreateId = queryCreateSel ? queryCreateSel.value : ''; + var selectedQueryFilterId = queryFilterSel ? queryFilterSel.value : ''; + var selectedChooseQueryShopId = chooseQueryShopSel ? chooseQueryShopSel.value : ''; + var createOpts = ['']; + var filterOpts = ['']; + shopManageGroups.forEach(function (g) { + var option = ''; + createOpts.push(option); + filterOpts.push(option); + }); + createSel.innerHTML = createOpts.join(''); + editSel.innerHTML = createOpts.join(''); + if (skipCreateSel) skipCreateSel.innerHTML = createOpts.join(''); + if (queryCreateSel) queryCreateSel.innerHTML = createOpts.join(''); + if (filterSel) filterSel.innerHTML = filterOpts.join(''); + if (skipFilterSel) skipFilterSel.innerHTML = filterOpts.join(''); + if (chooseSkipShopSel) chooseSkipShopSel.innerHTML = filterOpts.join(''); + if (queryFilterSel) queryFilterSel.innerHTML = filterOpts.join(''); + if (chooseQueryShopSel) chooseQueryShopSel.innerHTML = filterOpts.join(''); + if (selectedCreateId != null) createSel.value = String(selectedCreateId); + if (selectedEditId != null) editSel.value = String(selectedEditId); + if (filterSel && selectedFilterId) filterSel.value = selectedFilterId; + if (skipCreateSel && selectedSkipCreateId) skipCreateSel.value = selectedSkipCreateId; + if (skipFilterSel && selectedSkipFilterId) skipFilterSel.value = selectedSkipFilterId; + if (chooseSkipShopSel && selectedChooseSkipShopId) chooseSkipShopSel.value = selectedChooseSkipShopId; + if (queryCreateSel && selectedQueryCreateId) queryCreateSel.value = selectedQueryCreateId; + if (queryFilterSel && selectedQueryFilterId) queryFilterSel.value = selectedQueryFilterId; + if (chooseQueryShopSel && selectedChooseQueryShopId) chooseQueryShopSel.value = selectedChooseQueryShopId; + } + + function loadShopManageGroups(selectedCreateId, selectedEditId, force) { + if (!force && shopManageGroups.length && Date.now() - shopManageGroupsLoadedAt < 30000) { + refreshShopGroupSelects(selectedCreateId, selectedEditId); + return Promise.resolve(shopManageGroups); + } + return fetch('/api/admin/shop-manage-groups') + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) throw new Error(res.error || '加载分组失败'); + shopManageGroups = res.items || []; + shopManageGroupsLoadedAt = Date.now(); + refreshShopGroupSelects(selectedCreateId, selectedEditId); + return shopManageGroups; + }) + .catch(function () { + shopManageGroups = []; + shopManageGroupsLoadedAt = 0; + refreshShopGroupSelects(); + return []; + }); + } + + function resetShopManageGroupForm() { + document.getElementById('shopManageGroupEditId').value = ''; + document.getElementById('shopManageGroupLeaderUserId').value = currentUserId || ''; + document.getElementById('shopManageGroupLeaderName').value = currentUserUsername || ''; + document.getElementById('shopManageGroupInput').value = ''; + document.getElementById('msgShopManageGroup').textContent = ''; + document.getElementById('msgShopManageGroup').className = 'msg'; + refreshShopManageGroupMemberSelect(currentUserId, []); + } + + function setShopManageGroupGrantRoutes(routes) { + currentShopManageGroupGrantRoutes = Array.isArray(routes) ? routes.slice() : []; + } + + function updateShopManageGroupButtonsAccess() { + var canManageGroups = !!currentUserId; + ['btnManageShopGroups', 'btnManageShopGroupsFromEdit', 'btnManageSkipPriceAsinGroups', 'btnManageQueryAsinGroups'].forEach(function (id) { + var btn = document.getElementById(id); + if (btn) btn.style.display = canManageGroups ? '' : 'none'; + }); + } + + function openShopManageGroupModal() { + resetShopManageGroupForm(); + loadUserOptions() + .then(function () { return loadShopManageGroups(); }) + .then(function () { + renderShopManageGroupRows(); + document.getElementById('shopManageGroupModal').classList.add('show'); + }); + } + + function renderShopManageGroupRows() { + var tbody = document.getElementById('shopManageGroupListBody'); + if (!shopManageGroups.length) { + tbody.innerHTML = '暂无分组'; + return; + } + tbody.innerHTML = shopManageGroups.map(function (item, index) { + var memberNames = Array.isArray(item.member_usernames) ? item.member_usernames : []; + var memberHtml = memberNames.length + ? '
' + + memberNames.map(function (name) { + return '' + escapeHtml(name || '') + ''; + }).join('') + '
' + : '-'; + var canEdit = currentUserRole === 'super_admin' || String(item.leader_user_id || '') === String(currentUserId || ''); + var actionHtml = canEdit + ? (' ' + + '') + : '-'; + return '' + + '' + (index + 1) + '' + + '' + escapeHtml(item.group_name || '') + '' + + '' + escapeHtml(item.leader_username || '') + '' + + '' + escapeHtml(item.member_count || 0) + '' + + '' + memberHtml + '' + + '' + escapeHtml(item.created_at || '') + '' + + '' + escapeHtml(item.updated_at || '') + '' + + '' + actionHtml + '' + + ''; + }).join(''); + + document.querySelectorAll('[data-shop-group-edit]').forEach(function (btn) { + btn.onclick = function () { + var item = null; + shopManageGroups.some(function (group) { + if (String(group.id || '') === String(btn.dataset.shopGroupEdit || '')) { + item = group; + return true; + } + return false; + }); + if (!item) return; + document.getElementById('shopManageGroupEditId').value = item.id || ''; + document.getElementById('shopManageGroupInput').value = item.group_name || ''; + setShopManageGroupLeader(item.leader_user_id || '', item.leader_username || '', item.member_user_ids || []); + document.getElementById('msgShopManageGroup').textContent = ''; + document.getElementById('msgShopManageGroup').className = 'msg'; + }; + }); + + document.querySelectorAll('[data-shop-group-delete]').forEach(function (btn) { + btn.onclick = function () { + var gid = btn.dataset.shopGroupDelete; + var gname = (btn.dataset.shopGroupName || '').replace(/"/g, '"'); + if (!confirm('确定删除分组“' + gname + '”吗?')) return; + fetch('/api/admin/shop-manage-group/' + gid, { method: 'DELETE' }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + alert(res.error || '删除失败'); + return; + } + loadShopManageGroups(null, null, true).then(function () { + renderShopManageGroupRows(); + loadShopManage(shopManagePage); + loadSkipPriceAsin(skipPriceAsinPage); + }); + }); + }; + }); + } + + document.getElementById('btnManageShopGroups').onclick = function () { + setShopManageGroupGrantRoutes(['shop-manage']); + openShopManageGroupModal(); + }; + document.getElementById('btnManageShopGroupsFromEdit').onclick = function () { + setShopManageGroupGrantRoutes(['shop-manage']); + openShopManageGroupModal(); + }; + document.getElementById('btnManageSkipPriceAsinGroups').onclick = function () { + setShopManageGroupGrantRoutes(['skip-price-asin']); + openShopManageGroupModal(); + }; + document.getElementById('btnManageQueryAsinGroups').onclick = function () { + setShopManageGroupGrantRoutes(['query-asin']); + openShopManageGroupModal(); + }; + document.getElementById('btnSearchShopManage').onclick = function () { + loadShopManage(1); + }; + document.getElementById('shopManageFilterShopName').addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + loadShopManage(1); + } + }); + document.getElementById('btnCloseShopManageGroupModal').onclick = function () { + document.getElementById('shopManageGroupModal').classList.remove('show'); + }; + document.getElementById('btnCancelShopManageGroupEdit').onclick = resetShopManageGroupForm; + document.getElementById('btnSaveShopManageGroup').onclick = function () { + var editId = (document.getElementById('shopManageGroupEditId').value || '').trim(); + var groupName = (document.getElementById('shopManageGroupInput').value || '').trim(); + var memberUserIds = getSelectedShopManageGroupMemberIds(); + var msgEl = document.getElementById('msgShopManageGroup'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!groupName) { + msgEl.textContent = '请输入分组名称'; + msgEl.className = 'msg err'; + return; + } + var method = editId ? 'PUT' : 'POST'; + var url = editId ? ('/api/admin/shop-manage-group/' + editId) : '/api/admin/shop-manage-group'; + fetch(url, { + method: method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + group_name: groupName, + member_user_ids: memberUserIds, + grant_menu_routes: currentShopManageGroupGrantRoutes + }) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + msgEl.textContent = res.error || '保存失败'; + msgEl.className = 'msg err'; + return; + } + resetShopManageGroupForm(); + msgEl.textContent = res.msg || '保存成功'; + msgEl.className = 'msg ok'; + loadShopManageGroups(null, null, true).then(function () { + renderShopManageGroupRows(); + loadShopManage(shopManagePage); + loadSkipPriceAsin(skipPriceAsinPage); + }); + }) + .catch(function () { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + }); + }; + + document.getElementById('btnCreateShopManage').onclick = function () { + var groupId = (document.getElementById('shopManageGroupSelect').value || '').trim(); + var shopName = (document.getElementById('shopManageShopName').value || '').trim(); + var mallName = (document.getElementById('shopManageMallName').value || '').trim(); + var account = (document.getElementById('shopManageAccount').value || '').trim(); + var password = (document.getElementById('shopManagePassword').value || '').trim(); + var msgEl = document.getElementById('msgShopManage'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!groupId || !shopName || !mallName || !account || !password) { + msgEl.textContent = '请完整填写分组、店铺名、店铺商城名、账号、密码'; + msgEl.classList.add('err'); + return; + } + fetch('/api/admin/shop-manage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ group_id: Number(groupId), shop_name: shopName, mall_name: mallName, account: account, password: password }) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { + document.getElementById('shopManageGroupSelect').value = ''; + document.getElementById('shopManageShopName').value = ''; + document.getElementById('shopManageMallName').value = ''; + document.getElementById('shopManageAccount').value = ''; + document.getElementById('shopManagePassword').value = ''; + msgEl.textContent = res.msg || '创建成功'; + msgEl.className = 'msg ok'; + loadShopManage(1); + } else { + msgEl.textContent = res.error || '创建失败'; + msgEl.className = 'msg err'; + } + }) + .catch(function () { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + }); + }; + + document.getElementById('btnSaveShopManage').onclick = function () { + var itemId = document.getElementById('editShopManageId').value; + var groupId = (document.getElementById('editShopManageGroupSelect').value || '').trim(); + var shopName = (document.getElementById('editShopManageShopName').value || '').trim(); + var mallName = (document.getElementById('editShopManageMallName').value || '').trim(); + var account = (document.getElementById('editShopManageAccount').value || '').trim(); + var password = (document.getElementById('editShopManagePassword').value || '').trim(); + var msgEl = document.getElementById('msgEditShopManage'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!groupId || !shopName || !mallName || !account || !password) { + msgEl.textContent = '请完整填写分组、店铺名、店铺商城名、账号、密码'; + msgEl.classList.add('err'); + return; + } + fetch('/api/admin/shop-manage/' + itemId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ group_id: Number(groupId), shop_name: shopName, mall_name: mallName, account: account, password: password }) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { + document.getElementById('editShopManageModal').classList.remove('show'); + loadShopManage(shopManagePage); + } else { + msgEl.textContent = res.error || '保存失败'; + msgEl.classList.add('err'); + } + }) + .catch(function () { + msgEl.textContent = '请求失败'; + msgEl.classList.add('err'); + }); + }; + document.getElementById('btnCloseEditShopManage').onclick = function () { + document.getElementById('editShopManageModal').classList.remove('show'); + }; + + // ========== 版本管理 ========== + // ========== 跳过跟价 ASIN ========== + var skipPriceAsinPage = 1, skipPriceAsinPageSize = 15; + var chooseSkipPriceAsinShopPage = 1, chooseSkipPriceAsinShopPageSize = 10; + var skipPriceCountryColumns = [ + { code: 'DE', field: 'asin_de', minimumPriceField: 'minimum_price_de', label: '德国' }, + { code: 'UK', field: 'asin_uk', minimumPriceField: 'minimum_price_uk', label: '英国' }, + { code: 'FR', field: 'asin_fr', minimumPriceField: 'minimum_price_fr', label: '法国' }, + { code: 'IT', field: 'asin_it', minimumPriceField: 'minimum_price_it', label: '意大利' }, + { code: 'ES', field: 'asin_es', minimumPriceField: 'minimum_price_es', label: '西班牙' } + ]; + function buildChooseSkipPriceAsinShopQuery(page) { + var query = 'page=' + (page || 1) + '&page_size=' + chooseSkipPriceAsinShopPageSize; + var groupId = (document.getElementById('chooseSkipPriceAsinShopGroupId').value || '').trim(); + var shopName = (document.getElementById('chooseSkipPriceAsinShopKeyword').value || '').trim(); + if (groupId) query += '&group_id=' + encodeURIComponent(groupId); + if (shopName) query += '&shop_name=' + encodeURIComponent(shopName); + return query; + } + function loadChooseSkipPriceAsinShops(page) { + chooseSkipPriceAsinShopPage = page || 1; + fetch('/api/admin/shop-manages?' + buildChooseSkipPriceAsinShopQuery(chooseSkipPriceAsinShopPage)) + .then(function (r) { return r.json(); }) + .then(function (res) { + var tbody = document.getElementById('chooseSkipPriceAsinShopListBody'); + if (!res.success) { + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + return; + } + var items = res.items || []; + if (!items.length) { + tbody.innerHTML = '暂无店铺'; + } else { + tbody.innerHTML = items.map(function (item, index) { + var rowNo = (chooseSkipPriceAsinShopPage - 1) * chooseSkipPriceAsinShopPageSize + index + 1; + return '' + rowNo + '' + (item.group_name || '') + '' + (item.shop_name || '') + '' + (item.mall_name || '') + '' + (item.account || '') + '' + + '' + + ''; + }).join(''); + } + renderPagination('chooseSkipPriceAsinShopPagination', res.total, res.page, res.page_size, loadChooseSkipPriceAsinShops); + bindChooseSkipPriceAsinShopActions(); + }) + .catch(function () { + document.getElementById('chooseSkipPriceAsinShopListBody').innerHTML = '请求失败'; + }); + } + function bindChooseSkipPriceAsinShopActions() { + document.querySelectorAll('[data-choose-skip-price-asin-shop]').forEach(function (btn) { + btn.onclick = function () { + document.getElementById('skipPriceAsinShopName').value = (btn.dataset.shopName || '').replace(/"/g, '"'); + if (!document.getElementById('skipPriceAsinGroupSelect').value && btn.dataset.groupId) { + document.getElementById('skipPriceAsinGroupSelect').value = btn.dataset.groupId; + } + document.getElementById('chooseSkipPriceAsinShopModal').classList.remove('show'); + }; + }); + } + function openChooseSkipPriceAsinShopModal() { + var currentGroupId = (document.getElementById('skipPriceAsinGroupSelect').value || '').trim(); + if (currentGroupId) { + document.getElementById('chooseSkipPriceAsinShopGroupId').value = currentGroupId; + } + document.getElementById('chooseSkipPriceAsinShopKeyword').value = (document.getElementById('skipPriceAsinShopName').value || '').trim(); + document.getElementById('chooseSkipPriceAsinShopModal').classList.add('show'); + loadChooseSkipPriceAsinShops(1); + } + function setupSkipPriceAsinShopPicker() { + var input = document.getElementById('skipPriceAsinShopName'); + var button = document.getElementById('btnChooseSkipPriceAsinShop'); + if (!input || !button) return; + var inputParent = input.parentNode; + if (inputParent && inputParent.style && inputParent.style.display === 'flex') return; + var legacyWrap = button.parentNode; + var wrapper = document.createElement('div'); + wrapper.style.display = 'flex'; + wrapper.style.gap = '8px'; + wrapper.style.alignItems = 'center'; + input.parentNode.insertBefore(wrapper, input); + wrapper.appendChild(input); + wrapper.appendChild(button); + button.style.whiteSpace = 'nowrap'; + button.style.marginTop = '0'; + if (legacyWrap && legacyWrap !== wrapper) { + legacyWrap.style.display = 'none'; + } + } + function getSelectedSkipPriceCountries() { + return Array.from(document.getElementById('skipPriceAsinCountries').selectedOptions).map(function (option) { + return option.value; + }); + } + function formatSkipPriceMinimumPrice(value) { + if (value === null || value === undefined || value === '') return ''; + var num = Number(value); + if (!isFinite(num)) return String(value); + return num.toFixed(2); + } + function getSkipPriceCountryLabel(countryCode) { + var country = skipPriceCountryColumns.find(function (item) { return item.code === countryCode; }); + return country ? country.label : countryCode; + } + function renderSkipPriceAsinInputs() { + var container = document.getElementById('skipPriceAsinInputs'); + var selectedCountries = getSelectedSkipPriceCountries(); + var existingValues = {}; + var existingMinimumPrices = {}; + container.querySelectorAll('[data-skip-price-country-input]').forEach(function (input) { + existingValues[input.getAttribute('data-skip-price-country-input')] = input.value; + }); + container.querySelectorAll('[data-skip-price-country-minimum-price-input]').forEach(function (input) { + existingMinimumPrices[input.getAttribute('data-skip-price-country-minimum-price-input')] = input.value; + }); + if (!selectedCountries.length) { + container.innerHTML = '
请选择国家后输入 ASIN 和最低价
'; + return; + } + container.innerHTML = selectedCountries.map(function (countryCode) { + var label = getSkipPriceCountryLabel(countryCode); + var value = existingValues[countryCode] || ''; + var minimumPrice = existingMinimumPrices[countryCode] || ''; + return '
' + + '' + label + '' + + '' + + '' + + '
'; + }).join(''); + } + function collectSkipPriceAsinMappings(countries) { + var asinMappings = {}; + var minimumPriceMappings = {}; + for (var i = 0; i < countries.length; i++) { + var countryCode = countries[i]; + var input = document.querySelector('[data-skip-price-country-input="' + countryCode + '"]'); + var minimumPriceInput = document.querySelector('[data-skip-price-country-minimum-price-input="' + countryCode + '"]'); + var asin = input ? (input.value || '').trim().toUpperCase() : ''; + if (!asin) { + var label = getSkipPriceCountryLabel(countryCode); + throw new Error(label + ' ASIN 不能为空'); + } + var minimumPrice = minimumPriceInput ? (minimumPriceInput.value || '').trim() : ''; + if (minimumPrice) { + var minimumPriceNumber = Number(minimumPrice); + if (!isFinite(minimumPriceNumber) || minimumPriceNumber < 0) { + throw new Error(getSkipPriceCountryLabel(countryCode) + ' 最低价格式不正确'); + } + minimumPriceMappings[countryCode] = minimumPrice; + } + asinMappings[countryCode] = asin; + } + return { + asinMappings: asinMappings, + minimumPriceMappings: minimumPriceMappings + }; + } + function buildSkipPriceAsinQuery(page) { + var query = 'page=' + (page || 1) + '&page_size=' + skipPriceAsinPageSize; + var groupId = (document.getElementById('skipPriceAsinFilterGroupId').value || '').trim(); + var shopName = (document.getElementById('skipPriceAsinFilterShopName').value || '').trim(); + var asin = (document.getElementById('skipPriceAsinFilterAsin').value || '').trim(); + if (groupId) query += '&group_id=' + encodeURIComponent(groupId); + if (shopName) query += '&shop_name=' + encodeURIComponent(shopName); + if (asin) query += '&asin=' + encodeURIComponent(asin); + return query; + } + function renderSkipPriceAsinCell(item, country) { + var asinValue = item[country.field] || ''; + var minimumPriceValue = formatSkipPriceMinimumPrice(item[country.minimumPriceField]); + var hasValue = !!asinValue || !!minimumPriceValue; + var infoHtml = hasValue + ? ('
' + + '' + (asinValue || '-') + '' + + '最低价:' + (minimumPriceValue || '-') + '' + + '
') + : '-'; + var deleteHtml = hasValue + ? ('') + : ''; + return '
' + + infoHtml + + '' + + deleteHtml + + '
'; + } + function openEditSkipPriceAsinModal(itemId, countryCode, shopName, asinValue, minimumPriceValue) { + document.getElementById('editSkipPriceAsinId').value = itemId || ''; + document.getElementById('editSkipPriceAsinCountry').value = countryCode || ''; + document.getElementById('editSkipPriceAsinShopName').value = shopName || ''; + document.getElementById('editSkipPriceAsinCountryLabel').value = getSkipPriceCountryLabel(countryCode || ''); + document.getElementById('editSkipPriceAsinValue').value = asinValue || ''; + document.getElementById('editSkipPriceMinimumPrice').value = minimumPriceValue || ''; + document.getElementById('msgEditSkipPriceAsin').textContent = ''; + document.getElementById('msgEditSkipPriceAsin').className = 'msg'; + document.getElementById('editSkipPriceAsinModal').classList.add('show'); + } + function bindSkipPriceAsinActions() { + document.querySelectorAll('[data-skip-price-asin-edit]').forEach(function (btn) { + btn.onclick = function () { + var shopName = (btn.dataset.shopName || '').replace(/"/g, '"'); + var countryCode = btn.dataset.country || ''; + var currentAsin = (btn.dataset.asin || '').replace(/"/g, '"'); + var currentMinimumPrice = (btn.dataset.minimumPrice || '').replace(/"/g, '"'); + openEditSkipPriceAsinModal( + btn.dataset.skipPriceAsinEdit, + countryCode, + shopName, + currentAsin, + currentMinimumPrice + ); + }; + }); + document.querySelectorAll('[data-skip-price-asin-delete]').forEach(function (btn) { + btn.onclick = function () { + var shopName = (btn.dataset.shopName || '').replace(/"/g, '"'); + var countryCode = btn.dataset.country || ''; + if (!confirm('确定删除店铺“' + shopName + '”在 ' + countryCode + ' 的 ASIN 吗?')) return; + fetch('/api/admin/skip-price-asin/' + btn.dataset.skipPriceAsinDelete + '/country/' + countryCode, { + method: 'DELETE' + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) loadSkipPriceAsin(skipPriceAsinPage); + else alert(res.error || '删除失败'); + }); + }; + }); + } + function loadSkipPriceAsin(page) { + skipPriceAsinPage = page || 1; + fetch('/api/admin/skip-price-asins?' + buildSkipPriceAsinQuery(skipPriceAsinPage)) + .then(function (r) { return r.json(); }) + .then(function (res) { + var tbody = document.getElementById('skipPriceAsinListBody'); + if (!res.success) { + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + return; + } + var items = res.items || []; + if (items.length === 0) { + tbody.innerHTML = '暂无数据'; + } else { + tbody.innerHTML = items.map(function (item, index) { + var rowNo = (skipPriceAsinPage - 1) * skipPriceAsinPageSize + index + 1; + return '' + rowNo + '' + (item.group_name || '') + '' + (item.shop_name || '') + '' + + skipPriceCountryColumns.map(function (country) { + return '' + renderSkipPriceAsinCell(item, country) + ''; + }).join('') + + ''; + }).join(''); + } + renderPagination('skipPriceAsinPagination', res.total, res.page, res.page_size, loadSkipPriceAsin); + bindSkipPriceAsinActions(); + }) + .catch(function () { + document.getElementById('skipPriceAsinListBody').innerHTML = '请求失败'; + }); + } + var skipPriceAsinImportPollTimer = null; + var skipPriceAsinDeleteImportPollTimer = null; + var skipPriceAsinImportPollSeq = 0; + var skipPriceAsinDeleteImportPollSeq = 0; + function stopSkipPriceAsinImportProgress() { + skipPriceAsinImportPollSeq += 1; + if (skipPriceAsinImportPollTimer) { + clearTimeout(skipPriceAsinImportPollTimer); + skipPriceAsinImportPollTimer = null; + } + } + function stopSkipPriceAsinDeleteImportProgress() { + skipPriceAsinDeleteImportPollSeq += 1; + if (skipPriceAsinDeleteImportPollTimer) { + clearTimeout(skipPriceAsinDeleteImportPollTimer); + skipPriceAsinDeleteImportPollTimer = null; + } + } + function setSkipPriceAsinImportProgress(percent, text) { + var wrap = document.getElementById('skipPriceAsinImportProgressWrap'); + var fill = document.getElementById('skipPriceAsinImportProgressFill'); + var textEl = document.getElementById('skipPriceAsinImportProgressText'); + wrap.style.display = 'block'; + fill.style.width = Math.max(0, Math.min(100, percent || 0)) + '%'; + textEl.textContent = text || ''; + } + function setSkipPriceAsinDeleteImportProgress(percent, text) { + var wrap = document.getElementById('skipPriceAsinDeleteImportProgressWrap'); + var fill = document.getElementById('skipPriceAsinDeleteImportProgressFill'); + var textEl = document.getElementById('skipPriceAsinDeleteImportProgressText'); + wrap.style.display = 'block'; + fill.style.width = Math.max(0, Math.min(100, percent || 0)) + '%'; + textEl.textContent = text || ''; + } + function pollSkipPriceAsinImport(importId) { + stopSkipPriceAsinImportProgress(); + var pollSeq = ++skipPriceAsinImportPollSeq; + var finished = false; + var inFlight = false; + var attempts = 0; + var maxAttempts = 300; + function isActive() { + return !finished && pollSeq === skipPriceAsinImportPollSeq; + } + function finish() { + finished = true; + if (skipPriceAsinImportPollTimer) { + clearTimeout(skipPriceAsinImportPollTimer); + skipPriceAsinImportPollTimer = null; + } + } + function schedule() { + if (isActive()) { + skipPriceAsinImportPollTimer = setTimeout(tick, 1500); + } + } + function tick() { + if (!isActive() || inFlight) { + return; + } + attempts += 1; + if (attempts > maxAttempts) { + finish(); + document.getElementById('msgSkipPriceAsinImport').textContent = '查询导入进度超时,请稍后刷新列表确认结果'; + document.getElementById('msgSkipPriceAsinImport').className = 'msg err'; + return; + } + inFlight = true; + fetch('/api/admin/skip-price-asins/import/' + encodeURIComponent(importId)) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!isActive()) { + return; + } + if (!res.success) { + finish(); + document.getElementById('msgSkipPriceAsinImport').textContent = res.error || '查询导入进度失败'; + document.getElementById('msgSkipPriceAsinImport').className = 'msg err'; + return; + } + var progress = res.progress || {}; + var totalRows = progress.total_rows || 0; + var processedRows = progress.processed_rows || 0; + var percent = totalRows > 0 ? Math.round(processedRows * 100 / totalRows) : 0; + setSkipPriceAsinImportProgress(percent, '处理中:已处理 ' + processedRows + ' / ' + totalRows + ',新增 ' + (progress.inserted_count || 0) + ',跳过 ' + (progress.skipped_count || 0)); + if (progress.status === 'success') { + finish(); + document.getElementById('msgSkipPriceAsinImport').textContent = '导入成功:总行数 ' + totalRows + ',ASIN 数量 ' + (progress.asin_count || 0) + ',新增 ' + (progress.inserted_count || 0) + ',跳过 ' + (progress.skipped_count || 0); + document.getElementById('msgSkipPriceAsinImport').className = 'msg ok'; + loadSkipPriceAsin(1); + } else if (progress.status === 'failed') { + finish(); + document.getElementById('msgSkipPriceAsinImport').textContent = progress.error_message || '导入失败'; + document.getElementById('msgSkipPriceAsinImport').className = 'msg err'; + } else { + schedule(); + } + }) + .catch(function () { + if (!isActive()) { + return; + } + finish(); + document.getElementById('msgSkipPriceAsinImport').textContent = '查询导入进度失败'; + document.getElementById('msgSkipPriceAsinImport').className = 'msg err'; + }) + .finally(function () { + inFlight = false; + }); + } + tick(); + } + function pollSkipPriceAsinDeleteImport(importId) { + stopSkipPriceAsinDeleteImportProgress(); + var pollSeq = ++skipPriceAsinDeleteImportPollSeq; + var finished = false; + var inFlight = false; + var attempts = 0; + var maxAttempts = 300; + function isActive() { + return !finished && pollSeq === skipPriceAsinDeleteImportPollSeq; + } + function finish() { + finished = true; + if (skipPriceAsinDeleteImportPollTimer) { + clearTimeout(skipPriceAsinDeleteImportPollTimer); + skipPriceAsinDeleteImportPollTimer = null; + } + } + function schedule() { + if (isActive()) { + skipPriceAsinDeleteImportPollTimer = setTimeout(tick, 1500); + } + } + function tick() { + if (!isActive() || inFlight) { + return; + } + attempts += 1; + if (attempts > maxAttempts) { + finish(); + document.getElementById('msgSkipPriceAsinDeleteImport').textContent = '查询删除进度超时,请稍后刷新列表确认结果'; + document.getElementById('msgSkipPriceAsinDeleteImport').className = 'msg err'; + return; + } + inFlight = true; + fetch('/api/admin/skip-price-asins/delete-import/' + encodeURIComponent(importId)) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!isActive()) { + return; + } + if (!res.success) { + finish(); + document.getElementById('msgSkipPriceAsinDeleteImport').textContent = res.error || '查询删除进度失败'; + document.getElementById('msgSkipPriceAsinDeleteImport').className = 'msg err'; + return; + } + var progress = res.progress || {}; + var totalRows = progress.total_rows || 0; + var processedRows = progress.processed_rows || 0; + var percent = totalRows > 0 ? Math.round(processedRows * 100 / totalRows) : 0; + setSkipPriceAsinDeleteImportProgress(percent, '处理中:已处理 ' + processedRows + ' / ' + totalRows + ',删除 ' + (progress.deleted_count || 0) + ',跳过 ' + (progress.skipped_count || 0)); + if (progress.status === 'success') { + finish(); + document.getElementById('msgSkipPriceAsinDeleteImport').textContent = '删除成功:总行数 ' + totalRows + ',ASIN 数量 ' + (progress.asin_count || 0) + ',删除 ' + (progress.deleted_count || 0) + ',跳过 ' + (progress.skipped_count || 0); + document.getElementById('msgSkipPriceAsinDeleteImport').className = 'msg ok'; + loadSkipPriceAsin(1); + } else if (progress.status === 'failed') { + finish(); + document.getElementById('msgSkipPriceAsinDeleteImport').textContent = progress.error_message || '删除失败'; + document.getElementById('msgSkipPriceAsinDeleteImport').className = 'msg err'; + } else { + schedule(); + } + }) + .catch(function () { + if (!isActive()) { + return; + } + finish(); + document.getElementById('msgSkipPriceAsinDeleteImport').textContent = '查询删除进度失败'; + document.getElementById('msgSkipPriceAsinDeleteImport').className = 'msg err'; + }) + .finally(function () { + inFlight = false; + }); + } + tick(); + } + function uploadSkipPriceAsinImport(deleteMode) { + var fileInput = document.getElementById(deleteMode ? 'skipPriceAsinDeleteImportFile' : 'skipPriceAsinImportFile'); + var msgEl = document.getElementById(deleteMode ? 'msgSkipPriceAsinDeleteImport' : 'msgSkipPriceAsinImport'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (deleteMode) { + stopSkipPriceAsinDeleteImportProgress(); + document.getElementById('skipPriceAsinDeleteImportProgressWrap').style.display = 'none'; + } else { + stopSkipPriceAsinImportProgress(); + document.getElementById('skipPriceAsinImportProgressWrap').style.display = 'none'; + } + var groupId = (document.getElementById('skipPriceAsinGroupSelect').value || '').trim(); + if (!groupId) { + msgEl.textContent = '请先选择分组'; + msgEl.className = 'msg err'; + return; + } + if (!fileInput.files || fileInput.files.length === 0) { + msgEl.textContent = '请选择 Excel 文件'; + msgEl.className = 'msg err'; + return; + } + var file = fileInput.files[0]; + var lowerName = (file.name || '').toLowerCase(); + if (!(lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls'))) { + msgEl.textContent = '仅支持 .xlsx 或 .xls 文件'; + msgEl.className = 'msg err'; + return; + } + if (deleteMode && !confirm('确定按 Excel 中的删除ASIN批量删除该店铺跳过跟价 ASIN 吗?')) { + return; + } + var formData = new FormData(); + formData.append('file', file); + formData.append('group_id', groupId); + var xhr = new XMLHttpRequest(); + xhr.open('POST', deleteMode ? '/api/admin/skip-price-asins/delete-import' : '/api/admin/skip-price-asins/import', true); + xhr.upload.onprogress = function (event) { + if (event.lengthComputable) { + var percent = Math.round(event.loaded * 100 / event.total); + if (deleteMode) setSkipPriceAsinDeleteImportProgress(percent, '上传中:' + percent + '%'); + else setSkipPriceAsinImportProgress(percent, '上传中:' + percent + '%'); + } + }; + xhr.onreadystatechange = function () { + if (xhr.readyState !== 4) return; + if (xhr.status < 200 || xhr.status >= 300) { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + return; + } + var res; + try { res = JSON.parse(xhr.responseText || '{}'); } catch (e) { res = { success: false, error: '返回格式错误' }; } + if (!res.success) { + msgEl.textContent = res.error || (deleteMode ? '删除失败' : '导入失败'); + msgEl.className = 'msg err'; + return; + } + if (deleteMode) { + setSkipPriceAsinDeleteImportProgress(100, '上传完成,后端处理中...'); + pollSkipPriceAsinDeleteImport(res.import_id); + } else { + setSkipPriceAsinImportProgress(100, '上传完成,后端处理中...'); + pollSkipPriceAsinImport(res.import_id); + } + fileInput.value = ''; + }; + xhr.onerror = function () { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + }; + xhr.send(formData); + } + document.getElementById('btnChooseSkipPriceAsinShop').onclick = openChooseSkipPriceAsinShopModal; + document.getElementById('btnSearchChooseSkipPriceAsinShop').onclick = function () { + loadChooseSkipPriceAsinShops(1); + }; + document.getElementById('chooseSkipPriceAsinShopGroupId').onchange = function () { + loadChooseSkipPriceAsinShops(1); + }; + document.getElementById('chooseSkipPriceAsinShopKeyword').addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + loadChooseSkipPriceAsinShops(1); + } + }); + document.getElementById('btnCloseChooseSkipPriceAsinShopModal').onclick = function () { + document.getElementById('chooseSkipPriceAsinShopModal').classList.remove('show'); + }; + document.getElementById('btnCloseEditSkipPriceAsin').onclick = function () { + document.getElementById('editSkipPriceAsinModal').classList.remove('show'); + }; + document.getElementById('btnSaveSkipPriceAsin').onclick = function () { + var itemId = (document.getElementById('editSkipPriceAsinId').value || '').trim(); + var countryCode = (document.getElementById('editSkipPriceAsinCountry').value || '').trim(); + var asin = (document.getElementById('editSkipPriceAsinValue').value || '').trim().toUpperCase(); + var minimumPrice = (document.getElementById('editSkipPriceMinimumPrice').value || '').trim(); + var msgEl = document.getElementById('msgEditSkipPriceAsin'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!itemId || !countryCode) { + msgEl.textContent = '缺少编辑记录'; + msgEl.className = 'msg err'; + return; + } + if (!asin) { + msgEl.textContent = 'ASIN 不能为空'; + msgEl.className = 'msg err'; + return; + } + if (minimumPrice) { + var minimumPriceNumber = Number(minimumPrice); + if (!isFinite(minimumPriceNumber) || minimumPriceNumber < 0) { + msgEl.textContent = '最低价格式不正确'; + msgEl.className = 'msg err'; + return; + } + } + fetch('/api/admin/skip-price-asin/' + itemId + '/country/' + countryCode, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + asin: asin, + minimum_price: minimumPrice || null + }) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + msgEl.textContent = res.error || '保存失败'; + msgEl.className = 'msg err'; + return; + } + document.getElementById('editSkipPriceAsinModal').classList.remove('show'); + loadSkipPriceAsin(skipPriceAsinPage); + }) + .catch(function () { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + }); + }; + document.getElementById('skipPriceAsinCountries').addEventListener('change', renderSkipPriceAsinInputs); + document.getElementById('btnSearchSkipPriceAsin').onclick = function () { + loadSkipPriceAsin(1); + }; + document.getElementById('skipPriceAsinFilterShopName').addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + loadSkipPriceAsin(1); + } + }); + document.getElementById('skipPriceAsinFilterAsin').addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + loadSkipPriceAsin(1); + } + }); + document.getElementById('btnCreateSkipPriceAsin').onclick = function () { + var groupId = (document.getElementById('skipPriceAsinGroupSelect').value || '').trim(); + var shopName = (document.getElementById('skipPriceAsinShopName').value || '').trim(); + var countries = getSelectedSkipPriceCountries(); + var msgEl = document.getElementById('msgSkipPriceAsin'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!groupId || !shopName || !countries.length) { + msgEl.textContent = '请完整填写分组、店铺名、国家和 ASIN'; + msgEl.className = 'msg err'; + return; + } + var asinMappings = {}; + var minimumPriceMappings = {}; + try { + var mappings = collectSkipPriceAsinMappings(countries); + asinMappings = mappings.asinMappings || {}; + minimumPriceMappings = mappings.minimumPriceMappings || {}; + } catch (err) { + msgEl.textContent = err.message || '请输入 ASIN'; + msgEl.className = 'msg err'; + return; + } + var fallbackAsin = ''; + Object.keys(asinMappings).some(function (countryCode) { + fallbackAsin = asinMappings[countryCode] || ''; + return !!fallbackAsin; + }); + fetch('/api/admin/skip-price-asin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + group_id: Number(groupId), + shop_name: shopName, + countries: countries, + asin: fallbackAsin, + asin_mappings: asinMappings, + minimum_price_mappings: minimumPriceMappings + }) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + msgEl.textContent = res.error || '保存失败'; + msgEl.className = 'msg err'; + return; + } + document.getElementById('skipPriceAsinGroupSelect').value = ''; + document.getElementById('skipPriceAsinShopName').value = ''; + Array.from(document.getElementById('skipPriceAsinCountries').options).forEach(function (option) { + option.selected = false; + }); + refreshDropdownMultiSelect('skipPriceAsinCountries'); + renderSkipPriceAsinInputs(); + msgEl.textContent = res.msg || '保存成功'; + msgEl.className = 'msg ok'; + loadSkipPriceAsin(1); + }) + .catch(function () { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + }); + }; + setupSkipPriceAsinShopPicker(); + initDropdownMultiSelect('skipPriceAsinCountries', '请选择国家'); + document.getElementById('btnImportSkipPriceAsin').onclick = function () { + uploadSkipPriceAsinImport(false); + }; + document.getElementById('btnDeleteImportSkipPriceAsin').onclick = function () { + uploadSkipPriceAsinImport(true); + }; + renderSkipPriceAsinInputs(); + + // ========== 查询 ASIN ========== + var queryAsinPage = 1, queryAsinPageSize = 15; + var chooseQueryAsinShopPage = 1, chooseQueryAsinShopPageSize = 10; + var queryAsinCountryColumns = [ + { code: 'DE', field: 'asin_de', label: '德国' }, + { code: 'UK', field: 'asin_uk', label: '英国' }, + { code: 'FR', field: 'asin_fr', label: '法国' }, + { code: 'IT', field: 'asin_it', label: '意大利' }, + { code: 'ES', field: 'asin_es', label: '西班牙' } + ]; + function getQueryAsinCountryLabel(countryCode) { + var country = queryAsinCountryColumns.find(function (item) { return item.code === countryCode; }); + return country ? country.label : countryCode; + } + function getSelectedQueryAsinCountries() { + return Array.from(document.getElementById('queryAsinCountries').selectedOptions).map(function (option) { + return option.value; + }); + } + function renderQueryAsinInputs() { + var container = document.getElementById('queryAsinInputs'); + var selectedCountries = getSelectedQueryAsinCountries(); + var existingValues = {}; + container.querySelectorAll('[data-query-asin-country-input]').forEach(function (input) { + existingValues[input.getAttribute('data-query-asin-country-input')] = input.value; + }); + if (!selectedCountries.length) { + container.innerHTML = '
请选择国家后输入 ASIN
'; + return; + } + container.innerHTML = selectedCountries.map(function (countryCode) { + var label = getQueryAsinCountryLabel(countryCode); + var value = existingValues[countryCode] || ''; + return '
' + + '' + label + '' + + '' + + '
'; + }).join(''); + } + function collectQueryAsinMappings(countries) { + var asinMappings = {}; + for (var i = 0; i < countries.length; i++) { + var countryCode = countries[i]; + var input = document.querySelector('[data-query-asin-country-input="' + countryCode + '"]'); + var asin = input ? (input.value || '').trim().toUpperCase() : ''; + if (!asin) { + throw new Error(getQueryAsinCountryLabel(countryCode) + ' ASIN 不能为空'); + } + asinMappings[countryCode] = asin; + } + return asinMappings; + } + function buildChooseQueryAsinShopQuery(page) { + var query = 'page=' + (page || 1) + '&page_size=' + chooseQueryAsinShopPageSize; + var groupId = (document.getElementById('chooseQueryAsinShopGroupId').value || '').trim(); + var shopName = (document.getElementById('chooseQueryAsinShopKeyword').value || '').trim(); + if (groupId) query += '&group_id=' + encodeURIComponent(groupId); + if (shopName) query += '&shop_name=' + encodeURIComponent(shopName); + return query; + } + function loadChooseQueryAsinShops(page) { + chooseQueryAsinShopPage = page || 1; + fetch('/api/admin/shop-manages?' + buildChooseQueryAsinShopQuery(chooseQueryAsinShopPage)) + .then(function (r) { return r.json(); }) + .then(function (res) { + var tbody = document.getElementById('chooseQueryAsinShopListBody'); + if (!res.success) { + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + return; + } + var items = res.items || []; + if (items.length === 0) { + tbody.innerHTML = '暂无店铺'; + } else { + tbody.innerHTML = items.map(function (item, index) { + var rowNo = (chooseQueryAsinShopPage - 1) * chooseQueryAsinShopPageSize + index + 1; + return '' + rowNo + '' + (item.group_name || '') + '' + (item.shop_name || '') + '' + (item.mall_name || '') + '' + (item.username || '') + '' + + '' + + ''; + }).join(''); + } + renderPagination('chooseQueryAsinShopPagination', res.total, res.page, res.page_size, loadChooseQueryAsinShops); + bindChooseQueryAsinShopActions(); + }) + .catch(function () { + document.getElementById('chooseQueryAsinShopListBody').innerHTML = '请求失败'; + }); + } + function bindChooseQueryAsinShopActions() { + document.querySelectorAll('[data-choose-query-asin-shop]').forEach(function (btn) { + btn.onclick = function () { + document.getElementById('queryAsinShopName').value = (btn.dataset.shopName || '').replace(/"/g, '"'); + if (btn.dataset.groupId) { + document.getElementById('queryAsinGroupSelect').value = btn.dataset.groupId; + } + document.getElementById('chooseQueryAsinShopModal').classList.remove('show'); + }; + }); + } + function openChooseQueryAsinShopModal() { + var currentGroupId = (document.getElementById('queryAsinGroupSelect').value || '').trim(); + if (currentGroupId) { + document.getElementById('chooseQueryAsinShopGroupId').value = currentGroupId; + } + document.getElementById('chooseQueryAsinShopKeyword').value = (document.getElementById('queryAsinShopName').value || '').trim(); + document.getElementById('chooseQueryAsinShopModal').classList.add('show'); + loadChooseQueryAsinShops(1); + } + function buildQueryAsinQuery(page) { + var query = 'page=' + (page || 1) + '&page_size=' + queryAsinPageSize; + var groupId = (document.getElementById('queryAsinFilterGroupId').value || '').trim(); + var shopName = (document.getElementById('queryAsinFilterShopName').value || '').trim(); + var asin = (document.getElementById('queryAsinFilterAsin').value || '').trim(); + if (groupId) query += '&group_id=' + encodeURIComponent(groupId); + if (shopName) query += '&shop_name=' + encodeURIComponent(shopName); + if (asin) query += '&asin=' + encodeURIComponent(asin); + return query; + } + function renderQueryAsinCell(item, country) { + var asinValue = item[country.field] || ''; + var infoHtml = asinValue ? '' + asinValue + '' : '-'; + var deleteHtml = asinValue + ? ('') + : ''; + return '
' + + infoHtml + + '' + + deleteHtml + + '
'; + } + function openEditQueryAsinModal(itemId, countryCode, shopName, asinValue) { + document.getElementById('editQueryAsinId').value = itemId || ''; + document.getElementById('editQueryAsinCountry').value = countryCode || ''; + document.getElementById('editQueryAsinShopName').value = shopName || ''; + document.getElementById('editQueryAsinCountryLabel').value = getQueryAsinCountryLabel(countryCode || ''); + document.getElementById('editQueryAsinValue').value = asinValue || ''; + document.getElementById('msgEditQueryAsin').textContent = ''; + document.getElementById('msgEditQueryAsin').className = 'msg'; + document.getElementById('editQueryAsinModal').classList.add('show'); + } + function bindQueryAsinActions() { + document.querySelectorAll('[data-query-asin-edit]').forEach(function (btn) { + btn.onclick = function () { + openEditQueryAsinModal( + btn.dataset.queryAsinEdit, + btn.dataset.country || '', + (btn.dataset.shopName || '').replace(/"/g, '"'), + (btn.dataset.asin || '').replace(/"/g, '"') + ); + }; + }); + document.querySelectorAll('[data-query-asin-delete]').forEach(function (btn) { + btn.onclick = function () { + var shopName = (btn.dataset.shopName || '').replace(/"/g, '"'); + var countryCode = btn.dataset.country || ''; + if (!confirm('确定删除店铺“' + shopName + '”在 ' + countryCode + ' 的 ASIN 吗?')) return; + fetch('/api/admin/query-asin/' + btn.dataset.queryAsinDelete + '/country/' + countryCode, { + method: 'DELETE' + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) loadQueryAsin(queryAsinPage); + else alert(res.error || '删除失败'); + }); + }; + }); + } + function loadQueryAsin(page) { + queryAsinPage = page || 1; + fetch('/api/admin/query-asins?' + buildQueryAsinQuery(queryAsinPage)) + .then(function (r) { return r.json(); }) + .then(function (res) { + var tbody = document.getElementById('queryAsinListBody'); + if (!res.success) { + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + return; + } + var items = res.items || []; + if (items.length === 0) { + tbody.innerHTML = '暂无数据'; + } else { + tbody.innerHTML = items.map(function (item, index) { + var rowNo = (queryAsinPage - 1) * queryAsinPageSize + index + 1; + return '' + rowNo + '' + (item.group_name || '') + '' + (item.shop_name || '') + '' + + queryAsinCountryColumns.map(function (country) { + return '' + renderQueryAsinCell(item, country) + ''; + }).join('') + + ''; + }).join(''); + } + renderPagination('queryAsinPagination', res.total, res.page, res.page_size, loadQueryAsin); + bindQueryAsinActions(); + }) + .catch(function () { + document.getElementById('queryAsinListBody').innerHTML = '请求失败'; + }); + } + var queryAsinImportPollTimer = null; + var queryAsinDeleteImportPollTimer = null; + function stopQueryAsinImportProgress() { + if (queryAsinImportPollTimer) { + clearInterval(queryAsinImportPollTimer); + queryAsinImportPollTimer = null; + } + } + function stopQueryAsinDeleteImportProgress() { + if (queryAsinDeleteImportPollTimer) { + clearInterval(queryAsinDeleteImportPollTimer); + queryAsinDeleteImportPollTimer = null; + } + } + function setQueryAsinImportProgress(percent, text) { + var wrap = document.getElementById('queryAsinImportProgressWrap'); + var fill = document.getElementById('queryAsinImportProgressFill'); + var textEl = document.getElementById('queryAsinImportProgressText'); + wrap.style.display = 'block'; + fill.style.width = Math.max(0, Math.min(100, percent || 0)) + '%'; + textEl.textContent = text || ''; + } + function setQueryAsinDeleteImportProgress(percent, text) { + var wrap = document.getElementById('queryAsinDeleteImportProgressWrap'); + var fill = document.getElementById('queryAsinDeleteImportProgressFill'); + var textEl = document.getElementById('queryAsinDeleteImportProgressText'); + wrap.style.display = 'block'; + fill.style.width = Math.max(0, Math.min(100, percent || 0)) + '%'; + textEl.textContent = text || ''; + } + function pollQueryAsinImport(importId) { + stopQueryAsinImportProgress(); + function tick() { + fetch('/api/admin/query-asins/import/' + encodeURIComponent(importId)) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + stopQueryAsinImportProgress(); + document.getElementById('msgQueryAsinImport').textContent = res.error || '查询导入进度失败'; + document.getElementById('msgQueryAsinImport').className = 'msg err'; + return; + } + var progress = res.progress || {}; + var totalRows = progress.total_rows || 0; + var processedRows = progress.processed_rows || 0; + var percent = totalRows > 0 ? Math.round(processedRows * 100 / totalRows) : 0; + setQueryAsinImportProgress(percent, '处理中:已处理 ' + processedRows + ' / ' + totalRows + ',添加 ' + (progress.inserted_count || 0) + ',跳过 ' + (progress.skipped_count || 0)); + if (progress.status === 'success') { + stopQueryAsinImportProgress(); + document.getElementById('msgQueryAsinImport').textContent = '导入成功:总行数 ' + totalRows + ',ASIN 数量 ' + (progress.asin_count || 0) + ',添加 ' + (progress.inserted_count || 0) + ',跳过 ' + (progress.skipped_count || 0); + document.getElementById('msgQueryAsinImport').className = 'msg ok'; + loadQueryAsin(1); + } else if (progress.status === 'failed') { + stopQueryAsinImportProgress(); + document.getElementById('msgQueryAsinImport').textContent = progress.error_message || '导入失败'; + document.getElementById('msgQueryAsinImport').className = 'msg err'; + } + }) + .catch(function () { + stopQueryAsinImportProgress(); + document.getElementById('msgQueryAsinImport').textContent = '查询导入进度失败'; + document.getElementById('msgQueryAsinImport').className = 'msg err'; + }); + } + tick(); + queryAsinImportPollTimer = setInterval(tick, 1000); + } + function pollQueryAsinDeleteImport(importId) { + stopQueryAsinDeleteImportProgress(); + function tick() { + fetch('/api/admin/query-asins/delete-import/' + encodeURIComponent(importId)) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + stopQueryAsinDeleteImportProgress(); + document.getElementById('msgQueryAsinDeleteImport').textContent = res.error || '查询删除进度失败'; + document.getElementById('msgQueryAsinDeleteImport').className = 'msg err'; + return; + } + var progress = res.progress || {}; + var totalRows = progress.total_rows || 0; + var processedRows = progress.processed_rows || 0; + var percent = totalRows > 0 ? Math.round(processedRows * 100 / totalRows) : 0; + setQueryAsinDeleteImportProgress(percent, '处理中:已处理 ' + processedRows + ' / ' + totalRows + ',删除 ' + (progress.deleted_count || 0) + ',跳过 ' + (progress.skipped_count || 0)); + if (progress.status === 'success') { + stopQueryAsinDeleteImportProgress(); + document.getElementById('msgQueryAsinDeleteImport').textContent = '删除成功:总行数 ' + totalRows + ',ASIN 数量 ' + (progress.asin_count || 0) + ',删除 ' + (progress.deleted_count || 0) + ',跳过 ' + (progress.skipped_count || 0); + document.getElementById('msgQueryAsinDeleteImport').className = 'msg ok'; + loadQueryAsin(1); + } else if (progress.status === 'failed') { + stopQueryAsinDeleteImportProgress(); + document.getElementById('msgQueryAsinDeleteImport').textContent = progress.error_message || '删除失败'; + document.getElementById('msgQueryAsinDeleteImport').className = 'msg err'; + } + }) + .catch(function () { + stopQueryAsinDeleteImportProgress(); + document.getElementById('msgQueryAsinDeleteImport').textContent = '查询删除进度失败'; + document.getElementById('msgQueryAsinDeleteImport').className = 'msg err'; + }); + } + tick(); + queryAsinDeleteImportPollTimer = setInterval(tick, 1000); + } + function uploadQueryAsinImport(deleteMode) { + var fileInput = document.getElementById(deleteMode ? 'queryAsinDeleteImportFile' : 'queryAsinImportFile'); + var msgEl = document.getElementById(deleteMode ? 'msgQueryAsinDeleteImport' : 'msgQueryAsinImport'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (deleteMode) { + stopQueryAsinDeleteImportProgress(); + document.getElementById('queryAsinDeleteImportProgressWrap').style.display = 'none'; + } else { + stopQueryAsinImportProgress(); + document.getElementById('queryAsinImportProgressWrap').style.display = 'none'; + } + if (!fileInput.files || fileInput.files.length === 0) { + msgEl.textContent = '请选择 Excel 文件'; + msgEl.className = 'msg err'; + return; + } + var file = fileInput.files[0]; + var lowerName = (file.name || '').toLowerCase(); + if (!(lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls'))) { + msgEl.textContent = '仅支持 .xlsx 或 .xls 文件'; + msgEl.className = 'msg err'; + return; + } + if (deleteMode && !confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?')) { + return; + } + var formData = new FormData(); + formData.append('file', file); + var groupId = (document.getElementById('queryAsinGroupSelect').value || '').trim(); + if (groupId) formData.append('group_id', groupId); + var xhr = new XMLHttpRequest(); + xhr.open('POST', deleteMode ? '/api/admin/query-asins/delete-import' : '/api/admin/query-asins/import', true); + xhr.upload.onprogress = function (event) { + if (event.lengthComputable) { + var percent = Math.round(event.loaded * 100 / event.total); + if (deleteMode) setQueryAsinDeleteImportProgress(percent, '上传中:' + percent + '%'); + else setQueryAsinImportProgress(percent, '上传中:' + percent + '%'); + } + }; + xhr.onreadystatechange = function () { + if (xhr.readyState !== 4) return; + if (xhr.status < 200 || xhr.status >= 300) { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + return; + } + var res; + try { res = JSON.parse(xhr.responseText || '{}'); } catch (e) { res = { success: false, error: '返回格式错误' }; } + if (!res.success) { + msgEl.textContent = res.error || (deleteMode ? '删除失败' : '导入失败'); + msgEl.className = 'msg err'; + return; + } + if (deleteMode) { + setQueryAsinDeleteImportProgress(100, '上传完成,后端处理中...'); + pollQueryAsinDeleteImport(res.import_id); + } else { + setQueryAsinImportProgress(100, '上传完成,后端处理中...'); + pollQueryAsinImport(res.import_id); + } + fileInput.value = ''; + }; + xhr.onerror = function () { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + }; + xhr.send(formData); + } + document.getElementById('btnChooseQueryAsinShop').onclick = openChooseQueryAsinShopModal; + document.getElementById('btnSearchChooseQueryAsinShop').onclick = function () { + loadChooseQueryAsinShops(1); + }; + document.getElementById('chooseQueryAsinShopGroupId').onchange = function () { + loadChooseQueryAsinShops(1); + }; + document.getElementById('chooseQueryAsinShopKeyword').addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + loadChooseQueryAsinShops(1); + } + }); + document.getElementById('btnCloseChooseQueryAsinShopModal').onclick = function () { + document.getElementById('chooseQueryAsinShopModal').classList.remove('show'); + }; + document.getElementById('btnCloseEditQueryAsin').onclick = function () { + document.getElementById('editQueryAsinModal').classList.remove('show'); + }; + document.getElementById('btnSaveQueryAsin').onclick = function () { + var itemId = (document.getElementById('editQueryAsinId').value || '').trim(); + var countryCode = (document.getElementById('editQueryAsinCountry').value || '').trim(); + var asin = (document.getElementById('editQueryAsinValue').value || '').trim().toUpperCase(); + var msgEl = document.getElementById('msgEditQueryAsin'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!itemId || !countryCode) { + msgEl.textContent = '缺少编辑记录'; + msgEl.className = 'msg err'; + return; + } + if (!asin) { + msgEl.textContent = 'ASIN 不能为空'; + msgEl.className = 'msg err'; + return; + } + fetch('/api/admin/query-asin/' + itemId + '/country/' + countryCode, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ asin: asin }) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + msgEl.textContent = res.error || '保存失败'; + msgEl.className = 'msg err'; + return; + } + document.getElementById('editQueryAsinModal').classList.remove('show'); + loadQueryAsin(queryAsinPage); + }) + .catch(function () { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + }); + }; + document.getElementById('queryAsinCountries').addEventListener('change', renderQueryAsinInputs); + document.getElementById('btnSearchQueryAsin').onclick = function () { + loadQueryAsin(1); + }; + document.getElementById('queryAsinFilterShopName').addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + loadQueryAsin(1); + } + }); + document.getElementById('queryAsinFilterAsin').addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + loadQueryAsin(1); + } + }); + document.getElementById('btnCreateQueryAsin').onclick = function () { + var groupId = (document.getElementById('queryAsinGroupSelect').value || '').trim(); + var shopName = (document.getElementById('queryAsinShopName').value || '').trim(); + var countries = getSelectedQueryAsinCountries(); + var msgEl = document.getElementById('msgQueryAsin'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!groupId || !shopName || !countries.length) { + msgEl.textContent = '请完整填写分组、店铺名、国家和 ASIN'; + msgEl.className = 'msg err'; + return; + } + var asinMappings = {}; + try { + asinMappings = collectQueryAsinMappings(countries); + } catch (err) { + msgEl.textContent = err.message || '请输入 ASIN'; + msgEl.className = 'msg err'; + return; + } + var fallbackAsin = ''; + Object.keys(asinMappings).some(function (countryCode) { + fallbackAsin = asinMappings[countryCode] || ''; + return !!fallbackAsin; + }); + fetch('/api/admin/query-asin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + group_id: Number(groupId), + shop_name: shopName, + countries: countries, + asin: fallbackAsin, + asin_mappings: asinMappings + }) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + msgEl.textContent = res.error || '保存失败'; + msgEl.className = 'msg err'; + return; + } + document.getElementById('queryAsinGroupSelect').value = ''; + document.getElementById('queryAsinShopName').value = ''; + Array.from(document.getElementById('queryAsinCountries').options).forEach(function (option) { + option.selected = false; + }); + refreshDropdownMultiSelect('queryAsinCountries'); + renderQueryAsinInputs(); + msgEl.textContent = res.msg || '保存成功'; + msgEl.className = 'msg ok'; + loadQueryAsin(1); + }) + .catch(function () { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + }); + }; + initDropdownMultiSelect('queryAsinCountries', '请选择国家'); + document.getElementById('btnImportQueryAsin').onclick = function () { + uploadQueryAsinImport(false); + }; + document.getElementById('btnDeleteImportQueryAsin').onclick = function () { + uploadQueryAsinImport(true); + }; + renderQueryAsinInputs(); + + var productCategoryItems = []; + var editingProductCategoryId = null; + + function resetProductCategoryForm() { + editingProductCategoryId = null; + document.getElementById('productCategoryParentId').value = ''; + document.getElementById('productCategoryName').value = ''; + document.getElementById('productCategorySortOrder').value = ''; + document.getElementById('productCategoryDescription').value = ''; + document.getElementById('btnCreateProductCategory').textContent = '新增类目'; + document.getElementById('btnCancelProductCategoryEdit').style.display = 'none'; + renderProductCategoryParentOptions(); + } + + function renderProductCategoryParentOptions(selectedValue, excludedId) { + var select = document.getElementById('productCategoryParentId'); + if (!select) return; + var selected = selectedValue == null ? select.value : String(selectedValue || ''); + var excluded = excludedId == null ? editingProductCategoryId : excludedId; + var options = ['']; + productCategoryItems.forEach(function (item) { + if (excluded && String(item.id) === String(excluded)) return; + var prefix = new Array((item.level || 0) + 1).join('  '); + options.push(''); + }); + select.innerHTML = options.join(''); + select.value = selected; + } + + function renderProductCategoryRows(items) { + var tbody = document.getElementById('productCategoryListBody'); + if (!tbody) return; + if (!items.length) { + tbody.innerHTML = '暂无商品类目'; + return; + } + tbody.innerHTML = items.map(function (item) { + var indent = Math.max(0, item.level || 0) * 24; + var mark = item.child_count > 0 ? '+' : '-'; + return '' + + '
' + mark + '' + escapeHtml(item.name || '') + '
' + + '
' + escapeHtml(item.path || item.name || '') + '
' + escapeHtml(item.category_key || '') + '
' + + '' + escapeHtml(item.sort_order || 0) + '' + + '' + (item.is_builtin ? '内置' : '自定义') + '' + + '' + escapeHtml(item.description || '') + '' + + ' ' + + '' + + ''; + }).join(''); + document.querySelectorAll('[data-product-category-edit]').forEach(function (btn) { + btn.onclick = function () { + var item = productCategoryItems.find(function (row) { return String(row.id) === String(btn.dataset.productCategoryEdit); }); + if (!item) return; + editingProductCategoryId = item.id; + renderProductCategoryParentOptions(item.parent_id || '', item.id); + document.getElementById('productCategoryName').value = item.name || ''; + document.getElementById('productCategorySortOrder').value = item.sort_order || ''; + document.getElementById('productCategoryDescription').value = item.description || ''; + document.getElementById('btnCreateProductCategory').textContent = '保存类目'; + document.getElementById('btnCancelProductCategoryEdit').style.display = ''; + document.getElementById('msgProductCategory').textContent = ''; + document.getElementById('msgProductCategory').className = 'msg'; + }; + }); + document.querySelectorAll('[data-product-category-delete]').forEach(function (btn) { + btn.onclick = function () { + var name = btn.dataset.productCategoryName || '该类目'; + if (!confirm('确定删除“' + name + '”?')) return; + fetch('/api/admin/product-category/' + encodeURIComponent(btn.dataset.productCategoryDelete), { method: 'DELETE' }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + alert(res.error || '删除失败'); + return; + } + resetProductCategoryForm(); + loadProductCategories(); + }) + .catch(function () { alert('请求失败'); }); + }; + }); + } + + function loadProductCategories() { + fetch('/api/admin/product-categories') + .then(function (r) { return r.json(); }) + .then(function (res) { + var tbody = document.getElementById('productCategoryListBody'); + if (!res.success) { + if (tbody) tbody.innerHTML = '加载失败: ' + escapeHtml(res.error || '') + ''; + return; + } + productCategoryItems = res.items || []; + renderProductCategoryParentOptions(); + renderProductCategoryRows(productCategoryItems); + }) + .catch(function () { + var tbody = document.getElementById('productCategoryListBody'); + if (tbody) tbody.innerHTML = '请求失败'; + }); + } + + document.getElementById('btnCancelProductCategoryEdit').onclick = function () { + resetProductCategoryForm(); + }; + + document.getElementById('btnCreateProductCategory').onclick = function () { + var msgEl = document.getElementById('msgProductCategory'); + var name = (document.getElementById('productCategoryName').value || '').trim(); + var parentId = (document.getElementById('productCategoryParentId').value || '').trim(); + var sortOrder = (document.getElementById('productCategorySortOrder').value || '').trim(); + var description = (document.getElementById('productCategoryDescription').value || '').trim(); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!name) { + msgEl.textContent = '请填写类目名称'; + msgEl.className = 'msg err'; + return; + } + var payload = { + parent_id: parentId ? Number(parentId) : null, + name: name, + sort_order: sortOrder ? Number(sortOrder) : null, + description: description + }; + var url = editingProductCategoryId + ? '/api/admin/product-category/' + encodeURIComponent(editingProductCategoryId) + : '/api/admin/product-category'; + fetch(url, { + method: editingProductCategoryId ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (!res.success) { + msgEl.textContent = res.error || '保存失败'; + msgEl.className = 'msg err'; + return; + } + msgEl.textContent = res.msg || '保存成功'; + msgEl.className = 'msg ok'; + resetProductCategoryForm(); + loadProductCategories(); + }) + .catch(function () { + msgEl.textContent = '请求失败'; + msgEl.className = 'msg err'; + }); + }; + + function loadVersions() { + fetch('/api/admin/versions') + .then(function (r) { return r.json(); }) + .then(function (res) { + var tbody = document.getElementById('versionListBody'); + if (!res.success) { + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + return; + } + var items = res.items || []; + if (items.length === 0) { + tbody.innerHTML = '暂无版本记录'; + } else { + tbody.innerHTML = items.map(function (v) { + var url = (v.file_url || '').replace(/"/g, '"'); + return '' + (v.version || '') + '' + url + '' + (v.created_at || '') + '下载'; + }).join(''); + } + }) + .catch(function () { + document.getElementById('versionListBody').innerHTML = '请求失败'; + }); + } + document.getElementById('btnUploadVersion').onclick = function () { + var version = (document.getElementById('versionNumber').value || '').trim(); + var fileInput = document.getElementById('versionZip'); + var msgEl = document.getElementById('msgVersion'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!version) { + msgEl.textContent = '请填写版本号'; + msgEl.classList.add('err'); + return; + } + if (!fileInput.files || fileInput.files.length === 0) { + msgEl.textContent = '请选择 zip 压缩包'; + msgEl.classList.add('err'); + return; + } + var file = fileInput.files[0]; + if (!(file.name || '').toLowerCase().endsWith('.zip')) { + msgEl.textContent = '仅支持 .zip 格式'; + msgEl.classList.add('err'); + return; + } + var formData = new FormData(); + formData.append('version', version); + formData.append('file', file); + msgEl.textContent = '上传中...'; + msgEl.classList.remove('err', 'ok'); + fetch('/api/admin/version', { + method: 'POST', + body: formData + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { + msgEl.textContent = '发布成功。版本:' + res.version + ',链接:' + (res.file_url || ''); + msgEl.classList.add('ok'); + document.getElementById('versionNumber').value = ''; + fileInput.value = ''; + loadVersions(); + } else { + msgEl.textContent = res.error || '上传失败'; + msgEl.classList.add('err'); + } + }) + .catch(function () { + msgEl.textContent = '请求失败'; + msgEl.classList.add('err'); + }); + }; + + // ========== 栏目权限配置 ========== + function loadColumns() { + fetch('/api/admin/columns') + .then(function (r) { return r.json(); }) + .then(function (res) { + var tbody = document.getElementById('columnListBody'); + if (!res.success) { + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + return; + } + allColumnsList = res.items || []; + if (allColumnsList.length === 0) { + tbody.innerHTML = '暂无菜单,请在上方新增'; + } else { + tbody.innerHTML = allColumnsList.map(function (c) { + return '' + c.id + '' + (c.name || '') + '' + (c.column_key || '') + '' + (c.created_at || '') + '' + + ' ' + + ''; + }).join(''); + } + bindColumnActions(); + }) + .catch(function () { + document.getElementById('columnListBody').innerHTML = '请求失败'; + }); + } + function bindColumnActions() { + document.querySelectorAll('[data-column-edit]').forEach(function (btn) { + btn.onclick = function () { + document.getElementById('editColumnId').value = btn.dataset.columnEdit || ''; + document.getElementById('editColumnName').value = (btn.dataset.name || '').replace(/"/g, '"'); + document.getElementById('editColumnKey').value = (btn.dataset.key || '').replace(/"/g, '"'); + document.getElementById('msgEditColumn').textContent = ''; + document.getElementById('editColumnModal').classList.add('show'); + }; + }); + document.querySelectorAll('[data-column-delete]').forEach(function (btn) { + btn.onclick = function () { + if (!confirm('确定删除菜单“' + (btn.dataset.name || '').replace(/"/g, '"') + '”吗?')) return; + fetch('/api/admin/column/' + btn.dataset.columnDelete, { method: 'DELETE' }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { loadColumns(); loadColumnsForPermission(); loadAdminMenus(getActiveAdminTabName()); } + else { alert(res.error || '删除失败'); } + }); + }; + }); + } + document.getElementById('btnAddColumn').onclick = function () { + var name = (document.getElementById('columnName').value || '').trim(); + var key = (document.getElementById('columnKey').value || '').trim(); + var msgEl = document.getElementById('msgColumn'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!name) { msgEl.textContent = '请填写菜单名称'; msgEl.classList.add('err'); return; } + if (!key) { + msgEl.textContent = '请填写栏目标识'; msgEl.classList.add('err'); return; + } + fetch('/api/admin/column', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name, column_key: key }) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { + msgEl.textContent = res.msg || '新增成功'; + msgEl.classList.add('ok'); + document.getElementById('columnName').value = ''; + document.getElementById('columnKey').value = ''; + loadColumns(); + loadColumnsForPermission(); + loadAdminMenus(getActiveAdminTabName()); + } else { + msgEl.textContent = res.error || '新增失败'; + msgEl.classList.add('err'); + } + }) + .catch(function () { msgEl.textContent = '请求失败'; msgEl.classList.add('err'); }); + }; + document.getElementById('btnSaveColumn').onclick = function () { + var cid = document.getElementById('editColumnId').value; + var name = (document.getElementById('editColumnName').value || '').trim(); + var key = (document.getElementById('editColumnKey').value || '').trim(); + var msgEl = document.getElementById('msgEditColumn'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!name) { msgEl.textContent = '请填写菜单名称'; msgEl.classList.add('err'); return; } + if (!key) { + msgEl.textContent = '请填写栏目标识'; msgEl.classList.add('err'); return; + } + fetch('/api/admin/column/' + cid, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name, column_key: key }) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { + document.getElementById('editColumnModal').classList.remove('show'); + loadColumns(); + loadColumnsForPermission(); + loadAdminMenus(getActiveAdminTabName()); + } else { + msgEl.textContent = res.error || '保存失败'; + msgEl.classList.add('err'); + } + }); + }; + document.getElementById('btnCloseEditColumn').onclick = function () { + document.getElementById('editColumnModal').classList.remove('show'); + }; + + function loadColumns() { + fetch('/api/admin/columns') + .then(function (r) { return r.json(); }) + .then(function (res) { + var tbody = document.getElementById('columnListBody'); + if (!res.success) { + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + return; + } + allColumnsList = res.items || []; + if (allColumnsList.length === 0) { + tbody.innerHTML = '暂无菜单,请先在上方新增'; + } else { + tbody.innerHTML = allColumnsList.map(function (c, index) { + var moveButtons = + ' ' + + ' '; + return '' + c.id + '' + (c.name || '') + '' + (c.column_key || '') + '' + ((c.menu_type || '') === 'admin' ? '后台(admin)' : '软件(app)') + '' + (c.sort_order != null ? c.sort_order : 0) + '' + (c.route_path || '') + '' + (c.created_at || '') + '' + + moveButtons + + ' ' + + ''; + }).join(''); + } + bindColumnActions(); + }) + .catch(function () { + document.getElementById('columnListBody').innerHTML = '请求失败'; + }); + } + function bindColumnActions() { + document.querySelectorAll('[data-column-move-up]').forEach(function (btn) { + btn.onclick = function () { + if (btn.disabled) return; + moveColumnItem(btn.dataset.columnMoveUp, -1, btn); + }; + }); + document.querySelectorAll('[data-column-move-down]').forEach(function (btn) { + btn.onclick = function () { + if (btn.disabled) return; + moveColumnItem(btn.dataset.columnMoveDown, 1, btn); + }; + }); + document.querySelectorAll('[data-column-edit]').forEach(function (btn) { + btn.onclick = function () { + document.getElementById('editColumnId').value = btn.dataset.columnEdit || ''; + document.getElementById('editColumnName').value = (btn.dataset.name || '').replace(/"/g, '"'); + document.getElementById('editColumnKey').value = (btn.dataset.key || '').replace(/"/g, '"'); + document.getElementById('editColumnRoutePath').value = (btn.dataset.route || '').replace(/"/g, '"'); + document.getElementById('editColumnMenuType').value = ((btn.dataset.menuType || 'app').replace(/"/g, '"') || 'app'); + document.getElementById('editColumnSortOrder').value = ((btn.dataset.sortOrder || '0').replace(/"/g, '"') || '0'); + document.getElementById('msgEditColumn').textContent = ''; + document.getElementById('editColumnModal').classList.add('show'); + }; + }); + document.querySelectorAll('[data-column-delete]').forEach(function (btn) { + btn.onclick = function () { + if (!confirm('确定删除菜单“' + (btn.dataset.name || '').replace(/"/g, '"') + '”吗?')) return; + fetch('/api/admin/column/' + btn.dataset.columnDelete, { method: 'DELETE' }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { loadColumns(); loadColumnsForPermission(); loadAdminMenus(getActiveAdminTabName()); } + else { alert(res.error || '删除失败'); } + }); + }; + }); + } + function moveColumnItem(columnId, delta, triggerBtn) { + var currentIndex = -1; + for (var i = 0; i < allColumnsList.length; i++) { + if (String(allColumnsList[i].id) === String(columnId)) { + currentIndex = i; + break; + } + } + if (currentIndex < 0) return; + var targetIndex = currentIndex + delta; + if (targetIndex < 0 || targetIndex >= allColumnsList.length) return; + var currentItem = allColumnsList[currentIndex]; + var targetItem = allColumnsList[targetIndex]; + if (!currentItem || !targetItem) return; + if (triggerBtn) triggerBtn.disabled = true; + fetch('/api/admin/column/reorder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + column_id: currentItem.id, + target_id: targetItem.id + }) + }).then(function (r) { return r.json(); }) + .then(function (res) { + if (!res || !res.success) { + alert((res && res.error) || '排序保存失败'); + return; + } + loadColumns(); + loadColumnsForPermission(); + loadAdminMenus(getActiveAdminTabName()); + }).catch(function () { + alert('排序保存失败'); + }).finally(function () { + if (triggerBtn) triggerBtn.disabled = false; + }); + } + document.getElementById('btnAddColumn').onclick = function () { + var name = (document.getElementById('columnName').value || '').trim(); + var key = (document.getElementById('columnKey').value || '').trim(); + var routePath = (document.getElementById('columnRoutePath').value || '').trim(); + var sortOrderValue = (document.getElementById('columnSortOrder').value || '').trim(); + var menuType = (document.getElementById('columnMenuType').value || 'admin').trim() || 'admin'; + var sortOrder = sortOrderValue === '' ? null : parseInt(sortOrderValue, 10); + var msgEl = document.getElementById('msgColumn'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!name) { msgEl.textContent = '请填写菜单名称'; msgEl.classList.add('err'); return; } + if (!key) { + msgEl.textContent = '请填写栏目标识'; msgEl.classList.add('err'); return; + } + if (!routePath) { + msgEl.textContent = '请填写菜单路由'; msgEl.classList.add('err'); return; + } + if (sortOrderValue !== '' && isNaN(sortOrder)) { + msgEl.textContent = '排序必须是数字'; msgEl.classList.add('err'); return; + } + fetch('/api/admin/column', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name, column_key: key, route_path: routePath, menu_type: menuType, sort_order: sortOrder }) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { + msgEl.textContent = res.msg || '新增成功'; + msgEl.classList.add('ok'); + document.getElementById('columnName').value = ''; + document.getElementById('columnKey').value = ''; + document.getElementById('columnRoutePath').value = ''; + document.getElementById('columnSortOrder').value = ''; + document.getElementById('columnMenuType').value = 'admin'; + loadColumns(); + loadColumnsForPermission(); + loadAdminMenus(getActiveAdminTabName()); + } else { + msgEl.textContent = res.error || '新增失败'; + msgEl.classList.add('err'); + } + }) + .catch(function () { msgEl.textContent = '请求失败'; msgEl.classList.add('err'); }); + }; + document.getElementById('btnSaveColumn').onclick = function () { + var cid = document.getElementById('editColumnId').value; + var name = (document.getElementById('editColumnName').value || '').trim(); + var key = (document.getElementById('editColumnKey').value || '').trim(); + var routePath = (document.getElementById('editColumnRoutePath').value || '').trim(); + var sortOrderValue = (document.getElementById('editColumnSortOrder').value || '').trim(); + var menuType = (document.getElementById('editColumnMenuType').value || 'admin').trim() || 'admin'; + var sortOrder = sortOrderValue === '' ? null : parseInt(sortOrderValue, 10); + var msgEl = document.getElementById('msgEditColumn'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!name) { msgEl.textContent = '请填写菜单名称'; msgEl.classList.add('err'); return; } + if (!key) { + msgEl.textContent = '请填写栏目标识'; msgEl.classList.add('err'); return; + } + if (!routePath) { + msgEl.textContent = '请填写菜单路由'; msgEl.classList.add('err'); return; + } + if (sortOrderValue !== '' && isNaN(sortOrder)) { + msgEl.textContent = '排序必须是数字'; msgEl.classList.add('err'); return; + } + fetch('/api/admin/column/' + cid, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name, column_key: key, route_path: routePath, menu_type: menuType, sort_order: sortOrder }) + }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { + document.getElementById('editColumnModal').classList.remove('show'); + loadColumns(); + loadColumnsForPermission(); + loadAdminMenus(getActiveAdminTabName()); + } else { + msgEl.textContent = res.error || '保存失败'; + msgEl.classList.add('err'); + } + }); + }; + + // ========== 分页 ========== + function renderPagination(elId, total, page, pageSize, onPage) { + var el = document.getElementById(elId); + if (!el) return; + var totalPages = Math.max(1, Math.ceil(total / pageSize)); + el.innerHTML = '共' + total + ' 条' + + '' + + '第' + page + ' / ' + totalPages + ' 页' + + ''; + el.querySelectorAll('[data-p]').forEach(function (b) { + if (!b.disabled) b.onclick = function () { onPage(parseInt(b.dataset.p, 10)); }; + }); + } + + // 初始化 + function loadAdminCurrentUser() { + fetch('/api/admin/current-user') + .then(function (r) { + if (r.status === 404) { + return fetch('/api/auth/check') + .then(function (fallbackResp) { return fallbackResp.json(); }) + .then(function (fallbackRes) { + if (fallbackRes && fallbackRes.logged_in) { + return { + success: true, + item: { + username: '当前用户', + role: '' + } + }; + } + return { success: false }; + }); + } + return r.json(); + }) + .then(function (res) { + var nameEl = document.getElementById('adminCurrentUsername'); + var roleEl = document.getElementById('adminCurrentUserRole'); + if (!res.success) { + nameEl.textContent = '未登录'; + roleEl.style.display = 'none'; + return; + } + var item = res.item || {}; + currentUserId = item.id || currentUserId; + currentUserRole = item.role || currentUserRole; + currentUserUsername = item.username || currentUserUsername; + nameEl.textContent = item.username || '管理员'; + if (item.role) { + roleEl.textContent = item.role === 'super_admin' + ? '超级管理员' + : (item.role === 'admin' ? '管理员' : '普通账号'); + roleEl.style.display = 'inline-block'; + } else { + roleEl.style.display = 'none'; + } + updateShopManageGroupButtonsAccess(); + }) + .catch(function () { + fetch('/api/auth/check') + .then(function (r) { return r.json(); }) + .then(function (res) { + document.getElementById('adminCurrentUsername').textContent = res && res.logged_in ? '当前用户' : '未登录'; + document.getElementById('adminCurrentUserRole').style.display = 'none'; + updateShopManageGroupButtonsAccess(); + }) + .catch(function () { + document.getElementById('adminCurrentUsername').textContent = '当前用户'; + document.getElementById('adminCurrentUserRole').style.display = 'none'; + updateShopManageGroupButtonsAccess(); + }); + }); + } + + document.getElementById('btnAdminLogout').onclick = function () { + fetch('/api/admin/logout', { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) + .then(function (r) { + if (r.status === 404) { + window.location.href = '/logout'; + return null; + } + return r.json(); + }) + .then(function (res) { + if (!res) return; + if (!res.success) { + alert(res.error || '退出失败'); + return; + } + window.location.replace(res.redirect || '/login?logout=1'); + }) + .catch(function () { + window.location.replace('/logout'); + }); + }; + + loadAdminCurrentUser(); + loadUserOptions(); + loadColumnsForPermission(); + loadShopManageGroups(); + loadAdminMenus(); + }) (); + diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index cc8d4f7..01ef494 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -1,4 +1,4 @@ - + @@ -356,6 +356,46 @@ text-align: center; } + .tree-name-cell { + display: flex; + align-items: center; + gap: 8px; + min-height: 24px; + } + + .tree-indent { + flex: 0 0 auto; + width: var(--indent, 0px); + } + + .tree-node-mark { + width: 18px; + height: 18px; + border-radius: 4px; + background: #eef1ff; + color: #667eea; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + } + + .category-path { + color: #777; + font-size: 12px; + margin-top: 4px; + } + + .category-tag { + display: inline-block; + padding: 3px 8px; + border-radius: 999px; + background: #f1f3f5; + color: #666; + font-size: 12px; + } + .modal-mask { display: none; position: fixed; @@ -741,6 +781,7 @@
店铺管理
跳过跟价 ASIN
查询 ASIN
+
商品类目
查看生成记录
版本管理
@@ -1295,6 +1336,51 @@ +
+
+

新增商品类目

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+

+
+
+

类目树

+ + + + + + + + + + + + +
类目层级路径排序来源备注操作
+
+
+

筛选条件

@@ -1675,3378 +1761,8 @@
- - + + diff --git a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue index 95533e9..d739396 100644 --- a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue @@ -32,7 +32,7 @@ {{ pushing ? '推送中...' : '推送到 Python 队列' }} -

Python 回传字段按 asin、国家、url、标题提交;后端接收分片后按 10 条一批调用 Coze,结果区展示真实 Coze 批次进度。

+

Python 回传字段按 asin、国家、url、标题提交;后端接收分片后累计 50 条调用 Coze,当前任务区展示 Coze 回流和结果组装进度。

任务 ID:{{ visibleTaskSummary.taskId }}
@@ -75,6 +75,19 @@ {{ item.sourceFilename || '外观专利检测' }}
任务 ID:{{ item.taskId }}
行数:{{ item.rowCount ?? '-' }}
+
{{ pendingResultHint(item) }}
+
+
+ {{ displayFileProgressMessage(item, '处理中') }} + {{ fileProgressPercent(item) }}% +
+
+
+
+
+ {{ item.fileProgressCurrent || 0 }}/{{ item.fileProgressTotal || 0 }} +
+
{{ statusText(item) }} @@ -100,7 +113,7 @@
{{ pendingResultHint(item) }}
- {{ item.fileProgressMessage || '结果生成中' }} + {{ displayFileProgressMessage(item, '结果生成中') }} {{ fileProgressPercent(item) }}%
@@ -201,8 +214,14 @@ const pendingParseItem = computed(() => { } }) const currentItems = computed(() => { - const runningItems = historyItems.value.filter((i) => i.taskId != null && pollingTaskIds.value.includes(i.taskId)) - return pendingParseItem.value ? [pendingParseItem.value, ...runningItems] : runningItems + const activeIds = new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value]) + 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(() => historyItems.value.filter((i) => { @@ -594,6 +613,16 @@ function seedPendingFileTasksFromHistory() { 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() { dashboard.value = await getAppearancePatentDashboard() } @@ -653,6 +682,9 @@ function pendingResultHint(item: AppearancePatentHistoryItem) { if (isResultBuildFailed(item)) { return item.fileError || '结果文件生成失败,请稍后重试或检查后端日志' } + if (showFileProgress(item)) { + return '' + } if (!isResultPreparing(item)) return '' const fileStatus = (item.fileStatus || '').toUpperCase() if (fileStatus === 'RUNNING') { @@ -674,8 +706,37 @@ function fileProgressPercent(item: AppearancePatentHistoryItem) { 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 = { + '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) { - 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) { @@ -717,6 +778,7 @@ onMounted(async () => { loadDashboard().catch(() => undefined), loadHistory().catch(() => undefined), ]) + seedRunningTasksFromHistory() seedPendingFileTasksFromHistory() if (pollingTaskIds.value.length || pendingFileTaskIds.value.length) ensurePolling() }) diff --git a/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue b/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue index ef1e44f..109d8d4 100644 --- a/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue @@ -291,7 +291,9 @@ function sanitizeSchedulePickerValues(values: string[], ensureOne: boolean) { if (!deduped.length && ensureOne) return [getDefaultScheduleValue()] return deduped } -function buildQueuePayload(taskId: number, item: Pick, 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, 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 } } 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 } diff --git a/frontend-vue/src/shared/api/java-modules.ts b/frontend-vue/src/shared/api/java-modules.ts index 7051951..53bffa6 100644 --- a/frontend-vue/src/shared/api/java-modules.ts +++ b/frontend-vue/src/shared/api/java-modules.ts @@ -631,6 +631,28 @@ export interface ProductRiskShopQueueItem { matchStatus?: string; matchMessage?: string; queryAsins?: QueryAsinCountryAsins[]; + skipAsinsByCountry?: Record; + skip_asins_by_country?: Record; + skipAsinDetailsByCountry?: Record>; + skip_asin_details_by_country?: Record>; +} + +export interface ShopMatchResultRow { + asin?: string; + minimumPrice?: string; + minimum_price?: string; + status?: string; + done?: boolean; +} + +export interface ShopMatchSubmitShopPayload { + shopName?: string; + error?: string; + countries?: Record; + skipAsinsByCountry?: Record; + skip_asins_by_country?: Record; + skipAsinDetailsByCountry?: Record>; + skip_asin_details_by_country?: Record>; } export interface ProductRiskMatchShopsVo { @@ -737,6 +759,10 @@ export interface ProductRiskHistoryItem { fileError?: string; fileReady?: boolean; scheduledAt?: string; + skipAsinsByCountry?: Record; + skip_asins_by_country?: Record; + skipAsinDetailsByCountry?: Record>; + skip_asin_details_by_country?: Record>; } export interface ProductRiskHistoryVo {