/** * 触发器判定 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 3.1)。 * * 「触发器 = **流程层的系统级事件监听**:消费同一条事件流,动作是**起单**。 * 三类:周期(日历型 / 数据日历型)、事件、登记。 * 触发器与工作流**平级**:不入流程包结构、不随流程包版本; * 只负责起单,经调用接口调起流程包 —— 流程包与活动内部不含任何触发逻辑。」 */ import type { TriggerConfig } from '@contracts'; export type ConditionEvaluator = (expression: string, scope: Readonly>) => boolean; export type ExpressionEvaluator = (expression: string, scope: Readonly>) => unknown; /** 事件触发的求值输入:事件信封与载荷字段 */ export type EventScope = { readonly type: string; readonly keys: Readonly>; readonly payload: Readonly>; readonly source: string; readonly occurredAt: string; }; export function scopeOf(scope: EventScope): Record { 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 } /** 缺必填字段则**起单失败、入死信** */ | { readonly kind: 'missing'; readonly fields: readonly string[] }; export function assembleInput( config: TriggerConfig, scope: Readonly>, required: readonly string[], evaluateExpression: ExpressionEvaluator, ): AssemblyResult { /* * 登记触发的入参**就是登记人填的表单**(OS 3.1:「按调用接口 schema 填写输入, * 表单由 schema 自动渲染」)—— 那份表单已按调用接口成形,不需要再装配一遍。 * 所以这里以 scope 打底,mapping(若声明了)再覆盖其上。 * * 事件 / 周期触发不打底:它们的 scope 是事件载荷,与调用接口是两套字段名, * 直接铺进去会把无关字段塞给流程包 —— 那正是 mapping 存在的理由。 */ const data: Record = config.spec.kind === 'registration' ? { ...scope } : {}; 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>, path: string): unknown { return path.split('.').reduce((acc, seg) => { if (acc === null || acc === undefined || typeof acc !== 'object') return undefined; return (acc as Record)[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); }