采云台 · M3 申领单 · 深度分析

对应第三、四部分(完整执行链路 + 为什么这么设计)及第五、六、七部分中 M3 相关内容
依据:backend/cai-yun-tai 实际源码逐行核验;mvn -o -DskipTests compile 退出码 0
一句话定位:M3 是项目的主单据,也是事务边界最集中的地方。它的正确做法(金额重算、防重、归属校验、预算预扣)都在 627 行里,而它最大的缺口是——所有状态流转都是”先查再判断再写”,没有任何一条 SQL 把状态约束写进 WHERE。


零、先纠正上一轮我写错的一处

在 M2 分析里我提到”Redis 防重键不主动删除”。后果我上一轮说反了,必须纠正:

我说”它防的是重复点击,2 秒内合法重试会被拦截”——这是错的。

【实际代码事实】user/OrderController.submit:52-58 的 SET NX 在 orderService.submitOrder() 之前、事务之外执行:

Controller: SET NX order:submit:{userId}  →  成功
                                              ↓
Service:   @Transactional 开始
           扣预算失败 → 抛异常 → 事务回滚
                                              ↓
           ❌ Redis 的 order:submit:{userId} 键【不会回滚】,仍存在 5 秒

所以真实的副作用是:一次业务失败(余额不足、地址为空、采购已关闭)之后,用户在 5 秒内改好了条件重新提交,也会被”请勿重复提交申领单”挡住。错误提示与真实原因不符。

面试标准答法:

「防重键是在事务外写的,事务回滚不会回滚 Redis,所以一次失败提交后 5 秒内无法重试,提示还是’请勿重复提交’而不是真实原因。更好的做法是把防重做业务幂等——比如用客户端传的 requestId 做唯一索引,或者把 Redis key 的删除放到事务提交后的 TransactionSynchronization.afterCompletion 里,回滚时主动删除。」


一、业务场景:申领单到底承担了什么

申领单不是一张普通的业务表,它同时是四件事的载体:

承载物字段为什么必须在同一张表
状态机status 1→6单据生命周期,所有操作都要校验当前状态
金额与预算痕迹amount、pay_status钱和单据必须同事务,否则会出现”钱扣了没单据”
审批痕迹approve_user、approve_time、rejection_reason、cancel_reason、is_returned审计追溯,谁批的、为什么驳回
组织归属user_id(申请人)、requester_dept(申请部门,冗余)越权校验 + 按部门统计

requester_dept 是冗余字段,可以从 employee.dept_id 关联得到。冗余的理由在代码注释里写明了:“便于按部门统计”。代价是:员工换部门后,历史单据的部门归属保持在提交时的值——这恰恰是正确的(历史单据应该归到当时花钱的部门),所以这个冗余是合理的反范式。

状态机全貌(Orders 常量,Orders.java):

1 PENDING_PAYMENT  待提交(原"待付款",新流程基本闲置,无任何代码路径会写入 1)
2 TO_BE_CONFIRMED  待审批   ← submitOrder 落库即此状态
3 CONFIRMED        采购中   ← confirm / 管理员核销前置
4 DELIVERY_IN_PROGRESS 配送中 ← delivery
5 COMPLETED        已完成   ← complete / confirmReceipt
6 CANCELLED        已取消   ← userCancelById / rejection / cancel / returnOrder  【三态合一】

“6 三态合一”是一个值得主动讲的设计取舍:已取消、已驳回、已退换共用状态 6,靠 cancelReason / rejectionReason / isReturned 三个字段区分。

  • 好处:状态机简单,所有”终态”判断只需 status == 6;
  • 代价:无法用索引查询”所有被驳回的单据”,得 WHERE status=6 AND rejection_reason IS NOT NULL;而且从状态码本身看不出终态原因。

二、数据库与关键 SQL

2.1 状态流转全集(这是 M3 的核心 —— 注意每一条都不含状态条件)

<!-- OrderMapper.xml 第 27-63 行,全项目唯一的一条 orders UPDATE(通用动态 SQL,被 7 个方法复用)
     精简展示,只列出 M3 关心的几个字段 -->
<update id="update" parameterType="com.caiyuntai.entity.Orders">
    update orders
    <set>
        <if test="cancelReason != null and cancelReason!=''">cancel_reason=#{cancelReason},</if>
        <if test="rejectionReason != null and rejectionReason!=''">rejection_reason=#{rejectionReason},</if>
        <if test="cancelTime != null">cancel_time=#{cancelTime},</if>
        <if test="payStatus != null">pay_status=#{payStatus},</if>
        <if test="payMethod != null">pay_method=#{payMethod},</if>
        <if test="checkoutTime != null">checkout_time=#{checkoutTime},</if>
        <if test="status != null">status = #{status},</if>          ← 直接把目标状态写进去
        <if test="deliveryTime != null">delivery_time = #{deliveryTime},</if>
        <if test="approveUser != null">approve_user = #{approveUser},</if>
        <if test="approveTime != null">approve_time = #{approveTime},</if>
        <if test="approveRemark != null and approveRemark!=''">approve_remark = #{approveRemark},</if>
        <if test="remindTime != null">remind_time = #{remindTime},</if>
        <if test="isReturned != null">is_returned = #{isReturned},</if>
    </set>
    where id = #{id}          ← ★★★ 第 61 行,只有 id,没有任何 status 条件
</update>

where id = #{id}(第 61 行) —— 这一行是 M3 最大的技术缺口。所有状态校验都在 Java 内存里做,SQL 层全无防护;status 是无条件写入的目标值,不是校验条件。

代码里还留了一条注释(第 50-52 行):「注意:delivery_time 必须保持”带逗号”的写法且不能放在 <set> 最末位,否则当它单独为空、而后面还有审批字段命中时,会拼出 status = ? approve_user = ? 这种缺逗号的非法 SQL」。
说明作者曾经踩过坑。这其实是 <set> 标签的已知特性(它只去掉整个 set 内容首尾的逗号,不处理中间的空洞),更稳的写法是用 <trim suffixOverrides=","> 包一层。当前代码把所有 <if> 的逗号都放在字段后面,所以这个坑已经被规避了——注释是对历史写法的告警,属于留档性注释。

2.2 表结构与索引

索引/列说明
uk_orders_number 唯一索引M3 的单号唯一性兜底(Redis INCR 是主手段)
idx_orders_requester_dept按部门统计
idx_orders_approve_user按审批人统计
无索引user_id、status、order_time 都没有单独索引

⚠️ 这里有一个真实的查询性能缺口:员工端最常用的查询是

SELECT * FROM orders WHERE user_id = ? AND status = ? ORDER BY order_time DESC

但 user_id、status、order_time 三个都没有索引(只有 requester_dept 和 approve_user 建了索引,而这两个几乎只用于统计)。这意味着员工端的”我的申领单”查询是全表扫描 + filesort。 数据量小的时候看不出来,上万单就是明显的慢查询。

面试标准答法:

「当时只有 requester_dept 和 approve_user 建了索引,是为了部门维度的统计。但员工端最热的查询是 WHERE user_id = ? AND status = ? ORDER BY order_time DESC,这三个字段一个索引都没有,全表扫描加 filesort。正确的做法是建复合索引 idx_orders_user_status_time (user_id, status, order_time DESC)——user_id 和 status 用于等值过滤,order_time 用于排序,这样能同时消掉扫描和 filesort。管理端的 pageQuery 是动态条件(optional number/phone/beginTime/endTime),很难用一个索引覆盖,需要按实际查询模式再决定。」


三、完整代码执行链路

M3 有 7 条链路。★ 标记的是最需要掌握的三条。


★★★ 链路 1:提交申领单(submitOrder,全项目最重要的一段代码)

POST /user/order/submit    Body: {addressBookId, amount(会被覆盖), remark, payMethod, ...}
                           Header: authentication={JWT}
① JwtTokenUserInterceptor.preHandle()
     JwtUtil.parseJWT(userSecretKey, token)  →  验签
     Redis GET login:token:{JWT}            →  白名单(登出即失效)
     employeeMapper.getById(empId)          →  查库校验 status != DISABLE
     BaseContext.setCurrentId / setCurrentRole

② user/OrderController.submit(OrdersSubmitDTO)        ← 第 50 行
     ★ Redis SET NX order:submit:{empId} "1" TTL=5s
        抢到 → 继续
        未抢到 → throw OrderBusinessException(ORDER_REPEAT_SUBMIT)
        ※ 【关键】这一步在事务【之外】。事务回滚不会回滚这个键(见第零部分)
   └─ orderService.submitOrder(dto)

③ OrderServiceImpl.submitOrder(dto)   @Transactional    ← 第 65-66 行,事务起点

   【3.1】Redis GET shop:status
          status != null && status == 0 → throw OrderBusinessException(SHOP_CLOSED)
          ※ 这一句是"采购开关"的唯一真实消费点(M2 地图里提过)

   【3.2】AddressBookMapper.getById(dto.getAddressBookId())        → MySQL
          null → throw AddressBookBusinessException(ADDRESS_BOOK_IS_NULL)
          ※ 原项目的"百度地图配送范围校验 + FastJSON 解析"在此被删除,
            注释写明:企业内部物资送到部门/工位,不存在配送范围概念

   【3.3】userId = BaseContext.getCurrentId()
          ShoppingCartMapper.list(userId)                          → MySQL
          null 或 size()==0 → throw ShoppingCartBusinessException(SHOPPING_CART_IS_NULL)

   【3.4】★ EmployeeMapper.getById(userId)                         → MySQL
          employee == null || deptId == null
             → throw BudgetBusinessException(BUDGET_NOT_FOUND)
          deptId = employee.getDeptId()

   【3.5】★ 金额重算(安全关键,第 104 行)
          amount = shoppingCartList.stream()
                    .map(c -> c.getAmount().multiply(BigDecimal.valueOf(c.getNumber())))
                    .reduce(BigDecimal.ZERO, BigDecimal::add)
          ※ 完全忽略 DTO 里的 amount。
            修复前的缺陷是"前端算好钱告诉后端扣多少":
            可传 amount=0 零元提交;传负数会让预扣 SQL 的
            total_amount - used_amount >= amount 恒成立,
            used_amount = used_amount + 负数 会【反向下减】,凭空增加额度

   【3.6】★★ BudgetService.deduct(deptId, period, amount)          ← 第 107 行
          period = budgetService.currentPeriod()  → "2026-09"
          → UPDATE budget SET used_amount = used_amount + ?
             WHERE dept_id=? AND period=? AND status=1
               AND total_amount - used_amount >= ?      ← M2 的核心
          影响行数 0 → throw BudgetBusinessException(BUDGET_NOT_ENOUGH)
                      → ★★ 异常抛出 → 整个事务回滚 → 不产生任何单据、申领车不清空
          影响行数 1 → 继续

   【3.7】★ 生成申领单号(第 121-127 行)
          dateStr = LocalDate.now().format(BASIC_ISO_DATE)   → "20260930"
          seq = Redis INCR order:number:20260930
                seq == 1 → Redis EXPIRE 2 天(仅首次设 TTL,避免每天残留永久键)
          number = "PO" + dateStr + "-" + String.format("%06d", seq)
                 → "PO20260930-000137"
          ※ 修复前是 String.valueOf(System.currentTimeMillis()):
            毫秒时间戳,同毫秒并发会重复;且 orders.number 无唯一索引

   【3.8】组装并落库主表(第 113-135 行)
          BeanUtils.copyProperties(dto, orders)   ← DTO → 实体
          orders.setAmount(amount)                     ← 用重算值覆盖前端值
          orders.setOrderTime(LocalDateTime.now())
          orders.setPayStatus(Orders.PAID)             = 1 已扣减(注释:预算已预扣成功)
          orders.setStatus(Orders.TO_BE_CONFIRMED)     = 2 待审批
          orders.setAddress(addressBook.getDetail())
          orders.setPhone(addressBook.getPhone())
          orders.setConsignee(addressBook.getConsignee())
          orders.setUserId(userId)
          orders.setRequesterDept(deptId)
          orders.setIsReturned(0)
          OrderMapper.insert(orders)      → MySQL,useGeneratedKeys 回填 orders.id

   【3.9】循环组装明细(第 137-146 行)
          for (ShoppingCart cart : shoppingCartList)
              BeanUtils.copyProperties(cart, orderDetail)   ← cart → OrderDetail
              orderDetail.setOrderId(orders.getId())
          OrderDetailMapper.insertBatch(orderDetailList)
          ※ 一条 INSERT ... VALUES (...),(...),... 批量插入;
            因为 3.3 已保证申领车非空,这里不会产生空的 VALUES 语法错误

   【3.10】ShoppingCartMapper.deleteByUserId(userId)      → MySQL,清空申领车

   【3.11】返回 OrderSubmitVO{id, orderNumber, orderTime, orderAmount}

