e10-data 0.1.18-xiaoe → 0.1.21

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/bin/e10-data.mjs CHANGED
@@ -7,7 +7,11 @@
7
7
  * e10-data login xiaoe-env [--domain <url>] **无凭据时**用环境变量 XIAOE_USER_TOKEN + XIAOE_DOMAIN
8
8
  * (XiaoE 全局 token)换 ETEAMSID 并落 profile(免浏览器/免 python;2026-09-17 移植)
9
9
  * e10-data query DSL 查询 executeQueryDsl(conn/model/schema/tableId/dslQuery/debug)
10
- * e10-data ds tables|fields|list|chart|stat|count 数据源(业务模块)取数与元数据
10
+ * e10-data ds groups|find|describe|tables|fields|fields-dsl|list|chart|stat|count
11
+ * 数据源(业务模块)取数与元数据
12
+ * groups 内置分组清单(仅作 find 的默认扫描集)
13
+ * find 跨分组找表(进程内并发扇出;产物=候选表清单 + 路由建议,不取数)
14
+ * describe 一次并发取回「字段 + 词库」(不做权限校验,EB 权限仍走 eb perm-page)
11
15
  * appId 一律由调用方显式传入(v0.1.14 起 CLI 不再自动反查):
12
16
  * EB 建模表单(groupId=weaver-ebuilder-form-service)取数必须带 --app-id <id>,
13
17
  * 该值只能来自 chat_bi_eb_config_map 的权限校验查询(同一次查询给出权限与 appId);
@@ -19,11 +23,16 @@
19
23
  * 环境参数配置中心(chatBi config map,替代 config-resolve 脚本 URL 直连;
20
24
  * confType=api 时支持 confGroup 分组二级定位,见 --group)
21
25
  * e10-data attend statis 考勤假期余额统计 getAttendInfoStatis(hr-attendance 病假/事假,2026-09-07)
26
+ * e10-data module call 模块配置接口调用(配置中心驱动):**已确定「哪个模块的哪条具体接口」之后**的
27
+ * 统一取数入口 —— 精取该条 confAttrs → 按 http_method/input_params 组参 →
28
+ * 发请求(禁代理 + Cookie/eteamsid/UA 三件套)→ 提取精简结果(2026-09-28)
29
+ * 与技能自带 script/call_module.py 等价(参数/退出码/提取全对齐),
30
+ * 复用本 CLI 凭据缓存与 conf 通道、零临时文件
22
31
  *
23
32
  * 通用:--json 原样输出;复杂 JSON 参数支持 @文件 或 内联(中文走 @文件 更稳)
24
- * 认证:加解密全部由 e10-login 负责(本 CLI 不实现任何加解密)。凭据出口优先级:
25
- * ① e10-login auth --json → ② e10-login whoami --json → ③ e10-login whoami(文本解析)
26
- * → ④ 明文镜像 ~/.workbuddy/e10-login-cache.md(兜底,命中时告警);均不可用时提示先 e10-login oidc 登录
33
+ * 认证:凭据**唯一来源 = weaver-work-cli**(本 CLI 不实现任何加解密,**不设 e10-login 回退**):
34
+ * weaver-work-cli auth export-weaver-env —— 解析 WEAVER_BASE_URL / WEAVER_ETEAMSID / WEAVER_COOKIE
35
+ * e10-login 系出口 / 明文镜像已于 2026-09-24 停用;未登录时提示 `weaver-work-cli auth login`
27
36
  * 例外:`login xiaoe-env` 在"还没有任何凭据"时可独立完成登录(换 token 自己算,落 profile 仍交 e10-login)
28
37
  *
29
38
  * 用法示例:
@@ -196,7 +205,9 @@ function normalizeAuth(o) {
196
205
  const cookie = String(o.cookie || "").trim();
197
206
  if (!baseUrl || !eteamsid || !cookie) return null;
198
207
  if (/^\((未知|空)\)$/.test(eteamsid) || /^\((未知|空)\)$/.test(cookie)) return null;
199
- return { baseUrl, eteamsid, cookie, userId: String(o.userId || "").trim(), tenantKey: String(o.tenantKey || "").trim(), source: o.source };
208
+ // userAgent:模块配置通道的请求头需要 `AgentType=<agentType>,IsAgent=true`(见 module 命令族)。
209
+ // 属"非凭据"的请求头取值,缺失时由 moduleUserAgent() 从 cookie 的 agentType= 推导,不影响取数。
210
+ return { baseUrl, eteamsid, cookie, userId: String(o.userId || "").trim(), tenantKey: String(o.tenantKey || "").trim(), userAgent: String(o.userAgent || "").trim(), source: o.source };
200
211
  }
201
212
 
202
213
  /** ① e10-login auth --json(正式契约:{v,baseUrl,eteamsId,cookie,userAgent,userId,tenantKey,agentType,profile}) */
@@ -270,14 +281,152 @@ function providerCacheMd() {
270
281
  });
271
282
  }
272
283
 
