From e0303f9cbacc164604c144e351ded1cf5ee85b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Fri, 18 Sep 2026 16:08:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=B8=8A=E6=9E=B6):=20=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E4=BA=92=E6=96=A5=E6=94=B9=E4=B8=BA=E6=8C=89(=E8=AE=BE?= =?UTF-8?q?=E5=A4=87,=E5=BA=97=E9=93=BA)=EF=BC=8C=E5=90=8C=E5=BA=97?= =?UTF-8?q?=E5=8F=AF=E5=9C=A8=E4=B8=8D=E5=90=8C=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E5=B9=B6=E8=A1=8C=E8=B7=91=E4=B8=8D=E5=90=8C=E5=9B=BD=E5=AE=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:紫鸟浏览器会话是每台客户端一份,同一家店可以在两台机器上并行; 国家是会话内的可切换状态(SwitchingCountries,断线重连还会再切), 所以真正必须串行的只有"同一台设备上的同一家店"。原来的全局按店铺互斥 把跨机器的合法并行也挡了(2026-09-18 任务 28624 被 28616 误挡)。 - V130:biz_publish_file 增加 device_id 列 - AdminAuthSupport.currentDeviceId():只读 JWT 签名的 deviceId claim, 不信任客户端可控的 X-Device-Id 请求头 - activateFile 互斥键改为 (device_id, shop_name);device 为空(旧客户端 token 无该 claim / 内部令牌调用)时退回全局店铺互斥,保守不放宽; 存量 RUNNING 行的 NULL device 视为"来源不明",同样保守拦到跑完为止 - 前端与客户端无需改动:页面 axios 对 /newApi 已带 Authorization Bearer --- .../common/security/AdminAuthSupport.java | 28 ++++- .../publish/controller/PublishController.java | 12 +- .../model/entity/PublishFileEntity.java | 2 + .../publish/service/PublishTaskService.java | 55 +++++++-- .../db/V130__publish_file_device_id.sql | 33 ++++++ .../service/PublishTaskServiceTest.java | 106 ++++++++++++++++-- .../ResultSuccessTimingContractTest.java | 4 +- 7 files changed, 214 insertions(+), 26 deletions(-) create mode 100644 backend-java/src/main/resources/db/V130__publish_file_device_id.sql diff --git a/backend-java/src/main/java/com/nanri/aiimage/common/security/AdminAuthSupport.java b/backend-java/src/main/java/com/nanri/aiimage/common/security/AdminAuthSupport.java index a9e2b688..c3e13d26 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/common/security/AdminAuthSupport.java +++ b/backend-java/src/main/java/com/nanri/aiimage/common/security/AdminAuthSupport.java @@ -9,6 +9,7 @@ import io.jsonwebtoken.Claims; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; @@ -19,6 +20,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +@Slf4j @Component @RequiredArgsConstructor public class AdminAuthSupport { @@ -62,8 +64,30 @@ public class AdminAuthSupport { return user; } - /** 当前用户必须是管理员或超级管理员,否则抛 403。 */ - public AdminUserEntity requireAdmin(HttpServletRequest request) { + /** + * 解析当前请求 JWT 中**签名的**设备标识(deviceId claim);识别不出时返回空串。 + * + *

无 token、token 过期/非法、内部令牌通道调用一律返回空串——调用方必须把空串 + * 当作"来源不明"做保守判定,绝不据此放宽任何限制。只认签名 claim,不接受 + * X-Device-Id 请求头(头由客户端可控,见 {@link DeviceSessionPolicy} 类注释)。

+ * + *

本方法只做识别、不做鉴权,因此解析失败不抛异常,仅记日志后返回空串, + * 避免把匿名/内部调用直接升级成 401。

+ */ + public String currentDeviceId(HttpServletRequest request) { + String token = resolveToken(request); + if (token == null || token.isBlank()) { + return ""; + } + try { + return DeviceSessionPolicy.claimDeviceId(jwtService.parse(token)); + } catch (Exception ex) { + log.warn("[auth] 解析 token 取设备标识失败,按来源不明处理: {}", ex.getMessage()); + return ""; + } + } + + /** 当前用户必须是管理员或超级管理员,否则抛 403。 */ public AdminUserEntity requireAdmin(HttpServletRequest request) { AdminUserEntity user = requireUser(request); String role = currentRole(user); if (role == null) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java index 93e22316..ce580133 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java @@ -1,6 +1,7 @@ package com.nanri.aiimage.modules.publish.controller; import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.common.security.AdminAuthSupport; import com.nanri.aiimage.modules.publish.model.dto.PublishParseRequest; import com.nanri.aiimage.modules.publish.model.dto.PublishSubmitResultRequest; import com.nanri.aiimage.modules.publish.model.dto.PublishTaskBatchRequest; @@ -14,6 +15,7 @@ import com.nanri.aiimage.modules.publish.service.PublishTaskService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; @@ -34,6 +36,7 @@ import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; public class PublishController { private final PublishTaskService publishTaskService; + private final AdminAuthSupport adminAuthSupport; @PostMapping("/parse") @Operation( @@ -57,15 +60,18 @@ public class PublishController { @PostMapping("/tasks/{taskId}/files/{fileId}/activate") @Operation( summary = "激活任务中的单个文件", - description = "前端派发 Python 队列前调用。taskId 和 fileId 用于定位文件,user_id 可省略;如传入则校验任务归属。同一任务同一时间只允许一个 RUNNING 文件;重复激活当前 RUNNING 文件为幂等操作。") + description = "前端派发 Python 队列前调用。taskId 和 fileId 用于定位文件,user_id 可省略;如传入则校验任务归属。同一任务同一时间只允许一个 RUNNING 文件;重复激活当前 RUNNING 文件为幂等操作。" + + "店铺互斥按 (发起方设备, 店铺) 判定:同一台机器上同一店铺只允许一个任务在跑(该机器上该店铺只有一个紫鸟浏览器会话,并发会互相切换国家);不同设备各自持有独立会话,允许同一店铺并行跑不同国家。" + + "设备号取自 JWT 签名的 deviceId claim,缺失时退回按店铺全局互斥。") public ApiResponse activateFile( @Parameter(description = "上架任务 ID", required = true, example = "9001") @PathVariable Long taskId, @Parameter(description = "任务内文件 ID", required = true, example = "9101") @PathVariable Long fileId, @Parameter(description = "可选的任务所属用户 ID;省略时由 taskId 反查", required = false, example = "1") - @RequestParam(value = "user_id", required = false) Long userId) { - publishTaskService.activateFile(taskId, fileId, userId); + @RequestParam(value = "user_id", required = false) Long userId, + HttpServletRequest request) { + publishTaskService.activateFile(taskId, fileId, userId, adminAuthSupport.currentDeviceId(request)); return ApiResponse.success(null); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/entity/PublishFileEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/entity/PublishFileEntity.java index 3d339a14..37468143 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/entity/PublishFileEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/model/entity/PublishFileEntity.java @@ -20,6 +20,8 @@ public class PublishFileEntity { private Integer matched; private String shopId; private Long matchedUserId; + /** 激活该文件时客户端所在设备(JWT 签名的 deviceId);空表示来源不明,按全局店铺互斥保守处理。 */ + private String deviceId; private String platform; private String companyName; private String matchStatus; 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 7b0c3b09..5dc1c680 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 @@ -174,7 +174,14 @@ public class PublishTaskService { } } - public void activateFile(Long taskId, Long fileId, Long userId) { + /** + * 激活任务中的单个文件。 + * + * @param deviceId 发起方设备标识(JWT 签名的 deviceId claim);空串/空白表示来源不明 + * (旧客户端 token 无该 claim、内部令牌调用),此时退回全局店铺互斥 + */ + public void activateFile(Long taskId, Long fileId, Long userId, String deviceId) { + String device = deviceId == null ? "" : deviceId.trim(); try (TaskDistributedLockService.LockHandle lock = taskDistributedLockService.acquire(MODULE_TYPE, taskId)) { if (lock == null) { @@ -198,23 +205,45 @@ public class PublishTaskService { if (runningFile != null) { throw new BusinessException("同一任务已有文件正在执行: " + runningFile.getSourceFilename()); } - // 店铺级互斥:同一店铺同一时刻只允许一个上架任务在跑。 - // 2026-09-17 事故(28519/28520/28521 相继提交同一店铺「林洪武」):同一店铺被多个 - // 任务并发打开,客户端 startBrowser 全部返回 -10000,三个任务一起失败。激活是任务 - // 真正开跑的唯一入口,在这里挡掉并带出占用中的任务号,用户才知道要等谁。 + // 店铺级互斥:同一台设备上同一店铺同一时刻只允许一个上架任务在跑。 + // 2026-09-17 事故(28519/28520/28521 相继提交同一店铺「林洪武」):同一台机器上同一 + // 店铺被多个任务并发打开,客户端 startBrowser 全部返回 -10000,三个任务一起失败。 + // 激活是任务真正开跑的唯一入口,在这里挡掉并带出占用中的任务号,用户才知道要等谁。 + // + // 互斥键是 (设备, 店铺) 而不是店铺:紫鸟浏览器会话是**每台机器一份**,不同客户端 + // 各自持有独立会话,同一家店可以在两台机器上并行跑不同国家(2026-09-18 任务 28624 + // 在另一台机器上被 28616 误挡)。真正必须串行的是同一台设备——那里只有一个会话, + // 两个任务会互相切换国家(add_product.py 的 SwitchingCountries 是会话级状态,任务 + // 中途断线重连还会再切一次),轻则失败,重则把 A 国家的商品提交进 B 国家的店铺。 + // 设备号取自 JWT 签名的 deviceId claim;为空(旧客户端 token 无该 claim / 内部令牌 + // 调用)时退回改动前的全局店铺互斥,保守不放宽。 // // 注意:本校验与随后的状态更新之间仍有极小竞态窗口(两个请求恰好同时通过校验); // 真正的串行由客户端店铺锁保证,这一层的目的是尽早给出明确提示,避免白传文件与重复执行。 String shopName = file.getShopName(); if (shopName != null && !shopName.isBlank()) { - PublishFileEntity shopRunning = publishFileMapper.selectOne( - new LambdaQueryWrapper() - .eq(PublishFileEntity::getShopName, shopName) - .eq(PublishFileEntity::getStatus, STATUS_RUNNING) - .ne(PublishFileEntity::getTaskId, taskId) - .orderByAsc(PublishFileEntity::getId) - .last("limit 1")); + LambdaQueryWrapper shopRunningQuery = new LambdaQueryWrapper() + .eq(PublishFileEntity::getShopName, shopName) + .eq(PublishFileEntity::getStatus, STATUS_RUNNING) + .ne(PublishFileEntity::getTaskId, taskId) + .orderByAsc(PublishFileEntity::getId) + .last("limit 1"); + if (device.isEmpty()) { + log.info("[publish] 激活无设备标识,按全局店铺互斥判定 taskId={} fileId={} shop={}", + taskId, fileId, shopName); + } else { + // 本设备的 RUNNING 行,以及设备未知的存量行(旧客户端/内部调用,NULL 或空串) + // ——后者无法判断落在哪台机器上,一律保守视为可能同机。 + shopRunningQuery.and(wrapper -> wrapper + .eq(PublishFileEntity::getDeviceId, device) + .or().isNull(PublishFileEntity::getDeviceId) + .or().eq(PublishFileEntity::getDeviceId, "")); + } + PublishFileEntity shopRunning = publishFileMapper.selectOne(shopRunningQuery); if (shopRunning != null) { + log.warn("[publish] 店铺互斥拦截 taskId={} fileId={} shop={} device={} 占用任务={} 占用设备={}", + taskId, fileId, shopName, device, + shopRunning.getTaskId(), shopRunning.getDeviceId()); throw new BusinessException("店铺「" + shopName + "」已有上架任务正在执行(任务 " + shopRunning.getTaskId() + "),请等它完成后再提交"); } @@ -224,11 +253,13 @@ public class PublishTaskService { .eq(PublishFileEntity::getTaskId, taskId) .eq(PublishFileEntity::getStatus, STATUS_PENDING) .set(PublishFileEntity::getStatus, STATUS_RUNNING) + .set(PublishFileEntity::getDeviceId, device.isEmpty() ? null : device) .set(PublishFileEntity::getUpdatedAt, LocalDateTime.now()) .set(PublishFileEntity::getErrorMessage, null)); if (updated <= 0) { throw new BusinessException("文件激活失败,请刷新后重试"); } + log.info("[publish] 文件激活成功 taskId={} fileId={} shop={} device={}", taskId, fileId, shopName, device); if (STATUS_PENDING.equals(task.getStatus())) { fileTaskMapper.update(null, new LambdaUpdateWrapper() .eq(FileTaskEntity::getId, taskId) diff --git a/backend-java/src/main/resources/db/V130__publish_file_device_id.sql b/backend-java/src/main/resources/db/V130__publish_file_device_id.sql new file mode 100644 index 00000000..7d325d66 --- /dev/null +++ b/backend-java/src/main/resources/db/V130__publish_file_device_id.sql @@ -0,0 +1,33 @@ +-- V130: biz_publish_file 增加 device_id 列(激活该文件时客户端所在设备) +-- +-- 背景:同店铺互斥原本只按 shop_name 全局判定(PublishTaskService.activateFile)。 +-- 但紫鸟浏览器会话是**每台机器一份**:不同客户端各自持有独立的店铺会话,同一家店 +-- 完全可以在两台机器上并行跑不同国家。原来的全局判定把这种合法的跨机器并行也挡了 +-- (2026-09-18 任务 28624 被 28616 误挡:两条在不同机器上)。 +-- 真正必须串行的是「同一台机器上的同一家店」——那里只有一个浏览器会话,两个任务会 +-- 互相切换国家(add_product.py 的 SwitchingCountries 是会话级状态,任务中途断线重连 +-- 还会再切一次),轻则失败,重则把 A 国家的商品提交进 B 国家的店铺。 +-- +-- 因此互斥键由 shop_name 改为 (device_id, shop_name)。device 取自 JWT 里**签名的** +-- deviceId claim(绝不使用客户端可控的 X-Device-Id 请求头,见 DeviceSessionPolicy 注释)。 +-- +-- 空值语义:NULL/空串表示"来源不明"——旧客户端(未升级、token 无 claim)或内部令牌调用。 +-- 该情形退回改动前的全局店铺互斥,保持保守,不因迁移把风险放开。存量 RUNNING 行均为 NULL, +-- 因此会继续全挡直到跑完,随后新激活的行都带设备号,跨机器并行自然生效。 +-- +-- 风险:ADD COLUMN 走 INSTANT/INPLACE,生产该表仅数百行,秒级完成;建议低峰执行。 +-- 回滚:ALTER TABLE biz_publish_file DROP COLUMN device_id; + +SET @db_name = DATABASE(); + +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name AND TABLE_NAME = 'biz_publish_file' AND COLUMN_NAME = 'device_id' +); +SET @sql := IF(@col_exists = 0, + 'ALTER TABLE biz_publish_file ADD COLUMN device_id VARCHAR(128) NULL COMMENT ''device that activated this file, from signed JWT deviceId claim'' AFTER matched_user_id', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishTaskServiceTest.java index bc65fc37..f02a51aa 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishTaskServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishTaskServiceTest.java @@ -37,6 +37,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; @@ -109,6 +110,10 @@ class PublishTaskServiceTest { @InjectMocks private PublishTaskService service; + /** 店铺互斥按 (设备, 店铺) 判定:两台机器各自的设备标识。 */ + private static final String DEVICE_A = "device-aaaa"; + private static final String DEVICE_B = "device-bbbb"; + private final List storedChunks = new ArrayList<>(); private final List storedScopes = new ArrayList<>(); private final Map rustfsPayloads = new LinkedHashMap<>(); @@ -744,7 +749,7 @@ class PublishTaskServiceTest { when(publishFileMapper.selectOne(any())).thenReturn(running); BusinessException error = assertThrows(BusinessException.class, - () -> service.activateFile(taskId, fileId, 7L)); + () -> service.activateFile(taskId, fileId, 7L, DEVICE_A)); assertTrue(error.getMessage().contains("已有文件正在执行")); verify(publishFileMapper, never()).update(isNull(), any()); @@ -752,25 +757,26 @@ class PublishTaskServiceTest { } @Test - void activateFileRejectsShopAlreadyRunningInAnotherTask() { + void activateFileRejectsShopAlreadyRunningOnSameDevice() { long taskId = 106L; long fileId = 206L; FileTaskEntity task = task(taskId, 7L, "RUNNING"); PublishFileEntity target = file(taskId, fileId, "PENDING", "林洪武.xlsx"); target.setShopName("林洪武"); - // 另一个任务(28520)正跑同一店铺 —— 2026-09-17 事故形态 + // 同一台设备上另一个任务(28520)正跑同一店铺 —— 2026-09-17 事故形态 PublishFileEntity otherTaskRunning = file(105L, 205L, "RUNNING", "林洪武.xlsx"); otherTaskRunning.setShopName("林洪武"); + otherTaskRunning.setDeviceId(DEVICE_A); TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock); when(fileTaskMapper.selectById(taskId)).thenReturn(task); when(publishFileMapper.selectById(fileId)).thenReturn(target); - // 第一次 selectOne:同任务其它 RUNNING 文件(无);第二次:同店铺跨任务(有) + // 第一次 selectOne:同任务其它 RUNNING 文件(无);第二次:同设备同店铺跨任务(有) when(publishFileMapper.selectOne(any())).thenReturn(null, otherTaskRunning); BusinessException error = assertThrows(BusinessException.class, - () -> service.activateFile(taskId, fileId, 7L)); + () -> service.activateFile(taskId, fileId, 7L, DEVICE_A)); assertTrue(error.getMessage().contains("林洪武"), "提示要带店铺名: " + error.getMessage()); assertTrue(error.getMessage().contains("105"), "提示要带占用中的任务号: " + error.getMessage()); @@ -778,6 +784,90 @@ class PublishTaskServiceTest { verify(lock).close(); } + /** 用户需求:同一家店允许在不同客户端(不同机器)上并行跑不同国家。 */ + @Test + void activateFileAllowsShopRunningOnAnotherDevice() { + long taskId = 109L; + long fileId = 209L; + FileTaskEntity task = task(taskId, 7L, "RUNNING"); + PublishFileEntity target = file(taskId, fileId, "PENDING", "林芳.xlsx"); + target.setShopName("林芳"); + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + + when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock); + when(fileTaskMapper.selectById(taskId)).thenReturn(task); + when(publishFileMapper.selectById(fileId)).thenReturn(target); + // 另一台设备的占用行不会命中本设备的查询,因此这里返回 null + when(publishFileMapper.selectOne(any())).thenReturn(null); + when(publishFileMapper.update(isNull(), any())).thenReturn(1); + + service.activateFile(taskId, fileId, 7L, DEVICE_B); + + verify(publishFileMapper).update(isNull(), any()); + // 店铺互斥查询必须真正带上设备维度,否则跨机器并行会被重新挡住 + ArgumentCaptor captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(publishFileMapper, times(2)).selectOne(captor.capture()); + String shopSql = captor.getAllValues().get(1).getSqlSegment(); + assertTrue(shopSql.contains("device_id"), "店铺互斥查询要带设备维度: " + shopSql); + assertTrue(shopSql.contains("shop_name"), "设备维度不能替代店铺维度: " + shopSql); + } + + /** 存量行(改动前激活、device_id 为空)无法判断落在哪台机器上,一律保守视为可能同机。 */ + @Test + void activateFileRejectsWhenRunningRowHasUnknownDevice() { + long taskId = 110L; + long fileId = 210L; + FileTaskEntity task = task(taskId, 7L, "RUNNING"); + PublishFileEntity target = file(taskId, fileId, "PENDING", "林芳.xlsx"); + target.setShopName("林芳"); + PublishFileEntity legacyRunning = file(105L, 205L, "RUNNING", "林芳.xlsx"); + legacyRunning.setShopName("林芳"); + legacyRunning.setDeviceId(null); + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + + when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock); + when(fileTaskMapper.selectById(taskId)).thenReturn(task); + when(publishFileMapper.selectById(fileId)).thenReturn(target); + when(publishFileMapper.selectOne(any())).thenReturn(null, legacyRunning); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.activateFile(taskId, fileId, 7L, DEVICE_B)); + + assertTrue(error.getMessage().contains("105"), "存量未知设备行必须继续拦住: " + error.getMessage()); + verify(publishFileMapper, never()).update(isNull(), any()); + verify(lock).close(); + } + + /** 无设备标识(旧客户端 token 无 deviceId claim / 内部令牌调用)→ 退回改动前的全局店铺互斥。 */ + @Test + void activateFileFallsBackToGlobalShopCheckWhenDeviceMissing() { + long taskId = 111L; + long fileId = 211L; + FileTaskEntity task = task(taskId, 7L, "RUNNING"); + PublishFileEntity target = file(taskId, fileId, "PENDING", "林芳.xlsx"); + target.setShopName("林芳"); + PublishFileEntity otherDeviceRunning = file(105L, 205L, "RUNNING", "林芳.xlsx"); + otherDeviceRunning.setShopName("林芳"); + otherDeviceRunning.setDeviceId(DEVICE_A); + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + + when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock); + when(fileTaskMapper.selectById(taskId)).thenReturn(task); + when(publishFileMapper.selectById(fileId)).thenReturn(target); + when(publishFileMapper.selectOne(any())).thenReturn(null, otherDeviceRunning); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.activateFile(taskId, fileId, 7L, "")); + + assertTrue(error.getMessage().contains("105"), "来源不明时必须保持全局互斥: " + error.getMessage()); + ArgumentCaptor captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(publishFileMapper, times(2)).selectOne(captor.capture()); + assertFalse(captor.getAllValues().get(1).getSqlSegment().contains("device_id"), + "来源不明时不应按设备收窄,否则等于放宽互斥"); + verify(publishFileMapper, never()).update(isNull(), any()); + verify(lock).close(); + } + @Test void activateFileAllowsWhenShopHasNoOtherRunningTask() { long taskId = 107L; @@ -793,7 +883,7 @@ class PublishTaskServiceTest { when(publishFileMapper.selectOne(any())).thenReturn(null); when(publishFileMapper.update(isNull(), any())).thenReturn(1); - service.activateFile(taskId, fileId, 7L); + service.activateFile(taskId, fileId, 7L, DEVICE_A); verify(publishFileMapper).update(isNull(), any()); } @@ -813,7 +903,7 @@ class PublishTaskServiceTest { when(publishFileMapper.selectOne(any())).thenReturn(null); when(publishFileMapper.update(isNull(), any())).thenReturn(1); - service.activateFile(taskId, fileId, 7L); + service.activateFile(taskId, fileId, 7L, DEVICE_A); verify(publishFileMapper, times(1)).selectOne(any()); } @@ -830,7 +920,7 @@ class PublishTaskServiceTest { when(fileTaskMapper.selectById(taskId)).thenReturn(task); when(publishFileMapper.selectById(fileId)).thenReturn(running); - service.activateFile(taskId, fileId, 7L); + service.activateFile(taskId, fileId, 7L, DEVICE_A); verify(publishFileMapper, never()).selectOne(any()); verify(publishFileMapper, never()).update(isNull(), any()); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/ResultSuccessTimingContractTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/ResultSuccessTimingContractTest.java index 6e055990..23804684 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/ResultSuccessTimingContractTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/ResultSuccessTimingContractTest.java @@ -107,7 +107,9 @@ class ResultSuccessTimingContractTest { doAnswer(invocation -> null) .when(publishTaskService).submitResult(eq(TASK_ID), any(PublishSubmitResultRequest.class)); - ApiResponse response = new PublishController(publishTaskService) + // 第二个构造参数是 AdminAuthSupport(activateFile 取设备号用),本用例只测 submitResult, + // 不经过该依赖,故传 null(同文件其它 controller 用例亦有传 null 的先例)。 + ApiResponse response = new PublishController(publishTaskService, null) .submitResult(TASK_ID, new PublishSubmitResultRequest()); assertTrue(response.isSuccess(), "success=true");