处理后台管理系统、修复BUG、处理权限
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,6 +22,9 @@ backend-java/target/
|
|||||||
backend-java/data/
|
backend-java/data/
|
||||||
backend-java/*.log
|
backend-java/*.log
|
||||||
backend-java/logs/
|
backend-java/logs/
|
||||||
|
logs/
|
||||||
|
aiimage-backend.log*
|
||||||
|
backend/tmp/
|
||||||
|
|
||||||
# backend-java local config
|
# backend-java local config
|
||||||
backend-java/src/main/resources/application-local.yml
|
backend-java/src/main/resources/application-local.yml
|
||||||
@@ -39,3 +42,4 @@ backend/__pycache__
|
|||||||
app/assets/
|
app/assets/
|
||||||
app/new_web_source
|
app/new_web_source
|
||||||
app/user_data/
|
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.image import image_bp
|
||||||
from blueprints.brand import brand_bp
|
from blueprints.brand import brand_bp
|
||||||
from blueprints.communication import communication_bp
|
from blueprints.communication import communication_bp
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__, template_folder=BASE_DIR, static_folder=BASE_DIR)
|
app = Flask(__name__, template_folder=BASE_DIR, static_folder=BASE_DIR)
|
||||||
frontend_origin = os.environ.get('FRONTEND_ORIGIN', '*')
|
frontend_origin = os.environ.get('FRONTEND_ORIGIN', '*')
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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
|
import subprocess
|
||||||
|
|
||||||
from amazon.del_brand import kill_process
|
from amazon.del_brand import kill_process
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
with open("version.txt","w",encoding="utf-8") as file:
|
with open("version.txt","w",encoding="utf-8") as file:
|
||||||
file.write(json.dumps({"version":version}))
|
file.write(json.dumps({"version":version}))
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>格式转换 - 数富AI</title>
|
<title>格式转换 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/convert.js"></script>
|
<script type="module" crossorigin src="/assets/convert.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-ClWy2SbE.js">
|
<link rel="modulepreload" crossorigin href="/assets/pywebview-DuyK2jB1.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-DBpLCgAF.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-CKgrwtni.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-hj_EXwAP.css">
|
<link rel="stylesheet" crossorigin href="/assets/pywebview-D74jYk6P.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
|
<link rel="stylesheet" crossorigin href="/assets/convert-7wWJ02Tw.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>数据去重 - 数富AI</title>
|
<title>数据去重 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-ClWy2SbE.js">
|
<link rel="modulepreload" crossorigin href="/assets/pywebview-DuyK2jB1.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-DBpLCgAF.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-CKgrwtni.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-hj_EXwAP.css">
|
<link rel="stylesheet" crossorigin href="/assets/pywebview-D74jYk6P.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
|
<link rel="stylesheet" crossorigin href="/assets/dedupe-BpNHwt51.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>删除品牌 - 数富AI</title>
|
<title>删除品牌 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-ClWy2SbE.js">
|
<link rel="modulepreload" crossorigin href="/assets/pywebview-DuyK2jB1.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-DBpLCgAF.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-CKgrwtni.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-hj_EXwAP.css">
|
<link rel="stylesheet" crossorigin href="/assets/pywebview-D74jYk6P.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-BNvh4ru4.css">
|
<link rel="stylesheet" crossorigin href="/assets/delete-brand-CWLpe7lu.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>数据拆分 - 数富AI</title>
|
<title>数据拆分 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/split.js"></script>
|
<script type="module" crossorigin src="/assets/split.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-ClWy2SbE.js">
|
<link rel="modulepreload" crossorigin href="/assets/pywebview-DuyK2jB1.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-DBpLCgAF.js">
|
<link rel="modulepreload" crossorigin href="/assets/brand-CKgrwtni.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-hj_EXwAP.css">
|
<link rel="stylesheet" crossorigin href="/assets/pywebview-D74jYk6P.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
|
<link rel="stylesheet" crossorigin href="/assets/split-CRUIYKS6.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
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 shopMatchStaleTimeoutMinutes = 20;
|
||||||
private long shopMatchInitialTimeoutMinutes = 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 {
|
public class ModuleCleanupProperties {
|
||||||
private boolean enabled = true;
|
private boolean enabled = true;
|
||||||
private String cron = "0 0 0 * * *";
|
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;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@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 {
|
public class PropertiesConfig {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
|||||||
public class StorageProperties {
|
public class StorageProperties {
|
||||||
private String localTempDir;
|
private String localTempDir;
|
||||||
private boolean cleanupEnabled = true;
|
private boolean cleanupEnabled = true;
|
||||||
private String cleanupCron = "0 0 */6 * * *";
|
private String cleanupCron = "0 0 * * * *";
|
||||||
private long sourceRetentionHours = 48;
|
private long sourceRetentionHours = 24;
|
||||||
private long resultRetentionHours = 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,
|
@PathVariable Long taskId,
|
||||||
@Valid @RequestBody DeleteBrandSubmitResultRequest request,
|
@Valid @RequestBody DeleteBrandSubmitResultRequest request,
|
||||||
jakarta.servlet.http.HttpServletResponse response) {
|
jakarta.servlet.http.HttpServletResponse response) {
|
||||||
|
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
try {
|
try {
|
||||||
deleteBrandRunService.submitResult(taskId, request);
|
deleteBrandRunService.submitResult(taskId, request);
|
||||||
return ApiResponse.success(null);
|
return ApiResponse.success(null);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
import com.nanri.aiimage.config.DeleteBrandProgressProperties;
|
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.cache.DeleteBrandParsedFileCacheDto;
|
||||||
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandCountryResultItemDto;
|
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandCountryResultItemDto;
|
||||||
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandProcessedCountryDto;
|
import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandProcessedCountryDto;
|
||||||
@@ -77,6 +78,7 @@ public class DeleteBrandRunService {
|
|||||||
private final OssStorageService ossStorageService;
|
private final OssStorageService ossStorageService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
|
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
|
||||||
|
private final TaskPressureProperties taskPressureProperties;
|
||||||
|
|
||||||
public DeleteBrandRunVo run(DeleteBrandRunRequest request) {
|
public DeleteBrandRunVo run(DeleteBrandRunRequest request) {
|
||||||
if (request.getUserId() == null || request.getUserId() <= 0) {
|
if (request.getUserId() == null || request.getUserId() <= 0) {
|
||||||
@@ -248,7 +250,7 @@ public class DeleteBrandRunService {
|
|||||||
.distinct()
|
.distinct()
|
||||||
.toList();
|
.toList();
|
||||||
if (!taskIds.isEmpty()) {
|
if (!taskIds.isEmpty()) {
|
||||||
List<FileTaskEntity> tasks = fileTaskMapper.selectBatchIds(taskIds);
|
List<FileTaskEntity> tasks = selectTasksByIdsInBatches(taskIds);
|
||||||
for (FileTaskEntity task : tasks) {
|
for (FileTaskEntity task : tasks) {
|
||||||
try {
|
try {
|
||||||
statusByTaskId.put(task.getId(), task.getStatus());
|
statusByTaskId.put(task.getId(), task.getStatus());
|
||||||
@@ -320,7 +322,7 @@ public class DeleteBrandRunService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public DeleteBrandTaskDeletionStatusVo getTaskDeletionStatus(Long taskId, Long userId) {
|
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())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
|
||||||
throw new BusinessException("任务不存在");
|
throw new BusinessException("任务不存在");
|
||||||
}
|
}
|
||||||
@@ -487,7 +489,7 @@ public class DeleteBrandRunService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public DeleteBrandTaskDetailVo getTaskDetail(Long taskId) {
|
public DeleteBrandTaskDetailVo getTaskDetail(Long taskId) {
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
throw new BusinessException("任务不存在");
|
throw new BusinessException("任务不存在");
|
||||||
}
|
}
|
||||||
@@ -519,7 +521,7 @@ public class DeleteBrandRunService {
|
|||||||
|
|
||||||
java.util.Map<Long, FileTaskEntity> taskById = new java.util.LinkedHashMap<>(cachedTasks);
|
java.util.Map<Long, FileTaskEntity> taskById = new java.util.LinkedHashMap<>(cachedTasks);
|
||||||
if (!missingTaskIds.isEmpty()) {
|
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()))
|
.filter(task -> task != null && MODULE_TYPE.equals(task.getModuleType()))
|
||||||
.toList();
|
.toList();
|
||||||
for (FileTaskEntity dbTask : dbTasks) {
|
for (FileTaskEntity dbTask : dbTasks) {
|
||||||
@@ -569,7 +571,7 @@ public class DeleteBrandRunService {
|
|||||||
|
|
||||||
java.util.Map<Long, FileTaskEntity> taskById = new java.util.LinkedHashMap<>(cachedTasks);
|
java.util.Map<Long, FileTaskEntity> taskById = new java.util.LinkedHashMap<>(cachedTasks);
|
||||||
if (!missingTaskIds.isEmpty()) {
|
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()))
|
.filter(task -> task != null && MODULE_TYPE.equals(task.getModuleType()))
|
||||||
.toList();
|
.toList();
|
||||||
for (FileTaskEntity dbTask : dbTasks) {
|
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
|
@Transactional
|
||||||
public void submitResult(Long taskId, DeleteBrandSubmitResultRequest request) {
|
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());
|
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 不能为空");
|
throw new BusinessException("files 不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
throw new BusinessException("任务不存在");
|
throw new BusinessException("任务不存在");
|
||||||
}
|
}
|
||||||
@@ -981,8 +1009,10 @@ public class DeleteBrandRunService {
|
|||||||
task.setUpdatedAt(LocalDateTime.now());
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
task.setFinishedAt(null);
|
task.setFinishedAt(null);
|
||||||
fileTaskMapper.updateById(task);
|
fileTaskMapper.updateById(task);
|
||||||
|
deleteBrandTaskCacheService.saveTaskCache(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean shouldTryFinalize = false;
|
||||||
|
|
||||||
for (DeleteBrandResultFileDto fileDto : request.getFiles()) {
|
for (DeleteBrandResultFileDto fileDto : request.getFiles()) {
|
||||||
log.info("[DeleteBrand] submitResult processing chunk -> taskId: {}, file: {}, chunkIndex: {}/{}, processedRows: {}/{}",
|
log.info("[DeleteBrand] submitResult processing chunk -> taskId: {}, file: {}, chunkIndex: {}/{}, processedRows: {}/{}",
|
||||||
@@ -1000,15 +1030,11 @@ public class DeleteBrandRunService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 更早的 duplicate fast-path:尽量在较重的校验/进度写入之前直接短路
|
// 更早的 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);
|
validateChunk(fileDto, parsedFile);
|
||||||
validateResultFileAgainstParsed(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) {
|
if (!stored) {
|
||||||
|
log.info("[DeleteBrand] Duplicate chunk ignored for taskId: {}, file: {}, chunkIndex: {}", taskId, fileIdentity, fileDto.getChunkIndex());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,20 +1049,21 @@ public class DeleteBrandRunService {
|
|||||||
progress.put("current_asin", blankToEmpty(fileDto.getCurrentAsin()));
|
progress.put("current_asin", blankToEmpty(fileDto.getCurrentAsin()));
|
||||||
progress.put("updated_at", String.valueOf(System.currentTimeMillis()));
|
progress.put("updated_at", String.valueOf(System.currentTimeMillis()));
|
||||||
progress.put("last_heartbeat_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 能更精确
|
// 如果该文件已完成,立即更新成品计数的 Redis 缓存「提示」,让后续 tryFinalizeTask 能更精确
|
||||||
if (isFileCompleted(fileDto)) {
|
if (isFileCompleted(fileDto)) {
|
||||||
log.info("[DeleteBrand] File chunk transmission fully reached via chunkTotal for taskId: {}, file: {}", taskId, fileDto.getSourceFilename());
|
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);
|
shouldTryFinalize = true;
|
||||||
int finishedCount = parseInt(currentProgress.get("finished_files")) == null ? 0 : parseInt(currentProgress.get("finished_files"));
|
|
||||||
// 这里我们暂不直接 increment,而是标记需要重算或在 tryFinalizeTask 中重新计算
|
// 这里我们暂不直接 increment,而是标记需要重算或在 tryFinalizeTask 中重新计算
|
||||||
// 为了保险,我们直接在 tryFinalizeTask 里重新遍历分片状态
|
// 为了保险,我们直接在 tryFinalizeTask 里重新遍历分片状态
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tryFinalizeTask(taskId, false);
|
if (shouldTryFinalize) {
|
||||||
|
tryFinalizeTask(taskId, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void tryFinalizeTask(Long taskId, boolean fromCompensation) {
|
public void tryFinalizeTask(Long taskId, boolean fromCompensation) {
|
||||||
@@ -1044,7 +1071,7 @@ public class DeleteBrandRunService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1082,6 +1109,7 @@ public class DeleteBrandRunService {
|
|||||||
task.setUpdatedAt(LocalDateTime.now());
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
}
|
}
|
||||||
fileTaskMapper.updateById(task);
|
fileTaskMapper.updateById(task);
|
||||||
|
deleteBrandTaskCacheService.saveTaskCache(task);
|
||||||
|
|
||||||
log.info("[DeleteBrand] tryFinalizeTask skipping -> finishedFiles ({}) < expectedFiles ({})", finishedFiles, expectedFiles);
|
log.info("[DeleteBrand] tryFinalizeTask skipping -> finishedFiles ({}) < expectedFiles ({})", finishedFiles, expectedFiles);
|
||||||
return;
|
return;
|
||||||
@@ -1093,11 +1121,12 @@ public class DeleteBrandRunService {
|
|||||||
if (fromCompensation) {
|
if (fromCompensation) {
|
||||||
progress.put("last_heartbeat_at", String.valueOf(System.currentTimeMillis()));
|
progress.put("last_heartbeat_at", String.valueOf(System.currentTimeMillis()));
|
||||||
}
|
}
|
||||||
deleteBrandTaskCacheService.saveProgress(taskId, progress);
|
deleteBrandTaskCacheService.saveProgress(taskId, progress, true);
|
||||||
|
|
||||||
task.setSuccessFileCount(finishedFiles);
|
task.setSuccessFileCount(finishedFiles);
|
||||||
task.setUpdatedAt(LocalDateTime.now());
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
fileTaskMapper.updateById(task);
|
fileTaskMapper.updateById(task);
|
||||||
|
deleteBrandTaskCacheService.saveTaskCache(task);
|
||||||
|
|
||||||
log.info("[DeleteBrand] tryFinalizeTask calling finalizeTask -> finishedFiles: {}, expectedFiles: {}", finishedFiles, expectedFiles);
|
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.config.DeleteBrandProgressProperties;
|
||||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskCacheService;
|
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskCacheService;
|
||||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
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.PriceTrackTaskCacheService;
|
||||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskCacheService;
|
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 com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
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_PRODUCT_RISK = "PRODUCT_RISK_RESOLVE";
|
||||||
private static final String MODULE_TYPE_PRICE_TRACK = "PRICE_TRACK";
|
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_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 STALE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
|
||||||
private static final Duration FINALIZE_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_CLEANUP_LOCK_TTL = Duration.ofMinutes(10);
|
||||||
private static final Duration TEMP_DIR_RETENTION = Duration.ofHours(48);
|
|
||||||
|
|
||||||
private final FileTaskMapper fileTaskMapper;
|
private final FileTaskMapper fileTaskMapper;
|
||||||
private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
|
private final DeleteBrandTaskCacheService deleteBrandTaskCacheService;
|
||||||
@@ -51,9 +54,14 @@ public class DeleteBrandStaleTaskService {
|
|||||||
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
|
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
|
||||||
private final ShopMatchTaskService shopMatchTaskService;
|
private final ShopMatchTaskService shopMatchTaskService;
|
||||||
private final ShopMatchTaskCacheService shopMatchTaskCacheService;
|
private final ShopMatchTaskCacheService shopMatchTaskCacheService;
|
||||||
|
private final PatrolDeleteTaskService patrolDeleteTaskService;
|
||||||
|
private final PatrolDeleteTaskCacheService patrolDeleteTaskCacheService;
|
||||||
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
|
private final DeleteBrandProgressProperties deleteBrandProgressProperties;
|
||||||
private final DistributedJobLockService distributedJobLockService;
|
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 * * * *}")
|
@Scheduled(cron = "${aiimage.delete-brand-progress.stale-check-cron:0 */2 * * * *}")
|
||||||
public void failStaleRunningTasks() {
|
public void failStaleRunningTasks() {
|
||||||
DistributedJobLockService.LockHandle lockHandle =
|
DistributedJobLockService.LockHandle lockHandle =
|
||||||
@@ -69,6 +77,7 @@ public class DeleteBrandStaleTaskService {
|
|||||||
ProductRiskStaleCheckStats stats = failStaleProductRiskResolveTasks();
|
ProductRiskStaleCheckStats stats = failStaleProductRiskResolveTasks();
|
||||||
PriceTrackStaleCheckStats priceTrackStats = failStalePriceTrackTasks();
|
PriceTrackStaleCheckStats priceTrackStats = failStalePriceTrackTasks();
|
||||||
ShopMatchStaleCheckStats shopMatchStats = failStaleShopMatchTasks();
|
ShopMatchStaleCheckStats shopMatchStats = failStaleShopMatchTasks();
|
||||||
|
ShopMatchStaleCheckStats patrolDeleteStats = failStalePatrolDeleteTasks();
|
||||||
log.info("[stale-check] product-risk summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
|
log.info("[stale-check] product-risk summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
|
||||||
stats.scannedTaskCount,
|
stats.scannedTaskCount,
|
||||||
stats.finalizedTaskCount,
|
stats.finalizedTaskCount,
|
||||||
@@ -90,6 +99,13 @@ public class DeleteBrandStaleTaskService {
|
|||||||
shopMatchStats.skippedTaskCount,
|
shopMatchStats.skippedTaskCount,
|
||||||
System.currentTimeMillis() - startedAt,
|
System.currentTimeMillis() - startedAt,
|
||||||
Thread.currentThread().getName());
|
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;
|
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 * * * *}")
|
@Scheduled(cron = "${aiimage.delete-brand-progress.finalize-check-cron:30 */2 * * * *}")
|
||||||
public void finalizeCompletedRunningTasks() {
|
public void finalizeCompletedRunningTasks() {
|
||||||
DistributedJobLockService.LockHandle lockHandle =
|
DistributedJobLockService.LockHandle lockHandle =
|
||||||
@@ -369,12 +444,14 @@ public class DeleteBrandStaleTaskService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try (lockHandle) {
|
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(
|
List<String> dirNames = List.of(
|
||||||
"shop-match-cache",
|
"shop-match-cache",
|
||||||
|
"patrol-delete-cache",
|
||||||
"brand-task-cache",
|
"brand-task-cache",
|
||||||
"delete-brand-task-cache",
|
"delete-brand-task-cache",
|
||||||
"shop-match-result",
|
"shop-match-result",
|
||||||
|
"patrol-delete-result",
|
||||||
"delete-brand-result",
|
"delete-brand-result",
|
||||||
"product-risk-result",
|
"product-risk-result",
|
||||||
"price-track-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.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -24,10 +25,16 @@ public class DeleteBrandTaskCacheService {
|
|||||||
|
|
||||||
private static final long PAYLOAD_TTL_HOURS = 24;
|
private static final long PAYLOAD_TTL_HOURS = 24;
|
||||||
private static final long LOCAL_PARSED_PAYLOAD_CACHE_MILLIS = 3000;
|
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 StringRedisTemplate stringRedisTemplate;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final TaskPressureProperties taskPressureProperties;
|
||||||
private final ConcurrentHashMap<Long, LocalParsedPayloadCacheEntry> parsedPayloadLocalCache = new ConcurrentHashMap<>();
|
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) {
|
public void saveParsedPayload(Long taskId, Object payload) {
|
||||||
try {
|
try {
|
||||||
@@ -75,13 +82,44 @@ public class DeleteBrandTaskCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void saveProgress(Long taskId, java.util.Map<String, String> values) {
|
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);
|
String key = buildProgressKey(taskId);
|
||||||
stringRedisTemplate.opsForHash().putAll(key, values);
|
stringRedisTemplate.opsForHash().putAll(key, merged);
|
||||||
stringRedisTemplate.expire(key, Duration.ofHours(PAYLOAD_TTL_HOURS));
|
stringRedisTemplate.expire(key, Duration.ofHours(PAYLOAD_TTL_HOURS));
|
||||||
|
progressRedisFlushAt.put(taskId, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
public java.util.Map<Object, Object> getProgress(Long taskId) {
|
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) {
|
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;
|
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 -> {
|
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();
|
org.springframework.data.redis.serializer.RedisSerializer<String> serializer = stringRedisTemplate.getStringSerializer();
|
||||||
for (Long taskId : normalizedTaskIds) {
|
for (Long taskId : missingTaskIds) {
|
||||||
connection.hGetAll(serializer.serialize(buildProgressKey(taskId)));
|
connection.hGetAll(serializer.serialize(buildProgressKey(taskId)));
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
for (int i = 0; i < normalizedTaskIds.size(); i++) {
|
for (int i = 0; i < missingTaskIds.size(); i++) {
|
||||||
Long taskId = normalizedTaskIds.get(i);
|
Long taskId = missingTaskIds.get(i);
|
||||||
Object raw = pipelineResults != null && i < pipelineResults.size() ? pipelineResults.get(i) : null;
|
Object raw = pipelineResults != null && i < pipelineResults.size() ? pipelineResults.get(i) : null;
|
||||||
if (raw instanceof java.util.Map<?, ?> rawMap) {
|
if (raw instanceof java.util.Map<?, ?> rawMap) {
|
||||||
java.util.Map<Object, Object> converted = new java.util.LinkedHashMap<>();
|
java.util.Map<Object, Object> converted = new java.util.LinkedHashMap<>();
|
||||||
@@ -115,6 +167,7 @@ public class DeleteBrandTaskCacheService {
|
|||||||
converted.put(entry.getKey(), entry.getValue());
|
converted.put(entry.getKey(), entry.getValue());
|
||||||
}
|
}
|
||||||
result.put(taskId, converted);
|
result.put(taskId, converted);
|
||||||
|
progressLocalCache.put(taskId, new LocalProgressCacheEntry(now, toStringMap(converted)));
|
||||||
} else {
|
} else {
|
||||||
result.put(taskId, java.util.Collections.emptyMap());
|
result.put(taskId, java.util.Collections.emptyMap());
|
||||||
}
|
}
|
||||||
@@ -169,6 +222,10 @@ public class DeleteBrandTaskCacheService {
|
|||||||
return true;
|
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) {
|
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<Object, Object> stored = stringRedisTemplate.opsForHash().entries(buildResultChunksKey(taskId));
|
||||||
java.util.Map<String, java.util.List<String>> grouped = new java.util.LinkedHashMap<>();
|
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) {
|
public void delete(Long taskId) {
|
||||||
parsedPayloadLocalCache.remove(taskId);
|
parsedPayloadLocalCache.remove(taskId);
|
||||||
|
progressLocalCache.remove(taskId);
|
||||||
|
progressRedisFlushAt.remove(taskId);
|
||||||
|
taskEntityLocalCache.remove(taskId);
|
||||||
stringRedisTemplate.delete(buildProgressKey(taskId));
|
stringRedisTemplate.delete(buildProgressKey(taskId));
|
||||||
stringRedisTemplate.delete(buildResultChunksKey(taskId));
|
stringRedisTemplate.delete(buildResultChunksKey(taskId));
|
||||||
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
|
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
|
||||||
@@ -198,6 +258,11 @@ public class DeleteBrandTaskCacheService {
|
|||||||
if (task == null || task.getId() == null) {
|
if (task == null || task.getId() == null) {
|
||||||
return;
|
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 {
|
try {
|
||||||
stringRedisTemplate.opsForValue().set(buildTaskEntityKey(task.getId()), objectMapper.writeValueAsString(task), Duration.ofHours(PAYLOAD_TTL_HOURS));
|
stringRedisTemplate.opsForValue().set(buildTaskEntityKey(task.getId()), objectMapper.writeValueAsString(task), Duration.ofHours(PAYLOAD_TTL_HOURS));
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
@@ -215,15 +280,32 @@ public class DeleteBrandTaskCacheService {
|
|||||||
return result;
|
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);
|
java.util.List<String> values = stringRedisTemplate.opsForValue().multiGet(keys);
|
||||||
|
|
||||||
for (int i = 0; i < normalized.size(); i++) {
|
for (int i = 0; i < missingIds.size(); i++) {
|
||||||
Long taskId = normalized.get(i);
|
Long taskId = missingIds.get(i);
|
||||||
String val = values != null && i < values.size() ? values.get(i) : null;
|
String val = values != null && i < values.size() ? values.get(i) : null;
|
||||||
if (val != null && !val.isBlank()) {
|
if (val != null && !val.isBlank()) {
|
||||||
try {
|
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) {
|
} 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) {
|
private String buildProgressKey(Long taskId) {
|
||||||
return "delete-brand:task:progress:" + 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"
|
example = "200"
|
||||||
)
|
)
|
||||||
@PathVariable Long taskId,
|
@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);
|
priceTrackTaskService.submitResult(taskId, request);
|
||||||
return ApiResponse.success(null);
|
return ApiResponse.success(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,4 +20,7 @@ public class PriceTrackCreateTaskVo {
|
|||||||
|
|
||||||
@Schema(description = "parsed asin rows by country")
|
@Schema(description = "parsed asin rows by country")
|
||||||
private Map<String, List<Map<String, String>>> asinRowsByCountry;
|
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")
|
@Schema(description = "Parsed asin rows grouped by country code")
|
||||||
private Map<String, List<Map<String, String>>> asinRowsByCountry;
|
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
|
@Data
|
||||||
@Schema(description = "Single matched shop item")
|
@Schema(description = "Single matched shop item")
|
||||||
public static class PriceTrackShopQueueItem {
|
public static class PriceTrackShopQueueItem {
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ public class PriceTrackService {
|
|||||||
PriceTrackMatchShopsVo vo = new PriceTrackMatchShopsVo();
|
PriceTrackMatchShopsVo vo = new PriceTrackMatchShopsVo();
|
||||||
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
||||||
vo.setAsinRowsByCountry(asinRowsByCountry);
|
vo.setAsinRowsByCountry(asinRowsByCountry);
|
||||||
|
vo.setMinimumPriceByCountryAndAsin(priceTrackTaskService.buildMinimumPriceLookupForMatch(asinRowsByCountry));
|
||||||
for (String shopName : ordered) {
|
for (String shopName : ordered) {
|
||||||
vo.getItems().add(matchOneShop(shopName, skipAsinsByCountry));
|
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.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
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.pricetrack.model.dto.PriceTrackSubmitResultRequest;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class PriceTrackTaskCacheService {
|
public class PriceTrackTaskCacheService {
|
||||||
|
|
||||||
private static final long HEARTBEAT_TTL_HOURS = 24;
|
private static final long HEARTBEAT_TTL_HOURS = 24;
|
||||||
|
|
||||||
private final StringRedisTemplate stringRedisTemplate;
|
private final StringRedisTemplate stringRedisTemplate;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final TaskPressureProperties taskPressureProperties;
|
||||||
|
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public void touchTaskHeartbeat(Long taskId) {
|
public void touchTaskHeartbeat(Long taskId) {
|
||||||
if (taskId == null || taskId <= 0) {
|
if (taskId == null || taskId <= 0) {
|
||||||
@@ -112,8 +117,72 @@ public class PriceTrackTaskCacheService {
|
|||||||
if (taskId == null || taskId <= 0) {
|
if (taskId == null || taskId <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
taskEntityLocalCache.remove(taskId);
|
||||||
stringRedisTemplate.delete(buildShopPayloadKey(taskId));
|
stringRedisTemplate.delete(buildShopPayloadKey(taskId));
|
||||||
stringRedisTemplate.delete(buildTaskHeartbeatKey(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) {
|
private String buildShopPayloadKey(Long taskId) {
|
||||||
@@ -123,4 +192,15 @@ public class PriceTrackTaskCacheService {
|
|||||||
private String buildTaskHeartbeatKey(Long taskId) {
|
private String buildTaskHeartbeatKey(Long taskId) {
|
||||||
return "price-track:task:heartbeat:" + 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.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
import com.nanri.aiimage.common.util.ExcelStreamReader;
|
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.file.service.oss.OssStorageService;
|
||||||
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
|
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
|
||||||
import com.nanri.aiimage.modules.pricetrack.mapper.PriceTrackShopCandidateMapper;
|
import com.nanri.aiimage.modules.pricetrack.mapper.PriceTrackShopCandidateMapper;
|
||||||
@@ -64,6 +65,62 @@ public class PriceTrackTaskService {
|
|||||||
private final PriceTrackExcelAssemblyService excelAssemblyService;
|
private final PriceTrackExcelAssemblyService excelAssemblyService;
|
||||||
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
|
private final PriceTrackTaskCacheService priceTrackTaskCacheService;
|
||||||
private final PriceTrackLoopRunService priceTrackLoopRunService;
|
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) {
|
public PriceTrackDashboardVo dashboard(Long userId) {
|
||||||
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
||||||
@@ -104,7 +161,7 @@ public class PriceTrackTaskService {
|
|||||||
.distinct()
|
.distinct()
|
||||||
.toList();
|
.toList();
|
||||||
if (!taskIds.isEmpty()) {
|
if (!taskIds.isEmpty()) {
|
||||||
List<FileTaskEntity> tasks = fileTaskMapper.selectBatchIds(taskIds);
|
List<FileTaskEntity> tasks = selectTasksByIdsInBatches(taskIds);
|
||||||
for (FileTaskEntity t : tasks) {
|
for (FileTaskEntity t : tasks) {
|
||||||
if (t != null) statusByTaskId.put(t.getId(), t.getStatus());
|
if (t != null) statusByTaskId.put(t.getId(), t.getStatus());
|
||||||
}
|
}
|
||||||
@@ -134,7 +191,7 @@ public class PriceTrackTaskService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void deleteTask(Long taskId, Long userId) {
|
public void deleteTask(Long taskId, Long userId) {
|
||||||
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
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())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
|
||||||
throw new BusinessException("任务不存在");
|
throw new BusinessException("任务不存在");
|
||||||
}
|
}
|
||||||
@@ -164,7 +221,7 @@ public class PriceTrackTaskService {
|
|||||||
vo.setRemoved(false);
|
vo.setRemoved(false);
|
||||||
for (FileResultEntity fr : candidates) {
|
for (FileResultEntity fr : candidates) {
|
||||||
if (fr.getTaskId() == null) continue;
|
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 (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) continue;
|
||||||
if (!"RUNNING".equals(task.getStatus())) continue;
|
if (!"RUNNING".equals(task.getStatus())) continue;
|
||||||
fileResultMapper.deleteById(fr.getId());
|
fileResultMapper.deleteById(fr.getId());
|
||||||
@@ -178,10 +235,11 @@ public class PriceTrackTaskService {
|
|||||||
public PriceTrackTaskBatchVo getTaskDetailsBatch(List<Long> taskIds) {
|
public PriceTrackTaskBatchVo getTaskDetailsBatch(List<Long> taskIds) {
|
||||||
PriceTrackTaskBatchVo batch = new PriceTrackTaskBatchVo();
|
PriceTrackTaskBatchVo batch = new PriceTrackTaskBatchVo();
|
||||||
if (taskIds == null || taskIds.isEmpty()) return batch;
|
if (taskIds == null || taskIds.isEmpty()) return batch;
|
||||||
|
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(taskIds);
|
||||||
for (Long taskId : taskIds) {
|
for (Long taskId : taskIds) {
|
||||||
if (taskId == null || taskId <= 0) continue;
|
if (taskId == null || taskId <= 0) continue;
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = taskMap.get(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null) {
|
||||||
batch.getMissingTaskIds().add(taskId);
|
batch.getMissingTaskIds().add(taskId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -195,12 +253,13 @@ public class PriceTrackTaskService {
|
|||||||
if (taskIds == null || taskIds.isEmpty()) {
|
if (taskIds == null || taskIds.isEmpty()) {
|
||||||
return batch;
|
return batch;
|
||||||
}
|
}
|
||||||
|
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(taskIds);
|
||||||
for (Long taskId : taskIds) {
|
for (Long taskId : taskIds) {
|
||||||
if (taskId == null || taskId <= 0) {
|
if (taskId == null || taskId <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = taskMap.get(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null) {
|
||||||
batch.getMissingTaskIds().add(taskId);
|
batch.getMissingTaskIds().add(taskId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -237,6 +296,8 @@ public class PriceTrackTaskService {
|
|||||||
Map<String, List<Map<String, String>>> asinRowsByCountry = request.isAsinMode()
|
Map<String, List<Map<String, String>>> asinRowsByCountry = request.isAsinMode()
|
||||||
? parseAsinRowsByCountry(request.getAsinFiles(), request.getCountryCodes())
|
? parseAsinRowsByCountry(request.getAsinFiles(), request.getCountryCodes())
|
||||||
: new LinkedHashMap<>();
|
: new LinkedHashMap<>();
|
||||||
|
Map<String, Map<String, String>> minimumPriceByCountryAndAsin =
|
||||||
|
buildMinimumPriceByCountryAndAsin(asinRowsByCountry);
|
||||||
log.info("[price-track] createTask skipAsins countries={} asinMode={}",
|
log.info("[price-track] createTask skipAsins countries={} asinMode={}",
|
||||||
skipAsinsByCountry.keySet(), request.isAsinMode());
|
skipAsinsByCountry.keySet(), request.isAsinMode());
|
||||||
|
|
||||||
@@ -254,6 +315,7 @@ public class PriceTrackTaskService {
|
|||||||
task.setCreatedAt(LocalDateTime.now());
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
task.setUpdatedAt(LocalDateTime.now());
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
fileTaskMapper.insert(task);
|
fileTaskMapper.insert(task);
|
||||||
|
priceTrackTaskCacheService.saveTaskCache(task);
|
||||||
priceTrackTaskCacheService.touchTaskHeartbeat(task.getId());
|
priceTrackTaskCacheService.touchTaskHeartbeat(task.getId());
|
||||||
if (request.getLoopRunId() != null) {
|
if (request.getLoopRunId() != null) {
|
||||||
priceTrackLoopRunService.bindChildTask(request.getLoopRunId(), task.getId(), request.getRoundIndex(), request.getShopIndex());
|
priceTrackLoopRunService.bindChildTask(request.getLoopRunId(), task.getId(), request.getRoundIndex(), request.getShopIndex());
|
||||||
@@ -287,6 +349,7 @@ public class PriceTrackTaskService {
|
|||||||
ctx.put("countryCodes", request.getCountryCodes());
|
ctx.put("countryCodes", request.getCountryCodes());
|
||||||
ctx.put("skipAsinsByCountry", skipAsinsByCountry);
|
ctx.put("skipAsinsByCountry", skipAsinsByCountry);
|
||||||
ctx.put("asinRowsByCountry", asinRowsByCountry);
|
ctx.put("asinRowsByCountry", asinRowsByCountry);
|
||||||
|
ctx.put("minimumPriceByCountryAndAsin", minimumPriceByCountryAndAsin);
|
||||||
ctx.put("items", uniqueItems);
|
ctx.put("items", uniqueItems);
|
||||||
ctx.put("loopRunId", request.getLoopRunId());
|
ctx.put("loopRunId", request.getLoopRunId());
|
||||||
ctx.put("roundIndex", request.getRoundIndex());
|
ctx.put("roundIndex", request.getRoundIndex());
|
||||||
@@ -306,6 +369,7 @@ public class PriceTrackTaskService {
|
|||||||
vo.setItems(snapshot);
|
vo.setItems(snapshot);
|
||||||
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
||||||
vo.setAsinRowsByCountry(asinRowsByCountry);
|
vo.setAsinRowsByCountry(asinRowsByCountry);
|
||||||
|
vo.setMinimumPriceByCountryAndAsin(minimumPriceByCountryAndAsin);
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +379,7 @@ public class PriceTrackTaskService {
|
|||||||
if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
|
if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
|
||||||
throw new BusinessException("shops 不能为空");
|
throw new BusinessException("shops 不能为空");
|
||||||
}
|
}
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
throw new BusinessException("任务不存在");
|
throw new BusinessException("任务不存在");
|
||||||
}
|
}
|
||||||
@@ -386,7 +450,7 @@ public class PriceTrackTaskService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
|
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
|
||||||
if (taskId == null || taskId <= 0) return false;
|
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 (task == null || !MODULE_TYPE.equals(task.getModuleType())) return false;
|
||||||
if (!"RUNNING".equals(task.getStatus())) {
|
if (!"RUNNING".equals(task.getStatus())) {
|
||||||
log.warn("[price-track] stale finalize skipped taskId={} status={} fromCompensation={}",
|
log.warn("[price-track] stale finalize skipped taskId={} status={} fromCompensation={}",
|
||||||
@@ -490,7 +554,7 @@ public class PriceTrackTaskService {
|
|||||||
// ---- private helpers ----
|
// ---- private helpers ----
|
||||||
|
|
||||||
private void reconcileTaskAfterResultRemoval(Long taskId) {
|
private void reconcileTaskAfterResultRemoval(Long taskId) {
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) return;
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) return;
|
||||||
List<FileResultEntity> latest = fileResultMapper.selectList(
|
List<FileResultEntity> latest = fileResultMapper.selectList(
|
||||||
new LambdaQueryWrapper<FileResultEntity>()
|
new LambdaQueryWrapper<FileResultEntity>()
|
||||||
@@ -552,6 +616,11 @@ public class PriceTrackTaskService {
|
|||||||
return parseAsinRowsByCountry(asinFiles, countryCodes);
|
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) {
|
private Map<String, List<Map<String, String>>> parseAsinRowsByCountry(List<String> asinFiles, List<String> countryCodes) {
|
||||||
Map<String, List<Map<String, String>>> merged = new LinkedHashMap<>();
|
Map<String, List<Map<String, String>>> merged = new LinkedHashMap<>();
|
||||||
if (asinFiles == null || asinFiles.isEmpty()) {
|
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) {
|
private String resolveCountryCode(String sheetName, List<String> countryCodes, boolean singleSheetFallback) {
|
||||||
String normalized = normalizeHeaderText(sheetName).toUpperCase(Locale.ROOT);
|
String normalized = normalizeHeaderText(sheetName).toUpperCase(Locale.ROOT);
|
||||||
for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) {
|
for (ProductRiskCountryCode code : ProductRiskCountryCode.values()) {
|
||||||
@@ -832,7 +932,7 @@ public class PriceTrackTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void mergeQueueFieldsFromRequest(PriceTrackResultItemVo vo, Long taskId) {
|
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;
|
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) return;
|
||||||
try {
|
try {
|
||||||
Map<String, Object> payload = objectMapper.readValue(task.getRequestJson(), new TypeReference<Map<String, Object>>() {});
|
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);
|
productRiskTaskService.submitResult(taskId, request);
|
||||||
return ApiResponse.success(null);
|
return ApiResponse.success(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,29 @@ package com.nanri.aiimage.modules.productrisk.service;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
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.productrisk.model.dto.ProductRiskShopPayloadDto;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ProductRiskTaskCacheService {
|
public class ProductRiskTaskCacheService {
|
||||||
|
|
||||||
private static final long PAYLOAD_TTL_HOURS = 24;
|
private static final long PAYLOAD_TTL_HOURS = 24;
|
||||||
|
|
||||||
private final StringRedisTemplate stringRedisTemplate;
|
private final StringRedisTemplate stringRedisTemplate;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final TaskPressureProperties taskPressureProperties;
|
||||||
|
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public ProductRiskShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
|
public ProductRiskShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
|
||||||
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
|
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
|
||||||
@@ -109,8 +115,72 @@ public class ProductRiskTaskCacheService {
|
|||||||
if (taskId == null || taskId <= 0) {
|
if (taskId == null || taskId <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
taskEntityLocalCache.remove(taskId);
|
||||||
stringRedisTemplate.delete(buildShopPayloadKey(taskId));
|
stringRedisTemplate.delete(buildShopPayloadKey(taskId));
|
||||||
stringRedisTemplate.delete(buildTaskHeartbeatKey(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) {
|
private String buildShopPayloadKey(Long taskId) {
|
||||||
@@ -127,4 +197,15 @@ public class ProductRiskTaskCacheService {
|
|||||||
private String buildTaskHeartbeatKey(Long taskId) {
|
private String buildTaskHeartbeatKey(Long taskId) {
|
||||||
return "product-risk:task:heartbeat:" + 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.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
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.file.service.oss.OssStorageService;
|
||||||
import com.nanri.aiimage.modules.productrisk.mapper.ProductRiskShopCandidateMapper;
|
import com.nanri.aiimage.modules.productrisk.mapper.ProductRiskShopCandidateMapper;
|
||||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCreateTaskRequest;
|
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCreateTaskRequest;
|
||||||
@@ -57,6 +58,62 @@ public class ProductRiskTaskService {
|
|||||||
private final OssStorageService ossStorageService;
|
private final OssStorageService ossStorageService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ProductRiskTaskCacheService productRiskTaskCacheService;
|
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) {
|
public ProductRiskDashboardVo dashboard(Long userId) {
|
||||||
if (userId == null || userId <= 0) {
|
if (userId == null || userId <= 0) {
|
||||||
@@ -100,7 +157,7 @@ public class ProductRiskTaskService {
|
|||||||
.distinct()
|
.distinct()
|
||||||
.toList();
|
.toList();
|
||||||
if (!taskIds.isEmpty()) {
|
if (!taskIds.isEmpty()) {
|
||||||
List<FileTaskEntity> tasks = fileTaskMapper.selectBatchIds(taskIds);
|
List<FileTaskEntity> tasks = selectTasksByIdsInBatches(taskIds);
|
||||||
for (FileTaskEntity t : tasks) {
|
for (FileTaskEntity t : tasks) {
|
||||||
if (t != null) {
|
if (t != null) {
|
||||||
statusByTaskId.put(t.getId(), t.getStatus());
|
statusByTaskId.put(t.getId(), t.getStatus());
|
||||||
@@ -139,7 +196,7 @@ public class ProductRiskTaskService {
|
|||||||
if (userId == null || userId <= 0) {
|
if (userId == null || userId <= 0) {
|
||||||
throw new BusinessException("user_id 不合法");
|
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())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
|
||||||
throw new BusinessException("任务不存在");
|
throw new BusinessException("任务不存在");
|
||||||
}
|
}
|
||||||
@@ -178,7 +235,7 @@ public class ProductRiskTaskService {
|
|||||||
if (fr.getTaskId() == null) {
|
if (fr.getTaskId() == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
FileTaskEntity t = fileTaskMapper.selectById(fr.getTaskId());
|
FileTaskEntity t = loadTaskForExecution(fr.getTaskId());
|
||||||
if (t == null || !MODULE_TYPE.equals(t.getModuleType()) || !userId.equals(t.getUserId())) {
|
if (t == null || !MODULE_TYPE.equals(t.getModuleType()) || !userId.equals(t.getUserId())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -198,7 +255,7 @@ public class ProductRiskTaskService {
|
|||||||
* 删除一条 file_result 后重算父任务状态;若无剩余结果则删除任务。
|
* 删除一条 file_result 后重算父任务状态;若无剩余结果则删除任务。
|
||||||
*/
|
*/
|
||||||
private void reconcileTaskAfterResultRemoval(Long taskId) {
|
private void reconcileTaskAfterResultRemoval(Long taskId) {
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -270,12 +327,13 @@ public class ProductRiskTaskService {
|
|||||||
if (taskIds == null || taskIds.isEmpty()) {
|
if (taskIds == null || taskIds.isEmpty()) {
|
||||||
return batch;
|
return batch;
|
||||||
}
|
}
|
||||||
|
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(taskIds);
|
||||||
for (Long taskId : taskIds) {
|
for (Long taskId : taskIds) {
|
||||||
if (taskId == null || taskId <= 0) {
|
if (taskId == null || taskId <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = taskMap.get(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null) {
|
||||||
batch.getMissingTaskIds().add(taskId);
|
batch.getMissingTaskIds().add(taskId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -289,12 +347,13 @@ public class ProductRiskTaskService {
|
|||||||
if (taskIds == null || taskIds.isEmpty()) {
|
if (taskIds == null || taskIds.isEmpty()) {
|
||||||
return batch;
|
return batch;
|
||||||
}
|
}
|
||||||
|
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(taskIds);
|
||||||
for (Long taskId : taskIds) {
|
for (Long taskId : taskIds) {
|
||||||
if (taskId == null || taskId <= 0) {
|
if (taskId == null || taskId <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = taskMap.get(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null) {
|
||||||
batch.getMissingTaskIds().add(taskId);
|
batch.getMissingTaskIds().add(taskId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -359,6 +418,7 @@ public class ProductRiskTaskService {
|
|||||||
task.setCreatedAt(LocalDateTime.now());
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
task.setUpdatedAt(LocalDateTime.now());
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
fileTaskMapper.insert(task);
|
fileTaskMapper.insert(task);
|
||||||
|
productRiskTaskCacheService.saveTaskCache(task);
|
||||||
productRiskTaskCacheService.touchTaskHeartbeat(task.getId());
|
productRiskTaskCacheService.touchTaskHeartbeat(task.getId());
|
||||||
|
|
||||||
List<ProductRiskResultItemVo> snapshot = new ArrayList<>();
|
List<ProductRiskResultItemVo> snapshot = new ArrayList<>();
|
||||||
@@ -405,7 +465,7 @@ public class ProductRiskTaskService {
|
|||||||
throw new BusinessException("shops 不能为空");
|
throw new BusinessException("shops 不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
throw new BusinessException("任务不存在");
|
throw new BusinessException("任务不存在");
|
||||||
}
|
}
|
||||||
@@ -566,7 +626,7 @@ public class ProductRiskTaskService {
|
|||||||
if (taskId == null || taskId <= 0) {
|
if (taskId == null || taskId <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -861,7 +921,7 @@ public class ProductRiskTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void mergeQueueFieldsFromRequest(ProductRiskResultItemVo vo, Long taskId) {
|
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()) {
|
if (t == null || t.getRequestJson() == null || t.getRequestJson().isBlank()) {
|
||||||
return;
|
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 jakarta.validation.constraints.NotNull;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class ShopManageGroupCreateRequest {
|
public class ShopManageGroupCreateRequest {
|
||||||
|
|
||||||
@@ -12,9 +14,8 @@ public class ShopManageGroupCreateRequest {
|
|||||||
@NotBlank(message = "分组名称不能为空")
|
@NotBlank(message = "分组名称不能为空")
|
||||||
private String groupName;
|
private String groupName;
|
||||||
|
|
||||||
@Schema(description = "关联用户ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "组员用户ID列表")
|
||||||
@NotNull(message = "关联用户不能为空")
|
private List<Long> memberUserIds;
|
||||||
private Long userId;
|
|
||||||
|
|
||||||
@Schema(description = "创建人用户ID,由后台代理透传", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "创建人用户ID,由后台代理透传", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
@NotNull(message = "创建人不能为空")
|
@NotNull(message = "创建人不能为空")
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ package com.nanri.aiimage.modules.shopkey.model.dto;
|
|||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class ShopManageGroupUpdateRequest {
|
public class ShopManageGroupUpdateRequest {
|
||||||
|
|
||||||
@@ -12,7 +13,6 @@ public class ShopManageGroupUpdateRequest {
|
|||||||
@NotBlank(message = "分组名称不能为空")
|
@NotBlank(message = "分组名称不能为空")
|
||||||
private String groupName;
|
private String groupName;
|
||||||
|
|
||||||
@Schema(description = "关联用户ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "组员用户ID列表")
|
||||||
@NotNull(message = "关联用户不能为空")
|
private List<Long> memberUserIds;
|
||||||
private Long userId;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class ShopManageGroupItemVo {
|
public class ShopManageGroupItemVo {
|
||||||
private Long id;
|
private Long id;
|
||||||
private String groupName;
|
private String groupName;
|
||||||
|
private Long leaderUserId;
|
||||||
|
private String leaderUsername;
|
||||||
|
private List<Long> memberUserIds;
|
||||||
|
private List<String> memberUsernames;
|
||||||
|
private Integer memberCount;
|
||||||
private Long userId;
|
private Long userId;
|
||||||
private String username;
|
private String username;
|
||||||
private LocalDateTime createdAt;
|
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.mapper.AdminUserMapper;
|
||||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||||
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper;
|
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.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.ShopManageGroupCreateRequest;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.dto.ShopManageGroupUpdateRequest;
|
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.ShopManageEntity;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
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 com.nanri.aiimage.modules.shopkey.model.vo.ShopManageGroupItemVo;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ShopManageGroupService {
|
public class ShopManageGroupService {
|
||||||
|
|
||||||
private final ShopManageGroupMapper groupMapper;
|
private final ShopManageGroupMapper groupMapper;
|
||||||
|
private final ShopManageGroupMemberMapper groupMemberMapper;
|
||||||
private final ShopManageMapper shopManageMapper;
|
private final ShopManageMapper shopManageMapper;
|
||||||
|
private final SkipPriceAsinMapper skipPriceAsinMapper;
|
||||||
private final AdminUserMapper adminUserMapper;
|
private final AdminUserMapper adminUserMapper;
|
||||||
|
|
||||||
public List<ShopManageGroupItemVo> list() {
|
public List<ShopManageGroupItemVo> list() {
|
||||||
@@ -32,28 +43,22 @@ public class ShopManageGroupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<ShopManageGroupItemVo> list(Long createdById) {
|
public List<ShopManageGroupItemVo> list(Long createdById) {
|
||||||
return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
|
return toVoList(groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
|
||||||
.eq(createdById != null && createdById > 0, ShopManageGroupEntity::getCreatedById, createdById)
|
.eq(createdById != null && createdById > 0, ShopManageGroupEntity::getCreatedById, createdById)
|
||||||
.orderByAsc(ShopManageGroupEntity::getId))
|
.orderByAsc(ShopManageGroupEntity::getId)));
|
||||||
.stream()
|
|
||||||
.map(this::toVo)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ShopManageGroupItemVo> listAccessible(Long operatorId, boolean superAdmin) {
|
public List<ShopManageGroupItemVo> listAccessible(Long operatorId, boolean superAdmin) {
|
||||||
if (superAdmin) {
|
if (superAdmin) {
|
||||||
return list();
|
return list();
|
||||||
}
|
}
|
||||||
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
Set<Long> groupIds = listAccessibleGroupIds(operatorId, false);
|
||||||
return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
|
if (groupIds.isEmpty()) {
|
||||||
.and(wrapper -> wrapper
|
return List.of();
|
||||||
.eq(ShopManageGroupEntity::getCreatedById, normalizedOperatorId)
|
}
|
||||||
.or()
|
return toVoList(groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
|
||||||
.eq(ShopManageGroupEntity::getUserId, normalizedOperatorId))
|
.in(ShopManageGroupEntity::getId, groupIds)
|
||||||
.orderByAsc(ShopManageGroupEntity::getId))
|
.orderByAsc(ShopManageGroupEntity::getId)));
|
||||||
.stream()
|
|
||||||
.map(this::toVo)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Set<Long> listAccessibleGroupIds(Long operatorId, boolean superAdmin) {
|
public Set<Long> listAccessibleGroupIds(Long operatorId, boolean superAdmin) {
|
||||||
@@ -63,69 +68,80 @@ public class ShopManageGroupService {
|
|||||||
.stream()
|
.stream()
|
||||||
.map(ShopManageGroupEntity::getId)
|
.map(ShopManageGroupEntity::getId)
|
||||||
.filter(id -> id != null && id > 0)
|
.filter(id -> id != null && id > 0)
|
||||||
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
}
|
}
|
||||||
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
||||||
return groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
|
LinkedHashSet<Long> groupIds = groupMapper.selectList(new LambdaQueryWrapper<ShopManageGroupEntity>()
|
||||||
.select(ShopManageGroupEntity::getId)
|
.select(ShopManageGroupEntity::getId)
|
||||||
.and(wrapper -> wrapper
|
.eq(ShopManageGroupEntity::getCreatedById, normalizedOperatorId))
|
||||||
.eq(ShopManageGroupEntity::getCreatedById, normalizedOperatorId)
|
|
||||||
.or()
|
|
||||||
.eq(ShopManageGroupEntity::getUserId, normalizedOperatorId)))
|
|
||||||
.stream()
|
.stream()
|
||||||
.map(ShopManageGroupEntity::getId)
|
.map(ShopManageGroupEntity::getId)
|
||||||
.filter(id -> id != null && id > 0)
|
.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
|
@Transactional
|
||||||
public ShopManageGroupItemVo create(ShopManageGroupCreateRequest request, Long operatorId, boolean superAdmin) {
|
public ShopManageGroupItemVo create(ShopManageGroupCreateRequest request, Long operatorId, boolean superAdmin) {
|
||||||
Long createdById = normalizePositiveId(request.getCreatedById(), "创建人不能为空");
|
Long createdById = normalizePositiveId(request.getCreatedById(), "创建人不能为空");
|
||||||
AdminUserEntity bindUser = getBindableUser(request.getUserId(), operatorId, superAdmin);
|
validateLeaderOperator(createdById, operatorId, superAdmin);
|
||||||
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
|
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
|
||||||
ensureGroupNameMatchesUser(groupName, bindUser);
|
|
||||||
ensureGroupNameUnique(groupName, null);
|
ensureGroupNameUnique(groupName, null);
|
||||||
ensureUserUnique(request.getUserId(), null);
|
|
||||||
|
|
||||||
ShopManageGroupEntity entity = new ShopManageGroupEntity();
|
ShopManageGroupEntity entity = new ShopManageGroupEntity();
|
||||||
entity.setGroupName(groupName);
|
entity.setGroupName(groupName);
|
||||||
entity.setUserId(bindUser.getId());
|
|
||||||
entity.setCreatedById(createdById);
|
entity.setCreatedById(createdById);
|
||||||
groupMapper.insert(entity);
|
groupMapper.insert(entity);
|
||||||
|
replaceMembers(entity.getId(), request.getMemberUserIds(), createdById, operatorId, superAdmin);
|
||||||
return toVo(getById(entity.getId()));
|
return toVo(getById(entity.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ShopManageGroupItemVo update(Long id, Long operatorId, boolean superAdmin, ShopManageGroupUpdateRequest request) {
|
public ShopManageGroupItemVo update(Long id, Long operatorId, boolean superAdmin, ShopManageGroupUpdateRequest request) {
|
||||||
ShopManageGroupEntity entity = getById(id);
|
ShopManageGroupEntity entity = getManageableById(id, operatorId, superAdmin);
|
||||||
validateOwnership(entity, operatorId, superAdmin);
|
|
||||||
AdminUserEntity bindUser = getBindableUser(request.getUserId(), operatorId, superAdmin);
|
|
||||||
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
|
String groupName = normalizeRequired(request.getGroupName(), "分组名称不能为空");
|
||||||
ensureGroupNameMatchesUser(groupName, bindUser);
|
|
||||||
ensureGroupNameUnique(groupName, id);
|
ensureGroupNameUnique(groupName, id);
|
||||||
ensureUserUnique(bindUser.getId(), id);
|
|
||||||
|
|
||||||
entity.setGroupName(groupName);
|
entity.setGroupName(groupName);
|
||||||
entity.setUserId(bindUser.getId());
|
|
||||||
groupMapper.updateById(entity);
|
groupMapper.updateById(entity);
|
||||||
|
replaceMembers(entity.getId(), request.getMemberUserIds(), entity.getCreatedById(), operatorId, superAdmin);
|
||||||
return toVo(getById(id));
|
return toVo(getById(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void delete(Long id, Long operatorId, boolean superAdmin) {
|
public void delete(Long id, Long operatorId, boolean superAdmin) {
|
||||||
ShopManageGroupEntity entity = getById(id);
|
ShopManageGroupEntity entity = getManageableById(id, operatorId, superAdmin);
|
||||||
validateOwnership(entity, operatorId, superAdmin);
|
|
||||||
Long useCount = shopManageMapper.selectCount(new LambdaQueryWrapper<ShopManageEntity>()
|
Long useCount = shopManageMapper.selectCount(new LambdaQueryWrapper<ShopManageEntity>()
|
||||||
.eq(ShopManageEntity::getGroupId, entity.getId()));
|
.eq(ShopManageEntity::getGroupId, entity.getId()));
|
||||||
if (useCount != null && useCount > 0) {
|
if (useCount != null && useCount > 0) {
|
||||||
throw new BusinessException("该分组下存在店铺,无法删除");
|
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());
|
groupMapper.deleteById(entity.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
public ShopManageGroupEntity getAccessibleById(Long id, Long operatorId, boolean superAdmin) {
|
public ShopManageGroupEntity getAccessibleById(Long id, Long operatorId, boolean superAdmin) {
|
||||||
ShopManageGroupEntity entity = getById(id);
|
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;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,56 +165,105 @@ public class ShopManageGroupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ensureUserUnique(Long userId, Long excludeId) {
|
private void validateAccess(ShopManageGroupEntity entity, Long operatorId, boolean superAdmin) {
|
||||||
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) {
|
|
||||||
if (superAdmin) {
|
if (superAdmin) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
||||||
boolean createdBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId);
|
boolean createdBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId);
|
||||||
boolean boundToSelf = entity.getUserId() != null && entity.getUserId().equals(normalizedOperatorId);
|
boolean memberOfGroup = isGroupMember(entity.getId(), normalizedOperatorId);
|
||||||
if (!createdBySelf && !boundToSelf) {
|
if (!createdBySelf && !memberOfGroup) {
|
||||||
throw new BusinessException("只能操作自己有权限的分组数据");
|
throw new BusinessException("只能操作自己有权限的分组数据");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private AdminUserEntity getBindableUser(Long userId, Long operatorId, boolean superAdmin) {
|
private void validateManageAccess(ShopManageGroupEntity entity, Long operatorId, boolean superAdmin) {
|
||||||
Long normalizedUserId = normalizePositiveId(userId, "关联用户不能为空");
|
|
||||||
AdminUserEntity user = adminUserMapper.selectById(normalizedUserId);
|
|
||||||
if (user == null) {
|
|
||||||
throw new BusinessException("关联用户不存在");
|
|
||||||
}
|
|
||||||
if (superAdmin) {
|
if (superAdmin) {
|
||||||
return user;
|
return;
|
||||||
}
|
}
|
||||||
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
Long normalizedOperatorId = normalizePositiveId(operatorId, "操作人不能为空");
|
||||||
boolean self = user.getId() != null && user.getId().equals(normalizedOperatorId);
|
boolean createdBySelf = entity.getCreatedById() != null && entity.getCreatedById().equals(normalizedOperatorId);
|
||||||
boolean createdBySelf = user.getCreatedById() != null && user.getCreatedById().equals(normalizedOperatorId);
|
if (!createdBySelf) {
|
||||||
if (!self && !createdBySelf) {
|
throw new BusinessException("只有组长才能维护分组");
|
||||||
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) {
|
private String normalizeRequired(String value, String message) {
|
||||||
String normalized = value == null ? "" : value.trim();
|
String normalized = normalizeBlank(value);
|
||||||
if (normalized.isEmpty()) {
|
if (normalized.isEmpty()) {
|
||||||
throw new BusinessException(message);
|
throw new BusinessException(message);
|
||||||
}
|
}
|
||||||
@@ -212,15 +277,82 @@ public class ShopManageGroupService {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalizeBlank(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
private ShopManageGroupItemVo toVo(ShopManageGroupEntity entity) {
|
private ShopManageGroupItemVo toVo(ShopManageGroupEntity entity) {
|
||||||
ShopManageGroupItemVo vo = new ShopManageGroupItemVo();
|
return toVoList(List.of(entity)).stream().findFirst().orElseThrow();
|
||||||
vo.setId(entity.getId());
|
}
|
||||||
vo.setGroupName(entity.getGroupName());
|
|
||||||
vo.setUserId(entity.getUserId());
|
private List<ShopManageGroupItemVo> toVoList(List<ShopManageGroupEntity> groups) {
|
||||||
AdminUserEntity bindUser = entity.getUserId() == null ? null : adminUserMapper.selectById(entity.getUserId());
|
if (groups == null || groups.isEmpty()) {
|
||||||
vo.setUsername(bindUser == null ? "" : (bindUser.getUsername() == null ? "" : bindUser.getUsername()));
|
return List.of();
|
||||||
vo.setCreatedAt(entity.getCreatedAt());
|
}
|
||||||
vo.setUpdatedAt(entity.getUpdatedAt());
|
|
||||||
return vo;
|
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 可多次分批回传结果;后端会组装文件并在超时场景自动补偿收尾。")
|
@Operation(summary = "提交匹配结果", description = "Python 可多次分批回传结果;后端会组装文件并在超时场景自动补偿收尾。")
|
||||||
public ApiResponse<Void> submitResult(
|
public ApiResponse<Void> submitResult(
|
||||||
@Parameter(description = "任务主键", example = "200") @PathVariable Long taskId,
|
@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);
|
shopMatchTaskService.submitResult(taskId, request);
|
||||||
return ApiResponse.success(null);
|
return ApiResponse.success(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.nanri.aiimage.modules.shopmatch.service;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
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.shopmatch.model.dto.ShopMatchShopPayloadDto;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -14,10 +16,12 @@ import java.nio.file.Path;
|
|||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -25,8 +29,9 @@ import java.util.Map;
|
|||||||
public class ShopMatchTaskCacheService {
|
public class ShopMatchTaskCacheService {
|
||||||
|
|
||||||
private static final long PAYLOAD_TTL_HOURS = 24;
|
private static final long PAYLOAD_TTL_HOURS = 24;
|
||||||
|
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final TaskPressureProperties taskPressureProperties;
|
||||||
|
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public ShopMatchShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
|
public ShopMatchShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
|
||||||
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
|
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
|
||||||
@@ -127,9 +132,11 @@ public class ShopMatchTaskCacheService {
|
|||||||
if (taskId == null || taskId <= 0) {
|
if (taskId == null || taskId <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
taskEntityLocalCache.remove(taskId);
|
||||||
try {
|
try {
|
||||||
Path taskDir = buildTaskDir(taskId);
|
Path taskDir = buildTaskDir(taskId);
|
||||||
if (!Files.exists(taskDir)) {
|
if (!Files.exists(taskDir)) {
|
||||||
|
Files.deleteIfExists(buildTaskEntityFile(taskId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try (var walk = Files.walk(taskDir)) {
|
try (var walk = Files.walk(taskDir)) {
|
||||||
@@ -145,6 +152,69 @@ public class ShopMatchTaskCacheService {
|
|||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.warn("[shop-match] delete task cache failed taskId={} msg={}", taskId, ex.getMessage());
|
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() {
|
public Duration getPayloadTtl() {
|
||||||
@@ -159,6 +229,10 @@ public class ShopMatchTaskCacheService {
|
|||||||
return buildTaskDir(taskId).resolve(hashShopKey(shopKey) + ".json");
|
return buildTaskDir(taskId).resolve(hashShopKey(shopKey) + ".json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Path buildTaskEntityFile(Long taskId) {
|
||||||
|
return buildTaskDir(taskId).resolve("_task-entity.json");
|
||||||
|
}
|
||||||
|
|
||||||
private String hashShopKey(String shopKey) {
|
private String hashShopKey(String shopKey) {
|
||||||
try {
|
try {
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
@@ -181,4 +255,11 @@ public class ShopMatchTaskCacheService {
|
|||||||
this.payload = payload;
|
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.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
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.file.service.oss.OssStorageService;
|
||||||
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
|
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
|
||||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCreateTaskVo;
|
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCreateTaskVo;
|
||||||
@@ -60,6 +61,71 @@ public class ShopMatchTaskService {
|
|||||||
private final OssStorageService ossStorageService;
|
private final OssStorageService ossStorageService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ShopMatchTaskCacheService shopMatchTaskCacheService;
|
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) {
|
public ProductRiskDashboardVo dashboard(Long userId) {
|
||||||
if (userId == null || userId <= 0) {
|
if (userId == null || userId <= 0) {
|
||||||
@@ -103,7 +169,7 @@ public class ShopMatchTaskService {
|
|||||||
.distinct()
|
.distinct()
|
||||||
.toList();
|
.toList();
|
||||||
if (!taskIds.isEmpty()) {
|
if (!taskIds.isEmpty()) {
|
||||||
for (FileTaskEntity task : fileTaskMapper.selectBatchIds(taskIds)) {
|
for (FileTaskEntity task : selectTasksByIdsInBatches(taskIds)) {
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
statusByTaskId.put(task.getId(), task.getStatus());
|
statusByTaskId.put(task.getId(), task.getStatus());
|
||||||
}
|
}
|
||||||
@@ -138,7 +204,7 @@ public class ShopMatchTaskService {
|
|||||||
if (userId == null || userId <= 0) {
|
if (userId == null || userId <= 0) {
|
||||||
throw new BusinessException("user_id 不合法");
|
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())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
|
||||||
throw new BusinessException("任务不存在");
|
throw new BusinessException("任务不存在");
|
||||||
}
|
}
|
||||||
@@ -150,7 +216,7 @@ public class ShopMatchTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void reconcileTaskAfterResultRemoval(Long taskId) {
|
private void reconcileTaskAfterResultRemoval(Long taskId) {
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,12 +237,13 @@ public class ShopMatchTaskService {
|
|||||||
if (taskIds == null || taskIds.isEmpty()) {
|
if (taskIds == null || taskIds.isEmpty()) {
|
||||||
return batch;
|
return batch;
|
||||||
}
|
}
|
||||||
|
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(taskIds);
|
||||||
for (Long taskId : taskIds) {
|
for (Long taskId : taskIds) {
|
||||||
if (taskId == null || taskId <= 0) {
|
if (taskId == null || taskId <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = taskMap.get(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null) {
|
||||||
batch.getMissingTaskIds().add(taskId);
|
batch.getMissingTaskIds().add(taskId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -190,12 +257,13 @@ public class ShopMatchTaskService {
|
|||||||
if (taskIds == null || taskIds.isEmpty()) {
|
if (taskIds == null || taskIds.isEmpty()) {
|
||||||
return batch;
|
return batch;
|
||||||
}
|
}
|
||||||
|
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(taskIds);
|
||||||
for (Long taskId : taskIds) {
|
for (Long taskId : taskIds) {
|
||||||
if (taskId == null || taskId <= 0) {
|
if (taskId == null || taskId <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = taskMap.get(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null) {
|
||||||
batch.getMissingTaskIds().add(taskId);
|
batch.getMissingTaskIds().add(taskId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -270,6 +338,7 @@ public class ShopMatchTaskService {
|
|||||||
task.setUpdatedAt(now);
|
task.setUpdatedAt(now);
|
||||||
task.setScheduledAt(scheduleTimes.isEmpty() ? null : scheduleTimes.get(0));
|
task.setScheduledAt(scheduleTimes.isEmpty() ? null : scheduleTimes.get(0));
|
||||||
fileTaskMapper.insert(task);
|
fileTaskMapper.insert(task);
|
||||||
|
shopMatchTaskCacheService.saveTaskCache(task);
|
||||||
|
|
||||||
List<ProductRiskResultItemVo> snapshot = new ArrayList<>();
|
List<ProductRiskResultItemVo> snapshot = new ArrayList<>();
|
||||||
for (ProductRiskShopQueueItemVo item : uniqueItems) {
|
for (ProductRiskShopQueueItemVo item : uniqueItems) {
|
||||||
@@ -294,7 +363,7 @@ public class ShopMatchTaskService {
|
|||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
throw new BusinessException("task 不存在");
|
throw new BusinessException("task 不存在");
|
||||||
}
|
}
|
||||||
fileTaskMapper.updateById(task);
|
updateTaskAndRefreshCache(task);
|
||||||
|
|
||||||
ProductRiskCreateTaskVo vo = new ProductRiskCreateTaskVo();
|
ProductRiskCreateTaskVo vo = new ProductRiskCreateTaskVo();
|
||||||
vo.setTaskId(task.getId());
|
vo.setTaskId(task.getId());
|
||||||
@@ -313,7 +382,7 @@ public class ShopMatchTaskService {
|
|||||||
if (stageIndex == null || stageIndex < 0) {
|
if (stageIndex == null || stageIndex < 0) {
|
||||||
throw new BusinessException("stage_index 不合法");
|
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())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
|
||||||
throw new BusinessException("任务不存在");
|
throw new BusinessException("任务不存在");
|
||||||
}
|
}
|
||||||
@@ -341,7 +410,7 @@ public class ShopMatchTaskService {
|
|||||||
task.setStatus("RUNNING");
|
task.setStatus("RUNNING");
|
||||||
task.setUpdatedAt(now);
|
task.setUpdatedAt(now);
|
||||||
persistTaskRequest(task, state);
|
persistTaskRequest(task, state);
|
||||||
fileTaskMapper.updateById(task);
|
updateTaskAndRefreshCache(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -355,7 +424,7 @@ public class ShopMatchTaskService {
|
|||||||
if (request == null || request.getStageIndex() == null || request.getStageIndex() < 0) {
|
if (request == null || request.getStageIndex() == null || request.getStageIndex() < 0) {
|
||||||
throw new BusinessException("stage_index 不合法");
|
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())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
|
||||||
throw new BusinessException("任务不存在");
|
throw new BusinessException("任务不存在");
|
||||||
}
|
}
|
||||||
@@ -377,7 +446,7 @@ public class ShopMatchTaskService {
|
|||||||
task.setUpdatedAt(now());
|
task.setUpdatedAt(now());
|
||||||
task.setFinishedAt(null);
|
task.setFinishedAt(null);
|
||||||
persistTaskRequest(task, state);
|
persistTaskRequest(task, state);
|
||||||
fileTaskMapper.updateById(task);
|
updateTaskAndRefreshCache(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -390,7 +459,7 @@ public class ShopMatchTaskService {
|
|||||||
throw new BusinessException("shops 不能为空");
|
throw new BusinessException("shops 不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
throw new BusinessException("任务不存在");
|
throw new BusinessException("任务不存在");
|
||||||
}
|
}
|
||||||
@@ -460,7 +529,7 @@ public class ShopMatchTaskService {
|
|||||||
if (taskId == null || taskId <= 0) {
|
if (taskId == null || taskId <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -640,7 +709,7 @@ public class ShopMatchTaskService {
|
|||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.warn("[shop-match] compact result json failed: {}", ex.getMessage());
|
log.warn("[shop-match] compact result json failed: {}", ex.getMessage());
|
||||||
}
|
}
|
||||||
fileTaskMapper.updateById(task);
|
updateTaskAndRefreshCache(task);
|
||||||
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
||||||
shopMatchTaskCacheService.deleteTaskCache(task.getId());
|
shopMatchTaskCacheService.deleteTaskCache(task.getId());
|
||||||
}
|
}
|
||||||
@@ -799,12 +868,12 @@ public class ShopMatchTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private LocalDateTime loadScheduledAt(Long taskId) {
|
private LocalDateTime loadScheduledAt(Long taskId) {
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||||
return task == null ? null : task.getScheduledAt();
|
return task == null ? null : task.getScheduledAt();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void mergeQueueFieldsFromRequest(ProductRiskResultItemVo vo, Long taskId) {
|
private void mergeQueueFieldsFromRequest(ProductRiskResultItemVo vo, Long taskId) {
|
||||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||||
ShopMatchCreateTaskRequest request = parseTaskRequestSilently(task);
|
ShopMatchCreateTaskRequest request = parseTaskRequestSilently(task);
|
||||||
if (request == null) {
|
if (request == null) {
|
||||||
return;
|
return;
|
||||||
@@ -849,7 +918,8 @@ public class ShopMatchTaskService {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(task.getRequestJson(), ShopMatchCreateTaskRequest.class);
|
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;
|
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) {
|
private static String fmt(LocalDateTime time) {
|
||||||
return time == null ? null : time.toString();
|
return time == null ? null : time.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,26 @@ public class ZiniaoAuthService {
|
|||||||
return getOrLoadUserStores(apiKey, companyId, userId);
|
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) {
|
public boolean isInvalidUserStoresError(BusinessException ex) {
|
||||||
String message = ex == null ? null : ex.getMessage();
|
String message = ex == null ? null : ex.getMessage();
|
||||||
if (message == null || message.isBlank()) {
|
if (message == null || message.isBlank()) {
|
||||||
|
|||||||
@@ -190,12 +190,19 @@ public class ZiniaoShopIndexService {
|
|||||||
try {
|
try {
|
||||||
companyId = ziniaoAuthService.resolveCompanyIdForIndex(apiKey);
|
companyId = ziniaoAuthService.resolveCompanyIdForIndex(apiKey);
|
||||||
} catch (BusinessException ex) {
|
} catch (BusinessException ex) {
|
||||||
|
rethrowIfIpWhitelistError(ex);
|
||||||
log.warn("[ziniao-index] skip apiKey while resolving companyId, msg={}", ex.getMessage());
|
log.warn("[ziniao-index] skip apiKey while resolving companyId, msg={}", ex.getMessage());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<Long> invalidUserIds = new LinkedHashSet<>();
|
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)) {
|
for (Long userId : buildUserIds(staff)) {
|
||||||
if (invalidUserIds.contains(userId)) {
|
if (invalidUserIds.contains(userId)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -204,6 +211,7 @@ public class ZiniaoShopIndexService {
|
|||||||
try {
|
try {
|
||||||
stores = ziniaoAuthService.getOrLoadUserStoresForIndex(apiKey, companyId, userId);
|
stores = ziniaoAuthService.getOrLoadUserStoresForIndex(apiKey, companyId, userId);
|
||||||
} catch (BusinessException ex) {
|
} catch (BusinessException ex) {
|
||||||
|
rethrowIfIpWhitelistError(ex);
|
||||||
if (ziniaoAuthService.isInvalidUserStoresError(ex)) {
|
if (ziniaoAuthService.isInvalidUserStoresError(ex)) {
|
||||||
invalidUserIds.add(userId);
|
invalidUserIds.add(userId);
|
||||||
allInvalidUserIds.add(userId);
|
allInvalidUserIds.add(userId);
|
||||||
@@ -662,4 +670,10 @@ public class ZiniaoShopIndexService {
|
|||||||
throw new BusinessException("生成紫鸟 apiKey 缓存键失败");
|
throw new BusinessException("生成紫鸟 apiKey 缓存键失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void rethrowIfIpWhitelistError(BusinessException ex) {
|
||||||
|
if (ziniaoAuthService.isIpWhitelistError(ex)) {
|
||||||
|
throw new BusinessException("紫鸟刷新店铺索引失败:当前服务器 IP 未加入紫鸟白名单,已停止本轮刷新且不会更新索引数据");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,9 +70,11 @@ aiimage:
|
|||||||
storage:
|
storage:
|
||||||
local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp}
|
local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp}
|
||||||
cleanup-enabled: ${AIIMAGE_STORAGE_CLEANUP_ENABLED:true}
|
cleanup-enabled: ${AIIMAGE_STORAGE_CLEANUP_ENABLED:true}
|
||||||
cleanup-cron: ${AIIMAGE_STORAGE_CLEANUP_CRON:0 0 */6 * * *}
|
cleanup-cron: ${AIIMAGE_STORAGE_CLEANUP_CRON:0 0 * * * *}
|
||||||
source-retention-hours: ${AIIMAGE_STORAGE_SOURCE_RETENTION_HOURS:48}
|
source-retention-hours: ${AIIMAGE_STORAGE_SOURCE_RETENTION_HOURS:24}
|
||||||
result-retention-hours: ${AIIMAGE_STORAGE_RESULT_RETENTION_HOURS:24}
|
result-retention-hours: ${AIIMAGE_STORAGE_RESULT_RETENTION_HOURS:24}
|
||||||
|
temp-dir:
|
||||||
|
retention-hours: ${AIIMAGE_TEMP_DIR_RETENTION_HOURS:24}
|
||||||
brand-progress:
|
brand-progress:
|
||||||
ttl-hours: ${AIIMAGE_BRAND_PROGRESS_TTL_HOURS:24}
|
ttl-hours: ${AIIMAGE_BRAND_PROGRESS_TTL_HOURS:24}
|
||||||
failed-ttl-hours: ${AIIMAGE_BRAND_PROGRESS_FAILED_TTL_HOURS:2}
|
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}
|
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-stale-timeout-minutes: ${AIIMAGE_SHOP_MATCH_STALE_TIMEOUT_MINUTES:20}
|
||||||
shop-match-initial-timeout-minutes: ${AIIMAGE_SHOP_MATCH_INITIAL_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:
|
module-cleanup:
|
||||||
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
|
enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true}
|
||||||
cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *}
|
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:
|
security:
|
||||||
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
|
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
|
||||||
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}
|
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()
|
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_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
|
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
|
created_by_id = None
|
||||||
try:
|
try:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
if role == 'super_admin':
|
if can_view_all_users:
|
||||||
where_parts = ["1=1"]
|
where_parts = ["1=1"]
|
||||||
params = []
|
params = []
|
||||||
if search_username:
|
if search_username:
|
||||||
@@ -1526,6 +1527,11 @@ def list_shop_manage_groups():
|
|||||||
{
|
{
|
||||||
'id': item.get('id'),
|
'id': item.get('id'),
|
||||||
'group_name': item.get('groupName') or '',
|
'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'),
|
'user_id': item.get('userId'),
|
||||||
'username': item.get('username') or '',
|
'username': item.get('username') or '',
|
||||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||||
@@ -1545,7 +1551,7 @@ def create_shop_manage_group():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
payload = {
|
payload = {
|
||||||
'groupName': (data.get('group_name') or '').strip(),
|
'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,
|
'createdById': current_row.get('id') if current_row else None,
|
||||||
}
|
}
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
@@ -1566,6 +1572,11 @@ def create_shop_manage_group():
|
|||||||
'item': {
|
'item': {
|
||||||
'id': item.get('id'),
|
'id': item.get('id'),
|
||||||
'group_name': item.get('groupName') or '',
|
'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'),
|
'user_id': item.get('userId'),
|
||||||
'username': item.get('username') or '',
|
'username': item.get('username') or '',
|
||||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
'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 {}
|
data = request.get_json() or {}
|
||||||
payload = {
|
payload = {
|
||||||
'groupName': (data.get('group_name') or '').strip(),
|
'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(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'PUT',
|
'PUT',
|
||||||
@@ -1603,6 +1614,11 @@ def update_shop_manage_group(item_id):
|
|||||||
'item': {
|
'item': {
|
||||||
'id': item.get('id'),
|
'id': item.get('id'),
|
||||||
'group_name': item.get('groupName') or '',
|
'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'),
|
'user_id': item.get('userId'),
|
||||||
'username': item.get('username') or '',
|
'username': item.get('username') or '',
|
||||||
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||||
|
|||||||
@@ -314,6 +314,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#shopManageGroupModal {
|
||||||
|
z-index: 1010;
|
||||||
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -948,17 +952,24 @@
|
|||||||
<h3>分组管理</h3>
|
<h3>分组管理</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<input type="hidden" id="shopManageGroupEditId">
|
<input type="hidden" id="shopManageGroupEditId">
|
||||||
|
<input type="hidden" id="shopManageGroupLeaderUserId">
|
||||||
<div class="form-group" style="min-width:220px;">
|
<div class="form-group" style="min-width:220px;">
|
||||||
<label>关联用户</label>
|
<label>组长</label>
|
||||||
<select id="shopManageGroupUserSelect">
|
<input type="text" id="shopManageGroupLeaderName" placeholder="当前登录用户" readonly
|
||||||
<option value="">请选择用户</option>
|
style="background:#f5f5f5;">
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="min-width:260px;">
|
<div class="form-group" style="min-width:260px;">
|
||||||
<label>分组名称</label>
|
<label>分组名称</label>
|
||||||
<input type="text" id="shopManageGroupInput" placeholder="选择用户后自动带出" readonly
|
<input type="text" id="shopManageGroupInput" placeholder="请输入分组名称">
|
||||||
style="background:#f5f5f5;">
|
|
||||||
</div>
|
</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" id="btnSaveShopManageGroup" type="button">保存分组</button>
|
||||||
<button class="btn btn-secondary" id="btnCancelShopManageGroupEdit" type="button">取消编辑</button>
|
<button class="btn btn-secondary" id="btnCancelShopManageGroupEdit" type="button">取消编辑</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -968,7 +979,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>序号</th>
|
<th>序号</th>
|
||||||
<th>分组名称</th>
|
<th>分组名称</th>
|
||||||
<th>关联用户</th>
|
<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 || '';
|
sel.value = cur || '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
var shopManageGroupUsers = [];
|
var shopManageAllUsers = [];
|
||||||
function refreshShopManageGroupUserSelect(selectedUserId) {
|
function getEligibleShopManageGroupUsers(leaderUserId) {
|
||||||
var sel = document.getElementById('shopManageGroupUserSelect');
|
var canViewAllMembers = currentUserRole === 'super_admin';
|
||||||
if (!sel) return;
|
return shopManageAllUsers.filter(function (u) {
|
||||||
var opts = ['<option value="">请选择用户</option>'];
|
if (String(u.id || '') === String(leaderUserId || '')) return false;
|
||||||
shopManageGroupUsers.forEach(function (u) {
|
if (canViewAllMembers) return true;
|
||||||
opts.push('<option value="' + u.id + '">' + (u.username || '') + '</option>');
|
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() {
|
function refreshShopManageGroupMemberSelect(leaderUserId, selectedUserIds) {
|
||||||
var sel = document.getElementById('shopManageGroupUserSelect');
|
var sel = document.getElementById('shopManageGroupMemberSelect');
|
||||||
var input = document.getElementById('shopManageGroupInput');
|
if (!sel) return;
|
||||||
if (!sel || !input) return;
|
var selectedMap = {};
|
||||||
var selectedUser = null;
|
(selectedUserIds || []).forEach(function (id) {
|
||||||
shopManageGroupUsers.some(function (u) {
|
selectedMap[String(id)] = true;
|
||||||
if (String(u.id) === String(sel.value || '')) {
|
|
||||||
selectedUser = u;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
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() {
|
function loadUserOptions() {
|
||||||
return fetch('/api/admin/users?page=1&page_size=999')
|
return fetch('/api/admin/users?page=1&page_size=999')
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
var items = res.items || [];
|
var items = res.items || [];
|
||||||
shopManageGroupUsers = items.map(function (u) {
|
shopManageAllUsers = items.map(function (u) {
|
||||||
return { id: u.id, username: u.username || '', role: u.role || 'normal' };
|
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 sel = document.getElementById('filterUser');
|
||||||
var cur = sel.value;
|
var cur = sel.value;
|
||||||
sel.innerHTML = '<option value="">全部用户</option>';
|
sel.innerHTML = '<option value="">全部用户</option>';
|
||||||
@@ -2163,107 +2194,6 @@
|
|||||||
return query;
|
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) {
|
function loadShopManage(page) {
|
||||||
shopManagePage = page || 1;
|
shopManagePage = page || 1;
|
||||||
fetch('/api/admin/shop-manages?' + buildShopManageQuery(shopManagePage))
|
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) {
|
function refreshShopGroupSelects(selectedCreateId, selectedEditId) {
|
||||||
var createSel = document.getElementById('shopManageGroupSelect');
|
var createSel = document.getElementById('shopManageGroupSelect');
|
||||||
var editSel = document.getElementById('editShopManageGroupSelect');
|
var editSel = document.getElementById('editShopManageGroupSelect');
|
||||||
@@ -2434,10 +2305,12 @@
|
|||||||
|
|
||||||
function resetShopManageGroupForm() {
|
function resetShopManageGroupForm() {
|
||||||
document.getElementById('shopManageGroupEditId').value = '';
|
document.getElementById('shopManageGroupEditId').value = '';
|
||||||
|
document.getElementById('shopManageGroupLeaderUserId').value = currentUserId || '';
|
||||||
|
document.getElementById('shopManageGroupLeaderName').value = currentUserUsername || '';
|
||||||
document.getElementById('shopManageGroupInput').value = '';
|
document.getElementById('shopManageGroupInput').value = '';
|
||||||
document.getElementById('msgShopManageGroup').textContent = '';
|
document.getElementById('msgShopManageGroup').textContent = '';
|
||||||
document.getElementById('msgShopManageGroup').className = 'msg';
|
document.getElementById('msgShopManageGroup').className = 'msg';
|
||||||
refreshShopManageGroupUserSelect('');
|
refreshShopManageGroupMemberSelect(currentUserId, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openShopManageGroupModal() {
|
function openShopManageGroupModal() {
|
||||||
@@ -2453,20 +2326,33 @@
|
|||||||
function renderShopManageGroupRows() {
|
function renderShopManageGroupRows() {
|
||||||
var tbody = document.getElementById('shopManageGroupListBody');
|
var tbody = document.getElementById('shopManageGroupListBody');
|
||||||
if (!shopManageGroups.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
tbody.innerHTML = shopManageGroups.map(function (item, index) {
|
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>' +
|
var memberNames = Array.isArray(item.member_usernames) ? item.member_usernames.join('、') : '';
|
||||||
'<button class="btn btn-sm" data-shop-group-edit="' + item.id + '" data-shop-group-user-id="' + (item.user_id || '') + '">编辑</button> ' +
|
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>' +
|
'<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>';
|
'</td></tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
document.querySelectorAll('[data-shop-group-edit]').forEach(function (btn) {
|
document.querySelectorAll('[data-shop-group-edit]').forEach(function (btn) {
|
||||||
btn.onclick = function () {
|
btn.onclick = function () {
|
||||||
document.getElementById('shopManageGroupEditId').value = btn.dataset.shopGroupEdit;
|
var item = null;
|
||||||
refreshShopManageGroupUserSelect(btn.dataset.shopGroupUserId || '');
|
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('btnManageShopGroups').onclick = openShopManageGroupModal;
|
||||||
document.getElementById('btnManageShopGroupsFromEdit').onclick = openShopManageGroupModal;
|
document.getElementById('btnManageShopGroupsFromEdit').onclick = openShopManageGroupModal;
|
||||||
document.getElementById('btnManageSkipPriceAsinGroups').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('btnCloseShopManageGroupModal').onclick = function () {
|
||||||
document.getElementById('shopManageGroupModal').classList.remove('show');
|
document.getElementById('shopManageGroupModal').classList.remove('show');
|
||||||
};
|
};
|
||||||
@@ -2503,12 +2397,12 @@
|
|||||||
document.getElementById('btnSaveShopManageGroup').onclick = function () {
|
document.getElementById('btnSaveShopManageGroup').onclick = function () {
|
||||||
var editId = (document.getElementById('shopManageGroupEditId').value || '').trim();
|
var editId = (document.getElementById('shopManageGroupEditId').value || '').trim();
|
||||||
var groupName = (document.getElementById('shopManageGroupInput').value || '').trim();
|
var groupName = (document.getElementById('shopManageGroupInput').value || '').trim();
|
||||||
var userId = (document.getElementById('shopManageGroupUserSelect').value || '').trim();
|
var memberUserIds = getSelectedShopManageGroupMemberIds();
|
||||||
var msgEl = document.getElementById('msgShopManageGroup');
|
var msgEl = document.getElementById('msgShopManageGroup');
|
||||||
msgEl.textContent = '';
|
msgEl.textContent = '';
|
||||||
msgEl.className = 'msg';
|
msgEl.className = 'msg';
|
||||||
if (!userId || !groupName) {
|
if (!groupName) {
|
||||||
msgEl.textContent = '请选择关联用户';
|
msgEl.textContent = '请输入分组名称';
|
||||||
msgEl.className = 'msg err';
|
msgEl.className = 'msg err';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2517,7 +2411,7 @@
|
|||||||
fetch(url, {
|
fetch(url, {
|
||||||
method: method,
|
method: method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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 (r) { return r.json(); })
|
||||||
.then(function (res) {
|
.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">
|
<div class="page-shell module-page">
|
||||||
<BrandTopBar active="delete-brand" />
|
<BrandTopBar active="delete-brand" />
|
||||||
|
|
||||||
@@ -225,6 +225,7 @@ import {
|
|||||||
type DeleteBrandTaskDetailVo,
|
type DeleteBrandTaskDetailVo,
|
||||||
} from '@/shared/api/java-modules'
|
} from '@/shared/api/java-modules'
|
||||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||||
|
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||||
|
|
||||||
interface SessionDeleteBrandItem extends DeleteBrandResultItem {
|
interface SessionDeleteBrandItem extends DeleteBrandResultItem {
|
||||||
_pushed?: boolean
|
_pushed?: boolean
|
||||||
@@ -261,6 +262,7 @@ const chainStarted = ref(false)
|
|||||||
const activeItemKey = ref('')
|
const activeItemKey = ref('')
|
||||||
const autoAdvancing = ref(false)
|
const autoAdvancing = ref(false)
|
||||||
const autoRetryTimer = ref<number | null>(null)
|
const autoRetryTimer = ref<number | null>(null)
|
||||||
|
const waitingForBackendRecovery = ref(false)
|
||||||
const displayPaths = computed(() => selectedPaths.value.slice(0, 10))
|
const displayPaths = computed(() => selectedPaths.value.slice(0, 10))
|
||||||
|
|
||||||
const currentSectionItems = computed(() => {
|
const currentSectionItems = computed(() => {
|
||||||
@@ -855,6 +857,10 @@ async function refreshTaskDetails(taskIds?: number[]) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const batch = await getDeleteBrandTaskProgress(ids)
|
const batch = await getDeleteBrandTaskProgress(ids)
|
||||||
|
if (waitingForBackendRecovery.value && chainStarted.value) {
|
||||||
|
queuePushResult.value = '后端已恢复,继续执行当前任务并自动衔接后续任务...'
|
||||||
|
}
|
||||||
|
waitingForBackendRecovery.value = false
|
||||||
let changed = false
|
let changed = false
|
||||||
for (const detail of batch.items || []) {
|
for (const detail of batch.items || []) {
|
||||||
const id = detail?.task?.id
|
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 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()))
|
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
||||||
if (transient && chainStarted.value) {
|
if (transient && chainStarted.value) {
|
||||||
|
waitingForBackendRecovery.value = true
|
||||||
queuePushResult.value = '当前任务运行中,正在等待后端服务恢复...'
|
queuePushResult.value = '当前任务运行中,正在等待后端服务恢复...'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPollIntervalMs() {
|
function getPollIntervalMs() {
|
||||||
return document.visibilityState === 'visible' ? 6000 : 20000
|
return getTaskPollIntervalMs()
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleNextPoll(immediate = false) {
|
function scheduleNextPoll(immediate = false) {
|
||||||
@@ -1898,3 +1905,4 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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">
|
<div class="page-shell module-page">
|
||||||
<BrandTopBar active="pricing" />
|
<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 { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
|
||||||
import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
||||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||||
|
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||||
import {
|
import {
|
||||||
addPriceTrackCandidate,
|
addPriceTrackCandidate,
|
||||||
completePriceTrackLoopChild,
|
completePriceTrackLoopChild,
|
||||||
@@ -989,6 +990,9 @@ async function waitForTaskTerminal(taskId: number) {
|
|||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
const batch = await getPriceTrackTaskProgressBatch([taskId])
|
const batch = await getPriceTrackTaskProgressBatch([taskId])
|
||||||
|
if (transientErrorCount > 0) {
|
||||||
|
queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`
|
||||||
|
}
|
||||||
transientErrorCount = 0
|
transientErrorCount = 0
|
||||||
if ((batch.missingTaskIds || []).includes(taskId)) {
|
if ((batch.missingTaskIds || []).includes(taskId)) {
|
||||||
removePollingTask(taskId)
|
removePollingTask(taskId)
|
||||||
@@ -1055,69 +1059,81 @@ async function pushToPythonQueue() {
|
|||||||
}
|
}
|
||||||
pushing.value = true
|
pushing.value = true
|
||||||
queuePayloadText.value = ''
|
queuePayloadText.value = ''
|
||||||
|
let successCount = 0
|
||||||
|
let failedCount = 0
|
||||||
try {
|
try {
|
||||||
for (let index = 0; index < matchedRows.length; index += 1) {
|
for (let index = 0; index < matchedRows.length; index += 1) {
|
||||||
const row = matchedRows[index]
|
const row = matchedRows[index]
|
||||||
let attempt = 0
|
try {
|
||||||
const taskVo = await (async () => {
|
let attempt = 0
|
||||||
while (true) {
|
const taskVo = await (async () => {
|
||||||
try {
|
while (true) {
|
||||||
return await createPriceTrackTask({
|
try {
|
||||||
userId: 0,
|
return await createPriceTrackTask({
|
||||||
statusMode: statusModeEnabled.value,
|
userId: 0,
|
||||||
asinMode: asinModeEnabled.value,
|
statusMode: statusModeEnabled.value,
|
||||||
items: [row] as unknown as Record<string, unknown>[],
|
asinMode: asinModeEnabled.value,
|
||||||
asinFiles: resolveAsinRequestPaths(),
|
items: [row] as unknown as Record<string, unknown>[],
|
||||||
countryCodes: [...orderedCountryCodes.value],
|
asinFiles: resolveAsinRequestPaths(),
|
||||||
})
|
countryCodes: [...orderedCountryCodes.value],
|
||||||
} catch (error) {
|
})
|
||||||
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
} catch (error) {
|
||||||
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
||||||
attempt += 1
|
const transient = ['无法连接到后端服务', 'bad gateway', 'gateway timeout', 'network error', 'timeout', '502', '503', '504'].some((pattern) => message.includes(pattern.toLowerCase()))
|
||||||
if (!transient || attempt >= 10) throw error
|
attempt += 1
|
||||||
queuePushResult.value = `后端服务暂时不可用,正在重试创建任务(${attempt}/10)...`
|
if (!transient || attempt >= 10) throw error
|
||||||
await new Promise<void>((resolve) => { window.setTimeout(() => resolve(), getPollIntervalMs()) })
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})()
|
taskDetails.value = {
|
||||||
taskSnapshots.value = {
|
...taskDetails.value,
|
||||||
...taskSnapshots.value,
|
[taskVo.taskId]: 'RUNNING',
|
||||||
[taskVo.taskId]: {
|
}
|
||||||
task: { id: taskVo.taskId, status: 'RUNNING' },
|
saveTaskSnapshotsToStorage()
|
||||||
items: taskVo.items,
|
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) {
|
} catch (e) {
|
||||||
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
|
queuePushResult.value = e instanceof Error ? e.message : '推送异常'
|
||||||
ElMessage.error(queuePushResult.value)
|
ElMessage.error(queuePushResult.value)
|
||||||
@@ -1353,7 +1369,7 @@ async function pushToPythonLoopQueue() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPollIntervalMs() {
|
function getPollIntervalMs() {
|
||||||
return document.visibilityState === 'visible' ? 6000 : 20000
|
return getTaskPollIntervalMs()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshTaskBatch() {
|
async function refreshTaskBatch() {
|
||||||
@@ -2248,3 +2264,4 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-shell module-page">
|
<div class="page-shell module-page">
|
||||||
<BrandTopBar active="product-risk" />
|
<BrandTopBar active="product-risk" />
|
||||||
|
|
||||||
@@ -220,6 +220,7 @@ import {
|
|||||||
type ProductRiskTaskDetailVo,
|
type ProductRiskTaskDetailVo,
|
||||||
} from '@/shared/api/java-modules'
|
} from '@/shared/api/java-modules'
|
||||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||||
|
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||||
|
|
||||||
const shopInput = ref('')
|
const shopInput = ref('')
|
||||||
const candidates = ref<ProductRiskCandidateVo[]>([])
|
const candidates = ref<ProductRiskCandidateVo[]>([])
|
||||||
@@ -711,7 +712,7 @@ async function refreshTaskBatch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPollIntervalMs() {
|
function getPollIntervalMs() {
|
||||||
return document.visibilityState === 'visible' ? 6000 : 20000
|
return getTaskPollIntervalMs()
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleNextPoll(immediate = false) {
|
function scheduleNextPoll(immediate = false) {
|
||||||
@@ -761,6 +762,9 @@ async function waitForTaskTerminal(taskId: number) {
|
|||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
const batch = await getProductRiskTaskProgressBatch([taskId])
|
const batch = await getProductRiskTaskProgressBatch([taskId])
|
||||||
|
if (transientErrorCount > 0) {
|
||||||
|
queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`
|
||||||
|
}
|
||||||
transientErrorCount = 0
|
transientErrorCount = 0
|
||||||
const detail = (batch.items || []).find((d) => d.task?.id === taskId)
|
const detail = (batch.items || []).find((d) => d.task?.id === taskId)
|
||||||
const status = detail?.task?.status || ''
|
const status = detail?.task?.status || ''
|
||||||
@@ -984,7 +988,7 @@ async function pushToPythonQueue() {
|
|||||||
}
|
}
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.enqueue_json) {
|
if (!api?.enqueue_json) {
|
||||||
ElMessage.error('当前环境未启用 pywebview enqueue_json(浏览器内无法推队列)')
|
ElMessage.error('当前环境未启用 pywebview enqueue_json')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const toPush = matchedItems.value.filter((i) => i.matched)
|
const toPush = matchedItems.value.filter((i) => i.matched)
|
||||||
@@ -994,58 +998,66 @@ async function pushToPythonQueue() {
|
|||||||
}
|
}
|
||||||
pushing.value = true
|
pushing.value = true
|
||||||
queuePayloadText.value = ''
|
queuePayloadText.value = ''
|
||||||
|
let successCount = 0
|
||||||
|
let failedCount = 0
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < toPush.length; i++) {
|
for (let i = 0; i < toPush.length; i++) {
|
||||||
const item = toPush[i]
|
const item = toPush[i]
|
||||||
const created = await createProductRiskTaskWithRetry([item])
|
try {
|
||||||
const taskId = created.taskId
|
const created = await createProductRiskTaskWithRetry([item])
|
||||||
taskSnapshots.value = {
|
const taskId = created.taskId
|
||||||
...taskSnapshots.value,
|
taskSnapshots.value = {
|
||||||
[taskId]: {
|
...taskSnapshots.value,
|
||||||
task: { id: taskId, status: 'RUNNING' },
|
[taskId]: {
|
||||||
items: created.items,
|
task: { id: taskId, status: 'RUNNING' },
|
||||||
},
|
items: created.items,
|
||||||
}
|
},
|
||||||
saveTaskSnapshotsToStorage()
|
}
|
||||||
addPollingTask(taskId)
|
saveTaskSnapshotsToStorage()
|
||||||
ensurePolling(true)
|
addPollingTask(taskId)
|
||||||
|
ensurePolling(true)
|
||||||
const payload = {
|
const payload = {
|
||||||
type: 'product-risk-resolve-run',
|
type: 'product-risk-resolve-run',
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
data: {
|
data: {
|
||||||
taskId,
|
taskId,
|
||||||
items: [item],
|
items: [item],
|
||||||
country_codes: [...orderedCountryCodes.value],
|
country_codes: [...orderedCountryCodes.value],
|
||||||
risk_listing_filter: productRiskListingFilter.value,
|
risk_listing_filter: productRiskListingFilter.value,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
queuePayloadText.value = JSON.stringify(payload, null, 2)
|
queuePayloadText.value = JSON.stringify(payload, null, 2)
|
||||||
const pushResult = await api.enqueue_json(payload)
|
const pushResult = await api.enqueue_json(payload)
|
||||||
if (!pushResult?.success) {
|
if (!pushResult?.success) {
|
||||||
removePollingTask(taskId)
|
removePollingTask(taskId)
|
||||||
queuePushResult.value = `第 ${i + 1}/${toPush.length} 条推送失败:${pushResult?.error || '未知错误'}`
|
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)
|
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()
|
await loadHistory()
|
||||||
ensurePolling(true)
|
ensurePolling(true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1611,3 +1623,4 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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 { 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 { 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 { 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 COUNTRY_OPTIONS = [{ code: 'DE', label: '德国' }, { code: 'UK', label: '英国' }, { code: 'FR', label: '法国' }, { code: 'IT', label: '意大利' }, { code: 'ES', label: '西班牙' }] as const
|
||||||
const shopInput = ref('')
|
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 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 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 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) {
|
function parseScheduleDateTime(value?: string | null) {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
const normalized = value.trim().replace('T', ' ')
|
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 onCountryDragStart(index: number) { dragCountryIndex.value = index }
|
||||||
function onCountryDragEnd() { dragCountryIndex.value = null }
|
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 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 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 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()) }
|
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 } }
|
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 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 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 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 } }
|
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) }
|
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())) }
|
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 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[] } } }
|
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 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 ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
|
||||||
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } }
|
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 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 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} 次`}
|
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 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 '未命中或未就绪,请检查店铺名与索引刷新' }
|
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 } }
|
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) })
|
||||||
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) })
|
|
||||||
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() })
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -394,3 +435,4 @@ onUnmounted(() => { stopPolling(); stopScheduleHeartbeat(); window.removeEventLi
|
|||||||
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
|
.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; } }
|
@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>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type ActiveNavKey =
|
|||||||
| 'product-risk'
|
| 'product-risk'
|
||||||
| 'shop-match'
|
| 'shop-match'
|
||||||
| 'pricing'
|
| 'pricing'
|
||||||
|
| 'patrol-delete'
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
key: string
|
key: string
|
||||||
@@ -93,7 +94,7 @@ const navGroups: ReadonlyArray<NavGroup> = [
|
|||||||
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' },
|
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' },
|
||||||
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' },
|
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' },
|
||||||
{ key: 'pricing', label: '跟价', href: '/new_web_source/price-track.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: 'withdraw', label: '取款' },
|
||||||
{ key: 'shop-status', 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,
|
type JavaApiResponse,
|
||||||
unwrapJavaResponse,
|
unwrapJavaResponse,
|
||||||
} from "@/shared/api/http";
|
} from "@/shared/api/http";
|
||||||
|
import { getTaskProgressCacheTtlMs } from "@/shared/task-progress-config";
|
||||||
|
|
||||||
const JAVA_API_PREFIX = "/newApi/api";
|
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() {
|
function getCurrentUserId() {
|
||||||
const raw =
|
const raw =
|
||||||
@@ -22,6 +30,56 @@ function getCurrentUserId() {
|
|||||||
return value;
|
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 {
|
export interface UploadedFileRef {
|
||||||
fileKey: string;
|
fileKey: string;
|
||||||
originalFilename?: string;
|
originalFilename?: string;
|
||||||
@@ -916,6 +974,221 @@ export function getShopMatchResultDownloadUrl(resultId: number) {
|
|||||||
return getJavaDownloadUrl(`/shop-match/results/${resultId}/download`);
|
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 {
|
export interface PriceTrackCandidateVo {
|
||||||
@@ -1095,29 +1368,23 @@ export function matchPriceTrackShops(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getShopMatchTaskProgressBatch(taskIds: number[]) {
|
export function getShopMatchTaskProgressBatch(taskIds: number[]) {
|
||||||
return unwrapJavaResponse(
|
return postTaskProgressBatch<ShopMatchTaskBatchVo>(
|
||||||
post<JavaApiResponse<ShopMatchTaskBatchVo>, { taskIds: number[] }>(
|
`${JAVA_API_PREFIX}/shop-match/tasks/progress/batch`,
|
||||||
`${JAVA_API_PREFIX}/shop-match/tasks/progress/batch`,
|
taskIds,
|
||||||
{ taskIds },
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProductRiskTaskProgressBatch(taskIds: number[]) {
|
export function getProductRiskTaskProgressBatch(taskIds: number[]) {
|
||||||
return unwrapJavaResponse(
|
return postTaskProgressBatch<ProductRiskTaskBatchVo>(
|
||||||
post<JavaApiResponse<ProductRiskTaskBatchVo>, { taskIds: number[] }>(
|
`${JAVA_API_PREFIX}/product-risk-resolve/tasks/progress/batch`,
|
||||||
`${JAVA_API_PREFIX}/product-risk-resolve/tasks/progress/batch`,
|
taskIds,
|
||||||
{ taskIds },
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDeleteBrandTaskProgress(taskIds: number[]) {
|
export function getDeleteBrandTaskProgress(taskIds: number[]) {
|
||||||
return unwrapJavaResponse(
|
return postTaskProgressBatch<DeleteBrandTaskBatchVo>(
|
||||||
post<JavaApiResponse<DeleteBrandTaskBatchVo>, { taskIds: number[] }>(
|
`${JAVA_API_PREFIX}/delete-brand/tasks/progress/batch`,
|
||||||
`${JAVA_API_PREFIX}/delete-brand/tasks/progress/batch`,
|
taskIds,
|
||||||
{ taskIds },
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1268,11 +1535,9 @@ export function stopPriceTrackLoopRun(loopRunId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getPriceTrackTaskProgressBatch(taskIds: number[]) {
|
export function getPriceTrackTaskProgressBatch(taskIds: number[]) {
|
||||||
return unwrapJavaResponse(
|
return postTaskProgressBatch<PriceTrackTaskBatchVo>(
|
||||||
post<JavaApiResponse<PriceTrackTaskBatchVo>, { taskIds: number[] }>(
|
`${JAVA_API_PREFIX}/price-track/tasks/progress/batch`,
|
||||||
`${JAVA_API_PREFIX}/price-track/tasks/progress/batch`,
|
taskIds,
|
||||||
{ 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'),
|
'product-risk': resolve(__dirname, 'product-risk.html'),
|
||||||
'shop-match': resolve(__dirname, 'shop-match.html'),
|
'shop-match': resolve(__dirname, 'shop-match.html'),
|
||||||
'price-track': resolve(__dirname, 'price-track.html'),
|
'price-track': resolve(__dirname, 'price-track.html'),
|
||||||
|
'patrol-delete': resolve(__dirname, 'patrol-delete.html'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: 'assets/[name].js',
|
entryFileNames: 'assets/[name].js',
|
||||||
|
|||||||
Reference in New Issue
Block a user