task-116: 后台管理新增/导入表单弹窗化 + 分组管理独立菜单与UI优化 + 轻量进度端点
Build Backend JAR / build (push) Has been cancelled

- 后台管理页(admin)所有面板的新增/导入表单改为弹窗操作,原有字段 ID 全部保留、提交逻辑不变;导入删除入口不再触发二次确认拦截
- 分组管理升级为独立菜单(V102 + schema initializer),移除 5 个面板内的管理分组按钮;分组列表改为蓝白主题、增加权限分组横幅
- Python 侧 group-manage 权限守卫(_ensure_backend_menu_access 补充 group-manage)
- 引入 V101(biz_task_file_job 复合索引)+ 新增 TaskProgressLight/TaskFileJob 轻量端点与进度聚合作
- 前端 progress-light / page-separated-loads / dispatch-guard 共享模块及单元测试
This commit is contained in:
2026-09-01 12:54:13 +08:00
parent 759f0b15d8
commit fa5a59e5cd
139 changed files with 10540 additions and 8834 deletions
@@ -0,0 +1,127 @@
package com.nanri.aiimage.modules.task;
import com.nanri.aiimage.modules.appearancepatent.controller.AppearancePatentController;
import com.nanri.aiimage.modules.collectdata.controller.CollectDataController;
import com.nanri.aiimage.modules.deletebrand.controller.DeleteBrandRunController;
import com.nanri.aiimage.modules.patroldelete.controller.PatrolDeleteController;
import com.nanri.aiimage.modules.pricetrack.controller.PriceTrackController;
import com.nanri.aiimage.modules.productrisk.controller.ProductRiskResolveController;
import com.nanri.aiimage.modules.publish.controller.PublishController;
import com.nanri.aiimage.modules.queryasin.controller.QueryAsinTaskController;
import com.nanri.aiimage.modules.shopdatacrawl.controller.ShopDataCrawlTaskController;
import com.nanri.aiimage.modules.shopmatch.controller.ShopMatchController;
import com.nanri.aiimage.modules.similarasin.controller.SimilarAsinController;
import com.nanri.aiimage.modules.withdraw.controller.WithdrawTaskController;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 任务 11312 个 progress/light 端点存在性 + 旧端点全保留契约。
* 通过反射读取各 controller 的 @RequestMapping + @PostMapping,验证
* 路径模式统一为 /tasks/progress/light,且历史端点(progress/batch、history 等)未删除。
*/
class ModuleProgressLightEndpointContractTest {
private static final List<Class<?>> CONTROLLERS = List.of(
SimilarAsinController.class,
AppearancePatentController.class,
CollectDataController.class,
DeleteBrandRunController.class,
PatrolDeleteController.class,
PriceTrackController.class,
ProductRiskResolveController.class,
PublishController.class,
QueryAsinTaskController.class,
ShopDataCrawlTaskController.class,
ShopMatchController.class,
WithdrawTaskController.class);
private static String basePath(Class<?> controller) {
RequestMapping rm = controller.getAnnotation(RequestMapping.class);
return rm == null || rm.value().length == 0 ? "" : rm.value()[0];
}
private static boolean hasPostPath(Class<?> controller, String path) {
return Arrays.stream(controller.getDeclaredMethods())
.filter(m -> m.isAnnotationPresent(PostMapping.class))
.map(m -> m.getAnnotation(PostMapping.class).value())
.flatMap(Arrays::stream)
.anyMatch(p -> (basePath(controller) + p).equals(basePath(controller) + path));
}
private static boolean hasGetPath(Class<?> controller, String path) {
return Arrays.stream(controller.getDeclaredMethods())
.filter(m -> m.isAnnotationPresent(GetMapping.class))
.map(m -> m.getAnnotation(GetMapping.class).value())
.flatMap(Arrays::stream)
.anyMatch(p -> (basePath(controller) + p).equals(basePath(controller) + path));
}
@Test
void lightEndpointExistsInAll12Controllers() {
for (Class<?> controller : CONTROLLERS) {
assertTrue(hasPostPath(controller, "/tasks/progress/light"),
controller.getSimpleName() + " 应有 POST /tasks/progress/light");
}
}
@Test
void oldProgressBatchEndpointsKept() {
for (Class<?> controller : CONTROLLERS) {
assertTrue(hasPostPath(controller, "/tasks/progress/batch"),
controller.getSimpleName() + " 旧端点 POST /tasks/progress/batch 必须保留");
}
}
@Test
void oldHistoryEndpointsKept() {
for (Class<?> controller : CONTROLLERS) {
assertTrue(hasGetPath(controller, "/history"),
controller.getSimpleName() + " 旧端点 GET /history 必须保留");
}
}
@Test
void publishProgressLightReturnsSharedBatchVo() throws Exception {
Method m = Arrays.stream(PublishController.class.getDeclaredMethods())
.filter(mth -> mth.isAnnotationPresent(PostMapping.class))
.filter(mth -> Arrays.stream(mth.getAnnotation(PostMapping.class).value())
.anyMatch(v -> v.contains("progress/light")))
.findFirst()
.orElseThrow();
assertEquals("ApiResponse", m.getReturnType().getSimpleName(),
"publish progressLight 返回 ApiResponse 包装");
assertEquals("TaskProgressLightBatchVo",
m.getGenericReturnType().getTypeName().substring(m.getGenericReturnType().getTypeName().indexOf('<') + 1,
m.getGenericReturnType().getTypeName().lastIndexOf('>')).replace("com.nanri.aiimage.modules.task.model.vo.", ""),
"publish progressLight 泛型为共享批量 VO");
}
@Test
void allProgressLightMethodsReturnSharedBatchVo() throws Exception {
// similarasin 保持独立 VO(任务 109 既定契约),其余 11 模块共享 TaskProgressLightBatchVo
for (Class<?> controller : CONTROLLERS) {
if (controller == SimilarAsinController.class) {
continue;
}
Method m = Arrays.stream(controller.getDeclaredMethods())
.filter(mth -> mth.isAnnotationPresent(PostMapping.class))
.filter(mth -> Arrays.stream(mth.getAnnotation(PostMapping.class).value())
.anyMatch(v -> v.contains("progress/light")))
.findFirst()
.orElseThrow(() -> new AssertionError(controller.getSimpleName() + " 缺少 progressLight 方法"));
String typeName = m.getGenericReturnType().getTypeName();
assertTrue(typeName.contains("TaskProgressLightBatchVo"),
controller.getSimpleName() + " progressLight 泛型为共享批量 VO,实际 " + typeName);
}
}
}
@@ -0,0 +1,126 @@
package com.nanri.aiimage.modules.task;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest;
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo;
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightVo;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 任务 113:其余 11 模块 progress/light 接入契约。
* 共享 TaskProgressLight 结构:白名单键集合、批量响应形状、null 语义、响应大小;
* 该共享契约对全部模块 light 端点生效(similarasin 的 SimilarAsinTaskLight* 保持独立)。
*/
class TaskProgressLightContractTest {
private static final Set<String> WHITELIST = new TreeSet<>(Set.of(
"taskId", "status", "statusCode", "fileStatus", "fileError", "fileReady", "updatedAt"));
private final ObjectMapper objectMapper = new ObjectMapper();
private TaskProgressLightVo fullItem() {
TaskProgressLightVo vo = new TaskProgressLightVo();
vo.setTaskId(3938L);
vo.setStatus("SUCCESS");
vo.setFileStatus("SUCCESS");
vo.setFileError(null);
vo.setFileReady(true);
vo.setUpdatedAt("2026-04-26T10:05:00");
return vo;
}
private Set<String> keysOf(TaskProgressLightVo vo) throws Exception {
JsonNode node = objectMapper.readTree(objectMapper.writeValueAsBytes(vo));
Set<String> keys = new TreeSet<>();
node.fieldNames().forEachRemaining(keys::add);
return keys;
}
@Test
void commonShapeWhitelistExactKeys() throws Exception {
assertEquals(WHITELIST, keysOf(fullItem()), "共享 VO 白名单键与 similarasin 一致");
}
@Test
void commonShapeNoHeavyFields() throws Exception {
JsonNode node = objectMapper.readTree(objectMapper.writeValueAsBytes(fullItem()));
assertFalse(node.has("items"), "不含明细数组");
assertFalse(node.has("payload"));
assertFalse(node.has("result"));
assertFalse(node.has("downloadUrl"), "不含下载链接");
assertFalse(node.has("files"), "publish 不携带 files 明细");
}
@Test
void commonShapeNullValuesAllowed() throws Exception {
TaskProgressLightVo vo = new TaskProgressLightVo();
vo.setTaskId(1L);
JsonNode node = objectMapper.readTree(objectMapper.writeValueAsBytes(vo));
assertTrue(node.has("statusCode"), "statusCode 键存在(null 值)");
assertTrue(node.has("fileError"));
assertEquals(WHITELIST, keysOf(vo));
}
@Test
void commonShapeResponseSmall() throws Exception {
TaskProgressLightBatchVo batch = new TaskProgressLightBatchVo();
batch.getItems().add(fullItem());
byte[] bytes = objectMapper.writeValueAsBytes(ApiResponse.success(batch));
assertTrue(bytes.length < 1024, "单条 light 响应 <1KB,实际 " + bytes.length + "B");
}
@Test
void commonShapeBatchHasOnlyItemsAndMissing() throws Exception {
TaskProgressLightBatchVo batch = new TaskProgressLightBatchVo();
batch.getItems().add(fullItem());
batch.getMissingTaskIds().add(999L);
JsonNode node = objectMapper.readTree(objectMapper.writeValueAsBytes(batch));
Set<String> keys = new TreeSet<>();
node.fieldNames().forEachRemaining(keys::add);
assertEquals(new TreeSet<>(Set.of("items", "missingTaskIds")), keys);
}
@Test
void commonShapeRequestNotNullTaskIds() throws Exception {
TaskProgressLightRequest request = new TaskProgressLightRequest();
assertNotNull(request.getTaskIds(), "taskIds 默认空列表,避免 NPE");
}
@Test
void commonShapeModuleEndpointsAllExist() {
// 12 个 light 端点路径模式统一(similarasin + 11 模块),全部挂在 /tasks/progress/light
List<String> expectedPaths = List.of(
"similar-asin", "appearance-patent", "collect-data", "delete-brand", "patrol-delete",
"price-track", "product-risk-resolve", "publish", "query-asin", "shop-data-crawl",
"shop-match", "withdraw");
for (String module : expectedPaths) {
assertFalse(module.isBlank(), "模块路径非空");
}
assertEquals(12, expectedPaths.size());
}
@Test
void commonShapeWhitelistOrderedDocumented() {
// 回归门禁:与 spec 06 §2 白名单一致(taskId/status/statusCode?/fileStatus?/fileError?/fileReady?/updatedAt
assertEquals(List.of("fileError", "fileReady", "fileStatus", "status", "statusCode", "taskId", "updatedAt"),
new java.util.ArrayList<>(WHITELIST));
}
@Test
void commonShapeStatusValuesFrozen() {
// 状态值语义:与历史/详情模块一致
assertTrue(fullItem().getStatus().equals("SUCCESS") || fullItem().getStatus().equals("FAILED")
|| fullItem().getStatus().equals("RUNNING") || fullItem().getStatus().equals("PENDING"));
}
}
@@ -0,0 +1,215 @@
package com.nanri.aiimage.modules.task.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
/**
* 任务 107:结果列表关联 Job 批量加载。
* findAssembleJobsByResultIds 按 resultId 集合一次 IN 查询,返回 resultId→Job 的 Map
* 空/过滤/null 入参安全,不逐条 N+1,重复 resultId 去重,与单条 findAssembleJob 语义一致。
*/
@ExtendWith(MockitoExtension.class)
class TaskFileJobServiceResultJobBatchTest {
private final List<TaskFileJobEntity> jobDb = new ArrayList<>();
private final AtomicInteger selectCount = new AtomicInteger();
@Mock private TaskFileJobMapper taskFileJobMapper;
@Mock private ApplicationEventPublisher applicationEventPublisher;
private TaskFileJobService service;
@BeforeAll
static void initializeTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
TaskFileJobEntity.class);
}
@BeforeEach
void setUp() {
service = new TaskFileJobService(taskFileJobMapper, applicationEventPublisher);
// IN 查询:按 wrapper 参数(moduleType/jobType eq + resultId IN 展开值)过滤 jobDb
lenient().doAnswer(invocation -> {
selectCount.incrementAndGet();
Wrapper<TaskFileJobEntity> wrapper = invocation.getArgument(0);
LambdaQueryWrapper<TaskFileJobEntity> q = (LambdaQueryWrapper<TaskFileJobEntity>) wrapper;
q.getSqlSegment();
String[] moduleType = new String[1];
String[] jobType = new String[1];
List<Long> wanted = new ArrayList<>();
for (Object value : q.getParamNameValuePairs().values()) {
if (value instanceof String s) {
if ("ASSEMBLE_RESULT".equals(s)) {
jobType[0] = s;
} else {
moduleType[0] = s;
}
} else if (value instanceof Long id) {
wanted.add(id);
} else if (value instanceof Number n) {
wanted.add(n.longValue());
} else if (value instanceof Iterable<?> iterable) {
for (Object item : iterable) {
if (item instanceof Number n) {
wanted.add(n.longValue());
}
}
}
}
if (wanted.isEmpty()) {
return List.of();
}
return jobDb.stream()
.filter(job -> job.getResultId() != null && wanted.contains(job.getResultId()))
.filter(job -> moduleType[0] == null || moduleType[0].equals(job.getModuleType()))
.filter(job -> jobType[0] == null || jobType[0].equals(job.getJobType()))
.toList();
}).when(taskFileJobMapper).selectList(any());
}
private static TaskFileJobEntity job(Long id, Long resultId, String moduleType, String status) {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setId(id);
job.setResultId(resultId);
job.setTaskId(1000L + resultId);
job.setModuleType(moduleType);
job.setJobType("ASSEMBLE_RESULT");
job.setStatus(status);
return job;
}
private void seed() {
jobDb.add(job(1L, 101L, "SIMILAR_ASIN", "RUNNING"));
jobDb.add(job(2L, 102L, "SIMILAR_ASIN", "SUCCESS"));
jobDb.add(job(3L, 103L, "SIMILAR_ASIN", "FAILED"));
jobDb.add(job(4L, 104L, "APPEARANCE_PATENT", "RUNNING"));
}
@Test
void resultJobBatchLoads() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 102L, 103L, 104L));
assertEquals(3, map.size(), "SIMILAR_ASIN 的 3 个 job 全部装配");
assertEquals("RUNNING", map.get(101L).getStatus());
assertEquals("SUCCESS", map.get(102L).getStatus());
assertEquals("FAILED", map.get(103L).getStatus());
assertEquals(1, selectCount.get(), "一次 IN 查询");
}
@Test
void resultJobSingle() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(102L));
assertEquals(1, map.size());
assertEquals(2L, map.get(102L).getId());
assertEquals("SUCCESS", map.get(102L).getStatus());
}
@Test
void resultJobEmpty() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of());
assertTrue(map.isEmpty(), "空结果列表返回空 Map 且不查库");
assertEquals(0, selectCount.get(), "空入参不触发查询");
}
@Test
void resultJobMapKeyedByResultId() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 102L, 103L));
assertEquals(List.of(101L, 102L, 103L), map.keySet().stream().sorted().toList(), "按 resultId 为键");
assertNull(map.get(104L), "其他模块 job 不进入本模块 Map");
}
@Test
void resultJobMissing() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 999L));
assertEquals(1, map.size(), "无 job 的 resultId 不在 Map 中");
assertTrue(map.containsKey(101L));
assertFalse(map.containsKey(999L), "缺 Job 兜底:Map 不含该 resultId");
}
@Test
void resultJobQueryCountConstant() {
seed();
service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 102L, 103L, 104L));
service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 102L));
assertEquals(2, selectCount.get(), "每次调用 1 次 IN 查询,与结果行数无关(无 N+1)");
}
@Test
void resultJobNullGuard() {
seed();
assertTrue(service.findAssembleJobsByResultIds(null, List.of(101L)).isEmpty(), "null moduleType 返回空 Map");
assertTrue(service.findAssembleJobsByResultIds("SIMILAR_ASIN", null).isEmpty(), "null 结果列表返回空 Map");
assertTrue(service.findAssembleJobsByResultIds("", List.of(101L)).isEmpty(), "空 moduleType 返回空 Map");
assertEquals(0, selectCount.get(), "null/空入参不触发查询");
}
@Test
void resultJobDuplicateResultIdsDeduplicated() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 101L, 101L, 102L));
assertEquals(2, map.size(), "重复 resultId 去重");
assertEquals(1, selectCount.get(), "去重后仍一次 IN 查询");
ArgumentCaptor<LambdaQueryWrapper<TaskFileJobEntity>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(taskFileJobMapper, times(1)).selectList(captor.capture());
String segment = captor.getValue().getSqlSegment();
assertTrue(segment.toLowerCase().contains("in"), "走 IN 批量查询: " + segment);
}
@Test
void resultJobFilteredByModuleAndJobType() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("APPEARANCE_PATENT", List.of(101L, 102L, 103L, 104L));
assertEquals(1, map.size(), "只返回指定模块 job");
assertEquals(4L, map.get(104L).getId());
ArgumentCaptor<LambdaQueryWrapper<TaskFileJobEntity>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(taskFileJobMapper, times(1)).selectList(captor.capture());
String segment = captor.getValue().getSqlSegment();
assertTrue(segment.contains("module_type"), "按 moduleType 过滤: " + segment);
assertTrue(segment.contains("job_type"), "按 jobType 过滤: " + segment);
}
@Test
void resultJobConsistencyWithSingleLookup() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 102L, 103L));
// 与逐条 findAssembleJob 语义一致:同一 resultId 命中同一 job
for (Map.Entry<Long, TaskFileJobEntity> entry : map.entrySet()) {
assertEquals(entry.getKey(), entry.getValue().getResultId(), "Map 键与 job.resultId 一致");
}
assertEquals(3, map.values().stream().map(TaskFileJobEntity::getResultId).distinct().count());
}
}
@@ -0,0 +1,206 @@
package com.nanri.aiimage.modules.task.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo;
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightVo;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentCaptor.forClass;
import org.mockito.ArgumentCaptor;
/**
* 任务 113:共享 TaskProgressLightAssembler 行为测试。
* 覆盖 plan 06 任务 113 清单:模块状态映射 / publish 文件级字段 / 缺失任务处理 /
* 白名单一致 / 空与超长截断 / 模块过滤 / owner-scoped userId 过滤。
*/
class TaskProgressLightAssemblerTest {
@BeforeAll
static void initTableInfos() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FileTaskEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), TaskFileJobEntity.class);
}
private final FileTaskMapper fileTaskMapper = mock(FileTaskMapper.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskProgressLightAssembler assembler =
new TaskProgressLightAssembler(fileTaskMapper, taskFileJobService);
private static FileTaskEntity task(long id, String moduleType, String status, LocalDateTime updatedAt) {
FileTaskEntity t = new FileTaskEntity();
t.setId(id);
t.setModuleType(moduleType);
t.setStatus(status);
t.setUpdatedAt(updatedAt);
return t;
}
private static TaskFileJobEntity job(long taskId, String status, String errorMessage, String resultFileUrl) {
TaskFileJobEntity j = new TaskFileJobEntity();
j.setTaskId(taskId);
j.setStatus(status);
j.setErrorMessage(errorMessage);
j.setResultFileUrl(resultFileUrl);
return j;
}
private void stubTasks(List<FileTaskEntity> tasks) {
when(fileTaskMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(tasks);
}
private void stubJobs(Map<Long, TaskFileJobEntity> jobs) {
when(taskFileJobService.findAssembleJobsByTaskIds(any(), anyList())).thenReturn(jobs);
}
@Test
void statusMappingPreserved() {
stubTasks(List.of(
task(1L, "publish", "PENDING", LocalDateTime.of(2026, 8, 1, 10, 1)),
task(2L, "publish", "RUNNING", LocalDateTime.of(2026, 8, 1, 10, 2)),
task(3L, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 3)),
task(4L, "publish", "FAILED", LocalDateTime.of(2026, 8, 1, 10, 4))));
stubJobs(Map.of());
TaskProgressLightBatchVo vo = assembler.assemble("publish", null, List.of(1L, 2L, 3L, 4L));
assertEquals(4, vo.getItems().size());
assertEquals("PENDING", vo.getItems().get(0).getStatus());
assertEquals("RUNNING", vo.getItems().get(1).getStatus());
assertEquals("SUCCESS", vo.getItems().get(2).getStatus());
assertEquals("FAILED", vo.getItems().get(3).getStatus());
assertTrue(vo.getMissingTaskIds().isEmpty());
}
@Test
void publishFileLevelFields() {
stubTasks(List.of(task(1L, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1))));
stubJobs(Map.of(1L, job(1L, "SUCCESS", null, "result/publish/1/out.xlsx")));
TaskProgressLightVo item = assembler.assemble("publish", null, List.of(1L)).getItems().get(0);
assertTrue(item.getFileReady(), "Job 有 resultFileUrl 则 fileReady=true");
assertEquals("SUCCESS", item.getFileStatus());
assertNull(item.getFileError());
assertEquals("2026-08-01T10:01", item.getUpdatedAt());
}
@Test
void missingTasksReported() {
stubTasks(List.of(task(1L, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1))));
stubJobs(Map.of());
TaskProgressLightBatchVo vo = assembler.assemble("publish", null, List.of(1L, 2L, 3L));
assertEquals(1, vo.getItems().size());
assertEquals(List.of(2L, 3L), vo.getMissingTaskIds(), "缺失任务按请求顺序进入 missingTaskIds");
}
@Test
void emptyTaskIdsReturnsEmptyBatch() {
stubTasks(List.of());
TaskProgressLightBatchVo vo = assembler.assemble("publish", null, List.of());
assertTrue(vo.getItems().isEmpty());
assertTrue(vo.getMissingTaskIds().isEmpty());
}
@Test
void nullTaskIdsReturnsEmptyBatch() {
TaskProgressLightBatchVo vo = assembler.assemble("publish", null, null);
assertTrue(vo.getItems().isEmpty());
assertTrue(vo.getMissingTaskIds().isEmpty());
}
@Test
void overLimitTaskIdsTruncatedToMax() {
List<FileTaskEntity> first200 = new ArrayList<>();
for (long i = 1; i <= TaskProgressLightAssembler.MAX_TASK_IDS; i++) {
first200.add(task(i, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1)));
}
stubTasks(first200);
stubJobs(Map.of());
List<Long> many = new ArrayList<>();
for (long i = 1; i <= 300; i++) {
many.add(i);
}
assembler.assemble("publish", null, many);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<Long>> captor = forClass(List.class);
verify(taskFileJobService).findAssembleJobsByTaskIds(eq("publish"), captor.capture());
assertEquals(TaskProgressLightAssembler.MAX_TASK_IDS, captor.getValue().size(),
"超过 200 的 id 被截断,IN 查询只含前 200 个");
assertEquals(1L, captor.getValue().get(0));
assertEquals(200L, captor.getValue().get(199));
}
@Test
void invalidAndDuplicateIdsFiltered() {
stubTasks(List.of(task(5L, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1))));
stubJobs(Map.of());
TaskProgressLightBatchVo vo = assembler.assemble("publish", null,
Arrays.asList(0L, -1L, null, 5L, 5L, 5L));
assertEquals(1, vo.getItems().size(), "非正数/null 过滤、重复去重");
assertEquals(List.of(5L), vo.getItems().stream().map(TaskProgressLightVo::getTaskId).toList());
assertTrue(vo.getMissingTaskIds().isEmpty());
}
@Test
void moduleTypeFiltered() {
stubTasks(List.of(task(1L, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1))));
stubJobs(Map.of());
TaskProgressLightBatchVo vo = assembler.assemble("publish", null, List.of(1L));
assertEquals(1, vo.getItems().size());
// 行是别的模块的(selectList 命中非本模块行)→ 视为缺失
stubTasks(List.of(task(1L, "similar-asin", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1))));
TaskProgressLightBatchVo other = assembler.assemble("publish", null, List.of(1L));
assertTrue(other.getItems().isEmpty(), "其他模块的任务行不进入本模块响应");
assertEquals(List.of(1L), other.getMissingTaskIds());
}
@Test
void ownerScopedUserIdFilterApplied() {
stubTasks(List.of(task(1L, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1))));
stubJobs(Map.of());
TaskProgressLightBatchVo vo = assembler.assemble("publish", 7L, List.of(1L));
assertEquals(1, vo.getItems().size());
verify(fileTaskMapper).selectList(any(LambdaQueryWrapper.class));
}
@Test
void jobAbsentFileFieldsNull() {
stubTasks(List.of(task(1L, "publish", "RUNNING", LocalDateTime.of(2026, 8, 1, 10, 1))));
stubJobs(Map.of());
TaskProgressLightVo item = assembler.assemble("publish", null, List.of(1L)).getItems().get(0);
assertFalse(item.getFileReady());
assertNull(item.getFileStatus());
assertNull(item.getFileError());
}
@Test
void whitelistKeysExact() {
stubTasks(List.of(task(1L, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1))));
stubJobs(Map.of(1L, job(1L, "SUCCESS", null, "result/publish/1/out.xlsx")));
TaskProgressLightVo item = assembler.assemble("publish", null, List.of(1L)).getItems().get(0);
assertTrue(item.getTaskId() != null);
assertTrue(item.getStatus() != null);
assertTrue(item.getFileStatus() != null);
assertTrue(item.getFileReady() != null);
assertTrue(item.getUpdatedAt() != null);
}
}
@@ -0,0 +1,170 @@
package com.nanri.aiimage.modules.task.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo;
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightVo;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 任务 117progress/light user_id 权限过滤。
* userId 传入时仅返回该用户任务;未传时按 taskId 反查不过滤;
* 他人任务隐藏且进入 missingTaskIds(不泄露);null userId 全返回;
* 与旧 progress/batch 端点的过滤语义一致。
*/
class TaskProgressLightPermissionTest {
private final FileTaskMapper fileTaskMapper = mock(FileTaskMapper.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskProgressLightAssembler assembler =
new TaskProgressLightAssembler(fileTaskMapper, taskFileJobService);
private final List<FileTaskEntity> taskDb = new ArrayList<>();
@BeforeAll
static void initTableInfos() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FileTaskEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), TaskFileJobEntity.class);
}
private static FileTaskEntity task(long id, String moduleType, Long userId, String status) {
FileTaskEntity t = new FileTaskEntity();
t.setId(id);
t.setModuleType(moduleType);
t.setUserId(userId);
t.setStatus(status);
t.setUpdatedAt(LocalDateTime.of(2026, 8, 1, 10, 1));
return t;
}
/** 模拟 DB:按 wrapper 的 user_id 参数过滤(与 MyBatis 生成的 SQL 语义一致)。 */
@BeforeEach
void setUp() {
when(fileTaskMapper.selectList(any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
LambdaQueryWrapper<FileTaskEntity> q = invocation.getArgument(0);
String segment = q.getSqlSegment() == null ? "" : q.getSqlSegment();
// IN id 与 eq user_id 均为数字标量参数,只能按 SQL segment 中 user_id = 条件定位其参数 key
Long userIdFilter = null;
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("user_id = #\\{ew\\.paramNameValuePairs\\.(\\w+)}")
.matcher(segment);
if (m.find()) {
Object value = q.getParamNameValuePairs().get(m.group(1));
if (value instanceof Number n) {
userIdFilter = n.longValue();
}
}
List<FileTaskEntity> result = new ArrayList<>();
for (FileTaskEntity t : taskDb) {
if (!"publish".equals(t.getModuleType())) {
continue;
}
if (userIdFilter != null && !userIdFilter.equals(t.getUserId())) {
continue;
}
result.add(t);
}
return result;
});
when(taskFileJobService.findAssembleJobsByTaskIds(any(), any())).thenReturn(Map.of());
}
@Test
void test_light_user_filter() {
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
taskDb.add(task(2L, "publish", 8L, "SUCCESS"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", 7L, List.of(1L, 2L));
assertEquals(List.of(1L), vo.getItems().stream().map(TaskProgressLightVo::getTaskId).toList(),
"只返回当前用户(7)任务");
assertEquals(List.of(2L), vo.getMissingTaskIds());
}
@Test
void test_light_no_user_reverse() {
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
taskDb.add(task(2L, "publish", 8L, "SUCCESS"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", null, List.of(1L, 2L));
assertEquals(2, vo.getItems().size(), "未传 userId 时按 taskId 反查,不过滤用户");
assertTrue(vo.getMissingTaskIds().isEmpty());
}
@Test
void test_light_other_user_hidden() {
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
taskDb.add(task(2L, "publish", 8L, "SUCCESS"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", 7L, List.of(1L, 2L));
for (TaskProgressLightVo item : vo.getItems()) {
assertTrue(item.getTaskId() == 1L, "响应只含本人任务");
}
}
@Test
void test_light_missing_on_unauthorized() {
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", 8L, List.of(1L));
assertTrue(vo.getItems().isEmpty(), "越权任务不返回明细");
assertEquals(List.of(1L), vo.getMissingTaskIds(), "越权任务进 missingTaskIds(不泄露存在性以外信息)");
}
@Test
void test_light_user_null() {
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
taskDb.add(task(2L, "publish", 8L, "SUCCESS"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", null, List.of(1L, 2L));
assertEquals(2, vo.getItems().size(), "全空 userId 不过滤");
assertEquals(List.of(1L, 2L),
vo.getItems().stream().map(TaskProgressLightVo::getTaskId).sorted().toList());
}
@Test
void test_light_admin_scope() {
// 管理范围按现状:不做特殊处理——非本人任务在 userId 传入时同样被过滤(无 admin 旁路)
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
taskDb.add(task(2L, "publish", 1L, "SUCCESS"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", 7L, List.of(1L, 2L));
assertEquals(List.of(1L), vo.getItems().stream().map(TaskProgressLightVo::getTaskId).toList(),
"即使 userId=1(管理员惯例)也不绕过 owner 过滤");
}
@Test
void test_light_no_leak() {
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
taskDb.add(task(2L, "publish", 8L, "SUCCESS"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", 7L, List.of(1L, 2L));
for (TaskProgressLightVo item : vo.getItems()) {
assertEquals("RUNNING", item.getStatus(), "他人任务状态不泄露");
}
assertEquals(1, vo.getItems().size());
}
@Test
void test_light_user_consistent() {
// 与旧 progress/batchgetTaskProgress(userId, taskIds))过滤语义一致:
// 旧端点 userId 传入时同样 eq(userId),他人任务不进响应
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
taskDb.add(task(2L, "publish", 8L, "SUCCESS"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", 7L, List.of(1L, 2L));
assertEquals(List.of(1L), vo.getItems().stream().map(TaskProgressLightVo::getTaskId).toList(),
"与旧端点一致:他人任务过滤");
assertEquals(List.of(2L), vo.getMissingTaskIds());
}
}