Archived
M5(3/3): trigger —— 三类触发器与起单幂等
- matchesEvent:事件触发的命中判定(S-FEEL 规则;规则写坏按不命中处置,不让事件流停摆) - assembleInput:字段映射装配;缺必填即起单失败入死信,不静默丢弃 - 幂等:同一事件对同一触发器至多起一单;数据日历型同一行同一日期只触发一次 - 周期两型:日历表达式到点扫描 + 数据日历型(日期字段 + 偏移 + 过滤)逐行起单 - 登记触发:人工起单的统一形态,登记人即来源 - 触发器与流程包平级:配置独立存放,增删启停不改流程包版本 340 个测试通过,门禁五道全绿 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
co-authored by
Claude Opus 5
parent
a8f6eef610
commit
4ad901c452
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* module: trigger —— 对外类型(编码规范 §10.1;语义出处 OS 3.1)。
|
||||
*
|
||||
* 触发器三类,**独立配置存放**,判定与调用接口调起。
|
||||
* 触发器与流程包平级:增删启停不改流程包的任何版本。
|
||||
*/
|
||||
import type { TriggerConfig, TriggerKind } from '@contracts';
|
||||
import type { AssemblyResult, EventScope } from './internal/domain/matching.ts';
|
||||
export type { AssemblyResult, EventScope, TriggerConfig, TriggerKind };
|
||||
export declare class TriggerInputMissingError extends Error {
|
||||
readonly triggerId: string;
|
||||
readonly fields: readonly string[];
|
||||
constructor(triggerId: string, fields: readonly string[]);
|
||||
}
|
||||
export type FireOutcome = {
|
||||
readonly triggerId: string;
|
||||
readonly workflowId?: string;
|
||||
readonly skipped?: 'duplicate' | 'disabled' | 'input-missing';
|
||||
readonly missing?: readonly string[];
|
||||
};
|
||||
export type TriggerService = {
|
||||
list(): Promise<TriggerConfig[]>;
|
||||
upsert(config: TriggerConfig): Promise<TriggerConfig>;
|
||||
setEnabled(id: string, enabled: boolean): Promise<void>;
|
||||
remove(id: string): Promise<void>;
|
||||
/** 事件到达:命中判定 —— 供 `event` 的 `TriggerMatcher` 端口使用 */
|
||||
match(scope: EventScope): Promise<string[]>;
|
||||
/** 命中即起单;同一事件对同一触发器**至多起一单**(事件 ID 为幂等键) */
|
||||
fireByEvent(triggerId: string, eventId: string, scope: EventScope): Promise<FireOutcome>;
|
||||
/** 登记触发:人工起单的统一形态 —— 登记人即来源 */
|
||||
fireByRegistration(triggerId: string, by: string, data: Record<string, unknown>): Promise<FireOutcome>;
|
||||
/** 周期触发的到点扫描(由 pg-boss 定时调起) */
|
||||
sweepPeriodic(now: Date): Promise<FireOutcome[]>;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* module: trigger —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type { AssemblyResult, EventScope, FireOutcome, TriggerConfig, TriggerKind, TriggerService } from './contract.ts';
|
||||
export { TriggerInputMissingError } from './contract.ts';
|
||||
export type { Clock, DeadLetterSink, IdGenerator, LedgerScanner, ProcessGateway, Sfeel, TriggerStore } from './port.ts';
|
||||
export type { TriggerServiceDeps } from './internal/usecase/trigger-service.ts';
|
||||
export { createTriggerService } from './internal/usecase/trigger-service.ts';
|
||||
export { applyOffset, assembleInput, dataCalendarKey, matchesEvent, nextCalendarFire, scopeOf, triggerIdempotencyKey, } from './internal/domain/matching.ts';
|
||||
export { createTriggerStore } from './internal/adapter/trigger-store.ts';
|
||||
export { triggerClaimTable, triggerTable } from './internal/adapter/trigger-schema.ts';
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* module: trigger —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { TriggerConfig } from '@contracts';
|
||||
export type Clock = {
|
||||
now(): Date;
|
||||
};
|
||||
export type IdGenerator = {
|
||||
next(): string;
|
||||
};
|
||||
export type TriggerStore = {
|
||||
list(): Promise<TriggerConfig[]>;
|
||||
get(id: string): Promise<TriggerConfig | undefined>;
|
||||
upsert(config: TriggerConfig): Promise<TriggerConfig>;
|
||||
setEnabled(id: string, enabled: boolean): 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>;
|
||||
};
|
||||
/** 起单 —— app 注入 `process`(L7 同层?否:trigger 在 process 之上同层,经 app 装配) */
|
||||
export type ProcessGateway = {
|
||||
start(input: {
|
||||
packageId: string;
|
||||
subject: string;
|
||||
source: {
|
||||
kind: 'periodic' | 'event' | 'registration';
|
||||
ref: string;
|
||||
by?: string;
|
||||
};
|
||||
data: Record<string, unknown>;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
/** 调用接口的必填字段 —— 装配缺项即起单失败 */
|
||||
requiredInputs(packageId: string): Promise<string[]>;
|
||||
};
|
||||
export type Sfeel = {
|
||||
condition(expression: string, scope: Readonly<Record<string, unknown>>): boolean;
|
||||
expression(expression: string, scope: Readonly<Record<string, unknown>>): unknown;
|
||||
};
|
||||
/** 死信 —— app 注入 `message`(L2):起单失败入死信 */
|
||||
export type DeadLetterSink = {
|
||||
report(input: {
|
||||
triggerId: string;
|
||||
reason: string;
|
||||
detail: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
/** 数据日历型要读台账 —— app 注入 `ledger`(L1) */
|
||||
export type LedgerScanner = {
|
||||
scan(input: {
|
||||
domain: string;
|
||||
object: string;
|
||||
dateField: string;
|
||||
filter?: string;
|
||||
}): Promise<Array<{
|
||||
key: Record<string, string | number>;
|
||||
date: string;
|
||||
row: Record<string, unknown>;
|
||||
}>>;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
# module: trigger
|
||||
|
||||
职责: 触发器三类(周期两型 / 事件 / 登记)的配置、判定与经调用接口起单;
|
||||
起单幂等;数据日历型的逐行扫描
|
||||
不负责: 流程包的结构与流转(见 process)、事件的投递(见 event —— 触发器是它的一个消费方)
|
||||
|
||||
公开出口: index.ts
|
||||
- 类型:`TriggerService` `TriggerConfig` `EventScope` `FireOutcome` `AssemblyResult`
|
||||
- 错误:`TriggerInputMissingError`
|
||||
- 端口类型:`TriggerStore` `ProcessGateway` `Sfeel` `DeadLetterSink` `LedgerScanner`
|
||||
- 用例工厂:`createTriggerService(deps): TriggerService`
|
||||
- 纯函数:`matchesEvent` `assembleInput` `triggerIdempotencyKey` `nextCalendarFire`
|
||||
`dataCalendarKey` `applyOffset` `scopeOf`
|
||||
- adapter:`createTriggerStore`;Drizzle 表:`triggerTable` `triggerClaimTable`
|
||||
|
||||
层级: L7(编码规范 §4)
|
||||
依赖: @contracts;@shared/db;process(L7 同层 → 经 `ProcessGateway` 端口由 app 注入);
|
||||
port:`Sfeel` / `DeadLetterSink`(message)/ `LedgerScanner`(ledger)
|
||||
被依赖: event(经 `TriggerMatcher` 端口,由 app 注入 —— event 是 L1,不反向依赖本模块)、app
|
||||
|
||||
不变量(OS 3.1 / 6.3):
|
||||
- **触发器与流程包平级**:配置独立存放,增删启停**不改流程包的任何版本**
|
||||
- **只负责起单**:经调用接口调起流程包;流程包与活动内部不含任何触发逻辑
|
||||
- **同一事件流,不另设通路**:`match` 供 event 的三级消费判定使用
|
||||
- **同一事件对同一触发器至多起一单**:以事件 ID 为幂等键
|
||||
- **缺必填即起单失败入死信**,不静默丢弃
|
||||
- **数据日历型同一行同一日期只触发一次**(键含日期值);字段值变更后可再触发
|
||||
- 命中规则求值失败按**不命中**处置 —— 规则写坏了不让事件流停摆
|
||||
@@ -0,0 +1,121 @@
|
||||
/** 触发器判定与输入装配 —— domain 测试零 mock(OS 3.1 / 6.3) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TriggerConfig } from '@contracts';
|
||||
import { evaluateCondition, evaluate as evalSfeel } from '@sfeel';
|
||||
import {
|
||||
applyOffset,
|
||||
assembleInput,
|
||||
dataCalendarKey,
|
||||
matchesEvent,
|
||||
nextCalendarFire,
|
||||
scopeOf,
|
||||
triggerIdempotencyKey,
|
||||
} from '../internal/domain/matching.ts';
|
||||
|
||||
const eventTrigger = (over: Partial<TriggerConfig> = {}): TriggerConfig =>
|
||||
({
|
||||
id: 'tr-rfq' as never,
|
||||
name: '客户RFQ询价',
|
||||
enabled: true,
|
||||
processPackageId: 'quote' as never,
|
||||
mapping: [
|
||||
{ target: 'customerCode', source: { from: 'expression', sfeel: 'keys.customer' } },
|
||||
{ target: 'channel', source: { from: 'constant', value: 'mail' } },
|
||||
],
|
||||
spec: { kind: 'event', rule: 'contains(subject, "询价")', eventType: 'data' },
|
||||
...over,
|
||||
}) as TriggerConfig;
|
||||
|
||||
const scope = {
|
||||
type: 'data',
|
||||
keys: { customer: 'C001' },
|
||||
payload: { subject: '询价 RFQ - 车规 Analog' },
|
||||
source: 'channel',
|
||||
occurredAt: '2026-07-27T02:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('matchesEvent —— 流程层的系统级事件监听', () => {
|
||||
it('规则命中即起单候选', () => {
|
||||
expect(matchesEvent(eventTrigger(), scope, evaluateCondition)).toBe(true);
|
||||
});
|
||||
|
||||
it('规则不命中即跳过', () => {
|
||||
expect(matchesEvent(eventTrigger(), { ...scope, payload: { subject: '普通问候' } }, evaluateCondition)).toBe(false);
|
||||
});
|
||||
|
||||
it('停用的触发器恒不命中', () => {
|
||||
expect(matchesEvent(eventTrigger({ enabled: false }), scope, evaluateCondition)).toBe(false);
|
||||
});
|
||||
|
||||
it('事件类型不符即跳过', () => {
|
||||
expect(matchesEvent(eventTrigger(), { ...scope, type: 'time' }, evaluateCondition)).toBe(false);
|
||||
});
|
||||
|
||||
it('规则写坏了按不命中处置 —— 不让事件流停摆', () => {
|
||||
const broken = eventTrigger({ spec: { kind: 'event', rule: 'contains(', eventType: 'data' } } as never);
|
||||
expect(matchesEvent(broken, scope, evaluateCondition)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assembleInput —— 缺必填即起单失败入死信', () => {
|
||||
const evalExpr = (expr: string, s: Readonly<Record<string, unknown>>): unknown => evalSfeel(expr, s);
|
||||
|
||||
it('必填齐备即 ready', () => {
|
||||
const result = assembleInput(eventTrigger(), scopeOf(scope), ['customerCode'], evalExpr);
|
||||
expect(result).toEqual({ kind: 'ready', data: { customerCode: 'C001', channel: 'mail' } });
|
||||
});
|
||||
|
||||
it('缺必填即 missing,并指出缺谁', () => {
|
||||
const result = assembleInput(eventTrigger(), scopeOf({ ...scope, keys: {} }), ['customerCode'], evalExpr);
|
||||
expect(result).toEqual({ kind: 'missing', fields: ['customerCode'] });
|
||||
});
|
||||
|
||||
it('常量映射不依赖事件', () => {
|
||||
const result = assembleInput(eventTrigger(), {}, ['channel'], evalExpr);
|
||||
expect(result.kind).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('幂等键', () => {
|
||||
it('同一事件对同一触发器至多起一单', () => {
|
||||
expect(triggerIdempotencyKey('t1', 'e1')).toBe('trigger:t1:e1');
|
||||
expect(triggerIdempotencyKey('t1', 'e1')).not.toBe(triggerIdempotencyKey('t1', 'e2'));
|
||||
});
|
||||
|
||||
it('数据日历型:同一行同一日期只触发一次', () => {
|
||||
expect(dataCalendarKey('t1', 'pn=LM358', '2026-08-01')).toBe('trigger:t1:pn=LM358:2026-08-01');
|
||||
// 日期变了就能再触发
|
||||
expect(dataCalendarKey('t1', 'pn=LM358', '2026-09-01')).not.toBe(dataCalendarKey('t1', 'pn=LM358', '2026-08-01'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('周期触发', () => {
|
||||
it('日历型:每天到点', () => {
|
||||
const after = new Date('2026-07-27T01:00:00.000Z');
|
||||
const next = nextCalendarFire('daily 09:00', after);
|
||||
expect(next).toBeInstanceOf(Date);
|
||||
expect(next!.getHours()).toBe(9);
|
||||
expect(next!.getTime()).toBeGreaterThan(after.getTime());
|
||||
});
|
||||
|
||||
it('已过点即顺延到次日', () => {
|
||||
const morning = new Date('2026-07-27T00:00:00.000Z');
|
||||
morning.setHours(10, 0, 0, 0);
|
||||
const next = nextCalendarFire('daily 09:00', morning);
|
||||
expect(next!.getDate()).toBe(morning.getDate() + 1);
|
||||
});
|
||||
|
||||
it('周期表达式不认即 undefined', () => {
|
||||
expect(nextCalendarFire('每天九点', new Date())).toBeUndefined();
|
||||
});
|
||||
|
||||
it('数据日历型偏移:合同到期前 N 天', () => {
|
||||
const due = new Date('2026-08-10T00:00:00.000Z');
|
||||
expect(applyOffset(due, '-P3D').toISOString().slice(0, 10)).toBe('2026-08-07');
|
||||
expect(applyOffset(due, 'P0D').toISOString().slice(0, 10)).toBe('2026-08-10');
|
||||
});
|
||||
|
||||
it('非法偏移即报错', () => {
|
||||
expect(() => applyOffset(new Date(), '前三天')).toThrow(/非法日历偏移/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* module: trigger —— 对外类型(编码规范 §10.1;语义出处 OS 3.1)。
|
||||
*
|
||||
* 触发器三类,**独立配置存放**,判定与调用接口调起。
|
||||
* 触发器与流程包平级:增删启停不改流程包的任何版本。
|
||||
*/
|
||||
import type { TriggerConfig, TriggerKind } from '@contracts';
|
||||
import type { AssemblyResult, EventScope } from './internal/domain/matching.ts';
|
||||
|
||||
export type { AssemblyResult, EventScope, TriggerConfig, TriggerKind };
|
||||
|
||||
export class TriggerInputMissingError extends Error {
|
||||
constructor(
|
||||
readonly triggerId: string,
|
||||
readonly fields: readonly string[],
|
||||
) {
|
||||
super(`触发器 ${triggerId} 起单失败:缺必填字段 ${fields.join('、')} —— 入死信`);
|
||||
this.name = 'TriggerInputMissingError';
|
||||
}
|
||||
}
|
||||
|
||||
export type FireOutcome = {
|
||||
readonly triggerId: string;
|
||||
readonly workflowId?: string;
|
||||
readonly skipped?: 'duplicate' | 'disabled' | 'input-missing';
|
||||
readonly missing?: readonly string[];
|
||||
};
|
||||
|
||||
export type TriggerService = {
|
||||
list(): Promise<TriggerConfig[]>;
|
||||
upsert(config: TriggerConfig): Promise<TriggerConfig>;
|
||||
setEnabled(id: string, enabled: boolean): Promise<void>;
|
||||
remove(id: string): Promise<void>;
|
||||
|
||||
/** 事件到达:命中判定 —— 供 `event` 的 `TriggerMatcher` 端口使用 */
|
||||
match(scope: EventScope): Promise<string[]>;
|
||||
/** 命中即起单;同一事件对同一触发器**至多起一单**(事件 ID 为幂等键) */
|
||||
fireByEvent(triggerId: string, eventId: string, scope: EventScope): Promise<FireOutcome>;
|
||||
/** 登记触发:人工起单的统一形态 —— 登记人即来源 */
|
||||
fireByRegistration(triggerId: string, by: string, data: Record<string, unknown>): Promise<FireOutcome>;
|
||||
/** 周期触发的到点扫描(由 pg-boss 定时调起) */
|
||||
sweepPeriodic(now: Date): Promise<FireOutcome[]>;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* module: trigger —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type { AssemblyResult, EventScope, FireOutcome, TriggerConfig, TriggerKind, TriggerService } from './contract.ts';
|
||||
export { TriggerInputMissingError } from './contract.ts';
|
||||
|
||||
export type { Clock, DeadLetterSink, IdGenerator, LedgerScanner, ProcessGateway, Sfeel, TriggerStore } from './port.ts';
|
||||
|
||||
export type { TriggerServiceDeps } from './internal/usecase/trigger-service.ts';
|
||||
export { createTriggerService } from './internal/usecase/trigger-service.ts';
|
||||
|
||||
export {
|
||||
applyOffset,
|
||||
assembleInput,
|
||||
dataCalendarKey,
|
||||
matchesEvent,
|
||||
nextCalendarFire,
|
||||
scopeOf,
|
||||
triggerIdempotencyKey,
|
||||
} from './internal/domain/matching.ts';
|
||||
|
||||
export { createTriggerStore } from './internal/adapter/trigger-store.ts';
|
||||
export { triggerClaimTable, triggerTable } from './internal/adapter/trigger-schema.ts';
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* trigger 的 sys schema 表(编码规范 §7 / ADR-002)。
|
||||
* 触发器配置**独立存放**于系统自持存储 —— 不入流程包结构、不随流程包版本(OS 3.1)。
|
||||
*/
|
||||
import { boolean, index, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { sys } from '@shared/db.ts';
|
||||
|
||||
export const triggerTable = sys.table(
|
||||
'trigger',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
processPackageId: text('process_package_id').notNull(),
|
||||
mapping: jsonb('mapping').notNull().default([]),
|
||||
spec: jsonb('spec').notNull(),
|
||||
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 triggerClaimTable = sys.table('trigger_claim', {
|
||||
key: text('key').primaryKey(),
|
||||
claimedAt: timestamp('claimed_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* trigger 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
|
||||
*/
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { TriggerConfig } from '@contracts';
|
||||
import { triggerConfigSchema } from '@contracts';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { TriggerStore } from '../../port.ts';
|
||||
import { triggerClaimTable, triggerTable } from './trigger-schema.ts';
|
||||
|
||||
type Row = typeof triggerTable.$inferSelect;
|
||||
|
||||
function toConfig(row: Row): TriggerConfig {
|
||||
return triggerConfigSchema.parse({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
enabled: row.enabled,
|
||||
processPackageId: row.processPackageId,
|
||||
mapping: row.mapping,
|
||||
spec: row.spec,
|
||||
});
|
||||
}
|
||||
|
||||
export function createTriggerStore(db: Db): TriggerStore {
|
||||
return {
|
||||
async list() {
|
||||
const rows = await db.select().from(triggerTable).orderBy(triggerTable.name);
|
||||
return rows.map(toConfig);
|
||||
},
|
||||
async get(id) {
|
||||
const rows = await db.select().from(triggerTable).where(eq(triggerTable.id, id)).limit(1);
|
||||
return rows[0] === undefined ? undefined : toConfig(rows[0]);
|
||||
},
|
||||
async upsert(config) {
|
||||
const values = {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
enabled: config.enabled,
|
||||
processPackageId: config.processPackageId,
|
||||
mapping: config.mapping,
|
||||
spec: config.spec,
|
||||
};
|
||||
const [row] = await db
|
||||
.insert(triggerTable)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({ target: triggerTable.id, set: values })
|
||||
.returning();
|
||||
if (row === undefined) throw new Error('触发器写入未返回行');
|
||||
return toConfig(row);
|
||||
},
|
||||
async setEnabled(id, enabled) {
|
||||
await db.update(triggerTable).set({ enabled }).where(eq(triggerTable.id, id));
|
||||
},
|
||||
async remove(id) {
|
||||
await db.delete(triggerTable).where(eq(triggerTable.id, id));
|
||||
},
|
||||
async claim(key) {
|
||||
const inserted = await db
|
||||
.insert(triggerClaimTable)
|
||||
.values({ key })
|
||||
.onConflictDoNothing()
|
||||
.returning({ key: triggerClaimTable.key });
|
||||
return inserted.length > 0;
|
||||
},
|
||||
async lastFiredAt(id) {
|
||||
const rows = await db.select({ at: triggerTable.lastFiredAt }).from(triggerTable).where(eq(triggerTable.id, id)).limit(1);
|
||||
return rows[0]?.at ?? undefined;
|
||||
},
|
||||
async markFired(id, at) {
|
||||
await db.update(triggerTable).set({ lastFiredAt: at }).where(eq(triggerTable.id, id));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 触发器判定 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 3.1)。
|
||||
*
|
||||
* 「触发器 = **流程层的系统级事件监听**:消费同一条事件流,动作是**起单**。
|
||||
* 三类:周期(日历型 / 数据日历型)、事件、登记。
|
||||
* 触发器与工作流**平级**:不入流程包结构、不随流程包版本;
|
||||
* 只负责起单,经调用接口调起流程包 —— 流程包与活动内部不含任何触发逻辑。」
|
||||
*/
|
||||
import type { TriggerConfig } from '@contracts';
|
||||
|
||||
export type ConditionEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
|
||||
export type ExpressionEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => unknown;
|
||||
|
||||
/** 事件触发的求值输入:事件信封与载荷字段 */
|
||||
export type EventScope = {
|
||||
readonly type: string;
|
||||
readonly keys: Readonly<Record<string, string>>;
|
||||
readonly payload: Readonly<Record<string, unknown>>;
|
||||
readonly source: string;
|
||||
readonly occurredAt: string;
|
||||
};
|
||||
|
||||
export function scopeOf(scope: EventScope): Record<string, unknown> {
|
||||
return { type: scope.type, keys: scope.keys, source: scope.source, occurredAt: scope.occurredAt, ...scope.payload };
|
||||
}
|
||||
|
||||
/** 事件触发器是否命中 —— 命中规则用 S-FEEL,静态可检 */
|
||||
export function matchesEvent(config: TriggerConfig, scope: EventScope, evaluate: ConditionEvaluator): boolean {
|
||||
if (!config.enabled) return false;
|
||||
if (config.spec.kind !== 'event') return false;
|
||||
if (config.spec.eventType !== scope.type) return false;
|
||||
try {
|
||||
return evaluate(config.spec.rule, scopeOf(scope));
|
||||
} catch {
|
||||
// 规则写坏了不该让事件流停摆:按不命中处置
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 触发器输入装配(OS 3.1) ────────────────────────────────────────── */
|
||||
|
||||
export type AssemblyResult =
|
||||
| { readonly kind: 'ready'; readonly data: Record<string, unknown> }
|
||||
/** 缺必填字段则**起单失败、入死信** */
|
||||
| { readonly kind: 'missing'; readonly fields: readonly string[] };
|
||||
|
||||
export function assembleInput(
|
||||
config: TriggerConfig,
|
||||
scope: Readonly<Record<string, unknown>>,
|
||||
required: readonly string[],
|
||||
evaluateExpression: ExpressionEvaluator,
|
||||
): AssemblyResult {
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const mapping of config.mapping) {
|
||||
switch (mapping.source.from) {
|
||||
case 'constant':
|
||||
data[mapping.target] = mapping.source.value;
|
||||
break;
|
||||
case 'expression':
|
||||
data[mapping.target] = evaluateExpression(mapping.source.sfeel, scope);
|
||||
break;
|
||||
case 'initiation':
|
||||
case 'output-port':
|
||||
case 'workflow-var':
|
||||
data[mapping.target] = pick(scope, mapping.source.from === 'workflow-var' ? mapping.source.name : mapping.source.path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const missing = required.filter((field) => data[field] === undefined || data[field] === null);
|
||||
return missing.length > 0 ? { kind: 'missing', fields: missing } : { kind: 'ready', data };
|
||||
}
|
||||
|
||||
function pick(scope: Readonly<Record<string, unknown>>, path: string): unknown {
|
||||
return path.split('.').reduce<unknown>((acc, seg) => {
|
||||
if (acc === null || acc === undefined || typeof acc !== 'object') return undefined;
|
||||
return (acc as Record<string, unknown>)[seg];
|
||||
}, scope);
|
||||
}
|
||||
|
||||
/* ── 幂等:同一事件对同一触发器**至多起一单**,以事件 ID 为幂等键(OS 6.3) ── */
|
||||
|
||||
export function triggerIdempotencyKey(triggerId: string, eventId: string): string {
|
||||
return `trigger:${triggerId}:${eventId}`;
|
||||
}
|
||||
|
||||
/* ── 周期触发:日历型与数据日历型(OS 3.1) ─────────────────────────── */
|
||||
|
||||
/** 极简日历表达式:`daily HH:mm` / `weekly <1-7> HH:mm` / `monthly <1-28> HH:mm` */
|
||||
export function nextCalendarFire(expression: string, after: Date): Date | undefined {
|
||||
const daily = /^daily\s+(\d{1,2}):(\d{2})$/.exec(expression);
|
||||
if (daily !== null) return nextAt(after, Number(daily[1]), Number(daily[2]));
|
||||
|
||||
const weekly = /^weekly\s+([1-7])\s+(\d{1,2}):(\d{2})$/.exec(expression);
|
||||
if (weekly !== null) {
|
||||
let candidate = nextAt(after, Number(weekly[2]), Number(weekly[3]));
|
||||
const target = Number(weekly[1]) % 7;
|
||||
for (let i = 0; i < 7 && candidate.getDay() !== target; i += 1) {
|
||||
candidate = new Date(candidate.getTime() + 86_400_000);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const monthly = /^monthly\s+(\d{1,2})\s+(\d{1,2}):(\d{2})$/.exec(expression);
|
||||
if (monthly !== null) {
|
||||
let candidate = nextAt(after, Number(monthly[2]), Number(monthly[3]));
|
||||
const day = Number(monthly[1]);
|
||||
for (let i = 0; i < 62 && candidate.getDate() !== day; i += 1) {
|
||||
candidate = new Date(candidate.getTime() + 86_400_000);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function nextAt(after: Date, hour: number, minute: number): Date {
|
||||
const candidate = new Date(after);
|
||||
candidate.setSeconds(0, 0);
|
||||
candidate.setHours(hour, minute);
|
||||
if (candidate.getTime() <= after.getTime()) candidate.setDate(candidate.getDate() + 1);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据日历型:按台账日期字段 + 偏移 + 过滤条件**逐行起单**。
|
||||
* 「同一行同一日期只触发一次,字段值变更后可再触发。」
|
||||
*/
|
||||
export function dataCalendarKey(triggerId: string, rowKey: string, dateValue: string): string {
|
||||
return `trigger:${triggerId}:${rowKey}:${dateValue}`;
|
||||
}
|
||||
|
||||
/** 偏移:`-P3D` 表示日期字段前 3 天 */
|
||||
export function applyOffset(date: Date, offset: string): Date {
|
||||
const m = /^(-?)P(?:(\d+)D)?$/.exec(offset);
|
||||
if (m === null) throw new Error(`非法日历偏移:${offset}`);
|
||||
const days = Number(m[2] ?? 0) * (m[1] === '-' ? -1 : 1);
|
||||
return new Date(date.getTime() + days * 86_400_000);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* trigger 用例 —— 判定与起单(编码规范 §5.3;语义出处 OS 3.1)。
|
||||
*/
|
||||
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 {
|
||||
applyOffset,
|
||||
assembleInput,
|
||||
dataCalendarKey,
|
||||
matchesEvent,
|
||||
nextCalendarFire,
|
||||
scopeOf,
|
||||
triggerIdempotencyKey,
|
||||
} from '../domain/matching.ts';
|
||||
|
||||
export type TriggerServiceDeps = {
|
||||
readonly store: TriggerStore;
|
||||
readonly process: ProcessGateway;
|
||||
readonly sfeel: Sfeel;
|
||||
readonly deadLetters: DeadLetterSink;
|
||||
readonly ledger: LedgerScanner;
|
||||
readonly clock: Clock;
|
||||
readonly ids: IdGenerator;
|
||||
};
|
||||
|
||||
export function createTriggerService(deps: TriggerServiceDeps): TriggerService {
|
||||
/** 装配 → 起单;缺必填即入死信,不静默丢弃 */
|
||||
async function fire(
|
||||
config: TriggerConfig,
|
||||
source: { kind: 'periodic' | 'event' | 'registration'; ref: string; by?: string },
|
||||
scope: Record<string, unknown>,
|
||||
subject: string,
|
||||
): Promise<FireOutcome> {
|
||||
const required = await deps.process.requiredInputs(config.processPackageId);
|
||||
const assembled = assembleInput(config, scope, required, deps.sfeel.expression);
|
||||
if (assembled.kind === 'missing') {
|
||||
await deps.deadLetters.report({
|
||||
triggerId: config.id,
|
||||
reason: `起单失败:缺必填字段 ${assembled.fields.join('、')}`,
|
||||
detail: { scope },
|
||||
});
|
||||
return { triggerId: config.id, skipped: 'input-missing', missing: assembled.fields };
|
||||
}
|
||||
const workflow = await deps.process.start({
|
||||
packageId: config.processPackageId,
|
||||
subject,
|
||||
source,
|
||||
data: assembled.data,
|
||||
});
|
||||
await deps.store.markFired(config.id, deps.clock.now());
|
||||
return { triggerId: config.id, workflowId: workflow.id };
|
||||
}
|
||||
|
||||
return {
|
||||
async list() {
|
||||
return deps.store.list();
|
||||
},
|
||||
async upsert(config) {
|
||||
return deps.store.upsert(config);
|
||||
},
|
||||
async setEnabled(id, enabled) {
|
||||
await deps.store.setEnabled(id, enabled);
|
||||
},
|
||||
async remove(id) {
|
||||
await deps.store.remove(id);
|
||||
},
|
||||
|
||||
/** 供 event 的 TriggerMatcher 端口:同一事件流,不另设通路 */
|
||||
async match(scope: EventScope) {
|
||||
const all = await deps.store.list();
|
||||
return all.filter((c) => matchesEvent(c, scope, deps.sfeel.condition)).map((c) => c.id);
|
||||
},
|
||||
|
||||
async fireByEvent(triggerId, eventId, scope) {
|
||||
const config = await deps.store.get(triggerId);
|
||||
if (config === undefined || !config.enabled) return { triggerId, skipped: 'disabled' };
|
||||
// 同一事件对同一触发器至多起一单
|
||||
if (!(await deps.store.claim(triggerIdempotencyKey(triggerId, eventId)))) {
|
||||
return { triggerId, skipped: 'duplicate' };
|
||||
}
|
||||
return fire(config, { kind: 'event', ref: eventId }, scopeOf(scope), config.name);
|
||||
},
|
||||
|
||||
/** 登记触发:登记人即来源 */
|
||||
async fireByRegistration(triggerId, by, data) {
|
||||
const config = await deps.store.get(triggerId);
|
||||
if (config === undefined || !config.enabled) return { triggerId, skipped: 'disabled' };
|
||||
return fire(config, { kind: 'registration', ref: `registration:${by}`, by }, data, config.name);
|
||||
},
|
||||
|
||||
/** 周期扫描:日历型按表达式到点;数据日历型逐行起单 */
|
||||
async sweepPeriodic(now) {
|
||||
const outcomes: FireOutcome[] = [];
|
||||
for (const config of await deps.store.list()) {
|
||||
if (!config.enabled || config.spec.kind !== 'periodic') continue;
|
||||
|
||||
if (config.spec.mode === 'calendar') {
|
||||
const last = (await deps.store.lastFiredAt(config.id)) ?? new Date(now.getTime() - 86_400_000);
|
||||
const due = nextCalendarFire(config.spec.calendar, last);
|
||||
if (due === undefined || due.getTime() > now.getTime()) continue;
|
||||
if (!(await deps.store.claim(`${config.id}:${due.toISOString()}`))) continue;
|
||||
outcomes.push(await fire(config, { kind: 'periodic', ref: due.toISOString() }, { firedAt: due.toISOString() }, config.name));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 数据日历型:日期字段 + 偏移 + 过滤条件
|
||||
const spec = config.spec;
|
||||
const rows = await deps.ledger.scan({
|
||||
domain: spec.domain,
|
||||
object: spec.object,
|
||||
dateField: spec.dateField,
|
||||
...(spec.filter === undefined ? {} : { filter: spec.filter }),
|
||||
});
|
||||
for (const row of rows) {
|
||||
const due = applyOffset(new Date(row.date), spec.offset);
|
||||
if (due.getTime() > now.getTime()) continue;
|
||||
const rowKey = Object.entries(row.key)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([k, v]) => `${k}=${String(v)}`)
|
||||
.join('|');
|
||||
// 同一行同一日期只触发一次;字段值变更后可再触发
|
||||
if (!(await deps.store.claim(dataCalendarKey(config.id, rowKey, row.date)))) continue;
|
||||
outcomes.push(
|
||||
await fire(config, { kind: 'periodic', ref: `${rowKey}@${row.date}` }, { ...row.row, ...row.key }, config.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
return outcomes;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "@modules/trigger", "version": "0.0.0", "private": true, "type": "module", "exports": { ".": "./index.ts" } }
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* module: trigger —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { TriggerConfig } from '@contracts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
export type IdGenerator = { next(): string };
|
||||
|
||||
export type TriggerStore = {
|
||||
list(): Promise<TriggerConfig[]>;
|
||||
get(id: string): Promise<TriggerConfig | undefined>;
|
||||
upsert(config: TriggerConfig): Promise<TriggerConfig>;
|
||||
setEnabled(id: string, enabled: boolean): 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>;
|
||||
};
|
||||
|
||||
/** 起单 —— app 注入 `process`(L7 同层?否:trigger 在 process 之上同层,经 app 装配) */
|
||||
export type ProcessGateway = {
|
||||
start(input: {
|
||||
packageId: string;
|
||||
subject: string;
|
||||
source: { kind: 'periodic' | 'event' | 'registration'; ref: string; by?: string };
|
||||
data: Record<string, unknown>;
|
||||
}): Promise<{ id: string }>;
|
||||
/** 调用接口的必填字段 —— 装配缺项即起单失败 */
|
||||
requiredInputs(packageId: string): Promise<string[]>;
|
||||
};
|
||||
|
||||
export type Sfeel = {
|
||||
condition(expression: string, scope: Readonly<Record<string, unknown>>): boolean;
|
||||
expression(expression: string, scope: Readonly<Record<string, unknown>>): unknown;
|
||||
};
|
||||
|
||||
/** 死信 —— app 注入 `message`(L2):起单失败入死信 */
|
||||
export type DeadLetterSink = {
|
||||
report(input: { triggerId: string; reason: string; detail: Record<string, unknown> }): Promise<void>;
|
||||
};
|
||||
|
||||
/** 数据日历型要读台账 —— app 注入 `ledger`(L1) */
|
||||
export type LedgerScanner = {
|
||||
scan(input: {
|
||||
domain: string;
|
||||
object: string;
|
||||
dateField: string;
|
||||
filter?: string;
|
||||
}): Promise<Array<{ key: Record<string, string | number>; date: string; row: Record<string, unknown> }>>;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE "sys"."trigger_claim" (
|
||||
"key" text PRIMARY KEY NOT NULL,
|
||||
"claimed_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sys"."trigger" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"process_package_id" text NOT NULL,
|
||||
"mapping" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"spec" jsonb NOT NULL,
|
||||
"last_fired_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "trigger_enabled_idx" ON "sys"."trigger" USING btree ("enabled");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1785088864829,
|
||||
"tag": "0006_easy_enchantress",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1785089035016,
|
||||
"tag": "0007_swift_silver_samurai",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user