This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/API文档-活动模块.md
T
xcsandClaude Fable 5 e47ce92702 fix(w05): 两颗料不能是同一颗 —— 起单这一刻立墙,因为路只在这里
用户:「创建活动的启动参数里面不应该能选到同样的两个芯片」。
查下来这不只是没意义,是**把单子做死**:sub_eval.part 的主键是 eval_ref+internal_pn
(role 不在主键里),S1 配方铸的两行撞成同一个主键、后一笔覆盖前一笔,两行收成一行 ——
sub_eval_part_exists_status_found = 2 永不成立,S1 收不了步,而件上那两行人加不出来
(行源 mine:,没有「+加一行」),唯一出路是终止重开。与「两格留空」是同一条链。

平台加互斥声明 x-distinct(契约 distinctGroup)——与 x-link 正相反:那个是引导,这个是墙。
· 前端:别的格占着的候选这一格不列(自己填着的那个永远留着,否则 select 当场变空白),
  并说清是谁占了哪个;走「新增」自己写一个同样的 → 小红字 + 启动按不下去;
· 服务端 validateLaunchParams 独立拦(脚本 / API 绕不过),400 点名后填的那格;
· 「算不算同一个」= 去空白 + 不分大小写(launchDistinctKey,前后端同一把尺)——
  只挡精确相等的话 lm2596s-5.0 照样进去,混一颗垃圾主数据出来。
W05 两格挂 x-distinct: 'part',2.14.0 已装库核过。

测:服务端 6 条 + 前端 8 条(含联动与互斥合起来的真形状)+ 包内 1 条,三处变异验证过;
四道门禁绿(2548 测)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 22:03:56 +08:00

38 KiB
Raw Blame History

API 文档 —— 活动模块(L1 清单 / L2 活动详情 / L3 实例详情)

本文是活动模块前端(运营中心 · 活动区,2026-08-25 重构模版)与服务端之间的接口约定,给前端取数、后端维护用。 鉴权 / 票据 / 错误体 / 端归属的通则在 docs/API文档-鉴权与人员.md §0,本文 §0.1 只摘调用时必须知道的几条。 需求出处:docs/活动模块重构需求-2026-08-25.md;启动参数口径:ACT-WORK-05-一键启动入参说明.md(极简四参)。 契约已落地(2026-08-27):packages/contracts/src/activity-ops.ts(数据结构 zod)+ packages/contracts/src/api/activity-ops.ts (11 条路由,花名册 ACTIVITY_OPS_ROUTES);前端一律 import { xxxRoute } from '@contracts' 经 shared/typed-fetch 的 call(route, { params, query, body }) 调用, 类型直接用 @contracts 导出的(§1 每张表都标了导出名)。两者不一致时以契约为准并回来改本文。 设计端的活动库两条路由已改名让位:GET/POST /api/gov/activities → /api/gov/activity-packages(前端 import 路由对象,不用改)。

0. 通则

0.1 调用约定

