@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
@@ -13,6 +13,11 @@
13
13
  // 2. nonce 幂等:kv.incr 原子计数,首次才写入 TTL。计数而不是读改写,是为了
14
14
  // 「两个并发同 nonce」也只有一个拿到 1(oj 的 kv 在配了 Redis 时是多实例共享的)。
15
15
  //
16
+ // ⚡ 幂等是「处理成功」才算数:拿到的是一次**占用**(ReplayClaim),调用方处理失败必须
17
+ // `release()` 放回。不放回的后果不是理论问题——企微只在「拿不到 success」时重推,而那正是
18
+ // 需要重推的时刻;占用不放,重推会被自己的重放守卫判成 400,恢复路径当场死掉
19
+ // (2026-11-01 生产日志:create_auth 因上游 502 失败 → 重推 400「nonce 重复」)。
20
+ //
16
21
  // 两条都回调 400:它不是「本站要鉴权」的问题,401 会把排查带偏。
17
22
  const REPLAY_WINDOW_SECS = 5 * 60;
18
23
  /** 计数键存活时间:只要盖住重放窗口即可,避免 kv 无界增长 */
@@ -22,6 +27,8 @@ export interface ReplayGate {
22
27
  /** 原子自增并返回新值(kv.incr 同语义) */
23
28
  incr: (key: string) => Promise<number>
24
29
  expire: (key: string, ttlSecs: number) => Promise<unknown>
30
+ get: (key: string) => Promise<string | null>
31
+ set: (key: string, ttlSecs: number) => Promise<unknown>
25
32
  del: (key: string) => Promise<unknown>
26
33
  now: () => number
27
34
  }
@@ -35,16 +42,36 @@ export function runtimeReplayGate(): ReplayGate {
35
42
  return {
36
43
  incr: key => kv.incr(key),
37
44
  expire: (key, ttlSecs) => kv.expire(key, ttlSecs),
45
+ get: key => kv.get(key),
46
+ set: async (key, ttlSecs) => { await kv.set(key, "1"); if (!(await kv.expire(key, ttlSecs))) { log.warn("wecom applied marker ttl not set", "key", key); await kv.del(key); } },
38
47
  del: key => kv.del(key),
39
48
  now: () => Math.floor(Date.now() / 1000),
40
49
  };
41
50
  }
42
51
 
52
+ /** 一次 nonce 占用:处理成功就不必管(TTL 到期自然消失);失败必须 `release()` */
53
+ export interface ReplayClaim {
54
+ release: () => Promise<void>
55
+ }
56
+
57
+ /** 重推且首次已成功落库:抛出后由调用方直接回 success(幂等),不让企微卡在「应用安装失败」。 */
58
+ export class ReplayAlreadyApplied extends Error {
59
+ constructor() { super("nonce 已成功处理(幂等成功)"); }
60
+ }
61
+
62
+ /** 处理成功后调用:标记该 nonce 已落库成功,供重推幂等回 success(避免首次偏慢被企微判超时、重推又被重放守卫挡成 400)。 */
63
+ export async function markApplied(nonce: string, gate: ReplayGate): Promise<void> {
64
+ await gate.set("wecom:applied:" + nonce, NONCE_TTL_SECS);
65
+ }
66
+
43
67
  /**
44
- * 校验一次回调的时效与唯一性。通过 => void;不通过 => 抛 400。
68
+ * 校验一次回调的时效与唯一性。通过 => 返回占用凭据;不通过 => 抛 400。
45
69
  * 调用时机:**签名校验之后、解密之前**(先验签避免给未鉴权的请求写 kv,再拦重放省一次解密)。
70
+ *
71
+ * 占用是「这次报文归我处理」,不是「这条报文已经处理成功」——所以失败路径必须放回:
72
+ * 企微的重推是瞬态故障唯一的恢复手段,被自己的重放守卫挡掉等于把恢复路径拆了。
46
73
  */
