feat(上架): 激活时按店铺互斥,同一店铺不允许两个任务同时跑
2026-09-17 事故(28519/28520/28521 相继提交同一店铺「林洪武」):同一店铺被多个 任务并发打开,客户端 startBrowser 全部返回 -10000,三个任务一起失败。 - PublishTaskService.activateFile:激活前查是否已有其他任务在同一店铺 RUNNING, 有则拒绝并带出占用中的任务号(激活是任务真正开跑的唯一入口,能最早拦住); 校验与更新之间仍有极小竞态窗口,真正串行由客户端店铺锁保证,这层负责尽早提示; - 前端 BrandPublishTab:文件派发失败的原因回显给用户。原来错误只写进日志、提示 固定为"已记录并继续",用户会误以为是文件问题而反复重传,看不到真正原因; - 新增 3 个后端单测:同店铺被占用则拒绝、无占用则放行、店铺名为空跳过校验。
This commit is contained in:
+21
@@ -198,6 +198,27 @@ public class PublishTaskService {
|
||||
if (runningFile != null) {
|
||||
throw new BusinessException("同一任务已有文件正在执行: " + runningFile.getSourceFilename());
|
||||
}
|
||||
// 店铺级互斥:同一店铺同一时刻只允许一个上架任务在跑。
|
||||
// 2026-09-17 事故(28519/28520/28521 相继提交同一店铺「林洪武」):同一店铺被多个
|
||||
// 任务并发打开,客户端 startBrowser 全部返回 -10000,三个任务一起失败。激活是任务
|
||||
// 真正开跑的唯一入口,在这里挡掉并带出占用中的任务号,用户才知道要等谁。
|
||||
//
|
||||
// 注意:本校验与随后的状态更新之间仍有极小竞态窗口(两个请求恰好同时通过校验);
|
||||
// 真正的串行由客户端店铺锁保证,这一层的目的是尽早给出明确提示,避免白传文件与重复执行。
|
||||
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"));
|
||||
if (shopRunning != null) {
|
||||
throw new BusinessException("店铺「" + shopName + "」已有上架任务正在执行(任务 "
|
||||
+ shopRunning.getTaskId() + "),请等它完成后再提交");
|
||||
}
|
||||
}
|
||||
int updated = publishFileMapper.update(null, new LambdaUpdateWrapper<PublishFileEntity>()
|
||||
.eq(PublishFileEntity::getId, fileId)
|
||||
.eq(PublishFileEntity::getTaskId, taskId)
|
||||
|
||||
+67
@@ -751,6 +751,73 @@ class PublishTaskServiceTest {
|
||||
verify(lock).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void activateFileRejectsShopAlreadyRunningInAnotherTask() {
|
||||
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 事故形态
|
||||
PublishFileEntity otherTaskRunning = file(105L, 205L, "RUNNING", "林洪武.xlsx");
|
||||
otherTaskRunning.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);
|
||||
// 第一次 selectOne:同任务其它 RUNNING 文件(无);第二次:同店铺跨任务(有)
|
||||
when(publishFileMapper.selectOne(any())).thenReturn(null, otherTaskRunning);
|
||||
|
||||
BusinessException error = assertThrows(BusinessException.class,
|
||||
() -> service.activateFile(taskId, fileId, 7L));
|
||||
|
||||
assertTrue(error.getMessage().contains("林洪武"), "提示要带店铺名: " + error.getMessage());
|
||||
assertTrue(error.getMessage().contains("105"), "提示要带占用中的任务号: " + error.getMessage());
|
||||
verify(publishFileMapper, never()).update(isNull(), any());
|
||||
verify(lock).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void activateFileAllowsWhenShopHasNoOtherRunningTask() {
|
||||
long taskId = 107L;
|
||||
long fileId = 207L;
|
||||
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);
|
||||
when(publishFileMapper.selectOne(any())).thenReturn(null);
|
||||
when(publishFileMapper.update(isNull(), any())).thenReturn(1);
|
||||
|
||||
service.activateFile(taskId, fileId, 7L);
|
||||
|
||||
verify(publishFileMapper).update(isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void activateFileSkipsShopCheckWhenShopNameBlank() {
|
||||
long taskId = 108L;
|
||||
long fileId = 208L;
|
||||
FileTaskEntity task = task(taskId, 7L, "RUNNING");
|
||||
// 未匹配到店铺的文件:没有店铺标识就不做店铺维度校验(不能因此阻断激活)
|
||||
PublishFileEntity target = file(taskId, fileId, "PENDING", "未匹配.xlsx");
|
||||
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);
|
||||
when(publishFileMapper.update(isNull(), any())).thenReturn(1);
|
||||
|
||||
service.activateFile(taskId, fileId, 7L);
|
||||
|
||||
verify(publishFileMapper, times(1)).selectOne(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void activateFileIsIdempotentForTheSameRunningFile() {
|
||||
long taskId = 105L;
|
||||
|
||||
Reference in New Issue
Block a user