feat(上架): 店铺互斥改为按(设备,店铺),同店可在不同客户端并行跑不同国家
背景:紫鸟浏览器会话是每台客户端一份,同一家店可以在两台机器上并行; 国家是会话内的可切换状态(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
This commit is contained in:
@@ -9,6 +9,7 @@ import io.jsonwebtoken.Claims;
|
|||||||
import jakarta.servlet.http.Cookie;
|
import jakarta.servlet.http.Cookie;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AdminAuthSupport {
|
public class AdminAuthSupport {
|
||||||
@@ -62,8 +64,30 @@ public class AdminAuthSupport {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前用户必须是管理员或超级管理员,否则抛 403。 */
|
/**
|
||||||
public AdminUserEntity requireAdmin(HttpServletRequest request) {
|
* 解析当前请求 JWT 中**签名的**设备标识(deviceId claim);识别不出时返回空串。
|
||||||
|
*
|
||||||
|
* <p>无 token、token 过期/非法、内部令牌通道调用一律返回空串——调用方必须把空串
|
||||||
|
* 当作"来源不明"做保守判定,绝不据此放宽任何限制。只认签名 claim,不接受
|
||||||
|
* X-Device-Id 请求头(头由客户端可控,见 {@link DeviceSessionPolicy} 类注释)。</p>
|
||||||
|
*
|
||||||
|
* <p>本方法只做识别、不做鉴权,因此解析失败不抛异常,仅记日志后返回空串,
|
||||||
|
* 避免把匿名/内部调用直接升级成 401。</p>
|
||||||
|
*/
|
||||||
|
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);
|
AdminUserEntity user = requireUser(request);
|
||||||
String role = currentRole(user);
|
String role = currentRole(user);
|
||||||
if (role == null) {
|
if (role == null) {
|
||||||
|
|||||||
+9
-3
@@ -1,6 +1,7 @@
|
|||||||
package com.nanri.aiimage.modules.publish.controller;
|
package com.nanri.aiimage.modules.publish.controller;
|
||||||
|
|
||||||
import com.nanri.aiimage.common.api.ApiResponse;
|
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.PublishParseRequest;
|
||||||
import com.nanri.aiimage.modules.publish.model.dto.PublishSubmitResultRequest;
|
import com.nanri.aiimage.modules.publish.model.dto.PublishSubmitResultRequest;
|
||||||
import com.nanri.aiimage.modules.publish.model.dto.PublishTaskBatchRequest;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
@@ -34,6 +36,7 @@ import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo;
|
|||||||
public class PublishController {
|
public class PublishController {
|
||||||
|
|
||||||
private final PublishTaskService publishTaskService;
|
private final PublishTaskService publishTaskService;
|
||||||
|
private final AdminAuthSupport adminAuthSupport;
|
||||||
|
|
||||||
@PostMapping("/parse")
|
@PostMapping("/parse")
|
||||||
@Operation(
|
@Operation(
|
||||||
@@ -57,15 +60,18 @@ public class PublishController {
|
|||||||
@PostMapping("/tasks/{taskId}/files/{fileId}/activate")
|
@PostMapping("/tasks/{taskId}/files/{fileId}/activate")
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "激活任务中的单个文件",
|
summary = "激活任务中的单个文件",
|
||||||
description = "前端派发 Python 队列前调用。taskId 和 fileId 用于定位文件,user_id 可省略;如传入则校验任务归属。同一任务同一时间只允许一个 RUNNING 文件;重复激活当前 RUNNING 文件为幂等操作。")
|
description = "前端派发 Python 队列前调用。taskId 和 fileId 用于定位文件,user_id 可省略;如传入则校验任务归属。同一任务同一时间只允许一个 RUNNING 文件;重复激活当前 RUNNING 文件为幂等操作。"
|
||||||
|
+ "店铺互斥按 (发起方设备, 店铺) 判定:同一台机器上同一店铺只允许一个任务在跑(该机器上该店铺只有一个紫鸟浏览器会话,并发会互相切换国家);不同设备各自持有独立会话,允许同一店铺并行跑不同国家。"
|
||||||
|
+ "设备号取自 JWT 签名的 deviceId claim,缺失时退回按店铺全局互斥。")
|
||||||
public ApiResponse<Void> activateFile(
|
public ApiResponse<Void> activateFile(
|
||||||
@Parameter(description = "上架任务 ID", required = true, example = "9001")
|
@Parameter(description = "上架任务 ID", required = true, example = "9001")
|
||||||
@PathVariable Long taskId,
|
@PathVariable Long taskId,
|
||||||
@Parameter(description = "任务内文件 ID", required = true, example = "9101")
|
@Parameter(description = "任务内文件 ID", required = true, example = "9101")
|
||||||
@PathVariable Long fileId,
|
@PathVariable Long fileId,
|
||||||
@Parameter(description = "可选的任务所属用户 ID;省略时由 taskId 反查", required = false, example = "1")
|
@Parameter(description = "可选的任务所属用户 ID;省略时由 taskId 反查", required = false, example = "1")
|
||||||
@RequestParam(value = "user_id", required = false) Long userId) {
|
@RequestParam(value = "user_id", required = false) Long userId,
|
||||||
publishTaskService.activateFile(taskId, fileId, userId);
|
HttpServletRequest request) {
|
||||||
|
publishTaskService.activateFile(taskId, fileId, userId, adminAuthSupport.currentDeviceId(request));
|
||||||
return ApiResponse.success(null);
|
return ApiResponse.success(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
@@ -20,6 +20,8 @@ public class PublishFileEntity {
|
|||||||
private Integer matched;
|
private Integer matched;
|
||||||
private String shopId;
|
private String shopId;
|
||||||
private Long matchedUserId;
|
private Long matchedUserId;
|
||||||
|
/** 激活该文件时客户端所在设备(JWT 签名的 deviceId);空表示来源不明,按全局店铺互斥保守处理。 */
|
||||||
|
private String deviceId;
|
||||||
private String platform;
|
private String platform;
|
||||||
private String companyName;
|
private String companyName;
|
||||||
private String matchStatus;
|
private String matchStatus;
|
||||||
|
|||||||
+43
-12
@@ -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 =
|
try (TaskDistributedLockService.LockHandle lock =
|
||||||
taskDistributedLockService.acquire(MODULE_TYPE, taskId)) {
|
taskDistributedLockService.acquire(MODULE_TYPE, taskId)) {
|
||||||
if (lock == null) {
|
if (lock == null) {
|
||||||
@@ -198,23 +205,45 @@ public class PublishTaskService {
|
|||||||
if (runningFile != null) {
|
if (runningFile != null) {
|
||||||
throw new BusinessException("同一任务已有文件正在执行: " + runningFile.getSourceFilename());
|
throw new BusinessException("同一任务已有文件正在执行: " + runningFile.getSourceFilename());
|
||||||
}
|
}
|
||||||
// 店铺级互斥:同一店铺同一时刻只允许一个上架任务在跑。
|
// 店铺级互斥:同一台设备上同一店铺同一时刻只允许一个上架任务在跑。
|
||||||
// 2026-09-17 事故(28519/28520/28521 相继提交同一店铺「林洪武」):同一店铺被多个
|
// 2026-09-17 事故(28519/28520/28521 相继提交同一店铺「林洪武」):同一台机器上同一
|
||||||
// 任务并发打开,客户端 startBrowser 全部返回 -10000,三个任务一起失败。激活是任务
|
// 店铺被多个任务并发打开,客户端 startBrowser 全部返回 -10000,三个任务一起失败。
|
||||||
// 真正开跑的唯一入口,在这里挡掉并带出占用中的任务号,用户才知道要等谁。
|
// 激活是任务真正开跑的唯一入口,在这里挡掉并带出占用中的任务号,用户才知道要等谁。
|
||||||
|
//
|
||||||
|
// 互斥键是 (设备, 店铺) 而不是店铺:紫鸟浏览器会话是**每台机器一份**,不同客户端
|
||||||
|
// 各自持有独立会话,同一家店可以在两台机器上并行跑不同国家(2026-09-18 任务 28624
|
||||||
|
// 在另一台机器上被 28616 误挡)。真正必须串行的是同一台设备——那里只有一个会话,
|
||||||
|
// 两个任务会互相切换国家(add_product.py 的 SwitchingCountries 是会话级状态,任务
|
||||||
|
// 中途断线重连还会再切一次),轻则失败,重则把 A 国家的商品提交进 B 国家的店铺。
|
||||||
|
// 设备号取自 JWT 签名的 deviceId claim;为空(旧客户端 token 无该 claim / 内部令牌
|
||||||
|
// 调用)时退回改动前的全局店铺互斥,保守不放宽。
|
||||||
//
|
//
|
||||||
// 注意:本校验与随后的状态更新之间仍有极小竞态窗口(两个请求恰好同时通过校验);
|
// 注意:本校验与随后的状态更新之间仍有极小竞态窗口(两个请求恰好同时通过校验);
|
||||||
// 真正的串行由客户端店铺锁保证,这一层的目的是尽早给出明确提示,避免白传文件与重复执行。
|
// 真正的串行由客户端店铺锁保证,这一层的目的是尽早给出明确提示,避免白传文件与重复执行。
|
||||||
String shopName = file.getShopName();
|
String shopName = file.getShopName();
|
||||||
if (shopName != null && !shopName.isBlank()) {
|
if (shopName != null && !shopName.isBlank()) {
|
||||||
PublishFileEntity shopRunning = publishFileMapper.selectOne(
|
LambdaQueryWrapper<PublishFileEntity> shopRunningQuery = new LambdaQueryWrapper<PublishFileEntity>()
|
||||||
new LambdaQueryWrapper<PublishFileEntity>()
|
.eq(PublishFileEntity::getShopName, shopName)
|
||||||
.eq(PublishFileEntity::getShopName, shopName)
|
.eq(PublishFileEntity::getStatus, STATUS_RUNNING)
|
||||||
.eq(PublishFileEntity::getStatus, STATUS_RUNNING)
|
.ne(PublishFileEntity::getTaskId, taskId)
|
||||||
.ne(PublishFileEntity::getTaskId, taskId)
|
.orderByAsc(PublishFileEntity::getId)
|
||||||
.orderByAsc(PublishFileEntity::getId)
|
.last("limit 1");
|
||||||
.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) {
|
if (shopRunning != null) {
|
||||||
|
log.warn("[publish] 店铺互斥拦截 taskId={} fileId={} shop={} device={} 占用任务={} 占用设备={}",
|
||||||
|
taskId, fileId, shopName, device,
|
||||||
|
shopRunning.getTaskId(), shopRunning.getDeviceId());
|
||||||
throw new BusinessException("店铺「" + shopName + "」已有上架任务正在执行(任务 "
|
throw new BusinessException("店铺「" + shopName + "」已有上架任务正在执行(任务 "
|
||||||
+ shopRunning.getTaskId() + "),请等它完成后再提交");
|
+ shopRunning.getTaskId() + "),请等它完成后再提交");
|
||||||
}
|
}
|
||||||
@@ -224,11 +253,13 @@ public class PublishTaskService {
|
|||||||
.eq(PublishFileEntity::getTaskId, taskId)
|
.eq(PublishFileEntity::getTaskId, taskId)
|
||||||
.eq(PublishFileEntity::getStatus, STATUS_PENDING)
|
.eq(PublishFileEntity::getStatus, STATUS_PENDING)
|
||||||
.set(PublishFileEntity::getStatus, STATUS_RUNNING)
|
.set(PublishFileEntity::getStatus, STATUS_RUNNING)
|
||||||
|
.set(PublishFileEntity::getDeviceId, device.isEmpty() ? null : device)
|
||||||
.set(PublishFileEntity::getUpdatedAt, LocalDateTime.now())
|
.set(PublishFileEntity::getUpdatedAt, LocalDateTime.now())
|
||||||
.set(PublishFileEntity::getErrorMessage, null));
|
.set(PublishFileEntity::getErrorMessage, null));
|
||||||
if (updated <= 0) {
|
if (updated <= 0) {
|
||||||
throw new BusinessException("文件激活失败,请刷新后重试");
|
throw new BusinessException("文件激活失败,请刷新后重试");
|
||||||
}
|
}
|
||||||
|
log.info("[publish] 文件激活成功 taskId={} fileId={} shop={} device={}", taskId, fileId, shopName, device);
|
||||||
if (STATUS_PENDING.equals(task.getStatus())) {
|
if (STATUS_PENDING.equals(task.getStatus())) {
|
||||||
fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
|
fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
|
||||||
.eq(FileTaskEntity::getId, taskId)
|
.eq(FileTaskEntity::getId, taskId)
|
||||||
|
|||||||
@@ -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;
|
||||||
+98
-8
@@ -37,6 +37,7 @@ import org.junit.jupiter.api.BeforeAll;
|
|||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.Spy;
|
import org.mockito.Spy;
|
||||||
@@ -109,6 +110,10 @@ class PublishTaskServiceTest {
|
|||||||
|
|
||||||
@InjectMocks private PublishTaskService service;
|
@InjectMocks private PublishTaskService service;
|
||||||
|
|
||||||
|
/** 店铺互斥按 (设备, 店铺) 判定:两台机器各自的设备标识。 */
|
||||||
|
private static final String DEVICE_A = "device-aaaa";
|
||||||
|
private static final String DEVICE_B = "device-bbbb";
|
||||||
|
|
||||||
private final List<TaskChunkEntity> storedChunks = new ArrayList<>();
|
private final List<TaskChunkEntity> storedChunks = new ArrayList<>();
|
||||||
private final List<TaskScopeStateEntity> storedScopes = new ArrayList<>();
|
private final List<TaskScopeStateEntity> storedScopes = new ArrayList<>();
|
||||||
private final Map<String, String> rustfsPayloads = new LinkedHashMap<>();
|
private final Map<String, String> rustfsPayloads = new LinkedHashMap<>();
|
||||||
@@ -744,7 +749,7 @@ class PublishTaskServiceTest {
|
|||||||
when(publishFileMapper.selectOne(any())).thenReturn(running);
|
when(publishFileMapper.selectOne(any())).thenReturn(running);
|
||||||
|
|
||||||
BusinessException error = assertThrows(BusinessException.class,
|
BusinessException error = assertThrows(BusinessException.class,
|
||||||
() -> service.activateFile(taskId, fileId, 7L));
|
() -> service.activateFile(taskId, fileId, 7L, DEVICE_A));
|
||||||
|
|
||||||
assertTrue(error.getMessage().contains("已有文件正在执行"));
|
assertTrue(error.getMessage().contains("已有文件正在执行"));
|
||||||
verify(publishFileMapper, never()).update(isNull(), any());
|
verify(publishFileMapper, never()).update(isNull(), any());
|
||||||
@@ -752,25 +757,26 @@ class PublishTaskServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void activateFileRejectsShopAlreadyRunningInAnotherTask() {
|
void activateFileRejectsShopAlreadyRunningOnSameDevice() {
|
||||||
long taskId = 106L;
|
long taskId = 106L;
|
||||||
long fileId = 206L;
|
long fileId = 206L;
|
||||||
FileTaskEntity task = task(taskId, 7L, "RUNNING");
|
FileTaskEntity task = task(taskId, 7L, "RUNNING");
|
||||||
PublishFileEntity target = file(taskId, fileId, "PENDING", "林洪武.xlsx");
|
PublishFileEntity target = file(taskId, fileId, "PENDING", "林洪武.xlsx");
|
||||||
target.setShopName("林洪武");
|
target.setShopName("林洪武");
|
||||||
// 另一个任务(28520)正跑同一店铺 —— 2026-09-17 事故形态
|
// 同一台设备上另一个任务(28520)正跑同一店铺 —— 2026-09-17 事故形态
|
||||||
PublishFileEntity otherTaskRunning = file(105L, 205L, "RUNNING", "林洪武.xlsx");
|
PublishFileEntity otherTaskRunning = file(105L, 205L, "RUNNING", "林洪武.xlsx");
|
||||||
otherTaskRunning.setShopName("林洪武");
|
otherTaskRunning.setShopName("林洪武");
|
||||||
|
otherTaskRunning.setDeviceId(DEVICE_A);
|
||||||
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||||
|
|
||||||
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock);
|
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock);
|
||||||
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
|
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
|
||||||
when(publishFileMapper.selectById(fileId)).thenReturn(target);
|
when(publishFileMapper.selectById(fileId)).thenReturn(target);
|
||||||
// 第一次 selectOne:同任务其它 RUNNING 文件(无);第二次:同店铺跨任务(有)
|
// 第一次 selectOne:同任务其它 RUNNING 文件(无);第二次:同设备同店铺跨任务(有)
|
||||||
when(publishFileMapper.selectOne(any())).thenReturn(null, otherTaskRunning);
|
when(publishFileMapper.selectOne(any())).thenReturn(null, otherTaskRunning);
|
||||||
|
|
||||||
BusinessException error = assertThrows(BusinessException.class,
|
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("林洪武"), "提示要带店铺名: " + error.getMessage());
|
||||||
assertTrue(error.getMessage().contains("105"), "提示要带占用中的任务号: " + error.getMessage());
|
assertTrue(error.getMessage().contains("105"), "提示要带占用中的任务号: " + error.getMessage());
|
||||||
@@ -778,6 +784,90 @@ class PublishTaskServiceTest {
|
|||||||
verify(lock).close();
|
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<LambdaQueryWrapper> 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<LambdaQueryWrapper> 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
|
@Test
|
||||||
void activateFileAllowsWhenShopHasNoOtherRunningTask() {
|
void activateFileAllowsWhenShopHasNoOtherRunningTask() {
|
||||||
long taskId = 107L;
|
long taskId = 107L;
|
||||||
@@ -793,7 +883,7 @@ class PublishTaskServiceTest {
|
|||||||
when(publishFileMapper.selectOne(any())).thenReturn(null);
|
when(publishFileMapper.selectOne(any())).thenReturn(null);
|
||||||
when(publishFileMapper.update(isNull(), any())).thenReturn(1);
|
when(publishFileMapper.update(isNull(), any())).thenReturn(1);
|
||||||
|
|
||||||
service.activateFile(taskId, fileId, 7L);
|
service.activateFile(taskId, fileId, 7L, DEVICE_A);
|
||||||
|
|
||||||
verify(publishFileMapper).update(isNull(), any());
|
verify(publishFileMapper).update(isNull(), any());
|
||||||
}
|
}
|
||||||
@@ -813,7 +903,7 @@ class PublishTaskServiceTest {
|
|||||||
when(publishFileMapper.selectOne(any())).thenReturn(null);
|
when(publishFileMapper.selectOne(any())).thenReturn(null);
|
||||||
when(publishFileMapper.update(isNull(), any())).thenReturn(1);
|
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());
|
verify(publishFileMapper, times(1)).selectOne(any());
|
||||||
}
|
}
|
||||||
@@ -830,7 +920,7 @@ class PublishTaskServiceTest {
|
|||||||
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
|
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
|
||||||
when(publishFileMapper.selectById(fileId)).thenReturn(running);
|
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()).selectOne(any());
|
||||||
verify(publishFileMapper, never()).update(isNull(), any());
|
verify(publishFileMapper, never()).update(isNull(), any());
|
||||||
|
|||||||
+3
-1
@@ -107,7 +107,9 @@ class ResultSuccessTimingContractTest {
|
|||||||
doAnswer(invocation -> null)
|
doAnswer(invocation -> null)
|
||||||
.when(publishTaskService).submitResult(eq(TASK_ID), any(PublishSubmitResultRequest.class));
|
.when(publishTaskService).submitResult(eq(TASK_ID), any(PublishSubmitResultRequest.class));
|
||||||
|
|
||||||
ApiResponse<Void> response = new PublishController(publishTaskService)
|
// 第二个构造参数是 AdminAuthSupport(activateFile 取设备号用),本用例只测 submitResult,
|
||||||
|
// 不经过该依赖,故传 null(同文件其它 controller 用例亦有传 null 的先例)。
|
||||||
|
ApiResponse<Void> response = new PublishController(publishTaskService, null)
|
||||||
.submitResult(TASK_ID, new PublishSubmitResultRequest());
|
.submitResult(TASK_ID, new PublishSubmitResultRequest());
|
||||||
|
|
||||||
assertTrue(response.isSuccess(), "success=true");
|
assertTrue(response.isSuccess(), "success=true");
|
||||||
|
|||||||
Reference in New Issue
Block a user