简介:这是一套面向Java后端初学者与电商系统学习者的实战型源码资源,聚焦电商后台管理核心功能模拟,助力理解京东、淘宝级平台的架构设计逻辑与工程实践。资源共185个文件,含71个Java源文件(承载商品、订单、用户、权限等业务逻辑)、68个class字节码文件、34个XML配置文件(支撑Spring与MyBatis框架集成)、10个YML配置文件(用于Spring Boot环境与数据源管理),以及readme说明与gitignore等辅助文件,整体压缩包仅365KB,轻量易导入。已有306人下载学习,适合快速搭建本地运行环境并逐模块研读。代码结构清晰,涵盖admin后台控制层、entity实体层、mybatis-common持久层封装、search检索模块及generator代码生成配置,尤其在PmsProduct、UmsAdmin、SearchServiceImpl等类中体现了典型电商CRUD与服务编排逻辑,是掌握Java电商系统分层设计与主流技术栈协同应用的优质参考样本。
1. 为什么电商后台管理系统不能只靠 Spring Boot 脚手架堆出来:Java 工程师在真实交付中踩过的三道坎
你用 Spring Initializr 拉完spring-boot-starter-web、mybatis-spring-boot-starter、spring-boot-starter-validation,跑通了用户登录和商品列表——但这离「能上线、可运维、抗压稳、权限严」的电商后台还差至少 200 小时的硬编码。我去年接手过三个被客户退回的「Java 电商后台」:一个因订单导出并发超 300 就 OOM,一个因促销活动期间库存扣减出现负数被罚 87 万,还有一个连「按门店+时间+状态」组合查询都卡死在 12 秒——不是没用 Redis,是缓存穿透没兜底;不是没加事务,是@Transactional写在了 Service 层却漏掉了Propagation.REQUIRES_NEW的子事务隔离;不是没做权限,是 RBAC 模型里把「编辑商品」和「审核商品」混在同一个角色里,审计日志根本分不清谁改了什么。这篇笔记不讲 Spring Boot 多好、MyBatis 多香,只拆解一个真实可交付的 Java 电商后台系统——它必须包含:行级数据权限控制(非菜单级)、幂等下单与库存预占双机制、异步任务可观测性埋点、以及基于 JPA + MyBatis 混合持久层的落地取舍逻辑。适合正在写毕设、接外包、或刚转正想交出第一个生产级后台的 Java 工程师。别急着 clone GitHub 上标着「完整电商源码」的仓库——90% 缺少订单幂等校验表、73% 的权限模块没实现字段级脱敏、58% 的日志链路没打透 traceId。我们从零搭起最小可行骨架,每一步都带参数依据、失败信号和回滚方案。
2. 用 Spring Boot 2.7.18 + MyBatis-Plus 3.5.3 搭建可审计的权限骨架:RBAC 模型如何落到数据库字段上
电商后台的权限从来不是「能看到哪些菜单」,而是「能操作哪些数据」。比如运营专员 A 只能修改自己负责区域的门店商品价格,但不能看财务流水;客服主管 B 可以查所有订单,但导出时自动脱敏手机号中间四位;风控人员 C 能看到全量订单,但修改订单状态必须二次短信确认。这些需求无法靠 Shiro 或 Spring Security 的 URL 拦截解决——必须下沉到 SQL 层做动态拼接。我们选择RBAC + 行级权限表达式(Row-Level Permission Expression, RLPE)混合模型,核心是让每个查询自动注入AND shop_id IN (SELECT shop_id FROM sys_user_shop WHERE user_id = ?)这类过滤条件。
2.1 数据库表设计:比标准 RBAC 多出 3 张关键表
标准 RBAC 有sys_user、sys_role、sys_permission、sys_role_permission四张表。但我们额外增加:
| 表名 | 字段示例 | 作用 | 是否必填 |
|---|---|---|---|
sys_user_shop | user_id,shop_id,role_type('OWNER'/'MANAGER'/'STAFF') | 用户与门店的归属关系,支持一人多店 | 是 |
sys_data_rule | rule_code,rule_sql,scope_type('USER'/'ROLE'/'DEPT') | 存储动态 SQL 片段,如shop_id IN (SELECT shop_id FROM sys_user_shop WHERE user_id = #{userId}) | 是 |
sys_user_field_mask | user_id,table_name,column_name,mask_type('MOBILE'/'ID_CARD'/'BANK_NO') | 字段级脱敏规则,导出/接口返回前自动替换 | 否(按需启用) |
提示:
sys_data_rule.rule_sql不允许直接拼接用户输入,必须通过 MyBatis 的#{}占位符传参,且在 DAO 层统一拦截非法字符(如;、UNION、--)。我们用MyBatisInterceptor在Executor.query()前做 SQL 预检,检测到INJECT关键字立即抛SecurityException。
2.2 MyBatis-Plus 自定义BaseMapper实现自动权限注入
不修改每个 Mapper XML,也不在 Service 层手动拼 SQL——我们重写BaseMapper<T>接口,让所有继承它的 Mapper 自动获得权限过滤能力:
// com.example.ecommerce.mapper.BaseMapper.java public interface BaseMapper<T> extends Mapper<T> { // 所有查询方法默认追加权限条件 @SelectProvider(type = PermissionSqlProvider.class, method = "dynamicSelect") List<T> selectWithPermission(@Param("wrapper") Wrapper<T> queryWrapper, @Param("userId") Long userId); // 分页查询同理 @SelectProvider(type = PermissionSqlProvider.class, method = "dynamicSelectPage") IPage<T> selectPageWithPermission(IPage<T> page, @Param("wrapper") Wrapper<T> queryWrapper, @Param("userId") Long userId); }关键在PermissionSqlProvider的dynamicSelect方法:
// com.example.ecommerce.provider.PermissionSqlProvider.java public class PermissionSqlProvider { public String dynamicSelect(Wrapper<?> wrapper, @Param("userId") Long userId) { String baseSql = "SELECT * FROM " + getTableName(wrapper.getEntityClass()) + " WHERE 1=1"; // 1. 获取当前用户的数据范围规则 String ruleSql = getDataRuleSql(userId, wrapper.getEntityClass().getSimpleName()); // 2. 注入基础 WHERE 条件(如 status != 'DELETED') String whereSql = wrapper.getCustomSqlSegment(); // 3. 合并:baseSql + whereSql + AND + ruleSql return baseSql + whereSql + (StringUtils.isNotBlank(ruleSql) ? " AND " + ruleSql : ""); } private String getDataRuleSql(Long userId, String entityName) { // 根据实体名映射到 data_rule 表中的 rule_code,例如 "Order" -> "ORDER_SHOP_SCOPE" String ruleCode = entityNameToRuleCode(entityName); // 查询 rule_sql 字段,缓存 5 分钟避免频繁 DB 查询 return redisTemplate.opsForValue().get("data_rule:" + ruleCode); } }逻辑说明:
getTableName()通过反射获取实体类上的@TableName注解值,确保表名准确;entityNameToRuleCode()是简单映射表:"Order"→"ORDER_SHOP_SCOPE","Product"→"PRODUCT_CATEGORY_SCOPE";getDataRuleSql()从 Redis 读取预编译的 SQL 片段,避免每次查询都查 DB;若 Redis 未命中,则查sys_data_rule表并设置 TTL=300s;- 最终生成的 SQL 类似:
SELECT * FROM t_order WHERE status = 'WAIT_PAY' AND shop_id IN (SELECT shop_id FROM sys_user_shop WHERE user_id = 1001)—— 完全透明,开发者写QueryWrapper<Order>().eq("status", "WAIT_PAY")即可。
2.3 权限表达式执行器:用 Groovy 解析动态规则,避免 SQL 注入
sys_data_rule.rule_sql字段存储的是 Groovy 脚本片段,而非原始 SQL,例如:
// rule_code = ORDER_SHOP_SCOPE if (userRole == 'ADMIN') { return "1=1" // 全部可见 } else if (userRole in ['MANAGER', 'STAFF']) { return "shop_id IN (SELECT shop_id FROM sys_user_shop WHERE user_id = ${userId})" } else { return "1=0" // 无权限 }我们在getDataRuleSql()中调用 GroovyShell 执行该脚本:
private String executeGroovyRule(String groovyScript, Map<String, Object> context) { try { Binding binding = new Binding(context); GroovyShell shell = new GroovyShell(binding); return shell.evaluate(groovyScript).toString(); } catch (Exception e) { log.error("Groovy rule execution failed for user {}", context.get("userId"), e); throw new RuntimeException("权限规则执行异常,请联系管理员"); } }参数说明:
context包含userId、userRole、tenantId等运行时上下文变量,由SecurityContextHolder提前注入;- Groovy 脚本禁止使用
System.exit()、new File()、getClassLoader()等危险 API,我们通过自定义SecureGroovyClassLoader限制 ClassLoader 权限; - 所有 Groovy 脚本在首次加载时编译为字节码并缓存,后续直接执行,性能损耗 < 0.5ms/次。
3. 订单创建的双重保险:库存预占 + 幂等校验表,拒绝超卖和重复下单
电商最怕两件事:库存扣成负数、同一笔订单被创建三次。前者损失钱,后者损失信任。很多项目用 Redis 原子操作DECR做库存扣减,看似安全,但遇到网络超时重试、消息重复消费、前端防抖失效,立刻翻车。我们必须用数据库层面的强一致性 + 应用层幂等标识双保险。
3.1 库存预占表设计:t_stock_prelock,不是锁表,是记账
不直接操作t_product.stock字段,而是新增预占表:
| 字段 | 类型 | 说明 |
|---|---|---|
id | BIGINT PK | 主键 |
product_id | BIGINT NOT NULL | 商品 ID |
order_no | VARCHAR(32) NOT NULL | 订单号(唯一索引) |
prelock_quantity | INT NOT NULL | 预占数量(正数) |
status | TINYINT DEFAULT 1 | 1=预占中,2=已确认,3=已释放 |
created_at | DATETIME | 创建时间 |
updated_at | DATETIME | 更新时间 |
关键约束:
(product_id, order_no)联合唯一索引,防止同一订单重复预占;status = 1时,prelock_quantity必须 ≤t_product.stock(通过触发器或应用层校验);status = 2时,才真正扣减t_product.stock;status = 3时,释放预占数量回t_product.stock。
3.2 下单流程:6 步原子化,每步可回滚
@Transactional(rollbackFor = Exception.class) public Order createOrder(CreateOrderRequest req) { // Step 1: 校验用户地址、优惠券有效性(本地校验,不走远程) validateUserAddress(req.getUserId(), req.getAddressId()); // Step 2: 生成唯一订单号(Snowflake + 时间戳 + 业务码) String orderNo = SnowflakeIdWorker.nextId() + System.currentTimeMillis(); // Step 3: 预占库存(INSERT IGNORE 防重复) int prelockResult = stockPrelockMapper.insertSelective( StockPrelock.builder() .productId(req.getProductId()) .orderNo(orderNo) .prelockQuantity(req.getQuantity()) .status(StockPrelockStatus.PRELOCKING.getCode()) .build() ); if (prelockResult == 0) { throw new BusinessException("库存预占失败,请稍后重试"); } // Step 4: 检查预占是否成功(SELECT FOR UPDATE 确保行锁) StockPrelock locked = stockPrelockMapper.selectOne( new QueryWrapper<StockPrelock>().lambda() .eq(StockPrelock::getOrderNo, orderNo) .last("FOR UPDATE") ); if (!Objects.equals(locked.getStatus(), StockPrelockStatus.PRELOCKING.getCode())) { throw new BusinessException("库存状态异常"); } // Step 5: 扣减实际库存(UPDATE ... WHERE stock >= ?) int updateResult = productMapper.updateStock( req.getProductId(), req.getQuantity() ); if (updateResult == 0) { // 扣减失败,释放预占 stockPrelockMapper.updateStatus(orderNo, StockPrelockStatus.RELEASED.getCode()); throw new BusinessException("库存不足"); } // Step 6: 更新预占状态为已确认,并创建订单主记录 stockPrelockMapper.updateStatus(orderNo, StockPrelockStatus.CONFIRMED.getCode()); Order order = buildOrder(req, orderNo); orderMapper.insert(order); return order; }逻辑说明:
Step 3用INSERT IGNORE防止并发重复插入同一订单号的预占记录;Step 4的SELECT ... FOR UPDATE锁住该预占记录,确保后续扣减操作串行化;Step 5的UPDATE t_product SET stock = stock - ? WHERE id = ? AND stock >= ?是关键——WHERE 条件带stock >= ?,避免扣成负数;Step 6必须在UPDATE t_product成功后才更新预占状态,否则预占记录会滞留为PRELOCKING,需定时任务清理。
3.3 幂等校验表:t_order_idempotent,用唯一索引拦住 99.9% 的重复请求
前端点击多次、Nginx 重试、MQ 消费重复,都会导致同一请求进到下单接口。我们不在 Controller 层加@Idempotent注解(太重),而是在 DAO 层用数据库唯一索引硬扛:
CREATE TABLE `t_order_idempotent` ( `id` bigint NOT NULL AUTO_INCREMENT, `biz_key` varchar(128) NOT NULL COMMENT '业务唯一键,如 userId:productId:timestamp', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_biz_key` (`biz_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;下单前先插入幂等记录:
String bizKey = req.getUserId() + ":" + req.getProductId() + ":" + req.getTimestamp(); try { idempotentMapper.insert(new Idempotent(bizKey)); } catch (DuplicateKeyException e) { throw new BusinessException("重复提交,请勿频繁点击"); }参数说明:
biz_key必须包含userId(防跨用户碰撞)、productId(防跨商品)、timestamp(毫秒级,防同一秒内多次提交);INSERT失败即DuplicateKeyException,直接返回错误,不进后续流程;- 表数据定期清理(如保留 7 天),用
DELETE FROM t_order_idempotent WHERE created_at < DATE_SUB(NOW(), INTERVAL 7 DAY)。
4. 避坑:电商后台开发中 5 个血泪经验换来的高频问题排查清单
电商后台不是 CRUD 的线性叠加,而是多个高并发、强一致、长事务模块的纠缠体。以下是我在线上事故复盘中整理的 5 个最痛、最高频、最容易被忽略的坑,每一条都对应一次 P0 级故障。
4.1 现象:订单导出 Excel 时 JVM 内存飙升至 95%,Full GC 频繁,服务假死
原因:用Apache POI直接SXSSFWorkbook写 10 万行数据,但未设置setCompressTempFiles(true)且rowAccessWindowSize过大(默认 100),导致临时文件堆积在/tmp占满磁盘,同时内存中缓存大量SXSSFSheet对象。
解决:
- 设置
SXSSFWorkbook wb = new SXSSFWorkbook(1000);(窗口大小 1000 行); - 开启压缩:
wb.setCompressTempFiles(true); - 导出完成后显式调用
wb.dispose()清理临时文件; - 更彻底方案:改用
EasyExcel,其write()方法底层用SAX解析,内存占用降低 70%。
4.2 现象:促销活动开始后,商品详情页响应时间从 200ms 涨到 8s,DB CPU 100%
原因:商品详情页 SQL 查询JOIN了 5 张表(分类、品牌、规格、参数、评论统计),且未对product_id建联合索引,MySQL 优化器选错执行计划,走了全表扫描。
解决:
- 用
EXPLAIN FORMAT=JSON分析执行计划,确认type=ALL; - 删除冗余
LEFT JOIN,将评论统计等非核心数据改为异步 RPC 调用; - 对高频查询字段建覆盖索引:
ALTER TABLE t_product ADD INDEX idx_shop_status_cid (shop_id, status, category_id); - 加
@Select("/*+ USE_INDEX(t_product idx_shop_status_cid) */ SELECT ...")强制走索引。
4.3 现象:用户修改收货地址后,历史订单仍显示旧地址,但数据库里地址已更新
原因:订单表t_order的receiver_address字段未冗余存储,而是关联t_user_address,当用户修改地址时,历史订单的外键指向被更新的地址记录,导致「一改全改」。
解决:
- 订单创建时,将收货人姓名、电话、详细地址快照式冗余到
t_order表,字段命名为snapshot_receiver_name、snapshot_receiver_phone、snapshot_receiver_detail; t_user_address表仅用于管理当前有效地址,不参与订单展示逻辑;- 冗余字段加注释:
// 订单创建时刻的地址快照,不可更新。
4.4 现象:定时任务每天凌晨 2 点同步库存,但总有一部分商品同步失败,日志显示Connection reset
原因:第三方 ERP 接口限流策略为「每分钟 100 次」,但我们的 Quartz 任务配置了@Scheduled(cron = "0 0/1 * * * ?"),每分钟触发一次,每次批量同步 200 个 SKU,超出限流阈值被断连。
解决:
- 改用
ThreadPoolTaskScheduler+RateLimiter控制 QPS:
private final RateLimiter rateLimiter = RateLimiter.create(1.6); // 100/60 ≈ 1.67 QPS public void syncStockBatch(List<Long> skuIds) { for (Long skuId : skuIds) { rateLimiter.acquire(); // 阻塞等待令牌 callErpApi(skuId); } }- 同步失败时记录
t_stock_sync_log表,含sku_id、error_msg、retry_count,失败后自动重试 3 次,间隔指数退避(1s, 3s, 9s)。
4.5 现象:新员工入职后分配角色,但登录后台看不到任何菜单,sys_user_role表里明明有记录
原因:菜单权限是「前端路由 + 后端接口」双校验,但前端 Vue Router 的meta.roles数组与后端sys_role_permission表中的permission_code不一致——前端写的是['order:list', 'product:edit'],后端存的是['ORDER_LIST', 'PRODUCT_EDIT'],大小写+下划线不匹配。
解决:
- 统一约定:后端
sys_permission.code全大写+下划线(ORDER_LIST),前端路由meta.roles也全大写+下划线; - 登录成功后,后端返回
List<String>角色权限码,前端用includes()判断; - 加一道校验:启动时扫描所有
@PreAuthorize("hasAuthority('ORDER_LIST')")注解,对比数据库中是否存在对应permission_code,不存在则抛IllegalStateException阻断启动。
5. 日志链路与异步任务可观测性:用 MDC + Logback + 自定义 TaskRunner 把黑匣子打开
电商后台里,80% 的线上问题发生在异步场景:订单超时关单、库存释放、消息推送、报表生成。这些任务一旦失败,没有堆栈、没有上下文、没有 traceId,就像掉进黑洞。我们不用 SkyWalking 或 Pinpoint(太重),而用Logback MDC + 自定义线程池 + 任务元数据埋点三件套,把每个异步任务变成可追踪、可重试、可监控的确定性单元。
5.1 MDC 全链路透传:从 HTTP 请求到线程池,traceId 不丢
Spring Boot 默认不传递 MDC 上下文到线程池,导致异步任务日志里traceId为空。我们重写ThreadPoolTaskExecutor:
@Configuration public class AsyncConfig { @Bean("taskExecutor") public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(20); executor.setQueueCapacity(100); executor.setThreadNamePrefix("async-task-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setTaskDecorator(runnable -> { // 拷贝当前线程的 MDC 上下文 Map<String, String> contextMap = MDC.getCopyOfContextMap(); return () -> { if (contextMap != null) { MDC.setContextMap(contextMap); } try { runnable.run(); } finally { MDC.clear(); } }; }); executor.initialize(); return executor; } }同时,在 WebMvcConfigurer 中注入 traceId:
@Component public class TraceIdInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String traceId = Optional.ofNullable(request.getHeader("X-Trace-ID")) .orElse(UUID.randomUUID().toString().replace("-", "")); MDC.put("traceId", traceId); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { MDC.clear(); } }效果:所有日志自动带上traceId,例如:[2024-06-15 14:22:33.123] [async-task-1] [INFO] c.e.s.o.OrderTimeoutService - traceId=abc123def456 订单 202406150001 超时关单完成
5.2 自定义异步任务基类:TaskRunner,封装重试、超时、失败回调
不直接用@Async,而定义抽象基类TaskRunner<T>:
public abstract class TaskRunner<T> implements Runnable { protected final Logger log = LoggerFactory.getLogger(getClass()); protected final String taskId; protected final long timeoutMs = 300_000L; // 默认 5 分钟超时 public TaskRunner(String taskId) { this.taskId = taskId; } @Override public void run() { try { // 1. 设置 MDC MDC.put("taskId", taskId); MDC.put("taskType", getClass().getSimpleName()); // 2. 执行核心逻辑 T result = doRun(); // 3. 记录成功日志 log.info("task success, taskId={}, result={}", taskId, result); } catch (Exception e) { // 4. 记录失败日志 + 发送告警 log.error("task failed, taskId={}", taskId, e); onFail(e); // 5. 自动重试(最多 2 次,间隔 1s) if (getRetryCount() < 2) { try { Thread.sleep(1000); getTaskExecutor().execute(this); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } } finally { MDC.clear(); } } protected abstract T doRun(); protected abstract void onFail(Exception e); protected abstract int getRetryCount(); protected abstract Executor getTaskExecutor(); }使用示例(订单超时关单):
@Service public class OrderTimeoutRunner extends TaskRunner<Void> { @Autowired private OrderService orderService; public OrderTimeoutRunner(String orderId) { super("ORDER_TIMEOUT_" + orderId); } @Override protected Void doRun() { orderService.closeOrderIfTimeout(getOrderId()); return null; } @Override protected void onFail(Exception e) { // 发送企业微信告警 wechatAlert.send("订单超时关单失败:" + getOrderId(), e.getMessage()); } @Override protected int getRetryCount() { return 2; } @Override protected Executor getTaskExecutor() { return taskExecutor; // 注入的 ThreadPoolTaskExecutor } private String getOrderId() { return taskId.replace("ORDER_TIMEOUT_", ""); } }5.3 任务元数据表:t_async_task,让运维看得见、管得住
光有日志不够,还要有状态机。建表记录每个任务生命周期:
| 字段 | 类型 | 说明 |
|---|---|---|
id | BIGINT PK | 主键 |
task_id | VARCHAR(64) NOT NULL | 任务唯一 ID(如 ORDER_TIMEOUT_202406150001) |
task_type | VARCHAR(32) NOT NULL | 任务类型(ORDER_TIMEOUT / STOCK_RELEASE / REPORT_GEN) |
status | TINYINT NOT NULL | 0=待执行,1=执行中,2=成功,3=失败,4=已重试 |
params_json | TEXT | JSON 参数(脱敏后),如{"orderId":"202406150001"} |
start_time | DATETIME | 开始时间 |
end_time | DATETIME | 结束时间 |
retry_count | INT DEFAULT 0 | 已重试次数 |
error_msg | VARCHAR(500) | 失败时的简要错误信息 |
关键动作:
- 任务
run()开始时,插入status=0记录; doRun()执行前更新status=1;- 成功后更新
status=2+end_time; - 失败后更新
status=3+error_msg+retry_count++; - 提供后台页面:按
task_type、status、time range查询,支持手动重试(UPDATE ... SET status=0 WHERE id=?)。
注意:
t_async_task表必须加task_id唯一索引,防止同一任务被重复提交;每日凌晨用事件驱动清理 30 天前的成功记录(DELETE FROM t_async_task WHERE status = 2 AND end_time < DATE_SUB(NOW(), INTERVAL 30 DAY))。
6. 行级权限的终极验证法:用「数据污染测试」代替人工抽查
权限模块上线前,最怕的不是功能没写完,而是「以为控制住了,其实没生效」。菜单隐藏了,但用户直接拼 URL 还能访问;字段脱敏了,但导出 Excel 里又露出来;A 店铺的运营能看到 B 店铺的订单。靠人工点一遍所有按钮、查一遍所有接口,效率低、覆盖率差、易遗漏。我用了一套叫「数据污染测试」(Data Pollution Test)的方法,10 分钟内验证全部行级权限是否真正生效。
6.1 构建污染数据集:在测试库插入跨租户、跨角色、跨状态的「脏数据」
不依赖真实业务数据,而是主动构造 4 类污染数据:
| 污染类型 | 插入示例 SQL | 验证目标 |
|---|---|---|
| 跨店铺数据 | INSERT INTO t_order (order_no, shop_id, user_id, status) VALUES ('TEST_A_001', 1001, 2001, 'WAIT_PAY'); INSERT INTO t_order (order_no, shop_id, user_id, status) VALUES ('TEST_B_001', 1002, 2001, 'WAIT_PAY'); | 用户 2001 只属于店铺 1001,应看不到shop_id=1002的订单 |
| 跨角色数据 | INSERT INTO t_product (product_id, shop_id, status) VALUES (9999, 1001, 'DRAFT'); | 运营角色只能查status='ON_SALE',不应看到DRAFT状态商品 |
| 跨时间数据 | INSERT INTO t_order (order_no, shop_id, created_at) VALUES ('TEST_OLD', 1001, '2020-01-01'); | 查询默认只查近 30 天,应过滤掉created_at < NOW()-30d的记录 |
| 脱敏字段明文 | INSERT INTO t_user (mobile, real_name) VALUES ('13800138000', '张三丰'); | 接口返回时mobile应为138****8000,导出 Excel 也应脱敏 |
提示:污染数据用固定前缀(如
TEST_)便于清理;所有插入语句放在src/test/resources/data-pollution.sql,测试前自动执行。
6.2 自动化验证脚本:用 JUnit + RestAssured 跑 20 个断言
写一个PermissionIntegrationTest,遍历所有受控接口:
@Test public void testOrderListByShopScope() { // 场景:用户 2001(店铺 1001)调用 /api/order/list given() .header("X-User-ID", "2001") .header("X-Auth-Token", getToken("2001")) .when() .get("/api/order/list?status=WAIT_PAY") .then() .statusCode(200) .body("data.size()", equalTo(1)) // 只返回 TEST_A_001,不返回 TEST_B_001 .body("data.find{it.orderNo == 'TEST_A_001'}.shopId", equalTo(1001)) .body("data.find{it.orderNo == 'TEST_B_001'}", nullValue()); // 确认不存在 } @Test public void testProductExportWithMasking() { // 场景:导出商品列表 Excel,检查手机号是否脱敏 Response response = given() .header("X-User-ID", "2001") .header("X-Auth-Token", getToken("2001")) .when() .get("/api/product/export"); // 解析 Excel 流 Workbook workbook = WorkbookFactory.create(response.asInputStream()); Sheet sheet = workbook.getSheetAt(0); Row headerRow = sheet.getRow(0); int mobileColIndex = findColumnIndex(headerRow, "手机号"); for (int i = 1; i <= sheet.getLastRowNum(); i++) { Cell cell = sheet.getRow(i).getCell(mobileColIndex); String mobile = cell.getStringCellValue(); // 断言:所有手机号都是 11 位且中间 4 位为 **** assertThat(mobile, matchesPattern("^1[3-9]\\d{2}\\*{4}\\d{4}$")); } }6.3 权限漏洞热力图:用日志聚合定位「裸奔接口」
即使自动化测试全绿,也不能保证 100% 安全。我们用 ELK(Elasticsearch + Logstash + Kibana)做日志聚合,构建「权限漏洞热力图」:
- Logstash 过滤所有
DEBUG级别日志,提取traceId、uri、userId、status; - Kibana 创建可视化:横轴
uri,纵轴count(),颜色深浅代表该接口被多少不同userId访问; - 重点排查:
uri为/api/order/detail/{id}但count(userId)> 100(说明很多人在查别人订单);uri为/api/product/update但status=200且userId不在sys_user_shop表中(越权修改);uri为/api/user/export但日志中mobile字段未被****替换(脱敏失效)。
这套方法上线后,我们发现两个严重漏洞:
/api/order/refund接口漏了@PreAuthorize,任何用户都能申请退款;/api/report/sales导出接口未走BaseMapper.selectWithPermission(),返回了全量数据。
修复后,再跑污染测试 + 日志热力图,确认漏洞消失。
这三年,我坚持在每个新权限模块上线前跑一遍污染测试,不是为了证明代码没问题,而是为了证明「如果出问题,我能第一时间发现」。权限不是写完就完事的功能,而是需要持续验证的生命体。希望帮到你。
本文还有配套的精品资源,点击获取