④ Result.success(vo)

事务边界:@Transactional 在 submitOrder 上(第 65 行)。Redis 的三次操作(shop:status 读、order:submit 写、order:number INCR)全部在事务的语义之外——MySQL 回滚不会撤销它们。这是理解”混合存储的一致性”最好的示例。

并发:预扣靠 InnoDB 行锁(M2);防重靠 Redis SET NX;两者解决的是不同问题(一个防超支,一个防重复建单)。
缓存:shop:status 是唯一的缓存参与。


★★★ 链路 2:员工撤销 / 管理员驳回 / 管理员取消(三条链路同一模式)

这三条链路的结构完全一样,是同一个”读-判断-写”反模式的三份拷贝:

① OrderMapper.getById(id)                         → MySQL,读取当前单据
② 判空 + 状态校验(+ 归属校验,仅员工端)
③ releaseBudget(ordersDB)                         → 可能扣回预算(M2)
④ orderMapper.update(orders{status=6, ..., payStatus=2})   ← 只按 id 更新
链路入口归属校验状态校验事务releaseBudget
员工撤销userCancelById:232✅ assertOwnerstatus > 2 拒绝@Transactional(rollbackFor=Exception.class)✅
管理端驳回rejection:434❌ 不需要!status.equals(2) 拒绝同上✅
管理端取消cancel:463❌ 不需要status == 5 或 6 拒绝同上✅

⚠️ 三条链路都没有”状态条件更新”,所以第 ② 步的状态校验和 ④ 步的写入之间存在一个窗口。这在单用户顺序操作下没问题(第二次进来状态已经变了,被 ② 挡住),但在并发下会出问题。

具体演示(这是 M3 最值得准备的追问答案):

时刻   请求 A:员工撤销 (PUT /user/order/cancel/100)
       请求 B:管理员驳回 (PUT /admin/order/rejection {id:100})
─────────────────────────────────────────────────────────────────
t1     getById(100) → status=2, payStatus=1
t2                                     getById(100) → status=2, payStatus=1
t3     status=2 不 >2,通过
t4                                     status=2 == 2,通过
t5     releaseBudget: payStatus==1 → 返还预算 32
t6                                     releaseBudget: payStatus==1 → 再返还 32  ⚠️ 重复返还
t7     UPDATE ... WHERE id=100 → cancel_reason="员工撤销申领", status=6, pay_status=2
t8                                     UPDATE ... WHERE id=100 → rejection_reason=..., status=6, pay_status=2
─────────────────────────────────────────────────────────────────
结果:预算被返还了两次(M2 已知缺口的实际触发场景),且最终单据的
      cancel_reason 和 rejection_reason 同时存在,审计信息混乱

注意这里两个请求都写 status=6,所以”状态”看起来是对的,掩盖了重复返还。如果换成 confirm(写 status=3)和 rejection(写 status=6)并发,结果就是最终状态取决于谁后写,而预算可能已经被退了——单据显示”采购中”,钱却退回去了。

正确修法(和 M2 的思路完全一致:把判断下推到 WHERE):

<update id="updateStatus">
    update orders
    set status = #{newStatus}, pay_status = #{payStatus}, cancel_time = #{cancelTime}, ...
    where id = #{id} and status = #{expectedStatus}     ← 状态约束进 SQL
</update>
int rows = orderMapper.updateStatus(order, Orders.TO_BE_CONFIRMED);
if (rows == 0) {
    throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);  // 已被别人处理
}
// 只有影响行数 = 1 才继续 releaseBudget() —— 状态迁移成功即"认领处理权"

这一句话是 M3 最重要的面试答案:把状态迁移做成条件更新,用影响行数裁决,只有成功迁移的那一个请求才有权动预算。


★★ 链路 3:审批通过 / 发货 / 核销 / 确认收货(四个”流转”方法)

四个方法结构一样,而且都没有 @Transactional:

// confirm:408  —— 审批通过  【无 @Transactional】
Orders ordersDB = orderMapper.getById(dto.getId());
if (ordersDB == null || !Orders.TO_BE_CONFIRMED.equals(ordersDB.getStatus()))
    throw new OrderBusinessException(ORDER_STATUS_ERROR);
Orders orders = Orders.builder()
        .id(dto.getId()).status(Orders.CONFIRMED)
        .approveUser(BaseContext.getCurrentId())    ← 审批人(从 ThreadLocal 取)
        .approveTime(now)
        .checkoutTime(now)                          ← ★ 复用"结账时间"字段记录审批时间
        .build();
orderMapper.update(orders);
 
// delivery:491   —— 发货   校验 status == CONFIRMED(3) → 置 DELIVERY_IN_PROGRESS(4)
// complete:513   —— 核销   校验 status == DELIVERY_IN_PROGRESS(4) → 置 COMPLETED(5) + deliveryTime
// confirmReceipt:537 —— 员工确认收货
//     校验 status == 4 + 【二次归属校验】BaseContext.getCurrentId().equals(ordersDB.getUserId())
//     → 置 COMPLETED(5) + deliveryTime

三个技术点:

  1. confirm 无 @Transactional 是正确的:它只有一条 UPDATE,单语句自带原子性。不要为了规范乱加。

  2. checkoutTime 被复用记录审批时间(第 421 行,注释写明”复用 checkout_time 记录审批通过时间”)。但 confirm 同时写了 approveTime 和 checkoutTime 两个字段存同一个时间值——这是冗余写入,属于改造时”既想保留新语义字段又怕前端不认旧字段”的中间状态。面试被问”为什么写两次”只能承认是冗余。

  3. confirm 对已审批的单据会”成功”(status 变成 3 后再调 confirm,Java 校验会拦住 → 抛状态错误。等等,这里我要重新确认——)

    ⚠️ 重新核对:confirm 的校验是 !Orders.TO_BE_CONFIRMED.equals(ordersDB.getStatus()) → 第二次调用时 status=3,不等于 2,会抛异常。所以顺序重复是被拦住的。

    只有并发重复拦不住(两个请求都读到 status=2)。我在项目地图和前面 M2 里说的”重复审批会成功”表述不准确,这里更正为:顺序重复被 Java 校验拦住;并发重复拦不住。

  4. confirm 没有归属校验,是有意的——管理端接口,审批人可以是任何 ADMIN/PURCHASER(@RequireRole({ADMIN, PURCHASER}))。


链路 4:员工端分页查询(pageQuery4User —— N+1)

GET /user/order/historyOrders?page=1&pageSize=10&status=2
   ↓ user/OrderController.page
   ↓ OrderServiceImpl.pageQuery4User(pageNum, pageSize, status)     ← 第 170 行
   ├─ PageHelper.startPage(pageNum, pageSize)        ← ThreadLocal 存分页参数
   ├─ dto.setUserId(BaseContext.getCurrentId())      ← 强制只查自己的(无越权风险)
   ├─ dto.setStatus(status)
   ├─ Page<Orders> page = orderMapper.pageQuery(dto)
   │     SQL: SELECT * FROM orders
   │          <where> userId / status 动态条件 </where>
   │          ORDER BY order_time DESC
   │          ← PageHelper 拦截后自动加 LIMIT
   │     ⚠️ user_id / status / order_time 都【无索引】→ 全表扫描 + filesort
   ├─ if (page != null && page.getTotal() > 0)
   │     for (Orders orders : page) {
   │         ★ List<OrderDetail> orderDetails = orderDetailMapper.getByOrderId(orderId)
   │            ← ★ N+1:每页 10 条就多发 10 次 SQL
   │         OrderVO orderVO = new OrderVO();
   │         BeanUtils.copyProperties(orders, orderVO)   ← Orders → OrderVO
   │         orderVO.setOrderDetailList(orderDetails)
   │     }
   └─ return new PageResult(page.getTotal(), list)

两个缺口:

  1. N+1 查询:10 条单据 = 1 + 10 = 11 次 SQL。修法是一次 SELECT * FROM order_detail WHERE order_id IN (...) 再在内存按 orderId 分组。
  2. ⚠️ 潜在的 NPE:return new PageResult(page.getTotal(), list) 在 if 块外面。如果 page 为 null,这里直接 NPE。
    实际上 PageHelper 的 Page 不会为 null(查询无结果时返回空 Page),所以现在不会触发。但这是脆弱的写法——一旦以后把 pageQuery 换成返回普通 List,page.getTotal() 立刻 NPE。面试被问”这里会不会空指针”时,要能答出”PageHelper 保证了非空,但把 getTotal() 放在判空块外是脆弱的”。

链路 5:管理端条件搜索(conditionSearch —— 另一处 N+1 + 字符串拼接)

GET /admin/order/conditionSearch?page=1&pageSize=10&number=PO&status=2&beginTime=...&endTime=...
   ↓ OrderServiceImpl.conditionSearch:331
   ├─ PageHelper.startPage(dto.getPage(), dto.getPageSize())
   ├─ Page<Orders> page = orderMapper.pageQuery(dto)      ← 同一个 SQL,条件更全
   └─ getOrderVOList(page)                                ← 第 342 行
        for (Orders orders : page) {
            OrderVO orderVO = new OrderVO()
            BeanUtils.copyProperties(orders, orderVO)
            String orderGoods = getOrderGoodsStr(orders)    ← ★ N+1 在这里
            orderVO.setOrderGoods(orderGoods)               ← "A4纸*2;签字笔*1;"
            orderVOList.add(orderVO)
        }
 
   getOrderGoodsStr(orders)   ← 第 368 行
        List<OrderDetail> list = orderDetailMapper.getByOrderId(orders.getId())
        list.stream().map(x -> x.getName() + "*" + x.getNumber() + ";")
            .collect(Collectors.toList())
        return String.join("", orderGoodsList)

这比员工端的分页更值得说:它查出明细不是为了返回结构化的明细列表,而是为了拼成一个字符串给管理端表格显示。这说明:

  • 技术上:在 SQL 层做字符串聚合(GROUP_CONCAT)比拉回内存拼接更合适,一次查询就能带出所有单据的物资摘要;
  • 设计上:它暴露了接口设计的问题——管理端列表要显示物资摘要,就应该在 SQL 里聚合出来,而不是为了 UI 展示在应用层做 N 次查询 + 字符串拼接。

面试标准答法:

「这里有两个问题。一是 N+1,每页 10 条要发 11 次 SQL。二是它查明细不是为了返回明细结构,而是为了拼一个 "物资名*数量;" 的字符串给表格展示——这个拼接本可以在 SQL 层用 GROUP_CONCAT 一次完成,或者干脆用一次 WHERE order_id IN (...) 批量查回来在内存分组,两种做法都能把 11 次查询降到 2 次。」


