处理后台管理系统、修复BUG、处理权限

This commit is contained in:
super
2026-04-22 01:09:28 +08:00
parent d691e53886
commit 236b73755c
77 changed files with 5157 additions and 522 deletions

4
.gitignore vendored
View File

@@ -22,6 +22,9 @@ backend-java/target/
backend-java/data/
backend-java/*.log
backend-java/logs/
logs/
aiimage-backend.log*
backend/tmp/
# backend-java local config
backend-java/src/main/resources/application-local.yml
@@ -39,3 +42,4 @@ backend/__pycache__
app/assets/
app/new_web_source
app/user_data/
OPS_REDIS_MYSQL_OPTIMIZATION_NOTES.md

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -16,8 +16,6 @@ from blueprints.admin import admin_bp
from blueprints.image import image_bp
from blueprints.brand import brand_bp
from blueprints.communication import communication_bp
def create_app():
app = Flask(__name__, template_folder=BASE_DIR, static_folder=BASE_DIR)
frontend_origin = os.environ.get('FRONTEND_ORIGIN', '*')

View File

@@ -21,9 +21,6 @@ import requests
import subprocess
from amazon.del_brand import kill_process
with open("version.txt","w",encoding="utf-8") as file:
file.write(json.dumps({"version":version}))

View File

@@ -0,0 +1,126 @@
# 巡店删除 Python 提交接口
本文档给 Python 端同学说明 `patrol-delete` 的结果回传方式。前端不会再提供“手动归档”按钮任务收尾、XLSX 组装、OSS 上传与历史清理均由服务端自动完成。
## 1. 提交入口
- 方法:`POST`
- 路径:`/api/patrol-delete/tasks/{taskId}/result`
- Content-Type`application/json`
示例:
```http
POST /api/patrol-delete/tasks/123/result
Content-Type: application/json
```
```json
{
"shops": [
{
"shopName": "示例店铺",
"submissionId": "patrol-delete:123:示例店铺:1711111111111",
"chunkIndex": 1,
"chunkTotal": 3,
"shopDone": false,
"countrySections": [
{
"country": "德国",
"rows": [
{
"status": "正常",
"quantity": "12",
"deleteQuantity": "3",
"processStatus": "处理中"
}
]
}
],
"cartRatios": [
{
"country": "德国",
"ratio": "25%"
}
]
}
]
}
```
## 2. 字段说明
- `shops`: 本次提交的店铺结果分片列表,至少 1 条。
- `shopName`: 店铺名。服务端会按店铺名归并分片。
- `submissionId`: 建议每个店铺任务周期内保持同一个 submissionId方便排查日志。
- `chunkIndex`: 当前分片序号,建议从 1 开始。
- `chunkTotal`: 当前店铺总分片数。
- `shopDone`: 当前店铺是否已全部提交完成。最后一片必须传 `true`
- `error`: 当前店铺执行失败时传错误信息。传了 `error` 后该店铺会直接记为失败并结束。
- `countrySections`: 店铺各国家的状态数据。
- `cartRatios`: 店铺各国家的购物车比例数据。
## 3. 分片合并规则
- 服务端按 `taskId + shopName` 聚合缓存。
- 同一国家的 `countrySections[].rows` 会按提交顺序追加合并。
- `cartRatios` 按国家覆盖,后到数据覆盖先到数据。
-`shopDone=true` 时,该店铺会被标记为完成。
- 如果中途提交了 `error`,该店铺会被标记为失败,不再等待后续分片。
## 4. Excel 组装规则
最终结果文件使用 `xlsx/日常删除格式.xlsx` 对应的数据结构:
- 第 1 列:`店铺名`
- 接着是 5 个国家块,每个国家 4 列:
- 国家状态
- 数量
- 删除数量
- 删除结果
- 最后是 5 组购物车比例列:
- 国家
- 购物车比例
每个店铺输出规则:
- 一个店铺占用的行数 = 5 个国家中数据行数的最大值
- 每个店铺处理完成后会额外空一行
- 然后再写下一个店铺
任务整体完成后:
1. 服务端汇总所有已完成店铺的数据
2. 组装成 XLSX
3. 上传 OSS
4. 前端历史任务中出现下载按钮,用户可直接下载
## 5. 自动收尾与超时补偿
前端不会手动触发归档,服务端自动处理:
- Python 正常把所有店铺都提交完成后,服务端立即尝试收尾。
- 如果最后一次提交后未来得及触发收尾,定时补偿任务会再次尝试 finalize。
- 如果某店铺已有部分有效数据,但 Python 异常退出,补偿任务会尽量把已有数据收尾成成功店铺。
- 如果某店铺完全没有完整结果且已超时,服务端会将该店铺标记为失败。
当前超时配置来自后端配置项:
- `aiimage.delete-brand-progress.patrol-delete-stale-timeout-minutes`
- `aiimage.delete-brand-progress.patrol-delete-initial-timeout-minutes`
## 6. 历史清理
服务端会定时清理历史数据,但会跳过仍在执行中的任务。
- `RUNNING` 任务不会被历史清理删除
- 只有已完成或已失败的历史记录会进入清理范围
- 临时目录缓存与结果目录也会定期清理
## 7. Python 端建议
- 一个店铺处理结束的最后一片一定要传 `shopDone=true`
- 如果店铺失败,请直接回传 `error`
- 大店铺建议按国家或按分页做分片,避免一次提交过大
- 如果同一店铺会多次回传,`shopName` 必须保持一致
- 如果最后一片提交成功Python 端无需再调用额外“归档”接口

View File

@@ -32,4 +32,10 @@ public class DeleteBrandProgressProperties {
*/
private long shopMatchStaleTimeoutMinutes = 20;
private long shopMatchInitialTimeoutMinutes = 20;
/**
* Patrol delete RUNNING timeout auto-finalize/fail threshold in minutes.
*/
private long patrolDeleteStaleTimeoutMinutes = 20;
private long patrolDeleteInitialTimeoutMinutes = 20;
}

View File

@@ -11,5 +11,5 @@ import java.util.List;
public class ModuleCleanupProperties {
private boolean enabled = true;
private String cron = "0 0 0 * * *";
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK"));
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK", "SHOP_MATCH", "PATROL_DELETE"));
}

View File

@@ -4,6 +4,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties({OssProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class})
@EnableConfigurationProperties({OssProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class})
public class PropertiesConfig {
}

View File

@@ -8,7 +8,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
public class StorageProperties {
private String localTempDir;
private boolean cleanupEnabled = true;
private String cleanupCron = "0 0 */6 * * *";
private long sourceRetentionHours = 48;
private String cleanupCron = "0 0 * * * *";
private long sourceRetentionHours = 24;
private long resultRetentionHours = 24;
}

View File

@@ -0,0 +1,11 @@
package com.nanri.aiimage.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ConfigurationProperties(prefix = "aiimage.task-pressure")
public class TaskPressureProperties {
private long localTaskEntityCacheMillis = 3000;
private int dbSelectBatchSize = 200;
}

View File

