From aece8123c364eff0173536b1aadcecfb58789f22 Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Fri, 1 May 2026 21:09:54 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=A4=9A=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E5=BC=80=E5=90=AF=E5=A4=9A=E7=AA=97=E5=8F=A3=E3=80=81=E5=A4=96?= =?UTF-8?q?=E8=A7=82=E8=BF=9B=E5=BA=A6=E6=9D=A1=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/.env | 1 + app/amazon/main.py | 26 +- app/amazon/patrol_delete.py | 64 ++- app/blueprints/main.py | 16 + .../vo/AppearancePatentHistoryItemVo.java | 4 + .../service/AppearancePatentTaskService.java | 145 +++++- .../brand/service/BrandTaskService.java | 13 +- .../service/BrandTaskStorageService.java | 18 + .../controller/DedupeRunController.java | 16 +- .../dedupe/service/DedupeRunService.java | 12 +- .../controller/PatrolDeleteController.java | 28 ++ .../mapper/PatrolDeleteConditionMapper.java | 9 + .../dto/PatrolDeleteConditionAddRequest.java | 22 + .../entity/PatrolDeleteConditionEntity.java | 19 + .../model/vo/PatrolDeleteConditionVo.java | 17 + .../service/PatrolDeleteResolveService.java | 66 +++ .../service/PatrolDeleteTaskService.java | 51 ++- .../task/service/TaskFileJobService.java | 21 + .../service/TaskProgressSnapshotService.java | 10 + .../task/service/TaskResultItemService.java | 21 + .../db/V41__patrol_delete_condition.sql | 8 + .../components/BrandAppearancePatentTab.vue | 27 ++ .../brand/components/BrandPatrolDeleteTab.vue | 412 ++++++++++++------ .../brand/components/BrandProductRiskTab.vue | 58 ++- frontend-vue/src/shared/api/java-modules.ts | 44 ++ 25 files changed, 945 insertions(+), 183 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/mapper/PatrolDeleteConditionMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/dto/PatrolDeleteConditionAddRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/entity/PatrolDeleteConditionEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/vo/PatrolDeleteConditionVo.java create mode 100644 backend-java/src/main/resources/db/V41__patrol_delete_condition.sql diff --git a/app/.env b/app/.env index ef77969..20a1f7e 100644 --- a/app/.env +++ b/app/.env @@ -14,5 +14,6 @@ client_name=ShuFuAI # java_api_base=http://47.111.163.154:18080 java_api_base=http://127.0.0.1:18080 # java_api_base=http://8.136.19.173:18080 +# java_api_base=http://121.196.149.225:18080 diff --git a/app/amazon/main.py b/app/amazon/main.py index a5e7111..fd5864c 100644 --- a/app/amazon/main.py +++ b/app/amazon/main.py @@ -1,7 +1,8 @@ -import time -import traceback -import requests -from datetime import datetime +import time +import traceback +import requests +import threading +from datetime import datetime from typing import Dict, Any, List from concurrent.futures import ThreadPoolExecutor, as_completed from config import JSON_TASK_QUEUE, runing_task, runing_shop, DELETE_BRAND_API_BASE, ZN_COMPANY, ZN_USERNAME, ZN_PASSWORD @@ -31,7 +32,10 @@ class TaskMonitor: self.chunk_index = 1 # 当前处理的分块索引 self.max_workers = 5 # 线程池最大线程数 - self.executor = None # 线程池执行器 + self.executor = None # 线程池执行器 + self.serial_task_locks = { + "product-risk-resolve-run": threading.Lock(), + } # 在提交新任务前杀掉旧进程(确保环境干净) kill_process("v6") @@ -118,7 +122,17 @@ class TaskMonitor: except Exception as e: self.log(f"线程 {id(task_data)} 删除品牌任务处理异常: {traceback.format_exc()}", "ERROR") - def _process_approve_task_wrapper(self, task_data: Dict[str, Any],TASK_TYPE:str): + def _process_approve_task_wrapper(self, task_data: Dict[str, Any],TASK_TYPE:str): + task_type = task_data.get("type", "") + task_lock = self.serial_task_locks.get(task_type) + if task_lock is not None: + self.log(f"线程 {id(task_data)} 等待 {task_type} 串行锁...") + with task_lock: + self.log(f"线程 {id(task_data)} 获取 {task_type} 串行锁,开始处理...") + return self._process_approve_task_unlocked(task_data, TASK_TYPE) + return self._process_approve_task_unlocked(task_data, TASK_TYPE) + + def _process_approve_task_unlocked(self, task_data: Dict[str, Any],TASK_TYPE:str): """审批任务处理包装器(用于线程池调用) Args: diff --git a/app/amazon/patrol_delete.py b/app/amazon/patrol_delete.py index 7560373..a3408c0 100644 --- a/app/amazon/patrol_delete.py +++ b/app/amazon/patrol_delete.py @@ -234,12 +234,14 @@ class InventoryManage(AmamzonBase): country: str, max_delete_operations: Optional[int] = None, trace_id: str = "", + delete_conditions: Optional[List[Dict[str, Any]]] = None, ) -> Dict[str, Any]: """循环删除非白名单分类商品,直到当前国家没有可删项。""" trace_id = trace_id or _new_trace_id("del") logger.info( f"trace_id={trace_id} 开始批量删除非白名单商品: " - f"shop={shop_name}, country={country}, max_delete_operations={max_delete_operations}" + f"shop={shop_name}, country={country}, max_delete_operations={max_delete_operations}, " + f"delete_conditions={self._format_delete_conditions(delete_conditions)}" ) all_results: List[Dict[str, Any]] = [] delete_counts: Dict[str, int] = {} @@ -249,7 +251,10 @@ class InventoryManage(AmamzonBase): stop_reason = "no-candidates" latest_status_rows = self._read_listing_status_rows_for_delete(shop_name, country, trace_id=trace_id) - latest_delete_candidates = self._build_deletable_status_rows(latest_status_rows) + latest_delete_candidates = self._build_deletable_status_rows( + latest_status_rows, + delete_conditions=delete_conditions, + ) if not latest_delete_candidates: logger.info(f"trace_id={trace_id} 未找到可删除商品分类: shop={shop_name}, country={country}") final_status_rows = latest_status_rows @@ -783,7 +788,12 @@ class InventoryManage(AmamzonBase): ] @classmethod - def _build_deletable_status_rows(cls, rows: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]: + def _build_deletable_status_rows( + cls, + rows: Iterable[Dict[str, Any]], + delete_conditions: Optional[List[Dict[str, Any]]] = None, + ) -> List[Dict[str, Any]]: + condition_texts = cls._normalize_delete_condition_texts(delete_conditions) candidates = [] for row in rows: status = cls._normalize_option_text(row.get("status") or "") @@ -795,6 +805,8 @@ class InventoryManage(AmamzonBase): canonical_status = cls._canonical_listing_status(status) if not quantity_text or quantity <= 0 or canonical_status in LISTING_STATUS_DELETE_WHITELIST: continue + if condition_texts and not cls._matches_delete_conditions(status, condition_texts): + continue candidates.append( { @@ -805,6 +817,39 @@ class InventoryManage(AmamzonBase): ) return candidates + @classmethod + def _normalize_delete_condition_texts( + cls, + delete_conditions: Optional[List[Dict[str, Any]]], + ) -> List[str]: + texts = [] + for condition in delete_conditions or []: + if isinstance(condition, dict): + raw_text = condition.get("conditionText") or condition.get("condition_text") or condition.get("text") + else: + raw_text = condition + text = cls._normalize_option_text(str(raw_text or "")).lower() + if text: + texts.append(text) + return list(dict.fromkeys(texts)) + + @classmethod + def _matches_delete_conditions(cls, status: str, condition_texts: List[str]) -> bool: + normalized_status = cls._normalize_option_text(status).lower() + canonical_status = cls._canonical_listing_status(status).lower() + return any( + condition in normalized_status + or condition in canonical_status + or normalized_status in condition + or canonical_status in condition + for condition in condition_texts + ) + + @classmethod + def _format_delete_conditions(cls, delete_conditions: Optional[List[Dict[str, Any]]]) -> str: + texts = cls._normalize_delete_condition_texts(delete_conditions) + return ", ".join(texts) if texts else "default" + @staticmethod def _format_deletable_candidates(candidates: Iterable[Dict[str, Any]]) -> str: summary = [] @@ -1299,6 +1344,7 @@ class PatrolDeleteTask: items = data.get("items", []) country_sections = data.get("country_sections", []) cart_ratios = data.get("cart_ratios", []) + delete_conditions = data.get("delete_conditions") or data.get("deleteConditions") or [] if not task_id: self.log("任务ID为空,跳过", "WARNING") @@ -1321,10 +1367,14 @@ class PatrolDeleteTask: "failed_count": 0, "failed_countries": 0, "deleted_listings_count": 0, + "delete_conditions": delete_conditions, "stop_requested": False, } - self.log(f"开始处理巡店删除任务 {task_id},共 {len(items)} 个店铺,{total_countries} 个国家") + self.log( + f"开始处理巡店删除任务 {task_id},共 {len(items)} 个店铺,{total_countries} 个国家," + f"删除条件 {len(delete_conditions)} 条" + ) for index, shop_item in enumerate(items, 1): if runing_task[task_id].get("stop_requested", False): self.log(f"检测到任务 {task_id} 的暂停请求,停止处理", "WARNING") @@ -1333,7 +1383,7 @@ class PatrolDeleteTask: shop_name = shop_item.get("shopName", "未知店铺") self.log(f"[{index}/{len(items)}] 开始处理店铺: {shop_name}") - self.process_shop(task_id, shop_item, country_sections, cart_ratios) + self.process_shop(task_id, shop_item, country_sections, cart_ratios, delete_conditions) runing_task[task_id]["processed_shops"] += 1 if runing_task[task_id].get("stop_requested", False): @@ -1359,6 +1409,7 @@ class PatrolDeleteTask: shop_item: Dict[str, Any], country_sections: List[Dict[str, Any]], cart_ratios: List[Dict[str, Any]], + delete_conditions: Optional[List[Dict[str, Any]]] = None, ): """处理单个店铺。""" from config import runing_shop, runing_task @@ -1412,6 +1463,7 @@ class PatrolDeleteTask: task_id=task_id, shop_name=shop_name, country_name=country_name, + delete_conditions=delete_conditions, ) country_results.append(country_result) aggregated_sections[country_name] = country_result["countrySection"] @@ -1522,6 +1574,7 @@ class PatrolDeleteTask: task_id: int, shop_name: str, country_name: str, + delete_conditions: Optional[List[Dict[str, Any]]] = None, ) -> Dict[str, Any]: from config import runing_task @@ -1543,6 +1596,7 @@ class PatrolDeleteTask: shop_name, country_name, trace_id=trace_id, + delete_conditions=delete_conditions, ) latest_status_rows = delete_result.get("statusRows") or [] self.log( diff --git a/app/blueprints/main.py b/app/blueprints/main.py index 0ab1d93..00fde1f 100644 --- a/app/blueprints/main.py +++ b/app/blueprints/main.py @@ -168,6 +168,7 @@ def serve_assets(filename): @main_bp.route('/new_web_source/') +@login_required def serve_new_web_source(filename): """提供 static 目录及子目录下的静态文件访问。""" filepath = os.path.normpath(os.path.join("new_web_source", filename)) @@ -175,6 +176,21 @@ def serve_new_web_source(filename): file_abs = os.path.abspath(filepath) if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs): return '', 404 + if filename.lower().endswith('.html'): + with open(file_abs, 'rb') as f: + content = f.read().decode('utf-8', errors='replace') + raw_uid = session.get('user_id') + uid_value = str(int(raw_uid)) if str(raw_uid or '').isdigit() else '""' + uid_script = ( + "\n\n" + ) + if '' in content: + content = content.replace('', uid_script + '', 1) + else: + content = uid_script + content + return render_template_string(content) return send_file(file_abs, as_attachment=False) @main_bp.route('/logo.jpg', methods=['GET']) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java index cdd9e99..85756f6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java @@ -20,6 +20,10 @@ public class AppearancePatentHistoryItemVo { private String fileStatus; private String fileError; private Boolean fileReady; + private Integer fileProgressPercent; + private Integer fileProgressCurrent; + private Integer fileProgressTotal; + private String fileProgressMessage; @Schema(description = "任务状态:PENDING=已解析待推送,RUNNING=执行中,SUCCESS=成功,FAILED=失败。", example = "SUCCESS") private String taskStatus; @Schema(description = "结果是否成功。true 表示任务完成并生成结果文件;false 表示失败或未完成。", example = "true") 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 7783bbe..ee9b483 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 @@ -34,8 +34,10 @@ import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskProgressSnapshotEntity; import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity; import com.nanri.aiimage.modules.task.service.TaskFileJobService; +import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService; import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -59,6 +61,7 @@ import org.springframework.transaction.support.TransactionTemplate; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; +import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; @@ -106,6 +109,7 @@ public class AppearancePatentTaskService { private final AppearancePatentTaskCacheService taskCacheService; private final AppearancePatentProperties properties; private final TaskFileJobService taskFileJobService; + private final TaskProgressSnapshotService taskProgressSnapshotService; private final TransientPayloadStorageService transientPayloadStorageService; private final PlatformTransactionManager transactionManager; @@ -678,10 +682,10 @@ public class AppearancePatentTaskService { int batchSize = Math.max(1, properties.getCozeBatchSize()); List result = new ArrayList<>(); for (int i = 0; i < items.size(); i += batchSize) { + result.addAll(cozeClient.inspect(items.subList(i, Math.min(i + batchSize, items.size())), prompt)); if (progressHook != null) { progressHook.run(); } - result.addAll(cozeClient.inspect(items.subList(i, Math.min(i + batchSize, items.size())), prompt)); } return result; } @@ -697,9 +701,6 @@ public class AppearancePatentTaskService { .orderByAsc(TaskChunkEntity::getChunkIndex)); log.info("[appearance-patent] async coze start taskId={} chunks={}", task.getId(), chunks.size()); for (TaskChunkEntity chunk : chunks) { - if (progressHook != null) { - progressHook.run(); - } Map persistedRows = readChunkRows(chunk); if (persistedRows.isEmpty()) { continue; @@ -1021,12 +1022,68 @@ public class AppearancePatentTaskService { if (result == null || !MODULE_TYPE.equals(result.getModuleType())) { throw new BusinessException("结果记录不存在"); } - Runnable progressHook = () -> taskFileJobService.touchRunning(job.getId()); + List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, task.getId()) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .orderByAsc(TaskChunkEntity::getChunkIndex)); + int cozeWorkUnits = countCozeWorkUnits(chunks, Math.max(1, properties.getCozeBatchSize())); + int totalProgressUnits = Math.max(3, cozeWorkUnits + 3); + int[] completedProgressUnits = {0}; + saveFileBuildProgress(task, job, totalProgressUnits, completedProgressUnits[0], "正在请求 Coze"); + Runnable progressHook = () -> { + taskFileJobService.touchRunning(job.getId()); + completedProgressUnits[0] = Math.min(totalProgressUnits - 2, completedProgressUnits[0] + 1); + saveFileBuildProgress(task, job, totalProgressUnits, completedProgressUnits[0], "正在请求 Coze"); + }; applyCozeToPersistedChunks(task, progressHook); - progressHook.run(); + completedProgressUnits[0] = Math.max(completedProgressUnits[0], totalProgressUnits - 2); + saveFileBuildProgress(task, job, totalProgressUnits, completedProgressUnits[0], "正在组装 xlsx"); assembleResultWorkbook(task, result); - progressHook.run(); + completedProgressUnits[0] = totalProgressUnits - 1; + saveFileBuildProgress(task, job, totalProgressUnits, completedProgressUnits[0], "正在上传结果文件"); fileResultMapper.updateById(result); + saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits, "结果文件已生成"); + } + + private int countCozeWorkUnits(List chunks, int batchSize) { + if (chunks == null || chunks.isEmpty()) { + return 0; + } + int total = 0; + for (TaskChunkEntity chunk : chunks) { + Map persistedRows = readChunkRows(chunk); + if (persistedRows.isEmpty()) { + continue; + } + int unresolved = pickGroupRepresentativesForCoze(persistedRows.values()).size(); + if (unresolved > 0) { + total += Math.max(1, (unresolved + batchSize - 1) / batchSize); + } + } + return total; + } + + private void saveFileBuildProgress(FileTaskEntity task, + TaskFileJobEntity job, + int total, + int completed, + String message) { + if (task == null || task.getId() == null || job == null || job.getId() == null) { + return; + } + int safeTotal = Math.max(1, total); + int safeCompleted = Math.max(0, Math.min(completed, safeTotal)); + taskProgressSnapshotService.save( + task.getId(), + MODULE_TYPE, + STATUS_RUNNING, + safeTotal, + safeCompleted, + 0, + job.getScopeKey(), + message, + Map.of("phase", "RESULT_FILE", "jobId", job.getId()) + ); } public void cleanupResultFileJob(TaskFileJobEntity job) { @@ -1045,6 +1102,14 @@ public class AppearancePatentTaskService { private void assembleResultWorkbook(FileTaskEntity task, FileResultEntity result) { AppearancePatentParsedPayloadDto parsed = readParsedPayload(task); Map resultMap = loadPersistedResultRows(task.getId()); + long resolvedRows = parsed.getAllItems().stream() + .filter(row -> findResultRow(row, resultMap) != null || findResultRowByAsin(row.getAsin(), resultMap) != null) + .count(); + long reasonRows = resultMap.values().stream() + .filter(this::hasReasonFields) + .count(); + log.info("[appearance-patent] assemble workbook taskId={} parsedRows={} resultRows={} resolvedRows={} reasonRows={}", + task.getId(), parsed.getAllItems().size(), resultMap.size(), resolvedRows, reasonRows); File outputDir = new File(storageProperties.getLocalTempDir(), "appearance-patent-result"); if (!outputDir.exists() && !outputDir.mkdirs()) { throw new BusinessException("创建结果目录失败"); @@ -1087,8 +1152,8 @@ public class AppearancePatentTaskService { headerStyle.setFont(font); List resultHeaders = new ArrayList<>(RESULT_HEADERS); - resultHeaders.add(4, "title"); - resultHeaders.add(5, "url"); + resultHeaders.add(4, "标题"); + resultHeaders.add(5, "图片链接"); Row header = sheet.createRow(0); for (int i = 0; i < resultHeaders.size(); i++) { Cell cell = header.createCell(i); @@ -1099,6 +1164,9 @@ public class AppearancePatentTaskService { int rowIndex = 1; for (AppearancePatentParsedRowVo parsedRow : parsed.getAllItems()) { AppearancePatentResultRowDto resultRow = findResultRow(parsedRow, resultMap); + if (resultRow == null) { + resultRow = findResultRowByAsin(parsedRow.getAsin(), resultMap); + } String missingReason = ""; if (resultRow == null) { missingReason = hasPromptFields(parsedRow) ? "未匹配到检测结果" : "未送检:缺少标题或图片"; @@ -1162,12 +1230,28 @@ public class AppearancePatentTaskService { if (normalizedAsin.isBlank() || resultMap == null || resultMap.isEmpty()) { return null; } + AppearancePatentResultRowDto fallback = null; for (AppearancePatentResultRowDto row : resultMap.values()) { - if (row != null && normalizedAsin.equals(normalize(row.getAsin()).toUpperCase(Locale.ROOT))) { + if (row == null || !normalizedAsin.equals(normalize(row.getAsin()).toUpperCase(Locale.ROOT))) { + continue; + } + if (hasResolvedCozeFields(row) || hasReasonFields(row)) { return row; } + if (fallback == null) { + fallback = row; + } } - return null; + return fallback; + } + + private boolean hasReasonFields(AppearancePatentResultRowDto row) { + if (row == null) { + return false; + } + return !normalize(row.getAppearanceReason()).isBlank() + || !normalize(row.getPatentReason()).isBlank() + || !normalize(row.getTitleReason()).isBlank(); } private ParsedWorkbook parseWorkbook(File input, AppearancePatentSourceFileDto source) { @@ -1468,11 +1552,50 @@ public class AppearancePatentTaskService { vo.setFileReady(row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank()); if (job == null) { vo.setFileStatus(Boolean.TRUE.equals(vo.getFileReady()) ? "SUCCESS" : null); + attachFileProgress(vo, row, job); return; } vo.setFileJobId(job.getId()); vo.setFileStatus(job.getStatus()); vo.setFileError(job.getErrorMessage()); + attachFileProgress(vo, row, job); + } + + private void attachFileProgress(AppearancePatentHistoryItemVo vo, FileResultEntity row, TaskFileJobEntity job) { + if (row == null || row.getTaskId() == null) { + return; + } + if (Boolean.TRUE.equals(vo.getFileReady())) { + vo.setFileProgressCurrent(1); + vo.setFileProgressTotal(1); + vo.setFileProgressPercent(100); + vo.setFileProgressMessage("结果文件已生成"); + return; + } + TaskProgressSnapshotEntity snapshot = taskProgressSnapshotService.find(row.getTaskId(), MODULE_TYPE); + if (snapshot == null) { + return; + } + int total = snapshot.getTotalCount() == null ? 0 : snapshot.getTotalCount(); + int current = snapshot.getSuccessCount() == null ? 0 : snapshot.getSuccessCount(); + if (total <= 0) { + return; + } + current = Math.max(0, Math.min(current, total)); + int percent = Math.max(1, Math.min(99, (int) Math.floor(current * 100.0 / total))); + if (job != null && STATUS_RUNNING.equals(job.getStatus())) { + LocalDateTime baseTime = snapshot.getUpdatedAt() != null ? snapshot.getUpdatedAt() : job.getUpdatedAt(); + long elapsedSeconds = baseTime == null ? 0 : Math.max(0, Duration.between(baseTime, LocalDateTime.now()).getSeconds()); + if (current <= 0) { + percent = Math.max(percent, Math.min(35, 8 + (int) (elapsedSeconds / 6))); + } else if (current < total) { + percent = Math.max(percent, Math.min(92, percent + (int) (elapsedSeconds / 10))); + } + } + vo.setFileProgressCurrent(current); + vo.setFileProgressTotal(total); + vo.setFileProgressPercent(percent); + vo.setFileProgressMessage(snapshot.getMessage()); } private String fmt(LocalDateTime t) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java index 5354ceb..26e92f6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java @@ -279,16 +279,21 @@ public class BrandTaskService { throw new BusinessException("totalLines 超过原始文件行数: " + fileUrl); } BrandTaskStorageService.ChunkStoreResult storeResult = brandTaskStorageService.storeChunk(taskId, resultFile); + BrandTaskStorageService.ChunkStoreResult refreshResult = brandTaskStorageService.refreshFileAggregate(taskId, fileUrl); + BrandFileAggregateCacheDto aggregate = refreshResult.aggregate() == null ? storeResult.aggregate() : refreshResult.aggregate(); finishedCount = Math.max(finishedCount, storeResult.finishedFiles()); - log.info("[brand-submit] taskId={} fileUrl={} chunk={}/{} stored={} newlyCompletedFile={} finishedFiles={}", + finishedCount = Math.max(finishedCount, refreshResult.finishedFiles()); + log.info("[brand-submit] taskId={} fileUrl={} chunk={}/{} stored={} newlyCompletedFile={} finishedFiles={} receivedChunks={} processedLines={} totalLines={}", taskId, fileUrl, resultFile.getChunkIndex(), resultFile.getChunkTotal(), storeResult.stored(), - storeResult.newlyCompletedFile(), - finishedCount); - BrandFileAggregateCacheDto aggregate = storeResult.aggregate(); + storeResult.newlyCompletedFile() || refreshResult.newlyCompletedFile(), + finishedCount, + aggregate == null ? 0 : defaultInteger(aggregate.getReceivedChunkCount()), + aggregate == null ? 0 : defaultInteger(aggregate.getProcessedLineCount()), + aggregate == null ? 0 : defaultInteger(aggregate.getTotalLines())); String fileName = blankToDefault(sourceFile.getOriginalFilename(), sourceFile.getFileUrl()); int fileIndex = sourceIndexByUrl.getOrDefault(fileUrl, 0); int currentLine = aggregate == null ? 0 : defaultInteger(aggregate.getProcessedLineCount()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java index 2da2092..873f9a4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java @@ -126,6 +126,7 @@ public class BrandTaskStorageService { if (existingChunk != null) { if (payloadHash.equals(existingChunk.getPayloadHash())) { BrandFileAggregateCacheDto aggregate = rebuildAggregate(taskId, scopeKey, scopeHash); + saveAggregate(taskId, scopeKey, scopeHash, aggregate, now); return new ChunkStoreResult(false, false, countCompletedFiles(taskId), aggregate); } throw new BusinessException("Brand task chunk conflict: " + scopeKey + "#" + file.getChunkIndex()); @@ -155,6 +156,7 @@ public class BrandTaskStorageService { if (concurrentChunk != null && payloadHash.equals(concurrentChunk.getPayloadHash())) { transientPayloadStorageService.deletePayloadIfPresent(storedPayload); BrandFileAggregateCacheDto aggregate = rebuildAggregate(taskId, scopeKey, scopeHash); + saveAggregate(taskId, scopeKey, scopeHash, aggregate, now); return new ChunkStoreResult(false, false, countCompletedFiles(taskId), aggregate); } transientPayloadStorageService.deletePayloadIfPresent(storedPayload); @@ -218,6 +220,22 @@ public class BrandTaskStorageService { return count == null ? 0 : count.intValue(); } + @Transactional + public ChunkStoreResult refreshFileAggregate(Long taskId, String fileUrl) { + if (taskId == null || taskId <= 0 || isBlank(fileUrl)) { + return new ChunkStoreResult(false, false, countCompletedFiles(taskId), null); + } + String scopeKey = normalize(fileUrl); + String scopeHash = hash(scopeKey); + BrandFileAggregateCacheDto aggregate = rebuildAggregate(taskId, scopeKey, scopeHash); + if (aggregate == null) { + return new ChunkStoreResult(false, false, countCompletedFiles(taskId), null); + } + boolean completed = Boolean.TRUE.equals(aggregate.getCompleted()); + saveAggregate(taskId, scopeKey, scopeHash, aggregate, LocalDateTime.now()); + return new ChunkStoreResult(false, completed, countCompletedFiles(taskId), aggregate); + } + @Transactional public void deleteTaskData(Long taskId) { if (taskId == null || taskId <= 0) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeRunController.java index d953d7e..e275077 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeRunController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeRunController.java @@ -14,9 +14,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -27,6 +25,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.net.URI; + @RestController @RequiredArgsConstructor @RequestMapping("/api/dedupe") @@ -94,14 +94,14 @@ public class DedupeRunController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "返回结果文件流"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在") }) - public ResponseEntity download( + public ResponseEntity download( @Parameter(description = "去重结果记录 ID", required = true) @PathVariable Long resultId, @Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) { - Resource resource = dedupeRunService.getResultFile(resultId, userId); - return ResponseEntity.ok() - .contentType(MediaType.APPLICATION_OCTET_STREAM) - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment") - .body(resource); + String downloadUrl = dedupeRunService.getResultDownloadUrl(resultId, userId); + return ResponseEntity.status(302) + .location(URI.create(downloadUrl)) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .build(); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java index 360a1c4..946d945 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java @@ -24,8 +24,6 @@ import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.util.WorkbookUtil; import org.apache.poi.xssf.streaming.SXSSFWorkbook; -import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; import java.io.File; @@ -229,7 +227,7 @@ public class DedupeRunService { vo.setResultId(entity.getId()); vo.setSourceFilename(entity.getSourceFilename()); vo.setOutputFilename(entity.getResultFilename()); - vo.setDownloadUrl(null); + vo.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl())); vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); vo.setError(entity.getErrorMessage()); return vo; @@ -245,16 +243,12 @@ public class DedupeRunService { fileResultMapper.deleteById(resultId); } - public Resource getResultFile(Long resultId, Long userId) { + public String getResultDownloadUrl(Long resultId, Long userId) { FileResultEntity entity = fileResultMapper.selectById(resultId); if (entity == null || entity.getResultFileUrl() == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { throw new BusinessException("结果文件不存在"); } - File file = new File(entity.getResultFileUrl()); - if (!file.exists()) { - throw new BusinessException("结果文件不存在"); - } - return new FileSystemResource(file); + return ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()); } private void cleanExcelByLegacyRules(File inputFile, File outputFile, List selectedColumns, diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java index 13365e8..fc4f4f4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java @@ -1,10 +1,12 @@ package com.nanri.aiimage.modules.patroldelete.controller; import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteConditionAddRequest; import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCreateTaskRequest; import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteSubmitResultRequest; import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteTaskBatchRequest; import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteCreateTaskVo; +import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteConditionVo; import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteHistoryVo; import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteTaskBatchVo; import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteResolveService; @@ -92,6 +94,32 @@ public class PatrolDeleteController { return ApiResponse.success(null); } + @GetMapping("/conditions") + @Operation(summary = "查询删除条件", description = "返回当前用户保存的巡店删除条件。") + public ApiResponse> listConditions( + @Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") + @RequestParam("user_id") Long userId) { + return ApiResponse.success(patrolDeleteResolveService.listConditions(userId)); + } + + @PostMapping("/conditions") + @Operation(summary = "新增删除条件", description = "保存一条当前用户可复用的巡店删除条件。") + public ApiResponse addCondition( + @Valid @RequestBody PatrolDeleteConditionAddRequest request) { + return ApiResponse.success(patrolDeleteResolveService.addCondition(request)); + } + + @DeleteMapping("/conditions/{id}") + @Operation(summary = "删除删除条件", description = "删除当前用户名下的一条巡店删除条件。") + public ApiResponse deleteCondition( + @Parameter(description = "删除条件主键", example = "10") + @PathVariable Long id, + @Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") + @RequestParam("user_id") Long userId) { + patrolDeleteResolveService.deleteCondition(userId, id); + return ApiResponse.success(null); + } + @PostMapping("/match-shops") @Operation(summary = "批量匹配店铺", description = "根据店铺名批量匹配紫鸟店铺索引,返回是否命中、店铺 ID、平台、公司和匹配状态。") public ApiResponse matchShops( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/mapper/PatrolDeleteConditionMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/mapper/PatrolDeleteConditionMapper.java new file mode 100644 index 0000000..b2f5f83 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/mapper/PatrolDeleteConditionMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.patroldelete.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.patroldelete.model.entity.PatrolDeleteConditionEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PatrolDeleteConditionMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/dto/PatrolDeleteConditionAddRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/dto/PatrolDeleteConditionAddRequest.java new file mode 100644 index 0000000..42f9c31 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/dto/PatrolDeleteConditionAddRequest.java @@ -0,0 +1,22 @@ +package com.nanri.aiimage.modules.patroldelete.model.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +@Schema(description = "巡店删除条件新增请求") +public class PatrolDeleteConditionAddRequest { + + @NotNull + @JsonProperty("user_id") + @Schema(description = "当前用户 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + private Long userId; + + @NotBlank + @JsonProperty("condition_text") + @Schema(description = "删除条件文本", example = "删除商品信息草稿", requiredMode = Schema.RequiredMode.REQUIRED) + private String conditionText; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/entity/PatrolDeleteConditionEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/entity/PatrolDeleteConditionEntity.java new file mode 100644 index 0000000..21a26a3 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/entity/PatrolDeleteConditionEntity.java @@ -0,0 +1,19 @@ +package com.nanri.aiimage.modules.patroldelete.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_patrol_delete_condition") +public class PatrolDeleteConditionEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private String conditionText; + private LocalDateTime createdAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/vo/PatrolDeleteConditionVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/vo/PatrolDeleteConditionVo.java new file mode 100644 index 0000000..7d9dcb4 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/vo/PatrolDeleteConditionVo.java @@ -0,0 +1,17 @@ +package com.nanri.aiimage.modules.patroldelete.model.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class PatrolDeleteConditionVo { + + private Long id; + + @JsonProperty("conditionText") + private String conditionText; + + private LocalDateTime createdAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteResolveService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteResolveService.java index 4a2138f..a23ab0a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteResolveService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteResolveService.java @@ -2,8 +2,12 @@ package com.nanri.aiimage.modules.patroldelete.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.patroldelete.mapper.PatrolDeleteConditionMapper; import com.nanri.aiimage.modules.patroldelete.mapper.PatrolDeleteShopCandidateMapper; +import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteConditionAddRequest; +import com.nanri.aiimage.modules.patroldelete.model.entity.PatrolDeleteConditionEntity; import com.nanri.aiimage.modules.patroldelete.model.entity.PatrolDeleteShopCandidateEntity; +import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteConditionVo; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest; import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo; @@ -27,6 +31,7 @@ import java.util.Objects; public class PatrolDeleteResolveService { private final PatrolDeleteShopCandidateMapper candidateMapper; + private final PatrolDeleteConditionMapper conditionMapper; private final ZiniaoShopSwitchService ziniaoShopSwitchService; public List listCandidates(Long userId) { @@ -127,6 +132,67 @@ public class PatrolDeleteResolveService { return count == null ? 0L : count; } + public List listConditions(Long userId) { + validateUserId(userId); + List rows = conditionMapper.selectList( + new LambdaQueryWrapper() + .eq(PatrolDeleteConditionEntity::getUserId, userId) + .orderByDesc(PatrolDeleteConditionEntity::getId)); + List list = new ArrayList<>(); + for (PatrolDeleteConditionEntity row : rows) { + list.add(toConditionVo(row)); + } + return list; + } + + @Transactional + public PatrolDeleteConditionVo addCondition(PatrolDeleteConditionAddRequest request) { + validateUserId(request.getUserId()); + String text = normalizeConditionText(request.getConditionText()); + if (text.isBlank()) { + throw new BusinessException("删除条件不能为空"); + } + PatrolDeleteConditionEntity existing = conditionMapper.selectOne( + new LambdaQueryWrapper() + .eq(PatrolDeleteConditionEntity::getUserId, request.getUserId()) + .eq(PatrolDeleteConditionEntity::getConditionText, text) + .last("limit 1")); + if (existing != null) { + return toConditionVo(existing); + } + PatrolDeleteConditionEntity entity = new PatrolDeleteConditionEntity(); + entity.setUserId(request.getUserId()); + entity.setConditionText(text); + entity.setCreatedAt(LocalDateTime.now()); + conditionMapper.insert(entity); + return toConditionVo(entity); + } + + @Transactional + public void deleteCondition(Long userId, Long id) { + validateUserId(userId); + if (id == null || id <= 0) { + throw new BusinessException("id 不合法"); + } + PatrolDeleteConditionEntity row = conditionMapper.selectById(id); + if (row == null || !userId.equals(row.getUserId())) { + throw new BusinessException("记录不存在"); + } + conditionMapper.deleteById(id); + } + + private PatrolDeleteConditionVo toConditionVo(PatrolDeleteConditionEntity row) { + PatrolDeleteConditionVo vo = new PatrolDeleteConditionVo(); + vo.setId(row.getId()); + vo.setConditionText(row.getConditionText()); + vo.setCreatedAt(row.getCreatedAt()); + return vo; + } + + private String normalizeConditionText(String value) { + return value == null ? "" : value.trim(); + } + private ProductRiskShopQueueItemVo matchOneShop(String shopName) { ProductRiskShopQueueItemVo item = new ProductRiskShopQueueItemVo(); item.setShopName(shopName); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java index 8f6d54d..5215cc8 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java @@ -149,11 +149,16 @@ public class PatrolDeleteTaskService { Map taskMap = loadTaskMap(entities); Map> snapshotMap = buildSnapshotMap(taskMap); + Map jobMap = taskFileJobService.findAssembleJobsByResultIds(MODULE_TYPE, entities.stream() + .map(FileResultEntity::getId) + .filter(id -> id != null && id > 0) + .distinct() + .toList()); List items = new ArrayList<>(); for (FileResultEntity entity : entities) { FileTaskEntity task = taskMap.get(entity.getTaskId()); PatrolDeleteResultItemVo snapshot = snapshotMap.getOrDefault(entity.getTaskId(), Map.of()).get(entity.getId()); - items.add(toHistoryItem(entity, task, snapshot)); + items.add(toHistoryItem(entity, task, snapshot, jobMap.get(entity.getId()))); } vo.setItems(items); return vo; @@ -183,6 +188,11 @@ public class PatrolDeleteTaskService { for (FileResultEntity row : rows) { rowsByTaskId.computeIfAbsent(row.getTaskId(), ignored -> new ArrayList<>()).add(row); } + Map jobMap = taskFileJobService.findAssembleJobsByResultIds(MODULE_TYPE, rows.stream() + .map(FileResultEntity::getId) + .filter(id -> id != null && id > 0) + .distinct() + .toList()); for (Long taskId : normalizedTaskIds) { FileTaskEntity task = taskMap.get(taskId); @@ -195,7 +205,7 @@ public class PatrolDeleteTaskService { batch.getMissingTaskIds().add(taskId); continue; } - batch.getItems().addAll(buildProgressItems(task, taskRows)); + batch.getItems().addAll(buildProgressItems(task, taskRows, jobMap)); } return batch; } @@ -252,8 +262,6 @@ public class PatrolDeleteTaskService { task.setCreatedAt(now); task.setUpdatedAt(now); fileTaskMapper.insert(task); - taskCacheService.saveTaskCache(task); - taskCacheService.touchTaskHeartbeat(task.getId()); List snapshots = new ArrayList<>(); for (PatrolDeleteTaskItemDto item : uniqueItems) { @@ -272,9 +280,6 @@ public class PatrolDeleteTaskService { fileResultMapper.insert(result); snapshots.add(toSnapshotVo(result, item, task.getStatus(), null)); } - persistTaskJson(task, uniqueItems, snapshots); - taskCacheService.saveTaskCache(task); - PatrolDeleteCreateTaskVo vo = new PatrolDeleteCreateTaskVo(); vo.setTaskId(task.getId()); vo.setItems(snapshots); @@ -291,7 +296,8 @@ public class PatrolDeleteTaskService { FileTaskEntity task = loadTaskForExecution(taskId); if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { - throw new BusinessException("任务不存在"); + log.info("[patrol-delete] ignore result callback for deleted task taskId={}", taskId); + return; } if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) { throw new BusinessException("任务已结束,拒绝重复提交"); @@ -419,6 +425,7 @@ public class PatrolDeleteTaskService { fileResultMapper.delete(new LambdaQueryWrapper() .eq(FileResultEntity::getTaskId, taskId) .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + cleanupTaskAuxiliaryData(taskId); fileTaskMapper.deleteById(taskId); taskCacheService.deleteTaskCache(taskId); } @@ -432,6 +439,7 @@ public class PatrolDeleteTaskService { } Long taskId = entity.getTaskId(); fileResultMapper.deleteById(resultId); + cleanupResultAuxiliaryData(taskId, resultId); reconcileTaskAfterResultRemoval(taskId); } @@ -445,6 +453,7 @@ public class PatrolDeleteTaskService { } List rows = listTaskRows(taskId); if (rows.isEmpty()) { + cleanupTaskAuxiliaryData(taskId); fileTaskMapper.deleteById(taskId); taskCacheService.deleteTaskCache(taskId); return; @@ -455,6 +464,17 @@ public class PatrolDeleteTaskService { taskCacheService.saveTaskCache(task); } + private void cleanupTaskAuxiliaryData(Long taskId) { + taskResultItemService.deleteTaskItems(taskId, MODULE_TYPE); + taskProgressSnapshotService.delete(taskId, MODULE_TYPE); + taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE); + } + + private void cleanupResultAuxiliaryData(Long taskId, Long resultId) { + taskResultItemService.deleteResultItem(taskId, MODULE_TYPE, resultId); + taskFileJobService.deleteResultJobs(taskId, MODULE_TYPE, resultId); + } + private long countTasks(Long userId, List statuses) { Long count = fileTaskMapper.selectCount(new LambdaQueryWrapper() .eq(FileTaskEntity::getModuleType, MODULE_TYPE) @@ -517,6 +537,10 @@ public class PatrolDeleteTaskService { } private PatrolDeleteResultItemVo toHistoryItem(FileResultEntity entity, FileTaskEntity task, PatrolDeleteResultItemVo snapshot) { + return toHistoryItem(entity, task, snapshot, null); + } + + private PatrolDeleteResultItemVo toHistoryItem(FileResultEntity entity, FileTaskEntity task, PatrolDeleteResultItemVo snapshot, TaskFileJobEntity job) { PatrolDeleteResultItemVo item = snapshot != null ? snapshot : new PatrolDeleteResultItemVo(); item.setResultId(entity.getId()); item.setTaskId(entity.getTaskId()); @@ -529,7 +553,7 @@ public class PatrolDeleteTaskService { item.setFinishedAt(task != null ? task.getFinishedAt() : item.getFinishedAt()); item.setOutputFilename(firstNonBlank(item.getOutputFilename(), entity.getResultFilename())); item.setDownloadUrl(null); - attachFileJobState(item, entity); + attachFileJobState(item, entity, job); if (item.getCountrySections() == null) { item.setCountrySections(new ArrayList<>()); } @@ -540,7 +564,10 @@ public class PatrolDeleteTaskService { } private void attachFileJobState(PatrolDeleteResultItemVo item, FileResultEntity entity) { - TaskFileJobEntity job = taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId()); + attachFileJobState(item, entity, taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId())); + } + + private void attachFileJobState(PatrolDeleteResultItemVo item, FileResultEntity entity, TaskFileJobEntity job) { item.setFileReady(!blank(entity.getResultFileUrl())); if (job == null) { item.setFileStatus(Boolean.TRUE.equals(item.getFileReady()) ? "SUCCESS" : null); @@ -612,7 +639,7 @@ public class PatrolDeleteTaskService { return list; } - private List buildProgressItems(FileTaskEntity task, List rows) { + private List buildProgressItems(FileTaskEntity task, List rows, Map jobMap) { List list = new ArrayList<>(); for (FileResultEntity row : rows) { PatrolDeleteResultItemVo item = new PatrolDeleteResultItemVo(); @@ -627,7 +654,7 @@ public class PatrolDeleteTaskService { item.setFinishedAt(task == null ? null : task.getFinishedAt()); item.setOutputFilename(row.getResultFilename()); item.setDownloadUrl(null); - attachFileJobState(item, row); + attachFileJobState(item, row, jobMap == null ? null : jobMap.get(row.getId())); list.add(item); } return list; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java index db3d2f0..151164c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java @@ -225,6 +225,27 @@ public class TaskFileJobService { return count == null ? 0L : count; } + @Transactional + public void deleteTaskJobs(Long taskId, String moduleType) { + if (taskId == null || taskId <= 0 || moduleType == null || moduleType.isBlank()) { + return; + } + taskFileJobMapper.delete(new LambdaQueryWrapper() + .eq(TaskFileJobEntity::getTaskId, taskId) + .eq(TaskFileJobEntity::getModuleType, moduleType)); + } + + @Transactional + public void deleteResultJobs(Long taskId, String moduleType, Long resultId) { + if (taskId == null || taskId <= 0 || moduleType == null || moduleType.isBlank() || resultId == null || resultId <= 0) { + return; + } + taskFileJobMapper.delete(new LambdaQueryWrapper() + .eq(TaskFileJobEntity::getTaskId, taskId) + .eq(TaskFileJobEntity::getModuleType, moduleType) + .eq(TaskFileJobEntity::getResultId, resultId)); + } + private TaskFileJobEntity findJob(Long taskId, String moduleType, Long resultId, String jobType) { if (taskId == null || moduleType == null || moduleType.isBlank() || resultId == null || jobType == null || jobType.isBlank()) { return null; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskProgressSnapshotService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskProgressSnapshotService.java index d310ab5..ce70a43 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskProgressSnapshotService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskProgressSnapshotService.java @@ -84,6 +84,16 @@ public class TaskProgressSnapshotService { .last("limit 1")); } + @Transactional + public void delete(Long taskId, String moduleType) { + if (taskId == null || taskId <= 0 || isBlank(moduleType)) { + return; + } + taskProgressSnapshotMapper.delete(new LambdaQueryWrapper() + .eq(TaskProgressSnapshotEntity::getTaskId, taskId) + .eq(TaskProgressSnapshotEntity::getModuleType, moduleType)); + } + private String writeJson(Object value) { try { return objectMapper.writeValueAsString(value); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java index 66b9de9..bfe9eb8 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java @@ -94,6 +94,27 @@ public class TaskResultItemService { return count == null ? 0L : count; } + @Transactional + public void deleteResultItem(Long taskId, String moduleType, Long resultId) { + if (taskId == null || taskId <= 0 || isBlank(moduleType) || resultId == null || resultId <= 0) { + return; + } + List rows = taskResultItemMapper.selectList(new LambdaQueryWrapper() + .select(TaskResultItemEntity::getId, TaskResultItemEntity::getPayloadJson) + .eq(TaskResultItemEntity::getTaskId, taskId) + .eq(TaskResultItemEntity::getModuleType, moduleType) + .eq(TaskResultItemEntity::getResultId, resultId)); + if (rows != null) { + for (TaskResultItemEntity row : rows) { + transientPayloadStorageService.deletePayloadIfPresent(row.getPayloadJson()); + } + } + taskResultItemMapper.delete(new LambdaQueryWrapper() + .eq(TaskResultItemEntity::getTaskId, taskId) + .eq(TaskResultItemEntity::getModuleType, moduleType) + .eq(TaskResultItemEntity::getResultId, resultId)); + } + @Transactional public void deleteTaskItems(Long taskId, String moduleType) { if (taskId == null || taskId <= 0 || isBlank(moduleType)) { diff --git a/backend-java/src/main/resources/db/V41__patrol_delete_condition.sql b/backend-java/src/main/resources/db/V41__patrol_delete_condition.sql new file mode 100644 index 0000000..7e095d2 --- /dev/null +++ b/backend-java/src/main/resources/db/V41__patrol_delete_condition.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS biz_patrol_delete_condition ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + condition_text VARCHAR(500) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_patrol_delete_condition_user_text (user_id, condition_text), + KEY idx_patrol_delete_condition_user_id (user_id) +); diff --git a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue index 2ebb9c9..5010055 100644 --- a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue @@ -113,6 +113,18 @@ {{ item.resultFilename || '下载结果' }}
{{ pendingResultHint(item) }}
+
+
+ {{ item.fileProgressMessage || '结果生成中' }} + {{ fileProgressPercent(item) }}% +
+
+
+
+
+ {{ item.fileProgressCurrent || 0 }}/{{ item.fileProgressTotal || 0 }} +
+
错误:{{ item.error }}
@@ -633,6 +645,16 @@ function canDownload(item: AppearancePatentHistoryItem) { return Boolean(item.resultId && (item.fileReady || item.downloadUrl)) } +function fileProgressPercent(item: AppearancePatentHistoryItem) { + const percent = Number(item.fileProgressPercent || 0) + if (!Number.isFinite(percent)) return 0 + return Math.max(0, Math.min(100, Math.round(percent))) +} + +function showFileProgress(item: AppearancePatentHistoryItem) { + return isResultPreparing(item) && (item.fileProgressTotal || 0) > 0 +} + async function downloadResult(item: AppearancePatentHistoryItem) { if (!item.resultId) return const api = getPywebviewApi() @@ -729,6 +751,11 @@ onUnmounted(() => { .status.running { background: rgba(52, 152, 219, .18); color: #3498db; } .status.pending { background: rgba(149, 165, 166, .18); color: #bdc3c7; } .result-hint { margin-top: 6px; color: #e0b96d; } +.file-progress { margin-top: 8px; max-width: 520px; } +.file-progress-meta { display: flex; justify-content: space-between; gap: 12px; color: #d8c278; font-size: 12px; } +.file-progress-track { margin-top: 5px; height: 8px; border-radius: 999px; overflow: hidden; background: #303030; border: 1px solid #3b3b3b; } +.file-progress-bar { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #4aa3ff, #f0c75e); transition: width .25s ease; } +.file-progress-count { margin-top: 4px; color: #858585; font-size: 11px; } .download { padding: 6px 10px; color: #d6ecff; background: rgba(52, 152, 219, .18); } .btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); } @media (max-width: 1100px) { diff --git a/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue b/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue index fc8ea7b..e4af1d4 100644 --- a/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue @@ -7,7 +7,7 @@
店铺输入
- 左侧负责录入店铺并加入备选区,确认命中后推送到 Python 队列。巡店删除按店铺串行执行:上一个任务完成后会自动开始下一个,失败也会继续下一个。 + 左侧负责录入店铺并加入备选区,点击“匹配店铺”只会生成匹配结果;确认后需手动点击“推送到 Python 队列”才会创建并执行任务。
+
删除条件
+
+
+ + +
+
+ 暂无删除条件,保存后可在推送任务时多选携带。 +
+
    +
  • + + +
  • +
+
+

- 当前页面会按店铺串行推送任务,并在任务成功或失败后自动接续下一个任务。服务端临时重启时会自动重试查询状态,恢复后继续后续店铺。 + 当前页面会把匹配结果合并为一个巡店删除任务推送;多个店铺会在同一个任务和同一个结果文件中汇总。

@@ -192,19 +240,19 @@
  • - - {{ item.shopName || "-" }} + + {{ formatTaskGroupShopNames(item) }}
    任务 ID: {{ item.taskId ?? "-" }}
    -
    - 结果 ID: {{ item.resultId }} +
    + 结果 ID: {{ formatTaskGroupResultIds(item) }}
    -
    - 店铺 ID: {{ item.shopId }} +
    + 店铺 ID: {{ formatTaskGroupShopIds(item) }}
    平台: {{ item.platform }} @@ -213,10 +261,10 @@ 创建时间: {{ formatDateTime(item.createdAt) }}
    - 模板结构: {{ formatTemplateSummary(item) }} + 模板: {{ formatTemplateSummary(item) }}
    -
    - 错误: {{ item.error }} +
    + 错误: {{ formatTaskGroupErrors(item) }}
    @@ -243,19 +291,19 @@
    • - - {{ item.shopName || "-" }} + + {{ formatTaskGroupShopNames(item) }}
      任务 ID: {{ item.taskId ?? "-" }}
      -
      - 结果 ID: {{ item.resultId }} +
      + 结果 ID: {{ formatTaskGroupResultIds(item) }}
      -
      - 店铺 ID: {{ item.shopId }} +
      + 店铺 ID: {{ formatTaskGroupShopIds(item) }}
      平台: {{ item.platform }} @@ -267,10 +315,10 @@ 完成时间: {{ formatDateTime(item.finishedAt) }}
      - 模板结构: {{ formatTemplateSummary(item) }} + 模板: {{ formatTemplateSummary(item) }}
      -
      - 错误: {{ item.error }} +
      + 错误: {{ formatTaskGroupErrors(item) }}
      @@ -313,18 +361,22 @@ import { ElMessage } from "element-plus"; import BrandTopBar from "@/pages/brand/components/BrandTopBar.vue"; import { addPatrolDeleteCandidate, + addPatrolDeleteCondition, createPatrolDeleteTask, deletePatrolDeleteCandidate, + deletePatrolDeleteCondition, deletePatrolDeleteHistory, deletePatrolDeleteTask, getPatrolDeleteDashboard, getPatrolDeleteHistory, getPatrolDeleteTaskProgressBatch, getPatrolDeleteResultDownloadUrl, + listPatrolDeleteConditions, listPatrolDeleteCandidates, matchPatrolDeleteShops, submitPatrolDeleteTaskResult, type PatrolDeleteCandidateVo, + type PatrolDeleteConditionVo, type PatrolDeleteCartRatio, type PatrolDeleteCountrySection, type PatrolDeleteDashboardVo, @@ -340,8 +392,11 @@ const COUNTRY_TEMPLATE = ["德国", "英国", "法国", "意大利", "西班牙" const MAX_TRANSIENT_ERRORS = 30; const shopInput = ref(""); +const conditionInput = ref(""); const candidates = ref([]); const selectedCandidates = ref([]); +const deleteConditions = ref([]); +const selectedConditionIds = ref([]); const matchedItems = ref([]); const historyItems = ref([]); const dashboard = ref({ @@ -351,32 +406,30 @@ const dashboard = ref({ failedTaskCount: 0, }); const adding = ref(false); +const addingCondition = ref(false); const matching = ref(false); const pushing = ref(false); const queuePushResult = ref(""); const queuePayloadText = ref(""); -const pendingQueue = ref([]); const activeTaskId = ref(null); -const activeQueueItem = ref(null); const queueWorkerRunning = ref(false); -const autoQueueEnabled = ref(false); let historyPollTimer: number | null = null; const timers = createCategorizedTimers("patrol-delete"); const matchedRunnableItems = computed(() => matchedItems.value.filter((item) => item.matched), ); +const taskRecordItems = computed(() => groupHistoryItemsByTask(historyItems.value)); const currentSectionItems = computed(() => - historyItems.value.filter((item) => !isTaskTerminal(item.taskStatus)), + taskRecordItems.value.filter((item) => !isTaskTerminal(item.taskStatus)), ); const historySectionItems = computed(() => - historyItems.value.filter((item) => isTaskTerminal(item.taskStatus)), + taskRecordItems.value.filter((item) => isTaskTerminal(item.taskStatus)), ); const hasQueueWork = computed( () => queueWorkerRunning.value || - !!activeTaskId.value || - pendingQueue.value.length > 0, + !!activeTaskId.value, ); function uidForStorage() { @@ -401,6 +454,10 @@ function historyItemKey(item: PatrolDeleteHistoryItem) { return `${item.taskId ?? 0}:${item.resultId ?? 0}`; } +function taskGroupKey(item: PatrolDeleteHistoryItem) { + return `task:${item.taskId ?? item.resultId ?? 0}`; +} + let disposed = false; function sleep(ms: number) { @@ -546,8 +603,65 @@ function formatTemplateSummary( "shopName" | "countrySections" | "cartRatios" >, ) { - const rows = flattenTemplateRows(record); - return `${rows.length} 行状态数据,首行含 ${record.cartRatios?.length || 0} 个购物车比例列`; + const countries = new Set([ + ...(record.countrySections || []).map((item) => item.country).filter(Boolean), + ...(record.cartRatios || []).map((item) => item.country).filter(Boolean), + ]); + return `${countries.size || 0} 个国家,含状态数据和购物车比例`; +} + +function uniqueJoined(values: Array) { + return Array.from( + new Set( + values + .map((value) => (value == null ? "" : String(value).trim())) + .filter(Boolean), + ), + ).join("、"); +} + +function groupHistoryItemsByTask(items: PatrolDeleteHistoryItem[]) { + const groups = new Map(); + for (const item of items || []) { + const key = String(item.taskId ?? `result:${item.resultId ?? Math.random()}`); + const group = groups.get(key) || []; + group.push(item); + groups.set(key, group); + } + return Array.from(groups.values()).map((group) => { + const base = + group.find((item) => canDownload(item)) || + group.find((item) => item.taskStatus) || + group[0]; + const errors = uniqueJoined(group.map((item) => item.error)); + return { + ...base, + shopName: uniqueJoined(group.map((item) => item.shopName)) || base.shopName, + shopId: uniqueJoined(group.map((item) => item.shopId)) || base.shopId, + error: errors || base.error, + __groupItems: group, + } as PatrolDeleteHistoryItem & { __groupItems?: PatrolDeleteHistoryItem[] }; + }); +} + +function taskGroupItems(item: PatrolDeleteHistoryItem) { + return ((item as PatrolDeleteHistoryItem & { __groupItems?: PatrolDeleteHistoryItem[] }).__groupItems || [item]); +} + +function formatTaskGroupShopNames(item: PatrolDeleteHistoryItem) { + return uniqueJoined(taskGroupItems(item).map((row) => row.shopName)) || "-"; +} + +function formatTaskGroupResultIds(item: PatrolDeleteHistoryItem) { + return uniqueJoined(taskGroupItems(item).map((row) => row.resultId)); +} + +function formatTaskGroupShopIds(item: PatrolDeleteHistoryItem) { + return uniqueJoined(taskGroupItems(item).map((row) => row.shopId)); +} + +function formatTaskGroupErrors(item: PatrolDeleteHistoryItem) { + return uniqueJoined(taskGroupItems(item).map((row) => row.error)); } function isTaskTerminal(status?: string) { @@ -569,7 +683,9 @@ function statusClass(status?: string) { } function canDownload(item: PatrolDeleteHistoryItem) { - return Boolean(item.resultId && (item.fileReady || item.downloadUrl)); + return taskGroupItems(item).some((row) => + Boolean(row.resultId && (row.fileReady || row.downloadUrl)), + ); } function saveMatchedItems() { @@ -595,9 +711,7 @@ function loadMatchedItems() { function saveQueueState() { if (typeof window === "undefined") return; const payload = { - pendingQueue: pendingQueue.value, activeTaskId: activeTaskId.value, - activeQueueItem: activeQueueItem.value, }; window.localStorage.setItem(queueStateStorageKey(), JSON.stringify(payload)); } @@ -610,23 +724,16 @@ function loadQueueState() { : null; if (!raw) return; const parsed = JSON.parse(raw) as { - pendingQueue?: PatrolDeleteShopQueueItem[]; activeTaskId?: number | null; - activeQueueItem?: PatrolDeleteShopQueueItem | null; }; - pendingQueue.value = parsed.pendingQueue || []; activeTaskId.value = parsed.activeTaskId ?? null; - activeQueueItem.value = parsed.activeQueueItem ?? null; } catch { - pendingQueue.value = []; activeTaskId.value = null; - activeQueueItem.value = null; } } function clearActiveQueueTask() { activeTaskId.value = null; - activeQueueItem.value = null; saveQueueState(); } @@ -636,9 +743,8 @@ function isRecordMissingError(error: unknown) { } function removeMatchedRowLocally(row: PatrolDeleteShopQueueItem) { - matchedItems.value = matchedItems.value.filter( - (item) => rowKeyForMatch(item) !== rowKeyForMatch(row), - ); + const key = rowKeyForMatch(row); + matchedItems.value = matchedItems.value.filter((item) => rowKeyForMatch(item) !== key); saveMatchedItems(); } @@ -650,20 +756,18 @@ function removeHistoryItemLocally(item: PatrolDeleteHistoryItem) { }); } -function mergeQueueItems( - base: PatrolDeleteShopQueueItem[], - incoming: PatrolDeleteShopQueueItem[], -) { - const map = new Map(); - for (const item of base) map.set(rowKeyForMatch(item), item); - for (const item of incoming) map.set(rowKeyForMatch(item), item); - return Array.from(map.values()); -} - async function loadCandidates() { candidates.value = await listPatrolDeleteCandidates(); } +async function loadConditions() { + deleteConditions.value = await listPatrolDeleteConditions(); + const existingIds = new Set(deleteConditions.value.map((item) => item.id)); + selectedConditionIds.value = selectedConditionIds.value.filter((id) => + existingIds.has(id), + ); +} + async function loadDashboard() { dashboard.value = await getPatrolDeleteDashboard(); } @@ -705,6 +809,10 @@ function mergeHistoryProgressItems(incoming: PatrolDeleteHistoryItem[]) { historyItems.value = merged; } +function upsertHistoryItems(incoming: PatrolDeleteHistoryItem[]) { + mergeHistoryProgressItems(incoming); +} + async function refreshActiveTaskProgress(taskIds?: number[]) { const ids = Array.from( new Set( @@ -768,6 +876,41 @@ async function confirmAdd() { } } +async function confirmAddCondition() { + const text = conditionInput.value.trim(); + if (!text) { + ElMessage.warning("请输入删除条件"); + return; + } + addingCondition.value = true; + try { + const saved = await addPatrolDeleteCondition(text); + conditionInput.value = ""; + await loadConditions(); + if (saved?.id && !selectedConditionIds.value.includes(saved.id)) { + selectedConditionIds.value = [...selectedConditionIds.value, saved.id]; + } + ElMessage.success("删除条件已保存"); + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : "保存失败"); + } finally { + addingCondition.value = false; + } +} + +async function removeCondition(id: number) { + try { + await deletePatrolDeleteCondition(id); + selectedConditionIds.value = selectedConditionIds.value.filter( + (item) => item !== id, + ); + await loadConditions(); + ElMessage.success("删除条件已删除"); + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : "删除失败"); + } +} + async function removeCandidate(id: number) { try { await deletePatrolDeleteCandidate(id); @@ -804,11 +947,6 @@ async function runMatch() { const data = await matchPatrolDeleteShops(names); matchedItems.value = mergeMatchedItems(matchedItems.value, data.items || []); saveMatchedItems(); - if (autoQueueEnabled.value) { - pendingQueue.value = mergeQueueItems(pendingQueue.value, matchedRunnableItems.value); - saveQueueState(); - void processQueue(); - } ElMessage.success(`本次返回 ${data.items?.length || 0} 条匹配结果`); } catch (error) { ElMessage.error(error instanceof Error ? error.message : "匹配失败"); @@ -818,14 +956,8 @@ async function runMatch() { } function removeMatchedRow(row: PatrolDeleteShopQueueItem) { - if (hasQueueWork.value) { - ElMessage.warning("队列执行中,请等待当前串行任务结束后再调整"); - return; - } - matchedItems.value = matchedItems.value.filter( - (item) => rowKeyForMatch(item) !== rowKeyForMatch(row), - ); - saveMatchedItems(); + removeMatchedRowLocally(row); + ElMessage.success("已从匹配结果移除"); } function buildTaskItem(item: PatrolDeleteShopQueueItem): PatrolDeleteTaskItem { @@ -842,7 +974,19 @@ function buildTaskItem(item: PatrolDeleteShopQueueItem): PatrolDeleteTaskItem { }; } -function buildQueuePayload(taskId: number, item: PatrolDeleteHistoryItem) { +function selectedDeleteConditions() { + const selected = new Set(selectedConditionIds.value); + return deleteConditions.value + .filter((condition) => selected.has(condition.id)) + .map((condition) => ({ + id: condition.id, + conditionText: condition.conditionText, + })); +} + +function buildQueuePayload(taskId: number, items: PatrolDeleteHistoryItem[]) { + const firstItem = items[0]; + const deleteConditionsForTask = selectedDeleteConditions(); return { type: "patrol-delete-run", ts: Date.now(), @@ -850,8 +994,9 @@ function buildQueuePayload(taskId: number, item: PatrolDeleteHistoryItem) { taskId, user_id: Number(uidForStorage()) || 0, source: "frontend-vue-patrol-delete", - items: [ - { + delete_conditions: deleteConditionsForTask, + deleteConditions: deleteConditionsForTask, + items: items.map((item) => ({ shopName: item.shopName, shopId: item.shopId, platform: item.platform, @@ -859,11 +1004,17 @@ function buildQueuePayload(taskId: number, item: PatrolDeleteHistoryItem) { matched: item.matched, matchStatus: item.matchStatus, matchMessage: item.matchMessage, - }, - ], - template_rows: flattenTemplateRows(item), - country_sections: item.countrySections || [], - cart_ratios: item.cartRatios || [], + countrySections: item.countrySections || [], + cartRatios: item.cartRatios || [], + })), + template_rows: firstItem ? flattenTemplateRows(firstItem) : [], + shop_template_rows: items.map((item) => ({ + shopName: item.shopName, + shopId: item.shopId, + templateRows: flattenTemplateRows(item), + })), + country_sections: firstItem?.countrySections || [], + cart_ratios: firstItem?.cartRatios || [], }, }; } @@ -876,8 +1027,10 @@ async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; while (true) { if (disposed) return "STOPPED"; + if (activeTaskId.value !== taskId) return "DELETED"; try { await refreshActiveTaskProgress([taskId]); + if (activeTaskId.value !== taskId) return "DELETED"; if (transientErrorCount > 0) { queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`; } @@ -915,76 +1068,72 @@ async function processQueue() { throw new Error("当前客户端未提供 enqueue_json"); } - while (!disposed && (activeTaskId.value || pendingQueue.value.length)) { - if (activeTaskId.value) { - const finalStatus = await waitForTaskTerminal(activeTaskId.value); - queuePushResult.value = - pendingQueue.value.length > 0 - ? `任务 ${activeTaskId.value} ${finalStatus === "SUCCESS" ? "已完成" : "执行失败"},自动继续下一个(剩余 ${pendingQueue.value.length} 条)` - : `任务 ${activeTaskId.value} ${finalStatus === "SUCCESS" ? "已完成" : "执行失败"}`; - clearActiveQueueTask(); - continue; + if (activeTaskId.value) { + const finalStatus = await waitForTaskTerminal(activeTaskId.value); + queuePushResult.value = + finalStatus === "DELETED" + ? "任务已删除" + : `任务 ${activeTaskId.value} ${finalStatus === "SUCCESS" ? "已完成" : "执行失败"}`; + clearActiveQueueTask(); + return; + } + + const runnable = matchedRunnableItems.value; + if (!runnable.length) { + ElMessage.warning("请先匹配可用店铺"); + return; } - - const nextItem = pendingQueue.value.shift(); - saveQueueState(); - if (!nextItem) break; - const created = await withTransientRetry( - () => createPatrolDeleteTask([buildTaskItem(nextItem)]), + () => createPatrolDeleteTask(runnable.map(buildTaskItem)), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts})...`; }, ); - const createdItem = created.items?.[0]; - if (!createdItem?.taskId || !createdItem.resultId) { + const createdItems = created.items || []; + if (!created.taskId || !createdItems.length || createdItems.some((item) => !item.taskId || !item.resultId)) { throw new Error("后端未返回有效任务标识"); } activeTaskId.value = created.taskId; - activeQueueItem.value = nextItem; saveQueueState(); - await refreshTaskViews(); + upsertHistoryItems(createdItems); + void loadDashboard(); - const payload = buildQueuePayload(created.taskId, createdItem); + const payload = buildQueuePayload(created.taskId, createdItems); queuePayloadText.value = JSON.stringify(payload, null, 2); const pushResult = await api.enqueue_json(payload); if (!pushResult?.success) { await submitPatrolDeleteTaskResult(created.taskId, { - shops: [ - { - shopName: createdItem.shopName || nextItem.shopName || "", + shops: createdItems.map((createdItem) => ({ + shopName: createdItem.shopName || "", error: pushResult?.error || `任务 ${created.taskId} 推送失败`, countrySections: createdItem.countrySections || [], cartRatios: createdItem.cartRatios || [], shopDone: true, submissionId: createSubmissionId( created.taskId, - createdItem.shopName || nextItem.shopName, + createdItem.shopName, ), chunkIndex: 1, chunkTotal: 1, - }, - ], + })), }); - removeMatchedRowLocally(nextItem); + for (const item of runnable) removeMatchedRowLocally(item); await refreshTaskViews(); - queuePushResult.value = `任务 ${created.taskId} 推送失败,已自动继续下一个任务`; + queuePushResult.value = `任务 ${created.taskId} 推送失败`; clearActiveQueueTask(); - continue; + return; } - removeMatchedRowLocally(nextItem); - queuePushResult.value = - pendingQueue.value.length > 0 - ? `任务 ${created.taskId} 已入队,等待完成后自动继续下一个(剩余 ${pendingQueue.value.length} 条)` - : `任务 ${created.taskId} 已入队,等待执行完成`; - } + for (const item of runnable) removeMatchedRowLocally(item); + queuePushResult.value = `任务 ${created.taskId} 已入队,共 ${createdItems.length} 个店铺,等待执行完成`; - queuePushResult.value = "串行队列已执行完成"; + const finalStatus = await waitForTaskTerminal(created.taskId); + queuePushResult.value = `任务 ${created.taskId} ${finalStatus === "SUCCESS" ? "已完成" : finalStatus === "DELETED" ? "已删除" : "执行失败"}`; + clearActiveQueueTask(); await refreshTaskViews(); - ElMessage.success("巡店删除队列已按顺序执行完成"); + ElMessage.success("巡店删除任务已完成"); } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : "队列执行失败"; @@ -1000,27 +1149,28 @@ async function processQueue() { } async function pushToPythonQueue() { - autoQueueEnabled.value = true; const runnable = matchedRunnableItems.value; if (!runnable.length) { ElMessage.warning("请先匹配可用店铺"); return; } - pendingQueue.value = mergeQueueItems(pendingQueue.value, runnable); - saveQueueState(); - queuePushResult.value = `已加入 ${runnable.length} 条店铺,开始串行执行`; + queuePushResult.value = `开始创建任务,共 ${runnable.length} 个店铺`; await processQueue(); } async function downloadResult(item: PatrolDeleteHistoryItem) { - if (!item.resultId) return; + const downloadable = taskGroupItems(item).find((row) => + Boolean(row.resultId && (row.fileReady || row.downloadUrl)), + ); + if (!downloadable?.resultId) return; const api = getPywebviewApi(); if (!api?.save_file_from_url_new) { ElMessage.error("当前客户端未提供下载能力"); return; } - const url = getPatrolDeleteResultDownloadUrl(item.resultId); - const filename = item.outputFilename || `${item.shopName || "result"}.xlsx`; + const url = getPatrolDeleteResultDownloadUrl(downloadable.resultId); + const filename = + downloadable.outputFilename || `${item.shopName || "patrol-delete-result"}.xlsx`; const result = await api.save_file_from_url_new(url, filename); if (result.success) { ElMessage.success(`已保存: ${result.path || filename}`); @@ -1041,7 +1191,8 @@ async function deleteTaskRecord(item: PatrolDeleteHistoryItem) { } else { throw new Error("缺少可删除的任务标识"); } - await Promise.all([loadCandidates(), refreshTaskViews()]); + removeHistoryItemLocally(item); + await Promise.allSettled([loadCandidates(), refreshTaskViews()]); ElMessage.success("已删除"); } catch (error) { if (isRecordMissingError(error)) { @@ -1060,14 +1211,13 @@ async function deleteTaskRecord(item: PatrolDeleteHistoryItem) { onMounted(async () => { loadMatchedItems(); loadQueueState(); - await Promise.all([loadCandidates(), loadDashboard(), loadHistory()]); + await Promise.all([loadCandidates(), loadConditions(), loadDashboard(), loadHistory()]); - if (activeTaskId.value || pendingQueue.value.length) { - autoQueueEnabled.value = true; + if (activeTaskId.value) { queuePushResult.value = activeTaskId.value != null - ? `检测到未完成队列,继续等待任务 ${activeTaskId.value} 完成并自动接续后续店铺` - : `检测到未完成队列,继续执行剩余 ${pendingQueue.value.length} 条店铺`; + ? `检测到未完成任务,继续等待任务 ${activeTaskId.value} 完成` + : ""; void processQueue(); } }); @@ -1096,6 +1246,16 @@ onUnmounted(() => { .empty-candidates { color: #666; font-size: 13px; padding: 16px; border: 1px dashed #333; border-radius: 8px; margin-bottom: 16px; } .candidate-table-scroll { max-height: 320px; overflow: auto; margin-bottom: 18px; border-radius: 8px; border: 1px solid #2a2a2a; } .candidate-table { --el-table-bg-color: #252525; --el-table-tr-bg-color: #252525; --el-table-header-bg-color: #2a2a2a; --el-table-text-color: #ccc; --el-table-border-color: #333; } +.condition-panel { margin-bottom: 18px; border: 1px solid #2a2a2a; border-radius: 8px; background: #222; padding: 12px; } +.condition-input-row { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; } +.condition-input-row :deep(.el-input) { flex: 1; } +.empty-conditions { color: #666; font-size: 12px; padding: 10px 4px; } +.condition-list { list-style: none; margin: 0; padding: 0; max-height: 150px; overflow: auto; display: flex; flex-direction: column; gap: 8px; } +.condition-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 6px; border-bottom: 1px solid #303030; } +.condition-item:last-child { border-bottom: none; } +.condition-check { display: flex; align-items: center; gap: 8px; min-width: 0; color: #cfd6df; font-size: 12px; cursor: pointer; } +.condition-check input { width: 16px; height: 16px; margin: 0; flex: 0 0 auto; } +.condition-check span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .link-danger { background: none; border: none; color: #e74c3c; cursor: pointer; font-size: 12px; padding: 0; } .link-danger:hover { text-decoration: underline; } .run-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; } diff --git a/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue b/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue index d3bd023..a5571a5 100644 --- a/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandProductRiskTab.vue @@ -73,8 +73,7 @@ {{ pushing ? '推送中…' : '推送到 Python 队列' }}
      -

      与删除 ASIN 品牌相同:先创建任务,再按店铺逐条入队(每条 data.items - 仅含一个店铺)。队列串行执行;Python +

      在搜索结果中禁止显示、详情页面已删除会把匹配店铺合并为一个任务入队,避免同时打开多个紫鸟窗口;其它筛选仍按店铺逐条入队。Python 每处理完一店可 POST /api/product-risk-resolve/tasks/{taskId}/result(可多次,每次可只含已完成的店铺);全部完成后任务结束。前端轮询任务状态。

      @@ -1033,6 +1032,53 @@ function nextMatchedQueueItem() { return matchedItems.value.find((item) => item.matched) } +function shouldBatchProductRiskQueue() { + return productRiskListingFilter.value === 'SearchSuppressed' || productRiskListingFilter.value === 'DetailPageRemoved' +} + +async function processMatchedBatchQueue(toPush: ProductRiskShopQueueItem[]) { + const api = getPywebviewApi() + if (!api?.enqueue_json) { + ElMessage.error('当前环境未启用 pywebview enqueue_json') + return + } + const created = await createProductRiskTaskWithRetry(toPush) + const taskId = created.taskId + taskSnapshots.value = { + ...taskSnapshots.value, + [taskId]: { + task: { id: taskId, status: 'RUNNING' }, + items: created.items, + }, + } + saveTaskSnapshotsToStorage() + addPollingTask(taskId) + ensurePolling(true) + const payload = { + type: 'product-risk-resolve-run', + ts: Date.now(), + data: { + taskId, + items: toPush, + country_codes: [...orderedCountryCodes.value], + risk_listing_filter: productRiskListingFilter.value, + }, + } + queuePayloadText.value = JSON.stringify(payload, null, 2) + const pushResult = await api.enqueue_json(payload) + if (!pushResult?.success) { + removePollingTask(taskId) + throw new Error('任务 ' + taskId + ' 推送失败:' + (pushResult?.error || '未知错误')) + } + removeMatchedRowsLocally(toPush) + queuePushResult.value = '任务 ' + taskId + ' 已入队,共 ' + toPush.length + ' 个店铺,等待执行完成...' + const finalStatus = await waitForTaskTerminal(taskId) + queuePushResult.value = '任务 ' + taskId + (finalStatus === 'SUCCESS' ? ' 已完成' : ' 执行失败') + if (finalStatus !== 'SUCCESS') { + throw new Error(queuePushResult.value) + } +} + async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) { return @@ -1057,6 +1103,14 @@ async function processMatchedQueue() { let successCount = 0 let failedCount = 0 try { + if (shouldBatchProductRiskQueue()) { + await processMatchedBatchQueue(toPush) + ElMessage.success('店铺推送已完成:成功 ' + toPush.length + ' 条,失败 0 条') + await loadHistory() + ensurePolling(true) + return + } + let index = 0 while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem() diff --git a/frontend-vue/src/shared/api/java-modules.ts b/frontend-vue/src/shared/api/java-modules.ts index f1ed496..5860d66 100644 --- a/frontend-vue/src/shared/api/java-modules.ts +++ b/frontend-vue/src/shared/api/java-modules.ts @@ -1008,6 +1008,12 @@ export type PatrolDeleteCandidateVo = ProductRiskCandidateVo; export type PatrolDeleteDashboardVo = ProductRiskDashboardVo; export type PatrolDeleteShopQueueItem = ProductRiskShopQueueItem; +export interface PatrolDeleteConditionVo { + id: number; + conditionText: string; + createdAt?: string; +} + export interface PatrolDeleteCountryMetricRow { status: string; quantity: string; @@ -1110,6 +1116,40 @@ export function deletePatrolDeleteCandidate(id: number) { ); } +export function listPatrolDeleteConditions() { + return unwrapJavaResponse( + get>( + `${JAVA_API_PREFIX}/patrol-delete/conditions`, + { + params: { user_id: getCurrentUserId() }, + }, + ), + ); +} + +export function addPatrolDeleteCondition(conditionText: string) { + return unwrapJavaResponse( + post< + JavaApiResponse, + { user_id: number; condition_text: string } + >(`${JAVA_API_PREFIX}/patrol-delete/conditions`, { + user_id: getCurrentUserId(), + condition_text: conditionText, + }), + ); +} + +export function deletePatrolDeleteCondition(id: number) { + return unwrapJavaResponse( + del>( + `${JAVA_API_PREFIX}/patrol-delete/conditions/${id}`, + { + params: { user_id: getCurrentUserId() }, + }, + ), + ); +} + export function matchPatrolDeleteShops(shopNames: string[]) { return unwrapJavaResponse( post< @@ -1451,6 +1491,10 @@ export interface AppearancePatentHistoryItem { fileStatus?: string; fileError?: string; fileReady?: boolean; + fileProgressPercent?: number; + fileProgressCurrent?: number; + fileProgressTotal?: number; + fileProgressMessage?: string; taskStatus?: string; success?: boolean; error?: string;