链路 6:订单统计(statistics —— 三次 COUNT 可以合并)

GET /admin/order/statistics
   ↓ OrderServiceImpl.statistics:387
   ├─ orderMapper.countStatus(Orders.TO_BE_CONFIRMED)     → SELECT count(id) WHERE status=2
   ├─ orderMapper.countStatus(Orders.CONFIRMED)           → WHERE status=3
   ├─ orderMapper.countStatus(Orders.DELIVERY_IN_PROGRESS) → WHERE status=4
   └─ OrderStatisticsVO{toBeConfirmed, confirmed, deliveryInProgress}

三次独立查询,可以合并成一次:

SELECT status, count(id) FROM orders WHERE status IN (2,3,4) GROUP BY status

而且 status 也没有索引,三个 COUNT 都是全表扫描。

注意 status 的索引缺失在这里更尴尬:countStatus 的 SQL 是 WHERE status = #{status},唯一条件就是 status,却偏偏没有索引。加一个 status 索引就能把三次全表扫描变成三次索引范围扫描。


★ 链路 7:催办(reminder —— Redis 限流的正确用法示例)

GET /user/order/reminder/{id}
   ↓ OrderServiceImpl.reminder:595
   ├─ orderMapper.getById(id)                     → MySQL
   │     null → throw OrderBusinessException(ORDER_STATUS_ERROR)   ← 【问题】应该报 ORDER_NOT_FOUND
   ├─ assertOwner(ordersDB)                       → 归属校验
   ├─ if (!Orders.TO_BE_CONFIRMED.equals(status)) throw ORDER_STATUS_ERROR
   │     ← 只有待审批才需要催办
   ├─ ★ Redis 限流(第 610-618 行)
   │     remindKey = "order:reminder:" + id
   │     Boolean first = SET NX remindKey, userId, TTL=5min
   │     if (!TRUE.equals(first)) {
   │         Long ttl = GETEXPIRE remindKey, SECONDS          ← 读出剩余时间
   │         throw new OrderBusinessException(ORDER_REMINDER_TOO_FREQUENT
   │                 + "(剩余 " + (ttl == null ? "约 5 分钟" : ttl + " 秒") + ")")
   │     }
   └─ orderMapper.update(orders{id, remindTime=now})

这是全项目 Redis 限流用得最规范的一处,值得单独讲:

  1. 单条原子命令:用 setIfAbsent(key, value, timeout, unit) 一条命令完成”判断 + 设置 + 过期”,而不是 SETNX + 单独 EXPIRE 两步(两步之间进程崩溃会留下永不过期的锁,是经典事故);
  2. 先业务校验、后限流:assertOwner 和状态校验都在 setIfAbsent 之前。顺序很重要——如果先限流,那么一个非法请求(比如催办别人的单据)也会消耗掉 5 分钟的额度,把正常请求挤掉;
  3. 限流提示带剩余时间:读 TTL 返回”剩余 N 秒”,用户体验好;
  4. 不用主动删除:等 TTL 自然过期。催办是低频操作(每单 5 分钟一次),不需要额外的清理逻辑。

唯一的瑕疵:orderMapper.getById(id) == null 时抛的是 ORDER_STATUS_ERROR(“申领单状态错误”)而不是 ORDER_NOT_FOUND(“申领单不存在”)——单据根本不存在,提示却说状态错误。这是错误码复用不当,同一文件里 details、userCancelById、cancel、returnOrder 都正确用了 ORDER_NOT_FOUND,只有 reminder 用错了。


四、为什么这么设计(核心部分)

4.1 状态流转为什么不用”条件更新”——这是 M3 的核心问题

【业务场景】
一张申领单要被审批、驳回、取消、发货、核销、确认收货,多个操作者(员工 + 采购专员 + 管理员)可能同时操作同一张单。

【原始方案(即当前代码)】

Orders o = orderMapper.getById(id);              // ① 读
if (!TO_BE_CONFIRMED.equals(o.getStatus()))      // ② 判断(JVM 内存里)
    throw new OrderBusinessException(...);
orderMapper.update(orders{id, status = CONFIRMED});  // ③ 写:WHERE id = ?

【原始方案的问题】

① 读 和 ③ 写 之间有一个没被任何锁保护的窗口。在窗口内:

  • 另一个请求可以读到同样的旧状态,通过同样的校验;
  • 两个请求都会执行 ③,后写的覆盖先写的(最后写赢 last-write-wins)。

后果分三类:

场景后果
两个请求做同样的流转(审批 × 2)状态看起来正常,但 approve_user / approve_time 被后者覆盖——审批人可能记错人
两个请求做不同的流转(审批 + 驳回)最终状态取决于谁后写;且驳回可能已经退了预算 → 单据显示”采购中”,钱却退回去了
流转 + 撤销(员工撤销 + 管理员取消)预算被重复返还(M2 的已知缺口就是这个场景)

【当前方案的”防线”及其真实强度】

当前代码实际上依赖三层”看起来安全”的东西,但没有一层是原子的:

依赖实际强度
Java 里的状态 if 校验只能防顺序重复(第二次读到的状态已变),防不住并发
@Transactional(三条返还链路有)保证单次操作内部的原子性(预算 + 单据同时成功或同时失败),不提供互斥
payStatus 状态机守卫(M2)只能防顺序重复返还,并发下两个请求都可能读到 payStatus=1

【替代方案对比】

方案实现优点缺点是否适用本项目
A. 状态条件更新(推荐)UPDATE orders SET status=? WHERE id=? AND status=?,用影响行数裁决一次往返;状态迁移原子化;不需要锁;和 M2 的预扣同一个思路,架构一致SQL 数量会增加(每个流转一条带条件的 SQL,而不是复用一条通用 update)✅ 最优,改动小、收益明确
B. 复用现有 update + 在 <set> 加 status 条件同上,但 SQL 复用改动更小通用 update 要加 expectedStatus 参数,语义会变含糊✅ 折中
C. SELECT ... FOR UPDATE 悲观锁读的时候加锁,锁内判断+更新逻辑留在 Java、可表达复杂规则持锁时间变长;必须包在事务里,delivery/complete/confirmReceipt 现在都没有 @Transactional,加了锁还得加事务,改动面变大可行但不是首选
D. 乐观锁(version 列)加 version,UPDATE ... WHERE version=?无锁等待单据是热点吗?不是——同一张单被并发操作是低频事件。乐观锁的失败重试在这里是纯负担❌ 冲突模式不匹配
E. 分布式锁锁 order:{id}跨服务可用单库单行,过度设计(同 M2 的论证)❌
F. 数据库唯一约束(状态迁移表)建 order_transition(order_id, from, to) 唯一索引数据库层绝对防重多一张表;本项目太轻❌ 过度

【为什么选 A】

「这个问题的本质是状态机迁移的原子性。当前是”读出状态 → 在 JVM 里判断 → 无条件写回”,这在顺序操作下没问题,并发下就是典型的 read-modify-write 竞态。

修法和 M2 的预算预扣完全一致:把状态约束写进 WHERE,用影响行数判断这次迁移是否成功。UPDATE orders SET status=3 WHERE id=? AND status=2 返回 1 才说明”我把这张单从待审批推到了采购中”,返回 0 说明已经被别人处理过了。

这样做有三个好处:一是不需要锁,数据库的行锁就够;二是和项目里已有的并发方案保持一致,不用引入新模式;三是顺带解决了重复返还——只有成功迁移状态的请求才有权调 releaseBudget,天然就是幂等的。」


4.2 金额为什么必须后端重算

【业务场景】 员工提交申领单时,前端已经算好了总价并显示在页面上。

【原始方案(改造前的真实缺陷)】

BigDecimal amount = ordersSubmitDTO.getAmount() == null ? BigDecimal.ZERO : ordersSubmitDTO.getAmount();

【原始方案的问题】

amount 完全来自请求体,从不按 shopping_cart 重算。前端提交的正是 amount: Number(this.totalAmount)——前端算好钱,告诉后端扣多少。三个攻击面:

  1. 传 amount = 0 → 零成本提交申领单,used_amount += 0,单据金额为 0;
  2. 传 amount = -100000 → 预扣 SQL 的 total_amount - used_amount >= -100000 恒成立,used_amount = used_amount + (-100000) 反向下减已用金额 → 凭空增加可用额度;
  3. 项目接口文档已经导出(采云台-接口文档-用户端.json),任何拿到接口的人都能直接打,不需要篡改前端。

【当前方案】

BigDecimal amount = shoppingCartList.stream()
        .map(c -> c.getAmount().multiply(BigDecimal.valueOf(c.getNumber())))
        .reduce(BigDecimal.ZERO, BigDecimal::add);

【为什么这么设计】

  • 权威数据源原则:价格和数量都存在服务端(shopping_cart.amount 是加入申领车时从 dish.price 抄的快照,number 是服务端维护的),没有任何理由信任客户端传的钱;
  • 防御式设计:BudgetServiceImpl.deduct 入口还有第二道 amount.signum() <= 0 → return false。两道防线独立成立——即使将来有人改动重算逻辑漏掉负数,deduct 也拦得住。

【替代方案对比】

方案评价
A. 服务端按明细重算(当前方案)✅ 唯一正确做法。服务端掌握权威数据就必须自己算
B. 前端算 + 后端校验”与明细一致”等价于 A,但多一次比较,没有理由不比 A 更直接
C. 前端算 + 后端只做范围校验(>0 且 < 上限)❌ 防不住”把 320 改成 32”,仍可少付
D. 签名/加密金额❌ 治标不治本,密钥在前端就是公共知识

面试可延伸的一句:「这个缺陷的本质是信任边界划错了——把价格的权威性交给了客户端。项目里同类问题还有一处:‘采购开关’shop:status 也是前端不感知的(前端从未调用 /user/shop/status),所以关闭采购后前端界面还是照常显示的,只是提交时才被拦。前端该同步的状态,后端得推给它。」


4.3 单号为什么用 Redis INCR 而不是时间戳

【业务场景】 申领单号是业务主键,要对账、要跟外部沟通,绝对不能重复。

【原始方案】

orders.setNumber(String.valueOf(System.currentTimeMillis()));

【原始方案的问题】

  1. 毫秒级时间戳,同毫秒并发提交会生成完全相同的单号;
  2. orders.number 当时没有唯一索引,数据库也不会拦,重复单号直接落库。

【当前方案】

String dateStr = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
String numberKey = RedisKeyConstant.ORDER_NUMBER_PREFIX + dateStr;
Long seq = stringRedisTemplate.opsForValue().increment(numberKey, 1);
if (seq != null && seq == 1) {
    stringRedisTemplate.expire(numberKey, 2, TimeUnit.DAYS);   // 仅首次设 TTL
}
orders.setNumber("PO" + dateStr + "-" + String.format("%06d", seq));

得到 PO20260930-000137。

【为什么这么设计】

设计点理由
INCR 而不是 GET + SETINCR 是 Redis 单线程模型下的原子命令,天然并发安全,和 M2 用行锁是同一个思路
日期分片 order:number:{yyyyMMdd}单号每天重置,长度可控、可读、可按天排查;也避免单个 key 无限增长
序号补零到 6 位定长单号,字符串排序等价于时间排序
seq == 1 时才 EXPIRE只给首次创建的 key 设过期。如果每次都 EXPIRE,过期时间会被无限刷新,key 就变相永生了
2 天 TTL跨天之后当天的 key 就没用了,2 天留出容错窗口
配套 uk_orders_number 唯一索引数据库层最后兜底(见下面已知边界)

【已知边界 —— 必须主动说】

