完善多店铺开启多窗口、外观进度条等

This commit is contained in:
super
2026-05-01 21:09:54 +08:00
parent 70658041a5
commit aece8123c3
25 changed files with 945 additions and 183 deletions

View File

@@ -14,5 +14,6 @@ client_name=ShuFuAI
# java_api_base=http://47.111.163.154:18080 # java_api_base=http://47.111.163.154:18080
java_api_base=http://127.0.0.1:18080 java_api_base=http://127.0.0.1:18080
# java_api_base=http://8.136.19.173:18080 # java_api_base=http://8.136.19.173:18080
# java_api_base=http://121.196.149.225:18080

View File

@@ -1,7 +1,8 @@
import time import time
import traceback import traceback
import requests import requests
from datetime import datetime import threading
from datetime import datetime
from typing import Dict, Any, List from typing import Dict, Any, List
from concurrent.futures import ThreadPoolExecutor, as_completed 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 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.chunk_index = 1 # 当前处理的分块索引
self.max_workers = 5 # 线程池最大线程数 self.max_workers = 5 # 线程池最大线程数
self.executor = None # 线程池执行器 self.executor = None # 线程池执行器
self.serial_task_locks = {
"product-risk-resolve-run": threading.Lock(),
}
# 在提交新任务前杀掉旧进程(确保环境干净) # 在提交新任务前杀掉旧进程(确保环境干净)
kill_process("v6") kill_process("v6")
@@ -118,7 +122,17 @@ class TaskMonitor:
except Exception as e: except Exception as e:
self.log(f"线程 {id(task_data)} 删除品牌任务处理异常: {traceback.format_exc()}", "ERROR") 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: Args:

View File

