采云台 · M2 部门预算 · 深度分析

对应第三、四部分(完整执行链路 + 为什么这么设计)及第五、六、七部分中 M2 相关内容
依据:backend/cai-yun-tai 实际源码逐行核验;mvn -o -DskipTests compile 退出码 0
一句话定位:这是全项目唯一真正解决并发正确性问题的地方,而且是用零中间件解决的——没有 Redis 扣减、没有分布式锁,只有一条带条件的 SQL UPDATE。


零、先纠正项目地图里的两个错误

在深入之前,必须先修正我上一轮写错的两处。面试时说错这两点会被直接抓住:

项目地图里的说法实际情况源码依据
「M2 部门预算 ★★★★★ —— 部门月度额度」✅ 对,但没有”月度自动重置”机制。每月需要管理员手工为每个部门创建一条 period=yyyy-MM 的记录,没有任何定时任务或自动续期全项目无 @Scheduled、无 xxl-job;budget 表按 period 累积多行
「组合申领包有三条 @Transactional」✅ 对ComboServiceImpl:48,88,122
「预算返还由三条链路调用」✅ 对,是 3 条:rejection(管理端驳回)、userCancelById(员工撤销)、cancel(管理端取消)。returnOrder(员工发起退换)不返还预算——这是代码注释里写明的有意设计OrderServiceImpl:559-561 注释原文:「预算是否返还由管理端线下确认后处理,这里只做状态流转,避免”退换即退款”造成资金口径混乱」

另外补充两处地图没写、但 M2 范围内必须知道的事实:

  1. BudgetMapper.getVOById 只被 BudgetServiceImpl.getVOByDeptAndPeriod 调用,而管理端 BudgetController 没有”按 id 查单条预算”的接口(只有 list)。员工端 GET /user/budget/my 是唯一用到它的入口。
  2. BudgetServiceImpl 全程没有 @Transactional,这是正确的——deduct / release 都是单条 SQL,单表单语句自带原子性;save 里的两次操作是「先 SELECT 查重 → 再 INSERT」,靠唯一索引兜底,也不构成”必须回滚”的复合写。

一、业务场景:为什么需要”预算”这个模型

1.1 原始痛点

企业办公物资采购在系统化之前是这样的:

员工 → 微信群/口头:"我需要一个显示器支架"
行政 → 记在 Excel 里,攒一批一起买
财务 → 月底做账时才发现:技术部这个月花了 3 万,超了 1 万

三个具体问题:

问题后果
额度在提交时不受控员工可以随便提,超支只能在报销/结算环节事后发现,此时货已经买了
部门成本无法归集只知道公司总共花了多少,不知道哪个部门花的,无法分摊成本
审批责任不可追溯谁批的、什么时候批的、为什么驳回,全在聊天记录里

1.2 采云台的解法

引入 budget 表 = (部门, 周期) → 总额 + 已用,并把它卡在提交申领单的必经路径上:

员工提交申领单
   ↓
从申领车逐项重算金额(不信任前端)
   ↓
【卡点】原子预扣本部门本周期预算
   ├─ 成功 → 继续落库,单据状态 = 待审批
   └─ 失败 → 抛异常 → 整个事务回滚 → 不产生任何单据
   ↓
审批驳回 / 员工撤销 / 管理员取消 → 返还预算
审批通过 → 采购中 → 发货 → 已完成(预算**不返还**,这才是真实支出)

核心设计意图:把”额度够不够”的判断从事后对账提前到提交瞬间,并且是强一致的(不是”大概够”)。

1.3 为什么不用”支付”而用”预算”

原项目(苍穹外卖)这里走的是微信支付:下单 → 调微信支付 → 支付成功回调 → 订单转已支付。

改造时删掉了支付(application-dev.yml 注释明确说明移除了 sky.wechat),原因:

  • 企业内部采购不走第三方支付,走的是部门预算内部核算;
  • 但”需要一笔资金被预占、失败要回滚、成功后可能退还”这个业务形态完全一样。

所以 Orders.payStatus 这个字段被保留下来但语义重映射为预算扣减状态:

值原语义新语义
0UN_PAID 未支付未扣减
1PAID 已支付已扣减
2REFUND 已退款已返还

这是面试可以讲的一个点:改造时的取舍是”复用字段 + 重映射语义”,好处是改动面小(不用动表结构、不用改前端 DTO),代价是字段名和语义不符(payStatus 里装的是预算状态),需要在代码注释和数据库列注释里说清楚。fix_comments.sql 就是专门干这件事的。


二、数据库设计

2.1 表结构

