GoWind CMS 拆解:发布审批——多级推进、逐级角色与全程留痕

GoWind CMS 拆解:发布审批------多级推进、逐级角色与全程留痕

本文回答一个问题:一篇文章从「提交审核」到「正式发布」,审批流怎么推进(第几级了)、谁有权批(每级什么角色)、每一次动作如何留痕(谁在何时批了什么)、以及怎么保证不被 Update 接口绕过去?

GoWind CMS 的发布审批是内容域的最后一道闸:文章提交后进入审核中,审批人逐级推进(级数与每级角色都是租户可配的),末级通过才置 PUBLISHED;每一次 SUBMIT/APPROVE/REJECT 都落 sys_review_logs 表留痕,审核中的内容被 Update 接口封死了直改状态的后门。页面(page)走同款语义。这篇按审批流的四个组成部分拆:状态机、推进、授权、留痕与闸门。


一、状态机:一把 in_progress 旗子 + 三个 RPC

审批流的骨架极简。文章表上只有一把 in_progress 旗子,加上 status 枚举,构成全部状态空间:

ini 复制代码
DRAFT(in_progress=false)
   │ SubmitReview(置 in_progress=true,开新批次)
   ▼
审核中(in_progress=true)
   │ ApproveReview × N-1(中间级:保持审核中,通知进度)
   │ ApproveReview 第 N 级(末级:置 PUBLISHED + in_progress=false)
   ▼
PUBLISHED

审核中 ── RejectReview(带理由,in_progress=false)──► DRAFT

三个 RPC 对应三个动作:SubmitReview(校验:非 DRAFT 400、已在审 400------重复提交直接拒绝)、ApproveReview(推进或发布)、RejectReview(驳回回 DRAFT,理由必填进留痕)。in_progress 这把旗子的职责被刻意收窄:它只表达「有一场未完结的审批在进行」,不是发布状态------发布与否由 status 说话,两者正交。这个收窄直接决定了后面闸门的判定条件(第六节)。


二、留痕先行:sys_review_logs

每一次审批动作都落独立表(ent schema review_log.go):

go 复制代码
field.String("entity_type").Comment("实体类型(post/page)").NotEmpty().MaxLen(16),
field.Int64("entity_id").Comment("实体 ID"),
field.Enum("action").Comment("动作").NamedValues(
    "Submit", "SUBMIT", "Approve", "APPROVE", "Reject", "REJECT",
).Default("SUBMIT"),
field.Uint32("level").Comment("审批级别(submit=0;第 N 次 approve=N)"),
field.String("note").Comment("意见(驳回理由/批准备注)").Optional().Nillable().MaxLen(512),

五个业务字段之外,Mixin 带上了 TimeAt + AutoIncrementId64 + TenantID[int64] + OperatorID64------操作人和租户落在 Mixin 里 ,(entity_type, entity_id) 上建联合索引支撑「查一篇文章的全部审批史」。

这张表有个不显眼但重要的语义约定:批次由 in_progress 的翻转定义 。schema 注释原文:「in_progress false→true 即开新批次」------level 的语义是「本批次内的第几级」,而不是「这篇文章历史上第几次 approve」。驳回后重新提交会开新批次,level 从头计。没有这个约定,「当前在第几级」就无法从日志推导。

写入侧有个小而正的设计:仓储层不自己从 context 里取操作人,而是要求调用方显式传入(repo 注释原话「operatorID/tenantID 由调用方显式传入------写入方在服务层从 viewer 取」)。服务层的 Submit 处:

go 复制代码
// 审批留痕:开启新批次(level=0)
if s.reviewLogRepo != nil {
    if operatorID, tenantID, ok := operatorTenantUserID(ctx); ok {
        _ = s.reviewLogRepo.Create(ctx, "post", req.GetPostId(), "SUBMIT", 0, "", operatorID, tenantID)
    }
}

operatorTenantUserID 是从 viewer.FromContext(ctx) 显式取值的 helper。留痕失败只吞掉不阻断主流程------审批是主业务,日志是旁路,旁路不能把主路堵死。


三、多级推进:按「本批次最大 APPROVE level」计数

核心推进逻辑在 ApproveReview(post_service.go):

