@oj-module/cli 0.2.46 → 0.2.48

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 (97) hide show
  1. package/devkit/SKILL.md +1 -1
  2. package/devkit/framework-dev.md +2 -2
  3. package/devkit/manual.md +24 -14
  4. package/devkit/module-dev-and-cdn-playground.md +2 -2
  5. package/devkit/wecom-daikaifa.md +282 -27
  6. package/package.json +2 -2
  7. package/shell-dist/assets/runtime.js +2 -2
  8. package/shell-dist/index.html +2 -2
  9. package/shell-dist/versions.json +3 -3
  10. package/src/__tests__/templates-tenant-scope.test.ts +7 -2
  11. package/src/init.ts +1 -1
  12. package/src/market/__tests__/bootstrap.test.ts +1 -1
  13. package/src/market/__tests__/setup.test.ts +7 -7
  14. package/src/market/bootstrap.ts +1 -1
  15. package/templates/README.md +1 -1
  16. package/templates/config.yaml +26 -7
  17. package/templates/init.include.yaml +12 -0
  18. package/templates/openapi.json +2863 -549
  19. package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
  20. package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
  21. package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
  22. package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
  23. package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
  24. package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
  25. package/templates/src/_platform/api/manifest.yaml +3 -0
  26. package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
  27. package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
  28. package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
  29. package/templates/src/_platform/api/org/departments/api.ts +51 -0
  30. package/templates/src/_platform/api/org/is-member/api.ts +33 -0
  31. package/templates/src/_platform/api/org/members/api.ts +55 -0
  32. package/templates/src/_platform/api/org/picker/api.ts +59 -0
  33. package/templates/src/_platform/api/org/resolve/api.ts +38 -0
  34. package/templates/src/_platform/api/org/tree/api.ts +37 -0
  35. package/templates/src/_platform/api/org/user/api.ts +46 -0
  36. package/templates/src/_platform/api/schema.yaml +48 -0
  37. package/templates/src/_platform/api/seed.mysql.sql +10 -5
  38. package/templates/src/_platform/api/seed.pg.sql +10 -5
  39. package/templates/src/_platform/api/seed.sql +13 -5
  40. package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
  41. package/templates/src/_platform/web/__client/api.ts +170 -1
  42. package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
  43. package/templates/src/auth/api/_shared/session.ts +5 -1
  44. package/templates/src/auth/api/_shared/wecom/admin.ts +17 -10
  45. package/templates/src/auth/api/_shared/wecom/client.ts +311 -40
  46. package/templates/src/auth/api/_shared/wecom/config.ts +40 -17
  47. package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
  48. package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
  49. package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
  50. package/templates/src/auth/api/_shared/wecom/login.ts +152 -1
  51. package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
  52. package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
  53. package/templates/src/auth/api/_shared/wecom/sync.ts +487 -0
  54. package/templates/src/auth/api/manifest.yaml +3 -0
  55. package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
  56. package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
  57. package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
  58. package/templates/src/auth/api/schema.yaml +31 -0
  59. package/templates/src/auth/api/wecom/admin/api.ts +7 -5
  60. package/templates/src/auth/api/wecom/api.ts +100 -18
  61. package/templates/src/auth/api/wecom/sync/api.ts +199 -0
  62. package/templates/src/auth/web/__client/api.schemas.ts +97 -0
  63. package/templates/src/auth/web/__client/api.ts +73 -1
  64. package/templates/src/login/web/auth-provider.ts +9 -1
  65. package/templates/src/notification/api/seed.sql +1 -1
  66. package/templates/src/system/api/departments/api.ts +142 -0
  67. package/templates/src/system/api/departments/detach/api.ts +34 -0
  68. package/templates/src/system/api/departments/members/api.ts +142 -0
  69. package/templates/src/system/api/departments/move/api.ts +39 -0
  70. package/templates/src/system/api/users/api.ts +45 -5
  71. package/templates/src/system/api/users/profile/api.ts +44 -0
  72. package/templates/src/system/web/__client/api.schemas.ts +143 -0
  73. package/templates/src/system/web/__client/api.ts +293 -1
  74. package/templates/src/system/web/entry.ts +6 -0
  75. package/templates/src/system/web/locales/en-US.json +113 -7
  76. package/templates/src/system/web/locales/zh-CN.json +113 -7
  77. package/templates/src/system/web/pages/departments/index.tsx +346 -0
  78. package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
  79. package/templates/src/system/web/pages/departments/tree.ts +66 -0
  80. package/templates/src/system/web/pages/errors.ts +1 -1
  81. package/templates/src/system/web/pages/users/index.tsx +49 -7
  82. package/templates/src/system/web/pages/wecom/index.tsx +16 -1
  83. package/templates/tests/l1-builder.test.ts +1 -1
  84. package/templates/tests/org-departments.spec.ts +311 -0
  85. package/templates/tests/org-read.spec.ts +469 -0
  86. package/templates/tests/rbac-contacts.spec.ts +13 -3
  87. package/templates/tests/rbac-seed.spec.ts +11 -0
  88. package/templates/tests/seed-role-accounts.spec.ts +6 -2
  89. package/templates/tests/wecom-admin.spec.ts +64 -42
  90. package/templates/tests/wecom-client-timeout.spec.ts +99 -0
  91. package/templates/tests/wecom-connector.spec.ts +115 -11
  92. package/templates/tests/wecom-crypt.spec.ts +12 -3
  93. package/templates/tests/wecom-endpoints.spec.ts +182 -14
  94. package/templates/tests/wecom-org-sync.spec.ts +598 -0
  95. package/templates/tests/wecom-sync.spec.ts +286 -0
  96. package/vendor/host-versions.json +3 -3
  97. package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