@@ -106,6 +106,8 @@ public class DeleteBrandRunController {
@PathVariable Long taskId,
@Valid @RequestBody DeleteBrandSubmitResultRequest request,
jakarta.servlet.http.HttpServletResponse response) {
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("application/json;charset=UTF-8");
try {
deleteBrandRunService.submitResult(taskId, request);
return ApiResponse.success(null);

View File

@@ -8,6 +8,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.DeleteBrandProgressProperties;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.deletebrand.model.cache.DeleteBrandParsedFileCacheDto;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandCountryResultItemDto;
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandProcessedCountryDto;
@@ -77,6 +78,7 @@ public class DeleteBrandRunService {
private final OssStorageService ossStorageService;
private final ObjectMapper objectMapper;
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
private final TaskPressureProperties taskPressureProperties;
public DeleteBrandRunVo run(DeleteBrandRunRequest request) {
if (request.getUserId() == null || request.getUserId() <= 0) {
@@ -248,7 +250,7 @@ public class DeleteBrandRunService {
.distinct()
.toList();
if (!taskIds.isEmpty()) {
List<FileTaskEntity> tasks = fileTaskMapper.selectBatchIds(taskIds);
List<FileTaskEntity> tasks = selectTasksByIdsInBatches(taskIds);
for (FileTaskEntity task : tasks) {
try {
statusByTaskId.put(task.getId(), task.getStatus());
@@ -320,7 +322,7 @@ public class DeleteBrandRunService {
}
public DeleteBrandTaskDeletionStatusVo getTaskDeletionStatus(Long taskId, Long userId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
}
@@ -487,7 +489,7 @@ public class DeleteBrandRunService {
}
public DeleteBrandTaskDetailVo getTaskDetail(Long taskId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
}
@@ -519,7 +521,7 @@ public class DeleteBrandRunService {
java.util.Map<Long, FileTaskEntity> taskById = new java.util.LinkedHashMap<>(cachedTasks);
if (!missingTaskIds.isEmpty()) {
java.util.List<FileTaskEntity> dbTasks = fileTaskMapper.selectBatchIds(missingTaskIds).stream()
java.util.List<FileTaskEntity> dbTasks = selectTasksByIdsInBatches(missingTaskIds).stream()
.filter(task -> task != null && MODULE_TYPE.equals(task.getModuleType()))
.toList();
for (FileTaskEntity dbTask : dbTasks) {
@@ -569,7 +571,7 @@ public class DeleteBrandRunService {
java.util.Map<Long, FileTaskEntity> taskById = new java.util.LinkedHashMap<>(cachedTasks);
if (!missingTaskIds.isEmpty()) {
java.util.List<FileTaskEntity> dbTasks = fileTaskMapper.selectBatchIds(missingTaskIds).stream()
java.util.List<FileTaskEntity> dbTasks = selectTasksByIdsInBatches(missingTaskIds).stream()
.filter(task -> task != null && MODULE_TYPE.equals(task.getModuleType()))
.toList();
for (FileTaskEntity dbTask : dbTasks) {
@@ -949,6 +951,32 @@ public class DeleteBrandRunService {
}
}
private FileTaskEntity loadTaskForExecution(Long taskId) {
Map<Long, FileTaskEntity> cachedTasks = deleteBrandTaskCacheService.getTaskCacheBatch(List.of(taskId));
FileTaskEntity cached = cachedTasks.get(taskId);
if (cached != null) {
return cached;
}
FileTaskEntity dbTask = fileTaskMapper.selectById(taskId);
if (dbTask != null && MODULE_TYPE.equals(dbTask.getModuleType())) {
deleteBrandTaskCacheService.saveTaskCache(dbTask);
}
return dbTask;
}
private List<FileTaskEntity> selectTasksByIdsInBatches(List<Long> taskIds) {
List<FileTaskEntity> tasks = new ArrayList<>();
if (taskIds == null || taskIds.isEmpty()) {
return tasks;
}
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
for (int start = 0; start < taskIds.size(); start += batchSize) {
int end = Math.min(start + batchSize, taskIds.size());
tasks.addAll(fileTaskMapper.selectBatchIds(taskIds.subList(start, end)));
}
return tasks;
}
@Transactional
public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) {
log.info("[DeleteBrand] Received submitResult for taskId: {}, files count: {}", taskId, request == null || request.getFiles() == null ? 0 : request.getFiles().size());
@@ -960,7 +988,7 @@ public class DeleteBrandRunService {
throw new BusinessException("files 不能为空");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
}
@@ -981,8 +1009,10 @@ public class DeleteBrandRunService {
task.setUpdatedAt(LocalDateTime.now());
task.setFinishedAt(null);
fileTaskMapper.updateById(task);
deleteBrandTaskCacheService.saveTaskCache(task);
}
boolean shouldTryFinalize = false;
for (DeleteBrandResultFileDto fileDto : request.getFiles()) {
log.info("[DeleteBrand] submitResult processing chunk -> taskId: {}, file: {}, chunkIndex: {}/{}, processedRows: {}/{}",
@@ -1000,15 +1030,11 @@ public class DeleteBrandRunService {
}
// 更早的 duplicate fast-path尽量在较重的校验/进度写入之前直接短路
if (deleteBrandTaskCacheService.hasSameResultChunk(taskId, fileIdentity, fileDto.getChunkIndex(), fileDto)) {
log.info("[DeleteBrand] Duplicate chunk ignored for taskId: {}, file: {}, chunkIndex: {}", taskId, fileIdentity, fileDto.getChunkIndex());
continue;
}
validateChunk(fileDto, parsedFile);
validateResultFileAgainstParsed(fileDto, parsedFile);
boolean stored = deleteBrandTaskCacheService.mergeResultChunk(taskId, fileIdentity, fileDto.getChunkIndex(), fileDto);
boolean stored = deleteBrandTaskCacheService.storeResultChunkIfChanged(taskId, fileIdentity, fileDto.getChunkIndex(), fileDto);
if (!stored) {
log.info("[DeleteBrand] Duplicate chunk ignored for taskId: {}, file: {}, chunkIndex: {}", taskId, fileIdentity, fileDto.getChunkIndex());
continue;
}
@@ -1023,20 +1049,21 @@ public class DeleteBrandRunService {
progress.put("current_asin", blankToEmpty(fileDto.getCurrentAsin()));
progress.put("updated_at", String.valueOf(System.currentTimeMillis()));
progress.put("last_heartbeat_at", String.valueOf(System.currentTimeMillis()));
deleteBrandTaskCacheService.saveProgress(taskId, progress);
deleteBrandTaskCacheService.saveProgress(taskId, progress, true);
// 如果该文件已完成,立即更新成品计数的 Redis 缓存「提示」,让后续 tryFinalizeTask 能更精确
if (isFileCompleted(fileDto)) {
log.info("[DeleteBrand] File chunk transmission fully reached via chunkTotal for taskId: {}, file: {}", taskId, fileDto.getSourceFilename());
java.util.Map<Object, Object> currentProgress = deleteBrandTaskCacheService.getProgress(taskId);
int finishedCount = parseInt(currentProgress.get("finished_files")) == null ? 0 : parseInt(currentProgress.get("finished_files"));
shouldTryFinalize = true;
// 这里我们暂不直接 increment而是标记需要重算或在 tryFinalizeTask 中重新计算
// 为了保险,我们直接在 tryFinalizeTask 里重新遍历分片状态
}
}
tryFinalizeTask(taskId, false);
if (shouldTryFinalize) {
tryFinalizeTask(taskId, false);
}
}
public void tryFinalizeTask(Long taskId, boolean fromCompensation) {
@@ -1044,7 +1071,7 @@ public class DeleteBrandRunService {
return;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return;
}
@@ -1082,6 +1109,7 @@ public class DeleteBrandRunService {
task.setUpdatedAt(LocalDateTime.now());
}
fileTaskMapper.updateById(task);
deleteBrandTaskCacheService.saveTaskCache(task);
log.info("[DeleteBrand] tryFinalizeTask skipping -> finishedFiles ({}) < expectedFiles ({})", finishedFiles, expectedFiles);
return;
@@ -1093,11 +1121,12 @@ public class DeleteBrandRunService {
if (fromCompensation) {
progress.put("last_heartbeat_at", String.valueOf(System.currentTimeMillis()));
}
deleteBrandTaskCacheService.saveProgress(taskId, progress);
deleteBrandTaskCacheService.saveProgress(taskId, progress, true);
task.setSuccessFileCount(finishedFiles);
task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.updateById(task);
deleteBrandTaskCacheService.saveTaskCache(task);
log.info("[DeleteBrand] tryFinalizeTask calling finalizeTask -> finishedFiles: {}, expectedFiles: {}", finishedFiles, expectedFiles);

View File

@@ -6,6 +6,8 @@ import com.nanri.aiimage.common.service.DistributedJobLockService;
import com.nanri.aiimage.config.DeleteBrandProgressProperties;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskCacheService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskCacheService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskCacheService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskCacheService;
@@ -14,6 +16,7 @@ import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@@ -37,10 +40,10 @@ public class DeleteBrandStaleTaskService {
private static final String MODULE_TYPE_PRODUCT_RISK = "PRODUCT_RISK_RESOLVE";
private static final String MODULE_TYPE_PRICE_TRACK = "PRICE_TRACK";
private static final String MODULE_TYPE_SHOP_MATCH = "SHOP_MATCH";
private static final String MODULE_TYPE_PATROL_DELETE = "PATROL_DELETE";
private static final Duration STALE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
private static final Duration FINALIZE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
private static final Duration TEMP_DIR_CLEANUP_LOCK_TTL = Duration.ofMinutes(10);
private static final Duration TEMP_DIR_RETENTION = Duration.ofHours(48);
private final FileTaskMapper fileTaskMapper;
private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
@@ -51,9 +54,14 @@ public class DeleteBrandStaleTaskService {
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
private final ShopMatchTaskService shopMatchTaskService;
private final ShopMatchTaskCacheService shopMatchTaskCacheService;
private final PatrolDeleteTaskService patrolDeleteTaskService;
private final PatrolDeleteTaskCacheService patrolDeleteTaskCacheService;
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
private final DistributedJobLockService distributedJobLockService;
@Value("${aiimage.temp-dir.retention-hours:24}")
private long tempDirRetentionHours;
@Scheduled(cron = "${aiimage.delete-brand-progress.stale-check-cron:0 */2 * * * *}")
public void failStaleRunningTasks() {
DistributedJobLockService.LockHandle lockHandle =
@@ -69,6 +77,7 @@ public class DeleteBrandStaleTaskService {
ProductRiskStaleCheckStats stats = failStaleProductRiskResolveTasks();
PriceTrackStaleCheckStats priceTrackStats = failStalePriceTrackTasks();
ShopMatchStaleCheckStats shopMatchStats = failStaleShopMatchTasks();
ShopMatchStaleCheckStats patrolDeleteStats = failStalePatrolDeleteTasks();
log.info("[stale-check] product-risk summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
stats.scannedTaskCount,
stats.finalizedTaskCount,
@@ -90,6 +99,13 @@ public class DeleteBrandStaleTaskService {
shopMatchStats.skippedTaskCount,
System.currentTimeMillis() - startedAt,
Thread.currentThread().getName());
log.info("[stale-check] patrol-delete summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
patrolDeleteStats.scannedTaskCount,
patrolDeleteStats.finalizedTaskCount,
patrolDeleteStats.failedTaskCount,
patrolDeleteStats.skippedTaskCount,
System.currentTimeMillis() - startedAt,
Thread.currentThread().getName());
}
}
@@ -335,6 +351,65 @@ public class DeleteBrandStaleTaskService {
return stats;
}
private ShopMatchStaleCheckStats failStalePatrolDeleteTasks() {
ShopMatchStaleCheckStats stats = new ShopMatchStaleCheckStats();
long minutes = Math.max(1L, deleteBrandProgressProperties.getPatrolDeleteStaleTimeoutMinutes());
long initialMinutes = Math.max(minutes, deleteBrandProgressProperties.getPatrolDeleteInitialTimeoutMinutes());
long staleTimeoutMillis = Duration.ofMinutes(minutes).toMillis();
long nowMillis = System.currentTimeMillis();
LocalDateTime now = LocalDateTime.now();
LocalDateTime threshold = now.minusMinutes(minutes);
LocalDateTime initialThreshold = now.minusMinutes(initialMinutes);
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_PATROL_DELETE)
.eq(FileTaskEntity::getStatus, "RUNNING")
.lt(FileTaskEntity::getUpdatedAt, threshold)
.last("limit 200"));
stats.scannedTaskCount = runningTasks.size();
if (runningTasks.isEmpty()) {
return stats;
}
for (FileTaskEntity task : runningTasks) {
long lastHeartbeatMillis = patrolDeleteTaskCacheService.getTaskHeartbeatMillis(task.getId());
boolean hasUploadedPayload = patrolDeleteTaskCacheService.hasAnyShopMergedPayload(task.getId());
boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0)
|| (task.getFailedFileCount() != null && task.getFailedFileCount() > 0);
boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasUploadedPayload || hasFinishedRows;
if (lastHeartbeatMillis > 0L && nowMillis - lastHeartbeatMillis < staleTimeoutMillis) {
stats.skippedTaskCount++;
continue;
}
if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) {
stats.skippedTaskCount++;
continue;
}
try {
if (patrolDeleteTaskService.tryFinalizeTask(task.getId(), true)) {
stats.finalizedTaskCount++;
log.info("[stale-check] patrol-delete finalized taskId={}", task.getId());
continue;
}
} catch (Exception ex) {
log.warn("[stale-check] patrol-delete finalize threw taskId={} msg={}", task.getId(), ex.getMessage());
}
int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getId, task.getId())
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_PATROL_DELETE)
.eq(FileTaskEntity::getStatus, "RUNNING")
.set(FileTaskEntity::getStatus, "FAILED")
.set(FileTaskEntity::getErrorMessage, "长时间未收到 Python 结果回传,任务已自动失败")
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())
.set(FileTaskEntity::getFinishedAt, LocalDateTime.now()));
if (updated > 0) {
stats.failedTaskCount++;
patrolDeleteTaskCacheService.deleteTaskCache(task.getId());
} else {
stats.skippedTaskCount++;
}
}
return stats;
}
@Scheduled(cron = "${aiimage.delete-brand-progress.finalize-check-cron:30 */2 * * * *}")
public void finalizeCompletedRunningTasks() {
DistributedJobLockService.LockHandle lockHandle =
@@ -369,12 +444,14 @@ public class DeleteBrandStaleTaskService {
return;
}
try (lockHandle) {
Instant cutoff = Instant.now().minus(TEMP_DIR_RETENTION);
Instant cutoff = Instant.now().minus(Duration.ofHours(Math.max(1L, tempDirRetentionHours)));
List<String> dirNames = List.of(
"shop-match-cache",
"patrol-delete-cache",
"brand-task-cache",
"delete-brand-task-cache",
"shop-match-result",
"patrol-delete-result",
"delete-brand-result",
"product-risk-result",
"price-track-result"

View File

@@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.deletebrand.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.TaskPressureProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@@ -24,10 +25,16 @@ public class DeleteBrandTaskCacheService {
private static final long PAYLOAD_TTL_HOURS = 24;
private static final long LOCAL_PARSED_PAYLOAD_CACHE_MILLIS = 3000;
private static final long LOCAL_PROGRESS_CACHE_MILLIS = 1500;
private static final long MIN_PROGRESS_REDIS_FLUSH_MILLIS = 1200;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private final ConcurrentHashMap<Long, LocalParsedPayloadCacheEntry> parsedPayloadLocalCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, LocalProgressCacheEntry> progressLocalCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, Long> progressRedisFlushAt = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
public void saveParsedPayload(Long taskId, Object payload) {
try {
@@ -75,13 +82,44 @@ public class DeleteBrandTaskCacheService {
}
public void saveProgress(Long taskId, java.util.Map<String, String> values) {
saveProgress(taskId, values, false);
}
public void saveProgress(Long taskId, java.util.Map<String, String> values, boolean forceWrite) {
if (taskId == null || taskId <= 0 || values == null || values.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
java.util.Map<String, String> merged = new java.util.LinkedHashMap<>();
LocalProgressCacheEntry cached = progressLocalCache.get(taskId);
if (cached != null && cached.isFresh(now)) {
merged.putAll(cached.values());
}
merged.putAll(values);
progressLocalCache.put(taskId, new LocalProgressCacheEntry(now, merged));
Long lastFlushAt = progressRedisFlushAt.get(taskId);
if (!forceWrite && lastFlushAt != null && now - lastFlushAt < MIN_PROGRESS_REDIS_FLUSH_MILLIS) {
return;
}
String key = buildProgressKey(taskId);
stringRedisTemplate.opsForHash().putAll(key, values);
stringRedisTemplate.opsForHash().putAll(key, merged);
stringRedisTemplate.expire(key, Duration.ofHours(PAYLOAD_TTL_HOURS));
progressRedisFlushAt.put(taskId, now);
}
public java.util.Map<Object, Object> getProgress(Long taskId) {
return stringRedisTemplate.opsForHash().entries(buildProgressKey(taskId));
long now = System.currentTimeMillis();
LocalProgressCacheEntry cached = progressLocalCache.get(taskId);
if (cached != null && cached.isFresh(now)) {
return new java.util.LinkedHashMap<>(cached.values());
}
java.util.Map<Object, Object> values = stringRedisTemplate.opsForHash().entries(buildProgressKey(taskId));
if (!values.isEmpty()) {
progressLocalCache.put(taskId, new LocalProgressCacheEntry(now, toStringMap(values)));
}
return values;
}
public java.util.Map<Long, java.util.Map<Object, Object>> getProgressBatch(java.util.List<Long> taskIds) {
@@ -98,16 +136,30 @@ public class DeleteBrandTaskCacheService {
return result;
}
long now = System.currentTimeMillis();
java.util.List<Long> missingTaskIds = new java.util.ArrayList<>();
for (Long taskId : normalizedTaskIds) {
LocalProgressCacheEntry cached = progressLocalCache.get(taskId);
if (cached != null && cached.isFresh(now)) {
result.put(taskId, new java.util.LinkedHashMap<>(cached.values()));
} else {
missingTaskIds.add(taskId);
}
}
if (missingTaskIds.isEmpty()) {
return result;
}
java.util.List<Object> pipelineResults = stringRedisTemplate.executePipelined((org.springframework.data.redis.core.RedisCallback<Object>) connection -> {
org.springframework.data.redis.serializer.RedisSerializer<String> serializer = stringRedisTemplate.getStringSerializer();
for (Long taskId : normalizedTaskIds) {
for (Long taskId : missingTaskIds) {
connection.hGetAll(serializer.serialize(buildProgressKey(taskId)));
}
return null;
});
for (int i = 0; i < normalizedTaskIds.size(); i++) {
Long taskId = normalizedTaskIds.get(i);
for (int i = 0; i < missingTaskIds.size(); i++) {
Long taskId = missingTaskIds.get(i);
Object raw = pipelineResults != null && i < pipelineResults.size() ? pipelineResults.get(i) : null;
if (raw instanceof java.util.Map<?, ?> rawMap) {
java.util.Map<Object, Object> converted = new java.util.LinkedHashMap<>();
@@ -115,6 +167,7 @@ public class DeleteBrandTaskCacheService {
converted.put(entry.getKey(), entry.getValue());
}
result.put(taskId, converted);
progressLocalCache.put(taskId, new LocalProgressCacheEntry(now, toStringMap(converted)));
} else {
result.put(taskId, java.util.Collections.emptyMap());
}
@@ -169,6 +222,10 @@ public class DeleteBrandTaskCacheService {
return true;
}
public boolean storeResultChunkIfChanged(Long taskId, String fileIdentity, Integer chunkIndex, Object chunk) {
return mergeResultChunk(taskId, fileIdentity, chunkIndex, chunk);
}
public java.util.Map<String, java.util.List<String>> groupResultChunkJsonByFile(Long taskId) {
java.util.Map<Object, Object> stored = stringRedisTemplate.opsForHash().entries(buildResultChunksKey(taskId));
java.util.Map<String, java.util.List<String>> grouped = new java.util.LinkedHashMap<>();
@@ -188,6 +245,9 @@ public class DeleteBrandTaskCacheService {
public void delete(Long taskId) {
parsedPayloadLocalCache.remove(taskId);
progressLocalCache.remove(taskId);
progressRedisFlushAt.remove(taskId);
taskEntityLocalCache.remove(taskId);
stringRedisTemplate.delete(buildProgressKey(taskId));
stringRedisTemplate.delete(buildResultChunksKey(taskId));
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
@@ -198,6 +258,11 @@ public class DeleteBrandTaskCacheService {
if (task == null || task.getId() == null) {
return;
}
long now = System.currentTimeMillis();
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
now,
objectMapper.convertValue(task, com.nanri.aiimage.modules.task.model.entity.FileTaskEntity.class)
));
try {
stringRedisTemplate.opsForValue().set(buildTaskEntityKey(task.getId()), objectMapper.writeValueAsString(task), Duration.ofHours(PAYLOAD_TTL_HOURS));
} catch (Exception ignored) {
@@ -215,15 +280,32 @@ public class DeleteBrandTaskCacheService {
return result;
}
java.util.List<String> keys = normalized.stream().map(this::buildTaskEntityKey).toList();
long now = System.currentTimeMillis();
java.util.List<Long> missingIds = new java.util.ArrayList<>();
for (Long taskId : normalized) {
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
if (isLocalTaskEntityCacheFresh(cached, now)) {
result.put(taskId, objectMapper.convertValue(cached.task(), com.nanri.aiimage.modules.task.model.entity.FileTaskEntity.class));
} else {
missingIds.add(taskId);
}
}
if (missingIds.isEmpty()) {
return result;
}
java.util.List<String> keys = missingIds.stream().map(this::buildTaskEntityKey).toList();
java.util.List<String> values = stringRedisTemplate.opsForValue().multiGet(keys);
for (int i = 0; i < normalized.size(); i++) {
Long taskId = normalized.get(i);
for (int i = 0; i < missingIds.size(); i++) {
Long taskId = missingIds.get(i);
String val = values != null && i < values.size() ? values.get(i) : null;
if (val != null && !val.isBlank()) {
try {
result.put(taskId, objectMapper.readValue(val, com.nanri.aiimage.modules.task.model.entity.FileTaskEntity.class));
com.nanri.aiimage.modules.task.model.entity.FileTaskEntity task =
objectMapper.readValue(val, com.nanri.aiimage.modules.task.model.entity.FileTaskEntity.class);
result.put(taskId, task);
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
} catch (Exception ignored) {
}
}
@@ -265,6 +347,33 @@ public class DeleteBrandTaskCacheService {
}
}
private record LocalProgressCacheEntry(long cachedAtMillis, java.util.Map<String, String> values) {
private boolean isFresh(long now) {
return now - cachedAtMillis <= LOCAL_PROGRESS_CACHE_MILLIS;
}
}
private record LocalTaskEntityCacheEntry(
long cachedAtMillis,
com.nanri.aiimage.modules.task.model.entity.FileTaskEntity task
) {}
private boolean isLocalTaskEntityCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
return cached != null
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
}
private java.util.Map<String, String> toStringMap(java.util.Map<Object, Object> values) {
java.util.Map<String, String> converted = new java.util.LinkedHashMap<>();
for (java.util.Map.Entry<Object, Object> entry : values.entrySet()) {
if (entry.getKey() == null || entry.getValue() == null) {
continue;
}
converted.put(String.valueOf(entry.getKey()), String.valueOf(entry.getValue()));
}
return converted;
}
private String buildProgressKey(Long taskId) {
return "delete-brand:task:progress:" + taskId;
}

View File

@@ -0,0 +1,371 @@
package com.nanri.aiimage.modules.patroldelete.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCreateTaskRequest;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteSubmitResultRequest;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteTaskBatchRequest;
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteCreateTaskVo;
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteHistoryVo;
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteTaskBatchVo;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteResolveService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/patrol-delete")
@Tag(
name = "巡店删除",
description = "巡店删除模块:管理备选店铺、批量匹配紫鸟店铺、创建任务、接收 Python 分片结果、自动收尾生成 Excel 并提供下载。涉及查询和删除的接口需要携带 user_id。")
public class PatrolDeleteController {
private final PatrolDeleteResolveService patrolDeleteResolveService;
private final PatrolDeleteTaskService patrolDeleteTaskService;
@GetMapping("/candidates")
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在巡店删除模块中已保存的备选店铺。")
public ApiResponse<List<ProductRiskCandidateVo>> listCandidates(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(patrolDeleteResolveService.listCandidates(userId));
}
@PostMapping("/candidates")
@Operation(summary = "新增备选店铺", description = "向巡店删除备选区新增一条店铺记录,供后续匹配和创建任务使用。")
public ApiResponse<ProductRiskCandidateVo> addCandidate(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "新增备选店铺请求,需传入用户 ID 和店铺名。",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ProductRiskCandidateAddRequest.class),
examples = @ExampleObject(
name = "新增店铺示例",
value = """
{
"user_id": 1,
"shop_name": "郭亚芳"
}
""")))
@Valid @RequestBody ProductRiskCandidateAddRequest request) {
return ApiResponse.success(patrolDeleteResolveService.addCandidate(request));
}
@DeleteMapping("/candidates/{id}")
@Operation(summary = "删除备选店铺", description = "删除当前用户名下的一条备选店铺记录。")
public ApiResponse<Void> deleteCandidate(
@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.deleteCandidate(userId, id);
return ApiResponse.success(null);
}
@PostMapping("/match-shops")
@Operation(summary = "批量匹配店铺", description = "根据店铺名批量匹配紫鸟店铺索引,返回是否命中、店铺 ID、平台、公司和匹配状态。")
public ApiResponse<ProductRiskMatchShopsVo> matchShops(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "批量匹配请求,传入 user_id 和待匹配的店铺名称列表。",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ProductRiskMatchShopsRequest.class),
examples = @ExampleObject(
name = "匹配店铺示例",
value = """
{
"user_id": 1,
"shop_names": [
"郭亚芳",
"示例店铺A"
]
}
""")))
@Valid @RequestBody ProductRiskMatchShopsRequest request) {
return ApiResponse.success(patrolDeleteResolveService.matchShops(request));
}
@GetMapping("/dashboard")
@Operation(summary = "查询统计看板", description = "返回备选店铺数、已处理任务数、成功任务数和失败任务数。")
public ApiResponse<ProductRiskDashboardVo> dashboard(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(patrolDeleteTaskService.dashboard(userId));
}
@GetMapping("/history")
@Operation(summary = "查询任务记录", description = "返回当前用户在巡店删除模块中的当前任务和历史任务记录。")
public ApiResponse<PatrolDeleteHistoryVo> history(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
return ApiResponse.success(patrolDeleteTaskService.listHistory(userId));
}
@PostMapping("/tasks/progress/batch")
@Operation(summary = "批量查询巡店删除任务进度", description = "仅返回任务状态和店铺结果摘要,用于前端轮询降载。")
public ApiResponse<PatrolDeleteTaskBatchVo> taskProgressBatch(@Valid @RequestBody PatrolDeleteTaskBatchRequest request) {
return ApiResponse.success(patrolDeleteTaskService.getTaskProgressBatch(request.getTaskIds()));
}
@PostMapping("/tasks")
@Operation(summary = "创建巡店删除任务", description = "根据已匹配的店铺创建任务和占位结果记录,后续由 Python 端处理并回传结果。")
public ApiResponse<PatrolDeleteCreateTaskVo> createTask(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "创建任务请求。items 中每一项代表一个待处理店铺,需携带默认模板结构。",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = PatrolDeleteCreateTaskRequest.class),
examples = @ExampleObject(
name = "创建任务示例",
value = """
{
"user_id": 1,
"items": [
{
"shopName": "郭亚芳",
"matched": true,
"shopId": "27730548558377",
"platform": "亚马逊",
"companyName": "示例公司",
"matchStatus": "MATCHED",
"matchMessage": "索引已命中",
"countrySections": [
{
"country": "德国",
"rows": [
{
"status": "全部",
"quantity": "",
"deleteQuantity": "",
"processStatus": ""
}
]
}
],
"cartRatios": [
{
"country": "德国",
"ratio": ""
}
]
}
]
}
""")))
@Valid @RequestBody PatrolDeleteCreateTaskRequest request) {
return ApiResponse.success(patrolDeleteTaskService.createTask(request));
}
@PostMapping("/tasks/{taskId}/result")
@Operation(
summary = "提交巡店删除结果",
description = "供 Python 端回传处理结果。支持按店铺分片多次提交,服务端会合并分片、按店铺完成状态自动收尾,并在任务结束后生成 Excel。")
public ApiResponse<Void> submitResult(
@Parameter(description = "任务主键,必须是运行中的任务", example = "3089")
@PathVariable Long taskId,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "巡店删除结果回传请求。shops 表示本次提交的店铺结果分片列表。shopDone=true 表示该店铺已全部提交完成。",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = PatrolDeleteSubmitResultRequest.class),
examples = {
@ExampleObject(
name = "成功分片示例",
summary = "一个店铺分片回传,包含状态数据和购物车比例数据",
value = """
{
"shops": [
{
"shopName": "郭亚芳",
"submissionId": "patrol-delete:3089:郭亚芳:1711111111111",
"chunkIndex": 1,
"chunkTotal": 2,
"shopDone": false,
"countrySections": [
{
"country": "德国",
"rows": [
{
"status": "正常",
"quantity": "12",
"deleteQuantity": "3",
"processStatus": "处理中"
},
{
"status": "下架",
"quantity": "2",
"deleteQuantity": "2",
"processStatus": "已完成"
}
]
},
{
"country": "英国",
"rows": [
{
"status": "正常",
"quantity": "5",
"deleteQuantity": "1",
"processStatus": "处理中"
}
]
}
],
"cartRatios": [
{
"country": "德国",
"ratio": "25%"
},
{
"country": "英国",
"ratio": "18%"
}
]
}
]
}
"""),
@ExampleObject(
name = "店铺完成示例",
summary = "最后一片提交完成后,将 shopDone 置为 true",
value = """
{
"shops": [
{
"shopName": "郭亚芳",
"submissionId": "patrol-delete:3089:郭亚芳:1711111111111",
"chunkIndex": 2,
"chunkTotal": 2,
"shopDone": true,
"countrySections": [
{
"country": "法国",
"rows": [
{
"status": "正常",
"quantity": "4",
"deleteQuantity": "0",
"processStatus": "已完成"
}
]
}
],
"cartRatios": [
{
"country": "法国",
"ratio": "12%"
}
]
}
]
}
"""),
@ExampleObject(
name = "失败示例",
summary = "店铺处理失败时直接回传错误信息",
value = """
{
"shops": [
{
"shopName": "郭亚芳",
"shopDone": true,
"error": "紫鸟页面加载超时,未能完成删除数据采集"
}
]
}
""")
}))
@Valid @RequestBody PatrolDeleteSubmitResultRequest request,
jakarta.servlet.http.HttpServletResponse response) {
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("application/json;charset=UTF-8");
patrolDeleteTaskService.submitResult(taskId, request);
return ApiResponse.success(null);
}
@GetMapping("/results/{resultId}/download")
@Operation(summary = "下载结果文件", description = "按结果记录下载服务端已生成并上传到 OSS 的巡店删除 Excel 文件。")
public void downloadResult(
@Parameter(description = "结果记录主键", example = "4599")
@PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId,
jakarta.servlet.http.HttpServletResponse response) {
String url = patrolDeleteTaskService.resolveResultDownloadUrl(resultId, userId);
String filename = patrolDeleteTaskService.resolveResultDownloadFilename(resultId, userId);
if (url == null || url.isBlank()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果");
}
try {
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
response.setContentType("application/octet-stream");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename);
try (InputStream in = URI.create(url).toURL().openStream()) {
byte[] buffer = new byte[65536];
int read;
while ((read = in.read(buffer)) != -1) {
response.getOutputStream().write(buffer, 0, read);
}
response.getOutputStream().flush();
}
} catch (Exception ex) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
}
}
@DeleteMapping("/tasks/{taskId}")
@Operation(summary = "删除任务", description = "删除整条巡店删除任务以及该任务下关联的所有结果记录。")
public ApiResponse<Void> deleteTask(
@Parameter(description = "任务主键", example = "3089")
@PathVariable Long taskId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
patrolDeleteTaskService.deleteTask(taskId, userId);
return ApiResponse.success(null);
}
@DeleteMapping("/history/{resultId}")
@Operation(summary = "删除单条历史记录", description = "删除一条巡店删除结果记录,并同步重算其所属任务状态。")
public ApiResponse<Void> deleteHistory(
@Parameter(description = "结果记录主键", example = "4599")
@PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId) {
patrolDeleteTaskService.deleteHistory(resultId, userId);
return ApiResponse.success(null);
}
}

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.PatrolDeleteShopCandidateEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PatrolDeleteShopCandidateMapper extends BaseMapper<PatrolDeleteShopCandidateEntity> {
}

View File

@@ -0,0 +1,15 @@
package com.nanri.aiimage.modules.patroldelete.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "购物车比例数据")
public class PatrolDeleteCartRatioDto {
@Schema(description = "国家名称", example = "德国")
private String country;
@Schema(description = "购物车比例", example = "25%")
private String ratio;
}

View File

@@ -0,0 +1,24 @@
package com.nanri.aiimage.modules.patroldelete.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "国家状态数据中的单行记录")
public class PatrolDeleteCountryMetricRowDto {
@Schema(description = "商品状态", example = "正常")
private String status;
@Schema(description = "该状态下的商品数量", example = "12")
private String quantity;
@JsonProperty("deleteQuantity")
@Schema(description = "已删除数量", example = "3")
private String deleteQuantity;
@JsonProperty("processStatus")
@Schema(description = "处理结果或处理状态", example = "处理中")
private String processStatus;
}

View File

@@ -0,0 +1,18 @@
package com.nanri.aiimage.modules.patroldelete.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "单个国家的状态数据分组")
public class PatrolDeleteCountrySectionDto {
@Schema(description = "国家名称", example = "德国")
private String country;
@Schema(description = "该国家下的多行状态数据")
private List<PatrolDeleteCountryMetricRowDto> rows = new ArrayList<>();
}

View File

@@ -0,0 +1,26 @@
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.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "创建巡店删除任务请求")
public class PatrolDeleteCreateTaskRequest {
@NotNull
@JsonProperty("user_id")
@Schema(description = "当前用户 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
private Long userId;
@Valid
@NotEmpty
@Schema(description = "待创建的店铺任务列表,通常由已匹配成功的店铺组成", requiredMode = Schema.RequiredMode.REQUIRED)
private List<PatrolDeleteTaskItemDto> items = new ArrayList<>();
}

View File

@@ -0,0 +1,44 @@
package com.nanri.aiimage.modules.patroldelete.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "单个店铺的巡店删除结果分片")
public class PatrolDeleteShopPayloadDto {
@JsonProperty("shopName")
@Schema(description = "店铺名称,服务端按店铺名称聚合分片", example = "郭亚芳")
private String shopName;
@Schema(description = "店铺处理失败时的错误信息;有值时该店铺会直接标记失败", example = "紫鸟页面加载超时,未能完成删除数据采集")
private String error;
@JsonProperty("countrySections")
@Schema(description = "各国家的状态数据列表。同一国家可分多片提交,服务端会按顺序合并")
private List<PatrolDeleteCountrySectionDto> countrySections = new ArrayList<>();
@JsonProperty("cartRatios")
@Schema(description = "购物车比例数据列表,通常只在店铺首行展示")
private List<PatrolDeleteCartRatioDto> cartRatios = new ArrayList<>();
@JsonProperty("shopDone")
@Schema(description = "该店铺是否已全部提交完成。最后一片应传 true", example = "true")
private Boolean shopDone;
@JsonProperty("submissionId")
@Schema(description = "本次店铺提交批次标识,便于问题排查与日志跟踪", example = "patrol-delete:3089:郭亚芳:1711111111111")
private String submissionId;
@JsonProperty("chunkIndex")
@Schema(description = "当前分片序号,建议从 1 开始", example = "1")
private Integer chunkIndex;
@JsonProperty("chunkTotal")
@Schema(description = "当前店铺总分片数", example = "2")
private Integer chunkTotal;
}

View File

@@ -0,0 +1,19 @@
package com.nanri.aiimage.modules.patroldelete.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "巡店删除结果回传请求")
public class PatrolDeleteSubmitResultRequest {
@Valid
@NotEmpty
@Schema(description = "本次提交的店铺结果分片列表,支持一次提交多个店铺", requiredMode = Schema.RequiredMode.REQUIRED)
private List<PatrolDeleteShopPayloadDto> shops = new ArrayList<>();
}

View File

@@ -0,0 +1,14 @@
package com.nanri.aiimage.modules.patroldelete.model.dto;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class PatrolDeleteTaskBatchRequest {
@NotEmpty(message = "taskIds 不能为空")
private List<Long> taskIds = new ArrayList<>();
}

View File

@@ -0,0 +1,50 @@
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.Valid;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "巡店删除任务中的单个店铺项")
public class PatrolDeleteTaskItemDto {
@JsonProperty("shopName")
@Schema(description = "店铺名称", example = "郭亚芳")
private String shopName;
@Schema(description = "该店铺是否已完成紫鸟匹配,创建任务时必须为 true", example = "true")
private boolean matched;
@JsonProperty("shopId")
@Schema(description = "匹配到的紫鸟店铺 ID", example = "27730548558377")
private String shopId;
@Schema(description = "店铺所属平台", example = "亚马逊")
private String platform;
@JsonProperty("companyName")
@Schema(description = "店铺所属公司名称", example = "示例公司")
private String companyName;
@JsonProperty("matchStatus")
@Schema(description = "匹配状态,如 MATCHED、PENDING、CONFLICT、INDEX_STALE", example = "MATCHED")
private String matchStatus;
@JsonProperty("matchMessage")
@Schema(description = "匹配状态说明", example = "索引已命中")
private String matchMessage;
@Valid
@JsonProperty("countrySections")
@Schema(description = "默认模板中的国家状态数据结构")
private List<PatrolDeleteCountrySectionDto> countrySections = new ArrayList<>();
@Valid
@JsonProperty("cartRatios")
@Schema(description = "默认模板中的购物车比例结构")
private List<PatrolDeleteCartRatioDto> cartRatios = new ArrayList<>();
}

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_shop_candidate")
public class PatrolDeleteShopCandidateEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String shopName;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,13 @@
package com.nanri.aiimage.modules.patroldelete.model.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class PatrolDeleteCreateTaskVo {
private Long taskId;
private List<PatrolDeleteResultItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,12 @@
package com.nanri.aiimage.modules.patroldelete.model.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class PatrolDeleteHistoryVo {
private List<PatrolDeleteResultItemVo> items = new ArrayList<>();
}

View File

@@ -0,0 +1,52 @@
package com.nanri.aiimage.modules.patroldelete.model.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCartRatioDto;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCountrySectionDto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Data
public class PatrolDeleteResultItemVo {
private Long resultId;
private Long taskId;
@JsonProperty("shopName")
private String shopName;
@JsonProperty("shopId")
private String shopId;
private String platform;
@JsonProperty("companyName")
private String companyName;
private boolean matched;
@JsonProperty("matchStatus")
private String matchStatus;
@JsonProperty("matchMessage")
private String matchMessage;
@JsonProperty("taskStatus")
private String taskStatus;
private Boolean success;
private String error;
private LocalDateTime createdAt;
private LocalDateTime finishedAt;
private String outputFilename;
private String downloadUrl;
@JsonProperty("countrySections")
private List<PatrolDeleteCountrySectionDto> countrySections = new ArrayList<>();
@JsonProperty("cartRatios")
private List<PatrolDeleteCartRatioDto> cartRatios = new ArrayList<>();
}

View File

@@ -0,0 +1,14 @@
package com.nanri.aiimage.modules.patroldelete.model.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class PatrolDeleteTaskBatchVo {
private List<PatrolDeleteResultItemVo> items = new ArrayList<>();
private List<Long> missingTaskIds = new ArrayList<>();
}

View File

@@ -0,0 +1,144 @@
package com.nanri.aiimage.modules.patroldelete.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCartRatioDto;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCountryMetricRowDto;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCountrySectionDto;
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteResultItemVo;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.FileOutputStream;
import java.util.List;
@Service
@Slf4j
public class PatrolDeleteExcelAssemblyService {
private static final String COUNTRY_DE = "德国";
private static final String COUNTRY_UK = "英国";
private static final String COUNTRY_FR = "法国";
private static final String COUNTRY_IT = "意大利";
private static final String COUNTRY_ES = "西班牙";
private static final String[] HEADER = {
"店铺名",
COUNTRY_DE, "数量", "删除数量", "删除结果",
COUNTRY_UK, "数量", "删除数量", "删除结果",
COUNTRY_FR, "数量", "删除数量", "删除结果",
COUNTRY_IT, "数量", "删除数量", "删除结果",
COUNTRY_ES, "数量", "删除数量", "删除结果",
"国家", "购物车比例",
"国家", "购物车比例",
"国家", "购物车比例",
"国家", "购物车比例",
"国家", "购物车比例"
};
public void writeWorkbook(File outputXlsx, List<PatrolDeleteResultItemVo> items) {
try (XSSFWorkbook workbook = new XSSFWorkbook();
FileOutputStream outputStream = new FileOutputStream(outputXlsx)) {
Sheet sheet = workbook.createSheet("巡店删除结果");
Row headerRow = sheet.createRow(0);
for (int columnIndex = 0; columnIndex < HEADER.length; columnIndex++) {
headerRow.createCell(columnIndex).setCellValue(HEADER[columnIndex]);
}
int rowIndex = 1;
for (PatrolDeleteResultItemVo item : items) {
if (item == null || Boolean.FALSE.equals(item.getSuccess())) {
continue;
}
int shopRowCount = maxCountryRowCount(item.getCountrySections());
for (int shopRowIndex = 0; shopRowIndex < shopRowCount; shopRowIndex++) {
Row row = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(shopRowIndex == 0 ? safe(item.getShopName()) : "");
writeCountryColumns(row, 1, findSection(item.getCountrySections(), COUNTRY_DE), shopRowIndex);
writeCountryColumns(row, 5, findSection(item.getCountrySections(), COUNTRY_UK), shopRowIndex);
writeCountryColumns(row, 9, findSection(item.getCountrySections(), COUNTRY_FR), shopRowIndex);
writeCountryColumns(row, 13, findSection(item.getCountrySections(), COUNTRY_IT), shopRowIndex);
writeCountryColumns(row, 17, findSection(item.getCountrySections(), COUNTRY_ES), shopRowIndex);
if (shopRowIndex == 0) {
writeCartRatioColumns(row, 21, item.getCartRatios(), 0);
writeCartRatioColumns(row, 23, item.getCartRatios(), 1);
writeCartRatioColumns(row, 25, item.getCartRatios(), 2);
writeCartRatioColumns(row, 27, item.getCartRatios(), 3);
writeCartRatioColumns(row, 29, item.getCartRatios(), 4);
}
}
rowIndex++;
}
for (int columnIndex = 0; columnIndex < HEADER.length; columnIndex++) {
sheet.autoSizeColumn(columnIndex);
}
workbook.write(outputStream);
} catch (Exception ex) {
log.warn("[patrol-delete] write workbook failed: {}", ex.getMessage());
throw new BusinessException("生成巡店删除结果 Excel 失败: " + ex.getMessage());
}
}
public int countRows(List<PatrolDeleteResultItemVo> items) {
int count = 0;
if (items == null) {
return 0;
}
for (PatrolDeleteResultItemVo item : items) {
if (item != null && !Boolean.FALSE.equals(item.getSuccess())) {
count += maxCountryRowCount(item.getCountrySections());
}
}
return count;
}
private int maxCountryRowCount(List<PatrolDeleteCountrySectionDto> sections) {
int max = 1;
if (sections == null) {
return max;
}
for (PatrolDeleteCountrySectionDto section : sections) {
if (section != null && section.getRows() != null && section.getRows().size() > max) {
max = section.getRows().size();
}
}
return max;
}
private PatrolDeleteCountrySectionDto findSection(List<PatrolDeleteCountrySectionDto> sections, String country) {
if (sections == null) {
return null;
}
for (PatrolDeleteCountrySectionDto section : sections) {
if (section != null && country.equals(section.getCountry())) {
return section;
}
}
return null;
}
private void writeCountryColumns(Row row, int startColumn, PatrolDeleteCountrySectionDto section, int rowIndex) {
PatrolDeleteCountryMetricRowDto metric = null;
if (section != null && section.getRows() != null && rowIndex < section.getRows().size()) {
metric = section.getRows().get(rowIndex);
}
row.createCell(startColumn).setCellValue(metric == null ? "" : safe(metric.getStatus()));
row.createCell(startColumn + 1).setCellValue(metric == null ? "" : safe(metric.getQuantity()));
row.createCell(startColumn + 2).setCellValue(metric == null ? "" : safe(metric.getDeleteQuantity()));
row.createCell(startColumn + 3).setCellValue(metric == null ? "" : safe(metric.getProcessStatus()));
}
private void writeCartRatioColumns(Row row, int startColumn, List<PatrolDeleteCartRatioDto> ratios, int ratioIndex) {
PatrolDeleteCartRatioDto ratio = ratios != null && ratioIndex < ratios.size() ? ratios.get(ratioIndex) : null;
row.createCell(startColumn).setCellValue(ratio == null ? "" : safe(ratio.getCountry()));
row.createCell(startColumn + 1).setCellValue(ratio == null ? "" : safe(ratio.getRatio()));
}
private String safe(String value) {
return value == null ? "" : value;
}
}

View File

@@ -0,0 +1,166 @@
package com.nanri.aiimage.modules.patroldelete.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.patroldelete.mapper.PatrolDeleteShopCandidateMapper;
import com.nanri.aiimage.modules.patroldelete.model.entity.PatrolDeleteShopCandidateEntity;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskShopQueueItemVo;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
@Service
@RequiredArgsConstructor
public class PatrolDeleteResolveService {
private final PatrolDeleteShopCandidateMapper candidateMapper;
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
public List<ProductRiskCandidateVo> listCandidates(Long userId) {
validateUserId(userId);
List<PatrolDeleteShopCandidateEntity> rows = candidateMapper.selectList(
new LambdaQueryWrapper<PatrolDeleteShopCandidateEntity>()
.eq(PatrolDeleteShopCandidateEntity::getUserId, userId)
.orderByDesc(PatrolDeleteShopCandidateEntity::getId));
List<ProductRiskCandidateVo> list = new ArrayList<>();
for (PatrolDeleteShopCandidateEntity row : rows) {
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
vo.setId(row.getId());
vo.setShopName(row.getShopName());
vo.setCreatedAt(row.getCreatedAt());
list.add(vo);
}
return list;
}
@Transactional
public ProductRiskCandidateVo addCandidate(ProductRiskCandidateAddRequest request) {
validateUserId(request.getUserId());
String normalized = ziniaoShopSwitchService.normalizeShopName(request.getShopName());
if (normalized.isBlank()) {
throw new BusinessException("店铺名不能为空");
}
ZiniaoShopMatchResultVo indexHit = ziniaoShopSwitchService.findIndexedStoreByName(normalized, false);
if (indexHit == null || !indexHit.isMatched()) {
String hint = indexHit != null && indexHit.getMatchMessage() != null && !indexHit.getMatchMessage().isBlank()
? indexHit.getMatchMessage()
: "店铺索引未命中,无法加入备选";
throw new BusinessException(hint);
}
if (ZiniaoShopIndexService.MATCH_STATUS_CONFLICT.equals(indexHit.getMatchStatus())) {
throw new BusinessException(indexHit.getMatchMessage() != null ? indexHit.getMatchMessage() : "存在多个同名店铺,请人工确认");
}
PatrolDeleteShopCandidateEntity existing = candidateMapper.selectOne(
new LambdaQueryWrapper<PatrolDeleteShopCandidateEntity>()
.eq(PatrolDeleteShopCandidateEntity::getUserId, request.getUserId())
.eq(PatrolDeleteShopCandidateEntity::getShopName, normalized)
.last("limit 1"));
if (existing != null) {
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
vo.setId(existing.getId());
vo.setShopName(existing.getShopName());
vo.setCreatedAt(existing.getCreatedAt());
return vo;
}
PatrolDeleteShopCandidateEntity entity = new PatrolDeleteShopCandidateEntity();
entity.setUserId(request.getUserId());
entity.setShopName(normalized);
entity.setCreatedAt(LocalDateTime.now());
candidateMapper.insert(entity);
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
vo.setId(entity.getId());
vo.setShopName(entity.getShopName());
vo.setCreatedAt(entity.getCreatedAt());
return vo;
}
@Transactional
public void deleteCandidate(Long userId, Long id) {
validateUserId(userId);
if (id == null || id <= 0) {
throw new BusinessException("id 不合法");
}
PatrolDeleteShopCandidateEntity row = candidateMapper.selectById(id);
if (row == null || !userId.equals(row.getUserId())) {
throw new BusinessException("记录不存在");
}
candidateMapper.deleteById(id);
}
public ProductRiskMatchShopsVo matchShops(ProductRiskMatchShopsRequest request) {
validateUserId(request.getUserId());
LinkedHashSet<String> ordered = new LinkedHashSet<>();
for (String raw : request.getShopNames()) {
String normalized = ziniaoShopSwitchService.normalizeShopName(raw);
if (!normalized.isBlank()) {
ordered.add(normalized);
}
}
if (ordered.isEmpty()) {
throw new BusinessException("shop_names 无有效店铺名");
}
ProductRiskMatchShopsVo vo = new ProductRiskMatchShopsVo();
for (String shopName : ordered) {
vo.getItems().add(matchOneShop(shopName));
}
return vo;
}
public long countCandidates(Long userId) {
validateUserId(userId);
Long count = candidateMapper.selectCount(new LambdaQueryWrapper<PatrolDeleteShopCandidateEntity>()
.eq(PatrolDeleteShopCandidateEntity::getUserId, userId));
return count == null ? 0L : count;
}
private ProductRiskShopQueueItemVo matchOneShop(String shopName) {
ProductRiskShopQueueItemVo item = new ProductRiskShopQueueItemVo();
item.setShopName(shopName);
try {
ZiniaoShopMatchResultVo matched = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false);
if (matched == null) {
item.setMatched(false);
item.setMatchStatus("PENDING");
item.setMatchMessage("匹配结果为空");
return item;
}
item.setMatched(matched.isMatched());
item.setShopId(matched.getShopId());
item.setPlatform(matched.getPlatform());
item.setCompanyName(matched.getCompanyName());
item.setMatchedUserId(matched.getMatchedUserId());
item.setMatchStatus(matched.getMatchStatus());
item.setMatchMessage(matched.getMatchMessage());
item.setOpenStoreUrl(matched.getOpenStoreUrl());
} catch (BusinessException ex) {
item.setMatched(false);
item.setMatchStatus("PENDING");
item.setMatchMessage(ex.getMessage());
} catch (Exception ex) {
item.setMatched(false);
item.setMatchStatus("PENDING");
item.setMatchMessage(Objects.toString(ex.getMessage(), "匹配异常"));
}
return item;
}
private void validateUserId(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
}
}

View File

@@ -0,0 +1,211 @@
package com.nanri.aiimage.modules.patroldelete.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteShopPayloadDto;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
public class PatrolDeleteTaskCacheService {
private static final long PAYLOAD_TTL_HOURS = 24;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
public PatrolDeleteShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
return null;
}
Object raw = stringRedisTemplate.opsForHash().get(buildShopPayloadKey(taskId), shopKey);
if (!(raw instanceof String json) || json.isBlank()) {
return null;
}
try {
return objectMapper.readValue(json, PatrolDeleteShopPayloadDto.class);
} catch (Exception ex) {
throw new BusinessException("读取巡店删除店铺缓存失败");
}
}
public void saveShopMergedPayload(Long taskId, String shopKey, PatrolDeleteShopPayloadDto payload) {
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank() || payload == null) {
return;
}
try {
stringRedisTemplate.opsForHash().put(buildShopPayloadKey(taskId), shopKey, objectMapper.writeValueAsString(payload));
stringRedisTemplate.expire(buildShopPayloadKey(taskId), Duration.ofHours(PAYLOAD_TTL_HOURS));
touchTaskHeartbeat(taskId);
} catch (Exception ex) {
throw new BusinessException("暂存巡店删除店铺缓存失败");
}
}
public void removeShopMergedPayload(Long taskId, String shopKey) {
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
return;
}
stringRedisTemplate.opsForHash().delete(buildShopPayloadKey(taskId), shopKey);
}
public Map<String, PatrolDeleteShopPayloadDto> getAllShopMergedPayload(Long taskId) {
try {
Map<Object, Object> raw = stringRedisTemplate.opsForHash().entries(buildShopPayloadKey(taskId));
if (raw == null || raw.isEmpty()) {
return Map.of();
}
Map<String, PatrolDeleteShopPayloadDto> out = new LinkedHashMap<>();
for (Map.Entry<Object, Object> entry : raw.entrySet()) {
if (!(entry.getKey() instanceof String key) || !(entry.getValue() instanceof String val) || val.isBlank()) {
continue;
}
out.put(key, objectMapper.readValue(val, PatrolDeleteShopPayloadDto.class));
}
return out;
} catch (Exception ex) {
throw new BusinessException("读取巡店删除缓存失败");
}
}
public boolean hasAnyShopMergedPayload(Long taskId) {
if (taskId == null || taskId <= 0) {
return false;
}
Long size = stringRedisTemplate.opsForHash().size(buildShopPayloadKey(taskId));
return size != null && size > 0;
}
public long countShopMergedPayload(Long taskId) {
if (taskId == null || taskId <= 0) {
return 0L;
}
Long size = stringRedisTemplate.opsForHash().size(buildShopPayloadKey(taskId));
return size == null ? 0L : size;
}
public long getTaskHeartbeatMillis(Long taskId) {
if (taskId == null || taskId <= 0) {
return 0L;
}
String raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId));
if (raw == null || raw.isBlank()) {
return 0L;
}
try {
return Long.parseLong(raw);
} catch (NumberFormatException ignored) {
return 0L;
}
}
public void touchTaskHeartbeat(Long taskId) {
stringRedisTemplate.opsForValue().set(
buildTaskHeartbeatKey(taskId),
String.valueOf(Instant.now().toEpochMilli()),
Duration.ofHours(PAYLOAD_TTL_HOURS));
}
public void deleteTaskCache(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
taskEntityLocalCache.remove(taskId);
stringRedisTemplate.delete(buildShopPayloadKey(taskId));
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
}
public void saveTaskCache(FileTaskEntity task) {
if (task == null || task.getId() == null) {
return;
}
long now = System.currentTimeMillis();
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
now,
objectMapper.convertValue(task, FileTaskEntity.class)
));
try {
stringRedisTemplate.opsForValue().set(
buildTaskEntityKey(task.getId()),
objectMapper.writeValueAsString(task),
Duration.ofHours(PAYLOAD_TTL_HOURS)
);
} catch (Exception ignored) {
}
}
public Map<Long, FileTaskEntity> getTaskCacheBatch(java.util.List<Long> taskIds) {
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
return result;
}
java.util.List<Long> normalized = taskIds.stream()
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (normalized.isEmpty()) {
return result;
}
long now = System.currentTimeMillis();
java.util.List<Long> missingIds = new ArrayList<>();
for (Long taskId : normalized) {
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
if (isLocalCacheFresh(cached, now)) {
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
} else {
missingIds.add(taskId);
}
}
if (missingIds.isEmpty()) {
return result;
}
java.util.List<String> keys = missingIds.stream().map(this::buildTaskEntityKey).toList();
java.util.List<String> values = stringRedisTemplate.opsForValue().multiGet(keys);
for (int i = 0; i < missingIds.size(); i++) {
Long taskId = missingIds.get(i);
String val = values != null && i < values.size() ? values.get(i) : null;
if (val == null || val.isBlank()) {
continue;
}
try {
FileTaskEntity task = objectMapper.readValue(val, FileTaskEntity.class);
result.put(taskId, task);
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
} catch (Exception ignored) {
}
}
return result;
}
private String buildShopPayloadKey(Long taskId) {
return "patrol-delete:task:shop-payload:" + taskId;
}
private String buildTaskHeartbeatKey(Long taskId) {
return "patrol-delete:task:heartbeat:" + taskId;
}
private String buildTaskEntityKey(Long taskId) {
return "patrol-delete:task:entity:" + taskId;
}
private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
return cached != null
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
}
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
}

