真正有效的财务数据状态保护必须结合状态机校验+before update+明确报错;触发器需在before中枚举合法状态跳转、冻结终态关键字段、依赖应用层传入操作人变量,after仅用于异步审计日志。

不能靠触发器“防止”修改,只能在校验层面拦截明显违规的变更;真正有效的保护必须结合状态机校验 + BEFORE UPDATE + 明确报错。
BEFORE UPDATE 中校验状态跳转是否合法
财务数据常有严格的状态流转规则(如 draft → reviewing → paid → refunded),跳过中间状态属于业务违规。触发器应在 BEFORE UPDATE 阶段比对 OLD.status 和 NEW.status,只允许预定义的跳转路径:
- 用
IF显式枚举合法组合,例如:IF OLD.status = 'paid' AND NEW.status NOT IN ('refunded', 'reversed') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'paid 状态只允许转为 refunded 或 reversed'; - 避免用
NOT IN ('draft', 'reviewing')这类宽泛否定——漏写一个合法状态就等于放行非法跳转 - 状态字段必须是
ENUM或带检查约束的VARCHAR,否则NEW.status可能存入拼写错误值,触发器无法识别
禁止在终态下修改金额或关键字段
一旦状态变为 paid 或 closed,amount、currency、invoice_no 等字段应冻结。触发器需同时校验状态和字段值:
- 写成复合条件:
IF OLD.status IN ('paid', 'closed') AND (NEW.amount != OLD.amount OR NEW.currency != OLD.currency) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '终态记录禁止修改金额与币种'; - 不要只判
OLD.status = 'paid'就拦所有更新——那样会误杀updated_at等允许更新的辅助字段 - 若业务允许终态下更新备注(
memo),需在条件中显式排除:AND NOT (NEW.memo = OLD.memo)不够,要写成AND (NEW.amount != OLD.amount OR NEW.currency != OLD.currency OR NEW.invoice_no != OLD.invoice_no)
触发器里读不到真实操作人,别信 CURRENT_USER()
CURRENT_USER() 返回的是数据库连接账号(如 app@10.0.2.5),不是调用接口的员工ID或系统账号。把它当操作人记录进日志,审计时会发现全是同一个账号:
- 真正需要追溯操作人,必须由应用层在会话中设置变量,例如执行
SET @current_app_user = 'u12345';,再在触发器里用COALESCE(@current_app_user, CURRENT_USER()) - 如果应用没设该变量,
@current_app_user为NULL,COALESCE会 fallback 到CURRENT_USER(),但此时你已失去真实身份信息 - DBA 直连或 ETL 工具导入时,根本不会执行
SET语句,这类操作永远显示为root@localhost或类似值
AFTER 触发器只负责记日志,不参与校验
所有防御性逻辑必须放在 BEFORE UPDATE。一旦走到 AFTER UPDATE,数据已经落库,再抛错也改不回去了:
-
AFTER触发器唯一合理用途是写审计日志:插入到独立库的audit_db.finance_log表,字段仅含table_name、pk_id、old_status、new_status、operated_by、operate_time - 日志表必须建在独立库,避免和业务表争锁和 I/O;字段尽量精简,别存完整 JSON 差异——MySQL 触发器不支持
JSON_DIFF(),硬拼字符串易出错且拖慢性能 - 如果
AFTER写日志失败(如磁盘满),不能让整个事务回滚——否则一次日志失败会导致付款失败,这是不可接受的业务风险
最易被忽略的点:触发器不校验权限,只校验数据逻辑。DBA 用 root 执行 UPDATE finance_ledger SET status = 'refunded' WHERE id = 123,只要状态跳转合法,触发器就放行。真正的防线是账号权限最小化——让运维账号没有直接 DML 权限,只开放封装了校验逻辑的存储过程。











