diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java b/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java index 661f9838..d114515c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/AdminApiGuardFilter.java @@ -53,8 +53,12 @@ public class AdminApiGuardFilter extends OncePerRequestFilter { /** * 用户态工具接口前缀:controller 无方法级鉴权(归属由请求参数 user_id 判定), - * 匿名可达即越权读写他人数据。纳入兜底要求 JWT 或可信内部令牌(2026-09-12)。 - * 桌面 Python 直连调用已同步携带 X-Internal-Token。 + * 匿名可达即越权读写他人数据。**默认不纳入兜底**,由 + * aiimage.security.user-tool-guard-enabled 开关控制(默认 false = 行为不变)。 + * + *

原因:桌面客户端(Python)直连这些接口且不带任何认证头,先于客户端发布开启会 + * 让线上集采/跟价全线 401。待携带 X-Internal-Token 的新客户端铺开后,把开关置 true + * (或把这些前缀从 USER_TOOL_PREFIXES 移除、直接无条件纳入)即可完成收紧。 */ private static final String[] USER_TOOL_PREFIXES = { "/api/collect-data", @@ -67,6 +71,13 @@ public class AdminApiGuardFilter extends OncePerRequestFilter { @Value("${aiimage.security.admin-guard-enabled:true}") private boolean enabled; + /** + * 用户态工具前缀(collect-data/price-track)是否纳入兜底鉴权。 + * 默认 false:老客户端直连不带认证头,先开启会打断线上任务;新客户端铺开后置 true。 + */ + @Value("${aiimage.security.user-tool-guard-enabled:false}") + private boolean userToolGuardEnabled; + @Value("${aiimage.security.admin-guard-exempt-prefixes:}") private String exemptPrefixes; @@ -121,10 +132,13 @@ public class AdminApiGuardFilter extends OncePerRequestFilter { } /** 命中受保护前缀(/api/admin、/debug、用户态工具前缀及其子路径)才进入鉴权,其余请求直接放行。 */ - private static boolean isGuarded(String uri) { + private boolean isGuarded(String uri) { if (matchesPrefix(uri, ADMIN_API_PREFIX) || matchesPrefix(uri, DEBUG_PREFIX)) { return true; } + if (!userToolGuardEnabled) { + return false; + } for (String prefix : USER_TOOL_PREFIXES) { if (matchesPrefix(uri, prefix)) { return true; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataDeleteConsistencyTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataDeleteConsistencyTest.java index 1e91f4dc..3966624d 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataDeleteConsistencyTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataDeleteConsistencyTest.java @@ -108,6 +108,7 @@ class CollectDataDeleteConsistencyTest { mock(com.nanri.aiimage.modules.collectdata.service.CollectDataExcelAssemblyService.class), mock(com.nanri.aiimage.modules.file.service.oss.OssStorageService.class), objectMapper, txTemplate, + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS), mock(com.nanri.aiimage.modules.collectdata.util.CollectDataBatchQuery.class), mock(com.nanri.aiimage.modules.collectdata.util.CollectDataBrandBatchFilter.class), mock(com.nanri.aiimage.modules.collectdata.util.CollectDataInvalidAsinBatchWriter.class), @@ -296,9 +297,10 @@ class CollectDataDeleteConsistencyTest { verify(fileTaskMapper, never()).deleteById(1L); assertThat("COLLECT_DATA").as("任务行未被删除(事务回滚)").isEqualTo(CollectDataService.MODULE_TYPE); - // 恢复后重试:DB 行删除 + 物理删除全部完成(首次失败那次调用 + 重试的 2 次)。 + // 恢复后重试:DB 行删除 + 物理删除全部完成(首次失败那次 mock 计数已记一次, + // 真实 DB 因事务回滚未生效,故 mock 视角共 2 次)。 service.deleteTask(8L, 7L); - verify(fileTaskMapper).deleteById(8L); + verify(fileTaskMapper, times(2)).deleteById(8L); verify(transientPayloadStorageService, times(3)).deletePayloadIfPresent(anyString()); } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataServiceTest.java index 3f74f08c..21df82cf 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataServiceTest.java @@ -49,6 +49,10 @@ class CollectDataServiceTest { @Mock private TaskDistributedLockService.LockHandle lockHandle; + /** 调度互斥锁(双节点 Redis 锁):测试内返回可用句柄,使 stale 收尾路径正常执行。 */ + @Mock(answer = org.mockito.Answers.RETURNS_MOCKS) + private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataStorageCallCountTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataStorageCallCountTest.java index 0ac8f890..37834058 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataStorageCallCountTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataStorageCallCountTest.java @@ -107,6 +107,7 @@ class CollectDataStorageCallCountTest { mock(com.nanri.aiimage.modules.collectdata.service.CollectDataExcelAssemblyService.class), mock(com.nanri.aiimage.modules.file.service.oss.OssStorageService.class), objectMapper, txTemplate, + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS), mock(com.nanri.aiimage.modules.collectdata.util.CollectDataBatchQuery.class), mock(com.nanri.aiimage.modules.collectdata.util.CollectDataBrandBatchFilter.class), mock(com.nanri.aiimage.modules.collectdata.util.CollectDataInvalidAsinBatchWriter.class), @@ -282,12 +283,12 @@ class CollectDataStorageCallCountTest { assertThatThrownBy(() -> service.deleteHistory(16L, 7L)) .isInstanceOf(RuntimeException.class) .hasMessage("rustfs down"); - verify(fileResultMapper, never()).deleteById(16L); - assertThat(CollectDataService.MODULE_TYPE).as("结果行未删除(异常传播)") - .isEqualTo(CollectDataService.MODULE_TYPE); + // 实现已改为"事务内先删 DB 行、提交后再物理删除对象":行删除先于物理删除发生, + // 物理删除抛错时真实 DB 回滚,但 mock 计数已记一次(该次删除未生效)。 + verify(fileResultMapper, times(1)).deleteById(16L); service.deleteHistory(16L, 7L); - verify(fileResultMapper).deleteById(16L); + verify(fileResultMapper, times(2)).deleteById(16L); verify(transientPayloadStorageService, times(3)).deletePayloadIfPresent(anyString()); } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java index 48e34ef1..12d596f6 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java @@ -61,7 +61,8 @@ class ImageVideoAsyncTaskServiceTest { TaskExecutor directExecutor = Runnable::run; ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService( taskMapper, cozeService, workflowConfigService, mock(ImageVideoArchiveService.class), objectMapper, - directExecutor, instanceMetadata("server-121")); + directExecutor, instanceMetadata("server-121"), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS)); ImageVideoAsyncTaskEntity task = waitingDouyinTask(); Map cozeResult = Map.of("data", List.of(Map.of( @@ -97,7 +98,8 @@ class ImageVideoAsyncTaskServiceTest { ImageVideoWorkflowConfigService workflowConfigService = mock(ImageVideoWorkflowConfigService.class); ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService( taskMapper, cozeService, workflowConfigService, mock(ImageVideoArchiveService.class), new ObjectMapper(), - Runnable::run, instanceMetadata("server-121")); + Runnable::run, instanceMetadata("server-121"), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS)); ImageVideoAsyncTaskEntity task = waitingDouyinTask(); Map cozeResult = Map.of("data", List.of(Map.of("execute_status", "Running"))); @@ -125,7 +127,8 @@ class ImageVideoAsyncTaskServiceTest { ImageVideoArchiveService archiveService = mock(ImageVideoArchiveService.class); ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService( taskMapper, cozeService, workflowConfigService, archiveService, new ObjectMapper(), Runnable::run, - instanceMetadata("server-121")); + instanceMetadata("server-121"), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS)); ImageVideoAsyncTaskEntity task = waitingWorkflowTask(); Map cozeResult = Map.of("data", List.of(Map.of( "execute_status", "Fail", @@ -158,7 +161,8 @@ class ImageVideoAsyncTaskServiceTest { mock(ImageVideoArchiveService.class), new ObjectMapper(), Runnable::run, - instanceMetadata("server-121")); + instanceMetadata("server-121"), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS)); ImageVideoAsyncTaskEntity task = waitingWorkflowTask(); task.setStatus("FAILED"); task.setCozeStatus("FAIL"); @@ -182,7 +186,8 @@ class ImageVideoAsyncTaskServiceTest { mock(ImageVideoArchiveService.class), new ObjectMapper(), Runnable::run, - instanceMetadata("server-121")); + instanceMetadata("server-121"), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS)); ImageVideoAsyncTaskEntity task = waitingWorkflowTask(); task.setStatus("FAILED"); task.setCozeStatus("RUNNING"); @@ -206,7 +211,8 @@ class ImageVideoAsyncTaskServiceTest { mock(ImageVideoArchiveService.class), new ObjectMapper(), Runnable::run, - instanceMetadata("server-121")); + instanceMetadata("server-121"), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS)); when(taskMapper.delete(any())).thenReturn(3); service.cleanupFailedTasks(); @@ -224,7 +230,8 @@ class ImageVideoAsyncTaskServiceTest { mock(ImageVideoArchiveService.class), new ObjectMapper(), Runnable::run, - instanceMetadata("server-121")); + instanceMetadata("server-121"), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS)); DouyinCopyRequest request = new DouyinCopyRequest(); request.setUserId(1L); request.setUrl("https://example.com/video"); @@ -246,7 +253,8 @@ class ImageVideoAsyncTaskServiceTest { mock(ImageVideoArchiveService.class), new ObjectMapper(), Runnable::run, - instanceMetadata("server-121")); + instanceMetadata("server-121"), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS)); ImageVideoAsyncTaskEntity task = waitingWorkflowTask(); task.setOwnerInstanceId("server-110"); when(taskMapper.selectOne(any())).thenReturn(task); @@ -270,7 +278,8 @@ class ImageVideoAsyncTaskServiceTest { mock(ImageVideoArchiveService.class), new ObjectMapper(), Runnable::run, - instanceMetadata("server-121")); + instanceMetadata("server-121"), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS)); when(taskMapper.requeuePollingTasks("server-121")).thenReturn(2); service.recoverInterruptedPollingTasks(); @@ -290,7 +299,8 @@ class ImageVideoAsyncTaskServiceTest { mock(ImageVideoArchiveService.class), new ObjectMapper(), Runnable::run, - instanceMetadata("server-121")); + instanceMetadata("server-121"), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS)); ImageVideoAsyncTaskEntity task = waitingWorkflowTask(); task.setSubmittedAt(LocalDateTime.now().minusHours(2)); Map cozeResult = Map.of("data", List.of(Map.of( @@ -323,7 +333,8 @@ class ImageVideoAsyncTaskServiceTest { archiveService, new ObjectMapper(), Runnable::run, - instanceMetadata("server-121")); + instanceMetadata("server-121"), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS)); ImageVideoAsyncTaskEntity task = waitingWorkflowTask(); Map cozeResult = Map.of("data", List.of(Map.of( "execute_status", "Success", @@ -360,7 +371,8 @@ class ImageVideoAsyncTaskServiceTest { archiveService, new ObjectMapper(), Runnable::run, - instanceMetadata("server-121")); + instanceMetadata("server-121"), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS)); ImageVideoAsyncTaskEntity task = waitingVoiceListTask(); String error = "Query voices failed: invalid API key (status: 1004)"; Map cozeResult = Map.of("data", List.of(Map.of( @@ -396,7 +408,8 @@ class ImageVideoAsyncTaskServiceTest { mock(ImageVideoArchiveService.class), new ObjectMapper(), Runnable::run, - instanceMetadata("server-121")); + instanceMetadata("server-121"), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS)); ImageVideoAsyncTaskEntity task = waitingVoiceListTask(); Map cozeResult = Map.of("data", List.of(Map.of( "execute_status", "Success", @@ -429,7 +442,8 @@ class ImageVideoAsyncTaskServiceTest { mock(ImageVideoArchiveService.class), new ObjectMapper(), Runnable::run, - instanceMetadata("server-121")); + instanceMetadata("server-121"), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS)); ImageVideoAsyncTaskEntity task = waitingVoiceListTask(); Map cozeResult = Map.of("data", List.of(Map.of( "execute_status", "Success", diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishBatchLoadingSnapshotEquivTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishBatchLoadingSnapshotEquivTest.java index 61233216..69e58d1e 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishBatchLoadingSnapshotEquivTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishBatchLoadingSnapshotEquivTest.java @@ -301,6 +301,7 @@ class PublishBatchLoadingSnapshotEquivTest { mock(TaskScopeStateMapper.class), taskFileJobService, mock(TaskDistributedLockService.class), mock(TransientPayloadStorageService.class), ossStorageService, new ObjectMapper(), mock(TransactionTemplate.class), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS), mock(InstanceMetadata.class), mock(TaskProgressLightAssembler.class)); } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishDashboardAggregateTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishDashboardAggregateTest.java index 758e4547..e5ffd405 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishDashboardAggregateTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishDashboardAggregateTest.java @@ -179,6 +179,7 @@ class PublishDashboardAggregateTest { mock(TaskScopeStateMapper.class), taskFileJobService, mock(TaskDistributedLockService.class), mock(TransientPayloadStorageService.class), ossStorageService, new ObjectMapper(), mock(TransactionTemplate.class), + mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS), mock(InstanceMetadata.class), mock(TaskProgressLightAssembler.class)); } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/PublishFileLevelStatusTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/PublishFileLevelStatusTest.java index 784214a9..9de1e74c 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/PublishFileLevelStatusTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/PublishFileLevelStatusTest.java @@ -73,7 +73,8 @@ class PublishFileLevelStatusTest { localFileStorageService, ziniaoShopSwitchService, workbookService, publishFileMapper, publishItemMapper, fileTaskMapper, fileResultMapper, taskChunkMapper, taskScopeStateMapper, taskFileJobService, taskDistributedLockService, transientPayloadStorageService, ossStorageService, - objectMapper, transactionTemplate, instanceMetadata, taskProgressLightAssembler); + objectMapper, transactionTemplate, org.mockito.Mockito.mock(com.nanri.aiimage.common.service.DistributedJobLockService.class, org.mockito.Answers.RETURNS_MOCKS), + instanceMetadata, taskProgressLightAssembler); lenient().when(ossStorageService.generateFreshDownloadUrl(any())) .thenReturn("https://dl.example/publish-result.zip"); }