@oj-module/cli 0.2.49 → 0.2.53
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/README.md +8 -6
- package/devkit/SKILL.md +32 -12
- package/devkit/manual.md +6 -3
- package/devkit/wecom-daikaifa.md +95 -1
- package/devkit/wecom-internal-app.md +750 -0
- package/package.json +2 -2
- package/shell-dist/assets/{index-FYt9CjFd.js → index-e_Mp2mb5.js} +5 -1
- package/shell-dist/assets/runtime.js +24 -3
- package/shell-dist/index.html +9 -12
- package/shell-dist/versions.json +3 -3
- package/src/build.ts +19 -2
- package/src/init.ts +5 -4
- package/src/shared-deps.ts +27 -9
- package/src/site-base.ts +1 -1
- package/templates/config.yaml +28 -6
- package/templates/init.include.yaml +4 -7
- package/templates/openapi.json +2636 -2356
- package/templates/src/_platform/api/_shared/service/domain/users.ts +40 -4
- package/templates/src/auth/api/_shared/wecom/admin.ts +91 -7
- package/templates/src/auth/api/_shared/wecom/client.ts +9 -2
- package/templates/src/auth/api/_shared/wecom/config.ts +256 -46
- package/templates/src/auth/api/_shared/wecom/contacts.ts +7 -1
- package/templates/src/auth/api/_shared/wecom/events.ts +28 -6
- package/templates/src/auth/api/_shared/wecom/login.ts +21 -13
- package/templates/src/auth/api/_shared/wecom/store.ts +61 -12
- package/templates/src/auth/api/_shared/wecom/sync.ts +11 -1
- package/templates/src/auth/api/_shared/wecom/tenant-admin.ts +194 -0
- package/templates/src/auth/api/migrations/0003__credential_kind.mysql.sql +3 -0
- package/templates/src/auth/api/migrations/0003__credential_kind.pg.sql +3 -0
- package/templates/src/auth/api/migrations/0003__credential_kind.sql +9 -0
- package/templates/src/auth/api/schema.yaml +2 -0
- package/templates/src/auth/api/wecom/admin/api.ts +112 -11
- package/templates/src/auth/api/wecom/admin/tenant-admin/api.ts +41 -0
- package/templates/src/auth/api/wecom/admin/tenant-admin/password/api.ts +66 -0
- package/templates/src/auth/api/wecom/api.ts +102 -48
- package/templates/src/auth/api/wecom/sync/api.ts +21 -5
- package/templates/src/auth/web/__client/api.schemas.ts +22 -1
- package/templates/src/auth/web/__client/api.ts +47 -1
- package/templates/src/login/web/pages/login.tsx +54 -24
- package/templates/src/system/api/settings/api.ts +3 -3
- package/templates/src/system/web/entry.ts +4 -4
- package/templates/src/system/web/locales/en-US.json +38 -1
- package/templates/src/system/web/locales/zh-CN.json +38 -1
- package/templates/src/system/web/pages/departments/sync-bar.tsx +32 -7
- package/templates/src/system/web/pages/users/index.tsx +7 -4
- package/templates/src/system/web/pages/users/roles.tsx +14 -3
- package/templates/src/system/web/pages/wecom/index.tsx +292 -45
- package/templates/tests/wecom-admin.spec.ts +7 -7
- package/templates/tests/wecom-connector.spec.ts +7 -6
- package/templates/tests/wecom-corp.spec.ts +523 -0
- package/templates/tests/wecom-endpoints.spec.ts +10 -4
- package/templates/tests/wecom-org-sync.spec.ts +7 -1
- package/templates/tsconfig.paths.json +0 -1
- package/templates/web.config.ts +1 -2
- package/vendor/host-versions.json +3 -3
- package/shell-dist/assets/echarts-charts.js +0 -58571
- package/shell-dist/assets/echarts-features.js +0 -24057
- package/shell-dist/assets/echarts-for-react.js +0 -704
- package/shell-dist/assets/echarts.js +0 -82916
- package/shell-dist/assets/editor.js +0 -161457
- package/shell-dist/assets/vditor-index-css.js +0 -7
- package/shell-dist/assets/vditor.js +0 -15223
- package/templates/src/demo/api/manifest.yaml +0 -5
- package/templates/src/demo/api/migrations/0001__create_todos.sql +0 -9
- package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +0 -2
- package/templates/src/demo/api/migrations/0002__tenant.sql +0 -2
- package/templates/src/demo/api/schema.yaml +0 -12
- package/templates/src/demo/api/seed.sql +0 -5
- package/templates/src/demo/api/todos/api.ts +0 -59
- package/templates/src/demo/web/__client/api.schemas.ts +0 -20
- package/templates/src/demo/web/__client/api.ts +0 -89
- package/templates/src/demo/web/entry.ts +0 -42
- package/templates/src/demo/web/locales/en-US.json +0 -10
- package/templates/src/demo/web/locales/zh-CN.json +0 -10
- package/templates/src/demo/web/pages/index.tsx +0 -45
|
@@ -59,13 +59,30 @@ function assertNotSelf(actorId: number, targetUid: number): void {
|
|
|
59
59
|
fail(400, "cannot operate on yourself");
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* AC1-8(已修订):成员资格提权守卫——按**被授予的角色**分层,不再一刀切。
|
|
64
|
+
*
|
|
65
|
+
* · `superadmin` —— 仍严格只许 superadmin 授。它是**平台级**角色:握着它就能进
|
|
66
|
+
* /system/wecom 那类跨租户页面(listApps 列出**所有企业**的档案),一旦租户内可授
|
|
67
|
+
* 即为跨租户越权,故这条不因下面的修订而放宽。
|
|
68
|
+
* · `admin` —— 允许本租户内握有 `admin` 或 `superadmin` 的人任命。
|
|
69
|
+
* 判定与写入两侧都被租户作用域约束:`rolesOf()` 读 sys_user_role 走请求租户注入,
|
|
70
|
+
* 下面的写入同样带 tenant_id,所以租户管理员只能影响**自己租户**,不会越到别家。
|
|
71
|
+
* 动机:企业租户是隐式具象化的,平台 superadmin 因租户隔离查不到企业租户的用户
|
|
72
|
+
* (实测 404),若 admin 任命也只许 superadmin,企业租户就永远配不出管理员。
|
|
73
|
+
* · 其余角色(editor/viewer 等)—— 不校验,保持原行为。
|
|
74
|
+
*/
|
|
63
75
|
async function assertMembershipAllowed(actorId: number, nextRoles: string[]): Promise<void> {
|
|
64
76
|
if (!nextRoles.includes("superadmin") && !nextRoles.includes("admin"))
|
|
65
77
|
return;
|
|
66
78
|
const actorRoles = await rolesOf(Number(actorId));
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
const isSuper = actorRoles.includes("superadmin");
|
|
80
|
+
// 提权到平台级:只许 superadmin
|
|
81
|
+
if (nextRoles.includes("superadmin") && !isSuper)
|
|
82
|
+
fail(403, "granting superadmin requires superadmin");
|
|
83
|
+
// 任命本租户管理员:本租户的 admin 或 superadmin 均可
|
|
84
|
+
if (nextRoles.includes("admin") && !isSuper && !actorRoles.includes("admin"))
|
|
85
|
+
fail(403, "membership management requires admin or superadmin");
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
export async function listUsers(): Promise<UserRow[]> {
|
|
@@ -206,11 +223,30 @@ export async function updateUserRoles(actorId: number, targetUid: number, roles:
|
|
|
206
223
|
await t.table("sys_user_role").insert({ user_id: targetUid, role_name: role }).run();
|
|
207
224
|
});
|
|
208
225
|
invalidateUser(Number(targetUid));
|
|
226
|
+
// 提权类变更(牵涉 admin / superadmin)单列严重级:任命管理员=把该租户的最高权限交出去,
|
|
227
|
+
// 不该和「勾了个 editor」落在同一噪声水平上。收回也算——撤销提权同样是安全事件。
|
|
228
|
+
const touchesPrivilege = (r: string) => r === "admin" || r === "superadmin";
|
|
229
|
+
const elevated = next.some(touchesPrivilege) || [...beforeSet].some(touchesPrivilege);
|
|
209
230
|
await recordOp({
|
|
210
231
|
action: "user.roles.update",
|
|
211
232
|
module: "framework",
|
|
212
233
|
target: await usernameOf(Number(targetUid)),
|
|
213
|
-
detail: {
|
|
234
|
+
detail: {
|
|
235
|
+
added: next.filter(r => !beforeSet.has(r)),
|
|
236
|
+
removed: [...beforeSet].filter(r => !next.includes(r)),
|
|
237
|
+
// —— 租户维度 ——
|
|
238
|
+
// 每个租户都有自己的管理员,且**用户名撞车**(都叫 admin):只记 username 分不清
|
|
239
|
+
// 「哪个租户的 admin 任命了谁」。sys_oplog.tenant_id 虽由请求租户注入,但那是隐式列,
|
|
240
|
+
// 显式落一份 + 双方 uid,审计才能直接按租户筛、也能定位到具体两个账号。
|
|
241
|
+
tenant: http.tenantId ?? "",
|
|
242
|
+
actorUid: Number(actorId),
|
|
243
|
+
targetUid: Number(targetUid),
|
|
244
|
+
// 便于直接筛出「任命/撤销管理员」这类高价值条目,不必去解析 added/removed
|
|
245
|
+
elevated,
|
|
246
|
+
},
|
|
247
|
+
// 4=warning(与「重设租户管理员口令」同级):提权变更按安全事件留痕;
|
|
248
|
+
// 普通角色变更(editor/viewer)保持默认 6=info,避免审计被噪声淹没。
|
|
249
|
+
level: elevated ? 4 : 6,
|
|
214
250
|
});
|
|
215
251
|
}
|
|
216
252
|
|
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 企微后台(服务商运营面 + 自建应用接入面):企业应用清单、启停、开通入口、验证并接入。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 四个反常识点,都是踩过的坑:
|
|
4
4
|
// 1. **不读部署常量也能停用**:`wecom_base_url` 配错时正是最需要「先把这个企业停掉」的时刻,
|
|
5
|
-
// 所以清单与启停都不依赖 `
|
|
5
|
+
// 所以清单与启停都不依赖 `readSuiteConfig`,只有开通入口要(它得调企微换预授权码)。
|
|
6
6
|
// 2. **不能用 `asTenant` 清会话**:管理员请求自带自己的租户头(多半是 `default`),
|
|
7
7
|
// 而 `asTenant` 只能在「请求还没有租户」时调用,否则抛错。故吊销目标企业会话走带
|
|
8
8
|
// `tenant_id = ?` 的裸 SQL(守卫是 check-on-miss,带了租户条件即放行)。
|
|
9
9
|
// 3. **停用单个应用 ≠ 停用企业**:会话是按租户(= 企业)发的,一个企业可能有测试/正式两个应用。
|
|
10
10
|
// 只有该企业**再没有启用中的应用**时才吊销会话——否则停用测试应用会把正式环境的人踢下线。
|
|
11
11
|
import type { WecomClient } from "./client";
|
|
12
|
-
import type { WecomConfig } from "./config";
|
|
12
|
+
import type { WecomConfig, WecomCorpConfig, WecomKind } from "./config";
|
|
13
13
|
import type { WecomStoreDeps } from "./store";
|
|
14
14
|
import { invalidateAllUsers } from "#/_platform/_shared/service/infra/access";
|
|
15
15
|
import { assertProviderConfig, eventUrlOf } from "./config";
|
|
16
|
-
import {
|
|
16
|
+
import { provisionTenantAdmin } from "./tenant-admin";
|
|
17
|
+
import { corpScope, revokeTenantSessions, saveProfile, tenantOfCorp } from "./store";
|
|
17
18
|
|
|
18
19
|
export interface WecomAdminDeps {
|
|
19
20
|
client: WecomClient
|
|
@@ -29,7 +30,9 @@ export interface AppRow {
|
|
|
29
30
|
env: string
|
|
30
31
|
corp_name: string | null
|
|
31
32
|
status: string
|
|
32
|
-
/**
|
|
33
|
+
/** 应用来源:suite=代开发 / corp=企业内部应用(自建) */
|
|
34
|
+
credential_kind: WecomKind
|
|
35
|
+
/** 0=管理员授权 / 1=成员授权:成员授权下这家企业的应用做不了组织架构同步(§2.3);自建行恒 0 */
|
|
33
36
|
auth_mode: number
|
|
34
37
|
installed_at: number
|
|
35
38
|
update_time: number
|
|
@@ -37,7 +40,7 @@ export interface AppRow {
|
|
|
37
40
|
|
|
38
41
|
export async function listApps(): Promise<AppRow[]> {
|
|
39
42
|
const rows = await db.table("wecom_app_profile")
|
|
40
|
-
.select(["suite_id", "corp_id", "agent_id", "auth_corp_id", "env", "corp_name", "status", "auth_mode", "installed_at", "update_time"])
|
|
43
|
+
.select(["suite_id", "corp_id", "agent_id", "auth_corp_id", "env", "corp_name", "status", "credential_kind", "auth_mode", "installed_at", "update_time"])
|
|
41
44
|
.all() as unknown as AppRow[];
|
|
42
45
|
// 稳定序:先按企业、再按应用;同企业多环境并排,运维一眼能看出「哪两个是同一家」
|
|
43
46
|
return rows.sort((a, b) => a.corp_id.localeCompare(b.corp_id) || a.agent_id.localeCompare(b.agent_id));
|
|
@@ -117,3 +120,84 @@ export async function createInstall(
|
|
|
117
120
|
expiresIn: auth.expiresIn,
|
|
118
121
|
};
|
|
119
122
|
}
|
|
123
|
+
|
|
124
|
+
/** 接入校验最多探多少个部门找人(顶层部门常只有子部门,只看第一个会误判「读不到人」) */
|
|
125
|
+
const CONNECT_SAMPLE_DEPTS = 10;
|
|
126
|
+
|
|
127
|
+
export interface ConnectResult {
|
|
128
|
+
corpId: string
|
|
129
|
+
agentId: string
|
|
130
|
+
/** 应用可见范围内的部门数 */
|
|
131
|
+
departments: number
|
|
132
|
+
/** 示例部门里的直属成员数(>0 才说明通讯录权限真的通了) */
|
|
133
|
+
members: number
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 自建应用「验证并接入」:**四步全过才落库**(plan §4.3)。
|
|
138
|
+
*
|
|
139
|
+
* 为什么不能启动时自动写一行:自检失败会变成启动失败,而自建模式最常见的失败是客户还没加可信 IP、
|
|
140
|
+
* 可见范围没配——那是运营动作,不该让服务起不来。所以这一步放在人点的按钮后面。
|
|
141
|
+
*
|
|
142
|
+
* 顺序有讲究:
|
|
143
|
+
* 1. 先**作废 token 缓存**再换 token。换过 Secret 的部署若命中旧缓存,校验会「通过」,
|
|
144
|
+
* 而真正的问题(旧 secret 已失效)会在下一个登录请求上以 40001 冒出来。
|
|
145
|
+
* 2. gettoken 验 secret、department/simplelist 验通讯录读权限、user/simplelist 验能读到人——
|
|
146
|
+
* 这三件事对应三个最容易配错的开关(Secret 抄错 / 可见范围空 / 通讯录权限没给)。
|
|
147
|
+
*/
|
|
148
|
+
export async function connectCorp(cfg: WecomCorpConfig, client: WecomClient, deps: WecomStoreDeps): Promise<ConnectResult> {
|
|
149
|
+
await client.invalidateCorpToken(cfg.corpId, cfg.agentId);
|
|
150
|
+
await client.corpAccessToken(cfg.corpId, cfg.agentId, cfg.corpSecret);
|
|
151
|
+
|
|
152
|
+
const depts = await client.departmentChildren(cfg.corpId, cfg.agentId, cfg.corpSecret);
|
|
153
|
+
if (!depts.length)
|
|
154
|
+
throw Object.assign(new Error("接入校验失败:应用可见范围里没有任何部门(把「应用可见范围」设为全公司,或至少包含一个部门)"), { status: 400 });
|
|
155
|
+
|
|
156
|
+
// 顶层部门常常只有子部门、没有直属成员,所以往后再看几个再下结论
|
|
157
|
+
let members = 0;
|
|
158
|
+
for (const d of depts.slice(0, CONNECT_SAMPLE_DEPTS)) {
|
|
159
|
+
members = (await client.deptMembers(cfg.corpId, cfg.agentId, cfg.corpSecret, d.id)).length;
|
|
160
|
+
if (members)
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
if (!members)
|
|
164
|
+
throw Object.assign(new Error("接入校验失败:可见范围内读不到成员(请把应用可见范围扩大到至少一名成员)"), { status: 400 });
|
|
165
|
+
|
|
166
|
+
await saveProfile({
|
|
167
|
+
// corp 行没有套件:suite_id 用空串(主键第一段),来源由 credential_kind 表达
|
|
168
|
+
suiteId: "",
|
|
169
|
+
corpId: cfg.corpId,
|
|
170
|
+
authCorpId: cfg.corpId,
|
|
171
|
+
agentId: cfg.agentId,
|
|
172
|
+
env: "prod",
|
|
173
|
+
corpName: null,
|
|
174
|
+
credentialKind: "corp",
|
|
175
|
+
permanentCode: cfg.corpSecret,
|
|
176
|
+
// 自建应用没有「管理员授权 / 成员授权」之分:恒 0,同步闸门据此直接放行
|
|
177
|
+
authMode: 0,
|
|
178
|
+
}, deps);
|
|
179
|
+
|
|
180
|
+
// 换过 AgentId 的部署会留下旧应用的活跃行:旧行的 secret 已不可用,留着只会让
|
|
181
|
+
// 「同一企业多应用」的分辨逻辑误判,故随本次接入一并停用。
|
|
182
|
+
const stale = await db.table("wecom_app_profile").select(["agent_id"]).where({
|
|
183
|
+
and: [
|
|
184
|
+
corpScope(cfg.corpId),
|
|
185
|
+
{ field: "credential_kind", op: "eq", value: "corp" },
|
|
186
|
+
{ field: "status", op: "eq", value: "active" },
|
|
187
|
+
],
|
|
188
|
+
}).all() as Array<{ agent_id: string }>;
|
|
189
|
+
for (const r of stale) {
|
|
190
|
+
const old = String(r.agent_id);
|
|
191
|
+
if (old === cfg.agentId)
|
|
192
|
+
continue;
|
|
193
|
+
await db.table("wecom_app_profile").update({ status: "disabled", update_time: deps.now() }).where({
|
|
194
|
+
and: [corpScope(cfg.corpId), { field: "agent_id", op: "eq", value: old }],
|
|
195
|
+
}).run();
|
|
196
|
+
await client.invalidateCorpToken(cfg.corpId, old);
|
|
197
|
+
}
|
|
198
|
+
// 租户具象化:给这个企业配一个本租户的管理员(admin,非 superadmin),否则
|
|
199
|
+
// 该租户里只有 JIT 出来的员工号,没人能做组织架构同步、也没人能在租户内授权。
|
|
200
|
+
await provisionTenantAdmin(cfg.corpId);
|
|
201
|
+
invalidateAllUsers();
|
|
202
|
+
return { corpId: cfg.corpId, agentId: cfg.agentId, departments: depts.length, members };
|
|
203
|
+
}
|
|
@@ -518,11 +518,18 @@ export function createWecomClient(deps: WecomClientDeps) {
|
|
|
518
518
|
await invalidateCorpToken(corpId, agentId);
|
|
519
519
|
continue;
|
|
520
520
|
}
|
|
521
|
-
if (errcode !== 0)
|
|
521
|
+
if (errcode !== 0) {
|
|
522
|
+
// 部署侧错误(IP 白名单 / 限流)不是用户凭据问题:按 502 抛,避免被统一成 401 把排查带偏
|
|
523
|
+
log.warn("wecom getuserinfo rejected", "errcode", String(errcode), "errmsg", String(res.errmsg ?? ""));
|
|
524
|
+
if (errcode === 60020 || errcode === 45009)
|
|
525
|
+
throw upstream(`企微 auth/getuserinfo 失败:errcode=${errcode} ${String(res.errmsg ?? "")}`.trim());
|
|
522
526
|
rejectCode();
|
|
527
|
+
}
|
|
523
528
|
const userid = res.userid == null ? "" : String(res.userid);
|
|
524
|
-
if (!userid)
|
|
529
|
+
if (!userid) {
|
|
530
|
+
log.warn("wecom getuserinfo no userid", "keys", Object.keys(res).join(","));
|
|
525
531
|
rejectCode(); // 非企业成员(外部联系人)时企微只给 openid,不认作本应用用户
|
|
532
|
+
}
|
|
526
533
|
return userid;
|
|
527
534
|
}
|
|
528
535
|
throw upstream("企微 token 反复失效(40001/42001)");
|
|
@@ -1,15 +1,27 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 企微连接器的部署常量(config `vars:` 段,不进 DB、不进 sys_setting)。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// **两种应用来源,可同时启用**(docs/plans/20261101-2044-wecom-internal-app-plan.md §4.10):
|
|
4
|
+
// · suite(代开发):一个部署 = 一个服务商账号下的一套代开发模板。凭据是 suite_id/suite_secret,
|
|
5
|
+
// 「哪个企业装了哪个应用」不写配置:create_auth 回调推 AuthCode → 换 permanent_code →
|
|
6
|
+
// 落 wecom_app_profile,新增企业只需扫码授权,**不改配置、不重启**。
|
|
7
|
+
// · corp(企业内部应用 / 自建应用):一套部署 = 一家企业,凭据是 corpid + 应用 Secret + AgentId。
|
|
8
|
+
// 没有套件流程,也没有开通链接;档案行由后台「验证并接入」写入。
|
|
9
|
+
//
|
|
10
|
+
// 两套的来源不同但**企业侧数据链路完全相同**:代开发的 permanent_code 就是当 corpsecret 用的,
|
|
11
|
+
// 所以下面凡是「拿企业 token / 读通讯录 / 换 userid」的代码都只有一份实现。
|
|
12
|
+
//
|
|
13
|
+
// 回声(业务上最容易混):
|
|
14
|
+
// · `wecom_callback_token` / `wecom_encoding_aes_key` = 代开发**指令回调**那一组(服务商后台填);
|
|
15
|
+
// · `wecom_corp_callback_token` / `wecom_corp_encoding_aes_key` = 自建应用**通讯录同步 → 接收事件服务器**
|
|
16
|
+
// 那一组(企业管理员在自己后台填)。两组不同源,不能互相顶替。
|
|
8
17
|
//
|
|
9
18
|
// 与 ldap 同口径:启用开关走 sys_setting(`wecom.enabled`),凭据走部署常量。
|
|
10
19
|
|
|
11
20
|
import { b64ToBytes } from "./crypt";
|
|
12
21
|
|
|
22
|
+
/** 应用来源:代开发(套件) / 企业内部应用(自建) */
|
|
23
|
+
export type WecomKind = "suite" | "corp";
|
|
24
|
+
|
|
13
25
|
/**
|
|
14
26
|
* 回调引导配置(Tier A):echostr URL 验证 + 永久码落库加密所需的全部凭据。
|
|
15
27
|
* 这四项**全部由部署方在本地生成**,与企业微信无关——所以可以在「创建代开发模版 / 拿到
|
|
@@ -21,12 +33,12 @@ export interface WecomCallbackConfig {
|
|
|
21
33
|
token: string
|
|
22
34
|
/** 回调加密密钥(43 字符 EncodingAESKey) */
|
|
23
35
|
encodingAesKey: string
|
|
24
|
-
/** AES-GCM 落库密钥(hex/base64,16 或 32
|
|
36
|
+
/** AES-GCM 落库密钥(hex/base64,16 或 32 字节):加密永久码 / 应用 Secret */
|
|
25
37
|
encKey: string
|
|
26
38
|
/**
|
|
27
39
|
* 连接器基址(绝对地址,无尾斜杠),如 `https://site.example.com/api/auth/wecom`。
|
|
28
40
|
* 两个派生地址都由它拼出,避免「同一件事配两处、改一处漏一处」:
|
|
29
|
-
* ·
|
|
41
|
+
* · 回调 URL = `<base>/event` → **原样填进企微后台**(须逐字一致,含协议与路径)
|
|
30
42
|
* · 网页授权 redirect_uri = `<base>/callback`
|
|
31
43
|
*/
|
|
32
44
|
baseUrl: string
|
|
@@ -51,6 +63,61 @@ export interface WecomConfig extends WecomCallbackConfig {
|
|
|
51
63
|
providerSecret: string
|
|
52
64
|
}
|
|
53
65
|
|
|
66
|
+
/**
|
|
67
|
+
* 企业内部应用(自建应用)的部署常量。
|
|
68
|
+
*
|
|
69
|
+
* 一套部署只服务一家企业——这是产品定位而非技术妥协:凭据是「企业 × 应用」维度的,
|
|
70
|
+
* 进配置就意味着单企业;多企业共用一套部署的需求由代开发承担(扫码即装)。
|
|
71
|
+
*/
|
|
72
|
+
export interface WecomCorpConfig {
|
|
73
|
+
/** 企业 ID(企微后台「我的企业 → 企业信息 → 企业ID」),同时是租户键 */
|
|
74
|
+
corpId: string
|
|
75
|
+
/** 应用 Secret(应用详情页)——换过必须重新走「验证并接入」 */
|
|
76
|
+
corpSecret: string
|
|
77
|
+
/** 应用 AgentId(应用详情页) */
|
|
78
|
+
agentId: string
|
|
79
|
+
/** 通讯录同步「接收事件服务器」的 Token(不配 = 不做事件增量) */
|
|
80
|
+
callbackToken: string
|
|
81
|
+
/** 同上 → EncodingAESKey */
|
|
82
|
+
encodingAesKey: string
|
|
83
|
+
encKey: string
|
|
84
|
+
baseUrl: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 与来源无关的两项:登录/同步/回跳都只需要它们 */
|
|
88
|
+
export interface WecomBase {
|
|
89
|
+
encKey: string
|
|
90
|
+
baseUrl: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 一次性读出全部部署常量(未 trim 的尾斜杠在此归一) */
|
|
94
|
+
function readWecomVars() {
|
|
95
|
+
return {
|
|
96
|
+
token: vars.get("wecom_callback_token") ?? "",
|
|
97
|
+
encodingAesKey: vars.get("wecom_encoding_aes_key") ?? "",
|
|
98
|
+
encKey: vars.get("wecom_enc_key") ?? "",
|
|
99
|
+
baseUrl: (vars.get("wecom_base_url") ?? "").replace(/\/+$/, ""),
|
|
100
|
+
suiteId: vars.get("wecom_suite_id") ?? "",
|
|
101
|
+
suiteSecret: vars.get("wecom_suite_secret") ?? "",
|
|
102
|
+
providerId: vars.get("wecom_provider_id") ?? "",
|
|
103
|
+
providerSecret: vars.get("wecom_provider_secret") ?? "",
|
|
104
|
+
corpId: vars.get("wecom_corp_id") ?? "",
|
|
105
|
+
corpSecret: vars.get("wecom_corp_secret") ?? "",
|
|
106
|
+
agentId: vars.get("wecom_corp_agent_id") ?? "",
|
|
107
|
+
corpCallbackToken: vars.get("wecom_corp_callback_token") ?? "",
|
|
108
|
+
corpEncodingAesKey: vars.get("wecom_corp_encoding_aes_key") ?? "",
|
|
109
|
+
allowCoexist: vars.get("wecom_allow_coexist") ?? "",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type WecomVars = ReturnType<typeof readWecomVars>;
|
|
114
|
+
|
|
115
|
+
/** 与来源无关的两项:登录 / 回跳 / 同步 / 回调验签之外的一切路径都只认它 */
|
|
116
|
+
const REQUIRED_BASE: Array<[keyof WecomBase, string]> = [
|
|
117
|
+
["encKey", "wecom_enc_key"],
|
|
118
|
+
["baseUrl", "wecom_base_url"],
|
|
119
|
+
];
|
|
120
|
+
|
|
54
121
|
// Tier A:部署方本地生成,创建模版前即可配齐(token / EncodingAESKey / 落库密钥 / 基址)。
|
|
55
122
|
const REQUIRED_CALLBACK: Array<[keyof WecomCallbackConfig, string]> = [
|
|
56
123
|
["token", "wecom_callback_token"],
|
|
@@ -66,26 +133,21 @@ const REQUIRED_SUITE: Array<[keyof WecomConfig, string]> = [
|
|
|
66
133
|
];
|
|
67
134
|
|
|
68
135
|
// Tier C:服务商凭证(provider_access_token 源)。**仅「生成授权链接」需要**,不影响
|
|
69
|
-
// login/callback/create_auth 等 suite 流程,故不进
|
|
136
|
+
// login/callback/create_auth 等 suite 流程,故不进 readSuiteConfig 的必填集合(readSuiteConfig
|
|
70
137
|
// 被登录链路复用,缺 provider 凭证不该让登录 400)。createInstall 单独校验。
|
|
71
138
|
const REQUIRED_PROVIDER: Array<[keyof WecomConfig, string]> = [
|
|
72
139
|
["providerId", "wecom_provider_id"],
|
|
73
140
|
["providerSecret", "wecom_provider_secret"],
|
|
74
141
|
];
|
|
75
142
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
suiteSecret: vars.get("wecom_suite_secret") ?? "",
|
|
85
|
-
providerId: vars.get("wecom_provider_id") ?? "",
|
|
86
|
-
providerSecret: vars.get("wecom_provider_secret") ?? "",
|
|
87
|
-
};
|
|
88
|
-
}
|
|
143
|
+
// 自建应用:三项缺一不可(Secret 换 token,AgentId 既进授权 URL 也进 token 缓存键)。
|
|
144
|
+
// 类型按「变量名」而不是按配置接口来标:`WecomCorpConfig.encodingAesKey` 是**自建应用回调**那把,
|
|
145
|
+
// 而变量里同名字段 `wecom_encoding_aes_key` 是**代开发**那把——按接口标会在这两者之间打错。
|
|
146
|
+
const REQUIRED_CORP: Array<[keyof WecomVars, string]> = [
|
|
147
|
+
["corpId", "wecom_corp_id"],
|
|
148
|
+
["corpSecret", "wecom_corp_secret"],
|
|
149
|
+
["agentId", "wecom_corp_agent_id"],
|
|
150
|
+
];
|
|
89
151
|
|
|
90
152
|
/**
|
|
91
153
|
* 落库密钥格式校验(fail-fast 在**读配置**处,而不是等 create_auth 时在 crypto 里炸):
|
|
@@ -108,53 +170,201 @@ export function assertEncKey(encKey: string): void {
|
|
|
108
170
|
throw Object.assign(new Error("企业微信配置错误:wecom_enc_key 必须是 16 或 32 字节的 hex(32/64 字符)或 base64"), { status: 400 });
|
|
109
171
|
}
|
|
110
172
|
|
|
173
|
+
/** 缺项文案:一次报全,省掉逐轮补配置的往返 */
|
|
174
|
+
function missingError(names: string[], hint: string): Error {
|
|
175
|
+
return Object.assign(new Error(`企业微信未配置:config vars 缺少 ${names.join(" / ")}${hint}`), { status: 400 });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function assertBaseUrl(baseUrl: string): void {
|
|
179
|
+
if (!/^https?:\/\//.test(baseUrl))
|
|
180
|
+
throw Object.assign(new Error("企业微信配置错误:wecom_base_url 必须是绝对地址(企微回调与 redirect_uri 都要求)"), { status: 400 });
|
|
181
|
+
}
|
|
182
|
+
|
|
111
183
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
184
|
+
* 读与来源无关的两项(落库密钥 + 基址)。
|
|
185
|
+
*
|
|
186
|
+
* 登录 / 302 回跳 / 同步只用到这两项,**不该要求 suite 或 corp 的凭据**——这正是两种来源
|
|
187
|
+
* 能共存的前提:否则自建应用的部署会在「读 wecom_suite_id」处直接 400。
|
|
114
188
|
*/
|
|
115
|
-
export function
|
|
116
|
-
const
|
|
189
|
+
export function readBase(): WecomBase {
|
|
190
|
+
const v = readWecomVars();
|
|
191
|
+
const missing = REQUIRED_BASE.filter(([field]) => !v[field]).map(([, name]) => name);
|
|
117
192
|
if (missing.length)
|
|
118
|
-
throw
|
|
193
|
+
throw missingError(missing, "");
|
|
194
|
+
assertBaseUrl(v.baseUrl);
|
|
195
|
+
assertEncKey(v.encKey);
|
|
196
|
+
return { encKey: v.encKey, baseUrl: v.baseUrl };
|
|
119
197
|
}
|
|
120
198
|
|
|
121
199
|
/**
|
|
122
|
-
* 读 Tier A
|
|
123
|
-
* URL 验证与永久码落库加密所需的全部东西,部署方本地即可生成。引导期(suite_id
|
|
124
|
-
*
|
|
200
|
+
* 读 Tier A 凭据(**代开发**的回调 token / aeskey / 落库密钥 / 基址)。**不依赖 suite 凭据**:
|
|
201
|
+
* 这是 echostr URL 验证与永久码落库加密所需的全部东西,部署方本地即可生成。引导期(suite_id
|
|
202
|
+
* 尚未下发)服务也能靠它启动并响应企微的回调验证(plan §18)。
|
|
203
|
+
*
|
|
204
|
+
* 缺项校验**不复用 readBase**:那样只会报出最先撞上的那一项,运维得逐轮补配置
|
|
205
|
+
* (「缺项一次报全」是这套配置体系的既有纪律)。
|
|
125
206
|
*/
|
|
126
207
|
export function readCallbackConfig(): WecomCallbackConfig {
|
|
127
208
|
const v = readWecomVars();
|
|
128
209
|
const missing = REQUIRED_CALLBACK.filter(([field]) => !v[field]).map(([, name]) => name);
|
|
129
210
|
if (missing.length)
|
|
130
|
-
throw
|
|
131
|
-
|
|
132
|
-
throw Object.assign(new Error("企业微信配置错误:wecom_base_url 必须是绝对地址(企微回调与 redirect_uri 都要求)"), { status: 400 });
|
|
211
|
+
throw missingError(missing, "");
|
|
212
|
+
assertBaseUrl(v.baseUrl);
|
|
133
213
|
assertEncKey(v.encKey);
|
|
134
214
|
return { token: v.token, encodingAesKey: v.encodingAesKey, encKey: v.encKey, baseUrl: v.baseUrl };
|
|
135
215
|
}
|
|
136
216
|
|
|
137
217
|
/**
|
|
138
|
-
*
|
|
139
|
-
* 一次报全 Tier A + Tier B
|
|
140
|
-
*
|
|
218
|
+
* 读完整 suite 配置(代开发)。缺任一必需项即 400(「未配置」是运维事实,不是服务端缺陷)。
|
|
219
|
+
* 一次报全 Tier A + Tier B 所有缺失项;suite 凭据(Tier B)缺失时文案点明「由代开发模版创建成功后
|
|
220
|
+
* 下发」,避免运维误以为是本地漏配。
|
|
141
221
|
*
|
|
142
222
|
* 注:Tier B 缺失只影响 login/callback/create_auth 等 suite 流程;echostr 验证走
|
|
143
|
-
* `readCallbackConfig()
|
|
223
|
+
* `readCallbackConfig()`,不受此约束。
|
|
144
224
|
*/
|
|
145
|
-
export function
|
|
225
|
+
export function readSuiteConfig(): WecomConfig {
|
|
146
226
|
const v = readWecomVars();
|
|
147
227
|
const missing = [...REQUIRED_CALLBACK, ...REQUIRED_SUITE].filter(([field]) => !v[field]).map(([, name]) => name);
|
|
148
228
|
if (missing.length)
|
|
149
|
-
throw
|
|
150
|
-
|
|
151
|
-
|
|
229
|
+
throw missingError(missing, "(suite 凭据由代开发模版创建成功后下发)");
|
|
230
|
+
assertBaseUrl(v.baseUrl);
|
|
231
|
+
assertEncKey(v.encKey);
|
|
232
|
+
return {
|
|
233
|
+
token: v.token,
|
|
234
|
+
encodingAesKey: v.encodingAesKey,
|
|
235
|
+
encKey: v.encKey,
|
|
236
|
+
baseUrl: v.baseUrl,
|
|
237
|
+
suiteId: v.suiteId,
|
|
238
|
+
suiteSecret: v.suiteSecret,
|
|
239
|
+
providerId: v.providerId,
|
|
240
|
+
providerSecret: v.providerSecret,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 读自建应用配置。回调那两项**不在必填集合**:不配只是没有事件增量,
|
|
246
|
+
* 全量同步与登录照样能用(见 contacts 的「加速器不是权威」定位)。
|
|
247
|
+
*/
|
|
248
|
+
export function readCorpConfig(): WecomCorpConfig {
|
|
249
|
+
const v = readWecomVars();
|
|
250
|
+
const missing = REQUIRED_CORP.filter(([field]) => !v[field]).map(([, name]) => name);
|
|
251
|
+
if (missing.length)
|
|
252
|
+
throw missingError(missing, "(自建应用:企业ID / 应用Secret / AgentId,均在企微管理后台取)");
|
|
253
|
+
assertBaseUrl(v.baseUrl);
|
|
152
254
|
assertEncKey(v.encKey);
|
|
153
|
-
return
|
|
255
|
+
return {
|
|
256
|
+
corpId: v.corpId,
|
|
257
|
+
corpSecret: v.corpSecret,
|
|
258
|
+
agentId: v.agentId,
|
|
259
|
+
callbackToken: v.corpCallbackToken,
|
|
260
|
+
encodingAesKey: v.corpEncodingAesKey,
|
|
261
|
+
encKey: v.encKey,
|
|
262
|
+
baseUrl: v.baseUrl,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 是否显式允许两种来源同时生效 */
|
|
267
|
+
function coexistAllowed(v: WecomVars): boolean {
|
|
268
|
+
return /^(?:1|true|yes)$/i.test(v.allowCoexist.trim());
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 只看「哪套凭据填齐了」,**不做共存校验、不抛错**。
|
|
273
|
+
* 后台页要能展示「配了什么、还差什么」——配置事故恰恰是最需要它显示清楚的时刻。
|
|
274
|
+
*/
|
|
275
|
+
export function configuredKinds(): WecomKind[] {
|
|
276
|
+
const v = readWecomVars();
|
|
277
|
+
const kinds: WecomKind[] = [];
|
|
278
|
+
if (v.suiteId && v.suiteSecret)
|
|
279
|
+
kinds.push("suite");
|
|
280
|
+
if (v.corpId && v.corpSecret && v.agentId)
|
|
281
|
+
kinds.push("corp");
|
|
282
|
+
return kinds;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 本部署自建应用的三项(后台页展示用;未配齐时为 null) */
|
|
286
|
+
export function corpIdentity(): { corpId: string, agentId: string, hasCallback: boolean } | null {
|
|
287
|
+
const v = readWecomVars();
|
|
288
|
+
if (!v.corpId || !v.corpSecret || !v.agentId)
|
|
289
|
+
return null;
|
|
290
|
+
return { corpId: v.corpId, agentId: v.agentId, hasCallback: Boolean(v.corpCallbackToken && v.corpEncodingAesKey) };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 本部署已配置的**来源**(凭据齐 = 启用)。这是唯一判定口径,不另设 `wecom_mode`:
|
|
295
|
+
* 「变量填了没有」是客观事实,不会与运行期实际能力漂移。
|
|
296
|
+
*
|
|
297
|
+
* 两种来源都配齐时必须显式 `wecom_allow_coexist: 'true'`——否则 fail-fast。这条不是洁癖:
|
|
298
|
+
* 两套凭据同时生效会让回调验签多一次尝试、档案里多一类行,配置事故(比如从代开发迁到自建
|
|
299
|
+
* 却忘了清旧变量)应当被看见,而不是被静默择一。
|
|
300
|
+
*/
|
|
301
|
+
export function wecomKinds(): WecomKind[] {
|
|
302
|
+
const v = readWecomVars();
|
|
303
|
+
const kinds = configuredKinds();
|
|
304
|
+
if (kinds.length === 0) {
|
|
305
|
+
throw missingError(
|
|
306
|
+
["wecom_suite_id / wecom_suite_secret", "wecom_corp_id / wecom_corp_secret / wecom_corp_agent_id"],
|
|
307
|
+
"(二选一:代开发填 suite 两项,企业内部应用填 corp 三项)",
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if (kinds.length > 1 && !coexistAllowed(v))
|
|
311
|
+
throw missingError(["wecom_allow_coexist: 'true'"], "(检测到代开发与自建应用两套凭据同时配置;确认要共存请显式开启)");
|
|
312
|
+
return kinds;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** 一套可用于回调验签的凭据。`receiver` 是明文里的 receiveid(suite=suite_id,corp=corpid) */
|
|
316
|
+
export interface CallbackCreds {
|
|
317
|
+
kind: WecomKind
|
|
318
|
+
token: string
|
|
319
|
+
encodingAesKey: string
|
|
320
|
+
receiver: string
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 列出可用于回调验签的凭据,**按代开发优先**(存量部署行为不变)。
|
|
325
|
+
*
|
|
326
|
+
* 两套的 Token/EncodingAESKey 不同源,而验签必须发生在解密之前——所以入口只能逐个试签
|
|
327
|
+
* (SHA1 一次,成本可忽略),用命中的那把钥匙解密。这比给两种来源各开一条路由更省:
|
|
328
|
+
* 少一个端点、少一份重复的「验签→重放→解密」接线,且回调地址只有 `/event` 一个。
|
|
329
|
+
*
|
|
330
|
+
* **刻意不经过 `wecomKinds()`**:这里列的是「回调钥匙」,不是「启用了哪几种来源」。两者在引导期
|
|
331
|
+
* 不重合——代开发模版还没建、`suite_id`/`suite_secret` 一项未填,但 Token/EncodingAESKey 已经能填,
|
|
332
|
+
* 企微正在该 URL 上做保存校验。要是按来源过滤,验证立刻 400,死锁(plan §18)就又回来了。
|
|
333
|
+
* 所以判据只有一条:**这一组的 token 与 aeskey 是否都填了**。
|
|
334
|
+
*
|
|
335
|
+
* 共存守卫单独做:只有**两套身份凭据都齐**(`configuredKinds().length > 1`)才算配置事故,
|
|
336
|
+
* 引导期的「只有回调钥匙」不算。
|
|
337
|
+
*/
|
|
338
|
+
export function callbackCreds(): CallbackCreds[] {
|
|
339
|
+
const v = readWecomVars();
|
|
340
|
+
if (configuredKinds().length > 1 && !coexistAllowed(v))
|
|
341
|
+
throw missingError(["wecom_allow_coexist: 'true'"], "(检测到代开发与自建应用两套凭据同时配置;确认要共存请显式开启)");
|
|
342
|
+
const list: CallbackCreds[] = [];
|
|
343
|
+
if (v.token && v.encodingAesKey)
|
|
344
|
+
list.push({ kind: "suite", token: v.token, encodingAesKey: v.encodingAesKey, receiver: v.suiteId });
|
|
345
|
+
if (v.corpCallbackToken && v.corpEncodingAesKey)
|
|
346
|
+
list.push({ kind: "corp", token: v.corpCallbackToken, encodingAesKey: v.corpEncodingAesKey, receiver: v.corpId });
|
|
347
|
+
if (list.length === 0) {
|
|
348
|
+
throw missingError(
|
|
349
|
+
["wecom_callback_token / wecom_encoding_aes_key", "wecom_corp_callback_token / wecom_corp_encoding_aes_key"],
|
|
350
|
+
"(回调验签凭据一组都没配;自建应用填「通讯录同步 → 接收事件服务器」那一组)",
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
return list;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 校验服务商凭证(生成授权链接必需)。缺任一即 400 并点名缺哪两项——
|
|
358
|
+
* 区分于 suite 凭证:那是「模版创建后下发」,provider 凭证是「服务商后台通用开发参数」里拿的。
|
|
359
|
+
*/
|
|
360
|
+
export function assertProviderConfig(cfg: WecomConfig): void {
|
|
361
|
+
const missing = REQUIRED_PROVIDER.filter(([field]) => !cfg[field]).map(([, name]) => name);
|
|
362
|
+
if (missing.length)
|
|
363
|
+
throw Object.assign(new Error(`企业微信未配置:生成授权链接需要 ${missing.join(" / ")}(服务商后台「通用开发参数」页获取,与 suite 凭证不同源)`), { status: 400 });
|
|
154
364
|
}
|
|
155
365
|
|
|
156
366
|
/**
|
|
157
|
-
* 企微后台「指令回调 URL /
|
|
367
|
+
* 企微后台「指令回调 URL / 通讯录同步接收事件服务器」:原样填这个(base + `/event`)。
|
|
158
368
|
*
|
|
159
369
|
* **不带 `?callback_format=json`**——查过官方文档才这么定的:该参数只在「对外收款」的指令回调
|
|
160
370
|
* 文档(97478)里有说明,而代开发 / 第三方应用所属的**通用回调配置**(96238 §3.2)至今只写
|
|
@@ -165,12 +375,12 @@ export function readWecomConfig(): WecomConfig {
|
|
|
165
375
|
* 代价却是把「企业管理员扫码能否装成功」押在上游实现上。服务端两种包体都认(payload.ts),
|
|
166
376
|
* 上游哪天默认改 JSON 也不用动这里。
|
|
167
377
|
*/
|
|
168
|
-
export function eventUrlOf(cfg:
|
|
378
|
+
export function eventUrlOf(cfg: { baseUrl: string }): string {
|
|
169
379
|
return `${cfg.baseUrl}/event`;
|
|
170
380
|
}
|
|
171
381
|
|
|
172
382
|
/** 网页授权 redirect_uri(base + /callback) */
|
|
173
|
-
export function loginRedirectOf(cfg:
|
|
383
|
+
export function loginRedirectOf(cfg: { baseUrl: string }): string {
|
|
174
384
|
return `${cfg.baseUrl}/callback`;
|
|
175
385
|
}
|
|
176
386
|
|
|
@@ -184,7 +394,7 @@ const CONNECTOR_SUFFIX = "/api/auth/wecom";
|
|
|
184
394
|
* 回跳到 `/login` 会打到宿主机根的别的应用(或 404)。前缀不另配一个变量——它就等于
|
|
185
395
|
* `wecom_base_url` 去掉本连接器的固定后缀,配一处即可,且不匹配时**报错**而不是猜。
|
|
186
396
|
*/
|
|
187
|
-
export function siteRootOf(cfg:
|
|
397
|
+
export function siteRootOf(cfg: { baseUrl: string }): string {
|
|
188
398
|
const url = new URL(cfg.baseUrl);
|
|
189
399
|
const path = url.pathname.replace(/\/+$/, "");
|
|
190
400
|
if (!path.endsWith(CONNECTOR_SUFFIX))
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { WecomClient, WecomDept } from "./client";
|
|
2
|
+
import type { WecomKind } from "./config";
|
|
2
3
|
import type { MemberDetail } from "./login";
|
|
3
4
|
import type { CallbackFields } from "./payload";
|
|
4
5
|
import type { WecomStoreDeps } from "./store";
|
|
@@ -32,6 +33,11 @@ const ORIGIN = "wecom";
|
|
|
32
33
|
export interface ContactEventDeps {
|
|
33
34
|
client: WecomClient
|
|
34
35
|
store: WecomStoreDeps
|
|
36
|
+
/**
|
|
37
|
+
* 本次回调**验签命中的**凭据来源(由入口从 `callbackCreds()` 传下来)。
|
|
38
|
+
* 同一家企业可能有代开发行与自建行,取哪一行的 secret 决定这次事件读通讯录能不能成。
|
|
39
|
+
*/
|
|
40
|
+
credentialKind: WecomKind
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
/** 一条事件的执行上下文:企业已授权 → 应用 与 永久码(= 企业应用 secret) */
|
|
@@ -73,7 +79,7 @@ export async function handleContactChange(fields: CallbackFields, deps: ContactE
|
|
|
73
79
|
async function applyChange(changeType: string, corpId: string, fields: CallbackFields, deps: ContactEventDeps): Promise<boolean> {
|
|
74
80
|
if (!corpId)
|
|
75
81
|
throw new Error("报文里没有企业身份(ToUserName / AuthCorpId 皆空)");
|
|
76
|
-
const auth = await findCorpAuth(corpId, deps.store);
|
|
82
|
+
const auth = await findCorpAuth(corpId, deps.store, deps.credentialKind);
|
|
77
83
|
if (!auth)
|
|
78
84
|
throw new Error("该企业未授权本站(wecom_app_profile 无记录)");
|
|
79
85
|
const ctx: CorpCtx = {
|