go 复制代码
// ===== 多级审批推进:当前级别 +1;达到配置级数才置 PUBLISHED =====
totalLevels := s.reviewPolicy.Levels(ctx, tenantID)
currentLevel, err := s.reviewLogRepo.CurrentLevel(ctx, "post", req.GetPostId())
// ......
nextLevel := currentLevel + 1
if err = s.reviewPolicy.CheckApprover(ctx, tenantID, operatorID, int(nextLevel)); err != nil {
    return nil, err
}

finalApproval := int(nextLevel) >= totalLevels
if !finalApproval {
    // 中间级:保持审核中,落留痕并通知作者当前进度
    // ......只更新 in_progress=true,落 APPROVE 留痕,通知进度
    return updated, nil
}
// 末级:置 PUBLISHED + in_progress=false + publish_time 补缺
// ......落 APPROVE 留痕 + ES 重索引 / Webhook published 发布钩子 + 通知作者

两个实现细节值得展开:

「当前级别」为什么用 MAX 而不用 COUNT? CurrentLevel 的实现(review_log_repo.go)是按 entity_type + entity_id + action=APPROVE 查全部留痕行取 level 最大值,无记录返回 0。因为 level 是「本批次第 N 级」,COUNT 在重复审批(同一级批了两次,比如撤销重来)时会虚高,MAX 则天然幂等------进度是「到达过的最高处」,不是「签过的总次数」。这个语义同时决定了重试安全:ApproveReview 重放时推进结果不变。

配置缺省 = 单级。 级数来自系统参数 review.post.levels,读取处钳位 1..8、默认 1:

go 复制代码
// Levels 发布审批级数(钳位 1..8;配置缺失/非法回落 1=单级)。
n := p.configRepo.GetConfigInt(ctx, "review.post.levels", 1)
if n < 1 { n = 1 }
if n > 8 { n = 8 }

一级批完即发;多级是租户按需配置的能力。钳位上限 8 是务实的选择:现实中没人需要九级审批,而无限级数会让「当前级」的语义在 UI 上无法呈现。

末级通过后的发布钩子(ES 重索引、Webhook published 事件、站内信通知作者)是标准的发布链路------多级只改变「何时算发布」,不改变「发布做什么」。Page 侧同款语义独立实现(页面无 ES,只派发 Webhook)。


四、角色闸门:levelRoles 的逗号与竖线

级数解决了「走到第几步」,每步谁来批由另一个系统参数管:review.post.levelRoles。它的编码格式在注释里写得直白------逗号分隔按级对应,竖线分隔同级多角色:

复制代码
editor,chief_editor|publisher,admin
  → 第 1 级:editor
  → 第 2 级:chief_editor 或 publisher(同级多角色)
  → 第 3 级:admin

解析实现(review_policy.go)里藏着一个体贴的规则:

go 复制代码
parts := strings.Split(raw, ",")
// 逗号列表按级对应:超出配置长度的级别沿用最后一级的角色
idx := level - 1
if idx < 0 { idx = 0 }
if idx >= len(parts) { idx = len(parts) - 1 }

配置了三级角色却把 levels 调到五级时,第 4、5 级沿用最后配置段------参数之间允许不严格对齐,系统取最近似语义 ,而不是报错或放行所有人。校验时大小写不敏感(EqualFold),未命中返回 403(ErrorForbidden("insufficient review role for this level")),并带 Warn 日志把 have/want 都打出来。

角色码的解析器(查用户角色列表)由 wiring 注入而非 review 包自己 import 身份域------注释明说是为了避免循环依赖。一个小依赖倒置,换来 review 包对身份域零耦合。


五、封绕过口:审批闸门

推进和授权都做完了,还有个致命的口子要堵:审核中的文章,作者可以直接调 Update 接口把 status 改成 PUBLISHED ------审批流形同虚设。闸门堵在 Update 链路上(post_service.go):

go 复制代码
// 审批闸门:审核中的文章不允许经 Update 直改状态(绕过审批的口子)。
// 改状态须先驳回(回 DRAFT)或走审批通过;in_progress 之外的纯编辑不受限。
if existing.GetInProgress() && req.Data.GetStatus() != prevStatus {
    return nil, contentV1.ErrorBadRequest(
        "post is under review: status changes must go through the review flow (approve/reject)")
}

三个细节:

闸门设在 Update 的回读处。 Update 本来就要回读旧状态(记录 prevStatus 供 Webhook 判定是否为「发布」转移)------闸门条件 in_progress && status 变了 顺手就设在了这个已有回读之后,没有为校验增加额外的查询。好闸门长在必经之路上,不新开收费站。

