财务凭证不可修改的关键在于数据库触发器实时校验其会计期间是否已结账:在BEFORE UPDATE触发器中查询accounting_periods表,若period_code对应closed_at非NULL则RAISE EXCEPTION中止操作,确保绕过应用层的直接数据库修改也被拦截。

触发器判断凭证是否已结账的关键逻辑
财务凭证能否修改,核心在于判断其所属会计期间是否已结账。常见做法是维护一张 accounting_periods 表,含字段 period_code、closed_at(非 NULL 表示已结账)。触发器需在 UPDATE 前查该凭证关联的期间是否已关闭。
- 触发器必须定义为
BEFORE UPDATE,否则无法中止修改 - 凭证表(如
vouchers)应有明确的期间字段,例如period_code或通过外键关联到期间表 - 不要依赖应用层传入的“当前是否结账”标志——它可能被绕过;必须在数据库侧实时校验
PostgreSQL 中阻止修改的触发器写法
PostgreSQL 支持函数 + 触发器组合实现强约束。先建校验函数:
CREATE OR REPLACE FUNCTION check_voucher_not_closed()
RETURNS TRIGGER AS $$
BEGIN
IF EXISTS (
SELECT 1 FROM accounting_periods
WHERE period_code = NEW.period_code
AND closed_at IS NOT NULL
) THEN
RAISE EXCEPTION 'Cannot modify voucher in closed period: %', NEW.period_code;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
再绑定触发器:
CREATE TRIGGER prevent_voucher_update_after_close BEFORE UPDATE ON vouchers FOR EACH ROW EXECUTE FUNCTION check_voucher_not_closed();
-
RAISE EXCEPTION会中止整个事务,比RETURN NULL更可靠(后者在某些旧版本中可能静默失败) - 若凭证表用
period_id外键而非编码,查询条件要改为id = NEW.period_id - 注意:该触发器不拦截
INSERT或DELETE,如需同样限制,得另加触发器或扩展逻辑
MySQL 8.0 的等效实现与坑点
MySQL 不支持在触发器中直接抛出用户错误(SIGNAL 是 5.5+ 引入,但早期兼容性差),常用迂回方式是制造一个必然失败的操作:
DELIMITER $$
CREATE TRIGGER prevent_voucher_update_after_close
BEFORE UPDATE ON vouchers
FOR EACH ROW
BEGIN
IF EXISTS (
SELECT 1 FROM accounting_periods
WHERE period_code = NEW.period_code
AND closed_at IS NOT NULL
) THEN
INSERT INTO _invalid_table VALUES(); -- 故意引用不存在的表报错
END IF;
END$$
DELIMITER ;
- 这种“假报错”方式虽有效,但错误信息不直观,日志里显示的是
Table '_invalid_table' doesn't exist,不是业务语义错误 - MySQL 触发器不能调用存储函数返回结果用于条件判断(除非是内置函数),所以子查询必须内联写死
- 如果使用 MySQL 8.0.16+,可改用
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Cannot modify voucher in closed period';,更干净
为什么不能只靠应用层校验
应用代码里加个 if (period.closed) throw 看似简单,但存在几个硬伤:
- 直接连库的运维脚本、ETL 工具、其他微服务绕过主应用逻辑时,校验完全失效
- 多实例部署下,应用层查“是否结账”和后续 UPDATE 之间存在时间窗口,可能被并发操作穿透
- 权限控制若开放了
UPDATE给财务人员,他们可用客户端工具直连修改,跳过所有中间层
数据库触发器是最后一道防线,它不信任任何上游输入,只认当前事务上下文里的数据状态。真要防住,就得在这里卡死。
实际部署时,最容易被忽略的是触发器对批量更新的影响:比如 UPDATE vouchers SET amount = amount * 1.01 WHERE period_code = '202406',只要其中任意一条记录落在已结账期间,整条语句就会失败——这符合设计预期,但也意味着批量修正操作必须提前筛掉已结账数据。