47
- export async function guardReplay(timestampRaw: string, nonce: string, gate: ReplayGate): Promise<void> {
74
+ export async function guardReplay(timestampRaw: string, nonce: string, gate: ReplayGate): Promise<ReplayClaim> {
48
75
  const ts = Number(timestampRaw);
49
76
  if (!Number.isInteger(ts) || ts <= 0)
50
77
  fail(400, "回调 timestamp 非法");
@@ -55,12 +82,26 @@ export async function guardReplay(timestampRaw: string, nonce: string, gate: Rep
55
82
 
56
83
  const key = `wecom:nonce:${nonce}`;
57
84
  const seen = Number(await gate.incr(key));
58
- if (seen !== 1)
85
+ if (seen !== 1) {
86
+ // 重推场景:若首次处理已成功落库(标了 applied 标记),直接幂等回 success,
87
+ // 不让企微卡在「应用安装失败」。未标记者说明首推仍在进行或已失败(失败会 release 让重推重放),维持 400。
88
+ const applied = await gate.get("wecom:applied:" + nonce);
89
+ if (applied)
90
+ throw new ReplayAlreadyApplied();
59
91
  fail(400, "回调 nonce 重复:同一条指令回调已被处理过(重放请求已拒绝)");
92
+ }
60
93
  // 首次访问才有资格设 TTL:设不上就删键——留一个没有过期的计数键既让它永久增长,
61
94
  // 也会把未来的同一 nonce(虽概率极低)误判成重放。
62
95
  if (!(await gate.expire(key, NONCE_TTL_SECS))) {
63
96
  log.warn("wecom replay nonce ttl not set", "key", key);
64
97
  await gate.del(key);
65
98
  }
99
+ return {
100
+ // 直接 del,不做「是不是我占的那一次」的判断:oj 的 kv 没有 CAS,incr 的返回值
101
+ // 也已被消费在 seen 上。代价是并发同 nonce 时(同一秒内两次投递)可能多放过一次,
102
+ // 但放回的时机只在「处理失败」,而失败的这一条本来就该让企微重推。
103
+ release: async () => {
104
+ await gate.del(key);
105
+ },
106
+ };
66
107
  }
@@ -27,6 +27,8 @@ export interface AppProfile {
27
27
  corpName: string | null
28
28
  status: "active" | "disabled"
29
29
  permanentCode: string
30
+ /** 0=管理员授权 / 1=成员授权(成员授权下应用无任何部门权限,组织架构同步必然 60011,§2.3) */
31
+ authMode: number
30
32
  }
31
33
 
32
34
  /** 套件票据:企微每 10 分钟推一次,有效期 30 分钟;过期不得发起任何套件级 API 调用 */
@@ -120,6 +122,8 @@ export async function saveProfile(p: Omit<AppProfile, "status"> & { status?: App
120
122
  corp_name: p.corpName,
121
123
  status: p.status ?? "active",
122
124
  permanent_code_enc: deps.encrypt(p.permanentCode),
125
+ // 授权模式是「该企业应用能不能做组织架构同步」的判据(§2.3);重授权时必须跟着刷新
126
+ auth_mode: Number(p.authMode ?? 0),
123
127
  update_time: now,
124
128
  };
125
129
  if (rows.length) {
@@ -161,6 +165,43 @@ export async function findProfile(corpId: string, agentId: string | null, deps:
161
165
  corpName: r.corp_name == null ? null : String(r.corp_name),
162
166
  status: String(r.status) === "disabled" ? "disabled" : "active",
163
167
  permanentCode: deps.decrypt(String(r.permanent_code_enc)),
168
+ authMode: Number(r.auth_mode ?? 0),
169
+ };
170
+ }
171
+
172
+ /** 「这家企业能不能同步」的三个事实:装没装 / 什么授权模式 / 是否还启用 */
173
+ export interface ProfileProbe {
174
+ agentId: string
175
+ authMode: number
176
+ status: AppProfile["status"]
177
+ }
178
+
179
+ /**
180
+ * 授权探针(组织架构同步面的准入用):与 `findProfile` 只差一件事——**查不到返回 null 而不是 400**。
181
+ *
182
+ * 为什么敢把「没有」当正常状态:调用方是本租户管理员在自己的组织架构页自省,`corp_id`
183
+ * 取自租户头而不是外部输入,不存在 `findProfile` 要防的枚举面(§5.3)。「本站没装企微」
184
+ * 是一个要能画成空态的事实,不是错误。
185
+ *
186
+ * 选中口径与 `findProfile` 对齐(优选启用中的应用;全停用时退回停用行,让页面能画
187
+ * 「已取消授权」冻结态),只多一条:没有启用应用时也返回,而不是 400。
188
+ */
189
+ export async function probeProfile(corpId: string, agentId: string | null): Promise<ProfileProbe | null> {
190
+ const rows = await db.table("wecom_app_profile").select(["agent_id", "auth_mode", "status"]).where(corpScope(corpId)).all() as Array<Record<string, unknown>>;
191
+ if (rows.length === 0)
192
+ return null;
193
+ const active = rows.filter(r => String(r.status) === "active");
194
+ const pool = active.length > 0 ? active : rows;
195
+ const picked = agentId ? pool.filter(r => String(r.agent_id) === agentId) : pool;
196
+ if (picked.length === 0)
197
+ return null;
198
+ if (picked.length > 1)
199
+ fail(400, "该企业开通了多个应用,同步入口必须带 agent 参数");
200
+ const r = picked[0]!;
201
+ return {
202
+ agentId: String(r.agent_id),
203
+ authMode: Number(r.auth_mode ?? 0),
204
+ status: String(r.status) === "disabled" ? "disabled" : "active",
164
205
  };
165
206
  }
166
207
 
@@ -169,6 +210,30 @@ export async function setProfileStatus(corpId: string, status: AppProfile["statu
169
210
  return Number(await db.table("wecom_app_profile").update({ status, update_time: deps.now() }).where(corpScope(corpId)).run());
170
211
  }
171
212
 
213
+ /**
214
+ * 该企业已授权的应用 + 共享的永久授权码。
215
+ * 用于 `change_auth` 复核授权模式:事件只带 AuthCorpId(**没有 AuthCode**),
216
+ * 只能拿已落库的 permanent_code 去 `get_auth_info` 反查。未授权企业返回 null。
217
+ *
218
+ * 一并返回 `corpId`(真实企业 ID):事件里给的可能是 `AuthCorpId`(第三方体系下是 open_corpid),
219
+ * 而租户键只能用真实 corp_id(D10),故由这里统一把「事件身份 → 租户身份」的翻译交出来。
220
+ */
221
+ export async function findCorpAuth(corpId: string, deps: WecomStoreDeps): Promise<{ corpId: string, permanentCode: string, agentIds: string[] } | null> {
222
+ const rows = await db.table("wecom_app_profile").select(["corp_id", "agent_id", "permanent_code_enc"]).where(corpScope(corpId)).all() as Array<Record<string, unknown>>;
223
+ if (rows.length === 0)
224
+ return null;
225
+ return {
226
+ corpId: String(rows[0]!.corp_id),
227
+ permanentCode: deps.decrypt(String(rows[0]!.permanent_code_enc)),
228
+ agentIds: rows.map(r => String(r.agent_id)),
229
+ };
230
+ }
231
+
232
+ /** 授权模式变更:只改 auth_mode,不动 secret / 状态(改状态是 cancel_auth 的事) */
233
+ export async function setAuthMode(corpId: string, agentId: string, authMode: number, deps: WecomStoreDeps): Promise<void> {
234
+ await db.table("wecom_app_profile").update({ auth_mode: Number(authMode), update_time: deps.now() }).where(corpAgentScope(corpId, agentId)).run();
235
+ }
236
+
172
237
  export async function putSuiteTicket(suiteId: string, ticket: string, expiresAt: number, deps: WecomStoreDeps): Promise<void> {
173
238
  const rows = await db.table("wecom_suite_ticket").select(["suite_id"]).where({ field: "suite_id", op: "eq", value: suiteId }).all();
174
239
  const cols = { suite_ticket: ticket, expires_at: expiresAt, update_time: deps.now() };
@@ -0,0 +1,501 @@
1
+ // 企微组织架构**全量同步**(B2 / 方案 §6.2):唯一权威来源。
2
+ //
3
+ // 形态是「分片推进」而不是「一个长任务」——服务端 isolate 没有定时器,路由也有超时,
4
+ // 所以一次请求只推一片(默认 12 秒的预算),进度落在 wecom_sync_job 的游标里,
5
+ // 由调用方轮询驱动。好处是:可续跑、可观测、不受路由超时限制、崩了也不丢进度。
6
+ //
7
+ // 三个阶段(相序固定,游标就是在这三个之间迁移):
8
+ // departments → 一次 simplelist 拿可见范围内全部部门(兜底 department/list),按父子序落库;
9
+ // members → 逐部门 user/list(要职务/排序/主部门,只有重量接口有),按片处理;
10
+ // reconcile → **仅当本轮完整且零跳过**才把上一轮见过、这一轮没见的部门置隐(D9)。
11
+ //
12
+ // 三条纪律:
13
+ // · 归属权(D13):所有写路径都带 `origin = 'wecom'`,本地建的部门一根毛都不碰;
14
+ // · 错码三分(§5.1):权限类跳过并计数、限流类退避(client 内已重试,还错就停片等下轮)、
15
+ // 致命类计入熔断(连续 FATAL_LIMIT 次 → job failed);
16
+ // · 错误落库只存脱敏结构(`{errcode, phase, deptId, at}`):异常原文里可能夹带 URL 上的 token。
17
+ import type { WecomClient, WecomMember } from "./client";
18
+ import type { ProfileProbe } from "./store";
19
+
20
+ import { markSeen, reconcileHidden, upsertDepartment } from "#/_platform/_shared/service/domain/org";
21
+ import { upsertSourceUser } from "#/_platform/_shared/service/domain/users";
22
+ import { WecomApiError } from "./client";
23
+ import { applyMemberDetail, externalUsername } from "./login";
24
+
25
+ export type SyncStatus = "idle" | "running" | "done" | "failed" | "cancelled";
26
+ export type SyncPhase = "departments" | "members" | "reconcile";
27
+
28
+ export interface SyncStats {
29
+ /** 本轮落库的部门数 */
30
+ departments: number
31
+ /** 本轮结束时本企业的企微来源账号数(不是「人×部门」的调用次数) */
32
+ members: number
33
+ /** 因权限(60011/48002)跳过成员列表的部门数——页面要显示「N 个部门因权限跳过」 */
34
+ skipped: number
35
+ /** 是否执行了墓碑回收(有跳过就不敢回收,见 D9) */
36
+ reconciled: boolean
37
+ }
38
+
39
+ export interface SyncCursor {
40
+ phase: SyncPhase
41
+ /**
42
+ * 轮次号(单调递增,落 `sys_department.seen_job_id`)。
43
+ * 不用时间戳:同秒内重跑一轮会让「上一轮见过」和「本轮见过」撞成同一个值,
44
+ * 墓碑回收(`seen_job_id < round`)就永远不生效。
45
+ */
46
+ round: number
47
+ /** departments 阶段产出的部门清单;members 阶段按它逐部门拉人(游标只存本地 id + 企微 id) */
48
+ depts: Array<{ id: number, originId: string }>
49
+ /** 已处理到 depts 的下标(续跑锚点,避免同一部门重复拉) */
50
+ at: number
51
+ /** 连续致命错误计数(熔断) */
52
+ fatalRun: number
53
+ }
54
+
55
+ /** 同步任务本身的状态(只来自 `wecom_sync_job` 这一张表) */
56
+ export interface SyncJobView {
57
+ status: SyncStatus
58
+ phase: SyncPhase
59
+ stats: SyncStats
60
+ /** 进度:done/total 均以「部门」计(成员阶段按已处理部门推进) */
61
+ progress: { done: number, total: number }
62
+ /** 脱敏后的错码与明细;非 failed 时为空 */
63
+ errCode: string | null
64
+ errDetail: { errcode?: number, phase: SyncPhase, deptId?: string, at: number } | null
65
+ startedAt: number
66
+ finishedAt: number
67
+ updateTime: number
68
+ }
69
+
70
+ /**
71
+ * 给页面看的完整状态 = 任务状态 + 授权事实。
72
+ * 两者刻意分开:`status` 是「同步跑到哪了」,`connected`/`authMode` 是「这家企业能不同步」,
73
+ * 后者来自授权面、前者来自执行面,混在一张表里就会在「取消授权后同步状态还写着 done」时自相矛盾。
74
+ */
75
+ export interface SyncView extends SyncJobView {
76
+ /** 本站是否装过企微(有授权记录,含已停用)——false 时页面整条不显示 */
77
+ connected: boolean
78
+ /** 0=管理员授权 / 1=成员授权(1 时任何通讯录调用都是 60011,同步入口直接拒绝) */
79
+ authMode: number
80
+ /** 授权记录的应用状态;disabled = 已取消授权/被停用,同步冻结但数据保留(§7 黄条) */
81
+ profileStatus: "active" | "disabled" | null
82
+ }
83
+
84
+ export interface SyncDeps {
85
+ client: WecomClient
86
+ now: () => number
87
+ /** 单片时间预算(测试可调小;缺省见 DEFAULT_BUDGET_MS) */
88
+ budgetMs?: number
89
+ }
90
+
91
+ const ORIGIN = "wecom";
92
+ /**
93
+ * 单片的墙钟预算。定 12 秒是两头夹出来的:
94
+ * · 上界——路由全局超时 30 秒(`config.yaml server.timeout` 缺省 "30s"),而单次上游调用
95
+ * 自带 5 秒软超时(client.ts TIMEOUT_MS),预算到期后最多再等一次调用,12+5 < 30;
96
+ * · 下界——4 秒(初版)配合 5 次/分的限速,前端串行驱动第 6 次就被 429 掐断,
97
+ * 500 人的企业要跑好几分钟;12 秒下一片能干完 100+ 次上游调用,正常规模两三次到底。
98
+ */
99
+ const DEFAULT_BUDGET_MS = 12000;
100
+ /** 连续致命错误上限(方案 §5.1「连续 10 次 → failed」) */
101
+ const FATAL_LIMIT = 10;
102
+ /** 抢锁租约:单片最长 12+5 秒,120 秒留给「请求被平台强杀、finally 都没跑到」的场景 */
103
+ const LOCK_TTL_SECS = 120;
104
+
105
+ function fail(status: number, message: string): never {
106
+ throw Object.assign(new Error(message), { status });
107
+ }
108
+
109
+ function emptyStats(): SyncStats {
110
+ return { departments: 0, members: 0, skipped: 0, reconciled: false };
111
+ }
112
+
113
+ function emptyCursor(): SyncCursor {
114
+ return { phase: "departments", round: 0, depts: [], at: 0, fatalRun: 0 };
115
+ }
116
+
117
+ function parseJson<T>(raw: unknown, fallback: T): T {
118
+ if (raw == null || raw === "")
119
+ return fallback;
120
+ try {
121
+ return JSON.parse(String(raw)) as T;
122
+ }
123
+ catch {
124
+ return fallback;
125
+ }
126
+ }
127
+
128
+ interface JobRow {
129
+ status: string
130
+ phase: string
131
+ cursor_text: string | null
132
+ stats_text: string | null
133
+ err_code: string | null
134
+ err_detail: string | null
135
+ started_at: number
136
+ finished_at: number
137
+ update_time: number
138
+ }
139
+
140
+ function jobScope(corpId: string, agentId: string) {
141
+ return {
142
+ and: [
143
+ { field: "corp_id", op: "eq", value: corpId },
144
+ { field: "agent_id", op: "eq", value: agentId },
145
+ ],
146
+ };
147
+ }
148
+
149
+ async function readJob(corpId: string, agentId: string): Promise<JobRow | null> {
150
+ const rows = await db.table("wecom_sync_job").select(["*"]).where(jobScope(corpId, agentId)).all() as unknown as JobRow[];
151
+ return rows[0] ?? null;
152
+ }
153
+
154
+ function toView(job: JobRow | null): SyncJobView {
155
+ if (!job) {
156
+ return {
157
+ status: "idle",
158
+ phase: "departments",
159
+ stats: emptyStats(),
160
+ progress: { done: 0, total: 0 },
161
+ errCode: null,
162
+ errDetail: null,
163
+ startedAt: 0,
164
+ finishedAt: 0,
165
+ updateTime: 0,
166
+ };
167
+ }
168
+ const cursor = parseJson<SyncCursor>(job.cursor_text, emptyCursor());
169
+ return {
170
+ status: (job.status as SyncStatus) ?? "idle",
171
+ phase: (job.phase as SyncPhase) ?? cursor.phase,
172
+ stats: parseJson<SyncStats>(job.stats_text, emptyStats()),
173
+ progress: { done: cursor.at, total: cursor.depts.length },
174
+ errCode: job.err_code,
175
+ errDetail: parseJson<SyncJobView["errDetail"]>(job.err_detail, null),
176
+ startedAt: Number(job.started_at ?? 0),
177
+ finishedAt: Number(job.finished_at ?? 0),
178
+ updateTime: Number(job.update_time ?? 0),
179
+ };
180
+ }
181
+
182
+ /** 当前进度(只读本地行,不打上游):轮询用它,不用 start */
183
+ export async function syncStatus(corpId: string, agentId: string): Promise<SyncJobView> {
184
+ return toView(await readJob(corpId, agentId));
185
+ }
186
+
187
+ /** 拼上授权事实(见 SyncView 注释)。probe 为 null = 本站没装企微 */
188
+ export function attachProfile(view: SyncJobView, probe: ProfileProbe | null): SyncView {
189
+ return {
190
+ ...view,
191
+ connected: probe !== null,
192
+ authMode: probe?.authMode ?? 0,
193
+ profileStatus: probe?.status ?? null,
194
+ };
195
+ }
196
+
197
+ async function writeJob(corpId: string, agentId: string, patch: Record<string, unknown>, now: number): Promise<void> {
198
+ const cols = { ...patch, update_time: now };
199
+ const rows = await db.table("wecom_sync_job").select(["agent_id"]).where(jobScope(corpId, agentId)).all();
200
+ if (rows.length) {
201
+ await db.table("wecom_sync_job").update(cols).where(jobScope(corpId, agentId)).run();
202
+ return;
203
+ }
204
+ try {
205
+ await db.table("wecom_sync_job").insert({ corp_id: corpId, agent_id: agentId, started_at: now, finished_at: 0, ...cols }).run();
206
+ }
207
+ catch (e) {
208
+ // 唯一约束撞车(两片同时起):转为更新,同 saveProfile 先例
209
+ if (!/unique|constraint/i.test(e instanceof Error ? e.message : String(e)))
210
+ throw e;
211
+ await db.table("wecom_sync_job").update(cols).where(jobScope(corpId, agentId)).run();
212
+ }
213
+ }
214
+
215
+ /**
216
+ * 抢锁:**靠复合主键插入**判原子,不靠读 status 再改。
217
+ * 过期租约(请求崩溃留下的行)允许抢占——否则一次 OOM 会让这个企业永远不能同步。
218
+ */
219
+ async function acquireLock(agentId: string, owner: string, now: number): Promise<void> {
220
+ try {
221
+ await db.table("wecom_sync_lock").insert({ agent_id: agentId, owner, taken_at: now }).run();
222
+ return;
223
+ }
224
+ catch (e) {
225
+ if (!/unique|constraint/i.test(e instanceof Error ? e.message : String(e)))
226
+ throw e;
227
+ }
228
+ const rows = await db.table("wecom_sync_lock").select(["taken_at"]).where({ field: "agent_id", op: "eq", value: agentId }).all() as Array<{ taken_at: number }>;
229
+ const takenAt = Number(rows[0]?.taken_at ?? 0);
230
+ if (now - takenAt < LOCK_TTL_SECS)
231
+ fail(409, "同步正在进行,请稍后再试");
232
+ await db.table("wecom_sync_lock").update({ owner, taken_at: now }).where({ field: "agent_id", op: "eq", value: agentId }).run();
233
+ }
234
+
235
+ async function releaseLock(agentId: string): Promise<void> {
236
+ await db.table("wecom_sync_lock").where({ field: "agent_id", op: "eq", value: agentId }).delete().run();
237
+ }
238
+
239
+ /**
240
+ * 部门清单:主路径 `department/simplelist`(不带父 id = 应用可见范围内全部部门,一次拿完)。
241
+ * 上游明确写过 `department/list` 性能低(90208),故只当兜底——它多返回 `order`,
242
+ * 但大企业回包更容易撞 45029。
243
+ */
244
+ async function fetchDepartments(client: WecomClient, corpId: string, agentId: string, secret: string) {
245
+ try {
246
+ return await client.departmentChildren(corpId, agentId, secret);
247
+ }
248
+ catch (e) {
249
+ if (e instanceof WecomApiError && e.kind === "permission")
250
+ throw e;
251
+ return await client.departmentList(corpId, agentId, secret);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * 按父子序排好再落库:`upsertDepartment` 要的是**本地父 id**,所以父必须先存在。
257
+ * 孤儿(父不在结果集,通常是可见范围导致的)按方案挂到根,不报错;真成环的兜底也挂根
258
+ * (企微侧不会给出环,但这里绝不能死循环)。
259
+ */
260
+ function orderTopDown<T extends { id: string, parentId: string }>(list: T[]): T[] {
261
+ const pending = new Map<string, T>(list.map(d => [d.id, d]));
262
+ const resolved = new Set<string>();
263
+ const out: T[] = [];
264
+ while (pending.size) {
265
+ let progressed = false;
266
+ for (const [id, d] of pending) {
267
+ const parent = d.parentId;
268
+ // 父为空、父不在本批(孤儿)、或父已排好 → 现在就能落
269
+ if (!parent || !pending.has(parent) || resolved.has(parent)) {
270
+ out.push(d);
271
+ resolved.add(id);
272
+ pending.delete(id);
273
+ progressed = true;
274
+ }
275
+ }
276
+ if (!progressed) {
277
+ for (const d of pending.values())
278
+ out.push(d);
279
+ break;
280
+ }
281
+ }
282
+ return out;
283
+ }
284
+
285
+ function scrubError(e: unknown, phase: SyncPhase, deptId: string | null, now: number): { errCode: string, errDetail: SyncJobView["errDetail"] } {
286
+ if (e instanceof WecomApiError)
287
+ return { errCode: String(e.errcode), errDetail: { errcode: e.errcode, phase, deptId: deptId ?? undefined, at: now } };
288
+ const message = e instanceof Error ? e.message : String(e);
289
+ // 非企微错码(网络/超时):只留类别,不留原文(原文可能带 URL 上的 access_token)
290
+ return { errCode: /超时/.test(message) ? "timeout" : "network", errDetail: { phase, deptId: deptId ?? undefined, at: now } };
291
+ }
292
+
293
+ /** 没有 job 行时的空视图(导出给「本站没装企微」的空态用) */
294
+ export function emptyView(status: SyncStatus): SyncJobView {
295
+ return { ...toView(null), status };
296
+ }
297
+
298
+ /**
299
+ * 开始 / 继续一次同步——**每调一次推一片**。
300
+ *
301
+ * 语义取「开始或继续」而不是「必须从零开始」:可续跑是这套分片设计的全部意义,
302
+ * 前端只要在 `status === "running"` 时继续调它(或由用户点「继续」),直到 done/failed。
303
+ */
304
+ export async function startSync(ctx: { corpId: string, agentId: string, secret: string, authMode: number }, deps: SyncDeps): Promise<SyncJobView> {
305
+ // 准入判据:成员授权(auth_mode=1)下应用无任何部门权限,跑起来只会得到一片 60011
306
+ if (Number(ctx.authMode) !== 0)
307
+ fail(400, "该企业为「成员授权」模式,应用没有通讯录权限:请企业管理员在企微后台把授权模式改为「管理员授权」后再同步");
308
+
309
+ const now = Math.floor(deps.now() / 1000);
310
+ await acquireLock(ctx.agentId, `u${Number((globalThis as { http?: { user?: { id?: number } } }).http?.user?.id ?? 0)}`, now);
311
+ try {
312
+ const existing = await readJob(ctx.corpId, ctx.agentId);
313
+ const resumable = existing && existing.status === "running";
314
+ const prevCursor = existing ? parseJson<SyncCursor>(existing.cursor_text, emptyCursor()) : null;
315
+ let cursor = resumable && prevCursor
316
+ ? prevCursor
317
+ : { ...emptyCursor(), round: (prevCursor?.round ?? 0) + 1 };
318
+ let stats = resumable ? parseJson<SyncStats>(existing.stats_text, emptyStats()) : emptyStats();
319
+ const startedAt = resumable ? Number(existing.started_at) : now;
320
+ const roundId = cursor.round;
321
+
322
+ await writeJob(ctx.corpId, ctx.agentId, {
323
+ status: "running",
324
+ phase: cursor.phase,
325
+ cursor_text: JSON.stringify(cursor),
326
+ stats_text: JSON.stringify(stats),
327
+ err_code: null,
328
+ err_detail: null,
329
+ started_at: startedAt,
330
+ finished_at: 0,
331
+ }, now);
332
+
333
+ const result = await runSlice(ctx.corpId, ctx.agentId, ctx.secret, cursor, stats, roundId, deps);
334
+ cursor = result.cursor;
335
+ stats = result.stats;
336
+ // 三态收敛:失败 / 片内收手(还在 running,等下一次 start)/ 全部跑完
337
+ const status: SyncStatus = result.failed ? "failed" : result.paused ? "running" : "done";
338
+ await writeJob(ctx.corpId, ctx.agentId, {
339
+ status,
340
+ phase: cursor.phase,
341
+ cursor_text: JSON.stringify(cursor),
342
+ stats_text: JSON.stringify(stats),
343
+ err_code: result.errCode,
344
+ err_detail: result.errDetail ? JSON.stringify(result.errDetail) : null,
345
+ finished_at: status === "running" ? 0 : now,
346
+ }, now);
347
+ return toView(await readJob(ctx.corpId, ctx.agentId));
348
+ }
349
+ finally {
350
+ await releaseLock(ctx.agentId);
351
+ }
352
+ }
353
+
354
+ interface SliceResult {
355
+ cursor: SyncCursor
356
+ stats: SyncStats
357
+ failed: boolean
358
+ /** 片内收手(预算用完 / 限流 / 可恢复的致命错):进度已落库,下次接着推 */
359
+ paused: boolean
360
+ errCode: string | null
361
+ errDetail: SyncJobView["errDetail"]
362
+ }
363
+
364
+ async function runSlice(
365
+ corpId: string,
366
+ agentId: string,
367
+ secret: string,
368
+ cursor: SyncCursor,
369
+ stats: SyncStats,
370
+ roundId: number,
371
+ deps: SyncDeps,
372
+ ): Promise<SliceResult> {
373
+ const orgDeps = { now: deps.now };
374
+ const deadline = deps.now() + (deps.budgetMs ?? DEFAULT_BUDGET_MS);
375
+ const done = (extra: Partial<SliceResult> = {}): SliceResult => ({ cursor, stats, failed: false, paused: false, errCode: null, errDetail: null, ...extra });
376
+ const paused = (): SliceResult => done({ paused: true });
377
+
378
+ if (cursor.phase === "departments") {
379
+ let list: Array<{ id: string, parentId: string, name: string | null, order: number }>;
380
+ try {
381
+ list = await fetchDepartments(deps.client, corpId, agentId, secret);
382
+ }
383
+ catch (e) {
384
+ const scrubbed = scrubError(e, "departments", null, Math.floor(deps.now() / 1000));
385
+ return done({ failed: true, errCode: scrubbed.errCode, errDetail: scrubbed.errDetail });
386
+ }
387
+ const ordered = orderTopDown(list);
388
+ // 代开发 simplelist 不带部门名(见 client.ts departmentDetail 注释):逐部门回源 department/get 补名字。
389
+ // 已有名字则跳过(普通企业 simplelist 可能带名字,不重复打上游);单部门失败非致命,留空不影响骨架。
390
+ for (const d of ordered) {
391
+ if (d.name)
392
+ continue;
393
+ try {
394
+ const detail = await deps.client.departmentDetail(corpId, agentId, secret, d.id);
395
+ if (detail.name)
396
+ d.name = detail.name;
397
+ }
398
+ catch {
399
+ // 无权限 / 不在可见范围 / 上游抖动:名字留空,不因单部门失败中断整轮
400
+ }
401
+ }
402
+ const localByOrigin = new Map<string, number>();
403
+ const placedIds: number[] = [];
404
+ for (const d of ordered) {
405
+ const parentLocal = d.parentId && localByOrigin.has(d.parentId) ? localByOrigin.get(d.parentId)! : null;
406
+ const id = await upsertDepartment({ origin: ORIGIN, originId: d.id, parentId: parentLocal, name: d.name, orderNo: d.order }, orgDeps);
407
+ localByOrigin.set(d.id, id);
408
+ placedIds.push(id);
409
+ }
410
+ await markSeen(ORIGIN, placedIds, roundId, orgDeps);
411
+ cursor.depts = ordered.map(d => ({ id: localByOrigin.get(d.id)!, originId: d.id }));
412
+ cursor.at = 0;
413
+ cursor.phase = "members";
414
+ stats.departments = cursor.depts.length;
415
+ }
416
+
417
+ if (cursor.phase === "members") {
418
+ // 部门 id 映射:一次查询覆盖整片(500 部门以内一条 SQL;逐人查会把同步打成 N×M 次)
419
+ const rows = await db.table("sys_department").select(["id", "origin_id"]).where({ field: "origin", op: "eq", value: ORIGIN }).all() as Array<{ id: number, origin_id: string }>;
420
+ const deptByOrigin = new Map(rows.map(r => [String(r.origin_id), Number(r.id)]));
421
+ while (cursor.at < cursor.depts.length) {
422
+ if (deps.now() >= deadline)
423
+ return paused();
424
+ const dept = cursor.depts[cursor.at]!;
425
+ let members: WecomMember[];
426
+ try {
427
+ members = await deps.client.deptMembersDetail(corpId, agentId, secret, dept.originId);
428
+ cursor.fatalRun = 0;
429
+ }
430
+ catch (e) {
431
+ const kind = e instanceof WecomApiError ? e.kind : "fatal";
432
+ if (kind === "permission") {
433
+ // 单个部门无权限是常态(可见范围外的部门):跳过并计数,不升级成失败
434
+ stats.skipped++;
435
+ cursor.at++;
436
+ continue;
437
+ }
438
+ if (kind === "ratelimit")
439
+ return paused(); // 限流已在 client 内退避三次,还错就停片,游标留在本部门
440
+ cursor.fatalRun++;
441
+ if (cursor.fatalRun >= FATAL_LIMIT) {
442
+ const scrubbed = scrubError(e, "members", dept.originId, Math.floor(deps.now() / 1000));
443
+ return done({ failed: true, errCode: scrubbed.errCode, errDetail: scrubbed.errDetail });
444
+ }
445
+ return paused();
446
+ }
447
+ for (const m of members)
448
+ await applyMember(m, corpId, deptByOrigin, orgDeps);
449
+ cursor.at++;
450
+ }
451
+ cursor.phase = "reconcile";
452
+ }
453
+
454
+ if (cursor.phase === "reconcile") {
455
+ // D9:只有「本轮完整且零跳过」才敢回收——否则一次上游抖动会被读成「这些部门被删了」
456
+ if (stats.skipped === 0) {
457
+ await reconcileHidden(ORIGIN, roundId, orgDeps);
458
+ stats.reconciled = true;
459
+ }
460
+ stats.members = await countMembers(corpId);
461
+ }
462
+
463
+ return done();
464
+ }
465
+
466
+ async function countMembers(corpId: string): Promise<number> {
467
+ const rows = await db.table("users").select(["id"]).where({ and: [
468
+ { field: "source", op: "eq", value: ORIGIN },
469
+ { field: "corp_id", op: "eq", value: corpId },
470
+ ] }).all();
471
+ return rows.length;
472
+ }
473
+
474
+ /**
475
+ * 一个成员:建档(activated=0)→ 交给 `applyMemberDetail` 写资料与组织关系。
476
+ *
477
+ * 这里**只做「建档 + 传参」**:真正的字段口径(只写自己拥有的行、未知部门跳过、主部门兜底)
478
+ * 在 login.ts::applyMemberDetail 里,登录自更新与事件增量也调它。deptByOrigin 是本片预取的,
479
+ * 逐人去查会把这个 500 部门的同步打成 N×M 次查询。
480
+ */
481
+ async function applyMember(m: WecomMember, corpId: string, deptByOrigin: Map<string, number>, orgDeps: { now: () => number }): Promise<void> {
482
+ const uid = await upsertSourceUser({ username: externalUsername(m.userid), displayName: m.name, source: ORIGIN, corpId });
483
+ await applyMemberDetail({ uid, userid: m.userid }, m, { ...orgDeps, deptByOrigin });
484
+ }
485
+
486
+ /** 取消:清游标、置 cancelled(不回滚已写入的组织数据——同步幂等,重跑即可) */
487
+ export async function cancelSync(corpId: string, agentId: string, deps: { now: () => number }): Promise<SyncJobView> {
488
+ const now = Math.floor(deps.now() / 1000);
489
+ const job = await readJob(corpId, agentId);
490
+ if (!job)
491
+ return emptyView("idle");
492
+ await writeJob(corpId, agentId, {
493
+ status: "cancelled",
494
+ cursor_text: JSON.stringify(emptyCursor()),
495
+ err_code: null,
496
+ err_detail: null,
497
+ finished_at: now,
498
+ }, now);
499
+ await releaseLock(agentId);
500
+ return toView(await readJob(corpId, agentId));
501
+ }