mirror of
https://codeup.aliyun.com/67c68d4e484ca2f0a13ac3c1/ydc/jsowell-charger-web.git
synced 2026-07-22 23:22:32 +08:00
增强待办路由安全和查询索引
This commit is contained in:
@@ -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 天内不归档,超过保留期后仅逻辑隐藏,不物理删除。
|
||||
20
docs/sql/sys_todo_performance_index.sql
Normal file
20
docs/sql/sys_todo_performance_index.sql
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user