@oj-module/cli 0.2.47 → 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.
- package/devkit/SKILL.md +1 -1
- package/devkit/framework-dev.md +2 -2
- package/devkit/manual.md +24 -14
- package/devkit/module-dev-and-cdn-playground.md +2 -2
- package/devkit/wecom-daikaifa.md +237 -14
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/templates-tenant-scope.test.ts +7 -2
- package/src/init.ts +1 -1
- package/src/market/__tests__/bootstrap.test.ts +1 -1
- package/src/market/__tests__/setup.test.ts +7 -7
- package/src/market/bootstrap.ts +1 -1
- package/templates/README.md +1 -1
- package/templates/init.include.yaml +12 -0
- package/templates/openapi.json +2863 -549
- package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
- package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
- package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
- package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
- package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
- package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
- package/templates/src/_platform/api/manifest.yaml +3 -0
- package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
- package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
- package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
- package/templates/src/_platform/api/org/departments/api.ts +51 -0
- package/templates/src/_platform/api/org/is-member/api.ts +33 -0
- package/templates/src/_platform/api/org/members/api.ts +55 -0
- package/templates/src/_platform/api/org/picker/api.ts +59 -0
- package/templates/src/_platform/api/org/resolve/api.ts +38 -0
- package/templates/src/_platform/api/org/tree/api.ts +37 -0
- package/templates/src/_platform/api/org/user/api.ts +46 -0
- package/templates/src/_platform/api/schema.yaml +48 -0
- package/templates/src/_platform/api/seed.mysql.sql +10 -5
- package/templates/src/_platform/api/seed.pg.sql +10 -5
- package/templates/src/_platform/api/seed.sql +13 -5
- package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
- package/templates/src/_platform/web/__client/api.ts +170 -1
- package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
- package/templates/src/auth/api/_shared/session.ts +5 -1
- package/templates/src/auth/api/_shared/wecom/admin.ts +3 -1
- package/templates/src/auth/api/_shared/wecom/client.ts +243 -31
- package/templates/src/auth/api/_shared/wecom/config.ts +12 -1
- package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
- package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
- package/templates/src/auth/api/_shared/wecom/login.ts +152 -1
- package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
- package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
- package/templates/src/auth/api/_shared/wecom/sync.ts +487 -0
- package/templates/src/auth/api/manifest.yaml +3 -0
- package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
- package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
- package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
- package/templates/src/auth/api/schema.yaml +31 -0
- package/templates/src/auth/api/wecom/admin/api.ts +3 -1
- package/templates/src/auth/api/wecom/api.ts +100 -18
- package/templates/src/auth/api/wecom/sync/api.ts +199 -0
- package/templates/src/auth/web/__client/api.schemas.ts +97 -0
- package/templates/src/auth/web/__client/api.ts +73 -1
- package/templates/src/notification/api/seed.sql +1 -1
- package/templates/src/system/api/departments/api.ts +142 -0
- package/templates/src/system/api/departments/detach/api.ts +34 -0
- package/templates/src/system/api/departments/members/api.ts +142 -0
- package/templates/src/system/api/departments/move/api.ts +39 -0
- package/templates/src/system/api/users/api.ts +45 -5
- package/templates/src/system/api/users/profile/api.ts +44 -0
- package/templates/src/system/web/__client/api.schemas.ts +143 -0
- package/templates/src/system/web/__client/api.ts +293 -1
- package/templates/src/system/web/entry.ts +6 -0
- package/templates/src/system/web/locales/en-US.json +112 -6
- package/templates/src/system/web/locales/zh-CN.json +112 -6
- package/templates/src/system/web/pages/departments/index.tsx +346 -0
- package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
- package/templates/src/system/web/pages/departments/tree.ts +66 -0
- package/templates/src/system/web/pages/errors.ts +1 -1
- package/templates/src/system/web/pages/users/index.tsx +49 -7
- package/templates/src/system/web/pages/wecom/index.tsx +14 -0
- package/templates/tests/l1-builder.test.ts +1 -1
- package/templates/tests/org-departments.spec.ts +311 -0
- package/templates/tests/org-read.spec.ts +469 -0
- package/templates/tests/rbac-contacts.spec.ts +13 -3
- package/templates/tests/rbac-seed.spec.ts +11 -0
- package/templates/tests/seed-role-accounts.spec.ts +6 -2
- package/templates/tests/wecom-admin.spec.ts +1 -0
- package/templates/tests/wecom-connector.spec.ts +115 -8
- package/templates/tests/wecom-crypt.spec.ts +12 -3
- package/templates/tests/wecom-endpoints.spec.ts +182 -14
- package/templates/tests/wecom-org-sync.spec.ts +598 -0
- package/templates/tests/wecom-sync.spec.ts +286 -0
- package/vendor/host-versions.json +3 -3
- package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
|
@@ -118,6 +118,88 @@ 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
|
+
function agentListOf(authInfo: unknown): Array<Record<string, unknown>> {
|
|
181
|
+
const list = (authInfo as Record<string, unknown> | undefined)?.agent;
|
|
182
|
+
return Array.isArray(list) ? list.map(a => a as Record<string, unknown>) : [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* agent_id → auth_mode。auth_mode 在 agent 项上(每个应用各自一条);老回包里偶尔只在
|
|
187
|
+
* auth_info 顶层给,故两级都读,缺省按 0(管理员授权)——成员授权会导致同步 100% 60011,
|
|
188
|
+
* 不能靠猜成 1。
|
|
189
|
+
*/
|
|
190
|
+
function readAuthModes(authInfo: unknown): Record<string, number> {
|
|
191
|
+
const top = (authInfo as Record<string, unknown> | undefined)?.auth_mode;
|
|
192
|
+
const modes: Record<string, number> = {};
|
|
193
|
+
for (const a of agentListOf(authInfo)) {
|
|
194
|
+
const agentId = String(a.agentid ?? "");
|
|
195
|
+
if (!agentId)
|
|
196
|
+
continue;
|
|
197
|
+
const mode = Number(a.auth_mode ?? top ?? 0);
|
|
198
|
+
modes[agentId] = Number.isFinite(mode) ? mode : 0;
|
|
199
|
+
}
|
|
200
|
+
return modes;
|
|
201
|
+
}
|
|
202
|
+
|
|
121
203
|
export function createWecomClient(deps: WecomClientDeps) {
|
|
122
204
|
const { http, cache } = deps;
|
|
123
205
|
|
|
@@ -156,15 +238,15 @@ export function createWecomClient(deps: WecomClientDeps) {
|
|
|
156
238
|
corpId: string
|
|
157
239
|
corpName: string | null
|
|
158
240
|
agentIds: string[]
|
|
241
|
+
/** agent_id → auth_mode(0=管理员授权 / 1=成员授权);成员授权下应用无任何部门权限(§2.3) */
|
|
242
|
+
authModes: Record<string, number>
|
|
159
243
|
state: string | null
|
|
160
244
|
}> {
|
|
161
245
|
const res = await http.postJson(`${API_BASE}/cgi-bin/service/v2/get_permanent_code?suite_access_token=${encodeURIComponent(suiteAccessToken)}`, { auth_code: authCode });
|
|
162
246
|
assertOk(res, "get_permanent_code");
|
|
163
247
|
const authCorp = (res.auth_corp_info ?? {}) as Record<string, unknown>;
|
|
164
|
-
const
|
|
165
|
-
const agentIds =
|
|
166
|
-
? agentList.map(a => String((a as Record<string, unknown>).agentid)).filter(Boolean)
|
|
167
|
-
: [];
|
|
248
|
+
const agents = agentListOf(res.auth_info);
|
|
249
|
+
const agentIds = agents.map(a => String(a.agentid)).filter(Boolean);
|
|
168
250
|
if (!res.permanent_code || !authCorp.corpid)
|
|
169
251
|
throw upstream("企微 get_permanent_code 返回缺少 permanent_code / corpid");
|
|
170
252
|
if (agentIds.length === 0)
|
|
@@ -174,10 +256,26 @@ export function createWecomClient(deps: WecomClientDeps) {
|
|
|
174
256
|
corpId: String(authCorp.corpid),
|
|
175
257
|
corpName: authCorp.corp_name == null ? null : String(authCorp.corp_name),
|
|
176
258
|
agentIds,
|
|
259
|
+
authModes: readAuthModes(res.auth_info),
|
|
177
260
|
state: res.state == null ? null : String(res.state),
|
|
178
261
|
};
|
|
179
262
|
}
|
|
180
263
|
|
|
264
|
+
/**
|
|
265
|
+
* 复核授权模式(`get_auth_info`):`change_auth` 事件只带 AuthCorpId、**不带 AuthCode**,
|
|
266
|
+
* 所以刷新 auth_mode 只能拿已存的 permanent_code 反查。企业管理员在后台把「管理员授权」
|
|
267
|
+
* 切成「成员授权」(或反向)就靠这条路径同步——切过去以后部门接口一律 60011,
|
|
268
|
+
* 不刷新就会一直按「可同步」对待。
|
|
269
|
+
*/
|
|
270
|
+
async function authModesByPermanentCode(suiteAccessToken: string, corpId: string, permanentCode: string): Promise<Record<string, number>> {
|
|
271
|
+
const res = await http.postJson(`${API_BASE}/cgi-bin/service/get_auth_info?suite_access_token=${encodeURIComponent(suiteAccessToken)}`, {
|
|
272
|
+
auth_corpid: corpId,
|
|
273
|
+
permanent_code: permanentCode,
|
|
274
|
+
});
|
|
275
|
+
assertOk(res, "get_auth_info");
|
|
276
|
+
return readAuthModes(res.auth_info);
|
|
277
|
+
}
|
|
278
|
+
|
|
181
279
|
/**
|
|
182
280
|
* 服务商级 token(provider_access_token):代开发「生成授权链接」用,与套件 token 不同源。
|
|
183
281
|
* 由服务商 corpid + provider_secret 换得,全部署共享一份(不限 suite)。
|
|
@@ -239,6 +337,127 @@ export function createWecomClient(deps: WecomClientDeps) {
|
|
|
239
337
|
await cache.del(`wecom:corp_token:${corpId}:${agentId}`);
|
|
240
338
|
}
|
|
241
339
|
|
|
340
|
+
/**
|
|
341
|
+
* 通讯录类 GET 的公共通道:token 失效重取 + 三类错码分流。
|
|
342
|
+
*
|
|
343
|
+
* 重试上限是有意的:token 失效最多换两次(企微侧提前失效是常态),限流退避最多三次
|
|
344
|
+
* (500ms/1s/2s),其余错码立即抛——同步器靠 kind 决定「跳过并计数」还是「计入熔断」,
|
|
345
|
+
* 所以**必须把 errcode 带出去**,不能像 assertOk 那样拍平成一句中文。
|
|
346
|
+
*/
|
|
347
|
+
async function corpGet(
|
|
348
|
+
corpId: string,
|
|
349
|
+
agentId: string,
|
|
350
|
+
secret: string,
|
|
351
|
+
path: string,
|
|
352
|
+
params: Record<string, string | number | undefined>,
|
|
353
|
+
what: string,
|
|
354
|
+
): Promise<Record<string, unknown>> {
|
|
355
|
+
const qs = Object.entries(params)
|
|
356
|
+
.filter(([, v]) => v != null && v !== "")
|
|
357
|
+
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
|
|
358
|
+
.join("&");
|
|
359
|
+
for (let attempt = 0; ; attempt++) {
|
|
360
|
+
const token = await corpAccessToken(corpId, agentId, secret);
|
|
361
|
+
const res = await http.getJson(`${API_BASE}${path}?access_token=${encodeURIComponent(token)}${qs ? `&${qs}` : ""}`);
|
|
362
|
+
const errcode = Number(res.errcode ?? 0);
|
|
363
|
+
if (!errcode)
|
|
364
|
+
return res;
|
|
365
|
+
if (TOKEN_EXPIRED.has(errcode) && attempt < 2) {
|
|
366
|
+
await invalidateCorpToken(corpId, agentId);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const err = new WecomApiError(what, errcode, String(res.errmsg ?? ""));
|
|
370
|
+
if (err.kind === "ratelimit" && attempt < 2) {
|
|
371
|
+
await softSleep(500 * 2 ** attempt);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function toDept(raw: Record<string, unknown>): WecomDept {
|
|
379
|
+
return {
|
|
380
|
+
id: String(raw.id ?? ""),
|
|
381
|
+
parentId: raw.parentid == null ? "" : String(raw.parentid),
|
|
382
|
+
name: raw.name == null ? null : String(raw.name),
|
|
383
|
+
nameEn: raw.name_en == null ? null : String(raw.name_en),
|
|
384
|
+
order: Number(raw.order ?? 0),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* 子部门 id 列表(接口 1):全量同步的**主路径**——官方 90208 明写 `department/list` 性能低。
|
|
390
|
+
* `parentOriginId` 缺省时企微返回「应用可见范围内所有部门」,这既是 BFS 的起点,也是兜底路径。
|
|
391
|
+
*/
|
|
392
|
+
async function departmentChildren(corpId: string, agentId: string, secret: string, parentOriginId?: string): Promise<WecomDept[]> {
|
|
393
|
+
const res = await corpGet(corpId, agentId, secret, "/cgi-bin/department/simplelist", { id: parentOriginId }, "department/simplelist");
|
|
394
|
+
const list = Array.isArray(res.department_id) ? res.department_id as Array<Record<string, unknown>> : [];
|
|
395
|
+
return list.filter(d => d && d.id != null).map(toDept);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** 单部门详情(接口 2):simplelist 不带名字,名字只能从这里取(未授权时 name 为空) */
|
|
399
|
+
async function departmentDetail(corpId: string, agentId: string, secret: string, originId: string): Promise<WecomDept> {
|
|
400
|
+
const res = await corpGet(corpId, agentId, secret, "/cgi-bin/department/get", { id: originId }, "department/get");
|
|
401
|
+
const d = res.department as Record<string, unknown> | undefined;
|
|
402
|
+
if (!d || d.id == null)
|
|
403
|
+
throw upstream("企微 department/get 未返回部门详情");
|
|
404
|
+
return toDept(d);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** 一次拿全量部门(接口 3):降级兜底,1/2 因权限拿不到时用 */
|
|
408
|
+
async function departmentList(corpId: string, agentId: string, secret: string, parentOriginId?: string): Promise<WecomDept[]> {
|
|
409
|
+
const res = await corpGet(corpId, agentId, secret, "/cgi-bin/department/list", { id: parentOriginId }, "department/list");
|
|
410
|
+
const list = Array.isArray(res.department) ? res.department as Array<Record<string, unknown>> : [];
|
|
411
|
+
return list.filter(d => d && d.id != null).map(toDept);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function toMember(raw: Record<string, unknown>, detail: boolean): WecomMember {
|
|
415
|
+
const departments = Array.isArray(raw.department) ? raw.department.map(d => String(d)) : [];
|
|
416
|
+
const orders = detail && Array.isArray(raw.order) ? raw.order.map(o => Number(o)) : [];
|
|
417
|
+
const leader = detail && Array.isArray(raw.is_leader_in_dept) ? raw.is_leader_in_dept.map(o => Number(o)) : [];
|
|
418
|
+
return {
|
|
419
|
+
userid: String(raw.userid ?? ""),
|
|
420
|
+
name: raw.name == null ? null : String(raw.name),
|
|
421
|
+
departments,
|
|
422
|
+
orders,
|
|
423
|
+
leaderInDept: leader,
|
|
424
|
+
mainDepartment: detail && raw.main_department != null ? Number(raw.main_department) : null,
|
|
425
|
+
position: detail && raw.position != null ? String(raw.position) : null,
|
|
426
|
+
status: detail && raw.status != null ? Number(raw.status) : null,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** 部门成员(接口 4,轻量):`user/simplelist` 无 fetch_child,子部门要调用方自己递归 */
|
|
431
|
+
async function deptMembers(corpId: string, agentId: string, secret: string, deptOriginId: string): Promise<WecomMember[]> {
|
|
432
|
+
const res = await corpGet(corpId, agentId, secret, "/cgi-bin/user/simplelist", { department_id: deptOriginId }, "user/simplelist");
|
|
433
|
+
const list = Array.isArray(res.userlist) ? res.userlist as Array<Record<string, unknown>> : [];
|
|
434
|
+
return list.filter(u => u && u.userid != null).map(u => toMember(u, false));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** 部门成员详情(接口 5,重量):职务 / 排序 / 主部门 / 在职状态只有这里有;同一部门只该调一次 */
|
|
438
|
+
async function deptMembersDetail(corpId: string, agentId: string, secret: string, deptOriginId: string): Promise<WecomMember[]> {
|
|
439
|
+
const res = await corpGet(corpId, agentId, secret, "/cgi-bin/user/list", { department_id: deptOriginId }, "user/list");
|
|
440
|
+
const list = Array.isArray(res.userlist) ? res.userlist as Array<Record<string, unknown>> : [];
|
|
441
|
+
return list.filter(u => u && u.userid != null).map(u => toMember(u, true));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* 单成员详情(接口 6):**登录自更新**用它,四次/次调用就够。
|
|
446
|
+
*
|
|
447
|
+
* best-effort:任何失败(无权限 / 不在可见范围 / 上游抖)返回 null,绝不抛出——
|
|
448
|
+
* 补资料是体验优化,不能因为拿不到把登录打成 5xx。调用方拿到 null 就保留兜底值。
|
|
449
|
+
*/
|
|
450
|
+
async function userDetail(corpId: string, agentId: string, secret: string, userid: string): Promise<WecomMember | null> {
|
|
451
|
+
try {
|
|
452
|
+
const res = await corpGet(corpId, agentId, secret, "/cgi-bin/user/get", { userid }, "user/get");
|
|
453
|
+
// 回包偶尔不带 userid(老接口/精简回包):入参就是权威,用入参补
|
|
454
|
+
return toMember({ ...res, userid: res.userid ?? userid }, true);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
242
461
|
/**
|
|
243
462
|
* 成员姓名(best-effort):只用于把 JIT 号的 `display_name` 从工号变成真名。
|
|
244
463
|
*
|
|
@@ -248,32 +467,9 @@ export function createWecomClient(deps: WecomClientDeps) {
|
|
|
248
467
|
* token 复用 `userIdByCode` 刚取的那份缓存(同请求内通常 0 次额外换票)。
|
|
249
468
|
*/
|
|
250
469
|
async function userNameById(corpId: string, agentId: string, secret: string, userid: string): Promise<string | null> {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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;
|
|
470
|
+
const detail = await userDetail(corpId, agentId, secret, userid);
|
|
471
|
+
const name = detail?.name?.trim();
|
|
472
|
+
return name || null;
|
|
277
473
|
}
|
|
278
474
|
|
|
279
475
|
/** 网页授权 code → userid(snsapi_base;非企业成员/超出可见范围 → 401) */
|
|
@@ -296,7 +492,23 @@ export function createWecomClient(deps: WecomClientDeps) {
|
|
|
296
492
|
throw upstream("企微 token 反复失效(40001/42001)");
|
|
297
493
|
}
|
|
298
494
|
|
|
299
|
-
return {
|
|
495
|
+
return {
|
|
496
|
+
suiteAccessToken,
|
|
497
|
+
permanentCodeByAuthCode,
|
|
498
|
+
authModesByPermanentCode,
|
|
499
|
+
providerAccessToken,
|
|
500
|
+
customizedAuthUrl,
|
|
501
|
+
corpAccessToken,
|
|
502
|
+
invalidateCorpToken,
|
|
503
|
+
userIdByCode,
|
|
504
|
+
userNameById,
|
|
505
|
+
userDetail,
|
|
506
|
+
departmentChildren,
|
|
507
|
+
departmentDetail,
|
|
508
|
+
departmentList,
|
|
509
|
+
deptMembers,
|
|
510
|
+
deptMembersDetail,
|
|
511
|
+
};
|
|
300
512
|
}
|
|
301
513
|
|
|
302
514
|
export type WecomClient = ReturnType<typeof createWecomClient>;
|
|
@@ -153,7 +153,18 @@ export function readWecomConfig(): WecomConfig {
|
|
|
153
153
|
return v;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
/**
|
|
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
|
+
}
|
|
@@ -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
|
-
/**
|
|
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 >
|
|
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`
|
|
295
|
-
*
|
|
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
|
|
298
|
-
*
|
|
299
|
-
* receiveid 只是防御性兜底(防错配 aeskey
|
|
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}=`);
|