diff --git a/docs/plan/2026-07-16-todo-center-task-tracker.md b/docs/plan/2026-07-16-todo-center-task-tracker.md index 47fa3de21..1dbbc967a 100644 --- a/docs/plan/2026-07-16-todo-center-task-tracker.md +++ b/docs/plan/2026-07-16-todo-center-task-tracker.md @@ -42,8 +42,8 @@ PRD 同时要求“幂等键唯一”和“任务完成后允许同一业务重 | 批次 | 范围 | 优先级 | 状态 | 验收结果 | Commit | | --- | --- | --- | --- | --- | --- | | B01 | 任务拆分、数据表和索引 | P0 | done | SQL 结构和索引评审通过 | `e65017ab2` | -| B02 | 实体、命令模型、枚举、Mapper、Service | P0 | done | 模块编译和 Mapper XML 校验通过 | 本批提交 | -| B03 | 当前用户查询、已读、完成、取消接口及测试 | P0 | todo | - | - | +| B02 | 实体、命令模型、枚举、Mapper、Service | P0 | done | 模块编译和 Mapper XML 校验通过 | `181d4a5b4` | +| B03 | 当前用户查询、已读、完成、取消接口及测试 | P0 | done | 全量依赖编译、9 个专项测试通过 | 本批提交 | | B04 | 前端 API、待办中心页面和路由跳转 | P0 | todo | - | - | | B05 | 首页待办卡片、数量展示和轮询刷新 | P0 | todo | - | - | | B06 | 菜单权限 SQL、联调和首期回归 | P0 | todo | - | - | @@ -84,7 +84,7 @@ PRD 同时要求“幂等键唯一”和“任务完成后允许同一业务重 - 优先级:P0 - 批次:B03 -- 状态:todo +- 状态:done - 目标:实现未完成数量、首页列表、分页列表、详情、已读、全部已读、完成和取消接口。 - 验收标准: - 所有用户端查询和更新强制使用当前登录用户 ID。 @@ -175,4 +175,5 @@ PRD 同时要求“幂等键唯一”和“任务完成后允许同一业务重 - 2026-07-16,B01:完成优先级拆分、任务跟踪文档和 `sys_todo_task` 建表脚本;通过 SQL 人工结构评审与 `git diff --check`;commit `e65017ab2`。 - 2026-07-16,B01 环境进展:用户已在测试数据库创建 `sys_todo_task` 表,可供后续接口联调。 -- 2026-07-16,B02:完成领域模型、创建/查询 DTO、状态常量、Mapper 和统一 Service;`mvn -pl jsowell-system -am -DskipTests compile` 与 Mapper XML 语法校验通过;commit hash 在下一批记录中回填。 +- 2026-07-16,B02:完成领域模型、创建/查询 DTO、状态常量、Mapper 和统一 Service;`mvn -pl jsowell-system -am -DskipTests compile` 与 Mapper XML 语法校验通过;commit `181d4a5b4`。 +- 2026-07-16,B03:完成当前用户数量、首页列表、分页列表、详情、已读、全部已读、完成和取消接口;`mvn -pl jsowell-admin -am -DskipTests compile` 通过,9 个 Service/Controller 专项测试通过;commit hash 在下一批记录中回填。 diff --git a/jsowell-admin/src/main/java/com/jsowell/web/controller/system/SysTodoTaskController.java b/jsowell-admin/src/main/java/com/jsowell/web/controller/system/SysTodoTaskController.java new file mode 100644 index 000000000..8578c7802 --- /dev/null +++ b/jsowell-admin/src/main/java/com/jsowell/web/controller/system/SysTodoTaskController.java @@ -0,0 +1,118 @@ +package com.jsowell.web.controller.system; + +import com.jsowell.common.annotation.Log; +import com.jsowell.common.core.controller.BaseController; +import com.jsowell.common.core.domain.AjaxResult; +import com.jsowell.common.core.page.TableDataInfo; +import com.jsowell.common.enums.BusinessType; +import com.jsowell.system.domain.SysTodoTask; +import com.jsowell.system.domain.dto.TodoTaskCancelRequest; +import com.jsowell.system.domain.dto.TodoTaskQuery; +import com.jsowell.system.service.TodoTaskService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 当前登录用户待办任务接口。 + * + * @author jsowell + */ +@RestController +@RequestMapping("/system/todo") +public class SysTodoTaskController extends BaseController { + @Autowired + private TodoTaskService todoTaskService; + + /** + * 获取当前用户待办数量。 + */ + @PreAuthorize("@ss.hasPermi('system:todo:list')") + @GetMapping("/unreadCount") + public AjaxResult unreadCount() { + Long userId = getUserId(); + Map counts = new HashMap<>(2); + counts.put("activeCount", todoTaskService.countActive(userId)); + counts.put("unreadCount", todoTaskService.countUnread(userId)); + return AjaxResult.success(counts); + } + + /** + * 获取首页最近待办。 + */ + @PreAuthorize("@ss.hasPermi('system:todo:list')") + @GetMapping("/homeList") + public AjaxResult homeList(@RequestParam(required = false) Integer limit) { + return AjaxResult.success(todoTaskService.homeList(getUserId(), limit)); + } + + /** + * 分页查询当前用户待办。 + */ + @PreAuthorize("@ss.hasPermi('system:todo:list')") + @GetMapping("/list") + public TableDataInfo list(TodoTaskQuery query) { + startPage(); + List list = todoTaskService.listForAssignee(query, getUserId()); + return getDataTable(list); + } + + /** + * 查询当前用户待办详情。 + */ + @PreAuthorize("@ss.hasPermi('system:todo:query')") + @GetMapping("/{todoId}") + public AjaxResult getInfo(@PathVariable Long todoId) { + return AjaxResult.success(todoTaskService.getForAssignee(todoId, getUserId())); + } + + /** + * 标记当前用户的一条待办为已读。 + */ + @PreAuthorize("@ss.hasPermi('system:todo:read')") + @PutMapping("/{todoId}/read") + public AjaxResult markRead(@PathVariable Long todoId) { + return toAjax(todoTaskService.markRead(todoId, getUserId(), getUsername())); + } + + /** + * 标记当前用户全部待办为已读。 + */ + @PreAuthorize("@ss.hasPermi('system:todo:read')") + @PutMapping("/readAll") + public AjaxResult markAllRead() { + return AjaxResult.success(todoTaskService.markAllRead(getUserId(), getUsername())); + } + + /** + * 完成当前用户待办。 + */ + @PreAuthorize("@ss.hasPermi('system:todo:complete')") + @Log(title = "待办任务", businessType = BusinessType.UPDATE) + @PutMapping("/{todoId}/complete") + public AjaxResult complete(@PathVariable Long todoId) { + return toAjax(todoTaskService.complete(todoId, getUserId(), getUsername())); + } + + /** + * 取消当前用户待办。 + */ + @PreAuthorize("@ss.hasPermi('system:todo:cancel')") + @Log(title = "待办任务", businessType = BusinessType.UPDATE) + @PutMapping("/{todoId}/cancel") + public AjaxResult cancel(@PathVariable Long todoId, + @Validated @RequestBody TodoTaskCancelRequest request) { + return toAjax(todoTaskService.cancelForAssignee(todoId, getUserId(), getUsername(), request.getReason())); + } +} diff --git a/jsowell-admin/src/test/java/com/jsowell/system/service/TodoTaskServiceImplTest.java b/jsowell-admin/src/test/java/com/jsowell/system/service/TodoTaskServiceImplTest.java new file mode 100644 index 000000000..a8b2dea89 --- /dev/null +++ b/jsowell-admin/src/test/java/com/jsowell/system/service/TodoTaskServiceImplTest.java @@ -0,0 +1,149 @@ +package com.jsowell.system.service; + +import com.jsowell.common.exception.ServiceException; +import com.jsowell.system.constant.TodoTaskConstants; +import com.jsowell.system.domain.SysTodoTask; +import com.jsowell.system.domain.dto.TodoTaskCreateCommand; +import com.jsowell.system.domain.dto.TodoTaskQuery; +import com.jsowell.system.mapper.SysTodoTaskMapper; +import com.jsowell.system.service.impl.TodoTaskServiceImpl; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DuplicateKeyException; + +import java.lang.reflect.Field; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +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.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TodoTaskServiceImplTest { + @Test + void createTask_shouldSetDefaultsAndInsert() { + SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class); + TodoTaskServiceImpl service = newService(mapper); + TodoTaskCreateCommand command = createCommand(); + when(mapper.selectActiveByIdempotentKey("INVOICE:100:8")).thenReturn(null); + doAnswer(invocation -> { + SysTodoTask task = invocation.getArgument(0); + task.setTodoId(11L); + return 1; + }).when(mapper).insertTodoTask(any(SysTodoTask.class)); + + SysTodoTask result = service.createTask(command); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SysTodoTask.class); + verify(mapper).insertTodoTask(captor.capture()); + SysTodoTask inserted = captor.getValue(); + assertEquals(11L, result.getTodoId()); + assertEquals(TodoTaskConstants.PRIORITY_NORMAL, inserted.getPriority()); + assertEquals(TodoTaskConstants.STATUS_PENDING, inserted.getTaskStatus()); + assertEquals(TodoTaskConstants.READ_UNREAD, inserted.getReadStatus()); + assertEquals(inserted.getIdempotentKey(), inserted.getActiveIdempotentKey()); + } + + @Test + void createTask_shouldReturnExistingTask() { + SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class); + TodoTaskServiceImpl service = newService(mapper); + SysTodoTask existing = new SysTodoTask(); + existing.setTodoId(12L); + when(mapper.selectActiveByIdempotentKey("INVOICE:100:8")).thenReturn(existing); + + SysTodoTask result = service.createTask(createCommand()); + + assertSame(existing, result); + verify(mapper, never()).insertTodoTask(any(SysTodoTask.class)); + } + + @Test + void createTask_shouldRecoverFromConcurrentDuplicate() { + SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class); + TodoTaskServiceImpl service = newService(mapper); + SysTodoTask existing = new SysTodoTask(); + existing.setTodoId(13L); + when(mapper.selectActiveByIdempotentKey("INVOICE:100:8")).thenReturn(null, existing); + doThrow(new DuplicateKeyException("duplicate")) + .when(mapper).insertTodoTask(any(SysTodoTask.class)); + + SysTodoTask result = service.createTask(createCommand()); + + assertSame(existing, result); + } + + @Test + void listForAssignee_shouldOverrideRequestedUser() { + SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class); + TodoTaskServiceImpl service = newService(mapper); + TodoTaskQuery query = new TodoTaskQuery(); + query.setAssigneeUserId(999L); + when(mapper.selectTodoList(query)).thenReturn(Collections.emptyList()); + + service.listForAssignee(query, 8L); + + assertEquals(8L, query.getAssigneeUserId()); + verify(mapper).selectTodoList(query); + } + + @Test + void complete_shouldBeIdempotentWhenAlreadyCompleted() { + SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class); + TodoTaskServiceImpl service = newService(mapper); + SysTodoTask completed = new SysTodoTask(); + completed.setTaskStatus(TodoTaskConstants.STATUS_COMPLETED); + when(mapper.completeTodoForAssignee(20L, 8L, 8L, "tester")).thenReturn(0); + when(mapper.selectTodoByIdForAssignee(20L, 8L)).thenReturn(completed); + + assertTrue(service.complete(20L, 8L, "tester")); + } + + @Test + void complete_shouldRejectCancelledTask() { + SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class); + TodoTaskServiceImpl service = newService(mapper); + SysTodoTask cancelled = new SysTodoTask(); + cancelled.setTaskStatus(TodoTaskConstants.STATUS_CANCELLED); + when(mapper.completeTodoForAssignee(20L, 8L, 8L, "tester")).thenReturn(0); + when(mapper.selectTodoByIdForAssignee(20L, 8L)).thenReturn(cancelled); + + assertThrows(ServiceException.class, () -> service.complete(20L, 8L, "tester")); + } + + private static TodoTaskCreateCommand createCommand() { + return TodoTaskCreateCommand.builder() + .taskType("INVOICE_REVIEW") + .title("司机开票申请") + .businessType("INVOICE_APPLY") + .businessId("100") + .routeName("InvoiceApplyDetail") + .assigneeUserId(8L) + .idempotentKey("INVOICE:100:8") + .createBy("tester") + .build(); + } + + private static TodoTaskServiceImpl newService(SysTodoTaskMapper mapper) { + TodoTaskServiceImpl service = new TodoTaskServiceImpl(); + setField(service, "todoTaskMapper", mapper); + return service; + } + + private static void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } +} diff --git a/jsowell-admin/src/test/java/com/jsowell/web/controller/system/SysTodoTaskControllerTest.java b/jsowell-admin/src/test/java/com/jsowell/web/controller/system/SysTodoTaskControllerTest.java new file mode 100644 index 000000000..4b0da626f --- /dev/null +++ b/jsowell-admin/src/test/java/com/jsowell/web/controller/system/SysTodoTaskControllerTest.java @@ -0,0 +1,93 @@ +package com.jsowell.web.controller.system; + +import com.jsowell.common.core.domain.AjaxResult; +import com.jsowell.system.domain.SysTodoTask; +import com.jsowell.system.service.TodoTaskService; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SysTodoTaskControllerTest { + @Test + @SuppressWarnings("unchecked") + void unreadCount_shouldUseCurrentLoginUser() { + TodoTaskService service = mock(TodoTaskService.class); + TestController controller = newController(service, 8L, "tester"); + when(service.countActive(8L)).thenReturn(5); + when(service.countUnread(8L)).thenReturn(3); + + AjaxResult result = controller.unreadCount(); + + Map data = (Map) result.get(AjaxResult.DATA_TAG); + assertEquals(5, data.get("activeCount")); + assertEquals(3, data.get("unreadCount")); + } + + @Test + void getInfo_shouldNotAcceptAnotherAssignee() { + TodoTaskService service = mock(TodoTaskService.class); + TestController controller = newController(service, 8L, "tester"); + SysTodoTask task = new SysTodoTask(); + task.setTodoId(20L); + when(service.getForAssignee(20L, 8L)).thenReturn(task); + + AjaxResult result = controller.getInfo(20L); + + assertSame(task, result.get(AjaxResult.DATA_TAG)); + verify(service).getForAssignee(20L, 8L); + } + + @Test + void complete_shouldPassCurrentUserAndOperator() { + TodoTaskService service = mock(TodoTaskService.class); + TestController controller = newController(service, 8L, "tester"); + when(service.complete(20L, 8L, "tester")).thenReturn(true); + + controller.complete(20L); + + verify(service).complete(20L, 8L, "tester"); + } + + private static TestController newController(TodoTaskService service, Long userId, String username) { + TestController controller = new TestController(userId, username); + setField(controller, "todoTaskService", service); + return controller; + } + + private static void setField(Object target, String fieldName, Object value) { + try { + Field field = SysTodoTaskController.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } + + private static class TestController extends SysTodoTaskController { + private final Long userId; + private final String username; + + private TestController(Long userId, String username) { + this.userId = userId; + this.username = username; + } + + @Override + public Long getUserId() { + return userId; + } + + @Override + public String getUsername() { + return username; + } + } +} diff --git a/jsowell-system/src/main/java/com/jsowell/system/domain/dto/TodoTaskCancelRequest.java b/jsowell-system/src/main/java/com/jsowell/system/domain/dto/TodoTaskCancelRequest.java new file mode 100644 index 000000000..18862b7a4 --- /dev/null +++ b/jsowell-system/src/main/java/com/jsowell/system/domain/dto/TodoTaskCancelRequest.java @@ -0,0 +1,21 @@ +package com.jsowell.system.domain.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import java.io.Serializable; + +/** + * 取消待办请求。 + * + * @author jsowell + */ +@Data +public class TodoTaskCancelRequest implements Serializable { + private static final long serialVersionUID = 1L; + + @NotBlank(message = "取消原因不能为空") + @Size(max = 500, message = "取消原因不能超过500个字符") + private String reason; +}