**触发确认链路**(用户口述五步的第 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
176 lines
7.5 KiB
TypeScript
176 lines
7.5 KiB
TypeScript
/**
|
|
* 触发确认链路(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);
|
|
});
|
|
});
|