From d2d95f0b7149519410b4af26787ebb132f23b150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Thu, 17 Sep 2026 15:48:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=B8=8A=E6=9E=B6):=20=E6=BF=80=E6=B4=BB?= =?UTF-8?q?=E6=97=B6=E6=8C=89=E5=BA=97=E9=93=BA=E4=BA=92=E6=96=A5=EF=BC=8C?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E5=BA=97=E9=93=BA=E4=B8=8D=E5=85=81=E8=AE=B8?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=E4=BB=BB=E5=8A=A1=E5=90=8C=E6=97=B6=E8=B7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-09-17 事故(28519/28520/28521 相继提交同一店铺「林洪武」):同一店铺被多个 任务并发打开,客户端 startBrowser 全部返回 -10000,三个任务一起失败。 - PublishTaskService.activateFile:激活前查是否已有其他任务在同一店铺 RUNNING, 有则拒绝并带出占用中的任务号(激活是任务真正开跑的唯一入口,能最早拦住); 校验与更新之间仍有极小竞态窗口,真正串行由客户端店铺锁保证,这层负责尽早提示; - 前端 BrandPublishTab:文件派发失败的原因回显给用户。原来错误只写进日志、提示 固定为"已记录并继续",用户会误以为是文件问题而反复重传,看不到真正原因; - 新增 3 个后端单测:同店铺被占用则拒绝、无占用则放行、店铺名为空跳过校验。 --- .../publish/service/PublishTaskService.java | 21 ++++++ .../service/PublishTaskServiceTest.java | 67 +++++++++++++++++++ .../brand/components/BrandPublishTab.vue | 5 +- 3 files changed, 92 insertions(+), 1 deletion(-) 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 756960f9..7b0c3b09 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 @@ -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() + .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() .eq(PublishFileEntity::getId, fileId) .eq(PublishFileEntity::getTaskId, taskId) 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 3cec8c1f..bc65fc37 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 @@ -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; diff --git a/frontend-vue/src/pages/brand/components/BrandPublishTab.vue b/frontend-vue/src/pages/brand/components/BrandPublishTab.vue index 858baf6c..7394d77e 100644 --- a/frontend-vue/src/pages/brand/components/BrandPublishTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPublishTab.vue @@ -917,7 +917,10 @@ async function processQueue() { saveQueueState() throw new Error(queueMessage.value) } - queueMessage.value = `文件 ${file.sourceFilename || nextFileId} 启动失败,已记录并继续下一个文件。` + // 原因必须回显给用户:后端会因「该店铺已有上架任务在执行」直接拒绝激活, + // 只写日志的话用户只看到"启动失败",会以为是文件问题而反复重传 + queueMessage.value = `文件 ${file.sourceFilename || nextFileId} 启动失败:${reason}` + ElMessage.warning(queueMessage.value) activeFileId.value = null saveQueueState() }