三面拆分⑬:触发确认链路 + 运营中心「工作流」区 —— 设计只管设计,运营管细节
**触发确认链路**(用户口述五步的第 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:
@@ -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',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ATTRIBUTION_SIDE_LABEL,
|
||||
answerMessageRoute,
|
||||
chatCardReplySchema,
|
||||
confirmTriggerPendingRoute,
|
||||
listMessagesRoute,
|
||||
markMessageReadRoute,
|
||||
listTasksRoute,
|
||||
@@ -308,6 +309,15 @@ export function registerDeskRoutes(app: Hono<AppEnv>, 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<AppEnv>, 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<AppEnv>, 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) {
|
||||
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
resumeTaskRoute,
|
||||
staffIdSchema,
|
||||
taskIdSchema,
|
||||
setNodeOwnerRoute,
|
||||
setTriggerConfirmRoute,
|
||||
listNodeOwnersRoute,
|
||||
toggleTriggerRoute,
|
||||
upsertTriggerRoute,
|
||||
validateProcessRoute,
|
||||
@@ -549,6 +552,49 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, 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);改定义同样不动任何流程包版本。
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
被依赖: trigger, app(治理台 · 工作流管理)
|
||||
|
||||
不变量(OS 03 / 3.2):
|
||||
- **节点负责人三层**(2026-07-30):起单时合并 —— 显式指派(start 入参,测试台定向)>
|
||||
运营配置(`sys.node_owner`,运营中心 · 工作流里配)> 轮值(员工组 → 全员最闲者先)。
|
||||
运营配置不随包版本 —— 它说的是「这家企业现在这活谁管」,不是流程长什么样。
|
||||
- **编辑即其身**(产品决策 2026-07-28 第四轮):`savePackage` 对已存在的版本
|
||||
**就地写回**(号不变、状态不变);新版本只出自显式新建(`asNew: true`)或
|
||||
新流程首版(插入,`retired` 不生效)。两道底线:
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<string>(), candidates: async () => [], workGroupBinding: async () => undefined, isActive: async () => true },
|
||||
|
||||
@@ -79,6 +79,10 @@ export type ProcessService = {
|
||||
listWorkflows(limit?: number): Promise<WorkflowRecord[]>;
|
||||
instancesOf(workflowId: string): Promise<NodeInstance[]>;
|
||||
|
||||
/* ── 节点负责人(运营配置):显式指派 > 这里 > 轮值 ── */
|
||||
nodeOwners(packageId: string): Promise<Record<string, string>>;
|
||||
setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise<void>;
|
||||
|
||||
/** 任务终态到达:完成即继续流转;非完成即走声明的失败走向 */
|
||||
onTaskTerminal(
|
||||
taskId: string,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, string> = { ...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 切面):
|
||||
* 语义、留痕与副作用编排住在运行时实现里,薄壳只转发。
|
||||
|
||||
@@ -93,6 +93,10 @@ export type ProcessStore = {
|
||||
patch: Partial<NodeInstance> & { taskId?: string },
|
||||
): Promise<void>;
|
||||
findInstanceByTask(taskId: string): Promise<{ workflowId: string; nodeId: string; instance: number } | undefined>;
|
||||
|
||||
/* ── 节点负责人(运营配置) ──────────────────────────────── */
|
||||
nodeOwners(packageId: string): Promise<Record<string, string>>;
|
||||
setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise<void>;
|
||||
};
|
||||
|
||||
/** 活动契约面 —— app 注入 `activity-registry`(L4):编排校验与起单都要它 */
|
||||
|
||||
@@ -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 的三级消费判定使用
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
readonly source: { readonly kind: 'periodic' | 'event' | 'registration'; readonly ref: string; readonly by?: string };
|
||||
readonly preview: Readonly<Record<string, string>>;
|
||||
readonly createdAt: string;
|
||||
readonly consumedAt?: string;
|
||||
readonly workflowId?: string;
|
||||
};
|
||||
|
||||
export type TriggerService = {
|
||||
list(): Promise<TriggerConfig[]>;
|
||||
upsert(config: TriggerConfig): Promise<TriggerConfig>;
|
||||
@@ -40,4 +58,12 @@ export type TriggerService = {
|
||||
fireByRegistration(triggerId: string, by: string, data: Record<string, unknown>): Promise<FireOutcome>;
|
||||
/** 周期触发的到点扫描(由 pg-boss 定时调起) */
|
||||
sweepPeriodic(now: Date): Promise<FireOutcome[]>;
|
||||
|
||||
/* ── 触发确认链路(运营配置) ────────────────────────────────── */
|
||||
/** 运营口:要不要人工确认、谁来确认 —— 与设计面的 upsert 分口 */
|
||||
setConfirm(id: string, confirm: { required: boolean; staffId?: string }): Promise<void>;
|
||||
/** 指定人点头 —— 幂等:已被确认过就回那条流的号,绝不起第二条 */
|
||||
confirmPending(pendingId: string, by: string): Promise<{ workflowId: string; alreadyStarted: boolean }>;
|
||||
/** 待确认单批读 —— 工作台消息列表就地贴确认状态用 */
|
||||
pendingByIds(ids: readonly string[]): Promise<PendingFire[]>;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
source: row.source as PendingFire['source'],
|
||||
preview: row.preview as Record<string, string>,
|
||||
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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<TriggerConfig | undefined>;
|
||||
upsert(config: TriggerConfig): Promise<TriggerConfig>;
|
||||
setEnabled(id: string, enabled: boolean): Promise<void>;
|
||||
/** 运营口:确认配置 —— 设计面的 upsert 不碰这两列 */
|
||||
setConfirm(id: string, confirm: { required: boolean; staffId?: string }): Promise<void>;
|
||||
remove(id: string): Promise<void>;
|
||||
/** 幂等:同一键至多起一单 */
|
||||
claim(key: string): Promise<boolean>;
|
||||
lastFiredAt(id: string): Promise<Date | undefined>;
|
||||
markFired(id: string, at: Date): Promise<void>;
|
||||
|
||||
/* ── 待确认单(触发确认链路) ────────────────────────────────── */
|
||||
createPending(pending: PendingFire): Promise<void>;
|
||||
getPending(id: string): Promise<PendingFire | undefined>;
|
||||
pendingByIds(ids: readonly string[]): Promise<PendingFire[]>;
|
||||
/** 认领:consumed_at 空才认得到 —— 两个人同时点确认,只有一个赢,绝不起两条流 */
|
||||
claimPending(id: string, at: Date): Promise<boolean>;
|
||||
/** 起单成功后把流号写回;起单失败时用 releasePending 把认领退回去 */
|
||||
setPendingWorkflow(id: string, workflowId: string): Promise<void>;
|
||||
releasePending(id: string): Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 确认台 —— app 注入 `message`(L2):命中但要人点头时,
|
||||
* 把带来件内容的系统消息送到指定人的工作台。
|
||||
*/
|
||||
export type ConfirmDesk = {
|
||||
request(input: {
|
||||
pendingId: string;
|
||||
staffId: string;
|
||||
triggerName: string;
|
||||
subject: string;
|
||||
preview: Readonly<Record<string, string>>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
|
||||
/** 起单 —— app 注入 `process`(L7 同层?否:trigger 在 process 之上同层,经 app 装配) */
|
||||
|
||||
Reference in New Issue
Block a user