增强待办路由安全和查询索引

This commit is contained in:
jsowell
2026-07-17 18:12:53 +08:00
parent fd678c9ab3
commit 1bffb95b18
6 changed files with 208 additions and 3 deletions

View File

@@ -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 天内不归档,超过保留期后仅逻辑隐藏,不物理删除。

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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<String> 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<String> 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<Map.Entry<String, JsonNode>> fields = root.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> 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<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> 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()