@@ -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
+ }
@@ -12,6 +12,8 @@
12
12
  // 逆 S 盒由置换求逆(加载期一次性,<5ms)。
13
13
 
14
14
  const BLOCK = 16;
15
+ /** 企微的 PKCS#7 补位块长(90968:填充至 32 字节的倍数),与 AES 的 16 字节块是两回事 */
16
+ const PAD_BLOCK = 32;
15
17
 
16
18
  /** GF(2^8) 乘法,域多项式 x^8+x^4+x^3+x+1(0x11B),AES 唯一乘法 */
17
19
  function gmul(a: number, b: number): number {
@@ -153,12 +155,18 @@ export function aes256CbcDecrypt(cipher: Uint8Array, key: Uint8Array, iv: Uint8A
153
155
  return out;
154
156
  }
155
157
 
156
- /** PKCS#7 去填充(1..16 字节,逐字节校验;非法抛错) */
158
+ /**
159
+ * PKCS#7 去填充(逐字节校验;非法抛错)。
160
+ *
161
+ * 补位长度上限是 **32**,不是 AES 的块长 16:企微(90968「概述」)明确写「数据采用 PKCS#7 填充至
162
+ * **32 字节**的倍数」,官方各语言 demo 里 block_size 也等于 32。按 16 卡上限会让补位 17~32 的报文
163
+ * 一律判成 `bad padding`——而那恰好是真实回调里约一半的情况(补位值由报文长度决定)。
164
+ */
157
165
  export function pkcs7Unpad(data: Uint8Array): Uint8Array {
158
166
  if (data.length === 0 || data.length % BLOCK !== 0)
159
167
  throw new Error("pkcs7: bad length");
160
168
  const pad = data[data.length - 1]!;
161
- if (pad < 1 || pad > BLOCK || pad > data.length)
169
+ if (pad < 1 || pad > PAD_BLOCK || pad > data.length)
162
170
  throw new Error("pkcs7: bad padding");
163
171
  for (let i = data.length - pad; i < data.length; i++) {
164
172
  if (data[i] !== pad)
@@ -291,12 +299,14 @@ export function wecomSignature(...parts: string[]): string {
291
299
  * 解出企微消息明文(`<xml>…</xml>` 或裸 `echostr`)。
292
300
  *
293
301
  * 明文结构(企微 §消息加解密):`random(16B) | msg_len(4B, big-endian) | msg | receiveid`。
294
- * `receiveid` 对第三方平台/代开发模版是 **suite_id**;不一致即拒(错钥/错套件混用必须 fail-closed,
295
- * 否则不同套件的回调会互相污染)。43 字符 EncodingAESKey 末尾补 "=" 才是 44 字符 base64(32 字节)。
302
+ * `receiveid` 的含义**随场景变**(90968 附注):第三方事件的回调是 **suite_id**(suite_ticket /
303
+ * create_auth),企业应用的回调(含代开发应用的数据回调,通讯录变更就走这条)是 **corpid**。
304
+ * 不一致即拒(错钥/错套件混用必须 fail-closed)——但调用方得按场景给出候选值,
305
+ * 见 `wecom/api.ts::unwrapAccepting`。43 字符 EncodingAESKey 末尾补 "=" 才是 44 字符 base64(32 字节)。
296
306
  *
297
- * `tolerant`(默认 false):引导期 `suite_id` 尚未下发时,receiveid 无从比对——
298
- * 此时放宽该检查。放宽是安全的:最外层签名(基于开发者持有的 `token`)已鉴权,
299
- * receiveid 只是防御性兜底(防错配 aeskey)。`suite_id` 配齐后调用方应传 `false` 恢复严格。
307
+ * `tolerant`(默认 false):拿不到可比对的 receiveid 时放宽(引导期没有 suite_id、或 GET 只有
308
+ * echostr 没有报文信封)。放宽是安全的:最外层签名(基于开发者持有的 `token`)已鉴权,
309
+ * receiveid 只是防御性兜底(防错配 aeskey)。
300
310
  */
301
311
  export function unwrapWecomMessage(encryptedB64: string, encodingAesKey: string, expectedReceiver: string, tolerant = false): string {
302
312
  const key = b64ToBytes(`${encodingAesKey}=`);
@@ -2,7 +2,7 @@ import type { WecomClient } from "./client";
2
2
  import type { WecomConfig } from "./config";
3
3
  import type { WecomStoreDeps } from "./store";
4
4
  // 企微「指令回调」事件处理:suite_ticket / create_auth / change_auth / cancel_auth /
5
- // reset_permanent_code(代开发重置 secret 走后者)。
5
+ // reset_permanent_code(代开发重置 secret 走后者),以及轨三的通讯录变更(contacts.ts)。
6
6
  //
7
7
  // 为什么这几件事必须由回调驱动(而不是管理端手工录):
8
8
  // · suite_ticket 每 10 分钟推一次、30 分钟过期,拿不到就换不出 suite_access_token,
@@ -12,13 +12,16 @@ import type { WecomStoreDeps } from "./store";
12
12
  // · 企业解绑/取消授权只在 cancel_auth 出现。
13
13
  // 因此这里不是「可选的后台功能」,是代开发模式的下限能力。
14
14
  //
15
- // 响应纪律:处理完返回(路由回纯文本 success)。企微 5 秒内拿不到 success 会重推三次;
16
- // 重推对我们是幂等的(profile upsert / ticket 覆盖),但 AuthCode 一次有效,重推时已换取过 → 502,
15
+ // 响应纪律:处理完返回(路由回纯文本 success)。企微对 `create_auth` 的窗口是 **约 1 秒**
16
+ // (官方文档写 5 秒,实际按 1 秒卡;超时/非 success 一律在企业管理员浏览器上显示
17
+ // 「应用安装失败,联系服务商」)。拿不到 success 会重推三次;重推对我们是幂等的
18
+ // (profile upsert / ticket 覆盖),但 AuthCode 一次有效,重推时已换取过 → 502,
17
19
  // 属预期(日志留痕即可,不影响已落库的 profile)。
18
20
  import { invalidateAllUsers } from "#/_platform/_shared/service/infra/access";
19
21
  import { recordOp } from "#/_platform/_shared/service/infra/audit";
20
- import { corpScope, getSuiteTicket, putSuiteTicket, revokeTenantSessions, saveProfile, setProfileStatus, tenantOfCorp } from "./store";
21
- import { xmlField } from "./xml";
22
+ import { handleContactChange } from "./contacts";
23
+ import { callbackFields, field, requireField } from "./payload";
24
+ import { corpScope, ensureTenant, findCorpAuth, getSuiteTicket, putSuiteTicket, revokeTenantSessions, saveProfile, setAuthMode, setProfileStatus, tenantOfCorp } from "./store";
22
25
 
23
26
  /** suite_ticket 官方有效期 30 分钟(10 分钟推一次);本地按 30 分钟作废,宁可拒一次也不拿过期票打企微 */
24
27
  const SUITE_TICKET_TTL_SECS = 30 * 60;
@@ -40,15 +43,27 @@ export function envOfState(state: string | null): string {
40
43
  return /^(?:prod|test|staging|dev)$/i.test(trimmed) ? trimmed.toLowerCase() : "prod";
41
44
  }
42
45
 
43
- function requireField(xml: string, name: string, infoType: string): string {
44
- const value = xmlField(xml, name);
45
- if (!value)
46
- throw Object.assign(new Error(`企微事件 ${infoType} 缺少 ${name}`), { status: 400 });
47
- return value;
46
+ /**
47
+ * 事件路径的审计。两处纪律:
48
+ * 1. 调用前必须先 `ensureTenant(corp)`——`sys_oplog` 是租户表,本端点又是匿名路径(企微带不了
49
+ * `X-TENANT-ID`),`sql_guard: deny` 下不带租户上下文的 insert 会被守卫直接拒;
50
+ * 2. 审计失败**只告警**:它发生在 profile 已落库之后,一次成功的授权不能因为写日志失败
51
+ * 变成企业管理员看到的「应用安装失败」。
52
+ */
53
+ async function auditAuth(action: string, corpId: string, detail?: unknown, level?: number): Promise<void> {
54
+ try {
55
+ await recordOp({ action, module: "auth", target: corpId, detail, level });
56
+ }
57
+ catch (e) {
58
+ log.warn("wecom auth audit skipped", "action", action, "corpId", corpId, "reason", (e as Error).message);
59
+ }
48
60
  }
49
61
 
50
62
  /** 该 corp 的所有应用都标记为停用,并吊销其租户下的全部会话 */
51
63
  async function disableCorp(corpId: string, deps: SuiteEventDeps): Promise<void> {
64
+ // 取消授权同样是匿名回调:会话清理走裸 SQL,`sql_guard: deny` 下没有租户上下文会被拒——
65
+ // 而这里拒掉的后果是「旧 refresh 串仍能续命」,属安全红线,故必须绑成功再往下走。
66
+ ensureTenant(tenantOfCorp(corpId));
52
67
  const rows = await db.table("wecom_app_profile").select(["agent_id"]).where(corpScope(corpId)).all() as Array<{ agent_id: string }>;
53
68
  for (const r of rows)
54
69
  await deps.client.invalidateCorpToken(corpId, String(r.agent_id));
@@ -67,6 +82,9 @@ async function applyAuthCode(authCode: string, state: string | null, deps: Suite
67
82
  const suiteToken = await client.suiteAccessToken(cfg.suiteId, cfg.suiteSecret, suiteTicket.ticket);
68
83
  const granted = await client.permanentCodeByAuthCode(suiteToken, authCode);
69
84
  const env = envOfState(state ?? granted.state);
85
+ // 拿到企业身份就绑租户:profile 表虽共享,但其后的审计与后续写库都在这个租户下。
86
+ // 顺序不能挪到审计里——那样 saveProfile 之后才发现守卫拒,profile 已落库却回失败。
87
+ ensureTenant(tenantOfCorp(granted.corpId));
70
88
  for (const agentId of granted.agentIds) {
71
89
  await saveProfile({
72
90
  suiteId: cfg.suiteId,
@@ -76,52 +94,119 @@ async function applyAuthCode(authCode: string, state: string | null, deps: Suite
76
94
  env,
77
95
  corpName: granted.corpName,
78
96
  permanentCode: granted.permanentCode,
97
+ authMode: granted.authModes[agentId] ?? 0,
79
98
  }, store);
80
99
  // secret 可能已轮换:旧 token 必须立刻作废,否则下一个登录会带着失效 token 打 userinfo
81
100
  await client.invalidateCorpToken(granted.corpId, agentId);
82
101
  }
83
- await recordOp({ action: "wecom.auth.apply", module: "auth", target: granted.corpId, detail: { agentIds: granted.agentIds, env } });
102
+ await auditAuth("wecom.auth.apply", granted.corpId, { agentIds: granted.agentIds, env });
84
103
  return { corpId: granted.corpId, agentIds: granted.agentIds };
85
104
  }
86
105
 
106
+ /**
107
+ * `change_auth` 后复核授权模式并落库。
108
+ *
109
+ * 失败**只告警**:回调窗口 1 秒,一次上游抖动不该把通知打成失败(重推也补不回被误判的
110
+ * 授权模式,反而让企业管理员看到「配置失败」)。代价是 auth_mode 短期仍是旧值,
111
+ * 同步闸门那侧还有 60011 兜底(见 sync 的 permission 分支)。
112
+ */
113
+ async function refreshAuthModes(corpId: string, deps: SuiteEventDeps): Promise<void> {
114
+ try {
115
+ const auth = await findCorpAuth(corpId, deps.store);
116
+ if (!auth)
117
+ return;
118
+ const ticket = await getSuiteTicket(deps.cfg.suiteId, deps.store);
119
+ const suiteToken = await deps.client.suiteAccessToken(deps.cfg.suiteId, deps.cfg.suiteSecret, ticket.ticket);
120
+ const modes = await deps.client.authModesByPermanentCode(suiteToken, corpId, auth.permanentCode);
121
+ for (const agentId of auth.agentIds) {
122
+ const mode = modes[agentId];
123
+ if (mode == null)
124
+ continue;
125
+ await setAuthMode(corpId, agentId, mode, deps.store);
126
+ log.info("wecom auth_mode refreshed", "corpId", corpId, "agentId", agentId, "authMode", mode);
127
+ }
128
+ }
129
+ catch (e) {
130
+ log.warn("wecom auth_mode refresh skipped", "corpId", corpId, "reason", (e as Error).message);
131
+ }
132
+ }
133
+
87
134
  /** 处理一条已验签并解密的回调明文;返回值仅用于日志(未知 InfoType 也回 success,不触发重推) */
88
- export async function handleSuiteEvent(plaintext: string, deps: SuiteEventDeps): Promise<string> {
89
- const infoType = xmlField(plaintext, "InfoType") ?? "";
90
- const suiteId = xmlField(plaintext, "SuiteId") ?? "";
135
+ export async function handleSuiteEvent(payload: unknown, deps: SuiteEventDeps): Promise<string> {
136
+ // 入参是「原始报文」(对象 / JSON 串 / XML 串都行,见 payload.ts);归一化只在这里做一次,
137
+ // 往下全是字段名,函数体里再也看不到格式。
138
+ const fields = callbackFields(payload);
139
+ const infoType = field(fields, "InfoType");
140
+ const suiteId = field(fields, "SuiteId");
91
141
  if (suiteId && suiteId !== deps.cfg.suiteId)
92
142
  throw Object.assign(new Error("企微事件 SuiteId 与本部署配置不一致"), { status: 400 });
93
143
 
144
+ // 通讯录变更(轨三):**必须在 switch 之前分流**。两种形态都认——
145
+ // 应用侧数据回调是 `MsgType=event` + `Event=change_contact`(InfoType 为空),
146
+ // 代开发指令回调是 `InfoType=change_contact`;后者与 suite_ticket 同用 InfoType 这一列,
147
+ // 落进 default 只会被记成「不消费的事件」。
148
+ // 与下面几个分支的不同:通讯录事件失败**不抛**(事件是加速器,重推补不回来,见 contacts.ts)。
149
+ if (field(fields, "Event") === "change_contact" || infoType === "change_contact")
150
+ return await handleContactChange(fields, deps);
151
+
94
152
  switch (infoType) {
95
153
  case "suite_ticket": {
96
- const ticket = requireField(plaintext, "SuiteTicket", infoType);
154
+ const ticket = requireField(fields, "SuiteTicket", infoType);
97
155
  await putSuiteTicket(deps.cfg.suiteId, ticket, deps.store.now() + SUITE_TICKET_TTL_SECS, deps.store);
98
156
  log.info("wecom suite_ticket updated", "suiteId", deps.cfg.suiteId);
157
+ // 顺势换一次 suite_access_token(命中缓存则 0 次往返):create_auth 只有约 1 秒窗口,
158
+ // 这里先换掉,企业管理员扫码那条路径就只剩 get_permanent_code 一次出网。
159
+ // 副产品:suite_secret 配错会在票据推送时就进日志,而不是等某家企业扫码失败。
160
+ await deps.client.suiteAccessToken(deps.cfg.suiteId, deps.cfg.suiteSecret, ticket)
161
+ .catch((e: Error) => log.warn("wecom suite token warmup failed", "reason", e.message));
99
162
  return infoType;
100
163
  }
101
164
  case "create_auth":
102
165
  case "reset_permanent_code": {
103
- const authCode = requireField(plaintext, "AuthCode", infoType);
104
- const applied = await applyAuthCode(authCode, xmlField(plaintext, "State"), deps);
166
+ const authCode = requireField(fields, "AuthCode", infoType);
167
+ const applied = await applyAuthCode(authCode, field(fields, "State") || null, deps);
105
168
  log.info("wecom auth applied", "event", infoType, "corpId", applied.corpId, "agents", applied.agentIds.join(","));
106
169
  return infoType;
107
170
  }
108
171
  case "change_auth": {
109
- // 可见范围/权限/启停变化:MVP 只做「数据可见性不加码」——不改状态、不动 secret,
110
- // 靠下次登录重新走 userinfo(企微会拒掉已移出可见范围的成员)。停用/解绑走 cancel_auth。
111
- const authCorpId = requireField(plaintext, "AuthCorpId", infoType);
172
+ // 可见范围/权限/启停变化。其中**「管理员授权 ⇄ 成员授权」切换**必须跟:切到成员授权后
173
+ // 部门接口一律 60011,同步闸门还按旧值放行就会一直 502。官方要求服务商自行调
174
+ // `get_auth_info` 复核——通知本身只带 AuthCorpId(没有 AuthCode),只能拿已存永久码反查。
175
+ // 其余变化(可见范围收窄等)仍是「下次登录重新走 userinfo」,不在回调里处理。
176
+ const authCorpId = requireField(fields, "AuthCorpId", infoType);
177
+ ensureTenant(tenantOfCorp(authCorpId));
178
+ await refreshAuthModes(authCorpId, deps);
112
179
  log.info("wecom auth changed", "corpId", authCorpId);
113
180
  return infoType;
114
181
  }
115
182
  case "cancel_auth": {
116
- const authCorpId = requireField(plaintext, "AuthCorpId", infoType);
183
+ const authCorpId = requireField(fields, "AuthCorpId", infoType);
117
184
  await disableCorp(authCorpId, deps);
118
- await recordOp({ action: "wecom.auth.cancel", module: "auth", target: authCorpId, level: 4 });
185
+ await auditAuth("wecom.auth.cancel", authCorpId, undefined, 4);
119
186
  log.warn("wecom auth cancelled", "corpId", authCorpId);
120
187
  return infoType;
121
188
  }
122
- default:
123
- // 通讯录变更等事件本模块不消费:记账即可,回 success 让企微别再推
124
- log.info("wecom event ignored", "infoType", infoType);
125
- return infoType;
189
+ default: {
190
+ // 余下的事件(标签变更、外部联系人等)本模块不消费:记账即可,回 success 让企微别再推。
191
+ //
192
+ // 记账要记够「这条报文能不能用」的量:指令回调(有 InfoType)与数据回调
193
+ // (`MsgType=event` + `Event=xxx`,没有 InfoType)走的是同一个 `/event`,
194
+ // 而两者的 Token/AESKey 未必是同一套。真机验形态时只凭 InfoType 为空无法区分
195
+ // 「收到了一条事件」与「收到了一条别的消息」,所以把 Event 与字段清单一起记下。
196
+ // **只记字段名不记值**:明文里有姓名与工号。
197
+ const eventName = field(fields, "Event");
198
+ log.info(
199
+ "wecom event ignored",
200
+ "infoType",
201
+ infoType,
202
+ "msgType",
203
+ field(fields, "MsgType"),
204
+ "event",
205
+ eventName,
206
+ "fields",
207
+ fields.names.join(","),
208
+ );
209
+ return eventName || infoType;
210
+ }
126
211
  }
127
212
  }