View File

@@ -0,0 +1,883 @@
package com.nanri.aiimage.modules.patroldelete.service;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCartRatioDto;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCountryMetricRowDto;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCountrySectionDto;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCreateTaskRequest;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteShopPayloadDto;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteSubmitResultRequest;
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteTaskItemDto;
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteTaskBatchVo;
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteCreateTaskVo;
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteHistoryVo;
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteResultItemVo;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
@RequiredArgsConstructor
@Slf4j
public class PatrolDeleteTaskService {
private static final String MODULE_TYPE = "PATROL_DELETE";
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final String INTERRUPTED_MESSAGE = "Python 在该店铺结果提交完成前中断";
private final FileTaskMapper fileTaskMapper;
private final FileResultMapper fileResultMapper;
private final PatrolDeleteResolveService patrolDeleteResolveService;
private final PatrolDeleteExcelAssemblyService excelAssemblyService;
private final PatrolDeleteTaskCacheService taskCacheService;
private final OssStorageService ossStorageService;
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private FileTaskEntity loadTaskForExecution(Long taskId) {
Map<Long, FileTaskEntity> cachedTasks = taskCacheService.getTaskCacheBatch(List.of(taskId));
FileTaskEntity cached = cachedTasks.get(taskId);
if (cached != null) {
return cached;
}
FileTaskEntity dbTask = fileTaskMapper.selectById(taskId);
if (dbTask != null && MODULE_TYPE.equals(dbTask.getModuleType())) {
taskCacheService.saveTaskCache(dbTask);
}
return dbTask;
}
private Map<Long, FileTaskEntity> loadTaskMapByIds(List<Long> taskIds) {
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
return result;
}
List<Long> normalizedTaskIds = taskIds.stream()
.filter(taskId -> taskId != null && taskId > 0)
.distinct()
.toList();
if (normalizedTaskIds.isEmpty()) {
return result;
}
Map<Long, FileTaskEntity> cachedTasks = taskCacheService.getTaskCacheBatch(normalizedTaskIds);
result.putAll(cachedTasks);
List<Long> missingTaskIds = normalizedTaskIds.stream()
.filter(taskId -> !cachedTasks.containsKey(taskId))
.toList();
if (!missingTaskIds.isEmpty()) {
for (FileTaskEntity dbTask : selectTasksByIdsInBatches(missingTaskIds)) {
if (dbTask == null || !MODULE_TYPE.equals(dbTask.getModuleType())) {
continue;
}
result.put(dbTask.getId(), dbTask);
if ("RUNNING".equals(dbTask.getStatus())) {
taskCacheService.saveTaskCache(dbTask);
}
}
}
return result;
}
private List<FileTaskEntity> selectTasksByIdsInBatches(List<Long> taskIds) {
List<FileTaskEntity> tasks = new ArrayList<>();
if (taskIds == null || taskIds.isEmpty()) {
return tasks;
}
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
for (int start = 0; start < taskIds.size(); start += batchSize) {
int end = Math.min(start + batchSize, taskIds.size());
tasks.addAll(fileTaskMapper.selectBatchIds(taskIds.subList(start, end)));
}
return tasks;
}
public ProductRiskDashboardVo dashboard(Long userId) {
validateUserId(userId);
ProductRiskDashboardVo vo = new ProductRiskDashboardVo();
vo.setCandidateCount(patrolDeleteResolveService.countCandidates(userId));
vo.setProcessedTaskCount(countTasks(userId, List.of("SUCCESS", "FAILED")));
vo.setSuccessTaskCount(countTasks(userId, List.of("SUCCESS")));
vo.setFailedTaskCount(countTasks(userId, List.of("FAILED")));
return vo;
}
public PatrolDeleteHistoryVo listHistory(Long userId) {
validateUserId(userId);
PatrolDeleteHistoryVo vo = new PatrolDeleteHistoryVo();
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.eq(FileResultEntity::getUserId, userId)
.orderByDesc(FileResultEntity::getCreatedAt)
.last("limit 100"));
if (entities.isEmpty()) {
vo.setItems(List.of());
return vo;
}
Map<Long, FileTaskEntity> taskMap = loadTaskMap(entities);
Map<Long, Map<Long, PatrolDeleteResultItemVo>> snapshotMap = buildSnapshotMap(taskMap);
List<PatrolDeleteResultItemVo> items = new ArrayList<>();
for (FileResultEntity entity : entities) {
FileTaskEntity task = taskMap.get(entity.getTaskId());
PatrolDeleteResultItemVo snapshot = snapshotMap.getOrDefault(entity.getTaskId(), Map.of()).get(entity.getId());
items.add(toHistoryItem(entity, task, snapshot));
}
vo.setItems(items);
return vo;
}
public PatrolDeleteTaskBatchVo getTaskProgressBatch(List<Long> taskIds) {
PatrolDeleteTaskBatchVo batch = new PatrolDeleteTaskBatchVo();
if (taskIds == null || taskIds.isEmpty()) {
return batch;
}
List<Long> normalizedTaskIds = taskIds.stream()
.filter(taskId -> taskId != null && taskId > 0)
.distinct()
.limit(50)
.toList();
if (normalizedTaskIds.isEmpty()) {
return batch;
}
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(normalizedTaskIds);
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.in(FileResultEntity::getTaskId, normalizedTaskIds)
.orderByAsc(FileResultEntity::getId));
Map<Long, List<FileResultEntity>> rowsByTaskId = new LinkedHashMap<>();
for (FileResultEntity row : rows) {
rowsByTaskId.computeIfAbsent(row.getTaskId(), ignored -> new ArrayList<>()).add(row);
}
for (Long taskId : normalizedTaskIds) {
FileTaskEntity task = taskMap.get(taskId);
if (task == null) {
batch.getMissingTaskIds().add(taskId);
continue;
}
List<FileResultEntity> taskRows = rowsByTaskId.get(taskId);
if (taskRows == null || taskRows.isEmpty()) {
batch.getMissingTaskIds().add(taskId);
continue;
}
List<PatrolDeleteResultItemVo> snapshots = buildSnapshotFromDb(task, taskRows);
batch.getItems().addAll(snapshots);
}
return batch;
}
public String resolveResultDownloadUrl(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
}
if (blank(entity.getResultFileUrl())) {
throw new BusinessException("暂无可下载文件");
}
return ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl());
}
public String resolveResultDownloadFilename(Long resultId, Long userId) {
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
}
return !blank(entity.getResultFilename())
? entity.getResultFilename()
: safeFileStem(entity.getSourceFilename()) + ".xlsx";
}
@Transactional
public PatrolDeleteCreateTaskVo createTask(PatrolDeleteCreateTaskRequest request) {
validateUserId(request.getUserId());
if (request.getItems() == null || request.getItems().isEmpty()) {
throw new BusinessException("items 不能为空");
}
List<PatrolDeleteTaskItemDto> uniqueItems = dedupeItems(request.getItems());
if (uniqueItems.isEmpty()) {
throw new BusinessException("items 不能为空");
}
for (PatrolDeleteTaskItemDto item : uniqueItems) {
if (item == null || !item.isMatched()) {
throw new BusinessException("存在未匹配店铺,无法创建任务");
}
}
LocalDateTime now = LocalDateTime.now();
FileTaskEntity task = new FileTaskEntity();
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
task.setModuleType(MODULE_TYPE);
task.setTaskMode("PYTHON_QUEUE");
task.setStatus("RUNNING");
task.setSourceFileCount(uniqueItems.size());
task.setSuccessFileCount(0);
task.setFailedFileCount(0);
task.setCreatedBy("user:" + request.getUserId());
task.setUserId(request.getUserId());
task.setCreatedAt(now);
task.setUpdatedAt(now);
fileTaskMapper.insert(task);
taskCacheService.saveTaskCache(task);
taskCacheService.touchTaskHeartbeat(task.getId());
List<PatrolDeleteResultItemVo> snapshots = new ArrayList<>();
for (PatrolDeleteTaskItemDto item : uniqueItems) {
String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (blank(normalizedShopName)) {
throw new BusinessException("任务数据已失效,请刷新后重试");
}
FileResultEntity result = new FileResultEntity();
result.setTaskId(task.getId());
result.setModuleType(MODULE_TYPE);
result.setSourceFilename(normalizedShopName);
result.setSourceFileUrl(item.getShopId());
result.setUserId(request.getUserId());
result.setSuccess(null);
result.setCreatedAt(now);
fileResultMapper.insert(result);
snapshots.add(toSnapshotVo(result, item, task.getStatus(), null));
}
persistTaskJson(task, uniqueItems, snapshots);
PatrolDeleteCreateTaskVo vo = new PatrolDeleteCreateTaskVo();
vo.setTaskId(task.getId());
vo.setItems(snapshots);
return vo;
}
@Transactional
public void submitResult(Long taskId, PatrolDeleteSubmitResultRequest request) {
if (taskId == null || taskId <= 0) {
throw new BusinessException("taskId 不合法");
}
if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
throw new BusinessException("shops 不能为空");
}
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
throw new BusinessException("任务已结束,拒绝重复提交");
}
taskCacheService.touchTaskHeartbeat(taskId);
List<FileResultEntity> resultRows = listTaskRows(taskId);
List<PatrolDeleteResultItemVo> snapshots = buildSnapshotFromDb(task, resultRows);
Map<Long, PatrolDeleteResultItemVo> snapshotByResultId = indexSnapshotByResultId(snapshots);
Map<String, PatrolDeleteShopPayloadDto> payloadByShop = normalizePayloadByShop(request.getShops());
for (FileResultEntity row : resultRows) {
String shopKey = row.getSourceFilename();
PatrolDeleteShopPayloadDto incoming = payloadByShop.get(shopKey);
if (incoming == null) {
continue;
}
PatrolDeleteShopPayloadDto mergedPayload = mergeShopPayload(taskId, shopKey, incoming);
PatrolDeleteResultItemVo snapshot = snapshotByResultId.get(row.getId());
mergePayloadIntoSnapshot(snapshot, mergedPayload);
if (!blank(mergedPayload.getError())) {
markResultFailed(row, mergedPayload.getError());
applyFailureToSnapshot(snapshot, mergedPayload.getError());
taskCacheService.removeShopMergedPayload(taskId, shopKey);
continue;
}
if (Boolean.TRUE.equals(mergedPayload.getShopDone())) {
markResultSuccess(row);
applySuccessToSnapshot(snapshot);
taskCacheService.removeShopMergedPayload(taskId, shopKey);
}
}
persistSnapshotJson(task, snapshots);
fileTaskMapper.updateById(task);
tryFinalizeTask(taskId, false);
}
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) {
return false;
}
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return false;
}
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
return true;
}
List<FileResultEntity> rows = listTaskRows(taskId);
if (rows.isEmpty()) {
return false;
}
Map<String, PatrolDeleteShopPayloadDto> cachedPayloads = taskCacheService.getAllShopMergedPayload(taskId);
List<PatrolDeleteResultItemVo> snapshots = buildSnapshotFromDb(task, rows);
Map<Long, PatrolDeleteResultItemVo> snapshotByResultId = indexSnapshotByResultId(snapshots);
boolean changed = false;
for (FileResultEntity row : rows) {
if (isResultFinished(row)) {
continue;
}
PatrolDeleteResultItemVo snapshot = snapshotByResultId.get(row.getId());
PatrolDeleteShopPayloadDto cached = cachedPayloads.get(row.getSourceFilename());
if (cached == null) {
if (fromCompensation) {
markResultFailed(row, INTERRUPTED_MESSAGE);
applyFailureToSnapshot(snapshot, INTERRUPTED_MESSAGE);
changed = true;
}
continue;
}
mergePayloadIntoSnapshot(snapshot, cached);
if (!blank(cached.getError())) {
markResultFailed(row, cached.getError());
applyFailureToSnapshot(snapshot, cached.getError());
taskCacheService.removeShopMergedPayload(taskId, row.getSourceFilename());
changed = true;
continue;
}
if (Boolean.TRUE.equals(cached.getShopDone()) || (fromCompensation && hasAnyPayloadData(cached))) {
markResultSuccess(row);
applySuccessToSnapshot(snapshot);
taskCacheService.removeShopMergedPayload(taskId, row.getSourceFilename());
changed = true;
continue;
}
if (fromCompensation) {
markResultFailed(row, INTERRUPTED_MESSAGE);
applyFailureToSnapshot(snapshot, INTERRUPTED_MESSAGE);
taskCacheService.removeShopMergedPayload(taskId, row.getSourceFilename());
changed = true;
}
}
List<FileResultEntity> latestRows = listTaskRows(taskId);
updateTaskStatusFromRows(task, latestRows);
if (latestRows.stream().allMatch(this::isResultFinished)) {
finalizeTaskWorkbook(task, latestRows, snapshots);
return true;
}
persistSnapshotJson(task, snapshots);
fileTaskMapper.updateById(task);
return changed;
}
@Transactional
public void deleteTask(Long taskId, Long userId) {
validateUserId(userId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
}
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
fileTaskMapper.deleteById(taskId);
taskCacheService.deleteTaskCache(taskId);
}
@Transactional
public void deleteHistory(Long resultId, Long userId) {
validateUserId(userId);
FileResultEntity entity = fileResultMapper.selectById(resultId);
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
throw new BusinessException("记录不存在");
}
Long taskId = entity.getTaskId();
fileResultMapper.deleteById(resultId);
reconcileTaskAfterResultRemoval(taskId);
}
private void reconcileTaskAfterResultRemoval(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return;
}
List<FileResultEntity> rows = listTaskRows(taskId);
if (rows.isEmpty()) {
fileTaskMapper.deleteById(taskId);
taskCacheService.deleteTaskCache(taskId);
return;
}
updateTaskStatusFromRows(task, rows);
persistSnapshotJson(task, buildSnapshotFromDb(task, rows));
fileTaskMapper.updateById(task);
}
private long countTasks(Long userId, List<String> statuses) {
Long count = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
.eq(FileTaskEntity::getUserId, userId)
.in(FileTaskEntity::getStatus, statuses));
return count == null ? 0L : count;
}
private List<FileResultEntity> listTaskRows(Long taskId) {
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
.eq(FileResultEntity::getTaskId, taskId)
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
.orderByAsc(FileResultEntity::getId));
}
private Map<String, PatrolDeleteShopPayloadDto> normalizePayloadByShop(List<PatrolDeleteShopPayloadDto> shops) {
Map<String, PatrolDeleteShopPayloadDto> payloadByShop = new LinkedHashMap<>();
for (PatrolDeleteShopPayloadDto item : shops) {
if (item == null) {
continue;
}
String shopKey = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (blank(shopKey)) {
continue;
}
item.setShopName(shopKey);
payloadByShop.put(shopKey, item);
}
return payloadByShop;
}
private Map<Long, FileTaskEntity> loadTaskMap(List<FileResultEntity> entities) {
List<Long> taskIds = entities.stream()
.map(FileResultEntity::getTaskId)
.filter(id -> id != null && id > 0)
.distinct()
.toList();
Map<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
if (taskIds.isEmpty()) {
return taskMap;
}
for (FileTaskEntity task : selectTasksByIdsInBatches(taskIds)) {
if (task != null && MODULE_TYPE.equals(task.getModuleType())) {
taskMap.put(task.getId(), task);
}
}
return taskMap;
}
private Map<Long, Map<Long, PatrolDeleteResultItemVo>> buildSnapshotMap(Map<Long, FileTaskEntity> taskMap) {
Map<Long, Map<Long, PatrolDeleteResultItemVo>> out = new LinkedHashMap<>();
for (Map.Entry<Long, FileTaskEntity> entry : taskMap.entrySet()) {
out.put(entry.getKey(), indexSnapshotByResultId(parseTaskSnapshots(entry.getValue().getResultJson())));
}
return out;
}
private PatrolDeleteResultItemVo toHistoryItem(FileResultEntity entity, FileTaskEntity task, PatrolDeleteResultItemVo snapshot) {
PatrolDeleteResultItemVo item = snapshot != null ? snapshot : new PatrolDeleteResultItemVo();
item.setResultId(entity.getId());
item.setTaskId(entity.getTaskId());
item.setShopName(firstNonBlank(item.getShopName(), entity.getSourceFilename()));
item.setShopId(firstNonBlank(item.getShopId(), entity.getSourceFileUrl()));
item.setTaskStatus(task != null ? task.getStatus() : item.getTaskStatus());
item.setSuccess(entity.getSuccess() == null ? item.getSuccess() : entity.getSuccess() == 1);
item.setError(!blank(entity.getErrorMessage()) ? entity.getErrorMessage() : item.getError());
item.setCreatedAt(entity.getCreatedAt());
item.setFinishedAt(task != null ? task.getFinishedAt() : item.getFinishedAt());
item.setOutputFilename(firstNonBlank(item.getOutputFilename(), entity.getResultFilename()));
item.setDownloadUrl(blank(entity.getResultFileUrl())
? item.getDownloadUrl()
: ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
if (item.getCountrySections() == null) {
item.setCountrySections(new ArrayList<>());
}
if (item.getCartRatios() == null) {
item.setCartRatios(new ArrayList<>());
}
return item;
}
private List<PatrolDeleteTaskItemDto> dedupeItems(List<PatrolDeleteTaskItemDto> items) {
LinkedHashMap<String, PatrolDeleteTaskItemDto> map = new LinkedHashMap<>();
for (PatrolDeleteTaskItemDto item : items) {
if (item == null) {
continue;
}
String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
if (blank(normalizedShopName)) {
continue;
}
item.setShopName(normalizedShopName);
String key = normalizedShopName + "::" + Objects.toString(item.getShopId(), "");
map.put(key, item);
}
return new ArrayList<>(map.values());
}
private PatrolDeleteResultItemVo toSnapshotVo(FileResultEntity result, PatrolDeleteTaskItemDto item, String taskStatus, LocalDateTime finishedAt) {
PatrolDeleteResultItemVo vo = new PatrolDeleteResultItemVo();
vo.setResultId(result.getId());
vo.setTaskId(result.getTaskId());
vo.setShopName(item.getShopName());
vo.setShopId(item.getShopId());
vo.setPlatform(item.getPlatform());
vo.setCompanyName(item.getCompanyName());
vo.setMatched(item.isMatched());
vo.setMatchStatus(item.getMatchStatus());
vo.setMatchMessage(item.getMatchMessage());
vo.setTaskStatus(taskStatus);
vo.setSuccess(result.getSuccess() == null ? null : result.getSuccess() == 1);
vo.setError(result.getErrorMessage());
vo.setCreatedAt(result.getCreatedAt());
vo.setFinishedAt(finishedAt);
vo.setCountrySections(copyCountrySections(item.getCountrySections()));
vo.setCartRatios(copyCartRatios(item.getCartRatios()));
vo.setOutputFilename(result.getResultFilename());
vo.setDownloadUrl(null);
return vo;
}
private void persistTaskJson(FileTaskEntity task, List<PatrolDeleteTaskItemDto> requestItems, List<PatrolDeleteResultItemVo> snapshots) {
try {
task.setRequestJson(objectMapper.writeValueAsString(requestItems));
task.setResultJson(objectMapper.writeValueAsString(snapshots));
fileTaskMapper.updateById(task);
} catch (Exception ex) {
throw new BusinessException("巡店删除任务快照保存失败");
}
}
private List<PatrolDeleteResultItemVo> buildSnapshotFromDb(FileTaskEntity task, List<FileResultEntity> rows) {
List<PatrolDeleteResultItemVo> existing = parseTaskSnapshots(task.getResultJson());
Map<Long, PatrolDeleteResultItemVo> snapshotByResultId = indexSnapshotByResultId(existing);
List<PatrolDeleteResultItemVo> list = new ArrayList<>();
for (FileResultEntity row : rows) {
list.add(toHistoryItem(row, task, snapshotByResultId.get(row.getId())));
}
return list;
}
private Map<Long, PatrolDeleteResultItemVo> indexSnapshotByResultId(List<PatrolDeleteResultItemVo> snapshots) {
Map<Long, PatrolDeleteResultItemVo> map = new LinkedHashMap<>();
if (snapshots == null) {
return map;
}
for (PatrolDeleteResultItemVo snapshot : snapshots) {
if (snapshot != null && snapshot.getResultId() != null) {
map.put(snapshot.getResultId(), snapshot);
}
}
return map;
}
private void updateTaskStatusFromRows(FileTaskEntity task, List<FileResultEntity> rows) {
long successCount = rows.stream().filter(row -> Integer.valueOf(1).equals(row.getSuccess())).count();
long failedCount = rows.stream().filter(row -> Integer.valueOf(0).equals(row.getSuccess())).count();
long pendingCount = rows.stream().filter(row -> !isResultFinished(row)).count();
task.setSuccessFileCount((int) successCount);
task.setFailedFileCount((int) failedCount);
task.setUpdatedAt(LocalDateTime.now());
if (pendingCount > 0) {
task.setStatus("RUNNING");
task.setFinishedAt(null);
task.setErrorMessage(null);
return;
}
task.setStatus(failedCount > 0 ? "FAILED" : "SUCCESS");
task.setFinishedAt(LocalDateTime.now());
if (failedCount > 0) {
List<String> errors = new ArrayList<>();
for (FileResultEntity row : rows) {
if (!blank(row.getErrorMessage())) {
errors.add(row.getSourceFilename() + ": " + row.getErrorMessage());
}
}
task.setErrorMessage(String.join("; ", errors));
} else {
task.setErrorMessage(null);
}
}
private PatrolDeleteShopPayloadDto mergeShopPayload(Long taskId, String shopKey, PatrolDeleteShopPayloadDto incoming) {
PatrolDeleteShopPayloadDto merged = taskCacheService.getShopMergedPayload(taskId, shopKey);
if (merged == null) {
merged = new PatrolDeleteShopPayloadDto();
merged.setShopName(shopKey);
merged.setCountrySections(new ArrayList<>());
merged.setCartRatios(new ArrayList<>());
}
merged.setShopName(firstNonBlank(incoming.getShopName(), merged.getShopName()));
merged.setSubmissionId(firstNonBlank(incoming.getSubmissionId(), merged.getSubmissionId()));
merged.setChunkIndex(incoming.getChunkIndex());
merged.setChunkTotal(incoming.getChunkTotal());
if (!blank(incoming.getError())) {
merged.setError(incoming.getError().trim());
merged.setShopDone(Boolean.TRUE);
taskCacheService.saveShopMergedPayload(taskId, shopKey, merged);
return merged;
}
merged.setCountrySections(mergeCountrySections(merged.getCountrySections(), incoming.getCountrySections()));
merged.setCartRatios(mergeCartRatios(merged.getCartRatios(), incoming.getCartRatios()));
if (Boolean.TRUE.equals(incoming.getShopDone())) {
merged.setShopDone(Boolean.TRUE);
}
taskCacheService.saveShopMergedPayload(taskId, shopKey, merged);
return merged;
}
private void mergePayloadIntoSnapshot(PatrolDeleteResultItemVo snapshot, PatrolDeleteShopPayloadDto payload) {
if (snapshot == null || payload == null) {
return;
}
snapshot.setShopName(firstNonBlank(payload.getShopName(), snapshot.getShopName()));
snapshot.setCountrySections(copyCountrySections(payload.getCountrySections()));
snapshot.setCartRatios(copyCartRatios(payload.getCartRatios()));
if (!blank(payload.getError())) {
snapshot.setError(payload.getError().trim());
}
}
private void applySuccessToSnapshot(PatrolDeleteResultItemVo snapshot) {
if (snapshot == null) {
return;
}
snapshot.setSuccess(Boolean.TRUE);
snapshot.setError(null);
}
private void applyFailureToSnapshot(PatrolDeleteResultItemVo snapshot, String error) {
if (snapshot == null) {
return;
}
snapshot.setSuccess(Boolean.FALSE);
snapshot.setError(blankToNull(error));
}
private List<PatrolDeleteCountrySectionDto> mergeCountrySections(List<PatrolDeleteCountrySectionDto> base, List<PatrolDeleteCountrySectionDto> incoming) {
Map<String, PatrolDeleteCountrySectionDto> map = new LinkedHashMap<>();
for (PatrolDeleteCountrySectionDto section : copyCountrySections(base)) {
map.put(section.getCountry(), section);
}
for (PatrolDeleteCountrySectionDto section : copyCountrySections(incoming)) {
PatrolDeleteCountrySectionDto existing = map.get(section.getCountry());
if (existing == null) {
map.put(section.getCountry(), section);
continue;
}
List<PatrolDeleteCountryMetricRowDto> mergedRows = existing.getRows() == null
? new ArrayList<>()
: new ArrayList<>(existing.getRows());
if (section.getRows() != null) {
mergedRows.addAll(section.getRows());
}
existing.setRows(mergedRows);
}
return new ArrayList<>(map.values());
}
private List<PatrolDeleteCartRatioDto> mergeCartRatios(List<PatrolDeleteCartRatioDto> base, List<PatrolDeleteCartRatioDto> incoming) {
Map<String, PatrolDeleteCartRatioDto> map = new LinkedHashMap<>();
for (PatrolDeleteCartRatioDto ratio : copyCartRatios(base)) {
map.put(ratio.getCountry(), ratio);
}
for (PatrolDeleteCartRatioDto ratio : copyCartRatios(incoming)) {
if (!blank(ratio.getCountry())) {
map.put(ratio.getCountry(), ratio);
}
}
return new ArrayList<>(map.values());
}
private boolean hasAnyPayloadData(PatrolDeleteShopPayloadDto payload) {
if (payload == null) {
return false;
}
if (payload.getCountrySections() != null) {
for (PatrolDeleteCountrySectionDto section : payload.getCountrySections()) {
if (section == null || section.getRows() == null) {
continue;
}
for (PatrolDeleteCountryMetricRowDto row : section.getRows()) {
if (row != null && (!blank(row.getStatus())
|| !blank(row.getQuantity())
|| !blank(row.getDeleteQuantity())
|| !blank(row.getProcessStatus()))) {
return true;
}
}
}
}
if (payload.getCartRatios() != null) {
for (PatrolDeleteCartRatioDto ratio : payload.getCartRatios()) {
if (ratio != null && (!blank(ratio.getCountry()) || !blank(ratio.getRatio()))) {
return true;
}
}
}
return false;
}
private void finalizeTaskWorkbook(FileTaskEntity task, List<FileResultEntity> rows, List<PatrolDeleteResultItemVo> snapshots) {
List<PatrolDeleteResultItemVo> successItems = snapshots.stream()
.filter(item -> Boolean.TRUE.equals(item.getSuccess()))
.toList();
if (!successItems.isEmpty()) {
File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "patrol-delete-result", String.valueOf(task.getId())));
String filename = safeFileStem("巡店删除-" + task.getId()) + ".xlsx";
File xlsx = FileUtil.file(workRoot, filename);
try {
excelAssemblyService.writeWorkbook(xlsx, successItems);
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
long fileSize = xlsx.length();
int rowCount = excelAssemblyService.countRows(successItems);
for (FileResultEntity row : rows) {
if (Integer.valueOf(1).equals(row.getSuccess())) {
row.setResultFilename(filename);
row.setResultFileUrl(objectKey);
row.setResultFileSize(fileSize);
row.setResultContentType(CONTENT_TYPE_XLSX);
row.setRowCount(rowCount);
fileResultMapper.updateById(row);
}
}
snapshots = buildSnapshotFromDb(task, rows);
} finally {
FileUtil.del(xlsx);
}
}
persistSnapshotJson(task, snapshots);
fileTaskMapper.updateById(task);
taskCacheService.deleteTaskCache(task.getId());
}
private void markResultSuccess(FileResultEntity row) {
row.setSuccess(1);
row.setErrorMessage(null);
fileResultMapper.updateById(row);
}
private void markResultFailed(FileResultEntity row, String message) {
row.setSuccess(0);
row.setErrorMessage(blankToNull(message));
row.setResultFilename(null);
row.setResultFileUrl(null);
row.setResultFileSize(0L);
row.setResultContentType(null);
row.setRowCount(0);
fileResultMapper.updateById(row);
}
private boolean isResultFinished(FileResultEntity row) {
return Integer.valueOf(1).equals(row.getSuccess()) || Integer.valueOf(0).equals(row.getSuccess());
}
private List<PatrolDeleteResultItemVo> parseTaskSnapshots(String json) {
if (blank(json)) {
return new ArrayList<>();
}
try {
return objectMapper.readValue(json, new TypeReference<List<PatrolDeleteResultItemVo>>() {});
} catch (Exception ex) {
log.warn("[patrol-delete] parse task snapshot failed: {}", ex.getMessage());
return new ArrayList<>();
}
}
private void persistSnapshotJson(FileTaskEntity task, List<PatrolDeleteResultItemVo> snapshots) {
try {
task.setResultJson(objectMapper.writeValueAsString(snapshots == null ? List.of() : snapshots));
} catch (Exception ex) {
throw new BusinessException("巡店删除任务快照保存失败");
}
}
private List<PatrolDeleteCountrySectionDto> copyCountrySections(List<PatrolDeleteCountrySectionDto> sections) {
List<PatrolDeleteCountrySectionDto> copy = new ArrayList<>();
if (sections == null) {
return copy;
}
for (PatrolDeleteCountrySectionDto section : sections) {
if (section == null) {
continue;
}
PatrolDeleteCountrySectionDto item = new PatrolDeleteCountrySectionDto();
item.setCountry(section.getCountry());
item.setRows(section.getRows() == null ? new ArrayList<>() : new ArrayList<>(section.getRows()));
copy.add(item);
}
return copy;
}
private List<PatrolDeleteCartRatioDto> copyCartRatios(List<PatrolDeleteCartRatioDto> ratios) {
List<PatrolDeleteCartRatioDto> copy = new ArrayList<>();
if (ratios == null) {
return copy;
}
for (PatrolDeleteCartRatioDto ratio : ratios) {
if (ratio == null) {
continue;
}
PatrolDeleteCartRatioDto item = new PatrolDeleteCartRatioDto();
item.setCountry(ratio.getCountry());
item.setRatio(ratio.getRatio());
copy.add(item);
}
return copy;
}
private String firstNonBlank(String first, String second) {
return !blank(first) ? first : second;
}
private String safeFileStem(String value) {
String raw = value == null ? "result" : value.trim();
String safe = raw.replaceAll("[\\\\/:*?\"<>|]+", "_");
return safe.isBlank() ? "result" : safe;
}
private boolean blank(String value) {
return value == null || value.isBlank();
}
private String blankToNull(String value) {
return blank(value) ? null : value.trim();
}
private void validateUserId(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
}
}

