@oj-module/cli 0.2.49 → 0.2.50
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 +643 -0
- 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/init.ts +5 -4
- package/templates/config.yaml +28 -6
- package/templates/openapi.json +174 -2
- package/templates/src/auth/api/_shared/wecom/admin.ts +87 -7
- 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 +25 -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/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/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/system/web/locales/en-US.json +24 -1
- package/templates/src/system/web/locales/zh-CN.json +24 -1
- package/templates/src/system/web/pages/departments/sync-bar.tsx +32 -7
- package/templates/src/system/web/pages/wecom/index.tsx +164 -46
- 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/vendor/host-versions.json +3 -3
|
@@ -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 = {
|
|
@@ -59,16 +59,32 @@ async function auditAuth(action: string, corpId: string, detail?: unknown, level
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/** 该 corp
|
|
62
|
+
/** 该 corp 的代开发应用全部标记为停用;**只有该企业再没有启用中的应用时才吊销会话 */
|
|
63
63
|
async function disableCorp(corpId: string, deps: SuiteEventDeps): Promise<void> {
|
|
64
64
|
// 取消授权同样是匿名回调:会话清理走裸 SQL,`sql_guard: deny` 下没有租户上下文会被拒——
|
|
65
65
|
// 而这里拒掉的后果是「旧 refresh 串仍能续命」,属安全红线,故必须绑成功再往下走。
|
|
66
66
|
ensureTenant(tenantOfCorp(corpId));
|
|
67
|
-
|
|
67
|
+
// 只取 suite 行:共存部署里同一家企业可能还有自建应用,cancel_auth 与它无关(C4)
|
|
68
|
+
const rows = await db.table("wecom_app_profile").select(["agent_id"]).where({
|
|
69
|
+
and: [corpScope(corpId), { field: "credential_kind", op: "eq", value: "suite" }],
|
|
70
|
+
}).all() as Array<{ agent_id: string }>;
|
|
68
71
|
for (const r of rows)
|
|
69
72
|
await deps.client.invalidateCorpToken(corpId, String(r.agent_id));
|
|
70
|
-
await setProfileStatus(corpId, "disabled", deps.store);
|
|
71
|
-
|
|
73
|
+
await setProfileStatus(corpId, "disabled", deps.store, "suite");
|
|
74
|
+
await revokeOrKeep(corpId);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 会话与授权同频:解绑后旧 refresh 串不得继续换新 access token。
|
|
79
|
+
* 但「还有别的应用在用」时不吊销(同 `admin.ts::setAppStatus` 的语义)——否则取消代开发授权
|
|
80
|
+
* 会把同一家企业自建应用的在线用户一起踢下线。
|
|
81
|
+
*/
|
|
82
|
+
async function revokeOrKeep(corpId: string): Promise<void> {
|
|
83
|
+
const still = await db.table("wecom_app_profile").select(["agent_id"]).where({
|
|
84
|
+
and: [corpScope(corpId), { field: "status", op: "eq", value: "active" }],
|
|
85
|
+
}).all();
|
|
86
|
+
if (still.length)
|
|
87
|
+
return;
|
|
72
88
|
// tenant 条件写死在 SQL 里(不用 truncate:它的清理范围取决于本请求是否已绑过租户),
|
|
73
89
|
// 取消授权回调是匿名端点,不该依赖那种隐式前提。
|
|
74
90
|
await revokeTenantSessions(tenantOfCorp(corpId));
|
|
@@ -93,6 +109,8 @@ async function applyAuthCode(authCode: string, state: string | null, deps: Suite
|
|
|
93
109
|
agentId,
|
|
94
110
|
env,
|
|
95
111
|
corpName: granted.corpName,
|
|
112
|
+
// 来源由这一条路径决定:能走到 create_auth 的只可能是代开发
|
|
113
|
+
credentialKind: "suite",
|
|
96
114
|
permanentCode: granted.permanentCode,
|
|
97
115
|
authMode: granted.authModes[agentId] ?? 0,
|
|
98
116
|
}, store);
|
|
@@ -112,7 +130,8 @@ async function applyAuthCode(authCode: string, state: string | null, deps: Suite
|
|
|
112
130
|
*/
|
|
113
131
|
async function refreshAuthModes(corpId: string, deps: SuiteEventDeps): Promise<void> {
|
|
114
132
|
try {
|
|
115
|
-
|
|
133
|
+
// 只对 suite 行成立:自建应用没有「授权模式」这个概念,给它刷 auth_mode 只会写脏数据(C5)
|
|
134
|
+
const auth = await findCorpAuth(corpId, deps.store, "suite");
|
|
116
135
|
if (!auth)
|
|
117
136
|
return;
|
|
118
137
|
const ticket = await getSuiteTicket(deps.cfg.suiteId, deps.store);
|
|
@@ -147,7 +166,7 @@ export async function handleSuiteEvent(payload: unknown, deps: SuiteEventDeps):
|
|
|
147
166
|
// 落进 default 只会被记成「不消费的事件」。
|
|
148
167
|
// 与下面几个分支的不同:通讯录事件失败**不抛**(事件是加速器,重推补不回来,见 contacts.ts)。
|
|
149
168
|
if (field(fields, "Event") === "change_contact" || infoType === "change_contact")
|
|
150
|
-
return await handleContactChange(fields, deps);
|
|
169
|
+
return await handleContactChange(fields, { client: deps.client, store: deps.store, credentialKind: "suite" });
|
|
151
170
|
|
|
152
171
|
switch (infoType) {
|
|
153
172
|
case "suite_ticket": {
|
|
@@ -62,32 +62,40 @@ export function buildAuthorizeUrl(opts: {
|
|
|
62
62
|
/**
|
|
63
63
|
* 代开发模板常只服务一家企业:登录入口不传 corp 时,从已安装档案自动解析,
|
|
64
64
|
* 让「应用主页」直接填 https://<域名>/login 即可(无需 ${corpid} 占位符,避开企微后台对非法 URL 字符的校验)。
|
|
65
|
-
*
|
|
65
|
+
*
|
|
66
|
+
* `self` = 本部署自己那家企业内部应用(`wecom_corp_*` 配了什么)。
|
|
67
|
+
* 共存部署里必须传:否则别家企业通过代开发装进来的档案会把「本站默认登录哪家企业」淹掉,
|
|
68
|
+
* 本站自己的 `/login` 反倒回 400「已授权多家企业」。传了就按「自己的企业优先」解析,
|
|
69
|
+
* 且默认 agent 也来自配置,不再依赖写死的应用 id。
|
|
66
70
|
*/
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
export interface SelfApp {
|
|
72
|
+
corpId: string
|
|
73
|
+
agentId: string
|
|
74
|
+
}
|
|
69
75
|
|
|
70
|
-
export async function resolveWecomProfile(agentId: string | null): Promise<{ corpId: string, agentId: string, env: string }> {
|
|
76
|
+
export async function resolveWecomProfile(agentId: string | null, self?: SelfApp): Promise<{ corpId: string, agentId: string, env: string }> {
|
|
71
77
|
const rows = await db.table("wecom_app_profile")
|
|
72
78
|
.select(["corp_id", "agent_id", "env", "status"])
|
|
73
79
|
.where({ field: "status", op: "eq", value: "active" })
|
|
74
80
|
.all() as Array<{ corp_id: string, agent_id: string, env: string | null, status: string }>;
|
|
75
81
|
const active = rows.filter(r => String(r.status) === "active");
|
|
76
82
|
if (!active.length)
|
|
77
|
-
throw Object.assign(new Error("
|
|
78
|
-
const
|
|
83
|
+
throw Object.assign(new Error("尚未完成企业微信接入(无可用应用档案):代开发请先扫码开通;企业内部应用请在后台点「验证并接入」"), { status: 400 });
|
|
84
|
+
const pool = self ? active.filter(r => String(r.corp_id) === self.corpId) : active;
|
|
85
|
+
if (!pool.length)
|
|
86
|
+
throw Object.assign(new Error("本次部署配置的企业内部应用尚未接入:请在后台点「验证并接入」"), { status: 400 });
|
|
87
|
+
const corps = [...new Set(pool.map(r => String(r.corp_id)))];
|
|
79
88
|
if (corps.length > 1 && !agentId)
|
|
80
89
|
throw Object.assign(new Error("已授权多家企业,请在登录链接带 corp 参数以区分"), { status: 400 });
|
|
81
|
-
const corpId = corps[0]!;
|
|
82
|
-
const agents =
|
|
83
|
-
// 指定 agent
|
|
84
|
-
// 至少有一个属于 corpId——corps 就是按 active 的 corp_id 去重出来的)
|
|
90
|
+
const corpId = self ? self.corpId : corps[0]!;
|
|
91
|
+
const agents = pool.filter(r => String(r.corp_id) === corpId);
|
|
92
|
+
// 指定 agent 就必须命中;未指定时:自建应用优先取配置里的 agent,否则取首个活跃应用
|
|
85
93
|
const pick = agentId
|
|
86
|
-
? agents.find(a => a.agent_id === agentId)
|
|
87
|
-
: (agents.find(a => a.agent_id ===
|
|
94
|
+
? agents.find(a => String(a.agent_id) === agentId)
|
|
95
|
+
: (self ? agents.find(a => String(a.agent_id) === self.agentId) : undefined) ?? agents[0];
|
|
88
96
|
if (!pick)
|
|
89
97
|
throw Object.assign(new Error("指定的 agent 在该企业中不存在"), { status: 400 });
|
|
90
|
-
return { corpId: pick.corp_id, agentId: pick.agent_id, env: pick.env == null ? "prod" : String(pick.env) };
|
|
98
|
+
return { corpId: String(pick.corp_id), agentId: String(pick.agent_id), env: pick.env == null ? "prod" : String(pick.env) };
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
export async function putLoginState(nonce: string, pending: PendingLogin, cache: WecomCache): Promise<void> {
|