Redis 被清空 / 重启且无持久化时,序列从 1 重新开始。如果当天已经出过 PO20260930-000001,新单号会撞唯一索引 → 插入失败,用户提交不了申领单。

  • 当前状态下未触发(改造后单号格式变了,库里存量都是旧格式);
  • 有 2 天 TTL 收敛残留;
  • 彻底方案:首次 INCR 返回 1 时,用 SELECT MAX(number) FROM orders WHERE number LIKE 'PO20260930-%' 兜底初始化序列;或者捕获 DuplicateKeyException 后重试。

这个”主动说出边界”的答法比硬说”解决了唯一性问题”更有说服力,因为面试官很可能就是想问这里。


4.4 防重复提交为什么放 Controller 层、为什么用 Redis 而不是唯一索引

【业务场景】 用户手抖连点两下”提交”,会生成两张内容一样的申领单,扣两次预算。

【原始方案】 无任何防护。

【当前方案】 user/OrderController.submit 里:

Long userId = BaseContext.getCurrentId();
String submitKey = RedisKeyConstant.ORDER_SUBMIT_PREFIX + userId;
Boolean first = stringRedisTemplate.opsForValue().setIfAbsent(submitKey, "1", 5, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(first)) {
    throw new OrderBusinessException(MessageConstant.ORDER_REPEAT_SUBMIT);
}

【为什么用 Redis 而不是”去重表 + 唯一索引”】

方案优点缺点
A. Redis SET NX + TTL(当前)一条原子命令;TTL 自动过期,无需清理;不写库、无 IO 压力是尽力而为的(Redis 挂了就失效);会误伤”失败后马上重试”(即第零部分的纠正)
B. 去重表 + 唯一索引强一致,数据库层保证要建表、要清理历史数据、每次提交多一次写库
C. 前端按钮置灰零成本只防”手抖”,防不住直接打接口/脚本
D. 业务幂等键(客户端传 requestId,服务端唯一索引)真正的幂等,重试安全需要前端配合生成并传 requestId;需要一张幂等记录表

【为什么这么设计】

关键在于去重的语义是”短时间窗口”而不是”永久唯一”:用户 5 秒内重复点击是误操作,5 秒后重新提交是正常行为。Redis TTL 天然匹配这个语义——过期自动失效,不需要任何清理逻辑。用唯一索引做这件事反而麻烦:你得决定”多久算重复”,然后定期删历史记录。

【这个方案的真实缺陷(面试要主动讲)】

  1. 粒度是 userId 而不是”某一次提交”:所以它拦的是”这个人的第二次提交”,不是”这一次提交的重复”。5 秒内用户提交了 A 单、想立刻再提交 B 单,也会被拦住;
  2. 在事务外、且失败不回滚:一次业务失败后 5 秒内无法重试,提示还是”请勿重复提交”;
  3. Redis 是单点:Redis 挂了防重就失效(降级为无防护)。

如果要做得更严,应该是 D 方案(客户端幂等键 + 数据库唯一索引):前端每次提交生成一个 requestId,服务端把 requestId 写进 orders(或一张幂等表)并建唯一索引,重复请求会插入失败返回同一个单据。这才是真正的幂等而不是”限流”。


4.5 越权校验(assertOwner)—— 为什么”返回不存在”而不是”无权限”

【业务场景】 员工端接口都是”按 id 操作一张单”,如果只按 id 查、不校验归属,任意登录员工遍历 id 就能读到别人的单据、撤销别人的单、甚至把别人的明细灌进自己的申领车(IDOR,越权访问)。

【原始方案的缺陷(已修复)】

details、userCancelById、repetition、reminder 四个方法只按 id 查单据,不比对 BaseContext.getCurrentId() 和 ordersDB.getUserId()。而 confirmReceipt 和 returnOrder 里有这个校验——说明是漏改四个接口,不是有意设计。

【当前方案】 抽出私有方法统一复用(第 285-289 行):

private void assertOwner(Orders orders) {
    if (!BaseContext.getCurrentId().equals(orders.getUserId())) {
        throw new OrderBusinessException(MessageConstant.ORDER_NOT_FOUND);
    }
}

调用点:details:213、userCancelById:240、repetition:304、reminder:603。

【为什么抛 ORDER_NOT_FOUND(“申领单不存在”)而不是 NO_PERMISSION(“无操作权限”)】

这是一个有意的安全设计,值得单独讲:

  • 如果返回”无权限”,攻击者就确认了这个 id 对应的单据真实存在——可以靠响应差异枚举出系统里有多少单据(ID 存在性探测);
  • 返回”不存在”,越权和不存在在响应上完全一样,攻击者拿不到任何额外信息。

这个原则叫「不要向未授权方泄露资源是否存在」,是权限设计的标准做法。

【注意一处脆弱写法】 BaseContext.getCurrentId().equals(...) 把 getCurrentId() 放在前面。如果 getCurrentId() 返回 null(比如这个 Service 被其他非 HTTP 入口调用、拦截器没执行),会 NPE。更稳的写法是 orders.getUserId().equals(BaseContext.getCurrentId()),或者在方法开头判空。


五、M3 复习优先级文件清单(第五部分 · M3 切片)

M3 只有 627 行、一个文件,但不要从头读。按下面的顺序看,约 40 分钟。

A 类:必须能默写关键片段、能手画状态机

优先级文件 / 位置看什么为什么
P0OrderServiceImpl.java 第 65-160 行submitOrder 全流程(金额重算 → 预扣 → 单号 → 落库 → 清明细)全项目最重要的一段代码。要能按顺序口述 6 个步骤
P0同上 第 231-260 行(userCancelById)+ 第 271-283 行(releaseBudget)撤销链路 + 预算返还的双防线与 M2 的交界处,也是”重复返还”追问的落点
P0同上 第 408-425 行(confirm)+ 第 434-455 行(rejection)审批通过 vs 驳回对比着看:两者都是”读-判断-写”,且都无状态条件更新。这是 M3 缺口的现场
P0OrderMapper.xml 第 27-63 行(update 整块,where 在第 61 行)where id = #{id}就这一行说明了 M3 的全部问题。要能指着它说”status 是无条件写入的目标值,不是校验条件”
P0Orders.java 前 40 行6 个状态常量 + 3 个预算状态常量状态机的地图,必须背下来
P0user/OrderController.java 第 48-61 行Redis 防重(SET NX)要能说清它在事务之外这个事实及其后果

B 类:知道流程和存在的问题即可

文件 / 位置看什么
OrderServiceImpl 第 170-199 行 pageQuery4UserN+1 + page.getTotal() 放在判空块外的脆弱写法
OrderServiceImpl 第 331-380 行 conditionSearch + getOrderGoodsStr更严重的 N+1(为了拼字符串查明细)
OrderServiceImpl 第 387-399 行 statistics三次 COUNT 可合并、status 无索引
OrderServiceImpl 第 595-626 行 reminderRedis 限流最规范的一处(值得看,但只需记住 4 个要点)
OrderServiceImpl 第 537-586 行 confirmReceipt / returnOrder归属校验的正确写法 + 退换不返还预算
OrderMapper.xml pageQuery(第 64-85 行)动态条件查询的写法
sql/migration_fix_defects_20260929.sqluk_orders_number 唯一索引的建立方式(可重复执行的动态 SQL)

C 类:不用看

  • OrdersDTO(全项目无引用,死代码)、OrdersConfirmDTO.status(声明了但从不读取)
  • OrderVO / OrderSubmitVO / OrderStatisticsVO / OrderDetail —— Lombok 数据类
  • OrderMapper.xml 的 insert / sumByMap / countByMap —— 标准 SQL(sumByMap/countByMap 属于 M12 工作台)
  • OrderMapper.getByNumberAndUserId / getByStatusAndOrderTimeLT —— 全项目无引用,是原项目的遗留方法

40 分钟复习顺序

1. Orders.java 状态常量                      ← 2 分钟,背下来
2. OrderMapper.xml 第 27-63 行(where 在第 61 行)               ← 3 分钟,指着 where id 说出问题
3. submitOrder 第 96-160 行                  ← 12 分钟,能按顺序口述 6 步
4. releaseBudget + userCancelById            ← 6 分钟,理解双防线
5. confirm vs rejection 对比                 ← 6 分钟,找出并发窗口
6. OrderController 第 48-61 行               ← 4 分钟,防重 + 事务外的事实
7. 自己讲一遍"状态流转为什么会有并发问题"      ← 7 分钟,这是必问

六、M3 面试追问链(10 层)

追问链 1:并发下的状态流转(M3 最可能被问的一条)


【面试官问题 1】
你这个申领单有好几个状态,审批、驳回、取消、发货都要改状态。这些操作并发的时候会有问题吗?

【推荐回答】
会有,这是我这个模块最大的一个缺口。 我的所有状态流转都是同一个模式:先 getById 读出单据,在 Java 里判断状态对不对,然后执行 UPDATE orders ... WHERE id = ?。读和写之间没有任何锁保护,而且这条 UPDATE 里也没有状态条件,所以两个并发请求可能都读到同一个旧状态、都通过校验、都写入——最后的结果取决于谁后写。

【回答关键词】 主动承认 · read-modify-write 竞态 · 缺状态条件更新 · last-write-wins
【可能继续追问】 能举个具体的例子吗?


【面试官问题 2】
具体会出什么问题?

【推荐回答】
最严重的是”审批 + 驳回”并发。假设采购专员 A 点审批通过、B 点驳回:

  • 两个请求都 getById 读到状态是 2(待审批),都通过校验;
  • B 的驳回链路会先返还预算,再把状态写成 6;
  • A 的审批链路把状态写成 3。

如果 B 先写、A 后写,最终单据是**“采购中”(3),但预算已经退回去了**——单据和账目不一致,而且从单据状态上看完全正常,没有报错、很难发现。反过来如果两个都是驳回或撤销,就是预算被返还两次。

【回答关键词】 审批 vs 驳回 · 状态与账目不一致 · 预算重复返还 · 静默失败
【可能继续追问】 怎么修?


【面试官问题 3】
那这个怎么修?

【推荐回答】
把状态约束写进 WHERE,用影响行数做裁决。现在是:

UPDATE orders SET status = 3 WHERE id = ?

改成:

UPDATE orders SET status = 3, approve_user = ?, approve_time = ?
 WHERE id = ? AND status = 2

业务层看影响行数:返回 1 说明”我成功把这张单从待审批推进到采购中”,返回 0 说明它已经被别人处理过了,直接抛状态错误。只有影响行数为 1 的那个请求才有权继续去动预算,这样既解决了状态覆盖,也顺带把重复返还解决了。

【回答关键词】 状态条件更新 · 影响行数裁决 · 认领处理权 · 顺带解决幂等
【可能继续追问】 这跟你扣预算的方案是同一个思路吗?


【面试官问题 4】
这和你扣预算的方案是同一个思路吗?

【推荐回答】
是的,本质完全一样:都是把”判断”从 Java 内存下推到 SQL 的 WHERE 里,让”判断 + 写入”变成一条原子语句,业务层用影响行数判断成败。区别只是扣预算用的是数值比较(total_amount - used_amount >= amount),状态流转用的是状态等值比较(status = 2)。两者的并发保证都来自 InnoDB 的行锁 + UPDATE 的当前读。所以整个项目的并发方案是一致的一种模式,不是东一块西一块。

【回答关键词】 同一种模式 · 判断下推 · 行锁 + 当前读 · 方案一致性
【可能继续追问】 那为什么当初没这么写?


【面试官问题 5】
既然是想通的,为什么代码里没这么写?

