外观
审批平台首期 — 技术方案
对应 PRD 原文,总 PRD 见 lamolabs-docs#54,总追踪 lamolabs-docs#86。本文档定义"怎么做";"做什么"以迭代 PRD 为准。 状态:草案(待评审)
沿用 0913 / 0920 确立的真原子化原则:单服务单库、本地 ACID 事务、状态单一事实源。审批平台为独立服务
nexo-approval-api(单库nexo_approval),不与nexo_perm合库——权限是"谁能做什么"的常态判定,审批是"这一次能不能做"的流程实例,两者变更节奏与数据生命周期完全不同。 本文 §7 数据模型、§10 接口契约、§11 模板 JSON 契约是前后端并行开发的冻结源:字段名、错误码、请求响应结构以本文为准,改动先改本文再改代码。
1. 选型现实
与 0913 / 0920 相同的四条现实约束继续成立:单机 Docker Compose 双环境(test/prod)、无 Kafka/无 Redis/无对象存储、无独立 API 网关(各服务本地验签 + HMAC 内部通道)、小团队短窗口(09-22 至 09-26 共 5 天,09-27 验收)。
由此决定:
- 技术栈对齐生态:Node.js (Hono +
@hono/zod-openapi) + PostgreSQL 16 + drizzle-orm,复用@lamolabs/nexo-backend-sdk(日志脱敏、错误响应、HMAC 服务间鉴权、JWKS 本地验签、鉴权客户端);工程结构遵循后端工程规范模块四件套,与nexo-perm-api逐项对齐 - 无对象存储 → 表单无附件:全仓检索确认生态内无 OSS/S3/MinIO/upload 实现,
nexo-im-api消息类型只有'text'。表单字段类型收敛为六种,业务上下文靠resource_url外跳(PRD §4.1) - 无通知通道 → 门户待办即通知:
nexo-im-api无系统/机器人消息推送 internal 接口,本期不造。待办列表 + 未处理计数是审批人感知新单的唯一入口(PRD §4.2) - 无定时基础设施 → 超时只存不驱动:
timeout_config本期只做 schema 校验与存储,不起扫描器。auto_approved/auto_rejected保留枚举位但无代码路径产生
2. 服务形态与部署
nexo-approval-api:独立容器、独立库nexo_approval,端口 3003(3000nexo-im-api/ 3001nexo-account/ 3002nexo-perm-api);环境变量前缀APPROVAL_nexo-approval-console:静态站,构建产物走生态既有发布通道,经壳(nexo-app)iframe 装载,nexo-web-sdk取令牌- 三类调用方与通道:
| 调用方 | 通道 | 身份 | 用途 |
|---|---|---|---|
| 业务平台服务 | /internal/*,HMAC 内部通道 | app 主体(HMAC keyId) | 创建审批单、查状态、取消 |
| 审批门户(浏览器) | /api/*,Bearer JWT(JWKS 本地验签) | user(sub → 用户标识) | 提交、审批、列表、模板管理 |
审批平台 → nexo-perm-api | /internal/check、角色成员反查,HMAC | app 主体 approval | 接口鉴权、角色审批人解析 |
3. 配置清单
| 环境变量 | 默认值 | 说明 |
|---|---|---|
APPROVAL_PORT | 3003 | 服务端口 |
APPROVAL_DATABASE_URL | — | PostgreSQL 连接串(必填) |
APPROVAL_PORTAL_BASE_URL | — | 审批门户站点地址(必填,用于拼详情页 URL) |
APPROVAL_CORS_ALLOWED_ORIGINS | — | 门户来源白名单,逗号分隔(必填;门户是浏览器直连本服务,漏配即全部请求被 CORS 拦下)。对齐 PERM_CORS_ALLOWED_ORIGINS |
APPROVAL_CORS_ALLOWED_ORIGIN_PATTERNS | '' | 来源通配,供 PR 预览域名(*.nexo-approval-console-test.pages.dev)使用,对齐 PERM_CORS_ALLOWED_ORIGIN_PATTERNS |
APPROVAL_PERM_BASE_URL | — | nexo-perm-api 地址(必填) |
APPROVAL_PERM_APP_ID / APPROVAL_PERM_HMAC_SECRET | approval / — | 调 perm-api internal 的凭据(APP_ID 是身份标识,密钥必填)。注意不是 HMAC_KEY_ID:SDK 的 HMAC 原语没有 keyId(待签串只有 METHOD\npath\ntimestamp\nnonce\nsha256(body)),身份由「哪把密钥验签」承载,perm-api 侧读 X-Nexo-App 头取 app 主体标识 |
APPROVAL_PERM_CHECK_TIMEOUT_MS | 2000 | 鉴权判定调用超时,超时即 fail-closed |
APPROVAL_PERM_MEMBERS_TIMEOUT_MS | 3000 | 角色成员反查调用超时。比判定略宽:它在提交事务的前置阶段被调用,一次要展开同一审批单内所有节点的角色 |
APPROVAL_MAX_NODES_PER_ORDER | 20 | 单审批单节点数上限,路径遍历防御性截断即报错 |
APPROVAL_MAX_APPROVERS_PER_NODE | 20 | 单节点审批人数上限(角色展开后校验) |
APPROVAL_LIST_PAGE_SIZE_MAX | 100 | 列表分页上限 |
APPROVAL_ALERT_WEBHOOK_URL | — | 告警投递地址(perm-api 不可达、审计写失败) |
APPROVAL_INTERNAL_APP_SECRETS | — | /internal/* 的入站共享密钥表(JSON:{"<业务方 app 标识>":"<密钥>"},各值 ≥32 字符)。审批平台是业务方的被调用方,与 perm-api 的 PERM_INTERNAL_APP_SECRETS 同构——两侧必须同值 |
APPROVAL_AUTH_BASE_URL | — | 认证中心地址,拉 JWKS 本地验签门户令牌(必填) |
APPROVAL_AUTH_ACCESS_TOKEN_AUDIENCE | — | 门户令牌受众,必须与认证中心签发时的取值逐字一致(必填) |
APPROVAL_AUTH_INTERNAL_SHARED_SECRET | — | 调认证中心 GET /internal/users 取显示名(§10.4)的 HMAC 共享密钥,≥32 字符(必填)。必须与对应环境 nexo-auth 的 AUTH_INTERNAL_SHARED_SECRET 同值;认证中心地址复用 APPROVAL_AUTH_BASE_URL。取名失败只降级为 null,但密钥漏配会让名字恒为 null,故启动期即校验(lamolabs-docs#95 ①) |
APPROVAL_CORS_ALLOWED_ORIGINS | — | 逗号分隔的门户来源白名单,精确匹配(必填) |
APPROVAL_CORS_ALLOWED_ORIGIN_PATTERNS | — | 仅测试环境配 PR 预览域名模式,生产留空 |
内联模板不引入额外配置——它与持久化模板共用同一套校验与装配参数。
4. 领域不变量(全部由 DB 层兜底,应用层先校验)
- append-only:
approval_actions/audit_logs挂BEFORE UPDATE OR DELETE触发器RAISE EXCEPTION 'APPEND_ONLY_VIOLATION'——不是"代码里没写 update 路径",是写了也会被库拒(§7.3) - 操作幂等:主路径在应用层守卫——本人在该节点的状态已等于本次操作的目标态即判为重放,直接返回当前详情、不写任何记录(§9.2)。
UNIQUE (node_id, actor, action) WHERE action IN ('approve','reject')部分唯一索引是纵深防御:万一有路径绕过整单锁,重复决策也会被库拒 - 模板快照不可变:
approval_orders.template_snapshot挂触发器拒改——审批单一经创建,其模板视图冻结,上游模板怎么改都不影响 - 节点顺序唯一:
UNIQUE (order_id, seq)与UNIQUE (order_id, node_key)——条件求值产出的是线性路径,同一节点不得出现两次 - 审批人单行:
UNIQUE (node_id, approver_type, approver_id)——角色展开后去重由唯一键兜底,不靠应用层 dedupe - 资源类型唯一:
UNIQUE (type_key) - 终态不可退:审批单与节点的状态流转方向由 service 层守卫 + 状态列
CHECK约束枚举,终态行的任何状态写入一律先SELECT ... FOR UPDATE复核
5. 架构分层
src/
├── routes/ # api/(门户接口)+ internal/(业务方通道)
├── services/ # 仅跨模块基础设施:db、audit、perm-client(check + 角色反查)
├── modules/
│ ├── template/ # 模板 CRUD(B02)
│ ├── template-schema/# 配置校验器 —— 纯函数,无 DB 无 ctx(B02,内联路径复用)
│ ├── resource-type/ # 资源类型注册(B03)
│ ├── order/ # 装配管线、提交、查询(B04/B05/B12)
│ ├── slot/ # 槽位解析 —— 纯函数(B05)
│ ├── flow/ # 条件求值 + 节点实例化 —— 纯函数(B06)
│ ├── approver/ # 角色展开与快照(B07)
│ ├── decision/ # 判定引擎 —— 纯函数,无 DB(B08)
│ └── action/ # 操作与状态机(B09/B10)
└── env.ts # zod 校验(§3 全部变量)四个纯函数模块(template-schema / slot / flow / decision)是本期的核心资产:不依赖 DB 与请求上下文,输入输出全是普通对象。这样三路发起共用一套装配逻辑、判定规则可被单测穷举、前端 mock 能直接复用同一组样例。
6. 与 0913 / 0920 架构原则的对齐与差异
| 既有原则 | 本期对齐情况 |
|---|---|
| 单服务单库真原子 | ✅ nexo-approval-api + nexo_approval,提交与审批全程本地单事务 |
| 状态单一事实源 | ✅ 用户/角色事实源仍在 nexo-account / nexo-perm-api;审批平台持有的是节点审批人快照,实例化后不回查 |
| 权限平台未上线前不鉴权 | 已不适用——平台已上线,本服务建仓即接(B13),不新欠一笔;存量两家同期还债(B15/B16) |
| 无 MQ,HTTP + 兜底等价落地 | 本期无异步投递需求(不做通知),仅审计本地留存,无 outbox |
| 业务方隔离 | 部分对齐:namespace 字段本期建全并从调用方身份推导落库,按 namespace 的列表过滤行为下期开(PRD §4.6) |
7. 数据模型(单库 nexo_approval)
7.1 关键设计决策
| 决策 | 选择 | 理由 |
|---|---|---|
| 模板与审批单的绑定 | 三路统一存 template_snapshot(NOT NULL),template_id / resource_type 降级为溯源字段 | 总 PRD 数据模型只给内联路径留了 template_config,模板路径靠 template_id 引用。但模板可编辑——引用式会让已创建审批单的表单渲染跟着漂移,而节点已是快照,两边对不上。统一快照后:① 模板编辑零影响;② 门禁 #1「三路逐字段比对一致」变成 template_snapshot 直接相等,可机械验证;③ 装配管线取到模板后不再有任何 source_type 分支 |
| 节点内审批人 | 拆独立表 approval_node_approvers,API 仍按总 PRD 的 JSON 数组形状返回 | 总 PRD 的 approvers JSON 是逻辑形状。物理上拆表换来四件事:待办查询走 B-tree 点查而非 JSONB 扫描(首屏 < 2s 的前提)、单人状态更新是行更新而非整块 JSONB 重写、并发操作可行级加锁、去重与幂等由唯一键兜底 |
| 节点序列 | 条件求值后物化为线性 seq | flow_config 的 next 单出边 + B02 保证无环 ⇒ 遍历结果必然是线性路径。存 seq 后"下一节点"是 seq+1 点查,无需运行时重走图 |
| 槽位解析结果 | 存 resolved_slots(只读留档) | 排障与审计需要"当时解析成了什么",但判定不读它——审批人已进 approval_node_approvers,表单值已进 form_data |
| 超时配置 | 只存不驱动 | 本期无扫描器(§1.4)。字段与校验先落,下期接扫描器零迁移 |
7.2 SQL DDL
sql
-- 1. 审批模板:四份 JSON 配置,校验通过才写入
CREATE TABLE approval_templates (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
namespace TEXT NOT NULL, -- 业务方标识,对齐 nexo-perm-api 命名空间名
form_schema JSONB NOT NULL,
flow_config JSONB NOT NULL,
timeout_config JSONB NOT NULL DEFAULT '{"levels":[{"after_hours":48,"action":"auto_reject"}]}'::jsonb,
defaults JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (namespace, name)
);
-- 2. 资源类型:业务方接入的推荐入口
CREATE TABLE resource_types (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
type_key TEXT NOT NULL UNIQUE, -- 如 release / grant_request
template_id BIGINT NOT NULL REFERENCES approval_templates(id),
owner TEXT NOT NULL, -- 注册方业务方标识
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 3. 审批单:template_snapshot 是三路统一的事实源,一经写入不可改
CREATE TABLE approval_orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
namespace TEXT NOT NULL, -- 从调用方 HMAC 身份推导,覆盖自报值
source_type TEXT NOT NULL CHECK (source_type IN ('resource_type','template_id','inline')),
template_id BIGINT NULL REFERENCES approval_templates(id), -- 溯源,inline 时 NULL
resource_type TEXT NULL, -- 溯源,方式二/三时 NULL
template_snapshot JSONB NOT NULL, -- 四份配置的冻结快照(三路统一)
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','in_progress','approved','rejected',
'withdrawn','cancelled','auto_approved','auto_rejected')),
payload JSONB NOT NULL DEFAULT '{}'::jsonb, -- 业务方原始入参
form_data JSONB NOT NULL DEFAULT '{}'::jsonb, -- system 预填 + submitter 补充的合并值
resolved_slots JSONB NULL, -- 第二阶段解析留档(只读)
resource_url TEXT NULL,
submitted_by TEXT NOT NULL, -- 发起方用户标识
submitted_at TIMESTAMPTZ NULL, -- 提交人点提交的时刻(非创建时刻)
completed_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_orders_submitter ON approval_orders (submitted_by, status, created_at DESC);
CREATE INDEX idx_orders_ns ON approval_orders (namespace, status, created_at DESC);
-- 4. 审批节点:条件求值后物化的线性序列
CREATE TABLE approval_nodes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES approval_orders(id),
seq INT NOT NULL, -- 1 起;下一节点 = seq + 1
node_key TEXT NOT NULL, -- 模板中的节点 ID,如 node_tl
strategy TEXT NOT NULL CHECK (strategy IN ('single','countersign','any')),
countersign_rule JSONB NULL, -- 仅 countersign 非空
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','active','in_progress','approved','rejected','cancelled')),
started_at TIMESTAMPTZ NULL, -- 进入 active 的时刻,等待时长由此算
completed_at TIMESTAMPTZ NULL,
UNIQUE (order_id, seq),
UNIQUE (order_id, node_key),
CHECK ((strategy = 'countersign') = (countersign_rule IS NOT NULL))
);
CREATE INDEX idx_nodes_active ON approval_nodes (order_id, seq) WHERE status IN ('active','in_progress');
-- 5. 节点审批人:每人一行(总 PRD 的 approvers JSON 的物理落法)
CREATE TABLE approval_node_approvers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
node_id BIGINT NOT NULL REFERENCES approval_nodes(id),
order_id BIGINT NOT NULL REFERENCES approval_orders(id), -- 冗余,待办查询免 join
approver_type TEXT NOT NULL CHECK (approver_type IN ('user')), -- 角色已在实例化时展开为 user
approver_id TEXT NOT NULL,
from_role_id TEXT NULL, -- 来源角色(展开溯源,非空即"因角色而成为审批人")
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','approved','rejected','cancelled')),
acted_at TIMESTAMPTZ NULL,
comment TEXT NULL, -- 审批意见(approval_note 字段的值,时间线同源)
approver_input JSONB NULL, -- 审批人填写的全部 fill_by: approver 字段值(B5 增补,迁移 0003)
UNIQUE (node_id, approver_type, approver_id)
);
-- 待审批列表的唯一热路径索引
CREATE INDEX idx_approver_pending ON approval_node_approvers (approver_id, status)
WHERE status = 'pending';
-- 6. 操作记录:append-only
CREATE TABLE approval_actions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES approval_orders(id),
node_id BIGINT NULL REFERENCES approval_nodes(id), -- withdraw/cancel 为单级操作,可空
actor TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN
('submit','approve','reject','withdraw','cancel')), -- 评论不在此表,见表 7
comment TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 幂等不变量:同一人对同一节点的同一决策只可能有一条
CREATE UNIQUE INDEX uq_action_idempotent ON approval_actions (node_id, actor, action)
WHERE action IN ('approve','reject');
CREATE INDEX idx_actions_order ON approval_actions (order_id, created_at);
-- 7. 评论:不改状态,可关联节点或全局;评论只落本表,不写 approval_actions(否则详情页 timeline 与 comments 会重复展示同一条)
CREATE TABLE approval_comments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES approval_orders(id),
node_id BIGINT NULL REFERENCES approval_nodes(id),
author TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_comments_order ON approval_comments (order_id, created_at);
-- 8. 审计:append-only
CREATE TABLE audit_logs (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
actor TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN
('create_template','update_template','register_resource_type','rebind_resource_type',
'create_order','submit_approval','approve','reject','withdraw','cancel')), -- 十个动作码
target_type TEXT NOT NULL CHECK (target_type IN ('template','resource_type','order','node')),
target_id TEXT NOT NULL,
detail JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_audit_target ON audit_logs (target_type, target_id, created_at);7.3 append-only 与不可变列触发器
sql
-- append-only:两张表写了 UPDATE/DELETE 也会被库拒,不靠"代码里没写这条路径"
CREATE OR REPLACE FUNCTION reject_mutation() RETURNS trigger AS $$
BEGIN RAISE EXCEPTION 'APPEND_ONLY_VIOLATION'; END $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_actions_append_only BEFORE UPDATE OR DELETE ON approval_actions
FOR EACH ROW EXECUTE FUNCTION reject_mutation();
CREATE TRIGGER trg_audit_append_only BEFORE UPDATE OR DELETE ON audit_logs
FOR EACH ROW EXECUTE FUNCTION reject_mutation();
-- 模板快照冻结:审批单创建后 template_snapshot 与 source_type 不可改
CREATE OR REPLACE FUNCTION reject_snapshot_change() RETURNS trigger AS $$
BEGIN
IF NEW.template_snapshot IS DISTINCT FROM OLD.template_snapshot
OR NEW.source_type IS DISTINCT FROM OLD.source_type THEN
RAISE EXCEPTION 'IMMUTABLE_FIELD';
END IF;
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_order_snapshot_immutable BEFORE UPDATE ON approval_orders
FOR EACH ROW EXECUTE FUNCTION reject_snapshot_change();drizzle-kit 不生成触发器:DDL 由
db:generate产出,三个触发器与两个部分唯一索引写成手工迁移文件跟在其后——与nexo-perm-api的0001_reject-immutable-cols-trigger.sql同一做法。
8. 装配管线与槽位解析(B04 / B05 / B06 / B07)
8.1 单一装配管线(B04)
POST /internal/orders
│
├─ resolveTemplate(req) ←── 唯一的三路分叉点
│ ├ resource_type → SELECT rt JOIN tpl ON rt.template_id
│ ├ template_id → SELECT tpl
│ └ template_config→ 就地构造(不落 approval_templates)
│
▼ 返回统一的 Template 对象 { formSchema, flowConfig, timeoutConfig, defaults }
validateTemplate(tpl) ←── B02 的纯函数校验器,三路同一份
▼
prefillSystemFields(formSchema, payload) ←── 第一阶段
▼
INSERT approval_orders (template_snapshot = tpl, status = 'pending', ...)
▼
返回 { orderId, detailUrl: `${PORTAL_BASE_URL}/orders/${orderId}` }分叉点之后禁止出现任何 source_type 判断——这是门禁 #1 的实现约束,code review 时按此检查。
第一阶段只做一件事:formSchema.fields 中 fill_by === 'system' 的字段,取 payload[field.key] 填入 form_data。不解析 、不求值条件、不建节点。
namespace 从 HMAC keyId 对应的 app 主体推导,请求体不含该字段(沿用 perm-api "命名空间从已认证身份推导,自报无从发生"的做法)。
8.2 槽位解析(B05,纯函数 slot 模块)
merged = { ...defaults, ...payload, ...submitterInput } // 优先级从低到高
resolve(templateJson) = 深度遍历 JSON,把所有形如 "{{name}}" 的字符串整体替换为 merged[name]要点:
- 整值替换,不做字符串内插:
""替换为merged.approver_1的原值(可能是对象),"前缀后缀"不支持(B02 校验期即拒绝这种写法) merged[name]不存在 ⇒ 记入missingSlots,不静默置空- 解析产出
{ resolvedTemplate, resolvedSlots, missingSlots },全程不碰 DB
提交校验(解析后立即执行,任一条不过即整体回滚、审批单停留 pending):
| 校验 | 失败错误码 |
|---|---|
formSchema 中 required 字段在 form_data 有值 | REQUIRED_FIELD_MISSING(附字段 key 列表) |
| 被引用的审批人槽位均已解析 | SLOT_MISSING(附槽位名列表) |
| 每个将被实例化的节点,审批人展开后非空 | APPROVER_EMPTY(附 node_key) |
条件引用的槽位允许缺失:缺失时该条件判为不命中,走
next.default(PRD §3 FR-6)。
8.3 条件求值与节点物化(B06,纯函数 flow 模块)
cursor = flowConfig.static_nodes[0].id
seq = 1
while cursor != null:
node = 查 static_nodes ∪ dynamic_nodes 中 id == cursor 的节点
产出 { seq, node_key: node.id, strategy, countersign_rule, approvers: node.approvers }
seq += 1
if seq > APPROVAL_MAX_NODES_PER_ORDER: 抛 FLOW_TOO_LONG // 防御,B02 已保证无环
cursor = evalNext(node.next)
evalNext(next):
next 为 null → null
next 为字符串 → next
next 为对象 → 按 if 数组顺序短路匹配,命中返回 then;全不命中返回 defaultevalCondition({slot, operator, value}) 的类型规则:
| operator | 语义 | 类型要求 | 槽位缺失时 |
|---|---|---|---|
== / != | 标量严格相等 | 同类型才可能相等,跨类型直接 false | 判不命中 |
> >= < <= | 数值或 ISO8601 日期比较 | 两侧同为 number 或同为合法日期串,否则判不命中 | 判不命中 |
in / not_in | 数组成员判定 | value 必为数组(B02 已校验) | 判不命中 |
物化:首节点 status='active' 且 started_at=now(),其余 pending;审批单 pending → in_progress,submitted_at=now()。
8.4 角色审批人展开(B07)
节点的 approvers 经槽位替换后,每一项归一化为 { type, id }:
| 解析值形态 | 归一化结果 |
|---|---|
"u_001"(裸字符串) | { type: 'user', id: 'u_001' } |
{ type: 'user', id } | 原样 |
{ type: 'role', id } | 调 perm-api 反查展开为 N 个 user |
| 数组 | 逐项归一化后展平 |
perm-api 反查一次性批量调用(同一审批单内所有节点的 role id 合并去重后一次请求),展开结果写 approval_node_approvers,from_role_id 记来源角色。展开后按 (node_id, approver_type, approver_id) 唯一键去重(user + role 混排时天然合并)。
countersign_rule.veto_users 走同一套归一化与角色展开,展开后的用户 ID 写回节点的 countersign_rule 快照(B08 判定器拿到的已是用户 ID)。展开后必须是本节点审批人的子集,否则 VETO_NOT_APPROVER(400)——否决者自己不在审批人里就永远没机会投否决票,一票否决会静默失效,报错比静默好。
失败即整体回滚:perm-api 不可达 / 5xx / 超时 → 抛 PERM_UNAVAILABLE,提交事务回滚,不降级为空审批人(PRD §3 FR-8)。
角色标识口径:总 PRD 示例中的
r_app_tl是示意值。实际{ type:'role', id }的id为nexo-perm-api的角色主键(数值,以字符串承载)。模板defaults里写死角色 ID 意味着模板与权限平台数据耦合——本期接受(模板由平台管理员维护),后续可引入"角色名 + 组织"的间接寻址。
9. 判定引擎与状态机(B08 / B09 / B10)
9.1 判定器(B08,纯函数 decision 模块)
输入 (strategy, countersignRule, approvers[]),输出 'approved' | 'rejected' | 'pending'(pending 表示尚未决出)。
decide(strategy, rule, xs):
# 一票否决优先于一切 mode 判定
if rule?.veto_users 中任一人在 xs 里 status == 'rejected' → 'rejected'
switch strategy:
single:
xs[0].status ∈ {approved, rejected} → 该状态;否则 pending
any: # 或签(PRD §4.5 补死的规则)
任一 approved → 'approved' (其余 pending 置 cancelled)
全部 rejected → 'rejected'
否则 → 'pending'
countersign:
rule.mode == 'all':
任一 rejected → 'rejected';全部 approved → 'approved';否则 pending
rule.mode == 'majority':
approved 数 > 总数/2 → 'approved' # 无需等全员
rejected 数 >= 总数/2 → 'rejected' # 通过已不可能达成,提前终止
否则 pending
rule.mode == 'ratio':
仍有 pending → pending # ratio 必须等全员处理完
approved 数 / 总数 >= rule.threshold → 'approved';否则 'rejected'cancelled 的审批人不计入分母(或签提前结束、节点被取消的场景下分母才会变,而那时节点已是终态,不再判定)。
判定器不碰 DB、不写状态——落库是 B09 的事。这条边界让门禁 #3 / #4 的全部组合可以在单测里穷举。
9.2 操作与状态流转(B09)
POST /api/orders/:id/actions { action: 'approve'|'reject', nodeId, approvalNote? }
BEGIN
SELECT * FROM approval_orders WHERE id = $1 FOR UPDATE -- 单级锁,串行化整单
node = 按 nodeId 取节点(须属于该审批单,否则 NOT_FOUND)
me = actor 在 node 下的 approval_node_approvers 行;不存在 → FORBIDDEN
if me.status == 目标态(approve→approved / reject→rejected):
→ 重放:直接返回 200 + 当前详情,不写任何记录 -- 幂等,终态单同样适用
if 审批单已终态 → ORDER_FINALIZED
if node 不是当前节点 或 me.status != 'pending' → NODE_STALE -- 页面已过期,前端提示刷新
approverInput = 按快照过滤 fill_by: approver 字段 + 按类型校验;不合法 → VALIDATION_FAILED
UPDATE approval_node_approvers SET status, acted_at, comment, approver_input WHERE ...
INSERT approval_actions (...) -- 唯一索引纵深防御
verdict = decide(node.strategy, node.countersign_rule, 该节点全部 approvers)
if verdict == 'approved':
node → approved;or 签场景把其余 pending 审批人置 cancelled
下一节点存在(seq+1) → 其 status pending→active, started_at=now()
下一节点不存在 → order → approved, completed_at=now()
if verdict == 'rejected':
node → rejected;其余 pending 审批人置 cancelled
后续所有 pending 节点 → cancelled
order → rejected, completed_at=now()
if verdict == 'pending' 且节点原为 active:
node → in_progress
写 audit_logs
COMMIT并发:整单一把锁(approval_orders 行级 FOR UPDATE)。审批单粒度的并发量极低(同一单同时操作的最多几个人),单级锁比逐节点/逐审批人加锁简单得多,也杜绝了"两人同时通过导致重复流转到下一节点"。
幂等:重放判断必须排在终态检查与"是否当前节点"检查之前——否则最后一个节点的通过请求重试时,审批单已是 approved,会被 ORDER_FINALIZED 拒掉而不是幂等成功。整单锁已串行化同一单的请求,正常路径上 uq_action_idempotent 不会被命中;万一命中(23505)同样按重放处理(PRD §6)。
请求必须带 nodeId:审批人看到的是某个节点,点下去也只能作用于那个节点。不带 nodeId、由服务端取"当前节点"会出事——同一人在相邻两个节点都是审批人时(角色展开后很常见),他在过期页面上点"通过"会直接批掉他根本没看到的下一个节点。
评论不走本端点:POST /api/orders/:id/comments,只写 approval_comments,任意状态可用。
撤回 / 取消:withdraw(发起方,/api/orders/:id/withdraw)与 cancel(业务方,/internal/orders/:id/cancel)共用同一段级联逻辑——当前节点与后续全部 cancelled,审批单分别置 withdrawn / cancelled。两者唯一差别是调用通道与 actor 来源,不各写一份(B10)。
9.3 状态机守卫表
| 审批单当前态 | 允许的操作 |
|---|---|
pending | submit(提交人)、withdraw(发起方)、cancel(业务方)、comment |
in_progress | approve / reject(当前节点待处理审批人)、withdraw、cancel、comment |
| 其余终态 | comment 可用;approve / reject 的重放幂等返回成功,其余一律 ORDER_FINALIZED;cancel 幂等返回成功 |
10. 接口契约(冻结源)
10.1 统一错误响应与业务错误码
复用 backend-sdk 统一格式:{ "error": { "code": "<CODE>", "message": "<中文说明>", "detail": {...} } },HTTP 4xx。
| 错误码 | HTTP | 场景 |
|---|---|---|
VALIDATION_FAILED | 400 | 参数校验失败(zod) |
TEMPLATE_INVALID | 400 | 模板四份配置校验不过,detail.issues[] 带字段路径(F06 据此高亮) |
REQUIRED_FIELD_MISSING | 400 | 提交时必填字段缺失,detail.fields[] |
SLOT_MISSING | 400 | 提交时槽位未解析,detail.slots[] |
APPROVER_EMPTY | 400 | 某节点审批人展开后为空,detail.nodeKey |
FLOW_TOO_LONG | 400 | 节点数超 APPROVAL_MAX_NODES_PER_ORDER |
FORBIDDEN | 403 | 接口鉴权拒绝(附 denyReason)或非当前节点审批人/非发起方 |
NOT_FOUND | 404 | 实体不存在 |
DUPLICATE | 409 | 唯一约束冲突(同名模板、重复 type_key) |
ORDER_FINALIZED | 409 | 对终态审批单执行状态类操作 |
IMMUTABLE_FIELD | 409 | 试图修改 template_snapshot / source_type |
VETO_NOT_APPROVER | 400 | 一票否决者展开后不在本节点审批人内(§8.4) |
NODE_STALE | 409 | 操作的节点已不是当前节点,或本人已不在待处理态(页面过期,前端提示刷新) |
PERM_UNAVAILABLE | 503 | nexo-perm-api 不可达(角色展开或鉴权),fail-closed |
10.2 internal(HMAC 内部通道,业务方)
POST /internal/orders — 创建审批单(三路发起唯一入口)
jsonc
// 请求:resourceType / templateId / templateConfig 三选一(互斥,多传即 VALIDATION_FAILED)
{
"resourceType": "release", // 方式一
// "templateId": 12, // 方式二
// "templateConfig": { ... }, // 方式三(四份配置的完整对象,见 §11)
"submittedBy": "u_001", // 发起方用户标识
"payload": {
"app_name": "order-service",
"version": "v2.3.1",
"is_urgent": false,
"resource_url": "https://release.internal/integrations/INT-2026-0922-001"
}
}
// 响应 201
{ "orderId": 1024, "status": "pending",
"detailUrl": "https://approval.internal/orders/1024" }
namespace不在请求体内,从 HMAC 身份推导。payload.resource_url抽取到独立列,其余原样留在payload。
GET /internal/orders/:id/status — 业务方轮询状态(本期无 Webhook)
jsonc
{ "orderId": 1024, "status": "approved",
"completedAt": "2026-09-24T07:12:00Z",
"currentNode": null } // 未完成时为 { nodeKey, seq, pendingApprovers: ["u_201"] }POST /internal/orders/:id/cancel — 业务方取消
jsonc
{ "reason": "发布单已作废" }
// 响应 200 { "orderId": 1024, "status": "cancelled" };终态单幂等返回当前状态10.3 门户接口(/api/*,Bearer JWT)
| 端点 | 请求体要点 | 响应要点 |
|---|---|---|
GET /api/orders/:id | — | 详情,见 §10.4 |
POST /api/orders/:id/submit | { submitterInput?: {...} }(键为模板字段 key;仅 fill_by:submitter 字段被采纳,其余静默忽略;省略即 {}) | 详情(状态已变 in_progress) |
POST /api/orders/:id/actions | { action: 'approve'|'reject', nodeId, approvalNote?, approverInput? }(nodeId 必填,见 §9.2;approverInput 规则见下) | 详情 |
POST /api/orders/:id/comments | { content, nodeId? }(nodeId 空即全局评论) | 详情 |
POST /api/orders/:id/withdraw | — | 详情(withdrawn) |
GET /api/orders/pending | ?status=&q=&limit=&offset= | { items: [概要], total } |
GET /api/orders/submitted | 同上 | 同上 |
GET /api/orders/pending/count | — | { count: 3 }(红点) |
GET /api/templates / POST / PATCH /:id | { name, namespace, formSchema, flowConfig, timeoutConfig, defaults }(模板经门户创建,无 HMAC 身份可推导,namespace 显式传) | 模板实体 |
POST /api/templates/validate | { formSchema, flowConfig, timeoutConfig, defaults } | 200 { valid: true, nodeSequencePreview: [...] } 或 400 TEMPLATE_INVALID |
GET /api/resource-types / POST / PATCH /:id | { typeKey, templateId, owner } | 资源类型实体 |
GET /api/audit-logs | ?targetType=&targetId=&limit=&offset= | { items, total } |
approverInput(lamolabs-docs#95 G7 本地 E2E 发现的 B5 缺陷修复,只增字段、向后兼容):
- 形状
Record<string, unknown>,键为模板字段 key。仅fill_by: approver的字段被采纳,其余静默忽略(与submitterInput对称) - 按字段类型校验:
text为字符串且不超过 1000 字符、number为有限数、date为可解析的日期字符串、boolean为布尔、select为候选项之一、multiselect为候选项组成的数组;null表示未填。任一不合法 →400 VALIDATION_FAILED,detail.fields[]列出出错 key,整体回滚 - 落
approval_node_approvers.approver_input,与status/comment同一次更新写入;校验排在幂等判断之后,重放不看请求体 approvalNote保留兼容:approverInput.approval_note存在时以它为准;只传approvalNote时,若模板有approval_note审批人字段,它也作为该字段的值存入approver_input。comment始终写审批意见,时间线不受影响
POST /api/templates/validate是只校验不落库的端点,专供 F06 实时校验与节点序列预览——前端因此不需要重写一套校验规则(门禁与 PRD §10 Q2 的实现依据)。
列表「概要」形状(本节原写作 { items: [概要], total } 但未定义概要,B12 落地时定死;F04 / F05 的 mock 按此写):
jsonc
{
"orderId": 1024,
"status": "in_progress",
"sourceType": "resource_type",
"resourceUrl": "https://release.internal/...",
"submittedBy": "u_001",
"submittedByName": "张三", // 显示名,取不到为 null(规则见 §10.4 末「显示名」)
"submittedAt": "2026-09-24T07:12:00Z", // 未提交时为 null
"createdAt": "2026-09-24T06:00:00Z",
"completedAt": null,
"nodeProgress": { "total": 3, "completed": 1 },
// 未完成时为「当前节点 + 谁在等」;终态为 null。
// 形状刻意复用 §10.2 的 currentNode(nodeKey / seq / pendingApprovers)——
// 等待时长(startedAt)与「卡在谁那里」因此只有一个来源,不会两处漂移
"currentNode": { "seq": 2, "nodeKey": "node_sre", "status": "active",
"startedAt": "2026-09-24T07:12:00Z", "pendingApprovers": ["u_201"] }
}刻意不含 title:approval_orders 没有标题列,PRD 与技术方案也从未定义过标题。 服务端派生标题(如「取第一个 fill_by: system 字段的值」)会把一条渲染规则固化成接口契约, 而它没有任何冻结来源——门户据此写出的 mock 会把一个从未商定的规则编码进去。 行标签由门户按 resourceUrl 与详情接口已返回的 fields 自行渲染,展示层的事留在展示层。
10.4 详情响应形状(F02 / F03 的渲染契约)
jsonc
{
"orderId": 1024,
"status": "in_progress",
"sourceType": "resource_type",
"resourceUrl": "https://release.internal/...",
"submittedBy": "u_001",
"submittedByName": "张三", // 显示名,取不到为 null(见本节末「显示名」)
"viewerRole": "approver", // submitter | approver | observer —— 后端判定,前端不推断
"canSubmit": false,
"canAct": true, // 当前用户是本节点待处理审批人
"canWithdraw": false,
"fields": [ // 已按 fill_by × viewerRole 算好可写性
{ "key": "app_name", "label": "应用名称", "type": "text",
"required": true, "fillBy": "system", "value": "order-service", "editable": false },
{ "key": "submitter_note", "label": "发布说明", "type": "text",
"required": true, "fillBy": "submitter", "value": "修复下单超时", "editable": false },
{ "key": "approval_note", "label": "审批意见", "type": "text",
"required": false, "fillBy": "approver", "value": null, "editable": true },
{ "key": "region", "label": "发布区域", "type": "select", "options": ["华东", "华南"],
"required": false, "fillBy": "submitter", "value": "华东", "editable": false }
],
"nodes": [
{ "id": 31, "seq": 1, "nodeKey": "node_tl", "strategy": "single", "status": "approved",
"startedAt": "...", "completedAt": "...",
"approvers": [ { "type": "user", "id": "u_101", "displayName": "李四", "status": "approved",
"actedAt": "...", "comment": "同意",
"approverInput": { "approval_note": "同意" }, "fromRoleId": "7" } ] },
{ "id": 32, "seq": 2, "nodeKey": "node_sre", "strategy": "countersign", "status": "active",
"countersignRule": { "mode": "all" },
"approvers": [ { "type": "user", "id": "u_201", "status": "pending", ... },
{ "type": "user", "id": "u_202", "status": "pending", ... } ] }
],
"timeline": [ { "actor": "u_001", "actorName": "张三", "action": "submit", "comment": null, "createdAt": "..." } ],
"comments": [ { "author": "u_101", "authorName": "李四", "content": "看一下回滚预案", "createdAt": "..." } ]
}editable 的计算规则(唯一实现在后端):
fillBy | viewerRole=submitter 且 status=pending | viewerRole=approver 且本人待处理 | 其余一切情况 |
|---|---|---|---|
system | false | false | false |
submitter | true | false | false |
approver | false | true | false |
审批人填写字段(lamolabs-docs#95 B5 增补,只增字段):
nodes[].approvers[].approverInput:该审批人填写的fill_by: approver字段值(对象),未处理或未填为 nullfields[]中fill_by: approver字段的value:当前查看者是待处理审批人且该字段可编辑时为 null(留给他填,不预填上一节点的值);否则取本单最近一次已操作(按acted_at)且填了该字段的审批人的值,没有则为 null。审批人字段不进form_data
节点 id 与字段候选项(lamolabs-docs#95 联调增补,只增字段):
nodes[].id(number)即approval_nodes.id,POST /api/orders/:id/actions与comments的nodeId取这个值(§9.2)fields[].options(string[],可选)原样取自模板快照:select/multiselect必有(C02),其余类型不出现
显示名(lamolabs-docs#95 ① 增补,只增字段、向后兼容):详情的 submittedByName、nodes[].approvers[].displayName、timeline[].actorName、comments[].authorName,以及 §10.3 列表概要的 submittedByName,类型一律 string | null。
- 数据源:nexo-account
GET /internal/users?ids=…(HMAC 内部通道,密钥见 §3APPROVAL_AUTH_INTERNAL_SHARED_SECRET),显示名取realName || nickname || username - 响应组装完成后整单 / 整页去重、一次批量取;单次上限 200 个 id,超出分批;超时 1s。非 uuid 的标识(如业务方 app
nexo-release作为actor)不发送,名字为 null - 失败降级、不 fail-closed:认证中心不可达 / 超时 / 非 2xx / 形状不符时仍返回 200,名字字段为 null 并打 warn 日志;门户回退显示 ID。这与鉴权刻意不同(perm-api 不可用时直接拒绝)——名字只是展示信息
- submit / actions / comments / withdraw 四个端点返回的也是本形状,同样带名字
approvers 字段虽然物理上拆了表(§7.1),响应仍按总 PRD 的 JSON 数组形状组装——API 契约与总 PRD 一致,存储结构是实现细节。
11. 模板配置 JSON 契约(冻结源,前后端共用)
template-schema 模块导出 zod schema,同时用于:后端写入校验、/api/templates/validate 端点、以及导出给前端 F06 做类型提示。
jsonc
{
"form_schema": {
"fields": [
{ "key": "app_name", "label": "应用名称", "type": "text",
"required": true, "fill_by": "system" },
{ "key": "environment", "label": "目标环境", "type": "select",
"options": ["staging", "production"], "required": true, "fill_by": "system" }
]
},
"flow_config": {
"static_nodes": [
{ "id": "node_tl", "strategy": "single", "approvers": ["{{app_tl}}"],
"next": { "default": "node_sre",
"if": [ { "condition": { "slot": "is_urgent", "operator": "==", "value": true },
"then": "node_emergency" } ] } },
{ "id": "node_sre", "strategy": "countersign",
"countersign_rule": { "mode": "all" },
"approvers": ["{{sre_1}}", "{{sre_2}}"], "next": null }
],
"dynamic_nodes": [
{ "id": "node_emergency", "strategy": "single",
"approvers": ["{{oncall_lead}}"], "next": "node_sre" }
]
},
"timeout_config": { "levels": [ { "after_hours": 24, "action": "auto_reject" } ] },
"defaults": { "app_tl": { "type": "role", "id": "7" } }
}校验规则清单(B02 逐条实现,错误码统一 TEMPLATE_INVALID,detail.issues[] 带 path + reason):
| # | 规则 |
|---|---|
| C01 | field.key 全局唯一;type ∈ {text,number,date,select,multiselect,boolean}(无 file/attachment) |
| C02 | fill_by ∈ {system,submitter,approver};select/multiselect 必须带非空 options |
| C03 | 节点 id 在 static_nodes ∪ dynamic_nodes 内唯一;static_nodes 至少一个 |
| C04 | 所有 next 目标(字符串 / default / then)必须指向已定义节点,或为 null |
| C05 | 节点图无环(从 static_nodes[0] 出发沿全部可能出边做 DFS,回边即报错) |
| C06 | 每个 dynamic_node 至少被一条 next.if.then 引用,否则判为死节点 |
| C07 | strategy ∈ {single,countersign,any}(无 multi_level);countersign_rule 当且仅当 strategy=countersign 时存在 |
| C08 | mode ∈ {all,majority,ratio};mode=ratio 必须带 threshold ∈ (0,1] |
| C09 | operator ∈ {==,!=,>,>=,<,<=,in,not_in};in/not_in 的 value 必须是数组 |
| C10 | 槽位写法必须是整值 "",name 匹配 ^[a-zA-Z_][a-zA-Z0-9_]*$;不支持字符串内插 |
| C11 | defaults 的每个 key 必须是模板中实际出现过的槽位名 |
| C12 | timeout_config.levels 按 after_hours 升序;action ∈ {remind,escalate,auto_approve,auto_reject} |
| C13 | escalate 的升级对象只认模板显式声明的 槽位(平台不推导上下级) |
12. 审计实现(B11)
- 入口
audit.emit({ actor, action, targetType, targetId, detail }),在业务事务内写audit_logs——同事务保证"业务成功即审计落地",业务回滚则审计一并回滚,不留孤儿日志 detail只记录关键变化(状态前后、节点 key、模板 diff 摘要),不落完整 payload——审批单 payload 可能含业务敏感数据,审计表不是它的归宿- 本期无远端投递、无 outbox:生态审计平台未立项,本地表 + 结构化日志即终点;投递函数留成可替换的单一出口(沿用 0920 适配层口径)
- append-only 由 §7.3 触发器兜底,不是约定
13. 接口鉴权与权限点清单(B13)
平台自身接口经 nexo-perm-api /internal/check 判定,接入方式与 SDK 用法同 0920 B12。命名空间 approval,按资源粒度建点(沿用 0920 Q2 口径,避免首期权限点爆炸):
| 权限点 | 管控范围 |
|---|---|
approval.template.manage | 模板增删改(POST/PATCH /api/templates) |
approval.template.read | 模板查询与 validate 端点 |
approval.resource-type.manage | 资源类型注册与改绑 |
approval.order.read.all | 跨发起人查看审批单(平台/业务方管理员视角) |
approval.audit.read | 审计日志查询 |
不走 perm-api 的两类接口(重要边界):
- 审批操作本身(
submit/actions/withdraw)——"谁能批这一单"由节点审批人快照决定,是业务数据不是平台角色。走 perm-api 会既慢又错(角色变动会让历史单的可操作人漂移) - 自己的审批单(
GET /api/orders/pending|submitted、以及自己作为提交人/审批人的详情)——以登录身份为过滤条件,天然隔离,无需权限点。详情接口的边界:提交人、该单任一节点的审批人(含已处理、待处理、未轮到)可直接看;其余人必须持有approval.order.read.all才能以viewerRole: observer查看,否则FORBIDDEN——不设这条,任意登录用户按 ID 遍历就能读全部审批单的 payload
fail-closed:perm-api 超时/不可达时,管理接口一律拒绝(PERM_UNAVAILABLE),审批操作不受影响——这是把审批链路与权限平台解耦的直接收益。
初始化脚本(幂等):建命名空间 approval + 上述 5 个权限点 + 角色「审批平台管理员」并关联全部权限点。业务方管理员由超管按需分配,无硬编码特权路径。
13.1 同批接入的存量服务权限点(B15 / B16)
本期同时偿还 0920 的「合法 JWT 即放行」欠账,两家服务的权限点不在 approval 命名空间下,各自独立(B15 落地时定义、B16 登记):
nexo-account(命名空间 nexo-account,B15 · nexo-auth#83) —— 按资源粒度,8 个管理端点收敛为 6 个点:
| 权限点 | 管控范围 |
|---|---|
nexo-account.user.read | 用户列表与详情 |
nexo-account.user.create | 开户 |
nexo-account.user.update | 资料编辑 |
nexo-account.user.status | 停用 / 启用 |
nexo-account.user.credential | 凭据重置与激活链接重发 |
nexo-account.user.import | CSV 批量导入 |
user.update单列而非并入read或create:折进read会让只读账号拿到改写权,折进create会让能改昵称的人拿到开户权,两个方向都是权限放大。issue 正文只列了五类资源,这一项是落地时按最小权限原则补的。
nexo-im-api(命名空间 im,B16 · nexo-im-api#85) —— 4 个资源粒度点,本期零挂载:
im.user.directory.read / im.conversation.manage / im.message.manage / im.ops.read。
为什么零挂载:
nexo-im-api当前不存在管理侧接口——会话管理随 US-012(0.3.0)迁往nexo-auth,user_profiles是只读投影无写接口,全历史无 admin 路由。issue #85 的前提(「管理侧接口」)已过期。更关键的是不该挂:唯一候选
GET /users是通讯录热路径(nexo-im-pc/src/services/api.ts直接消费),给它加 perm-api 判定会把权限平台变成通讯录的同步依赖——perm-api 故障时通讯录 fail-closed 打不开,而 #85 的约束明确要求「消息链路不能被权限平台拖慢,也不能因 perm-api 故障而中断」。为还一笔债而新增一处耦合是净回归,因此 B16 交付的是「机制 + 边界证明 + 权限点登记」,挂载点数为 0,并有用例断言零挂载(grep -rn authz src/routes/...零命中)与消息链路不受影响。
14. 前端实现要点(F02 / F03 / F06)
14.1 渲染器与后端的职责边界
| 事项 | 归属 | 理由 |
|---|---|---|
字段是否可写(editable) | 后端(§10.4) | 规则只有一份;前端重算必然漂移 |
当前用户能否操作(canAct / canSubmit / canWithdraw) | 后端 | 同上;前端据此控制按钮可见性,不自行推断"我是不是审批人" |
| 字段级格式校验(必填、数字范围、日期格式、options 合法性) | 两边都做 | 前端就地高亮是体验,后端是正确性底线 |
| 模板配置校验 | 后端(/api/templates/validate) | 13 条规则(§11)只实现一次 |
| 未知字段类型 | 前端降级 | 渲染为只读文本 + 提示,不白屏——模板可能来自更新版本的后端 |
14.2 三角色读写矩阵(F02 验收的九格)
即 §10.4 的 editable 表。前端不实现这张表,只消费 editable 字段;这九格的验收在后端单测 + 前端表现走查两侧同时覆盖。
前端仍需保证:只读字段不可通过 DOM 改写后提交——提交前按 editable 过滤 payload,只提交 editable === true 的字段。
14.3 详情页 URL 与直达
detailUrl 形如 ${APPROVAL_PORTAL_BASE_URL}/orders/:id。业务方把它放进自己的页面,用户点进来时通常未登录审批门户——F01 必须实现登录后回跳原路由(沿用 nexo-web-sdk 授权码流程的 redirect 参数),否则整条"收到链接 → 审批完成"的路径断在第一步(门禁 #7)。
15. 排期(开发窗口 09-22 至 09-26,09-27 验收)
| 日期 | 后端(@snailuu) | 前端(@wshiqyuan) |
|---|---|---|
| 09-22 | B01 骨架与全量 DDL + 触发器;B14 perm-api 角色反查 | F01 骨架、登录态与详情页直达回跳 |
| 09-23 | B02 模板管理与校验器;B03 资源类型 | F02 动态表单渲染器(对 §10.4 契约 mock 先行) |
| 09-24 | B04 装配管线;B05 槽位解析;B06 条件求值与物化 | F02 收尾 + F06 模板编辑器与校验预览 |
| 09-25 | B07 角色展开;B08 判定引擎;B09 操作与状态机 | F03 审批详情页 |
| 09-26 | B10 取消;B11 审计;B12 查询列表;B13 接口鉴权 | F04 待审批 + F05 我发起的 + F07 壳挂载 + 联调 |
| 09-26 晚 | B15 / B16 还债;T01 预跑与证据归档(含 p99 取数) | 配合截图与录屏证据 |
前端 09-23 起按 §10 契约以 mock 先行,09-25 起逐页切真接口——契约冻结(§10 / §11)是并行的前提。
B 线 16 个任务压 5 天,比 0920 的 12 个多 4 个。验收底线是 B01–B09 + B12(对应门禁 #1–#5、#7);B10 / B11 / B13 / B15 / B16 允许顺延至验收会前一晚,顺延则对应门禁 #6、#8、#9 转下期。
16. 发布计划
- test 环境(09-26):compose 新增
nexo-approval-api(3003)+nexo_approval库;跑迁移(drizzle 生成 + 3 个手工触发器迁移)与初始化脚本;nexo-approval-console发测试站;壳配置审批门户入口地址;种入tpl_release_prod示例模板供 T01 用例使用 - prod 环境(验收通过后):同序执行
- 回滚:本期无存量业务方接入(发布平台未立项),回滚 = 停
nexo-approval-api容器 + 壳隐藏入口,对现有业务零影响;库保留不回滚 - 门户登录的跨仓前置(F01 负责,照 0920
nexo-auth@f09ba2a的做法):在nexo-auth的src/db/seed-clients.ts登记 clientnexo-approval-console(本地回调http://localhost:5177/auth/callback)。发布耦合:该改动部署后,生产 seed 会强制要求SEED_CLIENT_REDIRECTS含这个 client,缺了 seed 直接抛错、nexo-auth起不来——所以先在服务器.env追加nexo-approval-console=<门户域名>/auth/callback,再部署 nexo-auth,test / prod 各一遍 - 壳侧变量(F07):
nexo-app的 Cloudflare 环境变量新增VITE_APPROVAL_CONSOLE_URL,对照VITE_PERM_CONSOLE_URL - B15 / B16 单独回滚位:两处接入改造是唯一动了存量服务的部分,各自可独立回滚为"合法 JWT 即放行",不牵连审批平台
17. 稳定性保障
- perm-api 调用双层超时:HTTP 客户端
APPROVAL_PERM_CHECK_TIMEOUT_MS(2s)+ SDK 侧兜底,超时即 fail-closed - 提交事务内含一次外部调用(角色展开),先调 perm-api 拿到展开结果、再开写事务,避免外部 IO 持有 DB 事务——这是 0920 B08「判定第③步在事务内另取连接导致池耗尽死锁」那个 bug 的同类防御
- 节点数与审批人数双上限(§3),防御恶意或写错的模板把单库拖垮
- 列表查询全部走 §7.2 的两个专用索引,禁止 JSONB 扫描;p99 基准随 T01 归档,后续迭代按基准回归
18. 风险与待评审确认点
- 首批消费者缺位(PRD §10 Q1):发布平台未立项,门禁 #1–#6 全部用 T01 的脚本化用例验证,P5「接入无需开发」的证明推到场景接入迭代。倾向把权限授予审批作为首批真实场景——待拍板
- 模板
defaults写死 perm-api 角色主键(§8.4)造成跨平台耦合,本期接受;后续引入"角色名 + 组织"间接寻址后可解 template_snapshot三路统一是对总 PRD 数据模型的偏离(总 PRD 只给内联路径留template_config)——理由见 §7.1,需在评审时确认并回填总 PRD #54approval_node_approvers拆表同样偏离总 PRD 的approvers JSON——API 形状不变,仅存储结构调整,需确认- 整单一把锁(§9.2)在审批单粒度并发极低的前提下成立;若后续出现单个审批单几十人会签的场景需改为节点级锁,本期不预优化
- B 线 16 任务 5 天窗口偏紧,验收底线与顺延边界见 §15