Skip to content

审批平台首期 — 技术方案 ​

对应 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 验收)。

由此决定:

  1. 技术栈对齐生态:Node.js (Hono + @hono/zod-openapi) + PostgreSQL 16 + drizzle-orm,复用 @lamolabs/nexo-backend-sdk(日志脱敏、错误响应、HMAC 服务间鉴权、JWKS 本地验签、鉴权客户端);工程结构遵循后端工程规范模块四件套,与 nexo-perm-api 逐项对齐
  2. 无对象存储 → 表单无附件:全仓检索确认生态内无 OSS/S3/MinIO/upload 实现,nexo-im-api 消息类型只有 'text'。表单字段类型收敛为六种,业务上下文靠 resource_url 外跳(PRD §4.1)
  3. 无通知通道 → 门户待办即通知:nexo-im-api 无系统/机器人消息推送 internal 接口,本期不造。待办列表 + 未处理计数是审批人感知新单的唯一入口(PRD §4.2)
  4. 无定时基础设施 → 超时只存不驱动:timeout_config 本期只做 schema 校验与存储,不起扫描器。auto_approved / auto_rejected 保留枚举位但无代码路径产生

2. 服务形态与部署 ​

  • nexo-approval-api:独立容器、独立库 nexo_approval,端口 3003(3000 nexo-im-api / 3001 nexo-account / 3002 nexo-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、角色成员反查,HMACapp 主体 approval接口鉴权、角色审批人解析

3. 配置清单 ​

环境变量默认值说明
APPROVAL_PORT3003服务端口
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_SECRETapproval / —调 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_MS2000鉴权判定调用超时,超时即 fail-closed
APPROVAL_PERM_MEMBERS_TIMEOUT_MS3000角色成员反查调用超时。比判定略宽:它在提交事务的前置阶段被调用,一次要展开同一审批单内所有节点的角色
APPROVAL_MAX_NODES_PER_ORDER20单审批单节点数上限,路径遍历防御性截断即报错
APPROVAL_MAX_APPROVERS_PER_NODE20单节点审批人数上限(角色展开后校验)
APPROVAL_LIST_PAGE_SIZE_MAX100列表分页上限
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 层兜底,应用层先校验) ​

  1. append-only:approval_actions / audit_logs 挂 BEFORE UPDATE OR DELETE 触发器 RAISE EXCEPTION 'APPEND_ONLY_VIOLATION'——不是"代码里没写 update 路径",是写了也会被库拒(§7.3)
  2. 操作幂等:主路径在应用层守卫——本人在该节点的状态已等于本次操作的目标态即判为重放,直接返回当前详情、不写任何记录(§9.2)。UNIQUE (node_id, actor, action) WHERE action IN ('approve','reject') 部分唯一索引是纵深防御:万一有路径绕过整单锁,重复决策也会被库拒
  3. 模板快照不可变:approval_orders.template_snapshot 挂触发器拒改——审批单一经创建,其模板视图冻结,上游模板怎么改都不影响
  4. 节点顺序唯一:UNIQUE (order_id, seq) 与 UNIQUE (order_id, node_key)——条件求值产出的是线性路径,同一节点不得出现两次
  5. 审批人单行:UNIQUE (node_id, approver_type, approver_id)——角色展开后去重由唯一键兜底,不靠应用层 dedupe
  6. 资源类型唯一:UNIQUE (type_key)
  7. 终态不可退:审批单与节点的状态流转方向由 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 重写、并发操作可行级加锁、去重与幂等由唯一键兜底
节点序列条件求值后物化为线性 seqflow_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;全不命中返回 default