项 值
前缀 /api/gov/activities/*;11 条全部 access: 'ops' —— 登录人的 ends 里要有 ops,否则 403
请求头 Authorization: Bearer <Keycloak access_token>;写请求 content-type: application/json
票据 只有两条路带不了请求头(<iframe src> / <a href download>):§2.3 视图、§7 文件。GET 时改带 ?ticket=<票据>(取自 GET /api/auth/ticket,60 秒有效,用的那一刻再取)
响应 除 §2.3(text/html)与 §7 ?raw=1(文件字节)外,全部 application/json
时间 一律 ISO 8601 UTC 字符串(2026-08-27T02:25:11.000Z);展示格式化是前端的事(YYYY-MM-DD HH:mm:ss 本地时区,用 shared/at.ts)
人员 凡出人,都是 StaffRef = { id, name } —— 展示用 name,筛选 / 提交用 id(= 名册 staffId);候选名单用既有 GET /api/gov/staff
实时刷新 写路由(启动 / 禁用 / 激活 / 终止)成功后服务端广播 {kind:'system', id:'gov'},前端既有 SSE 订阅会失效 ['gov', …] 缓存 —— 查询键放在 ['gov', 'activities', …] 下就自动重取
分页 只有 §3 实例清单分页(page 从 1 起,size 默认 8、上限 50);其余不分页

0.2 错误体

所有非 2xx 都是同一形状:

{ "code": "validation-failed", "message": "启动参数不合要求:candidateInternalPns 必填,至少一项", "detail": [ { "key": "candidateInternalPns", "message": "必填,至少一项" } ] }
状态 code 何时 detail
400 validation-failed 查询参数 / 路径参数 / 请求体不合 schema;启动参数缺必填 / 类型不对 / select 值不在候选 / 不认识的参数名 / 责任人不在职 schema 不合 → zod issues 数组;启动参数问题 → [{ key, message }],key 是哪一格(责任人那一条 key='ownerId')
401 unauthenticated 没带 token / 过期 / 票据坏了 —
403 forbidden 登录人进不了运营中心 { need, ends }
404 not-found 活动不存在;实例不存在或不属于这个活动;活动没配视图;文件序号不存在 —
409 conflict 启动:同一 idempotencyKey 的实例正在创建(60 秒内再来);或这枚键已用在别的活动上 —
412 precondition-failed 启动:活动已禁用;终止:实例已终态 —
500 internal 服务端出错 —

0.3 状态口径

项 值
活动状态 ActivityStatus active(激活中)/ disabled(已禁用)。中文在 @contracts 的 ACTIVITY_STATUS_LABEL
实例状态 InstanceState 五态英文码:pending(待执行)/ running(进行中)/ completed(已完成)/ rejected(驳回)/ failed(失败);中文在 INSTANCE_STATE_LABEL。没有第六态 —— 手动终止归 failed,靠 reason.kind='terminated' 区分「自己跑挂」。口径:pending = 建了单、推进器还没领到;running = 在途(含等人回传 / 监听中);completed = 放行完成;rejected = 验收被负责人打回、模型还没重新提交的那一段(重新提交后回 running);failed = 跑挂(受阻 / 停滞熔断 / 预算耗尽)与手动终止
禁用语义 任何时刻都可禁用:只拦「新启动」(§5 回 412);已完成的实例是历史留痕,进行中的继续跑完;激活即恢复
活动档案 负责人 / 说明 / 图标 / 色相来自运营侧档案表,不在活动包里;没建档时 owner 是 null、desc 是空串、ico/hue 缺席,前端摆「—」并兜底图标色

1. 数据结构(@contracts 导出名在括号里)

字段列「必有」= 响应里一定带这个键(可能是 null 的会单独写);「可缺」= 键可能不出现。

1.1 StaffRef(staffRefSchema)

字段 类型 说明
id string 名册 staffId
name string 姓名

1.2 ActivitySummary(activitySummarySchema)—— L1 卡片

字段 类型 必有 说明
id string 是 活动 id,如 part-substitution-assessment;后面所有路径里的 :activityId
name string 是 活动名
biz string 是 业务分类(卡片副题)
status ActivityStatus 是 active / disabled
version string 是 当前生效的包版本,如 1.5.7
owner StaffRef | null 是 活动负责人;没建档 = null,摆「—」
llm string 是 绑定的模型(逻辑名,如 deepseek-v4-flash)
instanceCount number 是 该活动的实例总数(含测试台试跑单)
createdAt string(ISO) 是 首个版本装入的时刻
ico string 可缺 语义图标名;缺省前端兜底 inbox
hue 'blue' | 'teal' | 'amber' | 'violet' 可缺 业务色相;缺省前端兜底 blue

1.3 ActivityDetail(activityDetailSchema)—— L2 头部

ActivitySummary 去掉 instanceCount,加:

字段 类型 必有 说明
desc string 是 定义说明;没写就是 ''
view { url: string } | null 是 沙箱要渲染的活动视图地址(相对路径,见 §2.3);null = 藏起整个沙箱区块

1.4 InstanceSummary(instanceSummarySchema)—— L2 表格一行

字段 类型 必有 说明
id string 是 实例的寻址键(任务 id,uuid):L3 路由、终止、文件都用它
no string 是 人读实例号 INS-<序号>(表格上摆它、模糊搜也认它)
state InstanceState 是 五态
owner StaffRef 是 责任人
input string 是 输入一句话摘要(≤80 字,可为 '')
output string | null 是 产出一句话摘要;只有 completed 才有值(文件名,或「已发口:<口名>」);null 摆 -
startedAt string(ISO) 是 启动时刻

1.5 InstanceStats / InstanceList(instanceStatsSchema / instanceListSchema)

字段 类型 说明
stats.total number 该活动实例总数(不随检索条件动,页头统计带用)
stats.byState Record<InstanceState, number> 五个键都在(没有的是 0)
total number 筛后条数,分页器按它
page number 当前页(回显请求值,默认 1)
size number 单页条数(回显请求值,默认 8)
items InstanceSummary[] 当前页,启动时间倒序

1.6 LaunchParam / LaunchParams(launchParamSchema / launchParamsSchema)—— 启动表单规格

LaunchParams:

字段 类型 说明
activityId string
version string 参数表对应的包版本
params LaunchParam[] 按这个顺序渲染表单

LaunchParam:

字段 类型 必有 说明
key string 是 提交时 params 里的键名(英文)
label string 是 表单上的中文名
type 'text' | 'textarea' | 'select' | 'combo' | 'list' 是 控件种类:单行 / 多行 / 单选(只能选候选)/ 选择+新增(候选末项「新增」,选了换输入框自填)/ 字符串数组(逐行自由填,可加多项)。遇到不认识的 type 摆「暂不支持的控件」。前端只按 type 渲染 —— 活动契约里是数组的字段也可能摆成 combo/select(只选一颗,提交单个字符串,后端包成数组),前端不必知道
required boolean 可缺 缺席 = 选填。前端没填完禁提交;服务端仍再验一道(400)
desc string 可缺 控件下的辅助说明
placeholder string 可缺 text / textarea / combo(新增态)/ list 用
options { value: string; label: string }[] 可缺 select / combo 的候选,两段式:value 是提交值,label 是显示名。combo 可能没有这个键(候选读不到),那时就是自由输入
default string | string[] 可缺 预填值:list 给字符串数组,其余给字符串

1.7 LaunchInput(launchInputSchema)—— 启动请求体

字段 类型 必填 说明
idempotencyKey string(1–128) 是 幂等键:每开一次抽屉生成一枚(crypto.randomUUID());重复点提交、网络重试都带同一枚
ownerId string 是 责任人 staffId(固定字段,不在参数表里);默认当前登录人 me.staff.id,允许改;只列 GET /api/gov/staff 里 state='active' 的
params Record<string, string | string[]> 是 键 = LaunchParam.key;list 传 string[],其余传 string;select 传某个 options[].value;combo 传候选的 value 或新增的原样字符串(两种都合法);选填没填可以不传或传 ''。服务端宽收:活动契约里是数组的字段(含 list)传 string 或 string[] 都行,统一归成数组交活动

1.8 InstanceFile(instanceFileSchema)

字段 类型 说明
index number 在同一侧(input / output)文件里的序号,从 0 起;§7 下载用
name string 文件名(含扩展名)
contentType string MIME
size number 字节数

1.9 InstanceReason(instanceReasonSchema)

字段 类型 说明
kind 'rejected' | 'failed' | 'terminated' 驳回 / 自己跑挂 / 手动终止
text string 人话原因,直接摆。驳回:「验收驳回:<负责人写的意见> —— 负责人 <名>」(没写意见时「验收驳回:负责人未写明具体意见,已退回重做」);失败:引擎原话(预算耗尽已翻成「预算耗尽:步数 90/90、活跃时长 120/120 分钟」);终止:「由 <操作人> 手动终止」

1.10 InstanceSource(instanceSourceSchema)

字段 类型 说明
kind string manual(运营端手动启动)/ upstream(上游工作流节点)/ mail 等通道名(来件触发)/ testbed(测试台试跑)/ system(其余)。开放枚举,不认识的按 label 摆
label string 人读形式,直接摆:手动启动 · 柴浩 / 上游 · <工作流主题> / 邮件 · <发件方> / 测试台试跑 / 系统起单

1.11 InstanceStep(instanceStepSchema)—— L3 时间线一行

字段 类型 说明
seq number 从 1 起,按活动案例模型的步骤序
name string 步骤名(活动定义的计划项名)
by 'agent' | 'human' 这一步谁办:AI / 人
byName string agent → 模型名(同 llm);human → 责任人姓名
at string(ISO) | null 开始时刻;没到这一步 = null
durationMs number | null 用时(毫秒);未完成 = null。格式化如 6m
res 'done' | 'fail' | 'current' | 'pending' 已完成 / 出事(性质看 reason.kind:驳回 · 失败 · 已终止)/ 正在办 / 未到
note string 这一步交出什么的一句话;可为 ''

1.12 InstanceDetail(instanceDetailSchema)—— L3 整页

字段 类型 必有 说明
id string 是 任务 id
no string 是 人读实例号
activityId string 是
state InstanceState 是
owner StaffRef 是 责任人
llm string 是 模型名
startedAt string(ISO) 是
endedAt string(ISO) | null 是 未结束 = null;终止时 = 终止时刻
durationMs number | null 是 endedAt − startedAt;未结束 = null
reason InstanceReason | null 是 null = 不需要(待执行 / 进行中 / 正常完成)
input.content string 是 输入一句话摘要(= 清单上的 input)
input.source InstanceSource 是 来源
input.submittedAt string(ISO) 是 提交时刻(= startedAt)
input.files InstanceFile[] 是 输入文件元数据(可为空数组)
output.files InstanceFile[] 是 产出文件元数据;没产出是 [],摆「暂无产出」
steps InstanceStep[] 是 时间线
第 9 步的 res 与 at 跟第 8 步走(服务端交出前改好,2026-08-28 定):放行那一步没有自己的收步事件,不这么做已放行的单第 9 步永远 pending;用时不编

注意:详情里没有 input/output 那两句摘要字符串(它们是清单行的字段),input/output 在详情里是上面的结构体。

2. L1 / L2 活动

2.1 活动清单 —— GET /api/gov/activities(listOpsActivitiesRoute)

请求

位置 参数 类型 必填 说明
query status 'active' | 'disabled' 否 L1 两个页签各取一份;不带 = 全量。别的值 400

响应 200

字段 类型 说明
activities ActivitySummary[] 量小不分页;顺序 = 活动库顺序
{
  "activities": [
    {
      "id": "part-substitution-assessment",
      "name": "ACT-WORK-05 · 差异评估",
      "biz": "需求分析与方案匹配",
      "status": "active",
      "version": "1.5.7",
      "owner": null,
      "llm": "deepseek-v4-flash",
      "instanceCount": 12,
      "createdAt": "2026-08-26T01:00:00.000Z",
      "ico": "git-compare",
      "hue": "teal"
    }
  ]
}

2.2 活动详情 —— GET /api/gov/activities/:activityId(getOpsActivityRoute)

请求:路径参数 activityId(string,必填)。

响应 200:ActivityDetail。404:活动不存在。

{
  "id": "part-substitution-assessment",
  "name": "ACT-WORK-05 · 差异评估",
  "biz": "需求分析与方案匹配",
  "status": "active",
  "version": "1.5.7",
  "owner": { "id": "8f3c…", "name": "柴浩" },
  "llm": "deepseek-v4-flash",
  "createdAt": "2026-08-26T01:00:00.000Z",
  "ico": "git-compare",
  "hue": "teal",
  "desc": "对候选料号逐项比较参数、判四态、定推荐等级,审核后落 P-11 替代关系并回写 P-01。",
  "view": { "url": "/api/gov/activities/part-substitution-assessment/view" }
}

2.3 活动视图(沙箱)—— GET /api/gov/activities/:activityId/view(opsActivityViewRoute)

请求

位置 参数 类型 必填 说明
path activityId string 是
query ticket string 是(iframe 场景) <iframe src> 带不了请求头:装载那一刻 call(authTicketRoute) 取一张拼上,不要渲染时预取(60 秒过期)

响应 200:content-type: text/html(cache-control: no-store),整份 HTML,<iframe sandbox="allow-scripts" src="..."> 直接渲染。 404:活动不存在 / 该活动没配视图(detail.view === null 时压根别请求)。

沙箱里摆什么(2026-08-27 定):活动包里名为 activity-ledger 的视图 —— 关键台账数据看板, 摆本活动最近一次已放行实例的落账快照(页头:实例号 / 开案与放行时刻 / 负责人 / 案号 / 关联需求 / 评估基准与候选; 正文:P-01 一条 · P-08 逐颗料 + 参数瓷贴 · P-11 主记录 + 差异明细 · P-10 · P-06 本单挂的 + 同料号历史)。 行由服务端按活动清单 viewTemplates[].data 的取数声明从正式表圈出(在办中的单不摆),渲染完再注入报高脚本。 三种局面页面自己说清、都回 200:有落账(v-ready)/ 本活动还没有已放行实例(v-none,只留页头与横幅)/ 最近那一单已放行但案号在正式表里查无(v-missing,多半是起单时关了「落真库」)。前端不需要为这三态做任何事。

高度协议:页面自己会向父页 postMessage({ type: 'jcai:view-height', height: <number> }, '*')(load、尺寸变化、每秒兜底)。父页:

window.addEventListener('message', (e) => {
  if (e.source !== iframe.contentWindow) return;             // 沙箱帧 origin 是 'null',按 source 认,不按 origin
  if (e.data?.type !== ACTIVITY_VIEW_HEIGHT_MESSAGE) return;  // 常量在 @contracts,= 'jcai:view-height'
  setHeight(Number(e.data.height));
});

3. L2 实例清单 —— GET /api/gov/activities/:activityId/instances(listOpsInstancesRoute)

请求

位置 参数 类型 必填 说明
path activityId string 是
query id string 否 实例号模糊搜,如 INS-24:对 no 与 id 的子串,不分大小写
query ownerId string(staffId) 否 精确到人
query state InstanceState 否 精确;别的值 400
query startedFrom YYYY-MM-DD 否 启动日期段起点,含整日;格式不对 400
query startedTo YYYY-MM-DD 否 终点,含整日;可只给一端
query page number ≥ 1 否 默认 1
query size number 1–50 否 默认 8(与前端单页一致);>50 → 400

条件之间 AND;都不带 = 全部。

响应 200:InstanceList。404:活动不存在。

{
  "stats": { "total": 13, "byState": { "pending": 1, "running": 1, "completed": 7, "rejected": 2, "failed": 2 } },
  "total": 13,
  "page": 1,
  "size": 8,
  "items": [
    {
      "id": "0c9d3a1e-…",
      "no": "INS-2417",
      "state": "running",
      "owner": { "id": "8f3c…", "name": "柴浩" },
      "input": "工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND…",
      "output": null,
      "startedAt": "2026-08-25T02:12:00.000Z"
    },
    {
      "id": "7b21e6c0-…",
      "no": "INS-2415",
      "state": "completed",
      "owner": { "id": "8f3c…", "name": "柴浩" },
      "input": "…",
      "output": "已发口:assessment-released",
      "startedAt": "2026-08-24T09:03:00.000Z"
    }
  ]
}
  • 页头统计带摆 stats(全局),分页器按 total(筛后)走;筛中提示「筛出 total / 共 stats.total」。
  • 负责人下拉的候选用 GET /api/gov/staff(在职的),提交 ownerId。
  • 前端不再本地过滤 / 分页 / 数统计,全部上移到这里。

4. 启动参数表 —— GET /api/gov/activities/:activityId/launch-params(opsLaunchParamsRoute)

点「启动活动」开抽屉时取。一活动一份,前端只按 params 渲染表单,不写死字段。

请求:路径参数 activityId。响应 200:LaunchParams。404:活动不存在。

{
  "activityId": "part-substitution-assessment",
  "version": "1.5.7",
  "params": [
    {
      "key": "customerCode",
      "label": "客户",
      "type": "combo",
      "required": true,
      "desc": "台账里有就直接选;是新客户就自己填客户编码,启动后派数据录入卡建档。",
      "placeholder": "CU-0142",
      "options": [
        { "value": "CU-0142", "label": "Itelma" },
        { "value": "CU-2039", "label": "青岛新星电子有限公司" }
      ]
    },
    {
      "key": "customerName",
      "label": "客户名称(仅客户不在库时填)",
      "type": "text",
      "desc": "新客户先把名字摆上,启动后建档时让人核。",
      "placeholder": "深圳恒星电源有限公司"
    },
    {
      "key": "requestDescription",
      "label": "客户需求",
      "type": "textarea",
      "required": true,
      "desc": "保留原始需求语义,据此新建 P-01 需求主记录(版本 V1)。",
      "placeholder": "工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND,客户要求验证替代料"
    },
    {
      "key": "originalInternalPn",
      "label": "原内部料号",
      "type": "combo",
      "required": true,
      "desc": "差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。",
      "placeholder": "LM2596S-5.0",
      "linkGroup": "chip-type",
      "distinctGroup": "part",
      "options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0", "group": "1", "groupLabel": "DC-DC 降压稳压器" }]
    },
    {
      "key": "candidateInternalPns",
      "label": "候选内部料号",
      "type": "combo",
      "required": true,
      "desc": "被评估物料;库里有就直接选,没有就自己填料号,启动后经数据工单创建。",
      "placeholder": "XL2596S-5.0",
      "linkGroup": "chip-type",
      "distinctGroup": "part",
      "options": [
        { "value": "LM2596S-5.0", "label": "LM2596S-5.0", "group": "1", "groupLabel": "DC-DC 降压稳压器" },
        { "value": "AMS1117-3.3", "label": "AMS1117-3.3", "group": "2", "groupLabel": "LDO 线性稳压器" },
        { "value": "OLD-001", "label": "OLD-001", "group": "" }
      ]
    }
  ]
}

W05 的候选内部料号(2026-08-27 用户定)与原内部料号同款下拉、只选一颗:清单里它仍是数组字段,前端按 type='combo' 渲染、提交单个字符串,后端包成一元数组交给活动 —— 活动契约不变。

combo 语义(2026-08-27 用户定):启动表单大部分是自由输入;客户 / 料号这类「台账里可能有」的参数才用 combo —— 先选,候选里没有就新增自填。提交候选之外的值是合法的,不拦启动:服务端按值在台账里查,查不到照样启动,启动后派数据工单到工作台补这条档案(客户进客户台账、PN 补参数,确认即入库,活动从断点续跑)。combo 提交的值统一是字符串:选中候选传它的 value,新增传用户填的原样字符串。

候选量大到不宜内联时(如全量物料台账选 PN),后续加 optionsSource 引用检索接口;本期一律内联 options(封顶 500 行)。

按分组联动(2026-08-31 用户定):候选可带 group(分组键,如这颗料的芯片类型编号;"" = 那一列没值,即「未标类型」)与 groupLabel(它的人读名,字典读不到就没有这个键);参数可带 linkGroup(联动组名)。同一 linkGroup 的几格按 group 互相收窄:组内第一个填上值的那格是锚,锚自己永远摆全量候选,其余格只列与锚同 group 的候选 + group 为空的那些(殿后单独一组)+「新增」这条路;锚清空则整组解除收窄(锚不让位给还填着值的另一格 —— 否则「清空锚、改个类型重填」当场被另一格锁住),整组都空了才重新认锚。锚没有 group、或锚填的是候选之外的新值 → 不收窄。锚换了类型之后,另一格里在全量候选里、却不在收窄后候选里的值由前端清空并说明(人自己新增的值不动 —— 它压根不在候选里)。

这是表单的引导,不是墙:服务端不据 linkGroup 校验 —— 两格填不同 group 的值照样收得下(combo 本就允许候选之外的值)。类型一致性由活动自己的收步判据管(W05 是 S1 的 sub_eval_part_distinct_chip_type_id = 1)。

互斥(2026-08-31 用户定:「不应该能选到同样的两个芯片」):参数可带 distinctGroup(互斥组名)。同一 distinctGroup 的几格不能填同一个值 —— 前端把别的格此刻占着的候选从这一格的下拉里抽掉(唯独这一格自己填着的那个永远留着,否则 <select> 会当场变空白),并在控件下说清是谁占了哪个;人若走「新增」自己写了一个同样的值,小红字出来、「启动活动」按不下去。「算不算同一个」= 去空白 + 不分大小写(契约的 launchDistinctKey,前后端同一把尺;那句话是 launchDistinctMessage)。只管标量格(text / select / combo),数组格不参与。

这一条是墙,与 linkGroup 正相反:服务端 POST …/instances 独立校验,撞上就是 400 invalid、detail=[{key,message}] 点名后填的那格 —— 脚本 / API 绕过表单一样过不去。为什么非拦不可:W05 两格填同一颗料会把单子做死 —— sub_eval.part 的主键是 eval_ref + internal_pn,配方铸的两行撞成同一个主键、后一笔覆盖前一笔,两行收成一行,S1 的收步判据 sub_eval_part_exists_status_found = 2 永不成立,而件上那两行人加不出来,唯一出路是终止重开。墙立在起单这一刻,是因为路只在这里(那时换一格就好了)。

活动清单侧怎么声明(前端不用管,给写活动包的人看):properties.<key> 上 title→label、description→desc、examples[0]→placeholder、default、required[];enum+enumNames→select;'x-control' 定标量控件(text|textarea|select|combo);'x-options': { ledger:'域.对象', value:'列', label:'列' } 让候选从正式台账现读;'x-options'.group 让候选带上分组键 —— 只写列名('chip_type_id')= 有键没名,整份写 { column, ledger, value, label } 再从字典表换人读名;'x-link': '<组名>' 把几格结成联动组(同组按分组键互相收窄,见上);'x-distinct': '<组名>' 把几格结成互斥组(同组不能填同一个值 —— 这条是墙,见上);type:'array' 且元素是字符串→list,数组字段标 x-control: combo|select 就摆成单选(提交单个字符串,后端包成一元数组);'x-order': <数字> 定表单次序(清单存在 jsonb 里,书写顺序到不了这里);format:'file' 的字段本期不上表单;select 没候选降成 text。

5. 启动活动(创建实例)—— POST /api/gov/activities/:activityId/instances(launchOpsInstanceRoute)

请求:路径参数 activityId;请求体 LaunchInput(§1.7)。

{
  "idempotencyKey": "b5a2d6f0-3e1c-4a7b-9d2e-8f1c0a4b7e93",
  "ownerId": "8f3c1a2b-6c1e-4b3a-9f0e-2d5c7a1b9e44",
  "params": {
    "customerCode": "CU-0142",
    "requestDescription": "工业电源项目在用 LM2596S-5.0,原厂 PCN 转 NRND,客户要求验证替代料",
    "originalInternalPn": "LM2596S-5.0",
    "candidateInternalPns": "XL2596S-5.0"
  }
}

candidateInternalPns 在活动契约里是数组:表单单选传 "XL2596S-5.0",服务端包成 ["XL2596S-5.0"];传 ["XL2596S-5.0", "LM2596S-ADJ"] 也照收。

响应 200:InstanceDetail(§1.12,与 §6 同形)——新建的实例,初始 state='pending'(推进器领到即转 running,靠 SSE 失效重取)。同一 idempotencyKey 重放也回 200,给的是第一次创建的那一单,不起第二个。

错误

状态 何时 detail
400 请求体不合 schema zod issues
400 启动参数缺必填 / 标量格传了数组 / select 值不在候选 / 不认识的参数名 [{ key, message }],逐格点名 —— 前端把 message 摆到对应控件下
400 责任人不在名册 / 已停用 [{ key: 'ownerId', message }]
404 活动不存在 —
409 同一 idempotencyKey 的实例正在创建(60 秒内);或这枚键已用在别的活动上 —
412 活动已禁用(先激活再启动) —

成功后:关抽屉、失效 ['gov']、按返回的 id 跳 L3(路由 id = ${activityId}:${id})。存在性检查(客户 / 料号在不在台账)在启动后由活动做,查不到不拦启动 —— 响应里不单独回这件事,工单出现在工作台那一侧。

6. L3 实例详情 —— GET /api/gov/activities/:activityId/instances/:instanceId(getOpsInstanceRoute)

请求:路径参数 activityId、instanceId(= 清单行的 id,任务 uuid,不是 no)。

响应 200:InstanceDetail(§1.12)。404:实例不存在或不属于这个活动。

{
  "id": "0c9d3a1e-…",
  "no": "INS-2417",
  "activityId": "part-substitution-assessment",
  "state": "running",
  "owner": { "id": "8f3c…", "name": "柴浩" },
  "llm": "deepseek-v4-flash",
  "startedAt": "2026-08-25T02:12:00.000Z",
  "endedAt": null,
  "durationMs": null,
  "reason": null,
  "input": {
    "content": "工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND…",
    "source": { "kind": "manual", "label": "手动启动 · 柴浩" },
    "submittedAt": "2026-08-25T02:12:00.000Z",
    "files": [
      { "index": 0, "name": "XL2596S-5.0.pdf", "contentType": "application/pdf", "size": 48210 }
    ]
  },
  "output": {
    "files": [
      { "index": 0, "name": "差异评估报告.docx", "contentType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "size": 6102 }
    ]
  },
  "steps": [
    { "seq": 1, "name": "建立评估上下文:绑定四项、新建需求、存在性核对", "by": "agent", "byName": "deepseek-v4-flash", "at": "2026-08-25T02:12:00.000Z", "durationMs": 60000, "res": "done",    "note": "铸案号、建需求 V1、锁规则版本、逐个料号查库、写存在性核对表" },
    { "seq": 2, "name": "数据工单:补建芯片主数据(P-08)",       "by": "human", "byName": "柴浩",              "at": "2026-08-25T02:13:00.000Z", "durationMs": null,  "res": "current", "note": "" },
    { "seq": 3, "name": "评估输入与资料补齐确认",               "by": "human", "byName": "柴浩",              "at": null,                        "durationMs": null,  "res": "pending", "note": "" }
  ]
}

三种终局的 reason 示例:

{ "kind": "rejected",   "text": "验收驳回:测算毛利低于阈值,退回重新测算 —— 负责人 柴浩" }
{ "kind": "failed",     "text": "预算耗尽:步数 90/90、活跃时长 120/120 分钟" }
{ "kind": "terminated", "text": "由 柴浩 手动终止" }
  • 头部:状态丸(state)+ 责任人 + 启动 / 结束 · 耗时(startedAt / endedAt / durationMs)+ 模型(llm)。
  • 驳回 / 失败 / 已终止出原因块(reason.text);reason === null 不摆。
  • 时间线按 steps 渲染;res='fail' 那一行的字眼按 reason.kind 选(驳回 / 失败 / 已终止)。
  • 右栏输入(input.content / input.source.label / input.submittedAt / input.files)、输出(output.files;空数组摆「暂无产出」);文件行的下载走 §7。

7. 实例文件下载 —— GET /api/gov/activities/:activityId/instances/:instanceId/files/:io/:index(opsInstanceFileRoute)

请求

位置 参数 类型 必填 说明
path activityId string 是
path instanceId string 是 任务 id
path io 'input' | 'output' 是 哪一侧;别的值 400
path index number ≥ 0 是 对应 §6 文件行的 index;不是整数 → 400
query raw '1' 否 带 raw=1 直出文件字节(content-type = 文件 MIME,content-disposition: attachment; filename*=UTF-8''…);不带回 JSON
query ticket string <a href download> 场景必填 点击那一刻取票据拼上(shared 里的 downloadWithTicket(url))

响应 200(不带 raw):

字段 类型 说明
name string 文件名
contentType string MIME
dataBase64 string 文件字节的 base64(与 GET /api/gov/upload/:id 同形)

404:实例不存在 / 该序号没有文件。

下载钮的写法:downloadWithTicket(/api/gov/activities/${activityId}/instances/${id}/files/${io}/${index}?raw=1)。

8. 禁用 / 激活 / 终止

8.1 禁用 —— POST /api/gov/activities/:activityId/disable(disableOpsActivityRoute)

请求:路径参数 activityId;body 空。 响应 200:ActivityDetail(status='disabled')。404:活动不存在。已禁用再禁用 = 幂等 200。 任何时刻都可禁用,没有前置条件:禁用后 §5 回 412;已完成的实例是历史留痕,进行中的继续跑完。

8.2 激活 —— POST /api/gov/activities/:activityId/activate(activateOpsActivityRoute)

请求:路径参数 activityId;body 空。 响应 200:ActivityDetail(status='active')。404:活动不存在。已激活再激活 = 幂等 200。

8.3 终止实例 —— POST /api/gov/activities/:activityId/instances/:instanceId/terminate(terminateOpsInstanceRoute)

请求:路径参数 activityId、instanceId;body 空(终止理由本期不填,服务端生成「由 <操作人> 手动终止」)。 响应 200:InstanceDetail,state='failed'、reason.kind='terminated'、endedAt = 终止时刻。 404:实例不存在。412:实例已终态(已完成 / 已失败 / 已终止的不能再终止;pending 与 running 都可以)。 前端:二次确认(轻气泡)后调用;成功后由服务端重投影(广播 → 失效 → 重取),不要就地改 state。

9. 类型附录(packages/contracts/src/activity-ops.ts,同名导出)

type ActivityStatus = 'active' | 'disabled';
type InstanceState = 'pending' | 'running' | 'completed' | 'rejected' | 'failed';
type StaffRef = { id: string; name: string };

type ActivitySummary = { id: string; name: string; biz: string; status: ActivityStatus; version: string;
  owner: StaffRef | null; llm: string; instanceCount: number; createdAt: string; ico?: string; hue?: 'blue' | 'teal' | 'amber' | 'violet' };
type ActivityDetail = Omit<ActivitySummary, 'instanceCount'> & { desc: string; view: { url: string } | null };

type InstanceSummary = { id: string; no: string; state: InstanceState; owner: StaffRef; input: string; output: string | null; startedAt: string };
type InstanceStats = { total: number; byState: Record<InstanceState, number> };
type InstanceList = { stats: InstanceStats; total: number; page: number; size: number; items: InstanceSummary[] };

type LaunchParamType = 'text' | 'textarea' | 'select' | 'combo' | 'list';
type LaunchParam = { key: string; label: string; type: LaunchParamType; required?: boolean; desc?: string;
  placeholder?: string; options?: { value: string; label: string }[]; default?: string | string[] };
type LaunchParams = { activityId: string; version: string; params: LaunchParam[] };
type LaunchInput = { idempotencyKey: string; ownerId: string; params: Record<string, string | string[]> };

type InstanceFile = { index: number; name: string; contentType: string; size: number };
type InstanceReason = { kind: 'rejected' | 'failed' | 'terminated'; text: string };
type InstanceSource = { kind: string; label: string };
type InstanceStep = { seq: number; name: string; by: 'agent' | 'human'; byName: string;
  at: string | null; durationMs: number | null; res: 'done' | 'fail' | 'current' | 'pending'; note: string };
type InstanceDetail = Omit<InstanceSummary, 'input' | 'output'> & { activityId: string; llm: string; endedAt: string | null; durationMs: number | null;
  reason: InstanceReason | null;
  input: { content: string; source: InstanceSource; submittedAt: string; files: InstanceFile[] };
  output: { files: InstanceFile[] };
  steps: InstanceStep[] };

路由常量(packages/contracts/src/api/activity-ops.ts):

常量 方法 · 路径
listOpsActivitiesRoute GET /api/gov/activities
getOpsActivityRoute GET /api/gov/activities/:activityId
opsActivityViewRoute GET /api/gov/activities/:activityId/view
listOpsInstancesRoute GET /api/gov/activities/:activityId/instances
opsLaunchParamsRoute GET /api/gov/activities/:activityId/launch-params
launchOpsInstanceRoute POST /api/gov/activities/:activityId/instances
getOpsInstanceRoute GET /api/gov/activities/:activityId/instances/:instanceId
opsInstanceFileRoute GET /api/gov/activities/:activityId/instances/:instanceId/files/:io/:index
disableOpsActivityRoute POST /api/gov/activities/:activityId/disable
activateOpsActivityRoute POST /api/gov/activities/:activityId/activate
terminateOpsInstanceRoute POST /api/gov/activities/:activityId/instances/:instanceId/terminate

其它导出:INSTANCE_STATES / INSTANCE_STATE_LABEL(五态中文)、ACTIVITY_STATUSES / ACTIVITY_STATUS_LABEL、ACTIVITY_HUES、LAUNCH_PARAM_TYPES、ACTIVITY_VIEW_HEIGHT_MESSAGE、ACTIVITY_OPS_ROUTES。

调用示例(前端唯一通道 shared/typed-fetch):

import { call } from '@web-shared/typed-fetch.ts';
import { launchOpsInstanceRoute, listOpsInstancesRoute } from '@contracts';
import type { InstanceDetail, InstanceList } from '@contracts';

const list = await call<InstanceList>(listOpsInstancesRoute, { params: { activityId }, query: { state: 'running', page: '2', size: '8' } });
const created = await call<InstanceDetail>(launchOpsInstanceRoute, { params: { activityId }, body: { idempotencyKey, ownerId, params: values } });
// 失败抛 ApiCallError { status, code, message, detail }

10. 前端对接要点(现状 → 接口落地)

接口已落地,前端仍是占位数据(对接另起一轮,2026-08-27 用户定)。届时照这张表换:

现状(占位) 接口落地后
DEMO_ACTIVITIES 静态数组 §2.1;L1 页签切换即换 status 重取;owner 为 null 摆「—」,ico/hue 缺省兜底
demoInstances() 每活动共用一份 §3;检索四条件与分页全部上移到查询参数,前端删掉本地过滤/分页;表格摆 no,寻址用 id
统计带由前端从全量数组数 §3 stats(服务端给,分页下才数得准)
中文状态字面量当键 五态英文码 + INSTANCE_STATE_LABEL,前端一张 Record<InstanceState, {tone, ico}> 映射
LAUNCH_PARAMS 占位参数表 §4;options 从纯字符串升级为 {value,label} 两段(渲染器小改);控件种类已含 combo
onLaunch 在清单头落假行 §5;抽屉每开一次生成 idempotencyKey(crypto.randomUUID()),提交走 mutation,400 的 detail[].key 对回控件,成功失效 ['gov'] 并跳 L3
责任人固定字段 §5 ownerId;抽屉加「固定参数」段(名册下拉取 GET /api/gov/staff 在职的人,默认当前登录人 me.staff.id)
禁用/激活/终止就地改 state §8;mutation 成功后由服务端重投影(广播 → 失效 → 重取);L2 行内「终止活动」也要二次确认
demoSteps / terminatedSteps 五步占位 §6 steps;终止归态以 reason.kind='terminated' 为准,前端不再落「失败」占位
沙箱 src="/sandbox-demo/activity-view.html" 固定 480 高 §2.3 view.url + 票据(装载那一刻取);view 为 null 藏起沙箱区;高度听 jcai:view-height
输出下载钮置灰 §7 已落地:downloadWithTicket(url + '?raw=1')(从 desk-task/internal/FileViewer.tsx 抽到 shared)
设计中心活动库 / 测试台的 useGovActivities 路径改名对它们透明(import 的是路由对象),无需改动