View File

@@ -223,7 +223,10 @@ public class PriceTrackController {
example = "200"
)
@PathVariable Long taskId,
@Valid @RequestBody PriceTrackSubmitResultRequest request) {
@Valid @RequestBody PriceTrackSubmitResultRequest request,
jakarta.servlet.http.HttpServletResponse response) {
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("application/json;charset=UTF-8");
priceTrackTaskService.submitResult(taskId, request);
return ApiResponse.success(null);
}

View File

@@ -20,4 +20,7 @@ public class PriceTrackCreateTaskVo {
@Schema(description = "parsed asin rows by country")
private Map<String, List<Map<String, String>>> asinRowsByCountry;
@Schema(description = "minimum price grouped by country code and asin")
private Map<String, Map<String, String>> minimumPriceByCountryAndAsin;
}

View File

@@ -20,6 +20,9 @@ public class PriceTrackMatchShopsVo {
@Schema(description = "Parsed asin rows grouped by country code")
private Map<String, List<Map<String, String>>> asinRowsByCountry;
@Schema(description = "Minimum price grouped by country code and asin")
private Map<String, Map<String, String>> minimumPriceByCountryAndAsin;
@Data
@Schema(description = "Single matched shop item")
public static class PriceTrackShopQueueItem {

View File

@@ -158,6 +158,7 @@ public class PriceTrackService {
PriceTrackMatchShopsVo vo = new PriceTrackMatchShopsVo();
vo.setSkipAsinsByCountry(skipAsinsByCountry);
vo.setAsinRowsByCountry(asinRowsByCountry);
vo.setMinimumPriceByCountryAndAsin(priceTrackTaskService.buildMinimumPriceLookupForMatch(asinRowsByCountry));
for (String shopName : ordered) {
vo.getItems().add(matchOneShop(shopName, skipAsinsByCountry));
}

View File

@@ -2,24 +2,29 @@ package com.nanri.aiimage.modules.pricetrack.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
public class PriceTrackTaskCacheService {
private static final long HEARTBEAT_TTL_HOURS = 24;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
public void touchTaskHeartbeat(Long taskId) {
if (taskId == null || taskId <= 0) {
@@ -112,8 +117,72 @@ public class PriceTrackTaskCacheService {
if (taskId == null || taskId <= 0) {
return;
}
taskEntityLocalCache.remove(taskId);
stringRedisTemplate.delete(buildShopPayloadKey(taskId));
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
}
public void saveTaskCache(FileTaskEntity task) {
if (task == null || task.getId() == null) {
return;
}
long now = System.currentTimeMillis();
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
now,
objectMapper.convertValue(task, FileTaskEntity.class)
));
try {
stringRedisTemplate.opsForValue().set(
buildTaskEntityKey(task.getId()),
objectMapper.writeValueAsString(task),
Duration.ofHours(HEARTBEAT_TTL_HOURS)
);
} catch (Exception ignored) {
}
}
public Map<Long, FileTaskEntity> getTaskCacheBatch(java.util.List<Long> taskIds) {
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
return result;
}
java.util.List<Long> normalized = taskIds.stream()
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (normalized.isEmpty()) {
return result;
}
long now = System.currentTimeMillis();
java.util.List<Long> missingIds = new ArrayList<>();
for (Long taskId : normalized) {
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
if (isLocalCacheFresh(cached, now)) {
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
} else {
missingIds.add(taskId);
}
}
if (missingIds.isEmpty()) {
return result;
}
java.util.List<String> keys = missingIds.stream().map(this::buildTaskEntityKey).toList();
java.util.List<String> values = stringRedisTemplate.opsForValue().multiGet(keys);
for (int i = 0; i < missingIds.size(); i++) {
Long taskId = missingIds.get(i);
String val = values != null && i < values.size() ? values.get(i) : null;
if (val == null || val.isBlank()) {
continue;
}
try {
FileTaskEntity task = objectMapper.readValue(val, FileTaskEntity.class);
result.put(taskId, task);
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
} catch (Exception ignored) {
}
}
return result;
}
private String buildShopPayloadKey(Long taskId) {
@@ -123,4 +192,15 @@ public class PriceTrackTaskCacheService {
private String buildTaskHeartbeatKey(Long taskId) {
return "price-track:task:heartbeat:" + taskId;
}
private String buildTaskEntityKey(Long taskId) {
return "price-track:task:entity:" + taskId;
}
private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
return cached != null
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
}
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
}

View File

@@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.util.ExcelStreamReader;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
import com.nanri.aiimage.modules.pricetrack.mapper.PriceTrackShopCandidateMapper;
@@ -64,6 +65,62 @@ public class PriceTrackTaskService {
private final PriceTrackExcelAssemblyService excelAssemblyService;
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
private final PriceTrackLoopRunService priceTrackLoopRunService;
private final TaskPressureProperties taskPressureProperties;
private FileTaskEntity loadTaskForExecution(Long taskId) {
Map<Long, FileTaskEntity> cachedTasks = priceTrackTaskCacheService.getTaskCacheBatch(List.of(taskId));
FileTaskEntity cached = cachedTasks.get(taskId);
if (cached != null) {
return cached;
}
FileTaskEntity dbTask = fileTaskMapper.selectById(taskId);
if (dbTask != null && MODULE_TYPE.equals(dbTask.getModuleType())) {
priceTrackTaskCacheService.saveTaskCache(dbTask);
}
return dbTask;
}
private Map<Long, FileTaskEntity> loadTaskMapByIds(List<Long> taskIds) {
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
return result;
}
List<Long> normalizedTaskIds = taskIds.stream()
.filter(taskId -> taskId != null && taskId > 0)
.distinct()
.toList();
if (normalizedTaskIds.isEmpty()) {
return result;
}
Map<Long, FileTaskEntity> cachedTasks = priceTrackTaskCacheService.getTaskCacheBatch(normalizedTaskIds);
result.putAll(cachedTasks);
List<Long> missingTaskIds = normalizedTaskIds.stream()
.filter(taskId -> !cachedTasks.containsKey(taskId))
.toList();
for (FileTaskEntity dbTask : selectTasksByIdsInBatches(missingTaskIds)) {
if (dbTask == null || !MODULE_TYPE.equals(dbTask.getModuleType())) {
continue;
}
result.put(dbTask.getId(), dbTask);
if ("RUNNING".equals(dbTask.getStatus())) {
priceTrackTaskCacheService.saveTaskCache(dbTask);
}
}
return result;
}
private List<FileTaskEntity> selectTasksByIdsInBatches(List<Long> taskIds) {
List<FileTaskEntity> tasks = new ArrayList<>();
if (taskIds == null || taskIds.isEmpty()) {
return tasks;
}
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
for (int start = 0; start < taskIds.size(); start += batchSize) {
int end = Math.min(start + batchSize, taskIds.size());
tasks.addAll(fileTaskMapper.selectBatchIds(taskIds.subList(start, end)));
}
return tasks;
}
public PriceTrackDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
@@ -104,7 +161,7 @@ public class PriceTrackTaskService {
.distinct()
.toList();
if (!taskIds.isEmpty()) {
List<FileTaskEntity> tasks = fileTaskMapper.selectBatchIds(taskIds);
List<FileTaskEntity> tasks = selectTasksByIdsInBatches(taskIds);
for (FileTaskEntity t : tasks) {
if (t != null) statusByTaskId.put(t.getId(), t.getStatus());
}
@@ -134,7 +191,7 @@ public class PriceTrackTaskService {
@Transactional
public void deleteTask(Long taskId, Long userId) {
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
}
@@ -164,7 +221,7 @@ public class PriceTrackTaskService {
vo.setRemoved(false);
for (FileResultEntity fr : candidates) {
if (fr.getTaskId() == null) continue;
FileTaskEntity task = fileTaskMapper.selectById(fr.getTaskId());
FileTaskEntity task = loadTaskForExecution(fr.getTaskId());
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) continue;
if (!"RUNNING".equals(task.getStatus())) continue;
fileResultMapper.deleteById(fr.getId());
@@ -178,10 +235,11 @@ public class PriceTrackTaskService {
public PriceTrackTaskBatchVo getTaskDetailsBatch(List<Long> taskIds) {
PriceTrackTaskBatchVo batch = new PriceTrackTaskBatchVo();
if (taskIds == null || taskIds.isEmpty()) return batch;
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(taskIds);
for (Long taskId : taskIds) {
if (taskId == null || taskId <= 0) continue;
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
FileTaskEntity task = taskMap.get(taskId);
if (task == null) {
batch.getMissingTaskIds().add(taskId);
continue;
}
@@ -195,12 +253,13 @@ public class PriceTrackTaskService {
if (taskIds == null || taskIds.isEmpty()) {
return batch;
}
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(taskIds);
for (Long taskId : taskIds) {
if (taskId == null || taskId <= 0) {
continue;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
FileTaskEntity task = taskMap.get(taskId);
if (task == null) {
batch.getMissingTaskIds().add(taskId);
continue;
}
@@ -237,6 +296,8 @@ public class PriceTrackTaskService {
Map<String, List<Map<String, String>>> asinRowsByCountry = request.isAsinMode()
? parseAsinRowsByCountry(request.getAsinFiles(), request.getCountryCodes())
: new LinkedHashMap<>();
Map<String, Map<String, String>> minimumPriceByCountryAndAsin =
buildMinimumPriceByCountryAndAsin(asinRowsByCountry);
log.info("[price-track] createTask skipAsins countries={} asinMode={}",
skipAsinsByCountry.keySet(), request.isAsinMode());
@@ -254,6 +315,7 @@ public class PriceTrackTaskService {
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.insert(task);
priceTrackTaskCacheService.saveTaskCache(task);
priceTrackTaskCacheService.touchTaskHeartbeat(task.getId());
if (request.getLoopRunId() != null) {
priceTrackLoopRunService.bindChildTask(request.getLoopRunId(), task.getId(), request.getRoundIndex(), request.getShopIndex());
@@ -287,6 +349,7 @@ public class PriceTrackTaskService {
ctx.put("countryCodes", request.getCountryCodes());
ctx.put("skipAsinsByCountry", skipAsinsByCountry);
ctx.put("asinRowsByCountry", asinRowsByCountry);
ctx.put("minimumPriceByCountryAndAsin", minimumPriceByCountryAndAsin);
ctx.put("items", uniqueItems);
ctx.put("loopRunId", request.getLoopRunId());
ctx.put("roundIndex", request.getRoundIndex());
@@ -306,6 +369,7 @@ public class PriceTrackTaskService {
vo.setItems(snapshot);
vo.setSkipAsinsByCountry(skipAsinsByCountry);
vo.setAsinRowsByCountry(asinRowsByCountry);
vo.setMinimumPriceByCountryAndAsin(minimumPriceByCountryAndAsin);
return vo;
}
@@ -315,7 +379,7 @@ public class PriceTrackTaskService {
if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
throw new BusinessException("shops 不能为空");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
}
@@ -386,7 +450,7 @@ public class PriceTrackTaskService {
@Transactional
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
if (taskId == null || taskId <= 0) return false;
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) return false;
if (!"RUNNING".equals(task.getStatus())) {
log.warn("[price-track] stale finalize skipped taskId={} status={} fromCompensation={}",
@@ -490,7 +554,7 @@ public class PriceTrackTaskService {
// ---- private helpers ----
private void reconcileTaskAfterResultRemoval(Long taskId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) return;
List<FileResultEntity> latest = fileResultMapper.selectList(
new LambdaQueryWrapper<FileResultEntity>()
@@ -552,6 +616,11 @@ public class PriceTrackTaskService {
return parseAsinRowsByCountry(asinFiles, countryCodes);
}
public Map<String, Map<String, String>> buildMinimumPriceLookupForMatch(
Map<String, List<Map<String, String>>> asinRowsByCountry) {
return buildMinimumPriceByCountryAndAsin(asinRowsByCountry);
}
private Map<String, List<Map<String, String>>> parseAsinRowsByCountry(List<String> asinFiles, List<String> countryCodes) {
Map<String, List<Map<String, String>>> merged = new LinkedHashMap<>();
if (asinFiles == null || asinFiles.isEmpty()) {
@@ -654,6 +723,37 @@ public class PriceTrackTaskService {
}
}
private Map<String, Map<String, String>> buildMinimumPriceByCountryAndAsin(
Map<String, List<Map<String, String>>> asinRowsByCountry) {
Map<String, Map<String, String>> out = new LinkedHashMap<>();
if (asinRowsByCountry == null || asinRowsByCountry.isEmpty()) {
return out;
}
for (Map.Entry<String, List<Map<String, String>>> entry : asinRowsByCountry.entrySet()) {
String countryCode = entry.getKey();
if (countryCode == null || countryCode.isBlank()) {
continue;
}
Map<String, String> minimumPriceByAsin = new LinkedHashMap<>();
List<Map<String, String>> rows = entry.getValue();
if (rows != null) {
for (Map<String, String> row : rows) {
if (row == null || row.isEmpty()) {
continue;
}
String asin = normalizeCellText(row.get("asin")).toUpperCase(Locale.ROOT);
if (asin.isBlank()) {
continue;
}
String minimumPrice = normalizeCellText(row.get("minimumPrice"));
minimumPriceByAsin.put(asin, minimumPrice);
}
}
out.put(countryCode, minimumPriceByAsin);
}
return out;
}
private String resolveCountryCode(String sheetName, List<String> countryCodes, boolean singleSheetFallback) {
String normalized = normalizeHeaderText(sheetName).toUpperCase(Locale.ROOT);
for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) {
@@ -832,7 +932,7 @@ public class PriceTrackTaskService {
}
private void mergeQueueFieldsFromRequest(PriceTrackResultItemVo vo, Long taskId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) return;
try {
Map<String, Object> payload = objectMapper.readValue(task.getRequestJson(), new TypeReference<Map<String, Object>>() {});

View File

@@ -229,7 +229,10 @@ public class ProductRiskResolveController {
}
""")
}))
@Valid @RequestBody ProductRiskSubmitResultRequest request) {
@Valid @RequestBody ProductRiskSubmitResultRequest request,
jakarta.servlet.http.HttpServletResponse response) {
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("application/json;charset=UTF-8");
productRiskTaskService.submitResult(taskId, request);
return ApiResponse.success(null);
}

View File

@@ -2,23 +2,29 @@ package com.nanri.aiimage.modules.productrisk.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskShopPayloadDto;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
public class ProductRiskTaskCacheService {
private static final long PAYLOAD_TTL_HOURS = 24;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
public ProductRiskShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
@@ -109,8 +115,72 @@ public class ProductRiskTaskCacheService {
if (taskId == null || taskId <= 0) {
return;
}
taskEntityLocalCache.remove(taskId);
stringRedisTemplate.delete(buildShopPayloadKey(taskId));
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
}
public void saveTaskCache(FileTaskEntity task) {
if (task == null || task.getId() == null) {
return;
}
long now = System.currentTimeMillis();
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
now,
objectMapper.convertValue(task, FileTaskEntity.class)
));
try {
stringRedisTemplate.opsForValue().set(
buildTaskEntityKey(task.getId()),
objectMapper.writeValueAsString(task),
Duration.ofHours(PAYLOAD_TTL_HOURS)
);
} catch (Exception ignored) {
}
}
public Map<Long, FileTaskEntity> getTaskCacheBatch(java.util.List<Long> taskIds) {
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
return result;
}
java.util.List<Long> normalized = taskIds.stream()
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (normalized.isEmpty()) {
return result;
}
long now = System.currentTimeMillis();
java.util.List<Long> missingIds = new ArrayList<>();
for (Long taskId : normalized) {
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
if (isLocalCacheFresh(cached, now)) {
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
} else {
missingIds.add(taskId);
}
}
if (missingIds.isEmpty()) {
return result;
}
java.util.List<String> keys = missingIds.stream().map(this::buildTaskEntityKey).toList();
java.util.List<String> values = stringRedisTemplate.opsForValue().multiGet(keys);
for (int i = 0; i < missingIds.size(); i++) {
Long taskId = missingIds.get(i);
String val = values != null && i < values.size() ? values.get(i) : null;
if (val == null || val.isBlank()) {
continue;
}
try {
FileTaskEntity task = objectMapper.readValue(val, FileTaskEntity.class);
result.put(taskId, task);
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
} catch (Exception ignored) {
}
}
return result;
}
private String buildShopPayloadKey(Long taskId) {
@@ -127,4 +197,15 @@ public class ProductRiskTaskCacheService {
private String buildTaskHeartbeatKey(Long taskId) {
return "product-risk:task:heartbeat:" + taskId;
}
private String buildTaskEntityKey(Long taskId) {
return "product-risk:task:entity:" + taskId;
}
private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
return cached != null
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
}
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
}

View File

@@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.productrisk.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.productrisk.mapper.ProductRiskShopCandidateMapper;
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCreateTaskRequest;
@@ -57,6 +58,62 @@ public class ProductRiskTaskService {
private final OssStorageService ossStorageService;
private final ObjectMapper objectMapper;
private final ProductRiskTaskCacheService productRiskTaskCacheService;
private final TaskPressureProperties taskPressureProperties;
private FileTaskEntity loadTaskForExecution(Long taskId) {
Map<Long, FileTaskEntity> cachedTasks = productRiskTaskCacheService.getTaskCacheBatch(List.of(taskId));
FileTaskEntity cached = cachedTasks.get(taskId);
if (cached != null) {
return cached;
}
FileTaskEntity dbTask = fileTaskMapper.selectById(taskId);
if (dbTask != null && MODULE_TYPE.equals(dbTask.getModuleType())) {
productRiskTaskCacheService.saveTaskCache(dbTask);
}
return dbTask;
}
private Map<Long, FileTaskEntity> loadTaskMapByIds(List<Long> taskIds) {
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
return result;
}
List<Long> normalizedTaskIds = taskIds.stream()
.filter(taskId -> taskId != null && taskId > 0)
.distinct()
.toList();
if (normalizedTaskIds.isEmpty()) {
return result;
}
Map<Long, FileTaskEntity> cachedTasks = productRiskTaskCacheService.getTaskCacheBatch(normalizedTaskIds);
result.putAll(cachedTasks);
List<Long> missingTaskIds = normalizedTaskIds.stream()
.filter(taskId -> !cachedTasks.containsKey(taskId))
.toList();
for (FileTaskEntity dbTask : selectTasksByIdsInBatches(missingTaskIds)) {
if (dbTask == null || !MODULE_TYPE.equals(dbTask.getModuleType())) {
continue;
}
result.put(dbTask.getId(), dbTask);
if ("RUNNING".equals(dbTask.getStatus())) {
productRiskTaskCacheService.saveTaskCache(dbTask);
}
}
return result;
}
private List<FileTaskEntity> selectTasksByIdsInBatches(List<Long> taskIds) {
List<FileTaskEntity> tasks = new ArrayList<>();
if (taskIds == null || taskIds.isEmpty()) {
return tasks;
}
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
for (int start = 0; start < taskIds.size(); start += batchSize) {
int end = Math.min(start + batchSize, taskIds.size());
tasks.addAll(fileTaskMapper.selectBatchIds(taskIds.subList(start, end)));
}
return tasks;
}
public ProductRiskDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) {
@@ -100,7 +157,7 @@ public class ProductRiskTaskService {
.distinct()
.toList();
if (!taskIds.isEmpty()) {
List<FileTaskEntity> tasks = fileTaskMapper.selectBatchIds(taskIds);
List<FileTaskEntity> tasks = selectTasksByIdsInBatches(taskIds);
for (FileTaskEntity t : tasks) {
if (t != null) {
statusByTaskId.put(t.getId(), t.getStatus());
@@ -139,7 +196,7 @@ public class ProductRiskTaskService {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
}
@@ -178,7 +235,7 @@ public class ProductRiskTaskService {
if (fr.getTaskId() == null) {
continue;
}
FileTaskEntity t = fileTaskMapper.selectById(fr.getTaskId());
FileTaskEntity t = loadTaskForExecution(fr.getTaskId());
if (t == null || !MODULE_TYPE.equals(t.getModuleType()) || !userId.equals(t.getUserId())) {
continue;
}
@@ -198,7 +255,7 @@ public class ProductRiskTaskService {
* 删除一条 file_result 后重算父任务状态;若无剩余结果则删除任务。
*/
private void reconcileTaskAfterResultRemoval(Long taskId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return;
}
@@ -270,12 +327,13 @@ public class ProductRiskTaskService {
if (taskIds == null || taskIds.isEmpty()) {
return batch;
}
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(taskIds);
for (Long taskId : taskIds) {
if (taskId == null || taskId <= 0) {
continue;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
FileTaskEntity task = taskMap.get(taskId);
if (task == null) {
batch.getMissingTaskIds().add(taskId);
continue;
}
@@ -289,12 +347,13 @@ public class ProductRiskTaskService {
if (taskIds == null || taskIds.isEmpty()) {
return batch;
}
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(taskIds);
for (Long taskId : taskIds) {
if (taskId == null || taskId <= 0) {
continue;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
FileTaskEntity task = taskMap.get(taskId);
if (task == null) {
batch.getMissingTaskIds().add(taskId);
continue;
}
@@ -359,6 +418,7 @@ public class ProductRiskTaskService {
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
fileTaskMapper.insert(task);
productRiskTaskCacheService.saveTaskCache(task);
productRiskTaskCacheService.touchTaskHeartbeat(task.getId());
List<ProductRiskResultItemVo> snapshot = new ArrayList<>();
@@ -405,7 +465,7 @@ public class ProductRiskTaskService {
throw new BusinessException("shops 不能为空");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
}
@@ -566,7 +626,7 @@ public class ProductRiskTaskService {
if (taskId == null || taskId <= 0) {
return false;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return false;
}
@@ -861,7 +921,7 @@ public class ProductRiskTaskService {
}
private void mergeQueueFieldsFromRequest(ProductRiskResultItemVo vo, Long taskId) {
FileTaskEntity t = fileTaskMapper.selectById(taskId);
FileTaskEntity t = loadTaskForExecution(taskId);
if (t == null || t.getRequestJson() == null || t.getRequestJson().isBlank()) {
return;
}

View File

@@ -0,0 +1,9 @@
package com.nanri.aiimage.modules.shopkey.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupMemberEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ShopManageGroupMemberMapper extends BaseMapper<ShopManageGroupMemberEntity> {
}

View File

@@ -5,6 +5,8 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@Data
public class ShopManageGroupCreateRequest {
@@ -12,9 +14,8 @@ public class ShopManageGroupCreateRequest {
@NotBlank(message = "分组名称不能为空")
private String groupName;
@Schema(description = "关联用户ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "关联用户不能为空")
private Long userId;
@Schema(description = "组员用户ID列表")
private List<Long> memberUserIds;
@Schema(description = "创建人用户ID由后台代理透传", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "创建人不能为空")

View File

@@ -2,9 +2,10 @@ package com.nanri.aiimage.modules.shopkey.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@Data
public class ShopManageGroupUpdateRequest {
@@ -12,7 +13,6 @@ public class ShopManageGroupUpdateRequest {
@NotBlank(message = "分组名称不能为空")
private String groupName;
@Schema(description = "关联用户ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "关联用户不能为空")
private Long userId;
@Schema(description = "组员用户ID列表")
private List<Long> memberUserIds;
}

View File

@@ -0,0 +1,26 @@
package com.nanri.aiimage.modules.shopkey.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("biz_shop_manage_group_member")
public class ShopManageGroupMemberEntity {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("group_id")
private Long groupId;
@TableField("user_id")
private Long userId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -3,11 +3,17 @@ package com.nanri.aiimage.modules.shopkey.model.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class ShopManageGroupItemVo {
private Long id;
private String groupName;
private Long leaderUserId;
private String leaderUsername;
private List<Long> memberUserIds;
private List<String> memberUsernames;
private Integer memberCount;
private Long userId;
private String username;
private LocalDateTime createdAt;

View File

@@ -5,26 +5,37 @@ import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMemberMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupCreateRequest;
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupUpdateRequest;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupMemberEntity;
import com.nanri.aiimage.modules.shopkey.model.entity.SkipPriceAsinEntity;
import com.nanri.aiimage.modules.shopkey.model.vo.ShopManageGroupItemVo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class ShopManageGroupService {
private final ShopManageGroupMapper groupMapper;
private final ShopManageGroupMemberMapper groupMemberMapper;
private final ShopManageMapper shopManageMapper;
private final SkipPriceAsinMapper skipPriceAsinMapper;
private final AdminUserMapper adminUserMapper;
public List<ShopManageGroupItemVo> list() {
@@ -32,28 +43,22 @@ public class ShopManageGroupService {
}
public List<ShopManageGroupItemVo> list(Long createdById) {
return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.eq(createdById != null && createdById > 0, ShopManageGroupEntity::getCreatedById, createdById)
.orderByAsc(ShopManageGroupEntity::getId))
.stream()
.map(this::toVo)
.toList();
return toVoList(groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.eq(createdById != null && createdById > 0, ShopManageGroupEntity::getCreatedById, createdById)
.orderByAsc(ShopManageGroupEntity::getId)));
}
public List<ShopManageGroupItemVo> listAccessible(Long operatorId, boolean superAdmin) {
if (superAdmin) {
return list();
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.and(wrapper -> wrapper
.eq(ShopManageGroupEntity::getCreatedById, normalizedOperatorId)
.or()
.eq(ShopManageGroupEntity::getUserId, normalizedOperatorId))
.orderByAsc(ShopManageGroupEntity::getId))
.stream()
.map(this::toVo)
.toList();
Set<Long> groupIds = listAccessibleGroupIds(operatorId, false);
if (groupIds.isEmpty()) {
return List.of();
}
return toVoList(groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.in(ShopManageGroupEntity::getId, groupIds)
.orderByAsc(ShopManageGroupEntity::getId)));
}
public Set<Long> listAccessibleGroupIds(Long operatorId, boolean superAdmin) {
@@ -63,69 +68,80 @@ public class ShopManageGroupService {
.stream()
.map(ShopManageGroupEntity::getId)
.filter(id -> id != null && id > 0)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
.collect(Collectors.toCollection(LinkedHashSet::new));
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
LinkedHashSet<Long> groupIds = groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
.select(ShopManageGroupEntity::getId)
.and(wrapper -> wrapper
.eq(ShopManageGroupEntity::getCreatedById, normalizedOperatorId)
.or()
.eq(ShopManageGroupEntity::getUserId, normalizedOperatorId)))
.eq(ShopManageGroupEntity::getCreatedById, normalizedOperatorId))
.stream()
.map(ShopManageGroupEntity::getId)
.filter(id -> id != null && id > 0)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
.collect(Collectors.toCollection(LinkedHashSet::new));
groupIds.addAll(groupMemberMapper.selectList(new LambdaQueryWrapper<ShopManageGroupMemberEntity>()
.select(ShopManageGroupMemberEntity::getGroupId)
.eq(ShopManageGroupMemberEntity::getUserId, normalizedOperatorId))
.stream()
.map(ShopManageGroupMemberEntity::getGroupId)
.filter(id -> id != null && id > 0)
.collect(Collectors.toCollection(LinkedHashSet::new)));
return groupIds;
}
@Transactional
public ShopManageGroupItemVo create(ShopManageGroupCreateRequest request, Long operatorId, boolean superAdmin) {
Long createdById = normalizePositiveId(request.getCreatedById(), "创建人不能为空");
AdminUserEntity bindUser = getBindableUser(request.getUserId(), operatorId, superAdmin);
validateLeaderOperator(createdById, operatorId, superAdmin);
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
ensureGroupNameMatchesUser(groupName, bindUser);
ensureGroupNameUnique(groupName, null);
ensureUserUnique(request.getUserId(), null);
ShopManageGroupEntity entity = new ShopManageGroupEntity();
entity.setGroupName(groupName);
entity.setUserId(bindUser.getId());
entity.setCreatedById(createdById);
groupMapper.insert(entity);
replaceMembers(entity.getId(), request.getMemberUserIds(), createdById, operatorId, superAdmin);
return toVo(getById(entity.getId()));
}
@Transactional
public ShopManageGroupItemVo update(Long id, Long operatorId, boolean superAdmin, ShopManageGroupUpdateRequest request) {
ShopManageGroupEntity entity = getById(id);
validateOwnership(entity, operatorId, superAdmin);
AdminUserEntity bindUser = getBindableUser(request.getUserId(), operatorId, superAdmin);
ShopManageGroupEntity entity = getManageableById(id, operatorId, superAdmin);
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
ensureGroupNameMatchesUser(groupName, bindUser);
ensureGroupNameUnique(groupName, id);
ensureUserUnique(bindUser.getId(), id);
entity.setGroupName(groupName);
entity.setUserId(bindUser.getId());
groupMapper.updateById(entity);
replaceMembers(entity.getId(), request.getMemberUserIds(), entity.getCreatedById(), operatorId, superAdmin);
return toVo(getById(id));
}
@Transactional
public void delete(Long id, Long operatorId, boolean superAdmin) {
ShopManageGroupEntity entity = getById(id);
validateOwnership(entity, operatorId, superAdmin);
ShopManageGroupEntity entity = getManageableById(id, operatorId, superAdmin);
Long useCount = shopManageMapper.selectCount(new LambdaQueryWrapper<ShopManageEntity>()
.eq(ShopManageEntity::getGroupId, entity.getId()));
if (useCount != null && useCount > 0) {
throw new BusinessException("该分组下存在店铺,无法删除");
}
Long skipPriceAsinCount = skipPriceAsinMapper.selectCount(new LambdaQueryWrapper<SkipPriceAsinEntity>()
.eq(SkipPriceAsinEntity::getGroupId, entity.getId()));
if (skipPriceAsinCount != null && skipPriceAsinCount > 0) {
throw new BusinessException("该分组下存在跳过跟价 ASIN无法删除");
}
groupMemberMapper.delete(new LambdaQueryWrapper<ShopManageGroupMemberEntity>()
.eq(ShopManageGroupMemberEntity::getGroupId, entity.getId()));
groupMapper.deleteById(entity.getId());
}
public ShopManageGroupEntity getAccessibleById(Long id, Long operatorId, boolean superAdmin) {
ShopManageGroupEntity entity = getById(id);
validateOwnership(entity, operatorId, superAdmin);
validateAccess(entity, operatorId, superAdmin);
return entity;
}
public ShopManageGroupEntity getManageableById(Long id, Long operatorId, boolean superAdmin) {
ShopManageGroupEntity entity = getById(id);
validateManageAccess(entity, operatorId, superAdmin);
return entity;
}
@@ -149,56 +165,105 @@ public class ShopManageGroupService {
}
}
private void ensureUserUnique(Long userId, Long excludeId) {
LambdaQueryWrapper<ShopManageGroupEntity> query = new LambdaQueryWrapper<ShopManageGroupEntity>()
.eq(ShopManageGroupEntity::getUserId, userId);
if (excludeId != null) {
query.ne(ShopManageGroupEntity::getId, excludeId);
}
Long count = groupMapper.selectCount(query);
if (count != null && count > 0) {
throw new BusinessException("该用户已绑定分组");
}
}
private void ensureGroupNameMatchesUser(String groupName, AdminUserEntity bindUser) {
if (bindUser == null || bindUser.getUsername() == null || !groupName.equals(bindUser.getUsername().trim())) {
throw new BusinessException("分组名称必须与关联用户名一致");
}
}
private void validateOwnership(ShopManageGroupEntity entity, Long operatorId, boolean superAdmin) {
private void validateAccess(ShopManageGroupEntity entity, Long operatorId, boolean superAdmin) {
if (superAdmin) {
return;
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
boolean createdBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId);
boolean boundToSelf = entity.getUserId() != null && entity.getUserId().equals(normalizedOperatorId);
if (!createdBySelf && !boundToSelf) {
boolean memberOfGroup = isGroupMember(entity.getId(), normalizedOperatorId);
if (!createdBySelf && !memberOfGroup) {
throw new BusinessException("只能操作自己有权限的分组数据");
}
}
private AdminUserEntity getBindableUser(Long userId, Long operatorId, boolean superAdmin) {
Long normalizedUserId = normalizePositiveId(userId, "关联用户不能为空");
AdminUserEntity user = adminUserMapper.selectById(normalizedUserId);
if (user == null) {
throw new BusinessException("关联用户不存在");
}
private void validateManageAccess(ShopManageGroupEntity entity, Long operatorId, boolean superAdmin) {
if (superAdmin) {
return user;
return;
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
boolean self = user.getId() != null && user.getId().equals(normalizedOperatorId);
boolean createdBySelf = user.getCreatedById() != null && user.getCreatedById().equals(normalizedOperatorId);
if (!self && !createdBySelf) {
throw new BusinessException("只能关联自己可见的用户");
boolean createdBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId);
if (!createdBySelf) {
throw new BusinessException("只有组长才能维护分组");
}
return user;
}
private void validateLeaderOperator(Long leaderId, Long operatorId, boolean superAdmin) {
if (superAdmin) {
return;
}
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
if (!normalizedOperatorId.equals(leaderId)) {
throw new BusinessException("普通管理员只能创建自己作为组长的分组");
}
}
private boolean isGroupMember(Long groupId, Long userId) {
if (groupId == null || userId == null || groupId <= 0 || userId <= 0) {
return false;
}
Long count = groupMemberMapper.selectCount(new LambdaQueryWrapper<ShopManageGroupMemberEntity>()
.eq(ShopManageGroupMemberEntity::getGroupId, groupId)
.eq(ShopManageGroupMemberEntity::getUserId, userId));
return count != null && count > 0;
}
private void replaceMembers(Long groupId, List<Long> memberUserIds, Long leaderId, Long operatorId, boolean superAdmin) {
List<AdminUserEntity> members = resolveMembers(memberUserIds, leaderId, operatorId, superAdmin, groupId);
groupMemberMapper.delete(new LambdaQueryWrapper<ShopManageGroupMemberEntity>()
.eq(ShopManageGroupMemberEntity::getGroupId, groupId));
for (AdminUserEntity member : members) {
ShopManageGroupMemberEntity entity = new ShopManageGroupMemberEntity();
entity.setGroupId(groupId);
entity.setUserId(member.getId());
groupMemberMapper.insert(entity);
}
}
private List<AdminUserEntity> resolveMembers(List<Long> memberUserIds, Long leaderId, Long operatorId,
boolean superAdmin, Long currentGroupId) {
validateLeaderOperator(leaderId, operatorId, superAdmin);
if (memberUserIds == null || memberUserIds.isEmpty()) {
return List.of();
}
AdminUserEntity operatorUser = operatorId == null ? null : adminUserMapper.selectById(operatorId);
boolean operatorCanBindAllNormals = superAdmin
|| (operatorUser != null && "admin".equalsIgnoreCase(normalizeBlank(operatorUser.getUsername())));
LinkedHashSet<Long> uniqueIds = new LinkedHashSet<>();
for (Long memberUserId : memberUserIds) {
uniqueIds.add(normalizePositiveId(memberUserId, "组员用户不能为空"));
}
List<AdminUserEntity> members = new ArrayList<>();
for (Long memberUserId : uniqueIds) {
AdminUserEntity user = adminUserMapper.selectById(memberUserId);
if (user == null) {
throw new BusinessException("组员用户不存在");
}
if (leaderId != null && leaderId.equals(user.getId())) {
throw new BusinessException("组长不能作为组员重复加入分组");
}
if (!superAdmin && !"normal".equals(normalizeBlank(user.getRole()))) {
throw new BusinessException("只能绑定普通员工账号");
}
if (!operatorCanBindAllNormals && (user.getCreatedById() == null || !user.getCreatedById().equals(leaderId))) {
throw new BusinessException("只能添加组长自己创建的普通员工账号");
}
LambdaQueryWrapper<ShopManageGroupMemberEntity> query = new LambdaQueryWrapper<ShopManageGroupMemberEntity>()
.eq(ShopManageGroupMemberEntity::getUserId, user.getId());
if (currentGroupId != null) {
query.ne(ShopManageGroupMemberEntity::getGroupId, currentGroupId);
}
Long count = groupMemberMapper.selectCount(query);
if (count != null && count > 0) {
throw new BusinessException("该普通员工账号已加入其他分组");
}
members.add(user);
}
return members;
}
private String normalizeRequired(String value, String message) {
String normalized = value == null ? "" : value.trim();
String normalized = normalizeBlank(value);
if (normalized.isEmpty()) {
throw new BusinessException(message);
}
@@ -212,15 +277,82 @@ public class ShopManageGroupService {
return value;
}
private String normalizeBlank(String value) {
return value == null ? "" : value.trim();
}
private ShopManageGroupItemVo toVo(ShopManageGroupEntity entity) {
ShopManageGroupItemVo vo = new ShopManageGroupItemVo();
vo.setId(entity.getId());
vo.setGroupName(entity.getGroupName());
vo.setUserId(entity.getUserId());
AdminUserEntity bindUser = entity.getUserId() == null ? null : adminUserMapper.selectById(entity.getUserId());
vo.setUsername(bindUser == null ? "" : (bindUser.getUsername() == null ? "" : bindUser.getUsername()));
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
return toVoList(List.of(entity)).stream().findFirst().orElseThrow();
}
private List<ShopManageGroupItemVo> toVoList(List<ShopManageGroupEntity> groups) {
if (groups == null || groups.isEmpty()) {
return List.of();
}
List<Long> groupIds = groups.stream()
.map(ShopManageGroupEntity::getId)
.filter(id -> id != null && id > 0)
.toList();
Map<Long, List<ShopManageGroupMemberEntity>> membersByGroupId = groupIds.isEmpty()
? Map.of()
: groupMemberMapper.selectList(new LambdaQueryWrapper<ShopManageGroupMemberEntity>()
.in(ShopManageGroupMemberEntity::getGroupId, groupIds)
.orderByAsc(ShopManageGroupMemberEntity::getId))
.stream()
.collect(Collectors.groupingBy(
ShopManageGroupMemberEntity::getGroupId,
LinkedHashMap::new,
Collectors.toList()));
LinkedHashSet<Long> userIds = new LinkedHashSet<>();
for (ShopManageGroupEntity group : groups) {
if (group.getCreatedById() != null && group.getCreatedById() > 0) {
userIds.add(group.getCreatedById());
}
}
for (List<ShopManageGroupMemberEntity> members : membersByGroupId.values()) {
for (ShopManageGroupMemberEntity member : members) {
if (member.getUserId() != null && member.getUserId() > 0) {
userIds.add(member.getUserId());
}
}
}
Map<Long, AdminUserEntity> userById = userIds.isEmpty()
? Map.of()
: adminUserMapper.selectBatchIds(userIds).stream()
.filter(user -> user.getId() != null)
.collect(Collectors.toMap(AdminUserEntity::getId, user -> user, (left, right) -> left, LinkedHashMap::new));
List<ShopManageGroupItemVo> result = new ArrayList<>();
for (ShopManageGroupEntity entity : groups) {
ShopManageGroupItemVo vo = new ShopManageGroupItemVo();
vo.setId(entity.getId());
vo.setGroupName(entity.getGroupName());
vo.setLeaderUserId(entity.getCreatedById());
AdminUserEntity leaderUser = entity.getCreatedById() == null ? null : userById.get(entity.getCreatedById());
vo.setLeaderUsername(leaderUser == null ? "" : normalizeBlank(leaderUser.getUsername()));
List<ShopManageGroupMemberEntity> memberEntities = membersByGroupId.getOrDefault(entity.getId(), Collections.emptyList());
List<Long> memberUserIds = memberEntities.stream()
.map(ShopManageGroupMemberEntity::getUserId)
.filter(id -> id != null && id > 0)
.toList();
List<String> memberUsernames = memberUserIds.stream()
.map(userById::get)
.map(user -> user == null ? "" : normalizeBlank(user.getUsername()))
.toList();
vo.setMemberUserIds(memberUserIds);
vo.setMemberUsernames(memberUsernames);
vo.setMemberCount(memberUserIds.size());
vo.setUserId(memberUserIds.isEmpty() ? null : memberUserIds.get(0));
vo.setUsername(memberUsernames.isEmpty() ? "" : memberUsernames.get(0));
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
result.add(vo);
}
return result;
}
}

View File

@@ -173,7 +173,10 @@ public class ShopMatchController {
@Operation(summary = "提交匹配结果", description = "Python 可多次分批回传结果;后端会组装文件并在超时场景自动补偿收尾。")
public ApiResponse<Void> submitResult(
@Parameter(description = "任务主键", example = "200") @PathVariable Long taskId,
@Valid @RequestBody ShopMatchSubmitResultRequest request) {
@Valid @RequestBody ShopMatchSubmitResultRequest request,
jakarta.servlet.http.HttpServletResponse response) {
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("application/json;charset=UTF-8");
shopMatchTaskService.submitResult(taskId, request);
return ApiResponse.success(null);
}

View File

@@ -2,7 +2,9 @@ package com.nanri.aiimage.modules.shopmatch.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchShopPayloadDto;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -14,10 +16,12 @@ import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
@@ -25,8 +29,9 @@ import java.util.Map;
public class ShopMatchTaskCacheService {
private static final long PAYLOAD_TTL_HOURS = 24;
private final ObjectMapper objectMapper;
private final TaskPressureProperties taskPressureProperties;
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
public ShopMatchShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
@@ -127,9 +132,11 @@ public class ShopMatchTaskCacheService {
if (taskId == null || taskId <= 0) {
return;
}
taskEntityLocalCache.remove(taskId);
try {
Path taskDir = buildTaskDir(taskId);
if (!Files.exists(taskDir)) {
Files.deleteIfExists(buildTaskEntityFile(taskId));
return;
}
try (var walk = Files.walk(taskDir)) {
@@ -145,6 +152,69 @@ public class ShopMatchTaskCacheService {
} catch (Exception ex) {
log.warn("[shop-match] delete task cache failed taskId={} msg={}", taskId, ex.getMessage());
}
try {
Files.deleteIfExists(buildTaskEntityFile(taskId));
} catch (IOException ignored) {
}
}
public void saveTaskCache(FileTaskEntity task) {
if (task == null || task.getId() == null) {
return;
}
long now = System.currentTimeMillis();
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
now,
objectMapper.convertValue(task, FileTaskEntity.class)
));
try {
Files.createDirectories(buildTaskDir(task.getId()));
Files.writeString(
buildTaskEntityFile(task.getId()),
objectMapper.writeValueAsString(task),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE
);
} catch (Exception ignored) {
}
}
public Map<Long, FileTaskEntity> getTaskCacheBatch(java.util.List<Long> taskIds) {
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
return result;
}
java.util.List<Long> normalized = taskIds.stream()
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (normalized.isEmpty()) {
return result;
}
long now = System.currentTimeMillis();
java.util.List<Long> missingIds = new ArrayList<>();
for (Long taskId : normalized) {
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
if (isLocalCacheFresh(cached, now)) {
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
} else {
missingIds.add(taskId);
}
}
for (Long taskId : missingIds) {
Path file = buildTaskEntityFile(taskId);
if (!Files.isRegularFile(file)) {
continue;
}
try {
FileTaskEntity task = objectMapper.readValue(Files.readString(file), FileTaskEntity.class);
result.put(taskId, task);
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
} catch (Exception ignored) {
}
}
return result;
}
public Duration getPayloadTtl() {
@@ -159,6 +229,10 @@ public class ShopMatchTaskCacheService {
return buildTaskDir(taskId).resolve(hashShopKey(shopKey) + ".json");
}
private Path buildTaskEntityFile(Long taskId) {
return buildTaskDir(taskId).resolve("_task-entity.json");
}
private String hashShopKey(String shopKey) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
@@ -181,4 +255,11 @@ public class ShopMatchTaskCacheService {
this.payload = payload;
}
}
private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
return cached != null
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
}
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
}

View File

@@ -5,6 +5,7 @@ import cn.hutool.core.util.IdUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCreateTaskVo;
@@ -60,6 +61,71 @@ public class ShopMatchTaskService {
private final OssStorageService ossStorageService;
private final ObjectMapper objectMapper;
private final ShopMatchTaskCacheService shopMatchTaskCacheService;
private final TaskPressureProperties taskPressureProperties;
private FileTaskEntity loadTaskForExecution(Long taskId) {
Map<Long, FileTaskEntity> cachedTasks = shopMatchTaskCacheService.getTaskCacheBatch(List.of(taskId));
FileTaskEntity cached = cachedTasks.get(taskId);
if (cached != null) {
if (!MODULE_TYPE.equals(cached.getModuleType())
|| (cached.getRequestJson() != null && !cached.getRequestJson().isBlank())) {
return cached;
}
FileTaskEntity dbTask = fileTaskMapper.selectById(taskId);
if (dbTask != null && MODULE_TYPE.equals(dbTask.getModuleType())) {
shopMatchTaskCacheService.saveTaskCache(dbTask);
return dbTask;
}
return cached;
}
FileTaskEntity dbTask = fileTaskMapper.selectById(taskId);
if (dbTask != null && MODULE_TYPE.equals(dbTask.getModuleType())) {
shopMatchTaskCacheService.saveTaskCache(dbTask);
}
return dbTask;
}
private Map<Long, FileTaskEntity> loadTaskMapByIds(List<Long> taskIds) {
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
if (taskIds == null || taskIds.isEmpty()) {
return result;
}
List<Long> normalizedTaskIds = taskIds.stream()
.filter(taskId -> taskId != null && taskId > 0)
.distinct()
.toList();
if (normalizedTaskIds.isEmpty()) {
return result;
}
Map<Long, FileTaskEntity> cachedTasks = shopMatchTaskCacheService.getTaskCacheBatch(normalizedTaskIds);
result.putAll(cachedTasks);
List<Long> missingTaskIds = normalizedTaskIds.stream()
.filter(taskId -> !cachedTasks.containsKey(taskId))
.toList();
for (FileTaskEntity dbTask : selectTasksByIdsInBatches(missingTaskIds)) {
if (dbTask == null || !MODULE_TYPE.equals(dbTask.getModuleType())) {
continue;
}
result.put(dbTask.getId(), dbTask);
if ("RUNNING".equals(dbTask.getStatus()) || "SCHEDULED".equals(dbTask.getStatus())) {
shopMatchTaskCacheService.saveTaskCache(dbTask);
}
}
return result;
}
private List<FileTaskEntity> selectTasksByIdsInBatches(List<Long> taskIds) {
List<FileTaskEntity> tasks = new ArrayList<>();
if (taskIds == null || taskIds.isEmpty()) {
return tasks;
}
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
for (int start = 0; start < taskIds.size(); start += batchSize) {
int end = Math.min(start + batchSize, taskIds.size());
tasks.addAll(fileTaskMapper.selectBatchIds(taskIds.subList(start, end)));
}
return tasks;
}
public ProductRiskDashboardVo dashboard(Long userId) {
if (userId == null || userId <= 0) {
@@ -103,7 +169,7 @@ public class ShopMatchTaskService {
.distinct()
.toList();
if (!taskIds.isEmpty()) {
for (FileTaskEntity task : fileTaskMapper.selectBatchIds(taskIds)) {
for (FileTaskEntity task : selectTasksByIdsInBatches(taskIds)) {
if (task != null) {
statusByTaskId.put(task.getId(), task.getStatus());
}
@@ -138,7 +204,7 @@ public class ShopMatchTaskService {
if (userId == null || userId <= 0) {
throw new BusinessException("user_id 不合法");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
}
@@ -150,7 +216,7 @@ public class ShopMatchTaskService {
}
private void reconcileTaskAfterResultRemoval(Long taskId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return;
}
@@ -171,12 +237,13 @@ public class ShopMatchTaskService {
if (taskIds == null || taskIds.isEmpty()) {
return batch;
}
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(taskIds);
for (Long taskId : taskIds) {
if (taskId == null || taskId <= 0) {
continue;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
FileTaskEntity task = taskMap.get(taskId);
if (task == null) {
batch.getMissingTaskIds().add(taskId);
continue;
}
@@ -190,12 +257,13 @@ public class ShopMatchTaskService {
if (taskIds == null || taskIds.isEmpty()) {
return batch;
}
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(taskIds);
for (Long taskId : taskIds) {
if (taskId == null || taskId <= 0) {
continue;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
FileTaskEntity task = taskMap.get(taskId);
if (task == null) {
batch.getMissingTaskIds().add(taskId);
continue;
}
@@ -270,6 +338,7 @@ public class ShopMatchTaskService {
task.setUpdatedAt(now);
task.setScheduledAt(scheduleTimes.isEmpty() ? null : scheduleTimes.get(0));
fileTaskMapper.insert(task);
shopMatchTaskCacheService.saveTaskCache(task);
List<ProductRiskResultItemVo> snapshot = new ArrayList<>();
for (ProductRiskShopQueueItemVo item : uniqueItems) {
@@ -294,7 +363,7 @@ public class ShopMatchTaskService {
} catch (Exception ex) {
throw new BusinessException("task 不存在");
}
fileTaskMapper.updateById(task);
updateTaskAndRefreshCache(task);
ProductRiskCreateTaskVo vo = new ProductRiskCreateTaskVo();
vo.setTaskId(task.getId());
@@ -313,7 +382,7 @@ public class ShopMatchTaskService {
if (stageIndex == null || stageIndex < 0) {
throw new BusinessException("stage_index 不合法");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
}
@@ -341,7 +410,7 @@ public class ShopMatchTaskService {
task.setStatus("RUNNING");
task.setUpdatedAt(now);
persistTaskRequest(task, state);
fileTaskMapper.updateById(task);
updateTaskAndRefreshCache(task);
}
@Transactional
@@ -355,7 +424,7 @@ public class ShopMatchTaskService {
if (request == null || request.getStageIndex() == null || request.getStageIndex() < 0) {
throw new BusinessException("stage_index 不合法");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
throw new BusinessException("任务不存在");
}
@@ -377,7 +446,7 @@ public class ShopMatchTaskService {
task.setUpdatedAt(now());
task.setFinishedAt(null);
persistTaskRequest(task, state);
fileTaskMapper.updateById(task);
updateTaskAndRefreshCache(task);
}
@@ -390,7 +459,7 @@ public class ShopMatchTaskService {
throw new BusinessException("shops 不能为空");
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
throw new BusinessException("任务不存在");
}
@@ -460,7 +529,7 @@ public class ShopMatchTaskService {
if (taskId == null || taskId <= 0) {
return false;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return false;
}
@@ -640,7 +709,7 @@ public class ShopMatchTaskService {
} catch (Exception ex) {
log.warn("[shop-match] compact result json failed: {}", ex.getMessage());
}
fileTaskMapper.updateById(task);
updateTaskAndRefreshCache(task);
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
shopMatchTaskCacheService.deleteTaskCache(task.getId());
}
@@ -799,12 +868,12 @@ public class ShopMatchTaskService {
}
private LocalDateTime loadScheduledAt(Long taskId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
return task == null ? null : task.getScheduledAt();
}
private void mergeQueueFieldsFromRequest(ProductRiskResultItemVo vo, Long taskId) {
FileTaskEntity task = fileTaskMapper.selectById(taskId);
FileTaskEntity task = loadTaskForExecution(taskId);
ShopMatchCreateTaskRequest request = parseTaskRequestSilently(task);
if (request == null) {
return;
@@ -849,7 +918,8 @@ public class ShopMatchTaskService {
}
try {
return objectMapper.readValue(task.getRequestJson(), ShopMatchCreateTaskRequest.class);
} catch (Exception ignored) {
} catch (Exception ex) {
log.warn("[shop-match] parse task request failed taskId={} msg={}", task.getId(), ex.getMessage());
return null;
}
}
@@ -862,6 +932,11 @@ public class ShopMatchTaskService {
}
}
private void updateTaskAndRefreshCache(FileTaskEntity task) {
fileTaskMapper.updateById(task);
shopMatchTaskCacheService.saveTaskCache(task);
}
private static String fmt(LocalDateTime time) {
return time == null ? null : time.toString();
}

View File

@@ -142,6 +142,26 @@ public class ZiniaoAuthService {
return getOrLoadUserStores(apiKey, companyId, userId);
}
public boolean isIpWhitelistError(BusinessException ex) {
String message = ex == null ? null : ex.getMessage();
if (message == null || message.isBlank()) {
return false;
}
String lowerMessage = message.toLowerCase();
return message.contains("白名单")
|| message.contains("IP白名单")
|| message.contains("ip白名单")
|| message.contains("当前ip")
|| message.contains("当前IP")
|| message.contains("禁止当前ip")
|| message.contains("禁止当前IP")
|| message.contains("禁止访问")
|| lowerMessage.contains("whitelist")
|| lowerMessage.contains("current ip")
|| lowerMessage.contains("forbid")
|| lowerMessage.contains("forbidden");
}
public boolean isInvalidUserStoresError(BusinessException ex) {
String message = ex == null ? null : ex.getMessage();
if (message == null || message.isBlank()) {

View File

@@ -190,12 +190,19 @@ public class ZiniaoShopIndexService {
try {
companyId = ziniaoAuthService.resolveCompanyIdForIndex(apiKey);
} catch (BusinessException ex) {
rethrowIfIpWhitelistError(ex);
log.warn("[ziniao-index] skip apiKey while resolving companyId, msg={}", ex.getMessage());
continue;
}
Set<Long> invalidUserIds = new LinkedHashSet<>();
List<ZiniaoStaffItemVo> staff = ziniaoAuthService.getOrLoadStaffForIndex(apiKey, companyId);
List<ZiniaoStaffItemVo> staff;
try {
staff = ziniaoAuthService.getOrLoadStaffForIndex(apiKey, companyId);
} catch (BusinessException ex) {
rethrowIfIpWhitelistError(ex);
throw ex;
}
for (Long userId : buildUserIds(staff)) {
if (invalidUserIds.contains(userId)) {
continue;
@@ -204,6 +211,7 @@ public class ZiniaoShopIndexService {
try {
stores = ziniaoAuthService.getOrLoadUserStoresForIndex(apiKey, companyId, userId);
} catch (BusinessException ex) {
rethrowIfIpWhitelistError(ex);
if (ziniaoAuthService.isInvalidUserStoresError(ex)) {
invalidUserIds.add(userId);
allInvalidUserIds.add(userId);
@@ -662,4 +670,10 @@ public class ZiniaoShopIndexService {
throw new BusinessException("生成紫鸟 apiKey 缓存键失败");
}
}
private void rethrowIfIpWhitelistError(BusinessException ex) {
if (ziniaoAuthService.isIpWhitelistError(ex)) {
throw new BusinessException("紫鸟刷新店铺索引失败:当前服务器 IP 未加入紫鸟白名单,已停止本轮刷新且不会更新索引数据");
}
}
}

View File

@@ -70,9 +70,11 @@ aiimage:
storage:
local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp}
cleanup-enabled: ${AIIMAGE_STORAGE_CLEANUP_ENABLED:true}
cleanup-cron: ${AIIMAGE_STORAGE_CLEANUP_CRON:0 0 */6 * * *}
source-retention-hours: ${AIIMAGE_STORAGE_SOURCE_RETENTION_HOURS:48}
cleanup-cron: ${AIIMAGE_STORAGE_CLEANUP_CRON:0 0 * * * *}
source-retention-hours: ${AIIMAGE_STORAGE_SOURCE_RETENTION_HOURS:24}
result-retention-hours: ${AIIMAGE_STORAGE_RESULT_RETENTION_HOURS:24}
temp-dir:
retention-hours: ${AIIMAGE_TEMP_DIR_RETENTION_HOURS:24}
brand-progress:
ttl-hours: ${AIIMAGE_BRAND_PROGRESS_TTL_HOURS:24}
failed-ttl-hours: ${AIIMAGE_BRAND_PROGRESS_FAILED_TTL_HOURS:2}
@@ -88,10 +90,15 @@ aiimage:
price-track-initial-timeout-minutes: ${AIIMAGE_PRICE_TRACK_INITIAL_TIMEOUT_MINUTES:20}
shop-match-stale-timeout-minutes: ${AIIMAGE_SHOP_MATCH_STALE_TIMEOUT_MINUTES:20}
shop-match-initial-timeout-minutes: ${AIIMAGE_SHOP_MATCH_INITIAL_TIMEOUT_MINUTES:20}
patrol-delete-stale-timeout-minutes: ${AIIMAGE_PATROL_DELETE_STALE_TIMEOUT_MINUTES:20}
patrol-delete-initial-timeout-minutes: ${AIIMAGE_PATROL_DELETE_INITIAL_TIMEOUT_MINUTES:20}
module-cleanup:
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *}
module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE,PRICE_TRACK,SHOP_MATCH}
module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE,PRICE_TRACK,SHOP_MATCH,PATROL_DELETE}
task-pressure:
local-task-entity-cache-millis: ${AIIMAGE_TASK_LOCAL_ENTITY_CACHE_MILLIS:3000}
db-select-batch-size: ${AIIMAGE_TASK_DB_SELECT_BATCH_SIZE:200}
security:
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS biz_patrol_delete_shop_candidate (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'primary key',
user_id BIGINT NOT NULL COMMENT 'user id',
shop_name VARCHAR(255) NOT NULL COMMENT 'normalized shop name',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time',
UNIQUE KEY uk_user_shop (user_id, shop_name),
KEY idx_user_id (user_id)
) COMMENT='patrol delete candidate shops';

View File

@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS biz_shop_manage_group_member (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
group_id BIGINT NOT NULL COMMENT '分组ID',
user_id BIGINT NOT NULL COMMENT '组员用户ID',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_user_id (user_id),
KEY idx_group_id (group_id)
) COMMENT='店铺管理分组成员表';
INSERT INTO biz_shop_manage_group_member (group_id, user_id)
SELECT g.id, g.user_id
FROM biz_shop_manage_group g
LEFT JOIN biz_shop_manage_group_member m ON m.user_id = g.user_id
WHERE g.user_id IS NOT NULL
AND g.user_id > 0
AND m.id IS NULL;

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<springProperty scope="context" name="LOG_PATH" source="logging.file.path" defaultValue="./logs"/>
<springProperty scope="context" name="LOG_NAME" source="logging.file.name" defaultValue="aiimage-backend.log"/>
<springProperty scope="context" name="ROOT_LOG_LEVEL" source="logging.level.root" defaultValue="INFO"/>
<springProperty scope="context" name="APP_LOG_LEVEL" source="logging.level.com.nanri.aiimage" defaultValue="INFO"/>
<springProperty scope="context" name="FILE_PATTERN" source="logging.pattern.file" defaultValue="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
<springProperty scope="context" name="CONSOLE_PATTERN" source="logging.pattern.console" defaultValue="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
<property name="APP_LOG_FILE" value="${LOG_NAME}"/>
<property name="APP_LOG_ARCHIVE" value="${LOG_NAME}.%d{yyyy-MM-dd}.%i"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${APP_LOG_FILE}</file>
<encoder>
<pattern>${FILE_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${APP_LOG_ARCHIVE}</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>1</maxHistory>
<totalSizeCap>500MB</totalSizeCap>
<cleanHistoryOnStart>true</cleanHistoryOnStart>
</rollingPolicy>
</appender>
<logger name="com.nanri.aiimage" level="${APP_LOG_LEVEL}"/>
<root level="${ROOT_LOG_LEVEL}">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>

View File

@@ -406,12 +406,13 @@ def list_users():
search_username = (request.args.get('username') or request.args.get('search') or '').strip()
created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id')
created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None
if role != 'super_admin':
can_view_all_users = role == 'super_admin' or (current_row and (current_row.get('username') or '').strip().lower() == 'admin')
if not can_view_all_users:
created_by_id = None
try:
conn = get_db()
with conn.cursor() as cur:
if role == 'super_admin':
if can_view_all_users:
where_parts = ["1=1"]
params = []
if search_username:
@@ -1526,6 +1527,11 @@ def list_shop_manage_groups():
{
'id': item.get('id'),
'group_name': item.get('groupName') or '',
'leader_user_id': item.get('leaderUserId'),
'leader_username': item.get('leaderUsername') or '',
'member_user_ids': item.get('memberUserIds') or [],
'member_usernames': item.get('memberUsernames') or [],
'member_count': item.get('memberCount') or 0,
'user_id': item.get('userId'),
'username': item.get('username') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
@@ -1545,7 +1551,7 @@ def create_shop_manage_group():
data = request.get_json() or {}
payload = {
'groupName': (data.get('group_name') or '').strip(),
'userId': data.get('user_id'),
'memberUserIds': data.get('member_user_ids') or [],
'createdById': current_row.get('id') if current_row else None,
}
result, error_response, status = _proxy_backend_java(
@@ -1566,6 +1572,11 @@ def create_shop_manage_group():
'item': {
'id': item.get('id'),
'group_name': item.get('groupName') or '',
'leader_user_id': item.get('leaderUserId'),
'leader_username': item.get('leaderUsername') or '',
'member_user_ids': item.get('memberUserIds') or [],
'member_usernames': item.get('memberUsernames') or [],
'member_count': item.get('memberCount') or 0,
'user_id': item.get('userId'),
'username': item.get('username') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
@@ -1583,7 +1594,7 @@ def update_shop_manage_group(item_id):
data = request.get_json() or {}
payload = {
'groupName': (data.get('group_name') or '').strip(),
'userId': data.get('user_id'),
'memberUserIds': data.get('member_user_ids') or [],
}
result, error_response, status = _proxy_backend_java(
'PUT',
@@ -1603,6 +1614,11 @@ def update_shop_manage_group(item_id):
'item': {
'id': item.get('id'),
'group_name': item.get('groupName') or '',
'leader_user_id': item.get('leaderUserId'),
'leader_username': item.get('leaderUsername') or '',
'member_user_ids': item.get('memberUserIds') or [],
'member_usernames': item.get('memberUsernames') or [],
'member_count': item.get('memberCount') or 0,
'user_id': item.get('userId'),
'username': item.get('username') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],

View File

@@ -314,6 +314,10 @@
display: flex;
}
#shopManageGroupModal {
z-index: 1010;
}
.modal {
background: #fff;
border-radius: 8px;
@@ -948,17 +952,24 @@
<h3>分组管理</h3>
<div class="form-row">
<input type="hidden" id="shopManageGroupEditId">
<input type="hidden" id="shopManageGroupLeaderUserId">
<div class="form-group" style="min-width:220px;">
<label>关联用户</label>
<select id="shopManageGroupUserSelect">
<option value="">请选择用户</option>
</select>
<label>组长</label>
<input type="text" id="shopManageGroupLeaderName" placeholder="当前登录用户" readonly
style="background:#f5f5f5;">
</div>
<div class="form-group" style="min-width:260px;">
<label>分组名称</label>
<input type="text" id="shopManageGroupInput" placeholder="选择用户后自动带出" readonly
style="background:#f5f5f5;">
<input type="text" id="shopManageGroupInput" placeholder="请输入分组名称">
</div>
<div class="form-group" style="min-width:280px;flex:1;">
<label>组员</label>
<select id="shopManageGroupMemberSelect" multiple size="6" style="min-height:140px;">
</select>
<div style="color:#888;font-size:12px;margin-top:6px;">可添加当前组长创建的普通员工账号,按住 Ctrl 或 Command 可多选。</div>
</div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-bottom:12px;">
<button class="btn" id="btnSaveShopManageGroup" type="button">保存分组</button>
<button class="btn btn-secondary" id="btnCancelShopManageGroupEdit" type="button">取消编辑</button>
</div>
@@ -968,7 +979,9 @@
<tr>
<th>序号</th>
<th>分组名称</th>
<th>关联用户</th>
<th>组长</th>
<th>组员数量</th>
<th>组员</th>
<th>创建时间</th>
<th>修改时间</th>
<th>操作</th>
@@ -1658,43 +1671,61 @@
sel.value = cur || '';
});
}
var shopManageGroupUsers = [];
function refreshShopManageGroupUserSelect(selectedUserId) {
var sel = document.getElementById('shopManageGroupUserSelect');
if (!sel) return;
var opts = ['<option value="">请选择用户</option>'];
shopManageGroupUsers.forEach(function (u) {
opts.push('<option value="' + u.id + '">' + (u.username || '') + '</option>');
var shopManageAllUsers = [];
function getEligibleShopManageGroupUsers(leaderUserId) {
var canViewAllMembers = currentUserRole === 'super_admin';
return shopManageAllUsers.filter(function (u) {
if (String(u.id || '') === String(leaderUserId || '')) return false;
if (canViewAllMembers) return true;
return String(u.role || '') === 'normal' &&
String(u.created_by_id || '') === String(leaderUserId || '');
});
sel.innerHTML = opts.join('');
if (selectedUserId != null && selectedUserId !== '') {
sel.value = String(selectedUserId);
}
syncShopManageGroupNameWithUser();
}
function syncShopManageGroupNameWithUser() {
var sel = document.getElementById('shopManageGroupUserSelect');
var input = document.getElementById('shopManageGroupInput');
if (!sel || !input) return;
var selectedUser = null;
shopManageGroupUsers.some(function (u) {
if (String(u.id) === String(sel.value || '')) {
selectedUser = u;
return true;
}
return false;
function refreshShopManageGroupMemberSelect(leaderUserId, selectedUserIds) {
var sel = document.getElementById('shopManageGroupMemberSelect');
if (!sel) return;
var selectedMap = {};
(selectedUserIds || []).forEach(function (id) {
selectedMap[String(id)] = true;
});
input.value = selectedUser ? (selectedUser.username || '') : '';
var options = getEligibleShopManageGroupUsers(leaderUserId).map(function (u) {
return '<option value="' + u.id + '"' + (selectedMap[String(u.id)] ? ' selected' : '') + '>' + (u.username || '') + '</option>';
});
sel.innerHTML = options.join('');
}
function setShopManageGroupLeader(leaderUserId, leaderUsername, selectedUserIds) {
var leaderIdEl = document.getElementById('shopManageGroupLeaderUserId');
var leaderNameEl = document.getElementById('shopManageGroupLeaderName');
if (leaderIdEl) leaderIdEl.value = leaderUserId ? String(leaderUserId) : '';
if (leaderNameEl) leaderNameEl.value = leaderUsername || '';
refreshShopManageGroupMemberSelect(leaderUserId, selectedUserIds || []);
}
function getSelectedShopManageGroupMemberIds() {
var sel = document.getElementById('shopManageGroupMemberSelect');
if (!sel) return [];
return Array.prototype.slice.call(sel.options || [])
.filter(function (opt) { return !!opt.selected; })
.map(function (opt) { return Number(opt.value); })
.filter(function (id) { return !!id; });
}
function loadUserOptions() {
return fetch('/api/admin/users?page=1&page_size=999')
.then(function (r) { return r.json(); })
.then(function (res) {
var items = res.items || [];
shopManageGroupUsers = items.map(function (u) {
return { id: u.id, username: u.username || '', role: u.role || 'normal' };
shopManageAllUsers = items.map(function (u) {
return {
id: u.id,
username: u.username || '',
role: u.role || 'normal',
created_by_id: u.created_by_id || null
};
});
refreshShopManageGroupUserSelect(document.getElementById('shopManageGroupUserSelect') ? document.getElementById('shopManageGroupUserSelect').value : '');
setShopManageGroupLeader(
document.getElementById('shopManageGroupLeaderUserId') ? document.getElementById('shopManageGroupLeaderUserId').value : '',
document.getElementById('shopManageGroupLeaderName') ? document.getElementById('shopManageGroupLeaderName').value : '',
getSelectedShopManageGroupMemberIds()
);
var sel = document.getElementById('filterUser');
var cur = sel.value;
sel.innerHTML = '<option value="">全部用户</option>';
@@ -2163,107 +2194,6 @@
return query;
}
function refreshShopGroupSelects(selectedCreateId, selectedEditId) {
var createSel = document.getElementById('shopManageGroupSelect');
var editSel = document.getElementById('editShopManageGroupSelect');
var filterSel = document.getElementById('shopManageFilterGroupId');
var skipCreateSel = document.getElementById('skipPriceAsinGroupSelect');
var skipFilterSel = document.getElementById('skipPriceAsinFilterGroupId');
var chooseSkipShopSel = document.getElementById('chooseSkipPriceAsinShopGroupId');
var selectedFilterId = filterSel ? filterSel.value : '';
var selectedSkipCreateId = skipCreateSel ? skipCreateSel.value : '';
var selectedSkipFilterId = skipFilterSel ? skipFilterSel.value : '';
var selectedChooseSkipShopId = chooseSkipShopSel ? chooseSkipShopSel.value : '';
var createOpts = ['<option value="">请选择分组</option>'];
var filterOpts = ['<option value="">全部分组</option>'];
shopManageGroups.forEach(function (g) {
var option = '<option value="' + g.id + '">' + (g.group_name || '') + '</option>';
createOpts.push(option);
filterOpts.push(option);
});
createSel.innerHTML = createOpts.join('');
editSel.innerHTML = createOpts.join('');
if (skipCreateSel) skipCreateSel.innerHTML = createOpts.join('');
if (filterSel) filterSel.innerHTML = filterOpts.join('');
if (skipFilterSel) skipFilterSel.innerHTML = filterOpts.join('');
if (chooseSkipShopSel) chooseSkipShopSel.innerHTML = filterOpts.join('');
if (selectedCreateId != null) createSel.value = String(selectedCreateId);
if (selectedEditId != null) editSel.value = String(selectedEditId);
if (filterSel && selectedFilterId) filterSel.value = selectedFilterId;
if (skipCreateSel && selectedSkipCreateId) skipCreateSel.value = selectedSkipCreateId;
if (skipFilterSel && selectedSkipFilterId) skipFilterSel.value = selectedSkipFilterId;
if (chooseSkipShopSel && selectedChooseSkipShopId) chooseSkipShopSel.value = selectedChooseSkipShopId;
}
function loadShopManageGroups(selectedCreateId, selectedEditId) {
return fetch('/api/admin/shop-manage-groups')
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.success) throw new Error(res.error || '加载分组失败');
shopManageGroups = res.items || [];
refreshShopGroupSelects(selectedCreateId, selectedEditId);
return shopManageGroups;
})
.catch(function () {
shopManageGroups = [];
refreshShopGroupSelects();
return [];
});
}
function openShopManageGroupModal() {
document.getElementById('shopManageGroupEditId').value = '';
document.getElementById('shopManageGroupInput').value = '';
document.getElementById('msgShopManageGroup').textContent = '';
document.getElementById('msgShopManageGroup').className = 'msg';
loadShopManageGroups().then(function () {
renderShopManageGroupRows();
document.getElementById('shopManageGroupModal').classList.add('show');
});
}
function renderShopManageGroupRows() {
var tbody = document.getElementById('shopManageGroupListBody');
if (!shopManageGroups.length) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">暂无分组</td></tr>';
return;
}
tbody.innerHTML = shopManageGroups.map(function (item, index) {
return '<tr><td>' + (index + 1) + '</td><td>' + (item.group_name || '') + '</td><td>' + (item.created_at || '') + '</td><td>' + (item.updated_at || '') + '</td><td>' +
'<button class="btn btn-sm" data-shop-group-edit="' + item.id + '" data-shop-group-name="' + (item.group_name || '').replace(/"/g, '&quot;') + '">编辑</button> ' +
'<button class="btn btn-sm btn-danger" data-shop-group-delete="' + item.id + '" data-shop-group-name="' + (item.group_name || '').replace(/"/g, '&quot;') + '">删除</button>' +
'</td></tr>';
}).join('');
document.querySelectorAll('[data-shop-group-edit]').forEach(function (btn) {
btn.onclick = function () {
document.getElementById('shopManageGroupEditId').value = btn.dataset.shopGroupEdit;
document.getElementById('shopManageGroupInput').value = (btn.dataset.shopGroupName || '').replace(/&quot;/g, '"');
};
});
document.querySelectorAll('[data-shop-group-delete]').forEach(function (btn) {
btn.onclick = function () {
var gid = btn.dataset.shopGroupDelete;
var gname = (btn.dataset.shopGroupName || '').replace(/&quot;/g, '"');
if (!confirm('确定删除分组“' + gname + '”吗?')) return;
fetch('/api/admin/shop-manage-group/' + gid, { method: 'DELETE' })
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.success) {
alert(res.error || '删除失败');
return;
}
loadShopManageGroups().then(function () {
renderShopManageGroupRows();
loadShopManage(shopManagePage);
loadSkipPriceAsin(skipPriceAsinPage);
});
});
};
});
}
function loadShopManage(page) {
shopManagePage = page || 1;
fetch('/api/admin/shop-manages?' + buildShopManageQuery(shopManagePage))
@@ -2325,65 +2255,6 @@
});
}
document.getElementById('btnManageShopGroups').onclick = openShopManageGroupModal;
document.getElementById('btnManageShopGroupsFromEdit').onclick = openShopManageGroupModal;
document.getElementById('btnSearchShopManage').onclick = function () {
loadShopManage(1);
};
document.getElementById('shopManageFilterShopName').addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
loadShopManage(1);
}
});
document.getElementById('btnCloseShopManageGroupModal').onclick = function () {
document.getElementById('shopManageGroupModal').classList.remove('show');
};
document.getElementById('btnCancelShopManageGroupEdit').onclick = function () {
document.getElementById('shopManageGroupEditId').value = '';
document.getElementById('shopManageGroupInput').value = '';
};
document.getElementById('btnSaveShopManageGroup').onclick = function () {
var editId = (document.getElementById('shopManageGroupEditId').value || '').trim();
var groupName = (document.getElementById('shopManageGroupInput').value || '').trim();
var msgEl = document.getElementById('msgShopManageGroup');
msgEl.textContent = '';
msgEl.className = 'msg';
if (!groupName) {
msgEl.textContent = '请输入分组名称';
msgEl.className = 'msg err';
return;
}
var method = editId ? 'PUT' : 'POST';
var url = editId ? ('/api/admin/shop-manage-group/' + editId) : '/api/admin/shop-manage-group';
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group_name: groupName })
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.success) {
msgEl.textContent = res.error || '保存失败';
msgEl.className = 'msg err';
return;
}
document.getElementById('shopManageGroupEditId').value = '';
document.getElementById('shopManageGroupInput').value = '';
msgEl.textContent = res.msg || '保存成功';
msgEl.className = 'msg ok';
loadShopManageGroups().then(function () {
renderShopManageGroupRows();
loadShopManage(shopManagePage);
loadSkipPriceAsin(skipPriceAsinPage);
});
})
.catch(function () {
msgEl.textContent = '请求失败';
msgEl.className = 'msg err';
});
};
function refreshShopGroupSelects(selectedCreateId, selectedEditId) {
var createSel = document.getElementById('shopManageGroupSelect');
var editSel = document.getElementById('editShopManageGroupSelect');
@@ -2434,10 +2305,12 @@
function resetShopManageGroupForm() {
document.getElementById('shopManageGroupEditId').value = '';
document.getElementById('shopManageGroupLeaderUserId').value = currentUserId || '';
document.getElementById('shopManageGroupLeaderName').value = currentUserUsername || '';
document.getElementById('shopManageGroupInput').value = '';
document.getElementById('msgShopManageGroup').textContent = '';
document.getElementById('msgShopManageGroup').className = 'msg';
refreshShopManageGroupUserSelect('');
refreshShopManageGroupMemberSelect(currentUserId, []);
}
function openShopManageGroupModal() {
@@ -2453,20 +2326,33 @@
function renderShopManageGroupRows() {
var tbody = document.getElementById('shopManageGroupListBody');
if (!shopManageGroups.length) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">暂无分组</td></tr>';
tbody.innerHTML = '<tr><td colspan="8" class="empty-tip">暂无分组</td></tr>';
return;
}
tbody.innerHTML = shopManageGroups.map(function (item, index) {
return '<tr><td>' + (index + 1) + '</td><td>' + (item.group_name || '') + '</td><td>' + (item.username || '') + '</td><td>' + (item.created_at || '') + '</td><td>' + (item.updated_at || '') + '</td><td>' +
'<button class="btn btn-sm" data-shop-group-edit="' + item.id + '" data-shop-group-user-id="' + (item.user_id || '') + '">编辑</button> ' +
var memberNames = Array.isArray(item.member_usernames) ? item.member_usernames.join('') : '';
return '<tr><td>' + (index + 1) + '</td><td>' + (item.group_name || '') + '</td><td>' + (item.leader_username || '') + '</td><td>' + (item.member_count || 0) + '</td><td>' + (memberNames || '-') + '</td><td>' + (item.created_at || '') + '</td><td>' + (item.updated_at || '') + '</td><td>' +
'<button class="btn btn-sm" data-shop-group-edit="' + item.id + '">编辑</button> ' +
'<button class="btn btn-sm btn-danger" data-shop-group-delete="' + item.id + '" data-shop-group-name="' + (item.group_name || '').replace(/"/g, '&quot;') + '">删除</button>' +
'</td></tr>';
}).join('');
document.querySelectorAll('[data-shop-group-edit]').forEach(function (btn) {
btn.onclick = function () {
document.getElementById('shopManageGroupEditId').value = btn.dataset.shopGroupEdit;
refreshShopManageGroupUserSelect(btn.dataset.shopGroupUserId || '');
var item = null;
shopManageGroups.some(function (group) {
if (String(group.id || '') === String(btn.dataset.shopGroupEdit || '')) {
item = group;
return true;
}
return false;
});
if (!item) return;
document.getElementById('shopManageGroupEditId').value = item.id || '';
document.getElementById('shopManageGroupInput').value = item.group_name || '';
setShopManageGroupLeader(item.leader_user_id || '', item.leader_username || '', item.member_user_ids || []);
document.getElementById('msgShopManageGroup').textContent = '';
document.getElementById('msgShopManageGroup').className = 'msg';
};
});
@@ -2492,10 +2378,18 @@
});
}
document.getElementById('shopManageGroupUserSelect').onchange = syncShopManageGroupNameWithUser;
document.getElementById('btnManageShopGroups').onclick = openShopManageGroupModal;
document.getElementById('btnManageShopGroupsFromEdit').onclick = openShopManageGroupModal;
document.getElementById('btnManageSkipPriceAsinGroups').onclick = openShopManageGroupModal;
document.getElementById('btnSearchShopManage').onclick = function () {
loadShopManage(1);
};
document.getElementById('shopManageFilterShopName').addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
loadShopManage(1);
}
});
document.getElementById('btnCloseShopManageGroupModal').onclick = function () {
document.getElementById('shopManageGroupModal').classList.remove('show');
};
@@ -2503,12 +2397,12 @@
document.getElementById('btnSaveShopManageGroup').onclick = function () {
var editId = (document.getElementById('shopManageGroupEditId').value || '').trim();
var groupName = (document.getElementById('shopManageGroupInput').value || '').trim();
var userId = (document.getElementById('shopManageGroupUserSelect').value || '').trim();
var memberUserIds = getSelectedShopManageGroupMemberIds();
var msgEl = document.getElementById('msgShopManageGroup');
msgEl.textContent = '';
msgEl.className = 'msg';
if (!userId || !groupName) {
msgEl.textContent = '请选择关联用户';
if (!groupName) {
msgEl.textContent = '请输入分组名称';
msgEl.className = 'msg err';
return;
}
@@ -2517,7 +2411,7 @@
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group_name: groupName, user_id: Number(userId) })
body: JSON.stringify({ group_name: groupName, member_user_ids: memberUserIds })
})
.then(function (r) { return r.json(); })
.then(function (res) {

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>巡店删除 - 数富AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/patrol-delete-main.ts"></script>
</body>
</html>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="page-shell module-page">
<BrandTopBar active="delete-brand" />
@@ -225,6 +225,7 @@ import {
type DeleteBrandTaskDetailVo,
} from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
interface SessionDeleteBrandItem extends DeleteBrandResultItem {
_pushed?: boolean
@@ -261,6 +262,7 @@ const chainStarted = ref(false)
const activeItemKey = ref('')
const autoAdvancing = ref(false)
const autoRetryTimer = ref<number | null>(null)
const waitingForBackendRecovery = ref(false)
const displayPaths = computed(() => selectedPaths.value.slice(0, 10))
const currentSectionItems = computed(() => {
@@ -855,6 +857,10 @@ async function refreshTaskDetails(taskIds?: number[]) {
try {
const batch = await getDeleteBrandTaskProgress(ids)
if (waitingForBackendRecovery.value && chainStarted.value) {
queuePushResult.value = '后端已恢复,继续执行当前任务并自动衔接后续任务...'
}
waitingForBackendRecovery.value = false
let changed = false
for (const detail of batch.items || []) {
const id = detail?.task?.id
@@ -875,13 +881,14 @@ async function refreshTaskDetails(taskIds?: number[]) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
if (transient && chainStarted.value) {
waitingForBackendRecovery.value = true
queuePushResult.value = '当前任务运行中,正在等待后端服务恢复...'
}
}
}
function getPollIntervalMs() {
return document.visibilityState === 'visible' ? 6000 : 20000
return getTaskPollIntervalMs()
}
function scheduleNextPoll(immediate = false) {
@@ -1898,3 +1905,4 @@ onUnmounted(() => {
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="page-shell module-page">
<BrandTopBar active="pricing" />
@@ -262,6 +262,7 @@ import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
import { expandBrandFolderRecursive } from '@/shared/api/brand'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import {
addPriceTrackCandidate,
completePriceTrackLoopChild,
@@ -989,6 +990,9 @@ async function waitForTaskTerminal(taskId: number) {
while (true) {
try {
const batch = await getPriceTrackTaskProgressBatch([taskId])
if (transientErrorCount > 0) {
queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`
}
transientErrorCount = 0
if ((batch.missingTaskIds || []).includes(taskId)) {
removePollingTask(taskId)
@@ -1055,69 +1059,81 @@ async function pushToPythonQueue() {
}
pushing.value = true
queuePayloadText.value = ''
let successCount = 0
let failedCount = 0
try {
for (let index = 0; index < matchedRows.length; index += 1) {
const row = matchedRows[index]
let attempt = 0
const taskVo = await (async () => {
while (true) {
try {
return await createPriceTrackTask({
userId: 0,
statusMode: statusModeEnabled.value,
asinMode: asinModeEnabled.value,
items: [row] as unknown as Record<string, unknown>[],
asinFiles: resolveAsinRequestPaths(),
countryCodes: [...orderedCountryCodes.value],
})
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
attempt += 1
if (!transient || attempt >= 10) throw error
queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/10...`
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
try {
let attempt = 0
const taskVo = await (async () => {
while (true) {
try {
return await createPriceTrackTask({
userId: 0,
statusMode: statusModeEnabled.value,
asinMode: asinModeEnabled.value,
items: [row] as unknown as Record<string, unknown>[],
asinFiles: resolveAsinRequestPaths(),
countryCodes: [...orderedCountryCodes.value],
})
} catch (error) {
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
attempt += 1
if (!transient || attempt >= 10) throw error
queuePushResult.value = '后端服务暂时不可用,正在重试创建任务(' + attempt + '/10...'
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
}
}
})()
taskSnapshots.value = {
...taskSnapshots.value,
[taskVo.taskId]: {
task: { id: taskVo.taskId, status: 'RUNNING' },
items: taskVo.items,
},
}
})()
taskSnapshots.value = {
...taskSnapshots.value,
[taskVo.taskId]: {
task: { id: taskVo.taskId, status: 'RUNNING' },
items: taskVo.items,
},
taskDetails.value = {
...taskDetails.value,
[taskVo.taskId]: 'RUNNING',
}
saveTaskSnapshotsToStorage()
saveTaskDetailsToStorage()
const queuePayload = buildQueuePayload(taskVo, row)
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
const pushResult = await api.enqueue_json(queuePayload)
if (!pushResult?.success) {
failedCount += 1
queuePushResult.value = '任务 ' + taskVo.taskId + ' 推送失败,已自动继续下一条:' + (pushResult?.error || '未知错误')
ElMessage.error(queuePushResult.value)
continue
}
addPollingTask(taskVo.taskId)
scheduleNextPoll(true)
removeMatchedRowsLocally([row])
queuePushResult.value = matchedRows.length > 1
? '任务 ' + taskVo.taskId + ' 已入队,等待完成后继续下一条(' + (index + 1) + '/' + matchedRows.length + ''
: '任务 ' + taskVo.taskId + ' 已入队,等待执行完成'
const finalStatus = await waitForTaskTerminal(taskVo.taskId)
if (finalStatus !== 'SUCCESS') {
failedCount += 1
queuePushResult.value = '任务 ' + taskVo.taskId + ' 执行失败,已自动继续下一条(' + (index + 1) + '/' + matchedRows.length + ''
ElMessage.error(queuePushResult.value)
continue
}
successCount += 1
queuePushResult.value = index + 1 < matchedRows.length
? '任务 ' + taskVo.taskId + ' 已完成,继续推送下一条(' + (index + 1) + '/' + matchedRows.length + ''
: '任务 ' + taskVo.taskId + ' 已完成'
} catch (error) {
failedCount += 1
queuePushResult.value = '第 ' + (index + 1) + '/' + matchedRows.length + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
ElMessage.error(queuePushResult.value)
continue
}
taskDetails.value = {
...taskDetails.value,
[taskVo.taskId]: 'RUNNING',
}
saveTaskSnapshotsToStorage()
saveTaskDetailsToStorage()
const queuePayload = buildQueuePayload(taskVo, row)
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
const pushResult = await api.enqueue_json(queuePayload)
if (!pushResult?.success) {
throw new Error(pushResult?.error || `任务 ${taskVo.taskId} 推送失败`)
}
addPollingTask(taskVo.taskId)
scheduleNextPoll(true)
removeMatchedRowsLocally([row])
queuePushResult.value = matchedRows.length > 1
? `任务 ${taskVo.taskId} 已入队,等待完成后继续下一条(${index + 1}/${matchedRows.length}`
: `任务 ${taskVo.taskId} 已入队,等待执行完成`
const finalStatus = await waitForTaskTerminal(taskVo.taskId)
if (finalStatus !== 'SUCCESS') {
throw new Error(`任务 ${taskVo.taskId} 执行失败,已停止后续任务`)
}
queuePushResult.value = index + 1 < matchedRows.length
? `任务 ${taskVo.taskId} 已完成,继续推送下一条(${index + 1}/${matchedRows.length}`
: `任务 ${taskVo.taskId} 已完成`
}
ElMessage.success(`已串行完成 ${matchedRows.length} 条店铺任务推送`)
ElMessage.success('店铺任务推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
} catch (e) {
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
ElMessage.error(queuePushResult.value)
@@ -1353,7 +1369,7 @@ async function pushToPythonLoopQueue() {
}
function getPollIntervalMs() {
return document.visibilityState === 'visible' ? 6000 : 20000
return getTaskPollIntervalMs()
}
async function refreshTaskBatch() {
@@ -2248,3 +2264,4 @@ onUnmounted(() => {
}
}
</style>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="page-shell module-page">
<BrandTopBar active="product-risk" />
@@ -220,6 +220,7 @@ import {
type ProductRiskTaskDetailVo,
} from '@/shared/api/java-modules'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
const shopInput = ref('')
const candidates = ref<ProductRiskCandidateVo[]>([])
@@ -711,7 +712,7 @@ async function refreshTaskBatch() {
}
function getPollIntervalMs() {
return document.visibilityState === 'visible' ? 6000 : 20000
return getTaskPollIntervalMs()
}
function scheduleNextPoll(immediate = false) {
@@ -761,6 +762,9 @@ async function waitForTaskTerminal(taskId: number) {
while (true) {
try {
const batch = await getProductRiskTaskProgressBatch([taskId])
if (transientErrorCount > 0) {
queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`
}
transientErrorCount = 0
const detail = (batch.items || []).find((d) => d.task?.id === taskId)
const status = detail?.task?.status || ''
@@ -984,7 +988,7 @@ async function pushToPythonQueue() {
}
const api = getPywebviewApi()
if (!api?.enqueue_json) {
ElMessage.error('当前环境未启用 pywebview enqueue_json(浏览器内无法推队列)')
ElMessage.error('当前环境未启用 pywebview enqueue_json')
return
}
const toPush = matchedItems.value.filter((i) => i.matched)
@@ -994,58 +998,66 @@ async function pushToPythonQueue() {
}
pushing.value = true
queuePayloadText.value = ''
let successCount = 0
let failedCount = 0
try {
for (let i = 0; i < toPush.length; i++) {
const item = toPush[i]
const created = await createProductRiskTaskWithRetry([item])
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: [item],
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)
queuePushResult.value = `${i + 1}/${toPush.length} 条推送失败:${pushResult?.error || '未知错误'}`
try {
const created = await createProductRiskTaskWithRetry([item])
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: [item],
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)
failedCount += 1
queuePushResult.value = '第 ' + (i + 1) + '/' + toPush.length + ' 条推送失败:' + (pushResult?.error || '未知错误') + ',已自动继续下一条'
ElMessage.error(queuePushResult.value)
continue
}
removeMatchedRowsLocally([item])
queuePushResult.value = toPush.length > 1
? '任务 ' + taskId + ':第 ' + (i + 1) + '/' + toPush.length + ' 条店铺已入队,等待执行完成...'
: '任务 ' + taskId + ' 已入队,等待执行完成...'
const finalStatus = await waitForTaskTerminal(taskId)
if (finalStatus !== 'SUCCESS') {
failedCount += 1
queuePushResult.value = '任务 ' + taskId + ' 执行失败,已自动继续下一条(' + (i + 1) + '/' + toPush.length + ''
ElMessage.error(queuePushResult.value)
continue
}
successCount += 1
queuePushResult.value = i + 1 < toPush.length
? '任务 ' + taskId + ' 已完成,继续推送下一条(' + (i + 1) + '/' + toPush.length + ''
: '任务 ' + taskId + ' 已完成'
} catch (error) {
failedCount += 1
queuePushResult.value = '第 ' + (i + 1) + '/' + toPush.length + ' 条任务处理失败,已自动继续下一条:' + (error instanceof Error ? error.message : '未知错误')
ElMessage.error(queuePushResult.value)
return
continue
}
removeMatchedRowsLocally([item])
queuePushResult.value = toPush.length > 1
? `任务 ${taskId}:第 ${i + 1}/${toPush.length} 条店铺已入队,等待执行完成...`
: `任务 ${taskId} 已入队,等待执行完成...`
const finalStatus = await waitForTaskTerminal(taskId)
if (finalStatus !== 'SUCCESS') {
queuePushResult.value = `任务 ${taskId} 执行失败,已停止后续店铺推送`
ElMessage.error(queuePushResult.value)
return
}
queuePushResult.value = i + 1 < toPush.length
? `任务 ${taskId} 已完成,继续推送下一条(${i + 1}/${toPush.length}`
: `任务 ${taskId} 已完成`
}
ElMessage.success(`已按顺序完成 ${toPush.length} 条店铺推送`)
ElMessage.success('店铺推送已完成:成功 ' + successCount + ' 条,失败 ' + failedCount + ' 条')
await loadHistory()
ensurePolling(true)
} catch (e) {
@@ -1611,3 +1623,4 @@ onUnmounted(() => {
}
}
</style>

View File

@@ -110,6 +110,7 @@ import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
const COUNTRY_OPTIONS = [{ code: 'DE', label: '德国' }, { code: 'UK', label: '英国' }, { code: 'FR', label: '法国' }, { code: 'IT', label: '意大利' }, { code: 'ES', label: '西班牙' }] as const
const shopInput = ref('')
@@ -159,7 +160,7 @@ function taskRowKey(item: ShopMatchHistoryItem) { return `${normalizeTaskId(item
function countryLabel(code: string) { return COUNTRY_OPTIONS.find((item) => item.code === code)?.label || code }
function formatScheduleDateTime(date: Date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}` }
function formatPickerDateTime(date: Date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day} ${hours}:${minutes}:00` }
function getDefaultScheduleValue() { const date = new Date(); date.setSeconds(0, 0); date.setMinutes(date.getMinutes() + 1); return formatPickerDateTime(date) }
function getDefaultScheduleValue() { const date = new Date(); date.setSeconds(0, 0); date.setMinutes(date.getMinutes() + 5); return formatPickerDateTime(date) }
function parseScheduleDateTime(value?: string | null) {
if (!value) return null
const normalized = value.trim().replace('T', ' ')
@@ -235,15 +236,56 @@ function onCountryToggle(code: string, checked: boolean) { countryPrefUserTouche
function onCountryDragStart(index: number) { dragCountryIndex.value = index }
function onCountryDragEnd() { dragCountryIndex.value = null }
function onCountryDrop(toIndex: number) { const from = dragCountryIndex.value; dragCountryIndex.value = null; if (from == null || from === toIndex) return; countryPrefUserTouched.value = true; const list = [...orderedCountryCodes.value]; const [item] = list.splice(from, 1); list.splice(toIndex, 0, item); orderedCountryCodes.value = list; scheduleSaveCountryPreference() }
function onScheduleToggle() { if (scheduleEnabled.value && schedulePickerValues.value.length === 0) schedulePickerValues.value = [getDefaultScheduleValue()]; if (!scheduleEnabled.value) schedulePickerValues.value = [] }
function onScheduleToggle() { if (scheduleEnabled.value) schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, true); if (!scheduleEnabled.value) schedulePickerValues.value = [] }
function addScheduleValue() { const last = schedulePickerValues.value[schedulePickerValues.value.length - 1]; const nextValue = last ? nextScheduleValueAfter(last, 60) : getDefaultScheduleValue(); schedulePickerValues.value = [...schedulePickerValues.value, nextValue] }
function removeScheduleValue(index: number) { if (schedulePickerValues.value.length <= 1) return; schedulePickerValues.value = schedulePickerValues.value.filter((_, idx) => idx !== index) }
function updateScheduleValue(index: number, value: string | null) { const list = [...schedulePickerValues.value]; list[index] = value || ''; schedulePickerValues.value = list }
function updateScheduleValue(index: number, value: string | null) { const list = [...schedulePickerValues.value]; list[index] = value || ''; schedulePickerValues.value = sanitizeSchedulePickerValues(list, false) }
function mergeMatchedItems(base: ShopMatchShopQueueItem[], incoming: ShopMatchShopQueueItem[]) { const map = new Map<string, ShopMatchShopQueueItem>(); 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 runMatch() { const names = selectedCandidates.value.map((item) => item.shop_name).filter(Boolean); if (!names.length) { ElMessage.warning('请先选择候选店铺'); return } matching.value = true; try { const data = await matchShopMatchShops(names); matchedItems.value = mergeMatchedItems(matchedItems.value, data.items || []); saveMatchedItemsToStorage(); ElMessage.success(`本次返回 ${data.items?.length || 0} 条匹配结果`) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '匹配失败') } finally { matching.value = false } }
function removeMatchedRow(row: ShopMatchShopQueueItem) { matchedItems.value = matchedItems.value.filter((item) => rowKeyForMatch(item) !== rowKeyForMatch(row)); saveMatchedItemsToStorage() }
function removeMatchedRowsLocally(rows: ShopMatchHistoryItem[] | ShopMatchShopQueueItem[]) { const keys = new Set(rows.map((row) => rowKeyForMatch(row as ShopMatchShopQueueItem))); matchedItems.value = matchedItems.value.filter((item) => !keys.has(rowKeyForMatch(item))); saveMatchedItemsToStorage() }
function parseScheduleValues() { if (!scheduleEnabled.value) return undefined; const values = schedulePickerValues.value.map((item) => item.trim()).filter(Boolean); if (!values.length) throw new Error('请至少选择一个执行时间'); const withTime = values.map((value) => { const date = parseScheduleDateTime(value); if (!date) throw new Error(`时间格式无效: ${value}`); return { raw: formatScheduleDateTime(date), time: date.getTime() } }).sort((a, b) => a.time - b.time); for (let i = 1; i < withTime.length; i += 1) if (withTime[i].time === withTime[i - 1].time) throw new Error('执行时间不能重复'); return withTime.map((item) => item.raw) }
function parseScheduleValues() {
if (!scheduleEnabled.value) return undefined
const now = Date.now()
const values = schedulePickerValues.value.map((item) => item.trim()).filter(Boolean)
if (!values.length) throw new Error('请至少选择一个执行时间')
const withTime = values.map((value) => {
const date = parseScheduleDateTime(value)
if (!date) throw new Error(`时间格式无效: ${value}`)
return { raw: formatScheduleDateTime(date), picker: formatPickerDateTime(date), time: date.getTime() }
}).sort((a, b) => a.time - b.time)
for (let i = 1; i < withTime.length; i += 1) if (withTime[i].time === withTime[i - 1].time) throw new Error('执行时间不能重复')
const futureOnly = withTime.filter((item) => item.time > now)
if (!futureOnly.length) throw new Error('执行时间必须晚于当前时间,请重新选择')
if (futureOnly.length !== withTime.length) {
schedulePickerValues.value = futureOnly.map((item) => item.picker)
ElMessage.warning('已自动移除早于当前时间的执行时间')
}
return futureOnly.map((item) => item.raw)
}
function sanitizeSchedulePickerValues(values: string[], ensureOne: boolean) {
const now = Date.now()
const normalized = values
.map((item) => item.trim())
.filter(Boolean)
.map((value) => {
const date = parseScheduleDateTime(value)
return date ? { value: formatPickerDateTime(date), time: date.getTime() } : null
})
.filter((item): item is { value: string; time: number } => !!item)
.filter((item) => item.time > now)
.sort((a, b) => a.time - b.time)
const deduped: string[] = []
const seen = new Set<string>()
for (const item of normalized) {
if (seen.has(item.value)) continue
seen.add(item.value)
deduped.push(item.value)
}
if (!deduped.length && ensureOne) return [getDefaultScheduleValue()]
return deduped
}
function buildQueuePayload(taskId: number, item: Pick<ShopMatchHistoryItem, 'shopName' | 'shopId' | 'platform' | 'companyName' | 'matchStatus' | 'matchMessage'>, countryCodes: string[], stageIndex?: number, finalStage = true) { return { type: 'shop-match-run', ts: Date.now(), data: { taskId, user_id: currentUserId(), items: [{ shopName: item.shopName, shopId: item.shopId, platform: item.platform, companyName: item.companyName, matched: true, matchStatus: item.matchStatus, matchMessage: item.matchMessage }], country_codes: [...countryCodes], risk_listing_filter: shopMatchListingFilter.value, stage_index: stageIndex, final_stage: finalStage } } }
function nextScheduleStage(taskId: number) { const task = taskSnapshots.value[taskId]?.task; const stages = task?.scheduleStages || []; const currentStageIndex = task?.currentStageIndex; if (typeof currentStageIndex !== 'number') return null; const stage = stages.find((item) => item.stageIndex === currentStageIndex); if (!stage?.scheduledAt) return null; return { stageIndex: currentStageIndex, scheduledAt: stage.scheduledAt, finalStage: currentStageIndex === stages.length - 1 } }
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await withTransientRetry(() => activateShopMatchTask(taskId, stage.stageIndex), (attempt, maxAttempts) => { queuePushResult.value = `任务 ${taskId} 等待后端恢复后再激活执行(${attempt}/${maxAttempts}...` }); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; if (snapshot?.task) taskSnapshots.value = { ...taskSnapshots.value, [taskId]: { ...snapshot, task: { ...snapshot.task, status: 'RUNNING', activeStageIndex: stage.stageIndex } } }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId} 已推送第 ${stage.stageIndex + 1}`; ensurePolling(true) }
@@ -261,11 +303,11 @@ function sleep(ms: number) { return new Promise<void>((resolve) => { window.setT
function isTransientBackendError(error: unknown) { const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase(); return TRANSIENT_BACKEND_ERROR_PATTERNS.some((pattern) => message.includes(pattern.toLowerCase())) }
async function withTransientRetry<T>(action: () => Promise<T>, onRetry: (attempt: number, maxAttempts: number) => void, maxAttempts = 10) { let attempt = 0; while (true) { try { return await action() } catch (error) { attempt += 1; if (!isTransientBackendError(error) || attempt >= maxAttempts) throw error; onRetry(attempt, maxAttempts); await sleep(getPollIntervalMs()) } } }
async function refreshTaskBatch() { if (!pollingTaskIds.value.length) return { settledTaskIds: [] as number[] }; try { const batch = await getShopMatchTaskProgressBatch(pollingTaskIds.value); const nextSnapshots = { ...taskSnapshots.value }; const settledTaskIds = new Set<number>(); for (const missingId of batch.missingTaskIds || []) { removeTaskLocally(missingId); delete nextSnapshots[missingId]; settledTaskIds.add(missingId) } for (const detail of batch.items || []) { const taskId = detail.task?.id; if (!taskId) continue; const prev = nextSnapshots[taskId]; nextSnapshots[taskId] = prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail; const status = detail.task?.status || ''; if (!status) continue; taskDetails.value[taskId] = status; if (isTaskTerminalStatus(status)) { removePollingTask(taskId); settledTaskIds.add(taskId); continue } if (status === 'SCHEDULED') { stopPollingTask(taskId); settledTaskIds.add(taskId) } } taskSnapshots.value = nextSnapshots; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); restoreScheduledDispatches(); return { settledTaskIds: Array.from(settledTaskIds) } } catch { return { settledTaskIds: [] as number[] } } }
function getPollIntervalMs() { return document.visibilityState === 'visible' ? 6000 : 20000 }
function getPollIntervalMs() { return getTaskPollIntervalMs() }
function scheduleNextPoll(immediate = false) { if (pollTimer.value) { if (!immediate) return; window.clearTimeout(pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (pollingInFlight.value || !pollingTaskIds.value.length) { if (pollingTaskIds.value.length) scheduleNextPoll(); return } pollingInFlight.value = true; try { const { settledTaskIds } = await refreshTaskBatch(); if (settledTaskIds.length) await Promise.allSettled([loadHistory(), loadDashboard()]) } finally { pollingInFlight.value = false } if (pollingTaskIds.value.length) pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = window.setTimeout(run, getPollIntervalMs()) }
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } }
async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; const maxTransientErrors = 30; while (true) { try { const batch = await getShopMatchTaskProgressBatch([taskId]); transientErrorCount = 0; if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } } catch (error) { if (!isTransientBackendError(error)) throw error; transientErrorCount += 1; if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`); queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors}...`; if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`); await sleep(getPollIntervalMs()); continue } await sleep(getPollIntervalMs()) } }
async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; const maxTransientErrors = 30; while (true) { try { const batch = await getShopMatchTaskProgressBatch([taskId]); if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`; transientErrorCount = 0; if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } } catch (error) { if (!isTransientBackendError(error)) throw error; transientErrorCount += 1; if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`); queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors}...`; if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`); await sleep(getPollIntervalMs()); continue } await sleep(getPollIntervalMs()) } }
function resolvedTaskStatus(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); if (!taskId) return item.taskStatus || ''; return taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status || item.taskStatus || '' }
function taskSnapshotOf(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); return taskId ? taskSnapshots.value[taskId] : undefined }
function currentTaskStageText(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (!stages.length) return ''; const total = stages.length; const activeIndex = typeof task?.activeStageIndex === 'number' ? task.activeStageIndex : undefined; const currentIndex = typeof task?.currentStageIndex === 'number' ? task.currentStageIndex : undefined; if (typeof activeIndex === 'number') return `执行进度: 第 ${activeIndex + 1}/${total} 次执行中`; if (typeof currentIndex === 'number') return `执行进度: 等待第 ${currentIndex + 1}/${total}`; return `执行进度: 共 ${total}`}
@@ -302,9 +344,8 @@ async function deleteTaskRecord(item: ShopMatchHistoryItem) {
}
function formatMatchStatus(status?: string) { const value = (status || '').trim(); return { MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需人工确认', INDEX_STALE: '索引过期' }[value] || value || '—' }
function formatMatchRemark(row: ShopMatchShopQueueItem) { const message = (row.matchMessage || '').trim(); if (message) return message; if (row.matched && row.matchStatus === 'MATCHED') return '紫鸟索引已命中,可推送队列'; if (row.matched) return '已关联索引,请结合状态列查看是否可推送'; return '未命中或未就绪,请检查店铺名与索引刷新' }
async function pushToPythonQueue() { const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } pushing.value = true; queuePayloadText.value = ''; try { for (let index = 0; index < matched.length; index += 1) { const item = matched[index]; const created = await withTransientRetry(() => createShopMatchTask([item], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) continue; if (scheduleValues?.length) { restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = matched.length > 1 ? `任务 ${taskId} 已入队,等待完成后继续推送下一条(${index + 1}/${matched.length}` : `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { throw new Error(`任务 ${taskId} 执行失败,已停止后续店铺推送`) } queuePushResult.value = index + 1 < matched.length ? `任务 ${taskId} 已完成,继续推送下一条(${index + 1}/${matched.length}` : `任务 ${taskId} 已完成` } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); ElMessage.success(scheduleValues?.length ? '定时任务已创建,后续会按时间串行推入 Python 队列' : `已按顺序完成 ${matched.length}店铺推送`) } catch (error) { const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { pushing.value = false } }
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); startScheduleHeartbeat(); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
async function pushToPythonQueue() { const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; try { for (let index = 0; index < matched.length; index += 1) { const item = matched[index]; try { const created = await withTransientRetry(() => createShopMatchTask([item], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts}...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) continue; if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 推送失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = matched.length > 1 ? `任务 ${taskId} 已入队,等待完成后继续推送下一条(${index + 1}/${matched.length}` : `任务 ${taskId} 已入队,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条(${index + 1}/${matched.length}`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = index + 1 < matched.length ? `任务 ${taskId} 已完成,继续推送下一条(${index + 1}/${matched.length}` : `任务 ${taskId} 已完成` } catch (error) { failedCount += 1; queuePushResult.value = `${index + 1}/${matched.length} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount}` : `店铺推送已完成:成功 ${successCount} 条,失败 ${failedCount}`) } catch (error) { const message = error instanceof Error ? error.message : '推送失败'; queuePushResult.value = message; ElMessage.error(message) } finally { pushing.value = false } }
onMounted(async () => { loadPollingIdsFromStorage(); loadTaskDetailsFromStorage(); loadTaskSnapshotsFromStorage(); loadMatchedItemsFromStorage(); schedulePickerValues.value = sanitizeSchedulePickerValues(schedulePickerValues.value, scheduleEnabled.value); syncPollingIdsWithTaskState(); await Promise.allSettled([loadCandidates(), loadCountryPreference(), loadHistory(), loadDashboard()]); startScheduleHeartbeat(); window.addEventListener('focus', handleScheduleRecovery); document.addEventListener('visibilitychange', handleScheduleRecovery); restoreScheduledDispatches(); if (pollingTaskIds.value.length) ensurePolling(true) })
onUnmounted(() => { stopPolling(); stopScheduleHeartbeat(); window.removeEventListener('focus', handleScheduleRecovery); document.removeEventListener('visibilitychange', handleScheduleRecovery); if (countryPrefSaveTimer) clearTimeout(countryPrefSaveTimer); for (const timer of dispatchTimers.values()) window.clearTimeout(timer); dispatchTimers.clear() })
</script>
@@ -394,3 +435,4 @@ onUnmounted(() => { stopPolling(); stopScheduleHeartbeat(); window.removeEventLi
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
@media (max-width: 1100px) { .main-content { flex-direction: column; height: auto; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2a2a2a; } .clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .schedule-row { flex-direction: column; align-items: stretch; } }
</style>

View File

@@ -52,6 +52,7 @@ type ActiveNavKey =
| 'product-risk'
| 'shop-match'
| 'pricing'
| 'patrol-delete'
type NavItem = {
key: string
@@ -93,7 +94,7 @@ const navGroups: ReadonlyArray<NavGroup> = [
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' },
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' },
{ key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html' },
{ key: 'patrol-delete', label: '巡店删除' },
{ key: 'patrol-delete', label: '巡店删除', href: '/new_web_source/patrol-delete.html' },
{ key: 'withdraw', label: '取款' },
{ key: 'shop-status', label: '店铺状态查询' },
],

View File

@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import BrandPatrolDeleteTab from '@/pages/brand/components/BrandPatrolDeleteTab.vue'
dayjs.locale('zh-cn')
createApp(BrandPatrolDeleteTab).use(ElementPlus, { locale: zhCn }).mount('#app')

View File

@@ -7,8 +7,16 @@
type JavaApiResponse,
unwrapJavaResponse,
} from "@/shared/api/http";
import { getTaskProgressCacheTtlMs } from "@/shared/task-progress-config";
const JAVA_API_PREFIX = "/newApi/api";
type TaskProgressCacheEntry = {
expiresAt: number;
data: unknown;
};
const taskProgressResponseCache = new Map<string, TaskProgressCacheEntry>();
const taskProgressInflightRequests = new Map<string, Promise<unknown>>();
function getCurrentUserId() {
const raw =
@@ -22,6 +30,56 @@ function getCurrentUserId() {
return value;
}
function normalizeTaskIds(taskIds: number[]) {
return Array.from(
new Set(
(taskIds || [])
.map((taskId) => Number(taskId))
.filter((taskId) => Number.isFinite(taskId) && taskId > 0),
),
).sort((left, right) => left - right);
}
function buildTaskProgressRequestKey(path: string, taskIds: number[]) {
return `${path}::${taskIds.join(",")}`;
}
async function postTaskProgressBatch<T>(path: string, taskIds: number[]) {
const normalizedTaskIds = normalizeTaskIds(taskIds);
if (!normalizedTaskIds.length) {
return { items: [], missingTaskIds: [] } as T;
}
const cacheKey = buildTaskProgressRequestKey(path, normalizedTaskIds);
const now = Date.now();
const cached = taskProgressResponseCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.data as T;
}
const inflight = taskProgressInflightRequests.get(cacheKey);
if (inflight) {
return (await inflight) as T;
}
const requestPromise = unwrapJavaResponse(
post<JavaApiResponse<T>, { taskIds: number[] }>(path, { taskIds: normalizedTaskIds }),
)
.then((data) => {
taskProgressResponseCache.set(cacheKey, {
data,
expiresAt: Date.now() + getTaskProgressCacheTtlMs(),
});
return data;
})
.finally(() => {
taskProgressInflightRequests.delete(cacheKey);
});
taskProgressInflightRequests.set(cacheKey, requestPromise);
return requestPromise;
}
export interface UploadedFileRef {
fileKey: string;
originalFilename?: string;
@@ -916,6 +974,221 @@ export function getShopMatchResultDownloadUrl(resultId: number) {
return getJavaDownloadUrl(`/shop-match/results/${resultId}/download`);
}
export type PatrolDeleteCandidateVo = ProductRiskCandidateVo;
export type PatrolDeleteDashboardVo = ProductRiskDashboardVo;
export type PatrolDeleteShopQueueItem = ProductRiskShopQueueItem;
export interface PatrolDeleteCountryMetricRow {
status: string;
quantity: string;
deleteQuantity: string;
processStatus: string;
}
export interface PatrolDeleteCountrySection {
country: string;
rows: PatrolDeleteCountryMetricRow[];
}
export interface PatrolDeleteCartRatio {
country: string;
ratio: string;
}
export interface PatrolDeleteTaskItem {
shopName?: string;
matched?: boolean;
shopId?: string;
platform?: string;
companyName?: string;
matchStatus?: string;
matchMessage?: string;
countrySections: PatrolDeleteCountrySection[];
cartRatios: PatrolDeleteCartRatio[];
}
export interface PatrolDeleteHistoryItem {
resultId?: number;
taskId?: number;
shopName?: string;
shopId?: string;
platform?: string;
companyName?: string;
matched?: boolean;
matchStatus?: string;
matchMessage?: string;
taskStatus?: string;
success?: boolean;
error?: string;
createdAt?: string;
finishedAt?: string;
outputFilename?: string;
downloadUrl?: string;
countrySections: PatrolDeleteCountrySection[];
cartRatios: PatrolDeleteCartRatio[];
}
export interface PatrolDeleteHistoryVo {
items: PatrolDeleteHistoryItem[];
}
export interface PatrolDeleteTaskBatchVo {
items: PatrolDeleteHistoryItem[];
missingTaskIds: number[];
}
export interface PatrolDeleteCreateTaskVo {
taskId: number;
items: PatrolDeleteHistoryItem[];
}
export function listPatrolDeleteCandidates() {
return unwrapJavaResponse(
get<JavaApiResponse<PatrolDeleteCandidateVo[]>>(
`${JAVA_API_PREFIX}/patrol-delete/candidates`,
{
params: { user_id: getCurrentUserId() },
},
),
);
}
export function addPatrolDeleteCandidate(shopName: string) {
return unwrapJavaResponse(
post<
JavaApiResponse<PatrolDeleteCandidateVo>,
{ user_id: number; shop_name: string }
>(`${JAVA_API_PREFIX}/patrol-delete/candidates`, {
user_id: getCurrentUserId(),
shop_name: shopName,
}),
);
}
export function deletePatrolDeleteCandidate(id: number) {
return unwrapJavaResponse(
del<JavaApiResponse<null>>(
`${JAVA_API_PREFIX}/patrol-delete/candidates/${id}`,
{
params: { user_id: getCurrentUserId() },
},
),
);
}
export function matchPatrolDeleteShops(shopNames: string[]) {
return unwrapJavaResponse(
post<
JavaApiResponse<ProductRiskMatchShopsVo>,
{ user_id: number; shop_names: string[] }
>(`${JAVA_API_PREFIX}/patrol-delete/match-shops`, {
user_id: getCurrentUserId(),
shop_names: shopNames,
}),
);
}
export function getPatrolDeleteDashboard() {
return unwrapJavaResponse(
get<JavaApiResponse<PatrolDeleteDashboardVo>>(
`${JAVA_API_PREFIX}/patrol-delete/dashboard`,
{
params: { user_id: getCurrentUserId() },
},
),
);
}
export function getPatrolDeleteHistory() {
return unwrapJavaResponse(
get<JavaApiResponse<PatrolDeleteHistoryVo>>(
`${JAVA_API_PREFIX}/patrol-delete/history`,
{
params: { user_id: getCurrentUserId() },
},
),
);
}
export function getPatrolDeleteTaskProgressBatch(taskIds: number[]) {
return postTaskProgressBatch<PatrolDeleteTaskBatchVo>(
`${JAVA_API_PREFIX}/patrol-delete/tasks/progress/batch`,
taskIds,
);
}
export function createPatrolDeleteTask(items: PatrolDeleteTaskItem[]) {
return unwrapJavaResponse(
post<
JavaApiResponse<PatrolDeleteCreateTaskVo>,
{ user_id: number; items: PatrolDeleteTaskItem[] }
>(`${JAVA_API_PREFIX}/patrol-delete/tasks`, {
user_id: getCurrentUserId(),
items,
}),
);
}
export function submitPatrolDeleteTaskResult(
taskId: number,
payload: {
shops: Array<{
shopName: string;
error?: string;
countrySections: PatrolDeleteCountrySection[];
cartRatios: PatrolDeleteCartRatio[];
shopDone?: boolean;
submissionId?: string;
chunkIndex?: number;
chunkTotal?: number;
}>;
},
) {
return unwrapJavaResponse(
post<
JavaApiResponse<null>,
{
shops: Array<{
shopName: string;
error?: string;
countrySections: PatrolDeleteCountrySection[];
cartRatios: PatrolDeleteCartRatio[];
shopDone?: boolean;
submissionId?: string;
chunkIndex?: number;
chunkTotal?: number;
}>;
}
>(`${JAVA_API_PREFIX}/patrol-delete/tasks/${taskId}/result`, payload),
);
}
export function getPatrolDeleteResultDownloadUrl(resultId: number) {
return getJavaDownloadUrl(`/patrol-delete/results/${resultId}/download`);
}
export function deletePatrolDeleteTask(taskId: number) {
return unwrapJavaResponse(
del<JavaApiResponse<null>>(
`${JAVA_API_PREFIX}/patrol-delete/tasks/${taskId}`,
{
params: { user_id: getCurrentUserId() },
},
),
);
}
export function deletePatrolDeleteHistory(resultId: number) {
return unwrapJavaResponse(
del<JavaApiResponse<null>>(
`${JAVA_API_PREFIX}/patrol-delete/history/${resultId}`,
{
params: { user_id: getCurrentUserId() },
},
),
);
}
// ========== 跟价 ==========
export interface PriceTrackCandidateVo {
@@ -1095,29 +1368,23 @@ export function matchPriceTrackShops(
}
export function getShopMatchTaskProgressBatch(taskIds: number[]) {
return unwrapJavaResponse(
post<JavaApiResponse<ShopMatchTaskBatchVo>, { taskIds: number[] }>(
`${JAVA_API_PREFIX}/shop-match/tasks/progress/batch`,
{ taskIds },
),
return postTaskProgressBatch<ShopMatchTaskBatchVo>(
`${JAVA_API_PREFIX}/shop-match/tasks/progress/batch`,
taskIds,
);
}
export function getProductRiskTaskProgressBatch(taskIds: number[]) {
return unwrapJavaResponse(
post<JavaApiResponse<ProductRiskTaskBatchVo>, { taskIds: number[] }>(
`${JAVA_API_PREFIX}/product-risk-resolve/tasks/progress/batch`,
{ taskIds },
),
return postTaskProgressBatch<ProductRiskTaskBatchVo>(
`${JAVA_API_PREFIX}/product-risk-resolve/tasks/progress/batch`,
taskIds,
);
}
export function getDeleteBrandTaskProgress(taskIds: number[]) {
return unwrapJavaResponse(
post<JavaApiResponse<DeleteBrandTaskBatchVo>, { taskIds: number[] }>(
`${JAVA_API_PREFIX}/delete-brand/tasks/progress/batch`,
{ taskIds },
),
return postTaskProgressBatch<DeleteBrandTaskBatchVo>(
`${JAVA_API_PREFIX}/delete-brand/tasks/progress/batch`,
taskIds,
);
}
@@ -1268,11 +1535,9 @@ export function stopPriceTrackLoopRun(loopRunId: number) {
}
export function getPriceTrackTaskProgressBatch(taskIds: number[]) {
return unwrapJavaResponse(
post<JavaApiResponse<PriceTrackTaskBatchVo>, { taskIds: number[] }>(
`${JAVA_API_PREFIX}/price-track/tasks/progress/batch`,
{ taskIds },
),
return postTaskProgressBatch<PriceTrackTaskBatchVo>(
`${JAVA_API_PREFIX}/price-track/tasks/progress/batch`,
taskIds,
);
}

View File

@@ -0,0 +1,18 @@
export const TASK_POLL_VISIBLE_INTERVAL_MS = 6000;
export const TASK_POLL_HIDDEN_INTERVAL_MS = 20000;
export const TASK_PROGRESS_VISIBLE_CACHE_MILLIS = 2500;
export const TASK_PROGRESS_HIDDEN_CACHE_MILLIS = 10000;
export function getTaskPollIntervalMs() {
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
return TASK_POLL_HIDDEN_INTERVAL_MS;
}
return TASK_POLL_VISIBLE_INTERVAL_MS;
}
export function getTaskProgressCacheTtlMs() {
if (typeof document !== "undefined" && document.hidden) {
return TASK_PROGRESS_HIDDEN_CACHE_MILLIS;
}
return TASK_PROGRESS_VISIBLE_CACHE_MILLIS;
}

View File

@@ -49,6 +49,7 @@ export default defineConfig({
'product-risk': resolve(__dirname, 'product-risk.html'),
'shop-match': resolve(__dirname, 'shop-match.html'),
'price-track': resolve(__dirname, 'price-track.html'),
'patrol-delete': resolve(__dirname, 'patrol-delete.html'),
},
output: {
entryFileNames: 'assets/[name].js',