evalCondition({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 状态机守卫表 ​

审批单当前态允许的操作
pendingsubmit(提交人)、withdraw(发起方)、cancel(业务方)、comment
in_progressapprove / reject(当前节点待处理审批人)、withdraw、cancel、comment
其余终态comment 可用;approve / reject 的重放幂等返回成功,其余一律 ORDER_FINALIZED;cancel 幂等返回成功

10. 接口契约(冻结源) ​

10.1 统一错误响应与业务错误码 ​

复用 backend-sdk 统一格式:{ "error": { "code": "<CODE>", "message": "<中文说明>", "detail": {...} } },HTTP 4xx。

错误码HTTP场景
VALIDATION_FAILED400参数校验失败(zod)
TEMPLATE_INVALID400模板四份配置校验不过,detail.issues[] 带字段路径(F06 据此高亮)
REQUIRED_FIELD_MISSING400提交时必填字段缺失,detail.fields[]
SLOT_MISSING400提交时槽位未解析,detail.slots[]
APPROVER_EMPTY400某节点审批人展开后为空,detail.nodeKey
FLOW_TOO_LONG400节点数超 APPROVAL_MAX_NODES_PER_ORDER
FORBIDDEN403接口鉴权拒绝(附 denyReason)或非当前节点审批人/非发起方
NOT_FOUND404实体不存在
DUPLICATE409唯一约束冲突(同名模板、重复 type_key)
ORDER_FINALIZED409对终态审批单执行状态类操作
IMMUTABLE_FIELD409试图修改 template_snapshot / source_type
VETO_NOT_APPROVER400一票否决者展开后不在本节点审批人内(§8.4)
NODE_STALE409操作的节点已不是当前节点,或本人已不在待处理态(页面过期,前端提示刷新)
PERM_UNAVAILABLE503nexo-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 的计算规则(唯一实现在后端):

fillByviewerRole=submitter 且 status=pendingviewerRole=approver 且本人待处理其余一切情况
systemfalsefalsefalse
submittertruefalsefalse
approverfalsetruefalse

审批人填写字段(lamolabs-docs#95 B5 增补,只增字段):

  • nodes[].approvers[].approverInput:该审批人填写的 fill_by: approver 字段值(对象),未处理或未填为 null
  • fields[] 中 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 内部通道,密钥见 §3 APPROVAL_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):

#规则
C01field.key 全局唯一;type ∈ {text,number,date,select,multiselect,boolean}(无 file/attachment)
C02fill_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 引用,否则判为死节点
C07strategy ∈ {single,countersign,any}(无 multi_level);countersign_rule 当且仅当 strategy=countersign 时存在
C08mode ∈ {all,majority,ratio};mode=ratio 必须带 threshold ∈ (0,1]
C09operator ∈ {==,!=,>,>=,<,<=,in,not_in};in/not_in 的 value 必须是数组
C10槽位写法必须是整值 "",name 匹配 ^[a-zA-Z_][a-zA-Z0-9_]*$;不支持字符串内插
C11defaults 的每个 key 必须是模板中实际出现过的槽位名
C12timeout_config.levels 按 after_hours 升序;action ∈ {remind,escalate,auto_approve,auto_reject}
C13escalate 的升级对象只认模板显式声明的 槽位(平台不推导上下级)

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 的两类接口(重要边界):

  1. 审批操作本身(submit / actions / withdraw)——"谁能批这一单"由节点审批人快照决定,是业务数据不是平台角色。走 perm-api 会既慢又错(角色变动会让历史单的可操作人漂移)
  2. 自己的审批单(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.importCSV 批量导入

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-22B01 骨架与全量 DDL + 触发器;B14 perm-api 角色反查F01 骨架、登录态与详情页直达回跳
09-23B02 模板管理与校验器;B03 资源类型F02 动态表单渲染器(对 §10.4 契约 mock 先行)
09-24B04 装配管线;B05 槽位解析;B06 条件求值与物化F02 收尾 + F06 模板编辑器与校验预览
09-25B07 角色展开;B08 判定引擎;B09 操作与状态机F03 审批详情页
09-26B10 取消;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. 发布计划 ​

  1. test 环境(09-26):compose 新增 nexo-approval-api(3003)+ nexo_approval 库;跑迁移(drizzle 生成 + 3 个手工触发器迁移)与初始化脚本;nexo-approval-console 发测试站;壳配置审批门户入口地址;种入 tpl_release_prod 示例模板供 T01 用例使用
  2. prod 环境(验收通过后):同序执行
  3. 回滚:本期无存量业务方接入(发布平台未立项),回滚 = 停 nexo-approval-api 容器 + 壳隐藏入口,对现有业务零影响;库保留不回滚
  4. 门户登录的跨仓前置(F01 负责,照 0920 nexo-auth@f09ba2a 的做法):在 nexo-auth 的 src/db/seed-clients.ts 登记 client nexo-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 各一遍
  5. 壳侧变量(F07):nexo-app 的 Cloudflare 环境变量新增 VITE_APPROVAL_CONSOLE_URL,对照 VITE_PERM_CONSOLE_URL
  6. 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. 风险与待评审确认点 ​

  1. 首批消费者缺位(PRD §10 Q1):发布平台未立项,门禁 #1–#6 全部用 T01 的脚本化用例验证,P5「接入无需开发」的证明推到场景接入迭代。倾向把权限授予审批作为首批真实场景——待拍板
  2. 模板 defaults 写死 perm-api 角色主键(§8.4)造成跨平台耦合,本期接受;后续引入"角色名 + 组织"间接寻址后可解
  3. template_snapshot 三路统一是对总 PRD 数据模型的偏离(总 PRD 只给内联路径留 template_config)——理由见 §7.1,需在评审时确认并回填总 PRD #54
  4. approval_node_approvers 拆表同样偏离总 PRD 的 approvers JSON——API 形状不变,仅存储结构调整,需确认
  5. 整单一把锁(§9.2)在审批单粒度并发极低的前提下成立;若后续出现单个审批单几十人会签的场景需改为节点级锁,本期不预优化
  6. B 线 16 任务 5 天窗口偏紧,验收底线与顺延边界见 §15