@mldong/jeeflow 1.6.6 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.js CHANGED
@@ -1,4 +1,4 @@
1
- import { TypeStart, TypeEnd, TypeTask, TypeDecision, TypeFork, TypeJoin, TypeCustom, ProcessInstance, TaskState, } from './model.js';
1
+ import { TypeStart, TypeEnd, TypeTask, TypeDecision, TypeFork, TypeJoin, TypeCustom, ProcessInstance, TaskState, SubmitType, } from './model.js';
2
2
  import { EventType } from './extensions.js';
3
3
  export const KeySubmitType = 'submitType';
4
4
  export const KeyBusinessNo = 'BUSINESS_NO';
@@ -97,6 +97,9 @@ export class EngineImpl {
97
97
  await this.repo.updateInstance(inst);
98
98
  const curNode = findNode(flow, task.taskName);
99
99
  if (curNode) {
100
+ // 1.8.0:任务完成节点自身的后置拦截器(SYNC 同步演进——任务节点推进更新状态/字段)。
101
+ // createTask 不再触发(引擎语义修正),此处为完成任务节点的唯一触发点
102
+ await this.firePost(curNode, inst);
100
103
  const ct = curNode.properties?.countersignType;
101
104
  if (ct === 'SEQUENTIAL') {
102
105
  const doing = await this.repo.findDoingTasks(inst.id);
@@ -209,13 +212,16 @@ export class EngineImpl {
209
212
  return { task, inst };
210
213
  }
211
214
  async executeNode(flow, inst, node, operator, vars) {
215
+ // 任务创建(对齐 Java CreateTaskHandler:不触发节点拦截器——创建任务 ≠ 节点执行完成;
216
+ // 任务完成的拦截器由 executeProcessTask 显式触发,1.8.0 SYNC 同步演进)
217
+ if (node.type === TypeTask || node.type === TypeCustom) {
218
+ await this.createTask(node, inst, operator, vars);
219
+ return;
220
+ }
212
221
  if (!(await this.firePre(node, inst)))
213
222
  return;
214
223
  try {
215
224
  switch (node.type) {
216
- case TypeTask:
217
- case TypeCustom:
218
- return this.createTask(node, inst, operator, vars);
219
225
  case TypeDecision:
220
226
  return this.evaluateDecision(flow, inst, node, operator, vars);
221
227
  case TypeFork:
@@ -229,12 +235,20 @@ export class EngineImpl {
229
235
  await this.executeNode(flow, inst, n, operator, vars);
230
236
  return;
231
237
  }
232
- case TypeEnd:
233
- inst.finish(new Date());
238
+ case TypeEnd: {
239
+ // 对齐 Java EndProcessHandler:submitType=REJECT → reject,否则 finish
240
+ const submitType = inst.variables[KeySubmitType];
241
+ if (submitType != null && Number(submitType) === SubmitType.Reject) {
242
+ inst.reject(new Date());
243
+ }
244
+ else {
245
+ inst.finish(new Date());
246
+ }
234
247
  inst.variables = vars;
235
248
  await this.repo.updateInstance(inst);
236
249
  await this.fireEvent({ type: EventType.ProcessFinish, instanceId: inst.id, operator });
237
250
  return;
251
+ }
238
252
  }
239
253
  }
240
254
  finally {
package/dist/meta.d.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import type { DynamicTableWriter } from './persist.js';
3
+ /** 字段存储类型(对齐 mldong dev_schema_field 1-5 语义) */
4
+ export declare enum StorageType {
5
+ Normal = 1,// 直写列
6
+ Expand = 2,// 对象展开为多列(expandFields 定义子字段列映射)
7
+ Json = 3,// 对象/数组序列化为 JSON 串写列
8
+ One2One = 4,// 子表单条(外键=主表主键,同事务)
9
+ One2Many = 5
10
+ }
11
+ /** 解析 storageType:支持名称("EXPAND")与数字(2,mldong 语义) */
12
+ export declare function parseStorageType(v: unknown): StorageType;
13
+ /** 字段元数据——表单字段 → 存储语义映射 */
14
+ export interface FieldMeta {
15
+ name: string;
16
+ columnName?: string;
17
+ storageType?: StorageType;
18
+ expandFields?: Record<string, string>;
19
+ targetTable?: string;
20
+ foreignKey?: string;
21
+ }
22
+ /** 表元数据——一张业务表的字段存储规范 */
23
+ export interface TableMeta {
24
+ tableName: string;
25
+ primaryKey?: string;
26
+ fields: FieldMeta[];
27
+ }
28
+ export declare function columnOf(f: FieldMeta): string;
29
+ export declare function pkOf(m: TableMeta): string;
30
+ export declare function findField(m: TableMeta, name: string): FieldMeta | undefined;
31
+ export declare function findFieldByColumn(m: TableMeta, columnName: string): FieldMeta | undefined;
32
+ /** 驼峰转下划线(companyName → company_name) */
33
+ export declare function toUnderline(name: string): string;
34
+ /** 元数据提供者 SPI——集成方只实现这一件事;未定义返回 null(回落表结构探测) */
35
+ export interface IDynamicMetaProvider {
36
+ loadTableMeta(tableName: string): TableMeta | null;
37
+ }
38
+ /** 内置 JSON 配置加载器——从目录加载(文件名 = 表名,如 biz_leave.json) */
39
+ export declare class JsonMetaProvider implements IDynamicMetaProvider {
40
+ private dir;
41
+ private cache;
42
+ constructor(dir: string);
43
+ loadTableMeta(tableName: string): TableMeta | null;
44
+ }
45
+ /** 元数据驱动的动态写入器——按 TableMeta.storageType 语义执行插入;
46
+ * 无元数据时完全委托基础 writer(回落现状,零破坏)。读侧由 MetaTableReader 提供。 */
47
+ export declare class MetaTableWriter implements DynamicTableWriter {
48
+ private base;
49
+ private provider;
50
+ constructor(base: DynamicTableWriter, provider: IDynamicMetaProvider);
51
+ filterColumns(tableName: string, columns: string[]): string[] | Promise<string[]>;
52
+ insert(tableName: string, data: Record<string, unknown>): unknown;
53
+ /** 按元数据 storageType 组装 SET 列(SYNC 同步演进,issues/24)——
54
+ * NORMAL/JSON/EXPAND 参与更新;ONE2ONE/ONE2MANY 子表不参与中途更新
55
+ * (任务推进只更新主表行状态,子表数据变动走重新提交);未消费字段直通。 */
56
+ update(tableName: string, data: Record<string, unknown>, whereColumn: string, whereValue: unknown): number | Promise<number>;
57
+ exists(tableName: string, bizKey: string, bizKeyValue: unknown): boolean | Promise<boolean>;
58
+ fillSystemFields(data: Record<string, unknown>, isInsert: boolean): void;
59
+ private expandInto;
60
+ private insertSubTable;
61
+ /** 子表单行插入(issues/24):继承主表 apply_user_id(拦截器场景=流程 operator),
62
+ * 子表单显式同名字段优先(??=)——fillSystemFields 的用户列默认值可解析到 operator,
63
+ * 避免 BIGINT create_user/update_user 列回落 "system" 严格模式报错 */
64
+ private insertSubRow;
65
+ }
66
+ /** 业务表查询器——按列等值查询原始行(与写入器职责分离) */
67
+ export declare class JdbcTableReader {
68
+ private db;
69
+ constructor(db: DatabaseSync);
70
+ queryFirst(tableName: string, whereColumn: string, value: unknown): Record<string, unknown> | null;
71
+ queryList(tableName: string, whereColumn: string, value: unknown, limit: number): Record<string, unknown>[];
72
+ }
73
+ /** 元数据驱动的动态读取器——按流程实例回显业务数据。
74
+ * 边界(不做):通用条件分页 / 动态条件语法 / 数据权限 / 排序。 */
75
+ export declare class MetaTableReader {
76
+ private reader;
77
+ private provider;
78
+ constructor(reader: JdbcTableReader, provider: IDynamicMetaProvider);
79
+ readByProcessInstance(tableName: string, processInstanceId: unknown): Record<string, unknown> | null;
80
+ assemble(meta: TableMeta, row: Record<string, unknown>): Record<string, unknown>;
81
+ private expandFrom;
82
+ private readSubTable;
83
+ }
84
+ /** 按列名取值(宽松:忽略大小写) */
85
+ export declare function findRowValue(row: Record<string, unknown>, columnName: string): unknown;
package/dist/meta.js ADDED
@@ -0,0 +1,318 @@
1
+ // ═══ 元数据驱动的动态写入/读取规范(issues/23)— 与 Java/Go/Python 契约一致 ═══
2
+ /** 字段存储类型(对齐 mldong dev_schema_field 1-5 语义) */
3
+ export var StorageType;
4
+ (function (StorageType) {
5
+ StorageType[StorageType["Normal"] = 1] = "Normal";
6
+ StorageType[StorageType["Expand"] = 2] = "Expand";
7
+ StorageType[StorageType["Json"] = 3] = "Json";
8
+ StorageType[StorageType["One2One"] = 4] = "One2One";
9
+ StorageType[StorageType["One2Many"] = 5] = "One2Many";
10
+ })(StorageType || (StorageType = {}));
11
+ /** 解析 storageType:支持名称("EXPAND")与数字(2,mldong 语义) */
12
+ export function parseStorageType(v) {
13
+ if (typeof v === 'number')
14
+ return v;
15
+ if (typeof v === 'string')
16
+ return StorageType[v] ?? StorageType.Normal;
17
+ return StorageType.Normal;
18
+ }
19
+ export function columnOf(f) {
20
+ return f.columnName || toUnderline(f.name);
21
+ }
22
+ export function pkOf(m) {
23
+ return m.primaryKey || 'id';
24
+ }
25
+ export function findField(m, name) {
26
+ return m.fields.find(f => f.name.toLowerCase() === name.toLowerCase());
27
+ }
28
+ export function findFieldByColumn(m, columnName) {
29
+ return m.fields.find(f => columnOf(f).toLowerCase() === columnName.toLowerCase());
30
+ }
31
+ /** 驼峰转下划线(companyName → company_name) */
32
+ export function toUnderline(name) {
33
+ return name.replace(/([A-Z])/g, (c, _, i) => (i > 0 ? '_' : '') + c.toLowerCase());
34
+ }
35
+ /** 内置 JSON 配置加载器——从目录加载(文件名 = 表名,如 biz_leave.json) */
36
+ export class JsonMetaProvider {
37
+ dir;
38
+ cache = new Map();
39
+ constructor(dir) {
40
+ this.dir = dir;
41
+ }
42
+ loadTableMeta(tableName) {
43
+ if (this.cache.has(tableName))
44
+ return this.cache.get(tableName);
45
+ const data = readFileSyncSafe(`${this.dir}/${tableName}.json`);
46
+ if (data === null)
47
+ return null;
48
+ const raw = JSON.parse(data);
49
+ const meta = {
50
+ tableName: raw.tableName ?? tableName,
51
+ primaryKey: raw.primaryKey ?? 'id',
52
+ fields: (raw.fields ?? []).map((f) => ({
53
+ name: f.name,
54
+ columnName: f.columnName,
55
+ storageType: parseStorageType(f.storageType ?? 1),
56
+ expandFields: f.expandFields,
57
+ targetTable: f.targetTable,
58
+ foreignKey: f.foreignKey,
59
+ })),
60
+ };
61
+ this.cache.set(tableName, meta);
62
+ return meta;
63
+ }
64
+ }
65
+ import { readFileSync } from 'node:fs';
66
+ function readFileSyncSafe(path) {
67
+ try {
68
+ return readFileSync(path, 'utf-8');
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ // ═══ MetaTableWriter(写,纯写职责) ═══════════════════════════════════════════
75
+ /** 元数据驱动的动态写入器——按 TableMeta.storageType 语义执行插入;
76
+ * 无元数据时完全委托基础 writer(回落现状,零破坏)。读侧由 MetaTableReader 提供。 */
77
+ export class MetaTableWriter {
78
+ base;
79
+ provider;
80
+ constructor(base, provider) {
81
+ this.base = base;
82
+ this.provider = provider;
83
+ }
84
+ filterColumns(tableName, columns) {
85
+ return this.base.filterColumns(tableName, columns);
86
+ }
87
+ insert(tableName, data) {
88
+ const meta = this.provider.loadTableMeta(tableName);
89
+ if (!meta)
90
+ return this.base.insert(tableName, data); // 无元数据:回落现状
91
+ const subData = new Map();
92
+ const row = {};
93
+ for (const f of meta.fields) {
94
+ const v = data[f.name];
95
+ if (v === undefined || v === null)
96
+ continue;
97
+ const st = f.storageType ?? StorageType.Normal;
98
+ if (st === StorageType.Json) {
99
+ row[columnOf(f)] = JSON.stringify(v);
100
+ }
101
+ else if (st === StorageType.Expand) {
102
+ this.expandInto(f, v, row);
103
+ }
104
+ else if (st === StorageType.One2One || st === StorageType.One2Many) {
105
+ subData.set(f.name, v);
106
+ }
107
+ else {
108
+ row[columnOf(f)] = v;
109
+ }
110
+ }
111
+ // 未消费字段(流程上下文 process_instance_id 等)直通基础 writer
112
+ for (const [k, v] of Object.entries(data)) {
113
+ if (!findField(meta, k))
114
+ row[k] ??= v;
115
+ }
116
+ this.base.fillSystemFields(row, true);
117
+ let pk = this.base.insert(tableName, row); // 主表插入(自增/生成器返回主键)
118
+ if (pk === undefined || pk === null)
119
+ pk = findRowValue(row, pkOf(meta));
120
+ // 子表递归插入(外键=主表主键;继承主表 apply_user_id,issues/24)
121
+ for (const [name, v] of subData) {
122
+ this.insertSubTable(meta, findField(meta, name), v, pk, data);
123
+ }
124
+ return pk;
125
+ }
126
+ /** 按元数据 storageType 组装 SET 列(SYNC 同步演进,issues/24)——
127
+ * NORMAL/JSON/EXPAND 参与更新;ONE2ONE/ONE2MANY 子表不参与中途更新
128
+ * (任务推进只更新主表行状态,子表数据变动走重新提交);未消费字段直通。 */
129
+ update(tableName, data, whereColumn, whereValue) {
130
+ const meta = this.provider.loadTableMeta(tableName);
131
+ if (!meta)
132
+ return this.base.update(tableName, data, whereColumn, whereValue); // 无元数据:回落基础 writer
133
+ const row = {};
134
+ for (const f of meta.fields) {
135
+ const v = data[f.name];
136
+ if (v === undefined || v === null)
137
+ continue;
138
+ const st = f.storageType ?? StorageType.Normal;
139
+ if (st === StorageType.Json) {
140
+ row[columnOf(f)] = JSON.stringify(v);
141
+ }
142
+ else if (st === StorageType.Expand) {
143
+ this.expandInto(f, v, row);
144
+ }
145
+ else if (st === StorageType.One2One || st === StorageType.One2Many) {
146
+ continue; // 子表不参与中途更新
147
+ }
148
+ else {
149
+ row[columnOf(f)] = v;
150
+ }
151
+ }
152
+ // 未消费字段(流程上下文/状态字段等)直通基础 writer
153
+ for (const [k, v] of Object.entries(data)) {
154
+ if (!findField(meta, k))
155
+ row[k] ??= v;
156
+ }
157
+ return this.base.update(tableName, row, whereColumn, whereValue);
158
+ }
159
+ exists(tableName, bizKey, bizKeyValue) {
160
+ return this.base.exists(tableName, bizKey, bizKeyValue);
161
+ }
162
+ fillSystemFields(data, isInsert) {
163
+ this.base.fillSystemFields(data, isInsert);
164
+ }
165
+ expandInto(f, v, row) {
166
+ if (typeof v !== 'object' || v === null)
167
+ return;
168
+ const obj = v;
169
+ for (const [sub, col] of Object.entries(f.expandFields ?? {})) {
170
+ if (obj[sub] !== undefined && obj[sub] !== null)
171
+ row[col] = obj[sub];
172
+ }
173
+ }
174
+ insertSubTable(parentMeta, f, v, parentPk, parentData) {
175
+ if (parentPk === undefined || parentPk === null) {
176
+ throw new Error(`persist: parent primary key missing, cannot insert sub table ${f.name}`);
177
+ }
178
+ const fk = f.foreignKey || pkOf(parentMeta);
179
+ const st = f.storageType ?? StorageType.Normal;
180
+ if (st === StorageType.One2One && typeof v === 'object' && v !== null) {
181
+ this.insertSubRow(f, v, fk, parentPk, parentData);
182
+ }
183
+ else if (st === StorageType.One2Many && Array.isArray(v)) {
184
+ for (const item of v) {
185
+ if (typeof item === 'object' && item !== null) {
186
+ this.insertSubRow(f, item, fk, parentPk, parentData);
187
+ }
188
+ }
189
+ }
190
+ }
191
+ /** 子表单行插入(issues/24):继承主表 apply_user_id(拦截器场景=流程 operator),
192
+ * 子表单显式同名字段优先(??=)——fillSystemFields 的用户列默认值可解析到 operator,
193
+ * 避免 BIGINT create_user/update_user 列回落 "system" 严格模式报错 */
194
+ insertSubRow(f, subData, fk, parentPk, parentData) {
195
+ const row = { ...subData, [fk]: parentPk };
196
+ if (parentData['apply_user_id'] !== undefined) {
197
+ row['apply_user_id'] ??= parentData['apply_user_id'];
198
+ }
199
+ this.insert(f.targetTable, row); // 递归走子表自身元数据
200
+ }
201
+ }
202
+ // ═══ JdbcTableReader(读侧底层) ═══════════════════════════════════════════════
203
+ /** 业务表查询器——按列等值查询原始行(与写入器职责分离) */
204
+ export class JdbcTableReader {
205
+ db;
206
+ constructor(db) {
207
+ this.db = db;
208
+ }
209
+ queryFirst(tableName, whereColumn, value) {
210
+ const rows = this.queryList(tableName, whereColumn, value, 1);
211
+ return rows.length > 0 ? rows[0] : null;
212
+ }
213
+ queryList(tableName, whereColumn, value, limit) {
214
+ const sql = `SELECT * FROM ${tableName} WHERE ${whereColumn} = ?${limit > 0 ? ` LIMIT ${limit}` : ''}`;
215
+ return this.db.prepare(sql).all(value);
216
+ }
217
+ }
218
+ // ═══ MetaTableReader(读,流程回显最小闭环) ═══════════════════════════════════
219
+ /** 元数据驱动的动态读取器——按流程实例回显业务数据。
220
+ * 边界(不做):通用条件分页 / 动态条件语法 / 数据权限 / 排序。 */
221
+ export class MetaTableReader {
222
+ reader;
223
+ provider;
224
+ constructor(reader, provider) {
225
+ this.reader = reader;
226
+ this.provider = provider;
227
+ }
228
+ readByProcessInstance(tableName, processInstanceId) {
229
+ const row = this.reader.queryFirst(tableName, 'process_instance_id', processInstanceId);
230
+ if (!row)
231
+ return null;
232
+ const meta = this.provider.loadTableMeta(tableName);
233
+ if (!meta)
234
+ return row; // 无元数据:原样返回(列名→值)
235
+ return this.assemble(meta, row);
236
+ }
237
+ assemble(meta, row) {
238
+ const result = {};
239
+ for (const f of meta.fields) {
240
+ const v = findRowValue(row, columnOf(f));
241
+ const st = f.storageType ?? StorageType.Normal;
242
+ if (st === StorageType.Json) {
243
+ if (v !== undefined && v !== null) {
244
+ try {
245
+ result[f.name] = JSON.parse(String(v));
246
+ }
247
+ catch {
248
+ result[f.name] = v;
249
+ }
250
+ }
251
+ }
252
+ else if (st === StorageType.Expand) {
253
+ const obj = this.expandFrom(row, f);
254
+ if (Object.keys(obj).length > 0)
255
+ result[f.name] = obj;
256
+ }
257
+ else if (st === StorageType.One2One || st === StorageType.One2Many) {
258
+ const sub = this.readSubTable(meta, f, row);
259
+ if (sub !== undefined && sub !== null)
260
+ result[f.name] = sub;
261
+ }
262
+ else if (v !== undefined && v !== null) {
263
+ result[f.name] = v;
264
+ }
265
+ }
266
+ // 未在元数据中的列带出(key 统一小写);
267
+ // EXPAND 展开列(挂在某字段 expandFields 映射里,对象形式已带出)不重复平铺(issues/24)
268
+ for (const [k, v] of Object.entries(row)) {
269
+ if (!findFieldByColumn(meta, k) && !isExpandColumn(meta, k))
270
+ result[k.toLowerCase()] ??= v;
271
+ }
272
+ return result;
273
+ }
274
+ expandFrom(row, f) {
275
+ const obj = {};
276
+ for (const [sub, col] of Object.entries(f.expandFields ?? {})) {
277
+ const v = findRowValue(row, col);
278
+ if (v !== undefined && v !== null)
279
+ obj[sub] = v;
280
+ }
281
+ return obj;
282
+ }
283
+ readSubTable(parentMeta, f, row) {
284
+ const parentPk = findRowValue(row, pkOf(parentMeta));
285
+ if (parentPk === undefined || parentPk === null)
286
+ return null;
287
+ const fk = f.foreignKey || pkOf(parentMeta);
288
+ const subMeta = this.provider.loadTableMeta(f.targetTable);
289
+ const st = f.storageType ?? StorageType.Normal;
290
+ if (st === StorageType.One2One) {
291
+ const sub = this.reader.queryFirst(f.targetTable, fk, parentPk);
292
+ if (!sub)
293
+ return null;
294
+ return subMeta ? this.assemble(subMeta, sub) : sub;
295
+ }
296
+ // ONE2MANY
297
+ const subs = this.reader.queryList(f.targetTable, fk, parentPk, 0);
298
+ return subs.map(sub => (subMeta ? this.assemble(subMeta, sub) : sub));
299
+ }
300
+ }
301
+ /** 按列名取值(宽松:忽略大小写) */
302
+ export function findRowValue(row, columnName) {
303
+ for (const [k, v] of Object.entries(row)) {
304
+ if (k.toLowerCase() === columnName.toLowerCase())
305
+ return v;
306
+ }
307
+ return undefined;
308
+ }
309
+ /** 列是否为某字段的 EXPAND 展开列(issues/24:已消费,不重复平铺带出) */
310
+ function isExpandColumn(meta, column) {
311
+ for (const f of meta.fields) {
312
+ for (const col of Object.values(f.expandFields ?? {})) {
313
+ if (col.toLowerCase() === (column ?? '').toLowerCase())
314
+ return true;
315
+ }
316
+ }
317
+ return false;
318
+ }
package/dist/persist.d.ts CHANGED
@@ -12,6 +12,9 @@ export interface DynamicTableWriter {
12
12
  filterColumns(tableName: string, columns: string[]): string[] | Promise<string[]>;
13
13
  /** 参数化 INSERT(按列过滤结果落库),返回生成主键 */
14
14
  insert(tableName: string, data: Record<string, unknown>): unknown | Promise<unknown>;
15
+ /** 参数化 UPDATE(按列过滤结果组装 SET;条件列排除,防注入),返回受影响行数
16
+ * (SYNC 同步演进,issues/24) */
17
+ update(tableName: string, data: Record<string, unknown>, whereColumn: string, whereValue: unknown): number | Promise<number>;
15
18
  /** 幂等检查:指定业务键(如 process_instance_id)是否已存在 */
16
19
  exists(tableName: string, bizKey: string, bizKeyValue: unknown): boolean | Promise<boolean>;
17
20
  /** 按配置列名填充系统字段(未配置的列跳过) */
@@ -49,33 +52,58 @@ export declare class SqliteDynamicTableWriter implements DynamicTableWriter {
49
52
  /** 在 data 中找匹配指定表列的 key(宽松模式驼峰 key 匹配下划线列) */
50
53
  private findDataKey;
51
54
  exists(tableName: string, bizKey: string, bizKeyValue: unknown): boolean;
55
+ /** 参数化 UPDATE(SYNC 同步演进,issues/24):列过滤(宽松匹配)+ 条件列排除 +
56
+ * 参数化 SET,返回受影响行数。对齐 Java JdbcDynamicTableWriter.update。 */
57
+ update(tableName: string, data: Record<string, unknown>, whereColumn: string, whereValue: unknown): number;
52
58
  fillSystemFields(data: Record<string, unknown>, isInsert: boolean): void;
53
59
  /** 默认用户值(issues/19):优先取 data 中已注入的 apply_user_id
54
60
  * (拦截器场景 = 流程 operator,BIGINT 用户列表开箱即用),否则回落配置默认值 */
55
61
  private resolveDefaultUser;
56
62
  }
57
- /** 流程定义加载器(用于解析 relTableName / 流程 name),通常透传仓库 findDefineById */
63
+ /** 流程定义加载器(用于解析 relTableName / persistMode / 流程 name),通常透传仓库 findDefineById */
58
64
  export type DefineLoader = (defineId: number) => Promise<ProcessDefine | null>;
65
+ /** 持久化模式(流程定义顶层 persistMode,缺省 ARCHIVE) */
66
+ export declare const PersistModeArchive = "ARCHIVE";
67
+ export declare const PersistModeSync = "SYNC";
68
+ /** 字段权限值(任务节点 properties.field 的 PERMISSION_{字段名},vben5-wf 机制) */
69
+ export declare const PermReadOnly = 1;
70
+ export declare const PermEdit = 2;
71
+ export declare const PermHidden = 3;
59
72
  /**
60
- * 工作流业务数据入库适配拦截器——流程结束同意后,f_ 表单数据写入业务表。
73
+ * 工作流业务数据入库适配拦截器——按流程定义顶层 persistMode 分派:
61
74
  *
62
- * 语义(spec 契约,四语言一致):
63
- * - 时机:结束节点执行后 + 实例 Done + submitType=AGREE(不同意/退回不入库)
64
- * - 字段:实例 Variables f_ 前缀字段,去前缀
65
- * - 表名:流程定义 content 顶层 relTableName,缺省回落流程 name
66
- * - 系统字段:writer 通用字段 + 流程上下文(process_instance_id / apply_user_id /
67
- * apply_dept_id,蛇形列名约定)
68
- * - 幂等:bizKey = process_instance_id(先查后插,跨请求有效)
69
- * - 静默跳过:非结束节点 / 非同意 / 未配置表名 / writer 未注入
70
- * - 表不存在 = 配置错误 → 显性抛错(快速失败)
75
+ * - ARCHIVE(缺省):流程结束同意后,f_ 表单数据写入业务表(一次落库)
76
+ * - SYNC(1.8.0,issues/24 同步演进):提交申请即入库(start 节点 INSERT 全量),
77
+ * 任务节点推进 UPDATE(f_ 按节点字段权限过滤 + tf_ 冗余 + 状态字段=DOING),
78
+ * 结束节点定稿 UPDATE(最终状态 FINISHED/REJECT)——不管成功失败都入库
79
+ *
80
+ * 对标 Java PersistPostInterceptor(1.8.0)。
71
81
  */
72
82
  export declare class PersistPostInterceptor implements FlowInterceptor {
73
83
  private writer;
74
84
  private loader;
75
85
  order: number;
76
86
  fieldPrefix: string;
87
+ taskFieldPrefix: string;
77
88
  constructor(writer: DynamicTableWriter | null, loader: DefineLoader | null);
78
89
  preHandle(_node: FlowNode, _inst: ProcessInstance): Promise<boolean>;
79
90
  postHandle(node: FlowNode, inst: ProcessInstance): Promise<void>;
80
- private resolveTableName;
91
+ private handleArchive;
92
+ private handleSync;
93
+ private resolveDefine;
94
+ /** 同链重复触发防护(issues/19,1.8.0 节点级):同一执行链中**每个节点**触发一次
95
+ * (任务推进更新 + 结束定稿是不同节点,都要生效),同节点不重复;exists 兜底跨请求。 */
96
+ private markChain;
97
+ /** 提取字段:f_ 去前缀(SYNC 下按字段权限过滤——只读/隐藏不更新;
98
+ * includeFormFields=false 时不带出,用于非任务节点定稿避免覆盖只读限制);
99
+ * tf_ 去前缀冗余(有列则写,列过滤由 writer 做) */
100
+ private extractFields;
101
+ /** 任务节点字段权限(node.properties.field 的 PERMISSION_x;缺省 null=全部可编辑) */
102
+ private resolveFieldPermission;
103
+ /** 字段可编辑判定:无声明或 EDIT(2) 可更新;READ_ONLY(1)/HIDDEN(3) 不更新 */
104
+ private isEditable;
105
+ /** 状态字段写入:优先 {节点ID}_{状态码} 列,无则 {节点ID} 列(列探测过滤) */
106
+ private putStateField;
107
+ /** 流程上下文字段(蛇形列名约定,与 writer 系统字段一致) */
108
+ private fillContext;
81
109
  }
package/dist/persist.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { KeySubmitType, KeyDeptID } from './engine.js';
2
- import { TypeEnd, InstanceState, SubmitType } from './model.js';
2
+ import { TypeEnd, TypeTask, TypeCustom, InstanceState, SubmitType } from './model.js';
3
3
  // ─── 默认实现:node:sqlite(内置零依赖) ──────────────────────────────────────
4
4
  const TABLE_NAME_RE = /^[A-Za-z0-9_]+$/;
5
5
  /** 列名归一(issues/20):转小写 + 去下划线(companyName / company_name / COMPANY_NAME 等价) */
@@ -128,6 +128,30 @@ export class SqliteDynamicTableWriter {
128
128
  const row = this.db.prepare(`SELECT COUNT(1) AS c FROM ${tableName} WHERE ${bizKey} = ?`).get(bizKeyValue);
129
129
  return Number(row?.c ?? 0) > 0;
130
130
  }
131
+ /** 参数化 UPDATE(SYNC 同步演进,issues/24):列过滤(宽松匹配)+ 条件列排除 +
132
+ * 参数化 SET,返回受影响行数。对齐 Java JdbcDynamicTableWriter.update。 */
133
+ update(tableName, data, whereColumn, whereValue) {
134
+ checkTableName(tableName);
135
+ if (!whereColumn)
136
+ throw new Error(`persist: update ${tableName} requires where column`);
137
+ const cols = this.tableColumns(tableName);
138
+ const sets = [];
139
+ const values = [];
140
+ for (const m of cols) {
141
+ if (normalizeColumn(m.name) === normalizeColumn(whereColumn))
142
+ continue; // 条件列不参与 SET
143
+ const key = this.findDataKey(data, m.name);
144
+ if (key) {
145
+ sets.push(`${m.name} = ?`);
146
+ values.push(data[key]);
147
+ }
148
+ }
149
+ if (sets.length === 0)
150
+ return 0; // 无更新列(如结束节点仅状态探测未命中)
151
+ values.push(whereValue);
152
+ const res = this.db.prepare(`UPDATE ${tableName} SET ${sets.join(',')} WHERE ${whereColumn} = ?`).run(...values);
153
+ return Number(res.changes ?? 0);
154
+ }
131
155
  fillSystemFields(data, isInsert) {
132
156
  const now = nowText();
133
157
  if (isInsert) {
@@ -155,24 +179,29 @@ export class SqliteDynamicTableWriter {
155
179
  return data['apply_user_id'] ?? this.defaultUserValue;
156
180
  }
157
181
  }
182
+ /** 持久化模式(流程定义顶层 persistMode,缺省 ARCHIVE) */
183
+ export const PersistModeArchive = 'ARCHIVE'; // 结束归档(现状):流程结束同意后落库
184
+ export const PersistModeSync = 'SYNC'; // 同步演进:发起 INSERT → 任务节点 UPDATE → 结束定稿
185
+ /** 字段权限值(任务节点 properties.field 的 PERMISSION_{字段名},vben5-wf 机制) */
186
+ export const PermReadOnly = 1; // 只读:不更新
187
+ export const PermEdit = 2; // 可编辑:更新
188
+ export const PermHidden = 3; // 隐藏:不更新
158
189
  /**
159
- * 工作流业务数据入库适配拦截器——流程结束同意后,f_ 表单数据写入业务表。
190
+ * 工作流业务数据入库适配拦截器——按流程定义顶层 persistMode 分派:
191
+ *
192
+ * - ARCHIVE(缺省):流程结束同意后,f_ 表单数据写入业务表(一次落库)
193
+ * - SYNC(1.8.0,issues/24 同步演进):提交申请即入库(start 节点 INSERT 全量),
194
+ * 任务节点推进 UPDATE(f_ 按节点字段权限过滤 + tf_ 冗余 + 状态字段=DOING),
195
+ * 结束节点定稿 UPDATE(最终状态 FINISHED/REJECT)——不管成功失败都入库
160
196
  *
161
- * 语义(spec 契约,四语言一致):
162
- * - 时机:结束节点执行后 + 实例 Done + submitType=AGREE(不同意/退回不入库)
163
- * - 字段:实例 Variables 中 f_ 前缀字段,去前缀
164
- * - 表名:流程定义 content 顶层 relTableName,缺省回落流程 name
165
- * - 系统字段:writer 通用字段 + 流程上下文(process_instance_id / apply_user_id /
166
- * apply_dept_id,蛇形列名约定)
167
- * - 幂等:bizKey = process_instance_id(先查后插,跨请求有效)
168
- * - 静默跳过:非结束节点 / 非同意 / 未配置表名 / writer 未注入
169
- * - 表不存在 = 配置错误 → 显性抛错(快速失败)
197
+ * 对标 Java PersistPostInterceptor(1.8.0)。
170
198
  */
171
199
  export class PersistPostInterceptor {
172
200
  writer;
173
201
  loader;
174
202
  order = 0;
175
203
  fieldPrefix = 'f_';
204
+ taskFieldPrefix = 'tf_';
176
205
  constructor(writer, loader) {
177
206
  this.writer = writer;
178
207
  this.loader = loader;
@@ -183,57 +212,146 @@ export class PersistPostInterceptor {
183
212
  async postHandle(node, inst) {
184
213
  if (!this.writer || !this.loader)
185
214
  return; // 未注入:静默跳过
215
+ if (!node || !inst)
216
+ return;
217
+ const { tableName, persistMode } = await this.resolveDefine(inst);
218
+ if (!tableName)
219
+ return; // 未配置:静默跳过
220
+ if (persistMode.toUpperCase() === PersistModeSync) {
221
+ await this.handleSync(node, inst, tableName);
222
+ return;
223
+ }
224
+ await this.handleArchive(node, inst, tableName);
225
+ }
226
+ // ─── ARCHIVE(现状:结束同意归档) ──────────────────────────────────────────
227
+ async handleArchive(node, inst, tableName) {
228
+ const writer = this.writer;
229
+ if (!writer)
230
+ return;
186
231
  // 时机:仅结束节点 + 流程正常完成(Done)+ 同意
187
- if (!node || node.type !== TypeEnd)
232
+ if (node.type !== TypeEnd)
188
233
  return;
189
- if (!inst || inst.state !== InstanceState.Done)
234
+ if (inst.state !== InstanceState.Done)
190
235
  return;
191
236
  const submitType = Number(inst.variables[KeySubmitType]);
192
237
  if (submitType !== SubmitType.Agree)
193
238
  return;
194
- // 同链重复触发防护(issues/19):最后任务节点与结束节点都会触发后置拦截器,
195
- // 同一执行链(共享 inst.variables)只插一次。标记写入时实例已完成持久化
196
- // (引擎 executeNode 先 updateInstance 后触发拦截器,repo 存副本)不会落库;
197
- // exists 保留作为跨请求/重启的幂等兜底(先查后插语义不变)。
198
- const chainKey = `__persist_executed_${inst.id}`;
199
- if (inst.variables[chainKey] === true)
239
+ if (!this.markChain(node, inst))
200
240
  return;
201
- inst.variables[chainKey] = true;
202
- // 表名:流程定义顶层 relTableName,缺省回落流程 name
203
- const tableName = await this.resolveTableName(inst);
204
- if (!tableName)
205
- return; // 未配置:静默跳过
206
- // 幂等:以 process_instance_id 为键,先查后插
207
- if (await this.writer.exists(tableName, 'process_instance_id', inst.id))
241
+ // 幂等:以 process_instance_id 为键,先查后插。
242
+ // 表不存在等探测失败是配置错误,必须显性暴露(与 Java/Python 抛异常一致)
243
+ if (await writer.exists(tableName, 'process_instance_id', inst.id))
244
+ return;
245
+ const data = this.extractFields(inst, null, false, true); // 只 f_ 全量
246
+ this.fillContext(data, inst);
247
+ writer.fillSystemFields(data, true);
248
+ await writer.insert(tableName, data);
249
+ }
250
+ // ─── SYNC(1.8.0 同步演进:发起入库 → 节点推进 → 结束定稿) ──────────────────
251
+ async handleSync(node, inst, tableName) {
252
+ const writer = this.writer;
253
+ if (!writer)
254
+ return;
255
+ if (!this.markChain(node, inst))
256
+ return; // 同链同节点不重复(节点级,issues/19 演进)
257
+ const exists = await writer.exists(tableName, 'process_instance_id', inst.id);
258
+ // 任务节点(TypeTask/TypeCustom)才更新业务字段:f_ 按节点字段权限过滤;
259
+ // 结束/网关等非任务节点只定稿状态,避免全量覆盖任务节点的只读/隐藏限制
260
+ const isTask = node.type === TypeTask || node.type === TypeCustom;
261
+ const fieldPerm = isTask ? this.resolveFieldPermission(node) : null;
262
+ const data = this.extractFields(inst, fieldPerm, !exists || isTask, !exists || isTask);
263
+ // 状态字段:优先 {节点ID}_{状态码} 列,无则 {节点ID} 列。
264
+ // 任务节点写 DOING(10)——任务推进状态;结束节点写实例最终状态(FINISHED/REJECT)
265
+ let stateCode = Number(inst.state);
266
+ if (isTask)
267
+ stateCode = InstanceState.Doing;
268
+ this.putStateField(writer, tableName, data, node.id, stateCode);
269
+ this.fillContext(data, inst);
270
+ if (!exists) {
271
+ writer.fillSystemFields(data, true);
272
+ await writer.insert(tableName, data);
208
273
  return;
209
- // 提取 f_ 前缀字段(去前缀)
210
- const prefix = this.fieldPrefix || 'f_';
211
- const data = {};
212
- for (const [k, v] of Object.entries(inst.variables)) {
213
- if (k.startsWith(prefix) && k.length > prefix.length)
214
- data[k.slice(prefix.length)] = v;
215
274
  }
216
- // 流程上下文字段(蛇形列名约定,与 writer 系统字段一致)
217
- data['process_instance_id'] ??= inst.id;
218
- data['apply_user_id'] ??= inst.operator;
219
- data['apply_dept_id'] ??= inst.variables[KeyDeptID] ?? null;
220
- // 通用系统字段(writer 按配置列填充)
221
- this.writer.fillSystemFields(data, true);
222
- await this.writer.insert(tableName, data);
275
+ writer.fillSystemFields(data, false); // 只填 update
276
+ await writer.update(tableName, data, 'process_instance_id', inst.id);
223
277
  }
224
- async resolveTableName(inst) {
278
+ // ─── 公共 ───────────────────────────────────────────────────────────────────
279
+ async resolveDefine(inst) {
225
280
  const define = await this.loader(inst.defineId);
226
281
  if (!define)
227
- return '';
282
+ return { tableName: '', persistMode: '' };
228
283
  const content = typeof define.content === 'string' ? define.content : new TextDecoder().decode(define.content);
229
284
  let meta;
230
285
  try {
231
286
  meta = JSON.parse(content);
232
287
  }
233
288
  catch {
234
- return '';
289
+ return { tableName: '', persistMode: '' };
235
290
  }
236
291
  const tableName = (meta.relTableName ?? '').trim();
237
- return tableName || (meta.name ?? '').trim(); // 缺省回落流程 name
292
+ return {
293
+ tableName: tableName || (meta.name ?? '').trim(), // 缺省回落流程 name
294
+ persistMode: (meta.persistMode ?? '').trim(),
295
+ };
296
+ }
297
+ /** 同链重复触发防护(issues/19,1.8.0 节点级):同一执行链中**每个节点**触发一次
298
+ * (任务推进更新 + 结束定稿是不同节点,都要生效),同节点不重复;exists 兜底跨请求。 */
299
+ markChain(node, inst) {
300
+ const chainKey = `__persist_executed_${inst.id}_${node.id}`;
301
+ if (inst.variables[chainKey] === true)
302
+ return false;
303
+ inst.variables[chainKey] = true;
304
+ return true;
305
+ }
306
+ /** 提取字段:f_ 去前缀(SYNC 下按字段权限过滤——只读/隐藏不更新;
307
+ * includeFormFields=false 时不带出,用于非任务节点定稿避免覆盖只读限制);
308
+ * tf_ 去前缀冗余(有列则写,列过滤由 writer 做) */
309
+ extractFields(inst, fieldPerm, includeTaskFields, includeFormFields) {
310
+ const prefix = this.fieldPrefix || 'f_';
311
+ const taskPrefix = this.taskFieldPrefix || 'tf_';
312
+ const data = {};
313
+ for (const [k, v] of Object.entries(inst.variables)) {
314
+ if (includeFormFields && k.startsWith(prefix) && k.length > prefix.length) {
315
+ const name = k.slice(prefix.length);
316
+ if (!this.isEditable(fieldPerm, name))
317
+ continue;
318
+ data[name] = v;
319
+ }
320
+ else if (includeTaskFields && k.startsWith(taskPrefix) && k.length > taskPrefix.length) {
321
+ data[k.slice(taskPrefix.length)] = v;
322
+ }
323
+ }
324
+ return data;
325
+ }
326
+ /** 任务节点字段权限(node.properties.field 的 PERMISSION_x;缺省 null=全部可编辑) */
327
+ resolveFieldPermission(node) {
328
+ const field = node.properties?.field;
329
+ if (field && typeof field === 'object' && Object.keys(field).length > 0) {
330
+ return field;
331
+ }
332
+ return null;
333
+ }
334
+ /** 字段可编辑判定:无声明或 EDIT(2) 可更新;READ_ONLY(1)/HIDDEN(3) 不更新 */
335
+ isEditable(fieldPerm, fieldName) {
336
+ if (!fieldPerm)
337
+ return true;
338
+ const perm = fieldPerm[`PERMISSION_${fieldName}`];
339
+ if (perm == null)
340
+ return true;
341
+ return Number(perm) === PermEdit;
342
+ }
343
+ /** 状态字段写入:优先 {节点ID}_{状态码} 列,无则 {节点ID} 列(列探测过滤) */
344
+ putStateField(writer, tableName, data, nodeId, stateCode) {
345
+ if (!nodeId)
346
+ return;
347
+ const kept = writer.filterColumns(tableName, [`${nodeId}_${stateCode}`, nodeId]);
348
+ if (Array.isArray(kept) && kept.length > 0)
349
+ data[kept[0]] = stateCode;
350
+ }
351
+ /** 流程上下文字段(蛇形列名约定,与 writer 系统字段一致) */
352
+ fillContext(data, inst) {
353
+ data['process_instance_id'] ??= inst.id;
354
+ data['apply_user_id'] ??= inst.operator;
355
+ data['apply_dept_id'] ??= inst.variables[KeyDeptID] ?? null;
238
356
  }
239
357
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mldong/jeeflow",
3
- "version": "1.6.6",
3
+ "version": "1.8.0",
4
4
  "description": "jeeflow workflow engine — Node.js / TypeScript implementation",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",