处理后台管理系统、修复BUG、处理权限
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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.
BIN
app/amazon/__pycache__/approve - 副本.cpython-312.pyc
Normal file
BIN
app/amazon/__pycache__/approve - 副本.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/amazon/__pycache__/backend_api.cpython-312.pyc
Normal file
BIN
app/amazon/__pycache__/backend_api.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/amazon/__pycache__/price_match.cpython-312.pyc
Normal file
BIN
app/amazon/__pycache__/price_match.cpython-312.pyc
Normal file
Binary file not shown.
@@ -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', '*')
|
||||
|
||||
BIN
app/blueprints/__pycache__/brand_备份.cpython-312.pyc
Normal file
BIN
app/blueprints/__pycache__/brand_备份.cpython-312.pyc
Normal file
Binary file not shown.
@@ -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}))
|
||||
|
||||
|
||||
126
backend-java/docs/patrol-delete-python-api.md
Normal file
126
backend-java/docs/patrol-delete-python-api.md
Normal 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 端无需再调用额外“归档”接口
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 不合法");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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 不合法");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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>>() {});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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 = "创建人不能为空")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 未加入紫鸟白名单,已停止本轮刷新且不会更新索引数据");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
41
backend-java/src/main/resources/logback-spring.xml
Normal file
41
backend-java/src/main/resources/logback-spring.xml
Normal 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>
|
||||
Binary file not shown.
@@ -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],
|
||||
|
||||
@@ -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, '"') + '">编辑</button> ' +
|
||||
'<button class="btn btn-sm btn-danger" data-shop-group-delete="' + item.id + '" data-shop-group-name="' + (item.group_name || '').replace(/"/g, '"') + '">删除</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(/"/g, '"');
|
||||
};
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-shop-group-delete]').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
var gid = btn.dataset.shopGroupDelete;
|
||||
var gname = (btn.dataset.shopGroupName || '').replace(/"/g, '"');
|
||||
if (!confirm('确定删除分组“' + gname + '”吗?')) return;
|
||||
fetch('/api/admin/shop-manage-group/' + gid, { method: 'DELETE' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.success) {
|
||||
alert(res.error || '删除失败');
|
||||
return;
|
||||
}
|
||||
loadShopManageGroups().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, '"') + '">删除</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) {
|
||||
|
||||
12
frontend-vue/patrol-delete.html
Normal file
12
frontend-vue/patrol-delete.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
1099
frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue
Normal file
1099
frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: '店铺状态查询' },
|
||||
],
|
||||
|
||||
12
frontend-vue/src/patrol-delete-main.ts
Normal file
12
frontend-vue/src/patrol-delete-main.ts
Normal 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')
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
18
frontend-vue/src/shared/task-progress-config.ts
Normal file
18
frontend-vue/src/shared/task-progress-config.ts
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user