视频加载失败

苍穹外卖 Day06:操作日志、RBAC 权限与订单链路改造

2039 字
10 分钟
苍穹外卖 Day06:操作日志、RBAC 权限与订单链路改造

课程主线(员工、菜品、套餐、订单、报表)收尾后,针对原实现里几个明显的薄弱点做了一批改造:操作无审计、接口无权限区分、订单状态靠各处散落的 if 判断、超时关单靠每分钟扫全表、非法分类 id 可以打穿缓存直捣数据库。这批改动集中在五个模块,均未提交,属于对既有功能的加固。

操作日志:@OperLog 注解 + AOP 切面#

管理端的增删改操作原先没有任何审计记录。改造引入三件套:

  • @OperLog(module = "员工管理", operation = "新增员工") 方法注解,声明模块与操作内容;
  • OperLogAspect 切面,切入点 @annotation(com.sky.annotation.OperLog),用 @Around 统一记录;
  • sky_oper_log 表(见 sql/upgrade.sql),含操作人、模块、参数、结果、异常信息、耗时十个字段。

切面的几个设计决策:

  1. 日志组装放在 proceed() 之前,落库放在 finally 里——方法成功或抛异常都会留下记录,失败时额外截取异常信息前 300 字符。
  2. 日志入库本身包在 try/catch 里,失败只打 warn,不影响主流程。审计是旁路逻辑,不能反过来拖垮业务。
  3. 请求参数拼接后截断到 500 字符(与表字段宽度一致),并过滤掉 MultipartFile,避免上传接口把文件对象 toString 进日志。
  4. 操作人姓名通过 EmployeeMapper.getById 补齐,查询失败同样只告警。

落地范围:管理端共标注 10 处写操作——菜品新增/删除/修改、员工新增/启停/编辑、订单接单/拒单/取消、运营报表导出。

权限校验:@RequirePermission + 拦截器#

