三面拆分⑬:触发确认链路 + 运营中心「工作流」区 —— 设计只管设计,运营管细节

**触发确认链路**(用户口述五步的第 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-29 13:09:16 +08:00
co-authored by Claude Opus 5
parent 70b261d067
commit f9b2c07731
45 changed files with 1176 additions and 115 deletions
@@ -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<typeof createTriggerService>;
pendings: Map<string, PendingFire>;
started: Array<{ subject: string; by?: string; data: Record<string, unknown> }>;
desk: Array<{ pendingId: string; staffId: string }>;
dead: string[];
};
function harness(config: TriggerConfig, options?: { failFirstStart?: boolean }): Harness {
const pendings = new Map<string, PendingFire>();
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<unknown>((acc, seg) => (acc as Record<string, unknown>)?.[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);
});
});