Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
146 lines
6.2 KiB
TypeScript
146 lines
6.2 KiB
TypeScript
/**
|
|
* 触发器判定 —— 纯逻辑,零 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 {
|
|
/*
|
|
* 登记触发的入参**就是登记人填的表单**(OS 3.1:「按调用接口 schema 填写输入,
|
|
* 表单由 schema 自动渲染」)—— 那份表单已按调用接口成形,不需要再装配一遍。
|
|
* 所以这里以 scope 打底,mapping(若声明了)再覆盖其上。
|
|
*
|
|
* 事件 / 周期触发不打底:它们的 scope 是事件载荷,与调用接口是两套字段名,
|
|
* 直接铺进去会把无关字段塞给流程包 —— 那正是 mapping 存在的理由。
|
|
*/
|
|
const data: Record<string, unknown> = 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<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);
|
|
}
|