diff --git a/docs/plan/2026-07-17-todo-center-security-performance-checklist.md b/docs/plan/2026-07-17-todo-center-security-performance-checklist.md new file mode 100644 index 000000000..55b2130cb --- /dev/null +++ b/docs/plan/2026-07-17-todo-center-security-performance-checklist.md @@ -0,0 +1,63 @@ +# 待办中心安全与性能验证清单 + +## 部署前 SQL + +按顺序在测试库执行: + +1. `docs/sql/sys_todo_task.sql`(仅新环境)或 `docs/sql/sys_todo_performance_index.sql`(已有表)。 +2. `docs/sql/sys_todo_menu.sql`。 +3. `docs/sql/clearing_withdraw_invoice.sql`。 +4. `docs/sql/sys_todo_maintenance_job.sql`。 + +## 路由安全 + +- 默认仅允许 `invoiceDetail`、`financeDetail` 两个业务路由。 +- 新业务路由上线前必须追加到 `todo.route-whitelist`,不允许直接信任请求体中的路由名。 +- `route_params` 必须是 JSON 对象,只允许 `params`、`query` 两层结构,最终值只能是字符串、数字、布尔值或空值。 +- 用户端详情、已读、完成和取消接口继续使用当前登录用户 ID 作为 SQL 条件。 +- 管理端接口使用独立的 `system:todo:admin:*` 权限,普通角色不得授权。 + +## 索引与执行计划 + +使用具有代表性的数据量执行: + +```sql +EXPLAIN +SELECT todo_id, task_type, title, summary, business_type, business_id, + route_name, route_params, priority, task_status, read_status, create_time +FROM sys_todo_task +WHERE assignee_user_id = 100 + AND task_status IN ('PENDING', 'PROCESSING') + AND del_flag = '0' +ORDER BY priority DESC, create_time DESC, todo_id DESC +LIMIT 3; + +EXPLAIN +SELECT count(1) +FROM sys_todo_task +WHERE assignee_user_id = 100 + AND task_status IN ('PENDING', 'PROCESSING') + AND read_status = '0' + AND del_flag = '0'; +``` + +首页列表应命中 `idx_todo_user_active_sort`,未读数量应命中 `idx_todo_user_status`。如果优化器未选择目标索引,执行 `ANALYZE TABLE sys_todo_task` 后复测。 + +## HTTP 性能抽样 + +登录测试账号后分别对以下接口执行不少于 100 次请求,记录 P50、P95 和最大响应时间: + +- `GET /system/todo/unreadCount` +- `GET /system/todo/homeList?limit=3` +- `GET /system/todo/list?pageNum=1&pageSize=10&taskStatus=ACTIVE` + +验收目标:首页数量和最近待办接口 P95 小于 500ms;分页接口无全表扫描、无跨用户数据。 + +## 回归场景 + +- 修改 `userId`、`assigneeUserId` 等请求参数不能查看或更新其他用户待办。 +- 非白名单路由创建待办时返回明确业务错误。 +- 嵌套对象、数组或非法 JSON 的路由参数被拒绝。 +- 普通用户访问 `/system/todo/admin/**` 返回无权限。 +- 到期待办释放 `active_idempotent_key`,同业务后续可重新生成待办。 +- 终态任务在 180 天内不归档,超过保留期后仅逻辑隐藏,不物理删除。 diff --git a/docs/sql/sys_todo_performance_index.sql b/docs/sql/sys_todo_performance_index.sql new file mode 100644 index 000000000..e1d61a725 --- /dev/null +++ b/docs/sql/sys_todo_performance_index.sql @@ -0,0 +1,20 @@ +-- 待办首页列表排序索引(用于已存在的 sys_todo_task 表) +-- MySQL 5.7 兼容的幂等写法。 + +SET @todoIndexExists := ( + SELECT count(1) + FROM information_schema.statistics + WHERE table_schema = database() + AND table_name = 'sys_todo_task' + AND index_name = 'idx_todo_user_active_sort' +); + +SET @todoIndexSql := IF( + @todoIndexExists = 0, + 'ALTER TABLE sys_todo_task ADD INDEX idx_todo_user_active_sort (assignee_user_id, del_flag, task_status, priority, create_time, todo_id)', + 'SELECT ''idx_todo_user_active_sort already exists''' +); + +PREPARE todoIndexStatement FROM @todoIndexSql; +EXECUTE todoIndexStatement; +DEALLOCATE PREPARE todoIndexStatement; diff --git a/docs/sql/sys_todo_task.sql b/docs/sql/sys_todo_task.sql index 0222fe7af..6e160aebd 100644 --- a/docs/sql/sys_todo_task.sql +++ b/docs/sql/sys_todo_task.sql @@ -33,6 +33,7 @@ CREATE TABLE `sys_todo_task` ( PRIMARY KEY (`todo_id`) USING BTREE, UNIQUE KEY `uk_todo_active_idempotent` (`active_idempotent_key`) USING BTREE, KEY `idx_todo_user_status` (`assignee_user_id`, `task_status`, `read_status`, `priority`, `create_time`) USING BTREE, + KEY `idx_todo_user_active_sort` (`assignee_user_id`, `del_flag`, `task_status`, `priority`, `create_time`, `todo_id`) USING BTREE, KEY `idx_todo_merchant_status` (`assignee_merchant_id`, `task_status`, `create_time`) USING BTREE, KEY `idx_todo_business` (`business_type`, `business_id`) USING BTREE, KEY `idx_todo_history_cleanup` (`del_flag`, `task_status`, `completed_time`) USING BTREE diff --git a/jsowell-admin/src/main/resources/application.yml b/jsowell-admin/src/main/resources/application.yml index 65159b138..452962461 100644 --- a/jsowell-admin/src/main/resources/application.yml +++ b/jsowell-admin/src/main/resources/application.yml @@ -214,3 +214,9 @@ sms: enable: false host: 127.0.0.1 port: 8080 + +# 待办中心 +todo: + operator-admin-role-id: 3 + retention-days: 180 + route-whitelist: invoiceDetail,financeDetail 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 index 8e105de24..86d51624d 100644 --- a/jsowell-admin/src/test/java/com/jsowell/system/service/TodoTaskServiceImplTest.java +++ b/jsowell-admin/src/test/java/com/jsowell/system/service/TodoTaskServiceImplTest.java @@ -118,13 +118,35 @@ class TodoTaskServiceImplTest { assertThrows(ServiceException.class, () -> service.complete(20L, 8L, "tester")); } + @Test + void createTask_shouldRejectRouteOutsideWhitelist() { + SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class); + TodoTaskServiceImpl service = newService(mapper); + TodoTaskCreateCommand command = createCommand(); + command.setRouteName("UnsafeRoute"); + + assertThrows(ServiceException.class, () -> service.createTask(command)); + verify(mapper, never()).insertTodoTask(any(SysTodoTask.class)); + } + + @Test + void createTask_shouldRejectNestedRouteParams() { + SysTodoTaskMapper mapper = mock(SysTodoTaskMapper.class); + TodoTaskServiceImpl service = newService(mapper); + TodoTaskCreateCommand command = createCommand(); + command.setRouteParams("{\"query\":{\"payload\":{\"admin\":true}}}"); + + assertThrows(ServiceException.class, () -> service.createTask(command)); + verify(mapper, never()).insertTodoTask(any(SysTodoTask.class)); + } + private static TodoTaskCreateCommand createCommand() { return TodoTaskCreateCommand.builder() .taskType("INVOICE_REVIEW") .title("充电订单开票申请") .businessType("INVOICE_APPLY") .businessId("100") - .routeName("InvoiceApplyDetail") + .routeName("invoiceDetail") .assigneeUserId(8L) .idempotentKey("INVOICE:100:8") .createBy("tester") diff --git a/jsowell-system/src/main/java/com/jsowell/system/service/impl/TodoTaskServiceImpl.java b/jsowell-system/src/main/java/com/jsowell/system/service/impl/TodoTaskServiceImpl.java index f2e98568e..0f7d4fa11 100644 --- a/jsowell-system/src/main/java/com/jsowell/system/service/impl/TodoTaskServiceImpl.java +++ b/jsowell-system/src/main/java/com/jsowell/system/service/impl/TodoTaskServiceImpl.java @@ -1,5 +1,7 @@ package com.jsowell.system.service.impl; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.jsowell.common.exception.ServiceException; import com.jsowell.common.util.StringUtils; import com.jsowell.system.constant.TodoTaskConstants; @@ -9,11 +11,17 @@ import com.jsowell.system.domain.dto.TodoTaskQuery; import com.jsowell.system.mapper.SysTodoTaskMapper; import com.jsowell.system.service.TodoTaskService; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Iterator; import java.util.List; +import java.util.Map; +import java.util.Set; /** * 统一待办任务服务实现。 @@ -23,6 +31,12 @@ import java.util.List; @Service public class TodoTaskServiceImpl implements TodoTaskService { private static final String SYSTEM_OPERATOR = "system"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final Set DEFAULT_ROUTE_WHITELIST = new HashSet<>( + Arrays.asList("invoiceDetail", "financeDetail")); + + @Value("${todo.route-whitelist:invoiceDetail,financeDetail}") + private String routeWhitelist; @Autowired private SysTodoTaskMapper todoTaskMapper; @@ -223,8 +237,10 @@ public class TodoTaskServiceImpl implements TodoTaskService { todoTask.setContent(normalizeOptional(command.getContent(), "待办内容", 20000)); todoTask.setBusinessType(normalizeRequired(command.getBusinessType(), "业务类型", 64)); todoTask.setBusinessId(normalizeRequired(command.getBusinessId(), "业务主键", 64)); - todoTask.setRouteName(normalizeRequired(command.getRouteName(), "业务路由", 100)); - todoTask.setRouteParams(normalizeOptional(command.getRouteParams(), "路由参数", 1000)); + String routeName = normalizeRequired(command.getRouteName(), "业务路由", 100); + validateRouteName(routeName); + todoTask.setRouteName(routeName); + todoTask.setRouteParams(normalizeRouteParams(command.getRouteParams())); todoTask.setAssigneeUserId(command.getAssigneeUserId()); todoTask.setAssigneeMerchantId(normalizeOptional(command.getAssigneeMerchantId(), "运营商 ID", 64)); todoTask.setPriority(priority); @@ -288,6 +304,83 @@ public class TodoTaskServiceImpl implements TodoTaskService { return safeValue; } + private void validateRouteName(String routeName) { + Set allowedRoutes = DEFAULT_ROUTE_WHITELIST; + if (StringUtils.isNotBlank(routeWhitelist)) { + allowedRoutes = new HashSet<>(); + for (String configuredRoute : routeWhitelist.split(",")) { + if (StringUtils.isNotBlank(configuredRoute)) { + allowedRoutes.add(configuredRoute.trim()); + } + } + } + if (!allowedRoutes.contains(routeName)) { + throw new ServiceException("待办业务路由未加入白名单"); + } + } + + private String normalizeRouteParams(String routeParams) { + String safeRouteParams = normalizeOptional(routeParams, "路由参数", 1000); + if (safeRouteParams == null) { + return null; + } + try { + JsonNode root = OBJECT_MAPPER.readTree(safeRouteParams); + if (root == null || !root.isObject()) { + throw new ServiceException("待办路由参数必须是 JSON 对象"); + } + boolean structured = root.has("params") || root.has("query"); + Iterator> fields = root.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + validateRouteParamKey(field.getKey()); + if (structured) { + if (!"params".equals(field.getKey()) && !"query".equals(field.getKey())) { + throw new ServiceException("待办路由参数仅允许 params 和 query"); + } + validateRouteParamObject(field.getValue()); + } else { + validateRouteParamValue(field.getValue()); + } + } + return safeRouteParams; + } catch (ServiceException exception) { + throw exception; + } catch (Exception exception) { + throw new ServiceException("待办路由参数不是合法 JSON"); + } + } + + private void validateRouteParamObject(JsonNode node) { + if (node == null || !node.isObject()) { + throw new ServiceException("待办路由 params 和 query 必须是 JSON 对象"); + } + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + validateRouteParamKey(field.getKey()); + validateRouteParamValue(field.getValue()); + } + } + + private void validateRouteParamKey(String key) { + if (StringUtils.isBlank(key) || key.length() > 64) { + throw new ServiceException("待办路由参数名称不合法"); + } + } + + private void validateRouteParamValue(JsonNode value) { + if (value == null || value.isNull()) { + return; + } + if (!value.isTextual() && !value.isNumber() && !value.isBoolean()) { + throw new ServiceException("待办路由参数值只能是字符串、数字或布尔值"); + } + if (value.isTextual() && value.asText().length() > 500) { + throw new ServiceException("待办路由参数值不能超过500个字符"); + } + } + private String normalizeOperator(String operatorName, Long operatorId) { String safeOperator = StringUtils.isNotEmpty(operatorName) ? operatorName.trim()