bdip-office-assistant-cli 0.0.0-stage → 1.0.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/README.md +37 -2
- package/dist/accounts.js +30 -0
- package/dist/args.js +36 -0
- package/dist/auth-token.js +778 -0
- package/dist/auth.js +290 -0
- package/dist/blog.js +562 -0
- package/dist/browser-links.js +223 -0
- package/dist/captcha-display.js +30 -0
- package/dist/cli.js +5479 -0
- package/dist/client.js +254 -0
- package/dist/config.js +170 -0
- package/dist/createcontext.js +41 -0
- package/dist/crypto.js +139 -0
- package/dist/cube.js +1771 -0
- package/dist/email.js +841 -0
- package/dist/field-preparation.js +185 -0
- package/dist/fileutil.js +20 -0
- package/dist/hrm.js +478 -0
- package/dist/keepalive.js +99 -0
- package/dist/kq.js +280 -0
- package/dist/links.js +34 -0
- package/dist/nodemap.js +242 -0
- package/dist/odoc.js +199 -0
- package/dist/office-tools.js +138 -0
- package/dist/pageopen.js +29 -0
- package/dist/parse.js +177 -0
- package/dist/password-input.js +84 -0
- package/dist/password-login.js +208 -0
- package/dist/querystate.js +38 -0
- package/dist/render.js +21 -0
- package/dist/report.js +50 -0
- package/dist/session-worker.js +54 -0
- package/dist/session.js +280 -0
- package/dist/submissionplan.js +45 -0
- package/dist/submissionstate.js +81 -0
- package/dist/subordinate.js +458 -0
- package/dist/supervise.js +152 -0
- package/dist/todoaskidea.js +54 -0
- package/dist/todobatch.js +172 -0
- package/dist/todocc.js +128 -0
- package/dist/todoforward.js +66 -0
- package/dist/todoreject.js +215 -0
- package/dist/todosubmit.js +325 -0
- package/dist/todotransfer.js +54 -0
- package/dist/util.js +25 -0
- package/dist/wfagent.js +538 -0
- package/dist/wfcreate.js +102 -0
- package/dist/wfform.js +169 -0
- package/dist/wfops.js +40 -0
- package/dist/wfreceiver.js +43 -0
- package/dist/wfstart.js +558 -0
- package/dist/wfsummary.js +89 -0
- package/dist/wfupload.js +78 -0
- package/dist/wfuser.js +96 -0
- package/dist/workflow-runtime.js +151 -0
- package/dist/workflow.js +570 -0
- package/dist/workplan.js +431 -0
- package/package.json +28 -6
package/dist/session.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { updateConfigCookie } from './config.js';
|
|
3
|
+
|
|
4
|
+
const managers = new Map();
|
|
5
|
+
const warned = new Set();
|
|
6
|
+
const UA = 'Mozilla/5.0 BDIP-Office-Assistant';
|
|
7
|
+
export const HEARTBEAT_PATH = '/api/bdip/assistant/session/heartbeat';
|
|
8
|
+
|
|
9
|
+
function bounded(value, fallback, min, max) {
|
|
10
|
+
const n = Number(value);
|
|
11
|
+
return Number.isFinite(n) && n >= min && n <= max ? n : fallback;
|
|
12
|
+
}
|
|
13
|
+
export function sessionOptions(cfg) {
|
|
14
|
+
return {
|
|
15
|
+
enabled: cfg.session?.enabled !== false && cfg.session?.enabled !== 'false',
|
|
16
|
+
intervalMs: bounded(cfg.session?.heartbeatSeconds, 120, 15, 600) * 1000,
|
|
17
|
+
timeoutMs: bounded(cfg.session?.timeoutMs, 8000, 1000, 30000),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function sessionKey(cfg) {
|
|
21
|
+
return `${cfg.baseUrl.replace(/\/+$/, '')}\n${cfg._configPath ?? ''}`;
|
|
22
|
+
}
|
|
23
|
+
/** Reload just the Cookie. A worker never writes an old profile over an account switch. */
|
|
24
|
+
export function refreshCookie(cfg) {
|
|
25
|
+
if (!cfg._configPath) return;
|
|
26
|
+
const raw = JSON.parse(fs.readFileSync(cfg._configPath, 'utf8'));
|
|
27
|
+
if (String(raw.baseUrl).replace(/\/+$/, '') !== cfg.baseUrl.replace(/\/+$/, '')) {
|
|
28
|
+
const error = new Error('平台地址已变更,请停止保活并重新启动');
|
|
29
|
+
error.code = 'PROFILE_CHANGED';
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
cfg.cookie = typeof raw.cookie === 'string' ? raw.cookie : '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Apply only cookies valid for the configured OA, with compare-before-write persistence. */
|
|
36
|
+
export function mergeResponseCookies(cfg, headers, sentCookie) {
|
|
37
|
+
const lines = typeof headers.getSetCookie === 'function' ? headers.getSetCookie() :
|
|
38
|
+
(headers.get('set-cookie') ?? '').split(/,(?=\s*[^;,\s]+=)/).filter(Boolean);
|
|
39
|
+
if (!lines.length) return;
|
|
40
|
+
const base = new URL(cfg.baseUrl + '/wui/index.html');
|
|
41
|
+
const values = new Map(String(sentCookie ?? '').split(';').map(s => s.trim()).filter(Boolean).map(s => {
|
|
42
|
+
const i = s.indexOf('='); return [s.slice(0, i), s.slice(i + 1)];
|
|
43
|
+
}));
|
|
44
|
+
let changed = false;
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
if (/[\r\n]/.test(line)) continue;
|
|
47
|
+
const [pair, ...attributes] = line.split(';').map(s => s.trim());
|
|
48
|
+
const i = pair.indexOf('=');
|
|
49
|
+
if (i <= 0) continue;
|
|
50
|
+
const name = pair.slice(0, i), value = pair.slice(i + 1);
|
|
51
|
+
if (!/^[!#$%&'*+.^_`|~\w-]+$/.test(name)) continue;
|
|
52
|
+
const attrs = Object.fromEntries(attributes.map(s => {
|
|
53
|
+
const eq = s.indexOf('='); return eq < 0 ? [s.toLowerCase(), true] : [s.slice(0, eq).toLowerCase(), s.slice(eq + 1)];
|
|
54
|
+
}));
|
|
55
|
+
const domain = typeof attrs.domain === 'string' ? attrs.domain.replace(/^\./, '').toLowerCase() : base.hostname;
|
|
56
|
+
if (base.hostname !== domain && !base.hostname.endsWith('.' + domain)) continue;
|
|
57
|
+
if (attrs.secure && base.protocol !== 'https:') continue;
|
|
58
|
+
if (attrs.path && !(base.pathname === attrs.path || base.pathname.startsWith(String(attrs.path).replace(/\/$/, '') + '/'))) continue;
|
|
59
|
+
const remove = (attrs['max-age'] !== undefined && Number(attrs['max-age']) <= 0) ||
|
|
60
|
+
(attrs['max-age'] === undefined && attrs.expires && Date.parse(attrs.expires) <= Date.now());
|
|
61
|
+
if (remove) changed = values.delete(name) || changed;
|
|
62
|
+
else if (values.get(name) !== value) { values.set(name, value); changed = true; }
|
|
63
|
+
}
|
|
64
|
+
if (changed) updateConfigCookie(cfg, [...values].map(([k, v]) => `${k}=${v}`).join('; '), sentCookie);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function authFailureCode(status, json) {
|
|
68
|
+
const code = String(json?.errorCode ?? json?.code ?? '');
|
|
69
|
+
if (code === '003' || code === '004' || code === 'FORCED_OFFLINE') return 'FORCED_OFFLINE';
|
|
70
|
+
if (status === 401 || code === '002' || code === 'SESSION_EXPIRED' || code === 'NOT_AUTHENTICATED') return 'SESSION_EXPIRED';
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function identity(data) {
|
|
74
|
+
const id = String(data?.userId ?? '');
|
|
75
|
+
const loginId = typeof data?.loginId === 'string' ? data.loginId.trim() : '';
|
|
76
|
+
return /^[1-9]\d*$/.test(id) && loginId ? { userId: id, loginId } : null;
|
|
77
|
+
}
|
|
78
|
+
function result(authenticated, mode, fields = {}) {
|
|
79
|
+
return { authenticated, mode, checkedAt: Date.now(), ...fields };
|
|
80
|
+
}
|
|
81
|
+
async function cookieFetch(cfg, path, method, options) {
|
|
82
|
+
options.signal?.throwIfAborted();
|
|
83
|
+
const sentCookie = cfg.cookie ?? '';
|
|
84
|
+
const url = new URL(cfg.baseUrl.replace(/\/+$/, '') + path);
|
|
85
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('平台地址格式无效');
|
|
86
|
+
// Combine cancellation and the per-request timeout without AbortSignal.any (Node 18).
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
const abort = () => controller.abort();
|
|
89
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
90
|
+
const timeout = setTimeout(abort, options.timeoutMs);
|
|
91
|
+
timeout.unref?.();
|
|
92
|
+
try {
|
|
93
|
+
options.signal?.throwIfAborted();
|
|
94
|
+
options.onCredential?.(sentCookie);
|
|
95
|
+
const res = await (options.fetchImpl ?? fetch)(url, {
|
|
96
|
+
method, redirect: 'manual', signal: controller.signal,
|
|
97
|
+
headers: { Cookie: sentCookie, 'User-Agent': UA, Accept: 'application/json',
|
|
98
|
+
'X-Requested-With': 'XMLHttpRequest', Referer: cfg.baseUrl + '/wui/index.html',
|
|
99
|
+
...(method === 'POST' ? { 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' } : {}) },
|
|
100
|
+
...(method === 'POST' ? { body: '' } : {}),
|
|
101
|
+
});
|
|
102
|
+
controller.signal.throwIfAborted();
|
|
103
|
+
// Fetch never follows an authenticated request into another origin or a login page.
|
|
104
|
+
if (res.status >= 300 && res.status < 400) return { status: res.status, json: null, redirected: true };
|
|
105
|
+
const text = await res.text();
|
|
106
|
+
controller.signal.throwIfAborted();
|
|
107
|
+
let json;
|
|
108
|
+
try { json = JSON.parse(text); } catch { json = null; }
|
|
109
|
+
return { status: res.status, json, commitCookies: () => {
|
|
110
|
+
options.signal?.throwIfAborted();
|
|
111
|
+
mergeResponseCookies(cfg, res.headers, sentCookie);
|
|
112
|
+
} };
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
clearTimeout(timeout);
|
|
116
|
+
options.signal?.removeEventListener('abort', abort);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Prove current identity. Native heartbeat's status:1 alone is deliberately insufficient. */
|
|
121
|
+
export async function probeCookieSession(cfg, options = {}) {
|
|
122
|
+
const opts = { ...sessionOptions(cfg), ...options };
|
|
123
|
+
try {
|
|
124
|
+
opts.signal?.throwIfAborted();
|
|
125
|
+
if (options.refresh !== false) refreshCookie(cfg);
|
|
126
|
+
if (!cfg.cookie) return result(false, 'none', { code: 'NO_COOKIE', message: '未保存 Cookie' });
|
|
127
|
+
const response = await cookieFetch(cfg, HEARTBEAT_PATH, 'POST', opts);
|
|
128
|
+
const failure = authFailureCode(response.status, response.json);
|
|
129
|
+
if (failure) return result(false, 'extension', { code: failure, message: failure === 'FORCED_OFFLINE' ? '当前会话已被强制下线' : 'Cookie 登录已失效' });
|
|
130
|
+
const user = response.json?.success === true ? identity(response.json.data) : null;
|
|
131
|
+
if (response.status === 200 && user) {
|
|
132
|
+
response.commitCookies();
|
|
133
|
+
return result(true, 'extension', { ...user,
|
|
134
|
+
idleTimeoutSeconds: Number(response.json.data.idleTimeoutSeconds),
|
|
135
|
+
serverTime: response.json.data.serverTime });
|
|
136
|
+
}
|
|
137
|
+
if (response.status === 403) return result(false, 'extension', { code: 'FORBIDDEN', message: '心跳被服务器拒绝' });
|
|
138
|
+
// An unavailable extension may fall back to an independently proven native Cookie identity.
|
|
139
|
+
if (response.status >= 500 && response.status !== 501) return result(false, 'extension', { code: 'SERVER_ERROR', message: '会话扩展暂时不可用' });
|
|
140
|
+
const native = await cookieFetch(cfg, '/api/hrm/login/getAccountList', 'GET', opts);
|
|
141
|
+
const nativeFailure = authFailureCode(native.status, native.json);
|
|
142
|
+
if (nativeFailure) return result(false, 'native', { code: nativeFailure, message: 'Cookie 登录已失效' });
|
|
143
|
+
const rows = native.json?.data?.accountlist;
|
|
144
|
+
const current = Array.isArray(rows) ? rows.filter(row => String(row.iscurrent) === '1') : [];
|
|
145
|
+
const id = String(current[0]?.userid ?? '');
|
|
146
|
+
if (native.status !== 200 || native.json?.status === false || current.length !== 1 || !/^[1-9]\d*$/.test(id)) {
|
|
147
|
+
return result(false, 'native', { code: 'IDENTITY_UNVERIFIED', message: '未取得唯一当前用户,无法确认 Cookie 登录有效' });
|
|
148
|
+
}
|
|
149
|
+
native.commitCookies();
|
|
150
|
+
// getAccountList itself has already touched the authenticated HttpSession.
|
|
151
|
+
return result(true, 'native', { userId: id, loginId: current[0].loginid ?? null,
|
|
152
|
+
idleTimeoutSeconds: null, message: '已验证当前用户;需部署扩展才能延长空闲超时和生成免登录链接' });
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
if (opts.signal?.aborted) return result(false, 'unknown', { code: 'CANCELLED', message: '会话检查已停止' });
|
|
156
|
+
return result(false, 'unknown', { code: e.code === 'PROFILE_CHANGED' ? e.code : 'NETWORK_ERROR',
|
|
157
|
+
message: e.code === 'PROFILE_CHANGED' ? e.message : '会话检查失败,请检查网络或服务器' });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export class SessionHeartbeat {
|
|
162
|
+
constructor(cfg, options = {}) {
|
|
163
|
+
this.cfg = cfg;
|
|
164
|
+
this.options = { ...sessionOptions(cfg), ...options };
|
|
165
|
+
this.state = null;
|
|
166
|
+
this.checkedCookie = null;
|
|
167
|
+
this.timer = null;
|
|
168
|
+
this.inflight = null;
|
|
169
|
+
this.controller = null;
|
|
170
|
+
this.stopped = false;
|
|
171
|
+
}
|
|
172
|
+
async tick() {
|
|
173
|
+
if (this.inflight) return this.inflight;
|
|
174
|
+
if (this.stopped) return this.state;
|
|
175
|
+
const controller = new AbortController();
|
|
176
|
+
this.controller = controller;
|
|
177
|
+
const externalAbort = () => this.stop();
|
|
178
|
+
this.options.signal?.addEventListener('abort', externalAbort, { once: true });
|
|
179
|
+
if (this.options.signal?.aborted) this.stop();
|
|
180
|
+
// Replaced by the actual request credential after refreshCookie. Never bind
|
|
181
|
+
// a response identity to cfg.cookie after a concurrent update or rotation.
|
|
182
|
+
this.checkedCookie = this.cfg.cookie;
|
|
183
|
+
this.inflight = probeCookieSession(this.cfg, { ...this.options, signal: controller.signal,
|
|
184
|
+
onCredential: cookie => {
|
|
185
|
+
this.checkedCookie = cookie;
|
|
186
|
+
this.options.onCredential?.(cookie);
|
|
187
|
+
},
|
|
188
|
+
}).then(state => {
|
|
189
|
+
if (this.stopped || controller.signal.aborted) return this.state;
|
|
190
|
+
this.state = state;
|
|
191
|
+
this.options.onState?.(state);
|
|
192
|
+
if (['FORCED_OFFLINE', 'SESSION_EXPIRED', 'PROFILE_CHANGED', 'NO_COOKIE'].includes(state.code)) this.stop();
|
|
193
|
+
return state;
|
|
194
|
+
}).finally(() => {
|
|
195
|
+
this.options.signal?.removeEventListener('abort', externalAbort);
|
|
196
|
+
if (this.controller === controller) this.controller = null;
|
|
197
|
+
this.inflight = null;
|
|
198
|
+
});
|
|
199
|
+
return this.inflight;
|
|
200
|
+
}
|
|
201
|
+
async start() {
|
|
202
|
+
await this.tick();
|
|
203
|
+
if (!this.stopped && !this.timer) {
|
|
204
|
+
this.timer = setInterval(() => { void this.tick(); }, this.options.intervalMs);
|
|
205
|
+
// An ordinary CLI command must exit promptly when its actual work finishes.
|
|
206
|
+
if (this.options.unref !== false) this.timer.unref();
|
|
207
|
+
}
|
|
208
|
+
return this;
|
|
209
|
+
}
|
|
210
|
+
stop() {
|
|
211
|
+
this.stopped = true;
|
|
212
|
+
this.controller?.abort();
|
|
213
|
+
if (this.timer) clearInterval(this.timer);
|
|
214
|
+
this.timer = null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Bind a forced-offline observation to the credential actually checked. */
|
|
219
|
+
export function latchForcedOffline(cfg, state, checkedCookie = cfg.cookie) {
|
|
220
|
+
const key = sessionKey(cfg);
|
|
221
|
+
let manager = managers.get(key);
|
|
222
|
+
if (!manager) {
|
|
223
|
+
manager = new SessionHeartbeat(cfg);
|
|
224
|
+
managers.set(key, manager);
|
|
225
|
+
}
|
|
226
|
+
manager.stop();
|
|
227
|
+
manager.state = result(false, state?.mode ?? 'unknown', {
|
|
228
|
+
code: 'FORCED_OFFLINE', message: state?.message ?? '当前 Cookie 会话已被强制下线',
|
|
229
|
+
checkedAt: state?.checkedAt ?? Date.now(),
|
|
230
|
+
});
|
|
231
|
+
manager.checkedCookie = checkedCookie;
|
|
232
|
+
manager.forcedCookie = checkedCookie;
|
|
233
|
+
return manager;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function currentSessionState(cfg) {
|
|
237
|
+
const manager = managers.get(sessionKey(cfg));
|
|
238
|
+
return manager?.checkedCookie === cfg.cookie ? manager.state : null;
|
|
239
|
+
}
|
|
240
|
+
export function assertSessionNotForcedOffline(cfg) {
|
|
241
|
+
const manager = managers.get(sessionKey(cfg));
|
|
242
|
+
if (manager?.state?.code === 'FORCED_OFFLINE' && cfg.cookie === manager.forcedCookie) {
|
|
243
|
+
const error = new Error('当前 Cookie 会话已被强制下线;请核实后重新登录,未自动重登或继续业务操作。');
|
|
244
|
+
error.code = 'FORCED_OFFLINE';
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
export async function ensureSessionHeartbeat(cfg) {
|
|
249
|
+
if (!sessionOptions(cfg).enabled) return null;
|
|
250
|
+
refreshCookie(cfg);
|
|
251
|
+
const key = sessionKey(cfg);
|
|
252
|
+
let manager = managers.get(key);
|
|
253
|
+
if (manager?.inflight) await manager.inflight;
|
|
254
|
+
if (manager?.state?.code === 'FORCED_OFFLINE' && cfg.cookie === manager.forcedCookie) return manager;
|
|
255
|
+
if (manager?.stopped && cfg.cookie === manager.checkedCookie) return manager;
|
|
256
|
+
if (!manager || manager.stopped) {
|
|
257
|
+
manager = new SessionHeartbeat(cfg, { onState: state => {
|
|
258
|
+
if (state.code === 'FORCED_OFFLINE') manager.forcedCookie = manager.checkedCookie;
|
|
259
|
+
const warning = `${key}:${state.code ?? state.mode}`;
|
|
260
|
+
if (warned.has(warning)) return;
|
|
261
|
+
if (state.mode === 'native' && state.authenticated) {
|
|
262
|
+
console.error('[登录] 已按当前 Cookie 用户保活;部署办公助手扩展后可延长空闲登录时间。');
|
|
263
|
+
warned.add(warning);
|
|
264
|
+
} else if (state.code === 'FORCED_OFFLINE' || state.code === 'SESSION_EXPIRED') {
|
|
265
|
+
console.error('[登录] 当前 Cookie 会话已失效,保活已停止;请重新登录后再继续需要 Cookie 的操作。');
|
|
266
|
+
warned.add(warning);
|
|
267
|
+
}
|
|
268
|
+
} });
|
|
269
|
+
managers.set(key, manager);
|
|
270
|
+
await manager.start();
|
|
271
|
+
} else if (manager.checkedCookie !== cfg.cookie) {
|
|
272
|
+
await manager.tick();
|
|
273
|
+
}
|
|
274
|
+
return manager;
|
|
275
|
+
}
|
|
276
|
+
export function stopSessionHeartbeats() {
|
|
277
|
+
for (const manager of managers.values()) manager.stop();
|
|
278
|
+
managers.clear();
|
|
279
|
+
}
|
|
280
|
+
process.once('exit', stopSessionHeartbeats);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
function planDir() {
|
|
8
|
+
return path.resolve(process.cwd(), 'state', 'create_plans');
|
|
9
|
+
}
|
|
10
|
+
function planPath(id) {
|
|
11
|
+
if (!/^[a-zA-Z0-9-]+$/.test(String(id)))
|
|
12
|
+
throw new Error('plan_id 格式无效。');
|
|
13
|
+
return path.join(planDir(), `${id}.json`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 保存用户已确认的业务字段;短期 token 和签名绝不落盘。 */
|
|
17
|
+
export function saveSubmissionPlan(prepared) {
|
|
18
|
+
const plan = {
|
|
19
|
+
id: randomUUID(),
|
|
20
|
+
workflowId: String(prepared.workflowId),
|
|
21
|
+
workflowName: String(prepared.workflowName ?? ''),
|
|
22
|
+
requestName: String(prepared.requestName),
|
|
23
|
+
values: prepared.values,
|
|
24
|
+
details: prepared.details,
|
|
25
|
+
browserNames: prepared.browserNames,
|
|
26
|
+
files: prepared.files ?? [],
|
|
27
|
+
accountId: prepared.accountId, baseUrl: prepared.baseUrl,
|
|
28
|
+
prepared: prepared.prepared ? {...prepared.prepared, body:Object.fromEntries(Object.entries(prepared.prepared.body).filter(([k])=>!/token|signature|UUID/i.test(k)))} : undefined,
|
|
29
|
+
createdAt: new Date().toISOString(),
|
|
30
|
+
};
|
|
31
|
+
fs.mkdirSync(planDir(), { recursive: true });
|
|
32
|
+
fs.writeFileSync(planPath(plan.id), JSON.stringify(plan, null, 2), 'utf8');
|
|
33
|
+
return plan;
|
|
34
|
+
}
|
|
35
|
+
export function loadSubmissionPlan(id, workflowId) {
|
|
36
|
+
const file = planPath(id);
|
|
37
|
+
if (!fs.existsSync(file))
|
|
38
|
+
throw new Error('未找到确认计划。请先重新预览,确认后再提交。');
|
|
39
|
+
const plan = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
40
|
+
if (String(plan.workflowId) !== String(workflowId))
|
|
41
|
+
throw new Error('确认计划与当前流程不一致。请重新预览。');
|
|
42
|
+
if (Date.now() - Date.parse(plan.createdAt) > MAX_AGE_MS)
|
|
43
|
+
throw new Error('确认计划已超过 24 小时。请重新预览,避免提交过期内容。');
|
|
44
|
+
return plan;
|
|
45
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const ACTIVE_STATUSES = new Set(['submitting', 'uncertain', 'created']);
|
|
6
|
+
const DUPLICATE_WINDOW_MS = 10 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
function stateDir() {
|
|
9
|
+
return path.resolve(process.cwd(), 'state', 'create_submissions');
|
|
10
|
+
}
|
|
11
|
+
function statePath(id) {
|
|
12
|
+
if (!/^[a-zA-Z0-9-]+$/.test(String(id)))
|
|
13
|
+
throw new Error('submission_id 格式无效。');
|
|
14
|
+
return path.join(stateDir(), `${id}.json`);
|
|
15
|
+
}
|
|
16
|
+
function write(record) {
|
|
17
|
+
fs.mkdirSync(stateDir(), { recursive: true });
|
|
18
|
+
fs.writeFileSync(statePath(record.id), JSON.stringify(record, null, 2), 'utf8');
|
|
19
|
+
return record;
|
|
20
|
+
}
|
|
21
|
+
function fingerprint(workflowId, requestName, values) {
|
|
22
|
+
return crypto.createHash('sha256')
|
|
23
|
+
.update(JSON.stringify({ workflowId: String(workflowId), requestName: String(requestName ?? ''), values: values ?? {} }))
|
|
24
|
+
.digest('hex');
|
|
25
|
+
}
|
|
26
|
+
function readRecent() {
|
|
27
|
+
try {
|
|
28
|
+
return fs.readdirSync(stateDir())
|
|
29
|
+
.filter((name) => name.endsWith('.json'))
|
|
30
|
+
.map((name) => JSON.parse(fs.readFileSync(path.join(stateDir(), name), 'utf8')))
|
|
31
|
+
.filter((record) => record && record.id);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 为一次真实发起建立本地操作记录。相同内容在十分钟内处于进行/不确定/成功状态时不再次提交。
|
|
40
|
+
* 只存指纹而不存字段原文,避免把敏感表单值重复落盘。
|
|
41
|
+
*/
|
|
42
|
+
export function beginSubmission({ workflowId, workflowName = '', requestName = '', values = {} }) {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const key = fingerprint(workflowId, requestName, values);
|
|
45
|
+
const duplicate = readRecent().find((record) => record.fingerprint === key
|
|
46
|
+
&& ACTIVE_STATUSES.has(record.status)
|
|
47
|
+
&& now - Date.parse(record.updatedAt ?? record.createdAt ?? 0) < DUPLICATE_WINDOW_MS);
|
|
48
|
+
if (duplicate)
|
|
49
|
+
return { record: duplicate, duplicate: true };
|
|
50
|
+
const record = {
|
|
51
|
+
id: crypto.randomUUID(),
|
|
52
|
+
workflowId: String(workflowId),
|
|
53
|
+
workflowName: String(workflowName),
|
|
54
|
+
requestName: String(requestName),
|
|
55
|
+
fingerprint: key,
|
|
56
|
+
status: 'submitting',
|
|
57
|
+
requestId: '',
|
|
58
|
+
createdAt: new Date(now).toISOString(),
|
|
59
|
+
updatedAt: new Date(now).toISOString(),
|
|
60
|
+
};
|
|
61
|
+
return { record: write(record), duplicate: false };
|
|
62
|
+
}
|
|
63
|
+
export function loadSubmission(id) {
|
|
64
|
+
const p = statePath(id);
|
|
65
|
+
if (!fs.existsSync(p))
|
|
66
|
+
throw new Error(`未找到发起记录“${id}”。`);
|
|
67
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
68
|
+
}
|
|
69
|
+
export function updateSubmission(id, patch) {
|
|
70
|
+
const old = loadSubmission(id);
|
|
71
|
+
return write({ ...old, ...patch, updatedAt: new Date().toISOString() });
|
|
72
|
+
}
|
|
73
|
+
export function extractCreatedRequestId(response) {
|
|
74
|
+
const body = response?.json ?? {};
|
|
75
|
+
const type = body?.data?.type ?? body?.type ?? '';
|
|
76
|
+
const requestId = body?.requestid ?? body?.data?.requestid ?? body?.data?.resultInfo?.requestid ?? body?.data?.requestId;
|
|
77
|
+
// A success type without an addressable request id cannot be presented as a
|
|
78
|
+
// completed creation: the caller must verify it instead of retrying.
|
|
79
|
+
const accepted = !!requestId && (type === 'SUCCESS' || type === 'ASYNC_SUBMIT');
|
|
80
|
+
return { accepted, requestId: requestId ? String(requestId) : '', type: String(type) };
|
|
81
|
+
}
|