forked from ChaiHao/BPOS-MVP
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,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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user