
1. 项目概述为什么我们需要一个“通用”的审核功能做后端开发的朋友应该都遇到过这种需求采购单要审批、文章要审核、用户提现要复核、项目立项要走流程。每个模块都来一套审核逻辑刚开始觉得没什么写多了就会发现业务代码里有大段大段重复的状态判断和审核逻辑每张表都要配一个审核状态字段每个接口都要写一遍“判断当前用户有没有权限审核”的代码。我做过一个后台管理系统里面有十几张业务表都需要走审核流程。最开始是copy-paste每张表一套审核接口后来需求变化——从单级审核变成多级审核从固定审核人变成按部门找审核人——这下完了十几套代码同步改改到怀疑人生。后来我沉下心来直接用Go写了一个基于通用表的审核通用实现。核心思路很简单不再给每张业务表单独写审核逻辑而是把“审核”本身抽象成一个独立的通用模块任何一张业务表、任何一条记录只要按约定配置好就能直接接入审核流程。这套方案上线之后新增一张需要审核的表前后大概只需要半小时而且后续调整审核层级、审核规则完全不需要动业务代码。这篇博客就把我这个项目的完整实现思路、核心代码和踩过的坑整理出来。适合那些正在为“一堆表都要审核”发愁的Go后端开发者也适合想了解审核流程如何抽象设计的朋友。这套方案不依赖特定的Web框架Gin、Beego、Echo都能用。我实际项目里用的是Gin GORM下文代码也会按这个组合来写。2. 整体设计思路从“每张表都写审核”到“一张表统管所有审核”在设计这套通用审核方案之前我先把所有需要审核的业务场景梳理了一遍发现它们其实有一个共通模式。2.1 先梳理业务场景找到共性痛点我做过的审核需求大致分这么几类单据类采购单、报销单、请假单一般是提交后走多级审批内容类文章、评论、图片一般是提交后由管理员一审、二审敏感操作类用户提现、账号注销、数据导出一般是风控审核配置变更类价格修改、规则调整一般是提交后需要上级复核这几类场景表面看起来完全不一样但抽象到数据层面其实都是同一个模型某张业务表的某条记录从初始状态出发经过若干次“审核通过/驳回”操作最终到达终态。想通这一层之后我的设计方案就清晰了能不能把“审核”这个过程本身做成一张通用表这张表不关心业务是什么只关心“谁、在什么时候、对哪条业务数据、做了什么审核动作”同时把审核状态也统一维护起来。2.2 三种主流方案对比为什么选“独立审核表 业务状态字段”真正动手之前我调研了市面上几种做法简单对比一下方案思路优点缺点方案A业务表加状态字段每张业务表加audit_status字段代码里写if判断最简单小项目够用每张表都要重复写逻辑多级审核扩展困难方案B独立审核记录表 业务表冗余当前状态审核流水单独存放业务表只存最终状态可追溯查询业务列表时无需连表需要保证两张表数据一致性写操作要事务方案C完全事件溯源不存业务状态所有状态由事件推导最强追溯能力实现成本高日常查询复杂小团队扛不住我选择的是方案B一张通用的审核记录表记录每一次审核动作业务表本身保留一个当前审核状态字段。这样既满足追溯需求又保证业务列表查询时不需要每次都去审核表里用子查询算状态。方案A我最早用过确实简单但一旦出现“二级审核不通过要退回一级”这种需求状态字段就不够用了你根本不知道一级和二级的审核结果分别是什么。方案C太重了对大多数业务系统来说用不上。2.3 整个项目的核心设计蓝图整个通用审核模块我拆成了三个核心部分通用审核记录表audit_record记录每一次审核流水的“流水账”审核配置表audit_config定义某张业务表、某个业务节点需要经过哪些审核层级各层级审核人怎么确定业务状态同步机制审核动作发生时通过事务同时更新审核记录表和业务表状态这个结构下新增一张需要审核的表要做的事只剩两件一是在业务表里加一个audit_status字段二是在审核配置表里插入一条配置记录。其他所有代码都是通用的。3. 通用审核表结构设计与关键代码实现这是整个项目的核心我详细拆开讲。3.1 审核记录表怎么设计才能“通用”先看核心的审核流水表CREATE TABLE audit_record ( id bigint(20) NOT NULL AUTO_INCREMENT, biz_type varchar(64) NOT NULL COMMENT 业务类型如 purchase_order、article、withdraw, biz_id bigint(20) NOT NULL COMMENT 业务数据主键ID, audit_level int(11) NOT NULL DEFAULT 1 COMMENT 当前审核层级从1开始, auditor_id bigint(20) NOT NULL COMMENT 审核人用户ID, auditor_name varchar(64) NOT NULL DEFAULT COMMENT 审核人姓名冗余存储, audit_action tinyint(4) NOT NULL COMMENT 审核动作1通过 2驳回 3撤回, audit_comment varchar(512) NOT NULL DEFAULT COMMENT 审核意见, audit_status_before tinyint(4) NOT NULL COMMENT 审核前业务状态, audit_status_after tinyint(4) NOT NULL COMMENT 审核后业务状态, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_biz (biz_type,biz_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT通用审核流水表;这里有几个设计决策说一下biz_type我用字符串而不是数字。虽然字符串存储空间略大但可读性极强——看日志时直接看到purchase_order比看到1直观太多了。为了避免写错我在代码里用常量定义所有业务类型。biz_id不建外键。通用表不能跟任何一张具体业务表建立外键关系否则就失去“通用”的意义了。至于数据一致性靠业务代码和事务来保证。auditor_name冗余存储。一开始我不存姓名查询时要join用户表后来发现审核流水查看频率极高join用户表每次多花好几毫秒干脆冗余进去。用户改名了也不影响历史流水反而更合理。3.2 审核配置表让多级审核、条件审核变成“配置”而不是“代码”有了流水表还不够还需要一张配置表来定义“这张业务表要审几级每级谁审”。CREATE TABLE audit_config ( id bigint(20) NOT NULL AUTO_INCREMENT, biz_type varchar(64) NOT NULL COMMENT 业务类型, audit_level int(11) NOT NULL COMMENT 第几级审核, auditor_type tinyint(4) NOT NULL DEFAULT 1 COMMENT 审核人类型1指定用户 2指定角色 3指定部门主管 4发起人自选, auditor_value varchar(255) NOT NULL COMMENT 审核人配置值按auditor_type存用户ID/角色ID/部门ID等, need_condition varchar(255) NOT NULL DEFAULT COMMENT 触发该级审核的条件表达式空代表必审, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_biz_level (biz_type,audit_level) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT通用审核配置表;用这张表描述一个“采购单三级审批”流程数据长这样biz_typepurchase_order, audit_level1, auditor_type2, auditor_value10 -- 角色ID为10的部门经理审一级 biz_typepurchase_order, audit_level2, auditor_type2, auditor_value11 -- 角色ID为11的总监审二级 biz_typepurchase_order, audit_level3, auditor_type1, auditor_value100 -- 用户ID为100的总经理审三级need_condition字段是我后来加的。有时候某个审核层级不是每次都要走比如“金额超过5万才需要总经理审批”这个条件如果写在代码里就又变成业务代码了。我的做法是把条件存储成类似amount 50000的表达式在代码里用一个简单的表达式计算器去判断。Go生态里比较轻量的做法是用govaluate这个库可以直接解析字符串形式的条件表达式。用govaluate判断条件是否命中的核心代码import github.com/Knetic/govaluate func needThisLevel(needCondition string, bizData map[string]interface{}) (bool, error) { if needCondition { return true, nil } expression, err : govaluate.NewEvaluableExpression(needCondition) if err ! nil { return false, fmt.Errorf(解析审核条件表达式失败: %v, err) } result, err : expression.Evaluate(bizData) if err ! nil { return false, fmt.Errorf(评估审核条件表达式失败: %v, err) } need, ok : result.(bool) if !ok { return false, fmt.Errorf(审核条件表达式结果不是布尔值) } return need, nil }bizData是业务数据的map结构比如采购单的{amount: 80000, department_id: 3}拿到之后传给这个函数就能动态判断某个审核层级到底要不要走。这套机制上线之后业务方再提“金额超过XX要加审”这类需求我只需要在配置表里改一行数据代码完全不用动。3.3 业务表只需两个字段就接入了审核体系任何业务表只要加上下面两个字段就能接入这套审核体系audit_status tinyint(4) NOT NULL DEFAULT 0 COMMENT 审核状态0草稿 1待审核 2审核通过 3审核驳回 4已撤回, submit_time datetime DEFAULT NULL COMMENT 提交审核时间可用于统计审核时效audit_status这个字段我在设计时吃了不少亏反复调整过。核心经验是状态值必须全局统一不能每张业务表有自己的含义。我就吃过这个亏一开始文章表里审核通过是2采购单里审核通过是3后来通用代码直接用魔法数字判断状态时经常被这种不一致坑到。所以在项目里我用Go常量把所有审核状态统一管理起来const ( AuditStatusDraft int8 0 // 草稿还没有提交审核 AuditStatusPending int8 1 // 待审核已提交等待审核人处理 AuditStatusApproved int8 2 // 审核通过终态 AuditStatusRejected int8 3 // 审核驳回可修改后重新提交 AuditStatusWithdrawn int8 4 // 已撤回提交人主动撤回 )这里要特别注意Go语言里类型强转的问题。GORM从数据库读出来的tinyint如果你映射的字段类型是int8那直接用没问题但如果用int在某些数据库驱动下可能会读到[]byte类型判断时就出错了。我当时就被这个坑了好几个小时排查到最后发现是字段类型映射不一致。3.4 审核核心流程的代码到底怎么写上面设计好了表和状态接下来就是核心代码。审核提交动作看起来简单实际上要把下面几件事在一个数据库事务里一次性做完校验业务数据存在校验业务数据当前状态允许提交审核查询审核配置确定第一级审核人更新业务表状态为待审核同时写入审核流水我贴一下这个核心函数的实现代码用Go GORM写type AuditService struct { db *gorm.DB } // SubmitAudit 提交审核 func (s *AuditService) SubmitAudit(ctx context.Context, bizType string, bizID int64, submitterID int64, submitterName string) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 1. 查询业务数据这里通过一个通用的反射函数获取业务表信息 bizModel, err : getBizModel(bizType) if err ! nil { return err } bizRecord : bizModel.NewInstance() if err : tx.Table(bizModel.TableName()).Where(id ?, bizID).First(bizRecord).Error; err ! nil { return fmt.Errorf(查询业务数据失败: %v, err) } // 2. 检查当前状态是否允许提交 currentStatus : getAuditStatus(bizRecord) if currentStatus ! AuditStatusDraft currentStatus ! AuditStatusRejected { return fmt.Errorf(当前状态不允许提交审核) } // 3. 查询一级审核配置 var firstLevelConfig AuditConfig err tx.Where(biz_type ? AND audit_level 1, bizType).First(firstLevelConfig).Error if errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf(未配置审核流程请在审核配置中设置) } // 4. 构造审核流水 record : AuditRecord{ BizType: bizType, BizID: bizID, AuditLevel: 1, AuditorID: resolveAuditorID(firstLevelConfig, bizRecord), AuditStatusBefore: currentStatus, AuditStatusAfter: AuditStatusPending, } if err : tx.Create(record).Error; err ! nil { return fmt.Errorf(写入审核流水失败: %v, err) } // 5. 更新业务表状态为待审核 if err : tx.Table(bizModel.TableName()). Where(id ?, bizID). Update(audit_status, AuditStatusPending).Error; err ! nil { return fmt.Errorf(更新业务状态失败: %v, err) } return nil }) }resolveAuditorID是处理审核人解析的如果配置是指定用户直接返回指定角色就要查角色下的用户指定部门主管就查部门表里的主管ID。这个函数内部根据auditor_type做分流转。这块逻辑就属于那种“看起来简单写起来要小心”的地方——部门主管的数据结构不同项目差别很大需要自己适配。3.5 通用查询怎么知道某条业务数据当前审核到哪一级了除了提交审核、执行审核日常使用频率最高的其实是查询用户打开列表页要看到每条数据的审核状态点开详情页要看完整的审核历史。审核历史最好办直接用biz_type biz_id查审核流水表按audit_level升序排列就可以了func (s *AuditService) GetAuditHistory(ctx context.Context, bizType string, bizID int64) ([]AuditRecord, error) { var records []AuditRecord err : s.db.WithContext(ctx). Where(biz_type ? AND biz_id ?, bizType, bizID). Order(id ASC). Find(records).Error if err ! nil { return nil, fmt.Errorf(查询审核历史失败: %v, err) } return records, nil }但“当前审核状态”这个查询就得想清楚。我这边业务列表页往往需要显示“待我审核”的数据如果每次都去审核流水表里子查询SQL会很难写而且性能堪忧。我采用的折中方案是业务列表页面直接用业务表的audit_status字段过滤状态而“待我审核”的数据通过一个单独的视图或者缓存接口来查。实际项目中我的做法是给待审核记录建了一个轻量级的查询接口核心逻辑是查审核流水表中auditor_id 当前用户 AND status 待审核的记录然后按照biz_type分别回查业务表补齐业务信息func (s *AuditService) GetPendingAuditList(ctx context.Context, auditorID int64, bizType string, page, pageSize int) ([]map[string]interface{}, int64, error) { query : s.db.WithContext(ctx). Model(AuditRecord{}). Where(auditor_id ? AND audit_status_after ?, auditorID, AuditStatusPending) if bizType ! { query query.Where(biz_type ?, bizType) } var total int64 if err : query.Count(total).Error; err ! nil { return nil, 0, err } var records []AuditRecord if err : query.Order(id DESC).Offset((page - 1) * pageSize).Limit(pageSize).Find(records).Error; err ! nil { return nil, 0, err } // 按业务类型分组逐个回查业务表补齐信息 // 这里涉及到map的嵌套group注意go切片append时的别名坑 result : make([]map[string]interface{}, 0, len(records)) for _, record : range records { item : map[string]interface{}{ biz_type: record.BizType, biz_id: record.BizID, level: record.AuditLevel, create_at: record.CreatedAt, } // 补充业务摘要信息调用方通过bizType注册的摘要函数获取 if summaryFn, ok : getBizSummaryFn(record.BizType); ok { summary, err : summaryFn(ctx, s.db, record.BizID) if err nil { item[biz_summary] summary } } result append(result, item) } return result, total, nil }这种设计有一个点需要提醒getBizSummaryFn是一个业务注册函数每接入一张新表业务方需要提供一个“根据业务ID返回业务摘要”的函数然后注册到全局map里。这样通用审核模块就不需要了解具体业务表的字段结构保持了依赖方向的一致性——业务依赖通用模块而不是通用模块依赖业务。4. 完整实操过程接入一张新业务表到底要几步理论设计看完了接下来是实打实的接入过程。我带大家走一遍完整流程用“文章发布审核”作为例子。4.1 数据库层面的准备文章表本来就存在我先在原有表基础上增加审核字段ALTER TABLE article ADD COLUMN audit_status tinyint(4) NOT NULL DEFAULT 0 COMMENT 审核状态0草稿 1待审核 2通过 3驳回 4撤回, ADD COLUMN submit_time datetime DEFAULT NULL COMMENT 提交审核时间, ADD KEY idx_audit_status (audit_status);然后插入审核配置数据这里我配置的是“编辑提交——主管审核——主编终审”二级审核INSERT INTO audit_config (biz_type, audit_level, auditor_type, auditor_value, need_condition) VALUES (article, 1, 2, 20, ), (article, 2, 2, 21, );auditor_type2表示按角色审核角色ID为20的是内容主管21的是主编。4.2 Go代码层面的接入首先定义文章对应的业务类型常量const ( BizTypeArticle article )初始化阶段注册文章表的元信息和摘要函数func init() { // 注册文章表的审核摘要函数 RegisterBizSummaryFn(BizTypeArticle, func(ctx context.Context, db *gorm.DB, bizID int64) (map[string]interface{}, error) { var article Article if err : db.WithContext(ctx).Where(id ?, bizID).First(article).Error; err ! nil { return nil, err } return map[string]interface{}{ title: article.Title, author: article.Author, category: article.Category, }, nil }) }这步非常关键没有注册摘要函数待审核列表页会拿不到业务摘要。文章提交审核的接口就一行逻辑func (h *ArticleHandler) Submit(c *gin.Context) { id : getParamInt64(c, id) // 这里有个约定提交人ID从登录态拿不能用前端传的 userID, userName : getCurrentUser(c) if err : auditService.SubmitAudit(c.Request.Context(), BizTypeArticle, id, userID, userName); err ! nil { c.JSON(500, gin.H{error: err.Error()}) return } c.JSON(200, gin.H{message: 提交审核成功}) }审核接口也差不多func (h *ArticleHandler) Audit(c *gin.Context) { id : getParamInt64(c, id) userID, userName : getCurrentUser(c) var req AuditRequest if err : c.ShouldBindJSON(req); err ! nil { c.JSON(400, gin.H{error: err.Error()}) return } err : auditService.DoAudit(c.Request.Context(), BizTypeArticle, id, userID, userName, req.Action, req.Comment) if err ! nil { c.JSON(500, gin.H{error: err.Error()}) return } c.JSON(200, gin.H{message: 操作成功}) }到这里一个全新的业务就接入完成了。整个改动量如果忽略准备数据的时间核心代码不超过50行。4.3 接入新表时容易踩的三个“隐形坑”第一个坑是忘记给业务表加索引。我上面SQL里特别写了ADD KEY idx_audit_status别小看这个索引。业务列表页最常用的筛选条件是WHERE audit_status 1表数据量一旦上了十万行没有索引的查询直接全表扫描慢到怀疑人生。第二个坑是业务表主键类型不统一。有些表主键是自增int有些是雪花IDbigint还有些老系统是字符串UUID。审核流水表里的biz_id我用的是bigint如果业务表主键是字符串就得把流水表也改成varchar或者业务层做类型转换。改表结构很简单但老数据迁移就麻烦了所以在一开始定表结构时就要跟团队成员约定好新接入的业务表主键统一用bigint。第三个坑是事务里查了业务数据但忘了加锁。在高并发场景下如果用户疯狂点击“提交审核”按钮可能出现两个请求同时读到audit_status0的状态然后都认为可以提交最终产生两条审核流水。解决办法是在事务里查询业务数据时加上FOR UPDATE行锁bizRecord : bizModel.NewInstance() err : tx.Table(bizModel.TableName()). Clauses(clause.Locking{Strength: UPDATE}). Where(id ?, bizID). First(bizRecord).Error加了这个锁之后同一时间只有一个事务能读到这条业务记录另一个请求会等锁避免了并发提交的脏数据问题。4.4 高并发下的性能优化方案这套通用审核模块上线一段时间后随着业务量增长“待办列表”的查询开始成为性能瓶颈。原因很好理解所有业务类型的待办都集中到一张审核流水表上数据量增长快而且待办查询需要WHERE auditor_id ? AND audit_status_after ?这是两个必然的过滤条件。我做了两个优化效果立竿见影第一个是联合索引。原来有idx_biz(biz_type, biz_id)是给审核历史查询用的待办查询其实用不上这个索引。我额外加了一个idx_auditor_status索引ALTER TABLE audit_record ADD KEY idx_auditor_status (auditor_id, audit_status_after, id);这样待办列表查询就能走索引覆盖不用回表。第二个是待办数量缓存。列表页右上角经常要显示“待办3”这个数字如果每次都实时统计数据量大了会卡。我的做法是用Redis做计数缓存每次产生新的待办时INCR对应审核人的待办计数审核完成后DECR。缓存兜底方案是每五分钟全量重算一次。5. 状态机设计与扩展功能多级审核、加签、转审是怎么实现的通用审核方案的核心不只是几张表更关键的是状态流转的逻辑设计。这一章我详细说下状态机的实现方式以及几个常用扩展功能怎么做。5.1 审核状态机合法的状态流转必须由代码来约束刚才业务表里定义了audit_status的5种状态但不是说五个数字随便跳。我画了一个状态机约束代码层面必须强制校验草稿(0)-待审核(1)只有提交审核动作能触发待审核(1)-审核通过(2)只有最后一级审核通过才能触发待审核(1)-审核驳回(3)任何一级审核都可以触发待审核(1)-已撤回(4)只有提交人或管理员能触发审核驳回(3)-待审核(1)修改后重新提交审核驳回(3)-已撤回(4)提交人撤销审核通过和已撤回是终态不允许再流转回其他状态。这个状态机的约束我是通过一张映射表 配置驱动的var auditStateTransitions map[int8][]int8{ AuditStatusDraft: {AuditStatusPending}, AuditStatusPending: {AuditStatusApproved, AuditStatusRejected, AuditStatusWithdrawn}, AuditStatusRejected: {AuditStatusPending, AuditStatusWithdrawn}, AuditStatusApproved: {}, // 终态 AuditStatusWithdrawn: {}, // 终态 } func canTransition(from, to int8) bool { targets, ok : auditStateTransitions[from] if !ok { return false } for _, t : range targets { if t to { return true } } return false }每次执行审核操作之前先检查当前状态和目标状态是否允许流转。比如用户想对一条已经“审核通过”的数据再执行“驳回”操作canTransition(2, 3)返回false直接拒绝。这比单纯靠代码逻辑里的层层if判断要可靠得多把所有流转规则集中在一张表里一目了然。5.2 多级审核的核心逻辑谁通过了才真正通过多级审核是这套方案里最核心也是最容易出错的地方。我来说下核心逻辑。audit_status状态是“整体状态”它记录的是这条数据整体现在处于什么阶段。但每一级的审核结果是独立的这是多级审核实现的关键。举个例子采购单过了第一级审核后整体状态依然是“待审核”因为还有第二级没审完。只有最后一级审核通过后整体状态才能变成“审核通过”。这个逻辑在DoAudit函数里实现func (s *AuditService) DoAudit(ctx context.Context, bizType string, bizID int64, auditorID int64, auditorName string, action Action, comment string) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // ...查询业务数据和当前待审记录... var currentRecord AuditRecord if err : tx.Where(biz_type ? AND biz_id ? AND audit_status_after ?, bizType, bizID, AuditStatusPending). Order(id ASC). First(currentRecord).Error; err ! nil { return fmt.Errorf(未找到待审核记录) } // 校验当前操作人是否是该级审核人 if currentRecord.AuditorID ! auditorID { // 这里要不要加管理员权限判断根据业务需求来 return fmt.Errorf(当前用户不是该级审核人) } // 查审核配置判断当前是第几级、还有没有下一级 var configs []AuditConfig if err : tx.Where(biz_type ?, bizType).Order(audit_level ASC).Find(configs).Error; err ! nil { return err } currentLevel : currentRecord.AuditLevel var nextLevelConfig *AuditConfig for i : range configs { if configs[i].AuditLevel currentLevel1 { nextLevelConfig configs[i] break } } // 更新当前审核记录的审核结果 newBusinessStatus : AuditStatusPending if nextLevelConfig nil { // 没有下一级了当前审核就是最后一环业务状态变为审核通过 newBusinessStatus AuditStatusApproved } if err : tx.Model(currentRecord).Updates(map[string]interface{}{ audit_action: action, audit_comment: comment, audit_status_before: currentRecord.AuditStatusBefore, audit_status_after: newBusinessStatus, }).Error; err ! nil { return err } // 更新业务状态 if err : tx.Table(bizModel.TableName()). Where(id ?, bizID). Update(audit_status, newBusinessStatus).Error; err ! nil { return err } // 如果下一级存在且当前审核通过需要创建下一级的待审记录 if action ActionApproved nextLevelConfig ! nil { nextAuditorID : resolveAuditorID(*nextLevelConfig, bizRecord) nextRecord : AuditRecord{ BizType: bizType, BizID: bizID, AuditLevel: nextLevelConfig.AuditLevel, AuditorID: nextAuditorID, AuditStatusBefore: AuditStatusPending, AuditStatusAfter: AuditStatusPending, } if err : tx.Create(nextRecord).Error; err ! nil { return err } } return nil }) }这里核心逻辑是当前审核通过 存在下一级 业务状态不变创建下一级待审记录当前审核通过 不存在下一级 业务状态变为审核通过。驳回和撤回的处理逻辑类似只是不管存不存在下一级业务状态都直接变为驳回或撤回。5.3 加签和转审不要改表结构用“借道流转”的思路扩展业务需求总是会超出最简单的设计。加签当前审核人觉得需要多一个人一起审和转审把审核任务转移给别人是上线后经常被提的需求。我最初的设计里没有这两个能力后来发现如果为加签和转审单独建表会破坏整个通用审核模块的简洁性。我的方案是给审核流水表增加一个parent_id字段加签产生的子任务记录通过parent_id指向原审核记录。加签的流程是当前审核人对某条待审记录执行“加签”操作系统生成一条新的审核记录parent_id指向原记录audit_level相同auditor_id是被加签人被加签人审核通过后原记录的审核人需要再次确认或者根据配置自动确认原记录确认后系统才真正产生这次审核的结果转审更简单就是把原记录里的auditor_id直接替换成转审对象同时在audit_comment里记录转审事由。从数据模型角度转审不产生新记录只是修改待完成任务的归属人。这两个功能的设计我回头单独写一篇详细讲本章先提一下设计思路方便大家在接入时心里有个底。6. 找回丢失的数据一致性事务边界与并发控制的再思考开发过程中我还遇到过一个特别头疼的问题明明所有写操作都在事务里审核完的数据偶尔会出现审核流水和业务状态对不上的情况。6.1 查了一个下午最后发现是“事务还没提交缓存已经更新了”现象是这样的用户提交审核后在列表页马上看到状态变成了“待审核”——看起来正常。但过一会儿刷新状态又变回“草稿”了。查代码发现初次提交审核成功返回后接口把业务状态更新到了Redis缓存里而虽然数据库事务提交成功了可Redis的更新和数据库的更新不在同一个原子操作里。这其实是一个典型的缓存与数据库不一致问题。我当时的解决思路是先更新数据库再删除缓存。也就是说提交审核接口里数据库事务提交成功后不直接写缓存而是把Redis里对应的业务数据缓存删除掉等下次查询时再回源数据库重建缓存。这样即使删除缓存失败最多是读到一次旧缓存不会造成数据库和缓存长期不一致。这个经验虽然跟审核模块本身关系不大但审核类功能出问题的概率比其他普通数据要高因为审核状态变更频繁缓存命中率本来就低缓存穿透的几率也大。6.2 分布式部署时怎么保证“同一时间只有一个人能审”如果服务是单机部署事务加行锁就够了。但很多项目是分布式部署多个实例同时跑这时候单纯靠数据库行锁依然有效但会出现锁等待超时的问题。我当时的处理是加了一道Redis分布式锁审核操作开始执行前先获取audit:lock:{biz_type}:{biz_id}这把锁拿到锁才允许执行审核逻辑执行完成后释放锁。这样即使在极端情况下一级审核节点超时也能保证不会出现重复审核。不过要提醒大家的是Go操作Redis的库有好几个如果是用go-redis的话分布式锁可以直接参考github.com/go-redsync/redsync这个库的实现比自己在Lua脚本里拼SETNX要稳当特别是续期和释放时的原子性处理自己写很容易出bug。我自己第一版是用手写的Lua脚本后来同事在压测时发现会有锁误释放的场景换成redsync之后就没再出问题。6.3 用SQL打印排查审核状态异常接入过程中我最推荐大家做的一件事是开发环境把GORM执行的SQL全部打印出来。尤其审核这种涉及多张表更新的事务操作日志里看不到SQL排查问题基本靠猜。GORM打印SQL的方式很简单// 全局开启日志 db.Logger logger.Default.LogMode(logger.Info) // 或者只针对某条操作开启 db.Debug().Where(biz_type ?, article).Find(records)我这边的经验是审核接口的模式比较固定不需要全开Info日志会刷屏。我通常只对包含审核、提交、撤回这几个写操作的方法单独在事务里每行打印关键步骤日志。比如日志格式[audit] submit article id123, submitter456, new_statuspending [audit] audit article id123, level1, auditor789, actionapproved, next_level2 [audit] audit article id123, level2, auditor101, actionapproved, final_statusapproved这种日志对线上问题复盘非常有价值比打印一堆SQL再自己去拼状态演化过程要直观得多。如果是用sqlx而不是GORM那就更直接了sqlx本身就是SQL原声操作每一条SQL都是手写的打印SQL只需要在封装层加个日志函数。好处是全SQL可控坏处是自己要写很多模板代码。我的建议是项目里如果已经有GORM依赖直接用GORM的日志能力别为了这个切换ORM。7. 真实项目里踩过的坑与排查经验总结最后这部分我把这套方案上线大半年时间里遇到过的典型问题整理成一份速查表包含定位思路和解决方案希望能帮大家少走一些弯路。7.1 问题速查表问题现象可能原因排查思路与解决方案提交审核后状态没变事务没提交成功或者提交逻辑里状态更新被条件过滤了开启GORM Info日志查看UPDATE SQL是否执行成功检查更新条件里是否有audit_status 0之类的判断而这条数据的实际状态不是0待办列表能看到记录但点进去提示“无权限审核”审核记录里的auditor_id和登录用户的ID对不上检查解析审核人的逻辑重点看按角色解析时有没有取错角色的情况另外要留意同一用户在多部门下的归属策略二级审核时二级审核人列表查不到该条待办生成二级待办记录的时机不对核心检查DoAudit函数里是否在“当前级通过且存在下一级”的情况下创建了下一条待办确认新建的待办里auditor_id正确审核通过后业务数据状态仍是待审核下一级待办创建失败或者状态更新逻辑写错查事务里有没有因为校验失败提前return导致更新业务状态那步没执行注意事务里return了error之后整个事务会回滚所以要看日志里原始错误是什么并发提交审核产生了多条审核流水缺少行锁或分布式锁在事务里对业务数据加FOR UPDATE锁分布式场景加Redis锁同时在(biz_type, biz_id, audit_status_after)上建唯一索引兜底审核历史里审核人与审核意见对不上更新审核记录时用的是Model(currentRecord).Updates()如果不小心把整条记录覆盖了用Updates(map[string]interface{})只更新指定字段不要用Save()全量保存Update前再查询一次当前记录确认未变条件审核不生效该跳过的级别还在审条件表达式格式问题或者bizData里的字段名对不上调试时在条件判断入口加日志把needCondition和bizData都打出来注意govaluate的字段名是大小写敏感的审核记录表数据膨胀太快没有数据归档策略配置定期归档任务把终态数据审核通过/已撤回迁移到历史表保留近3个月的记录在线可查更早的归档到audit_record_history表7.2 上线前后必做的三件“小事”第一件事给审核流水表的biz_type和biz_id加联合索引。这条我在设计表结构时写进去了之所以再强调一次是因为很多人建表时嫌麻烦等线上数据量大了再补索引期间会面临锁表风险。建表时就加好后面省心。第二件事给所有审核操作加上操作审计日志。我指的是除了审核流水表之外应用层的操作日志也要有——谁在什么时间调用了审核接口、传了什么参数、返回了什么结果。审核流水表主要记录业务数据的变化而操作日志是为了排查“有人恶意刷接口”或者“代码bug导致重复审核”这类问题。用Go标准库的log/slog或者zap打一条结构化日志就够了关键是调料要全用户ID、接口名、请求参数、耗时、错误信息。第三件事给审核配置表加一个版本号或生效时间的字段。审核配置是会被业务人员调整的比如更换审核人一旦配置变了正在审核流程中的数据怎么处理我的方案是配置表加一个effective_time字段待办创建时读取的是当时生效的配置后续配置变更不影响已生成的待办记录。这样既能满足业务调整需求也不会把正在跑的流程搞乱。7.3 个人心得与后续演进方向整套通用审核方案从设计到现在跑了大半年最大的心得是在做通用方案之前一定要先想清楚哪些是可变的哪些是不变的。不可变的是审核的基本流程模型提交、审核、通过/驳回、多级流转可变的是不同业务对审核层级、审核人、条件触发的差异化需求。把不可变的部分做成通用模块把可变的部分用配置化和注册化开放出去这样才是名副其实的“通用实现”而不是把所有需求都写在代码里然后自欺欺人地叫“通用”。后续演进方向上有几个值得探索的点接入消息通知审核状态变化时推送给相关人这块可以做成适配器模式对接企业微信、钉钉、飞书增加超时提醒和自动处理策略比如一级审核48小时未处理自动升级给上级这块我目前还没做但已经有业务方提需求了基于审核数据分析审核时效通过审核流水表里created_at和业务表里的submit_time可以统计每个环节的平均耗时这对流程优化很有价值把audit_config从每张表一张配置升级为按条件路由的规则引擎比如不同金额区间匹配不同的审核链路最后说回开始那个问题到底怎么实现“一个通用表的审核通用实现”答案不是在代码层面堆一堆if判断而是把审核从具体业务中剥离出来用独立的表、独立的状态机、独立的服务去承载它。业务只需要做好“注册”和“接入”剩下的交给通用模块。如果你手头也在被一堆审核需求折磨不妨试试这个思路大概率能帮你省下大量重复劳动。