@oj-module/cli 0.2.47 → 0.2.49

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.
Files changed (101) hide show
  1. package/devkit/README.md +1 -1
  2. package/devkit/SKILL.md +23 -4
  3. package/devkit/framework-dev.md +2 -2
  4. package/devkit/manual.md +221 -14
  5. package/devkit/module-dev-and-cdn-playground.md +2 -2
  6. package/devkit/wecom-daikaifa.md +242 -14
  7. package/package.json +2 -2
  8. package/shell-dist/assets/runtime.js +2 -2
  9. package/shell-dist/index.html +2 -2
  10. package/shell-dist/versions.json +3 -3
  11. package/src/__tests__/build-backend.test.ts +80 -0
  12. package/src/__tests__/templates-tenant-scope.test.ts +7 -2
  13. package/src/build.ts +50 -0
  14. package/src/init.ts +1 -1
  15. package/src/market/__tests__/bootstrap.test.ts +1 -1
  16. package/src/market/__tests__/setup.test.ts +7 -7
  17. package/src/market/bootstrap.ts +1 -1
  18. package/templates/README.md +1 -1
  19. package/templates/init.include.yaml +12 -0
  20. package/templates/openapi.json +2863 -549
  21. package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
  22. package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
  23. package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
  24. package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
  25. package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
  26. package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
  27. package/templates/src/_platform/api/manifest.yaml +3 -0
  28. package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
  29. package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
  30. package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
  31. package/templates/src/_platform/api/org/departments/api.ts +51 -0
  32. package/templates/src/_platform/api/org/is-member/api.ts +33 -0
  33. package/templates/src/_platform/api/org/members/api.ts +55 -0
  34. package/templates/src/_platform/api/org/picker/api.ts +59 -0
  35. package/templates/src/_platform/api/org/resolve/api.ts +38 -0
  36. package/templates/src/_platform/api/org/tree/api.ts +37 -0
  37. package/templates/src/_platform/api/org/user/api.ts +46 -0
  38. package/templates/src/_platform/api/schema.yaml +48 -0
  39. package/templates/src/_platform/api/seed.mysql.sql +10 -5
  40. package/templates/src/_platform/api/seed.pg.sql +10 -5
  41. package/templates/src/_platform/api/seed.sql +13 -5
  42. package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
  43. package/templates/src/_platform/web/__client/api.ts +170 -1
  44. package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
  45. package/templates/src/auth/api/_shared/session.ts +5 -1
  46. package/templates/src/auth/api/_shared/wecom/admin.ts +3 -1
  47. package/templates/src/auth/api/_shared/wecom/client.ts +280 -32
  48. package/templates/src/auth/api/_shared/wecom/config.ts +12 -1
  49. package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
  50. package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
  51. package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
  52. package/templates/src/auth/api/_shared/wecom/login.ts +188 -5
  53. package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
  54. package/templates/src/auth/api/_shared/wecom/replay.ts +44 -3
  55. package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
  56. package/templates/src/auth/api/_shared/wecom/sync.ts +501 -0
  57. package/templates/src/auth/api/manifest.yaml +3 -0
  58. package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
  59. package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
  60. package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
  61. package/templates/src/auth/api/schema.yaml +31 -0
  62. package/templates/src/auth/api/wecom/admin/api.ts +3 -1
  63. package/templates/src/auth/api/wecom/api.ts +155 -35
  64. package/templates/src/auth/api/wecom/sync/api.ts +199 -0
  65. package/templates/src/auth/web/__client/api.schemas.ts +97 -0
  66. package/templates/src/auth/web/__client/api.ts +73 -1
  67. package/templates/src/login/web/locales/en-US.json +2 -0
  68. package/templates/src/login/web/locales/zh-CN.json +2 -0
  69. package/templates/src/login/web/pages/login.tsx +39 -3
  70. package/templates/src/notification/api/seed.sql +1 -1
  71. package/templates/src/system/api/departments/api.ts +142 -0
  72. package/templates/src/system/api/departments/detach/api.ts +34 -0
  73. package/templates/src/system/api/departments/members/api.ts +142 -0
  74. package/templates/src/system/api/departments/move/api.ts +39 -0
  75. package/templates/src/system/api/users/api.ts +45 -5
  76. package/templates/src/system/api/users/profile/api.ts +44 -0
  77. package/templates/src/system/web/__client/api.schemas.ts +143 -0
  78. package/templates/src/system/web/__client/api.ts +293 -1
  79. package/templates/src/system/web/entry.ts +6 -0
  80. package/templates/src/system/web/locales/en-US.json +112 -6
  81. package/templates/src/system/web/locales/zh-CN.json +112 -6
  82. package/templates/src/system/web/pages/departments/index.tsx +346 -0
  83. package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
  84. package/templates/src/system/web/pages/departments/tree.ts +66 -0
  85. package/templates/src/system/web/pages/errors.ts +1 -1
  86. package/templates/src/system/web/pages/users/index.tsx +49 -7
  87. package/templates/src/system/web/pages/wecom/index.tsx +14 -0
  88. package/templates/tests/l1-builder.test.ts +1 -1
  89. package/templates/tests/org-departments.spec.ts +311 -0
  90. package/templates/tests/org-read.spec.ts +469 -0
  91. package/templates/tests/rbac-contacts.spec.ts +13 -3
  92. package/templates/tests/rbac-seed.spec.ts +11 -0
  93. package/templates/tests/seed-role-accounts.spec.ts +6 -2
  94. package/templates/tests/wecom-admin.spec.ts +1 -0
  95. package/templates/tests/wecom-connector.spec.ts +146 -10
  96. package/templates/tests/wecom-crypt.spec.ts +12 -3
  97. package/templates/tests/wecom-endpoints.spec.ts +247 -19
  98. package/templates/tests/wecom-org-sync.spec.ts +598 -0
  99. package/templates/tests/wecom-sync.spec.ts +286 -0
  100. package/vendor/host-versions.json +3 -3
  101. package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