284
+ // ---------------- weaver-work-cli 凭据出口(首选来源)----------------
285
+ // 背景:本包要求 e10-data 的凭据**优先取自 weaver-work-cli**(与连接器无关的用户级登录体系)。
286
+ // weaver-work-cli 的登录态存于 ~/.weaver-work-cli/(@napi-rs/keyring 加密),与 e10-login 完全独立、
287
+ // 互不通用;它提供 `auth export-weaver-env` 输出 WEAVER_* 变量,其中
288
+ // WEAVER_BASE_URL / WEAVER_ETEAMSID / WEAVER_COOKIE 正好对应本 CLI 需要的三要素。
289
+ // 安全边界:**只在进程内存中解析** —— 不经 shell、不落盘、不打印、不写入任何日志/报告/错误信息;
290
+ // 凭据随进程结束即释放(后续调用仍走 v0.1.17 的内存 TTL 缓存,不落明文)。
291
+ // 定位策略:与 e10-login 相同的四段式(WEAVER_WORK_CLI_ENTRY → 模块解析 → PATH shim → 常见全局前缀)。
292
+ const WEAVER_PROVIDER_NAME = "weaver-work-cli";
293
+ const WEAVER_CLI_ENTRY_ENV = "WEAVER_WORK_CLI_ENTRY";
294
+
295
+ let _weaverEntry;
296
+ let _weaverEntryDone = false;
297
+
298
+ function resolveWeaverEntry() {
299
+ if (_weaverEntryDone) return _weaverEntry;
300
+ _weaverEntryDone = true;
301
+ const isFile = (p) => { try { return !!p && fs.statSync(p).isFile(); } catch { return false; } };
302
+
303
+ const entryFromPkgDir = (dir) => {
304
+ try {
305
+ const pj = path.join(dir, "package.json");
306
+ if (!isFile(pj)) return null;
307
+ const j = JSON.parse(fs.readFileSync(pj, "utf-8"));
308
+ if (j.name !== "weaver-work-cli") return null;
309
+ let b = j.bin;
310
+ if (typeof b === "string") b = { "weaver-work-cli": b };
311
+ const rel = b && b["weaver-work-cli"];
312
+ if (!rel) return null;
313
+ const entry = path.resolve(dir, rel);
314
+ return isFile(entry) ? entry : null;
315
+ } catch { return null; }
316
+ };
317
+
318
+ const envEntry = (process.env[WEAVER_CLI_ENTRY_ENV] || "").trim();
319
+ if (envEntry && isFile(envEntry)) { _weaverEntry = envEntry; return _weaverEntry; }
320
+
321
+ try {
322
+ const pj = createRequire(import.meta.url).resolve("weaver-work-cli/package.json");
323
+ const e = entryFromPkgDir(path.dirname(pj));
324
+ if (e) { _weaverEntry = e; return _weaverEntry; }
325
+ } catch {}
326
+
327
+ const pathDirs = String(process.env.PATH || "").split(path.delimiter).filter(Boolean);
328
+ const shimNames = process.platform === "win32"
329
+ ? ["weaver-work-cli.cmd", "weaver-work-cli.exe", "weaver-work-cli"]
330
+ : ["weaver-work-cli"];
331
+ for (const d of pathDirs) {
332
+ for (const n of shimNames) {
333
+ if (!isFile(path.join(d, n))) continue;
334
+ const e = entryFromPkgDir(path.join(d, "node_modules", "weaver-work-cli"));
335
+ if (e) { _weaverEntry = e; return _weaverEntry; }
336
+ }
337
+ }
338
+
339
+ const home = os.homedir();
340
+ const prefixes = [
341
+ path.join(path.dirname(process.execPath), "node_modules"),
342
+ path.join(path.dirname(process.execPath), "..", "lib", "node_modules"),
343
+ path.join(home, ".npm-global", "lib", "node_modules"),
344
+ process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules") : null,
345
+ process.env.ProgramFiles ? path.join(process.env.ProgramFiles, "nodejs", "node_modules") : null,
346
+ ].filter(Boolean);
347
+ for (const p of prefixes) {
348
+ const e = entryFromPkgDir(path.join(p, "weaver-work-cli"));
349
+ if (e) { _weaverEntry = e; return _weaverEntry; }
350
+ }
351
+
352
+ _weaverEntry = null;
353
+ return null;
354
+ }
355
+
356
+ /** 调用 weaver-work-cli(失败返回 null,诊断留在 lastLoginDiag 供 loadAuth 汇总) */
357
+ function runWeaverCli(args, timeout = 20000) {
358
+ try {
359
+ const entry = resolveWeaverEntry();
360
+ const r = entry
361
+ ? spawnSync(process.execPath, [entry, ...args], { encoding: "utf-8", timeout, windowsHide: true })
362
+ : spawnSync("weaver-work-cli", args, { shell: true, encoding: "utf-8", timeout, windowsHide: true });
363
+ const out = (r.stdout || "").trim();
364
+ if (r.error || r.status !== 0) {
365
+ lastLoginDiag = ((r.stderr || "") + out).trim().split(/\r?\n/).slice(0, 2).join(" / ") || (r.error ? String(r.error.message) : "exit=" + r.status);
366
+ return null;
367
+ }
368
+ return out || null;
369
+ } catch (e) { lastLoginDiag = String((e && e.message) || e); return null; }
370
+ }
371
+
372
+ /** 去掉 shell 引号包装(兼容 POSIX 单/双引号与 PowerShell 双引号) */
373
+ function unquoteShellValue(raw) {
374
+ const s = String(raw || "").trim();
375
+ if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1).replace(/'\\''/g, "'");
376
+ if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1).replace(/\\(["\\$`])/g, "$1");
377
+ return s;
378
+ }
379
+
380
+ /** 解析 KEY=VALUE 行(POSIX `export K='V'` / PowerShell `$env:K = "V"`) */
381
+ function parseShellExports(text) {
382
+ const env = {};
383
+ for (const line of String(text || "").split(/\r?\n/)) {
384
+ const s = line.trim();
385
+ if (!s) continue;
386
+ let m = s.match(/^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
387
+ if (!m) m = s.match(/^\$env:([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
388
+ if (m) env[m[1]] = unquoteShellValue(m[2]);
389
+ }
390
+ return env;
391
+ }
392
+
393
+ /** ⓪ weaver-work-cli auth export-weaver-env(唯一凭据来源) */
394
+ function providerWeaverWorkCli() {
395
+ const out = runWeaverCli(["auth", "export-weaver-env", "--shell", "posix"]);
396
+ if (!out) return null;
397
+ const env = parseShellExports(out);
398
+ const auth = normalizeAuth({
399
+ baseUrl: env.WEAVER_BASE_URL,
400
+ eteamsid: env.WEAVER_ETEAMSID,
401
+ cookie: env.WEAVER_COOKIE,
402
+ userAgent: env.WEAVER_USER_AGENT,
403
+ source: "weaver-work-cli auth export-weaver-env",
404
+ });
405
+ if (!auth) return null;
406
+ // export-weaver-env 不导出 userId / tenantKey,需另向 auth status 取。
407
+ // 二者均为「非凭据」的身份字段:userId 会被部分命令用作"当前登录用户"默认值(如 attend statis 的 --user),
408
+ // 缺失会导致这些命令报「缺少查询人员」。取不到不影响凭据本身的可用性。
409
+ try {
410
+ const st = runWeaverCli(["--json", "auth", "status", "--no-check"]);
411
+ if (st) {
412
+ const j = JSON.parse(st);
413
+ if (j && j.loggedIn) {
414
+ auth.userId = String(j.userId || "").trim();
415
+ auth.tenantKey = String(j.tenantKey || "").trim();
416
+ }
417
+ }
418
+ } catch {}
419
+ return auth;
420
+ }
421
+
422
+ // ⚠️ 2026-09-24:凭据**唯一来源 = weaver-work-cli**,不再回退 e10-login。
423
+ // providerAuthJson / providerWhoamiJson / providerWhoamiText / providerCacheMd 保留函数定义但不挂载
424
+ // (如需恢复回退,把对应条目加回本数组即可)。
273
425
  const AUTH_PROVIDERS = [
274
- ["auth-json", providerAuthJson],
275
- ["whoami-json", providerWhoamiJson],
276
- ["whoami-text", providerWhoamiText],
277
- ["cache-md", providerCacheMd],
426
+ ["weaver-work-cli", providerWeaverWorkCli],
278
427
  ];
279
- /** 兜底出口不进记忆:否则 e10-login 偶发不可用一次,就会把"用镜像"永久设为首选、次次告警 */
280
- const AUTH_FALLBACK_PROVIDERS = new Set(["cache-md"]);
428
+ /** 预留:登记"命中后不进记忆"的出口(当前为空——唯一出口需要被记忆) */
429
+ const AUTH_FALLBACK_PROVIDERS = new Set();
281
430
 
282
431
  function readAuthModeMemo() {
283
432
  try {
@@ -296,7 +445,13 @@ function loadAuth() {
296
445
  const memo = override || (process.env.E10_DATA_AUTH_PROBE ? null : readAuthModeMemo());
297
446
  const order = memo
298
447
  ? AUTH_PROVIDERS.filter(([n]) => n === memo).concat(AUTH_PROVIDERS.filter(([n]) => n !== memo))
299
- : AUTH_PROVIDERS;
448
+ : AUTH_PROVIDERS.slice();
449
+ // 首选来源固定为 weaver-work-cli:无论 memo 指向谁,都把它提到最前再依次尝试
450
+ // (仅当未用 E10_DATA_AUTH_MODE 显式指定时;它取不到凭据会自动回落后续出口)
451
+ if (!override) {
452
+ const wi = order.findIndex(([n]) => n === WEAVER_PROVIDER_NAME);
453
+ if (wi > 0) order.unshift(order.splice(wi, 1)[0]);
454
+ }
300
455
 
301
456
  const diag = [];
302
457
  for (const [name, fn] of order) {
@@ -317,10 +472,10 @@ function loadAuth() {
317
472
  }
318
473
 
319
474
  throw new Error(
320
- "未取得登录凭据(解密由 e10-login 负责,本 CLI 不参与)。请依次检查:\n" +
321
- " ① e10-login 是否可用:`e10-login --version`(缺失 → `npm i -g e10-login`);`e10-login whoami` 应能输出 cookies\n" +
322
- " ② 是否已登录 / 登录态是否失效:`e10-login oidc` 重新登录(当前 profile: " + profile + ")\n" +
323
- " ③ auth 文件:" + (fs.existsSync(file) ? "存在" : "不存在") + "(" + file + ")—— 其加解密由 e10-login 负责\n" +
475
+ "未取得登录凭据(唯一来源 = weaver-work-cli,**不设回退**)。请按顺序检查:\n" +
476
+ " ① 命令是否可用:`weaver-work-cli --version`(缺失 → 安装 weaver-work-cli)\n" +
477
+ " ② 是否已登录:`weaver-work-cli auth status --no-check`\n" +
478
+ " ③ 未登录 / 登录态失效 → 执行 `weaver-work-cli auth login --base-url <域名>` 重新登录(当前 profile: " + profile + ")\n" +
324
479
  (diag.length ? " 探测诊断:" + diag.join(" | ") + "\n" : "")
325
480
  );
326
481
  }
@@ -400,7 +555,7 @@ function credCachePublish(auth) {
400
555
  profile: auth.profile || "default",
401
556
  auth: {
402
557
  baseUrl: auth.baseUrl, eteamsid: auth.eteamsid, cookie: auth.cookie,
403
- userId: auth.userId, tenantKey: auth.tenantKey, source: auth.source,
558
+ userId: auth.userId, tenantKey: auth.tenantKey, userAgent: auth.userAgent, source: auth.source,
404
559
  },
405
560
  });
406
561
  let settled = false;
@@ -544,7 +699,7 @@ async function cmdLoginXiaoeEnv(args, json) {
544
699
  // 为什么需要:loadAuth() 写在模块顶层,过去"未知命令"也要先付一整笔取凭据的开销
545
700
  // (Windows 上 ~1.0s,去 shell 后 ~0.22s)才打印用法。v0.1.17 起提前判定。
546
701
  const CMD_SUBS = {
547
- ds: ["tables", "fields", "fields-dsl", "list", "chart", "stat", "count"],
702
+ ds: ["groups", "find", "describe", "tables", "fields", "fields-dsl", "list", "chart", "stat", "count"],
548
703
  doc: ["count", "list"],
549
704
  noun: ["list"],
550
705
  field: ["id2name", "name2id"],
@@ -552,6 +707,7 @@ const CMD_SUBS = {
552
707
  exp: ["status", "manifest", "commits", "file", "pull", "init", "commit", "lock", "release", "rollback"],
553
708
  attend: ["statis"],
554
709
  eb: ["perm-page"],
710
+ module: ["call"],
555
711
  };
556
712
  function commandIsKnown(pos) {
557
713
  const family = pos[0], sub = pos[1];
@@ -608,9 +764,7 @@ const USER = { id: auth.userId || "", tenantKey: auth.tenantKey || "" };
608
764
  function checkAuthFail(resp, text, j) {
609
765
  if (resp.status === 401 || (j && String(j.code) === "-1" && /token|登录|失效/i.test(String(j.msg || "")))) {
610
766
  const from = (auth && auth.source) || "未知";
611
- const hint = /镜像/.test(from)
612
- ? "当前用的是明文镜像兜底凭据(可能已过期),请先确认 `e10-login whoami` 能正常导出 cookies,或执行 `e10-login oidc` 重新登录"
613
- : "凭据由 e10-login 提供,请执行 `e10-login oidc` 重新登录";
767
+ const hint = "凭据由 weaver-work-cli 提供,请执行 `weaver-work-cli auth login --base-url <域名>` 重新登录";
614
768
  throw new Error("登录态失效(" + (j.msg || resp.status) + "):凭据来源=" + from + ";" + hint);
615
769
  }
616
770
  }
@@ -865,6 +1019,15 @@ function parseOrder(val) {
865
1019
  function dsFieldsForm(gid, tid, st) {
866
1020
  return { groupId: gid, sourceId: tid, sourceType: st, detailFieldsGroup: "true", enableMerge: "true", needTableName: "true", params: "[]" };
867
1021
  }
1022
+ // ---- ds listByPage 端点(唯一):/api/dw/newetl/chatBi/ds/listByPage ----
1023
+ // ⚠️ 该端点**当前会忽略 name**(传 name/tableName/keyword/title 等任意字段名,都返回该分组全量,实测 8531 条)。
1024
+ // 后端后续会升级支持 name 过滤(用户 2026-09-24 确认)——升级后 `ds find` / `ds tables --name` **无需改代码**
1025
+ // 即自动恢复服务端过滤能力(本封装与分页逻辑已按「total 为过滤后总数」的语义写好)。
1026
+ // 在此之前,`ds find` 走「分页抓取 + 本地过滤」,未命中只代表「未在已扫描窗口内命中」。
1027
+ const DS_LB_PATH = "/api/dw/newetl/chatBi/ds/listByPage";
1028
+ async function dsListByPage(form) {
1029
+ return postMultipart(DS_LB_PATH, form);
1030
+ }
868
1031
  // ds 取数统一入口:appId 只认显式 --app-id <id>
869
1032
  // (--app-id auto / --no-app-id 一律视为未传:v0.1.14 起不再自动解析,也不做缺失拦截)
870
1033
  async function dsDto(args, tid, gid, pageNo, pageSize, queryType) {
@@ -877,7 +1040,7 @@ async function cmdTables(args, json) {
877
1040
  const gid = need("group", args);
878
1041
  const form = { name: args.name || "", pageNo: args.page || "1", pageSize: args.size || "100", groupId: gid, sourceType: args["source-type"] || "LOGIC", dataUsage: "" };
879
1042
  if (typeof args["app-id"] === "string" && args["app-id"] !== "" && args["app-id"] !== "auto") form.appId = args["app-id"];
880
- const r = await postMultipart("/api/dw/newetl/chatBi/ds/listByPage", form);
1043
+ const r = await dsListByPage(form);
881
1044
  if (json) return r;
882
1045
  const recs = ((r.data || {}).records) || [];
883
1046
  printHuman("分组表清单 (total=" + ((r.data || {}).total) + ")", recs, ["id", "name", "appId", "tableText", "detailTable", "usage"]);
@@ -950,6 +1113,433 @@ async function cmdCount(args, json) {
950
1113
  console.log("totalCount = " + (data.totalCount ?? "-"));
951
1114
  }
952
1115
 
1116
+ // ================= ds 发现层聚合:groups / find / describe(2026-09-24 新增)=================
1117
+ // 背景:一次「表单业务词」问数原本要串行跑 5 跳(定分组 → 找表 → 取字段 → 查词库 → 查权限),
1118
+ // 每跳都是一次独立 CLI 调用。实测单次调用固定开销 ~5.4s,其中「凭据现取」~4.8s 且
1119
+ // **与请求条数/返回体量无关**(取 48 条 vs 取 1 条:5.5s vs 5.8s)⇒ 5 跳 ≈ 27s;
1120
+ // 若逐进程扫 58 个分组,串行累计约 313s。
1121
+ // 故把「找表」「字段+词库」各收敛成 1 次 CLI 调用,**在进程内并发发 HTTP**(一次凭据搞定)。
1122
+ // 实测同样的全分组扇出:进程内并发 ≈5.3~5.9s(逐进程 47.5s / 逐进程串行 ~313s)。
1123
+ //
1124
+ // ⚠️ 权限闸门不在本命令范围:`ds/listByPage` 虽然也回带 appId,但**数据源通道不拦权限**
1125
+ // (实测:未授权表在数据源上照样能取到数据)。拿 listByPage 的 appId 跳过校验 = 闸门静默失效。
1126
+ // ⇒ EB 建模表单取数前**必须单独**执行 `e10-data eb perm-page`,appId 只从那次调用取。
1127
+
1128
+ // 分组清单(技能文档 data-source-query.md 1.1 的 58 个)。**仅作 ds find 的「默认扫描候选集」**,
1129
+ // 不参与任何取数参数推导。分组随环境模块安装情况增减,故支持 --groups 显式覆盖。
1130
+ const DS_GROUPS = [
1131
+ [1, "业务表单", "weaver-formreport-service", "form"],
1132
+ [2, "流程", "weaver-workflow-report-serviceworkflowreport", "workflow"],
1133
+ [3, "任务", "weaver-project-servicetask", "task"],
1134
+ [4, "日志", "weaver-elog-service", "elog"],
1135
+ [5, "会议", "weaver-meeting-service", "meeting"],
1136
+ [6, "日报", "weaver-blog-service", "blog"],
1137
+ [7, "OKR", "weaver-wr-goal-service", "goal"],
1138
+ [8, "流程列表", "weaver-workflow-list-service", "workflow"],
1139
+ [9, "考勤", "weaver-attend-service", "attend"],
1140
+ [10, "CRM", "weaver-crm-service", "customer"],
1141
+ [11, "组织画像", "weaver-portrait-service", "portrait"],
1142
+ [12, "工作流报告", "weaver-workflow-report-serviceworkflow_report", "workflow"],
1143
+ [13, "绩效", "weaver-wr-performance-service", "kpi"],
1144
+ [14, "项目", "weaver-project-service", "mainline"],
1145
+ [15, "基础定时模块", "weaver-basic-schedule-service", "escheduler"],
1146
+ [16, "知识画像", "weaver-doc-service", "document"],
1147
+ [17, "HRM", "weaver-hrm-service", "hrm"],
1148
+ [18, "e-builder报表", "weaver-edcreportd-service", null],
1149
+ [19, "e-builder应用", "weaver-ebuilder-app-service", "ebuilder"],
1150
+ [20, "e-builder表单", "weaver-ebuilder-form-service", "ebuilder"],
1151
+ [21, "日程", "weaver-calendar-service", "calendar"],
1152
+ [22, "文档", "weaver-doc-servicedocument", "document"],
1153
+ [23, "文件", "weaver-file-service", "file"],
1154
+ [24, "公文管理", "weaver-odoc-service", "odoc"],
1155
+ [25, "SCRM", "weaver-crm-market-service", "scrm"],
1156
+ [26, "协作区", "weaver-cowork-service", "cowork"],
1157
+ [27, "数据源", "weaver-datasource-service", "datasource"],
1158
+ [28, "计划报告", "weaver-wr-plan-service", "workreport"],
1159
+ [29, "电子签", "weaver-signcenter-service", "signcontract"],
1160
+ [30, "表单加密", "weaver-datasecurity", "datasecurity"],
1161
+ [31, "人事", "weaver-hr-service", null],
1162
+ [32, "动作流", "weaver-esb-setting-serviceesbCustom", "esb"],
1163
+ [33, "发票云", "weaver-inc-biz-service", "inc"],
1164
+ [34, "绩效核算", "weaver-ebuilder-contract-servicecmdatauFJXHS", "ebuilder"],
1165
+ [35, "EB合同管理", "weaver-ebuilder-contract-service", null],
1166
+ [36, "待办事项", "weaver-my-service", null],
1167
+ [37, "签名", "weaver-signature-service", null],
1168
+ [38, "邮件", "weaver-mail-base-service", "email"],
1169
+ [39, "薪酬", "weaver-hrm-salary", "hrmsalary"],
1170
+ [40, "系统安全", "weaver-security-framework-service", "security"],
1171
+ [41, "公文交换中心", "weaver-odocexchange-service", "odocexchange"],
1172
+ [42, "招聘管理", "weaver-recruit-service", "recruit"],
1173
+ [43, "微搜", "weaver-esearch-search-service", null],
1174
+ [44, "统一审批中心", "weaver-intunifytodo-server-config-service", "intunifytodos"],
1175
+ [45, "公共数据源", "weaver-component-web-service", "common"],
1176
+ [46, "数据分析", "weaver-analyze-service", "analyze"],
1177
+ [47, "财务模块", "weaver-fna-expense-servicefexs", "fna"],
1178
+ [48, "ESB连接器", "weaver-esb-setting-serviceesbConnect", "esb"],
1179
+ [49, "AI连接器", "weaver-ai-application-service", "ai"],
1180
+ [50, "外部流程", "weaver-workflow-core-serviceworkflowCoreOuterwf", "workflow"],
1181
+ [51, "绩效考核", "weaver-wr-performance-eb-service", "ebuilder"],
1182
+ [52, "问题定位平台", "weaver-tissot-service", "tissot"],
1183
+ [53, "工作流基础效率", "weaver-workflow-report-serviceworkflowEffReport", "workflow"],
1184
+ [54, "工作流操作者效率", "weaver-workflow-report-serviceworkflowEffOperateReport", "workflow"],
1185
+ [55, "工作流节点效率", "weaver-workflow-report-serviceworkflowEffNodeReport", "workflow"],
1186
+ [56, "规则引擎", "weaver-logicflow-service", "logicflow"],
1187
+ [57, "工作流表单", "weaver-workflow-report-serviceworkflowFormReport", "workflow"],
1188
+ [58, "统一认证中心", "weaver-intunifyauth-server-base-service", null],
1189
+ ];
1190
+
1191
+ // 必探的核心分组(技能文档「分组探查强制范围」:e-builder 表单 + 业务表单 必探;流程类业务词也常落在流程分组)
1192
+ const DS_CORE_GROUPS = [
1193
+ "weaver-formreport-service",
1194
+ "weaver-ebuilder-form-service",
1195
+ "weaver-workflow-report-serviceworkflowreport",
1196
+ ];
1197
+ // 语义命中规则:只是「关键词 → 分组名片段」的启发式,命中即并入扫描集(不做任何取数参数推导)
1198
+ const DS_FIND_SEMANTIC_RULES = [
1199
+ { tag: "考勤/假期", re: /考勤|打卡|出勤|请假|假期|年假|事假|病假|加班|外勤|工时|调休|补卡|申诉/, groups: ["weaver-attend-service", "weaver-hr-service"] },
1200
+ { tag: "流程/审批", re: /流程|审批|待办|已办|发起|抄送|传阅|征询|签字|节点|报销|申请单/, groups: ["weaver-workflow-report-serviceworkflowreport", "weaver-workflow-list-service"] },
1201
+ { tag: "日报/周报", re: /日报|周报|月报|年报|工作总结/, groups: ["weaver-blog-service", "weaver-wr-plan-service"] },
1202
+ { tag: "文档/附件", re: /文档|附件|文件夹|共享|资料/, groups: ["weaver-doc-servicedocument", "weaver-doc-service", "weaver-file-service"] },
1203
+ { tag: "项目/任务", re: /项目|任务|工时|里程碑|延期|验收/, groups: ["weaver-project-service", "weaver-project-servicetask"] },
1204
+ { tag: "客户/商机", re: /客户|商机|线索|联系人|公海|销售|CRM|营销/i, groups: ["weaver-crm-service", "weaver-crm-market-service"] },
1205
+ { tag: "会议/日程", re: /会议|日程|日历|预约|会议室|预定/, groups: ["weaver-meeting-service", "weaver-calendar-service"] },
1206
+ { tag: "人事/招聘", re: /招聘|入职|离职|offer|人才|员工|人员|通讯录|调岗|组织/i, groups: ["weaver-recruit-service", "weaver-hr-service", "weaver-hrm-service"] },
1207
+ { tag: "薪酬/绩效", re: /薪酬|薪资|工资|社保|公积金|绩效|考核|KPI|OKR/i, groups: ["weaver-hrm-salary", "weaver-wr-performance-service", "weaver-wr-goal-service"] },
1208
+ { tag: "合同/收款", re: /合同|签约|收款|回款|开票|发票|应收/, groups: ["weaver-ebuilder-contract-service", "weaver-ebuilder-contract-servicecmdatauFJXHS", "weaver-inc-biz-service"] },
1209
+ { tag: "邮件/公文", re: /邮件|收件箱|公文|发文|档案|借阅/, groups: ["weaver-mail-base-service", "weaver-odoc-service"] },
1210
+ { tag: "资产/日志", re: /资产|台账|日志|动作流|自动化/, groups: ["weaver-elog-service", "weaver-esb-setting-serviceesbCustom"] },
1211
+ ];
1212
+
1213
+ function dsGroupLabel(gid) { const g = DS_GROUPS.find((x) => x[2] === gid); return g ? g[1] : null; }
1214
+ function dsGroupMark(gid) { const g = DS_GROUPS.find((x) => x[2] === gid); return g ? g[3] : null; }
1215
+
1216
+ /** 进程内并发闸门:一次凭据、N 个 HTTP 并发(把「N 次 CLI 调用」压成 1 次调用的核心) */
1217
+ async function mapLimit(items, limit, fn) {
1218
+ const out = new Array(items.length);
1219
+ let next = 0;
1220
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
1221
+ for (;;) {
1222
+ const i = next++;
1223
+ if (i >= items.length) return;
1224
+ out[i] = await fn(items[i], i);
1225
+ }
1226
+ });
1227
+ await Promise.all(workers);
1228
+ return out;
1229
+ }
1230
+
1231
+ async function cmdDsGroups(args, json) {
1232
+ const pat = args.pattern ? String(args.pattern) : null;
1233
+ const groups = DS_GROUPS
1234
+ .filter((g) => !pat || String(g[2]).includes(pat) || String(g[1]).includes(pat))
1235
+ .map((g) => ({ groupId: g[2], label: g[1], serviceMark: g[3] }));
1236
+ if (json) {
1237
+ return {
1238
+ count: groups.length,
1239
+ groups,
1240
+ coreGroups: DS_CORE_GROUPS,
1241
+ semanticRules: DS_FIND_SEMANTIC_RULES.map((r) => ({ tag: r.tag, groups: r.groups })),
1242
+ note: "内置分组清单,仅用于 ds find 的默认扫描候选集;分组随环境变动时用 --groups 显式覆盖",
1243
+ };
1244
+ }
1245
+ printHuman("内置分组清单 " + groups.length + " 个", groups, ["groupId", "label", "serviceMark"]);
1246
+ }
1247
+
1248
+ /** 选扫描集:--groups 显式 > --all-groups 全量 > 核心分组 + 语义命中 */
1249
+ function pickFindGroups(args, keyword) {
1250
+ if (args.groups) {
1251
+ return { mode: "explicit", groups: String(args.groups).split(",").map((s) => s.trim()).filter(Boolean) };
1252
+ }
1253
+ if (args["all-groups"]) return { mode: "all", groups: DS_GROUPS.map((g) => g[2]) };
1254
+ const max = Math.max(parseInt(args["max-groups"] || "6", 10) || 6, DS_CORE_GROUPS.length);
1255
+ const picked = [];
1256
+ const push = (gid) => { if (gid && !picked.includes(gid)) picked.push(gid); };
1257
+ for (const gid of DS_CORE_GROUPS) push(gid);
1258
+ for (const r of DS_FIND_SEMANTIC_RULES) if (r.re.test(keyword)) for (const gid of r.groups) push(gid);
1259
+ return { mode: "core", groups: picked.slice(0, max) };
1260
+ }
1261
+
1262
+ /** 进程内并发扫分组(每分组 1~N 页 listByPage)。
1263
+ * ⚠️ 端点当前忽略 name ⇒ 抓回来的是「全量窗口」,关键词过滤由调用方在本地做;
1264
+ * 窗口大小 = pageSize × pages,**未命中只代表未落在窗口内**(后端支持 name 后即为精确结果)。
1265
+ * ⚠️ appId:EB 分组(weaver-ebuilder-form-service)**按应用分域**,需要时透传 */
1266
+ async function dsScanGroups(gidList, keyword, sourceType, pageSize, concurrency, appId, pages) {
1267
+ return mapLimit(gidList, concurrency, async (gid) => {
1268
+ const out = { groupId: gid, label: dsGroupLabel(gid), serviceMark: dsGroupMark(gid), records: [], error: null, total: null, pagesFetched: 0 };
1269
+ try {
1270
+ for (let p = 1; p <= pages; p++) {
1271
+ const form = { name: keyword, pageNo: String(p), pageSize: String(pageSize), groupId: gid, sourceType, dataUsage: "" };
1272
+ if (appId) form.appId = String(appId);
1273
+ const r = await dsListByPage(form);
1274
+ const recs = ((r.data || {}).records) || [];
1275
+ if (out.total === null) out.total = (r.data || {}).total ?? null; // 后端过滤生效时 = 过滤后总数
1276
+ out.records = out.records.concat(recs);
1277
+ out.pagesFetched++;
1278
+ if (recs.length < pageSize) break; // 已到末页
1279
+ }
1280
+ } catch (e) { out.error = String((e && e.message) || e); }
1281
+ return out;
1282
+ });
1283
+ }
1284
+
1285
+ // 同一实体(如一个流程)会在多个「报表/效率」分组里重复出现 → 归并去重,并保留落选分组做溯源
1286
+ function dsGroupPriority(gid) {
1287
+ if (gid === "weaver-ebuilder-form-service") return 100;
1288
+ if (gid === "weaver-formreport-service") return 95;
1289
+ if (gid === "weaver-workflow-report-serviceworkflowreport") return 90;
1290
+ if (/workflowEff|workflowFormReport|workflow_report$/i.test(gid)) return 20;
1291
+ return 50;
1292
+ }
1293
+ function mergeDsCandidates(scanResults, limit) {
1294
+ const map = new Map();
1295
+ for (const r of scanResults) {
1296
+ for (const rec of r.records) {
1297
+ const gid = rec.groupId || r.groupId;
1298
+ const name = String(rec.originalName || rec.tableText || rec.name || "").trim();
1299
+ const key = name.replace(/\s+/g, "").toLowerCase() || gid + "|" + rec.id;
1300
+ const item = {
1301
+ groupId: gid, groupLabel: r.label, serviceMark: rec.serviceMark ?? r.serviceMark ?? null,
1302
+ tableId: String(rec.id ?? ""), name: String(rec.tableText || rec.name || name), originalName: name,
1303
+ objType: rec.objType ?? null, physicalTable: rec.physicalTable ?? null, appId: rec.appId ?? null,
1304
+ isPhysical: rec.isPhysical ?? null, detailTable: !!rec.detailTable, usage: rec.usage ?? null,
1305
+ sourceType: rec.sourceType ?? null, alsoInGroups: [], _pri: dsGroupPriority(gid),
1306
+ };
1307
+ const prev = map.get(key);
1308
+ if (!prev) { map.set(key, item); continue; }
1309
+ const win = prev._pri >= item._pri ? prev : item;
1310
+ const lose = prev._pri >= item._pri ? item : prev;
1311
+ win.alsoInGroups = win.alsoInGroups.concat(
1312
+ [{ groupId: lose.groupId, groupLabel: lose.groupLabel, tableId: lose.tableId, serviceMark: lose.serviceMark }],
1313
+ lose.alsoInGroups || [],
1314
+ );
1315
+ map.set(key, win);
1316
+ }
1317
+ }
1318
+ const list = [...map.values()].sort((a, b) => b._pri - a._pri || String(a.originalName).localeCompare(String(b.originalName)));
1319
+ const n = parseInt(limit || "0", 10) || 0;
1320
+ return (n > 0 ? list.slice(0, n) : list).map((x) => { const { _pri, ...rest } = x; return rest; });
1321
+ }
1322
+ // 路由建议:命中所在的分组分布本身就是通道信号(比“先定通道再找表”更先验)
1323
+ function dsRouteHint(cands) {
1324
+ const has = (fn) => cands.some(fn);
1325
+ if (has((c) => /workflow/i.test(c.groupId) || c.serviceMark === "workflow")) {
1326
+ return { channel: "WORKFLOW", desc: "WORKFLOW 模块配置通道 —— 流程类问题一律走模块接口,不用 DSL,也不走数据源", reason: "命中流程类分组" };
1327
+ }
1328
+ if (has((c) => c.groupId === "weaver-ebuilder-form-service" || c.serviceMark === "ebuilder")) {
1329
+ return { channel: "DS_EB", desc: "数据源通道(EB 建模表单)—— 取数前必须先过 eb perm-page 权限校验,appId 取自那次校验", reason: "命中 EB 分组" };
1330
+ }
1331
+ if (has((c) => c.serviceMark === "form")) {
1332
+ return { channel: "DS_FORM", desc: "数据源通道(业务表单 / 流程业务表单)", reason: "命中业务表单分组" };
1333
+ }
1334
+ if (has((c) => c.serviceMark === "task" || c.serviceMark === "mainline")) return { channel: "PROJECT/PMO", desc: "项目类:PMO 指标走 business/project-mgmt + DSL;明细/下拉选项走 PROJECT 模块", reason: "命中项目分组" };
1335
+ if (has((c) => c.serviceMark === "attend")) return { channel: "EHR", desc: "EHR 模块配置通道(考勤/人事)", reason: "命中考勤分组" };
1336
+ if (has((c) => c.serviceMark === "blog")) return { channel: "BLOG_DAILY", desc: "BLOG_DAILY 模块配置通道(日报)", reason: "命中日报分组" };
1337
+ if (has((c) => c.serviceMark === "document")) return { channel: "DOCUMENT", desc: "DOCUMENT 模块配置通道(文档)", reason: "命中文档分组" };
1338
+ if (has((c) => c.serviceMark === "customer" || c.serviceMark === "scrm")) return { channel: "CRM", desc: "CRM 模块配置通道(客户/商机/线索)", reason: "命中 CRM 分组" };
1339
+ if (has((c) => c.serviceMark === "esb")) return { channel: "ESB", desc: "动作流(ESB)—— 无标准问数通道,需人工确认", reason: "仅命中动作流分组" };
1340
+ return { channel: "DS", desc: "数据源通道(ds list / chart / stat / count)", reason: "通用" };
1341
+ }
1342
+
1343
+ async function cmdDsFind(args, json) {
1344
+ const keyword = String(args.name || args.keyword || "").trim();
1345
+ if (!keyword) throw new Error("缺少必填参数 --name <关键词>");
1346
+ const sourceType = args["source-type"] || "LOGIC";
1347
+ // 端点忽略 name ⇒ 抓取窗口 = --size × --pages(后端支持 name 后,total 即为过滤后总数、pages 语义不变)
1348
+ const pageSize = Math.min(Math.max(parseInt(args.size || "100", 10) || 100, 1), 2000);
1349
+ const pages = Math.min(Math.max(parseInt(args.pages || "1", 10) || 1, 1), 50);
1350
+ const concurrency = Math.max(1, parseInt(args.concurrency || "10", 10) || 10);
1351
+ const appId = (typeof args["app-id"] === "string" && args["app-id"] !== "" && args["app-id"] !== "auto") ? args["app-id"] : null;
1352
+ const picked = pickFindGroups(args, keyword);
1353
+
1354
+ const t0 = Date.now();
1355
+ let scan = await dsScanGroups(picked.groups, keyword, sourceType, pageSize, concurrency, appId, pages);
1356
+ // 后端 name 搜索是宽松的:实测部分分组会忽略 name 直接返回全量、或按描述命中 ⇒ 本地再按「表名包含关键词」过一遍。
1357
+ // ⚠️ 端点忽略 name ⇒ 关键词过滤当前**只能在本地做**(见文件头 DS_LB_PATH 注释)。
1358
+ // 落选项**不静默丢弃**,计数与样例放在 nameFilter 里;--loose 可完全关闭该过滤(保守用法)。
1359
+ const kwNorm = keyword.replace(/\s+/g, "").toLowerCase();
1360
+ const looseSample = [];
1361
+ let looseDropped = 0;
1362
+ let rowsScanned = 0;
1363
+ const applyStrict = (rows) => rows.map((r) => {
1364
+ rowsScanned += r.records.length;
1365
+ if (args.loose) return r;
1366
+ const keep = [], drop = [];
1367
+ for (const rec of r.records) {
1368
+ const nm = String(rec.originalName || rec.tableText || rec.name || "").replace(/\s+/g, "").toLowerCase();
1369
+ (nm.includes(kwNorm) ? keep : drop).push(rec);
1370
+ }
1371
+ looseDropped += drop.length;
1372
+ for (const d of drop.slice(0, 3)) {
1373
+ if (looseSample.length < 6) looseSample.push({ groupId: r.groupId, name: String(d.originalName || d.tableText || d.name || "") });
1374
+ }
1375
+ return { ...r, records: keep };
1376
+ });
1377
+ scan = applyStrict(scan);
1378
+ let escalated = false;
1379
+ // 默认小集合全 miss → 自动升级为全分组扫一次(语义规则没覆盖到时兜底;全扫也是进程内并发)
1380
+ // ⚠️ 端点忽略 name 时「miss」不代表表不存在(只是没落进窗口)⇒ 窗口较大时不再自动全扫,避免 58 组 × N 页的请求风暴
1381
+ const escalateOk = pages * pageSize <= 2000;
1382
+ if (!scan.some((r) => r.records.length) && picked.mode === "core" && !args["no-escalate"] && escalateOk) {
1383
+ escalated = true;
1384
+ const seen = new Set(picked.groups);
1385
+ const extra = DS_GROUPS.map((g) => g[2]).filter((gid) => !seen.has(gid));
1386
+ scan = scan.concat(applyStrict(await dsScanGroups(extra, keyword, sourceType, pageSize, concurrency, appId, pages)));
1387
+ }
1388
+ const elapsedMs = Date.now() - t0;
1389
+
1390
+ const okScan = scan.filter((r) => !r.error);
1391
+ const errs = scan.filter((r) => r.error);
1392
+ // 全部失败且像登录态问题 → 直接抛(不要把 401 伪装成“没找到表”)
1393
+ if (!okScan.length && errs.length && errs.some((e) => /登录态失效|401/.test(e.error))) {
1394
+ throw new Error("登录态失效:请重新登录 weaver-work-cli(`weaver-work-cli auth login --base-url <域名>`)后重试(本次未取到任何分组)");
1395
+ }
1396
+ const candidates = mergeDsCandidates(scan, args.limit);
1397
+ const out = {
1398
+ keyword, sourceType, scanMode: picked.mode + (escalated ? "+escalated" : ""),
1399
+ scannedGroups: scan.length, hitGroups: scan.filter((r) => r.records.length).length,
1400
+ escalated, elapsedMs, concurrency,
1401
+ candidates, routeHint: dsRouteHint(candidates),
1402
+ // 端点忽略 name:必须让调用方知道「未命中 ≠ 表不存在」
1403
+ nameFilter: args.loose
1404
+ ? { serverSide: false, filtered: false, pages, pageSize, rowsScanned, note: "--loose:未做本地过滤,直接采用后端返回" }
1405
+ : {
1406
+ serverSide: false, filtered: true, pages, pageSize, rowsScanned, dropped: looseDropped, sample: looseSample,
1407
+ note: "端点 /api/dw/newetl/chatBi/ds/listByPage **当前忽略 name**(后端待升级)⇒ 本次为「抓 " + pages + " 页 × " + pageSize + " 行 + 本地按表名过滤」,共比对 " + rowsScanned + " 行;"
1408
+ + "**未命中只代表未落在该窗口内,不等于该表不存在**(若该分组行数 > " + (pages * pageSize) + " 请调大 --size/--pages)。后端支持 name 后自动恢复精确过滤,无需改调用方式。",
1409
+ },
1410
+ scopeNote: scan.some((r) => r.groupId === "weaver-ebuilder-form-service")
1411
+ ? (appId
1412
+ ? "本次已带 --app-id=" + appId + "(EB 分组按应用分域,用于限定/对齐应用范围)"
1413
+ : "⚠️ 本次未带 --app-id:EB 分组(weaver-ebuilder-form-service)按应用分域,如需限定某个应用请带 --app-id <该应用ID>")
1414
+ : null,
1415
+ errors: errs.map((e) => ({ groupId: e.groupId, error: e.error })),
1416
+ };
1417
+ if (json) return out;
1418
+
1419
+ console.log("== 找表「" + keyword + "」扫描 " + out.scannedGroups + " 组 / 抓取 " + rowsScanned + " 行 / 命中 " + out.hitGroups + " 组 (" + elapsedMs + "ms" + (escalated ? ",已升级全扫" : "") + ") ==");
1420
+ if (!candidates.length) {
1421
+ console.log("(未命中) ⚠️ 端点当前忽略 name,本次是「抓 " + pages + " 页 × " + pageSize + " 行 + 本地过滤」比对 " + rowsScanned + " 行;");
1422
+ console.log(" **未命中 ≠ 该表不存在** —— 该分组行数若超过 " + (pages * pageSize) + ",请调大 --size(≤2000)/--pages(≤50)再试;");
1423
+ console.log(" 也可显式指定 --groups a,b / --all-groups;分组清单见 ds groups。");
1424
+ } else {
1425
+ for (const c of candidates) {
1426
+ const extra = c.alsoInGroups && c.alsoInGroups.length ? " [同实体亦见于: " + c.alsoInGroups.map((x) => x.groupId).join(", ") + "]" : "";
1427
+ console.log([c.groupId, c.tableId, c.originalName || c.name, c.physicalTable ? "物理表=" + c.physicalTable : "", c.appId ? "appId=" + c.appId : ""].filter(Boolean).join("\t") + extra);
1428
+ }
1429
+ }
1430
+ console.log("路由建议: " + out.routeHint.channel + " —— " + out.routeHint.desc);
1431
+ if (out.scopeNote) console.log("范围提示: " + out.scopeNote);
1432
+ if (errs.length) console.log("⚠ 有 " + errs.length + " 个分组请求失败(--json 看详情)");
1433
+ }
1434
+
1435
+ // describe:一次并发取回「字段 + 词库」(两者入参同为 (groupId, tableId),彼此无依赖)
1436
+ // ⚠️ 本命令**不做权限校验** —— EB 建模表单取数前的权限闸门仍必须**单独**执行 `e10-data eb perm-page`
1437
+ // (appId 只从那次调用取)。原因:数据源通道本身不拦权限,权限判定与取数必须分开。
1438
+ function collectFieldNodes(list, out, subName) {
1439
+ for (const f of list || []) {
1440
+ if (!f) continue;
1441
+ if (f.isDetailtable) {
1442
+ const node = { subTableName: f.name, fields: [] };
1443
+ collectFieldNodes(f.fields, node.fields, f.name);
1444
+ out.push({ __detail: true, ...node });
1445
+ } else {
1446
+ out.push({
1447
+ name: f.name, text: f.text ?? null, type: f.type ?? null, dbFieldType: f.dbFieldType ?? null,
1448
+ isPK: !!f.mainPrimaryKey, subTable: subName || null,
1449
+ dataKey: (f.config && f.config.dataKey) || null,
1450
+ formTableId: (f.config && f.config.formTableId) || null,
1451
+ });
1452
+ }
1453
+ }
1454
+ }
1455
+ function normalizeFieldsResp(r) {
1456
+ const node = (r.data || [])[0] || {};
1457
+ const flat = [];
1458
+ collectFieldNodes(node.fields, flat, null);
1459
+ const main = flat.filter((x) => !x.__detail);
1460
+ const detail = flat.filter((x) => x.__detail).map((x) => ({ subTableName: x.subTableName, fieldCount: x.fields.length, fields: x.fields }));
1461
+ return {
1462
+ tableName: node.name ?? null, tableId: node.id ?? null,
1463
+ mainCount: main.length, detailCount: detail.reduce((a, b) => a + b.fieldCount, 0), main, detail,
1464
+ };
1465
+ }
1466
+ // 关联字段提取「表来源判定」的预判:groupId/serviceMark 已隐含表来源大类,不必再花 1~6 次 DSL 去反推
1467
+ function dsRelatedHint(gid, detailCount) {
1468
+ if (gid === "weaver-ebuilder-form-service") {
1469
+ return { likelyApplicable: true, tableOrigin: "eb", note: "EB 表:按 ebdf_obj ⋈ form_table ⋈ form 三表 JOIN 定位,再用 form_field / form_table_field 提关联字段;明细子表走 sub_form(本表明细字段 " + detailCount + " 个)" };
1470
+ }
1471
+ if (/^weaver-workflow/.test(gid)) return { likelyApplicable: true, tableOrigin: "flow", note: "流程相关表:按 ec_weaver_wfp_fusion 的 form_field / form_table_field 提关联字段" };
1472
+ if (gid === "weaver-formreport-service") {
1473
+ return { likelyApplicable: false, tableOrigin: "form-report", note: "业务表单(formreport):表名是中文显示名、非物理表名 ⇒ relate-fields-extract 的 ①ETL ②SQL视图 ③④EB ⑤⑥流程 六类判定全部不适用,该跳必然空转,**不要发起**" };
1474
+ }
1475
+ return { likelyApplicable: false, tableOrigin: "unknown", note: "非 EB / 非流程表,关联字段提取通常不适用;如确需先按表名形态(sv_ / uf_ / 系统表名)人工确认" };
1476
+ }
1477
+
1478
+ async function cmdDsDescribe(args, json) {
1479
+ const gid = need("group", args);
1480
+ const tid = String(need("table", args));
1481
+ const sourceType = args["source-type"] || "LOGIC";
1482
+ const t0 = Date.now();
1483
+
1484
+ const tasks = [
1485
+ (async () => {
1486
+ try {
1487
+ const form = dsFieldsForm(gid, tid, sourceType);
1488
+ if (typeof args["app-id"] === "string" && args["app-id"] !== "" && args["app-id"] !== "auto") form.appId = args["app-id"];
1489
+ return { key: "fields", value: normalizeFieldsResp(await postMultipart("/api/ebuilder/common/ds/fields", form)) };
1490
+ } catch (e) { return { key: "fields", error: String((e && e.message) || e) }; }
1491
+ })(),
1492
+ (async () => {
1493
+ const skillKey = args.skill || "data-source-query";
1494
+ try {
1495
+ const q = new URLSearchParams({ skillKey, sourceType, sourceGroupId: gid, sourceTableId: tid });
1496
+ const r = await getUrl("/api/dw/newetl/chatBi/skill/getChatDocsBySkillKey?" + q.toString());
1497
+ const data = r.data;
1498
+ const missing = !data || !Array.isArray(data.nouns);
1499
+ return {
1500
+ key: "lexicon",
1501
+ value: {
1502
+ skillKey, sourceType, sourceGroupId: gid, sourceTableId: tid,
1503
+ noLexicon: missing, count: missing ? 0 : data.nouns.length,
1504
+ nouns: missing ? [] : data.nouns.map((n) => ({ keyword: n.keyword ?? null, synonyms: n.synonyms ?? null, wordExplain: n.wordExplain ?? null })),
1505
+ note: missing
1506
+ ? "该数据集无词库(后端:" + (r.msg || "未返回 nouns") + ")—— 四参数均取自本次调用,故为**确定结论**;按铁律如实说明『该数据集暂无名词解释』,不自动建库、不新增名词"
1507
+ : "词库命中:须先把每条规则逐条映射到 --where / --fields / --group-by / --stat 并自检无遗漏,再生成查询",
1508
+ },
1509
+ };
1510
+ } catch (e) { return { key: "lexicon", error: String((e && e.message) || e) }; }
1511
+ })(),
1512
+ ];
1513
+
1514
+ const settled = await Promise.all(tasks);
1515
+ const out = { groupId: gid, groupLabel: dsGroupLabel(gid), tableId: tid, sourceType, branches: settled.length };
1516
+ const errors = [];
1517
+ for (const s of settled) {
1518
+ if (s.error) { out[s.key] = { error: s.error }; errors.push({ branch: s.key, error: s.error }); }
1519
+ else out[s.key] = s.value;
1520
+ }
1521
+ out.relatedFields = dsRelatedHint(gid, (out.fields && out.fields.detailCount) || 0);
1522
+ out.channelHint = dsRouteHint([{ groupId: gid, serviceMark: dsGroupMark(gid) }]);
1523
+ // ⚠️ 权限闸门**不在本命令范围**:EB 建模表单取数前必须另行执行 `e10-data eb perm-page`(appId 只从那次校验取)
1524
+ out.permissionNote = gid === "weaver-ebuilder-form-service"
1525
+ ? "该分组是 EB 建模表单:取数前必须另行执行 `e10-data eb perm-page --obj-id <tableId> --form-table <物理表名>` 判权限并取 appId(--form-table 用 ds find / ds tables 返回的 physicalTable)"
1526
+ : "非 EB 分组,数据源通道无需 eb perm-page 前置校验";
1527
+ out.elapsedMs = Date.now() - t0;
1528
+ if (errors.length && args.require) throw new Error("describe 存在失败分支,--require 下不继续:" + JSON.stringify(errors));
1529
+ if (json) return out;
1530
+
1531
+ const f = out.fields || {};
1532
+ console.log("== describe " + gid + " / " + tid + " (" + out.elapsedMs + "ms,并发 " + settled.length + " 路) ==");
1533
+ if (f.error) console.log("字段: 失败 " + f.error);
1534
+ else console.log("字段: 主表 " + f.mainCount + " 个,明细 " + f.detailCount + " 个(表名 " + (f.tableName ?? "-") + ")");
1535
+ const lx = out.lexicon || {};
1536
+ if (lx.error) console.log("词库: 失败 " + lx.error);
1537
+ else console.log("词库: " + (lx.noLexicon ? "该数据集无词库" : "命中 " + lx.count + " 条(skill=" + lx.skillKey + ")"));
1538
+ console.log("关联字段: " + (out.relatedFields.likelyApplicable ? "适用(" + out.relatedFields.tableOrigin + ")" : "不适用(" + out.relatedFields.tableOrigin + ")") + " —— " + out.relatedFields.note);
1539
+ console.log("通道提示: " + out.channelHint.channel + " —— " + out.channelHint.desc);
1540
+ console.log("权限: 不在本命令范围 —— " + out.permissionNote);
1541
+ }
1542
+
953
1543
  // ---------------- eb:EB 建模表单权限校验 ----------------
954
1544
  // chatBi EB 配置映射分页查询:EB 表权限前置校验的 CLI 通道
955
1545
  // (替代原「DSL 查 dw.chat_bi_eb_config_map + 客户端拼 (all_scop=1 OR target_id=本人)」写法)
@@ -1412,6 +2002,338 @@ async function cmdConf(sub, args, json, pos) {
1412
2002
  }
1413
2003
  }
1414
2004
 
2005
+ // ---------------- module:模块配置接口调用(配置中心驱动,2026-09-28 新增)----------------
2006
+ // 用途:**已确定「哪个模块的哪条具体接口」之后**,用本命令一条命令完成取数:
2007
+ // 复用本 CLI 已加载的凭据 → 精取该条 confAttrs(confType=api,按 confGroup 二级定位)
2008
+ // → 按 http_method / input_params 组参 → 发请求(自动禁代理 + Cookie/eteamsid/UA 三件套)
2009
+ // → 按外层结构提取精简结果(datajson / data / records / 裸数组皆可)。
2010
+ // 与技能自带 python 调用器 call_module.py **等价**(参数名、退出码、提取口径全对齐),区别:
2011
+ // · 复用本 CLI 的凭据缓存与 conf 通道(不额外起 python,也不重复取凭据);
2012
+ // · 全程零临时文件(body 由内存直发,不落盘)。
2013
+ // 退出码(照 call_module.py 语义,见 main().catch 的 exitCode 处理):
2014
+ // 0 正常(**空结果也算正常**)/ 2 未登录·凭据失效 / 3 非 JSON·confAttrs 异常
2015
+ // / 4 权限受限(终端态:立即终止)/ 6 命中 HRM 路径黑名单 / 7 请求失败 / 8 用法错误
2016
+ const MODULE_HRM_PATTERNS = ["/api/hrm/", "/api/bs/hrm/", "/api/app/hrm/"];
2017
+ const MODULE_PERM_WORDS = ["没有权限", "无权限", "权限不足", "未授权", "无权访问", "无查询权限"];
2018
+ const MODULE_INVOICE_TOKEN_PATH = "/api/inc/proxy/getToken?appType=1";
2019
+ const MODULE_EXIT = { OK: 0, AUTH: 2, JSON: 3, PERM: 4, HRM: 6, HTTP: 7, USAGE: 8 };
2020
+
2021
+ /** 带退出码的错误:main().catch 按 e.exitCode 退出(未设置仍为 1) */
2022
+ class ModuleExit extends Error {
2023
+ constructor(code, message) { super(message); this.exitCode = code; }
2024
+ }
2025
+
2026
+ /** json 模式下先向 stdout 打一份结构化错误信封,再带退出码抛出(对齐 call_module.py 的输出约定) */
2027
+ function moduleDie(json, code, msg) {
2028
+ if (json) console.log(JSON.stringify({ ok: false, exit: code, error: msg }, null, 1));
2029
+ throw new ModuleExit(code, msg);
2030
+ }
2031
+
2032
+ /** 模块接口调用的 User-Agent:`AgentType=<agentType>,IsAgent=true`
2033
+ * 优先取登录态自带的 UA(weaver-work-cli export-weaver-env 的 WEAVER_USER_AGENT),但**必须是 AgentType 形态**;
2034
+ * 缺失 / 形态不符 / 混入非 ASCII(会致请求头编码失败)时,从 cookie 的 agentType= 推导,再退回 workbuddy。 */
2035
+ function moduleUserAgent() {
2036
+ const raw = String((auth && auth.userAgent) || "").trim();
2037
+ if (raw && !/[^\x00-\x7F]/.test(raw) && /agenttype=/i.test(raw)) return raw;
2038
+ const m = String((auth && auth.cookie) || "").match(/agentType=([^;]+)/i);
2039
+ return "AgentType=" + (m ? m[1].trim() : "workbuddy") + ",IsAgent=true";
2040
+ }
2041
+
2042
+ /** 业务参数 → query 串(数组按多值展开、对象 JSON 化;对齐 urlencode(..., doseq=True)) */
2043
+ function moduleQueryString(obj) {
2044
+ const sp = new URLSearchParams();
2045
+ for (const [k, v] of Object.entries(obj || {})) {
2046
+ if (v === undefined || v === null) continue;
2047
+ if (Array.isArray(v)) for (const x of v) sp.append(k, x !== null && typeof x === "object" ? JSON.stringify(x) : String(x));
2048
+ else sp.append(k, typeof v === "object" ? JSON.stringify(v) : String(v));
2049
+ }
2050
+ return sp.toString();
2051
+ }
2052
+
2053
+ /** 发请求(带超时);网络层失败 → 退出码 7 */
2054
+ async function moduleFetch(method, pathAndQuery, headers, body, timeoutMs) {
2055
+ const ctrl = new AbortController();
2056
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
2057
+ try {
2058
+ const resp = await fetch(BASE + pathAndQuery, {
2059
+ method,
2060
+ headers,
2061
+ ...(body !== undefined ? { body } : {}),
2062
+ signal: ctrl.signal,
2063
+ });
2064
+ return { status: resp.status, text: await resp.text() };
2065
+ } catch (e) {
2066
+ const aborted = String((e && e.name) || "") === "AbortError";
2067
+ throw new ModuleExit(MODULE_EXIT.HTTP, "请求失败:" + (aborted ? "超时 " + timeoutMs + "ms" : String((e && e.message) || e)));
2068
+ } finally { clearTimeout(timer); }
2069
+ }
2070
+
2071
+ /** 按已知外层形态找列表:datajson.data / datajson.datas / data.data / data.records / data / datas / list */
2072
+ function moduleRowsOf(obj) {
2073
+ const numish = (v) => (typeof v === "number" || typeof v === "string") ? v : null;
2074
+ if (Array.isArray(obj)) return { rows: obj, total: null };
2075
+ if (!obj || typeof obj !== "object") return { rows: null, total: null };
2076
+ for (const k of ["datajson", "data", "result"]) {
2077
+ const v = obj[k];
2078
+ if (Array.isArray(v)) return { rows: v, total: null };
2079
+ if (v && typeof v === "object") {
2080
+ for (const kk of ["data", "datas", "records", "list", "items", "rows"]) {
2081
+ if (Array.isArray(v[kk])) return { rows: v[kk], total: numish(v.total) };
2082
+ }
2083
+ return { rows: null, total: null };
2084
+ }
2085
+ }
2086
+ for (const kk of ["datas", "list", "records", "items", "rows"]) {
2087
+ if (Array.isArray(obj[kk])) return { rows: obj[kk], total: numish(obj.total) };
2088
+ }
2089
+ return { rows: null, total: null };
2090
+ }
2091
+
2092
+ /** 取载荷(datajson 优先,其次 data,否则根)与来源名 */
2093
+ function modulePayloadOf(obj) {
2094
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
2095
+ for (const k of ["datajson", "data"]) {
2096
+ if (obj[k] !== undefined && obj[k] !== null) return { value: obj[k], via: k };
2097
+ }
2098
+ }
2099
+ return { value: obj, via: "(root)" };
2100
+ }
2101
+
2102
+ /** 权限/二次验证标志位识别(权限受限 ≠ 无数据;二次验证 ≠ 无数据) */
2103
+ function moduleFlagsOf(obj, payload) {
2104
+ const flags = [];
2105
+ if (payload && typeof payload === "object" && !Array.isArray(payload) && payload.hasSearchRight === false) {
2106
+ flags.push("hasSearchRight=false(权限受限,其余字段必为空 → 立即终止,不得交付为「无数据」)");
2107
+ }
2108
+ if (payload && typeof payload === "object" && !Array.isArray(payload) && payload.pageUid === "checkSecondFail") {
2109
+ flags.push("pageUid=checkSecondFail(二次身份验证:total 可信、明细恒 0 行 → 如实说明,不得当无数据)");
2110
+ }
2111
+ let txt = "";
2112
+ try { txt = JSON.stringify(obj).slice(0, 4000); } catch { txt = ""; }
2113
+ if (MODULE_PERM_WORDS.some((w) => txt.includes(w))) flags.push("响应含权限关键词 → 按「无权限即终止」处理");
2114
+ return flags;
2115
+ }
2116
+
2117
+ /** INVOICE 业务接口的发票云 Token(内存透传;🚫 不打印、不落盘) */
2118
+ async function moduleInvoiceToken(json, timeoutMs) {
2119
+ const headers = {
2120
+ "accept": "application/json, text/plain, */*",
2121
+ "langType": "zh_CN",
2122
+ "cookie": auth.cookie,
2123
+ "eteamsid": auth.eteamsid,
2124
+ "user-agent": moduleUserAgent(),
2125
+ };
2126
+ const { status, text } = await moduleFetch("GET", MODULE_INVOICE_TOKEN_PATH, headers, undefined, timeoutMs);
2127
+ let tk = null;
2128
+ try { const j = parseJsonSafe(text); tk = j && j.data && j.data.token; } catch {}
2129
+ if (!tk) moduleDie(json, MODULE_EXIT.HTTP, "未取到发票云 Token(INVOICE 业务接口必需;HTTP " + status + ")");
2130
+ return tk;
2131
+ }
2132
+
2133
+ async function cmdModuleCall(args, json) {
2134
+ // ---- 必填与选项(缺参一律退出码 8,对齐 call_module.py)----
2135
+ if (!args["conf-key"] || args["conf-key"] === true) {
2136
+ moduleDie(json, MODULE_EXIT.USAGE, "缺必填参数 --conf-key(用法: module call --conf-key <confKey> --group <模块>)");
2137
+ }
2138
+ if (!args.group || args.group === true) {
2139
+ moduleDie(json, MODULE_EXIT.USAGE, "缺必填参数 --group(模块唯一标识,如 WORKFLOW / EHR / DOCUMENT)");
2140
+ }
2141
+ const confKey = String(args["conf-key"]);
2142
+ const group = String(args.group);
2143
+ const timeoutMs = Math.max(1, parseInt(args.timeout || "60", 10) || 60) * 1000;
2144
+ const limit = (args.limit === undefined || args.limit === true) ? 50 : (parseInt(String(args.limit), 10) || 0);
2145
+ const fields = (typeof args.fields === "string" && args.fields) ? args.fields.split(",").map((s) => s.trim()).filter(Boolean) : null;
2146
+
2147
+ // ---- ① 精取该条接口配置(confType 固定 api;confGroup 二级定位必带)----
2148
+ let rec;
2149
+ try { rec = await confGetData("api", confKey, false, group); }
2150
+ catch (e) {
2151
+ const m = String((e && e.message) || e);
2152
+ moduleDie(json, /登录态失效|未取得登录凭据/.test(m) ? MODULE_EXIT.AUTH : MODULE_EXIT.JSON,
2153
+ "conf get 失败:" + m + "(先核对 confKey / confGroup;登录态问题请重新登录)");
2154
+ }
2155
+ let attrs = rec.confAttrs;
2156
+ if (typeof attrs === "string") {
2157
+ try { attrs = parseJsonSafe(attrs, "confAttrs"); }
2158
+ catch { moduleDie(json, MODULE_EXIT.JSON, "confAttrs 二次解析失败(非 JSON 字符串): " + confKey); }
2159
+ }
2160
+ if (!attrs || typeof attrs !== "object" || Array.isArray(attrs)) {
2161
+ moduleDie(json, MODULE_EXIT.JSON, "confAttrs 缺失或形态异常: " + confKey + "(--group " + group + ")");
2162
+ }
2163
+ const apiPath = String(attrs.api_path || rec.confValue || "");
2164
+ if (!apiPath) moduleDie(json, MODULE_EXIT.JSON, "confAttrs 未提供 api_path / confValue: " + confKey);
2165
+
2166
+ // ---- ② HRM 路径黑名单(人员/部门/岗位只走 weaver-work-cli 的 hrm 子命令)----
2167
+ if (MODULE_HRM_PATTERNS.some((p) => apiPath.includes(p))) {
2168
+ moduleDie(json, MODULE_EXIT.HRM,
2169
+ "该接口命中 HRM 路径黑名单(" + apiPath + ")→ 人员/部门/岗位一律走 weaver-work-cli 的 hrm 子命令,禁止直调");
2170
+ }
2171
+
2172
+ const apiName = attrs.api_name || rec.confName || confKey;
2173
+
2174
+ // ---- ③ --show-params:只给入/出参说明与请求示例,不调接口(用于正确组参)----
2175
+ if (args["show-params"]) {
2176
+ const out = {
2177
+ confKey, module: group, api_name: apiName,
2178
+ http_method: attrs.http_method ?? null, api_path: apiPath,
2179
+ business_desc: attrs.business_desc ?? null,
2180
+ api_params: attrs.api_params ?? null,
2181
+ input_params: attrs.input_params ?? null,
2182
+ output_params: attrs.output_params ?? null,
2183
+ request_example: attrs.request_example ?? null,
2184
+ };
2185
+ console.log(JSON.stringify(out, null, 1));
2186
+ return;
2187
+ }
2188
+
2189
+ // ---- ④ 组参:--args(@文件 / 内联)+ --query(k=v&k2=v2)+ --header / --headers ----
2190
+ let bizArgs = {};
2191
+ if (args.args !== undefined && args.args !== true) {
2192
+ try { bizArgs = readJsonArg(String(args.args), "--args"); }
2193
+ catch (e) { moduleDie(json, MODULE_EXIT.USAGE, "--args 读取/解析失败:" + ((e && e.message) || e)); }
2194
+ }
2195
+ if (!bizArgs || typeof bizArgs !== "object" || Array.isArray(bizArgs)) {
2196
+ moduleDie(json, MODULE_EXIT.USAGE, "--args 必须是 JSON 对象");
2197
+ }
2198
+ if (typeof args.query === "string" && args.query) {
2199
+ for (const pair of args.query.split("&")) {
2200
+ if (!pair) continue;
2201
+ const i = pair.indexOf("=");
2202
+ if (i > 0) bizArgs[pair.slice(0, i)] = pair.slice(i + 1);
2203
+ }
2204
+ }
2205
+ const extraHeaders = {};
2206
+ if (args.headers !== undefined && args.headers !== true) {
2207
+ let h;
2208
+ try { h = readJsonArg(String(args.headers), "--headers"); }
2209
+ catch (e) { moduleDie(json, MODULE_EXIT.USAGE, "--headers 读取/解析失败:" + ((e && e.message) || e)); }
2210
+ if (h && typeof h === "object" && !Array.isArray(h)) Object.assign(extraHeaders, h);
2211
+ }
2212
+ if (typeof args.header === "string" && args.header.includes(":")) {
2213
+ const i = args.header.indexOf(":");
2214
+ extraHeaders[args.header.slice(0, i).trim()] = args.header.slice(i + 1).trim();
2215
+ }
2216
+
2217
+ const declared = String(args.method || attrs.http_method || "POST").toUpperCase();
2218
+ if (declared !== "GET" && declared !== "POST" && declared !== "FORM") {
2219
+ moduleDie(json, MODULE_EXIT.USAGE, "--method 只支持 GET / POST / FORM(当前: " + args.method + ")");
2220
+ }
2221
+ const effMethod = declared === "FORM" ? "POST" : declared;
2222
+ const isForm = declared === "FORM" || /x-www-form-urlencoded/i.test(String(attrs.api_definition || ""));
2223
+
2224
+ let qs = "", body;
2225
+ if (effMethod === "GET") {
2226
+ const q = moduleQueryString(bizArgs);
2227
+ qs = q ? "?" + q : "";
2228
+ } else {
2229
+ body = isForm ? moduleQueryString(bizArgs) : JSON.stringify(bizArgs);
2230
+ }
2231
+
2232
+ // ---- ⑤ --dry-run:只组装不发送(凭据一律脱敏)----
2233
+ if (args["dry-run"]) {
2234
+ const masked = {};
2235
+ for (const [k, v] of Object.entries(extraHeaders)) masked[k] = /^token$/i.test(k) ? "<脱敏>" : v;
2236
+ const out = {
2237
+ ok: true, confKey, api_name: apiName, method: effMethod,
2238
+ url: "<baseUrl>" + apiPath + qs,
2239
+ body: bizArgs,
2240
+ headers: {
2241
+ Cookie: "<脱敏>", eteamsid: "<脱敏>", "User-Agent": "<AgentType=…,IsAgent=true>",
2242
+ "Content-Type": effMethod === "POST" ? (isForm ? "application/x-www-form-urlencoded" : "application/json;charset=UTF-8") : null,
2243
+ ...masked,
2244
+ },
2245
+ note: "--dry-run:未发送请求",
2246
+ };
2247
+ console.log(JSON.stringify(out, null, 1));
2248
+ return;
2249
+ }
2250
+
2251
+ // ---- ⑥ 发请求 ----
2252
+ const headers = {
2253
+ "accept": "application/json, text/plain, */*",
2254
+ "langType": "zh_CN",
2255
+ "cookie": auth.cookie,
2256
+ "eteamsid": auth.eteamsid,
2257
+ "ebbusinessid": EB_BIZ_ID,
2258
+ "user-agent": moduleUserAgent(),
2259
+ "timezoneoffset": "-480",
2260
+ };
2261
+ if (effMethod === "POST") headers["Content-Type"] = isForm ? "application/x-www-form-urlencoded" : "application/json;charset=UTF-8";
2262
+ // INVOICE:业务接口除通用三件套外必须带发票云 token(先取换、再允许 --header 覆盖)
2263
+ if (group.toUpperCase() === "INVOICE" || String(attrs.module_key || "").toUpperCase() === "INVOICE") {
2264
+ headers["token"] = await moduleInvoiceToken(json, timeoutMs);
2265
+ }
2266
+ Object.assign(headers, extraHeaders);
2267
+
2268
+ const { status, text } = await moduleFetch(effMethod, apiPath + qs, headers, body, timeoutMs);
2269
+
2270
+ if (status === 401) {
2271
+ moduleDie(json, MODULE_EXIT.AUTH,
2272
+ "登录态失效(HTTP 401):凭据来源=" + ((auth && auth.source) || "未知") + ";请执行 `weaver-work-cli auth login --base-url <域名>` 重新登录");
2273
+ }
2274
+
2275
+ let obj = null;
2276
+ try { obj = parseJsonSafe(text); } catch {}
2277
+ if (obj === null) {
2278
+ // 非 JSON:原样输出前 2000 字符(2xx 视为正常,其余按请求失败)
2279
+ if (json) console.log(JSON.stringify({ ok: status >= 200 && status < 300, api_name: apiName, http: status, nonJson: text.slice(0, 2000) }, null, 1));
2280
+ else console.log(text.slice(0, 2000));
2281
+ if (status < 200 || status >= 300) throw new ModuleExit(MODULE_EXIT.HTTP, "HTTP " + status + " 且响应非 JSON(已原样输出前 2000 字符)");
2282
+ return;
2283
+ }
2284
+
2285
+ // ---- ⑦ --raw:原样输出响应体 ----
2286
+ if (args.raw) {
2287
+ console.log(JSON.stringify(obj, null, 1));
2288
+ return;
2289
+ }
2290
+
2291
+ // ---- ⑧ 提取精简结果 ----
2292
+ const payload = modulePayloadOf(obj);
2293
+ const flags = moduleFlagsOf(obj, payload.value);
2294
+ const { rows, total } = moduleRowsOf(obj);
2295
+ const out = { ok: true, api_name: apiName, http: status, via: payload.via };
2296
+ let projected = null;
2297
+ if (rows !== null) {
2298
+ const shown = limit > 0 ? rows.slice(0, limit) : rows;
2299
+ projected = fields
2300
+ ? shown.map((r) => {
2301
+ const o = {};
2302
+ if (r && typeof r === "object") for (const k of fields) if (k in r) o[k] = r[k];
2303
+ return o;
2304
+ })
2305
+ : shown;
2306
+ out.total = total;
2307
+ out.rows = rows.length;
2308
+ out.shown = projected.length;
2309
+ out.data = projected;
2310
+ } else {
2311
+ out.data = payload.value;
2312
+ }
2313
+ if (flags.length) out.flags = flags;
2314
+
2315
+ if (json) {
2316
+ console.log(JSON.stringify(out, null, 1));
2317
+ } else {
2318
+ console.log("== " + apiName + " ==");
2319
+ console.log(effMethod + " " + apiPath + " → HTTP " + status + "(via=" + payload.via + ")");
2320
+ if (rows !== null) {
2321
+ console.log("rows=" + rows.length
2322
+ + (total !== null && total !== undefined ? " / total=" + total : "")
2323
+ + (limit > 0 && rows.length > limit ? "(已截断到 " + limit + " 行)" : ""));
2324
+ printHuman("", projected);
2325
+ } else {
2326
+ console.log(JSON.stringify(payload.value, null, 1));
2327
+ }
2328
+ for (const f of flags) console.log("[!] " + f);
2329
+ }
2330
+
2331
+ // 权限受限是终端态:结果照常给出(供调用方留证),但退出码必须是非 0(对齐 call_module.py)
2332
+ if (flags.some((f) => f.startsWith("hasSearchRight=false"))) {
2333
+ throw new ModuleExit(MODULE_EXIT.PERM, "权限受限:hasSearchRight=false(立即终止,不重试、不换参、不交付为「无数据」)");
2334
+ }
2335
+ }
2336
+
1415
2337
  // ---------------- attend:考勤 REST 接口 ----------------
1416
2338
  // 2026-09-07:封装 /api/attend/web/attendInfoV2/getAttendInfoStatis(假期余额统计,hr-attendance 场景六病假/事假)。
1417
2339
  // 请求参数 {beginDate, endDate, userId};返回 data.vacationBalanceList[]:{id,title,data:[{name,value,key}]},
@@ -1459,7 +2381,7 @@ async function expGet(url) {
1459
2381
  });
1460
2382
  const raw = await resp.text();
1461
2383
  let j; try { j = parseJsonSafe(raw); } catch { throw new Error("HTTP " + resp.status + " 非JSON响应: " + raw.slice(0, 300)); }
1462
- if (resp.status === 401 || (j && String(j.code) === "-1" && /token|登录|失效/i.test(String(j.msg || "")))) throw new Error("登录态失效,请重新 e10-login oidc 登录");
2384
+ if (resp.status === 401 || (j && String(j.code) === "-1" && /token|登录|失效/i.test(String(j.msg || "")))) throw new Error("登录态失效,请重新登录 weaver-work-cli(`weaver-work-cli auth login --base-url <域名>`)");
1463
2385
  return { raw, j };
1464
2386
  }
1465
2387
  function rawNum(raw, key) {
@@ -1531,7 +2453,7 @@ async function rawGet(url) {
1531
2453
  method: "GET",
1532
2454
  headers: { "accept": "*/*", "langType": "zh_CN", "cookie": auth.cookie, "eteamsid": auth.eteamsid, "ebbusinessid": EB_BIZ_ID, "user-agent": UA },
1533
2455
  });
1534
- if (resp.status === 401) throw new Error("登录态失效,请重新 e10-login oidc 登录");
2456
+ if (resp.status === 401) throw new Error("登录态失效,请重新登录 weaver-work-cli(`weaver-work-cli auth login --base-url <域名>`)");
1535
2457
  return Buffer.from(await resp.arrayBuffer());
1536
2458
  }
1537
2459
  async function expStatus(args, json) {
@@ -1793,6 +2715,23 @@ login 登录(**唯一不需要已有凭据**的命令族;其余命令都
1793
2715
  query DSL 数据查询 executeQueryDsl
1794
2716
  query --conn <connId> --model <ebuilder|flow|eteams|attend|hr|document|dw|blog> --schema <数据库> [--table-id <流程ID>] [--debug true|false] --dsl @file.json|'{...}'
1795
2717
  ds 数据源(业务模块)取数与元数据
2718
+ ds groups [--pattern <关键词>] [--json] # 内置分组清单(仅用于 find 的默认扫描集)
2719
+ ds find --name <关键词> [--groups g1,g2] [--all-groups] [--max-groups N] [--limit N]
2720
+ [--app-id <id>] [--size 100] [--pages N] [--concurrency 10] [--no-escalate] [--loose] [--source-type LOGIC]
2721
+ # 跨分组找表:**进程内并发**扇出(一次凭据),产物=候选表清单 + 路由建议,**不取数**
2722
+ # 默认扫描集 = 核心 3 组(业务表单/e-builder表单/流程)+ 语义命中组,≤ --max-groups
2723
+ # 默认集合全 miss 时自动升级为全分组扫一次(--no-escalate 可关;窗口 >2000 行时不自动升级)
2724
+ # --all-groups = 直接扫全部内置分组(进程内并发,实测 ~5.5s)
2725
+ # --app-id:EB 分组(weaver-ebuilder-form-service)按应用分域,需限定应用时带上
2726
+ # ⚠️ 端点 /api/dw/newetl/chatBi/ds/listByPage **当前忽略 name**(后端待升级)⇒ 本次是
2727
+ # 「抓 --pages 页 × --size 行 + 本地按表名过滤」:**未命中 ≠ 该表不存在**,
2728
+ # 分组行数大时请调大 --size(≤2000)/--pages(≤50)。后端支持 name 后自动精确、无需改调用
2729
+ # 输出按「实体」归并去重(同一流程会在多个报表/效率分组重复出现)
2730
+ ds describe --group <gid> --table <tid> [--app-id <id>] [--skill <skillKey,默认 data-source-query>]
2731
+ [--require] [--source-type LOGIC]
2732
+ # 一次**并发**取回两件套:字段 + 词库(两者入参同为 (groupId, tableId),互不依赖)
2733
+ # ⚠️ **不做权限校验**:EB 分组取数前仍必须单独执行 eb perm-page(appId 只从那次调用取)
2734
+ # --require:任一路失败即报错退出
1796
2735
  ds tables --group <gid> [--name 关键词] [--app-id <id>]
1797
2736
  ds fields --group <gid> --table <tid> [--app-id <id>]
1798
2737
  ds fields-dsl --group <gid> --table <tid> [--app-id <id>] # 同 fields,接口 /api/dw/newetl/chatBi/ds/fieldsForDSL
@@ -1851,6 +2790,20 @@ eb EB 建模表单权限校验(chatBi EB 配置映射分页查询,替
1851
2790
  # obj-id / form-table 必填(本地强校验;缺失后端只回空、不报错)
1852
2791
  # obj-id / app-id 必须字符串传参(19 位大整数,Number 舍入 → 静默 total=0)
1853
2792
  # total>=1 = 有权限,并回带该行 appId;--require 时 total=0 直接报错退出
2793
+ module 模块配置接口调用(配置中心驱动;**已确定「哪个模块的哪条具体接口」之后**用它取数,2026-09-28 新增)
2794
+ module call --conf-key <confKey> --group <模块> [--args '<JSON>'|--args @params.json]
2795
+ [--query "k=v&k2=v2"] [--method GET|POST|FORM] [--header 'K: V'|--headers '<JSON>'|@file]
2796
+ [--limit 50] [--fields a,b] [--raw] [--show-params] [--dry-run] [--timeout 60] [--json]
2797
+ # 一条命令完成:精取该条 confAttrs → 按 http_method/input_params 组参 → 发请求
2798
+ # (自动禁代理 + Cookie/eteamsid/UA 三件套)→ 按外层结构(datajson/data/records/裸数组)提取精简结果
2799
+ # 与技能自带 python 调用器 call_module.py 等价(参数名/退出码/提取口径全对齐),但复用本 CLI 凭据缓存与
2800
+ # conf 通道、零临时文件 —— 调用器首选本命令,call_module.py 仅作兜底
2801
+ # --show-params:只打印入/出参说明与请求示例(不调接口),用于正确组参
2802
+ # --dry-run:只组装请求不发送(凭据脱敏);--limit 0 = 不截断
2803
+ # --query 多对用 & 连接;INVOICE 自动换取发票云 token(内存透传,不打印不落盘)
2804
+ # 退出码:0 正常(**空结果也算正常**)|2 未登录·凭据失效|3 非 JSON·confAttrs 异常
2805
+ # |4 权限受限(hasSearchRight=false,终端态)|6 命中 HRM 路径黑名单|7 请求失败|8 用法错误
2806
+ # 示例: module call --conf-key POST_EHR_GET_ATTEND_INFO_STATIS --group EHR --args @params.json --json
1854
2807
  认证: 加解密由 e10-login 负责(本 CLI 不参与);凭据出口 = e10-login auth/whoami(自动同步镜像);皆无请先 e10-login oidc
1855
2808
  (无浏览器/无 python 环境:login xiaoe-env 用环境变量 token 直接登录)`);
1856
2809
  }
@@ -1869,6 +2822,9 @@ async function main() {
1869
2822
  if (family === "ds") {
1870
2823
  if (!sub) { usage(); process.exit(1); }
1871
2824
  switch (sub) {
2825
+ case "groups": result = await cmdDsGroups(args, json); break;
2826
+ case "find": result = await cmdDsFind(args, json); break;
2827
+ case "describe": result = await cmdDsDescribe(args, json); break;
1872
2828
  case "tables": result = await cmdTables(args, json); break;
1873
2829
  case "fields": result = await cmdFields(args, json); break;
1874
2830
  case "fields-dsl": result = await cmdFieldsForDSL(args, json); break;
@@ -1918,7 +2874,17 @@ async function main() {
1918
2874
  case "perm-page": result = await cmdEbPermPage(args, json); break;
1919
2875
  default: usage(); process.exit(1);
1920
2876
  }
2877
+ } else if (family === "module") {
2878
+ if (!sub) { usage(); process.exit(1); }
2879
+ switch (sub) {
2880
+ case "call": result = await cmdModuleCall(args, json); break;
2881
+ default: usage(); process.exit(1);
2882
+ }
1921
2883
  } else { usage(); process.exit(1); }
1922
2884
  if (json && result !== undefined) console.log(JSON.stringify(result, null, 1));
1923
2885
  }
1924
- main().catch((e) => { console.error("ERROR: " + e.message); process.exit(1); });
2886
+ // 退出码:默认 1(用法/未知错误);错误对象带 exitCode 时按其退出(module call 用到 2/3/4/6/7/8)
2887
+ main().catch((e) => {
2888
+ console.error("ERROR: " + ((e && e.message) || e));
2889
+ process.exit(Number.isInteger(e && e.exitCode) ? e.exitCode : 1);
2890
+ });