CREATE TABLE budget (
    id           BIGINT         NOT NULL AUTO_INCREMENT,
    dept_id      BIGINT         NOT NULL COMMENT '部门id',
    period       VARCHAR(7)     NOT NULL COMMENT '预算周期,格式 yyyy-MM',
    total_amount DECIMAL(12, 2) NOT NULL DEFAULT 0 COMMENT '预算总额',
    used_amount  DECIMAL(12, 2) NOT NULL DEFAULT 0 COMMENT '已使用金额',
    status       INT            NOT NULL DEFAULT 1 COMMENT '状态:1启用 0停用',
    create_time  DATETIME       NULL,
    update_time  DATETIME       NULL,
    create_user  BIGINT         NULL,
    update_user  BIGINT         NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uk_budget_dept_period (dept_id, period),   -- ← 关键
    KEY idx_budget_period (period)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门预算表';

(来源:sql/migration_office_supplies.sql 第 89-104 行)

2.2 设计决策逐条解释

① 为什么是「部门 + 周期」而不是「部门 + 年 / 月 + 日」?

period 用 VARCHAR(7) 存 yyyy-MM,而不是拆成 year INT, month INT,也不是直接用 DATE。

理由:

  • 一张表表达多期:(dept_id, period) 唯一,一个部门在库里会有多行(2026-08、2026-09、2026-10…),历史可追溯,不需要每月清空重来;
  • 字符串可以直接比较:'2026-09' > '2026-08',ORDER BY period DESC 天然按时间倒序,WHERE period = '2026-09' 走索引等值查询;
  • 格式固定 7 字符、定长可比,是”可读性和可比较性”的折中。如果拆成两个 INT,查询要写 WHERE year=? AND month=?,唯一索引变成三列,且无法直接排序。

② 为什么 (dept_id, period) 要建唯一索引?

两重作用,都很关键:

  • 业务约束:防止管理员给同一部门同一周期建两条预算(“到底哪条才算数”);
  • 并发正确性的前提:这是 deduct 那条 UPDATE 的驱动索引。有它,WHERE dept_id=? AND period=? 能直接定位到唯一一行,加行锁的粒度就是这一行;如果没有,可能扫多行甚至锁范围。

对应的应用层校验在 BudgetServiceImpl.save:36:

Budget exist = budgetMapper.getByDeptAndPeriod(budgetDTO.getDeptId(), budgetDTO.getPeriod());
if (exist != null) {
    throw new BaseException("该部门在该周期的预算已存在,请直接修改");
}

但这里有个真实的并发缺口(后面”易被质疑处”会详细展开):getByDeptAndPeriod 的 SQL 带 AND status = 1,所以如果一个部门该周期的预算是停用状态,查重查不到 → 会尝试 INSERT → 撞唯一索引 → 抛 SQLIntegrityConstraintViolationException → 被全局异常处理器捕获,但解析分支只认 “Duplicate entry”,然后按空格切分第 3 段,得到的消息会是类似 '2-2026-09'已存在,对用户来说是个奇怪的提示而不是”该周期预算已存在(已停用状态)”。

③ 为什么金额用 DECIMAL(12,2)?

  • 绝不能用 FLOAT/DOUBLE:二进制浮点无法精确表示 0.1,0.1 + 0.2 != 0.3,累加会漂移,账目对不上;
  • Java 侧对应 BigDecimal(Budget.totalAmount / usedAmount / Orders.amount 全是 BigDecimal);
  • DECIMAL(12,2) 上限 9,999,999,999.99,对企业部门月度预算足够。
  • 注意一个细节:Orders.packAmount、tablewareNumber 用的是 int(原始类型),不是 Integer。这在实体里是隐患(默认值 0 而不是 null,MyBatis 动态 SQL 判 null 会失效),但不在 M2 范围内。

④ 为什么 status 要单独一列?

停用预算 ≠ 删除预算。停用后:

  • deduct 的 SQL 带 status = 1,所以停用期间提交申领单会失败(走”余额不足”分支)——这是有意的业务开关;
  • 但 release 不带 status 条件,所以停用后仍然能返还——这个设计是对的,否则停用预算会导致之前已扣的钱永远退不回去。

这里有不一致:deduct 判 status=1,release 不判。这是有意的不对称,但代码里没写注释说明,面试官如果细看会问。

⑤ 为什么没有 budget_log(预算流水表)?

当前项目未实现。这是 M2 最大的设计缺口,也是面试最容易被追问的点。

现在”预算花了多少”只有一个汇总数字 used_amount,没有明细。想要回答”这个月技术部的 3 万分别花在哪些单子上”,只能反查 orders 表(requester_dept = ? AND period(order_time) = ?)再求和。

问题:

  • orders 表没有 period 列,要按 order_time 计算周期,无法走索引;
  • 想查”某笔预扣是否被返还过”只能看 orders.pay_status,而这是当前状态,不是流水——一旦有 bug 导致状态错乱,没有账本可以对。

面试标准答法(见第六部分追问链与第七部分):

“这里是汇总式设计,不是流水式。好处是并发控制简单——一条 UPDATE 加行锁就够了。代价是没有审计明细,对账要靠 orders 反推。如果金额敏感度更高,我会加一张 budget_log(budget_id, order_id, type, amount, create_time),扣减和返还各插一条,used_amount 变成流水派生出来的冗余字段,这样既有账本又不失去原子性。“


三、完整代码执行链路

M2 共有 4 条独立链路,全部覆盖。


链路 1:管理员设置部门预算

POST /admin/budget
Body: {"deptId":2, "period":"2026-09", "totalAmount":50000, "status":1}
Header: token={JWT}

   ↓ JwtTokenAdminInterceptor.preHandle()          ← 认证
       ├─ JwtUtil.parseJWT(adminSecretKey, token)
       ├─ Redis GET login:token:{JWT}               ← 白名单(登出即失效)
       ├─ employeeMapper.getById(empId)             ← 查库校验角色与状态
       └─ BaseContext.setCurrentId / setCurrentRole

   ↓ RoleAspect.checkRole()   @RequireRole(ADMIN)   ← 授权(PURCHASER 也会被拒)
       └─ 不通过 → 抛 BaseException(NO_PERMISSION) → 全局异常处理 → code=0

   ↓ admin/BudgetController.save(BudgetDTO)         ← 第 44 行
   ↓
BudgetServiceImpl.save(BudgetDTO)                   ← 第 34 行,【无 @Transactional】
   ├─ BudgetMapper.getByDeptAndPeriod(deptId, period)
   │     SQL: SELECT * FROM budget WHERE dept_id=? AND period=? AND status=1
   │     命中 → 抛 BaseException("该部门在该周期的预算已存在,请直接修改")
   ├─ 组装 Budget 实体(Builder 模式)
   │     period 为空 → currentPeriod() 兜底为当前月 yyyy-MM
   │     totalAmount 为空 → BigDecimal.ZERO
   │     usedAmount → 硬编码 BigDecimal.ZERO            ← 新建预算已用金额必为 0
   │     status 为空 → StatusConstant.ENABLE (1)
   ├─ BudgetMapper.insert(budget)
   │     ← @AutoFill(INSERT) 切面在此介入:反射填 createTime/createUser/updateTime/updateUser
   │       其中 currentId = BaseContext.getCurrentId()(来自拦截器)
   └─ useGeneratedKeys + keyProperty="id" 回填主键
   ↓
Result.success()

关键对象流转:BudgetDTO(入参)→ Budget(实体)→ DB;没有出参 VO。

Redis 参与:仅拦截器的登录态校验(login:token:{JWT})。
MQ 参与:无。
事务:无(也不必要——单条 INSERT)。
并发:有缺口(见第七部分)。


链路 2:管理员修改预算 / 查询预算列表

PUT /admin/budget  @RequireRole(ADMIN)
   ↓ admin/BudgetController.update:59
   ↓ BudgetServiceImpl.update:57
        └─ 组装 Budget(id, deptId, period, totalAmount, status)
             └─ BudgetMapper.update → XML 动态 <set>
                  ⚠ usedAmount 不在 DTO 里 → 永远不会被前端改到    ← 这是有意的安全设计
                  ⚠ 但 deptId / period 可以被改 → 有撞唯一索引的风险(见第七部分)
             ← @AutoFill(UPDATE) 切面填 updateTime / updateUser
   ↓ Result.success()

GET /admin/budget/list?deptId=&period=&status=  @RequireRole({ADMIN, PURCHASER})
   ↓ admin/BudgetController.list:74
   ↓ BudgetServiceImpl.list:69
        └─ BudgetMapper.list → LEFT JOIN department 带出 deptName
             并计算 (total_amount - used_amount) AS remainAmount      ← 余额在 SQL 里算
             WHERE 动态:deptId / period / status
             ORDER BY period DESC, dept_id ASC
   ↓ Result.success(List<BudgetVO>)

注意 remainAmount 的算法:它不是数据库里的列,而是 SQL 里的派生列 (b.total_amount - b.used_amount)。

  • 好处:不存冗余、永远一致、不会出现”总额减已用不等于余额”的脏状态;
  • 代价:无法直接对”余额”建索引,没法高效查询”余额小于 X 的部门”(当前业务没这个需求)。

链路 3:员工查询本部门预算(唯一使用 getVOById 的入口)

GET /user/budget/my
Header: authentication={JWT}

   ↓ JwtTokenUserInterceptor.preHandle()           ← 员工端认证(不同密钥、不同请求头)

   ↓ user/BudgetController.my()                     ← 第 41 行
   ├─ BaseContext.getCurrentId() → empId
   ├─ EmployeeMapper.getById(empId)                 ← MySQL:拿 deptId
   │     employee == null 或 deptId == null
   │        → return Result.success()               ← data 为 null,不报错
   ├─ BudgetService.currentPeriod() → "2026-09"     ← LocalDateTime.now() 格式化
   └─ BudgetService.getVOByDeptAndPeriod(deptId, period)   ← 第 73 行
         ├─ BudgetMapper.getByDeptAndPeriod(deptId, period)   -- 第一次查询,拿 id
         │     为 null → 返回 null(前端提示"未设置预算")
         └─ BudgetMapper.getVOById(id)                        -- 第二次查询
               SELECT b.id, b.dept_id, d.name AS deptName, b.period,
                      b.total_amount, b.used_amount,
                      (b.total_amount - b.used_amount) AS remainAmount
               FROM budget b LEFT JOIN department d ON b.dept_id = d.id
               WHERE b.id = ?
   ↓ Result.success(BudgetVO{deptId, deptName, period, totalAmount, usedAmount, remainAmount})

这条链路里有一个明显的冗余查询:getVOByDeptAndPeriod 先按 (deptId, period) 查一次拿 id,再按 id 查一次拿 VO。两次查询可以合并成一次:

SELECT b.id, b.dept_id, d.name AS deptName, b.period, b.total_amount, b.used_amount,
       (b.total_amount - b.used_amount) AS remainAmount
FROM budget b LEFT JOIN department d ON b.dept_id = d.id
WHERE b.dept_id = ? AND b.period = ? AND b.status = 1

这是一处可以主动承认的优化点(第七部分详述)。面试官看到 getVOByDeptAndPeriod 的实现方法体(先查 ID 再查 VO)很可能会问”为什么不一次查完”。

为什么它还没造成问题:员工端只在进入申领页时调一次,且 (dept_id, period) 走唯一索引,两次查询都是索引等值命中,代价很小。但属于可以避免的往返。


链路 4:预算预扣(提交申领单)—— 这是 M2 的核心

POST /user/order/submit
Body: {"addressBookId":1, "amount":32.00, ...}       ← 注意 amount 会被后端覆盖
Header: authentication={JWT}

   ↓ JwtTokenUserInterceptor.preHandle()

   ↓ user/OrderController.submit()                   ← 第 50 行
   ├─ BaseContext.getCurrentId() → userId
   ├─ Redis SET NX order:submit:{userId} "1" TTL=5s  ← 防重复提交(第一道幂等)
   │     未抢到 → 抛 OrderBusinessException(ORDER_REPEAT_SUBMIT)
   └─ orderService.submitOrder(dto)

   ↓ OrderServiceImpl.submitOrder()   @Transactional  ← 第 65-66 行,【事务从这里开始】
   │
   ├─【步骤 1】Redis GET shop:status
   │     status != null && status == 0 → 抛 OrderBusinessException(SHOP_CLOSED)
   │
   ├─【步骤 2】AddressBookMapper.getById(addressBookId)     ← MySQL
   │     null → 抛 AddressBookBusinessException
   │     ※ 原项目的"百度地图配送范围校验"在这里被删除(企业内部无配送范围概念)
   │
   ├─【步骤 3】ShoppingCartMapper.list(userId)              ← MySQL
   │     空 → 抛 ShoppingCartBusinessException
   │
   ├─【步骤 4】EmployeeMapper.getById(userId)               ← MySQL,拿 deptId
   │     employee == null 或 deptId == null
   │        → 抛 BudgetBusinessException(BUDGET_NOT_FOUND)
   │     deptId = employee.getDeptId()
   │
   ├─【步骤 5】★ 金额重算(安全关键)
   │     amount = shoppingCartList.stream()
   │                .map(c -> c.getAmount().multiply(BigDecimal.valueOf(c.getNumber())))
   │                .reduce(BigDecimal.ZERO, BigDecimal::add)
   │     ※ 完全忽略 DTO 里的 amount。原缺陷是"前端算好钱告诉后端扣多少",
   │       可传 amount=0 零元提交,甚至传负数反向下减已用金额
   │
   ├─【步骤 6】★ 预算预扣 —— M2 的核心
   │     period = budgetService.currentPeriod()             → "2026-09"
   │     boolean deducted = budgetService.deduct(deptId, period, amount)
   │
   │     ↓ BudgetServiceImpl.deduct(deptId, period, amount)         ← 第 90 行
   │         【防御式编程】
   │         if (deptId == null || amount == null || amount.signum() <= 0) return false;
   │             └─ amount.signum() <= 0 拦住 0 和负数(第二道防线,与步骤 5 互补)
   │         ↓
   │         int rows = BudgetMapper.deduct(deptId, period, amount)
   │         ↓
   │         【BudgetMapper.xml 第 31-38 行】
   │         UPDATE budget
   │            SET used_amount = used_amount + #{amount}
   │          WHERE dept_id = #{deptId}
   │            AND period = #{period}
   │            AND status = 1
   │            AND total_amount - used_amount >= #{amount}
   │         ↓
   │         【并发语义】(这里是最值得讲的地方)
   │         · WHERE 用 uk_budget_dept_period 定位到唯一一行 → 对该行加 X 锁(排他锁)
   │         · MySQL 默认 RR 隔离级别下,UPDATE 读的是【最新已提交版本】而不是快照
   │           (SELECT 读快照,UPDATE 读当前读 current read)——这正是本方案能成立的根本原因
   │         · 两条并发事务必然串行:后到者等锁释放后,在【新值】上重新核对 WHERE
   │         · total_amount - used_amount >= amount 不成立 → 该行不匹配 → rows = 0
   │
   │         rows == 0 → log.warn 记录 deptId/period/amount → return false
   │         rows == 1 → return true
   │
   │     deducted == false
   │        → 抛 BudgetBusinessException(BUDGET_NOT_ENOUGH)
   │        → ★ 异常向上抛出 → @Transactional 回滚 → 【不会产生任何单据、申领车不清空】
   │
   ├─【步骤 7】生成申领单号
   │     dateStr = LocalDate.now().format(BASIC_ISO_DATE)   → "20260930"
   │     Redis INCR order:number:20260930 → seq
   │         seq == 1 → EXPIRE 2 天(仅首次设 TTL,避免每天残留永久键)
   │     number = "PO" + dateStr + "-" + String.format("%06d", seq)
   │                                                      → "PO20260930-000137"
   │
   ├─【步骤 8】落库(主表 + 明细)
   │     orders.setAmount(amount)        ← 用重算后的金额,不是前端的
   │     orders.setPayStatus(Orders.PAID)     = 1 已扣减
   │     orders.setStatus(Orders.TO_BE_CONFIRMED) = 2 待审批
   │     orders.setRequesterDept(deptId)  ← 冗余存部门,便于按部门统计
   │     orders.setIsReturned(0)
   │     OrderMapper.insert(orders)        → MySQL,useGeneratedKeys 回填 id
   │     OrderDetailMapper.insertBatch(orderDetailList)   → MySQL,n 行
   │
   ├─【步骤 9】ShoppingCartMapper.deleteByUserId(userId)     ← MySQL
   │
   └─【步骤 10】返回 OrderSubmitVO{id, orderNumber, orderTime, orderAmount}

这条链路的三个”防线”层次(面试时可作为结构化回答):

层次位置防什么
接入层Redis SET NX 5s同一个人 5 秒内重复点击
应用层按申领车重算金额 + amount.signum() <= 0前端篡改金额、零元/负数提交
数据库层UPDATE 的 WHERE 条件 + 行锁 + 唯一索引并发超额扣减、重复建预算

链路 5:预算返还(三条链路共用同一段代码)

返还的入口有三个,但都收敛到同一个私有方法 releaseBudget:

【入口 A】管理端审批驳回
PUT /admin/order/rejection  @RequireRole({ADMIN, PURCHASER})
   ↓ OrderServiceImpl.rejection()   @Transactional(rollbackFor = Exception.class)   ← 第 434 行
   ├─ orderMapper.getById(id)
   │     null 或 status != TO_BE_CONFIRMED(2) → 抛 OrderBusinessException(ORDER_STATUS_ERROR)
   ├─ releaseBudget(ordersDB)                ← 返还
   ├─ orderMapper.update(orders{status=CANCELLED(6), rejectionReason, cancelTime, payStatus=REFUND(2)})
   └─ 提交事务

【入口 B】员工自助撤销
PUT /user/order/cancel/{id}
   ↓ OrderServiceImpl.userCancelById()  @Transactional(rollbackFor = Exception.class)  ← 第 232 行
   ├─ orderMapper.getById(id);null → 抛 ORDER_NOT_FOUND
   ├─ assertOwner(ordersDB)                  ← 归属校验(防 IDOR 越权)
   ├─ status > 2 → 抛 ORDER_STATUS_ERROR     ← 只有"待审批"之前可自行撤销
   ├─ releaseBudget(ordersDB)
   ├─ orderMapper.update(orders{status=6, cancelReason="员工撤销申领", cancelTime, payStatus=2})
   └─ 提交事务

【入口 C】管理员取消
PUT /admin/order/cancel  @RequireRole({ADMIN, PURCHASER})
   ↓ OrderServiceImpl.cancel()  @Transactional(rollbackFor = Exception.class)   ← 第 463 行
   ├─ orderMapper.getById(id);null → 抛 ORDER_NOT_FOUND
   ├─ status == COMPLETED(5) 或 CANCELLED(6) → 抛 ORDER_STATUS_ERROR
   ├─ releaseBudget(ordersDB)
   ├─ orderMapper.update(orders{status=6, cancelReason, cancelTime, payStatus=2})
   └─ 提交事务

──────────────────────────────────────────────────────────
三条链路共同调用的核心方法(第 271-283 行):

private void releaseBudget(Orders ordersDB) {

    // ★ 第一道防线:业务状态机守卫
    if (!Orders.PAID.equals(ordersDB.getPayStatus())) {
        return;          // payStatus 不是 1(已扣减) → 说明没扣过或已经返还过 → 直接跳过
    }

    Long deptId = ordersDB.getRequesterDept();
    if (deptId == null) {
        log.warn("申领单 {} 缺少申请部门,无法返还预算", ordersDB.getId());
        return;
    }

    // ★ 关键:按【申领单自己的下单时间】反推周期,不是用当前月份!
    String period = budgetService.periodOf(ordersDB.getOrderTime());

    budgetService.release(deptId, period, ordersDB.getAmount());

    // ↓ BudgetServiceImpl.release(deptId, period, amount)   第 110 行
    //     if (deptId == null || amount == null) return false;
    //     int rows = BudgetMapper.release(deptId, period, amount)
    //     rows == 0 → log.warn("预算返还失败(可能已返还过)") → return false
    //
    //     ↓ BudgetMapper.xml 第 43-49 行
    //     UPDATE budget
    //        SET used_amount = used_amount - #{amount}
    //      WHERE dept_id = #{deptId}
    //        AND period = #{period}
    //        AND used_amount >= #{amount}      ← ★ 第二道防线:条件更新
}

双防线设计(这是 M2 最值得讲的一个点):

防线位置防的场景失效方式
第一道应用层 payStatus == PAID 判断业务性重复:员工撤销后又管理员取消(同一个单据走了两条链路)如果某个入口忘记把 payStatus 改成 REFUND,这道防线就失效
第二道SQL 层 used_amount >= amount并发性重复:两个请求同时返还同一单据(都读到了 payStatus=1)只能防”刷成负数”,如果额度充足仍会重复多退

为什么需要两道? 因为它们防的是不同的问题:

  • 第一道防的是「同一单据被不同业务流程反复返还」——这是逻辑问题;
  • 第二道防的是「同一单据被并发请求同时返还」——这是时序问题。

并且第二道的 SQL 条件不足以单独解决问题,这点必须说清楚:

假设部门可用额度充足(used_amount = 1000,要退 32),两个并发请求都通过了第一道防线(都读到 payStatus=1),那么:

  • 请求 1:used_amount = 1000 - 32 = 968 ✅
  • 请求 2:加锁后重新校验 968 >= 32 成立 → used_amount = 968 - 32 = 936 ❌ 多退了 32

所以严格来说,“双防线”这个说法在并发场景下是不严密的。 真正的保证来自:三个入口都有 @Transactional + 同一事务内会先更新 pay_status,加上 payStatus 是第一道守卫。如果要严格保证并发幂等,正确做法是让返还本身也变成单据状态驱动的条件更新:

-- 更严格的做法:把"是否已返还"变成一条原子状态迁移
UPDATE orders SET pay_status = 2, status = 6, ...
 WHERE id = ? AND pay_status = 1;      -- 影响行数=1 才执行预算返还
-- 影响行数=0 → 说明已被其他请求处理,直接返回,不动预算

这是第七部分会详细展开的一个”易被质疑处”,面试时如果被追问”并发下会不会重复返还”,不能只说”有 SQL 条件兜底”,要主动说出上面这个缺口和自己想到的更严格方案。


链路 6:退换单不返还预算(有意设计)

POST /user/order/return/{id}?reason=xxx
   ↓ OrderServiceImpl.returnOrder()        ← 第 565 行,【无 @Transactional,也无 releaseBudget】
   ├─ orderMapper.getById(id);null → 抛 ORDER_NOT_FOUND
   ├─ status != COMPLETED(5) → 抛 OrderBusinessException(ORDER_CANNOT_RETURN)
   ├─ 归属校验
   └─ orderMapper.update(orders{status=CANCELLED(6), cancelReason="退换:xxx",
                                cancelTime, isReturned=1})
                                 ↑ payStatus 保持 1(已扣减),预算不退

代码注释(第 560-561 行)原文:

「预算是否返还由管理端线下确认后处理,这里只做状态流转,避免”退换即退款”造成资金口径混乱。」

这个设计对不对? 分两面看:

站得住脚的理由:退换的货物可能已经采购、甚至已经在用了,退换是否成功、退多少是线下协商结果,不是系统能自动判定的。如果系统一提交退换就把预算退回去,员工可以”申请退换 → 预算退回 → 线下不退了”,形成预算套利。所以让退换只做状态流转、由管理员线下确认后再处理,是把不可判定的决策交给人,符合”系统不该替业务做不了主的事做主”的原则。

站不住脚的地方:returnOrder 把单据置成了 CANCELLED(6),而 cancel 的守卫是”status == CANCELLED 就拒绝”。这意味着退换后管理员再也不能通过 /admin/order/cancel 来返还这笔预算了——因为单据已经是 6 了。所以”由管理端线下确认后处理”这句话在系统里没有对应的操作入口,只能去数据库改数据。

这是一个真实的闭环缺口,面试时如果被问”退换的预算怎么退”,唯一诚实的回答是:

“当前实现里退换只做状态流转、不返还预算,注释说由管理端线下确认,但系统里确实没有对应的返还入口——因为单据已经被置为已取消,/admin/order/cancel 会拒绝。这是个已知的闭环缺口,合理的修法是给退换加一个独立的待确认状态(比如 7),由管理员审批通过后才调用 releaseBudget。”

(这个状态 7 不存在于当前代码里,Orders 只到 6。)


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

逐个技术点回答:业务场景 → 原始方案 → 问题 → 当前方案 → 为什么选它 → 替代方案 → 方案对比。


4.1 预算扣减

【业务场景】
员工提交申领单时要预占本部门月度额度。这是资金安全问题——超支意味着财务失控。

【原始方案】
先查余额,判断够不够,再更新:

// 反例,项目里没有这么写
Budget budget = budgetMapper.getByDeptAndPeriod(deptId, period);
if (budget.getTotalAmount().subtract(budget.getUsedAmount()).compareTo(amount) >= 0) {
    budget.setUsedAmount(budget.getUsedAmount().add(amount));
    budgetMapper.update(budget);
}

【原始方案的问题】
典型的 check-then-act 竞态。两个员工(或同一员工连点两次)并发提交,额度只够一个人:

时刻  T1                              T2
──────────────────────────────────────────────────────────
t1    SELECT → used=0, total=100
t2                                    SELECT → used=0, total=100
t3    0+80 <= 100 ✅ 判断通过
t4                                    0+80 <= 100 ✅ 判断通过
t5    UPDATE used = 80
t6                                    UPDATE used = 80      ← 丢失更新
──────────────────────────────────────────────────────────
结果:扣了两次 80(共 160),但 used_amount 只记了 80 → 超额 60 完全不记账

这个是”丢失更新”(lost update),而且比”只超额”更糟——账实不符,事后对账都查不出来。

【当前方案】
把判断下推到 SQL 的 WHERE 里,让”判断 + 更新”成为一条语句、一个原子操作:

<update id="deduct">
    update budget
    set used_amount = used_amount + #{amount}
    where dept_id = #{deptId}
      and period = #{period}
      and status = 1
      and total_amount - used_amount >= #{amount}
</update>

业务层只用影响行数判断结果:

int rows = budgetMapper.deduct(deptId, period, amount);
if (rows == 0) { /* 余额不足或预算不存在 */ return false; }
return true;

【为什么这样能解决】

三个要点,缺一不可,面试要能讲清:

  1. 行锁:WHERE dept_id=? AND period=? 命中 uk_budget_dept_period 唯一索引,InnoDB 直接定位到目标行并加排他锁(X 锁),锁的不是表、也不是索引区间,就是这一行。
  2. 当前读(current read):MySQL 默认隔离级别是 REPEATABLE READ,普通 SELECT 读的是事务开始时的快照(一致性读),但 UPDATE 在加锁后会读取最新已提交版本(当前读)。所以第二个事务拿到锁后,看到的是第一个事务已经加完的 used_amount,然后在新值上重新评估 WHERE。如果这里用的是 SELECT,就会像原始方案那样读到旧快照——这是本案能成立的根本原因,也是面试最能体现深度的一点。
  3. 唯一索引保证定位到单行:唯一索引等值查询退化为行锁。如果 (dept_id, period) 没有唯一约束而是普通索引,仍能定位到具体行,但如果有重复数据就会锁多行;更糟的情况是 WHERE 走不到索引,InnoDB 会对扫描到的所有行加锁甚至锁住间隙(gap lock),并发度急剧下降。

【替代方案对比】

方案实现优点缺点本项目是否适用
A. 条件 UPDATE(当前方案)判断下推到 WHERE,看影响行数一次数据库往返;无锁租约/续期/释放问题;行锁自动随事务释放;无额外组件只能做”加/减”这类单调运算,无法表达复杂业务规则;逻辑散在 SQL 里✅ 选它
B. SELECT ... FOR UPDATE 悲观锁先加锁查,再判断,再更新逻辑留在 Java 里,可读性好,能表达复杂规则两次往返(查+改);锁持有时间变长(包含 Java 判断时间);要显式包事务,漏了 @Transactional 直接失效可选,但没必要
C. Redis DECR / Lua 脚本余额放 Redis,用原子命令扣极快;不占数据库连接余额会丢(Redis 非强持久);双写一致性问题(Redis 扣了 DB 没落);资金数据放缓存违背权威性原则❌ 资金数据不适合
D. Redisson 分布式锁锁 budget:{deptId}:{period},锁内查改跨服务、跨库都能用;业务逻辑完整引入新组件;锁租约/续期/watchdog 复杂度;单库单行场景属于杀鸡用牛刀;锁失效或超时引入新的不确定性❌ 过度设计
E. 乐观锁(version / CAS)加 version 列,UPDATE ... WHERE version=?无锁等待;适合冲突少的场景冲突高时大量失败重试;部门预算恰好是热点行(同部门员工同时提交),重试风暴风险大❌ 冲突模式不匹配
F. 消息队列串行化扣减扣减请求进 MQ,单消费者顺序处理天然串行;可削峰引入 MQ;变成异步,前端拿不到即时结果(“够不够”要等回调);端到端延迟不可控❌ 交互语义不允许

【为什么选 A 不选 D(分布式锁)——这是面试必问】

「这个场景是单库单行的并发正确性问题,不是分布式问题。数据只有一个权威副本在 MySQL,InnoDB 的行锁本身就提供了原子性,而且锁是随事务自动获取和释放的,没有租约过期、没有续期、没有锁误删的风险。引入 Redisson 只会把’数据库能保证的事’搬到应用层,增加故障面。分布式锁要解决的是跨多个资源/多个服务的互斥,这里不满足那个条件。」

【如果一定要用 Redis 扣减,怎么讲才不被扣分】

主动说清边界:

「如果预算数据要脱离 MySQL 单独承载高并发,可以考虑 Redis + Lua 做原子扣减,但必须解决『Redis 扣了、数据库落库失败』的一致性问题(比如用 Redis Stream 或本地消息表做补偿),而且资金数据以缓存为权威源本身就有可靠性风险。当前规模下没必要。」


4.2 预算返还的幂等

【业务场景】
驳回、撤销、取消三条链路都要返还预算。同一个单据可能被多条链路碰到(先员工撤销,管理员再想取消),也可能被并发请求同时碰到。

【原始方案】
每条链路直接调 UPDATE budget SET used_amount = used_amount - ?。

【原始方案的问题】

  1. 重复返还致账面虚增:员工撤销(退 32),管理员再取消(又退 32)→ 部门凭空多了 32 的可用额度;
  2. 并发返还刷成负数:两个请求同时退,used_amount 可能变成负数,之后新提交的申领单会误判为”额度充足”。

【当前方案】双防线

  • 应用层:if (!Orders.PAID.equals(ordersDB.getPayStatus())) return; ——用单据状态机守卫;
  • SQL 层:WHERE used_amount >= #{amount} ——防刷成负数。

【为什么这么设计】
payStatus 是业务语义上的”这笔钱的状态”(0 未扣 / 1 已扣 / 2 已还),它比”某个接口是否被调用过”更稳定:无论从哪个入口进来,只要看到 payStatus=2 就跳过。SQL 条件则是最后兜底,防止应用层判断被绕过(比如以后有人加了新入口忘记判 payStatus)。

【替代方案对比】

方案实现评价
A. 单据状态机 + 条件 UPDATE(当前方案)payStatus 守卫 + used_amount >= amount✅ 选它。零额外组件,两道防线针对不同失效模式
B. 预算流水表 + 唯一约束budget_log(budget_id, order_id, type),uk(budget_id, order_id, type)最严格,数据库层面绝对防重复;还能留下审计账本。代价是多一张表和一次插入;本项目未实现
C. 把返还做成 condition-update 的状态迁移先 UPDATE orders SET pay_status=2 WHERE id=? AND pay_status=1,看影响行数决定是否返还比当前方案更严格(把守卫也变成原子的),推荐作为改进方向。本项目未实现
D. 分布式锁锁单据 id过度设计,同上

4.3 金额为什么用 BigDecimal

【业务场景】 预算是钱,涉及加减、比较、累加。

【原始方案】 double / float。

【问题】 二进制浮点无法精确表示十进制小数。0.1 + 0.2 = 0.30000000000000004。累加几千笔后误差可能到分级别,对账对不上。

【当前方案】 Java 侧 BigDecimal,数据库侧 DECIMAL(12,2)。

【关键细节】 BigDecimal 自己的坑:

  • 必须用字符串构造 new BigDecimal("0.1"),用 new BigDecimal(0.1) 会引入 double 的误差;
  • equals 比”值和 scale”,compareTo 只比”值”——BigDecimal("1.0").equals(BigDecimal("1.00")) 是 false。所以判断金额相等要用 compareTo() == 0;
  • 除法必须指定精度和舍入模式,divide() 不指定在无限循环小数时会抛 ArithmeticException。

本项目的实际用法是安全的:c.getAmount().multiply(BigDecimal.valueOf(c.getNumber()))——BigDecimal.valueOf(long) 是安全的(内部走 Long.toString),multiply 不丢精度。没有用到除法,所以没有舍入问题。

【面试可以主动提的点】 unitPrice = turnover / validOrderCount 在 WorkspaceServiceImpl:75 用的是 Double(不是 BigDecimal),因为那是统计报表的展示值、不参与账务,用 double 可以接受。能区分”钱”和”统计值”两种精度要求,是加分的。


4.4 为什么 period 用 String("yyyy-MM") 而不是 Date

方案优点缺点
A. VARCHAR(7) = “2026-09”(当前方案)唯一索引简单;可直接字符串比较和排序;ORDER BY period DESC 即时间倒序;跨库/跨语言无歧义无法做”按天”粒度;格式靠约定(无数据库层校验)
B. DATE 存每月 1 号类型安全;可以用日期函数要防”有人存成 9 月 5 号”这种脏数据,需要额外约束;WHERE 要用范围查询而不是等值,索引效率略差
C. INT 存 202609紧凑、可比可读性差,202609 是什么日期要靠人脑解析;格式化要额外代码
D. 拆 year INT, month INT语义最清晰唯一索引变三列;无法直接 ORDER BY 时间;查询要写两个条件

选 A 的核心理由:这是”业务周期标识”,不是”时间点”。它只需要”可比较、可等值匹配、可读”,不需要日期运算。

⚠️ 但要主动说清一个缺陷:格式校验只在应用层(DateTimeFormatter 生成),管理员如果手工传一个不合法的 period(比如 "2026/09" 或 "abc"),BudgetServiceImpl.save 会原样入库(budgetDTO.getPeriod() == null ? currentPeriod() : budgetDTO.getPeriod()),不会报错。之后员工端用 currentPeriod() 生成的标准格式去查,永远查不到这条预算 → 员工看到”未设置预算”。当前项目未做 period 格式校验,这是一处真实的健壮性缺口。


4.5 为什么 deduct 判 status = 1 而 release 不判

【业务场景】

  • 停用预算 = 管理员”这个部门这个月不再批新申领”;
  • 但已经发生的扣减必须能退回。

【当前方案】 有意的不对称:

  • deduct 带 AND status = 1 → 停用期间提交申领单会失败(走”余额不足”提示);
  • release 不带 status 条件 → 停用后仍能返还。

【如果反过来会怎样】

假如 deduct 不判 status:停用形同虚设,管理员关掉预算后员工照样能提交。
假如 release 判 status=1:管理员在员工撤销之前停用了预算 → 返还失败 → 员工的钱永远退不回来,只能改数据库。

【替代方案】 更严谨的做法是让两条路径读同一个”预算是否可变更”的语义,并在停用时级联处理在途单据(要么禁止停用,要么停用后仍允许返还但记录原因)。当前项目是”不对称 + 靠 SQL 条件自然区分”,简单但依赖读者理解这段不对称的意图——代码里没有一行注释说明这个不对称,属于隐性知识,面试时是自己主动讲出来的加分点。

【面试可以补一句】 顺带指出这里的一个体验问题:预算不存在、预算停用、余额不足,三种情况在 deduct 里全部返回 rows = 0,业务层统一抛 BUDGET_NOT_ENOUGH(“部门预算余额不足,无法提交申领单”)。无法区分”没设置预算”和”钱不够”。更好的做法是先查一次预算存不存在(或者让 SQL 返回不同的错误码/用 RETURNING),给用户更准确的提示。MessageConstant.BUDGET_NOT_FOUND(“未查询到该部门的预算,请联系管理员设置”)这个常量已经定义好了,但在 deduct 路径上永远不会被触发——它只在”员工没有 dept_id”时才用(OrderServiceImpl:101)。这是”常量定义了但语义没用上”的一个小痕迹,可以直接指出。


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

如果面试前只有 2 小时,M2 只需要打开这 4 个文件、看这 6 段代码。

A 类:必须能背下来、能手画

优先级文件看什么为什么
P0BudgetMapper.xml第 31-38 行 deduct、第 43-49 行 release整个 M2 的技术核心,就这两条 SQL。要能默写 WHERE 条件并解释为什么这么写
P0BudgetServiceImpl.javadeduct:90、release:110(各 10 行)理解”业务层只看影响行数”这个模式;注意 amount.signum() <= 0 的防御
P0OrderServiceImpl.javasubmitOrder 的 第 96-110 行(金额重算 + 预扣)、第 271-283 行 releaseBudget预扣的调用上下文 + 双防线的第一道
P0sql/migration_office_supplies.sql第 89-104 行 budget 建表唯一索引是方案成立的前提,必须能说清

B 类:知道调用了什么、流程怎么走即可

文件看什么
admin/BudgetController.java3 个接口 + @RequireRole 的角色分工
user/BudgetController.javamy() 里 employee == null 时返回空 data
BudgetMapper.java@Select 注解式 vs XML 式混用的现象
sql/regress_api_test.sh第 128-131 行(提交前预算)、第 156-158 行(预扣 32)、第 194-205 行(驳回返还)、第 207-215 行(撤销返还 + 重复撤销幂等)、第 217-227 行(超额拦截)

C 类:不用看

  • Budget.java / BudgetDTO.java / BudgetVO.java —— Lombok 数据类,扫一眼字段即可
  • BudgetMapper.xml 的 insert / update / list —— 标准 MyBatis 动态 SQL
  • Department 相关 —— 属于 M10/M11 范畴

2 小时复习顺序建议(M2 部分约 25 分钟)

1. BudgetMapper.xml 的 deduct 和 release      ← 5 分钟,默写 WHERE 条件
2. BudgetServiceImpl.deduct / release         ← 3 分钟,记住"看影响行数"
3. OrderServiceImpl 第 96-110 行              ← 5 分钟,能口述预扣上下文
4. OrderServiceImpl.releaseBudget             ← 5 分钟,理解双防线
5. budget 建表语句的唯一索引                  ← 2 分钟
6. 用自己的话讲一遍"为什么不用分布式锁"         ← 5 分钟,这是必问

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

追问链 1:并发正确性(最可能被问的一条)


【面试官问题 1】
你这里提交申领单要扣部门预算,怎么保证不会超支?

【推荐回答】
我把”额度够不够”的判断直接下推到了 SQL 的 WHERE 条件里,用一条带条件的 UPDATE 同时完成”判断”和”扣减”,业务层只看这条 SQL 的影响行数——返回 1 就是扣成功,返回 0 就是余额不足,然后抛异常回滚整个事务。这样判断和更新是一个原子操作,不存在”查完了再改”的中间窗口。

【回答关键词】 条件 UPDATE · 判断下推 · 影响行数 · InnoDB 行锁 · 原子性
【可能继续追问】 为什么”先查再改”不行?


【面试官问题 2】
为什么不先查出来判断一下再更新?那样代码不是更直观吗?

【推荐回答】
那样会有 check-then-act 竞态。举个具体的:部门额度 100,两个人同时提交 80。两个请求都先 SELECT 到 used_amount = 0,都判断”0+80 <= 100,够”,然后都执行 UPDATE。虽然 InnoDB 会串行化这两个 UPDATE,但第二次 UPDATE 是 SET used_amount = 80(如果写成赋值而不是累加),最后账面只记了 80,实际却占了 160 —— 这是丢失更新,账实不符,事后对账都查不出来。

【回答关键词】 竞态 · check-then-act · 丢失更新 · 账实不符
【可能继续追问】 那你的 SQL 为什么能避免?


【面试官问题 3】
你这条 UPDATE 靠什么保证第二个请求不会读到旧数据?

【推荐回答】
靠两点。第一是行锁:WHERE dept_id=? AND period=? 命中了 (dept_id, period) 上的唯一索引,InnoDB 直接定位到唯一一行并加排他锁,第二个事务必须等锁释放。第二,也是更关键的一点:MySQL 默认隔离级别是 REPEATABLE READ,普通 SELECT 读的是事务开始时的快照,但 UPDATE 是当前读——加锁之后会读取最新已提交的版本,然后在这个新值上重新评估 WHERE 条件。所以第二个事务拿到锁时,看到的是第一个事务已经加完的 used_amount,total_amount - used_amount >= amount 就不成立了,影响行数是 0。

【回答关键词】 当前读 / current read · 一致性读(快照读) · RR 隔离级别 · 排他锁
【可能继续追问】 唯一索引在这里起什么作用?如果不是唯一索引呢?


【面试官问题 4】
如果 (dept_id, period) 上没有唯一索引,会怎么样?

【推荐回答】
两个问题。第一是业务上允许同一部门同一周期出现两条预算记录,那到底按哪条扣就不确定了。第二是锁的粒度和范围会变化:唯一索引等值查询会退化成行锁,锁的就是那一行;如果只是普通索引或者走不到索引,InnoDB 可能对扫描到的多行加锁,甚至在高并发下出现间隙锁导致更大范围的阻塞,并发吞吐会明显下降。所以这个唯一索引不只是业务约束,也是并发方案的前提。

【回答关键词】 唯一索引退化为行锁 · 普通索引锁多行 · 间隙锁 gap lock · 并发度
【可能继续追问】 为什么不用分布式锁?


【面试官问题 5】
听起来这是并发问题,为什么不用 Redis 或 Redisson 分布式锁?

【推荐回答】
因为这个场景不是分布式问题,是单库单行的并发正确性问题。预算数据只有一个权威副本在 MySQL 里,InnoDB 的行锁本身就能保证原子性,而且锁是随事务自动获取和释放的——没有租约过期、没有续期、没有锁误删、没有需要额外引入的组件。用分布式锁会把这个数据库本来就能保证的事情搬到应用层,只会增加故障面。分布式锁要解决的是跨多个资源或多个服务的互斥,这里不满足那个条件。

【回答关键词】 单库单行 · 不是分布式问题 · 锁自动释放 · 故障面 · 规模边界
【可能继续追问】 那如果是多实例部署呢?


【面试官问题 6】
如果后端部署了多个实例,你这条 SQL 还成立吗?

【推荐回答】
还成立,而且正是这种方案的优势。因为并发控制落在数据库这一层,不在应用进程里,多实例访问的是同一个 MySQL,行锁对所有连接一视同仁。反过来说,如果用的是 synchronized 或者 ReentrantLock 这种 JVM 级别的锁,多实例下就完全失效了——这是很多项目里的隐藏 bug。

【回答关键词】 数据库层并发控制 · JVM 锁在多实例下失效 · 无状态应用
【可能继续追问】 那资金数据放 Redis 扣减行不行?


【面试官问题 7】
有些项目会把余额放 Redis 用 DECR 扣,你觉得呢?

【推荐回答】
对资金类数据我不倾向这么做,主要是两个问题。一是权威性:Redis 默认是异步持久化,宕机可能丢数据,而余额丢了意味着账实不符;二是双写一致性:Redis 扣成功、MySQL 落库失败,或者反过来,都会导致两边不一致,得额外引入补偿机制。如果确实要承载极高并发,我会考虑 Redis + Lua 做原子扣减,但必须配套 Redis Stream 或本地消息表做可靠的落库补偿,而且要接受”缓存承担权威源”的可靠性风险。当前项目的数据量和并发量完全没必要走到这一步。

【回答关键词】 权威数据源 · 异步持久化丢数据 · 双写一致性 · 补偿机制 · 数据量边界
【可能继续追问】 那你这个方案有什么缺点?


【面试官问题 8】
你这个方案自己没有不满意的地方吗?

【推荐回答】
有几个我明确知道的问题。第一,业务逻辑跑到 SQL 里了,“额度够不够”这个规则只存在于 WHERE 里,可读性和可测试性不如写在 Java 里,规则复杂一点就表达不了。第二,错误信息不够精确——预算不存在、预算被停用、余额不足,这三种情况在 SQL 里都是影响行数 0,业务层统一报”余额不足”,用户分辨不出到底是哪种;我代码里甚至定义了 BUDGET_NOT_FOUND 这个提示常量,但在扣减路径上永远走不到。第三,没有预算流水表,used_amount 只是个汇总数字,要查”这笔钱花在哪张单子上”只能反查 orders 再求和。如果要改进,我会加一张 budget_log,扣减和返还各插一条流水,used_amount 作为流水派生出来的冗余字段。

【回答关键词】 逻辑泄漏到 SQL · 错误码不精确 · 缺流水表/审计账本 · 主动暴露短板
【可能继续追问】 (这时候面试官通常会转向别的模块,或者追问返还幂等)


追问链 2:返还幂等(第二条高概率链路)


【面试官问题 1】
审批驳回、员工撤销、管理员取消,三条路径都要退预算。怎么保证不重复退?

【推荐回答】
我用了两层。第一层在业务代码里:releaseBudget 一开始就判断 orders.pay_status 是不是 1(已扣减),不是就直接 return——因为返还完会把 pay_status 改成 2(已返还),所以哪怕同一张单据走了两条链路,第二次进来会被这个状态机挡住。第二层在 SQL 里:返还的 UPDATE 带了 AND used_amount >= amount 条件,防止并发或者漏判导致把已用金额刷成负数。

【回答关键词】 单据状态机 pay_status · 守卫 return · 条件 UPDATE 兜底 · 双防线
【可能继续追问】 这两层分别防什么?


【面试官问题 2】
为什么要两层?一层不够吗?

【推荐回答】
因为它们防的是不同性质的问题。第一层 pay_status 防的是业务性重复——同一张单据被不同业务流程反复返还,这是逻辑问题。第二层 SQL 条件防的是并发性重复——两个请求同时返还同一张单据,理论上两个都可能读到 pay_status=1,这是时序问题。所以这两层不是重复,是针对不同失效模式的。

【回答关键词】 业务性重复 vs 并发性重复 · 逻辑问题 vs 时序问题 · 不同失效模式
【可能继续追问】 那第二层能防住并发重复吗?


【面试官问题 3】
好,那我追问一下:并发下两个请求都读到 pay_status=1,你的 SQL 条件能防住重复返还吗?

【推荐回答】
严格说,防不住。 这是我这套方案的一个真实缺口,我得说清楚。假设部门可用额度还很充足,要退 32:两个请求都通过了 pay_status 判断,第一个把 used_amount 从 1000 减到 968,第二个拿到锁后重新校验 968 >= 32 仍然成立,于是减到 936 —— 确实多退了 32。used_amount >= amount 这个条件只能防止把余额刷成负数,不能防止额度充足时的重复扣减。

【回答关键词】 主动承认缺口 · 条件只能防负数 · 额度充足时仍会多退
【可能继续追问】 那怎么才能严格保证?


【面试官问题 4】
那要怎么改才能严格保证幂等?

【推荐回答】
把”是否已返还”这个判断本身也变成原子的状态迁移,而不是先查后判断。具体做法是先执行一条条件更新:

UPDATE orders SET pay_status = 2 WHERE id = ? AND pay_status = 1;

看影响行数——只有返回 1 的那个请求才继续去调预算返还,返回 0 的说明已经被别的请求处理过了,直接返回。这样”认领处理权”和”执行返还”的先后关系就确定了,和扣减预算用的是同一个思路:把判断下推到 WHERE,用影响行数做裁决。更彻底的做法是加一张 budget_log 流水表,在 (order_id, type) 上建唯一索引,从数据库层面绝对拒绝重复插入。

【回答关键词】 状态迁移原子化 · 影响行数裁决 · 认领处理权 · 流水表唯一索引
【可能继续追问】 那你现在为什么没改?


【面试官问题 5】
既然你知道更严格的写法,为什么代码里没这么写?

【推荐回答】
因为在当前项目里,这个缺口实际上不容易被触发。三个返还入口都标了 @Transactional,返还和更新 pay_status 在同一个事务里,而且单张单据的并发返还需要”同一个人同时点撤销、同时管理员点取消”这种很难复现的时序。回归脚本第 20 节验证的是”重复撤销不造成重复返还”(顺序重复,被 pay_status 挡住了),覆盖不到这个并发场景。要真正验证得写并发压测。所以我的判断是收益不高、改动面不小,就留在已知问题里了 —— 但如果这个系统要上生产、金额再大一点,我会优先改这里。

【回答关键词】 事务边界锁住 · 触发条件苛刻 · 触发成本 vs 收益 · 优先级判断 · 诚实
【可能继续追问】 那你为什么不用 select for update?


追问链 3:表设计与替代方案(较常见,5 层)


【面试官问题 1】
预算表为什么这么设计?为什么 (dept_id, period) 是唯一索引?

【推荐回答】
budget 表的核心是 (部门, 周期) → 总额 + 已用。建唯一索引有两个原因:一是业务约束,同一个部门同一个周期只能有一条预算,否则”按哪条扣”就说不清;二是并发方案的前提——我的扣减 SQL 用 WHERE dept_id = ? AND period = ?,唯一索引能让 InnoDB 直接定位到唯一一行并加行锁,锁粒度最小。

【回答关键词】 业务约束 · 并发前提 · 唯一索引定位单行 · 锁粒度
【可能继续追问】 为什么 period 用字符串?


【面试官问题 2】
period 为什么用 VARCHAR(7) 存 "2026-09",不用 DATE 或者年份月份两个字段?

【推荐回答】
因为它是业务周期标识,不是时间点。yyyy-MM 定长 7 字符,可以直接做等值匹配和字符串比较——'2026-09' > '2026-08' 成立,ORDER BY period DESC 天然就是按时间倒序,索引也能直接用。如果用 DATE 存每月 1 号,得防止有人存成 9 月 5 号,而且查询要用范围;如果拆成 year 和 month 两个 INT,唯一索引变三列,还不能直接按时间排序。

【回答关键词】 业务周期标识 vs 时间点 · 定长可比 · 可直接排序 · 索引等值
【可能继续追问】 这个设计有什么风险?


【面试官问题 3】
有什么风险?

【推荐回答】
格式校验只在应用层。period 是管理员传进来的,BudgetServiceImpl.save 里只做了 null 判断——传空就用当前月兜底,但传一个不合法的格式(比如 "2026/09" 或者 "abc")会原样入库,不会报错。之后员工端用 currentPeriod() 生成的标准格式去查,永远匹配不上,员工会看到”未设置预算”,而管理员在列表里能看到这条预算存在 —— 变成一个很难排查的问题。合理的修法是加格式校验(正则或者 DateTimeFormatter 解析),或者干脆让前端用月份选择器而不是文本框。

【回答关键词】 应用层校验缺失 · 脏数据导致查不到 · 加格式校验/日期选择器
【可能继续追问】 那余额是怎么算的?


【面试官问题 4】
remainAmount 是数据库里的字段吗?

【推荐回答】
不是,它是 SQL 里的派生列 (total_amount - used_amount)。这样不存冗余、永远一致,不会出现”总额减已用跟余额对不上”的脏状态。代价是没法给余额建索引,如果以后要查”余额低于某个值的部门”就走不了索引。当前业务不需要这个查询,所以能接受。

【回答关键词】 派生列 · 不存冗余 · 永不失配 · 无法建索引
【可能继续追问】 如果要查”某笔预算的变更历史”呢?


【面试官问题 5】
如果管理员问”这个月技术部这 3 万都花在哪些单子上”,你怎么查?

【推荐回答】
现在只能反查 orders 表:按 requester_dept 和 order_time 落在本月的条件,把单据金额求和 —— 我刚查过,结果应该等于 used_amount。但这里有两个问题:一是 orders 表没有 period 列,按 order_time 算周期走不了索引;二是这只能得到一个和,看不到”每笔预扣和返还的过程”。根本原因是项目没有预算流水表,used_amount 只是个汇总数字,没有账本。

如果要改进,我会加一张 budget_log(budget_id, order_id, type, amount, create_time),扣减和返还各插一条,used_amount 变成由流水派生的冗余字段——这样既有审计账本,又保留了一个可以快速读取的汇总值。这属于当前项目的已知缺口。

【回答关键词】 反查 orders 求和 · 无 period 列无法走索引 · 缺流水表/审计账本 · 改进方案
【可能继续追问】 (转向”为什么不用消息队列”)


追问链 4:异步与 MQ(M2 范围内最容易被”劝你上 MQ”的追问)


【面试官问题 1】
提交申领单的时候,扣预算和写单据为什么不做成异步?用消息队列解耦不是更好吗?

【推荐回答】
因为交互语义不允许。员工点提交之后要立刻知道结果——扣成功了就返回单号,余额不足就直接告诉他”余额不足”。如果做成异步,接口只能返回”已受理”,员工不知道到底成没成功,前端也没法提示。而且扣预算失败必须让整个落库事务回滚、不能产生单据,这种”要么全成要么全不成”的强一致要求,做了异步反而要靠补偿去还原,复杂度上升、正确性下降。

【回答关键词】 同步交互语义 · 前端要即时结果 · 强一致/事务边界 · 异步反而要补偿
【可能继续追问】 那这个项目里有适合用 MQ 的地方吗?


【面试官问题 2】
那你项目里有没有什么地方其实适合用 MQ,但你没用?

【推荐回答】
有几个我识别到但目前没做的。一是催办通知:现在是写 orders.remind_time,如果以后要真正推送消息给采购专员(短信、企业微信),那就该走 MQ 异步,不应该卡在员工请求的主链路上。二是申领单完成后的下游动作,比如通知财务、同步 ERP、生成台账,这些都属于”主流程成功之后的附带动作”,适合用 MQ 解耦,失败了也不影响主流程。三是工作台统计,现在是每次打开都做全表聚合,可以用 MQ 把单据状态变更事件发出来,消费端增量更新统计表或者 Redis 计数器。

但我要说清楚:这三件事当前项目都没实现,我也没有引入 MQ。

【回答关键词】 识别适用场景但未实现 · 催办通知 · 下游动作解耦 · 统计增量更新 · 诚实边界
【可能继续追问】 如果真上了 MQ,怎么保证消息不丢、不重复消费?


【面试官问题 3】
如果真引入 MQ,扣预算这个动作怎么保证消息不丢、不重复消费?

【推荐回答】
这块我只是设计层面的理解,项目里没有实践。思路是:不丢靠生产端本地消息表或者事务消息——先把”要发消息”和业务数据写进同一个本地事务,再由定时任务或事务回查去投递,投递成功才标记,这样避免”数据库成功但消息没发出去”;消费端要在业务处理成功后才 ack,不能用自动 ack,否则消费失败消息就丢了。

不重复消费靠消费端幂等,不是靠 MQ 保证 exactly-once —— 主流 MQ 基本都是 at-least-once。幂等的做法和我现在预算返还的思路是一样的:用业务唯一键加唯一索引,或者做条件状态更新。比如预算扣减消息,我会在流水表上用 (order_id, type) 唯一索引,重复消费时插入冲突直接忽略。

【回答关键词】 本地消息表 / 事务消息 · 手动 ack · at-least-once ≠ exactly-once · 消费端幂等 · 唯一索引去重
【可能继续追问】 (如果面试官懂 MQ,会继续问具体选型)


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

#问题面试官为什么可能质疑当前代码实际情况我应该怎么解释是否建议修改
M2-1used_amount >= amount 不足以防并发重复返还面试官会说”条件更新只防负数,额度够的时候照样重复退”✅ 质疑成立。两个并发请求都通过 pay_status 判断后,第二个仍能成功返还主动承认:「这道 SQL 只能防刷成负数,不能防额度充足时的重复扣减。严格的修法是把返还做成 UPDATE orders SET pay_status=2 WHERE id=? AND pay_status=1,用影响行数裁决,只有影响行数为 1 的请求才去动预算。」建议修(改动小、收益明确,是提升”并发幂等”理解深度的好素材)
M2-2没有预算流水表,缺审计账本”资金操作怎么追溯?出错怎么对账?”✅ 成立。used_amount 是汇总数字,无明细、无操作记录,Budget 表也无任何操作日志「当前是汇总式设计,并发控制简单;代价是缺审计明细,对账只能反查 orders。金额敏感度更高的话应该加 budget_log 流水表,用 (order_id, type) 唯一索引同时解决幂等和可追溯。」建议加(这是把 M2 从”能讲清”提升到”有完整设计”的关键一步)
M2-3退换单的预算永远退不了”员工发起退换,预算怎么退?”✅ 真实的闭环缺口。returnOrder 把状态置为 CANCELLED(6),但不返还预算;而 cancel 的守卫是”status == CANCELLED 就拒绝”,所以管理员也无法通过接口返还。注释说”由管理端线下确认后处理”,但系统里没有这个入口「退换只做状态流转不返还预算是有意的,为了避免’退换即退款’的套利。但你说得对,注释说由管理端线下处理,系统里却没有对应入口——因为单据已经是已取消状态,/admin/order/cancel 会拒绝。合理的修法是加一个独立的’退换待确认’状态,由管理员审批通过后才返还。」建议修(加状态 7,或允许退换单走 cancel 路径)
M2-4deduct 的三种失败原因无法区分”预算不存在和余额不足,提示为什么一样?”✅ 成立。deduct 一律返回 rows=0,业务层统一抛 BUDGET_NOT_ENOUGH。MessageConstant.BUDGET_NOT_FOUND 只用于”员工无 dept_id”场景,在扣减路径上永远触发不到「SQL 层只能区分’匹配到几行’,所以三种情况收敛成一个错误。改法是先查一次预算判断存在性(多一次往返),或者让 SQL 用不同返回值/分开两条语句。当前为了少一次往返牺牲了提示精度。那个 BUDGET_NOT_FOUND 常量确实没在这个路径上用到。」可选(体验优化,优先级低于 M2-1/M2-3)
M2-5deduct 判 status=1,release 不判,且无注释”为什么两条 SQL 条件不对称?“⚠️ 不对称是有意的且正确(停用后不该阻止返还),但代码里一行注释都没有「这个不对称是有意的:deduct 判 status=1 让停用生效,release 不判是因为’钱必须能退回去’——如果返还也判 status=1,管理员在员工撤销之前停用了预算,员工的钱就永远退不回来了。应该在这个不对称处加注释说明。」建议加注释(零风险)
M2-6save 的查重 SQL 带 status = 1,与唯一索引语义不一致”你的唯一索引是 (dept_id, period),但查重是 (dept_id, period, status=1),能对上吗?”✅ 存在不一致。如果该周期预算已是停用状态,getByDeptAndPeriod 查不到 → 应用层不报”已存在” → 尝试 INSERT → 撞唯一索引 → 抛 SQLIntegrityConstraintViolationException → 全局异常处理器走”Duplicate entry”分支,按空格切分第 3 段生成消息,得到类似 '2-2026-09'已存在 的奇怪提示「这是个真实的不一致。应用层的查重带了 status=1,但数据库的唯一索引不带,所以停用状态的预算会让查重漏过、然后被数据库拒绝。修法有两种:查重去掉 status 条件,或者唯一索引改成带 status 的复合(但那会允许重复的历史停用记录)。我倾向查重去掉 status。」建议修(一行改动,消除一个能复现的怪提示)
M2-7getVOByDeptAndPeriod 查了两次库”为什么先查 id 再查 VO,不一次查完?”✅ 成立。方法体是”按 (deptId, period) 查 Budget 拿 id”+“按 id 查 BudgetVO”两次查询,而 getVOById 的 SQL 完全可以加上 dept_id/period 条件合并成一次「这两次查询可以合并,getVOById 的 SQL 加上 WHERE dept_id=? AND period=? 就够了。当时是复用了已有的 getVOById 方法,没合并。代价很小——都走唯一索引等值命中,但这个往返确实是多余的。」建议合并(改动小,消除一处明显冗余)
M2-8没有月度自动续期机制”新月份预算怎么来?忘了设置会怎样?”✅ 成立。全项目无 @Scheduled、无定时任务。新周期必须管理员手工逐部门创建。某部门当月没设置 → 员工提交时报”预算余额不足”(因为 deduct 的 WHERE 匹配不到行,影响行数 0),提示误导「当前是手工维护,没有自动续期。这会导致两个问题:一是管理员漏设一个月,该部门当月完全无法提交;二是提示是’余额不足’,但实际原因是’没设置预算’,员工会去找管理员问’我们部门明明有钱’。合理的修法是加一个定时任务在月初按上月总额生成新周期预算,或者把 deduct 的失败原因细分。」建议修(定时任务 + 错误码细分,是很实际的改进项)
M2-9锁的是行,热点部门会串行”同一个部门 100 个员工同时提交,性能怎么样?“⚠️ 成立。同一部门同一周期的所有提交都竞争同一行的 X 锁,完全串行「是的,同一部门同一周期的提交会串行化,因为都锁同一行。但这里的临界区只有一条 UPDATE,没有网络调用、没有复杂计算,单行 UPDATE 的持锁时间是微秒级的,单部门百人同时提交在我们这个场景是完全能接受的。真正的瓶颈不在这里。如果部门规模到了万人级,可以考虑把预算拆成多个’额度桶’分散热点行,但那会引入额度分配的新复杂度。」不需要改(要说清规模边界,不要吹”高并发”)
M2-10periodOf 用下单时间反推周期,跨月返还会记到上月”1 月 31 号提交、2 月 5 号驳回,退到哪个月?“⚠️ 代码行为是退到 1 月(budgetService.periodOf(ordersDB.getOrderTime()))——这是有意的,因为扣的是 1 月的钱「退到扣的那个月,这是有意的——钱从哪个月扣的就还到哪个月。但会产生一个业务上的困惑:部门 1 月的已用金额在 2 月才被更新,1 月的报表如果已经出了,就变成’事后变动’。严格来说应该用一条带周期的流水记录这个跨期调整。这是’汇总式设计’的又一个代价。」可选(配合 M2-2 的流水表一起解决)
M2-11update 允许改 deptId / period,可能撞唯一索引”修改预算的时候如果改了部门或周期,会怎样?“⚠️ 成立。BudgetDTO 里有 deptId 和 period,BudgetMapper.xml 的 update 动态 <set> 会把它们写进去。如果改成已存在的 (dept_id, period) 组合,会撞 uk_budget_dept_period,抛出的又是那个”按空格切分”的怪提示「dept_id 和 period 其实是预算的业务主键,不应该允许修改——修改预算应该只允许改 total_amount 和 status。当前 DTO 里带着这两个字段,前端如果传了就会被更新。这是应该收窄的:要么从 DTO 里去掉,要么在 update 里显式忽略。」注意:usedAmount 已经正确地不在 DTO 里,所以前端改不到已用金额,这一点是对的建议修(收窄可更新字段,防止业务主键被改)
M2-12金额重算与预算扣减之间无锁,理论上申领车内容可能已变”重算金额和扣预算之间,申领车被改了怎么办?“⚠️ 理论成立,实际风险极低。submitOrder 在同一事务内先 shoppingCartMapper.list() 再 deduct,但申领车表没有被锁,另一个并发请求理论上可以在两者之间修改申领车内容「理论上是有的:金额是按 T 时刻读到的申领车算的,扣的是这个金额,如果申领车内容在中间变了,单据明细和金额可能不完全对应。但 DELETE FROM shopping_cart WHERE user_id=? 在事务末尾执行,而同一个用户的申领车修改通常是串行的(一个用户不会真的并发改自己的车),所以实际风险很低。严格的话可以给申领车查询加 FOR UPDATE,但那会扩大锁范围、影响并发,收益不明显。」不建议改(要能说清为什么可接受)
M2-13budget.dept_id 无外键、save 不校验部门是否存在”给一个不存在的部门设预算会怎样?”✅ 成立。BudgetServiceImpl.save 只做”同部门同周期是否已存在”的查重,从不校验 deptId 在 department 表里是否存在;budget 表也没有外键。而 DepartmentServiceImpl.deleteById 只拦”部门下还有员工”,不清理该部门的预算行「会插入一条孤儿预算行——dept_id 指向不存在的部门。这不是假设:项目的回归脚本第 27 节专门写了一步清理”孤儿预算行”,注释里写明”历史上就因此留下过 dept_id 指向已删部门的孤儿预算行”。根因是没有外键 + 没有应用层校验。修法是在 save 里查一次部门是否存在(能复用 DepartmentMapper.getById),并在删部门时一并处理预算;要么就正式加外键约束。」建议修(这是已经真实发生过的数据问题,不是理论推演,面试官问到时能拿”回归脚本里有清理步骤”作为证据,很有说服力)
M2-14total_amount - used_amount >= amount 有精度隐患”DECIMAL 相减再和入参比较,会不会有精度问题?“⚠️ 理论风险。total_amount/used_amount 是 DECIMAL(12,2),而 #{amount} 由 Java BigDecimal 传入,其 scale 取决于运算结果。本次重算走的是 multiply(BigDecimal.valueOf(int)),得到的是 2 位小数,所以当前没有触发问题「amount 现在是 单价(2位) × 数量(整数),结果就是 2 位小数,和列定义 DECIMAL(12,2) 一致,没问题。但这个安全是巧合而不是设计保证——如果以后金额来源变成 divide(比如打折、按比例分摊),scale 可能变成 4 位甚至无限,那时 >= 的比较就可能出现’看起来相等却判不足’的边界情况,也会让 used_amount 存进超过 2 位的小数被静默截断。稳妥做法是在预算边界上统一 setScale(2, RoundingMode.HALF_UP)。」另外要注意:used_amount 是 DECIMAL(12,2),但预算是按部门汇总的,多个部门累加不会溢出;单部门超大额(>百亿)才会,实际不可能可选(当前无实际风险,但答得上来说明对 BigDecimal 和 DECIMAL 的边界有感觉)

明确不是问题、面试官可能问但可以理直气壮回答的点

点回答
BudgetServiceImpl 没有 @Transactional它是正确的:deduct / release 是单条 SQL,本身就是一个事务;save 是”查重 + 插入”,靠唯一索引兜底,也不需要跨表回滚。不要为了”看起来规范”乱加 @Transactional
扣的是”已用金额”而不是真的转账这是内部核算模型,不是支付系统。used_amount 是部门成本的记账口径,真实付款走线下采购流程。这个边界要说清,别说成”实现了支付”
为什么不用 SELECT ... FOR UPDATE两次往返(查+改),锁持有时间包含 Java 判断;本方案一次往返、锁持有时间最短。能对比出这一点是加分
为什么不用乐观锁部门预算是热点行(同部门员工同时提交),乐观锁在冲突高时会有大量失败重试

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

事实值 / 位置
建表位置sql/migration_office_supplies.sql 第 89-104 行
唯一索引uk_budget_dept_period (dept_id, period)
金额类型DECIMAL(12,2) ↔ Java BigDecimal
周期格式VARCHAR(7),yyyy-MM,由 DateTimeFormatter.ofPattern("yyyy-MM") 生成(BudgetServiceImpl:24)
核心 SQL 1BudgetMapper.xml 第 31-38 行 deduct(4 个 WHERE 条件)
核心 SQL 2BudgetMapper.xml 第 43-49 行 release(3 个 WHERE 条件)
扣减入口OrderServiceImpl.submitOrder 第 107 行(唯一扣减点)
返还入口releaseBudget 第 271 行,被 rejection:444、userCancelById:249、cancel:474 三处调用
返还防线应用层 payStatus == PAID(第 272 行)+ SQL used_amount >= amount
事务注解submitOrder 用 @Transactional;三个返还入口用 @Transactional(rollbackFor = Exception.class)(受检异常必须显式声明)
余额算法SQL 派生列 (total_amount - used_amount) AS remainAmount,不是表字段
接口权限设置/修改预算 @RequireRole(ADMIN);查列表 {ADMIN, PURCHASER};员工端 /user/budget/my 无注解(员工端拦截器已保证登录)
回归验证regress_api_test.sh 第 156-158(预扣 32)、194-205(驳回返还)、207-215(撤销 + 重复撤销幂等)、217-227(超额拦截,先压低余额到 100 再提交 199 元的单)
定时任务无(无 @Scheduled)
流水表无
dept_id 外键无(曾因此产生孤儿预算行,见 M2-13 与回归脚本第 27 节)
分布式锁无
MQ无

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

问题:员工提交申领单要预占部门月度额度,写成”先查余额、判断够不够、再更新”会有并发竞态——两个人同时提交,都读到”够”,最后记了两次扣减但只落一次账,这是丢失更新,账实不符。

方案:我把额度判断下推到 SQL 的 WHERE 条件里,用一条 UPDATE 同时完成判断和扣减:

UPDATE budget SET used_amount = used_amount + ?
 WHERE dept_id = ? AND period = ? AND status = 1
   AND total_amount - used_amount >= ?

业务层只看影响行数,0 就是余额不足,抛异常回滚整个事务,不产生单据。

为什么成立:(dept_id, period) 上有唯一索引,InnoDB 能定位到唯一一行加排他锁;更关键的是 MySQL 在 RR 隔离级别下 UPDATE 是当前读,会读最新已提交版本,所以第二个事务拿到锁后是在新值上重新核对条件,必然失败。

返还用两道防线:单据 pay_status 状态机守卫 + SQL 的 used_amount >= amount 兜底,防重复返还。

不用分布式锁是因为:这是单库单行的并发正确性问题,不是分布式问题。InnoDB 行锁本身就保证原子性,而且随事务自动释放,没有租约和续期的复杂度。

我知道的短板:① used_amount >= amount 只防刷成负数,防不住额度充足时的并发重复返还,严格做法应该是把返还也做成 UPDATE orders SET pay_status=2 WHERE id=? AND pay_status=1 的条件状态迁移,用影响行数裁决;② 没有预算流水表,缺审计账本;③ 退换单的预算实际上退不了,系统里没有对应入口;④ 没有月度自动续期,靠管理员手工建。