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

343 lines
19 KiB
Markdown

# API 文档 —— 活动模块(L1 清单 / L2 活动详情 / L3 实例详情)
> 本文是活动模块前端(运营中心 · 活动区,2026-08-25 重构模版)与服务端之间的接口约定,
> 给后端编写接口、前端取数用。鉴权 / 票据 / 错误体 / 端归属通则:`docs/API文档-鉴权与人员.md` §0,本文不重复。
> 需求出处:`docs/活动模块重构需求-2026-08-25.md`;启动参数口径:`ACT-WORK-05-一键启动入参说明.md`(极简四参)。
> 契约落地后唯一真相在 `packages/contracts/src/api/*.ts`(zod schema,前后端共用);两者不一致时以契约为准并回来改本文。
## 0. 通则(本文特有)
| 项 | 值 |
|---|---|
| 前缀 | `/api/gov/activities/*`;**全部 `access: 'ops'`** —— 活动区落在运营中心,与工作流/任务看板同端 |
| 时间 | 一律 ISO 8601 UTC 字符串(`2026-08-27T02:25:11.000Z`);**展示格式化是前端的事**(`YYYY-MM-DD HH:mm:ss` 本地时区) |
| 人员 | 凡出人,都是 `{ "id": "<staffId>", "name": "柴浩" }` 两段 —— 展示用 name,筛选/提交用 id;人员候选名单用既有 `GET /api/gov/staff`,本文不另做 |
| 广播 | 写路由(启动 / 禁用 / 激活 / 终止)成功后广播 `{kind:'system', id:'gov'}` —— 前端按既有套路失效 `['gov',*]` 重取 |
| 实例状态 | 五态,英文码:`pending`(待执行)/ `running`(进行中)/ `completed`(已完成)/ `rejected`(驳回)/ `failed`(失败);**没有第六态** —— 手动终止归入 `failed`,用 `reason.kind='terminated'` 区分「自己跑挂」(见 §6) |
| 禁用语义 | **任何时刻都可禁用**(2026-08-27 用户重定,废除「有实例不能禁用」):禁用只拦「新启动」—— 已完成的实例是历史留痕,进行中的实例**继续跑完不受影响**;激活即恢复启动能力 |
状态码怎么读(通则之外本文特有的):
| 状态 | code | 何时 |
|---|---|---|
| 400 | `validation-failed` | 入参不合 schema(`detail` 是 zod issues);启动参数缺必填 / 类型不对(`detail` 指明是哪个 `key`) |
| 404 | `not-found` | 活动 / 实例不存在;实例不属于这个活动 |
| 412 | `precondition-failed` | 启动:活动已禁用;终止:实例不在 `running` |
## 1. L1 活动清单
### `GET /api/gov/activities?status=active|disabled` —— access: `ops`
L1 两个页签(激活中 / 已禁用)各取一份;不带 `status` = 全量。量小,**不分页**。
```json
{
"activities": [
{
"id": "quote-intake",
"name": "询价接收与结构化",
"biz": "报价业务",
"status": "active",
"version": "1.3.0",
"owner": { "id": "8f3c…", "name": "柴浩" },
"llm": "K3 · quote-intake",
"instanceCount": 12,
"createdAt": "2026-08-12T01:00:00.000Z",
"ico": "inbox",
"hue": "blue"
}
]
}
```
- `ico` / `hue`:展示元数据(语义图标名 / 业务色相 `blue|teal|amber|violet`),创建活动时可配;**缺省时前端兜底**(`inbox` / `blue`),后端可以暂时不存。
- `llm`:这个活动绑定的专属 agent 的人读标签;要链到 LLM 实例时再加 `llmId`,本期没有。
## 2. L2 活动定义
### `GET /api/gov/activities/:activityId` —— access: `ops`
```json
{
"id": "quote-intake",
"name": "询价接收与结构化",
"biz": "报价业务",
"status": "active",
"version": "1.3.0",
"owner": { "id": "8f3c…", "name": "柴浩" },
"llm": "K3 · quote-intake",
"createdAt": "2026-08-12T01:00:00.000Z",
"ico": "inbox",
"hue": "blue",
"desc": "活动定义只读 —— 输入与输出在创建时定死,演进走版本迭代;验收在工作台,这里只看状态。",
"view": { "url": "/api/gov/activities/quote-intake/view" }
}
```
- `desc`:定义说明,创建时填,只读。
- `view`:沙箱要渲染的活动视图;**为 `null` 时前端藏起整个沙箱区块**(不再摆空 iframe)。
### `GET /api/gov/activities/:activityId/view` —— access: `ops`(支持 `?ticket=`)
→ `content-type: text/html`,服务器托管的活动视图 HTML,前端 `<iframe sandbox="allow-scripts">` 直接渲染。
iframe 带不了 Authorization 头,走票据(`GET /api/auth/ticket`,ADR-012 §6)—— 前端每次装载前取一张拼 `?ticket=`。
**内容高度用 postMessage 通讯自适应(视图脚本向父页报高),协议随第一版视图实现一起定。**404:该活动没配视图。
## 3. L2 实例清单(历史活动)
### `GET /api/gov/activities/:activityId/instances` —— access: `ops`
查询参数(可组合,AND;都不带 = 全部):
| 参数 | 形 | 说明 |
|---|---|---|
| `id` | 字符串 | 实例号**模糊**搜,如 `INS-24` |
| `ownerId` | staffId | 精确到人 |
| `state` | 五态之一 | 精确 |
| `startedFrom` / `startedTo` | `YYYY-MM-DD` | 启动日期段,**含两端整日**;可只给一端 |
| `page` | 整数,从 1 | 默认 1 |
| `size` | 整数 | 默认 8(与前端单页一致),上限 50 |
```json
{
"stats": { "total": 13, "byState": { "pending": 1, "running": 1, "completed": 7, "rejected": 2, "failed": 2 } },
"total": 13,
"page": 1,
"size": 8,
"items": [
{
"id": "INS-2417",
"state": "running",
"owner": { "id": "8f3c…", "name": "柴浩" },
"input": "Itelma 车身控制器询价 · 3 项物料",
"output": null,
"startedAt": "2026-08-25T02:12:00.000Z"
},
{
"id": "INS-2415",
"state": "completed",
"owner": { "id": "8f3c…", "name": "柴浩" },
"input": "GAZ 底盘线束询价 · 7 项物料",
"output": "quote-20260824-07.json",
"startedAt": "2026-08-24T09:03:00.000Z"
}
]
}
```
- **`stats` 是活动整体的五态统计,不随检索条件动**(页头统计带摆的是全局数);`total` 是**筛后**条数,分页器按它走。
- `input` / `output` 都是**一句话摘要**;`output` 为 `null` 前端摆 `-`。排序:启动时间倒序(新的在前)。
## 4. 启动参数表
### `GET /api/gov/activities/:activityId/launch-params` —— access: `ops`
点「启动活动」开抽屉时取。**一活动一份,前端只按此表渲染表单,不写死字段。**
```json
{
"activityId": "quote-intake",
"version": "1.3.0",
"params": [
{
"key": "customer_id",
"label": "客户",
"type": "combo",
"required": true,
"desc": "台账里有就直接选;是新客户就自己填,启动后派数据工单补档案。",
"options": [
{ "value": "cust-itelma", "label": "Itelma" },
{ "value": "cust-gaz", "label": "GAZ" }
]
},
{
"key": "request_description",
"label": "客户需求",
"type": "textarea",
"required": true,
"desc": "保留原始需求语义,据此新建需求主记录(V1)。",
"placeholder": "如:车身控制器询价 · 3 项物料,期望 9 月交期"
},
{
"key": "original_internal_pn",
"label": "原内部料号",
"type": "combo",
"required": true,
"desc": "差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。",
"placeholder": "如 PN-880501",
"options": [
{ "value": "PN-880101", "label": "PN-880101" }
]
},
{
"key": "candidate_internal_pn",
"label": "候选内部料号",
"type": "list",
"required": true,
"desc": "被评估物料,一次可传多个、逐个评估 —— 至少 1 个。",
"placeholder": "如 PN-880412"
}
]
}
```
| 字段 | 说明 |
|---|---|
| `key` | 提交时回传的字段名(接口契约,英文) |
| `label` | 表单上的中文名 |
| `type` | 控件种类:`text` 单行 / `textarea` 多行 / `select` 单选(只能选候选) / **`combo` 选择+新增**(候选末项是「新增」,选了换输入框自己填;见下方「combo 语义」) / `list` 字符串数组(一次可传多个,逐行自由填);**新增种类前要前后端一起升级**,前端遇到不认识的 type 摆「暂不支持的控件」 |
| `required` | 必填;前端在没填完时禁提交,**服务端仍要再验一道**(400) |
| `desc` | 参数描述,挂在控件下当辅助说明 |
| `placeholder` | 选填,text / textarea / combo(新增态) / list 用 |
| `options` | `select` / `combo` 必填;**两段式 `{ value, label }`** —— value 是提交的标识(如客户 ID),label 是显示名;标识即显示名时两段相同 |
| `default` | 选填;预填值(`list` 给字符串数组,其余给字符串)—— 上游触发预填场景用 |
**combo 语义(2026-08-27 用户定)**:启动表单大部分是自由输入;客户 / 料号这类「台账里可能有」的参数才用 combo —— 先选,候选里没有就新增自填。**提交候选之外的值是合法的,不拦启动**:服务端按 value 在台账里查,查不到照样启动,启动后派**数据工单**到工作台补这条档案(客户进客户台账、PN 补参数,确认即入库,活动从断点续跑)。combo 提交的值统一回字符串:选中候选回它的 `value`,新增回用户填的原样字符串 —— 服务端不靠类型区分新旧,靠台账存在性检查。
候选量大到不宜内联时(如全量物料台账选 PN),后续加 `optionsSource` 引用检索接口,**本期一律内联 `options`**。
## 5. 启动活动(创建实例)
### `POST /api/gov/activities/:activityId/instances` —— access: `ops`
```json
{
"idempotencyKey": "01J9…",
"ownerId": "8f3c1a2b-6c1e-4b3a-9f0e-2d5c7a1b9e44",
"params": {
"customer_id": "cust-itelma",
"request_description": "车身控制器询价 · 3 项物料,期望 9 月交期",
"original_internal_pn": "PN-880101",
"candidate_internal_pn": ["PN-880412", "PN-880530"]
}
}
```
**`ownerId`(必填,2026-08-27 用户定)是启动契约的固定字段,不进 §4 参数表** —— 每个活动启动都要指定这次实例的**责任人**:负责人验收、存在性检查派出的数据工单都落到责任人身上。候选名单 = 既有 `GET /api/gov/staff`(前端只列 `state='active'` 的);前端默认填当前登录人,允许改。服务端要验:ownerId 是名册里在职的人(400),不验「是不是启动人自己」(代别人启动是合法场景)。
启动成功后,实例视图(§3/§6)里的 `owner` 就是它。
→ 200 一条实例详情(同 §6)。**同一 `idempotencyKey` 重放返回第一次创建的实例,不起第二个**(前端每开一次抽屉生成一枚;重复点提交、网络重试都靠它挡住)。
`params` 的键值形状由 §4 参数表决定:`list` 回字符串数组,其余回字符串;`select` 回 `options` 里某个 `value`;**`combo` 回候选的 `value` 或新增的原样字符串,两种都合法**。
412:活动已禁用(先激活再启动)。400:缺必填 / 类型不符 / select 的值不在 options 里(**combo 不适用这条** —— 候选之外的值是合法新增)。
正常落地初始状态 `pending`(排入队列,调度器领到即转 `running`);**存在性检查(客户 / 料号在不在台账)在启动后做,查不到不拦启动** —— 按入参说明口径,缺口派数据工单到工作台逐项补(确认即入库,活动从断点续跑);响应里不单独回这件事,工单出现在工作台那一侧(工单接口不在本文范围)。
## 6. L3 实例详情
### `GET /api/gov/activities/:activityId/instances/:instanceId` —— access: `ops`
```json
{
"id": "INS-2417",
"activityId": "quote-intake",
"state": "running",
"owner": { "id": "8f3c…", "name": "柴浩" },
"llm": "K3 · quote-intake",
"startedAt": "2026-08-25T02:12:00.000Z",
"endedAt": null,
"durationMs": null,
"reason": null,
"input": {
"content": "Itelma 车身控制器询价 · 3 项物料",
"source": { "kind": "manual", "label": "手动启动 · 柴浩" },
"submittedAt": "2026-08-25T02:12:00.000Z",
"files": [
{ "index": 0, "name": "RFQ-2026-081.eml", "contentType": "message/rfc822", "size": 48210 }
]
},
"output": {
"files": [
{ "index": 0, "name": "quote-20260825-07.json", "contentType": "application/json", "size": 6102 }
]
},
"steps": [
{ "seq": 1, "name": "输入接收", "by": "agent", "byName": "K3 · quote-intake", "at": "2026-08-25T02:12:00.000Z", "durationMs": 60000, "res": "done", "note": "询价单已进入,要素识别完成,实例正式起步。" },
{ "seq": 2, "name": "要素结构化", "by": "agent", "byName": "K3 · quote-intake", "at": "2026-08-25T02:15:00.000Z", "durationMs": 360000, "res": "done", "note": "抽取客户 / 物料 / 交期等字段,落入结构化模型。" },
{ "seq": 3, "name": "定价测算", "by": "agent", "byName": "K3 · quote-intake", "at": "2026-08-25T02:21:00.000Z", "durationMs": null, "res": "current", "note": "按成本模型逐项给出建议价与毛利测算。" },
{ "seq": 4, "name": "负责人验收", "by": "human", "byName": "柴浩", "at": null, "durationMs": null, "res": "pending", "note": "确认测算结果 —— 规划类产出必须由人验收担责。" },
{ "seq": 5, "name": "输出归档", "by": "agent", "byName": "K3 · quote-intake", "at": null, "durationMs": null, "res": "pending", "note": "生成报价文件并归档,产出回写台账。" }
]
}
```
- `endedAt` / `durationMs`:未结束(null)。`durationMs` 毫秒,前端格式化(`27m`)。
- **`reason`**:终态的原因说明,`null` = 不需要(进行中 / 待执行 / 正常完成)。
`{ "kind": "rejected", "text": "验收驳回:测算毛利低于阈值,退回重新测算 —— 负责人 柴浩。" }`
`{ "kind": "failed", "text": "要素结构化失败:询价单附件缺失,已通知发起人补齐后重试。" }`
`{ "kind": "terminated", "text": "已于 08-27 10:41 由 柴浩 手动终止。" }` —— **手动终止的实例状态归 `failed`,靠 `reason.kind='terminated'` 与「自己跑挂」区分**(过程面当前步记「已终止」,不摆失败原因)。
- `input.source.kind`:`manual`(运营端手动启动)/ `upstream`(上游活动触发)/ `mail`(邮件触发)…… 枚举随触发渠道增加;`label` 是人读形式,前端直接摆。
- `steps[].res`:`done` / `fail` / `current` / `pending`;`fail` 那行的性质由 `reason.kind` 定(驳回 / 失败 / 已终止)。
- `input.files` / `output.files`:只有元数据;**下载走 §7**。没有产出时 `output.files` 是空数组(前端摆「暂无产出」)。
## 7. 实例文件下载
### `GET /api/gov/activities/:activityId/instances/:instanceId/files/:io/:index` —— access: `ops`(支持 `?ticket=`)
`:io` ∈ `input` | `output`,`:index` 对应 §6 文件行的 `index`。
→ `{ "name": "…", "contentType": "…", "dataBase64": "…" }`(与 `GET /api/gov/upload/:id` 同形);可带 `?ticket=` 用 `<a href download>`。
**本期可以只回元数据、这条路先 404** —— 前端下载钮当前就是「随接口开放」的置灰态。
## 8. 禁用 / 激活 / 终止
### `POST /api/gov/activities/:activityId/disable` —— access: `ops`
→ 200 活动视图(§2 同形)。**任何时刻都可禁用,没有前置条件**(2026-08-27 用户重定):
禁用后这个活动不再允许启动(§5 返回 412);**已完成的实例是历史留痕,进行中的实例继续跑完,都不受影响**。
已禁用再禁用 = 幂等 200。
### `POST /api/gov/activities/:activityId/activate` —— access: `ops`
→ 200 活动视图。已激活再激活 = 幂等 200。
### `POST /api/gov/activities/:activityId/instances/:instanceId/terminate` —— access: `ops`
body 空(终止理由本期不填,`reason.text` 由服务端生成)。
→ 200 实例详情(§6 同形,`state='failed'` + `reason.kind='terminated'`)。412:实例不在 `running`(已终态的不能再终止)。
## 9. Schema 附录(落地时进 `packages/contracts`,同名)
```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; 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; 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 }[] /* select / combo 必填 */; default?: string | string[] };
type LaunchParams = { activityId: string; version: string; params: LaunchParam[] };
type LaunchInput = { idempotencyKey: string; ownerId: string /* 责任人,固定字段,名册 staffId */; params: Record<string, string | string[]> };
type InstanceFile = { index: number; name: string; contentType: string; size: number };
type InstanceReason = { kind: 'rejected' | 'failed' | 'terminated'; text: 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 = InstanceSummary & { activityId: string; llm: string; endedAt: string | null; durationMs: number | null;
reason: InstanceReason | null;
input: { content: string; source: { kind: string; label: string }; submittedAt: string; files: InstanceFile[] };
output: { files: InstanceFile[] };
steps: InstanceStep[] };
```
## 10. 前端对接要点(现状 → 接口落地)
| 现状(占位) | 接口落地后 |
|---|---|
| `DEMO_ACTIVITIES` 静态数组 | §1;L1 页签切换即换 `status` 重取 |
| `demoInstances()` 每活动共用一份 | §3;检索四条件与分页全部上移到查询参数,前端删掉本地过滤/分页 |
| 统计带由前端从全量数组数 | §3 `stats`(服务端给,分页下才数得准) |
| `LAUNCH_PARAMS` 占位参数表 | §4;`options` 从纯字符串升级为 `{value,label}` 两段(渲染器小改);控件种类已含 `combo`(选择+新增,前端已实现) |
| `onLaunch` 在清单头落假行 | §5;抽屉每开一次生成 `idempotencyKey`,提交走 mutation,成功失效 `['gov']` |
| —(新增)责任人固定字段 | §5 `ownerId`;抽屉「固定参数」段已实现(名册下拉、默认当前登录人),接接口后直接随 body 走 |
| 禁用/激活/终止就地改 state | §8;mutation 成功后由服务端重投影(广播 → 失效 → 重取) |
| `demoSteps` / `terminatedSteps` 五步占位 | §6 `steps`;终止归态以 `reason.kind='terminated'` 为准,前端不再落「失败」占位 |
| 沙箱 `src="/sandbox-demo/activity-view.html"` 固定 480 高 | §2 `view.url` + 票据;`view` 为 null 藏起沙箱区;高度 postMessage 随第一版视图定 |
| 输出下载钮置灰 | §7(本期可继续 404,钮保持置灰) |