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:
2026-09-18 16:08:08 +08:00
parent 9d39705c77
commit e0303f9cba
7 changed files with 214 additions and 26 deletions
@@ -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);识别不出时返回空串。
*
* <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);
String role = currentRole(user);
if (role == null) {
@@ -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<Void> 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);
}
@@ -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;
@@ -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<PublishFileEntity>()
.eq(PublishFileEntity::getShopName, shopName)
.eq(PublishFileEntity::getStatus, STATUS_RUNNING)
.ne(PublishFileEntity::getTaskId, taskId)
.orderByAsc(PublishFileEntity::getId)
.last("limit 1"));
LambdaQueryWrapper<PublishFileEntity> shopRunningQuery = new LambdaQueryWrapper<PublishFileEntity>()
.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<FileTaskEntity>()
.eq(FileTaskEntity::getId, taskId)