【推荐回答】
两个原因。一是改造的优先级:预算是资金安全,P0;状态覆盖是数据一致性,虽然也重要但当时排在后。二是成本考虑:现在 OrderMapper.update 是一条通用动态 SQL 被所有流转复用,要加状态条件就得给每个流转方法传一个 expectedStatus 参数,或者拆成多条专用 SQL——改动面比改预算那条 SQL 大得多。所以当时先修了预算,状态这块留在了已知问题里。

这个取舍我认,但如果现在让我重做,我会把状态流转也做成条件更新,因为它是幂等性的基础,而且越晚改,依赖这条通用 SQL 的地方越多。

【回答关键词】 优先级取舍 · 通用 SQL 改动面 · 承认决策 · 会重做
【可能继续追问】 那 @Transactional 能解决这个问题吗?


【面试官问题 6】
@Transactional 能不能解决这个并发问题?

【推荐回答】
不能。 @Transactional 保证的是单个事务内部的原子性——要么全成功要么全回滚,比如”返预算”和”改单据状态”这两步要么都做要么都不做。但它不提供互斥,两个事务可以同时读到同一行数据然后都去改。要解决并发覆盖,得靠锁(悲观锁 / FOR UPDATE)或者条件更新 / 乐观锁,事务本身解决不了。

这也是一个常见误解:很多人以为加了 @Transactional 就”并发安全了”,其实它只解决”部分成功”的问题,不解决”互相覆盖”的问题。

【回答关键词】 原子性 ≠ 互斥性 · 事务不解决竞态 · 需要锁或条件更新 · 常见误解
【可能继续追问】 那你怎么验证这个问题存在?


【面试官问题 7】
你怎么证明这个问题真的存在?

【推荐回答】
写并发测试就能复现:起两个线程,用同一个单据 id 同时打 /admin/order/confirm 和 /admin/order/rejection,然后查库看 status 和 budget.used_amount——预期能看到”状态是采购中但预算被退了”这种组合。

说实话,我现在的回归脚本覆盖不到这个场景。regress_api_test.sh 是纯 bash + curl 的顺序接口测试,第 20 节验证的是”重复撤销不造成重复返还”,那是顺序重复(第二次进来被 pay_status 挡住了),并发场景脚本测不出来。要真正验证得写多线程的压测代码。这是我测试覆盖上的一个缺口,我知道它没被验证过。

【回答关键词】 并发测试才能复现 · 脚本是顺序测试 · 主动承认测试缺口
【可能继续追问】 那你觉得这个缺口的严重程度如何?


【面试官问题 8】
这个缺口影响大吗?值得修吗?

【推荐回答】
取决于并发量,但这个缺口比 M2 那个更值得修,理由是触发门槛低得多:

  • M2 的重复返还缺口需要”同一张单被两个请求同时返还”,而审批和驳回本身就是两个不同的人在操作同一张单,这是业务上正常存在的并发,不需要恶意构造。采购专员可能真的有两个人同时在看待审批列表;
  • 后果是静默的数量不一致——没有报错、单据状态看起来正常,只能靠对账发现,而项目恰好没有预算流水表(M2-2),所以对账还得反查 orders 求和。

所以如果要我排修复优先级:状态条件更新 > 预算流水表 > 退换闭环。

【回答关键词】 触发门槛低 · 业务上正常的并发 · 静默不一致 · 无流水表难发现 · 排优先级
【可能继续追问】 好,那你再说说你这个单号是怎么生成的?


追问链 2:单号生成(高频、层次清晰)


【面试官问题 1】
申领单号是怎么生成的?

【推荐回答】
用 Redis 的 INCR 加日期分片。key 是 order:number:{yyyyMMdd},INCR 之后把返回值补零到 6 位,拼成 PO20260930-000137 这样的格式。只有在 INCR 返回 1(也就是这个 key 第一次被创建)的时候设置 2 天的过期时间。

【回答关键词】 Redis INCR · 日期分片 · 补零定长 · 首次 INCR 设 TTL
【可能继续追问】 为什么不用时间戳?


【面试官问题 2】
为什么不用时间戳?很多项目都那么写。

【推荐回答】
时间戳是毫秒级的,两个请求落在同一毫秒就会生成完全相同的单号。而且当时 orders.number 没有唯一索引,数据库也不会拦,重复单号就直接落库了。单号是对账和对外沟通的业务主键,重复的后果很严重。用 Redis INCR 靠的是 Redis 单线程执行命令这个特性,天然并发安全。

【回答关键词】 毫秒碰撞 · 无唯一索引兜底 · INCR 原子性 · 单线程模型
【可能继续追问】 为什么只在第一次 INCR 时设置过期时间?


【面试官问题 3】
为什么只在 INCR 返回 1 的时候设置过期时间?

【推荐回答】
因为 EXPIRE 是重新设置过期时间,不是”如果没有才设置”。如果每次 INCR 都跟着一个 EXPIRE,那只要当天还有单在提交,过期时间就会被不断刷新,这个 key 就变相永生了,永远不回收。只在首次创建时设一次,才能保证它在最后一次使用后的 2 天内过期。

这是一个很常见的坑:限流、计数器场景里常常写成”每次 INCR 都 EXPIRE”,看起来是”续期”,实际上让 TTL 失去了意义。同样的原则我在登录失败计数里也用到了——只有 INCR 返回 1 才设 15 分钟的过期。

【回答关键词】 EXPIRE 是覆盖不是新建 · 避免 TTL 被无限刷新 · 首次才设 · 同类应用(登录计数)
【可能继续追问】 那 Redis 挂了会怎样?


【面试官问题 4】
如果 Redis 挂了,或者数据丢了,会怎样?

【推荐回答】
这是这套方案的已知边界。如果 Redis 被清空或重启且没有持久化,序列会从 1 重新开始。当天如果已经出过 PO20260930-000001,新单号就会撞上 orders.number 的唯一索引,插入失败——用户端表现为提交申领单报错。

当前这个风险还没被触发,因为改造后单号格式变了(加了 PO 前缀),库里存量都是旧格式的时间戳;另外 2 天 TTL 也收敛了残留。

彻底的修法有两种:一是首次 INCR 返回 1 时,用 SELECT MAX(number) FROM orders WHERE number LIKE 'PO{date}-%' 从数据库读当前最大序号来兜底初始化;二是捕获 DuplicateKeyException 后重试一次(重新 INCR)。我倾向于第一种,因为它是”事前修正”而不是”事后补救”。

【回答关键词】 主动承认边界 · 撞唯一索引 · 兜底初始化 MAX · 或捕获重试 · 事前优于事后
【可能继续追问】 那为什么还要建唯一索引?


【面试官问题 5】
既然用 Redis 保证唯一了,为什么还要给 number 建唯一索引?

【推荐回答】
因为唯一索引是最后一道防线,而且它是兜底而不是主手段。 Redis 是外部依赖,可能丢数据、可能被清、可能有人手工 FLUSHDB;唯一索引是数据库层的硬约束,任何路径想写入重复单号都会被拒绝。

更重要的理由是失败行为:如果没有唯一索引,上面说的 Redis 丢数据场景会静默地写入重复单号,等到对账时才发现;有了唯一索引,它会在写入瞬间就失败,错误暴露在第一现场。

这也是我改造这个缺陷时的思路:INCR 解决”正常情况下不重复”,唯一索引解决”异常情况下不静默出错”。两者不是二选一,而是分层的。

【回答关键词】 分层防御 · 不静默出错 · 失败暴露在第一现场 · 主手段 vs 兜底
【可能继续追问】 (通常转向事务边界)


追问链 3:事务边界与”部分成功”(高频,考察对 Spring 事务的理解)


【面试官问题 1】
你这个提交申领单的方法里做了很多事情——扣预算、写主表、写明细、清空申领车。事务边界在哪?

【推荐回答】
事务边界就是 submitOrder 这一个方法,标了 @Transactional。方法里的四次数据库写操作——budget.deduct(UPDATE)、orders.insert、order_detail.insertBatch、shopping_cart.deleteByUserId——都在同一个事务里,要么全成功,要么全回滚。

【回答关键词】 方法级事务 · 四次写操作同事务 · 原子性
【可能继续追问】 为什么扣预算要放在写单据之前?


【面试官问题 2】
为什么先扣预算再写单据?反过来的话不行吗?

【推荐回答】
放在前面是刻意的,因为它是一个”失败要尽早”的校验步骤。

  • 先扣预算:扣失败就立刻抛异常,事务回滚,此时什么都还没写——不会产生单据、申领车也不清空,用户改一下就能重提交;
  • 如果先写单据再扣预算:扣失败了虽然事务也会回滚、单据也会被撤销,但中间多了一次无用的 INSERT,而且如果这个方法的后续逻辑被别处复用、漏了事务注解,就会留下”有单据但没扣钱”的脏数据。

校验前置、写操作后置是这个方法的基本结构,和”先查地址、先查申领车”是同一个原则。

【回答关键词】 失败尽早 · 校验前置 · 减少无用写入 · 防御未知调用方
【可能继续追问】 那 Redis 的操作在事务里吗?


【面试官问题 3】
你这个方法里也用了 Redis——读采购开关、INCR 生成单号。它们也在事务里吗?

【推荐回答】
不在,这是关键的一点。 @Transactional 只管 JDBC 连接上的事务,Redis 的操作是通过独立的连接发的,不受 Spring 事务管理。所以:

  • Redis 的操作不会因为数据库回滚而被撤销;
  • 数据库的写也不会因为 Redis 操作失败而回滚(除非异常抛出)。

具体到我的代码,有两个实际后果:

  1. INCR 生成的序号在回滚后不会退回,会留下”跳号”。这可以接受——单号不连续不是问题,唯一才重要;
  2. 更值得注意的是 Controller 层的防重键:它在事务外面写的,业务失败回滚后这个键还在,所以 5 秒内用户改好了也提交不了,提示还是”请勿重复提交”。这是一个真实的体验缺陷。

【回答关键词】 Redis 不受 Spring 事务管理 · 独立连接 · 序号跳号可接受 · 防重键不回滚是缺陷
【可能继续追问】 那如果想让 Redis 也参与事务怎么办?


【面试官问题 4】
如果想让 Redis 的操作和数据库事务保持一致,有什么办法?

【推荐回答】
几种思路,各有代价:

  1. 把 Redis 操作挪到事务提交之后——用 TransactionSynchronizationManager.registerSynchronization,在 afterCommit 里做。适合”成功后才需要通知/清理”的场景;
  2. 回滚时主动补偿——在 afterCompletion 里判断状态是 STATUS_ROLLED_BACK 就主动 DEL 掉那个防重键。这正好能修我上面说的那个缺陷;
  3. 不在事务里做 Redis 的事——最省事,但要接受不一致;
  4. 真正要强一致就不能靠 Redis——用数据库表 + 事务,比如把防重做成”幂等键 + 唯一索引”,让数据库事务来保证。

我现在是第 3 种(接受不一致),因为防重键的设计本身就是”5 秒短窗口、过期即失效”,容忍这点误差。但如果要修那个体验问题,第 2 种最直接——几行代码在 afterCompletion 里删键。

【回答关键词】 afterCommit / afterCompletion 钩子 · 主动补偿 · 或改回数据库 · 权衡
【可能继续追问】 你这个方法会不会有长事务的问题?


【面试官问题 5】
这个事务里有 SQL、有 Redis 操作,会不会事务太长?

【推荐回答】
当前不会,但值得说明。 事务里的四步都是本地操作——三次数据库写、一次 Redis 读(在方法开头),没有远程调用、没有 MQ 发送、没有文件 IO。整个事务的持锁时间就是几次本地数据库操作,毫秒级。

