bdip-office-assistant-cli 0.0.0-stage → 1.0.1
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 +783 -0
- package/dist/auth.js +331 -0
- package/dist/blog.js +562 -0
- package/dist/browser-links.js +224 -0
- package/dist/captcha-display.js +30 -0
- package/dist/cli.js +5533 -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/documents.js +98 -0
- package/dist/email.js +841 -0
- package/dist/field-preparation.js +237 -0
- package/dist/fileutil.js +20 -0
- package/dist/formula-runtime.js +55 -0
- package/dist/hrm.js +478 -0
- package/dist/keepalive.js +99 -0
- package/dist/kq.js +280 -0
- package/dist/linkage-calculations.js +68 -0
- package/dist/links.js +34 -0
- package/dist/meetings.js +172 -0
- package/dist/nodemap.js +242 -0
- package/dist/odoc.js +199 -0
- package/dist/office-tools.js +155 -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/person-selection.js +81 -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 +83 -0
- package/dist/wfuser.js +96 -0
- package/dist/workflow-runtime.js +177 -0
- package/dist/workflow.js +570 -0
- package/dist/workplan.js +431 -0
- package/package.json +29 -6
package/dist/report.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { endpointFor } from './config.js';
|
|
2
|
+
import { getWfList } from './workflow.js';
|
|
3
|
+
import { getMyCalendar } from './workplan.js';
|
|
4
|
+
import { getEmailList } from './email.js';
|
|
5
|
+
import { getButtons } from './kq.js';
|
|
6
|
+
import { getMyBlogList, isBlogRecalled } from './blog.js';
|
|
7
|
+
|
|
8
|
+
function source(module, id, time, data) {
|
|
9
|
+
return { module, id: String(id ?? ''), time: String(time ?? ''), data };
|
|
10
|
+
}
|
|
11
|
+
function onDate(value, date) {
|
|
12
|
+
return String(value ?? '').slice(0, 10) === date;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 只汇总当前 BDIP5 环境可回溯且状态可确认的数据;不把聊天内容、浏览行为或撤回微博当作事实。 */
|
|
16
|
+
export async function collectQueryableBusiness(cfg, date) {
|
|
17
|
+
const results = await Promise.allSettled([
|
|
18
|
+
getWfList(cfg, { category: 'done', page: 1, operateTime: date }),
|
|
19
|
+
getWfList(cfg, { category: 'mine', page: 1, createTime: date }),
|
|
20
|
+
getMyCalendar(cfg, { selectDate: date }),
|
|
21
|
+
getEmailList(cfg, { folder: 'inbox', current: 1, startdate: date, enddate: date }),
|
|
22
|
+
getEmailList(cfg, { folder: 'sent', current: 1, startdate: date, enddate: date }),
|
|
23
|
+
getButtons(cfg),
|
|
24
|
+
getMyBlogList(cfg, date, 1),
|
|
25
|
+
]);
|
|
26
|
+
const value = (index, fallback) => results[index].status === 'fulfilled' ? results[index].value : fallback;
|
|
27
|
+
const failed = results.map((result, index) => result.status === 'rejected' ? ({ index, message: String(result.reason?.message ?? result.reason) }) : null).filter(Boolean);
|
|
28
|
+
const done = value(0, { items: [] }).items.filter((item) => onDate(item.operatedate ?? item.operateTime, date)).map((item) => source('已办流程', item.requestId, item.operatedate ?? item.operateTime, { title: item.title }));
|
|
29
|
+
const mine = value(1, { items: [] }).items.filter((item) => onDate(item.createdate ?? item.createTime, date)).map((item) => source('我发起的流程', item.requestId, item.createdate ?? item.createTime, { title: item.title }));
|
|
30
|
+
const calendar = value(2, { items: [] }).items.filter((item) => onDate(item.beginDateTime, date)).map((item) => source('日程', item.id, item.beginDateTime, { name: item.name, status: item.status }));
|
|
31
|
+
const inbox = value(3, { items: [] }).items.filter((item) => onDate(item.date, date)).map((item) => source('收件箱', item.id, item.date, { subject: item.subject, from: item.from }));
|
|
32
|
+
const sent = value(4, { items: [] }).items.filter((item) => onDate(item.date, date)).map((item) => source('已发送邮件', item.id, item.date, { subject: item.subject, to: item.to }));
|
|
33
|
+
const attendance = value(5, { timeline: [] }).timeline.map((item) => source('考勤', `${item.date}:${item.type}`, item.signTime, { type: item.type, signTime: item.signTime, status: item.kqstatus }));
|
|
34
|
+
// 微博:列表接口返回 isReCalled(已抓包验证,0=正常发布,1/2=已撤回)。
|
|
35
|
+
// 仅纳入正常发布(isReCalled=0)的微博,已撤回微博剔除并记入 notes。
|
|
36
|
+
const blogToday = value(6, { items: [] }).items.filter((it) => onDate(it.workdate, date) || onDate(it.createdate, date));
|
|
37
|
+
const recalledBlogs = blogToday.filter((it) => isBlogRecalled(it));
|
|
38
|
+
const blogs = blogToday.filter((it) => !isBlogRecalled(it)).map((item) => source('微博', item.id, item.workdate, { content: item.content?.slice(0, 60) }));
|
|
39
|
+
const notes = [];
|
|
40
|
+
if (recalledBlogs.length > 0)
|
|
41
|
+
notes.push(`微博:${recalledBlogs.length} 条已撤回(isReCalled≠0)未纳入汇总`);
|
|
42
|
+
return {
|
|
43
|
+
scope: '当前 BDIP5 环境可查询业务数据汇总(不含操作审计、页面浏览及其他不可查询行为)',
|
|
44
|
+
baseUrl: endpointFor(cfg).baseUrl,
|
|
45
|
+
date,
|
|
46
|
+
sources: { done, mine, calendar, inbox, sent, attendance, blogs },
|
|
47
|
+
notes,
|
|
48
|
+
failures: failed,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
import { SessionHeartbeat } from './session.js';
|
|
5
|
+
import { keepalivePaths } from './keepalive.js';
|
|
6
|
+
|
|
7
|
+
async function run() {
|
|
8
|
+
const [configPath, statePath, leaseId] = process.argv.slice(2);
|
|
9
|
+
const cfg = loadConfig(configPath);
|
|
10
|
+
const paths = keepalivePaths(cfg);
|
|
11
|
+
if (path.resolve(statePath ?? '') !== paths.state || !/^[a-f0-9]{48}$/.test(leaseId ?? '')) throw new Error('Invalid lease');
|
|
12
|
+
const read = () => { try { return JSON.parse(fs.readFileSync(paths.state, 'utf8')); } catch { return null; } };
|
|
13
|
+
const initial = read();
|
|
14
|
+
if (initial?.leaseId !== leaseId || initial.status !== 'starting' || initial.expiresAt <= Date.now()) return;
|
|
15
|
+
let finished = false, watch, heartbeat, failures = 0;
|
|
16
|
+
const write = patch => {
|
|
17
|
+
const current = read();
|
|
18
|
+
if (current?.leaseId !== leaseId) return false;
|
|
19
|
+
fs.writeFileSync(paths.state, JSON.stringify({ ...current, ...patch, pid: process.pid }), { mode: 0o600 });
|
|
20
|
+
return true;
|
|
21
|
+
};
|
|
22
|
+
const finish = (status, message) => {
|
|
23
|
+
if (finished) return;
|
|
24
|
+
finished = true;
|
|
25
|
+
if (watch) clearInterval(watch);
|
|
26
|
+
heartbeat?.stop();
|
|
27
|
+
write({ status, message, active: false, lastSeen: Date.now(), endedAt: Date.now() });
|
|
28
|
+
process.off('SIGINT', interrupt); process.off('SIGTERM', interrupt);
|
|
29
|
+
};
|
|
30
|
+
const interrupt = () => finish('stopped', '已停止保活');
|
|
31
|
+
heartbeat = new SessionHeartbeat(cfg, { unref: false, onState: state => {
|
|
32
|
+
if (finished) return;
|
|
33
|
+
failures = state.authenticated ? 0 : failures + 1;
|
|
34
|
+
if (state.authenticated) {
|
|
35
|
+
write({ status: 'running', lastSeen: Date.now(), userId: state.userId, loginId: state.loginId,
|
|
36
|
+
mode: state.mode, idleTimeoutSeconds: state.idleTimeoutSeconds, message: state.message ?? null, failures: 0 });
|
|
37
|
+
} else {
|
|
38
|
+
write({ lastSeen: Date.now(), failures, message: state.message, code: state.code });
|
|
39
|
+
if (['FORCED_OFFLINE', 'SESSION_EXPIRED', 'NO_COOKIE', 'PROFILE_CHANGED'].includes(state.code) || failures >= 3) finish('failed', state.message);
|
|
40
|
+
}
|
|
41
|
+
} });
|
|
42
|
+
process.once('SIGINT', interrupt); process.once('SIGTERM', interrupt);
|
|
43
|
+
// Observe cancellation even while the first network check is still pending.
|
|
44
|
+
watch = setInterval(() => {
|
|
45
|
+
const current = read();
|
|
46
|
+
if (current?.leaseId !== leaseId) finish('stopped', '保活租约已被替换');
|
|
47
|
+
else if (current.stopRequested) finish('stopped', '已按请求停止保活');
|
|
48
|
+
else if (Date.now() >= initial.expiresAt) finish('expired', '保活已达到设定截止时间');
|
|
49
|
+
}, 250);
|
|
50
|
+
await heartbeat.start();
|
|
51
|
+
if (finished) heartbeat.stop();
|
|
52
|
+
else if (!heartbeat.state?.authenticated) finish('failed', heartbeat.state?.message ?? '当前用户检查失败');
|
|
53
|
+
}
|
|
54
|
+
run().catch(() => { process.exitCode = 1; });
|
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
|
+
}
|