weaver-e9-assistant-cli 1.1.5 → 1.1.11

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.
@@ -16,7 +16,6 @@
16
16
  * - 姓名→userid 解析唯一入口是 blog at-list(SKILL.md 硬规则),候选按 精确>前缀>拼音 排序后过滤到范围。
17
17
  * - 下属表找不到 id 列 → scope 标记 reliable:false → 非本人查询拒绝(安全但功能受限,实跑校准回填规则)。
18
18
  */
19
- import fs from 'node:fs';
20
19
  import path from 'node:path';
21
20
  import os from 'node:os';
22
21
  import { createApiFromProfile } from './client.js';
@@ -24,16 +23,22 @@ import { extractDataKey, extractRows, extractColumns, extractTotal, pickByPaths,
24
23
  import { getCurrentUser } from './wfuser.js';
25
24
  import { getAtAllList } from './blog.js';
26
25
  import { profileNameFromBaseUrl } from './config.js';
26
+ import { cacheGet, cacheSet } from './cache-store.js';
27
27
 
28
28
  /** 全层级下属翻页上限(超限仍未收全 → reliable:false)。 */
29
29
  export const MAX_SCOPE_PAGES = 20;
30
+ /**
31
+ * 授权集合拉取的单页条数。服务端 table/datas 认 pageSize(实测 pageSize=500 一次返回全部 96 条),
32
+ * 用大页把「逐页排队」压成通常 1 次请求;超过 500 人才继续翻页。
33
+ */
34
+ const SCOPE_PAGE_SIZE = 500;
30
35
  /** 授权集合缓存 TTL(默认 12h,比台账目录的 7 天短——越权集合同理会随组织变动,宁短勿长)。 */
31
36
  export const SCOPE_TTL_MS = (Number(process.env.E9_SCOPE_TTL_HOURS ?? '12') || 12) * 3600 * 1000;
32
37
  /** 缓存目录(测试可用 E9_SCOPE_CACHE_DIR 覆盖,避免污染真实 ~/.e9)。 */
33
38
  function scopeCacheDir() {
34
39
  return process.env.E9_SCOPE_CACHE_DIR || path.join(os.homedir(), '.e9');
35
40
  }
36
- function scopeCachePath(cfg) {
41
+ export function scopeCachePath(cfg) {
37
42
  const envkey = profileNameFromBaseUrl(cfg.baseUrl);
38
43
  return path.join(scopeCacheDir(), `subordinate_scope_${envkey}.json`);
39
44
  }
@@ -56,10 +61,13 @@ export async function fetchSubordinateDataKey(cfg, { lastname = '', showAllLevel
56
61
  return { dataKey, raw: r?.json ?? null };
57
62
  }
58
63
 
59
- /** 取下属表一页数据(不带 pageSize,跟随服务端默认每页条数)。 */
60
- export async function fetchSubordinateDataPage(cfg, dataKey, page) {
64
+ /** 取下属表一页数据(pageSize 可选;不传则跟随服务端默认每页条数)。 */
65
+ export async function fetchSubordinateDataPage(cfg, dataKey, page, { pageSize } = {}) {
61
66
  const api = await createApiFromProfile(cfg);
62
- const r = await api.post('/api/ec/dev/table/datas', { dataKey, current: String(page), sortParams: '[]' });
67
+ const body = { dataKey, current: String(page), sortParams: '[]' };
68
+ if (pageSize != null)
69
+ body.pageSize = String(pageSize);
70
+ const r = await api.post('/api/ec/dev/table/datas', body);
63
71
  const json = r?.json ?? {};
64
72
  return {
65
73
  rows: extractRows(json),
@@ -129,14 +137,19 @@ export async function getSubordinates(cfg, opts = {}) {
129
137
  const page = Number(opts.page ?? '1') || 1;
130
138
  const { dataKey, raw: searchRaw } = await fetchSubordinateDataKey(cfg, { lastname, showAllLevel: allLevel });
131
139
  const data = await fetchSubordinateDataPage(cfg, dataKey, page);
132
- const count = await fetchSubordinateCount(cfg, dataKey);
133
- const total = count.total ?? null;
140
+ // counts 按需:首页且未填满一页 → 总数即本页条数,省一次请求;否则才查总数。
141
+ const pageSize = data.pageSize ?? (data.rows.length || null);
142
+ const firstPageComplete = page === 1 && data.pageSize && data.rows.length < data.pageSize;
143
+ let count = { total: null, raw: null };
144
+ if (!firstPageComplete)
145
+ count = await fetchSubordinateCount(cfg, dataKey);
146
+ const total = count.total ?? (firstPageComplete ? data.rows.length : null);
134
147
  const items = data.rows.map((r) => normalizeSubordinateRow(r, data.columns));
135
148
  return {
136
149
  lastname,
137
150
  allLevel,
138
151
  page,
139
- pageSize: data.pageSize ?? (data.rows.length || null),
152
+ pageSize,
140
153
  total,
141
154
  count: items.length,
142
155
  dataKey,
@@ -146,6 +159,53 @@ export async function getSubordinates(cfg, opts = {}) {
146
159
  };
147
160
  }
148
161
 
162
+ // ─── 全量下属列表(批量按人查询用) ──────────────────────
163
+
164
+ /**
165
+ * 拉取全部下属(直接/全部层级),供「按人批量查数据」类命令使用。
166
+ * - 大页 pageSize(默认 500)减少翻页;翻页串行(同一 dataKey 并发翻页不可靠)。
167
+ * - names 非空时按姓名精确过滤,并返回未匹配名单(不静默丢人)。
168
+ * - complete=false 表示达到 MAX_SCOPE_PAGES 仍未收全(调用方须提示可能不完整)。
169
+ */
170
+ export async function listAllSubordinates(cfg, { allLevel = false, names = [], pageSize = SCOPE_PAGE_SIZE } = {}) {
171
+ const wanted = (Array.isArray(names) ? names : [names]).map((n) => String(n ?? '').trim()).filter(Boolean);
172
+ const { dataKey } = await fetchSubordinateDataKey(cfg, { lastname: '', showAllLevel: !!allLevel });
173
+ let page = 1;
174
+ let complete = false;
175
+ let columns = [];
176
+ let rows = [];
177
+ while (page <= MAX_SCOPE_PAGES) {
178
+ const d = await fetchSubordinateDataPage(cfg, dataKey, page, { pageSize });
179
+ if (page === 1)
180
+ columns = d.columns;
181
+ rows = rows.concat(d.rows);
182
+ if (d.rows.length === 0 || (d.pageSize && d.rows.length < d.pageSize)) {
183
+ complete = true;
184
+ break;
185
+ }
186
+ page++;
187
+ }
188
+ const seen = new Set();
189
+ const all = [];
190
+ for (const row of rows) {
191
+ const item = normalizeSubordinateRow(row, columns);
192
+ const id = String(item.id ?? '');
193
+ if (!id || seen.has(id))
194
+ continue;
195
+ seen.add(id);
196
+ all.push({ id, name: item.name, fields: item.fields });
197
+ }
198
+ let items = all;
199
+ let unmatched = [];
200
+ if (wanted.length) {
201
+ const wantedSet = new Set(wanted);
202
+ items = all.filter((it) => wantedSet.has(String(it.name ?? '').trim()));
203
+ const found = new Set(items.map((it) => String(it.name ?? '').trim()));
204
+ unmatched = wanted.filter((n) => !found.has(n));
205
+ }
206
+ return { items, columns, total: all.length, count: items.length, unmatched, complete };
207
+ }
208
+
149
209
  // ─── 授权集合加载 + 缓存(严格 fail-closed) ─────────────
150
210
 
151
211
  /**
@@ -175,7 +235,10 @@ export async function loadScope(cfg, opts = {}) {
175
235
  return { ...cached, selfId, selfName, userIds: new Set(cached.userIds), userNames: new Set(cached.userNames), fromCache: true };
176
236
  }
177
237
  }
178
- // 3. 拉全层级下属,逐页收行
238
+ // 3. 拉全层级下属:大页 + 串行翻页
239
+ // - 服务端认 pageSize,SCOPE_PAGE_SIZE=500 通常一次拿全,避免逐页排队;
240
+ // - ⚠ 不能并发翻页:实测同一 dataKey 并发请求 table/datas 时,服务端偶发返回空页
241
+ // (5 次里 1 次出现连续两页 0 行),会导致 scope 判定不可靠。此处必须串行。
179
242
  const userIds = new Set([selfId]);
180
243
  const userNames = new Set([selfName].filter(Boolean));
181
244
  let reliable = false;
@@ -188,7 +251,7 @@ export async function loadScope(cfg, opts = {}) {
188
251
  let page = 1;
189
252
  let hitEnd = false;
190
253
  while (page <= MAX_SCOPE_PAGES) {
191
- const d = await fetchSubordinateDataPage(cfg, dataKey, page);
254
+ const d = await fetchSubordinateDataPage(cfg, dataKey, page, { pageSize: SCOPE_PAGE_SIZE });
192
255
  if (page === 1) {
193
256
  idCol = detectSubordinateIdColumn(d.columns);
194
257
  nameCol = detectSubordinateNameColumn(d.columns);
@@ -233,35 +296,23 @@ export async function loadScope(cfg, opts = {}) {
233
296
 
234
297
  /** 读缓存;selfId 不匹配 / 过期 / 不可靠 → null。 */
235
298
  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
- }
299
+ const raw = cacheGet(scopeCachePath(cfg), SCOPE_TTL_MS);
300
+ if (!raw || raw.selfId !== selfId || raw.reliable !== true) return null;
301
+ return raw;
247
302
  }
248
303
 
249
304
  /** 写缓存(失败不阻断)。 */
250
305
  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 { /* 缓存写失败不阻断查询 */ }
306
+ cacheSet(scopeCachePath(cfg), {
307
+ envkey: profileNameFromBaseUrl(cfg.baseUrl),
308
+ baseUrl: cfg.baseUrl,
309
+ selfId: scope.selfId,
310
+ selfName: scope.selfName,
311
+ userIds: [...scope.userIds],
312
+ userNames: [...scope.userNames],
313
+ reliable: scope.reliable,
314
+ fetchedAt: scope.fetchedAt,
315
+ });
265
316
  }
266
317
 
267
318
  /** 是否在授权集合内(纯函数,便于单测)。 */
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 下属日报批量查询(按人并发)。
3
+ *
4
+ * 背景:CLI 原先没有「一次查多个下属日报」的入口,AI 只能逐个下属调用,
5
+ * 每次都是新进程 + 一次请求,N 个下属就 N 次排队。
6
+ *
7
+ * 本模块按人并发调用微博主页接口(blogUserId 不同、彼此独立,可安全并发):
8
+ * 1) subordinateinfo/getSearchResult → dataKey(一次)
9
+ * 2) table/datas 大页拉全下属(串行翻页,同一 dataKey 不可并发)
10
+ * 3) 每个下属 GET /api/blog/base/getBlogHomepageList?blogUserId=<id>&startDate=endDate=date
11
+ * 并发上限 TEAM_REPORT_CONCURRENCY
12
+ *
13
+ * 判定:当天存在 isReCalled=0(非撤回)的微博 → 已写日报;仅撤回/无记录 → 未写。
14
+ */
15
+ import { getBlogHomepageList, isBlogRecalled } from './blog.js';
16
+ import { listAllSubordinates } from './subordinate.js';
17
+ import { mapLimit, todayStr } from './util.js';
18
+
19
+ /** 按人并发上限:下属数量可能很多,限流避免打满服务端。 */
20
+ export const TEAM_REPORT_CONCURRENCY = 5;
21
+
22
+ /** 条目是否属于目标日期(workdate 优先,回退 createdate;无日期字段时按接口已按日期过滤处理)。 */
23
+ export function isReportOnDate(item, date) {
24
+ const workdate = String(item?.workdate ?? '').trim();
25
+ if (workdate)
26
+ return workdate.startsWith(date);
27
+ const createdate = String(item?.createdate ?? '').trim();
28
+ if (createdate)
29
+ return createdate.startsWith(date);
30
+ return true;
31
+ }
32
+
33
+ /**
34
+ * 批量查询下属日报。
35
+ * 返回 { date, allLevel, total, writtenCount, items, unmatched, complete }。
36
+ * items[i] = { id, name, written, count, recalled, posts, error }。
37
+ */
38
+ export async function collectSubordinateReports(cfg, { date, allLevel = false, names = [], concurrency = TEAM_REPORT_CONCURRENCY } = {}) {
39
+ const targetDate = date ?? todayStr();
40
+ const subs = await listAllSubordinates(cfg, { allLevel, names });
41
+ const items = await mapLimit(subs.items, concurrency, async (person) => {
42
+ try {
43
+ const r = await getBlogHomepageList(cfg, {
44
+ scope: '全部',
45
+ blogUserId: person.id,
46
+ startDate: targetDate,
47
+ endDate: targetDate,
48
+ });
49
+ const dayItems = r.items.filter((it) => isReportOnDate(it, targetDate));
50
+ const normal = dayItems.filter((it) => !isBlogRecalled(it));
51
+ return {
52
+ id: person.id,
53
+ name: person.name,
54
+ written: normal.length > 0,
55
+ count: normal.length,
56
+ recalled: dayItems.length - normal.length,
57
+ posts: normal,
58
+ error: null,
59
+ };
60
+ }
61
+ catch (e) {
62
+ // 单人失败不拖垮整批:保留错误,由调用方展示
63
+ return {
64
+ id: person.id, name: person.name, written: false, count: 0, recalled: 0, posts: [], error: e?.message ?? String(e),
65
+ };
66
+ }
67
+ });
68
+ return {
69
+ date: targetDate,
70
+ allLevel: !!allLevel,
71
+ total: items.length,
72
+ writtenCount: items.filter((x) => x.written).length,
73
+ items,
74
+ unmatched: subs.unmatched,
75
+ complete: subs.complete,
76
+ };
77
+ }