只拦状态变更,不拦编辑。 in_progress 期间作者依然可以改标题改正文(纯编辑不受限)------审核看的本来就是内容,编辑内容不该锁死;要拦的只有「跳过审批自己把状态改成已发布」这一种越权。闸门的粒度越细,正常用户的感知越少。

改状态的合法路径只有两条:驳回(回 DRAFT 重新编辑)或逐级批到末级(自动发布)。错误信息把这两条路都写明了------拦下来之后告诉用户怎么办,比拦下来就完事重要。


六、查询端点与前端

留痕查出来了要有出口:ReviewLogService 提供 /admin/v1/review-logs 查询端点(要求 entity_type + entity_id 必填,按 id 正序返回------即时间正序的审批史)。admin BFF 直透 core。

前端侧,审批动作挂进了内容列表页:post/page 两处操作列有提交/通过/驳回(hooks 层 useSubmitPostReview / useApprovePostReview / useRejectPostReview),进度通过站内信通知作者(中间级每批一次通知当前进度,驳回带理由)。审批操作列所在的帖子列表页与编辑页:

诚实交代一处:review-logs 的专用查询 UI 还没建------端点有了、数据有了,但管理端还没有一个「审批历史」页面。这是典型的「后端先行」留白:先保证留痕不可抵赖(数据在),UI 什么时候补都不影响审计能力。

回归覆盖分两层:多级审批 E2E 10 断言(单级回归、两级逐级推进、留痕内容校验、闸门 400、驳回理由落痕、页面同款流程)+ 旧审批流 E2E 8/8 回归。其中「单级回归」这个用例值得单独点名:默认配置下的旧行为一致性,是这类「可配置增强」功能的生命线。


七、收尾

审批流的四个组成部分,各自的解法落点:

组成部分 性质 解法落点
推进(走到第几级) 状态机问题 level 按批次 MAX 幂等推进,配置缺省单级
授权(谁批哪一级) 参数编码问题 levelRoles 逗号按级、竖线同级,越界沿用最后段
留痕(谁批了什么) 审计问题 sys_review_logs 独立表,批次语义钉死,operator 显式取
防绕过(不被 Update 穿透) 入口控制问题 Update 必经回读处设闸,只拦状态变更

四个问题四种性质、四个位置、四套薄实现------没有出现一个「审批引擎」把它们搅在一起。in_progress 一把旗子撑起了状态机,日志表撑起了审计,一行闸门条件撑起了入口控制。

留痕这件事还有一层前瞻价值:sys_review_logs 的 (entity, action, level, operator, note) 结构已经把审批流建模成了可回放的事件序列 。将来要加代签、转办、SLA 超时提醒、按角色统计审批负载------全部是在这张表和这个序列上做增量,不动状态机。审计先行,扩展才有地基。

仓库(本文所有代码路径均可逐行核对):

相关推荐
喵个咪20 分钟前
GoWind Flow 拆解:一个 Go 工作流引擎与它的双模式设计器
前端·后端·go
喵个咪23 分钟前
GoWind CMS 拆解:搜索三板斧——高亮、联想、同义词
后端·搜索引擎·go
喵个咪26 分钟前
GoWind 拆解:AI Agent 编排引擎——波次并行、子图递归与人工审批
人工智能·后端·go
喵个咪40 分钟前
GoWind Admin 拆解:打印模板设计器——从画布到服务端 PDF 的完整打印链路
前端·后端·go
总要学点什么1 小时前
第96篇:三维数据加载-城市建筑白模样式+着色器——让色带按体量上色,最白的那档却涂不上去
前端·gis·webgl·cesium·着色器·空间分析·白模
喵个咪1 小时前
GoWind Admin 拆解:BI 大屏设计器——schema 即 JSON、resolver 注入与三张快照表的口径谱系
大数据·后端·go
喵个咪1 小时前
GoWind Admin 拆解:AI 能力层——工具循环、pgvector 知识库与机器凭证
人工智能·后端·agent
Hilaku2 小时前
你们公司有 35 岁以上的前端吗?
前端·javascript·程序员
泡海椒2 小时前
jquick-pdf 柱状图(JBar)实战:Java 生成政务项目审批数量趋势 PDF
后端