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,
|
PRIMARY KEY (`todo_id`) USING BTREE,
|
||||||
UNIQUE KEY `uk_todo_active_idempotent` (`active_idempotent_key`) 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_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_merchant_status` (`assignee_merchant_id`, `task_status`, `create_time`) USING BTREE,
|
||||||
KEY `idx_todo_business` (`business_type`, `business_id`) 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
|
KEY `idx_todo_history_cleanup` (`del_flag`, `task_status`, `completed_time`) USING BTREE
|
||||||
|
|||||||
@@ -214,3 +214,9 @@ sms:
|
|||||||
enable: false
|
enable: false
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: 8080
|
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"));
|
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() {
|
private static TodoTaskCreateCommand createCommand() {
|
||||||
return TodoTaskCreateCommand.builder()
|
return TodoTaskCreateCommand.builder()
|
||||||
.taskType("INVOICE_REVIEW")
|
.taskType("INVOICE_REVIEW")
|
||||||
.title("充电订单开票申请")
|
.title("充电订单开票申请")
|
||||||
.businessType("INVOICE_APPLY")
|
.businessType("INVOICE_APPLY")
|
||||||
.businessId("100")
|
.businessId("100")
|
||||||
.routeName("InvoiceApplyDetail")
|
.routeName("invoiceDetail")
|
||||||
.assigneeUserId(8L)
|
.assigneeUserId(8L)
|
||||||
.idempotentKey("INVOICE:100:8")
|
.idempotentKey("INVOICE:100:8")
|
||||||
.createBy("tester")
|
.createBy("tester")
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.jsowell.system.service.impl;
|
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.exception.ServiceException;
|
||||||
import com.jsowell.common.util.StringUtils;
|
import com.jsowell.common.util.StringUtils;
|
||||||
import com.jsowell.system.constant.TodoTaskConstants;
|
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.mapper.SysTodoTaskMapper;
|
||||||
import com.jsowell.system.service.TodoTaskService;
|
import com.jsowell.system.service.TodoTaskService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一待办任务服务实现。
|
* 统一待办任务服务实现。
|
||||||
@@ -23,6 +31,12 @@ import java.util.List;
|
|||||||
@Service
|
@Service
|
||||||
public class TodoTaskServiceImpl implements TodoTaskService {
|
public class TodoTaskServiceImpl implements TodoTaskService {
|
||||||
private static final String SYSTEM_OPERATOR = "system";
|
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
|
@Autowired
|
||||||
private SysTodoTaskMapper todoTaskMapper;
|
private SysTodoTaskMapper todoTaskMapper;
|
||||||
@@ -223,8 +237,10 @@ public class TodoTaskServiceImpl implements TodoTaskService {
|
|||||||
todoTask.setContent(normalizeOptional(command.getContent(), "待办内容", 20000));
|
todoTask.setContent(normalizeOptional(command.getContent(), "待办内容", 20000));
|
||||||
todoTask.setBusinessType(normalizeRequired(command.getBusinessType(), "业务类型", 64));
|
todoTask.setBusinessType(normalizeRequired(command.getBusinessType(), "业务类型", 64));
|
||||||
todoTask.setBusinessId(normalizeRequired(command.getBusinessId(), "业务主键", 64));
|
todoTask.setBusinessId(normalizeRequired(command.getBusinessId(), "业务主键", 64));
|
||||||
todoTask.setRouteName(normalizeRequired(command.getRouteName(), "业务路由", 100));
|
String routeName = normalizeRequired(command.getRouteName(), "业务路由", 100);
|
||||||
todoTask.setRouteParams(normalizeOptional(command.getRouteParams(), "路由参数", 1000));
|
validateRouteName(routeName);
|
||||||
|
todoTask.setRouteName(routeName);
|
||||||
|
todoTask.setRouteParams(normalizeRouteParams(command.getRouteParams()));
|
||||||
todoTask.setAssigneeUserId(command.getAssigneeUserId());
|
todoTask.setAssigneeUserId(command.getAssigneeUserId());
|
||||||
todoTask.setAssigneeMerchantId(normalizeOptional(command.getAssigneeMerchantId(), "运营商 ID", 64));
|
todoTask.setAssigneeMerchantId(normalizeOptional(command.getAssigneeMerchantId(), "运营商 ID", 64));
|
||||||
todoTask.setPriority(priority);
|
todoTask.setPriority(priority);
|
||||||
@@ -288,6 +304,83 @@ public class TodoTaskServiceImpl implements TodoTaskService {
|
|||||||
return safeValue;
|
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) {
|
private String normalizeOperator(String operatorName, Long operatorId) {
|
||||||
String safeOperator = StringUtils.isNotEmpty(operatorName)
|
String safeOperator = StringUtils.isNotEmpty(operatorName)
|
||||||
? operatorName.trim()
|
? operatorName.trim()
|
||||||
|
|||||||
Reference in New Issue
Block a user