@@ -234,12 +234,14 @@ class InventoryManage(AmamzonBase):
country: str, country: str,
max_delete_operations: Optional[int] = None, max_delete_operations: Optional[int] = None,
trace_id: str = "", trace_id: str = "",
delete_conditions: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""循环删除非白名单分类商品,直到当前国家没有可删项。""" """循环删除非白名单分类商品,直到当前国家没有可删项。"""
trace_id = trace_id or _new_trace_id("del") trace_id = trace_id or _new_trace_id("del")
logger.info( logger.info(
f"trace_id={trace_id} 开始批量删除非白名单商品: " 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]] = [] all_results: List[Dict[str, Any]] = []
delete_counts: Dict[str, int] = {} delete_counts: Dict[str, int] = {}
@@ -249,7 +251,10 @@ class InventoryManage(AmamzonBase):
stop_reason = "no-candidates" stop_reason = "no-candidates"
latest_status_rows = self._read_listing_status_rows_for_delete(shop_name, country, trace_id=trace_id) 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: if not latest_delete_candidates:
logger.info(f"trace_id={trace_id} 未找到可删除商品分类: shop={shop_name}, country={country}") logger.info(f"trace_id={trace_id} 未找到可删除商品分类: shop={shop_name}, country={country}")
final_status_rows = latest_status_rows final_status_rows = latest_status_rows
@@ -783,7 +788,12 @@ class InventoryManage(AmamzonBase):
] ]
@classmethod @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 = [] candidates = []
for row in rows: for row in rows:
status = cls._normalize_option_text(row.get("status") or "") status = cls._normalize_option_text(row.get("status") or "")
@@ -795,6 +805,8 @@ class InventoryManage(AmamzonBase):
canonical_status = cls._canonical_listing_status(status) canonical_status = cls._canonical_listing_status(status)
if not quantity_text or quantity <= 0 or canonical_status in LISTING_STATUS_DELETE_WHITELIST: if not quantity_text or quantity <= 0 or canonical_status in LISTING_STATUS_DELETE_WHITELIST:
continue continue
if condition_texts and not cls._matches_delete_conditions(status, condition_texts):
continue
candidates.append( candidates.append(
{ {
@@ -805,6 +817,39 @@ class InventoryManage(AmamzonBase):
) )
return candidates 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 @staticmethod
def _format_deletable_candidates(candidates: Iterable[Dict[str, Any]]) -> str: def _format_deletable_candidates(candidates: Iterable[Dict[str, Any]]) -> str:
summary = [] summary = []
@@ -1299,6 +1344,7 @@ class PatrolDeleteTask:
items = data.get("items", []) items = data.get("items", [])
country_sections = data.get("country_sections", []) country_sections = data.get("country_sections", [])
cart_ratios = data.get("cart_ratios", []) cart_ratios = data.get("cart_ratios", [])
delete_conditions = data.get("delete_conditions") or data.get("deleteConditions") or []
if not task_id: if not task_id:
self.log("任务ID为空跳过", "WARNING") self.log("任务ID为空跳过", "WARNING")
@@ -1321,10 +1367,14 @@ class PatrolDeleteTask:
"failed_count": 0, "failed_count": 0,
"failed_countries": 0, "failed_countries": 0,
"deleted_listings_count": 0, "deleted_listings_count": 0,
"delete_conditions": delete_conditions,
"stop_requested": False, "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): for index, shop_item in enumerate(items, 1):
if runing_task[task_id].get("stop_requested", False): if runing_task[task_id].get("stop_requested", False):
self.log(f"检测到任务 {task_id} 的暂停请求,停止处理", "WARNING") self.log(f"检测到任务 {task_id} 的暂停请求,停止处理", "WARNING")
@@ -1333,7 +1383,7 @@ class PatrolDeleteTask:
shop_name = shop_item.get("shopName", "未知店铺") shop_name = shop_item.get("shopName", "未知店铺")
self.log(f"[{index}/{len(items)}] 开始处理店铺: {shop_name}") 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 runing_task[task_id]["processed_shops"] += 1
if runing_task[task_id].get("stop_requested", False): if runing_task[task_id].get("stop_requested", False):
@@ -1359,6 +1409,7 @@ class PatrolDeleteTask:
shop_item: Dict[str, Any], shop_item: Dict[str, Any],
country_sections: List[Dict[str, Any]], country_sections: List[Dict[str, Any]],
cart_ratios: 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 from config import runing_shop, runing_task
@@ -1412,6 +1463,7 @@ class PatrolDeleteTask:
task_id=task_id, task_id=task_id,
shop_name=shop_name, shop_name=shop_name,
country_name=country_name, country_name=country_name,
delete_conditions=delete_conditions,
) )
country_results.append(country_result) country_results.append(country_result)
aggregated_sections[country_name] = country_result["countrySection"] aggregated_sections[country_name] = country_result["countrySection"]
@@ -1522,6 +1574,7 @@ class PatrolDeleteTask:
task_id: int, task_id: int,
shop_name: str, shop_name: str,
country_name: str, country_name: str,
delete_conditions: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
from config import runing_task from config import runing_task
@@ -1543,6 +1596,7 @@ class PatrolDeleteTask:
shop_name, shop_name,
country_name, country_name,
trace_id=trace_id, trace_id=trace_id,
delete_conditions=delete_conditions,
) )
latest_status_rows = delete_result.get("statusRows") or [] latest_status_rows = delete_result.get("statusRows") or []
self.log( self.log(

View File

@@ -168,6 +168,7 @@ def serve_assets(filename):
@main_bp.route('/new_web_source/<path:filename>') @main_bp.route('/new_web_source/<path:filename>')
@login_required
def serve_new_web_source(filename): def serve_new_web_source(filename):
"""提供 static 目录及子目录下的静态文件访问。""" """提供 static 目录及子目录下的静态文件访问。"""
filepath = os.path.normpath(os.path.join("new_web_source", filename)) 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) file_abs = os.path.abspath(filepath)
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs): if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
return '', 404 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<script>"
f"window.localStorage.setItem('uid', {uid_value});"
"</script>\n"
)
if '</head>' in content:
content = content.replace('</head>', uid_script + '</head>', 1)
else:
content = uid_script + content
return render_template_string(content)
return send_file(file_abs, as_attachment=False) return send_file(file_abs, as_attachment=False)
@main_bp.route('/logo.jpg', methods=['GET']) @main_bp.route('/logo.jpg', methods=['GET'])

View File

@@ -20,6 +20,10 @@ public class AppearancePatentHistoryItemVo {
private String fileStatus; private String fileStatus;
private String fileError; private String fileError;
private Boolean fileReady; private Boolean fileReady;
private Integer fileProgressPercent;
private Integer fileProgressCurrent;
private Integer fileProgressTotal;
private String fileProgressMessage;
@Schema(description = "任务状态PENDING=已解析待推送RUNNING=执行中SUCCESS=成功FAILED=失败。", example = "SUCCESS") @Schema(description = "任务状态PENDING=已解析待推送RUNNING=执行中SUCCESS=成功FAILED=失败。", example = "SUCCESS")
private String taskStatus; private String taskStatus;
@Schema(description = "结果是否成功。true 表示任务完成并生成结果文件false 表示失败或未完成。", example = "true") @Schema(description = "结果是否成功。true 表示任务完成并生成结果文件false 表示失败或未完成。", example = "true")

View File

@@ -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.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; 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.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.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskFileJobService; 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 com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -59,6 +61,7 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
@@ -106,6 +109,7 @@ public class AppearancePatentTaskService {
private final AppearancePatentTaskCacheService taskCacheService; private final AppearancePatentTaskCacheService taskCacheService;
private final AppearancePatentProperties properties; private final AppearancePatentProperties properties;
private final TaskFileJobService taskFileJobService; private final TaskFileJobService taskFileJobService;
private final TaskProgressSnapshotService taskProgressSnapshotService;
private final TransientPayloadStorageService transientPayloadStorageService; private final TransientPayloadStorageService transientPayloadStorageService;
private final PlatformTransactionManager transactionManager; private final PlatformTransactionManager transactionManager;
@@ -678,10 +682,10 @@ public class AppearancePatentTaskService {
int batchSize = Math.max(1, properties.getCozeBatchSize()); int batchSize = Math.max(1, properties.getCozeBatchSize());
List<AppearancePatentResultRowDto> result = new ArrayList<>(); List<AppearancePatentResultRowDto> result = new ArrayList<>();
for (int i = 0; i < items.size(); i += batchSize) { 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) { if (progressHook != null) {
progressHook.run(); progressHook.run();
} }
result.addAll(cozeClient.inspect(items.subList(i, Math.min(i + batchSize, items.size())), prompt));
} }
return result; return result;
} }
@@ -697,9 +701,6 @@ public class AppearancePatentTaskService {
.orderByAsc(TaskChunkEntity::getChunkIndex)); .orderByAsc(TaskChunkEntity::getChunkIndex));
log.info("[appearance-patent] async coze start taskId={} chunks={}", task.getId(), chunks.size()); log.info("[appearance-patent] async coze start taskId={} chunks={}", task.getId(), chunks.size());
for (TaskChunkEntity chunk : chunks) { for (TaskChunkEntity chunk : chunks) {
if (progressHook != null) {
progressHook.run();
}
Map<String, AppearancePatentResultRowDto> persistedRows = readChunkRows(chunk); Map<String, AppearancePatentResultRowDto> persistedRows = readChunkRows(chunk);
if (persistedRows.isEmpty()) { if (persistedRows.isEmpty()) {
continue; continue;
@@ -1021,12 +1022,68 @@ public class AppearancePatentTaskService {
if (result == null || !MODULE_TYPE.equals(result.getModuleType())) { if (result == null || !MODULE_TYPE.equals(result.getModuleType())) {
throw new BusinessException("结果记录不存在"); throw new BusinessException("结果记录不存在");
} }
Runnable progressHook = () -> taskFileJobService.touchRunning(job.getId()); List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
.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); applyCozeToPersistedChunks(task, progressHook);
progressHook.run(); completedProgressUnits[0] = Math.max(completedProgressUnits[0], totalProgressUnits - 2);
saveFileBuildProgress(task, job, totalProgressUnits, completedProgressUnits[0], "正在组装 xlsx");
assembleResultWorkbook(task, result); assembleResultWorkbook(task, result);
progressHook.run(); completedProgressUnits[0] = totalProgressUnits - 1;
saveFileBuildProgress(task, job, totalProgressUnits, completedProgressUnits[0], "正在上传结果文件");
fileResultMapper.updateById(result); fileResultMapper.updateById(result);
saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits, "结果文件已生成");
}
private int countCozeWorkUnits(List<TaskChunkEntity> chunks, int batchSize) {
if (chunks == null || chunks.isEmpty()) {
return 0;
}
int total = 0;
for (TaskChunkEntity chunk : chunks) {
Map<String, AppearancePatentResultRowDto> 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) { public void cleanupResultFileJob(TaskFileJobEntity job) {
@@ -1045,6 +1102,14 @@ public class AppearancePatentTaskService {
private void assembleResultWorkbook(FileTaskEntity task, FileResultEntity result) { private void assembleResultWorkbook(FileTaskEntity task, FileResultEntity result) {
AppearancePatentParsedPayloadDto parsed = readParsedPayload(task); AppearancePatentParsedPayloadDto parsed = readParsedPayload(task);
Map<String, AppearancePatentResultRowDto> resultMap = loadPersistedResultRows(task.getId()); Map<String, AppearancePatentResultRowDto> 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"); File outputDir = new File(storageProperties.getLocalTempDir(), "appearance-patent-result");
if (!outputDir.exists() && !outputDir.mkdirs()) { if (!outputDir.exists() && !outputDir.mkdirs()) {
throw new BusinessException("创建结果目录失败"); throw new BusinessException("创建结果目录失败");
@@ -1087,8 +1152,8 @@ public class AppearancePatentTaskService {
headerStyle.setFont(font); headerStyle.setFont(font);
List<String> resultHeaders = new ArrayList<>(RESULT_HEADERS); List<String> resultHeaders = new ArrayList<>(RESULT_HEADERS);
resultHeaders.add(4, "title"); resultHeaders.add(4, "标题");
resultHeaders.add(5, "url"); resultHeaders.add(5, "图片链接");
Row header = sheet.createRow(0); Row header = sheet.createRow(0);
for (int i = 0; i < resultHeaders.size(); i++) { for (int i = 0; i < resultHeaders.size(); i++) {
Cell cell = header.createCell(i); Cell cell = header.createCell(i);
@@ -1099,6 +1164,9 @@ public class AppearancePatentTaskService {
int rowIndex = 1; int rowIndex = 1;
for (AppearancePatentParsedRowVo parsedRow : parsed.getAllItems()) { for (AppearancePatentParsedRowVo parsedRow : parsed.getAllItems()) {
AppearancePatentResultRowDto resultRow = findResultRow(parsedRow, resultMap); AppearancePatentResultRowDto resultRow = findResultRow(parsedRow, resultMap);
if (resultRow == null) {
resultRow = findResultRowByAsin(parsedRow.getAsin(), resultMap);
}
String missingReason = ""; String missingReason = "";
if (resultRow == null) { if (resultRow == null) {
missingReason = hasPromptFields(parsedRow) ? "未匹配到检测结果" : "未送检:缺少标题或图片"; missingReason = hasPromptFields(parsedRow) ? "未匹配到检测结果" : "未送检:缺少标题或图片";
@@ -1162,12 +1230,28 @@ public class AppearancePatentTaskService {
if (normalizedAsin.isBlank() || resultMap == null || resultMap.isEmpty()) { if (normalizedAsin.isBlank() || resultMap == null || resultMap.isEmpty()) {
return null; return null;
} }
AppearancePatentResultRowDto fallback = null;
for (AppearancePatentResultRowDto row : resultMap.values()) { 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; 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) { private ParsedWorkbook parseWorkbook(File input, AppearancePatentSourceFileDto source) {
@@ -1468,11 +1552,50 @@ public class AppearancePatentTaskService {
vo.setFileReady(row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank()); vo.setFileReady(row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank());
if (job == null) { if (job == null) {
vo.setFileStatus(Boolean.TRUE.equals(vo.getFileReady()) ? "SUCCESS" : null); vo.setFileStatus(Boolean.TRUE.equals(vo.getFileReady()) ? "SUCCESS" : null);
attachFileProgress(vo, row, job);
return; return;
} }
vo.setFileJobId(job.getId()); vo.setFileJobId(job.getId());
vo.setFileStatus(job.getStatus()); vo.setFileStatus(job.getStatus());
vo.setFileError(job.getErrorMessage()); 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) { private String fmt(LocalDateTime t) {

View File

@@ -279,16 +279,21 @@ public class BrandTaskService {
throw new BusinessException("totalLines 超过原始文件行数: " + fileUrl); throw new BusinessException("totalLines 超过原始文件行数: " + fileUrl);
} }
BrandTaskStorageService.ChunkStoreResult storeResult = brandTaskStorageService.storeChunk(taskId, resultFile); 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()); 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, taskId,
fileUrl, fileUrl,
resultFile.getChunkIndex(), resultFile.getChunkIndex(),
resultFile.getChunkTotal(), resultFile.getChunkTotal(),
storeResult.stored(), storeResult.stored(),
storeResult.newlyCompletedFile(), storeResult.newlyCompletedFile() || refreshResult.newlyCompletedFile(),
finishedCount); finishedCount,
BrandFileAggregateCacheDto aggregate = storeResult.aggregate(); 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()); String fileName = blankToDefault(sourceFile.getOriginalFilename(), sourceFile.getFileUrl());
int fileIndex = sourceIndexByUrl.getOrDefault(fileUrl, 0); int fileIndex = sourceIndexByUrl.getOrDefault(fileUrl, 0);
int currentLine = aggregate == null ? 0 : defaultInteger(aggregate.getProcessedLineCount()); int currentLine = aggregate == null ? 0 : defaultInteger(aggregate.getProcessedLineCount());

View File

@@ -126,6 +126,7 @@ public class BrandTaskStorageService {
if (existingChunk != null) { if (existingChunk != null) {
if (payloadHash.equals(existingChunk.getPayloadHash())) { if (payloadHash.equals(existingChunk.getPayloadHash())) {
BrandFileAggregateCacheDto aggregate = rebuildAggregate(taskId, scopeKey, scopeHash); BrandFileAggregateCacheDto aggregate = rebuildAggregate(taskId, scopeKey, scopeHash);
saveAggregate(taskId, scopeKey, scopeHash, aggregate, now);
return new ChunkStoreResult(false, false, countCompletedFiles(taskId), aggregate); return new ChunkStoreResult(false, false, countCompletedFiles(taskId), aggregate);
} }
throw new BusinessException("Brand task chunk conflict: " + scopeKey + "#" + file.getChunkIndex()); throw new BusinessException("Brand task chunk conflict: " + scopeKey + "#" + file.getChunkIndex());
@@ -155,6 +156,7 @@ public class BrandTaskStorageService {
if (concurrentChunk != null && payloadHash.equals(concurrentChunk.getPayloadHash())) { if (concurrentChunk != null && payloadHash.equals(concurrentChunk.getPayloadHash())) {
transientPayloadStorageService.deletePayloadIfPresent(storedPayload); transientPayloadStorageService.deletePayloadIfPresent(storedPayload);
BrandFileAggregateCacheDto aggregate = rebuildAggregate(taskId, scopeKey, scopeHash); BrandFileAggregateCacheDto aggregate = rebuildAggregate(taskId, scopeKey, scopeHash);
saveAggregate(taskId, scopeKey, scopeHash, aggregate, now);
return new ChunkStoreResult(false, false, countCompletedFiles(taskId), aggregate); return new ChunkStoreResult(false, false, countCompletedFiles(taskId), aggregate);
} }
transientPayloadStorageService.deletePayloadIfPresent(storedPayload); transientPayloadStorageService.deletePayloadIfPresent(storedPayload);
@@ -218,6 +220,22 @@ public class BrandTaskStorageService {
return count == null ? 0 : count.intValue(); 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 @Transactional
public void deleteTaskData(Long taskId) { public void deleteTaskData(Long taskId) {
if (taskId == null || taskId <= 0) { if (taskId == null || taskId <= 0) {

View File

@@ -14,9 +14,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; 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.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.net.URI;
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequestMapping("/api/dedupe") @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 = "200", description = "返回结果文件流"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在")
}) })
public ResponseEntity<Resource> download( public ResponseEntity<Void> download(
@Parameter(description = "去重结果记录 ID", required = true) @PathVariable Long resultId, @Parameter(description = "去重结果记录 ID", required = true) @PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY) @Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
@RequestParam("user_id") Long userId) { @RequestParam("user_id") Long userId) {
Resource resource = dedupeRunService.getResultFile(resultId, userId); String downloadUrl = dedupeRunService.getResultDownloadUrl(resultId, userId);
return ResponseEntity.ok() return ResponseEntity.status(302)
.contentType(MediaType.APPLICATION_OCTET_STREAM) .location(URI.create(downloadUrl))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment") .header(HttpHeaders.CACHE_CONTROL, "no-store")
.body(resource); .build();
} }
} }

View File

@@ -24,8 +24,6 @@ import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.util.WorkbookUtil; import org.apache.poi.ss.util.WorkbookUtil;
import org.apache.poi.xssf.streaming.SXSSFWorkbook; 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 org.springframework.stereotype.Service;
import java.io.File; import java.io.File;
@@ -229,7 +227,7 @@ public class DedupeRunService {
vo.setResultId(entity.getId()); vo.setResultId(entity.getId());
vo.setSourceFilename(entity.getSourceFilename()); vo.setSourceFilename(entity.getSourceFilename());
vo.setOutputFilename(entity.getResultFilename()); 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.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1);
vo.setError(entity.getErrorMessage()); vo.setError(entity.getErrorMessage());
return vo; return vo;
@@ -245,16 +243,12 @@ public class DedupeRunService {
fileResultMapper.deleteById(resultId); fileResultMapper.deleteById(resultId);
} }
public Resource getResultFile(Long resultId, Long userId) { public String getResultDownloadUrl(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId); FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || entity.getResultFileUrl() == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { if (entity == null || entity.getResultFileUrl() == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("结果文件不存在"); throw new BusinessException("结果文件不存在");
} }
File file = new File(entity.getResultFileUrl()); return ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl());
if (!file.exists()) {
throw new BusinessException("结果文件不存在");
}
return new FileSystemResource(file);
} }
private void cleanExcelByLegacyRules(File inputFile, File outputFile, List<String> selectedColumns, private void cleanExcelByLegacyRules(File inputFile, File outputFile, List<String> selectedColumns,

View File

@@ -1,10 +1,12 @@
package com.nanri.aiimage.modules.patroldelete.controller; package com.nanri.aiimage.modules.patroldelete.controller;
import com.nanri.aiimage.common.api.ApiResponse; 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.PatrolDeleteCreateTaskRequest;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteSubmitResultRequest; 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.dto.PatrolDeleteTaskBatchRequest;
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteCreateTaskVo; 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.PatrolDeleteHistoryVo;
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteTaskBatchVo; import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteTaskBatchVo;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteResolveService; import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteResolveService;
@@ -92,6 +94,32 @@ public class PatrolDeleteController {
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@GetMapping("/conditions")
@Operation(summary = "查询删除条件", description = "返回当前用户保存的巡店删除条件。")
public ApiResponse<List<PatrolDeleteConditionVo>> 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<PatrolDeleteConditionVo> addCondition(
@Valid @RequestBody PatrolDeleteConditionAddRequest request) {
return ApiResponse.success(patrolDeleteResolveService.addCondition(request));
}
@DeleteMapping("/conditions/{id}")
@Operation(summary = "删除删除条件", description = "删除当前用户名下的一条巡店删除条件。")
public ApiResponse<Void> 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") @PostMapping("/match-shops")
@Operation(summary = "批量匹配店铺", description = "根据店铺名批量匹配紫鸟店铺索引,返回是否命中、店铺 ID、平台、公司和匹配状态。") @Operation(summary = "批量匹配店铺", description = "根据店铺名批量匹配紫鸟店铺索引,返回是否命中、店铺 ID、平台、公司和匹配状态。")
public ApiResponse<ProductRiskMatchShopsVo> matchShops( public ApiResponse<ProductRiskMatchShopsVo> matchShops(

View File

@@ -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<PatrolDeleteConditionEntity> {
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -2,8 +2,12 @@ package com.nanri.aiimage.modules.patroldelete.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException; 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.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.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.ProductRiskCandidateAddRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo; import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
@@ -27,6 +31,7 @@ import java.util.Objects;
public class PatrolDeleteResolveService { public class PatrolDeleteResolveService {
private final PatrolDeleteShopCandidateMapper candidateMapper; private final PatrolDeleteShopCandidateMapper candidateMapper;
private final PatrolDeleteConditionMapper conditionMapper;
private final ZiniaoShopSwitchService ziniaoShopSwitchService; private final ZiniaoShopSwitchService ziniaoShopSwitchService;
public List<ProductRiskCandidateVo> listCandidates(Long userId) { public List<ProductRiskCandidateVo> listCandidates(Long userId) {
@@ -127,6 +132,67 @@ public class PatrolDeleteResolveService {
return count == null ? 0L : count; return count == null ? 0L : count;
} }
public List<PatrolDeleteConditionVo> listConditions(Long userId) {
validateUserId(userId);
List<PatrolDeleteConditionEntity> rows = conditionMapper.selectList(
new LambdaQueryWrapper<PatrolDeleteConditionEntity>()
.eq(PatrolDeleteConditionEntity::getUserId, userId)
.orderByDesc(PatrolDeleteConditionEntity::getId));
List<PatrolDeleteConditionVo> 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<PatrolDeleteConditionEntity>()
.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) { private ProductRiskShopQueueItemVo matchOneShop(String shopName) {
ProductRiskShopQueueItemVo item = new ProductRiskShopQueueItemVo(); ProductRiskShopQueueItemVo item = new ProductRiskShopQueueItemVo();
item.setShopName(shopName); item.setShopName(shopName);

View File

@@ -149,11 +149,16 @@ public class PatrolDeleteTaskService {
Map<Long, FileTaskEntity> taskMap = loadTaskMap(entities); Map<Long, FileTaskEntity> taskMap = loadTaskMap(entities);
Map<Long, Map<Long, PatrolDeleteResultItemVo>> snapshotMap = buildSnapshotMap(taskMap); Map<Long, Map<Long, PatrolDeleteResultItemVo>> snapshotMap = buildSnapshotMap(taskMap);
Map<Long, TaskFileJobEntity> jobMap = taskFileJobService.findAssembleJobsByResultIds(MODULE_TYPE, entities.stream()
.map(FileResultEntity::getId)
.filter(id -> id != null && id > 0)
.distinct()
.toList());
List<PatrolDeleteResultItemVo> items = new ArrayList<>(); List<PatrolDeleteResultItemVo> items = new ArrayList<>();
for (FileResultEntity entity : entities) { for (FileResultEntity entity : entities) {
FileTaskEntity task = taskMap.get(entity.getTaskId()); FileTaskEntity task = taskMap.get(entity.getTaskId());
PatrolDeleteResultItemVo snapshot = snapshotMap.getOrDefault(entity.getTaskId(), Map.of()).get(entity.getId()); 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); vo.setItems(items);
return vo; return vo;
@@ -183,6 +188,11 @@ public class PatrolDeleteTaskService {
for (FileResultEntity row : rows) { for (FileResultEntity row : rows) {
rowsByTaskId.computeIfAbsent(row.getTaskId(), ignored -> new ArrayList<>()).add(row); rowsByTaskId.computeIfAbsent(row.getTaskId(), ignored -> new ArrayList<>()).add(row);
} }
Map<Long, TaskFileJobEntity> jobMap = taskFileJobService.findAssembleJobsByResultIds(MODULE_TYPE, rows.stream()
.map(FileResultEntity::getId)
.filter(id -> id != null && id > 0)
.distinct()
.toList());
for (Long taskId : normalizedTaskIds) { for (Long taskId : normalizedTaskIds) {
FileTaskEntity task = taskMap.get(taskId); FileTaskEntity task = taskMap.get(taskId);
@@ -195,7 +205,7 @@ public class PatrolDeleteTaskService {
batch.getMissingTaskIds().add(taskId); batch.getMissingTaskIds().add(taskId);
continue; continue;
} }
batch.getItems().addAll(buildProgressItems(task, taskRows)); batch.getItems().addAll(buildProgressItems(task, taskRows, jobMap));
} }
return batch; return batch;
} }
@@ -252,8 +262,6 @@ public class PatrolDeleteTaskService {
task.setCreatedAt(now); task.setCreatedAt(now);
task.setUpdatedAt(now); task.setUpdatedAt(now);
fileTaskMapper.insert(task); fileTaskMapper.insert(task);
taskCacheService.saveTaskCache(task);
taskCacheService.touchTaskHeartbeat(task.getId());
List<PatrolDeleteResultItemVo> snapshots = new ArrayList<>(); List<PatrolDeleteResultItemVo> snapshots = new ArrayList<>();
for (PatrolDeleteTaskItemDto item : uniqueItems) { for (PatrolDeleteTaskItemDto item : uniqueItems) {
@@ -272,9 +280,6 @@ public class PatrolDeleteTaskService {
fileResultMapper.insert(result); fileResultMapper.insert(result);
snapshots.add(toSnapshotVo(result, item, task.getStatus(), null)); snapshots.add(toSnapshotVo(result, item, task.getStatus(), null));
} }
persistTaskJson(task, uniqueItems, snapshots);
taskCacheService.saveTaskCache(task);
PatrolDeleteCreateTaskVo vo = new PatrolDeleteCreateTaskVo(); PatrolDeleteCreateTaskVo vo = new PatrolDeleteCreateTaskVo();
vo.setTaskId(task.getId()); vo.setTaskId(task.getId());
vo.setItems(snapshots); vo.setItems(snapshots);
@@ -291,7 +296,8 @@ public class PatrolDeleteTaskService {
FileTaskEntity task = loadTaskForExecution(taskId); FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { 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())) { if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
throw new BusinessException("任务已结束,拒绝重复提交"); throw new BusinessException("任务已结束,拒绝重复提交");
@@ -419,6 +425,7 @@ public class PatrolDeleteTaskService {
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>() fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId) .eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)); .eq(FileResultEntity::getModuleType, MODULE_TYPE));
cleanupTaskAuxiliaryData(taskId);
fileTaskMapper.deleteById(taskId); fileTaskMapper.deleteById(taskId);
taskCacheService.deleteTaskCache(taskId); taskCacheService.deleteTaskCache(taskId);
} }
@@ -432,6 +439,7 @@ public class PatrolDeleteTaskService {
} }
Long taskId = entity.getTaskId(); Long taskId = entity.getTaskId();
fileResultMapper.deleteById(resultId); fileResultMapper.deleteById(resultId);
cleanupResultAuxiliaryData(taskId, resultId);
reconcileTaskAfterResultRemoval(taskId); reconcileTaskAfterResultRemoval(taskId);
} }
@@ -445,6 +453,7 @@ public class PatrolDeleteTaskService {
} }
List<FileResultEntity> rows = listTaskRows(taskId); List<FileResultEntity> rows = listTaskRows(taskId);
if (rows.isEmpty()) { if (rows.isEmpty()) {
cleanupTaskAuxiliaryData(taskId);
fileTaskMapper.deleteById(taskId); fileTaskMapper.deleteById(taskId);
taskCacheService.deleteTaskCache(taskId); taskCacheService.deleteTaskCache(taskId);
return; return;
@@ -455,6 +464,17 @@ public class PatrolDeleteTaskService {
taskCacheService.saveTaskCache(task); 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<String> statuses) { private long countTasks(Long userId, List<String> statuses) {
Long count = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>() Long count = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE) .eq(FileTaskEntity::getModuleType, MODULE_TYPE)
@@ -517,6 +537,10 @@ public class PatrolDeleteTaskService {
} }
private PatrolDeleteResultItemVo toHistoryItem(FileResultEntity entity, FileTaskEntity task, PatrolDeleteResultItemVo snapshot) { 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(); PatrolDeleteResultItemVo item = snapshot != null ? snapshot : new PatrolDeleteResultItemVo();
item.setResultId(entity.getId()); item.setResultId(entity.getId());
item.setTaskId(entity.getTaskId()); item.setTaskId(entity.getTaskId());
@@ -529,7 +553,7 @@ public class PatrolDeleteTaskService {
item.setFinishedAt(task != null ? task.getFinishedAt() : item.getFinishedAt()); item.setFinishedAt(task != null ? task.getFinishedAt() : item.getFinishedAt());
item.setOutputFilename(firstNonBlank(item.getOutputFilename(), entity.getResultFilename())); item.setOutputFilename(firstNonBlank(item.getOutputFilename(), entity.getResultFilename()));
item.setDownloadUrl(null); item.setDownloadUrl(null);
attachFileJobState(item, entity); attachFileJobState(item, entity, job);
if (item.getCountrySections() == null) { if (item.getCountrySections() == null) {
item.setCountrySections(new ArrayList<>()); item.setCountrySections(new ArrayList<>());
} }
@@ -540,7 +564,10 @@ public class PatrolDeleteTaskService {
} }
private void attachFileJobState(PatrolDeleteResultItemVo item, FileResultEntity entity) { 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())); item.setFileReady(!blank(entity.getResultFileUrl()));
if (job == null) { if (job == null) {
item.setFileStatus(Boolean.TRUE.equals(item.getFileReady()) ? "SUCCESS" : null); item.setFileStatus(Boolean.TRUE.equals(item.getFileReady()) ? "SUCCESS" : null);
@@ -612,7 +639,7 @@ public class PatrolDeleteTaskService {
return list; return list;
} }
private List<PatrolDeleteResultItemVo> buildProgressItems(FileTaskEntity task, List<FileResultEntity> rows) { private List<PatrolDeleteResultItemVo> buildProgressItems(FileTaskEntity task, List<FileResultEntity> rows, Map<Long, TaskFileJobEntity> jobMap) {
List<PatrolDeleteResultItemVo> list = new ArrayList<>(); List<PatrolDeleteResultItemVo> list = new ArrayList<>();
for (FileResultEntity row : rows) { for (FileResultEntity row : rows) {
PatrolDeleteResultItemVo item = new PatrolDeleteResultItemVo(); PatrolDeleteResultItemVo item = new PatrolDeleteResultItemVo();
@@ -627,7 +654,7 @@ public class PatrolDeleteTaskService {
item.setFinishedAt(task == null ? null : task.getFinishedAt()); item.setFinishedAt(task == null ? null : task.getFinishedAt());
item.setOutputFilename(row.getResultFilename()); item.setOutputFilename(row.getResultFilename());
item.setDownloadUrl(null); item.setDownloadUrl(null);
attachFileJobState(item, row); attachFileJobState(item, row, jobMap == null ? null : jobMap.get(row.getId()));
list.add(item); list.add(item);
} }
return list; return list;

View File

@@ -225,6 +225,27 @@ public class TaskFileJobService {
return count == null ? 0L : count; 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<TaskFileJobEntity>()
.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<TaskFileJobEntity>()
.eq(TaskFileJobEntity::getTaskId, taskId)
.eq(TaskFileJobEntity::getModuleType, moduleType)
.eq(TaskFileJobEntity::getResultId, resultId));
}
private TaskFileJobEntity findJob(Long taskId, String moduleType, Long resultId, String jobType) { private TaskFileJobEntity findJob(Long taskId, String moduleType, Long resultId, String jobType) {
if (taskId == null || moduleType == null || moduleType.isBlank() || resultId == null || jobType == null || jobType.isBlank()) { if (taskId == null || moduleType == null || moduleType.isBlank() || resultId == null || jobType == null || jobType.isBlank()) {
return null; return null;

View File

@@ -84,6 +84,16 @@ public class TaskProgressSnapshotService {
.last("limit 1")); .last("limit 1"));
} }
@Transactional
public void delete(Long taskId, String moduleType) {
if (taskId == null || taskId <= 0 || isBlank(moduleType)) {
return;
}
taskProgressSnapshotMapper.delete(new LambdaQueryWrapper<TaskProgressSnapshotEntity>()
.eq(TaskProgressSnapshotEntity::getTaskId, taskId)
.eq(TaskProgressSnapshotEntity::getModuleType, moduleType));
}
private String writeJson(Object value) { private String writeJson(Object value) {
try { try {
return objectMapper.writeValueAsString(value); return objectMapper.writeValueAsString(value);

View File

@@ -94,6 +94,27 @@ public class TaskResultItemService {
return count == null ? 0L : count; 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<TaskResultItemEntity> rows = taskResultItemMapper.selectList(new LambdaQueryWrapper<TaskResultItemEntity>()
.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<TaskResultItemEntity>()
.eq(TaskResultItemEntity::getTaskId, taskId)
.eq(TaskResultItemEntity::getModuleType, moduleType)
.eq(TaskResultItemEntity::getResultId, resultId));
}
@Transactional @Transactional
public void deleteTaskItems(Long taskId, String moduleType) { public void deleteTaskItems(Long taskId, String moduleType) {
if (taskId == null || taskId <= 0 || isBlank(moduleType)) { if (taskId == null || taskId <= 0 || isBlank(moduleType)) {

View File

@@ -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)
);

View File

@@ -113,6 +113,18 @@
{{ item.resultFilename || '下载结果' }} {{ item.resultFilename || '下载结果' }}
</div> </div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div> <div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ item.fileProgressMessage || '结果生成中' }}</span>
<span>{{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
<div class="file-progress-count">
{{ item.fileProgressCurrent || 0 }}/{{ item.fileProgressTotal || 0 }}
</div>
</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div> <div v-if="item.error" class="files">错误{{ item.error }}</div>
</div> </div>
<div class="task-right"> <div class="task-right">
@@ -633,6 +645,16 @@ function canDownload(item: AppearancePatentHistoryItem) {
return Boolean(item.resultId && (item.fileReady || item.downloadUrl)) 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) { async function downloadResult(item: AppearancePatentHistoryItem) {
if (!item.resultId) return if (!item.resultId) return
const api = getPywebviewApi() const api = getPywebviewApi()
@@ -729,6 +751,11 @@ onUnmounted(() => {
.status.running { background: rgba(52, 152, 219, .18); color: #3498db; } .status.running { background: rgba(52, 152, 219, .18); color: #3498db; }
.status.pending { background: rgba(149, 165, 166, .18); color: #bdc3c7; } .status.pending { background: rgba(149, 165, 166, .18); color: #bdc3c7; }
.result-hint { margin-top: 6px; color: #e0b96d; } .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); } .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); } .btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
@media (max-width: 1100px) { @media (max-width: 1100px) {

View File

@@ -7,7 +7,7 @@
<div class="section-title">店铺输入</div> <div class="section-title">店铺输入</div>
<div class="input-zone"> <div class="input-zone">
<div class="hint"> <div class="hint">
左侧负责录入店铺并加入备选区确认命中后推送到 Python 队列巡店删除按店铺串行执行上一个任务完成后会自动开始下一个失败也会继续下一个 左侧负责录入店铺并加入备选区点击匹配店铺只会生成匹配结果确认后需手动点击推送到 Python 队列才会创建并执行任务
</div> </div>
<div class="input-row"> <div class="input-row">
<el-input <el-input
@@ -60,6 +60,54 @@
</el-table> </el-table>
</div> </div>
<div class="section-title">删除条件</div>
<div class="condition-panel">
<div class="condition-input-row">
<el-input
v-model="conditionInput"
clearable
placeholder="请输入删除条件"
@keyup.enter="confirmAddCondition"
/>
<button
type="button"
class="opt-btn"
:disabled="addingCondition"
@click="confirmAddCondition"
>
{{ addingCondition ? "保存中..." : "保存" }}
</button>
</div>
<div v-if="!deleteConditions.length" class="empty-conditions">
暂无删除条件保存后可在推送任务时多选携带
</div>
<ul v-else class="condition-list">
<li
v-for="condition in deleteConditions"
:key="condition.id"
class="condition-item"
>
<label class="condition-check">
<input
v-model="selectedConditionIds"
type="checkbox"
:value="condition.id"
/>
<span :title="condition.conditionText">
{{ condition.conditionText }}
</span>
</label>
<button
type="button"
class="link-danger"
@click="removeCondition(condition.id)"
>
删除
</button>
</li>
</ul>
</div>
<div class="run-row"> <div class="run-row">
<button <button
type="button" type="button"
@@ -75,12 +123,12 @@
:disabled="pushing || !matchedRunnableItems.length" :disabled="pushing || !matchedRunnableItems.length"
@click="pushToPythonQueue" @click="pushToPythonQueue"
> >
{{ pushing ? "串行执行中..." : "推送到 Python 队列" }} {{ pushing ? "任务执行中..." : "推送到 Python 队列" }}
</button> </button>
</div> </div>
<p class="loading-msg"> <p class="loading-msg">
当前页面会按店铺串行推送任务并在任务成功或失败后自动接续下一个任务服务端临时重启时会自动重试查询状态恢复后继续后续店铺 当前页面会把匹配结果合并为一个巡店删除任务推送多个店铺会在同一个任务和同一个结果文件中汇总
</p> </p>
<div v-if="queuePushResult" class="queue-debug-card"> <div v-if="queuePushResult" class="queue-debug-card">
@@ -192,19 +240,19 @@
<ul class="task-list"> <ul class="task-list">
<li <li
v-for="item in currentSectionItems" v-for="item in currentSectionItems"
:key="`${item.resultId}-${item.taskId}`" :key="taskGroupKey(item)"
class="task-item" class="task-item"
> >
<div class="left split-result-main"> <div class="left split-result-main">
<span class="id" :title="item.shopName || ''"> <span class="id" :title="formatTaskGroupShopNames(item)">
{{ item.shopName || "-" }} {{ formatTaskGroupShopNames(item) }}
</span> </span>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div> <div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
<div v-if="item.resultId" class="files"> <div v-if="formatTaskGroupResultIds(item)" class="files">
结果 ID: {{ item.resultId }} 结果 ID: {{ formatTaskGroupResultIds(item) }}
</div> </div>
<div v-if="item.shopId" class="files"> <div v-if="formatTaskGroupShopIds(item)" class="files">
店铺 ID: {{ item.shopId }} 店铺 ID: {{ formatTaskGroupShopIds(item) }}
</div> </div>
<div v-if="item.platform" class="files"> <div v-if="item.platform" class="files">
平台: {{ item.platform }} 平台: {{ item.platform }}
@@ -213,10 +261,10 @@
创建时间: {{ formatDateTime(item.createdAt) }} 创建时间: {{ formatDateTime(item.createdAt) }}
</div> </div>
<div class="files"> <div class="files">
模板结构: {{ formatTemplateSummary(item) }} 模板: {{ formatTemplateSummary(item) }}
</div> </div>
<div v-if="item.error" class="files"> <div v-if="formatTaskGroupErrors(item)" class="files">
错误: {{ item.error }} 错误: {{ formatTaskGroupErrors(item) }}
</div> </div>
</div> </div>
<div class="task-right"> <div class="task-right">
@@ -243,19 +291,19 @@
<ul class="task-list"> <ul class="task-list">
<li <li
v-for="item in historySectionItems" v-for="item in historySectionItems"
:key="`${item.resultId}-${item.taskId}`" :key="taskGroupKey(item)"
class="task-item" class="task-item"
> >
<div class="left split-result-main"> <div class="left split-result-main">
<span class="id" :title="item.shopName || ''"> <span class="id" :title="formatTaskGroupShopNames(item)">
{{ item.shopName || "-" }} {{ formatTaskGroupShopNames(item) }}
</span> </span>
<div class="files">任务 ID: {{ item.taskId ?? "-" }}</div> <div class="files">任务 ID: {{ item.taskId ?? "-" }}</div>
<div v-if="item.resultId" class="files"> <div v-if="formatTaskGroupResultIds(item)" class="files">
结果 ID: {{ item.resultId }} 结果 ID: {{ formatTaskGroupResultIds(item) }}
</div> </div>
<div v-if="item.shopId" class="files"> <div v-if="formatTaskGroupShopIds(item)" class="files">
店铺 ID: {{ item.shopId }} 店铺 ID: {{ formatTaskGroupShopIds(item) }}
</div> </div>
<div v-if="item.platform" class="files"> <div v-if="item.platform" class="files">
平台: {{ item.platform }} 平台: {{ item.platform }}
@@ -267,10 +315,10 @@
完成时间: {{ formatDateTime(item.finishedAt) }} 完成时间: {{ formatDateTime(item.finishedAt) }}
</div> </div>
<div class="files"> <div class="files">
模板结构: {{ formatTemplateSummary(item) }} 模板: {{ formatTemplateSummary(item) }}
</div> </div>
<div v-if="item.error" class="files"> <div v-if="formatTaskGroupErrors(item)" class="files">
错误: {{ item.error }} 错误: {{ formatTaskGroupErrors(item) }}
</div> </div>
</div> </div>
<div class="task-right"> <div class="task-right">
@@ -313,18 +361,22 @@ import { ElMessage } from "element-plus";
import BrandTopBar from "@/pages/brand/components/BrandTopBar.vue"; import BrandTopBar from "@/pages/brand/components/BrandTopBar.vue";
import { import {
addPatrolDeleteCandidate, addPatrolDeleteCandidate,
addPatrolDeleteCondition,
createPatrolDeleteTask, createPatrolDeleteTask,
deletePatrolDeleteCandidate, deletePatrolDeleteCandidate,
deletePatrolDeleteCondition,
deletePatrolDeleteHistory, deletePatrolDeleteHistory,
deletePatrolDeleteTask, deletePatrolDeleteTask,
getPatrolDeleteDashboard, getPatrolDeleteDashboard,
getPatrolDeleteHistory, getPatrolDeleteHistory,
getPatrolDeleteTaskProgressBatch, getPatrolDeleteTaskProgressBatch,
getPatrolDeleteResultDownloadUrl, getPatrolDeleteResultDownloadUrl,
listPatrolDeleteConditions,
listPatrolDeleteCandidates, listPatrolDeleteCandidates,
matchPatrolDeleteShops, matchPatrolDeleteShops,
submitPatrolDeleteTaskResult, submitPatrolDeleteTaskResult,
type PatrolDeleteCandidateVo, type PatrolDeleteCandidateVo,
type PatrolDeleteConditionVo,
type PatrolDeleteCartRatio, type PatrolDeleteCartRatio,
type PatrolDeleteCountrySection, type PatrolDeleteCountrySection,
type PatrolDeleteDashboardVo, type PatrolDeleteDashboardVo,
@@ -340,8 +392,11 @@ const COUNTRY_TEMPLATE = ["德国", "英国", "法国", "意大利", "西班牙"
const MAX_TRANSIENT_ERRORS = 30; const MAX_TRANSIENT_ERRORS = 30;
const shopInput = ref(""); const shopInput = ref("");
const conditionInput = ref("");
const candidates = ref<PatrolDeleteCandidateVo[]>([]); const candidates = ref<PatrolDeleteCandidateVo[]>([]);
const selectedCandidates = ref<PatrolDeleteCandidateVo[]>([]); const selectedCandidates = ref<PatrolDeleteCandidateVo[]>([]);
const deleteConditions = ref<PatrolDeleteConditionVo[]>([]);
const selectedConditionIds = ref<number[]>([]);
const matchedItems = ref<PatrolDeleteShopQueueItem[]>([]); const matchedItems = ref<PatrolDeleteShopQueueItem[]>([]);
const historyItems = ref<PatrolDeleteHistoryItem[]>([]); const historyItems = ref<PatrolDeleteHistoryItem[]>([]);
const dashboard = ref<PatrolDeleteDashboardVo>({ const dashboard = ref<PatrolDeleteDashboardVo>({
@@ -351,32 +406,30 @@ const dashboard = ref<PatrolDeleteDashboardVo>({
failedTaskCount: 0, failedTaskCount: 0,
}); });
const adding = ref(false); const adding = ref(false);
const addingCondition = ref(false);
const matching = ref(false); const matching = ref(false);
const pushing = ref(false); const pushing = ref(false);
const queuePushResult = ref(""); const queuePushResult = ref("");
const queuePayloadText = ref(""); const queuePayloadText = ref("");
const pendingQueue = ref<PatrolDeleteShopQueueItem[]>([]);
const activeTaskId = ref<number | null>(null); const activeTaskId = ref<number | null>(null);
const activeQueueItem = ref<PatrolDeleteShopQueueItem | null>(null);
const queueWorkerRunning = ref(false); const queueWorkerRunning = ref(false);
const autoQueueEnabled = ref(false);
let historyPollTimer: number | null = null; let historyPollTimer: number | null = null;
const timers = createCategorizedTimers("patrol-delete"); const timers = createCategorizedTimers("patrol-delete");
const matchedRunnableItems = computed(() => const matchedRunnableItems = computed(() =>
matchedItems.value.filter((item) => item.matched), matchedItems.value.filter((item) => item.matched),
); );
const taskRecordItems = computed(() => groupHistoryItemsByTask(historyItems.value));
const currentSectionItems = computed(() => const currentSectionItems = computed(() =>
historyItems.value.filter((item) => !isTaskTerminal(item.taskStatus)), taskRecordItems.value.filter((item) => !isTaskTerminal(item.taskStatus)),
); );
const historySectionItems = computed(() => const historySectionItems = computed(() =>
historyItems.value.filter((item) => isTaskTerminal(item.taskStatus)), taskRecordItems.value.filter((item) => isTaskTerminal(item.taskStatus)),
); );
const hasQueueWork = computed( const hasQueueWork = computed(
() => () =>
queueWorkerRunning.value || queueWorkerRunning.value ||
!!activeTaskId.value || !!activeTaskId.value,
pendingQueue.value.length > 0,
); );
function uidForStorage() { function uidForStorage() {
@@ -401,6 +454,10 @@ function historyItemKey(item: PatrolDeleteHistoryItem) {
return `${item.taskId ?? 0}:${item.resultId ?? 0}`; return `${item.taskId ?? 0}:${item.resultId ?? 0}`;
} }
function taskGroupKey(item: PatrolDeleteHistoryItem) {
return `task:${item.taskId ?? item.resultId ?? 0}`;
}
let disposed = false; let disposed = false;
function sleep(ms: number) { function sleep(ms: number) {
@@ -546,8 +603,65 @@ function formatTemplateSummary(
"shopName" | "countrySections" | "cartRatios" "shopName" | "countrySections" | "cartRatios"
>, >,
) { ) {
const rows = flattenTemplateRows(record); const countries = new Set([
return `${rows.length} 行状态数据,首行含 ${record.cartRatios?.length || 0} 个购物车比例列`; ...(record.countrySections || []).map((item) => item.country).filter(Boolean),
...(record.cartRatios || []).map((item) => item.country).filter(Boolean),
]);
return `${countries.size || 0} 个国家,含状态数据和购物车比例`;
}
function uniqueJoined(values: Array<string | number | undefined | null>) {
return Array.from(
new Set(
values
.map((value) => (value == null ? "" : String(value).trim()))
.filter(Boolean),
),
).join("、");
}
function groupHistoryItemsByTask(items: PatrolDeleteHistoryItem[]) {
const groups = new Map<string, PatrolDeleteHistoryItem[]>();
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) { function isTaskTerminal(status?: string) {
@@ -569,7 +683,9 @@ function statusClass(status?: string) {
} }
function canDownload(item: PatrolDeleteHistoryItem) { 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() { function saveMatchedItems() {
@@ -595,9 +711,7 @@ function loadMatchedItems() {
function saveQueueState() { function saveQueueState() {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const payload = { const payload = {
pendingQueue: pendingQueue.value,
activeTaskId: activeTaskId.value, activeTaskId: activeTaskId.value,
activeQueueItem: activeQueueItem.value,
}; };
window.localStorage.setItem(queueStateStorageKey(), JSON.stringify(payload)); window.localStorage.setItem(queueStateStorageKey(), JSON.stringify(payload));
} }
@@ -610,23 +724,16 @@ function loadQueueState() {
: null; : null;
if (!raw) return; if (!raw) return;
const parsed = JSON.parse(raw) as { const parsed = JSON.parse(raw) as {
pendingQueue?: PatrolDeleteShopQueueItem[];
activeTaskId?: number | null; activeTaskId?: number | null;
activeQueueItem?: PatrolDeleteShopQueueItem | null;
}; };
pendingQueue.value = parsed.pendingQueue || [];
activeTaskId.value = parsed.activeTaskId ?? null; activeTaskId.value = parsed.activeTaskId ?? null;
activeQueueItem.value = parsed.activeQueueItem ?? null;
} catch { } catch {
pendingQueue.value = [];
activeTaskId.value = null; activeTaskId.value = null;
activeQueueItem.value = null;
} }
} }
function clearActiveQueueTask() { function clearActiveQueueTask() {
activeTaskId.value = null; activeTaskId.value = null;
activeQueueItem.value = null;
saveQueueState(); saveQueueState();
} }
@@ -636,9 +743,8 @@ function isRecordMissingError(error: unknown) {
} }
function removeMatchedRowLocally(row: PatrolDeleteShopQueueItem) { function removeMatchedRowLocally(row: PatrolDeleteShopQueueItem) {
matchedItems.value = matchedItems.value.filter( const key = rowKeyForMatch(row);
(item) => rowKeyForMatch(item) !== rowKeyForMatch(row), matchedItems.value = matchedItems.value.filter((item) => rowKeyForMatch(item) !== key);
);
saveMatchedItems(); saveMatchedItems();
} }
@@ -650,20 +756,18 @@ function removeHistoryItemLocally(item: PatrolDeleteHistoryItem) {
}); });
} }
function mergeQueueItems(
base: PatrolDeleteShopQueueItem[],
incoming: PatrolDeleteShopQueueItem[],
) {
const map = new Map<string, PatrolDeleteShopQueueItem>();
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() { async function loadCandidates() {
candidates.value = await listPatrolDeleteCandidates(); 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() { async function loadDashboard() {
dashboard.value = await getPatrolDeleteDashboard(); dashboard.value = await getPatrolDeleteDashboard();
} }
@@ -705,6 +809,10 @@ function mergeHistoryProgressItems(incoming: PatrolDeleteHistoryItem[]) {
historyItems.value = merged; historyItems.value = merged;
} }
function upsertHistoryItems(incoming: PatrolDeleteHistoryItem[]) {
mergeHistoryProgressItems(incoming);
}
async function refreshActiveTaskProgress(taskIds?: number[]) { async function refreshActiveTaskProgress(taskIds?: number[]) {
const ids = Array.from( const ids = Array.from(
new Set( 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) { async function removeCandidate(id: number) {
try { try {
await deletePatrolDeleteCandidate(id); await deletePatrolDeleteCandidate(id);
@@ -804,11 +947,6 @@ async function runMatch() {
const data = await matchPatrolDeleteShops(names); const data = await matchPatrolDeleteShops(names);
matchedItems.value = mergeMatchedItems(matchedItems.value, data.items || []); matchedItems.value = mergeMatchedItems(matchedItems.value, data.items || []);
saveMatchedItems(); saveMatchedItems();
if (autoQueueEnabled.value) {
pendingQueue.value = mergeQueueItems(pendingQueue.value, matchedRunnableItems.value);
saveQueueState();
void processQueue();
}
ElMessage.success(`本次返回 ${data.items?.length || 0} 条匹配结果`); ElMessage.success(`本次返回 ${data.items?.length || 0} 条匹配结果`);
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : "匹配失败"); ElMessage.error(error instanceof Error ? error.message : "匹配失败");
@@ -818,14 +956,8 @@ async function runMatch() {
} }
function removeMatchedRow(row: PatrolDeleteShopQueueItem) { function removeMatchedRow(row: PatrolDeleteShopQueueItem) {
if (hasQueueWork.value) { removeMatchedRowLocally(row);
ElMessage.warning("队列执行中,请等待当前串行任务结束后再调整"); ElMessage.success("已从匹配结果移除");
return;
}
matchedItems.value = matchedItems.value.filter(
(item) => rowKeyForMatch(item) !== rowKeyForMatch(row),
);
saveMatchedItems();
} }
function buildTaskItem(item: PatrolDeleteShopQueueItem): PatrolDeleteTaskItem { 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 { return {
type: "patrol-delete-run", type: "patrol-delete-run",
ts: Date.now(), ts: Date.now(),
@@ -850,8 +994,9 @@ function buildQueuePayload(taskId: number, item: PatrolDeleteHistoryItem) {
taskId, taskId,
user_id: Number(uidForStorage()) || 0, user_id: Number(uidForStorage()) || 0,
source: "frontend-vue-patrol-delete", source: "frontend-vue-patrol-delete",
items: [ delete_conditions: deleteConditionsForTask,
{ deleteConditions: deleteConditionsForTask,
items: items.map((item) => ({
shopName: item.shopName, shopName: item.shopName,
shopId: item.shopId, shopId: item.shopId,
platform: item.platform, platform: item.platform,
@@ -859,11 +1004,17 @@ function buildQueuePayload(taskId: number, item: PatrolDeleteHistoryItem) {
matched: item.matched, matched: item.matched,
matchStatus: item.matchStatus, matchStatus: item.matchStatus,
matchMessage: item.matchMessage, matchMessage: item.matchMessage,
}, countrySections: item.countrySections || [],
], cartRatios: item.cartRatios || [],
template_rows: flattenTemplateRows(item), })),
country_sections: item.countrySections || [], template_rows: firstItem ? flattenTemplateRows(firstItem) : [],
cart_ratios: item.cartRatios || [], 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; let transientErrorCount = 0;
while (true) { while (true) {
if (disposed) return "STOPPED"; if (disposed) return "STOPPED";
if (activeTaskId.value !== taskId) return "DELETED";
try { try {
await refreshActiveTaskProgress([taskId]); await refreshActiveTaskProgress([taskId]);
if (activeTaskId.value !== taskId) return "DELETED";
if (transientErrorCount > 0) { if (transientErrorCount > 0) {
queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`; queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`;
} }
@@ -915,76 +1068,72 @@ async function processQueue() {
throw new Error("当前客户端未提供 enqueue_json"); throw new Error("当前客户端未提供 enqueue_json");
} }
while (!disposed && (activeTaskId.value || pendingQueue.value.length)) { if (activeTaskId.value) {
if (activeTaskId.value) { const finalStatus = await waitForTaskTerminal(activeTaskId.value);
const finalStatus = await waitForTaskTerminal(activeTaskId.value); queuePushResult.value =
queuePushResult.value = finalStatus === "DELETED"
pendingQueue.value.length > 0 ? "任务已删除"
? `任务 ${activeTaskId.value} ${finalStatus === "SUCCESS" ? "已完成" : "执行失败"},自动继续下一个(剩余 ${pendingQueue.value.length} 条)` : `任务 ${activeTaskId.value} ${finalStatus === "SUCCESS" ? "已完成" : "执行失败"}`;
: `任务 ${activeTaskId.value} ${finalStatus === "SUCCESS" ? "已完成" : "执行失败"}`; clearActiveQueueTask();
clearActiveQueueTask(); return;
continue; }
const runnable = matchedRunnableItems.value;
if (!runnable.length) {
ElMessage.warning("请先匹配可用店铺");
return;
} }
const nextItem = pendingQueue.value.shift();
saveQueueState();
if (!nextItem) break;
const created = await withTransientRetry( const created = await withTransientRetry(
() => createPatrolDeleteTask([buildTaskItem(nextItem)]), () => createPatrolDeleteTask(runnable.map(buildTaskItem)),
(attempt, maxAttempts) => { (attempt, maxAttempts) => {
queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...`; queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...`;
}, },
); );
const createdItem = created.items?.[0]; const createdItems = created.items || [];
if (!createdItem?.taskId || !createdItem.resultId) { if (!created.taskId || !createdItems.length || createdItems.some((item) => !item.taskId || !item.resultId)) {
throw new Error("后端未返回有效任务标识"); throw new Error("后端未返回有效任务标识");
} }
activeTaskId.value = created.taskId; activeTaskId.value = created.taskId;
activeQueueItem.value = nextItem;
saveQueueState(); 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); queuePayloadText.value = JSON.stringify(payload, null, 2);
const pushResult = await api.enqueue_json(payload); const pushResult = await api.enqueue_json(payload);
if (!pushResult?.success) { if (!pushResult?.success) {
await submitPatrolDeleteTaskResult(created.taskId, { await submitPatrolDeleteTaskResult(created.taskId, {
shops: [ shops: createdItems.map((createdItem) => ({
{ shopName: createdItem.shopName || "",
shopName: createdItem.shopName || nextItem.shopName || "",
error: pushResult?.error || `任务 ${created.taskId} 推送失败`, error: pushResult?.error || `任务 ${created.taskId} 推送失败`,
countrySections: createdItem.countrySections || [], countrySections: createdItem.countrySections || [],
cartRatios: createdItem.cartRatios || [], cartRatios: createdItem.cartRatios || [],
shopDone: true, shopDone: true,
submissionId: createSubmissionId( submissionId: createSubmissionId(
created.taskId, created.taskId,
createdItem.shopName || nextItem.shopName, createdItem.shopName,
), ),
chunkIndex: 1, chunkIndex: 1,
chunkTotal: 1, chunkTotal: 1,
}, })),
],
}); });
removeMatchedRowLocally(nextItem); for (const item of runnable) removeMatchedRowLocally(item);
await refreshTaskViews(); await refreshTaskViews();
queuePushResult.value = `任务 ${created.taskId} 推送失败,已自动继续下一个任务`; queuePushResult.value = `任务 ${created.taskId} 推送失败`;
clearActiveQueueTask(); clearActiveQueueTask();
continue; return;
} }
removeMatchedRowLocally(nextItem); for (const item of runnable) removeMatchedRowLocally(item);
queuePushResult.value = queuePushResult.value = `任务 ${created.taskId} 已入队,共 ${createdItems.length} 个店铺,等待执行完成`;
pendingQueue.value.length > 0
? `任务 ${created.taskId} 已入队,等待完成后自动继续下一个(剩余 ${pendingQueue.value.length} 条)`
: `任务 ${created.taskId} 已入队,等待执行完成`;
}
queuePushResult.value = "串行队列已执行完成"; const finalStatus = await waitForTaskTerminal(created.taskId);
queuePushResult.value = `任务 ${created.taskId} ${finalStatus === "SUCCESS" ? "已完成" : finalStatus === "DELETED" ? "已删除" : "执行失败"}`;
clearActiveQueueTask();
await refreshTaskViews(); await refreshTaskViews();
ElMessage.success("巡店删除队列已按顺序执行完成"); ElMessage.success("巡店删除任务已完成");
} catch (error) { } catch (error) {
if (disposed) return; if (disposed) return;
const message = error instanceof Error ? error.message : "队列执行失败"; const message = error instanceof Error ? error.message : "队列执行失败";
@@ -1000,27 +1149,28 @@ async function processQueue() {
} }
async function pushToPythonQueue() { async function pushToPythonQueue() {
autoQueueEnabled.value = true;
const runnable = matchedRunnableItems.value; const runnable = matchedRunnableItems.value;
if (!runnable.length) { if (!runnable.length) {
ElMessage.warning("请先匹配可用店铺"); ElMessage.warning("请先匹配可用店铺");
return; return;
} }
pendingQueue.value = mergeQueueItems(pendingQueue.value, runnable); queuePushResult.value = `开始创建任务,共 ${runnable.length} 个店铺`;
saveQueueState();
queuePushResult.value = `已加入 ${runnable.length} 条店铺,开始串行执行`;
await processQueue(); await processQueue();
} }
async function downloadResult(item: PatrolDeleteHistoryItem) { 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(); const api = getPywebviewApi();
if (!api?.save_file_from_url_new) { if (!api?.save_file_from_url_new) {
ElMessage.error("当前客户端未提供下载能力"); ElMessage.error("当前客户端未提供下载能力");
return; return;
} }
const url = getPatrolDeleteResultDownloadUrl(item.resultId); const url = getPatrolDeleteResultDownloadUrl(downloadable.resultId);
const filename = item.outputFilename || `${item.shopName || "result"}.xlsx`; const filename =
downloadable.outputFilename || `${item.shopName || "patrol-delete-result"}.xlsx`;
const result = await api.save_file_from_url_new(url, filename); const result = await api.save_file_from_url_new(url, filename);
if (result.success) { if (result.success) {
ElMessage.success(`已保存: ${result.path || filename}`); ElMessage.success(`已保存: ${result.path || filename}`);
@@ -1041,7 +1191,8 @@ async function deleteTaskRecord(item: PatrolDeleteHistoryItem) {
} else { } else {
throw new Error("缺少可删除的任务标识"); throw new Error("缺少可删除的任务标识");
} }
await Promise.all([loadCandidates(), refreshTaskViews()]); removeHistoryItemLocally(item);
await Promise.allSettled([loadCandidates(), refreshTaskViews()]);
ElMessage.success("已删除"); ElMessage.success("已删除");
} catch (error) { } catch (error) {
if (isRecordMissingError(error)) { if (isRecordMissingError(error)) {
@@ -1060,14 +1211,13 @@ async function deleteTaskRecord(item: PatrolDeleteHistoryItem) {
onMounted(async () => { onMounted(async () => {
loadMatchedItems(); loadMatchedItems();
loadQueueState(); loadQueueState();
await Promise.all([loadCandidates(), loadDashboard(), loadHistory()]); await Promise.all([loadCandidates(), loadConditions(), loadDashboard(), loadHistory()]);
if (activeTaskId.value || pendingQueue.value.length) { if (activeTaskId.value) {
autoQueueEnabled.value = true;
queuePushResult.value = queuePushResult.value =
activeTaskId.value != null activeTaskId.value != null
? `检测到未完成队列,继续等待任务 ${activeTaskId.value} 完成并自动接续后续店铺` ? `检测到未完成任务,继续等待任务 ${activeTaskId.value} 完成`
: `检测到未完成队列,继续执行剩余 ${pendingQueue.value.length} 条店铺`; : "";
void processQueue(); 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; } .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-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; } .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 { background: none; border: none; color: #e74c3c; cursor: pointer; font-size: 12px; padding: 0; }
.link-danger:hover { text-decoration: underline; } .link-danger:hover { text-decoration: underline; }
.run-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; } .run-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }

View File

@@ -73,8 +73,7 @@
{{ pushing ? '推送中' : '推送到 Python 队列' }} {{ pushing ? '推送中' : '推送到 Python 队列' }}
</button> </button>
</div> </div>
<p class="loading-msg">与删除 ASIN 品牌相同先创建任务再按店铺<strong>逐条</strong>入队每条 <code>data.items</code> <p class="loading-msg">在搜索结果中禁止显示详情页面已删除会把匹配店铺合并为一个任务入队避免同时打开多个紫鸟窗口其它筛选仍按店铺逐条入队Python
仅含一个店铺队列串行执行Python
每处理完一店可 POST <code>/api/product-risk-resolve/tasks/{taskId}/result</code>可多次每次可只含已完成的店铺全部完成后任务结束前端轮询任务状态 每处理完一店可 POST <code>/api/product-risk-resolve/tasks/{taskId}/result</code>可多次每次可只含已完成的店铺全部完成后任务结束前端轮询任务状态
</p> </p>
@@ -1033,6 +1032,53 @@ function nextMatchedQueueItem() {
return matchedItems.value.find((item) => item.matched) 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() { async function processMatchedQueue() {
if (disposed || queueWorkerRunning.value) { if (disposed || queueWorkerRunning.value) {
return return
@@ -1057,6 +1103,14 @@ async function processMatchedQueue() {
let successCount = 0 let successCount = 0
let failedCount = 0 let failedCount = 0
try { try {
if (shouldBatchProductRiskQueue()) {
await processMatchedBatchQueue(toPush)
ElMessage.success('店铺推送已完成:成功 ' + toPush.length + ' 条,失败 0 条')
await loadHistory()
ensurePolling(true)
return
}
let index = 0 let index = 0
while (!disposed && autoQueueEnabled.value) { while (!disposed && autoQueueEnabled.value) {
const item = nextMatchedQueueItem() const item = nextMatchedQueueItem()

View File

@@ -1008,6 +1008,12 @@ export type PatrolDeleteCandidateVo = ProductRiskCandidateVo;
export type PatrolDeleteDashboardVo = ProductRiskDashboardVo; export type PatrolDeleteDashboardVo = ProductRiskDashboardVo;
export type PatrolDeleteShopQueueItem = ProductRiskShopQueueItem; export type PatrolDeleteShopQueueItem = ProductRiskShopQueueItem;
export interface PatrolDeleteConditionVo {
id: number;
conditionText: string;
createdAt?: string;
}
export interface PatrolDeleteCountryMetricRow { export interface PatrolDeleteCountryMetricRow {
status: string; status: string;
quantity: string; quantity: string;
@@ -1110,6 +1116,40 @@ export function deletePatrolDeleteCandidate(id: number) {
); );
} }
export function listPatrolDeleteConditions() {
return unwrapJavaResponse(
get<JavaApiResponse<PatrolDeleteConditionVo[]>>(
`${JAVA_API_PREFIX}/patrol-delete/conditions`,
{
params: { user_id: getCurrentUserId() },
},
),
);
}
export function addPatrolDeleteCondition(conditionText: string) {
return unwrapJavaResponse(
post<
JavaApiResponse<PatrolDeleteConditionVo>,
{ 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<JavaApiResponse<null>>(
`${JAVA_API_PREFIX}/patrol-delete/conditions/${id}`,
{
params: { user_id: getCurrentUserId() },
},
),
);
}
export function matchPatrolDeleteShops(shopNames: string[]) { export function matchPatrolDeleteShops(shopNames: string[]) {
return unwrapJavaResponse( return unwrapJavaResponse(
post< post<
@@ -1451,6 +1491,10 @@ export interface AppearancePatentHistoryItem {
fileStatus?: string; fileStatus?: string;
fileError?: string; fileError?: string;
fileReady?: boolean; fileReady?: boolean;
fileProgressPercent?: number;
fileProgressCurrent?: number;
fileProgressTotal?: number;
fileProgressMessage?: string;
taskStatus?: string; taskStatus?: string;
success?: boolean; success?: boolean;
error?: string; error?: string;