三面拆分⑬:触发确认链路 + 运营中心「工作流」区 —— 设计只管设计,运营管细节
**触发确认链路**(用户口述五步的第 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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user