@@ -118,6 +118,96 @@ function assertOk(res: Record<string, unknown>, what: string): void {
118
118
 
119
119
  const TOKEN_EXPIRED = new Set([40001, 40014, 42001]);
120
120
 
121
+ /**
122
+ * 错码三分类(方案 §5.1)。同步器按 kind 决定处置,**不再一律当 502**:
123
+ * · permission:该部门不在应用权限/可见范围内 → 跳过并计数,本轮不判失败(否则一个部门无权限就整轮 failed);
124
+ * · ratelimit:指数退避重试(企微每企业 ≤1 万次/分、15 万次/小时,正常量级撞不到,撞到就是并发高了);
125
+ * · fatal:60020(出口 IP 未白名单)/ 46004(成员已删)/ 网络等——重试无用,计入熔断。
126
+ */
127
+ export type WecomErrorKind = "permission" | "ratelimit" | "fatal";
128
+
129
+ const PERMISSION_CODES = new Set([48002, 60011]);
130
+ const RATELIMIT_CODES = new Set([45009, 45033]);
131
+
132
+ export class WecomApiError extends Error {
133
+ readonly kind: WecomErrorKind;
134
+ readonly errcode: number;
135
+ constructor(what: string, errcode: number, errmsg: string) {
136
+ super(`企微 ${what} 失败:errcode=${errcode} ${errmsg}`.trimEnd());
137
+ this.name = "WecomApiError";
138
+ this.errcode = errcode;
139
+ this.kind = PERMISSION_CODES.has(errcode) ? "permission" : RATELIMIT_CODES.has(errcode) ? "ratelimit" : "fatal";
140
+ // 502:对上层仍是「上游出错」,路由信封口径不变;kind 只给同步器分流用
141
+ Object.assign(this, { status: 502 });
142
+ }
143
+ }
144
+
145
+ /** 退避用的软 sleep:isolate 无 timer 全局,拿不到 tasks 就退化成「不等待直接重试」 */
146
+ function softSleep(ms: number): Promise<void> {
147
+ const g = globalThis as unknown as { tasks?: { sleep?: (ms: number) => Promise<void> } };
148
+ const sleep = g.tasks?.sleep;
149
+ if (typeof sleep !== "function")
150
+ return Promise.resolve();
151
+ return sleep(ms).catch(() => undefined);
152
+ }
153
+
154
+ /** 通讯录部门(simplelist/list 的公共形态):企微部门 id 是数字,这里统一按字符串处理(§4.1 origin_id) */
155
+ export interface WecomDept {
156
+ id: string
157
+ parentId: string
158
+ name: string | null
159
+ nameEn: string | null
160
+ order: number
161
+ }
162
+
163
+ /** 通讯录成员(user/list 的形态):`department` / `order` / `is_leader_in_dept` 逐位对应 */
164
+ export interface WecomMember {
165
+ userid: string
166
+ name: string | null
167
+ departments: string[]
168
+ orders: number[]
169
+ leaderInDept: number[]
170
+ mainDepartment: number | null
171
+ position: string | null
172
+ /** 1=已激活 2=已禁用 4=未激活 5=退出企业(§4.3,只存快照不改 is_forbidden) */
173
+ status: number | null
174
+ }
175
+
176
+ /**
177
+ * 授权信息里的应用列表(`auth_info.agent`)——`get_permanent_code` 与 `get_auth_info` 同形,
178
+ * 抽出来是为了两处对 auth_mode 的读法永远一致(同步闸门认的就是这个值)。
179
+ *
180
+ * **数组与单对象都认**:官方示例是数组,但 2026-11-01 的生产回包在代开发 v2 上给的是空列表
181
+ * (见 permanentCodeByAuthCode 的回源兜底)。单对象形态认下来没有代价,少一次「明明有应用却
182
+ * 判成没授权」的误杀。
183
+ */
184
+ function agentListOf(authInfo: unknown): Array<Record<string, unknown>> {
185
+ const list = (authInfo as Record<string, unknown> | undefined)?.agent;
186
+ if (Array.isArray(list))
187
+ return list.map(a => a as Record<string, unknown>);
188
+ if (list && typeof list === "object")
189
+ return [list as Record<string, unknown>];
190
+ return [];
191
+ }
192
+
193
+ /**
194
+ * agent_id → auth_mode。auth_mode 在 agent 项上(每个应用各自一条);老回包里偶尔只在
195
+ * auth_info 顶层给,故两级都读,缺省按 0(管理员授权)——成员授权会导致同步 100% 60011,
196
+ * 不能靠猜成 1。
197
+ */
198
+ function readAuthModes(authInfo: unknown): Record<string, number> {
199
+ const top = (authInfo as Record<string, unknown> | undefined)?.auth_mode;
200
+ const modes: Record<string, number> = {};
201
+ for (const a of agentListOf(authInfo)) {
202
+ const agentId = String(a.agentid ?? "");
203
+ if (!agentId)
204
+ continue;
205
+ const mode = Number(a.auth_mode ?? top ?? 0);
206
+ modes[agentId] = Number.isFinite(mode) ? mode : 0;
207
+ }
208
+ return modes;
209
+ }
210
+
121
211
  export function createWecomClient(deps: WecomClientDeps) {
122
212
  const { http, cache } = deps;
123
213
 
@@ -156,28 +246,72 @@ export function createWecomClient(deps: WecomClientDeps) {
156
246
  corpId: string
157
247
  corpName: string | null
158
248
  agentIds: string[]
249
+ /** agent_id → auth_mode(0=管理员授权 / 1=成员授权);成员授权下应用无任何部门权限(§2.3) */
250
+ authModes: Record<string, number>
159
251
  state: string | null
160
252
  }> {
161
253
  const res = await http.postJson(`${API_BASE}/cgi-bin/service/v2/get_permanent_code?suite_access_token=${encodeURIComponent(suiteAccessToken)}`, { auth_code: authCode });
162
254
  assertOk(res, "get_permanent_code");
163
255
  const authCorp = (res.auth_corp_info ?? {}) as Record<string, unknown>;
164
- const agentList = ((res.auth_info ?? {}) as Record<string, unknown>).agent;
165
- const agentIds = Array.isArray(agentList)
166
- ? agentList.map(a => String((a as Record<string, unknown>).agentid)).filter(Boolean)
167
- : [];
168
256
  if (!res.permanent_code || !authCorp.corpid)
169
257
  throw upstream("企微 get_permanent_code 返回缺少 permanent_code / corpid");
258
+ let authInfo = res.auth_info;
259
+ let agents = agentListOf(authInfo);
260
+ if (agents.length === 0) {
261
+ // 代开发 v2 出现过「永久码拿到了、auth_info.agent 却是空的」(2026-11-01 生产日志)。
262
+ // 此时别急着判死:永久码已经在手,`get_auth_info` 是同形的复核接口,能补回应用列表。
263
+ // 只记字段名,不记值——回包里没有任何可公开的东西。
264
+ log.warn(
265
+ "wecom agent list missing in get_permanent_code",
266
+ "keys",
267
+ Object.keys(res).join(","),
268
+ "authInfoKeys",
269
+ authInfo && typeof authInfo === "object" ? Object.keys(authInfo as Record<string, unknown>).join(",") : typeof authInfo,
270
+ );
271
+ try {
272
+ authInfo = await authInfoByPermanentCode(suiteAccessToken, String(authCorp.corpid), String(res.permanent_code));
273
+ agents = agentListOf(authInfo);
274
+ }
275
+ catch (e) {
276
+ log.warn("wecom get_auth_info fallback failed", "reason", (e as Error).message);
277
+ }
278
+ }
279
+ const agentIds = agents.map(a => String(a.agentid)).filter(Boolean);
280
+ // 列表有元素却取不到 agentid:字段被改名的信号,记下键名(不记值)省下一次盲猜
281
+ if (agents.length > 0 && agentIds.length === 0)
282
+ log.warn("wecom agent item has no agentid", "keys", Object.keys(agents[0]!).join(","));
170
283
  if (agentIds.length === 0)
171
- throw upstream("企微 get_permanent_code 未返回授权应用(auth_info.agent 为空)");
284
+ throw upstream("企微 get_permanent_code 未返回授权应用(auth_info.agent 为空,get_auth_info 复核也没有)");
172
285
  return {
173
286
  permanentCode: String(res.permanent_code),
174
287
  corpId: String(authCorp.corpid),
175
288
  corpName: authCorp.corp_name == null ? null : String(authCorp.corp_name),
176
289
  agentIds,
290
+ authModes: readAuthModes(authInfo),
177
291
  state: res.state == null ? null : String(res.state),
178
292
  };
179
293
  }
180
294
 
295
+ /**
296
+ * 复核授权模式(`get_auth_info`):`change_auth` 事件只带 AuthCorpId、**不带 AuthCode**,
297
+ * 所以刷新 auth_mode 只能拿已存的 permanent_code 反查。企业管理员在后台把「管理员授权」
298
+ * 切成「成员授权」(或反向)就靠这条路径同步——切过去以后部门接口一律 60011,
299
+ * 不刷新就会一直按「可同步」对待。
300
+ */
301
+ async function authModesByPermanentCode(suiteAccessToken: string, corpId: string, permanentCode: string): Promise<Record<string, number>> {
302
+ return readAuthModes(await authInfoByPermanentCode(suiteAccessToken, corpId, permanentCode));
303
+ }
304
+
305
+ /** `get_auth_info` 的原始 `auth_info`:授权模式刷新与「永久码回包缺 agent」的回源兜底共用 */
306
+ async function authInfoByPermanentCode(suiteAccessToken: string, corpId: string, permanentCode: string): Promise<unknown> {
307
+ const res = await http.postJson(`${API_BASE}/cgi-bin/service/get_auth_info?suite_access_token=${encodeURIComponent(suiteAccessToken)}`, {
308
+ auth_corpid: corpId,
309
+ permanent_code: permanentCode,
310
+ });
311
+ assertOk(res, "get_auth_info");
312
+ return res.auth_info;
313
+ }
314
+
181
315
  /**
182
316
  * 服务商级 token(provider_access_token):代开发「生成授权链接」用,与套件 token 不同源。
183
317
  * 由服务商 corpid + provider_secret 换得,全部署共享一份(不限 suite)。
@@ -239,6 +373,127 @@ export function createWecomClient(deps: WecomClientDeps) {
239
373
  await cache.del(`wecom:corp_token:${corpId}:${agentId}`);
240
374
  }
241
375
 
376
+ /**
377
+ * 通讯录类 GET 的公共通道:token 失效重取 + 三类错码分流。
378
+ *
379
+ * 重试上限是有意的:token 失效最多换两次(企微侧提前失效是常态),限流退避最多三次
380
+ * (500ms/1s/2s),其余错码立即抛——同步器靠 kind 决定「跳过并计数」还是「计入熔断」,
381
+ * 所以**必须把 errcode 带出去**,不能像 assertOk 那样拍平成一句中文。
382
+ */
383
+ async function corpGet(
384
+ corpId: string,
385
+ agentId: string,
386
+ secret: string,
387
+ path: string,
388
+ params: Record<string, string | number | undefined>,
389
+ what: string,
390
+ ): Promise<Record<string, unknown>> {
391
+ const qs = Object.entries(params)
392
+ .filter(([, v]) => v != null && v !== "")
393
+ .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
394
+ .join("&");
395
+ for (let attempt = 0; ; attempt++) {
396
+ const token = await corpAccessToken(corpId, agentId, secret);
397
+ const res = await http.getJson(`${API_BASE}${path}?access_token=${encodeURIComponent(token)}${qs ? `&${qs}` : ""}`);
398
+ const errcode = Number(res.errcode ?? 0);
399
+ if (!errcode)
400
+ return res;
401
+ if (TOKEN_EXPIRED.has(errcode) && attempt < 2) {
402
+ await invalidateCorpToken(corpId, agentId);
403
+ continue;
404
+ }
405
+ const err = new WecomApiError(what, errcode, String(res.errmsg ?? ""));
406
+ if (err.kind === "ratelimit" && attempt < 2) {
407
+ await softSleep(500 * 2 ** attempt);
408
+ continue;
409
+ }
410
+ throw err;
411
+ }
412
+ }
413
+
414
+ function toDept(raw: Record<string, unknown>): WecomDept {
415
+ return {
416
+ id: String(raw.id ?? ""),
417
+ parentId: raw.parentid == null ? "" : String(raw.parentid),
418
+ name: raw.name == null ? null : String(raw.name),
419
+ nameEn: raw.name_en == null ? null : String(raw.name_en),
420
+ order: Number(raw.order ?? 0),
421
+ };
422
+ }
423
+
424
+ /**
425
+ * 子部门 id 列表(接口 1):全量同步的**主路径**——官方 90208 明写 `department/list` 性能低。
426
+ * `parentOriginId` 缺省时企微返回「应用可见范围内所有部门」,这既是 BFS 的起点,也是兜底路径。
427
+ */
428
+ async function departmentChildren(corpId: string, agentId: string, secret: string, parentOriginId?: string): Promise<WecomDept[]> {
429
+ const res = await corpGet(corpId, agentId, secret, "/cgi-bin/department/simplelist", { id: parentOriginId }, "department/simplelist");
430
+ const list = Array.isArray(res.department_id) ? res.department_id as Array<Record<string, unknown>> : [];
431
+ return list.filter(d => d && d.id != null).map(toDept);
432
+ }
433
+
434
+ /** 单部门详情(接口 2):simplelist 不带名字,名字只能从这里取(未授权时 name 为空) */
435
+ async function departmentDetail(corpId: string, agentId: string, secret: string, originId: string): Promise<WecomDept> {
436
+ const res = await corpGet(corpId, agentId, secret, "/cgi-bin/department/get", { id: originId }, "department/get");
437
+ const d = res.department as Record<string, unknown> | undefined;
438
+ if (!d || d.id == null)
439
+ throw upstream("企微 department/get 未返回部门详情");
440
+ return toDept(d);
441
+ }
442
+
443
+ /** 一次拿全量部门(接口 3):降级兜底,1/2 因权限拿不到时用 */
444
+ async function departmentList(corpId: string, agentId: string, secret: string, parentOriginId?: string): Promise<WecomDept[]> {
445
+ const res = await corpGet(corpId, agentId, secret, "/cgi-bin/department/list", { id: parentOriginId }, "department/list");
446
+ const list = Array.isArray(res.department) ? res.department as Array<Record<string, unknown>> : [];
447
+ return list.filter(d => d && d.id != null).map(toDept);
448
+ }
449
+
450
+ function toMember(raw: Record<string, unknown>, detail: boolean): WecomMember {
451
+ const departments = Array.isArray(raw.department) ? raw.department.map(d => String(d)) : [];
452
+ const orders = detail && Array.isArray(raw.order) ? raw.order.map(o => Number(o)) : [];
453
+ const leader = detail && Array.isArray(raw.is_leader_in_dept) ? raw.is_leader_in_dept.map(o => Number(o)) : [];
454
+ return {
455
+ userid: String(raw.userid ?? ""),
456
+ name: raw.name == null ? null : String(raw.name),
457
+ departments,
458
+ orders,
459
+ leaderInDept: leader,
460
+ mainDepartment: detail && raw.main_department != null ? Number(raw.main_department) : null,
461
+ position: detail && raw.position != null ? String(raw.position) : null,
462
+ status: detail && raw.status != null ? Number(raw.status) : null,
463
+ };
464
+ }
465
+
466
+ /** 部门成员(接口 4,轻量):`user/simplelist` 无 fetch_child,子部门要调用方自己递归 */
467
+ async function deptMembers(corpId: string, agentId: string, secret: string, deptOriginId: string): Promise<WecomMember[]> {
468
+ const res = await corpGet(corpId, agentId, secret, "/cgi-bin/user/simplelist", { department_id: deptOriginId }, "user/simplelist");
469
+ const list = Array.isArray(res.userlist) ? res.userlist as Array<Record<string, unknown>> : [];
470
+ return list.filter(u => u && u.userid != null).map(u => toMember(u, false));
471
+ }
472
+
473
+ /** 部门成员详情(接口 5,重量):职务 / 排序 / 主部门 / 在职状态只有这里有;同一部门只该调一次 */
474
+ async function deptMembersDetail(corpId: string, agentId: string, secret: string, deptOriginId: string): Promise<WecomMember[]> {
475
+ const res = await corpGet(corpId, agentId, secret, "/cgi-bin/user/list", { department_id: deptOriginId }, "user/list");
476
+ const list = Array.isArray(res.userlist) ? res.userlist as Array<Record<string, unknown>> : [];
477
+ return list.filter(u => u && u.userid != null).map(u => toMember(u, true));
478
+ }
479
+
480
+ /**
481
+ * 单成员详情(接口 6):**登录自更新**用它,四次/次调用就够。
482
+ *
483
+ * best-effort:任何失败(无权限 / 不在可见范围 / 上游抖)返回 null,绝不抛出——
484
+ * 补资料是体验优化,不能因为拿不到把登录打成 5xx。调用方拿到 null 就保留兜底值。
485
+ */
486
+ async function userDetail(corpId: string, agentId: string, secret: string, userid: string): Promise<WecomMember | null> {
487
+ try {
488
+ const res = await corpGet(corpId, agentId, secret, "/cgi-bin/user/get", { userid }, "user/get");
489
+ // 回包偶尔不带 userid(老接口/精简回包):入参就是权威,用入参补
490
+ return toMember({ ...res, userid: res.userid ?? userid }, true);
491
+ }
492
+ catch {
493
+ return null;
494
+ }
495
+ }
496
+
242
497
  /**
243
498
  * 成员姓名(best-effort):只用于把 JIT 号的 `display_name` 从工号变成真名。
244
499
  *
@@ -248,32 +503,9 @@ export function createWecomClient(deps: WecomClientDeps) {
248
503
  * token 复用 `userIdByCode` 刚取的那份缓存(同请求内通常 0 次额外换票)。
249
504
  */
250
505
  async function userNameById(corpId: string, agentId: string, secret: string, userid: string): Promise<string | null> {
251
- for (let attempt = 0; attempt < 2; attempt++) {
252
- let token: string;
253
- try {
254
- token = await corpAccessToken(corpId, agentId, secret);
255
- }
256
- catch {
257
- return null;
258
- }
259
- let res: Record<string, unknown>;
260
- try {
261
- res = await http.getJson(`${API_BASE}/cgi-bin/user/get?access_token=${encodeURIComponent(token)}&userid=${encodeURIComponent(userid)}`);
262
- }
263
- catch {
264
- return null;
265
- }
266
- const errcode = Number(res.errcode ?? 0);
267
- if (TOKEN_EXPIRED.has(errcode) && attempt === 0) {
268
- await invalidateCorpToken(corpId, agentId);
269
- continue;
270
- }
271
- if (errcode !== 0)
272
- return null;
273
- const name = res.name == null ? "" : String(res.name).trim();
274
- return name || null;
275
- }
276
- return null;
506
+ const detail = await userDetail(corpId, agentId, secret, userid);
507
+ const name = detail?.name?.trim();
508
+ return name || null;
277
509
  }
278
510
 
279
511
  /** 网页授权 code → userid(snsapi_base;非企业成员/超出可见范围 → 401) */
@@ -296,7 +528,23 @@ export function createWecomClient(deps: WecomClientDeps) {
296
528
  throw upstream("企微 token 反复失效(40001/42001)");
297
529
  }
298
530
 
299
- return { suiteAccessToken, permanentCodeByAuthCode, providerAccessToken, customizedAuthUrl, corpAccessToken, invalidateCorpToken, userIdByCode, userNameById };
531
+ return {
532
+ suiteAccessToken,
533
+ permanentCodeByAuthCode,
534
+ authModesByPermanentCode,
535
+ providerAccessToken,
536
+ customizedAuthUrl,
537
+ corpAccessToken,
538
+ invalidateCorpToken,
539
+ userIdByCode,
540
+ userNameById,
541
+ userDetail,
542
+ departmentChildren,
543
+ departmentDetail,
544
+ departmentList,
545
+ deptMembers,
546
+ deptMembersDetail,
547
+ };
300
548
  }
301
549
 
302
550
  export type WecomClient = ReturnType<typeof createWecomClient>;
@@ -153,7 +153,18 @@ export function readWecomConfig(): WecomConfig {
153
153
  return v;
154
154
  }
155
155
 
156
- /** 企微后台「指令回调 URL」:原样填这个(base + /event) */
156
+ /**
157
+ * 企微后台「指令回调 URL / 应用回调 URL」:原样填这个(base + `/event`)。
158
+ *
159
+ * **不带 `?callback_format=json`**——查过官方文档才这么定的:该参数只在「对外收款」的指令回调
160
+ * 文档(97478)里有说明,而代开发 / 第三方应用所属的**通用回调配置**(96238 §3.2)至今只写
161
+ * XML 外层信封(`<ToUserName><AgentID><Encrypt>`),全文没有这个参数;「部门通知事件」(92655)
162
+ * 虽给了 JSON 示例,但示例地址同样不带它,也没说怎么切过去。
163
+ *
164
+ * 也就是说:给我们这类回调加上它,是**文档未覆盖**的行为。省下的只是服务端一次正则,
165
+ * 代价却是把「企业管理员扫码能否装成功」押在上游实现上。服务端两种包体都认(payload.ts),
166
+ * 上游哪天默认改 JSON 也不用动这里。
167
+ */
157
168
  export function eventUrlOf(cfg: WecomConfig): string {
158
169
  return `${cfg.baseUrl}/event`;
159
170
  }
@@ -0,0 +1,263 @@
1
+ import type { WecomClient, WecomDept } from "./client";
2
+ import type { MemberDetail } from "./login";
3
+ import type { CallbackFields } from "./payload";
4
+ import type { WecomStoreDeps } from "./store";
5
+ // 轨三:通讯录变更事件增量(`change_contact`)。
6
+ //
7
+ // 定位是**加速器,不是权威**(方案 §6.3):权威永远是全量同步。事件只负责「管理员刚改完,
8
+ // 页面上马上能看见」,所以这里的纪律与全量同步正好相反——
9
+ // · 任何失败都**不抛**(`handleContactChange` 全包在 try/catch 里),照旧回 success。
10
+ // 企微拿不到 success 会重推三次,而重推补不回一次已经判死的失败,只会让企业管理员
11
+ // 在后台看到「应用安装失败」;真正的兜底是下一轮全量同步。
12
+ // · 只记 `ChangeType` / 企业 ID / 失败原因,**不记姓名与工号**(明文里有)。
13
+ //
14
+ // 两种报文形态都认(归一化在 payload.ts):
15
+ // · 应用侧数据回调(90970):`ToUserName`(=corpid) / `FromUserName=sys` / `MsgType=event` /
16
+ // `Event=change_contact` / `ChangeType` / `UserID` / `Name` / `Department` / `MainDepartment` /
17
+ // `IsLeaderInDept` / `Position`;
18
+ // · 代开发指令回调(91134):`SuiteId` / `AuthCorpId` / `InfoType=change_contact` /
19
+ // `ChangeType` / `UserID`(或部门 `Id`)。它**只给 id**,姓名与归属得回源 `user/get` /
20
+ // `department/get` 拿——这就是「事件里为什么还要出一次网」的全部原因。
21
+ //
22
+ // 写库一律复用轨一同一套函数(login.ts::applyMemberDetail):三套写库逻辑必然长歪。
23
+ import { markDeleted, upsertDepartment } from "#/_platform/_shared/service/domain/org";
24
+ import { upsertSourceUser } from "#/_platform/_shared/service/domain/users";
25
+ import { applyMemberDetail, externalUsername } from "./login";
26
+ import { field, requireField } from "./payload";
27
+ import { ensureTenant, findCorpAuth, tenantOfCorp } from "./store";
28
+
29
+ /** 归属权标识(D13):与 sync.ts 的 ORIGIN 同值,事件与同步必须写同一批行 */
30
+ const ORIGIN = "wecom";
31
+
32
+ export interface ContactEventDeps {
33
+ client: WecomClient
34
+ store: WecomStoreDeps
35
+ }
36
+
37
+ /** 一条事件的执行上下文:企业已授权 → 应用 与 永久码(= 企业应用 secret) */
38
+ interface CorpCtx {
39
+ corpId: string
40
+ agentId: string
41
+ secret: string
42
+ now: () => number
43
+ client: WecomClient
44
+ }
45
+
46
+ /**
47
+ * 事件入口:**永不抛出**,返回值只用于日志(`handleSuiteEvent` 原样回给调用方记一行)。
48
+ *
49
+ * 企业身份两处取:应用侧数据回调放在 `ToUserName`(corpid),代开发指令回调放在 `AuthCorpId`。
50
+ * 两者都空时无从判断租户,直接记一笔放弃——猜一个默认企业比不处理更危险。
51
+ */
52
+ export async function handleContactChange(fields: CallbackFields, deps: ContactEventDeps): Promise<string> {
53
+ const changeType = field(fields, "ChangeType");
54
+ const corpId = field(fields, "AuthCorpId") || field(fields, "ToUserName");
55
+ if (!changeType) {
56
+ log.info("wecom change_contact skipped", "reason", "报文里没有 ChangeType");
57
+ return "change_contact";
58
+ }
59
+ try {
60
+ const handled = await applyChange(changeType, corpId, fields, deps);
61
+ if (handled)
62
+ log.info("wecom change_contact applied", "changeType", changeType, "corpId", corpId);
63
+ else
64
+ log.info("wecom change_contact not consumed", "changeType", changeType, "corpId", corpId);
65
+ }
66
+ catch (e) {
67
+ log.warn("wecom change_contact ignored", "changeType", changeType, "corpId", corpId, "reason", (e as Error).message);
68
+ }
69
+ return changeType;
70
+ }
71
+
72
+ /** 返回 true = 本 ChangeType 被消费;false = 不认识(标签变更等),记账即可 */
73
+ async function applyChange(changeType: string, corpId: string, fields: CallbackFields, deps: ContactEventDeps): Promise<boolean> {
74
+ if (!corpId)
75
+ throw new Error("报文里没有企业身份(ToUserName / AuthCorpId 皆空)");
76
+ const auth = await findCorpAuth(corpId, deps.store);
77
+ if (!auth)
78
+ throw new Error("该企业未授权本站(wecom_app_profile 无记录)");
79
+ const ctx: CorpCtx = {
80
+ corpId: auth.corpId,
81
+ // 永久码是 (suite, corp) 级的,应用只决定 token 缓存键,任取一个已授权应用即可
82
+ agentId: auth.agentIds[0] ?? "",
83
+ secret: auth.permanentCode,
84
+ now: deps.store.now,
85
+ client: deps.client,
86
+ };
87
+ // 顺序刚性:先绑租户再碰 users / sys_department / sys_dept_member——
88
+ // 本端点匿名(企微带不了 X-TENANT-ID),`sql_guard: deny` 下没有租户上下文的写会被直接拒。
89
+ ensureTenant(tenantOfCorp(auth.corpId));
90
+
91
+ switch (changeType) {
92
+ case "create_user":
93
+ case "update_user":
94
+ await applyUserEvent(fields, ctx);
95
+ return true;
96
+ case "delete_user":
97
+ await removeUserEvent(fields);
98
+ return true;
99
+ case "create_party":
100
+ case "update_party":
101
+ await applyDeptEvent(fields, ctx);
102
+ return true;
103
+ case "delete_party":
104
+ // `deleted=1` 的唯一写入口(子部门一并标记,见 org.ts::markDeleted)
105
+ await markDeleted(ORIGIN, requireField(fields, "Id", "change_contact"), { now: ctx.now });
106
+ return true;
107
+ default:
108
+ return false;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * 成员新增/变更。事件自带姓名与部门,但**不带排序、不带在职状态**,代开发形态甚至只有一个
114
+ * `UserID`——所以统一回源 `user/get` 拿全量快照;拿不到(无通讯录权限/不在可见范围/上游抖)
115
+ * 才退回事件自带字段,缺的字段不写(`undefined` 与 `null` 在 applyMemberDetail 里语义不同)。
116
+ */
117
+ async function applyUserEvent(fields: CallbackFields, ctx: CorpCtx): Promise<void> {
118
+ const userid = requireField(fields, "UserID", "change_contact");
119
+ // userid 变更与建档是两件事:先把旧行改名(同一行),再按新号补资料
120
+ const newUserid = field(fields, "NewUserID");
121
+ if (newUserid && newUserid !== userid)
122
+ await renameUser(userid, newUserid);
123
+ const target = newUserid || userid;
124
+
125
+ const detail = await ctx.client.userDetail(ctx.corpId, ctx.agentId, ctx.secret, target);
126
+ const eventName = field(fields, "Name").trim();
127
+ const uid = await upsertSourceUser({
128
+ username: externalUsername(target),
129
+ displayName: detail?.name?.trim() || eventName || null,
130
+ source: ORIGIN,
131
+ corpId: ctx.corpId,
132
+ });
133
+ // activated 不置位:建档 ≠ 用过本站(§9.1 的通讯录目录只收登录过的人)
134
+ await applyMemberDetail({ uid, userid: target }, detail ?? memberFromEvent(fields), { now: ctx.now });
135
+ }
136
+
137
+ /**
138
+ * userid 变更 = **同一行的改名**,不是新建(方案 §6.1):否则旧号残留,而旧号还能登录。
139
+ *
140
+ * 新号已有本地行时**不合并**,抛给上层记一笔放弃:两行合并是「谁是真身」的判断题
141
+ * (角色、历史操作、文件归属都在旧行上),交给全量同步与人工,不在回调的 1 秒窗口里赌。
142
+ */
143
+ async function renameUser(fromUserid: string, toUserid: string): Promise<void> {
144
+ const rows = await db.table("users").select(["id"]).where({ and: [
145
+ { field: "username", op: "eq", value: externalUsername(fromUserid) },
146
+ { field: "source", op: "eq", value: ORIGIN },
147
+ ] }).all() as Array<{ id: number }>;
148
+ if (!rows.length)
149
+ return; // 旧号没建过档(全量同步还没跑到):按新号走建档即可
150
+ const taken = await db.table("users").select(["id"]).where({ field: "username", op: "eq", value: externalUsername(toUserid) }).all();
151
+ if (taken.length)
152
+ throw new Error("新 userid 已有本地账号,跳过改名(交给全量同步/人工处理)");
153
+ const uid = Number(rows[0]!.id);
154
+ await db.table("users").update({ username: externalUsername(toUserid) }).where({ field: "id", op: "eq", value: uid }).run();
155
+ await revokeSessions(uid);
156
+ }
157
+
158
+ /**
159
+ * 退出企业:**不删账号**(审计、角色、历史引用都挂在 `users.id` 上),只把外部状态置 5 并摘掉
160
+ * 部门关联;`is_forbidden` 一律不动(那是本站管理员的手动启停,不能被 IdP 覆盖)。
161
+ *
162
+ * 保留期结束后的匿名化要靠定时清扫(方案 §9.3,D 阶段才有调度器):现在写一个「删除」只会
163
+ * 得到一行立即过期的数据,所以这里刻意不做。
164
+ */
165
+ async function removeUserEvent(fields: CallbackFields): Promise<void> {
166
+ const userid = requireField(fields, "UserID", "change_contact");
167
+ const rows = await db.table("users").select(["id"]).where({ and: [
168
+ { field: "username", op: "eq", value: externalUsername(userid) },
169
+ { field: "source", op: "eq", value: ORIGIN },
170
+ ] }).all() as Array<{ id: number }>;
171
+ if (!rows.length)
172
+ return; // 没建过档就没什么可摘,等下一轮全量
173
+ const uid = Number(rows[0]!.id);
174
+ await db.tx(async (t) => {
175
+ await t.table("sys_dept_member").where({ field: "user_id", op: "eq", value: uid }).delete().run();
176
+ await t.table("users").update({ external_status: 5 }).where({ field: "id", op: "eq", value: uid }).run();
177
+ });
178
+ await revokeSessions(uid);
179
+ }
180
+
181
+ /**
182
+ * 部门新增/变更。
183
+ *
184
+ * 父级有**三态**,判断顺序不能换:上游详情 > 事件自带 `ParentId` > 保持本地现值。
185
+ * 最关键的是第三态——事件与上游都没给父级时**绝不能写成 null**,那会把整棵子树拍平到根,
186
+ * 而企微的 `ParentId` 为空串时既可能表示「根部门」也可能表示「这条报文没带」,光看值分不出来,
187
+ * 所以只在确认上游详情拿到时才敢按空处理(详情里的空就是根)。
188
+ */
189
+ async function applyDeptEvent(fields: CallbackFields, ctx: CorpCtx): Promise<void> {
190
+ const originId = requireField(fields, "Id", "change_contact");
191
+ let detail: WecomDept | null = null;
192
+ try {
193
+ detail = await ctx.client.departmentDetail(ctx.corpId, ctx.agentId, ctx.secret, originId);
194
+ }
195
+ catch (e) {
196
+ // 无权限/不在可见范围是常态:退回事件自带字段,再不行就只改名字
197
+ log.warn("wecom dept detail skipped", "originId", originId, "reason", (e as Error).message);
198
+ }
199
+
200
+ const local = await localDept(originId);
201
+ let name: string | null = detail?.name ?? null;
202
+ if (!name)
203
+ name = field(fields, "Name").trim() || local?.name || null;
204
+ if (!name)
205
+ throw new Error("部门事件既没有名字、本地也没有这一行:不建无名部门");
206
+
207
+ let parentLocal: number | null = local?.parentId ?? null;
208
+ if (detail)
209
+ parentLocal = detail.parentId ? await localDeptId(detail.parentId) : null;
210
+ else if (fields.values.has("parentid"))
211
+ parentLocal = field(fields, "ParentId") ? await localDeptId(field(fields, "ParentId")) : null;
212
+
213
+ await upsertDepartment({
214
+ origin: ORIGIN,
215
+ originId,
216
+ parentId: parentLocal,
217
+ name,
218
+ orderNo: detail?.order ?? Number(field(fields, "Order") || 0),
219
+ }, { now: ctx.now });
220
+ }
221
+
222
+ /**
223
+ * 事件自带的成员字段。代开发形态只有 UserID,这里会退化成「只建档、不写组织关系」——
224
+ * 那没关系:下一次全量同步会把该有的都补上,而事件本来只是加速器。
225
+ */
226
+ function memberFromEvent(fields: CallbackFields): MemberDetail {
227
+ const main = Number(field(fields, "MainDepartment"));
228
+ const position = field(fields, "Position").trim();
229
+ return {
230
+ name: field(fields, "Name").trim() || null,
231
+ // 空串 = 这条报文没给:不写(否则会把已知职务清成 null)
232
+ position: position || undefined,
233
+ status: undefined,
234
+ departments: splitIds(field(fields, "Department")),
235
+ orders: [],
236
+ leaderInDept: splitIds(field(fields, "IsLeaderInDept")).map(v => Number(v)),
237
+ mainDepartment: Number.isFinite(main) && main > 0 ? main : null,
238
+ };
239
+ }
240
+
241
+ /** 多值字段分隔符:企微用 `|`;逗号一并认,免得哪天换格式时静默只取到第一个 */
242
+ function splitIds(raw: string): string[] {
243
+ return raw.split(/[|,]/).map(s => s.trim()).filter(Boolean);
244
+ }
245
+
246
+ async function localDept(originId: string): Promise<{ id: number, parentId: number | null, name: string | null } | null> {
247
+ const rows = await db.table("sys_department").select(["id", "parent_id", "name"]).where({ and: [
248
+ { field: "origin", op: "eq", value: ORIGIN },
249
+ { field: "origin_id", op: "eq", value: originId },
250
+ ] }).all() as Array<{ id: number, parent_id: number | null, name: string | null }>;
251
+ const r = rows[0];
252
+ return r ? { id: Number(r.id), parentId: r.parent_id == null ? null : Number(r.parent_id), name: r.name } : null;
253
+ }
254
+
255
+ async function localDeptId(originId: string): Promise<number | null> {
256
+ const row = await localDept(originId);
257
+ return row ? row.id : null;
258
+ }
259
+
260
+ /** 删该用户全部 refresh 记录(与 session.ts::revokeAll 同语义,这里不构造 session 免得带进登录依赖) */
261
+ async function revokeSessions(uid: number): Promise<void> {
262
+ await db.table("sys_refresh_token").where({ field: "user_id", op: "eq", value: uid }).delete().run();
263
+ }