不过有两处需要注意:

  1. employeeMapper.getById(userId)、addressBookMapper.getById()、shoppingCartMapper.list() 这些查询也在事务里。查询本身不加锁(普通快照读),但会让事务持续时间略长。如果要优化,可以把这些只读校验挪到事务开始之前;
  2. budget 那一行的 X 锁会一直持有到事务提交。所以同一部门的并发提交是串行的,持锁时间 = 后续所有操作的时间(插入主表、批量插入明细、清空申领车)。这是 M2 和 M3 交界处一个值得说的点:扣减放在事务末尾会更优,因为持锁时间最短。

【回答关键词】 无远程调用 · 持锁时间毫秒级 · 只读校验可前置 · 扣减放末尾可缩短持锁
【可能继续追问】 那受检异常为什么用 rollbackFor?


【面试官问题 6】
我看到你的 rejection、cancel 都写了 @Transactional(rollbackFor = Exception.class),为什么要显式指定?直接 @Transactional 不行吗?

【推荐回答】
因为这三个方法声明了 throws Exception,抛的是受检异常。 Spring 的默认回滚规则是:只在遇到 RuntimeException 和 Error 时回滚,遇到受检异常(Exception 的非运行时子类)默认不回滚。

这三个方法如果按默认规则,一旦 orderMapper.update() 抛出一个受检异常,事务就会提交——变成”预算已经退回去了,但单据状态还是待审批”,这是资金口径不一致。

所以必须显式写 rollbackFor = Exception.class,让所有异常都回滚。

对比一下:submitOrder 只标了 @Transactional 没有 rollbackFor,它是对的——因为 submitOrder 抛的都是 OrderBusinessException / BudgetBusinessException 这类运行时异常(它们继承自 BaseException,而 BaseException 是 RuntimeException 的子类),默认就会回滚。所以两处不一样是因为抛出的异常类型不一样,不是写法不统一。

【回答关键词】 默认只回滚 RuntimeException · 受检异常需显式 rollbackFor · submitOrder 抛运行时异常所以不用 · 差异有原因
【可能继续追问】 那你怎么验证回滚真的生效了?


【面试官问题 7】
你怎么确认事务回滚真的生效了?

【推荐回答】
有三层证据。

代码层面:BaseException 继承 RuntimeException,submitOrder 抛的预算不足异常走默认回滚规则,一定会回滚。

功能层面:我的回归脚本第 21 节专门验证了”超额拦截后预算未变动”——先把部门余额压低到 100,然后提交一张 199 元的单,断言接口返回 code=0(失败)、并且预算的 used_amount 完全没有变化。这就证明了扣减失败时事务确实回滚了。

但我要诚实说一个缺口:这个断言验证的是”扣减失败这个分支没有留下副作用”,没有验证”扣减成功之后、后续步骤失败”时的回滚。比如”预算扣成功了、插入订单时失败”,理论上事务会回滚把预算还回去,但我的脚本没有构造这个场景(要让 orderMapper.insert 抛异常,得注入故障或者故意造一个约束冲突)。这是测试覆盖上的一个空白,我知道它没被验证过——要验证的话得写一个会失败的插入,或者用 Mock 让 Mapper 抛异常。

【回答关键词】 异常继承关系 · 回归脚本 21 节实证 · 主动承认未验证的分支 · 如何补测
【可能继续追问】 (通常转向查询性能)


追问链 4:查询性能与 N+1(高频,考察索引和查询设计)


【面试官问题 1】
员工端看”我的申领单”这个列表,SQL 是怎么执行的?

【推荐回答】
SELECT * FROM orders WHERE user_id = ? AND status = ? ORDER BY order_time DESC,PageHelper 在前面拦一下自动加 LIMIT。然后这里有两个问题:一是 user_id、status、order_time 一个索引都没有,所以是全表扫描加 filesort;二是查出来每一条单据之后,我又在循环里逐条去查明细,N+1。

【回答关键词】 主动暴露 · 无索引 · 全表扫描 + filesort · N+1
【可能继续追问】 索引该怎么加?


【面试官问题 2】
那索引应该怎么加?

【推荐回答】
建一个复合索引 idx_orders_user_status_time (user_id, status, order_time DESC)。 理由按最左前缀原则:

  • user_id 放最左:员工端查询必然带上它,而且区分度高——每个员工只看自己的单;
  • status 放中间:它是可选条件(不传 status 就查全部),放在 user_id 后面仍然能用上(只是等值部分少了),不会让索引失效;
  • order_time 放最后:它只用于排序,放在最后才能让 MySQL 直接按索引顺序取出、消掉 filesort。

这里要特别说清一个点:ORDER BY 用上索引的前提是前面所有的等值条件都命中。如果只传 user_id 不传 status,status 这一列就断了,order_time 的排序就用不上索引——这就是”最左前缀”的边界。如果这个查询模式很关键,可以考虑按”是否传 status”走两条不同的 SQL,或者再建一个 (user_id, order_time) 的索引。

【回答关键词】 复合索引 · 最左前缀 · 等值列在前排序列在后 · 消 filesort · 可选条件的边界
【可能继续追问】 那 N+1 怎么解决?


【面试官问题 3】
N+1 怎么解决?你在项目里别的地方也遇到过吗?

【推荐回答】
用 IN 批量查回来再在内存分组:先把这一页的 orderId 收集成一个列表,然后一次 SELECT * FROM order_detail WHERE order_id IN (...) 查出所有明细,再用 Collectors.groupingBy(OrderDetail::getOrderId) 按单据分组,最后填回每个 VO。从 1+N 次查询降到 2 次。

项目里有三处同类问题:

  1. OrderServiceImpl.pageQuery4User(第 189 行)——员工端分页,循环查明细;
  2. OrderServiceImpl.getOrderGoodsStr(第 370 行)——管理端分页,而且这个更特别:它查明细不是为了返回明细结构,而是为了拼一个 "物资名*数量;" 的字符串。这种情况其实用 SQL 的 GROUP_CONCAT 一次就能聚合出来,连内存分组都省了;
  3. GoodsServiceImpl.listWithSpecs(第 222 行)——物资列表,循环查规格。

第 3 处最严重,因为员工端物资目录是高频读,虽然结果进了缓存,但每次缓存重建都要打 N+1 次库。

【回答关键词】 IN + groupingBy · 两次查询 · 三处同类 · GROUP_CONCAT 更优 · 高频读放大了影响
【可能继续追问】 那你怎么知道哪个索引真的有效?


【面试官问题 4】
你怎么确认索引真的被用上了?

【回答回答】
用 EXPLAIN 看几个关键列:type(最好到 ref 或 range,ALL 就是全表扫描)、key(实际用了哪个索引)、rows(预估扫描行数)、Extra(出现 Using filesort 说明排序没走索引,Using index 说明是覆盖索引)。

而且要诚实说:这个项目我没有做压测,也没有系统性地跑过 EXPLAIN 分析所有慢查询。 我说”这三个字段没索引”是根据表结构和 SQL 推断的,不是实测结论。如果要严谨,应该开慢查询日志、收集真实的慢 SQL,再用 EXPLAIN 逐条确认。

这也是我不想把项目说成”做了性能优化”的原因——索引这块我是识别出了问题,但没有度量过收益。

【回答关键词】 EXPLAIN 关键列 · 承认没有压测 · 靠推断不是实测 · 严谨做法是慢查询日志
【可能继续追问】 那 statistics 那个接口呢?


【面试官问题 5】
管理端那个各状态数量统计的接口,有什么问题?

【推荐回答】
两个问题。

一是三次查询可以合并成一次。 现在是分别对 status = 2、3、4 各做一次 SELECT count(id),其实一条 SELECT status, count(id) FROM orders WHERE status IN (2,3,4) GROUP BY status 就够了,返回三行在内存里组装。

二是更尴尬的——status 这个字段本身没有索引。 countStatus 的 SQL 是 WHERE status = #{status},唯一的条件就是 status,却偏偏没给它建索引,所以三次 COUNT 都是全表扫描。加一个 status 单列索引,就能把三次全表扫描变成三次索引范围扫描。

另外还有个语义问题:这个统计是全量历史的(没有时间条件),所以”待审批”里包含了几年前的旧单。如果业务上只关心近期的,应该像工作台那样加时间范围。当前是没加的,这也是一个可以改进的点。

【回答关键词】 三次 COUNT 合并为 GROUP BY · status 无索引 · 全量无时间范围 · 语义待确认
【可能继续追问】 (通常转向越权/IDOR)


追问链 5:越权与安全(5 层)


【面试官问题 1】
员工端有个”查询申领单详情”的接口,如果我把 id 改成别人的单号,能查到吗?

【推荐回答】
查不到,我用统一的方法做了归属校验。 在 details 里查出单据之后会调 assertOwner(orders),比对 BaseContext.getCurrentId() 和 orders.getUserId(),不一致就抛异常。

【回答关键词】 归属校验 · assertOwner · 统一复用
【可能继续追问】 你为什么返回”单据不存在”而不是”无权限”?


【面试官问题 2】
如果不一致,你返回什么?“无权限”还是”不存在”?

【推荐回答】
返回”申领单不存在”。 这是有意的:如果返回”无权限”,攻击者就确认了这个 id 对应的单据真实存在,可以通过响应差异枚举出系统里有多少单据、哪些 id 有效。返回”不存在”的话,越权和真的不存在在响应上完全一样,拿不到额外信息。

这是权限设计里”不要向未授权方泄露资源是否存在”的原则。

【回答关键词】 不泄露资源存在性 · 防 ID 枚举 · 幂等响应
【可能继续追问】 这个校验是每个方法都做了吗?


【面试官问题 3】
这几个员工端接口你都做了校验吗?

【推荐回答】
现在都做了。但这恰恰是改出来的——原来漏了四个。

最开始只有 confirmReceipt 和 returnOrder 做了归属校验,details、userCancelById、repetition、reminder 四个都漏了。漏的后果挺严重:遍历 id 就能读别人的单据详情(包含收货人、电话、地址、全部明细)、撤销别人的单据并触发预算返还、把别人的单据明细灌进自己的申领车、催办别人的单据。

修的时候我抽了个私有方法 assertOwner 统一复用,现在四个地方都调了它。这个教训是:权限校验必须有一个统一入口,一处一处写必然漏。

【回答关键词】 原来是漏改的 · IDOR 后果具体化 · 抽公共方法 · 统一入口原则
【可能继续追问】 那管理端接口呢?


【面试官问题 4】
管理端接口怎么控制权限?会不会有越权?