苍穹外卖原生只有一个管理员角色,所有 /admin/** 接口登录即可用。改造加了轻量 RBAC:

  • 登录签发 JWT 时追加 role 声明(JwtClaimsConstant.ROLE),当前统一写 "ADMIN",为多角色预留结构;
  • @RequirePermission(role = "ADMIN") 标注在 Controller 方法上,声明最低角色;
  • PermissionInterceptor 在管理员 JWT 校验之后执行,从 token 解出 role,与注解声明的角色比对,层级为 ADMIN > MANAGER > STAFF,不足直接返回 403 JSON。
registry.addInterceptor(permissionInterceptor)
.addPathPatterns("/admin/**")
.excludePathPatterns("/admin/employee/login");

角色比对是硬编码的三级枚举判断,没有引入权限表。对单体应用来说够用,也保住了「注解即文档」的可读性——打开 Controller 就知道每个接口要什么角色。

订单状态机:单一权威的流转校验#

原实现里,支付回调用 status > 2 就报错 这类散落的 if 保护状态,用户取消、商家接单各自一套判断。多个入口并发修改时,「已取消订单被再次支付」「已完成订单被重复接单」都可能发生。

改造抽出 OrderStatusMachine,用一张「当前状态 → 禁止到达状态集合」的表描述全部约束:

当前状态允许流转
待付款(1)待接单(2)、已取消(6)
待接单(2)已接单(3)、已取消(6)
已接单(3)派送中(4)、已取消(6)
派送中(4)已完成(5)、已取消(6)
已完成(5) / 已取消(6)终态,禁止任何流转

两个细节:

  • 当前状态等于目标状态时直接放行,天然支持重复支付、重复点击这类幂等场景;
  • 支付回调单独限定「仅待付款 → 待接单」,且要求当前未支付。

支付回调、用户取消、超时关单、商家接单四个入口现在全部先走 assertCanTransition,非法流转抛 OrderBusinessException。

乐观锁条件更新:一行 where 解决并发覆盖#

状态机解决「该不该改」,乐观锁解决「改的时候别人是不是已经改过了」。Orders 实体增加 expectedStatus 条件字段——它不落库,只作为 update 的 where 条件:

<update id="updateByStatus" parameterType="com.sky.entity.Orders">
update orders
<set>
<if test="status != null">status = #{status},</if>
<!-- 其余动态字段略 -->
</set>
where id = #{id}
and status = #{expectedStatus}
</update>

业务层拿到影响行数为 0 即判定并发冲突:paySuccess 里直接抛「订单状态已变化,无法完成支付」,防止超时关单和支付回调赛跑时,支付状态回写覆盖掉刚取消的订单;用户取消返回「请刷新后重试」。原来所有裸调 orderMapper.update 的状态变更点都换成了这个条件更新。

超时关单:Redisson 延迟队列为主,扫表兜底#

Day05 用 Spring Task 每分钟扫一次全表的方案有两个问题:关单精度取决于扫描周期,最长差一分钟;队列空转时也在反复查库。改造换成双轨:

主路径:OrderDelayProcessor 基于 Redisson 延迟队列。下单成功后立刻投递订单号,延时 15 分钟(sky.order.timeout-minutes 可配)。底层是一个 ZSET(score 为到期时间)加一个阻塞队列:到期前元素留在 ZSET,到期后由 Redisson 线程移入阻塞队列,应用侧的守护线程 take() 取出并处理——队列为空时线程休眠在 take() 上,不产生任何数据库查询。

RBlockingDeque<String> pendingDeque = redissonClient.getBlockingDeque(PENDING_KEY);
RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(pendingDeque);
delayedQueue.offer(orderNumber, timeoutMinutes, TimeUnit.MINUTES);

到期处理依旧是「核对支付状态 → 状态机校验 → 乐观锁关单」,已支付的直接忽略(并发下可能刚付完队列才到期)。

兜底:OrderTask 的定时扫描保留,但配合 (status, order_time) 联合索引把全表扫描收敛成 range 扫描,且每次更新同样带 expectedStatus 条件。两条路径并发执行也不会重复关单——谁的条件更新先命中,另一边影响行数就是 0。

顺带清理:演示用的 Mytask 移除了无效 cron 表达式(原表达式会导致启动报错)。

布隆过滤器:把非法 id 挡在缓存之前#

C 端「根据分类 id 查菜品」的老问题:请求带一个不存在的 categoryId 时,缓存未命中必然打到数据库,这类恶意遍历就是典型的缓存穿透。

改造自己实现了一个基于 Redis 位图的布隆过滤器 RedisBloomFilter,而不是引 Guava——位图存在 Redis 上,多实例共享同一份数据,重启不丢,也不占 JVM 内存:

  • 位图长度 2^22 bit(约 512KB),足够万级 id 规模;
  • k=4 个哈希位置,基础哈希是 31 进制字符叠加,再移位三次派生出另外三个位置;
  • add 把四个位置全部置 1(SETBIT),mightContain 任一位置为 0 即返回 false(GETBIT)。

使用方式分三步:

// 启动时全量预载启用中的分类 id(BloomFilterLoader @PostConstruct)
redisBloomFilter.addAll(DISH_CATEGORY_BLOOM, idStrings);
// C 端查询入口短路:一定不存在的 id 直接返回空列表,不再碰缓存和 DB
if (!redisBloomFilter.mightContain("dish_category", String.valueOf(categoryId))) {
return Result.success(Collections.emptyList());
}
// 新增分类后同步加入,避免合法新分类被误短路
bloomFilterLoader.addCategoryId(category.getId());

布隆过滤器的固有限制也如实保留在代码注释里:不支持单元素删除(位被多个元素共享,清位会误伤其他元素),删除分类只记日志,靠定期全量重建控制误报率。

一个配置坑:Redisson 抢占 CacheManager#

引入 Redisson 后 @Cacheable("setmealCache") 启动即抛 Cannot find cache named 'setmealCache'。根因是 Redisson 在 META-INF/services 注册了 JCache 的 CachingProvider,Spring Boot 检测到后把缓存类型自动判定为 JCache 并抢占了 CacheManager,Spring Cache 的 redis 缓存定义整个失效。解法是在 application.yml 显式指定缓存类型:

spring:
cache:
type: redis

另一个小修:OrderMapper.xml 里 countByDto 的 resultType 属性与 parameterType 粘连导致解析错误,已修正为 countByMap 并补上空格。

改造清单#

模块新增/修改要点
操作日志annotation/OperLog、aspect/OperLogAspect、entity/OperLog、mapper/OperLogMapper、sql/upgrade.sql@Around + finally 落库,旁路失败不阻主流程
权限校验annotation/RequirePermission、interceptor/PermissionInterceptor、JWT role 声明三级角色层级,403 直接返回 JSON
订单状态机statemachine/OrderStatusMachine非法流转表 + 终态保护 + 幂等放行
乐观锁Orders.expectedStatus、OrderMapper.updateByStatuswhere status = #{expectedStatus},行数 0 判冲突
延迟关单config/RedissonConfiguration、delay/OrderDelayProcessor、task/OrderTask、联合索引延迟队列精准触发,扫表降级为兜底
缓存穿透filter/RedisBloomFilter、filter/BloomFilterLoader、C 端 DishControllerRedis 位图布隆过滤器,启动预载 + 增量维护

代码锚点均在 sky-server/src/main/java/com/sky 对应包下;配套 SQL 在项目根目录 sql/upgrade.sql,其中订单表联合索引语句默认注释,需要时手动执行。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
苍穹外卖 Day06:操作日志、RBAC 权限与订单链路改造
https://ryuki.icu/posts/sky-takeout-day06/
作者
RK
发布于
2026-08-24
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
RK
Long may the sunshine
公告
Long may the sunshine
分类
标签
最新动态
站点天气
天气加载中…
站点统计
文章
27
分类
6
标签
59
总字数
39,139
篝火已燃
0 天
最后活动
0 天前
每日一签