From f9b2c07731af758e04c72d00a48c5bc0bdc81539 Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Wed, 29 Jul 2026 13:08:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=89=E9=9D=A2=E6=8B=86=E5=88=86=E2=91=AC:?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E7=A1=AE=E8=AE=A4=E9=93=BE=E8=B7=AF=20+=20?= =?UTF-8?q?=E8=BF=90=E8=90=A5=E4=B8=AD=E5=BF=83=E3=80=8C=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E3=80=8D=E5=8C=BA=20=E2=80=94=E2=80=94=20=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E5=8F=AA=E7=AE=A1=E8=AE=BE=E8=AE=A1,=E8=BF=90?= =?UTF-8?q?=E8=90=A5=E7=AE=A1=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **触发确认链路**(用户口述五步的第 4/5 步): 接收邮件 → 分类识别 → 命中 RFQ 事件 → ④(可选,运营勾)系统消息到指定人 工作台(带邮件内容)→ ⑤ 工作台点「确认起单」,流程才起。 · 命中不等于必起:勾了「需人工确认」,装配好的入参存成待确认单 (sys.trigger_pending,migration 0019),ConfirmDesk 端口把带来件内容的 系统消息送指定人(eventRef 挂票号,消息列表就地贴确认状态与那枚钮)。 · 先认领后起单:consumed_at 空才认得到 —— 两人同时点、一人点两次,都只起一条流; 起单失败退回认领,不留死票。确认人记进 source.by,留痕可查。 · 登记触发不走确认(登记那一下本身就是人在确认);勾了没指定人 = 配置错误入死信。 · 4 条单测 + 全链实测:投信 → triggersHit=1 且零直起 → AI 工作台收到确认消息 → 确认起单 → 二次确认幂等回同一流号 → source.by = AI。 **运营中心重排**(区名即运行时:工作流是流程包的运行时,任务是活动的运行时): · 新「工作流」区:在途看板(从任务区迁入)+ 运行配置 —— 每包的触发器 起单开关 / 确认打勾 / 确认人,和节点负责人(sys.node_owner)。 · 负责人三层:显式指派 > 运营配置 > 轮值 —— 合并点在 process.start, 复用既有派发链一行不改。 · 任务区瘦身成活动运行时(任务/审批/需处置);知识库区改名「数据」 (与设计中心的知识库解开同名);系统区只剩通道实例。 · 设计中心触发器详情页摘掉启停按钮 —— 设计只管设计,启停/确认全在运营。 · 旧深链有改嫁:tasks/board → runs/board,tasks/trg → runs/conf。 reset-runs 补 sys.trigger_pending(待确认单是运行痕 —— 和上回漏 trace_span 同类洞)。 邮件分类 Agent(第 ② 步)现由抽键正则 + S-FEEL 代位,升级路径记进 debt.md。 产品设计.md 运营章、trigger/process 两份 CONTRACT.md 同步。 五闸绿;契约快照随本提交入库。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../api/src/modules/process/contract.d.ts | 2 + .../apps/api/src/modules/process/port.d.ts | 2 + .../api/src/modules/trigger/contract.d.ts | 35 ++- .../apps/api/src/modules/trigger/port.d.ts | 27 +++ .../contracts/src/api/governance.d.ts | 59 +++++ .../packages/contracts/src/api/workbench.d.ts | 22 ++ .../contracts/src/process-package.d.ts | 4 + apps/api/src/app/reset-runs.ts | 2 + apps/api/src/app/routes/desk.ts | 48 ++++ apps/api/src/app/routes/governance-ops.ts | 46 ++++ apps/api/src/app/wiring/engines.ts | 20 ++ apps/api/src/modules/process/CONTRACT.md | 3 + .../process/__tests__/save-package.test.ts | 2 + .../process/__tests__/start-guard.test.ts | 2 + apps/api/src/modules/process/contract.ts | 4 + .../internal/adapter/process-schema.ts | 11 + .../process/internal/adapter/process-store.ts | 12 +- .../internal/usecase/process-service.ts | 17 +- apps/api/src/modules/process/port.ts | 4 + apps/api/src/modules/trigger/CONTRACT.md | 6 + .../modules/trigger/__tests__/confirm.test.ts | 175 +++++++++++++++ apps/api/src/modules/trigger/contract.ts | 28 ++- .../internal/adapter/trigger-schema.ts | 25 +++ .../trigger/internal/adapter/trigger-store.ts | 69 +++++- .../internal/usecase/trigger-service.ts | 85 ++++++- apps/api/src/modules/trigger/port.ts | 27 +++ apps/web/src/app/gov-shell.tsx | 2 + apps/web/src/app/styles/console.css | 15 ++ .../desk-message/internal/MessageDesk.tsx | 46 +++- apps/web/src/features/gov-runs/index.ts | 5 + .../src/features/gov-runs/internal/panel.tsx | 211 ++++++++++++++++++ apps/web/src/features/gov-task/index.ts | 2 +- .../src/features/gov-task/internal/panel.tsx | 21 +- apps/web/src/features/gov-trigger/index.ts | 2 +- .../features/gov-trigger/internal/panel.tsx | 80 +------ .../src/shared/gov/__tests__/surfaces.test.ts | 3 +- apps/web/src/shared/gov/gov-route.ts | 28 ++- apps/web/src/shared/gov/surfaces.ts | 6 +- db/migrations/0019_trigger_confirm.sql | 32 +++ db/migrations/meta/_journal.json | 7 + docs/debt.md | 11 + docs/产品设计.md | 30 ++- packages/contracts/src/api/governance.ts | 29 +++ packages/contracts/src/api/workbench.ts | 15 ++ packages/contracts/src/process-package.ts | 9 + 45 files changed, 1176 insertions(+), 115 deletions(-) create mode 100644 apps/api/src/modules/trigger/__tests__/confirm.test.ts create mode 100644 apps/web/src/features/gov-runs/index.ts create mode 100644 apps/web/src/features/gov-runs/internal/panel.tsx create mode 100644 db/migrations/0019_trigger_confirm.sql diff --git a/api-report/apps/api/src/modules/process/contract.d.ts b/api-report/apps/api/src/modules/process/contract.d.ts index 5740333..46ec3b4 100644 --- a/api-report/apps/api/src/modules/process/contract.d.ts +++ b/api-report/apps/api/src/modules/process/contract.d.ts @@ -54,6 +54,8 @@ export type ProcessService = { getWorkflow(id: string): Promise; listWorkflows(limit?: number): Promise; instancesOf(workflowId: string): Promise; + nodeOwners(packageId: string): Promise>; + setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise; /** 任务终态到达:完成即继续流转;非完成即走声明的失败走向 */ onTaskTerminal(taskId: string, terminal: TerminalState, ports: Record>): Promise; applyFailure(workflowId: string, nodeId: string, instance: number, node: Extract; + nodeOwners(packageId: string): Promise>; + setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise; }; /** 活动契约面 —— app 注入 `activity-registry`(L4):编排校验与起单都要它 */ export type ActivityDirectory = { diff --git a/api-report/apps/api/src/modules/trigger/contract.d.ts b/api-report/apps/api/src/modules/trigger/contract.d.ts index c82ecea..51c7222 100644 --- a/api-report/apps/api/src/modules/trigger/contract.d.ts +++ b/api-report/apps/api/src/modules/trigger/contract.d.ts @@ -15,9 +15,30 @@ export declare class TriggerInputMissingError extends Error { export type FireOutcome = { readonly triggerId: string; readonly workflowId?: string; - readonly skipped?: 'duplicate' | 'disabled' | 'input-missing'; + /** 命中但勾了「需人工确认」:没起单,等指定人点头 —— 这一票的号 */ + readonly pendingId?: string; + readonly skipped?: 'duplicate' | 'disabled' | 'input-missing' | 'no-confirmer'; readonly missing?: readonly string[]; }; +/** 待确认单:装配好的一次起单,等指定人点「确认起单」 */ +export type PendingFire = { + readonly id: string; + readonly triggerId: string; + readonly packageId: string; + /** 确认人快照 —— 确认路由按它认门 */ + readonly staffId: string; + readonly subject: string; + readonly data: Record; + readonly source: { + readonly kind: 'periodic' | 'event' | 'registration'; + readonly ref: string; + readonly by?: string; + }; + readonly preview: Readonly>; + readonly createdAt: string; + readonly consumedAt?: string; + readonly workflowId?: string; +}; export type TriggerService = { list(): Promise; upsert(config: TriggerConfig): Promise; @@ -31,4 +52,16 @@ export type TriggerService = { fireByRegistration(triggerId: string, by: string, data: Record): Promise; /** 周期触发的到点扫描(由 pg-boss 定时调起) */ sweepPeriodic(now: Date): Promise; + /** 运营口:要不要人工确认、谁来确认 —— 与设计面的 upsert 分口 */ + setConfirm(id: string, confirm: { + required: boolean; + staffId?: string; + }): Promise; + /** 指定人点头 —— 幂等:已被确认过就回那条流的号,绝不起第二条 */ + confirmPending(pendingId: string, by: string): Promise<{ + workflowId: string; + alreadyStarted: boolean; + }>; + /** 待确认单批读 —— 工作台消息列表就地贴确认状态用 */ + pendingByIds(ids: readonly string[]): Promise; }; diff --git a/api-report/apps/api/src/modules/trigger/port.d.ts b/api-report/apps/api/src/modules/trigger/port.d.ts index 1a24a92..f1ebd3c 100644 --- a/api-report/apps/api/src/modules/trigger/port.d.ts +++ b/api-report/apps/api/src/modules/trigger/port.d.ts @@ -2,6 +2,7 @@ * module: trigger —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 */ import type { TriggerConfig } from '@contracts'; +import type { PendingFire } from './contract.ts'; export type Clock = { now(): Date; }; @@ -13,11 +14,37 @@ export type TriggerStore = { get(id: string): Promise; upsert(config: TriggerConfig): Promise; setEnabled(id: string, enabled: boolean): Promise; + /** 运营口:确认配置 —— 设计面的 upsert 不碰这两列 */ + setConfirm(id: string, confirm: { + required: boolean; + staffId?: string; + }): Promise; remove(id: string): Promise; /** 幂等:同一键至多起一单 */ claim(key: string): Promise; lastFiredAt(id: string): Promise; markFired(id: string, at: Date): Promise; + createPending(pending: PendingFire): Promise; + getPending(id: string): Promise; + pendingByIds(ids: readonly string[]): Promise; + /** 认领:consumed_at 空才认得到 —— 两个人同时点确认,只有一个赢,绝不起两条流 */ + claimPending(id: string, at: Date): Promise; + /** 起单成功后把流号写回;起单失败时用 releasePending 把认领退回去 */ + setPendingWorkflow(id: string, workflowId: string): Promise; + releasePending(id: string): Promise; +}; +/** + * 确认台 —— app 注入 `message`(L2):命中但要人点头时, + * 把带来件内容的系统消息送到指定人的工作台。 + */ +export type ConfirmDesk = { + request(input: { + pendingId: string; + staffId: string; + triggerName: string; + subject: string; + preview: Readonly>; + }): Promise; }; /** 起单 —— app 注入 `process`(L7 同层?否:trigger 在 process 之上同层,经 app 装配) */ export type ProcessGateway = { diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 0f1086c..15c4fe9 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -1325,6 +1325,10 @@ export declare const listTriggersRoute: { sfeel: z.ZodString; }, z.core.$strip>], "from">; }, z.core.$strip>>>; + confirm: z.ZodDefault; + staffId: z.ZodOptional>; + }, z.core.$strip>>; spec: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"periodic-calendar">; calendar: z.ZodString; @@ -1400,6 +1404,10 @@ export declare const upsertTriggerRoute: { sfeel: z.ZodString; }, z.core.$strip>], "from">; }, z.core.$strip>>>; + confirm: z.ZodDefault; + staffId: z.ZodOptional>; + }, z.core.$strip>>; spec: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"periodic-calendar">; calendar: z.ZodString; @@ -1451,6 +1459,10 @@ export declare const upsertTriggerRoute: { sfeel: z.ZodString; }, z.core.$strip>], "from">; }, z.core.$strip>>>; + confirm: z.ZodDefault; + staffId: z.ZodOptional>; + }, z.core.$strip>>; spec: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"periodic-calendar">; calendar: z.ZodString; @@ -2123,6 +2135,53 @@ export declare const listApprovalsRoute: { * 起一条流(测试台专用):**带节点指派**把整条流定向到指定账号 —— * 真实任务照旧落到那个人的工作台,测试台只管起单与观测。 */ +/** + * 触发器的**运营配置**:要不要人工确认、谁来确认 —— 与设计面的 upsert 分口, + * 设计动规则不碰这两项,运营动这两项不碰规则。 + */ +export declare const setTriggerConfirmRoute: { + readonly method: "POST"; + readonly path: "/api/gov/triggers/:id/confirm"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + required: z.ZodBoolean; + staffId: z.ZodOptional; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + }, z.core.$strip>; +}; +/** 节点负责人(运营配置):流程包的哪个节点归谁 —— 显式指派 > 这里 > 轮值 */ +export declare const listNodeOwnersRoute: { + readonly method: "GET"; + readonly path: "/api/gov/packages/:id/owners"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + owners: z.ZodArray>; + }, z.core.$strip>; +}; +export declare const setNodeOwnerRoute: { + readonly method: "PUT"; + readonly path: "/api/gov/packages/:id/owners/:nodeId"; + readonly params: z.ZodObject<{ + id: z.ZodString; + nodeId: z.ZodString; + }, z.core.$strip>; + /** null = 撤掉运营指定,回到轮值 */ + readonly body: z.ZodObject<{ + staffId: z.ZodNullable; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + }, z.core.$strip>; +}; export declare const testRunRoute: { readonly method: "POST"; readonly path: "/api/gov/testbed/run"; diff --git a/api-report/packages/contracts/src/api/workbench.d.ts b/api-report/packages/contracts/src/api/workbench.d.ts index ba16655..bc89b7b 100644 --- a/api-report/packages/contracts/src/api/workbench.d.ts +++ b/api-report/packages/contracts/src/api/workbench.d.ts @@ -2352,6 +2352,11 @@ export declare const systemMessageSchema: z.ZodObject<{ rejectedReason: z.ZodOptional; supersededBy: z.ZodOptional; }, z.core.$strip>>; + confirm: z.ZodOptional; + }, z.core.$strip>>; }, z.core.$strip>; export type SystemMessageView = z.infer; export declare const listMessagesRoute: { @@ -2400,6 +2405,11 @@ export declare const listMessagesRoute: { rejectedReason: z.ZodOptional; supersededBy: z.ZodOptional; }, z.core.$strip>>; + confirm: z.ZodOptional; + }, z.core.$strip>>; }, z.core.$strip>>; }, z.core.$strip>; }; @@ -2427,6 +2437,18 @@ export declare const answerMessageRoute: { ok: z.ZodBoolean; }, z.core.$strip>; }; +/** 确认起单 —— 幂等:已被确认过就如实说,并回那条流的号,绝不起第二条 */ +export declare const confirmTriggerPendingRoute: { + readonly method: "POST"; + readonly path: "/api/desk/trigger-pending/:id/confirm"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + workflowId: z.ZodString; + alreadyStarted: z.ZodBoolean; + }, z.core.$strip>; +}; export type ListMessagesOutput = z.infer; export declare const minuteResolutionSchema: z.ZodObject<{ id: z.ZodString; diff --git a/api-report/packages/contracts/src/process-package.d.ts b/api-report/packages/contracts/src/process-package.d.ts index 8d0db09..553145a 100644 --- a/api-report/packages/contracts/src/process-package.d.ts +++ b/api-report/packages/contracts/src/process-package.d.ts @@ -480,6 +480,10 @@ export declare const triggerConfigSchema: z.ZodObject<{ sfeel: z.ZodString; }, z.core.$strip>], "from">; }, z.core.$strip>>>; + confirm: z.ZodDefault; + staffId: z.ZodOptional>; + }, z.core.$strip>>; spec: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"periodic-calendar">; calendar: z.ZodString; diff --git a/apps/api/src/app/reset-runs.ts b/apps/api/src/app/reset-runs.ts index be9c2be..bd23a60 100644 --- a/apps/api/src/app/reset-runs.ts +++ b/apps/api/src/app/reset-runs.ts @@ -37,6 +37,8 @@ const RUN_TABLES = [ 'sys.outbound_mail', 'sys.outbound_ref', 'sys.trigger_claim', + // 待确认单是运行痕:命中未确认的那一票随运行数据一起清 —— 配置(trigger/node_owner)不动 + 'sys.trigger_pending', 'sys.report', 'sys.replay_model', 'sys.replay_tool', diff --git a/apps/api/src/app/routes/desk.ts b/apps/api/src/app/routes/desk.ts index 648f7d6..4fccdaf 100644 --- a/apps/api/src/app/routes/desk.ts +++ b/apps/api/src/app/routes/desk.ts @@ -9,6 +9,7 @@ import { ATTRIBUTION_SIDE_LABEL, answerMessageRoute, chatCardReplySchema, + confirmTriggerPendingRoute, listMessagesRoute, markMessageReadRoute, listTasksRoute, @@ -308,6 +309,15 @@ export function registerDeskRoutes(app: Hono, container: Container): voi return c.json(body, 401); } const messages = await container.message.inbox(staff.id); + /* + * 待确认起单(触发确认链路):`eventRef` 挂着待确认单号的消息, + * 就地贴出那一票的状态 —— 界面靠它决定「确认起单」钮是活的还是已收线。 + */ + const pendingIds = messages + .map((m) => m.eventRef) + .filter((r): r is string => r !== undefined && r.startsWith('trigger-pending:')) + .map((r) => r.slice('trigger-pending:'.length)); + const pendings = new Map((await container.trigger.pendingByIds(pendingIds)).map((x) => [x.id, x])); return c.json({ messages: messages.map((m) => ({ id: m.id, @@ -316,6 +326,19 @@ export function registerDeskRoutes(app: Hono, container: Container): voi body: m.body, at: m.at, read: m.readAt !== undefined, + ...(() => { + const ref = m.eventRef; + if (ref === undefined || !ref.startsWith('trigger-pending:')) return {}; + const pending = pendings.get(ref.slice('trigger-pending:'.length)); + if (pending === undefined) return {}; + return { + confirm: { + pendingId: pending.id, + consumed: pending.consumedAt !== undefined, + ...(pending.workflowId === undefined ? {} : { workflowId: pending.workflowId }), + }, + }; + })(), // 跨任务征答卡随消息一并出:就地作答,不必跳进别人的任务 ...(m.card === undefined ? {} @@ -334,6 +357,31 @@ export function registerDeskRoutes(app: Hono, container: Container): voi }); }); + /** 确认起单(触发确认链路第 5 步)—— 幂等;只有被点名的确认人按得动 */ + app.post(confirmTriggerPendingRoute.path, async (c) => { + const staff = await me(c.get('account')); + if (staff === undefined) { + const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; + return c.json(body, 401); + } + const id = c.req.param('id'); + const pending = (await container.trigger.pendingByIds([id]))[0]; + if (pending === undefined) { + const body: ApiError = { code: 'not-found', message: `无此待确认单:${id}` }; + return c.json(body, 404); + } + if (pending.staffId !== staff.id) { + const body: ApiError = { code: 'forbidden', message: '这一票点名的确认人不是你' }; + return c.json(body, 403); + } + try { + return c.json(await container.trigger.confirmPending(id, staff.id)); + } catch (error) { + const body: ApiError = { code: 'validation-failed', message: error instanceof Error ? error.message : String(error) }; + return c.json(body, 400); + } + }); + app.post(markMessageReadRoute.path, async (c) => { const staff = await me(c.get('account')); if (staff === undefined) { diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts index 624ed95..e02de48 100644 --- a/apps/api/src/app/routes/governance-ops.ts +++ b/apps/api/src/app/routes/governance-ops.ts @@ -35,6 +35,9 @@ import { resumeTaskRoute, staffIdSchema, taskIdSchema, + setNodeOwnerRoute, + setTriggerConfirmRoute, + listNodeOwnersRoute, toggleTriggerRoute, upsertTriggerRoute, validateProcessRoute, @@ -549,6 +552,49 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai return c.json({ ok: true }); }); + /** + * 触发器的**运营配置**:要不要人工确认、谁来确认 —— 与设计面的 upsert 分口, + * 设计动规则不碰这两项,运营动这两项不碰规则。 + */ + app.post(setTriggerConfirmRoute.path, async (c) => { + const denied = await governTrigger(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const parsed = setTriggerConfirmRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; + return c.json(body, 400); + } + if (parsed.data.required && parsed.data.staffId === undefined) { + const body: ApiError = { code: 'validation-failed', message: '勾了「需人工确认」就得指定确认人 —— 不然命中的单没人点头,全进死信' }; + return c.json(body, 400); + } + await container.trigger.setConfirm(c.req.param('id'), { + required: parsed.data.required, + ...(parsed.data.staffId === undefined ? {} : { staffId: parsed.data.staffId }), + }); + return c.json({ ok: true }); + }); + + /** 节点负责人(运营配置):显式指派 > 这里 > 轮值 */ + app.get(listNodeOwnersRoute.path, async (c) => { + const denied = await readProcess(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const owners = await container.process.nodeOwners(c.req.param('id')); + return c.json({ owners: Object.entries(owners).map(([nodeId, staffId]) => ({ nodeId, staffId })) }); + }); + + app.put(setNodeOwnerRoute.path, async (c) => { + const denied = await governProcess(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const parsed = setNodeOwnerRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; + return c.json(body, 400); + } + await container.process.setNodeOwner(c.req.param('id'), c.req.param('nodeId'), parsed.data.staffId ?? undefined); + return c.json({ ok: true }); + }); + /** * 调起规则的定义面(设计中心)—— 新建 / 改规则 / 改起单装配。 * 启停不走这里(那是运营的事,见 toggle);改定义同样不动任何流程包版本。 diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 6e7eaca..691cd9f 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -945,6 +945,26 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { }, }, sfeel, + /* + * 确认台:命中但勾了「需人工确认」—— 带来件内容的系统消息送到指定人工作台, + * `eventRef` 挂待确认单号,工作台消息列表凭它贴出「确认起单」那枚钮。 + */ + confirmDesk: { + request: async (input) => { + const lines = [ + `触发器「${input.triggerName}」命中一件来件,按运营配置须你确认后才起单。`, + input.preview['from'] === undefined ? undefined : `发件人:${input.preview['from']}`, + input.preview['subject'] === undefined ? undefined : `主题:${input.preview['subject']}`, + input.preview['body'] === undefined ? undefined : `\n${input.preview['body'].slice(0, 600)}`, + ].filter((l): l is string => l !== undefined); + await deps.message.notify({ + staffId: input.staffId as never, + title: `确认起单:${input.subject}`, + body: lines.join('\n'), + eventRef: `trigger-pending:${input.pendingId}`, + }); + }, + }, deadLetters: { // 起单失败入死信 —— 落日志 + 送治理台的消息要素,不静默丢弃 report: async (input) => { diff --git a/apps/api/src/modules/process/CONTRACT.md b/apps/api/src/modules/process/CONTRACT.md index 71ef2dc..53c0f83 100644 --- a/apps/api/src/modules/process/CONTRACT.md +++ b/apps/api/src/modules/process/CONTRACT.md @@ -25,6 +25,9 @@ 被依赖: trigger, app(治理台 · 工作流管理) 不变量(OS 03 / 3.2): + - **节点负责人三层**(2026-07-30):起单时合并 —— 显式指派(start 入参,测试台定向)> + 运营配置(`sys.node_owner`,运营中心 · 工作流里配)> 轮值(员工组 → 全员最闲者先)。 + 运营配置不随包版本 —— 它说的是「这家企业现在这活谁管」,不是流程长什么样。 - **编辑即其身**(产品决策 2026-07-28 第四轮):`savePackage` 对已存在的版本 **就地写回**(号不变、状态不变);新版本只出自显式新建(`asNew: true`)或 新流程首版(插入,`retired` 不生效)。两道底线: diff --git a/apps/api/src/modules/process/__tests__/save-package.test.ts b/apps/api/src/modules/process/__tests__/save-package.test.ts index 4c386bd..86d1dd8 100644 --- a/apps/api/src/modules/process/__tests__/save-package.test.ts +++ b/apps/api/src/modules/process/__tests__/save-package.test.ts @@ -58,6 +58,8 @@ function harness(seed: ProcessPackage[], pinned = 0) { insertInstance: async () => undefined, updateInstance: async () => undefined, findInstanceByTask: async () => undefined, + nodeOwners: async () => ({}), + setNodeOwner: async () => undefined, }, activities: { faces: async () => new Map() }, org: { diff --git a/apps/api/src/modules/process/__tests__/start-guard.test.ts b/apps/api/src/modules/process/__tests__/start-guard.test.ts index 25b97df..b669213 100644 --- a/apps/api/src/modules/process/__tests__/start-guard.test.ts +++ b/apps/api/src/modules/process/__tests__/start-guard.test.ts @@ -69,6 +69,8 @@ function harness(validationErrors: readonly string[]): Harness { }, updateInstance: async () => undefined, findInstanceByTask: async () => undefined, + nodeOwners: async () => ({}), + setNodeOwner: async () => undefined, }, activities: { faces: async () => new Map() }, org: { positions: async () => new Set(), candidates: async () => [], workGroupBinding: async () => undefined, isActive: async () => true }, diff --git a/apps/api/src/modules/process/contract.ts b/apps/api/src/modules/process/contract.ts index 369941f..8796acb 100644 --- a/apps/api/src/modules/process/contract.ts +++ b/apps/api/src/modules/process/contract.ts @@ -79,6 +79,10 @@ export type ProcessService = { listWorkflows(limit?: number): Promise; instancesOf(workflowId: string): Promise; + /* ── 节点负责人(运营配置):显式指派 > 这里 > 轮值 ── */ + nodeOwners(packageId: string): Promise>; + setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise; + /** 任务终态到达:完成即继续流转;非完成即走声明的失败走向 */ onTaskTerminal( taskId: string, diff --git a/apps/api/src/modules/process/internal/adapter/process-schema.ts b/apps/api/src/modules/process/internal/adapter/process-schema.ts index 3a0ff48..a407d41 100644 --- a/apps/api/src/modules/process/internal/adapter/process-schema.ts +++ b/apps/api/src/modules/process/internal/adapter/process-schema.ts @@ -63,3 +63,14 @@ export const nodeInstanceTable = sys.table( index('node_instance_task_idx').on(t.taskId), ], ); + +/** + * 节点负责人(**运营配置**,2026-07-30):流程包的哪个节点归谁。 + * 起单时垫在显式指派之下、轮值之上 —— 显式指派 > 这里 > 员工组轮值 > 全员轮值。 + * 不随包版本:它说的是「这家企业现在这活谁管」,不是流程长什么样。 + */ +export const nodeOwnerTable = sys.table('node_owner', { + packageId: text('package_id').notNull(), + nodeId: text('node_id').notNull(), + staffId: text('staff_id').notNull(), +}); diff --git a/apps/api/src/modules/process/internal/adapter/process-store.ts b/apps/api/src/modules/process/internal/adapter/process-store.ts index c26831c..363447a 100644 --- a/apps/api/src/modules/process/internal/adapter/process-store.ts +++ b/apps/api/src/modules/process/internal/adapter/process-store.ts @@ -7,7 +7,7 @@ import { processPackageSchema } from '@contracts'; import type { Db } from '@shared/db.ts'; import type { ProcessStore, WorkflowRecord } from '../../port.ts'; import type { NodeInstance } from '../domain/owner-resolution.ts'; -import { nodeInstanceTable, processPackageTable, workflowTable } from './process-schema.ts'; +import { nodeInstanceTable, processPackageTable, workflowTable, nodeOwnerTable } from './process-schema.ts'; type WorkflowRow = typeof workflowTable.$inferSelect; @@ -195,6 +195,16 @@ export function createProcessStore(db: Db): ProcessStore { ); }, + async nodeOwners(packageId) { + const rows = await db.select().from(nodeOwnerTable).where(eq(nodeOwnerTable.packageId, packageId)); + return Object.fromEntries(rows.map((r) => [r.nodeId, r.staffId])); + }, + async setNodeOwner(packageId, nodeId, staffId) { + // 一节点至多一位运营负责人:先清后写;undefined = 撤掉,回到轮值 + await db.delete(nodeOwnerTable).where(and(eq(nodeOwnerTable.packageId, packageId), eq(nodeOwnerTable.nodeId, nodeId))); + if (staffId !== undefined) await db.insert(nodeOwnerTable).values({ packageId, nodeId, staffId }); + }, + async findInstanceByTask(taskId) { const rows = await db .select() diff --git a/apps/api/src/modules/process/internal/usecase/process-service.ts b/apps/api/src/modules/process/internal/usecase/process-service.ts index bfd4143..7b68493 100644 --- a/apps/api/src/modules/process/internal/usecase/process-service.ts +++ b/apps/api/src/modules/process/internal/usecase/process-service.ts @@ -189,6 +189,12 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { const binding = input.source.by === undefined ? undefined : await deps.org.workGroupBinding(input.source.by); + /* + * 负责人合并(2026-07-30):运营中心 · 工作流里配的「节点负责人」垫在显式指派之下。 + * 显式指派赢 —— 测试台点名是这一单的意图;运营配置是常态,轮值是兜底。 + */ + const opsOwners = await deps.store.nodeOwners(input.packageId); + const assignments: Record = { ...opsOwners, ...input.assignments }; const record: WorkflowRecord = { id: deps.ids.next(), packageId: pkg.id, @@ -201,8 +207,8 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { vars: {}, startedAt: deps.clock.now().toISOString(), ...(binding === undefined ? {} : { workGroup: { ...binding } }), - // 节点指派:起单时定死某几个节点归谁(测试台定向);不传即照旧轮值 - ...(input.assignments === undefined ? {} : { assignments: { ...input.assignments } }), + // 节点指派三层:显式指派(测试台定向)> 运营配置的节点负责人 > 轮值 + ...(Object.keys(assignments).length === 0 ? {} : { assignments }), }; await deps.store.insertWorkflow(record); await deps.events.publish({ workflowId: record.id, kind: 'workflow-started', detail: { source: input.source } }); @@ -223,6 +229,13 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { return deps.store.listInstances(workflowId); }, + async nodeOwners(packageId) { + return deps.store.nodeOwners(packageId); + }, + async setNodeOwner(packageId, nodeId, staffId) { + await deps.store.setNodeOwner(packageId, nodeId, staffId); + }, + /* * 执行面五方法 —— 全部委托 FlowRuntime(M5-1 切面): * 语义、留痕与副作用编排住在运行时实现里,薄壳只转发。 diff --git a/apps/api/src/modules/process/port.ts b/apps/api/src/modules/process/port.ts index 8422604..f8da518 100644 --- a/apps/api/src/modules/process/port.ts +++ b/apps/api/src/modules/process/port.ts @@ -93,6 +93,10 @@ export type ProcessStore = { patch: Partial & { taskId?: string }, ): Promise; findInstanceByTask(taskId: string): Promise<{ workflowId: string; nodeId: string; instance: number } | undefined>; + + /* ── 节点负责人(运营配置) ──────────────────────────────── */ + nodeOwners(packageId: string): Promise>; + setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise; }; /** 活动契约面 —— app 注入 `activity-registry`(L4):编排校验与起单都要它 */ diff --git a/apps/api/src/modules/trigger/CONTRACT.md b/apps/api/src/modules/trigger/CONTRACT.md index 8788ac0..59d525b 100644 --- a/apps/api/src/modules/trigger/CONTRACT.md +++ b/apps/api/src/modules/trigger/CONTRACT.md @@ -19,6 +19,12 @@ 被依赖: event(经 `TriggerMatcher` 端口,由 app 注入 —— event 是 L1,不反向依赖本模块)、app 不变量(OS 3.1 / 6.3): + - **命中不等于必起**(2026-07-30 触发确认链路):触发器可勾「需人工确认」(运营配置, + 设计面的 upsert 不写 confirm 两列,只有 setConfirm 动)。勾了,命中后装配好的入参 + 存成待确认单(`sys.trigger_pending`),带来件内容的系统消息(`ConfirmDesk` 端口)送 + 指定人工作台;`confirmPending` 才起单。**先认领后起单**(consumed_at 空才认得到), + 并发确认只有一个赢;起单失败退回认领,不留死票。登记触发不走确认 —— + 登记那一下本身就是人在确认;勾了确认没指定人 = 配置错误入死信,不静默直起。 - **触发器与流程包平级**:配置独立存放,增删启停**不改流程包的任何版本** - **只负责起单**:经调用接口调起流程包;流程包与活动内部不含任何触发逻辑 - **同一事件流,不另设通路**:`match` 供 event 的三级消费判定使用 diff --git a/apps/api/src/modules/trigger/__tests__/confirm.test.ts b/apps/api/src/modules/trigger/__tests__/confirm.test.ts new file mode 100644 index 0000000..8c462ac --- /dev/null +++ b/apps/api/src/modules/trigger/__tests__/confirm.test.ts @@ -0,0 +1,175 @@ +/** + * 触发确认链路(2026-07-30,用户口述五步的第 4/5 步)—— + * 勾了「需人工确认」,**命中不等于起单**: + * 命中 → 装配好的入参存成待确认单 + 系统消息到指定人工作台 → 他点头 → 才起单。 + * + * 这里守四条命门: + * ① 勾了确认:不起单,发确认消息,入参存的是**装配后**的(确认时不重新装配); + * ② 点头:起单、来源记确认人、幂等 —— 第二次点头回同一条流,绝不起两条; + * ③ 并发点头:store 认领只让一个赢,输家拿到的是赢家那条流; + * ④ 登记触发不走确认 —— 登记那一下本身就是人在确认;没指定确认人是配置错误,入死信。 + */ +import { describe, expect, it } from 'vitest'; +import type { TriggerConfig } from '@contracts'; +import type { PendingFire } from '../contract.ts'; +import { createTriggerService } from '../internal/usecase/trigger-service.ts'; +import type { TriggerServiceDeps } from '../internal/usecase/trigger-service.ts'; + +const CONFIG: TriggerConfig = { + id: 'trg-1', + name: '客户RFQ询价', + enabled: true, + processPackageId: 'unified-quote', + mapping: [ + { source: { from: 'expression', sfeel: 'keys.customerCode' }, target: 'customerCode' }, + { source: { from: 'expression', sfeel: 'subject' }, target: 'subject' }, + ], + confirm: { required: true, staffId: 'staff-bd' }, + spec: { kind: 'event', eventType: 'data', rule: 'true' }, +} as TriggerConfig; + +type Harness = { + service: ReturnType; + pendings: Map; + started: Array<{ subject: string; by?: string; data: Record }>; + desk: Array<{ pendingId: string; staffId: string }>; + dead: string[]; +}; + +function harness(config: TriggerConfig, options?: { failFirstStart?: boolean }): Harness { + const pendings = new Map(); + const started: Harness['started'] = []; + const desk: Harness['desk'] = []; + const dead: string[] = []; + let seq = 0; + let failed = false; + + const deps: TriggerServiceDeps = { + store: { + list: async () => [config], + get: async () => config, + upsert: async (c) => c, + setEnabled: async () => undefined, + setConfirm: async () => undefined, + remove: async () => undefined, + claim: async () => true, + lastFiredAt: async () => undefined, + markFired: async () => undefined, + createPending: async (p) => { + pendings.set(p.id, p); + }, + getPending: async (id) => pendings.get(id), + pendingByIds: async (ids) => ids.flatMap((id) => (pendings.has(id) ? [pendings.get(id)!] : [])), + claimPending: async (id, at) => { + const p = pendings.get(id); + if (p === undefined || p.consumedAt !== undefined) return false; + pendings.set(id, { ...p, consumedAt: at.toISOString() }); + return true; + }, + setPendingWorkflow: async (id, workflowId) => { + pendings.set(id, { ...pendings.get(id)!, workflowId }); + }, + releasePending: async (id) => { + const { consumedAt: _drop, ...rest } = pendings.get(id)!; + pendings.set(id, rest as PendingFire); + }, + }, + process: { + start: async (input) => { + if (options?.failFirstStart === true && started.length === 0 && failed === false) { + failed = true; + throw new Error('起单炸了(注入)'); + } + started.push({ subject: input.subject, ...(input.source.by === undefined ? {} : { by: input.source.by }), data: input.data }); + return { id: `wf-${started.length}` }; + }, + requiredInputs: async () => ['customerCode'], + }, + sfeel: { + condition: () => true, + expression: (expr, scope) => expr.split('.').reduce((acc, seg) => (acc as Record)?.[seg], scope), + }, + confirmDesk: { + request: async (input) => { + desk.push({ pendingId: input.pendingId, staffId: input.staffId }); + }, + }, + deadLetters: { + report: async (input) => { + dead.push(input.reason); + }, + }, + ledger: { scan: async () => [] }, + clock: { now: () => new Date('2026-07-30T00:00:00.000Z') }, + ids: { next: () => `id-${(seq += 1)}` }, + }; + + return { service: createTriggerService(deps), pendings, started, desk, dead }; +} + +const SCOPE = { + type: 'data', + keys: { customerCode: 'C014' }, + payload: { subject: '【询价】C014', body: '正文', from: 'buyer@x.com' }, + source: 'channel', + occurredAt: '2026-07-30T00:00:00.000Z', +} as const; + +describe('触发确认链路', () => { + it('① 勾了确认:命中不起单 —— 存待确认单 + 消息到指定人,入参是装配后的', async () => { + const h = harness(CONFIG); + const out = await h.service.fireByEvent('trg-1', 'evt-1', SCOPE); + + expect(out.workflowId, '不该直接起单').toBeUndefined(); + expect(out.pendingId).toBeDefined(); + expect(h.started).toHaveLength(0); + expect(h.desk).toEqual([{ pendingId: out.pendingId, staffId: 'staff-bd' }]); + + const pending = h.pendings.get(out.pendingId!)!; + expect(pending.data['customerCode'], '存的是装配后的入参,确认时不重新装配').toBe('C014'); + expect(pending.preview['from'], '消息要带来件内容').toBe('buyer@x.com'); + expect(pending.staffId, '确认人快照在票上').toBe('staff-bd'); + }); + + it('② 点头起单:来源记确认人;第二次点头回同一条流,绝不起两条', async () => { + const h = harness(CONFIG); + const { pendingId } = await h.service.fireByEvent('trg-1', 'evt-1', SCOPE); + + const first = await h.service.confirmPending(pendingId!, 'staff-bd'); + expect(first.alreadyStarted).toBe(false); + expect(h.started).toHaveLength(1); + expect(h.started[0]!.by, '这条流是谁点头放行的,留痕要看得到').toBe('staff-bd'); + + const second = await h.service.confirmPending(pendingId!, 'staff-bd'); + expect(second.alreadyStarted).toBe(true); + expect(second.workflowId).toBe(first.workflowId); + expect(h.started, '幂等 —— 只起一条').toHaveLength(1); + }); + + it('③ 起单失败把认领退回去 —— 这一票还能再确认,不变成死票', async () => { + const h = harness(CONFIG, { failFirstStart: true }); + const { pendingId } = await h.service.fireByEvent('trg-1', 'evt-1', SCOPE); + + await expect(h.service.confirmPending(pendingId!, 'staff-bd')).rejects.toThrow('起单炸了'); + expect(h.pendings.get(pendingId!)!.consumedAt, '认领退回去了').toBeUndefined(); + + const retry = await h.service.confirmPending(pendingId!, 'staff-bd'); + expect(retry.alreadyStarted).toBe(false); + expect(h.started).toHaveLength(1); + }); + + it('④ 登记触发不走确认;勾了确认没指定人 = 配置错误入死信', async () => { + // 登记规格 + 零映射(登记表单本身就按调用接口成形)—— 与种子里的「人工登记起单」同形 + const h = harness({ ...CONFIG, mapping: [], spec: { kind: 'registration' } } as TriggerConfig); + // 登记:登记那一下本身就是人在确认 —— 直接起单 + const reg = await h.service.fireByRegistration('trg-1', 'staff-op', { customerCode: 'C014', subject: 's' }); + expect(reg.workflowId).toBeDefined(); + expect(h.desk, '登记不发确认消息').toHaveLength(0); + + const h2 = harness({ ...CONFIG, confirm: { required: true } } as TriggerConfig); + const out = await h2.service.fireByEvent('trg-1', 'evt-2', SCOPE); + expect(out.skipped).toBe('no-confirmer'); + expect(h2.started).toHaveLength(0); + expect(h2.dead.some((r) => r.includes('确认人')), '如实入死信,不静默丢、不擅自直起').toBe(true); + }); +}); diff --git a/apps/api/src/modules/trigger/contract.ts b/apps/api/src/modules/trigger/contract.ts index 39070cb..9c4190b 100644 --- a/apps/api/src/modules/trigger/contract.ts +++ b/apps/api/src/modules/trigger/contract.ts @@ -22,10 +22,28 @@ export class TriggerInputMissingError extends Error { export type FireOutcome = { readonly triggerId: string; readonly workflowId?: string; - readonly skipped?: 'duplicate' | 'disabled' | 'input-missing'; + /** 命中但勾了「需人工确认」:没起单,等指定人点头 —— 这一票的号 */ + readonly pendingId?: string; + readonly skipped?: 'duplicate' | 'disabled' | 'input-missing' | 'no-confirmer'; readonly missing?: readonly string[]; }; +/** 待确认单:装配好的一次起单,等指定人点「确认起单」 */ +export type PendingFire = { + readonly id: string; + readonly triggerId: string; + readonly packageId: string; + /** 确认人快照 —— 确认路由按它认门 */ + readonly staffId: string; + readonly subject: string; + readonly data: Record; + readonly source: { readonly kind: 'periodic' | 'event' | 'registration'; readonly ref: string; readonly by?: string }; + readonly preview: Readonly>; + readonly createdAt: string; + readonly consumedAt?: string; + readonly workflowId?: string; +}; + export type TriggerService = { list(): Promise; upsert(config: TriggerConfig): Promise; @@ -40,4 +58,12 @@ export type TriggerService = { fireByRegistration(triggerId: string, by: string, data: Record): Promise; /** 周期触发的到点扫描(由 pg-boss 定时调起) */ sweepPeriodic(now: Date): Promise; + + /* ── 触发确认链路(运营配置) ────────────────────────────────── */ + /** 运营口:要不要人工确认、谁来确认 —— 与设计面的 upsert 分口 */ + setConfirm(id: string, confirm: { required: boolean; staffId?: string }): Promise; + /** 指定人点头 —— 幂等:已被确认过就回那条流的号,绝不起第二条 */ + confirmPending(pendingId: string, by: string): Promise<{ workflowId: string; alreadyStarted: boolean }>; + /** 待确认单批读 —— 工作台消息列表就地贴确认状态用 */ + pendingByIds(ids: readonly string[]): Promise; }; diff --git a/apps/api/src/modules/trigger/internal/adapter/trigger-schema.ts b/apps/api/src/modules/trigger/internal/adapter/trigger-schema.ts index e6769ce..4c0da27 100644 --- a/apps/api/src/modules/trigger/internal/adapter/trigger-schema.ts +++ b/apps/api/src/modules/trigger/internal/adapter/trigger-schema.ts @@ -14,12 +14,37 @@ export const triggerTable = sys.table( processPackageId: text('process_package_id').notNull(), mapping: jsonb('mapping').notNull().default([]), spec: jsonb('spec').notNull(), + /* + * 人工确认(**运营配置**):设计面的 upsert 不写这两列 —— 那会把运营的勾抹掉; + * 只有 setConfirm 动它们。读回时并进 TriggerConfig.confirm。 + */ + confirmRequired: boolean('confirm_required').notNull().default(false), + confirmStaffId: text('confirm_staff_id'), lastFiredAt: timestamp('last_fired_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => [index('trigger_enabled_idx').on(t.enabled)], ); +/** + * 待确认单:命中但等人点头的那一票 —— 装配好的调用入参存在这里, + * 确认那一刻直接起单,不重新装配(事件早已过去,重抽可能抽不到了)。 + */ +export const triggerPendingTable = sys.table('trigger_pending', { + id: text('id').primaryKey(), + triggerId: text('trigger_id').notNull(), + packageId: text('package_id').notNull(), + /** 确认人快照:发消息那一刻的指定人 —— 确认路由按它认门 */ + staffId: text('staff_id').notNull(), + subject: text('subject').notNull(), + data: jsonb('data').notNull(), + source: jsonb('source').notNull(), + preview: jsonb('preview').notNull().default({}), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + consumedAt: timestamp('consumed_at', { withTimezone: true }), + workflowId: text('workflow_id'), +}); + /** 起单幂等表:同一事件对同一触发器至多起一单 */ export const triggerClaimTable = sys.table('trigger_claim', { key: text('key').primaryKey(), diff --git a/apps/api/src/modules/trigger/internal/adapter/trigger-store.ts b/apps/api/src/modules/trigger/internal/adapter/trigger-store.ts index bda4292..2d43609 100644 --- a/apps/api/src/modules/trigger/internal/adapter/trigger-store.ts +++ b/apps/api/src/modules/trigger/internal/adapter/trigger-store.ts @@ -1,12 +1,13 @@ /** * trigger 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。 */ -import { eq } from 'drizzle-orm'; +import { eq, inArray, isNull, and } from 'drizzle-orm'; import type { TriggerConfig } from '@contracts'; import { triggerConfigSchema } from '@contracts'; import type { Db } from '@shared/db.ts'; +import type { PendingFire } from '../../contract.ts'; import type { TriggerStore } from '../../port.ts'; -import { triggerClaimTable, triggerTable } from './trigger-schema.ts'; +import { triggerClaimTable, triggerPendingTable, triggerTable } from './trigger-schema.ts'; type Row = typeof triggerTable.$inferSelect; @@ -18,9 +19,28 @@ function toConfig(row: Row): TriggerConfig { processPackageId: row.processPackageId, mapping: row.mapping, spec: row.spec, + confirm: { required: row.confirmRequired, ...(row.confirmStaffId === null ? {} : { staffId: row.confirmStaffId }) }, }); } +type PendingRow = typeof triggerPendingTable.$inferSelect; + +function toPending(row: PendingRow): PendingFire { + return { + id: row.id, + triggerId: row.triggerId, + packageId: row.packageId, + staffId: row.staffId, + subject: row.subject, + data: row.data as Record, + source: row.source as PendingFire['source'], + preview: row.preview as Record, + createdAt: row.createdAt.toISOString(), + ...(row.consumedAt === null ? {} : { consumedAt: row.consumedAt.toISOString() }), + ...(row.workflowId === null ? {} : { workflowId: row.workflowId }), + }; +} + export function createTriggerStore(db: Db): TriggerStore { return { async list() { @@ -32,6 +52,7 @@ export function createTriggerStore(db: Db): TriggerStore { return rows[0] === undefined ? undefined : toConfig(rows[0]); }, async upsert(config) { + // 设计面的 upsert **不含 confirm 两列** —— 运营的勾只归 setConfirm 动 const values = { id: config.id, name: config.name, @@ -69,5 +90,49 @@ export function createTriggerStore(db: Db): TriggerStore { async markFired(id, at) { await db.update(triggerTable).set({ lastFiredAt: at }).where(eq(triggerTable.id, id)); }, + + async setConfirm(id, confirm) { + await db + .update(triggerTable) + .set({ confirmRequired: confirm.required, confirmStaffId: confirm.staffId ?? null }) + .where(eq(triggerTable.id, id)); + }, + + async createPending(pending) { + await db.insert(triggerPendingTable).values({ + id: pending.id, + triggerId: pending.triggerId, + packageId: pending.packageId, + staffId: pending.staffId, + subject: pending.subject, + data: pending.data, + source: pending.source, + preview: pending.preview, + }); + }, + async getPending(id) { + const rows = await db.select().from(triggerPendingTable).where(eq(triggerPendingTable.id, id)).limit(1); + return rows[0] === undefined ? undefined : toPending(rows[0]); + }, + async pendingByIds(ids) { + if (ids.length === 0) return []; + const rows = await db.select().from(triggerPendingTable).where(inArray(triggerPendingTable.id, [...ids])); + return rows.map(toPending); + }, + async claimPending(id, at) { + // 只认 consumed_at 还空着的 —— 两人同时点确认,数据库替我们选出唯一赢家 + const rows = await db + .update(triggerPendingTable) + .set({ consumedAt: at }) + .where(and(eq(triggerPendingTable.id, id), isNull(triggerPendingTable.consumedAt))) + .returning({ id: triggerPendingTable.id }); + return rows.length > 0; + }, + async setPendingWorkflow(id, workflowId) { + await db.update(triggerPendingTable).set({ workflowId }).where(eq(triggerPendingTable.id, id)); + }, + async releasePending(id) { + await db.update(triggerPendingTable).set({ consumedAt: null }).where(eq(triggerPendingTable.id, id)); + }, }; } diff --git a/apps/api/src/modules/trigger/internal/usecase/trigger-service.ts b/apps/api/src/modules/trigger/internal/usecase/trigger-service.ts index 8b0fba7..fc21710 100644 --- a/apps/api/src/modules/trigger/internal/usecase/trigger-service.ts +++ b/apps/api/src/modules/trigger/internal/usecase/trigger-service.ts @@ -3,7 +3,7 @@ */ import type { TriggerConfig } from '@contracts'; import type { EventScope, FireOutcome, TriggerService } from '../../contract.ts'; -import type { Clock, DeadLetterSink, IdGenerator, LedgerScanner, ProcessGateway, Sfeel, TriggerStore } from '../../port.ts'; +import type { Clock, ConfirmDesk, DeadLetterSink, IdGenerator, LedgerScanner, ProcessGateway, Sfeel, TriggerStore } from '../../port.ts'; import { applyOffset, assembleInput, @@ -19,6 +19,7 @@ export type TriggerServiceDeps = { readonly process: ProcessGateway; readonly sfeel: Sfeel; readonly deadLetters: DeadLetterSink; + readonly confirmDesk: ConfirmDesk; readonly ledger: LedgerScanner; readonly clock: Clock; readonly ids: IdGenerator; @@ -42,6 +43,46 @@ export function createTriggerService(deps: TriggerServiceDeps): TriggerService { }); return { triggerId: config.id, skipped: 'input-missing', missing: assembled.fields }; } + /* + * 触发确认链路(OS 用户口述五步的第 4/5 步,2026-07-30): + * 勾了「需人工确认」,命中**不等于起单** —— 装配好的入参存成待确认单, + * 带来件内容的系统消息送到指定人工作台;他点「确认起单」流程才起。 + * 登记触发不走这条:登记那一下本身就是人在确认。 + */ + if (config.confirm.required && source.kind !== 'registration') { + const staffId = config.confirm.staffId; + if (staffId === undefined) { + // 勾了要确认却没指定谁 —— 这是配置错误,如实入死信,不静默丢、也不擅自直起 + await deps.deadLetters.report({ + triggerId: config.id, + reason: '勾了「需人工确认」但没有指定确认人 —— 去运营中心 · 工作流补上', + detail: { subject }, + }); + return { triggerId: config.id, skipped: 'no-confirmer' }; + } + const pendingId = deps.ids.next(); + // 来件预览只收字符串字段 —— 消息上带内容,确认的人不用回头翻邮箱 + const preview = Object.fromEntries( + (['from', 'subject', 'body'] as const).flatMap((k) => { + const v = scope[k]; + return typeof v === 'string' ? [[k, v.slice(0, 2000)]] : []; + }), + ); + await deps.store.createPending({ + id: pendingId, + triggerId: config.id, + packageId: config.processPackageId, + staffId, + subject, + data: assembled.data, + source, + preview, + createdAt: deps.clock.now().toISOString(), + }); + await deps.confirmDesk.request({ pendingId, staffId, triggerName: config.name, subject, preview }); + return { triggerId: config.id, pendingId }; + } + const workflow = await deps.process.start({ packageId: config.processPackageId, subject, @@ -135,5 +176,47 @@ export function createTriggerService(deps: TriggerServiceDeps): TriggerService { } return outcomes; }, + + async setConfirm(id, confirm) { + await deps.store.setConfirm(id, confirm); + }, + + async confirmPending(pendingId, by) { + const pending = await deps.store.getPending(pendingId); + if (pending === undefined) throw new Error(`无此待确认单:${pendingId}`); + if (pending.consumedAt !== undefined) { + if (pending.workflowId === undefined) throw new Error('这一票正在被确认 —— 稍等两秒再看'); + return { workflowId: pending.workflowId, alreadyStarted: true }; + } + /* + * 先认领后起单:consumed_at 空着才认得到,两人同时点确认只有一个赢 —— + * 绝不起两条流。起单失败就把认领退回去,这一票还能再确认。 + */ + const won = await deps.store.claimPending(pendingId, deps.clock.now()); + if (!won) { + const again = await deps.store.getPending(pendingId); + if (again?.workflowId === undefined) throw new Error('这一票正在被确认 —— 稍等两秒再看'); + return { workflowId: again.workflowId, alreadyStarted: true }; + } + try { + const workflow = await deps.process.start({ + packageId: pending.packageId, + subject: pending.subject, + // 确认人记进来源:这条流是谁点头放行的,留痕上要看得到 + source: { ...pending.source, by }, + data: pending.data, + }); + await deps.store.setPendingWorkflow(pendingId, workflow.id); + await deps.store.markFired(pending.triggerId, deps.clock.now()); + return { workflowId: workflow.id, alreadyStarted: false }; + } catch (error) { + await deps.store.releasePending(pendingId); + throw error; + } + }, + + async pendingByIds(ids) { + return deps.store.pendingByIds(ids); + }, }; } diff --git a/apps/api/src/modules/trigger/port.ts b/apps/api/src/modules/trigger/port.ts index 5fe5d37..044fe33 100644 --- a/apps/api/src/modules/trigger/port.ts +++ b/apps/api/src/modules/trigger/port.ts @@ -2,6 +2,7 @@ * module: trigger —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 */ import type { TriggerConfig } from '@contracts'; +import type { PendingFire } from './contract.ts'; export type Clock = { now(): Date }; export type IdGenerator = { next(): string }; @@ -11,11 +12,37 @@ export type TriggerStore = { get(id: string): Promise; upsert(config: TriggerConfig): Promise; setEnabled(id: string, enabled: boolean): Promise; + /** 运营口:确认配置 —— 设计面的 upsert 不碰这两列 */ + setConfirm(id: string, confirm: { required: boolean; staffId?: string }): Promise; remove(id: string): Promise; /** 幂等:同一键至多起一单 */ claim(key: string): Promise; lastFiredAt(id: string): Promise; markFired(id: string, at: Date): Promise; + + /* ── 待确认单(触发确认链路) ────────────────────────────────── */ + createPending(pending: PendingFire): Promise; + getPending(id: string): Promise; + pendingByIds(ids: readonly string[]): Promise; + /** 认领:consumed_at 空才认得到 —— 两个人同时点确认,只有一个赢,绝不起两条流 */ + claimPending(id: string, at: Date): Promise; + /** 起单成功后把流号写回;起单失败时用 releasePending 把认领退回去 */ + setPendingWorkflow(id: string, workflowId: string): Promise; + releasePending(id: string): Promise; +}; + +/** + * 确认台 —— app 注入 `message`(L2):命中但要人点头时, + * 把带来件内容的系统消息送到指定人的工作台。 + */ +export type ConfirmDesk = { + request(input: { + pendingId: string; + staffId: string; + triggerName: string; + subject: string; + preview: Readonly>; + }): Promise; }; /** 起单 —— app 注入 `process`(L7 同层?否:trigger 在 process 之上同层,经 app 装配) */ diff --git a/apps/web/src/app/gov-shell.tsx b/apps/web/src/app/gov-shell.tsx index 0408983..cafb0d3 100644 --- a/apps/web/src/app/gov-shell.tsx +++ b/apps/web/src/app/gov-shell.tsx @@ -25,6 +25,7 @@ import { domainShort, sortDomains } from '@web-shared/gov/domains.ts'; import { queryKeys } from './query.ts'; import { BlueprintHome } from '@features/gov-blueprint'; import { TasksHome, TaskLogPage, TaskPage, WfPage } from '@features/gov-task'; +import { RunsHome } from '@features/gov-runs'; import { EmpPage, PeopleHome, RolePage } from '@features/gov-staff'; import { PkgPage, WorkflowsHome } from '@features/gov-process'; import { TriggerPage, TriggerRulesHome } from '@features/gov-trigger'; @@ -293,6 +294,7 @@ function ZoneHome({ zone, tab, nav }: { zone: GovZone; tab: string; nav: Nav }) testbed: TestbedHome, // 运营中心 cockpit: CockpitHome, + runs: RunsHome, tasks: TasksHome, people: PeopleHome, ledgers: LedgersHome, diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index 3cfc494..77f9077 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -665,6 +665,21 @@ .g-maprow > .g-btn { grid-column: 6; justify-self: stretch; padding: 4px 0; display: inline-flex; align-items: center; justify-content: center; } .g-maparr { color: var(--sh-steel, #46586a); font-weight: 700; text-align: center; } +/* ── 运营中心 · 工作流 · 运行配置:一包一卡,里面两节(触发器 / 节点负责人)── */ +.g-runconf { display: flex; flex-direction: column; gap: 4px; padding: 10px 14px 12px; } +.g-runconf + .g-runconf { border-top: 1px dashed var(--line-soft); } +.g-runconf-h { display: flex; align-items: center; gap: 7px; padding-bottom: 6px; font: 600 9.5px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3); } +.g-runconf-none { margin: 0; font-size: 11.5px; line-height: 1.8; color: var(--ink-3); } +.g-runconf-r { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; padding: 7px 0; border-bottom: 1px dashed var(--line-soft); } +.g-runconf-r:last-child { border-bottom: 0; } +.g-runconf-k { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ink-2); cursor: pointer; user-select: none; } +.g-runconf-k input { accent-color: var(--brand); width: 14px; height: 14px; cursor: pointer; } +.g-runconf-r .g-select { width: 180px; } +.g-runconf-own { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 4px 24px; } +.g-runconf-node { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto minmax(140px, 170px); align-items: center; gap: 9px; padding: 5px 0; } +.g-runconf-node .nid { font-family: var(--mono); font-size: 11px; font-weight: 700; color: var(--ink); } +.g-runconf-node .lb { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--ink-2); } + /* ═══════════ 测试台 · 仿真试跑(2026-07-30 重制)═══════════ 概念:**浅色桌面上的一台仪器**。整台机器是页面里唯一的暗色区 —— 有边、有圆角、 有台面,不是把纸刷黑。机器说 mono(读数、灯、编号、TRACE), diff --git a/apps/web/src/features/desk-message/internal/MessageDesk.tsx b/apps/web/src/features/desk-message/internal/MessageDesk.tsx index 3d7d13f..d170877 100644 --- a/apps/web/src/features/desk-message/internal/MessageDesk.tsx +++ b/apps/web/src/features/desk-message/internal/MessageDesk.tsx @@ -9,7 +9,7 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { FormField, ListMessagesOutput, SystemMessageView } from '@contracts'; -import { answerMessageRoute, listMessagesRoute, markMessageReadRoute } from '@contracts'; +import { answerMessageRoute, confirmTriggerPendingRoute, listMessagesRoute, markMessageReadRoute } from '@contracts'; import { Ico } from '@web-shared/Ico.tsx'; import ScrollArea from '@web-shared/ScrollArea.tsx'; import { call } from '@web-shared/typed-fetch.ts'; @@ -63,7 +63,7 @@ export function MessageDesk() { {visible.map((m) => { const meta = metaOf(m.kind); - const pending = m.card !== undefined && !m.card.answered; + const pending = (m.card !== undefined && !m.card.answered) || (m.confirm !== undefined && !m.confirm.consumed); return ( + {fire.isError && 没起成:{(fire.error as { message?: string }).message ?? '未知原因'}} + + ); +} diff --git a/apps/web/src/features/gov-runs/index.ts b/apps/web/src/features/gov-runs/index.ts new file mode 100644 index 0000000..8b30fff --- /dev/null +++ b/apps/web/src/features/gov-runs/index.ts @@ -0,0 +1,5 @@ +/** + * feature `gov-runs` —— 运营中心 · 工作流:**流程包的运行时**。 + * 在途实例的监控,和每个包的运行配置(起单开关 / 人工确认 / 节点负责人)。 + */ +export { RunsHome } from './internal/panel.tsx'; diff --git a/apps/web/src/features/gov-runs/internal/panel.tsx b/apps/web/src/features/gov-runs/internal/panel.tsx new file mode 100644 index 0000000..871c8c4 --- /dev/null +++ b/apps/web/src/features/gov-runs/internal/panel.tsx @@ -0,0 +1,211 @@ +/** + * 运营中心 · 工作流 —— **流程包的运行时**(设计中心只负责设计,运营中心管细节)。 + * + * 两页签: + * · 在途:工作流看板(从任务区迁入 —— 工作流一级归这儿,任务区只留活动一级); + * · 运行配置:每个包**今天怎么跑**的细节,设计一个字不碰 —— + * 触发器:起单开关 / 命中后要不要人工确认 / 谁来确认(触发确认链路第 4 步的配置面) + * 节点负责人:流程包的哪个节点归谁 —— 显式指派 > 这里 > 轮值 + */ +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; +import type { TriggerConfig } from '@contracts'; +import { + listNodeOwnersRoute, + setNodeOwnerRoute, + setTriggerConfirmRoute, + toggleTriggerRoute, +} from '@contracts'; +import { call } from '@web-shared/typed-fetch.ts'; +import { Ico } from '@web-shared/Ico.tsx'; +import { Card, Chip, Clear, QueryGate, St } from '@web-shared/gov/primitives.tsx'; +import type { Nav } from '@web-shared/gov/gov-route.ts'; +import { useGovPackages, useGovStaff, useGovTriggers } from '@web-shared/gov/gov-api.ts'; +import { WorkflowBoard } from '@features/gov-task'; + +export function RunsHome({ tab, nav }: { tab: string; nav: Nav }) { + if (tab === 'conf') return ; + return ; +} + +/** 治理写动作统一入口:成功即失效相关查询,画面由服务端重投影 */ +function useGovAction(mutate: (input: TInput) => Promise, keys: readonly string[]) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: mutate, + onSuccess: () => { + for (const key of keys) void queryClient.invalidateQueries({ queryKey: ['gov', key] }); + }, + }); +} + +/* ==================== 运行配置:一包一节 ==================== */ + +function RunConfig() { + const pkgQ = useGovPackages(); + const trgQ = useGovTriggers(); + const staffQ = useGovStaff(); + const packages = (pkgQ.data?.packages ?? []).filter((p) => p.state === 'active'); + const triggers = trgQ.data?.triggers ?? []; + const staff = (staffQ.data?.staff ?? []).filter((s) => s.state === 'active'); + + return ( + + {packages.length === 0 ? ( + 没有生效的流程包 —— 设计中心激活一版,这里就有它的运行配置。 + ) : ( + <> + {packages.map((p) => ( + n.kind === 'activity' || n.kind === 'approval') + .map((n) => ({ id: n.id, label: n.label, kind: n.kind }))} + triggers={triggers.filter((t) => t.processPackageId === p.id)} + staff={staff.map((s) => ({ id: s.id, name: s.name, code: s.position.code }))} + /> + ))} + + )} + + ); +} + +type StaffOpt = { id: string; name: string; code: string }; + +function PackageConfig({ + packageId, + name, + nodes, + triggers, + staff, +}: { + packageId: string; + name: string; + nodes: ReadonlyArray<{ id: string; label: string; kind: string }>; + triggers: readonly TriggerConfig[]; + staff: readonly StaffOpt[]; +}) { + const ownersQ = useQuery({ + queryKey: ['gov', 'node-owners', packageId], + queryFn: () => call<{ owners: Array<{ nodeId: string; staffId: string }> }>(listNodeOwnersRoute, { params: { id: packageId } }), + }); + const owners = new Map((ownersQ.data?.owners ?? []).map((o) => [o.nodeId, o.staffId])); + + const toggle = useGovAction( + (input: { id: string; enabled: boolean }) => + call(toggleTriggerRoute, { params: { id: input.id }, body: { enabled: input.enabled } }), + ['triggers'], + ); + const confirm = useGovAction( + (input: { id: string; required: boolean; staffId?: string }) => + call(setTriggerConfirmRoute, { + params: { id: input.id }, + body: { required: input.required, ...(input.staffId === undefined ? {} : { staffId: input.staffId }) }, + }), + ['triggers'], + ); + const setOwner = useGovAction( + (input: { nodeId: string; staffId: string | null }) => + call(setNodeOwnerRoute, { params: { id: packageId, nodeId: input.nodeId }, body: { staffId: input.staffId } }), + ['node-owners'], + ); + + return ( + {packageId}}> + {/* ── 触发器的运营面:开不开 · 要不要人点头 · 谁点头 —— 规则本身在设计中心改 ── */} +
+
+ + 触发器 —— 起单开关与人工确认 +
+ {triggers.length === 0 ? ( +

这个包还没有调起规则 —— 去设计中心 · 触发器配一条。

+ ) : ( + triggers.map((t) => { + const confirmed = t.confirm.required; + return ( +
+ + {t.name} + + + + + {confirmed && t.confirm.staffId === undefined && 还没指定确认人} +
+ ); + }) + )} +
+ + {/* ── 节点负责人:显式指派 > 这里 > 轮值 ── */} +
+
+ + 节点负责人 —— 留空即轮值(有员工组按组轮,没有按全员最闲者先) +
+
+ {nodes.map((n) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/web/src/features/gov-task/index.ts b/apps/web/src/features/gov-task/index.ts index beb7ea2..e6364a8 100644 --- a/apps/web/src/features/gov-task/index.ts +++ b/apps/web/src/features/gov-task/index.ts @@ -2,4 +2,4 @@ * feature `gov-task` 唯一出口(编码规范 §9.1)。 * 任务管理:在途监控、控制事件(暂停/叫停/终止)、恢复、改派、任务日志查看 */ -export { TasksHome, WfPage, TaskPage, TaskLogPage } from './internal/panel.tsx'; +export { TasksHome, WfPage, TaskPage, TaskLogPage, WorkflowBoard } from './internal/panel.tsx'; diff --git a/apps/web/src/features/gov-task/internal/panel.tsx b/apps/web/src/features/gov-task/internal/panel.tsx index 4b4ff1b..af653af 100644 --- a/apps/web/src/features/gov-task/internal/panel.tsx +++ b/apps/web/src/features/gov-task/internal/panel.tsx @@ -23,7 +23,6 @@ import { Ava, Card, Chip, Clear, Entry, Facts, Kpi, NList, NRow, Note, PageHead, import type { ActSpec } from '@web-shared/gov/primitives.tsx'; import { APR_ST, PARA, RUNTIME_ST, TERMINAL_ST, WF_ST } from '@web-shared/gov/tone.ts'; import type { Nav } from '@web-shared/gov/gov-route.ts'; -import { TriggerOpsTab } from '@features/gov-trigger'; import { FlowFigure } from '@web-shared/gov/flow/FlowFigure.tsx'; import { useGovApprovals, @@ -36,14 +35,15 @@ import { export type HomeProps = { tab: string; nav: Nav }; export function TasksHome({ tab, nav }: HomeProps) { - // 起单开关 = 触发器的**处置面**:今天开不开是当班主管的事,规则怎么写在设计中心改 - if (tab === 'trg') return ; return ; } -function RuntimeHome({ tab, nav }: HomeProps) { +/** + * 在途工作流看板 —— **工作流是流程包的运行时**,一级看板归「运营中心 · 工作流」区; + * 任务区(活动的运行时)不再摆它。 + */ +export function WorkflowBoard({ nav }: { nav: Nav }) { const q = useGovTasks(); - const approvals = useGovApprovals(); const tasks = q.data?.tasks ?? []; const workflows = q.data?.workflows ?? []; @@ -55,7 +55,6 @@ function RuntimeHome({ tab, nav }: HomeProps) { return stuck ? { name: stuck.ownerName, label: '最后经手 · 待处置' } : undefined; }; - if (tab === 'board') { const lanes = [ { k: 'running', tone: 'brand' }, { k: 'waiting', tone: 'warn' }, @@ -122,8 +121,14 @@ function RuntimeHome({ tab, nav }: HomeProps) { ); })} - ); - } + ); +} + +function RuntimeHome({ tab, nav }: HomeProps) { + const q = useGovTasks(); + const approvals = useGovApprovals(); + const tasks = q.data?.tasks ?? []; + const workflows = q.data?.workflows ?? []; if (tab === 'apr') { const rows = approvals.data?.approvals ?? []; diff --git a/apps/web/src/features/gov-trigger/index.ts b/apps/web/src/features/gov-trigger/index.ts index 5023284..c554f84 100644 --- a/apps/web/src/features/gov-trigger/index.ts +++ b/apps/web/src/features/gov-trigger/index.ts @@ -2,4 +2,4 @@ * feature `gov-trigger` 唯一出口(编码规范 §9.1)。 * 触发器的两副面孔:规则定义在设计中心(流程包 - 调起规则),启停在运营中心(任务 - 起单开关)。 */ -export { TriggerOpsTab, TriggerPage, TriggerRulesHome } from './internal/panel.tsx'; +export { TriggerPage, TriggerRulesHome } from './internal/panel.tsx'; diff --git a/apps/web/src/features/gov-trigger/internal/panel.tsx b/apps/web/src/features/gov-trigger/internal/panel.tsx index 0085529..8ebafed 100644 --- a/apps/web/src/features/gov-trigger/internal/panel.tsx +++ b/apps/web/src/features/gov-trigger/internal/panel.tsx @@ -17,7 +17,6 @@ import type { TriggerConfig } from '@contracts'; import { TRIGGER_KIND_LABEL, processPackageIdSchema, - toggleTriggerRoute, triggerKindOf, upsertTriggerRoute, } from '@contracts'; @@ -29,8 +28,6 @@ import { Clear, Field, ListEdit, - NList, - NRow, Note, PageHead, Prop, @@ -394,62 +391,9 @@ function TriggerForm({ /* ==================== 运营侧:起单开关(处置面) ==================== */ -export function TriggerOpsTab({ nav }: { nav: Nav }) { - const q = useGovTriggers(); - const triggers = q.data?.triggers ?? []; - const toggle = useGovAction( - (input: { id: string; enabled: boolean }) => - call(toggleTriggerRoute, { params: { id: input.id }, body: { enabled: input.enabled } }), - ['triggers'], - ); - - return ( - - <> - - 开关管的是今天起不起单,不是规则本身。停用后此后不再起新单,已起的照常跑完; - 规则怎么写在设计中心 · 流程包 - 调起规则里改。 - - {triggers.length === 0 ? ( - 还没有调起规则 —— 设计中心配好之后,开关会出现在这里。 - ) : ( - - {triggers.map((t) => ( - {TRIGGER_KIND_LABEL[triggerKindOf(t.spec)]} }, - { k: '调起', v: t.processPackageId }, - ]} - right={ - - {t.enabled ? '已启用' : '已停用'} - - } - onClick={() => nav.push({ k: 'trigger', id: t.id })} - /> - ))} - - )} - {toggle.isError && 切换失败,请重试。} - - - ); -} - -/* ==================== 触发器详情(两面共用) ==================== */ - export function TriggerPage({ id }: { id: string }) { const q = useGovTriggers(); const t = (q.data?.triggers ?? []).find((x) => x.id === id); - const toggle = useGovAction( - (enabled: boolean) => call(toggleTriggerRoute, { params: { id }, body: { enabled } }), - ['triggers'], - ); if (t === undefined) return 触发器不存在。; return ( <> @@ -462,27 +406,13 @@ export function TriggerPage({ id }: { id: string }) { <> {TRIGGER_KIND_LABEL[triggerKindOf(t.spec)]} 调起 {t.processPackageId} + + {t.enabled ? '起单中' : '已停用'} + + {t.confirm.required && 命中须人工确认} } - desc="触发器与流程包平级——不入包结构、不随包版本,增删启停不改任何版本。规则在设计中心改,开关在运营中心拨。" - acts={[ - { - label: t.enabled ? '停用' : '启用', - ico: 'power', - danger: t.enabled, - ask: { - title: `${t.enabled ? '停用' : '启用'}触发器:${t.name}`, - note: 启停不改任何版本;已起的单不受影响。, - impact: { - who: t.enabled ? '此后不再自动起单,已在途工作流不受影响' : '此后按规则自动起单', - undo: '可随时反向切换', - when: '确认后即时', - }, - okLabel: t.enabled ? '确认停用' : '确认启用', - onOk: () => toggle.mutate(!t.enabled), - }, - }, - ]} + desc="设计中心只负责设计:这里改的是规则与装配。启停开关、要不要人工确认、谁来确认,在运营中心 · 工作流的运行配置里。" /> { }); it('运行态区改写到 /ops', () => { - expect(legacyGovTarget('#/gov/tasks/board')).toBe('/ops#/ops/tasks/board'); + // 看板已迁「工作流」区(2026-07-30):工作流是流程包的运行时,一级看板归它 + expect(legacyGovTarget('#/gov/tasks/board')).toBe('/ops#/ops/runs/board'); }); it('已删的区落到对应新家', () => { diff --git a/apps/web/src/shared/gov/gov-route.ts b/apps/web/src/shared/gov/gov-route.ts index 4300d24..22d89fe 100644 --- a/apps/web/src/shared/gov/gov-route.ts +++ b/apps/web/src/shared/gov/gov-route.ts @@ -43,8 +43,9 @@ export const GOV_ZONES = [ 'archive', 'models', 'testbed', - // 运营中心 + // 运营中心 —— 区名即运行时:工作流是流程包的运行时,任务是活动的运行时 'cockpit', + 'runs', 'tasks', 'people', 'ledgers', @@ -64,10 +65,11 @@ export const ZONES: ReadonlyArray<{ key: GovZone; label: string; icon: string }> { key: 'models', label: '大模型', icon: 'cpu' }, { key: 'testbed', label: '测试台', icon: 'flask-conical' }, { key: 'cockpit', label: '驾驶舱', icon: 'gauge' }, - { key: 'tasks', label: '任务', icon: 'activity' }, + { key: 'runs', label: '工作流', icon: 'activity' }, + { key: 'tasks', label: '任务', icon: 'list-checks' }, { key: 'people', label: '员工', icon: 'users' }, { key: 'ledgers', label: '台账', icon: 'table' }, - { key: 'knowledge', label: '知识库', icon: 'layers' }, + { key: 'knowledge', label: '数据', icon: 'layers' }, { key: 'system', label: '系统', icon: 'settings' }, ]; @@ -92,16 +94,22 @@ export const ZM: Readonly> = { design: ['blueprint', 'triggers', 'workflows', 'library', 'tools', 'data', 'archive', 'models', 'testbed'], - ops: ['cockpit', 'tasks', 'people', 'ledgers', 'knowledge', 'system'], + ops: ['cockpit', 'runs', 'tasks', 'people', 'ledgers', 'knowledge', 'system'], }; /** @@ -66,6 +66,8 @@ function isGovZone(value: string): value is GovZone { * 精确到页签的先匹配 —— 记忆的两个旧页签各归其位(度量升驾驶舱,活动记忆归知识库)。 */ const LEGACY_MOVES: Readonly> = { + 'tasks/board': 'ops/runs/board', + 'tasks/trg': 'ops/runs/conf', 'workflows/rules': 'design/triggers/rules', 'testbed/inbound': 'design/testbed/run', 'system/llm': 'design/models/llm', diff --git a/db/migrations/0019_trigger_confirm.sql b/db/migrations/0019_trigger_confirm.sql new file mode 100644 index 0000000..98aa10d --- /dev/null +++ b/db/migrations/0019_trigger_confirm.sql @@ -0,0 +1,32 @@ +-- 触发确认链路(2026-07-30):命中不等于必起 —— 触发器可勾「需人工确认」, +-- 命中后先发系统消息到指定人工作台,他点「确认起单」流程才起。 +-- 这两列是**运营配置**:设计面的 upsert 不写它们,只有运营口(setConfirm)动。 +alter table sys.trigger add column confirm_required boolean not null default false; +alter table sys.trigger add column confirm_staff_id text; + +-- 待确认单:命中但等人点头的那一票。装配好的调用入参存在这里 —— +-- 确认那一刻直接起单,不重新装配(事件早已过去,重抽可能抽不到了)。 +create table sys.trigger_pending ( + id text primary key, + trigger_id text not null, + package_id text not null, + -- 确认人快照:发消息那一刻的指定人;确认路由按它认门 + staff_id text not null, + subject text not null, + data jsonb not null, + source jsonb not null, + -- 来件预览(from/subject/body 节选)—— 消息上带内容,人不用回头翻邮箱 + preview jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + consumed_at timestamptz, + workflow_id text +); +create index trigger_pending_open_idx on sys.trigger_pending (trigger_id) where consumed_at is null; + +-- 节点负责人(运营配置):流程包的哪个节点归谁 —— 起单时垫在显式指派之下、轮值之上。 +create table sys.node_owner ( + package_id text not null, + node_id text not null, + staff_id text not null, + primary key (package_id, node_id) +); diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index f4732ad..ad9cdb5 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1785130113173, "tag": "0018_tool_display_name", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1785130114173, + "tag": "0019_trigger_confirm", + "breakpoints": true } ] } diff --git a/docs/debt.md b/docs/debt.md index 72a0683..f04df75 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -170,3 +170,14 @@ intel-archive)只有契约面,入口产物是抛「未实现」的占位 —— 第三个入参,api:check 报「公开契约无变化 ✔」。名单变化(增删导出)照样能拦住。 要补得让契约面也收 domain 的 .d.ts,代价是全部纯函数签名进快照、日常改动噪声变大; 在那之前:**改纯函数签名时手动同步 CONTRACT.md**,别指望闸提醒。 + +## 触发链路 · 邮件分类 Agent(2026-07-30) + +用户定的触发逻辑五步:接收邮件 → **分类 Agent 识别消息类别** → 命中 RFQ 事件 → +(可选)系统消息到指定人工作台 → 工作台确认起单。第 ④⑤ 步(确认链路)本轮已落地 +(`sys.trigger_pending` + 消息 `eventRef` + 幂等确认);第 ② 步的「分类」目前由 +**通道抽键正则 + 触发器 S-FEEL 规则**代位 —— 判得了「主题带不带询价/RFQ」,判不了语义类别。 + +升级路径:通道入站归一后挂一个 LLM 分类工具(工具箱五件之 classify,经模型网关一缝), +产出 `messageCategory` 进事件载荷,触发器规则改判 `messageCategory = "rfq"`。 +前置:模型实例已绑定(大模型区)、分类样例集与回放审。不阻塞现有 RFQ 流 —— 正则今天够用。 diff --git a/docs/产品设计.md b/docs/产品设计.md index 645d9f1..93bdd72 100644 --- a/docs/产品设计.md +++ b/docs/产品设计.md @@ -83,8 +83,9 @@ ``` ◉ 驾驶舱(首页) -── 固定 ── -任务 +── 固定(区名即运行时) ── +工作流 ← 流程包的运行时 +任务 ← 活动的运行时 员工 ── 台账(动态,随数据域长) ── quote_intake @@ -92,10 +93,14 @@ quote_pricing … shared ── 固定 ── -知识库 +数据 ← 运行沉淀:活动记忆 + 技能资产 系统 ``` +**命名纪律(2026-07-30)**:运营中心的区名就是运行时对象 —— +工作流是流程包的运行时,任务是活动的运行时,台账是数据域的运行时。 +设计中心只负责设计,运营中心管细节(启停、确认、负责人都在运营侧配)。 + > 落地实测:域的实际粒度是**活动域**(`quote_intake` / `quote_pricing` / `quote_sourcing` / > `quote_negotiate` / `quote_alternative`)+ `shared`,不是本文起初设想的「报价域 / 共享域」。 > 决策不变(域 = 导航项),只是数量比示意图多。域名是复合键,左轨按下划线断行呈现。 @@ -115,10 +120,21 @@ shared | 位 | 内容 | |---|---| -| **任务** | 现任务管理区平移:运行看板 / 任务 / 审批单 / 需处置。**触发器的处置并入**:启停开关、近 30 天起单、死信重投 —— 定义在设计中心,开关在这里 | -| **员工** | 现员工区平移(员工 / 岗位授权),**补上工作组管理**(ADR-011 欠账:建组 / 改组 —— 起单时的岗位绑定) | -| **知识库** | 活动记忆四层 + 技能资产 + 候选增量的回放与放行。"记忆的终点是成为模型"这条进化管线的**人工放行口**在这里 | -| **系统** | LLM 实例 / 通道实例,现系统区平移 | +| **工作流** | 流程包的运行时,两页签:**在途**(工作流看板,从任务区迁入)+ **运行配置**(每包:触发器起单开关 / 命中后要不要人工确认 / 谁来确认;节点负责人 —— 显式指派 > 运营配置 > 轮值) | +| **任务** | 活动的运行时:任务 / 审批单 / 需处置。看板与起单开关已迁「工作流」区 | +| **员工** | 员工 / 岗位授权 / 工作组(起单时的岗位绑定) | +| **数据** | 运行沉淀的数据资产:活动记忆四层 + 技能资产 + 候选增量的回放与放行。"记忆的终点是成为模型"这条进化管线的**人工放行口**在这里 | +| **系统** | 通道实例(模型实例已独立成设计中心「大模型」区) | + +### 触发确认链路(2026-07-30 定) + +事件触发的完整五步:**① 系统接收邮件 → ② 分类识别消息类别 → ③ 命中 RFQ 询价事件 → +④(可选,触发器配置打勾)系统消息送指定人工作台,带邮件内容 → ⑤ 工作台点「确认起单」,流程才起。** + +- 打勾与指定人在**运营中心 · 工作流 · 运行配置**里配,设计中心一个字不动; +- 确认是幂等的:两个人同时点、一个人点两次,都只起一条流; +- 登记触发不走确认 —— 登记那一下本身就是人在确认; +- 第 ② 步现由通道抽键 + 触发器 S-FEEL 规则代位,邮件分类 Agent 是升级项(见 debt)。 ### 动态位:台账 ×N diff --git a/packages/contracts/src/api/governance.ts b/packages/contracts/src/api/governance.ts index 678222a..5a98fe0 100644 --- a/packages/contracts/src/api/governance.ts +++ b/packages/contracts/src/api/governance.ts @@ -524,6 +524,35 @@ export const listApprovalsRoute = { * 起一条流(测试台专用):**带节点指派**把整条流定向到指定账号 —— * 真实任务照旧落到那个人的工作台,测试台只管起单与观测。 */ +/** + * 触发器的**运营配置**:要不要人工确认、谁来确认 —— 与设计面的 upsert 分口, + * 设计动规则不碰这两项,运营动这两项不碰规则。 + */ +export const setTriggerConfirmRoute = { + method: 'POST', + path: '/api/gov/triggers/:id/confirm', + params: z.object({ id: z.string() }), + body: z.object({ required: z.boolean(), staffId: z.string().optional() }), + output: z.object({ ok: z.boolean() }), +} as const satisfies RouteContract; + +/** 节点负责人(运营配置):流程包的哪个节点归谁 —— 显式指派 > 这里 > 轮值 */ +export const listNodeOwnersRoute = { + method: 'GET', + path: '/api/gov/packages/:id/owners', + params: z.object({ id: z.string() }), + output: z.object({ owners: z.array(z.object({ nodeId: z.string(), staffId: z.string() })) }), +} as const satisfies RouteContract; + +export const setNodeOwnerRoute = { + method: 'PUT', + path: '/api/gov/packages/:id/owners/:nodeId', + params: z.object({ id: z.string(), nodeId: z.string() }), + /** null = 撤掉运营指定,回到轮值 */ + body: z.object({ staffId: z.string().nullable() }), + output: z.object({ ok: z.boolean() }), +} as const satisfies RouteContract; + export const testRunRoute = { method: 'POST', path: '/api/gov/testbed/run', diff --git a/packages/contracts/src/api/workbench.ts b/packages/contracts/src/api/workbench.ts index d3b6d89..2021314 100644 --- a/packages/contracts/src/api/workbench.ts +++ b/packages/contracts/src/api/workbench.ts @@ -186,6 +186,13 @@ export const systemMessageSchema = z.object({ supersededBy: z.string().optional(), }) .optional(), + /** + * 待确认起单(触发确认链路):触发器命中但勾了「需人工确认」—— + * 这条消息带着那一票,`confirm.consumed` 前工作台上有一枚「确认起单」钮。 + */ + confirm: z + .object({ pendingId: z.string(), consumed: z.boolean(), workflowId: z.string().optional() }) + .optional(), }); export type SystemMessageView = z.infer; @@ -211,6 +218,14 @@ export const answerMessageRoute = { output: z.object({ ok: z.boolean() }), } as const satisfies RouteContract; +/** 确认起单 —— 幂等:已被确认过就如实说,并回那条流的号,绝不起第二条 */ +export const confirmTriggerPendingRoute = { + method: 'POST', + path: '/api/desk/trigger-pending/:id/confirm', + params: z.object({ id: z.string() }), + output: z.object({ workflowId: z.string(), alreadyStarted: z.boolean() }), +} as const satisfies RouteContract; + export type ListMessagesOutput = z.infer; /* ── 纪要:工作台五要素里唯一的主动动作 ─────────────────────────────── */ diff --git a/packages/contracts/src/process-package.ts b/packages/contracts/src/process-package.ts index af5b534..be42a74 100644 --- a/packages/contracts/src/process-package.ts +++ b/packages/contracts/src/process-package.ts @@ -278,6 +278,15 @@ export const triggerConfigSchema = z.object({ processPackageId: processPackageIdSchema, /** 起单载荷装配:事件信封与载荷字段 → 调用接口的 data 字段;缺必填即起单失败入死信 */ mapping: z.array(fieldMappingSchema).default([]), + /** + * 人工确认 —— **运营配置**(运营中心 · 工作流里勾),不随设计动: + * 勾了,命中后不直接起单,先发带来件内容的系统消息到指定人工作台, + * 他点「确认起单」流程才起。登记触发不适用 —— 登记那一下本身就是人在确认。 + * 设计面的 upsert **不写**这两项(那会把运营的勾抹掉),只有运营口(setConfirm)动它。 + */ + confirm: z + .object({ required: z.boolean().default(false), staffId: staffIdSchema.optional() }) + .default({ required: false }), /** * 判别式取**四个互斥值**:周期触发的两型(日历 / 数据日历)在此展开 —— * `TRIGGER_KINDS` 仍是三类分类学(周期 / 事件 / 登记),两者以 `triggerKindOf()` 对应。