weaver-e9-assistant-cli 1.0.0 → 1.1.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/dist/auth.js +18 -8
- package/dist/blog.js +65 -2
- package/dist/cli.js +332 -56
- package/dist/client.js +26 -5
- package/dist/cube.js +121 -18
- package/dist/email.js +4 -1
- package/dist/subordinate.js +419 -0
- package/dist/todobatch.js +7 -0
- package/dist/wfuser.js +37 -0
- package/dist/workplan.js +37 -0
- package/package.json +27 -25
package/dist/client.js
CHANGED
|
@@ -174,15 +174,36 @@ export class E9Api {
|
|
|
174
174
|
throw new Error(`请求失败:${method} ${url}。服务器可能不可达或网络不通。`);
|
|
175
175
|
return er;
|
|
176
176
|
}
|
|
177
|
-
/**
|
|
178
|
-
|
|
179
|
-
|
|
177
|
+
/**
|
|
178
|
+
* GET。URL+query 拼好后委托 requestWithRetry。
|
|
179
|
+
* opts.forceCookie=true 时强制走 cookie 头裸发一次(跳过 token/ensureValid)——
|
|
180
|
+
* 供 token 通道 no-op(200 但业务空、非 401 不走重试)的接口逃生,如 /api/ec/dev/app/getUserInfo。
|
|
181
|
+
*/
|
|
182
|
+
async get(path, query, opts = {}) {
|
|
183
|
+
const url = buildUrl(this.baseUrl, path, query);
|
|
184
|
+
if (opts?.forceCookie) {
|
|
185
|
+
const headers = await this.getHeaders(undefined, { forceCookie: true });
|
|
186
|
+
const res = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(30000) }).catch(() => null);
|
|
187
|
+
return res ? await toE9Response(res) : null;
|
|
188
|
+
}
|
|
189
|
+
return this.requestWithRetry('GET', url, undefined, 30000);
|
|
180
190
|
}
|
|
181
|
-
/**
|
|
182
|
-
|
|
191
|
+
/**
|
|
192
|
+
* POST。body 用 URLSearchParams(与原 e9Request 编码一致),委托 requestWithRetry。
|
|
193
|
+
* opts.forceCookie=true 时强制走 cookie 头裸发一次(跳过 token/ensureValid)——
|
|
194
|
+
* 供 token 通道 no-op(200 但业务空、非 401 不走重试)的接口逃生,如 hrm 下属查询。
|
|
195
|
+
*/
|
|
196
|
+
async post(path, body, opts = {}) {
|
|
183
197
|
const form = new URLSearchParams();
|
|
184
198
|
for (const [k, v] of Object.entries(body ?? {}))
|
|
185
199
|
form.append(k, v);
|
|
200
|
+
if (opts?.forceCookie) {
|
|
201
|
+
const headers = await this.getHeaders('application/x-www-form-urlencoded; charset=utf-8', { forceCookie: true });
|
|
202
|
+
const res = await fetch(buildUrl(this.baseUrl, path), {
|
|
203
|
+
method: 'POST', headers, body: form.toString(), signal: AbortSignal.timeout(30000),
|
|
204
|
+
}).catch(() => null);
|
|
205
|
+
return res ? await toE9Response(res) : null;
|
|
206
|
+
}
|
|
186
207
|
return this.requestWithRetry('POST', buildUrl(this.baseUrl, path), form.toString(), 30000);
|
|
187
208
|
}
|
|
188
209
|
}
|
package/dist/cube.js
CHANGED
|
@@ -79,6 +79,74 @@ export async function getCubeCondition(cfg, opts = {}) {
|
|
|
79
79
|
.filter((it) => it.label);
|
|
80
80
|
return { items, raw: j };
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* ★越权防护 人员条件 label 白名单(专项守卫正则,不复用 wfuser.js PERSON_LABEL——
|
|
84
|
+
* 它漏了 审批人/接收人/操作人/下属,且含 联系人 会误伤 联系人电话)。
|
|
85
|
+
* 判定前先剔除含 电话/手机/邮箱/号码/传真/部门/日期/时间 的字段(非人筛选)。
|
|
86
|
+
* 说明:方案A(label 正则)为主判据;conditionType 码未知,实跑校准后补 type 码 OR。
|
|
87
|
+
*/
|
|
88
|
+
/**
|
|
89
|
+
* 明确排除的非人字段:联系方式(联系人电话…)/ 日期时间(填报日期…)/ 计数单位(人数/人次/人天…)。
|
|
90
|
+
* 不排除「部门」——部门负责人/部门经办人/部门审批人仍是人员,后缀「人」判据应当命中。
|
|
91
|
+
*/
|
|
92
|
+
const NON_PERSON_LABEL = /电话|手机|邮箱|号码|传真|日期|时间|区间|人数|人次|人天/;
|
|
93
|
+
/** 明确的人员白名单(不含「人」字或不在后缀规则内的:姓名/人员/联系人/下属/上级)。 */
|
|
94
|
+
export const PERSON_CONDITION_LABEL =
|
|
95
|
+
/^(姓名|人员|联系人|下属|上级|领导)$/u;
|
|
96
|
+
/** 人员后缀判据(先剔除 NON_PERSON 后):X人/ X人员/ X员/ X者(报销人/值班人员/采购员/阅读者…)。
|
|
97
|
+
* 人员类别/人员类型等「人员+名词」不是人员过滤(人员类别=正式工),靠「末尾字符∈人者员」排除。 */
|
|
98
|
+
const PERSON_SUFFIX = /[人者员]$/u;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 该 label 是否属于「按人过滤」查询条件(fail-closed:宁可多拦)。
|
|
102
|
+
* 判据 = 后缀启发式「X人」(报销人/使用人/领用人/申请人/审批人/经办人/填报人…全部覆盖)+
|
|
103
|
+
* 白名单(姓名/人员/联系人/下属/上级);先剔除联系方式/日期时间/计数单位。
|
|
104
|
+
* 后缀启发式替代旧白名单枚举——旧白名单漏「报销人/使用人/领用人」等,会放行越权查询。
|
|
105
|
+
*/
|
|
106
|
+
export function isPersonConditionLabel(label) {
|
|
107
|
+
if (typeof label !== 'string' || !label.trim())
|
|
108
|
+
return false;
|
|
109
|
+
const l = label.trim();
|
|
110
|
+
if (NON_PERSON_LABEL.test(l))
|
|
111
|
+
return false;
|
|
112
|
+
if (PERSON_SUFFIX.test(l))
|
|
113
|
+
return true;
|
|
114
|
+
if (l.includes('姓名'))
|
|
115
|
+
return true;
|
|
116
|
+
return PERSON_CONDITION_LABEL.test(l);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* ★越权防护 人员条件分类(纯函数,便于单测)。
|
|
121
|
+
* condItems:getCubeCondition 返回的条件项 [{label, domkey, type}]。
|
|
122
|
+
* rawPairs:[["键","值"],...](键可能是 domkey 也可能是中文 label)。
|
|
123
|
+
* 返回 [{label, domkey, value}]——命中人员条件白名单的键值对。
|
|
124
|
+
*/
|
|
125
|
+
export function classifyPersonConditions(condItems, rawPairs) {
|
|
126
|
+
if (!Array.isArray(rawPairs) || rawPairs.length === 0)
|
|
127
|
+
return [];
|
|
128
|
+
// domkey → label 索引(label 键也能命中 condItems 的 label)
|
|
129
|
+
const domToLabel = {};
|
|
130
|
+
for (const it of condItems || [])
|
|
131
|
+
for (const dk of (it.domkey ?? []))
|
|
132
|
+
if (it.label && !(dk in domToLabel))
|
|
133
|
+
domToLabel[dk] = it.label;
|
|
134
|
+
const labels = new Set((condItems || []).map((it) => it.label).filter(Boolean));
|
|
135
|
+
const personConditions = [];
|
|
136
|
+
for (const [k, v] of rawPairs) {
|
|
137
|
+
const key = String(k ?? '').trim();
|
|
138
|
+
const val = String(v ?? '').trim();
|
|
139
|
+
if (!key || !val)
|
|
140
|
+
continue;
|
|
141
|
+
const label = domToLabel[key] ?? (labels.has(key) ? key : null);
|
|
142
|
+
// 后缀启发式直接判按键名(即使条件定义缺失,也能拦「报销人=王五」);
|
|
143
|
+
// domkey 依赖 domToLabel 反查;两者皆无则落到 unverifiablePerson 由调用方 fail-closed 拒绝。
|
|
144
|
+
if (isPersonConditionLabel(label ?? key))
|
|
145
|
+
personConditions.push({ label: label ?? key, domkey: key, value: val });
|
|
146
|
+
}
|
|
147
|
+
return personConditions;
|
|
148
|
+
}
|
|
149
|
+
|
|
82
150
|
/**
|
|
83
151
|
* ★新 条件字符串 → conditionValues 映射(P2-3,0.4.8)。
|
|
84
152
|
*
|
|
@@ -86,7 +154,10 @@ export async function getCubeCondition(cfg, opts = {}) {
|
|
|
86
154
|
* - 键以 `con_`/`dom_`/`field`/`col` 开头 → 视为合法 domkey,原样透传(向后兼容)。
|
|
87
155
|
* - 其它 → 视为中文 label,用 getCubeCondition 的 label→domkey 映射自动转换;
|
|
88
156
|
* 未匹配时返回 unresolved(含候选 label 列表),由调用方报错提示,不静默放空。
|
|
89
|
-
*
|
|
157
|
+
* ★越权防护(1.1.0):只要传了条件就取一次条件定义——含 domkey 直传也要反查 label 分类,
|
|
158
|
+
* 堵 `con_7261=...` 直接给 domkey 绕开中文 label 的越权口;getCubeCondition 抛错 → 透传不阻断
|
|
159
|
+
* 但打 stderr 警告「本次未做人员越权校验」。
|
|
160
|
+
* 返回 { conditionValues, unresolved:[{label, candidates:[...]}], personConditions:[{label, domkey, value}] }。
|
|
90
161
|
*/
|
|
91
162
|
export async function resolveCubeCondition(cfg, opts = {}) {
|
|
92
163
|
const customid = String(opts.customid);
|
|
@@ -100,21 +171,27 @@ export async function resolveCubeCondition(cfg, opts = {}) {
|
|
|
100
171
|
pairs.push([kv.slice(0, i).trim(), kv.slice(i + 1).trim()]);
|
|
101
172
|
}
|
|
102
173
|
if (pairs.length === 0)
|
|
103
|
-
return { conditionValues, unresolved: [] };
|
|
174
|
+
return { conditionValues, unresolved: [], personConditions: [] };
|
|
104
175
|
// 判断是否含中文 label 键
|
|
105
176
|
const needsLabelMap = pairs.some(([k]) => !/^(con|dom|field|col)[-_]?/i.test(k));
|
|
106
|
-
let
|
|
107
|
-
let
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
177
|
+
let condItems = [];
|
|
178
|
+
let condFetched = false;
|
|
179
|
+
// ★越权防护:只要传了条件就取一次条件定义(含 domkey 直传也要反查 label 分类)
|
|
180
|
+
try {
|
|
181
|
+
const cond = await getCubeCondition(cfg, { customid });
|
|
182
|
+
condItems = cond.items;
|
|
183
|
+
condFetched = true;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// fail-closed:定义读不到时 domkey 无法反查 → 由下方 unverifiablePerson 拒绝;中文 label 按名称判定不受影响
|
|
187
|
+
if (pairs.some(([k]) => /^(con|dom|field|col)[-_]?/i.test(k)))
|
|
188
|
+
process.stderr.write('⚠ 无法读取台账查询条件定义:domkey 类条件将被拒绝(无法确认是否按人过滤),中文字段名条件按名称判定。\n');
|
|
117
189
|
}
|
|
190
|
+
const labelToDom = {};
|
|
191
|
+
for (const it of condItems)
|
|
192
|
+
for (const dk of (it.domkey ?? []))
|
|
193
|
+
if (it.label && !(it.label in labelToDom))
|
|
194
|
+
labelToDom[it.label] = dk;
|
|
118
195
|
const unresolved = [];
|
|
119
196
|
for (const [k, v] of pairs) {
|
|
120
197
|
if (/^(con|dom|field|col)[-_]?/i.test(k)) {
|
|
@@ -125,8 +202,8 @@ export async function resolveCubeCondition(cfg, opts = {}) {
|
|
|
125
202
|
if (domkey) {
|
|
126
203
|
conditionValues[domkey] = v;
|
|
127
204
|
}
|
|
128
|
-
else if (
|
|
129
|
-
//
|
|
205
|
+
else if (!condFetched) {
|
|
206
|
+
// 条件定义接口本身失败(网络抖动/无权限):按原样透传,不阻断(用户可能直接给了 domkey)——但已打越权警告
|
|
130
207
|
conditionValues[k] = v;
|
|
131
208
|
}
|
|
132
209
|
else {
|
|
@@ -135,7 +212,21 @@ export async function resolveCubeCondition(cfg, opts = {}) {
|
|
|
135
212
|
unresolved.push({ label: k, candidates });
|
|
136
213
|
}
|
|
137
214
|
}
|
|
138
|
-
|
|
215
|
+
// ★越权防护(fail-closed):无法确认是否按人过滤的条件 → 调用方硬拒绝。
|
|
216
|
+
// - 条件定义读取失败:domkey 类键无法反查 label → 无法确认;
|
|
217
|
+
// - 定义读取成功但 domkey 不在定义中:可能是未进筛选定义的人员字段 → 无法确认;
|
|
218
|
+
// 中文 label 键可经后缀启发式直接判定,不依赖定义。
|
|
219
|
+
const knownDom = new Set();
|
|
220
|
+
for (const it of condItems)
|
|
221
|
+
for (const dk of (it.domkey ?? []))
|
|
222
|
+
knownDom.add(dk);
|
|
223
|
+
let unverifiablePerson = false;
|
|
224
|
+
for (const [k, v] of pairs)
|
|
225
|
+
if (v && /^(con|dom|field|col)[-_]?/i.test(String(k)) && !knownDom.has(String(k).trim()))
|
|
226
|
+
unverifiablePerson = true;
|
|
227
|
+
// ★越权防护:人员条件分类(供调用方 assertNamesInScope 校验)
|
|
228
|
+
const personConditions = classifyPersonConditions(condItems, pairs);
|
|
229
|
+
return { conditionValues, unresolved, unverifiablePerson, personConditions };
|
|
139
230
|
}
|
|
140
231
|
/**
|
|
141
232
|
* ★新 字段下拉选项全表。GET /api/cube/search/getSearchColumnInfo?customid=<id>。
|
|
@@ -1112,9 +1203,13 @@ export async function submitCubeCard(cfg, opts = {}) {
|
|
|
1112
1203
|
await e9Request(ep, { method: 'POST', path: '/api/cube/new/card/cubeVerifyRepeat', body: { billid, type: '1', modeId, formId, _key, guid: 'card', verifys: '{}' } });
|
|
1113
1204
|
await e9Request(ep, { method: 'POST', path: '/api/cube/new/card/checkCardDataChange', body: { billid, type: '1', modeId, formId, _key, guid: 'card', changeDataTime: cdt, iscreate } });
|
|
1114
1205
|
// ④ JSONStr 全字段(cf.fields 所有 id,values 覆盖,未填给空)
|
|
1206
|
+
// 修复 #37:只读字段(isedit=false)未填时优先带 mainFields 返回的默认值 f.value——
|
|
1207
|
+
// 填报人(dkr)/填报日期(dkrq)/填报时间(dksj)/员工类型(yglx) 等由后端在新建态 mainFields
|
|
1208
|
+
// 预填当前用户/今天/当前时间,旧实现兜底 '' 覆盖为空格,实测 billid=35 这些字段提交后为空。
|
|
1209
|
+
// f.value 兜底同样修复 edit:编辑态 mainFields 带当前值,未传字段不再被清空。
|
|
1115
1210
|
const jsonObj = {};
|
|
1116
1211
|
for (const f of fields)
|
|
1117
|
-
jsonObj[`field${f.id}`] = String(values[f.id] ?? values[`field${f.id}`] ?? '');
|
|
1212
|
+
jsonObj[`field${f.id}`] = String(values[f.id] ?? values[`field${f.id}`] ?? f.value ?? '');
|
|
1118
1213
|
// ⑤ doSubmit(cookie,同 session)
|
|
1119
1214
|
const body = {
|
|
1120
1215
|
billid, type: '1', modeId, formId, _key, guid: 'card', token: cardToken,
|
|
@@ -1157,7 +1252,15 @@ export async function previewCubeCard(cfg, opts = {}) {
|
|
|
1157
1252
|
return { fieldId: String(fid), label: f?.label ?? `field${fid}`, value: so?.name ?? info.value };
|
|
1158
1253
|
});
|
|
1159
1254
|
}
|
|
1160
|
-
|
|
1255
|
+
// 未填字段并入 mainFields 默认值(只读字段填报人/填报日期等后端预填),
|
|
1256
|
+
// 保证 dry-run 确认表与真实提交字段集一致(同 submitCubeCard 的 f.value ?? '' 兜底)。
|
|
1257
|
+
const finalValues = { ...values };
|
|
1258
|
+
for (const f of fields) {
|
|
1259
|
+
const fid = String(f.id);
|
|
1260
|
+
if (finalValues[fid] === undefined && finalValues[`field${fid}`] === undefined && f.value !== undefined && f.value !== null && f.value !== '')
|
|
1261
|
+
finalValues[fid] = f.value;
|
|
1262
|
+
}
|
|
1263
|
+
return { modeId, formId, billid, layoutid, fields, finalValues, linked, files };
|
|
1161
1264
|
}
|
|
1162
1265
|
/** 删除台账记录(cookie)。GET /api/cube/expand/deleteData?operate=DeleteAction&billids=。 */
|
|
1163
1266
|
export async function deleteCubeData(cfg, opts = {}) {
|
package/dist/email.js
CHANGED
|
@@ -773,7 +773,10 @@ export async function uploadEmailAttachment(cfg, filePath, { name } = {}) {
|
|
|
773
773
|
catch {
|
|
774
774
|
j = null;
|
|
775
775
|
}
|
|
776
|
-
|
|
776
|
+
// 修复 #3:cookie 过期时后端返回 HTTP 200 + {msg:"登录信息超时",errorCode:"002",status:false},
|
|
777
|
+
// 旧判断把 r.ok(HTTP 200)当成功 → 附件实际未上传却显示成功。只认 body 的 status 成功标志。
|
|
778
|
+
const ok = j?.status === '1' || j?.status === 1 || j?.status === true;
|
|
779
|
+
return { ok, data: j?.data ?? null, raw: j };
|
|
777
780
|
}
|
|
778
781
|
/**
|
|
779
782
|
* 附件列表。链路:GET /api/email/attachment/list?filename=&subject= → sessionkey
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 我的下属:查询直接/全部层级下属 + 越权防护(fail-closed)。
|
|
3
|
+
*
|
|
4
|
+
* 链路(用户抓包确认,POST 均表单编码):
|
|
5
|
+
* 1) /api/hrm/subordinateinfo/getSearchResult body { id, lastname, showAllLevel } → dataKey
|
|
6
|
+
* 2) /api/ec/dev/table/datas body { dataKey, current, sortParams } → 行 + columns(不带 pageSize,跟随服务端默认)
|
|
7
|
+
* 3) /api/ec/dev/table/counts body { dataKey } → 总数 {count:N,status:true}
|
|
8
|
+
*
|
|
9
|
+
* 越权边界(用户明确要求,fail-closed):
|
|
10
|
+
* 查询数据只能查「当前登录用户 + 全部层级下属」,目标人员不在授权集合 → 拒绝。
|
|
11
|
+
* 授权集合加载失败/不可靠时,非本人查询一律拒绝;本人永远允许。
|
|
12
|
+
*
|
|
13
|
+
* 安全设计要点:
|
|
14
|
+
* - 授权集合按环境隔离缓存 ~/.e9/subordinate_scope_<envkey>.json,TTL 12h(E9_SCOPE_TTL_HOURS 可调)。
|
|
15
|
+
* - 缓存必须 selfId 匹配才可用(错用户/过期/不可靠一律不用,防离职下属仍被放行)。
|
|
16
|
+
* - 姓名→userid 解析唯一入口是 blog at-list(SKILL.md 硬规则),候选按 精确>前缀>拼音 排序后过滤到范围。
|
|
17
|
+
* - 下属表找不到 id 列 → scope 标记 reliable:false → 非本人查询拒绝(安全但功能受限,实跑校准回填规则)。
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import { createApiFromProfile } from './client.js';
|
|
23
|
+
import { extractDataKey, extractRows, extractColumns, extractTotal, pickByPaths, stripHtml } from './parse.js';
|
|
24
|
+
import { getCurrentUser } from './wfuser.js';
|
|
25
|
+
import { getAtAllList } from './blog.js';
|
|
26
|
+
import { profileNameFromBaseUrl } from './config.js';
|
|
27
|
+
|
|
28
|
+
/** 全层级下属翻页上限(超限仍未收全 → reliable:false)。 */
|
|
29
|
+
export const MAX_SCOPE_PAGES = 20;
|
|
30
|
+
/** 授权集合缓存 TTL(默认 12h,比台账目录的 7 天短——越权集合同理会随组织变动,宁短勿长)。 */
|
|
31
|
+
export const SCOPE_TTL_MS = (Number(process.env.E9_SCOPE_TTL_HOURS ?? '12') || 12) * 3600 * 1000;
|
|
32
|
+
/** 缓存目录(测试可用 E9_SCOPE_CACHE_DIR 覆盖,避免污染真实 ~/.e9)。 */
|
|
33
|
+
function scopeCacheDir() {
|
|
34
|
+
return process.env.E9_SCOPE_CACHE_DIR || path.join(os.homedir(), '.e9');
|
|
35
|
+
}
|
|
36
|
+
function scopeCachePath(cfg) {
|
|
37
|
+
const envkey = profileNameFromBaseUrl(cfg.baseUrl);
|
|
38
|
+
return path.join(scopeCacheDir(), `subordinate_scope_${envkey}.json`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── 链路函数 ────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** 拿下属列表 dataKey。token no-op 逃生:200 但无 dataKey 且配了 cookie → 强制 cookie 重试一次。 */
|
|
44
|
+
export async function fetchSubordinateDataKey(cfg, { lastname = '', showAllLevel = false } = {}) {
|
|
45
|
+
const api = await createApiFromProfile(cfg);
|
|
46
|
+
const body = { id: '', lastname: String(lastname ?? ''), showAllLevel: showAllLevel ? 'true' : 'false' };
|
|
47
|
+
let r = await api.post('/api/hrm/subordinateinfo/getSearchResult', body);
|
|
48
|
+
let dataKey = extractDataKey(r?.json);
|
|
49
|
+
if (!dataKey && cfg.cookie) {
|
|
50
|
+
r = await api.post('/api/hrm/subordinateinfo/getSearchResult', body, { forceCookie: true });
|
|
51
|
+
dataKey = extractDataKey(r?.json);
|
|
52
|
+
}
|
|
53
|
+
if (!dataKey) {
|
|
54
|
+
throw new Error('未能从 getSearchResult 响应中提取 dataKey。\n' + `响应(前300字): ${truncate(r?.text)}`);
|
|
55
|
+
}
|
|
56
|
+
return { dataKey, raw: r?.json ?? null };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 取下属表一页数据(不带 pageSize,跟随服务端默认每页条数)。 */
|
|
60
|
+
export async function fetchSubordinateDataPage(cfg, dataKey, page) {
|
|
61
|
+
const api = await createApiFromProfile(cfg);
|
|
62
|
+
const r = await api.post('/api/ec/dev/table/datas', { dataKey, current: String(page), sortParams: '[]' });
|
|
63
|
+
const json = r?.json ?? {};
|
|
64
|
+
return {
|
|
65
|
+
rows: extractRows(json),
|
|
66
|
+
columns: extractColumns(json),
|
|
67
|
+
pageSize: pickNum(json, ['pageSize', 'data.pageSize', 'rootMap.pagesize', 'data.rootMap.pagesize']),
|
|
68
|
+
raw: json,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 下属表总数。 */
|
|
73
|
+
export async function fetchSubordinateCount(cfg, dataKey) {
|
|
74
|
+
const api = await createApiFromProfile(cfg);
|
|
75
|
+
const r = await api.post('/api/ec/dev/table/counts', { dataKey });
|
|
76
|
+
return { total: extractTotal(r?.json), raw: r?.json ?? null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── 列检测(校准锚点,fail-closed 关键) ────────────────
|
|
80
|
+
|
|
81
|
+
/** 找下属表的 id 列(拿不到 → scope 不可靠 → 非本人查询拒绝)。 */
|
|
82
|
+
export function detectSubordinateIdColumn(columns) {
|
|
83
|
+
if (!Array.isArray(columns))
|
|
84
|
+
return null;
|
|
85
|
+
return columns.find((c) => {
|
|
86
|
+
const di = String(c?.dataIndex ?? '').toLowerCase();
|
|
87
|
+
const db = String(c?.dbField ?? '').toLowerCase();
|
|
88
|
+
return /^(id|userid|hrmid|randomfieldid)$/i.test(di) || /^(id|userid|hrmid)$/i.test(db);
|
|
89
|
+
}) ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 找下属表的姓名列(取不到 → userName 匹配失效,但 userId 匹配仍生效)。 */
|
|
93
|
+
export function detectSubordinateNameColumn(columns) {
|
|
94
|
+
if (!Array.isArray(columns))
|
|
95
|
+
return null;
|
|
96
|
+
return columns.find((c) => {
|
|
97
|
+
const di = String(c?.dataIndex ?? '').toLowerCase();
|
|
98
|
+
const title = String(c?.title ?? '');
|
|
99
|
+
return /^(lastname|name)$/i.test(di) || /姓名|人员|名字/.test(title);
|
|
100
|
+
}) ?? null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── 列表命令后端 ────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/** 规范化一行下属:id/name + 全部列(<field>span 优先去 HTML)。人员无独立页面 → 纯文本。 */
|
|
106
|
+
export function normalizeSubordinateRow(row, columns) {
|
|
107
|
+
if (!row || typeof row !== 'object')
|
|
108
|
+
return {};
|
|
109
|
+
const idCol = detectSubordinateIdColumn(columns);
|
|
110
|
+
const nameCol = detectSubordinateNameColumn(columns);
|
|
111
|
+
const get = (k) => pickByPaths(row, [k]);
|
|
112
|
+
const id = idCol ? stripHtml(get(idCol.dataIndex)) : '';
|
|
113
|
+
const name = nameCol ? stripHtml(get(`${nameCol.dataIndex}span`) ?? get(nameCol.dataIndex)) : '';
|
|
114
|
+
const fields = (columns || []).map((c) => ({
|
|
115
|
+
dataIndex: c.dataIndex,
|
|
116
|
+
title: c.title,
|
|
117
|
+
value: stripHtml(get(`${c.dataIndex}span`) ?? get(c.dataIndex)),
|
|
118
|
+
}));
|
|
119
|
+
return { id, name, fields, raw: row };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 查询我的下属列表。showAllLevel=false=直接下属(抓包默认)/true=全部层级。
|
|
124
|
+
* 返回 { lastname, allLevel, page, pageSize, total, count, dataKey, columns, items, raw }。
|
|
125
|
+
*/
|
|
126
|
+
export async function getSubordinates(cfg, opts = {}) {
|
|
127
|
+
const lastname = opts.lastname ?? '';
|
|
128
|
+
const allLevel = !!opts.allLevel;
|
|
129
|
+
const page = Number(opts.page ?? '1') || 1;
|
|
130
|
+
const { dataKey, raw: searchRaw } = await fetchSubordinateDataKey(cfg, { lastname, showAllLevel: allLevel });
|
|
131
|
+
const data = await fetchSubordinateDataPage(cfg, dataKey, page);
|
|
132
|
+
const count = await fetchSubordinateCount(cfg, dataKey);
|
|
133
|
+
const total = count.total ?? null;
|
|
134
|
+
const items = data.rows.map((r) => normalizeSubordinateRow(r, data.columns));
|
|
135
|
+
return {
|
|
136
|
+
lastname,
|
|
137
|
+
allLevel,
|
|
138
|
+
page,
|
|
139
|
+
pageSize: data.pageSize ?? (data.rows.length || null),
|
|
140
|
+
total,
|
|
141
|
+
count: items.length,
|
|
142
|
+
dataKey,
|
|
143
|
+
columns: data.columns,
|
|
144
|
+
items,
|
|
145
|
+
raw: { getSearchResult: searchRaw, datas: data.raw, counts: count.raw },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── 授权集合加载 + 缓存(严格 fail-closed) ─────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 加载授权集合 { 当前用户 } ∪ { 全部层级下属 }。
|
|
153
|
+
* 返回 { reliable, error?, selfId, selfName, userIds:Set, userNames:Set, fromCache, fetchedAt }。
|
|
154
|
+
* reliable:false 表示集合不可靠(身份未确认/拉取失败/未收全/id 列缺失)→ 调用方只允许放行本人。
|
|
155
|
+
*/
|
|
156
|
+
export async function loadScope(cfg, opts = {}) {
|
|
157
|
+
// 1. 自证身份:getCurrentUser → cookie 兜底 → 仍空则连本人都无法确认,一切带人查询拒绝
|
|
158
|
+
let selfId = '';
|
|
159
|
+
let selfName = '';
|
|
160
|
+
try {
|
|
161
|
+
const u = await getCurrentUser(cfg);
|
|
162
|
+
selfId = String(u?.userid ?? '');
|
|
163
|
+
selfName = String(u?.username ?? '');
|
|
164
|
+
}
|
|
165
|
+
catch { /* 走 cookie 兜底 */ }
|
|
166
|
+
if (!selfId)
|
|
167
|
+
selfId = parseUserIdFromCookie(cfg?.cookie);
|
|
168
|
+
if (!selfId) {
|
|
169
|
+
return { reliable: false, error: 'no-self', selfId: '', selfName: '', userIds: new Set(), userNames: new Set() };
|
|
170
|
+
}
|
|
171
|
+
// 2. 缓存命中:selfId 匹配 + 未过期 + reliable 才可用(错用户/过期/不可靠一律弃)
|
|
172
|
+
if (!opts.refresh && !opts.noCache) {
|
|
173
|
+
const cached = readScopeCache(cfg, selfId);
|
|
174
|
+
if (cached) {
|
|
175
|
+
return { ...cached, selfId, selfName, userIds: new Set(cached.userIds), userNames: new Set(cached.userNames), fromCache: true };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// 3. 拉全层级下属,逐页收行
|
|
179
|
+
const userIds = new Set([selfId]);
|
|
180
|
+
const userNames = new Set([selfName].filter(Boolean));
|
|
181
|
+
let reliable = false;
|
|
182
|
+
let idCol = null;
|
|
183
|
+
let nameCol = null;
|
|
184
|
+
let rows = [];
|
|
185
|
+
let total = null;
|
|
186
|
+
try {
|
|
187
|
+
const { dataKey } = await fetchSubordinateDataKey(cfg, { lastname: '', showAllLevel: true });
|
|
188
|
+
let page = 1;
|
|
189
|
+
let hitEnd = false;
|
|
190
|
+
while (page <= MAX_SCOPE_PAGES) {
|
|
191
|
+
const d = await fetchSubordinateDataPage(cfg, dataKey, page);
|
|
192
|
+
if (page === 1) {
|
|
193
|
+
idCol = detectSubordinateIdColumn(d.columns);
|
|
194
|
+
nameCol = detectSubordinateNameColumn(d.columns);
|
|
195
|
+
}
|
|
196
|
+
const pageRows = d.rows;
|
|
197
|
+
rows = rows.concat(pageRows);
|
|
198
|
+
if (pageRows.length === 0) {
|
|
199
|
+
hitEnd = true;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
// 短页 = 末尾(响应给了 pageSize 时能判;不给就靠 counts total 判完整)
|
|
203
|
+
if (d.pageSize && pageRows.length < d.pageSize) {
|
|
204
|
+
hitEnd = true;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
page++;
|
|
208
|
+
}
|
|
209
|
+
const count = await fetchSubordinateCount(cfg, dataKey);
|
|
210
|
+
total = count.total;
|
|
211
|
+
// 完整性判定:有 total 时收行数必须 >= total;无 total 时必须见到空页/短页才算到底
|
|
212
|
+
reliable = idCol != null && (total != null ? rows.length >= total : hitEnd);
|
|
213
|
+
if (reliable) {
|
|
214
|
+
for (const row of rows) {
|
|
215
|
+
const uid = stripHtml(pickByPaths(row, [idCol.dataIndex]));
|
|
216
|
+
if (/^\d+$/.test(uid))
|
|
217
|
+
userIds.add(uid);
|
|
218
|
+
if (nameCol) {
|
|
219
|
+
const nm = stripHtml(pickByPaths(row, [`${nameCol.dataIndex}span`, nameCol.dataIndex]));
|
|
220
|
+
if (nm)
|
|
221
|
+
userNames.add(nm);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch { reliable = false; }
|
|
227
|
+
const scope = { reliable, error: reliable ? undefined : 'scope-unreliable', selfId, selfName, userIds, userNames, fetchedAt: Date.now() };
|
|
228
|
+
// 仅 reliable 才写缓存(写失败不阻断)
|
|
229
|
+
if (reliable)
|
|
230
|
+
writeScopeCache(cfg, scope);
|
|
231
|
+
return scope;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 读缓存;selfId 不匹配 / 过期 / 不可靠 → null。 */
|
|
235
|
+
function readScopeCache(cfg, selfId) {
|
|
236
|
+
try {
|
|
237
|
+
const raw = JSON.parse(fs.readFileSync(scopeCachePath(cfg), 'utf8'));
|
|
238
|
+
if (!raw || raw.selfId !== selfId || raw.reliable !== true)
|
|
239
|
+
return null;
|
|
240
|
+
if (Date.now() - (raw.fetchedAt || 0) > SCOPE_TTL_MS)
|
|
241
|
+
return null;
|
|
242
|
+
return raw;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 写缓存(失败不阻断)。 */
|
|
250
|
+
function writeScopeCache(cfg, scope) {
|
|
251
|
+
try {
|
|
252
|
+
fs.mkdirSync(scopeCacheDir(), { recursive: true });
|
|
253
|
+
fs.writeFileSync(scopeCachePath(cfg), JSON.stringify({
|
|
254
|
+
envkey: profileNameFromBaseUrl(cfg.baseUrl),
|
|
255
|
+
baseUrl: cfg.baseUrl,
|
|
256
|
+
selfId: scope.selfId,
|
|
257
|
+
selfName: scope.selfName,
|
|
258
|
+
userIds: [...scope.userIds],
|
|
259
|
+
userNames: [...scope.userNames],
|
|
260
|
+
reliable: scope.reliable,
|
|
261
|
+
fetchedAt: scope.fetchedAt,
|
|
262
|
+
}, null, 2) + '\n', 'utf8');
|
|
263
|
+
}
|
|
264
|
+
catch { /* 缓存写失败不阻断查询 */ }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** 是否在授权集合内(纯函数,便于单测)。 */
|
|
268
|
+
export function isInScope(scope, { userId, userName } = {}) {
|
|
269
|
+
if (!scope || !scope.userIds)
|
|
270
|
+
return false;
|
|
271
|
+
if (userId != null && scope.userIds.has(String(userId).trim()))
|
|
272
|
+
return true;
|
|
273
|
+
if (userName != null && String(userName).trim() && scope.userNames?.has(String(userName).trim()))
|
|
274
|
+
return true;
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ─── 校验/拒绝 ───────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 按数字 userid 校验:目标必须 == 本人 或 ∈ 下属集合,否则 throw。
|
|
282
|
+
* ids 里的每个目标都校验;scope 不可靠时本人放行、他人一律拒绝。
|
|
283
|
+
*/
|
|
284
|
+
export async function assertUserIdsInScope(cfg, ids, opts = {}) {
|
|
285
|
+
const scope = await loadScope(cfg, opts);
|
|
286
|
+
for (const id of ids) {
|
|
287
|
+
const s = String(id ?? '').trim();
|
|
288
|
+
if (!s)
|
|
289
|
+
continue;
|
|
290
|
+
// 本人恒允许(即使 scope 不可靠)——越权边界定义:本人 + 全部层级下属;
|
|
291
|
+
// no-self 时 selfId 为空,非空 id 自然落空走拒绝。
|
|
292
|
+
if (s === scope.selfId)
|
|
293
|
+
continue;
|
|
294
|
+
if (scope.reliable && scope.userIds.has(s))
|
|
295
|
+
continue;
|
|
296
|
+
throw new Error(outOfScopeMessage(s, { reason: scope.reliable ? 'not-subordinate' : (scope.error === 'no-self' ? 'no-self' : 'scope-unreliable') }));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 按姓名校验:纯数字走 assertUserIdsInScope;否则 blog at-list 解析(SKILL.md 硬规则唯一入口),
|
|
302
|
+
* 候选按 精确>前缀>拼音 排序后过滤到授权集合:
|
|
303
|
+
* - 恰 1 个在范围 → 放行,返回其 uid;
|
|
304
|
+
* - 全部越界 / 多个在范围 → throw(拒绝,附范围内候选清单)。
|
|
305
|
+
* 返回单个 uid(names 长度 1)或 uid 数组。调用方可忽略返回值(如 cube 仅校验、值保持原样)。
|
|
306
|
+
*/
|
|
307
|
+
export async function assertNamesInScope(cfg, names, opts = {}) {
|
|
308
|
+
const scope = await loadScope(cfg, opts);
|
|
309
|
+
const resolved = [];
|
|
310
|
+
for (const name of names) {
|
|
311
|
+
const n = String(name ?? '').trim();
|
|
312
|
+
if (!n)
|
|
313
|
+
continue;
|
|
314
|
+
if (/^\d+$/.test(n)) {
|
|
315
|
+
await assertUserIdsInScope(cfg, [n], opts);
|
|
316
|
+
resolved.push(n);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const r = await getAtAllList(cfg, { name: n });
|
|
320
|
+
const candidates = sortPersonCandidates(r.items, n);
|
|
321
|
+
const { inScope, outCount } = filterCandidatesToScope(scope, candidates);
|
|
322
|
+
if (inScope.length === 0) {
|
|
323
|
+
throw new Error(outOfScopeMessage(n, { reason: 'none-in-scope', candidates: candidates.slice(0, 5), outCount }));
|
|
324
|
+
}
|
|
325
|
+
if (inScope.length === 1) {
|
|
326
|
+
resolved.push(String(inScope[0].uid ?? ''));
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
throw new Error(outOfScopeMessage(n, { reason: 'multiple-in-scope', candidates: inScope }));
|
|
330
|
+
}
|
|
331
|
+
return resolved.length === 1 ? resolved[0] : resolved;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** 候选过滤到授权集合(纯函数)。 */
|
|
335
|
+
export function filterCandidatesToScope(scope, candidates) {
|
|
336
|
+
const inScope = [];
|
|
337
|
+
let outCount = 0;
|
|
338
|
+
for (const c of candidates) {
|
|
339
|
+
const uid = String(c.uid ?? '');
|
|
340
|
+
const name = stripHtml(String(c.name ?? '')).trim();
|
|
341
|
+
if (uid && isInScope(scope, { userId: uid })) {
|
|
342
|
+
inScope.push({ ...c, name });
|
|
343
|
+
}
|
|
344
|
+
else if (name && isInScope(scope, { userName: name })) {
|
|
345
|
+
inScope.push({ ...c, name });
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
outCount++;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return { inScope, outCount };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 人员候选排序:精确 > 前缀 > 其它(复刻 cli.js at-list 逻辑)。 */
|
|
355
|
+
function sortPersonCandidates(items, query) {
|
|
356
|
+
const q = String(query ?? '').trim().toLowerCase();
|
|
357
|
+
return (items || [])
|
|
358
|
+
.map((it) => {
|
|
359
|
+
const name = String(it.name ?? '').trim();
|
|
360
|
+
const nameLow = name.toLowerCase();
|
|
361
|
+
let matchType = 'other';
|
|
362
|
+
if (q && nameLow === q)
|
|
363
|
+
matchType = 'exact';
|
|
364
|
+
else if (q && nameLow.startsWith(q))
|
|
365
|
+
matchType = 'prefix';
|
|
366
|
+
return { ...it, name, matchType, _sort: matchType === 'exact' ? 0 : matchType === 'prefix' ? 1 : 2 };
|
|
367
|
+
})
|
|
368
|
+
.sort((a, b) => a._sort - b._sort)
|
|
369
|
+
.map(({ _sort, ...rest }) => rest);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** 越权拒绝文案(fail-closed 显式归因)。 */
|
|
373
|
+
export function outOfScopeMessage(target, { reason = 'not-subordinate', candidates = [], outCount = 0 } = {}) {
|
|
374
|
+
let cause;
|
|
375
|
+
if (reason === 'no-self')
|
|
376
|
+
cause = '无法确认当前登录用户身份,为安全起见拒绝所有按人查询';
|
|
377
|
+
else if (reason === 'scope-unreliable')
|
|
378
|
+
cause = '下属范围加载失败,为安全起见仅允许查询您本人的数据';
|
|
379
|
+
else if (reason === 'none-in-scope')
|
|
380
|
+
cause = `匹配到 ${outCount} 位人员,均不在您的下属名单中`;
|
|
381
|
+
else if (reason === 'multiple-in-scope')
|
|
382
|
+
cause = '匹配到多位在您授权范围内的人员,请指定唯一目标';
|
|
383
|
+
else
|
|
384
|
+
cause = '目标不在您的下属名单中';
|
|
385
|
+
const lines = [`拒绝越权查询:目标“${target}”不在您的授权范围(您本人 + 您的全部层级下属)内。`, `原因:${cause}。`];
|
|
386
|
+
if (candidates.length) {
|
|
387
|
+
lines.push('在您授权范围内匹配到以下人员:');
|
|
388
|
+
candidates.forEach((c, i) => {
|
|
389
|
+
lines.push(`${i + 1}. ${c.name} [uid=${c.uid}]${c.matchType === 'exact' ? ' ★精确匹配' : c.matchType === 'prefix' ? ' ·前缀' : ''}`);
|
|
390
|
+
});
|
|
391
|
+
lines.push('如需继续,请指定其中一人的 ID 作为目标。');
|
|
392
|
+
}
|
|
393
|
+
lines.push('如需查询其本人数据,请在 OA 中由其本人或对其有权限的上级办理。');
|
|
394
|
+
return lines.join('\n');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ─── 工具 ────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
function parseUserIdFromCookie(cookie) {
|
|
400
|
+
if (!cookie)
|
|
401
|
+
return '';
|
|
402
|
+
const m = String(cookie).match(/loginuuids=([^;]+)/);
|
|
403
|
+
return m ? m[1].trim() : '';
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function pickNum(obj, paths) {
|
|
407
|
+
const v = pickByPaths(obj, paths);
|
|
408
|
+
if (typeof v === 'number')
|
|
409
|
+
return v;
|
|
410
|
+
if (typeof v === 'string' && /^\d+$/.test(v))
|
|
411
|
+
return parseInt(v, 10);
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function truncate(s, n = 300) {
|
|
416
|
+
if (s == null)
|
|
417
|
+
return '(空)';
|
|
418
|
+
return String(s).length > n ? String(s).slice(0, n) + '…(已截断)' : String(s);
|
|
419
|
+
}
|