【推荐回答】
管理端用了两层:

  1. 认证层:JwtTokenAdminInterceptor 拦 /admin/**,校验 JWT + Redis 白名单,并且查库确认这个员工不是 EMPLOYEE 角色、账号没被停用;
  2. 授权层:@RequireRole 注解 + RoleAspect 切面,判断当前角色在不在允许列表里。

但有一个设计取舍我必须说清:RequireRole 是未标注即放行的。RoleAspect 里第一句就是 if (requireRole == null) return;。所以新增一个管理端接口如果忘记加注解,默认是放行的。

当前覆盖率是管理端 27/28 个写接口都标了(只有 logout 没标,那是合理的),读接口按设计不标。但这依赖开发者记得加,是个隐患。更安全的做法是改成”默认拒绝、显式放行”——对管理端统一要求标注,或者干脆在拦截器层做一次兜底检查。

【回答关键词】 认证与授权分层 · 默认放行是隐患 · 覆盖率 27/28 · 应改默认拒绝
【可能继续追问】 那你怎么知道有没有漏的接口?


【面试官问题 5】
你怎么确认没有漏掉的接口?

【回答回答】
说实话,我主要是靠人工核对 + 回归脚本的抽样验证,没有一个自动化的保障机制。

回归脚本里验证了几条关键路径:普通员工不能登录管理端、员工 token 调管理端返回 401、采购专员不能新增部门、采购专员不能改预算、采购专员可以查部门列表。但这些都是抽样,不是全量覆盖——我没法证明每个管理端接口都被测过。

要真正做到有保障,应该:① 把 @RequireRole 改成默认拒绝,让”漏标”变成”访问被拒”而不是”静默放行”(让错误暴露出来比事后审计有效);② 写一个测试遍历所有 /admin/** 接口,用最低权限角色的 token 去打,断言全部返回拒绝。

第二点是这个项目没做的,属于我识别出来的测试缺口。

【回答关键词】 靠人工核对 + 抽样 · 无自动化保障 · 默认拒绝让错误暴露 · 遍历式权限测试
【可能继续追问】 (通常转向”防重复提交”)


追问链 6:防重复提交与幂等(6 层)


【面试官问题 1】
用户连点两次提交按钮,会不会生成两张单、扣两次钱?

【推荐回答】
不会,我在 Controller 层用了 Redis 的 SET NX。 key 是 order:submit:{员工id},TTL 5 秒。只有抢到锁的那次请求才会进 Service,第二次会抛”请勿重复提交申领单”。

【回答关键词】 SET NX · 5 秒 TTL · 按用户维度 · Controller 层拦截
【可能继续追问】 为什么用 Redis 不用数据库唯一索引?


【面试官问题 2】
为什么用 Redis 而不是建个去重表加唯一索引?

【推荐回答】
因为这个去重的语义是”短时间窗口”,不是”永久唯一”。5 秒内重复点是误操作,5 秒后重新提交是正常行为。Redis 的 TTL 天然匹配这个语义——过期自动失效,不需要任何清理逻辑。用唯一索引的话,得先定义”多久算重复”,然后定期删历史记录,反而更麻烦。

【回答关键词】 短窗口语义 vs 永久唯一 · TTL 自动过期 · 无需清理 · 语义匹配
【可能继续追问】 这个方案的缺点是什么?


【面试官问题 3】
那它有什么缺点?

【推荐回答】
三个,我都清楚。

一、粒度不对。 key 是 userId,所以它拦的是”这个人的第二次提交”,不是”这一次提交的重复”。如果用户 5 秒内想提交两张不同的单,第二张会被误拦。

二、在事务外,失败不回滚。 这个是更实际的缺陷:SET NX 在 Controller 里、事务之前执行。如果 Service 里因为”余额不足”回滚了,Redis 的键不会回滚,用户 5 秒内改好了重新提交,会被”请勿重复提交”挡住——提示和真实原因不符,体验很差。

三、Redis 是单点。 Redis 挂了,防重就完全失效,降级成无防护。这是”尽力而为”的方案,不是强保证。

【回答关键词】 粒度是用户不是请求 · 事务外不回滚 · 失败后无法立即重试 · 提示误导 · 单点降级
【可能继续追问】 那怎么才能做到真正的幂等?


【面试官问题 4】
怎么才算真正的幂等?

【推荐回答】
真正的幂等需要”客户端幂等键 + 服务端唯一约束”。 具体做法:前端每次生成一个新的 requestId(UUID)并在提交时带上;服务端把 requestId 一起写进单据表(或者一张幂等记录表),在 requestId 上建唯一索引。

这样重复提交时,第二次插入会撞唯一索引 → 服务端捕获后返回第一次创建的那张单据,而不是报错。这才是幂等:同一请求无论发多少次,结果都一样,而且是同一个结果。

区别在于:

  • 我现在的 SET NX 是限流/去重——它防的是”短时间内再次进入”,重复请求被拒绝;
  • 幂等键是幂等——重复请求被接受并返回同一个结果。

对于”提交申领单”这种场景,幂等键其实更合适,因为用户的诉求是”我不小心点了两下,但我要一张单”,而不是”我要被拒绝”。

【回答关键词】 幂等键 requestId · 唯一索引 · 返回同一个结果 · 幂等 vs 去重的区别 · 语义更合适
【可能继续追问】 那你为什么没这么做?


【面试官问题 5】
既然幂等键更合适,你为什么没做?

【回答回答】
因为它是跨前后端的改动,而当时我在做的是后端侧的缺陷修复。 幂等键需要前端配合生成和传递 requestId,还要决定 requestId 存在 orders 表里还是单独一张幂等表——如果存在 orders 表里要加列和唯一索引,如果单独建表还要考虑清理策略。

而且当时 Redis 防重已经能覆盖最主要的场景(手抖连点,两次点击间隔通常几百毫秒,远小于 5 秒)。所以我选了改动最小、见效最快的方案,同时清楚它是个”够用但不是最优”的解。

如果要继续优化,我的优先级是:先把”事务回滚后删除防重键”这个体验问题修掉(几行代码),再考虑上幂等键(需要前端配合)。

【回答关键词】 跨端改动 · 最小改动优先 · 覆盖主要场景 · 分优先级的改进计划
【可能继续追问】 那这个防重逻辑为什么放在 Controller 而不是 Service?


【面试官问题 6】
这段防重逻辑为什么写在 Controller 里,不写在 Service 里?

【推荐回答】
两个理由,但我要承认第二个理由更多是”当前的样子”而不是”最好的样子”。

理由一:防重是接入层的关注点,本质上是”拒绝重复的 HTTP 请求”,和业务逻辑无关。放在 Controller 层符合分层职责,而且能直接拿到 BaseContext.getCurrentId()。

但理由二是我要坦白的:它其实更应该放在 Service 里,因为:

  • 放在 Service 里才能被事务同步机制管理——可以用 TransactionSynchronization 在事务回滚时主动删键,解决上面说的”失败不能重试”的问题;
  • 放在 Service 里意味着所有调用入口都受保护,而现在如果将来有别的入口(比如内部调用、批量导入)直接调 submitOrder,就绕过了防重。

所以这是一个我可以承认的架构味道:Controller 里塞了业务关注点,Service 层拿不到这个保护。

【回答关键词】 接入层关注点 · 但事务同步能管理 · 所有入口受保护 · 承认架构味道
【可能继续追问】 (通常转向”退换为什么不退预算”)


追问链 7:退换单的预算(4 层,容易问出闭环缺口)


【面试官问题 1】
员工发起退换了,预算会退回去吗?

【推荐回答】
不会。这是有意的设计。 returnOrder 只做状态流转——把单据状态置成已取消、打上 is_returned = 1 标记,不调用 releaseBudget。

代码注释写明了理由:避免”退换即退款”造成资金口径混乱。因为退换的货物可能已经采购甚至已经用了,退换是否成功、退多少是线下协商的结果,不是系统能自动判定的。如果系统一提交退换就把预算退回去,员工可以”申请退换 → 预算退回 → 线下确认不退了”,形成预算套利。

【回答关键词】 有意不返还 · 防预算套利 · 线下协商不可自动判定 · 代码注释有说明
【可能继续追问】 那这笔预算最后怎么退?


【面试官问题 2】
那这笔预算最后要怎么退?管理端有对应操作吗?

【推荐回答】
这里确实有个闭环缺口,我得承认。 注释说”由管理端线下确认后处理”,但系统里其实没有这个入口。

原因是:returnOrder 把状态置成了 CANCELLED(6),而管理端 cancel 方法的守卫是 “status == COMPLETED 或 status == CANCELLED 就拒绝”。所以退换之后,管理员没法通过 /admin/order/cancel 来返还这笔预算——单据已经是 6 了,会被直接拒绝。

结果就是:退换单的预算在系统里永远退不回来,只能直接改数据库。这不是设计,这是改造时留下的缺口。

【回答关键词】 主动承认闭环缺口 · cancel 的守卫会拒绝 · 只能改库 · 是缺口不是设计
【可能继续追问】 那应该怎么改?


【面试官问题 3】
那应该怎么修?

【推荐回答】
给退换一个独立的中间状态,让管理员审批。 大致是:

  1. Orders 加一个状态常量 RETURNING = 7(“退换待确认”);
  2. returnOrder 把状态从 COMPLETED(5) 改成 RETURNING(7),而不是直接跳到 CANCELLED(6),同时记录 is_returned = 1 和原因;
  3. 管理端加一个”确认退换”的接口,校验状态是 7,然后调 releaseBudget() 返还预算并把状态推进到 CANCELLED(6);再加一个”拒绝退换”的接口把状态退回 COMPLETED(5)。

这样预算返还是一个显式的、有审批的管理端动作,既保留了”不自动退”的设计意图,又补上了闭环。

代价是状态机从 6 个状态变成 7 个,所有跟”终态判断”有关的地方都要检查一遍(比如 cancel 的守卫、前端的状态显示、统计接口)。所以这是我排在后面的原因之一。

【回答关键词】 加状态 7 · 退换待确认 · 管理端审批后返还 · 拒绝则退回已完成 · 状态机扩张的代价
【可能继续追问】 为什么当初不一起做?


【面试官问题 4】
这个缺口严重吗?为什么当时不一起做?

【回答回答】
严重程度中等:它不会导致数据被破坏,只是一笔钱退不回去,需要人工改库。而且触发频率低——退换本身是低频操作。

当初没做的原因是我在做”资金安全类”的缺陷修复,优先修的是那些”会导致钱算错”的问题(前端传金额、重复返还、越权撤销)。退换不返还预算不是”算错”,而是”少退”,风险等级低一档,所以排在了后面。

我认这个优先级判断。 但如果从”系统完整性”的角度看,一个有注释说明设计意图、却没有对应实现入口的功能,其实是比没有这个功能更糟的——因为它会让人以为这件事被处理了。

【回答关键词】 风险等级中等 · 不会算错只是少退 · 低频 · 优先级判断合理 · 但”有注释无实现”比没有更糟
【可能继续追问】 (通常收尾)


七、M3 容易被质疑的地方(严格视角,不强行合理化)

#问题面试官为什么可能质疑当前代码实际情况我应该怎么解释是否建议修改
M3-1所有状态流转都无状态条件更新”审批和驳回同时发生时,状态和预算哪个赢?”✅ 成立,M3 最大缺口。OrderMapper.update 的 WHERE 只有 id;confirm/delivery/complete/confirmReceipt/rejection/cancel/userCancelById 七个方法全是”读-判断-写”主动承认,并给出修法:把状态约束写进 WHERE,用影响行数裁决——UPDATE orders SET status=3 WHERE id=? AND status=2,只有影响行数=1 的请求才有权继续动预算。这与 M2 的预扣是同一个模式,架构上一致建议修(优先级最高)。触发门槛低(两个采购专员同时处理同一张单是业务上正常的并发),后果是静默不一致
M3-2user_id / status / order_time 无索引”员工端最热的查询走什么索引?”✅ 成立。orders 只有 uk_orders_number、idx_orders_requester_dept、idx_orders_approve_user。而员工端核心 SQL 是 WHERE user_id=? AND status=? ORDER BY order_time DESC → 全表扫描 + filesort。countStatus 的 WHERE status=? 也没有索引「应该建复合索引 idx_orders_user_status_time (user_id, status, order_time DESC)。列序是按”必带且区分度高 → 可选等值 → 排序”排的,这样等值过滤和排序都能走索引、消掉 filesort。但我必须说清:我没做压测、没跑过 EXPLAIN,这是根据表结构和 SQL 推断的,不是实测结论。」建议加(这是最实在的性能优化项)
M3-3防重键在事务外、失败不回滚”提交失败了改一下再提交,会被拦吗?”✅ 成立。SET NX 在 Controller 层、@Transactional 之外。业务失败(余额不足等)回滚后,Redis 键仍在 5 秒「会被拦,提示还是’请勿重复提交’,和真实原因不符。修法是用 TransactionSynchronization.afterCompletion 在回滚时主动 DEL 这个键,或者把防重挪到 Service 层让它受事务同步管理。」建议修(几行代码,消除一个能稳定复现的体验缺陷)
M3-4退换单的预算系统内退不回来”退换之后钱怎么退?”✅ 真实闭环缺口。returnOrder 不调 releaseBudget(有意),但把状态置成 CANCELLED(6),而 cancel 的守卫拒绝已取消单据 → 管理员也没有入口能退「不自动退是有意的,防预算套利。但注释说’管理端线下处理’,系统里却没有对应入口——因为状态已经是 6 了,/admin/order/cancel 会拒绝。修法是加状态 7(退换待确认),由管理员审批后才返还。」建议修(补上状态 7 + 两个管理端接口)
M3-5pageQuery4User 和 getOrderGoodsStr 两处 N+1”一页 10 条单据发了几次 SQL?”✅ 成立。各 1+N(每次 getByOrderId)。getOrderGoodsStr 更特别:查明细是为了拼展示字符串,这种情况 SQL 用 GROUP_CONCAT 一次就能出「用 WHERE order_id IN (...) 一次批量查回来,再用 Collectors.groupingBy 内存分组,能从 1+N 降到 2 次。管理端那个拼接字符串的场景,更该在 SQL 层用 GROUP_CONCAT 聚合。」顺带说项目里三处同类(第三处 GoodsServiceImpl.listWithSpecs 影响最大,因为员工端物资目录是高频读)建议修(N+1 是最容易被追问的性能问题)
M3-6reminder 单据不存在时错误码用错”催办一张不存在的单,提示什么?”✅ 成立。reminder 里 ordersDB == null 抛的是 ORDER_STATUS_ERROR(“申领单状态错误”),而同一文件的 details / userCancelById / cancel / returnOrder 都正确用了 ORDER_NOT_FOUND。只有这一处用错「单据根本不存在,提示却说状态错误,错误码复用不当。同一文件其他四个方法都用了 ORDER_NOT_FOUND,这里是漏改。」建议修(一行改动)
M3-7confirm 同时写 approveTime 和 checkoutTime”为什么两个字段写同一个时间?“⚠️ 冗余写入,注释写明”复用 checkout_time 记录审批通过时间”。属于改造时”想引入新语义字段又怕动旧字段”的中间状态「checkout_time 原本是’结账时间’,我复用它记录审批通过时间,同时又加了 approve_time 存同一个值,确实是冗余。干净的做法是只保留 approve_time,把 checkout_time 废弃;但要同步改前端和接口文档,所以当时没做。」可选(数据冗余,不影响正确性)
M3-8pageQuery4User 把 page.getTotal() 放在判空块外”如果 page 是 null 呢?“⚠️ 脆弱但不触发。return new PageResult(page.getTotal(), list) 在 if (page != null ...) 块外;实际 PageHelper 的 Page 不会为 null,所以现在不会 NPE「PageHelper 保证了 Page 非空,所以现在不会空指针。但把 getTotal() 放在判空块外是脆弱的写法——如果以后 pageQuery 换成返回普通 List,这一行立刻 NPE。应该把 return 也挪进判空块,或者用三目兜底。」建议修(防御性,零风险)
M3-9管理端分页参数未校验默认值”不传 page/pageSize 会怎样?“⚠️ OrdersPageQueryDTO 的 page / pageSize 是原始类型 int,不传默认为 0。PageHelper.startPage(0, 0) 会返回空结果,不报错但也不给提示。而员工端 pageQuery4User(page, pageSize, status) 的入参也是 int「不传分页参数会静默返回空列表,因为 int 默认 0 而 PageHelper 对 0 不报错。改成包装类型 Integer 加默认值(比如 1 和 10)会更友好。」可选(体验优化)
M3-10statistics 三次 COUNT 可合并、且全量无时间范围”为什么查三次?统计的是哪个时间段?“⚠️ 成立。三次独立 countStatus;且没有时间条件,统计的是全量历史,status=2 里包含几年前的旧单「三次 COUNT 可以合并成 SELECT status, count(id) ... GROUP BY status。另外它统计的是全量历史,语义上’待审批’可能包含很久以前的单——如果业务只关心近期,应该像工作台那样加时间范围。这一点需要跟业务确认语义,不是纯技术问题。」可选(合并查询 + 确认业务语义)
M3-11assertOwner 的写法有潜在 NPE”如果 getCurrentId() 返回 null 呢?“⚠️ 成立。BaseContext.getCurrentId().equals(orders.getUserId()) 把可能为 null 的调用放在前面。经拦截器进入的请求不会为 null,但如果 Service 被其他非 HTTP 入口调用就会 NPE「经拦截器的请求不会为 null,但把 getCurrentId() 放在 .equals() 前面是脆弱的。反过来写 orders.getUserId().equals(BaseContext.getCurrentId()),或者方法开头判空更稳。」建议改(防御性,零风险)
M3-12OrdersDTO 是死代码,OrdersConfirmDTO.status 声明未用”这个 DTO 用在哪?确认接口的 status 有什么用?”✅ 成立。OrdersDTO 全项目零引用(仅定义在 pojo 里);OrdersConfirmDTO 有 status 字段但 confirm() 从不读取它,状态是硬编码 Orders.CONFIRMED「这两个是原项目遗留:OrdersDTO 已经没有任何引用了;OrdersConfirmDTO.status 声明了但服务端不用,状态是写死的。清理掉更干净,也避免读者以为可以从前端指定状态(那会是安全问题)。」建议清理(尤其 status 字段——留着会让人误以为前端能指定目标状态)
M3-13OrderMapper 有两个无引用方法”这两个方法是干什么的?”✅ 成立。getByNumberAndUserId、getByStatusAndOrderTimeLT 全项目无调用(原项目用于微信支付回调和自动取消超时订单,改造后支付环节移除、也没有定时任务)「这两个是原项目遗留的死方法。getByStatusAndOrderTimeLT 原来配合定时任务做’超时未支付自动取消’,但我的新流程里提交即预扣预算、没有支付环节,也没有定时任务,所以它没用了。应该删掉,否则会让人以为有超时取消机制。」建议删除(避免”看起来有定时取消”的误解)

明确不是问题、但可能被问的点

点回答
confirm / delivery / complete / confirmReceipt 没有 @Transactional是正确的。每个方法只有一条 UPDATE,单语句自带原子性,不需要事务。只有涉及”多次写库”(预扣 + 落库 / 返还 + 改状态)的方法才需要事务,而它们都有
submitOrder 用 @Transactional 而 rejection 用 @Transactional(rollbackFor = Exception.class),写法”不统一”不统一是因为抛出的异常类型不同:submitOrder 抛的都是继承 RuntimeException 的业务异常,默认回滚;三个返还方法声明了 throws Exception、抛受检异常,默认不回滚,必须显式 rollbackFor。这是正确的差异,不是疏漏
状态 6 是”已取消 / 已驳回 / 已退换”三合一有意的取舍:终态判断简化为 status == 6;代价是无法用索引按终态原因查询,且从状态码看不出原因(靠 cancelReason / rejectionReason / isReturned 区分)
requester_dept 是冗余字段合理的反范式:员工换部门后,历史单据应该归到当时花钱的部门,冗余正是为了这个语义。代码注释写明”便于按部门统计”
orders.number 有唯一索引,为什么 INCR 还要做分层防御:INCR 解决”正常情况下不重复”,唯一索引解决”异常情况下不静默出错”。两者不是二选一
员工端 pageQuery4User 强制 setUserId(BaseContext.getCurrentId())正确的安全设计:不接受前端传 userId,避免员工查别人的单据列表

八、M3 关键数字与事实速查(面试前扫一眼)

事实值 / 位置
文件规模OrderServiceImpl.java 627 行,全项目最大文件
方法总数OrderServiceImpl 18 个方法(含 3 个私有)
接口数员工端 8 个(/user/order/**)+ 管理端 7 个(/admin/order/**)
状态常量6 个(PENDING_PAYMENT=1 → CANCELLED=6);预算状态 3 个(UN_PAID=0 / PAID=1 / REFUND=2)
唯一的 UPDATE SQLOrderMapper.xml 第 27-63 行(where 在第 61 行),WHERE id = #{id},无状态条件 ← M3 的核心问题
事务注解submitOrder 用 @Transactional;userCancelById / rejection / cancel 用 @Transactional(rollbackFor = Exception.class);其余 4 个流转方法无事务(且正确)
金额重算submitOrder 第 104 行,stream().map(...).reduce(...)
单号格式PO{yyyyMMdd}-{6位序号},如 PO20260930-000137,来源 order:number:{date} 的 INCR
防重order:submit:{empId},SET NX,TTL 5s,在事务外
催办限流order:reminder:{orderId},SET NX,TTL 5min,限流前先做业务校验(正确顺序)
越权校验assertOwner(第 285 行),4 个调用点:details:213 / userCancelById:240 / repetition:304 / reminder:603;另有 confirmReceipt:544、returnOrder:573 内联校验
索引uk_orders_number、idx_orders_requester_dept、idx_orders_approve_user;user_id / status / order_time 无索引
N+1 位置pageQuery4User:189、getOrderGoodsStr:370(另 GoodsServiceImpl.listWithSpecs:222)
已知缺口① 状态流转无 CAS ② 三字段无索引 ③ 防重键事务外 ④ 退换预算退不掉 ⑤ 两处 N+1 ⑥ reminder 错误码用错
死代码OrdersDTO(零引用)、OrdersConfirmDTO.status(不读)、OrderMapper.getByNumberAndUserId / getByStatusAndOrderTimeLT(零引用)
回归验证regress_api_test.sh 第 10-21 节(提交 → 预扣 → 审批 → 发货 → 收货 → 完成不返还 → 驳回返还 → 撤销幂等 → 超额拦截)、第 13 节(催办写 remindTime)、第 14 节(员工 token 调管理端 401)

九、如果只给我 3 分钟讲 M3

定位:申领单是项目主单据,627 行,承载状态机、金额、审批痕迹、组织归属四件事。员工端 8 个接口、管理端 7 个。

最核心的一段是 submitOrder,六步:① 读 shop:status 判采购开关;② 查地址簿和申领车(空则拒);③ 按申领车逐项重算金额,完全忽略前端传的 amount——原来这里信任前端,可以传 0 元甚至负数(负数会让预扣 SQL 的 >= 恒成立,反向下减已用金额);④ 调 M2 的原子预扣,失败就抛异常让整个事务回滚,不产生单据;⑤ 用 Redis INCR 加日期分片生成 PO20260930-000137 单号,配唯一索引兜底;⑥ 批量写明细、清空申领车。全部四次写库在同一个 @Transactional 里。

安全上,员工端所有按 id 操作的接口都走 assertOwner 做归属校验,越权时返回”单据不存在”而不是”无权限”,避免攻击者枚举出单据是否存在。

并发上我要坦白一个缺口:所有状态流转都是”读出来判断、再无条件写回”,OrderMapper.update 的 WHERE 只有 id,没有状态条件。顺序重复会被 Java 校验挡住,但并发挡不住——采购专员 A 点审批、B 点驳回同时发生,可能出现”单据状态是采购中,但预算已经被退回去了”这种静默的账实不一致。修法是把状态约束写进 WHERE、用影响行数裁决,和 M2 预扣是同一个模式,而且只有状态迁移成功的请求才有权动预算,顺带就把重复返还也解决了。

性能上已知两处 N+1(分页逐条查明细);更实在的问题是 user_id、status、order_time 三个字段都没有索引,而员工端最热的查询正好用这三个字段——应该建 (user_id, status, order_time DESC) 复合索引。