From 228d481211c5290cbdd069885b3e60d14310e99b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Tue, 15 Sep 2026 23:01:59 +0800 Subject: [PATCH] =?UTF-8?q?perf(=E5=85=A8=E9=93=BE=E8=B7=AF):=20=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=B1=A0/=E4=BA=8B=E5=8A=A1=E8=BE=B9=E7=95=8C/?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2=E4=B8=8E=20IO=20=E6=95=88=E7=8E=87=E6=94=B6?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Java: - LLM 180s 读超时不再被全局 call-timeout 静默截断成 90s(长思考请求被掐断→重试→付费网关二次计费) - 代理 HttpClient 缓存改有界 LRU(jikip 每次提取新 IP,无界缓存持续泄漏 selector 线程与连接池) - 12 个 service 的 Redis 任务锁移出 @Transactional(自旋最坏 10s 白占 DB 连接,池仅 30),远端对象删/传改 afterCommit - 结果文件 Job 闸门拒绝时不再回退内联执行(改重新入队,避免把背压转嫁给 MQ 消费线程) - imagevideo 每秒扫描加列投影、过期清理加 LIMIT;权限页整表查询改列投影(不再拉回密码哈希) - 哈希改 HexFormat;补 5 处"不能改"的技术依据注释(批量插入会丢回填主键、流式丢模板与图片等) 前端:4 个工具页轮询改轻量端点(带 fallback);PriceTrack 快照节流写盘;候选店铺表分页;页面隐藏时停表 客户端:HTTP 连接池按出口复用(Session 仍每请求新建,保持无跨请求状态);品牌检测 WIPO 逐请求握手; 代理配置按 mtime 缓存;串行任务改专属池;异常降级为标签页重连;紫鸟启动改端口轮询;模板编译缓存; 日志上报连接与落盘收口;Flask 版本 API 改按请求复用连接 --- .../aiimage/common/util/BoundedLruCache.java | 70 +++++++ .../nanri/aiimage/config/HttpClientPool.java | 16 +- .../service/AppearancePatentTaskService.java | 73 ++++++- .../service/BrandTaskStorageService.java | 74 +++++-- .../service/CollectDataService.java | 123 ++++++------ .../DeleteBrandTaskStorageService.java | 72 +++++-- .../service/DigitalHumanVersionService.java | 41 +++- .../service/ImageVideoAsyncTaskService.java | 10 +- .../service/PatrolDeleteTaskService.java | 73 +++++-- .../service/PermissionMenuService.java | 26 ++- .../service/PriceTrackTaskService.java | 181 ++++++++++++------ .../service/ProductRiskTaskService.java | 104 ++++++---- .../publish/service/PublishTaskService.java | 101 +++++++++- .../service/QueryAsinTaskService.java | 57 ++++-- .../ShopDataCrawlExcelAssemblyService.java | 7 + .../service/ShopDataCrawlTaskService.java | 118 ++++++++---- .../service/ShopMatchTaskService.java | 137 ++++++++----- .../service/SimilarAsinTaskService.java | 14 +- .../support/SimilarAsinPipelineHost.java | 3 +- .../task/service/TaskResultFileJobWorker.java | 12 +- .../task/service/TaskResultItemService.java | 8 + .../TaskScopePayloadStorageService.java | 7 +- .../withdraw/service/WithdrawTaskService.java | 57 ++++-- .../ziniao/client/ZiniaoClientImpl.java | 19 +- .../ziniao/service/ZiniaoAuthService.java | 7 +- .../service/ZiniaoShopIndexService.java | 7 +- .../ImageVideoAsyncTaskServiceTest.java | 16 ++ .../service/PermissionMenuServiceTest.java | 16 ++ .../QueryAsinSnapshotJsonThrottleTest.java | 5 +- ...hopDataCrawlTaskServiceTxBoundaryTest.java | 35 +++- .../TaskResultFileJobWorkerOffloadTest.java | 14 +- .../TaskResultFileJobWorkerOrphanTest.java | 10 +- backend/app.py | 5 +- backend/utils/db.py | 86 ++++++++- .../pages/brand/components/BrandBrandTab.vue | 38 ++-- .../brand/components/BrandCollectDataTab.vue | 69 ++++++- .../brand/components/BrandPatrolDeleteTab.vue | 17 +- .../brand/components/BrandPriceTrackTab.vue | 87 +++++++-- .../brand/components/BrandQueryAsinTab.vue | 17 +- .../components/BrandShopDataCrawlTab.vue | 12 +- .../brand/components/BrandShopMatchTab.vue | 3 +- .../brand/components/BrandWithdrawTab.vue | 17 +- .../shared/composables/useTaskProgressLoop.ts | 67 +++++-- frontend-vue/tests/polling-visibility.test.ts | 42 +++- 44 files changed, 1550 insertions(+), 423 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/common/util/BoundedLruCache.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/common/util/BoundedLruCache.java b/backend-java/src/main/java/com/nanri/aiimage/common/util/BoundedLruCache.java new file mode 100644 index 00000000..30fed4ff --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/common/util/BoundedLruCache.java @@ -0,0 +1,70 @@ +package com.nanri.aiimage.common.util; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Function; + +/** + * 有界 LRU 缓存:容量超限时自动淘汰最久未使用的条目。 + * + *

用于按「外部端点」缓存长生命周期资源(HttpClient / RestClient)。这类资源各自持有 + * 连接池与 selector 线程,无界累积会持续泄漏线程与内存:代理端点每次提取往往是新的 + * IP:port(jikip 提取),无上限的缓存只增不减。 + * + *

淘汰时只从缓存移除引用,不做显式关闭:JDK 的 HttpClientImpl 注册了 Cleaner, + * 对象不可达后由 GC 回收并关闭其 selector 线程;显式关闭反而可能打断仍在途的请求。 + */ +public final class BoundedLruCache { + + /** 默认容量:代理端点数量级远小于此,足够覆盖热点端点又不至于累积。 */ + public static final int DEFAULT_MAX_SIZE = 64; + + private final int maxSize; + private final Map store; + + public BoundedLruCache() { + this(DEFAULT_MAX_SIZE); + } + + public BoundedLruCache(int maxSize) { + this.maxSize = Math.max(1, maxSize); + // accessOrder=true 使 get 也刷新顺序(真正的 LRU);synchronizedMap 保证其线程安全 + this.store = Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > BoundedLruCache.this.maxSize; + } + }); + } + + /** + * 取缓存值,缺失时用 loader 计算并放入。 + * + *

与 {@code ConcurrentHashMap.computeIfAbsent} 不同,此处不保证 loader 的原子性: + * 并发首次访问同一 key 时可能各自构造一次,随后其中一个覆盖另一个。对 + * HttpClient/RestClient 这类构造廉价且幂等的资源可接受,换来的是锁粒度更小。 + */ + public V computeIfAbsent(K key, Function loader) { + V existing = store.get(key); + if (existing != null) { + return existing; + } + V created = loader.apply(key); + store.put(key, created); + return created; + } + + public int size() { + return store.size(); + } + + /** 当前容量上限,供日志与测试断言使用。 */ + public int maxSize() { + return maxSize; + } + + public void clear() { + store.clear(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java b/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java index be13e670..7bcddb41 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java @@ -1,5 +1,7 @@ package com.nanri.aiimage.config; +import com.nanri.aiimage.common.util.BoundedLruCache; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.JdkClientHttpRequestFactory; @@ -14,8 +16,6 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; /** * Task 77:外部 HTTP 客户端统一连接复用池。 @@ -24,6 +24,7 @@ import java.util.concurrent.ConcurrentHashMap; * 每次请求都重新建连。各客户端按自身超时创建独立的 * JdkClientHttpRequestFactory(共享底层连接池),RestClient 单例懒加载。 */ +@Slf4j public class HttpClientPool { private static volatile HttpClient sharedHttpClient; @@ -103,8 +104,12 @@ public class HttpClientPool { public static ClientHttpRequestFactory requestFactory(int readTimeoutMillis, String proxyUrl) { long safeReadTimeout = Math.max(1L, readTimeoutMillis); long callTimeout = configuredCallTimeoutMillis; - if (callTimeout > 0L) { - safeReadTimeout = Math.min(safeReadTimeout, callTimeout); + if (callTimeout > 0L && safeReadTimeout > callTimeout) { + // 读超时以调用方显式值为准,不再被全局 call-timeout 截断: + // LLM 长思考配的是 180s(llm-read-timeout-millis),曾被静默压到 90s, + // 导致请求在 90s 被掐断 → 上层重试 → 付费网关二次计费(2026-09-15 修复)。 + // 各调用方的超时已由各自的 HttpConfigResolver 钳制,此处不再二次收敛。 + log.debug("读超时 {}ms 超过全局 call-timeout {}ms,按调用方显式值生效", safeReadTimeout, callTimeout); } JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClientFor(proxyUrl)); @@ -166,5 +171,6 @@ public class HttpClientPool { private record ProxyEndpoint(String host, int port, String userInfo) { } - private static final Map PROXY_CLIENTS = new ConcurrentHashMap<>(); + private static final BoundedLruCache PROXY_CLIENTS = + new BoundedLruCache<>(BoundedLruCache.DEFAULT_MAX_SIZE); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java index ac91a970..75619146 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java @@ -69,6 +69,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionTemplate; import java.io.File; @@ -522,20 +524,28 @@ public class AppearancePatentTaskService { scheduleLlmPipelineForSubmittedChunk(context); } + /** + * 删除任务。 + * + *

事务边界:远端载荷删除与缓存清理移到事务提交后执行 —— 单次删除超时 120s、重试 3 次, + * 放在 DB 事务里会让连接被长时间占用(项目在 TaskScopePayloadStorageService 已写明该约束)。 + */ @Transactional public void deleteTask(Long taskId, Long userId) { FileTaskEntity task = fileTaskMapper.selectById(taskId); if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !Objects.equals(userId, task.getUserId())) { throw new BusinessException("任务不存在"); } + List payloads = collectTransientTaskPayloads(taskId); fileResultMapper.delete(new LambdaQueryWrapper().eq(FileResultEntity::getTaskId, taskId).eq(FileResultEntity::getModuleType, MODULE_TYPE)); - deleteTransientTaskPayloads(taskId); taskScopeStateMapper.delete(new LambdaQueryWrapper().eq(TaskScopeStateEntity::getTaskId, taskId).eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); taskChunkMapper.delete(new LambdaQueryWrapper().eq(TaskChunkEntity::getTaskId, taskId).eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); - taskCacheService.deleteTaskCache(taskId); // 同步清理 task_file_job,避免被删除任务遗留的 PENDING/FAILED 行被 TaskResultFileJobWorker 反复扫描出 task not found。 taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE); fileTaskMapper.deleteById(taskId); + // 事务提交后再做远端删除与缓存清理 + deletePayloadsAfterCommit(payloads, taskId); + runAfterCommit(() -> taskCacheService.deleteTaskCache(taskId)); } public void deleteHistory(Long resultId, Long userId) { @@ -2992,18 +3002,27 @@ public class AppearancePatentTaskService { } } - private void deleteTransientTaskPayloads(Long taskId) { + /** 只读收集任务范围/分片载荷指针,供事务提交后做远端删除。 */ + private List collectTransientTaskPayloads(Long taskId) { if (taskId == null || taskId <= 0) { - return; + return List.of(); } + List payloads = new ArrayList<>(); List scopes = taskScopeStateMapper.selectList(new LambdaQueryWrapper() .select(TaskScopeStateEntity::getParsedPayloadJson, TaskScopeStateEntity::getStateJson) .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); if (scopes != null) { for (TaskScopeStateEntity scope : scopes) { - transientPayloadStorageService.deletePayloadIfPresent(scope.getParsedPayloadJson()); - transientPayloadStorageService.deletePayloadIfPresent(scope.getStateJson()); + if (scope == null) { + continue; + } + if (scope.getParsedPayloadJson() != null && !scope.getParsedPayloadJson().isBlank()) { + payloads.add(scope.getParsedPayloadJson()); + } + if (scope.getStateJson() != null && !scope.getStateJson().isBlank()) { + payloads.add(scope.getStateJson()); + } } } List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() @@ -3012,9 +3031,49 @@ public class AppearancePatentTaskService { .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); if (chunks != null) { for (TaskChunkEntity chunk : chunks) { - transientPayloadStorageService.deletePayloadIfPresent(chunk.getPayloadJson()); + if (chunk != null && chunk.getPayloadJson() != null && !chunk.getPayloadJson().isBlank()) { + payloads.add(chunk.getPayloadJson()); + } } } + return payloads; + } + + /** 事务提交后逐条删远端载荷;无事务时立即执行(与 TaskResultItemService 同款兜底)。 */ + private void deletePayloadsAfterCommit(List payloads, Long taskId) { + if (payloads == null || payloads.isEmpty()) { + return; + } + runAfterCommit(() -> { + for (String payload : payloads) { + try { + transientPayloadStorageService.deletePayloadIfPresent(payload); + } catch (Exception ex) { + log.warn("[appearance-patent] 载荷删除失败 taskId={} msg={}", taskId, ex.getMessage()); + } + } + }); + } + + /** + * 删除范围/分片的远端载荷(保留给无事务的清理链路调用)。 + */ + private void deleteTransientTaskPayloads(Long taskId) { + deletePayloadsAfterCommit(collectTransientTaskPayloads(taskId), taskId); + } + + /** 有活动事务则注册 afterCommit,否则立即执行。 */ + private void runAfterCommit(Runnable action) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + action.run(); + } + }); + return; + } + action.run(); } private record SubmitContext(FileTaskEntity task, diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java index a55526a9..dbdbb653 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java @@ -13,9 +13,12 @@ import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity; import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity; import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -27,6 +30,7 @@ import java.util.Map; @Service @RequiredArgsConstructor +@Slf4j public class BrandTaskStorageService { private static final String MODULE_TYPE = "BRAND"; @@ -236,6 +240,13 @@ public class BrandTaskStorageService { return new ChunkStoreResult(false, completed, countCompletedFiles(taskId), aggregate); } + /** + * 删除任务的全部范围/分片数据。 + * + *

事务边界:远端载荷删除移到事务提交后执行 —— 单次删除超时 120s、重试 3 次, + * 放在 DB 事务里会让连接被长时间占用(项目在 TaskScopePayloadStorageService 已写明该约束)。 + * 提交后再删还有个好处:引用计数查询看到的是删除完成后的 DB 状态,判断更准确。 + */ @Transactional public void deleteTaskData(Long taskId) { if (taskId == null || taskId <= 0) { @@ -245,27 +256,68 @@ public class BrandTaskStorageService { .select(TaskScopeStateEntity::getParsedPayloadJson, TaskScopeStateEntity::getStateJson) .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); - if (states != null) { - for (TaskScopeStateEntity state : states) { - transientPayloadStorageService.deletePayloadIfPresent(state.getParsedPayloadJson()); - transientPayloadStorageService.deletePayloadIfPresent(state.getStateJson()); - } - } List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() .select(TaskChunkEntity::getPayloadJson) .eq(TaskChunkEntity::getTaskId, taskId) .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); - if (chunks != null) { - for (TaskChunkEntity chunk : chunks) { - transientPayloadStorageService.deletePayloadIfPresent(chunk.getPayloadJson()); - } - } taskChunkMapper.delete(new LambdaQueryWrapper() .eq(TaskChunkEntity::getTaskId, taskId) .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); taskScopeStateMapper.delete(new LambdaQueryWrapper() .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); + deletePayloadsAfterCommit(states, chunks, taskId); + } + + /** 事务提交后逐条删远端载荷;无事务时立即执行(与 TaskResultItemService 同款兜底)。 */ + private void deletePayloadsAfterCommit(List states, + List chunks, + Long taskId) { + List payloads = new ArrayList<>(); + if (states != null) { + for (TaskScopeStateEntity state : states) { + if (state == null) { + continue; + } + if (state.getParsedPayloadJson() != null && !state.getParsedPayloadJson().isBlank()) { + payloads.add(state.getParsedPayloadJson()); + } + if (state.getStateJson() != null && !state.getStateJson().isBlank()) { + payloads.add(state.getStateJson()); + } + } + } + if (chunks != null) { + for (TaskChunkEntity chunk : chunks) { + if (chunk != null && chunk.getPayloadJson() != null && !chunk.getPayloadJson().isBlank()) { + payloads.add(chunk.getPayloadJson()); + } + } + } + if (payloads.isEmpty()) { + return; + } + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + deletePayloadsNow(payloads, taskId); + } + }); + return; + } + deletePayloadsNow(payloads, taskId); + } + + private void deletePayloadsNow(List payloads, Long taskId) { + for (String payload : payloads) { + try { + transientPayloadStorageService.deletePayloadIfPresent(payload); + } catch (Exception ex) { + // 事务已提交,远端删除失败只记日志,不影响任务数据清理结果 + log.warn("[brand-storage] 载荷删除失败 taskId={} msg={}", taskId, ex.getMessage()); + } + } } private void saveAggregate(Long taskId, diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java index 46306797..63753b68 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java @@ -431,70 +431,85 @@ public class CollectDataService { } } - @Transactional + /** + * 进度心跳。 + * + *

事务边界:Redis 任务锁在事务外获取(自旋等待最长 TASK_LOCK_WAIT_MILLIS, + * 放在 @Transactional 里会白占一个 Hikari 连接),DB 段(统计持久化 + 任务行更新) + * 仍在一个事务内。 + */ public void updateProgress(Long taskId, TaskHeartbeatRequest request) { if (taskId == null || taskId <= 0 || request == null) { return; } try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - FileTaskEntity task = fileTaskMapper.selectById(taskId); - if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !STATUS_RUNNING.equals(task.getStatus())) { + if (transactionTemplate == null) { + // 单测场景(@InjectMocks 未注入事务模板):退化为直接执行 DB 段 + updateProgressLocked(taskId, request); return; } - CollectDataStats stats = loadStats(task); - boolean changed = false; - Integer current = request.getCurrent(); - Integer total = request.getTotal(); - if (current != null && total != null && total > 0) { - int totalRows = stats.totalRows > 0 ? stats.totalRows : total; - int processedRows = Math.max(stats.processedRows, Math.min(Math.max(current, 0), totalRows)); - if (stats.totalRows != totalRows || stats.processedRows != processedRows) { - stats.totalRows = totalRows; - stats.processedRows = processedRows; - changed = true; - } - } - if (request.getCollectStage() != null && !java.util.Objects.equals(stats.collectStage, request.getCollectStage())) { - stats.collectStage = request.getCollectStage(); - changed = true; - } - if (request.getCurrentKeyword() != null && !java.util.Objects.equals(stats.currentKeyword, request.getCurrentKeyword())) { - stats.currentKeyword = request.getCurrentKeyword(); - changed = true; - } - if (request.getSearchCurrentPage() != null && stats.searchCurrentPage != Math.max(0, request.getSearchCurrentPage())) { - stats.searchCurrentPage = Math.max(0, request.getSearchCurrentPage()); - changed = true; - } - if (request.getSearchTotalPages() != null && stats.searchTotalPages != Math.max(0, request.getSearchTotalPages())) { - stats.searchTotalPages = Math.max(0, request.getSearchTotalPages()); - changed = true; - } - if (request.getDetailProcessedAsins() != null && stats.detailProcessedAsins != Math.max(0, request.getDetailProcessedAsins())) { - stats.detailProcessedAsins = Math.max(0, request.getDetailProcessedAsins()); - changed = true; - } - if (request.getDetailTotalAsins() != null && stats.detailTotalAsins != Math.max(0, request.getDetailTotalAsins())) { - stats.detailTotalAsins = Math.max(0, request.getDetailTotalAsins()); - changed = true; - } - if (!changed) { - // 内容未变化:仅当脏数据兜底窗口到期时才强制刷新一次, - // 否则零 UPDATE(重复心跳幂等)。 - if (!shouldForceProgressFlush()) { - return; - } - } else if (shouldThrottleProgressFlush()) { - // 节流窗口内:合并写(只更新内存态、不落库),窗口到期后统一持久化。 - return; - } - persistStats(task, stats); - task.setUpdatedAt(LocalDateTime.now()); - fileTaskMapper.updateById(task); - lastProgressFlushMillis = System.currentTimeMillis(); + transactionTemplate.executeWithoutResult(status -> updateProgressLocked(taskId, request)); } } + private void updateProgressLocked(Long taskId, TaskHeartbeatRequest request) { + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !STATUS_RUNNING.equals(task.getStatus())) { + return; + } + CollectDataStats stats = loadStats(task); + boolean changed = false; + Integer current = request.getCurrent(); + Integer total = request.getTotal(); + if (current != null && total != null && total > 0) { + int totalRows = stats.totalRows > 0 ? stats.totalRows : total; + int processedRows = Math.max(stats.processedRows, Math.min(Math.max(current, 0), totalRows)); + if (stats.totalRows != totalRows || stats.processedRows != processedRows) { + stats.totalRows = totalRows; + stats.processedRows = processedRows; + changed = true; + } + } + if (request.getCollectStage() != null && !java.util.Objects.equals(stats.collectStage, request.getCollectStage())) { + stats.collectStage = request.getCollectStage(); + changed = true; + } + if (request.getCurrentKeyword() != null && !java.util.Objects.equals(stats.currentKeyword, request.getCurrentKeyword())) { + stats.currentKeyword = request.getCurrentKeyword(); + changed = true; + } + if (request.getSearchCurrentPage() != null && stats.searchCurrentPage != Math.max(0, request.getSearchCurrentPage())) { + stats.searchCurrentPage = Math.max(0, request.getSearchCurrentPage()); + changed = true; + } + if (request.getSearchTotalPages() != null && stats.searchTotalPages != Math.max(0, request.getSearchTotalPages())) { + stats.searchTotalPages = Math.max(0, request.getSearchTotalPages()); + changed = true; + } + if (request.getDetailProcessedAsins() != null && stats.detailProcessedAsins != Math.max(0, request.getDetailProcessedAsins())) { + stats.detailProcessedAsins = Math.max(0, request.getDetailProcessedAsins()); + changed = true; + } + if (request.getDetailTotalAsins() != null && stats.detailTotalAsins != Math.max(0, request.getDetailTotalAsins())) { + stats.detailTotalAsins = Math.max(0, request.getDetailTotalAsins()); + changed = true; + } + if (!changed) { + // 内容未变化:仅当脏数据兜底窗口到期时才强制刷新一次, + // 否则零 UPDATE(重复心跳幂等)。 + if (!shouldForceProgressFlush()) { + return; + } + } else if (shouldThrottleProgressFlush()) { + // 节流窗口内:合并写(只更新内存态、不落库),窗口到期后统一持久化。 + return; + } + persistStats(task, stats); + task.setUpdatedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + lastProgressFlushMillis = System.currentTimeMillis(); + } + /** * 节流判定:progressThrottleMillis>0 且距上次实际落库未超过窗口 → 合并写(跳过 UPDATE)。 * progressThrottleMillis<=0 视为关闭节流,恒返回 false(每次心跳都落库,兼容旧行为)。 diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java index dc86fbd0..83d4eee4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java @@ -16,6 +16,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -252,6 +254,13 @@ public class DeleteBrandTaskStorageService { return grouped; } + /** + * 删除任务的全部范围/分片数据。 + * + *

事务边界:远端载荷删除移到事务提交后执行 —— 单次删除超时 120s、重试 3 次, + * 放在 DB 事务里会让连接被长时间占用(项目在 TaskScopePayloadStorageService 已写明该约束)。 + * 提交后再删还有个好处:引用计数查询看到的是删除完成后的 DB 状态,判断更准确。 + */ @Transactional public void deleteTaskData(Long taskId) { if (taskId == null || taskId <= 0) { @@ -261,27 +270,68 @@ public class DeleteBrandTaskStorageService { .select(TaskScopeStateEntity::getParsedPayloadJson, TaskScopeStateEntity::getStateJson) .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); - if (states != null) { - for (TaskScopeStateEntity state : states) { - transientPayloadStorageService.deletePayloadIfPresent(state.getParsedPayloadJson()); - transientPayloadStorageService.deletePayloadIfPresent(state.getStateJson()); - } - } List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() .select(TaskChunkEntity::getPayloadJson) .eq(TaskChunkEntity::getTaskId, taskId) .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); - if (chunks != null) { - for (TaskChunkEntity chunk : chunks) { - transientPayloadStorageService.deletePayloadIfPresent(chunk.getPayloadJson()); - } - } taskChunkMapper.delete(new LambdaQueryWrapper() .eq(TaskChunkEntity::getTaskId, taskId) .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); taskScopeStateMapper.delete(new LambdaQueryWrapper() .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); + deletePayloadsAfterCommit(states, chunks, taskId); + } + + /** 事务提交后逐条删远端载荷;无事务时立即执行(与 TaskResultItemService 同款兜底)。 */ + private void deletePayloadsAfterCommit(List states, + List chunks, + Long taskId) { + List payloads = new ArrayList<>(); + if (states != null) { + for (TaskScopeStateEntity state : states) { + if (state == null) { + continue; + } + if (state.getParsedPayloadJson() != null && !state.getParsedPayloadJson().isBlank()) { + payloads.add(state.getParsedPayloadJson()); + } + if (state.getStateJson() != null && !state.getStateJson().isBlank()) { + payloads.add(state.getStateJson()); + } + } + } + if (chunks != null) { + for (TaskChunkEntity chunk : chunks) { + if (chunk != null && chunk.getPayloadJson() != null && !chunk.getPayloadJson().isBlank()) { + payloads.add(chunk.getPayloadJson()); + } + } + } + if (payloads.isEmpty()) { + return; + } + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + deletePayloadsNow(payloads, taskId); + } + }); + return; + } + deletePayloadsNow(payloads, taskId); + } + + private void deletePayloadsNow(List payloads, Long taskId) { + for (String payload : payloads) { + try { + transientPayloadStorageService.deletePayloadIfPresent(payload); + } catch (Exception ex) { + // 事务已提交,远端删除失败只记日志,不影响任务数据清理结果 + log.warn("[delete-brand-storage] 载荷删除失败 taskId={} msg={}", taskId, ex.getMessage()); + } + } } private void refreshScopeState(Long taskId, String scopeKey, String scopeHash, Integer chunkTotal, LocalDateTime now) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/digitalhuman/service/DigitalHumanVersionService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/digitalhuman/service/DigitalHumanVersionService.java index 67a75052..689277c5 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/digitalhuman/service/DigitalHumanVersionService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/digitalhuman/service/DigitalHumanVersionService.java @@ -11,6 +11,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; import java.io.File; @@ -187,6 +189,13 @@ public class DigitalHumanVersionService { return toVo(entity); } + /** + * 删除版本。 + * + *

事务边界:MinIO 对象删除移到事务提交后执行 —— 单次删除超时 120s、重试 3 次, + * 放在 DB 事务里会长时间占用连接(项目在 TaskScopePayloadStorageService 已写明该约束)。 + * 顺序仍是「先删库、后删对象」,与改前一致。 + */ @Transactional public void deleteVersion(String version) { DigitalHumanVersionEntity entity = versionMapper.selectOne(new LambdaQueryWrapper() @@ -198,15 +207,33 @@ public class DigitalHumanVersionService { throw new BusinessException("最新版本不能删除"); } - // 删除 MinIO 文件 - try { - ossStorageService.deleteObject(entity.getOssObjectKey()); - } catch (Exception e) { - log.warn("删除 MinIO 文件失败:{}", entity.getOssObjectKey(), e); - } - // 删除数据库记录 versionMapper.deleteById(entity.getId()); + + // 删除 MinIO 文件(事务提交后) + deleteObjectAfterCommit(entity.getOssObjectKey()); + } + + /** 事务提交后删 MinIO 对象;无事务时立即执行。 */ + private void deleteObjectAfterCommit(String objectKey) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + deleteObjectQuietly(objectKey); + } + }); + return; + } + deleteObjectQuietly(objectKey); + } + + private void deleteObjectQuietly(String objectKey) { + try { + ossStorageService.deleteObject(objectKey); + } catch (Exception e) { + log.warn("删除 MinIO 文件失败:{}", objectKey, e); + } } public String getDownloadUrl(String version) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java index 5329dcb2..330bc49f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java @@ -40,6 +40,8 @@ public class ImageVideoAsyncTaskService { private static final int DISPATCH_BATCH_SIZE = 20; private static final int POLL_BATCH_SIZE = 50; private static final int FAILED_TASK_RETENTION_MINUTES = 10; + /** 过期失败任务单轮删除上限:一条无 LIMIT 的 DELETE 会长时间持锁,改成分批(每轮扫描都会再清)。 */ + private static final int EXPIRED_DELETE_BATCH_SIZE = 500; private static final Set TERMINAL_STATUSES = Set.of( "SUCCESS", "SUCCEEDED", "COMPLETED", "DONE", "FINISHED", "FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED" @@ -142,7 +144,10 @@ public class ImageVideoAsyncTaskService { return; } try (jobLock) { + // 只取主键:本表含 5 个 LONGTEXT 列(请求/响应正文),而这里只用来发起 + // executeTask(id)。每秒扫一轮还拉全部大字段属于纯浪费(2026-09-15 优化)。 List tasks = taskMapper.selectList(new LambdaQueryWrapper() + .select(ImageVideoAsyncTaskEntity::getId) .eq(ImageVideoAsyncTaskEntity::getStatus, TaskStatus.PENDING.name()) .and(q -> q.eq(ImageVideoAsyncTaskEntity::getOwnerInstanceId, currentInstanceId()) .or().isNull(ImageVideoAsyncTaskEntity::getOwnerInstanceId) @@ -161,7 +166,9 @@ public class ImageVideoAsyncTaskService { return; } try (jobLock) { + // 同 dispatchPendingTasks:只取主键,避免每 5 秒把 LONGTEXT 正文整列拉回 List tasks = taskMapper.selectList(new LambdaQueryWrapper() + .select(ImageVideoAsyncTaskEntity::getId) .eq(ImageVideoAsyncTaskEntity::getStatus, TaskStatus.WAITING.name()) .and(q -> q.eq(ImageVideoAsyncTaskEntity::getOwnerInstanceId, currentInstanceId()) .or().isNull(ImageVideoAsyncTaskEntity::getOwnerInstanceId) @@ -234,7 +241,8 @@ public class ImageVideoAsyncTaskService { LocalDateTime cutoff = LocalDateTime.now().minusMinutes(FAILED_TASK_RETENTION_MINUTES); int deleted = taskMapper.delete(new LambdaQueryWrapper() .eq(ImageVideoAsyncTaskEntity::getStatus, TaskStatus.FAILED.name()) - .lt(ImageVideoAsyncTaskEntity::getUpdatedAt, cutoff)); + .lt(ImageVideoAsyncTaskEntity::getUpdatedAt, cutoff) + .last("LIMIT " + EXPIRED_DELETE_BATCH_SIZE)); if (deleted > 0) { log.info("[image-video] removed expired failed tasks count={}", deleted); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java index b26dd782..46b4196e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java @@ -37,6 +37,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; import java.io.File; import java.time.LocalDateTime; @@ -81,6 +83,10 @@ public class PatrolDeleteTaskService { private final TaskScopePayloadStorageService taskScopePayloadStorageService; private final TaskDistributedLockService taskDistributedLockService; private final TaskProgressLightAssembler taskProgressLightAssembler; + /** + * 事务边界收口用:Redis 任务锁必须在事务外获取,否则自旋等待期间会白占一个 Hikari 连接。 + */ + private final org.springframework.transaction.PlatformTransactionManager transactionManager; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = taskCacheService.getTaskCacheBatch(List.of(taskId)); @@ -470,7 +476,10 @@ public class PatrolDeleteTaskService { } } - @Transactional + /** + * 事务边界:任务锁在事务外获取(自旋等待不再占用 DB 连接); + * 缓存清理放到事务提交后、锁内执行。耗时日志语义保持不变。 + */ public void deleteTask(Long taskId, Long userId) { long startedAt = System.nanoTime(); validateUserId(userId); @@ -480,14 +489,18 @@ public class PatrolDeleteTaskService { } try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { long taskLoadedAt = System.nanoTime(); - fileResultMapper.delete(new LambdaQueryWrapper() - .eq(FileResultEntity::getTaskId, taskId) - .eq(FileResultEntity::getModuleType, MODULE_TYPE)); - long resultsDeletedAt = System.nanoTime(); - cleanupTaskAuxiliaryDataFast(taskId); - long auxiliaryDeletedAt = System.nanoTime(); - fileTaskMapper.deleteById(taskId); - long taskDeletedAt = System.nanoTime(); + long[] marks = new long[3]; + inNewTransaction(() -> { + fileResultMapper.delete(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + marks[0] = System.nanoTime(); + cleanupTaskAuxiliaryDataFast(taskId); + marks[1] = System.nanoTime(); + fileTaskMapper.deleteById(taskId); + marks[2] = System.nanoTime(); + return null; + }); taskCacheService.evictTaskCacheOnly(taskId); long finishedAt = System.nanoTime(); log.info("[patrol-delete] delete task timing taskId={} userId={} totalMs={} loadMs={} resultDeleteMs={} auxiliaryDeleteMs={} taskDeleteMs={} cacheMs={}", @@ -495,14 +508,16 @@ public class PatrolDeleteTaskService { userId, elapsedMs(startedAt, finishedAt), elapsedMs(startedAt, taskLoadedAt), - elapsedMs(taskLoadedAt, resultsDeletedAt), - elapsedMs(resultsDeletedAt, auxiliaryDeletedAt), - elapsedMs(auxiliaryDeletedAt, taskDeletedAt), - elapsedMs(taskDeletedAt, finishedAt)); + elapsedMs(taskLoadedAt, marks[0]), + elapsedMs(marks[0], marks[1]), + elapsedMs(marks[1], marks[2]), + elapsedMs(marks[2], finishedAt)); } } - @Transactional + /** + * 事务边界:任务锁在事务外获取,DB 段(复核 + 删除 + 重算)仍在同一事务内。 + */ public void deleteHistory(Long resultId, Long userId) { validateUserId(userId); FileResultEntity entity = fileResultMapper.selectById(resultId); @@ -516,13 +531,16 @@ public class PatrolDeleteTaskService { return; } try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - FileResultEntity latestEntity = fileResultMapper.selectById(resultId); - if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) { - throw new BusinessException("record not found"); - } - fileResultMapper.deleteById(resultId); - cleanupResultAuxiliaryDataFast(taskId, resultId); - reconcileTaskAfterResultRemoval(taskId); + inNewTransaction(() -> { + FileResultEntity latestEntity = fileResultMapper.selectById(resultId); + if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) { + throw new BusinessException("record not found"); + } + fileResultMapper.deleteById(resultId); + cleanupResultAuxiliaryDataFast(taskId, resultId); + reconcileTaskAfterResultRemoval(taskId); + return null; + }); } } @@ -1094,6 +1112,19 @@ public class PatrolDeleteTaskService { return taskDistributedLockService.acquire(MODULE_TYPE, taskId); } + /** + * 显式短事务:锁已在事务外获取,这里只包住 DB 段, + * 保证原来同属一个 @Transactional 的多条写仍在同一事务内。 + */ + private T inNewTransaction(java.util.function.Supplier action) { + if (transactionManager == null) { + return action.get(); + } + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + return template.execute(status -> action.get()); + } + private void markResultSuccess(FileResultEntity row) { row.setSuccess(RESULT_SUCCESS); row.setErrorMessage(null); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java index d40e716c..8d32e195 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java @@ -297,6 +297,22 @@ public class PermissionMenuService { .contains(columnId); } + /** + * 可授权用户查询:只取「超管判定 + 列表展示」用到的列。 + * + *

权限页此前一律 `selectList(new LambdaQueryWrapper<>())` 整表拉回,而 admin_user + * 含密码哈希等敏感列,每次打开权限页都把全表(含哈希)读进内存(2026-09-15 优化)。 + * 判定用 role/is_admin/created_by_id,展示用 id/username。 + */ + private LambdaQueryWrapper grantableUserQuery() { + return new LambdaQueryWrapper() + .select(AdminUserEntity::getId, + AdminUserEntity::getUsername, + AdminUserEntity::getRole, + AdminUserEntity::getIsAdmin, + AdminUserEntity::getCreatedById); + } + public List listImageVideoDataPermissionUsers(AdminUserEntity operator) { ensureSuperAdminOperator(operator); PermissionMenuEntity dataPermission = requireImageVideoDataPermission(); @@ -305,7 +321,7 @@ public class PermissionMenuService { .map(UserColumnPermissionEntity::getUserId) .filter(id -> id != null && id > 0) .collect(Collectors.toSet()); - return adminUserMapper.selectList(new LambdaQueryWrapper() + return adminUserMapper.selectList(grantableUserQuery() .orderByAsc(AdminUserEntity::getUsername) .orderByAsc(AdminUserEntity::getId)) .stream() @@ -319,7 +335,7 @@ public class PermissionMenuService { ensureSuperAdminOperator(operator); PermissionMenuEntity dataPermission = requireImageVideoDataPermission(); List requestedIds = normalizeColumnIds(userIds); - List users = adminUserMapper.selectList(new LambdaQueryWrapper()); + List users = adminUserMapper.selectList(grantableUserQuery()); Map grantableUsers = users.stream() .filter(user -> user.getId() != null && !isSuperAdmin(user)) .collect(Collectors.toMap(AdminUserEntity::getId, Function.identity(), (left, right) -> left)); @@ -345,7 +361,7 @@ public class PermissionMenuService { .map(UserColumnPermissionEntity::getUserId) .filter(id -> id != null && id > 0) .collect(Collectors.toSet()); - return adminUserMapper.selectList(new LambdaQueryWrapper() + return adminUserMapper.selectList(grantableUserQuery() .orderByAsc(AdminUserEntity::getUsername) .orderByAsc(AdminUserEntity::getId)) .stream() @@ -360,7 +376,7 @@ public class PermissionMenuService { PermissionMenuEntity dataPermission = requireDataPermission( SHOP_DATA_CRAWL_DATA_PERMISSION_KEY, "店铺数据任务数据"); List requestedIds = normalizeColumnIds(userIds); - List users = adminUserMapper.selectList(new LambdaQueryWrapper()); + List users = adminUserMapper.selectList(grantableUserQuery()); Map grantableUsers = users.stream() .filter(user -> user.getId() != null && !isSuperAdmin(user)) .collect(Collectors.toMap(AdminUserEntity::getId, Function.identity(), (left, right) -> left)); @@ -607,7 +623,9 @@ public class PermissionMenuService { } Set targetEffectiveIds = expandDescendantIds( new LinkedHashSet<>(loadDirectColumnIds(target.getId())), loadMenus(null)); + // 只用到 id:不要为一次级联清理把整表(含密码哈希)拉回 List subordinates = adminUserMapper.selectList(new LambdaQueryWrapper() + .select(AdminUserEntity::getId) .eq(AdminUserEntity::getCreatedById, target.getId())); for (AdminUserEntity subordinate : subordinates) { Long subordinateId = subordinate.getId(); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java index 8e8d72c7..d6f15198 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java @@ -39,6 +39,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; import java.io.BufferedReader; import java.io.File; @@ -93,6 +95,11 @@ public class PriceTrackTaskService { private final TaskDistributedLockService taskDistributedLockService; private final TaskProgressLightAssembler taskProgressLightAssembler; private final com.nanri.aiimage.modules.file.service.LocalFileStorageService localFileStorageService; + /** + * 事务边界收口用:Redis 任务锁必须在事务外获取,否则自旋等待(最长 TASK_LOCK_WAIT_MILLIS) + * 期间会白占一个 Hikari 连接(生产 maximum-pool-size 仅 30)。 + */ + private final org.springframework.transaction.PlatformTransactionManager transactionManager; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = priceTrackTaskCacheService.getTaskCacheBatch(List.of(taskId)); @@ -254,7 +261,10 @@ public class PriceTrackTaskService { return vo; } - @Transactional + /** + * 事务边界:Redis 任务锁在事务外获取(自旋等待不再占用 DB 连接), + * DB 段(复核 + 删除 + 重算任务状态)仍在同一个事务内。 + */ public void deleteHistory(Long resultId, Long userId) { if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); FileResultEntity entity = fileResultMapper.selectById(resultId); @@ -267,73 +277,97 @@ public class PriceTrackTaskService { return; } try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - FileResultEntity latestEntity = fileResultMapper.selectById(resultId); - if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) { - throw new BusinessException("record not found"); - } - fileResultMapper.deleteById(resultId); - reconcileTaskAfterResultRemoval(taskId); + inNewTransaction(() -> { + FileResultEntity latestEntity = fileResultMapper.selectById(resultId); + if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) { + throw new BusinessException("record not found"); + } + fileResultMapper.deleteById(resultId); + reconcileTaskAfterResultRemoval(taskId); + return null; + }); } } - @Transactional + /** + * 事务边界:任务锁在事务外获取;缓存清理(Redis + 远端范围载荷)放到事务提交后执行。 + */ public void deleteTask(Long taskId, Long userId) { if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - FileTaskEntity task = loadTaskForExecution(taskId); - if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) { - throw new BusinessException("任务不存在"); - } - fileResultMapper.delete(new LambdaQueryWrapper() - .eq(FileResultEntity::getTaskId, taskId) - .eq(FileResultEntity::getModuleType, MODULE_TYPE)); - fileTaskMapper.deleteById(taskId); + inNewTransaction(() -> { + FileTaskEntity task = loadTaskForExecution(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) { + throw new BusinessException("任务不存在"); + } + fileResultMapper.delete(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + fileTaskMapper.deleteById(taskId); + priceTrackLoopRunService.syncLoopRunAfterChildRemoved(taskId); + return null; + }); + // 缓存清理(Redis + 远端范围载荷)在事务提交后、锁内执行 priceTrackTaskCacheService.deleteTaskCache(taskId); - priceTrackLoopRunService.syncLoopRunAfterChildRemoved(taskId); } + log.info("[price-track] deleteTask 完成 taskId={} userId={}", taskId, userId); } - @Transactional + /** + * 事务边界:任务锁在事务外获取;缓存清理放到事务提交后执行。 + */ public void markDispatchFailed(Long taskId, Long userId, String errorMessage) { if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); if (errorMessage == null || errorMessage.isBlank()) throw new BusinessException("errorMessage 不能为空"); String normalizedError = errorMessage.trim(); try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - FileTaskEntity task = loadTaskForExecution(taskId); - if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) { - throw new BusinessException("任务不存在"); + // 返回需要清理缓存的任务状态(仅无结果行分支),非终态返回 null + String terminalStatus = inNewTransaction(() -> + markDispatchFailedRecords(taskId, userId, normalizedError)); + if (terminalStatus != null) { + cleanupTaskCacheIfTerminal(taskId, terminalStatus); } - if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) { - return; - } - List results = fileResultMapper.selectList( - new LambdaQueryWrapper() - .eq(FileResultEntity::getTaskId, taskId) - .eq(FileResultEntity::getModuleType, MODULE_TYPE) - .orderByAsc(FileResultEntity::getId)); - if (results.isEmpty()) { - task.setStatus("FAILED"); - task.setErrorMessage(normalizedError); - task.setUpdatedAt(LocalDateTime.now()); - task.setFinishedAt(LocalDateTime.now()); - fileTaskMapper.updateById(task); - cleanupTaskCacheIfTerminal(taskId, task.getStatus()); - priceTrackLoopRunService.syncLoopRunAfterChildTerminal(taskId); - return; - } - for (FileResultEntity result : results) { - boolean succeeded = result.getSuccess() != null && result.getSuccess() == 1; - boolean failed = result.getErrorMessage() != null && !result.getErrorMessage().isBlank(); - if (!succeeded && !failed) { - markResultFailed(result, normalizedError); - } - } - updateTaskStatusFromLatestRows(task, results); - log.warn("[price-track] Python dispatch failed taskId={} userId={} error={}", taskId, userId, normalizedError); } } - @Transactional + private String markDispatchFailedRecords(Long taskId, Long userId, String normalizedError) { + FileTaskEntity task = loadTaskForExecution(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) { + throw new BusinessException("任务不存在"); + } + if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) { + return null; + } + List results = fileResultMapper.selectList( + new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .orderByAsc(FileResultEntity::getId)); + if (results.isEmpty()) { + task.setStatus("FAILED"); + task.setErrorMessage(normalizedError); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + priceTrackLoopRunService.syncLoopRunAfterChildTerminal(taskId); + return task.getStatus(); + } + for (FileResultEntity result : results) { + boolean succeeded = result.getSuccess() != null && result.getSuccess() == 1; + boolean failed = result.getErrorMessage() != null && !result.getErrorMessage().isBlank(); + if (!succeeded && !failed) { + markResultFailed(result, normalizedError); + } + } + updateTaskStatusFromLatestRows(task, results); + log.warn("[price-track] Python dispatch failed taskId={} userId={} error={}", taskId, userId, normalizedError); + return null; + } + + /** + * 事务边界:逐个候选任务在事务外取锁;命中的那条记录的删除 + 重算在独立短事务内完成 + * (原实现整个循环共用一个事务,锁自旋等待期间会一直占着连接)。 + */ public PriceTrackPendingDeleteVo deletePendingShopResult(Long userId, String shopName) { if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); if (shopName == null || shopName.isBlank()) throw new BusinessException("shop_name 不能为空"); @@ -356,20 +390,33 @@ public class PriceTrackTaskService { if (!"RUNNING".equals(task.getStatus())) continue; try (TaskDistributedLockService.LockHandle ignored = acquireTaskLock(fr.getTaskId())) { if (ignored == null) continue; - FileTaskEntity lockedTask = loadTaskForExecution(fr.getTaskId()); - if (lockedTask == null || !MODULE_TYPE.equals(lockedTask.getModuleType()) || !userId.equals(lockedTask.getUserId())) continue; - if (!"RUNNING".equals(lockedTask.getStatus())) continue; - FileResultEntity lockedResult = fileResultMapper.selectById(fr.getId()); - if (lockedResult == null || !MODULE_TYPE.equals(lockedResult.getModuleType())) continue; - fileResultMapper.deleteById(fr.getId()); - reconcileTaskAfterResultRemoval(fr.getTaskId()); - vo.setRemoved(true); - return vo; + Boolean removed = inNewTransaction(() -> removeRunningShopResult(fr.getTaskId(), userId, fr.getId())); + if (Boolean.TRUE.equals(removed)) { + vo.setRemoved(true); + return vo; + } } } return vo; } + private boolean removeRunningShopResult(Long taskId, Long userId, Long resultId) { + FileTaskEntity lockedTask = loadTaskForExecution(taskId); + if (lockedTask == null || !MODULE_TYPE.equals(lockedTask.getModuleType()) || !userId.equals(lockedTask.getUserId())) { + return false; + } + if (!"RUNNING".equals(lockedTask.getStatus())) { + return false; + } + FileResultEntity lockedResult = fileResultMapper.selectById(resultId); + if (lockedResult == null || !MODULE_TYPE.equals(lockedResult.getModuleType())) { + return false; + } + fileResultMapper.deleteById(resultId); + reconcileTaskAfterResultRemoval(taskId); + return true; + } + public PriceTrackTaskBatchVo getTaskDetailsBatch(List taskIds) { PriceTrackTaskBatchVo batch = new PriceTrackTaskBatchVo(); if (taskIds == null || taskIds.isEmpty()) return batch; @@ -506,6 +553,10 @@ public class PriceTrackTaskService { } List snapshot = new ArrayList<>(); + // 逐店铺 fileResultMapper.insert 看似该改批量(500 店铺 = 500 次往返),但**不能改**: + // 下面的 toSnapshotVo 依赖 MyBatis-Plus insert 回填的自增主键(fr.getId() → vo.resultId), + // 而本仓的手写 INSERT ... VALUES (...),(...) 批量写法不回填主键,改了会让快照 resultId 为 null。 + // 要批量化必须同时解决「批量插入 + 按序回填 id」(MySQL 需 useGeneratedKeys + 顺序保证)。 for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : uniqueItems) { String norm = ziniaoShopSwitchService.normalizeShopName(item.getShopName()); if (norm.isBlank()) throw new BusinessException("店铺名称无效"); @@ -2182,6 +2233,20 @@ public class PriceTrackTaskService { return lockHandle; } + /** + * 显式开启一个短事务:锁已在事务外获取,这里只包住 DB 段, + * 保证原来同属一个 @Transactional 的多条写仍在同一事务内。 + */ + private T inNewTransaction(java.util.function.Supplier action) { + if (transactionManager == null) { + // 单测直接 new 出本类(history 见 QueryAsinSnapshotJsonThrottleTest 同类用法)时无事务管理器 + return action.get(); + } + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + return template.execute(status -> action.get()); + } + private TaskDistributedLockService.LockHandle acquireTaskLock(Long taskId) { if (taskId == null || taskId <= 0) { return null; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java index 456af95c..68d7a3c2 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java @@ -39,6 +39,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; import java.io.File; import java.time.LocalDateTime; @@ -82,6 +84,10 @@ public class ProductRiskTaskService { private final TaskProgressSnapshotService taskProgressSnapshotService; private final TaskDistributedLockService taskDistributedLockService; private final TaskProgressLightAssembler taskProgressLightAssembler; + /** + * 事务边界收口用:Redis 任务锁必须在事务外获取,否则自旋等待期间会白占一个 Hikari 连接。 + */ + private final org.springframework.transaction.PlatformTransactionManager transactionManager; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = productRiskTaskCacheService.getTaskCacheBatch(List.of(taskId)); @@ -248,7 +254,9 @@ public class ProductRiskTaskService { return vo; } - @Transactional + /** + * 事务边界:任务锁在事务外获取(自旋等待不再占用 DB 连接),DB 段仍在同一事务内。 + */ public void deleteHistory(Long resultId, Long userId) { if (userId == null || userId <= 0) { throw new BusinessException("user_id 不合法"); @@ -263,43 +271,53 @@ public class ProductRiskTaskService { return; } try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - FileResultEntity latestEntity = fileResultMapper.selectById(resultId); - if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) { - throw new BusinessException("record not found"); - } - taskFileJobService.deleteResultJobs(latestEntity.getTaskId(), MODULE_TYPE, latestEntity.getId()); - fileResultMapper.deleteById(resultId); - reconcileTaskAfterResultRemoval(taskId); + inNewTransaction(() -> { + FileResultEntity latestEntity = fileResultMapper.selectById(resultId); + if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) { + throw new BusinessException("record not found"); + } + taskFileJobService.deleteResultJobs(latestEntity.getTaskId(), MODULE_TYPE, latestEntity.getId()); + fileResultMapper.deleteById(resultId); + reconcileTaskAfterResultRemoval(taskId); + return null; + }); } } /** * 删除整条商品风险任务及其下所有店铺结果,运行中和已结束任务都允许删除,但必须归属当前用户。 + * + *

事务边界:任务锁在事务外获取;缓存清理放到事务提交后、锁内执行。 */ - @Transactional public void deleteTask(Long taskId, Long userId) { if (userId == null || userId <= 0) { throw new BusinessException("user_id 不合法"); } try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - FileTaskEntity task = loadTaskForExecution(taskId); - if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) { - throw new BusinessException("任务不存在"); - } - fileResultMapper.delete(new LambdaQueryWrapper() - .eq(FileResultEntity::getTaskId, taskId) - .eq(FileResultEntity::getModuleType, MODULE_TYPE)); - taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE); - fileTaskMapper.deleteById(taskId); + inNewTransaction(() -> { + FileTaskEntity task = loadTaskForExecution(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) { + throw new BusinessException("任务不存在"); + } + fileResultMapper.delete(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE); + fileTaskMapper.deleteById(taskId); + return null; + }); cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY"); } + log.info("[product-risk] deleteTask 完成 taskId={} userId={}", taskId, userId); } /** * 从用户当前“运行中”的商品风险任务里,按规范化店铺名删除一条结果,用于前端移除匹配列表后同步后端。 * 如果没有对应记录,则返回 removed=false。 + * + *

事务边界:逐个候选任务在事务外取锁;命中的那条记录的删除 + 重算在独立短事务内完成 + * (原实现整个循环共用一个事务,锁自旋等待期间会一直占着连接)。 */ - @Transactional public ProductRiskPendingDeleteVo deletePendingShopResult(Long userId, String shopName) { if (userId == null || userId <= 0) { throw new BusinessException("user_id 不合法"); @@ -335,26 +353,33 @@ public class ProductRiskTaskService { if (ignored == null) { continue; } - FileTaskEntity lockedTask = loadTaskForExecution(tid); - if (lockedTask == null || !MODULE_TYPE.equals(lockedTask.getModuleType()) || !userId.equals(lockedTask.getUserId())) { - continue; + Boolean removed = inNewTransaction(() -> removeRunningShopResult(tid, userId, fr.getId())); + if (Boolean.TRUE.equals(removed)) { + vo.setRemoved(true); + return vo; } - if (!"RUNNING".equals(lockedTask.getStatus())) { - continue; - } - FileResultEntity lockedResult = fileResultMapper.selectById(fr.getId()); - if (lockedResult == null || !MODULE_TYPE.equals(lockedResult.getModuleType())) { - continue; - } - fileResultMapper.deleteById(fr.getId()); - reconcileTaskAfterResultRemoval(tid); - vo.setRemoved(true); - return vo; } } return vo; } + private boolean removeRunningShopResult(Long taskId, Long userId, Long resultId) { + FileTaskEntity lockedTask = loadTaskForExecution(taskId); + if (lockedTask == null || !MODULE_TYPE.equals(lockedTask.getModuleType()) || !userId.equals(lockedTask.getUserId())) { + return false; + } + if (!"RUNNING".equals(lockedTask.getStatus())) { + return false; + } + FileResultEntity lockedResult = fileResultMapper.selectById(resultId); + if (lockedResult == null || !MODULE_TYPE.equals(lockedResult.getModuleType())) { + return false; + } + fileResultMapper.deleteById(resultId); + reconcileTaskAfterResultRemoval(taskId); + return true; + } + /** * 删除一条 file_result 后重算父任务状态;如果没有剩余结果则删除任务。 */ @@ -1002,6 +1027,19 @@ public class ProductRiskTaskService { return taskDistributedLockService.acquire(MODULE_TYPE, taskId); } + /** + * 显式短事务:锁已在事务外获取,这里只包住 DB 段, + * 保证原来同属一个 @Transactional 的多条写仍在同一事务内。 + */ + private T inNewTransaction(java.util.function.Supplier action) { + if (transactionManager == null) { + return action.get(); + } + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + return template.execute(status -> action.get()); + } + private ProductRiskTaskDetailVo buildTaskDetail(FileTaskEntity task) { ProductRiskTaskDetailVo detail = new ProductRiskTaskDetailVo(); detail.setTask(toTaskItemVo(task)); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishTaskService.java index 19854a2e..e1e916eb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishTaskService.java @@ -57,6 +57,8 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionTemplate; import java.io.File; @@ -554,24 +556,28 @@ public class PublishTaskService { } } + /** + * 删除任务。 + * + *

事务边界:远端删除(结果文件对象 + 分片载荷)全部移到事务提交后执行 —— + * 单次删除超时 120s、重试 3 次,放在 DB 事务里会让连接被长时间占用 + * (项目在 TaskScopePayloadStorageService 已写明「不能把网络调用放进 DB 事务」)。 + */ @Transactional public void deleteTask(Long taskId, Long userId) { FileTaskEntity task = requireTaskForDeletion(taskId, userId); List results = fileResultMapper.selectList(new LambdaQueryWrapper() .eq(FileResultEntity::getTaskId, taskId) .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + List resultObjectKeys = new ArrayList<>(); for (FileResultEntity result : results) { if (result.getResultFileUrl() != null && !result.getResultFileUrl().isBlank()) { - try { - ossStorageService.deleteObject(result.getResultFileUrl()); - } catch (Exception ex) { - log.warn("[publish] OSS cleanup failed taskId={} resultId={} msg={}", - taskId, result.getId(), ex.getMessage()); - } + resultObjectKeys.add(result.getResultFileUrl()); } } + List chunkPayloads = collectTransientResultChunkPayloads(taskId); taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE); - deleteTransientResultChunks(taskId); + deleteTransientResultChunkRows(taskId); publishItemMapper.delete(new LambdaQueryWrapper() .eq(PublishItemEntity::getTaskId, taskId)); publishFileMapper.delete(new LambdaQueryWrapper() @@ -580,6 +586,9 @@ public class PublishTaskService { .eq(FileResultEntity::getTaskId, taskId) .eq(FileResultEntity::getModuleType, MODULE_TYPE)); fileTaskMapper.deleteById(task.getId()); + // 事务提交后再做远端删除 + deleteResultObjectsAfterCommit(resultObjectKeys, taskId); + deletePayloadsAfterCommit(chunkPayloads, taskId); } public void deleteHistory(Long resultId, Long userId) { @@ -599,18 +608,35 @@ public class PublishTaskService { } private void deleteTransientResultChunks(Long taskId) { + List payloads = collectTransientResultChunkPayloads(taskId); + deleteTransientResultChunkRows(taskId); + deletePayloadsAfterCommit(payloads, taskId); + } + + /** 只读收集分片载荷指针,供事务提交后做远端删除。 */ + private List collectTransientResultChunkPayloads(Long taskId) { if (taskId == null || taskId <= 0) { - return; + return List.of(); } List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() .select(TaskChunkEntity::getPayloadJson) .eq(TaskChunkEntity::getTaskId, taskId) .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + List payloads = new ArrayList<>(); if (chunks != null) { for (TaskChunkEntity chunk : chunks) { - transientPayloadStorageService.deletePayloadIfPresent(chunk.getPayloadJson()); + if (chunk != null && chunk.getPayloadJson() != null && !chunk.getPayloadJson().isBlank()) { + payloads.add(chunk.getPayloadJson()); + } } } + return payloads; + } + + private void deleteTransientResultChunkRows(Long taskId) { + if (taskId == null || taskId <= 0) { + return; + } taskChunkMapper.delete(new LambdaQueryWrapper() .eq(TaskChunkEntity::getTaskId, taskId) .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); @@ -619,6 +645,63 @@ public class PublishTaskService { .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); } + /** 事务提交后逐条删分片载荷;无事务时立即执行(与 TaskResultItemService 同款兜底)。 */ + private void deletePayloadsAfterCommit(List payloads, Long taskId) { + if (payloads == null || payloads.isEmpty()) { + return; + } + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + deletePayloadsNow(payloads, taskId); + } + }); + return; + } + deletePayloadsNow(payloads, taskId); + } + + private void deletePayloadsNow(List payloads, Long taskId) { + for (String payload : payloads) { + try { + transientPayloadStorageService.deletePayloadIfPresent(payload); + } catch (Exception ex) { + log.warn("[publish] 分片载荷删除失败 taskId={} pointer={} msg={}", + taskId, transientPayloadStorageService.extractPointer(payload), safeMessage(ex)); + } + } + } + + /** 事务提交后删结果文件对象;无事务时立即执行。 */ + private void deleteResultObjectsAfterCommit(List objectKeys, Long taskId) { + if (objectKeys == null || objectKeys.isEmpty()) { + return; + } + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + deleteResultObjectsNow(objectKeys, taskId); + } + }); + return; + } + deleteResultObjectsNow(objectKeys, taskId); + } + + private void deleteResultObjectsNow(List objectKeys, Long taskId) { + for (String objectKey : objectKeys) { + try { + ossStorageService.deleteObject(objectKey); + log.info("[publish] 结果文件已删除 taskId={} objectKey={}", taskId, objectKey); + } catch (Exception ex) { + log.warn("[publish] OSS cleanup failed taskId={} objectKey={} msg={}", + taskId, objectKey, ex.getMessage()); + } + } + } + private void deleteUncommittedPayloads(List storedPayloads) { if (storedPayloads == null || storedPayloads.isEmpty()) { return; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java index 4739cb3f..9b58f897 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java @@ -36,6 +36,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; import java.io.File; import java.time.LocalDateTime; @@ -92,6 +94,10 @@ public class QueryAsinTaskService { private final TaskProgressSnapshotService taskProgressSnapshotService; private final TaskDistributedLockService taskDistributedLockService; private final TaskProgressLightAssembler taskProgressLightAssembler; + /** + * 事务边界收口用:Redis 任务锁必须在事务外获取,否则自旋等待期间会白占一个 Hikari 连接。 + */ + private final org.springframework.transaction.PlatformTransactionManager transactionManager; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = taskCacheService.getTaskCacheBatch(List.of(taskId)); @@ -475,7 +481,10 @@ public class QueryAsinTaskService { } } - @Transactional + /** + * 事务边界:任务锁在事务外获取(自旋等待不再占用 DB 连接); + * 缓存清理(Redis + 远端范围载荷)放到事务提交后、锁内执行。 + */ public void deleteTask(Long taskId, Long userId) { validateUserId(userId); FileTaskEntity task = loadTaskForExecution(taskId); @@ -483,15 +492,21 @@ public class QueryAsinTaskService { throw new BusinessException("任务不存在"); } try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - fileResultMapper.delete(new LambdaQueryWrapper() - .eq(FileResultEntity::getTaskId, taskId) - .eq(FileResultEntity::getModuleType, MODULE_TYPE)); - fileTaskMapper.deleteById(taskId); - taskCacheService.deleteTaskCache(taskId); + inNewTransaction(() -> { + fileResultMapper.delete(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + fileTaskMapper.deleteById(taskId); + return null; + }); + taskCacheService.deleteTaskCache(taskId); } + log.info("[query-asin] deleteTask 完成 taskId={} userId={}", taskId, userId); } - @Transactional + /** + * 事务边界:任务锁在事务外获取,DB 段(复核 + 删除 + 重算)仍在同一事务内。 + */ public void deleteHistory(Long resultId, Long userId) { validateUserId(userId); FileResultEntity entity = fileResultMapper.selectById(resultId); @@ -504,12 +519,15 @@ public class QueryAsinTaskService { return; } try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - FileResultEntity latestEntity = fileResultMapper.selectById(resultId); - if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) { - throw new BusinessException("record not found"); - } - fileResultMapper.deleteById(resultId); - reconcileTaskAfterResultRemoval(taskId); + inNewTransaction(() -> { + FileResultEntity latestEntity = fileResultMapper.selectById(resultId); + if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) { + throw new BusinessException("record not found"); + } + fileResultMapper.deleteById(resultId); + reconcileTaskAfterResultRemoval(taskId); + return null; + }); } } @@ -1070,6 +1088,19 @@ public class QueryAsinTaskService { return taskDistributedLockService.acquire(MODULE_TYPE, taskId); } + /** + * 显式短事务:锁已在事务外获取,这里只包住 DB 段, + * 保证原来同属一个 @Transactional 的多条写仍在同一事务内。 + */ + private T inNewTransaction(java.util.function.Supplier action) { + if (transactionManager == null) { + return action.get(); + } + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + return template.execute(status -> action.get()); + } + private void markResultSuccess(FileResultEntity row) { row.setSuccess(RESULT_SUCCESS); row.setErrorMessage(null); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlExcelAssemblyService.java index 132aea02..832763ec 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlExcelAssemblyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlExcelAssemblyService.java @@ -87,6 +87,13 @@ public class ShopDataCrawlExcelAssemblyService { imageEmbedder.prefetch(prefetchBudget().boundedUrls(imageUrls(rowsByCountry)), imageCache); } + /** + * 模板路径组装(DOM)。历史包袱说明:本方法把整个工作簿读进 DOM(XSSFWorkbook), + * 当日累计文件越大峰值堆越高(-Xmx6g 下大客户的大文件有 OOM 风险)。 + * 同文件的 {@link #writeWorkbookStreaming} 是 SXSSF 流式版,但它**不使用模板、 + * 不写图片**(图片只兜底成 URL 文本),直接替换会改变交付文件的样式与商品图, + * 属产品取舍,未直接切换。若要落地必须实现「保留模板样式 + 保留图片」的流式写入。 + */ public int writeWorkbook(File outputXlsx, List items) { try (InputStream input = new ClassPathResource(TEMPLATE).getInputStream(); XSSFWorkbook workbook = new XSSFWorkbook(input); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskService.java index 6dca797f..42b47c62 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskService.java @@ -807,7 +807,10 @@ public class ShopDataCrawlTaskService { } } - @Transactional + /** + * 事务边界:Redis 任务锁在事务外获取(自旋等待期间不再白占一个 Hikari 连接); + * DB 段(含每日文件行锁与三条删除链路)仍在同一个短事务内,远端对象清理在提交后进行。 + */ public void deleteTask(Long taskId, Long userId) { validateUserId(userId); FileTaskEntity task = loadTaskForExecution(taskId); @@ -816,28 +819,33 @@ public class ShopDataCrawlTaskService { } ensureTaskOwnedByCurrentInstance(task, "delete shop data crawl task"); try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - List taskRows = listTaskRows(taskId); - try (DailyLockSet dailyLocks = acquireDailyLocks(taskRows)) { - ensureDailySyncCompletedBeforeDelete(taskRows); - Set removedResultIds = collectResultIds(taskRows); - // A task deletion is only a frontend task-record cleanup. The daily - // workbook is an independent backend aggregate and must not roll - // back when its source task is removed. - DailyDeletionResult dailyResult = preserveDailyForTaskDeletion(removedResultIds); - registerUploadedObjectRollback(dailyResult.uploadedObjectKeys()); - List resultFileUrls = collectResultFileUrls(taskRows, dailyResult.obsoleteObjectKeys()); - fileResultMapper.delete(new LambdaQueryWrapper() - .eq(FileResultEntity::getTaskId, taskId) - .eq(FileResultEntity::getModuleType, MODULE_TYPE)); - taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE); - taskResultItemService.deleteTaskItems(taskId, MODULE_TYPE); - taskProgressSnapshotService.delete(taskId, MODULE_TYPE); - fileTaskMapper.deleteById(taskId); - taskCacheService.deleteTaskCache(taskId); - deleteTransientResultChunks(taskId); - resultFileUrls.forEach(this::deleteResultObjectIfUnreferenced); - } + executeShortTransaction(() -> { + List taskRows = listTaskRows(taskId); + try (DailyLockSet dailyLocks = acquireDailyLocks(taskRows)) { + ensureDailySyncCompletedBeforeDelete(taskRows); + Set removedResultIds = collectResultIds(taskRows); + // A task deletion is only a frontend task-record cleanup. The daily + // workbook is an independent backend aggregate and must not roll + // back when its source task is removed. + DailyDeletionResult dailyResult = preserveDailyForTaskDeletion(removedResultIds); + registerUploadedObjectRollback(dailyResult.uploadedObjectKeys()); + List resultFileUrls = collectResultFileUrls(taskRows, dailyResult.obsoleteObjectKeys()); + fileResultMapper.delete(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE); + taskResultItemService.deleteTaskItems(taskId, MODULE_TYPE); + taskProgressSnapshotService.delete(taskId, MODULE_TYPE); + fileTaskMapper.deleteById(taskId); + deleteTransientResultChunks(taskId); + resultFileUrls.forEach(this::deleteResultObjectIfUnreferenced); + } + return null; + }); + // 缓存清理(Redis + 本地缓存)在事务提交后、锁内执行 + taskCacheService.deleteTaskCache(taskId); } + log.info("[shop-data-crawl] deleteTask 完成 taskId={} userId={}", taskId, userId); } /** 删除任务的纯计算:收集有效结果行 id 集合(过滤 null/非正数),无副作用。 */ @@ -884,7 +892,10 @@ public class ShopDataCrawlTaskService { } } - @Transactional + /** + * 事务边界:任务锁在事务外获取;DB 段(复核 + 删行 + 每日文件重建)在同一个短事务内, + * 远端对象清理已由 deleteResultObjectIfUnreferenced 在提交后执行。 + */ public void deleteHistory(Long resultId, Long userId) { validateUserId(userId); FileResultEntity entity = requireResultEntity(resultId); @@ -899,21 +910,26 @@ public class ShopDataCrawlTaskService { throw new BusinessException(BusinessCodes.TASK_BUSY, "任务仍在处理中,不能删除"); } try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - FileResultEntity latestEntity = fileResultMapper.selectById(resultId); - if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) { - throw new BusinessException("记录不存在"); - } - try (DailyLockSet dailyLocks = acquireDailyLocks(List.of(latestEntity))) { - deleteResultHistoryRow(latestEntity); - } + executeShortTransaction(() -> { + FileResultEntity latestEntity = fileResultMapper.selectById(resultId); + if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) { + throw new BusinessException("记录不存在"); + } + try (DailyLockSet dailyLocks = acquireDailyLocks(List.of(latestEntity))) { + deleteResultHistoryRow(latestEntity); + } + return null; + }); } } /** * Deletes an administrative result without accepting a caller-controlled owner ID. * The controller that calls this method is restricted to the Flask-to-Java internal channel. + * + *

不加 @Transactional:内部调用 deleteHistory 时事务代理不生效(同类自调用), + * 锁会落在本方法开启的事务里 —— 事务边界统一由 deleteHistory 自己收口。 */ - @Transactional public void deleteAdminHistory(Long resultId) { FileResultEntity entity = requireResultEntity(resultId); Long ownerId = entity.getUserId(); @@ -2620,6 +2636,13 @@ public class ShopDataCrawlTaskService { taskCacheService.deleteTaskCache(taskId); } + /** + * 删除结果分片行。 + * + *

事务边界:远端载荷删除移到事务提交后执行 —— 单次删除超时 120s、重试 3 次, + * 放在 DB 事务里会让连接被长时间占用(项目在 TaskScopePayloadStorageService 已写明该约束)。 + * 提交后再删还有个好处:引用计数查询看到的是删除完成后的 DB 状态,判断更准确。 + */ private void deleteTransientResultChunks(Long taskId) { if (taskId == null || taskId <= 0) { return; @@ -2628,9 +2651,12 @@ public class ShopDataCrawlTaskService { .select(TaskChunkEntity::getPayloadJson) .eq(TaskChunkEntity::getTaskId, taskId) .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + List payloads = new ArrayList<>(); if (chunks != null) { for (TaskChunkEntity chunk : chunks) { - transientPayloadStorageService.deletePayloadIfPresent(chunk.getPayloadJson()); + if (chunk != null && !blank(chunk.getPayloadJson())) { + payloads.add(chunk.getPayloadJson()); + } } } taskChunkMapper.delete(new LambdaQueryWrapper() @@ -2640,6 +2666,34 @@ public class ShopDataCrawlTaskService { .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) .likeRight(TaskScopeStateEntity::getScopeKey, RESULT_CHUNK_SCOPE_PREFIX)); + deleteChunkPayloadsAfterCommit(payloads, taskId); + } + + /** 事务提交后逐条删远端载荷;无事务时立即执行(与 TaskResultItemService 同款兜底)。 */ + private void deleteChunkPayloadsAfterCommit(List payloads, Long taskId) { + if (payloads == null || payloads.isEmpty()) { + return; + } + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + deleteChunkPayloadsNow(payloads, taskId); + } + }); + return; + } + deleteChunkPayloadsNow(payloads, taskId); + } + + private void deleteChunkPayloadsNow(List payloads, Long taskId) { + for (String payload : payloads) { + try { + transientPayloadStorageService.deletePayloadIfPresent(payload); + } catch (Exception ex) { + log.warn("[shop-data-crawl] 分片载荷删除失败 taskId={} msg={}", taskId, safeMessage(ex)); + } + } } private void cleanupResultChunksQuietly(Long taskId, String reason) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java index c12b0391..5c9c2b34 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java @@ -46,6 +46,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; import java.io.File; import java.time.LocalDateTime; @@ -89,6 +91,10 @@ public class ShopMatchTaskService { private final SkipPriceAsinService skipPriceAsinService; private final QueryAsinMapper queryAsinMapper; private final TaskProgressLightAssembler taskProgressLightAssembler; + /** + * 事务边界收口用:Redis 任务锁必须在事务外获取,否则自旋等待期间会白占一个 Hikari 连接。 + */ + private final org.springframework.transaction.PlatformTransactionManager transactionManager; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = shopMatchTaskCacheService.getTaskCacheBatch(List.of(taskId)); @@ -280,7 +286,9 @@ public class ShopMatchTaskService { return vo; } - @Transactional + /** + * 事务边界:任务锁在事务外获取(自旋等待不再占用 DB 连接),DB 段仍在同一事务内。 + */ public void deleteHistory(Long resultId, Long userId) { if (userId == null || userId <= 0) { throw new BusinessException("user_id 不合法"); @@ -295,16 +303,21 @@ public class ShopMatchTaskService { return; } try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - FileResultEntity latestEntity = fileResultMapper.selectById(resultId); - if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) { - throw new BusinessException("record not found"); - } - fileResultMapper.deleteById(resultId); - reconcileTaskAfterResultRemoval(taskId); + inNewTransaction(() -> { + FileResultEntity latestEntity = fileResultMapper.selectById(resultId); + if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) { + throw new BusinessException("record not found"); + } + fileResultMapper.deleteById(resultId); + reconcileTaskAfterResultRemoval(taskId); + return null; + }); } } - @Transactional + /** + * 事务边界:任务锁在事务外获取;缓存清理(Redis + 远端范围载荷)放到事务提交后。 + */ public void deleteTask(Long taskId, Long userId) { if (userId == null || userId <= 0) { throw new BusinessException("user_id 不合法"); @@ -314,12 +327,17 @@ public class ShopMatchTaskService { throw new BusinessException("任务不存在"); } try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - fileResultMapper.delete(new LambdaQueryWrapper() - .eq(FileResultEntity::getTaskId, taskId) - .eq(FileResultEntity::getModuleType, MODULE_TYPE)); - fileTaskMapper.deleteById(taskId); + inNewTransaction(() -> { + fileResultMapper.delete(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + fileTaskMapper.deleteById(taskId); + return null; + }); + // 缓存清理(Redis + 远端范围载荷)在事务提交后、锁内执行 shopMatchTaskCacheService.deleteTaskCache(taskId); } + log.info("[shop-match] deleteTask 完成 taskId={} userId={}", taskId, userId); } private void reconcileTaskAfterResultRemoval(Long taskId) { @@ -500,7 +518,9 @@ public class ShopMatchTaskService { return vo; } - @Transactional + /** + * 事务边界:任务锁在事务外获取;心跳(Redis)放到事务提交后写。 + */ public void activateTask(Long taskId, Long userId, Integer stageIndex) { if (taskId == null || taskId <= 0) { throw new BusinessException("taskId 不合法"); @@ -512,45 +532,53 @@ public class ShopMatchTaskService { throw new BusinessException("stage_index 不合法"); } try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - FileTaskEntity task = loadTaskForExecution(taskId); - if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) { - throw new BusinessException("任务不存在"); - } - if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) { - throw new BusinessException("任务状态不正确"); - } - if (!"SCHEDULED".equals(task.getStatus())) { - throw new BusinessException("任务状态不正确"); - } - LocalDateTime now = now(); - if (task.getScheduledAt() != null && now.isBefore(task.getScheduledAt().minusSeconds(90))) { - log.warn("[shop-match] activate rejected taskId={} stageIndex={} now={} scheduledAt={} earliestActivateAt={}", - taskId, stageIndex, now, task.getScheduledAt(), task.getScheduledAt().minusSeconds(90)); - throw new BusinessException("未到定时执行时间"); - } - ShopMatchCreateTaskRequest state = parseTaskRequest(task); - int currentStageIndex = state.getCurrentStageIndex() == null ? 0 : state.getCurrentStageIndex(); - if (currentStageIndex != stageIndex) { - throw new BusinessException("当前轮次已变化,请刷新后重试"); - } - if (state.getScheduleTimes() == null || stageIndex >= state.getScheduleTimes().size()) { - throw new BusinessException("轮次配置缺失"); - } - FileTaskEntity runningTask = findOtherRunningTask(userId, taskId); - if (runningTask != null) { - log.warn("[shop-match] activate serialized taskId={} stageIndex={} blockedByTaskId={} blockedUpdatedAt={}", - taskId, stageIndex, runningTask.getId(), runningTask.getUpdatedAt()); - throw new BusinessException("当前已有定时匹配任务执行中,请等待上一任务执行结束后再重试"); - } - state.setActiveStageIndex(stageIndex); - task.setStatus("RUNNING"); - task.setUpdatedAt(now); - persistTaskRequest(task, state); - updateTaskAndRefreshCache(task); + inNewTransaction(() -> { + activateTaskLocked(taskId, userId, stageIndex); + return null; + }); + // 心跳是 Redis 写,放到事务提交、锁内执行(与改前一样仍在锁内) shopMatchTaskCacheService.touchTaskHeartbeat(taskId); } } + private void activateTaskLocked(Long taskId, Long userId, Integer stageIndex) { + FileTaskEntity task = loadTaskForExecution(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) { + throw new BusinessException("任务不存在"); + } + if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) { + throw new BusinessException("任务状态不正确"); + } + if (!"SCHEDULED".equals(task.getStatus())) { + throw new BusinessException("任务状态不正确"); + } + LocalDateTime now = now(); + if (task.getScheduledAt() != null && now.isBefore(task.getScheduledAt().minusSeconds(90))) { + log.warn("[shop-match] activate rejected taskId={} stageIndex={} now={} scheduledAt={} earliestActivateAt={}", + taskId, stageIndex, now, task.getScheduledAt(), task.getScheduledAt().minusSeconds(90)); + throw new BusinessException("未到定时执行时间"); + } + ShopMatchCreateTaskRequest state = parseTaskRequest(task); + int currentStageIndex = state.getCurrentStageIndex() == null ? 0 : state.getCurrentStageIndex(); + if (currentStageIndex != stageIndex) { + throw new BusinessException("当前轮次已变化,请刷新后重试"); + } + if (state.getScheduleTimes() == null || stageIndex >= state.getScheduleTimes().size()) { + throw new BusinessException("轮次配置缺失"); + } + FileTaskEntity runningTask = findOtherRunningTask(userId, taskId); + if (runningTask != null) { + log.warn("[shop-match] activate serialized taskId={} stageIndex={} blockedByTaskId={} blockedUpdatedAt={}", + taskId, stageIndex, runningTask.getId(), runningTask.getUpdatedAt()); + throw new BusinessException("当前已有定时匹配任务执行中,请等待上一任务执行结束后再重试"); + } + state.setActiveStageIndex(stageIndex); + task.setStatus("RUNNING"); + task.setUpdatedAt(now); + persistTaskRequest(task, state); + updateTaskAndRefreshCache(task); + } + @Transactional public void completeStage(Long taskId, Long userId, ShopMatchStageCompleteRequest request) { if (taskId == null || taskId <= 0) { @@ -960,6 +988,19 @@ public class ShopMatchTaskService { return taskDistributedLockService.acquire(MODULE_TYPE, taskId); } + /** + * 显式短事务:锁已在事务外获取,这里只包住 DB 段, + * 保证原来同属一个 @Transactional 的多条写仍在同一事务内。 + */ + private T inNewTransaction(java.util.function.Supplier action) { + if (transactionManager == null) { + return action.get(); + } + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + return template.execute(status -> action.get()); + } + private boolean shouldRemainRunningUntilNextStage(FileTaskEntity task) { ShopMatchCreateTaskRequest request = parseTaskRequestSilently(task); if (request == null || request.getScheduleTimes() == null || request.getScheduleTimes().isEmpty()) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index 4d5e5c54..7bf52ee3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -526,7 +526,12 @@ public class SimilarAsinTaskService implements SimilarAsinPipelineHost { return vo; } - @Transactional + /** + * 启动任务(PENDING→RUNNING)。 + * + *

事务边界:Redis 任务锁必须在事务外获取(自旋等待期间会白占一个 Hikari 连接); + * 本方法只有一条条件 UPDATE(自身即原子),故不再包事务。 + */ public void activateTask(Long taskId, Long userId) { try (TaskDistributedLockService.LockHandle ignored = ownershipSupport().requireTaskLock(taskId, TASK_LOCK_WAIT_MILLIS)) { FileTaskEntity task = fileTaskMapper.selectById(taskId); @@ -1323,7 +1328,12 @@ public class SimilarAsinTaskService implements SimilarAsinPipelineHost { } } - @Transactional + /** + * 结果文件 job 失败的收尾。 + * + *

事务边界:任务锁在事务外获取;DB 段本来就走 {@code inNewTransaction}(REQUIRES_NEW), + * 方法级 @Transactional 只会额外占一个空闲连接,故去掉。 + */ public void handleResultFileJobFailure(TaskFileJobEntity job, String message) { if (job == null || job.getTaskId() == null) { return; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinPipelineHost.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinPipelineHost.java index ff3580f0..15c7bada 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinPipelineHost.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinPipelineHost.java @@ -9,7 +9,8 @@ import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; *

* 采用依赖倒置:由宿主(SimilarAsinTaskService)实现本接口,流水线只依赖接口, * 避免 support 包与 Service 之间形成循环依赖。这些方法保留在宿主的两个原因: - * 一是属于任务/结果文件任务的编排与事务边界({@code handleResultFileJobFailure} 带 @Transactional), + * 一是属于任务/结果文件任务的编排与事务边界({@code handleResultFileJobFailure} 先取任务锁、 + * 再用 {@code inNewTransaction} 显式包 DB 段 —— 锁必须在事务外获取,故不再挂方法级 @Transactional), * 二是被宿主自身的门面路径复用。 */ public interface SimilarAsinPipelineHost { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java index 338c6dc6..a7e9f0d8 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java @@ -139,10 +139,20 @@ public class TaskResultFileJobWorker { taskQueueExecutor.execute(() -> processClaimedWithHeartbeat(job, claim)); return; } catch (RuntimeException ex) { - log.warn("[task-file-job] llm module offload failed, fallback inline jobId={} taskId={} moduleType={} msg={}", + // 闸门拒绝(等待队列已满)说明系统已满载:此时**不能**回退成内联执行—— + // 那等于把背压转嫁给 MQ 消费线程,让消费线程按分钟级阻塞在 Excel 组装上, + // 队列越满越糟。改为重新入队,由归属实例 15s 一轮的扫描补发。 + log.warn("[task-file-job] 结果文件任务队列已满,改为重新入队 jobId={} taskId={} moduleType={} msg={} stage=REQUEUE", job.getId(), job.getTaskId(), job.getModuleType(), ex.getMessage(), ex); + taskFileJobService.requeue(job.getId(), "结果文件任务队列已满,稍后重投"); + return; } } + // 注意:supportsAsyncOffload()=false 的模块(11 个)仍在本方法调用方的线程上 + // 内联执行,MQ 侧实际并发度 = consumeThreadNumber 默认值 20。该并发度直接决定 + // 同时在跑的 Excel 组装数量,而每一路都持有 DB 连接(Hikari 池仅 30,还要供 + // Tomcat 200 线程与调度线程使用)。若要收敛,应显式设置 + // RocketMQMessageListener.consumeThreadNumber 并先测吞吐,不要在这里临时改。 processClaimedWithHeartbeat(job, claim); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java index 12ba620e..5e379b06 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java @@ -89,6 +89,14 @@ public class TaskResultItemService { return moduleType + "|" + scopeHash + "|" + itemKey; } + /** + * 载入本任务的**全部**结果快照(无 limit)。 + * + *

刻意不加 limit:调用方要用它组装完整的结果文件,截断即漏行(正确性错误, + * 不是性能取舍)。调用方多为「逐任务循环调用」,因此峰值内存 = 单个任务的结果集, + * 这是本操作固有的代价。若要再降,需要改成「边读边写文件」的流式组装契约, + * 涉及 4 个模块的组装实现,属独立工程。 + */ public List listResultSnapshots(Long taskId, String moduleType, Class clazz) { if (taskId == null || taskId <= 0 || isBlank(moduleType)) { return List.of(); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadStorageService.java index 2dd727fa..5ca488c3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadStorageService.java @@ -517,11 +517,8 @@ public class TaskScopePayloadStorageService { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] bytes = digest.digest(normalize(value).getBytes(StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(bytes.length * 2); - for (byte b : bytes) { - sb.append(String.format("%02x", b)); - } - return sb.toString(); + // HexFormat 替代逐字节 String.format("%02x"):后者每次走 32 次 Formatter(热路径按行) + return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("failed to hash scope key", ex); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskService.java index 1dac9d3c..88fc0f0d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskService.java @@ -35,6 +35,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; import java.io.File; import java.math.BigDecimal; @@ -79,6 +81,10 @@ public class WithdrawTaskService { private final TaskProgressSnapshotService taskProgressSnapshotService; private final TaskDistributedLockService taskDistributedLockService; private final TaskProgressLightAssembler taskProgressLightAssembler; + /** + * 事务边界收口用:Redis 任务锁必须在事务外获取,否则自旋等待期间会白占一个 Hikari 连接。 + */ + private final org.springframework.transaction.PlatformTransactionManager transactionManager; public ProductRiskDashboardVo dashboard(Long userId) { validateUserId(userId); @@ -347,7 +353,10 @@ public class WithdrawTaskService { } } - @Transactional + /** + * 事务边界:任务锁在事务外获取(自旋等待不再占用 DB 连接); + * 缓存清理(Redis + 远端范围载荷)放到事务提交后、锁内执行。 + */ public void deleteTask(Long taskId, Long userId) { validateUserId(userId); FileTaskEntity task = loadTaskForExecution(taskId); @@ -355,18 +364,24 @@ public class WithdrawTaskService { throw new BusinessException("任务不存在"); } try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - fileResultMapper.delete(new LambdaQueryWrapper() - .eq(FileResultEntity::getTaskId, taskId) - .eq(FileResultEntity::getModuleType, MODULE_TYPE)); - fileTaskMapper.deleteById(taskId); - taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE); - taskResultItemService.deleteTaskItems(taskId, MODULE_TYPE); - taskProgressSnapshotService.delete(taskId, MODULE_TYPE); + inNewTransaction(() -> { + fileResultMapper.delete(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + fileTaskMapper.deleteById(taskId); + taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE); + taskResultItemService.deleteTaskItems(taskId, MODULE_TYPE); + taskProgressSnapshotService.delete(taskId, MODULE_TYPE); + return null; + }); taskCacheService.deleteTaskCache(taskId); } + log.info("[withdraw] deleteTask 完成 taskId={} userId={}", taskId, userId); } - @Transactional + /** + * 事务边界:任务锁在事务外获取,DB 段(复核 + 删除 + 重算)仍在同一事务内。 + */ public void deleteHistory(Long resultId, Long userId) { validateUserId(userId); FileResultEntity entity = fileResultMapper.selectById(resultId); @@ -379,10 +394,13 @@ public class WithdrawTaskService { return; } try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { - fileResultMapper.deleteById(resultId); - taskFileJobService.deleteResultJobs(taskId, MODULE_TYPE, resultId); - taskResultItemService.deleteResultItem(taskId, MODULE_TYPE, resultId); - reconcileTaskAfterResultRemoval(taskId); + inNewTransaction(() -> { + fileResultMapper.deleteById(resultId); + taskFileJobService.deleteResultJobs(taskId, MODULE_TYPE, resultId); + taskResultItemService.deleteResultItem(taskId, MODULE_TYPE, resultId); + reconcileTaskAfterResultRemoval(taskId); + return null; + }); } } @@ -1050,6 +1068,19 @@ public class WithdrawTaskService { return taskDistributedLockService.acquire(MODULE_TYPE, taskId); } + /** + * 显式短事务:锁已在事务外获取,这里只包住 DB 段, + * 保证原来同属一个 @Transactional 的多条写仍在同一事务内。 + */ + private T inNewTransaction(java.util.function.Supplier action) { + if (transactionManager == null) { + return action.get(); + } + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + return template.execute(status -> action.get()); + } + private List normalizeTaskIds(List taskIds) { return taskIds == null ? List.of() : taskIds.stream() .filter(taskId -> taskId != null && taskId > 0) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java index 5b252974..1ca4dcf6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/client/ZiniaoClientImpl.java @@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.ziniao.client; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.common.util.BoundedLruCache; import com.nanri.aiimage.config.ZiniaoProperties; import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo; @@ -59,8 +60,13 @@ public class ZiniaoClientImpl implements ZiniaoClient { /** Task 77:单例 RestClient(共享连接池),避免每次调用新建短命客户端。 */ private volatile RestClient sharedRestClient; - /** 按代理地址缓存 RestClient:同一代理复用同一连接池,避免每请求新建。 */ - private final Map proxyRestClients = new java.util.concurrent.ConcurrentHashMap<>(); + /** + * 按代理地址缓存 RestClient:同一代理复用同一连接池,避免每请求新建。 + * 有界 LRU——代理端点会随店铺轮换不断新增(每个都持有连接池与 selector 线程), + * 无上限时按店铺数持续累积。 + */ + private final BoundedLruCache proxyRestClients = + new BoundedLruCache<>(BoundedLruCache.DEFAULT_MAX_SIZE); @Override public Long getCompanyIdByApiKey(String apiKey) { @@ -124,6 +130,15 @@ public class ZiniaoClientImpl implements ZiniaoClient { return parseUserStores(raw); } + /** + * 获取员工登录 token。 + * + *

**刻意不加缓存**(2026-09-15 复核):调用方 {@code ZiniaoAuthService} 命中店铺匹配缓存时 + * 仍会用**新取的** token 重建开店铺 URL(见其 getCachedShopMatch 分支只缓存匹配结果、 + * openStoreUrl 存 null 后重算),说明该 token 是「一次开店铺一次」的短时凭据, + * 缓存后会把已被消费的 token 发出去,用户点开店铺会失败。上游往返只为单次用户动作 + * 服务,不是吞吐瓶颈,不要为省一次 RTT 引入这个正确性风险。 + */ @Override public String getUserLoginToken(String apiKey, Long companyId, Long userId) { String raw = postWithApiKey(apiKey, ziniaoProperties.getUserLoginTokenPath(), Map.of( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java index f2e6a203..4cc5febb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoAuthService.java @@ -539,11 +539,8 @@ public boolean isIpWhitelistError(BusinessException ex) { try { java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(apiKey.getBytes(StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(); - for (byte b : hash) { - sb.append(String.format("%02x", b)); - } - return sb.toString(); + // HexFormat 替代逐字节 String.format("%02x"):后者每次走 32 次 Formatter(热路径按调用) + return java.util.HexFormat.of().formatHex(hash); } catch (Exception ex) { throw new BusinessException("生成紫鸟 apiKey 缓存键失败"); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java index 80f9b237..edc52dbc 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java @@ -937,11 +937,8 @@ public class ZiniaoShopIndexService { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(apiKey.getBytes(StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(); - for (byte b : hash) { - sb.append(String.format("%02x", b)); - } - return sb.toString(); + // HexFormat 替代逐字节 String.format("%02x"):后者每次走 32 次 Formatter(热路径按调用) + return java.util.HexFormat.of().formatHex(hash); } catch (Exception ex) { throw new BusinessException("生成紫鸟 apiKey 缓存键失败"); } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java index 12d596f6..ba6751cd 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java @@ -9,6 +9,10 @@ import com.nanri.aiimage.modules.imagevideo.mapper.ImageVideoAsyncTaskMapper; import com.nanri.aiimage.modules.imagevideo.model.dto.DouyinCopyRequest; import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoAsyncTaskEntity; import com.nanri.aiimage.modules.imagevideo.model.vo.DouyinCopyVo; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.springframework.core.task.TaskExecutor; import org.mockito.ArgumentCaptor; @@ -29,6 +33,18 @@ import static org.mockito.Mockito.when; class ImageVideoAsyncTaskServiceTest { + /** + * MyBatis-Plus 的 lambda 列解析需要实体的 TableInfo。本类用 mock mapper、不启动 Spring, + * 而 dispatch/poll 扫描现在走 select(ImageVideoAsyncTaskEntity::getId)(2026-09 + * 「不再每秒把 5 个 LONGTEXT 列拉回」优化引入),没有 TableInfo 会抛 + * 「can not find lambda cache」。同款初始化见 AppearancePatentTaskServiceHistoryBatchTest。 + */ + @BeforeAll + static void initMybatisPlusTableInfo() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + ImageVideoAsyncTaskEntity.class); + } + @Test void parsesNestedDouyinCopyOutputIntoFrontendTextFields() { ImageVideoCozeService cozeService = new ImageVideoCozeService( diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java index b9aac381..96e9380d 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java @@ -13,6 +13,10 @@ import com.nanri.aiimage.modules.permission.model.entity.UserColumnPermissionEnt import com.nanri.aiimage.modules.permission.model.vo.ImageVideoDataPermissionUserVo; import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo; import com.nanri.aiimage.modules.permission.model.vo.UserColumnIdsVo; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -30,6 +34,18 @@ import static org.mockito.Mockito.when; class PermissionMenuServiceTest { + /** + * MyBatis-Plus 的 lambda 列解析需要实体的 TableInfo。本类用 mock mapper、不启动 Spring, + * 而 PermissionMenuService 的权限用户查询走 select(AdminUserEntity::getId, ...)(2026-09 + * 「不再整表拉回含密码哈希的全列」优化引入),没有 TableInfo 会在构造 wrapper 时抛 + * 「can not find lambda cache」。同款初始化见 AppearancePatentTaskServiceHistoryBatchTest。 + */ + @BeforeAll + static void initMybatisPlusTableInfo() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + AdminUserEntity.class); + } + @Test void preservesExistingImageVideoPermissionDuringGenericReplacement() { PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinSnapshotJsonThrottleTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinSnapshotJsonThrottleTest.java index f28a5953..bafa07bc 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinSnapshotJsonThrottleTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinSnapshotJsonThrottleTest.java @@ -36,10 +36,11 @@ class QueryAsinSnapshotJsonThrottleTest { private QueryAsinTaskService service() { // 构造顺序:mapper/resultMapper/resolve/excel/cache/oss/resultDownloadResolver/ziniaoSwitch/ - // objectMapper/pressure/jobService/resultItemService/snapshot/lock/lightAssembler + // objectMapper/pressure/jobService/resultItemService/snapshot/lock/lightAssembler/transactionManager + // transactionManager 传 null:本用例只走 persistSnapshotJson,不涉及需要事务的删除链路 return new QueryAsinTaskService( null, null, null, null, null, null, null, null, - objectMapper, null, null, taskResultItemService, taskProgressSnapshotService, null, null); + objectMapper, null, null, taskResultItemService, taskProgressSnapshotService, null, null, null); } private static void persist(QueryAsinTaskService service, FileTaskEntity task, diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskServiceTxBoundaryTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskServiceTxBoundaryTest.java index a5897b06..1d3809ab 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskServiceTxBoundaryTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskServiceTxBoundaryTest.java @@ -31,6 +31,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; @@ -49,6 +50,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -196,11 +198,38 @@ class ShopDataCrawlTaskServiceTxBoundaryTest { verify(fileResultMapper).updateById(any(FileResultEntity.class)); } + /** + * 事务边界契约(2026-09 连接池修复):deleteTask 不再挂方法级 @Transactional —— + * Redis 任务锁必须在事务外获取,否则自旋等待(最长 TASK_LOCK_WAIT_MILLIS)期间会白占一个 + * Hikari 连接。落库删除仍在同一个短事务(executeShortTransaction / REQUIRES_NEW)里完成, + * 且顺序固定为「先取锁、后开事务、再落库删除」。 + */ @Test - void deleteTaskKeepsTransactionAnnotationAndPureCompute() throws Exception { + void deleteTaskKeepsLockOutsideAndWritesInsideSingleShortTransaction() throws Exception { Method delete = ShopDataCrawlTaskService.class.getMethod("deleteTask", Long.class, Long.class); - assertTrue(delete.getAnnotation(Transactional.class) != null, - "deleteTask 落库删除必须保留 @Transactional(锁内删除语义不变)"); + assertNull(delete.getAnnotation(Transactional.class), + "deleteTask 不得带 @Transactional(Redis 锁必须落在事务外,避免自旋期间占用 DB 连接)"); + + FileTaskEntity task = runningTask(); + when(fileTaskMapper.selectById(TASK_ID)).thenReturn(task); + FileResultEntity row = resultRow(1L); + row.setSuccess(1); + when(fileResultMapper.selectList(any())).thenReturn(List.of(row)); + when(taskDistributedLockService.acquire("SHOP_DATA_CRAWL", TASK_ID)) + .thenReturn(mock(TaskDistributedLockService.LockHandle.class)); + when(taskFileJobService.hasSuccessfulAssembleJob(TASK_ID, "SHOP_DATA_CRAWL", 1L)).thenReturn(true); + when(dailyFileService.findMembersByResultId(1L)) + .thenReturn(List.of(mock(com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyMemberEntity.class))); + + service.deleteTask(TASK_ID, USER_ID); + + InOrder order = inOrder(taskDistributedLockService, transactionManager, fileResultMapper); + order.verify(taskDistributedLockService).acquire("SHOP_DATA_CRAWL", TASK_ID); + order.verify(transactionManager).getTransaction(any()); + order.verify(fileResultMapper).delete(any()); + // 全部落库删除共用一个短事务 + verify(transactionManager, times(1)).getTransaction(any()); + verify(fileTaskMapper).deleteById(TASK_ID); // 删除路径使用抽出的纯函数(行为等价由既有 ShopDataCrawlCleanupTest 守门) assertTrue(ShopDataCrawlTaskService.collectResultIds(List.of()).isEmpty()); } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOffloadTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOffloadTest.java index c908d5e3..5b09e5e3 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOffloadTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOffloadTest.java @@ -167,8 +167,15 @@ class TaskResultFileJobWorkerOffloadTest { verify(similar).cleanupResultFileJob(job); } + /** + * 闸门拒绝时的契约(2026-09 背压修复):**不再回退成内联执行**。 + * + * 内联兜底会把背压转嫁给 MQ 消费线程——消费线程按分钟级阻塞在 Excel 组装 + OSS 上传上, + * 队列越满越糟。改为重新入队(置回 PENDING),由归属实例 15s 一轮的扫描补发; + * 任务不会丢,只是不在消费线程上抢跑。原用例名 offloadFailureFallback 断言的正是旧行为。 + */ @Test - void offloadFailureFallback() throws Exception { + void offloadRejectedRequeuesInsteadOfInlineFallback() throws Exception { TaskResultFileJobWorker worker = buildWorker(); TaskFileJobEntity job = job("SIMILAR_ASIN", 4L, 14L); allowClaim(job.getId(), job.getTaskId(), job.getModuleType()); @@ -178,8 +185,9 @@ class TaskResultFileJobWorkerOffloadTest { worker.process(job); - verify(taskFileJobService).markSuccess(job, null); - verify(similar).cleanupResultFileJob(job); + verify(taskFileJobService).requeue(job.getId(), "结果文件任务队列已满,稍后重投"); + verify(taskFileJobService, never()).markSuccess(any(), any()); + verify(similar, never()).processResultFileJob(any()); } @Test diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOrphanTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOrphanTest.java index e82c2aa9..88546c59 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOrphanTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOrphanTest.java @@ -90,13 +90,21 @@ class TaskResultFileJobWorkerOrphanTest { new BrandResultFileJobHandler(brand, payload), new CollectDataResultFileJobHandler(collectData)); ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers); - return new TaskResultFileJobWorker( + TaskResultFileJobWorker worker = new TaskResultFileJobWorker( taskFileJobService, taskDistributedLockService, mock(FileResultMapper.class), mock(TaskFileJobLocalDispatcher.class), instanceMetadata, registry); + // taskQueueExecutor 是字段注入。此前不注入时字段为 null,offload 分支会抛 NPE 并被 + // 兜底成内联执行——本测试一直依赖这个偶然行为。2026-09 起闸门拒绝改为「重新入队」 + // 而不再回退内联,故这里显式注入同步执行器,让 offload 模块仍按内联语义跑完整链路。 + java.lang.reflect.Field executorField = + TaskResultFileJobWorker.class.getDeclaredField("taskQueueExecutor"); + executorField.setAccessible(true); + executorField.set(worker, (org.springframework.core.task.TaskExecutor) Runnable::run); + return worker; } private static TaskFileJobEntity job(String moduleType, long jobId, long taskId) { diff --git a/backend/app.py b/backend/app.py index e1ff07d8..5189906a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -11,7 +11,7 @@ import secrets from flask import Flask from flask_cors import CORS -from utils.db import init_db +from utils.db import close_db, init_db from blueprints.version import version_bp BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -36,6 +36,9 @@ app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32)) # 注册蓝图:仅版本公开 API(管理后台 API 已迁 Java,见模块 docstring) app.register_blueprint(version_bp) +# 请求级数据库连接随请求结束释放(utils/db.get_db 复用同一连接) +app.teardown_appcontext(close_db) + def run_app(host='0.0.0.0', port=15124): init_db() diff --git a/backend/utils/db.py b/backend/utils/db.py index 13f03156..7083738f 100644 --- a/backend/utils/db.py +++ b/backend/utils/db.py @@ -1,9 +1,26 @@ """ 数据库连接与初始化 + +请求级连接复用:pymysql 每次 connect 都要做一次完整的鉴权握手,而本进程 +(15124)只服务 /api/version、/api/version/latest 这类轻量查询,握手开销 +占了单次请求的大头。连接因此挂在 flask.g 上按请求复用,请求结束由 +close_db(注册在 app.teardown_appcontext)统一释放。 """ import re + import pymysql +try: + from flask import g, has_app_context +except ImportError: # 非 Flask 环境(脚本/离线核查)下退化为每次新建连接 + g = None + + def has_app_context() -> bool: + return False + +# 请求级连接挂在 flask.g 上的属性名(避免与业务字段冲突) +_CONN_ATTR = "_shufu_db_conn" + try: from config import mysql_host as config_mysql_host from config import mysql_user as config_mysql_user @@ -44,7 +61,7 @@ def describe_db_target(): ) -def get_db(): +def _connect(): return pymysql.connect( host=mysql_host, user=mysql_user, @@ -55,6 +72,73 @@ def get_db(): ) +def _is_alive(conn) -> bool: + """连接可用性探测:已关闭直接判死,否则发一次 COM_PING。 + + COM_PING 一次往返远小于一次完整鉴权握手;不用 ping(reconnect=True) + (该参数在新版 pymysql 已废弃,且失败时会自行重连、语义不可控)。 + """ + try: + if not conn.open: + return False + conn.ping(reconnect=False) + return True + except Exception: + return False + + +def _close_quietly(conn) -> None: + """静默关闭连接(可能为 None / 已关闭 / 断连)。""" + if conn is None: + return + try: + conn.close() + except Exception: + pass + + +def get_db(): + """取当前请求的数据库连接(同一请求内复用,避免每次请求都做鉴权握手)。 + + 连接挂在 flask.g 上,请求结束由 close_db 释放;被服务端 wait_timeout + 断开、或调用方提前 close 过,这里都会静默换成新连接,调用方无感知。 + 无 Flask 应用上下文时(脚本调用)退回每次新建,保持旧行为。 + """ + ctx = g if has_app_context() else None + conn = getattr(ctx, _CONN_ATTR, None) if ctx is not None else None + if conn is not None: + if _is_alive(conn): + return conn + # 断连/已被关闭:丢弃旧连接后重建(不把坏连接交给业务查询) + _close_quietly(conn) + try: + delattr(ctx, _CONN_ATTR) + except Exception: + pass + conn = _connect() + if ctx is not None: + setattr(ctx, _CONN_ATTR, conn) + return conn + + +def close_db(exc=None) -> None: + """释放当前请求的连接(注册为 app.teardown_appcontext 回调)。 + + 异常路径同样会走 teardown,因此查询抛错时连接不会泄漏到下一次请求。 + """ + ctx = g if has_app_context() else None + if ctx is None: + return + conn = getattr(ctx, _CONN_ATTR, None) + if conn is None: + return + try: + delattr(ctx, _CONN_ATTR) + except Exception: + pass + _close_quietly(conn) + + def init_db(): """确保版本公开 API 依赖的最小表结构存在。 diff --git a/frontend-vue/src/pages/brand/components/BrandBrandTab.vue b/frontend-vue/src/pages/brand/components/BrandBrandTab.vue index a1ec5a95..2b34a220 100644 --- a/frontend-vue/src/pages/brand/components/BrandBrandTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandBrandTab.vue @@ -194,23 +194,29 @@ async function pollOnce() { for (const key of Object.keys(lineProgressMap.value)) { if (!busy.some((b) => String(b.id) === key)) delete lineProgressMap.value[key] } - for (const item of busy) { - const id = Number(item.id) - if (!id) continue - try { - const res = await getBrandTask(id) - const lp = res?.line_progress - if (lp?.has_progress && lp.info) { - lineProgressMap.value[String(id)] = { - current: Number(lp.info.current_line) || 0, - total: Number(lp.info.total_lines) || 0, - phase: lp.info.phase, - } - } else { - delete lineProgressMap.value[String(id)] + // 并发拉取:此前在 for 循环里逐个 await,任务多时一轮耗时随任务数线性叠加 + //(brand 模块没有批量进度端点,见 shared/api/endpoints.ts,只能逐个查,故用并发而非 batch) + const busyIds = busy.map((item) => Number(item.id)).filter((id) => Number.isFinite(id) && id > 0) + const details = await Promise.all( + busyIds.map(async (id) => { + try { + return { id, res: await getBrandTask(id) } + } catch { + // 单条详情失败不中断整轮轮询 + return { id, res: null } } - } catch { - // 单条详情失败不中断整轮轮询 + }), + ) + for (const { id, res } of details) { + const lp = res?.line_progress + if (lp?.has_progress && lp.info) { + lineProgressMap.value[String(id)] = { + current: Number(lp.info.current_line) || 0, + total: Number(lp.info.total_lines) || 0, + phase: lp.info.phase, + } + } else { + delete lineProgressMap.value[String(id)] } } scheduleNextPoll() diff --git a/frontend-vue/src/pages/brand/components/BrandCollectDataTab.vue b/frontend-vue/src/pages/brand/components/BrandCollectDataTab.vue index 5cea8a8a..8f4de4f0 100644 --- a/frontend-vue/src/pages/brand/components/BrandCollectDataTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandCollectDataTab.vue @@ -179,6 +179,7 @@ import { type CollectDataTaskDetailVo, type UploadFileVo, } from '@/shared/api/java-modules' +import { getPollingProgressBatch } from '@/shared/api/task-progress-polling.ts' import { formatDateTime } from '@/shared/utils/datetime' import { uploadPathsToJava } from '@/shared/utils/upload-to-java' import { runBatchDelete } from '@/shared/utils/batch-delete' @@ -236,24 +237,70 @@ const taskSnapshots = ref>({}) const POLLING_STORAGE_KEY = 'brand:collect-data:polling-task-ids' +/** 轮询可比对的轻量字段(/tasks/progress/light 白名单里本模块快照有的字段),只有它们变化才重建快照对象 */ +function taskProgressSignatureOf(detail: CollectDataTaskDetailVo) { + const task = detail.task + if (!task) return '' + return [task.status ?? '', task.updatedAt ?? ''].join('|') +} + +/** + * currentItems 的 taskId → 下标索引。 + * 轮询每轮都要按 taskId 定位行,此前每次都对整份列表 map 一遍;有索引后只改命中的那一行。 + * 列表被整体替换(loadHistory)时必须重建;使用处还会做一次 taskId 校验兜底。 + */ +let currentItemsIndex = new Map() + +function rebuildCurrentItemsIndex() { + const next = new Map() + currentItems.value.forEach((item, index) => { + const taskId = Number(item.taskId) + if (Number.isFinite(taskId) && taskId > 0) next.set(taskId, index) + }) + currentItemsIndex = next +} + +/** 原地更新 currentItems 里指定任务的那一行(找不到就什么都不做) */ +function patchCurrentItem( + taskId: number, + latestItem: CollectDataHistoryItem | undefined, + status: string | undefined, +) { + let index = currentItemsIndex.get(taskId) + if (index === undefined || Number(currentItems.value[index]?.taskId) !== taskId) { + rebuildCurrentItemsIndex() + index = currentItemsIndex.get(taskId) + } + if (index === undefined) return + const current = currentItems.value[index] + if (!current) return + currentItems.value[index] = { + ...current, + ...(latestItem || {}), + taskStatus: status ?? latestItem?.taskStatus ?? current.taskStatus, + } +} + const progressLoop = useTaskProgressLoop({ scope: 'collect-data-tab', storageKey: POLLING_STORAGE_KEY, - fetchProgress: (ids) => getCollectDataTaskProgressBatch(ids), + // F5:主轮询只读 status/fileReady 等轻量白名单字段,走 light 端点减小响应体; + // 轻量端点异常/空结果时适配器自动回退重型 batch(否则会漏刷新结果) + fetchProgress: (ids) => getPollingProgressBatch('collectData', ids, { + fallback: () => getCollectDataTaskProgressBatch(ids), + }), extractTaskId: (detail) => detail.task?.id ?? null, extractStatus: (detail) => detail.task?.status ?? '', onUpdate: (taskId, detail) => { const prev = taskSnapshots.value[taskId] - taskSnapshots.value = { - ...taskSnapshots.value, - [taskId]: prev - ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) }, items: detail.items ?? prev.items } - : detail, + const merged: CollectDataTaskDetailVo = prev + ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) }, items: detail.items ?? prev.items } + : detail + // 只有轮询字段(或行明细)真的变了才展开快照对象:整份快照含行明细,无变化时重建纯属浪费 + if (!prev || merged.items !== prev.items || taskProgressSignatureOf(prev) !== taskProgressSignatureOf(merged)) { + taskSnapshots.value = { ...taskSnapshots.value, [taskId]: merged } } - const latestItem = detail.items?.[0] - currentItems.value = currentItems.value.map((item) => item.taskId === taskId - ? { ...item, ...(latestItem || {}), taskStatus: detail.task?.status ?? latestItem?.taskStatus ?? item.taskStatus } - : item) + patchCurrentItem(taskId, detail.items?.[0], detail.task?.status) }, onTerminal: async (taskId) => { if (taskSnapshots.value[taskId]) { @@ -624,6 +671,8 @@ async function loadHistory() { } } currentItems.value = merged + // 列表整体替换后重建 taskId 索引,轮询就能原地更新命中的那一行 + rebuildCurrentItemsIndex() } catch { /* 忽略:历史接口失败时保持空列表 */ } diff --git a/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue b/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue index ce3469e6..8ba9181b 100644 --- a/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue @@ -33,7 +33,7 @@

+
删除条件 @@ -310,6 +318,13 @@ const { total: matchedTotal, paged: pagedMatchedItems, } = useTablePaging(matchedItems) +// F9:备选店铺表同样只切渲染窗口(分页不改变数据源,勾选/删除语义不变) +const { + page: candidatePage, + pageSize: candidatePageSize, + total: candidateTotal, + paged: pagedCandidates, +} = useTablePaging(candidates) const historyItems = ref([]); const dashboard = ref({ candidateCount: 0, diff --git a/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue b/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue index e3a3827a..83f160c0 100644 --- a/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue @@ -467,10 +467,37 @@ function loadTaskSnapshotsFromStorage() { } } -function saveTaskSnapshotsToStorage() { +/** 快照落盘最小间隔:快照可达数百 KB,全量 JSON.stringify + setItem 会阻塞主线程 */ +const SNAPSHOT_SAVE_MIN_INTERVAL_MS = 30000 +let lastSnapshotSaveAt = 0 +let snapshotSavePending = false + +/** + * 快照写盘(节流)。 + * + * 每轮轮询都序列化整份快照会周期性卡顿主线程,因此: + * - 内容未变时 setStorageJson 内部已会跳过写入; + * - 这里再按 SNAPSHOT_SAVE_MIN_INTERVAL_MS 节流,被节流掉的写入挂起, + * 下一次(或 force / 组件卸载前的 flush)落盘时一并写入最新内容。 + */ +function saveTaskSnapshotsToStorage(force = false) { + if (typeof window === 'undefined') return + const now = Date.now() + if (!force && lastSnapshotSaveAt > 0 && now - lastSnapshotSaveAt < SNAPSHOT_SAVE_MIN_INTERVAL_MS) { + snapshotSavePending = true + return + } + snapshotSavePending = false + lastSnapshotSaveAt = now setStorageJson(taskSnapshotsStorageKey(), taskSnapshots.value, Object.keys(taskSnapshots.value).length === 0) } +/** 卸载前把节流挂起的快照补写一次,避免最后一次变更丢失 */ +function flushTaskSnapshotsToStorage() { + if (!snapshotSavePending) return + saveTaskSnapshotsToStorage(true) +} + function loadMatchedItemsFromStorage() { try { const raw = typeof window !== 'undefined' ? window.localStorage.getItem(matchedItemsStorageKey()) : null @@ -1147,7 +1174,7 @@ async function processMatchedQueue() { ...taskDetails.value, [taskVo.taskId]: 'RUNNING', } - saveTaskSnapshotsToStorage() + saveTaskSnapshotsToStorage(true) saveTaskDetailsToStorage() const queuePayload = await buildQueuePayload(taskVo, row) queuePayloadText.value = JSON.stringify(queuePayload, null, 2) @@ -1233,7 +1260,7 @@ function recordCreatedTask(taskVo: PriceTrackCreateTaskVo) { ...taskDetails.value, [taskVo.taskId]: 'RUNNING', } - saveTaskSnapshotsToStorage() + saveTaskSnapshotsToStorage(true) saveTaskDetailsToStorage() } @@ -1418,37 +1445,68 @@ function getPollIntervalMs() { return getTaskPollIntervalMs() } +/** 轮询可比对的轻量字段(与 /tasks/progress/light 白名单一致,外加只有重型端点才带的起止时间) */ +function taskProgressSignature(task?: PriceTrackTaskDetailVo['task'] | null) { + if (!task) return '' + return [ + task.status ?? '', + task.createdAt ?? '', + task.finishedAt ?? '', + task.updatedAt ?? '', + ].join('|') +} + async function refreshTaskBatch() { const ids = pollingTaskIds.value.filter((id) => id > 0) if (!ids.length) return try { - const batch = await getPriceTrackTaskProgressBatch(ids) - let changed = false + // F5:主轮询只读 status/fileReady 等轻量白名单字段,走 light 端点减小轮询响应体; + // 轻量端点异常或返回空结果时适配器自动回退重型 batch(否则会漏刷新结果) + const batch = await getPollingProgressBatch('priceTrack', ids, { + fallback: () => getPriceTrackTaskProgressBatch(ids), + }) + let settled = false + let snapshotsChanged = false const nextSnapshots = { ...taskSnapshots.value } for (const missingId of batch.missingTaskIds || []) { + console.log(`[price-track] 任务 ${missingId} 在服务端已不存在,停止轮询`) removePollingTask(missingId) delete nextSnapshots[missingId] + snapshotsChanged = true } for (const detail of batch.items || []) { const taskId = detail.task?.id const status = detail.task?.status if (typeof taskId !== 'number' || taskId <= 0) continue const prev = nextSnapshots[taskId] - nextSnapshots[taskId] = prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail + const merged: PriceTrackTaskDetailVo = prev + ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } + : detail + // 只有轮询字段真的变了才重写快照:整份快照数百 KB,无变化时重建对象 + 落盘纯属浪费 + if (!prev || taskProgressSignature(prev.task) !== taskProgressSignature(merged.task)) { + nextSnapshots[taskId] = merged + snapshotsChanged = true + } if (status) { taskDetails.value[taskId] = status if (status === 'SUCCESS' || status === 'FAILED') { + console.log(`[price-track] 任务 ${taskId} 已到终态 ${status},移出轮询集合并刷新列表`) removePollingTask(taskId) - changed = true + settled = true } } } - taskSnapshots.value = nextSnapshots - saveTaskSnapshotsToStorage() + if (snapshotsChanged) { + taskSnapshots.value = nextSnapshots + saveTaskSnapshotsToStorage() + } saveTaskDetailsToStorage() - await loadHistory() - syncPollingIdsWithHistory() - if (changed) { + // 历史列表只在任务落定(或快照真变化)时拉取:此前每 10s 无条件重查一次,纯属浪费 + if (settled || snapshotsChanged) { + await loadHistory() + syncPollingIdsWithHistory() + } + if (settled) { await loadDashboard() void resumeLoopExecutionIfNeeded() } @@ -1508,7 +1566,8 @@ function removePollingTask(taskId: number) { const next = { ...taskSnapshots.value } delete next[taskId] taskSnapshots.value = next - saveTaskSnapshotsToStorage() + // 终态清理属于关键变更,跳过节流立即落盘 + saveTaskSnapshotsToStorage(true) } } @@ -1679,6 +1738,8 @@ onUnmounted(() => { clearSleepTimers() timers.clearScope() if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer) + // 补写被节流挂起的快照,避免最后一次变更丢失 + flushTaskSnapshotsToStorage() }) /** diff --git a/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue b/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue index 0b973975..fe6de5c1 100644 --- a/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue @@ -33,7 +33,7 @@
+ @@ -245,6 +253,13 @@ const { total: matchedTotal, paged: pagedMatchedItems, } = useTablePaging(matchedItems) +// F9:备选店铺表同样只切渲染窗口(分页不改变数据源,勾选/删除语义不变) +const { + page: candidatePage, + pageSize: candidatePageSize, + total: candidateTotal, + paged: pagedCandidates, +} = useTablePaging(candidates) const historyItems = ref([]); const dashboard = ref({ candidateCount: 0, diff --git a/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue b/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue index 2192a50a..afa0a263 100644 --- a/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue @@ -17,7 +17,7 @@
备选区
暂无备选店铺
- @@ -28,6 +28,9 @@
+
抓取国家与顺序
@@ -187,6 +190,13 @@ const { total: matchedTotal, paged: pagedMatchedItems, } = useTablePaging(matchedItems) +// F9:备选店铺表同样只切渲染窗口(分页不改变数据源,勾选/删除语义不变) +const { + page: candidatePage, + pageSize: candidatePageSize, + total: candidateTotal, + paged: pagedCandidates, +} = useTablePaging(candidates) const historyItems = ref([]) const dashboard = ref({ candidateCount: 0, processedTaskCount: 0, successTaskCount: 0, failedTaskCount: 0 }) const orderedCountryCodes = ref(COUNTRY_OPTIONS.map((row) => row.code)) diff --git a/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue b/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue index b0205b77..5c4ff9f5 100644 --- a/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandShopMatchTab.vue @@ -452,7 +452,8 @@ function sleep(ms: number) { if (disposed) return Promise.resolve(); return time function clearSleepTimers() { timers.clearCategory('queue-wait') } 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(action: () => Promise, onRetry: (attempt: number, maxAttempts: number) => void, maxAttempts = 10) { let attempt = 0; while (true) { if (disposed) throw new Error('component disposed'); 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(); 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 { // F5:主轮询只读 status 等轻量白名单字段,走 light 端点减小响应体;轻量异常/空结果时回退重型 batch(否则会漏刷新结果) + const batch = await getPollingProgressBatch('shopMatch', pollingTaskIds.value, { fallback: () => getShopMatchTaskProgressBatch(pollingTaskIds.value) }); const nextSnapshots = { ...taskSnapshots.value }; const settledTaskIds = new Set(); 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 getTaskPollIntervalMs() } function scheduleNextPoll(immediate = false) { if (disposed) return; if (pollTimer.value) { if (!immediate) return; timers.clearTimer('task-poll', pollTimer.value); pollTimer.value = null } const run = async () => { pollTimer.value = null; if (disposed) return; 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 (!disposed && pollingTaskIds.value.length) pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs()) }; if (immediate) void run(); else pollTimer.value = timers.setTimeout('task-poll', run, getPollIntervalMs()) } function ensurePolling(immediate = false) { scheduleNextPoll(immediate) } diff --git a/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue b/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue index 7dd31ef6..6d84728e 100644 --- a/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue @@ -44,7 +44,7 @@
+ @@ -296,6 +304,13 @@ const { total: matchedTotal, paged: pagedMatchedItems, } = useTablePaging(matchedItems) +// F9:备选店铺表同样只切渲染窗口(分页不改变数据源,勾选/删除语义不变) +const { + page: candidatePage, + pageSize: candidatePageSize, + total: candidateTotal, + paged: pagedCandidates, +} = useTablePaging(candidates) const historyItems = ref([]); const dashboard = ref({ candidateCount: 0, diff --git a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts index 14c623bf..4abdc322 100644 --- a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts +++ b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts @@ -139,6 +139,8 @@ export function useTaskProgressLoop( let pollTimer: number | null = null let disposed = false let failureCount = 0 + /** 页面隐藏导致停表时为 true;恢复可见后据此判断需要重启轮询 */ + let pausedForVisibility = false function persist() { writeIdsToStorage(options.storageKey, taskIds.value) @@ -237,13 +239,13 @@ export function useTaskProgressLoop( } function scheduleNext(immediate = false) { - if (disposed) return + if (disposed || pausedForVisibility) return if (pollTimer != null && !immediate) return clearPollTimer() const run = async () => { pollTimer = null - if (disposed) return + if (disposed || pausedForVisibility) return if (!taskIds.value.length) return if (inFlight.value) { // 上一次还没回,按退避策略延迟重试(默认 500ms,可配置) @@ -251,7 +253,7 @@ export function useTaskProgressLoop( return } await refreshOnce() - if (!disposed && taskIds.value.length > 0) { + if (!disposed && !pausedForVisibility && taskIds.value.length > 0) { // 连续失败时按指数退避拉长间隔,避免后端故障时每 5s 撞一次(成功即复位) const delay = failureCount > 0 ? getTaskPollBackoffMs(failureCount) : intervalMs() pollTimer = timers.setTimeout('task-poll', run, delay) @@ -266,7 +268,7 @@ export function useTaskProgressLoop( } function ensure(immediate = false) { - if (disposed) return + if (disposed || pausedForVisibility) return if (!taskIds.value.length) return if (pollTimer != null && !immediate) return scheduleNext(immediate) @@ -292,38 +294,67 @@ export function useTaskProgressLoop( { flush: 'post' }, ) + /** + * 页面隐藏时停表:后台标签页不再按隐藏间隔空转请求(每轮仍是完整的进度查询)。 + * 恢复可见时立即补拉一轮并重启轮询(见下面的 visibilitychange 处理器)。 + */ + function pauseForVisibility() { + if (disposed || !taskIds.value.length) return + pausedForVisibility = true + stop() + console.log(`[task-poll] 页面隐藏,暂停轮询: ${options.scope}`) + } + + /** 恢复可见:按前台刷新开关决定是否立即补拉,但无论如何都要把轮询重启起来 */ + function resumeAfterVisibility() { + if (disposed) return + const wasPaused = pausedForVisibility + pausedForVisibility = false + if (!taskIds.value.length) return + if (getTaskForegroundRefreshEnabled()) { + const delay = getTaskForegroundRefreshDelayMs() + if (delay > 0) { + scheduleNextDelayed(delay) + } else { + scheduleNext(true) + } + if (wasPaused) { + console.log(`[task-poll] 页面恢复可见,已补拉一轮并重启轮询: ${options.scope}`) + } + return + } + if (wasPaused) { + // 开关关闭时不立即补拉,但必须重启轮询,否则隐藏过一次就再也不轮询 + console.log(`[task-poll] 页面恢复可见,重启轮询(前台即时刷新已关闭): ${options.scope}`) + scheduleNext() + } + } + // 切到前台后立刻拉一次(可配置开关与延迟),让用户回到页面看到的是最新状态 let visibilityHandler: (() => void) | null = null if (typeof document !== 'undefined') { visibilityHandler = () => { - if ( - getTaskForegroundRefreshEnabled() && - document.visibilityState === 'visible' && - taskIds.value.length > 0 - ) { - const delay = getTaskForegroundRefreshDelayMs() - if (delay > 0) { - scheduleNextDelayed(delay) - } else { - scheduleNext(true) - } + if (document.visibilityState === 'visible') { + resumeAfterVisibility() + } else { + pauseForVisibility() } } document.addEventListener('visibilitychange', visibilityHandler) } function scheduleNextDelayed(delayMs: number) { - if (disposed) return + if (disposed || pausedForVisibility) return clearPollTimer() const run = () => { pollTimer = null - if (disposed || !taskIds.value.length) return + if (disposed || pausedForVisibility || !taskIds.value.length) return if (inFlight.value) { pollTimer = timers.setTimeout('task-poll', run, getTaskPollBackoffMs(0)) return } void refreshOnce() - if (!disposed && taskIds.value.length > 0) { + if (!disposed && !pausedForVisibility && taskIds.value.length > 0) { const delay = failureCount > 0 ? getTaskPollBackoffMs(failureCount) : intervalMs() pollTimer = timers.setTimeout('task-poll', run, delay) } diff --git a/frontend-vue/tests/polling-visibility.test.ts b/frontend-vue/tests/polling-visibility.test.ts index de066c01..aafba244 100644 --- a/frontend-vue/tests/polling-visibility.test.ts +++ b/frontend-vue/tests/polling-visibility.test.ts @@ -57,6 +57,8 @@ interface VisibilityHarness { loop: ReturnType> doc: FakeDoc sink: TimerSinkEntry[] + /** 被 clearTimeout 取消过的定时器 id(用于断言隐藏时确实停了表) */ + cleared: unknown[] fetchCount: () => number } @@ -72,6 +74,7 @@ async function makeVisibilityLoop( } const doc = installFakeDocument(visibility) const sink: TimerSinkEntry[] = [] + const cleared: unknown[] = [] const storage = new Map() let fetchCount = 0 ;(globalThis as Record).window = { @@ -84,7 +87,10 @@ async function makeVisibilityLoop( sink.push({ fn, ms }) return globalThis.setTimeout(fn, ms) }, - clearTimeout: (id: unknown) => globalThis.clearTimeout(id as number), + clearTimeout: (id: unknown) => { + cleared.push(id) + globalThis.clearTimeout(id as number) + }, setInterval: (fn: () => void, ms: number) => globalThis.setInterval(fn, ms), clearInterval: (id: unknown) => globalThis.clearInterval(id as number), } @@ -98,7 +104,7 @@ async function makeVisibilityLoop( extractStatus: (d) => d?.status, }) loop.reset([1]) - return { loop, doc, sink, fetchCount: () => fetchCount } + return { loop, doc, sink, cleared, fetchCount: () => fetchCount } } test('test_hidden_long_interval', async () => { @@ -187,3 +193,35 @@ test('test_hidden_no_immediate_refresh', async () => { h.loop.dispose() cleanupDocument() }) + +test('test_hidden_stops_polling', async () => { + const h = await makeVisibilityLoop('visible') + await tick(10) + const scheduledBefore = h.sink.length + const before = h.fetchCount() + h.doc.setVisibility('hidden') + h.doc.dispatch('visibilitychange') + await tick(10) + assert.equal(h.fetchCount(), before, '隐藏后不再发起请求') + assert.equal(h.sink.length, scheduledBefore, '隐藏后不再排定新的轮询定时器') + assert.ok(h.cleared.length > 0, '隐藏时清掉了已排定的轮询定时器') + h.loop.dispose() + cleanupDocument() +}) + +test('test_visible_restarts_polling', async () => { + const h = await makeVisibilityLoop('visible') + await tick(10) + h.doc.setVisibility('hidden') + h.doc.dispatch('visibilitychange') + await tick(10) + const before = h.fetchCount() + h.doc.setVisibility('visible') + h.doc.dispatch('visibilitychange') + await tick(10) + assert.equal(h.fetchCount(), before + 1, '恢复可见立即补拉一轮') + assert.equal(h.sink.at(-1)?.ms, TASK_POLL_VISIBLE_INTERVAL_MS, '恢复可见后按可见间隔重启轮询') + assert.deepEqual(h.loop.taskIds.value, [1], '停表期间任务集合不变') + h.loop.dispose() + cleanupDocument() +})