@oj-module/cli 0.2.45 → 0.2.47
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/wecom-daikaifa.md +99 -38
- 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/templates/config.yaml +26 -7
- package/templates/src/auth/api/_shared/wecom/admin.ts +14 -9
- package/templates/src/auth/api/_shared/wecom/client.ts +69 -10
- package/templates/src/auth/api/_shared/wecom/config.ts +94 -39
- package/templates/src/auth/api/_shared/wecom/crypt.ts +7 -2
- package/templates/src/auth/api/wecom/admin/api.ts +4 -4
- package/templates/src/auth/api/wecom/api.ts +24 -4
- package/templates/src/login/web/auth-provider.ts +9 -1
- package/templates/src/system/web/locales/en-US.json +1 -1
- package/templates/src/system/web/locales/zh-CN.json +2 -2
- package/templates/src/system/web/pages/wecom/index.tsx +2 -1
- package/templates/tests/wecom-admin.spec.ts +63 -42
- package/templates/tests/wecom-client-timeout.spec.ts +99 -0
- package/templates/tests/wecom-connector.spec.ts +31 -4
- package/templates/tests/wecom-endpoints.spec.ts +11 -0
- package/vendor/host-versions.json +3 -3
package/devkit/wecom-daikaifa.md
CHANGED
|
@@ -49,19 +49,44 @@
|
|
|
49
49
|
|
|
50
50
|
## 2. 凭据清单
|
|
51
51
|
|
|
52
|
-
### 2.1
|
|
53
|
-
|
|
54
|
-
一个部署 =
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
52
|
+
### 2.1 八项部署常量(全部填在工程根 `config.yaml` 的 `vars:` 段)
|
|
53
|
+
|
|
54
|
+
一个部署 = 一套代开发模板,所以这八项是**部署维度**的常量,不进数据库。
|
|
55
|
+
|
|
56
|
+
八项分三层,**来源与必填时序不同**——这是破解「先有 suite 还是先有回调」死锁的关键
|
|
57
|
+
(详见 plan §18):
|
|
58
|
+
|
|
59
|
+
- **Tier A(部署方本地生成,创建模版前即可配齐)**:`wecom_callback_token` /
|
|
60
|
+
`wecom_encoding_aes_key` / `wecom_enc_key` / `wecom_base_url`。这四项决定服务能否启动并响应
|
|
61
|
+
回调验证,**不依赖企业微信下发**;即使 `suite_id` / `suite_secret` 还空着,服务也能跑通 echostr。
|
|
62
|
+
- **Tier B(企业微信在「代开发模版创建成功」后下发)**:`wecom_suite_id` / `wecom_suite_secret`。
|
|
63
|
+
只影响登录 / 授权等 suite 流程(login / callback / create_auth);**回调 URL 验证(echostr)不依赖它们**。
|
|
64
|
+
- **Tier C(服务商级凭证,只供「生成开通链接」用)**:`wecom_provider_id`(= 服务商后台
|
|
65
|
+
**服务商信息**页的 CorpID)/ `wecom_provider_secret`(= 服务商后台 **应用管理 → 通用开发参数** 点「编辑」
|
|
66
|
+
后的 `ProviderSecret` 字段,**不在**服务商信息页,也**不是** suite 的 secret)。代开发的开通入口走
|
|
67
|
+
`get_customized_auth_url`,该接口只认 `provider_access_token`,而它必须由服务商 corpid + provider_secret 换得。
|
|
68
|
+
缺它**不影响登录**(login 走 suite 流程),但 `/system/wecom` 点「生成开通链接」会 400 点名缺这两项。
|
|
69
|
+
|
|
70
|
+
Tier A 任一为空 → 服务启动即 400 拒绝(宁可不登录,也不明文存 `permanent_code`)。
|
|
71
|
+
Tier B 为空时服务仍可启动并响应 echostr,但 suite 流程会 400——先配 Tier A 把回调验证跑通、
|
|
72
|
+
创建模版、拿到 suite 凭据,再补全 Tier B 即可,无需鸡生蛋。Tier C 可与 Tier B 同一阶段补全。
|
|
73
|
+
|
|
74
|
+
> 📌 **官方术语对齐**:企微服务商后台「代开发应用 → 模板详情 → **基本信息**」页里,
|
|
75
|
+
> 这两项就直接标作 **模板ID** 与 **模板Secret**(英文即 SuiteID / SuiteSecret)。
|
|
76
|
+
> 也就是说:本仓的 `wecom_suite_id` ← 官方「模板ID」,`wecom_suite_secret` ← 官方「模板Secret」,
|
|
77
|
+
> **不是**企业的 CorpID / CorpSecret,也**不是**某个自建应用的 Secret。照着「基本信息」页抄这两格即可。
|
|
78
|
+
> 两者都是「创建模板成功后」才出现在该页,创建前只有 Tier A 的 Token / EncodingAESKey 可设。
|
|
79
|
+
|
|
80
|
+
| # | 变量 | 层级 | 从哪取 | 格式 / 校验 | 配错或缺失的后果 |
|
|
81
|
+
|---|---|---|---|---|---|
|
|
82
|
+
| 1 | `wecom_suite_id` | Tier B | 企微服务商后台 → 应用管理 → 代开发应用 → 模板详情 → **基本信息**页(**创建成功后**才有),标作「**模板ID**」,即 **SuiteID**(形如 `ww…` / `wwb…`) | 非空字符串 | Tier A 配齐时服务可启动并验证回调;此项空 → login/callback/create_auth 等 suite 流程 400 `config vars 缺少 wecom_suite_id`;`event.post` 还会判 `SuiteId 与本部署配置不一致` |
|
|
83
|
+
| 2 | `wecom_suite_secret` | Tier B | 同页「基本信息」标作「**模板Secret**」,即 **SuiteSecret**(可重置) | 非空字符串;重置后旧值立即失效 | 换不出 `suite_access_token` → 502 `企微 get_suite_token 失败:errcode=40001 …`;开通链接生成也失败 |
|
|
84
|
+
| 3 | `wecom_callback_token` | Tier A | 同页 **Token**(自设,字母数字 3–32 位) | 与企微后台**逐字一致** | 保存回调 URL 时 `echostr` 解不解得开;事件被判验签失败,企微持续重推 |
|
|
85
|
+
| 4 | `wecom_encoding_aes_key` | Tier A | 同页 **EncodingAESKey**(自设) | **43 字符** | 同上:回调报文解不开,`suite_ticket` 永远收不到 |
|
|
86
|
+
| 5 | `wecom_enc_key` | Tier A | **自己生成**,见 §2.4 | hex 32 字符(16 字节)或 64 字符(32 字节),或 base64 解码后 16 / 32 字节。**AES-192(24 字节)不支持** | 400 `企业微信配置错误:wecom_enc_key 必须是 16 或 32 字节的 hex(32/64 字符)或 base64` |
|
|
87
|
+
| 6 | `wecom_base_url` | Tier A | **基址**:你站点的对外地址 + `/api/auth/wecom`(注意这是基址,**不是**企微后台要填的回调 URL) | **绝对地址、无尾斜杠、必须以 `/api/auth/wecom` 结尾** | 400 `wecom_base_url 必须是绝对地址` 或 `必须以 /api/auth/wecom 结尾`;配错还会让登录回跳落到别的应用或 404 |
|
|
88
|
+
| 7 | `wecom_provider_id` | Tier C | 服务商后台 → **服务商信息**页显示的 CorpID(服务商自己的 corpid,不是企业 corpid,也不是 suite_id) | 非空字符串 | 只影响「生成开通链接」:`get_provider_token` 换不出 `provider_access_token` → 生成链接 502;此项为空 → 生成链接 400 `config vars 缺少 wecom_provider_id` |
|
|
89
|
+
| 8 | `wecom_provider_secret` | Tier C | 服务商后台 → **应用管理 → 通用开发参数**(**点「编辑」按钮**那一栏的 `ProviderSecret` 字段)。**不在「服务商信息」页**(那里只显示 CorpID),也**不是** suite_secret | 非空字符串;重置后旧值立即失效 | 换不出 `provider_access_token` → 生成链接 502 `企微 get_provider_token 失败:errcode=40001 …`;此项为空 → 生成链接 400 `config vars 缺少 wecom_provider_secret` |
|
|
65
90
|
|
|
66
91
|
`wecom_base_url` 是唯一「配一处、派生两处」的值——它同时决定:
|
|
67
92
|
|
|
@@ -70,6 +95,11 @@
|
|
|
70
95
|
- 登录成功后的站点回跳 = 去掉 `/api/auth/wecom` 的那部分(子路径部署也正确,例如
|
|
71
96
|
`https://example.com/oj/api/auth/wecom` → 回跳 `https://example.com/oj/login`)
|
|
72
97
|
|
|
98
|
+
> ⚠️ **`wecom_base_url` 不是回调 URL 本身,别把它直接填进企微后台。** 企微后台要填的
|
|
99
|
+
> 「指令回调 URL」是 `<wecom_base_url>/event`(**必须带 `/event`**)。裸的 `/api/auth/wecom`
|
|
100
|
+
> 没有任何 handler,企微验签时会打到它 → **404 → 验证失败、模版保存不了**。
|
|
101
|
+
> 派生关系如上:回调 URL = base + `/event`,网页授权回跳 = base + `/callback`。
|
|
102
|
+
|
|
73
103
|
### 2.2 两项备案(都在企微后台,不写在代码里)
|
|
74
104
|
|
|
75
105
|
| 备案项 | 填什么 | 不做的症状 |
|
|
@@ -119,20 +149,43 @@ openssl rand -hex 32 # 输出 64 个 hex 字符,直接贴进 wecom_enc_
|
|
|
119
149
|
|
|
120
150
|
## 4. 首次开通:顺序错了一定卡在 503
|
|
121
151
|
|
|
122
|
-
|
|
152
|
+
开通分两个阶段,两阶段之间由「代开发模版创建成功、拿到 suite 凭据」衔接——这正是
|
|
153
|
+
破解鸡生蛋死锁的关键(详见 plan §18):**回调验证只需 Tier A,不必等 suite**。
|
|
154
|
+
|
|
155
|
+
### 阶段一:配 Tier A + 跑通回调验证(此时还没有 suite)
|
|
156
|
+
|
|
157
|
+
| 步 | 动作 | 为什么 | 错序的症状 |
|
|
158
|
+
|---|---|---|---|
|
|
159
|
+
| 1 | 配好 §2.1 的 **Tier A 四项**(`callback_token` / `encoding_aes_key` / `enc_key` / `base_url`),重启服务 | 这四项本地生成,服务凭它们即可启动并响应 echostr | 400 `企业微信未配置:config vars 缺少 …` |
|
|
160
|
+
| 2 | 站点对外 https 可达;把指令回调 URL `<base>/event` 填进企微后台并保存 | 保存动作本身就是第一次验签(GET `echostr`)——而 echostr 只依赖 Tier A | 保存失败:`Token` / `EncodingAESKey` 对不上 |
|
|
161
|
+
|
|
162
|
+
> 阶段一**不需要** `suite_id` / `suite_secret`:企微在「模版创建成功」后才下发它们,而 echostr
|
|
163
|
+
> 验证在创建期就能跑通(receiveid 校验在 suite 未下发时自动放宽,签名仍是唯一鉴权闸门)。
|
|
164
|
+
|
|
165
|
+
### 阶段二:拿 suite 凭据,再走 suite 流程
|
|
123
166
|
|
|
124
|
-
| 步 | 动作 | 为什么必须在这一步 |
|
|
167
|
+
| 步 | 动作 | 为什么必须在这一步 | 错序的症状 |
|
|
125
168
|
|---|---|---|---|
|
|
126
|
-
|
|
|
127
|
-
|
|
|
128
|
-
|
|
|
129
|
-
|
|
|
130
|
-
|
|
|
131
|
-
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
>
|
|
169
|
+
| 3 | 模版创建成功后,从模版详情拿 **SuiteID / SuiteSecret**,填入 §2.1 的 **Tier B**,重启服务 | 否则 `suite_ticket` 推来时 `event.post` 因缺 suite 凭据 400,票据永远收不到 | 日志看不到 `wecom suite_ticket updated`;`/system/wecom` 生成链接报 503 |
|
|
170
|
+
| 4 | **等 `suite_ticket` 到手**(企微每 10 分钟推一次) | 换 `suite_access_token` 必须有票据,而票据只能由回调推来(登录流程要用) | 503 `suite_ticket 尚未收到:回调 URL 未打通或企微尚未推送` |
|
|
171
|
+
| 5 | 配好 §2.1 的 **Tier C**(`wecom_provider_id` / `wecom_provider_secret`,服务商后台「通用开发参数」页取),重启服务;再用超级管理员登录,进 `/system/wecom` → 「生成开通链接」 | 代开发的开通入口走 `get_customized_auth_url`(返回 `qrcode_url`),它只认 `provider_access_token`,而 provider_access_token 必须由服务商 corpid + provider_secret 换得(= Tier C)。**不是**第三方应用的 `get_pre_auth_code`(代开发调它会 48002) | 400 `config vars 缺少 wecom_provider_id / wecom_provider_secret`;或 502 `get_provider_token 失败`(provider_secret 错/重置);或链接生成失败 |
|
|
172
|
+
| 6 | 把链接/二维码发给企业管理员,由其扫码/打开授权 | 只有企业管理员能授权;确认后企微推 `create_auth` | 企业侧没有应用;本站无该企业记录 |
|
|
173
|
+
| 7 | 进 `/system/settings` 打开「启用企业微信登录」 | `wecom.enabled` 默认 **关**,只是「发现登录方式」的开关 | 登录页根本看不到「企业微信」页签 |
|
|
174
|
+
| 8 | 企业员工从企微工作台/扫码/专属链接进入 | 首次登录自动建档 | 401 `登录凭据无效或不在应用可见范围内`(多半是可见范围没配) |
|
|
175
|
+
|
|
176
|
+
> 🔐 **进 `/system/wecom` 必须用 `superadmin` 账号,不是 `admin`。** 该页是跨租户的服务商
|
|
177
|
+
> 运营面(生成开通链接 = 调企微 `get_customized_auth_url` 换 `qrcode_url`、管理所有已授权企业),前端(`system/web/pages/wecom/index.tsx:61`
|
|
178
|
+
> 的 `roles.includes("superadmin")`)与后端 `auth/wecom/admin/*`(每个端点 `assertSuperAdmin()`)
|
|
179
|
+
> 都只放行 `superadmin`。模板 seed 已把 `admin` 从 `superadmin` 角色解绑(收敛为「账号名 = 角色名」,
|
|
180
|
+
> 见 `_platform/api/seed.sql` 的 `DELETE … WHERE role_name='superadmin' AND username='admin'`),
|
|
181
|
+
> 所以拿 `admin` 登录会直接显示「无权限」。请用 `superadmin` 登录(seed 口令 `123456`,
|
|
182
|
+
> **登录后立即改密**);若坚持让 `admin` 也能进,由 `superadmin` 在 `/system/roles` 把 `admin`
|
|
183
|
+
> 绑到 `superadmin`,但不推荐——这会取消「日常管理员 vs 跨租户运营面」的隔离。
|
|
184
|
+
|
|
185
|
+
> **为什么第 4 步容易漏**:`suite_ticket` 是被推送的,不是拉取的。第一次部署时如果回调
|
|
135
186
|
> URL 保存得晚,就要等下一个 10 分钟周期。看到 503 不必怀疑代码——先确认回调真的通。
|
|
187
|
+
> **阶段二衔接的新坑**:若第 3 步的 suite 凭据还没填、企微就推来了 `suite_ticket`,那几条会被
|
|
188
|
+
> 400 拒掉(属预期),填好 Tier B 重启后下一个推送周期会自动补上,不必手动回放。
|
|
136
189
|
|
|
137
190
|
---
|
|
138
191
|
|
|
@@ -185,7 +238,7 @@ openssl rand -hex 32 # 输出 64 个 hex 字符,直接贴进 wecom_enc_
|
|
|
185
238
|
企微只回调**公网 https**,所以本地必须内网穿透。最小闭环:
|
|
186
239
|
|
|
187
240
|
```sh
|
|
188
|
-
# 1)
|
|
241
|
+
# 1) 八项 vars 填进工程根 config.yaml 的 vars:(见 §2.1,含 Tier C 的 provider 凭证)
|
|
189
242
|
# 其中 wecom_base_url 先写成穿透地址:https://<穿透域名>/api/auth/wecom
|
|
190
243
|
# 2) 起前后端
|
|
191
244
|
pnpm dev
|
|
@@ -239,10 +292,12 @@ pnpm dev
|
|
|
239
292
|
|---|---|---|
|
|
240
293
|
| 已开通企业清单 | `GET /auth/wecom/admin/apps` | **不返回** `permanent_code`,最多到 `corp_name` |
|
|
241
294
|
| 启用 / 停用某企业某应用 | `POST /auth/wecom/admin/status` | 停用是安全动作:**只在该企业没有其它 active 应用时**才吊销该租户会话 |
|
|
242
|
-
| 生成开通链接 | `GET /auth/wecom/admin/install` | 返回 `{installUrl, callbackUrl, expiresIn}`;`callbackUrl` 就是要逐字填进企微后台的那个值 |
|
|
295
|
+
| 生成开通链接 | `GET /auth/wecom/admin/install` | 返回 `{installUrl, callbackUrl, expiresIn}`;`installUrl` 是代开发 `get_customized_auth_url` 返回的 `qrcode_url`(企业管理员扫/开即授权),`callbackUrl` 就是要逐字填进企微后台的那个值 |
|
|
243
296
|
|
|
244
|
-
|
|
245
|
-
`
|
|
297
|
+
开通链接由企微 `get_customized_auth_url` 生成,**有效期以企微返回为准**(文档口径可达 10 天,实测可能较短),
|
|
298
|
+
过期后界面上的二维码会置灰,重新点一次即可(每次点都重新换 `provider_access_token` + 调
|
|
299
|
+
`get_customized_auth_url`,不做缓存)。代开发**不支持**第三方应用的 `get_pre_auth_code`——本接口
|
|
300
|
+
刻意走 `get_customized_auth_url`,踩过 48002 的坑。
|
|
246
301
|
|
|
247
302
|
权限是**超级管理员**而不是站点 admin:这三件事跨租户(能看别家企业的 corp_id)。
|
|
248
303
|
|
|
@@ -295,7 +350,9 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
|
|
|
295
350
|
|
|
296
351
|
### 7.1 上线前 checklist
|
|
297
352
|
|
|
298
|
-
- [ ]
|
|
353
|
+
- [ ] 服务商账号已认证,代开发应用模板已创建
|
|
354
|
+
- [ ] Tier A 四项已生成并填好(`wecom_callback_token` / `wecom_encoding_aes_key` 在企微后台同页自设;`wecom_enc_key` 见 §2.4 本地生成;`wecom_base_url` 为站点对外地址)
|
|
355
|
+
- [ ] 模版创建成功后拿到 **SuiteID / SuiteSecret**(Tier B),已填入配置并重启(详见 §4 阶段二)
|
|
299
356
|
- [ ] 站点已用 **https** 对外,反向代理放行 `POST /api/auth/wecom/event`(含原始 XML 请求体,不要改写)
|
|
300
357
|
- [ ] 可信域名已备案(含校验文件)
|
|
301
358
|
- [ ] 可信 IP 已列全所有出口 IP
|
|
@@ -306,16 +363,17 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
|
|
|
306
363
|
|
|
307
364
|
### 7.2 上线顺序
|
|
308
365
|
|
|
309
|
-
照 §4
|
|
366
|
+
照 §4 的两阶段八步走。每步的验收点:
|
|
310
367
|
|
|
311
368
|
| 步 | 验收 |
|
|
312
369
|
|---|---|
|
|
313
|
-
| 1–2 | 企微后台保存回调 URL
|
|
314
|
-
| 3 |
|
|
315
|
-
| 4 |
|
|
316
|
-
| 5 |
|
|
317
|
-
| 6 |
|
|
318
|
-
| 7 |
|
|
370
|
+
| 1–2(阶段一) | 企微后台保存回调 URL 成功(说明 Tier A 的验签与解密都对——此时还没有 suite 凭据也照常通) |
|
|
371
|
+
| 3(阶段二) | 填好 Tier B 并重启成功 |
|
|
372
|
+
| 4 | 服务日志出现 `wecom suite_ticket updated` |
|
|
373
|
+
| 5 | `/system/wecom` 能出链接,且显示的回调地址与企微后台一致 |
|
|
374
|
+
| 6 | 列表出现该企业;日志出现 `wecom auth applied … agents=…` |
|
|
375
|
+
| 7 | 登录页出现「企业微信」页签 |
|
|
376
|
+
| 8 | 用一个**普通员工**账号登录成功(不要只用管理员自己的号) |
|
|
319
377
|
|
|
320
378
|
### 7.3 启停与解绑
|
|
321
379
|
|
|
@@ -369,7 +427,10 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
|
|
|
369
427
|
| 401 `登录票据无效或已使用` / `登录票据已过期,请重新登录` | 票据被重放,或 180 秒没换 | 重新从入口进 |
|
|
370
428
|
| 400 `授权未完成:企业微信未返回 code` | 用户在授权页点了取消 | 重新授权 |
|
|
371
429
|
| 400 `登录状态已失效,请从企业微信重新进入` | `state` 过期(10 分钟)/ 已用 / 换过部署 | 重新进;跨部署时正常现象 |
|
|
372
|
-
| 502 `企微 … 失败:errcode=60020
|
|
430
|
+
| 502 `企微 … 失败:errcode=60020 … client ip 113.80.7.113` | 调用企微 API 的**服务器出口公网 IP**未在可信 IP 白名单(代开发套件级/服务商级;`get_suite_token` 与「生成开通链接」用的 `get_provider_token` / `get_customized_auth_url` **都必校**) | 企微服务商后台 → 应用管理 → 代开发应用 → 对应套件 → **IP 白名单/可信IP**,把报错里的 `client ip`(例 `113.80.7.113`,即你服务器访问公网的出口 IP,**不是内网 IP**)加进去;多出口/运营商 NAT 就加整段 CIDR。改完等几分钟再点「生成开通链接」。注:此码**不在自动重试验证集**(只有 40001/42001 重换一次),属配置阻断、重试无用 |
|
|
431
|
+
| 502 `企微 … 失败:errcode=48002 api forbidden` | 代开发应用调了第三方应用的 `get_pre_auth_code`(代开发**明确不支持**该接口) | 本系统「生成开通链接」已改用代开发专属的 `get_customized_auth_url`(返回 `qrcode_url`)。若仍见此码:确认跑的是本版代码(`admin.ts` 的 `createInstall` 不再调 `get_pre_auth_code`),并检查是否别处/旧版本在调它 |
|
|
432
|
+
| 502 `企微 … 失败:errcode=40058 state missing` | `get_customized_auth_url` 的 `state` 必填,本系统没传/传空(旧版) | 已改为始终发送 `state`(调用方不传则补默认常量)。若仍见此码:确认跑的是本版代码(`client.ts` 的 `customizedAuthUrl` 始终带 `state`) |
|
|
433
|
+
| 502 `企微 … 失败:errcode=842002 代开发模板尚未上架/上线` | 传入的代开发模板(`templateid_list` 里的 suite_id)只是「创建成功」但**未上线**——`get_customized_auth_url` 只在模板正式上线后才生成 `qrcode_url` | **运营动作,非代码缺陷**:服务商后台 → 应用管理 → 代开发应用上线 → 提交模板上线审核 → 审核通过后手动点「提交上线」。模板状态变「已上线/上架」后再点「生成开通链接」。凭证/参数都对却卡这步,就是模板没上线(创建成功 ≠ 上线) |
|
|
373
434
|
| 502 `企微 … 失败:errcode=40001/40014/42001` | 凭据错或 token 过期(会自动重换一次) | 若持续:核对 SuiteSecret / 是否重置过 |
|
|
374
435
|
| 502 `企微接口不可达` / `HTTP 5xx` | 出网被墙、DNS、代理 | 查机器出网与 DNS |
|
|
375
436
|
| 点登录页「企业微信」按钮页面只闪一下、无请求 | 跳转拼了站点 base 而不是 API base | 见 §6.6 |
|
|
@@ -382,7 +443,7 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
|
|
|
382
443
|
## 9. 一分钟自检
|
|
383
444
|
|
|
384
445
|
```text
|
|
385
|
-
[ ]
|
|
446
|
+
[ ] 八个 vars 非空(Tier A/B/C),wecom_base_url 以 /api/auth/wecom 结尾
|
|
386
447
|
[ ] 企微后台的指令回调 URL == <wecom_base_url>/event(逐字)
|
|
387
448
|
[ ] 服务日志有 wecom suite_ticket updated(近期)
|
|
388
449
|
[ ] /system/wecom 列表里有目标企业,status=active
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oj-module/cli",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.47",
|
|
5
5
|
"description": "React Antd 模块工程命令行工具(含预构建宿主 shell-dist)",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
],
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@faker-js/faker": "^10.4.0",
|
|
52
|
-
"@oj-module/runtime": "0.2.
|
|
52
|
+
"@oj-module/runtime": "0.2.47",
|
|
53
53
|
"@redocly/cli": "^2.51.0",
|
|
54
54
|
"@tailwindcss/vite": "^4.3.0",
|
|
55
55
|
"cac": "^6.7.14",
|
|
@@ -304,10 +304,10 @@ function getAppInfo() {
|
|
|
304
304
|
return {
|
|
305
305
|
"pkg": {
|
|
306
306
|
"name": "@oj-module/runtime",
|
|
307
|
-
"version": "0.2.
|
|
307
|
+
"version": "0.2.47",
|
|
308
308
|
"license": "MIT"
|
|
309
309
|
},
|
|
310
|
-
"lastBuildTime": "2026-10-31
|
|
310
|
+
"lastBuildTime": "2026-10-31 16:11:28"
|
|
311
311
|
};
|
|
312
312
|
}
|
|
313
313
|
var init_get_app_info = __esmMin((() => {}));
|
package/shell-dist/index.html
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" href="/favicon.ico" />
|
|
7
7
|
<title>React Antd Admin · Shell</title>
|
|
8
|
-
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-
|
|
8
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-2NkaM2/61JPKHd1FH61mYw=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
|
|
9
9
|
|
|
10
|
-
<script type="importmap" nonce="
|
|
10
|
+
<script type="importmap" nonce="2NkaM2/61JPKHd1FH61mYw==">{
|
|
11
11
|
"imports": {
|
|
12
12
|
"react": "/assets/react.js",
|
|
13
13
|
"react/jsx-runtime": "/assets/jsx-runtime.js",
|
package/shell-dist/versions.json
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
"react-router": "7.18.2",
|
|
8
8
|
"react-router/dom": "7.18.2",
|
|
9
9
|
"@tanstack/react-query": "5.102.8",
|
|
10
|
-
"@oj-module/runtime": "0.2.
|
|
11
|
-
"@oj-module/runtime/contract": "0.2.
|
|
12
|
-
"@oj-module/runtime/contract/errors": "0.2.
|
|
10
|
+
"@oj-module/runtime": "0.2.47",
|
|
11
|
+
"@oj-module/runtime/contract": "0.2.47",
|
|
12
|
+
"@oj-module/runtime/contract/errors": "0.2.47",
|
|
13
13
|
"@plane/kit": "0.1.20",
|
|
14
14
|
"@plane/editor": "0.1.20",
|
|
15
15
|
"antd": "6.6.1",
|
package/templates/config.yaml
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
# 站点挂载:一行 = 一个 URL 前缀 + 一个目录(api/web 二选一)。
|
|
10
10
|
# 这里不放 api 挂载是有意的:本工程是 src/<name>/{api,web} 布局,源码由 ojm dev
|
|
11
11
|
# 以 --module-root src 供给、产物由 ojm preview 以 --api-path dist/api 供给,都不走 mounts。
|
|
12
|
-
# 手写部署(不用 ojm)时才需要加回一条 `- prefix: /api` + `api: dist`。
|
|
12
|
+
# 手写部署(不用 ojm)时才需要加回一条 `- prefix: /api` + `api: dist/api`。
|
|
13
|
+
# 注意:手写部署同时要把下面 platform.root 从 `src` 改成 `dist/api`(二者必须同源),
|
|
14
|
+
# 否则 oj serve 报 `platform.root must be mounted as an api mount`。
|
|
13
15
|
mounts:
|
|
14
16
|
# 本地市场 CDN:直出工程根 cdn/。seed 里 framework.market_cdn_url 缺省就是 /cdn,本站点自供免 setup。
|
|
15
17
|
# 生产删掉这条,并把该设置指向真 CDN(绝对地址)。不能设 spa:缺文件必须 404,
|
|
@@ -55,20 +57,33 @@ vars:
|
|
|
55
57
|
# canonical_origin: "https://your-site.example.com"
|
|
56
58
|
|
|
57
59
|
# ---- 企业微信代开发(服务商侧,部署级常量)----
|
|
58
|
-
# 一个部署 = 一个服务商账号下的一套代开发模板,所以这
|
|
60
|
+
# 一个部署 = 一个服务商账号下的一套代开发模板,所以这 8 个值是「部署」维度,不进 DB。
|
|
59
61
|
# 「哪个企业装了哪个应用」不写在这里:企微 create_auth 回调推 AuthCode → 换永久授权码 →
|
|
60
62
|
# 落 wecom_app_profile(主键含 agent_id,同一企业测试/正式两个应用天然并存),
|
|
61
63
|
# 新增企业只需企业管理员扫码授权,**不改配置、不重启**。
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
64
|
+
# 分三层(来源与必填时序不同,破解「先有 suite 还是先有回调」死锁):
|
|
65
|
+
# · Tier A 本地生成、创建模版前即可配齐:callback_token / encoding_aes_key / enc_key / base_url;
|
|
66
|
+
# 任一为空 → 启动即 400 拒绝服务(宁可不登录,也不明文存 permanent_code)。
|
|
67
|
+
# · Tier B 企微创建模版后下发:suite_id / suite_secret;为空时仍可启动并响应 echostr 验证,
|
|
68
|
+
# 仅 login/callback/create_auth 等 suite 流程会 400——先配 Tier A 跑通回调、建模版、拿 suite 再补全。
|
|
69
|
+
# · Tier C 服务商后台「通用开发参数」页拿(不是企业、也不是 suite):provider_id / provider_secret;
|
|
70
|
+
# 只供「生成授权链接」用(get_customized_auth_url 要 provider_access_token)。缺它不影响登录,
|
|
71
|
+
# 但 /system/wecom 点「生成开通链接」会 400 点名缺这两项。
|
|
72
|
+
# wecom_suite_id 代开发应用「模板详情 → 基本信息」页里的「模板ID」(即 SuiteID);创建模板成功后才出现
|
|
73
|
+
# wecom_suite_secret 同页「模板Secret」(即 SuiteSecret,可重置);不是企业 CorpID/CorpSecret
|
|
74
|
+
# wecom_provider_id 服务商的 corpid(服务商自己的,不是企业 corpid);**位置:服务商后台「服务商信息」页显示的 CorpID**
|
|
75
|
+
# wecom_provider_secret 服务商 provider_secret;**位置:服务商后台 → 应用管理 → 通用开发参数(点「编辑」)→ ProviderSecret 字段**,
|
|
76
|
+
# **不在「服务商信息」页**(那里只显示 CorpID),也**不是** suite_secret(suite 那个是另一套)。
|
|
65
77
|
# wecom_callback_token 「Token」(回调验签用)
|
|
66
78
|
# wecom_encoding_aes_key 「EncodingAESKey」(43 字符,回调解密用)
|
|
67
79
|
# wecom_enc_key permanent_code 落库加密密钥(hex/base64,16 或 32 字节;建议 oj secret keygen)
|
|
68
|
-
# wecom_base_url
|
|
69
|
-
# → 企微后台「指令回调URL」填 <base>/event
|
|
80
|
+
# wecom_base_url 连接器基址(绝对地址,无尾斜杠,**这是基址、不是回调 URL**),如 https://site.example.com/api/auth/wecom
|
|
81
|
+
# → 企微后台「指令回调URL」填 <base>/event(必须带 /event,裸 <base> 无 handler 会 404);
|
|
82
|
+
# 网页授权 redirect_uri 用 <base>/callback
|
|
70
83
|
wecom_suite_id: ''
|
|
71
84
|
wecom_suite_secret: ''
|
|
85
|
+
wecom_provider_id: ''
|
|
86
|
+
wecom_provider_secret: ''
|
|
72
87
|
wecom_callback_token: ''
|
|
73
88
|
wecom_encoding_aes_key: ''
|
|
74
89
|
wecom_enc_key: ''
|
|
@@ -77,6 +92,10 @@ vars:
|
|
|
77
92
|
platform:
|
|
78
93
|
modules: [_platform] # 安装器热更 config 需要 _platform 特权;删了会在装到最后一步报 NotCapable
|
|
79
94
|
root: src # 后端模块源码根(src/<name>/api 的父目录);preview 派生 config 时改写成 dist
|
|
95
|
+
# ⚠️ 手写部署(不用 ojm,靠 mounts 起站)时:root 必须等于 api 挂载目录,
|
|
96
|
+
# 否则 oj serve 直接拒启 `platform.root (…) must be mounted as an api mount`。
|
|
97
|
+
# 编译产物下把这里改成 `dist/api`(与下面 `- prefix: /api api: dist/api` 同源)。
|
|
98
|
+
# ojm dev/preview 不受影响:dev 用 --module-root、preview 派生 config 会重写 root。
|
|
80
99
|
# 信任库默认落在安装子树之外(.solutions/trusted-keys.yaml),第三方方案模块碰不到。
|
|
81
100
|
# U6 加固:第三方模块的 fs 读写范围 = fs.root(= modules/ 安装子树),信任库必须在其外,
|
|
82
101
|
# 否则方案包可写自己的信任锚(伪造签名来源)。部署侧还应补 platform.trusted_paths 授权
|
|
@@ -12,8 +12,8 @@ import type { WecomClient } from "./client";
|
|
|
12
12
|
import type { WecomConfig } from "./config";
|
|
13
13
|
import type { WecomStoreDeps } from "./store";
|
|
14
14
|
import { invalidateAllUsers } from "#/_platform/_shared/service/infra/access";
|
|
15
|
-
import {
|
|
16
|
-
import { corpScope,
|
|
15
|
+
import { assertProviderConfig, eventUrlOf } from "./config";
|
|
16
|
+
import { corpScope, revokeTenantSessions, tenantOfCorp } from "./store";
|
|
17
17
|
|
|
18
18
|
export interface WecomAdminDeps {
|
|
19
19
|
client: WecomClient
|
|
@@ -88,25 +88,30 @@ export async function setAppStatus(
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
export interface InstallResult {
|
|
91
|
+
/** 开通链接(代开发 = `get_customized_auth_url` 返回的 qrcode_url,企业管理员扫/开即授权) */
|
|
91
92
|
installUrl: string
|
|
92
93
|
/** 企微后台「指令回调 URL」应填这个(逐字) */
|
|
93
94
|
callbackUrl: string
|
|
95
|
+
/** 链接有效期(秒,以企微返回为准,可达 10 天) */
|
|
94
96
|
expiresIn: number
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* 生成开通入口(代开发):provider_access_token → get_customized_auth_url → qrcode_url。
|
|
101
|
+
* 与第三方应用的「预授权码拼安装链接」不是一个接口——代开发**不支持** get_pre_auth_code
|
|
102
|
+
* (errcode 48002),必须用这个。授权完成由企微回调 create_auth 落库,与这里无关。
|
|
103
|
+
*/
|
|
98
104
|
export async function createInstall(
|
|
99
105
|
cfg: WecomConfig,
|
|
100
106
|
client: WecomClient,
|
|
101
|
-
store: WecomStoreDeps,
|
|
102
107
|
state: string,
|
|
103
108
|
): Promise<InstallResult> {
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
const
|
|
109
|
+
assertProviderConfig(cfg);
|
|
110
|
+
const providerToken = await client.providerAccessToken(cfg.providerId, cfg.providerSecret);
|
|
111
|
+
const auth = await client.customizedAuthUrl(providerToken, [cfg.suiteId], state);
|
|
107
112
|
return {
|
|
108
|
-
installUrl:
|
|
113
|
+
installUrl: auth.url,
|
|
109
114
|
callbackUrl: eventUrlOf(cfg),
|
|
110
|
-
expiresIn:
|
|
115
|
+
expiresIn: auth.expiresIn,
|
|
111
116
|
};
|
|
112
117
|
}
|
|
@@ -33,11 +33,36 @@ export interface WecomClientDeps {
|
|
|
33
33
|
cache: WecomCache
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* 软超时:服务端 isolate 无 `setTimeout` / `AbortSignal` 全局,不能用 `AbortSignal.timeout`。
|
|
38
|
+
* 这里用运行时注入的 `tasks.sleep(ms)` 起一个「到点就 reject」的竞态;fetch 不被真正 abort,
|
|
39
|
+
* 只是不再等它(后台连接最终由 OS/池回收)。`tasks` 不可用时降级为原 promise(无超时)。
|
|
40
|
+
* 输家 promise 在 race 结束后仍可能 reject,挂 `.catch` 防 unhandled rejection。
|
|
41
|
+
*/
|
|
42
|
+
function withSoftTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
|
|
43
|
+
const g = globalThis as unknown as { tasks?: { sleep?: (ms: number) => Promise<void> } };
|
|
44
|
+
const sleep = g.tasks?.sleep;
|
|
45
|
+
if (typeof sleep !== "function")
|
|
46
|
+
return p;
|
|
47
|
+
const timeout = sleep(ms).then(
|
|
48
|
+
() => { throw new Error(`超时 ${ms}ms`); },
|
|
49
|
+
() => undefined as never, // sleep 自身异常不中断主链路(返回 never 以免 race 结果退化为 void | T)
|
|
50
|
+
);
|
|
51
|
+
return Promise.race([p, timeout]).finally(() => {
|
|
52
|
+
// 输家 promise 在 race 结束后仍可能 reject,挂 catch 防 unhandled rejection
|
|
53
|
+
timeout.catch(() => {});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
36
57
|
export function runtimeHttp(): WecomHttp {
|
|
37
58
|
async function call(url: string, init: RequestInit): Promise<Record<string, unknown>> {
|
|
38
59
|
let res: Response;
|
|
39
60
|
try {
|
|
40
|
-
|
|
61
|
+
// 服务端 isolate 无 `AbortSignal` / `setTimeout` 全局(见 oj-api-dev global.d.ts:
|
|
62
|
+
// 「本运行时无 timer 全局」),`AbortSignal.timeout()` 直接 ReferenceError。
|
|
63
|
+
// 用运行时提供的 `tasks.sleep` 做软超时(到点 reject;fetch 仍在后台跑、不 abort),
|
|
64
|
+
// 拿不到 tasks 时降级为「不超时」——网络层失败仍会被下面 catch 转成 502。
|
|
65
|
+
res = await withSoftTimeout(fetch(url, init), TIMEOUT_MS);
|
|
41
66
|
}
|
|
42
67
|
catch (e) {
|
|
43
68
|
throw upstream(`企微接口不可达:${(e as Error).message}`);
|
|
@@ -154,15 +179,49 @@ export function createWecomClient(deps: WecomClientDeps) {
|
|
|
154
179
|
}
|
|
155
180
|
|
|
156
181
|
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
182
|
+
* 服务商级 token(provider_access_token):代开发「生成授权链接」用,与套件 token 不同源。
|
|
183
|
+
* 由服务商 corpid + provider_secret 换得,全部署共享一份(不限 suite)。
|
|
184
|
+
* 注意:这是**服务商**凭证,不是 suite 凭证——代开发的 `get_customized_auth_url`
|
|
185
|
+
* 只认 provider_access_token(用 suite_access_token 调会报 token 无效类错误)。
|
|
159
186
|
*/
|
|
160
|
-
async function
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
187
|
+
async function providerAccessToken(providerId: string, providerSecret: string): Promise<string> {
|
|
188
|
+
return cachedToken("wecom:provider_token", async () => {
|
|
189
|
+
const res = await http.postJson(`${API_BASE}/cgi-bin/service/get_provider_token`, {
|
|
190
|
+
corpid: providerId,
|
|
191
|
+
provider_secret: providerSecret,
|
|
192
|
+
});
|
|
193
|
+
assertOk(res, "get_provider_token");
|
|
194
|
+
if (!res.provider_access_token)
|
|
195
|
+
throw upstream("企微 get_provider_token 未返回 provider_access_token");
|
|
196
|
+
return { token: String(res.provider_access_token), expiresIn: Number(res.expires_in ?? 7200) };
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 代开发授权链接(get_customized_auth_url):返回 `qrcode_url`(即企业管理员要扫/开的开通链接,
|
|
202
|
+
* 运营侧常称 auth_url)。**代开发明确不支持**第三方应用那个 `get_pre_auth_code`(errcode 48002),
|
|
203
|
+
* 必须用本接口。链接有效期以企微返回为准(可达 10 天),state 透传给授权成功回调原样回传。
|
|
204
|
+
* `templateid_list` 填代开发模板 ID(本系统即 suite_id,代开发模板与 suite 同标识)。
|
|
205
|
+
*/
|
|
206
|
+
async function customizedAuthUrl(providerAccessToken: string, templateIds: string[], state: string): Promise<{ url: string, expiresIn: number }> {
|
|
207
|
+
// state 必填(errcode 40058 state missing),限 ≤32 字节;调用方没传(前端目前不传)就给默认常量。
|
|
208
|
+
// 注:本系统开通链接不靠 state 携带 env(env 走登录链接),故空时给固定默认值即可。
|
|
209
|
+
const st = state ? state.slice(0, 32) : "ojmdaikaifa";
|
|
210
|
+
const res = await http.postJson(`${API_BASE}/cgi-bin/service/get_customized_auth_url?provider_access_token=${encodeURIComponent(providerAccessToken)}`, {
|
|
211
|
+
state: st,
|
|
212
|
+
templateid_list: templateIds,
|
|
213
|
+
});
|
|
214
|
+
// 842002 = 代开发模板未上架/上线:模板创建成功 ≠ 上线,必须到服务商后台
|
|
215
|
+
// 「应用管理 → 代开发应用上线」提交审核、审核通过后再手动「提交上线」,
|
|
216
|
+
// 模板正式上线后 get_customized_auth_url 才会生成 qrcode_url。这是后台运营前置动作,
|
|
217
|
+
// 与代码/参数/凭证无关(否则会先报 40058/60020/40001 之类)。单独挑出给可操作提示。
|
|
218
|
+
if (Number(res.errcode ?? 0) === 842002)
|
|
219
|
+
throw upstream("企微 get_customized_auth_url 失败:errcode=842002 代开发模板尚未上架/上线——请到服务商后台「应用管理 → 代开发应用上线」提交模板上线审核,审核通过后手动点「提交上线」,模板正式上线后再生成开通链接");
|
|
220
|
+
assertOk(res, "get_customized_auth_url");
|
|
221
|
+
const url = res.qrcode_url ?? res.auth_url;
|
|
222
|
+
if (!url)
|
|
223
|
+
throw upstream("企微 get_customized_auth_url 未返回 qrcode_url");
|
|
224
|
+
return { url: String(url), expiresIn: Number(res.expires_in ?? 7200) };
|
|
166
225
|
}
|
|
167
226
|
|
|
168
227
|
/** 企业 access_token(代开发 = 自建应用的 gettoken,corpsecret 就是 permanent_code) */
|
|
@@ -237,7 +296,7 @@ export function createWecomClient(deps: WecomClientDeps) {
|
|
|
237
296
|
throw upstream("企微 token 反复失效(40001/42001)");
|
|
238
297
|
}
|
|
239
298
|
|
|
240
|
-
return { suiteAccessToken, permanentCodeByAuthCode,
|
|
299
|
+
return { suiteAccessToken, permanentCodeByAuthCode, providerAccessToken, customizedAuthUrl, corpAccessToken, invalidateCorpToken, userIdByCode, userNameById };
|
|
241
300
|
}
|
|
242
301
|
|
|
243
302
|
export type WecomClient = ReturnType<typeof createWecomClient>;
|
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
import { b64ToBytes } from "./crypt";
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
/**
|
|
14
|
+
* 回调引导配置(Tier A):echostr URL 验证 + 永久码落库加密所需的全部凭据。
|
|
15
|
+
* 这四项**全部由部署方在本地生成**,与企业微信无关——所以可以在「创建代开发模版 / 拿到
|
|
16
|
+
* suite_id」之前就配齐并启动服务、响应企微的 URL 验证。这是打破「先有 suite 还是先有回调」
|
|
17
|
+
* 死锁的关键(见 plan §18):鸡生蛋问题里,这四项属于「蛋还没孵出来前就能自己备齐的料」。
|
|
18
|
+
*/
|
|
19
|
+
export interface WecomCallbackConfig {
|
|
17
20
|
/** 回调签名 token(企微后台「Token」) */
|
|
18
21
|
token: string
|
|
19
22
|
/** 回调加密密钥(43 字符 EncodingAESKey) */
|
|
@@ -29,15 +32,61 @@ export interface WecomConfig {
|
|
|
29
32
|
baseUrl: string
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
/**
|
|
36
|
+
* 完整配置(Tier A + Tier B):login/callback/create_auth 等 suite 流程额外需要
|
|
37
|
+
* `suiteId` 与 `suiteSecret`——它们由企业微信在「代开发模版创建成功」后下发(模版详情页可见),
|
|
38
|
+
* 缺失只影响 suite 流程,不影响回调 URL 验证(后者只需 Tier A)。
|
|
39
|
+
*/
|
|
40
|
+
export interface WecomConfig extends WecomCallbackConfig {
|
|
41
|
+
/** 代开发应用模版 id(企微侧即 suite_id),也是回调明文里的 receiveid */
|
|
42
|
+
suiteId: string
|
|
43
|
+
suiteSecret: string
|
|
44
|
+
/**
|
|
45
|
+
* 服务商 corpid(不是企业 corpid,是「服务商」自己的 corpid)。
|
|
46
|
+
* 代开发「生成授权链接」走 `get_customized_auth_url`,该接口只认 provider_access_token,
|
|
47
|
+
* 而 provider_access_token 由服务商 corpid + provider_secret 换得——与 suite 凭证不同源。
|
|
48
|
+
*/
|
|
49
|
+
providerId: string
|
|
50
|
+
/** 服务商 provider_secret(在服务商后台「通用开发参数」页拿,别和 suite_secret 搞混) */
|
|
51
|
+
providerSecret: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Tier A:部署方本地生成,创建模版前即可配齐(token / EncodingAESKey / 落库密钥 / 基址)。
|
|
55
|
+
const REQUIRED_CALLBACK: Array<[keyof WecomCallbackConfig, string]> = [
|
|
35
56
|
["token", "wecom_callback_token"],
|
|
36
57
|
["encodingAesKey", "wecom_encoding_aes_key"],
|
|
37
58
|
["encKey", "wecom_enc_key"],
|
|
38
59
|
["baseUrl", "wecom_base_url"],
|
|
39
60
|
];
|
|
40
61
|
|
|
62
|
+
// Tier B:suite 凭据,企业微信在「代开发模版创建成功」后下发(模版详情页可查)。
|
|
63
|
+
const REQUIRED_SUITE: Array<[keyof WecomConfig, string]> = [
|
|
64
|
+
["suiteId", "wecom_suite_id"],
|
|
65
|
+
["suiteSecret", "wecom_suite_secret"],
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// Tier C:服务商凭证(provider_access_token 源)。**仅「生成授权链接」需要**,不影响
|
|
69
|
+
// login/callback/create_auth 等 suite 流程,故不进 readWecomConfig 的必填集合(readWecomConfig
|
|
70
|
+
// 被登录链路复用,缺 provider 凭证不该让登录 400)。createInstall 单独校验。
|
|
71
|
+
const REQUIRED_PROVIDER: Array<[keyof WecomConfig, string]> = [
|
|
72
|
+
["providerId", "wecom_provider_id"],
|
|
73
|
+
["providerSecret", "wecom_provider_secret"],
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/** 一次性读出六处部署常量(未 trim 的尾斜杠在此归一) */
|
|
77
|
+
function readWecomVars(): WecomConfig {
|
|
78
|
+
return {
|
|
79
|
+
token: vars.get("wecom_callback_token") ?? "",
|
|
80
|
+
encodingAesKey: vars.get("wecom_encoding_aes_key") ?? "",
|
|
81
|
+
encKey: vars.get("wecom_enc_key") ?? "",
|
|
82
|
+
baseUrl: (vars.get("wecom_base_url") ?? "").replace(/\/+$/, ""),
|
|
83
|
+
suiteId: vars.get("wecom_suite_id") ?? "",
|
|
84
|
+
suiteSecret: vars.get("wecom_suite_secret") ?? "",
|
|
85
|
+
providerId: vars.get("wecom_provider_id") ?? "",
|
|
86
|
+
providerSecret: vars.get("wecom_provider_secret") ?? "",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
41
90
|
/**
|
|
42
91
|
* 落库密钥格式校验(fail-fast 在**读配置**处,而不是等 create_auth 时在 crypto 里炸):
|
|
43
92
|
* oj 的 aesGcmEncrypt 只收「16 或 32 字节的 hex/base64」,AES-192(24 字节)明确不支持。
|
|
@@ -60,26 +109,48 @@ export function assertEncKey(encKey: string): void {
|
|
|
60
109
|
}
|
|
61
110
|
|
|
62
111
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* 宁可拒服务,也不退化成明文存储(§7.5 数据合规红线)。
|
|
112
|
+
* 校验服务商凭证(生成授权链接必需)。缺任一即 400 并点名缺哪两项——
|
|
113
|
+
* 区分于 suite 凭证:那是「模版创建后下发」,provider 凭证是「服务商后台通用开发参数」里拿的。
|
|
66
114
|
*/
|
|
67
|
-
export function
|
|
68
|
-
const cfg
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
115
|
+
export function assertProviderConfig(cfg: WecomConfig): void {
|
|
116
|
+
const missing = REQUIRED_PROVIDER.filter(([field]) => !cfg[field]).map(([, name]) => name);
|
|
117
|
+
if (missing.length)
|
|
118
|
+
throw Object.assign(new Error(`企业微信未配置:生成授权链接需要 ${missing.join(" / ")}(服务商后台「通用开发参数」页获取,与 suite 凭证不同源)`), { status: 400 });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 读 Tier A 凭据(token / aeskey / encKey / baseUrl)。**不依赖 suite 凭据**:这是 echostr
|
|
123
|
+
* URL 验证与永久码落库加密所需的全部东西,部署方本地即可生成。引导期(suite_id 尚未下发)
|
|
124
|
+
* 服务也能靠它启动并响应企微的回调验证(plan §18)。
|
|
125
|
+
*/
|
|
126
|
+
export function readCallbackConfig(): WecomCallbackConfig {
|
|
127
|
+
const v = readWecomVars();
|
|
128
|
+
const missing = REQUIRED_CALLBACK.filter(([field]) => !v[field]).map(([, name]) => name);
|
|
77
129
|
if (missing.length)
|
|
78
130
|
throw Object.assign(new Error(`企业微信未配置:config vars 缺少 ${missing.join(" / ")}`), { status: 400 });
|
|
79
|
-
if (!/^https?:\/\//.test(
|
|
131
|
+
if (!/^https?:\/\//.test(v.baseUrl))
|
|
132
|
+
throw Object.assign(new Error("企业微信配置错误:wecom_base_url 必须是绝对地址(企微回调与 redirect_uri 都要求)"), { status: 400 });
|
|
133
|
+
assertEncKey(v.encKey);
|
|
134
|
+
return { token: v.token, encodingAesKey: v.encodingAesKey, encKey: v.encKey, baseUrl: v.baseUrl };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 读完整部署常量。缺任一必需项即 400(「未配置」是运维事实,不是服务端缺陷)。
|
|
139
|
+
* 一次报全 Tier A + Tier B 所有缺失项,省掉逐轮补配置的往返;suite 凭据(Tier B)缺失时
|
|
140
|
+
* 文案点明「由代开发模版创建成功后下发」,避免运维误以为是本地漏配。
|
|
141
|
+
*
|
|
142
|
+
* 注:Tier B 缺失只影响 login/callback/create_auth 等 suite 流程;echostr 验证走
|
|
143
|
+
* `readCallbackConfig()`,不受此约束(见 api.ts::event.get)。
|
|
144
|
+
*/
|
|
145
|
+
export function readWecomConfig(): WecomConfig {
|
|
146
|
+
const v = readWecomVars();
|
|
147
|
+
const missing = [...REQUIRED_CALLBACK, ...REQUIRED_SUITE].filter(([field]) => !v[field]).map(([, name]) => name);
|
|
148
|
+
if (missing.length)
|
|
149
|
+
throw Object.assign(new Error(`企业微信未配置:config vars 缺少 ${missing.join(" / ")}(suite 凭据由代开发模版创建成功后下发)`), { status: 400 });
|
|
150
|
+
if (!/^https?:\/\//.test(v.baseUrl))
|
|
80
151
|
throw Object.assign(new Error("企业微信配置错误:wecom_base_url 必须是绝对地址(企微回调与 redirect_uri 都要求)"), { status: 400 });
|
|
81
|
-
assertEncKey(
|
|
82
|
-
return
|
|
152
|
+
assertEncKey(v.encKey);
|
|
153
|
+
return v;
|
|
83
154
|
}
|
|
84
155
|
|
|
85
156
|
/** 企微后台「指令回调 URL」:原样填这个(base + /event) */
|
|
@@ -110,22 +181,6 @@ export function siteRootOf(cfg: WecomConfig): string {
|
|
|
110
181
|
return `${url.origin}${path.slice(0, -CONNECTOR_SUFFIX.length)}`;
|
|
111
182
|
}
|
|
112
183
|
|
|
113
|
-
/**
|
|
114
|
-
* 代开发「开通入口」:企业管理员点开 → 企微授权安装页。
|
|
115
|
-
* `redirect_uri` 落在站点根下的登录页(安装完回站点,指令回调已把 profile 落好)。
|
|
116
|
-
*/
|
|
117
|
-
export function installUrlOf(cfg: WecomConfig, preAuthCode: string, state: string): string {
|
|
118
|
-
const redirect = `${siteRootOf(cfg)}/login?wecom_installed=1`;
|
|
119
|
-
const q = new URLSearchParams({
|
|
120
|
-
suite_id: cfg.suiteId,
|
|
121
|
-
pre_auth_code: preAuthCode,
|
|
122
|
-
redirect_uri: redirect,
|
|
123
|
-
});
|
|
124
|
-
if (state)
|
|
125
|
-
q.set("state", state);
|
|
126
|
-
return `https://open.work.weixin.qq.com/3rdapp/install?${q.toString()}`;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
184
|
/** 登录方式发现用:只看开关,不读凭据(登录页要先知道「有没有这个方式」) */
|
|
130
185
|
export async function wecomEnabled(): Promise<boolean> {
|
|
131
186
|
const rows = await db.table("sys_setting").select(["value"]).where({ field: "key", op: "eq", value: "wecom.enabled" }).all() as Array<{ value: string | null }>;
|
|
@@ -293,8 +293,12 @@ export function wecomSignature(...parts: string[]): string {
|
|
|
293
293
|
* 明文结构(企微 §消息加解密):`random(16B) | msg_len(4B, big-endian) | msg | receiveid`。
|
|
294
294
|
* `receiveid` 对第三方平台/代开发模版是 **suite_id**;不一致即拒(错钥/错套件混用必须 fail-closed,
|
|
295
295
|
* 否则不同套件的回调会互相污染)。43 字符 EncodingAESKey 末尾补 "=" 才是 44 字符 base64(32 字节)。
|
|
296
|
+
*
|
|
297
|
+
* `tolerant`(默认 false):引导期 `suite_id` 尚未下发时,receiveid 无从比对——
|
|
298
|
+
* 此时放宽该检查。放宽是安全的:最外层签名(基于开发者持有的 `token`)已鉴权,
|
|
299
|
+
* receiveid 只是防御性兜底(防错配 aeskey)。`suite_id` 配齐后调用方应传 `false` 恢复严格。
|
|
296
300
|
*/
|
|
297
|
-
export function unwrapWecomMessage(encryptedB64: string, encodingAesKey: string, expectedReceiver: string): string {
|
|
301
|
+
export function unwrapWecomMessage(encryptedB64: string, encodingAesKey: string, expectedReceiver: string, tolerant = false): string {
|
|
298
302
|
const key = b64ToBytes(`${encodingAesKey}=`);
|
|
299
303
|
if (key.length !== 32)
|
|
300
304
|
throw new Error(`wecom: EncodingAESKey must decode to 32 bytes, got ${key.length}`);
|
|
@@ -306,7 +310,8 @@ export function unwrapWecomMessage(encryptedB64: string, encodingAesKey: string,
|
|
|
306
310
|
throw new Error("wecom: msg length out of range");
|
|
307
311
|
const msg = bytesToUtf8(plain.subarray(20, 20 + msgLen));
|
|
308
312
|
const receiver = bytesToUtf8(plain.subarray(20 + msgLen));
|
|
309
|
-
|
|
313
|
+
// 引导期(suite_id 未下发)且显式 tolerant → 跳过 receiveid 比对;其余情况严格比对。
|
|
314
|
+
if (!tolerant && receiver !== expectedReceiver)
|
|
310
315
|
throw new Error(`wecom: receiver mismatch (got ${receiver === "" ? "<empty>" : "other"}, expected suite)`);
|
|
311
316
|
return msg;
|
|
312
317
|
}
|
|
@@ -12,7 +12,6 @@ import { recordOp } from "#/_platform/_shared/service/infra/audit";
|
|
|
12
12
|
import { createInstall, listApps, setAppStatus } from "../../_shared/wecom/admin";
|
|
13
13
|
import { createWecomClient, runtimeCache, runtimeHttp } from "../../_shared/wecom/client";
|
|
14
14
|
import { readWecomConfig } from "../../_shared/wecom/config";
|
|
15
|
-
import { runtimeStoreDeps } from "../../_shared/wecom/store";
|
|
16
15
|
|
|
17
16
|
/** 清单:已授权企业 × 应用(不含 permanent_code,secret 不出库) */
|
|
18
17
|
async function get() {
|
|
@@ -92,8 +91,10 @@ status.post.response = {
|
|
|
92
91
|
};
|
|
93
92
|
|
|
94
93
|
const install = {
|
|
95
|
-
//
|
|
96
|
-
//
|
|
94
|
+
// 开通入口(代开发):provider_access_token → get_customized_auth_url → qrcode_url。
|
|
95
|
+
// 该接口**只**对代开发开放(第三方应用那个 get_pre_auth_code 对代开发报 48002);
|
|
96
|
+
// 链接有效期以企微返回为准,故不缓存,每次点都重新换。顺带回「指令回调 URL」——
|
|
97
|
+
// 服务商后台要把它逐字填进企微「代开发」配置页。
|
|
97
98
|
async get() {
|
|
98
99
|
await respond(async () => {
|
|
99
100
|
await assertSuperAdmin();
|
|
@@ -102,7 +103,6 @@ const install = {
|
|
|
102
103
|
return createInstall(
|
|
103
104
|
cfg,
|
|
104
105
|
createWecomClient({ http: runtimeHttp(), cache: runtimeCache() }),
|
|
105
|
-
runtimeStoreDeps(cfg),
|
|
106
106
|
state,
|
|
107
107
|
);
|
|
108
108
|
});
|
|
@@ -17,7 +17,7 @@ import { failWith, respond } from "#/_platform/_shared/respond";
|
|
|
17
17
|
import { clientIp, guardLoginThrottle, noteLoginFailed, noteLoginSucceeded } from "../_shared/login-throttle";
|
|
18
18
|
import { createSession } from "../_shared/session";
|
|
19
19
|
import { createWecomClient, runtimeCache, runtimeHttp } from "../_shared/wecom/client";
|
|
20
|
-
import { loginRedirectOf, readWecomConfig, siteRootOf } from "../_shared/wecom/config";
|
|
20
|
+
import { loginRedirectOf, readCallbackConfig, readWecomConfig, siteRootOf } from "../_shared/wecom/config";
|
|
21
21
|
import { unwrapWecomMessage, wecomSignature } from "../_shared/wecom/crypt";
|
|
22
22
|
import { handleSuiteEvent } from "../_shared/wecom/events";
|
|
23
23
|
import {
|
|
@@ -192,11 +192,31 @@ ticket.post.schema = {
|
|
|
192
192
|
};
|
|
193
193
|
|
|
194
194
|
const event = {
|
|
195
|
-
// 企微后台「保存回调 URL」时会 GET 一次:解出 echostr
|
|
195
|
+
// 企微后台「保存回调 URL」时会 GET 一次:解出 echostr 后**原样明文回**(不套信封、不加引号)。
|
|
196
|
+
//
|
|
197
|
+
// 只依赖 Tier A 凭据(readCallbackConfig):token/aeskey/baseUrl/encKey 全部部署方本地生成,
|
|
198
|
+
// 不需要 suite_id——因此「配齐四项正确即可响应验证」,**不必等代开发模版创建、拿到 suite_id 之后**。
|
|
199
|
+
// 这正是打破鸡生蛋死锁(plan §18)的落点:先有可验证的回调,才能创建模版、拿到 suite 凭据。
|
|
200
|
+
//
|
|
201
|
+
// suite_id 在「模版创建成功」后才下发;URL 验证可能发生在拿到 suite_id 之前(创建表单内联校验),
|
|
202
|
+
// 此时 receiveid 无从比对 → 走 tolerant(签名已是最外层鉴权闸门,receiveid 仅为防御性兜底)。
|
|
196
203
|
async get() {
|
|
197
204
|
try {
|
|
198
|
-
const
|
|
199
|
-
|
|
205
|
+
const cfg = readCallbackConfig();
|
|
206
|
+
const encrypted = query("echostr");
|
|
207
|
+
const expected = wecomSignature(cfg.token, query("timestamp"), query("nonce"), encrypted);
|
|
208
|
+
if (expected !== query("msg_signature"))
|
|
209
|
+
throw badRequest("回调签名校验失败");
|
|
210
|
+
await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
|
|
211
|
+
const suiteId = (vars.get("wecom_suite_id") ?? "").trim();
|
|
212
|
+
try {
|
|
213
|
+
plainText(unwrapWecomMessage(encrypted, cfg.encodingAesKey, suiteId, !suiteId));
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
// 解密失败只透出「失败」:错钥/未下发套件/被篡改三者不可区分,也不该让外部据此判断哪一环配错
|
|
217
|
+
log.warn("wecom callback decrypt failed", "reason", (e as Error).message);
|
|
218
|
+
throw badRequest("回调消息解密失败");
|
|
219
|
+
}
|
|
200
220
|
}
|
|
201
221
|
catch (e) {
|
|
202
222
|
failWith(e);
|
|
@@ -41,7 +41,15 @@ export const authProvider: AuthProvider = {
|
|
|
41
41
|
},
|
|
42
42
|
|
|
43
43
|
async logout(): Promise<void> {
|
|
44
|
-
|
|
44
|
+
// 服务端吊销是「尽力而为」:本地登出(runtime 的 finally → reset)才是真相,
|
|
45
|
+
// 网络/服务端超时(如反代 `proxy_read_timeout` 短于上游)不应让退出卡死或抛
|
|
46
|
+
// 未捕获异常(点击退出 → Uncaught TimeoutError)。失败仅记日志,不向上抛。
|
|
47
|
+
try {
|
|
48
|
+
await fetchLogout({ refreshToken: useAuthStore.getState().refreshToken });
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
console.warn("[auth-provider] logout 服务端吊销失败(本地会话已清空):", e);
|
|
52
|
+
}
|
|
45
53
|
},
|
|
46
54
|
|
|
47
55
|
getUserInfo: () => fetchUserInfo(),
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
},
|
|
80
80
|
"wecom": {
|
|
81
81
|
"title": "Onboard a corporation",
|
|
82
|
-
"hint": "Click \"Generate install link\" and send the link or QR code to the corporation's admin for approval.
|
|
82
|
+
"hint": "Click \"Generate install link\" and send the link or QR code to the corporation's admin to scan/open for approval. On success this system auto-registers the corporation via the create_auth callback (writes wecom_app_profile). The link is generated by WeCom get_customized_auth_url; its validity follows WeCom's response (up to 10 days). The QR code greys out when it expires, so regenerate it.",
|
|
83
83
|
"appsTitle": "Onboarded corporations",
|
|
84
84
|
"genInstall": "Generate install link",
|
|
85
85
|
"installOk": "Generated. Send the link to the corporation's admin.",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"oplog": "操作日志",
|
|
8
8
|
"settings": "站点设置",
|
|
9
9
|
"loginMethods": "登录方式",
|
|
10
|
-
"wecom": "
|
|
10
|
+
"wecom": "企微集成"
|
|
11
11
|
},
|
|
12
12
|
"common": {
|
|
13
13
|
"confirm": "确认",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
},
|
|
80
80
|
"wecom": {
|
|
81
81
|
"title": "开通企业",
|
|
82
|
-
"hint": "
|
|
82
|
+
"hint": "点「生成开通链接」把链接或二维码发给企业管理员,由其扫码/打开授权;授权成功后本系统经 create_auth 回调自动登记该企业(落库 wecom_app_profile)。链接由企微 get_customized_auth_url 生成,有效期以企微返回为准(可达 10 天),过期后二维码置灰,可重新生成。",
|
|
83
83
|
"appsTitle": "已开通企业",
|
|
84
84
|
"genInstall": "生成开通链接",
|
|
85
85
|
"installOk": "已生成,请把链接发给企业管理员",
|
|
@@ -51,7 +51,8 @@ async function createInstall(): Promise<InstallInfo> {
|
|
|
51
51
|
* 企微代开发后台(服务商运营面,superadmin 独占):
|
|
52
52
|
* · 已授权企业 × 应用清单(同一企业测试/正式两个应用各一行);
|
|
53
53
|
* · 启停(停用企业最后一个应用时后端同时吊销该企业会话);
|
|
54
|
-
* ·
|
|
54
|
+
* · 开通入口(点「生成开通链接」→ 后端调企微 get_customized_auth_url 换 qrcode_url,
|
|
55
|
+
* 企业管理员扫/开即授权,授权成功由 create_auth 回调落库)。
|
|
55
56
|
*/
|
|
56
57
|
export default function WecomAdminPage() {
|
|
57
58
|
const { t } = useTranslation();
|
|
@@ -15,7 +15,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
|
15
15
|
import { assertSuperAdmin, invalidateAllUsers } from "../src/_platform/api/_shared/service/infra/access";
|
|
16
16
|
import { listApps, setAppStatus } from "../src/auth/api/_shared/wecom/admin";
|
|
17
17
|
import { createWecomClient, runtimeCache, runtimeHttp } from "../src/auth/api/_shared/wecom/client";
|
|
18
|
-
import {
|
|
18
|
+
import { readWecomConfig } from "../src/auth/api/_shared/wecom/config";
|
|
19
19
|
import { installDb } from "./fixture";
|
|
20
20
|
|
|
21
21
|
const g = globalThis as any;
|
|
@@ -51,6 +51,8 @@ function installVars(overrides: Record<string, string> = {}): void {
|
|
|
51
51
|
const base: Record<string, string> = {
|
|
52
52
|
wecom_suite_id: SUITE,
|
|
53
53
|
wecom_suite_secret: "suite_secret",
|
|
54
|
+
wecom_provider_id: "provider_1",
|
|
55
|
+
wecom_provider_secret: "provider_secret",
|
|
54
56
|
wecom_callback_token: "cb_token",
|
|
55
57
|
wecom_encoding_aes_key: AES_KEY_B64.replace(/=$/, ""),
|
|
56
58
|
wecom_enc_key: "0123456789abcdef0123456789abcdef",
|
|
@@ -97,10 +99,6 @@ function seedCorp(corpId = "ww_corp_a", agentId = "100001", env = "prod", status
|
|
|
97
99
|
.run(SUITE, corpId, agentId, authCorpId, env, `企业 ${corpId}`, status, `gcm(perm_${corpId}_${agentId})`, NOW, NOW);
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
function seedTicket(ticket = "tk_9", expiresAt = NOW + 1800): void {
|
|
101
|
-
dbo.prepare("INSERT INTO wecom_suite_ticket (suite_id, suite_ticket, expires_at, update_time) VALUES (?, ?, ?, ?)").run(SUITE, ticket, expiresAt, NOW);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
102
|
function mkUser(username: string, roles: string[] = []): number {
|
|
105
103
|
dbo.prepare("INSERT INTO users (username, password_hash) VALUES (?, 'x')").run(username);
|
|
106
104
|
const id = (dbo.prepare("SELECT id FROM users WHERE username = ?").get(username) as { id: number }).id;
|
|
@@ -124,10 +122,10 @@ function fakeClient(): { client: any, invalidated: string[] } {
|
|
|
124
122
|
|
|
125
123
|
const deps = { store: { encrypt: (p: string) => `gcm(${p})`, decrypt: (b: string) => b, now: () => NOW } };
|
|
126
124
|
|
|
127
|
-
/** createInstall
|
|
125
|
+
/** createInstall 的薄包装:省掉动态 import 的样板 */
|
|
128
126
|
async function createInstallForTest(cfg: ReturnType<typeof readWecomConfig>, client: ReturnType<typeof createWecomClient>, state: string) {
|
|
129
127
|
const { createInstall } = await import("../src/auth/api/_shared/wecom/admin");
|
|
130
|
-
return createInstall(cfg, client,
|
|
128
|
+
return createInstall(cfg, client, state);
|
|
131
129
|
}
|
|
132
130
|
|
|
133
131
|
const api = async () => (await import("../src/auth/api/wecom/admin/api")).default;
|
|
@@ -251,70 +249,93 @@ describe("P3-B:启停(setAppStatus)", () => {
|
|
|
251
249
|
});
|
|
252
250
|
});
|
|
253
251
|
|
|
254
|
-
describe("P3-C:开通入口(createInstall)", () => {
|
|
255
|
-
|
|
252
|
+
describe("P3-C:开通入口(createInstall,代开发 get_customized_auth_url)", () => {
|
|
253
|
+
// 代开发**不支持** get_pre_auth_code(48002):本段钉死调用链走 provider_access_token +
|
|
254
|
+
// get_customized_auth_url,绝不回退到第三方应用的预授权码接口。
|
|
255
|
+
function stubUpstream(qrcodeUrl = "https://open.work.weixin.qq.com/3rdservice/QR_1", expiresIn = 7200): { calls: string[], bodies: any[] } {
|
|
256
256
|
const calls: string[] = [];
|
|
257
|
-
|
|
257
|
+
const bodies: any[] = [];
|
|
258
|
+
g.fetch = async (url: string, init?: any) => {
|
|
258
259
|
calls.push(url);
|
|
260
|
+
bodies.push(init?.body ? JSON.parse(init.body) : null);
|
|
259
261
|
return {
|
|
260
262
|
ok: true,
|
|
261
263
|
json: async () => {
|
|
262
|
-
if (url.includes("
|
|
263
|
-
return {
|
|
264
|
-
if (url.includes("
|
|
265
|
-
return {
|
|
264
|
+
if (url.includes("get_provider_token"))
|
|
265
|
+
return { provider_access_token: "prov_tk", expires_in: 7200 };
|
|
266
|
+
if (url.includes("get_customized_auth_url"))
|
|
267
|
+
return { qrcode_url: qrcodeUrl, expires_in: expiresIn };
|
|
266
268
|
throw new Error(`stub fetch 未预期:${url}`);
|
|
267
269
|
},
|
|
268
270
|
};
|
|
269
271
|
};
|
|
270
|
-
return calls;
|
|
272
|
+
return { calls, bodies };
|
|
271
273
|
}
|
|
272
274
|
|
|
273
|
-
it("
|
|
274
|
-
|
|
275
|
-
const calls = stubUpstream();
|
|
275
|
+
it("provider_token → get_customized_auth_url → qrcode_url(即开通链接),且绝不调 get_pre_auth_code", async () => {
|
|
276
|
+
const { calls, bodies } = stubUpstream();
|
|
276
277
|
const cfg = readWecomConfig();
|
|
277
278
|
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
278
279
|
|
|
279
280
|
const r = await createInstallForTest(cfg, client, "st_1");
|
|
280
|
-
expect(calls.some(u => u.includes("
|
|
281
|
-
expect(calls.some(u => u.includes("
|
|
281
|
+
expect(calls.some(u => u.includes("get_provider_token"))).toBe(true);
|
|
282
|
+
expect(calls.some(u => u.includes("get_customized_auth_url"))).toBe(true);
|
|
283
|
+
// 代开发 48002 的根因就是错用了 get_pre_auth_code——这条断言防止回归
|
|
284
|
+
expect(calls.some(u => u.includes("get_pre_auth_code"))).toBe(false);
|
|
285
|
+
|
|
286
|
+
const body = bodies.find(b => b && b.templateid_list) as any;
|
|
287
|
+
expect(body).toBeTruthy();
|
|
288
|
+
// get_customized_auth_url 要求 state 必填(errcode 40058 state missing):本系统必传、非空
|
|
289
|
+
expect(body.state).toBe("st_1");
|
|
290
|
+
expect(body.templateid_list).toEqual([SUITE]);
|
|
291
|
+
|
|
292
|
+
expect(r.installUrl).toBe("https://open.work.weixin.qq.com/3rdservice/QR_1");
|
|
282
293
|
expect(r.callbackUrl).toBe("https://site.example.com/api/auth/wecom/event");
|
|
283
|
-
expect(r.expiresIn).toBe(
|
|
284
|
-
|
|
285
|
-
const url = new URL(r.installUrl);
|
|
286
|
-
expect(`${url.origin}${url.pathname}`).toBe("https://open.work.weixin.qq.com/3rdapp/install");
|
|
287
|
-
expect(url.searchParams.get("suite_id")).toBe(SUITE);
|
|
288
|
-
expect(url.searchParams.get("pre_auth_code")).toBe("PRE_1");
|
|
289
|
-
expect(url.searchParams.get("redirect_uri")).toBe("https://site.example.com/login?wecom_installed=1");
|
|
290
|
-
expect(url.searchParams.get("state")).toBe("st_1");
|
|
294
|
+
expect(r.expiresIn).toBe(7200);
|
|
291
295
|
});
|
|
292
296
|
|
|
293
|
-
it("
|
|
294
|
-
|
|
297
|
+
it("调用方没传 state 时仍发默认 state(避免 40058 state missing)", async () => {
|
|
298
|
+
const { bodies } = stubUpstream();
|
|
295
299
|
const cfg = readWecomConfig();
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
expect(
|
|
300
|
+
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
301
|
+
await createInstallForTest(cfg, client, "");
|
|
302
|
+
const body = bodies.find(b => b && b.templateid_list) as any;
|
|
303
|
+
expect(body.state).toBeTruthy();
|
|
304
|
+
expect(body.state.length).toBeLessThanOrEqual(32);
|
|
301
305
|
});
|
|
302
306
|
|
|
303
|
-
it("缺
|
|
304
|
-
|
|
307
|
+
it("缺 provider 凭证(wecom_provider_id / wecom_provider_secret)→ 400 并点名缺哪两个 vars", async () => {
|
|
308
|
+
installVars({ wecom_provider_id: "", wecom_provider_secret: "" });
|
|
305
309
|
const cfg = readWecomConfig();
|
|
306
310
|
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
307
|
-
await expect(createInstallForTest(cfg, client, "")).rejects.toMatchObject({ status:
|
|
308
|
-
expect(calls).toEqual([]);
|
|
311
|
+
await expect(createInstallForTest(cfg, client, "")).rejects.toMatchObject({ status: 400 });
|
|
309
312
|
});
|
|
310
313
|
|
|
311
|
-
it("上游 errcode
|
|
312
|
-
|
|
313
|
-
g.fetch = async () => ({ ok: true, json: async () => ({ errcode: 40001, errmsg: "invalid credential" }) });
|
|
314
|
+
it("上游 errcode(如 provider_secret 错)→ 502(可重试语义,不是 500)", async () => {
|
|
315
|
+
g.fetch = async () => ({ ok: true, json: async () => ({ errcode: 40001, errmsg: "invalid provider secret" }) });
|
|
314
316
|
const cfg = readWecomConfig();
|
|
315
317
|
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
316
318
|
await expect(createInstallForTest(cfg, client, "")).rejects.toMatchObject({ status: 502 });
|
|
317
319
|
});
|
|
320
|
+
|
|
321
|
+
it("get_customized_auth_url 返回 842002(模板未上架/上线)→ 502 且提示去后台提交上线(运营动作,非代码缺陷)", async () => {
|
|
322
|
+
g.fetch = async (url: string) => ({
|
|
323
|
+
ok: true,
|
|
324
|
+
json: async () => {
|
|
325
|
+
if (url.includes("get_provider_token"))
|
|
326
|
+
return { provider_access_token: "prov_tk", expires_in: 7200 };
|
|
327
|
+
if (url.includes("get_customized_auth_url"))
|
|
328
|
+
return { errcode: 842002, errmsg: "template not on shelves" };
|
|
329
|
+
throw new Error(`stub fetch 未预期:${url}`);
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
const cfg = readWecomConfig();
|
|
333
|
+
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
334
|
+
await expect(createInstallForTest(cfg, client, "st_1")).rejects.toMatchObject({
|
|
335
|
+
status: 502,
|
|
336
|
+
message: expect.stringContaining("代开发模板尚未上架/上线"),
|
|
337
|
+
});
|
|
338
|
+
});
|
|
318
339
|
});
|
|
319
340
|
|
|
320
341
|
describe("P3-D:端点接线(/auth/wecom/admin/*)", () => {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// 回归:企微 API 客户端在「服务端 isolate」下的超时与崩溃防护。
|
|
2
|
+
//
|
|
3
|
+
// 真实 oj 服务端 isolate 没有 `AbortSignal` 全局,也没有 `setTimeout`
|
|
4
|
+
// (oj-api-dev global.d.ts:「本运行时无 timer 全局,setTimeout 不可用」)。
|
|
5
|
+
// 旧实现 `fetch(url, { signal: AbortSignal.timeout(ms) })` 会直接 ReferenceError
|
|
6
|
+
// → 所有企微调用(换 token / 生成开通链接 / 登录)一律 502。
|
|
7
|
+
// 这里在测试里**模拟**该 isolate(删掉 AbortSignal、tasks 视情况),断言:
|
|
8
|
+
// 1. 无 AbortSignal 时调用照常工作(不抛 `AbortSignal is not defined`);
|
|
9
|
+
// 2. 不再把 `signal` 传给 fetch(旧 bug 的根);
|
|
10
|
+
// 3. tasks.sleep 存在 → 慢请求被软超时拦截并抛「超时」文案;
|
|
11
|
+
// 4. tasks 缺失 → 安全降级为「不超时」,快速请求正常返回。
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
import { runtimeHttp } from "../src/auth/api/_shared/wecom/client";
|
|
15
|
+
|
|
16
|
+
const g = globalThis as any;
|
|
17
|
+
|
|
18
|
+
let savedAbortSignal: unknown;
|
|
19
|
+
let savedTasks: unknown;
|
|
20
|
+
let savedFetch: unknown;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
savedAbortSignal = (globalThis as { AbortSignal?: unknown }).AbortSignal;
|
|
24
|
+
savedTasks = g.tasks;
|
|
25
|
+
savedFetch = g.fetch;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
Object.defineProperty(globalThis, "AbortSignal", { value: savedAbortSignal, configurable: true, writable: true });
|
|
30
|
+
g.tasks = savedTasks;
|
|
31
|
+
g.fetch = savedFetch;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("wecom client:无 AbortSignal 的 isolate 不崩(回归)", () => {
|
|
35
|
+
it("abortSignal 被删掉时,getJson/postJson 仍能调用企微接口", async () => {
|
|
36
|
+
// 模拟服务端 isolate:把 AbortSignal 强制为 undefined(兼覆盖「整类不存在」与「.timeout 缺失」两种形态)
|
|
37
|
+
Object.defineProperty(globalThis, "AbortSignal", { value: undefined, configurable: true, writable: true });
|
|
38
|
+
g.tasks = undefined;
|
|
39
|
+
|
|
40
|
+
const fetchMock = vi.fn(async () => ({
|
|
41
|
+
ok: true,
|
|
42
|
+
json: async () => ({ errcode: 0, suite_access_token: "TOK", expires_in: 7200 }),
|
|
43
|
+
}));
|
|
44
|
+
g.fetch = fetchMock;
|
|
45
|
+
|
|
46
|
+
const http = runtimeHttp();
|
|
47
|
+
const data = await http.postJson("https://qyapi.weixin.qq.com/cgi-bin/service/get_suite_token", { a: 1 });
|
|
48
|
+
expect(data.errcode).toBe(0);
|
|
49
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("不再把 signal 传给 fetch(旧实现传 AbortSignal.timeout 的结果)", async () => {
|
|
53
|
+
Object.defineProperty(globalThis, "AbortSignal", { value: undefined, configurable: true, writable: true });
|
|
54
|
+
g.tasks = undefined;
|
|
55
|
+
|
|
56
|
+
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ errcode: 0 }) }));
|
|
57
|
+
g.fetch = fetchMock;
|
|
58
|
+
|
|
59
|
+
await runtimeHttp().getJson("https://qyapi.weixin.qq.com/x");
|
|
60
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
61
|
+
expect(init.signal).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("abortSignal.timeout 绝不被调用(防止旧 bug 回潮)", async () => {
|
|
65
|
+
const spy = vi.spyOn(globalThis.AbortSignal as { timeout: (ms: number) => unknown }, "timeout");
|
|
66
|
+
g.tasks = undefined;
|
|
67
|
+
g.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ errcode: 0 }) }));
|
|
68
|
+
|
|
69
|
+
await runtimeHttp().getJson("https://qyapi.weixin.qq.com/x");
|
|
70
|
+
expect(spy).not.toHaveBeenCalled();
|
|
71
|
+
spy.mockRestore();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("wecom client:软超时(tasks.sleep)", () => {
|
|
76
|
+
it("tasks.sleep 存在时,慢请求被软超时拦截并抛出「超时」文案", async () => {
|
|
77
|
+
// 用立即 resolve 的 sleep 模拟(不真的等 TIMEOUT_MS,测试秒级完成)
|
|
78
|
+
g.tasks = { sleep: () => Promise.resolve() };
|
|
79
|
+
let resolveFetch: (v: unknown) => void = () => {};
|
|
80
|
+
const fetchMock = vi.fn(() => new Promise<unknown>((resolve) => {
|
|
81
|
+
resolveFetch = resolve;
|
|
82
|
+
}));
|
|
83
|
+
g.fetch = fetchMock;
|
|
84
|
+
|
|
85
|
+
const p = runtimeHttp().getJson("https://qyapi.weixin.qq.com/x");
|
|
86
|
+
await expect(p).rejects.toThrow(/超时/);
|
|
87
|
+
// 收尾挂起的 fetch,避免 open handle
|
|
88
|
+
resolveFetch({ ok: true, json: async () => ({ errcode: 0 }) });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("tasks 缺失时降级为不超时:快速请求正常返回", async () => {
|
|
92
|
+
g.tasks = undefined;
|
|
93
|
+
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ errcode: 0 }) }));
|
|
94
|
+
g.fetch = fetchMock;
|
|
95
|
+
|
|
96
|
+
const data = await runtimeHttp().getJson("https://qyapi.weixin.qq.com/x");
|
|
97
|
+
expect(data.errcode).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -19,8 +19,8 @@ import { createWecomClient, runtimeCache } from "../src/auth/api/_shared/wecom/c
|
|
|
19
19
|
import {
|
|
20
20
|
assertEncKey,
|
|
21
21
|
eventUrlOf,
|
|
22
|
-
installUrlOf,
|
|
23
22
|
loginRedirectOf,
|
|
23
|
+
readCallbackConfig,
|
|
24
24
|
readWecomConfig,
|
|
25
25
|
siteRootOf,
|
|
26
26
|
} from "../src/auth/api/_shared/wecom/config";
|
|
@@ -620,7 +620,36 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
|
|
|
620
620
|
|
|
621
621
|
it("缺项一次报全(不是报第一个就返回,省掉逐轮补配置的往返)", () => {
|
|
622
622
|
installVars({ wecom_suite_id: "", wecom_callback_token: "", wecom_enc_key: "" });
|
|
623
|
-
|
|
623
|
+
const msg = (() => {
|
|
624
|
+
try {
|
|
625
|
+
readWecomConfig();
|
|
626
|
+
return "";
|
|
627
|
+
}
|
|
628
|
+
catch (e) {
|
|
629
|
+
return (e as Error).message;
|
|
630
|
+
}
|
|
631
|
+
})();
|
|
632
|
+
for (const name of ["wecom_suite_id", "wecom_callback_token", "wecom_enc_key"])
|
|
633
|
+
expect(msg).toMatch(new RegExp(name));
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it("readCallbackConfig 只校验 Tier A(token/aeskey/encKey/baseUrl),不依赖 suite 凭据——引导期可独立运行", () => {
|
|
637
|
+
// 即使 suite 两项全空,只要 Tier A 齐,readCallbackConfig 就不抛(供 echostr 验证用)
|
|
638
|
+
installVars({ wecom_suite_id: "", wecom_suite_secret: "" });
|
|
639
|
+
expect(() => readCallbackConfig()).not.toThrow();
|
|
640
|
+
// Tier A 缺项仍一次报全
|
|
641
|
+
installVars({ wecom_suite_id: "", wecom_suite_secret: "", wecom_callback_token: "", wecom_enc_key: "" });
|
|
642
|
+
const msg = (() => {
|
|
643
|
+
try {
|
|
644
|
+
readCallbackConfig();
|
|
645
|
+
return "";
|
|
646
|
+
}
|
|
647
|
+
catch (e) {
|
|
648
|
+
return (e as Error).message;
|
|
649
|
+
}
|
|
650
|
+
})();
|
|
651
|
+
for (const name of ["wecom_callback_token", "wecom_enc_key"])
|
|
652
|
+
expect(msg).toMatch(new RegExp(name));
|
|
624
653
|
});
|
|
625
654
|
|
|
626
655
|
it("baseUrl 去尾斜杠后拼出两个企微侧地址(回调 URL 要原样填进后台)", () => {
|
|
@@ -638,8 +667,6 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
|
|
|
638
667
|
expect(siteRootOf(cfg)).toBe("https://site.example.com/oj");
|
|
639
668
|
expect(loginLandingUrl({ origin: siteRootOf(cfg), ticket: "tk", tenant: "ww_corp_a" }))
|
|
640
669
|
.toBe("https://site.example.com/oj/login?wecom_ticket=tk&tenant=ww_corp_a");
|
|
641
|
-
// 开通入口的 redirect_uri 同样要带子路径
|
|
642
|
-
expect(installUrlOf(cfg, "PAC1", "env=prod")).toContain(`redirect_uri=${encodeURIComponent("https://site.example.com/oj/login?wecom_installed=1")}`);
|
|
643
670
|
});
|
|
644
671
|
|
|
645
672
|
it("baseUrl 后缀不对 → 400(宁可拒,也不猜站点根拼出错误回跳)", () => {
|
|
@@ -384,6 +384,17 @@ describe("P1-L:GET/POST /auth/wecom/event(企微指令回调)", () => {
|
|
|
384
384
|
expect(out.body).toBeNull();
|
|
385
385
|
});
|
|
386
386
|
|
|
387
|
+
it("URL 验证(鸡生蛋解法):suite_id 尚未下发时也能通过——receiveid 放宽,签名仍是唯一闸门", async () => {
|
|
388
|
+
// 引导期:模版还没建、suite_id 为空,但 Tier A 凭据(token/aeskey)已配齐,服务应能响应验证。
|
|
389
|
+
installVars({ wecom_suite_id: "" });
|
|
390
|
+
const echo = await seal("echo_bootstrap");
|
|
391
|
+
request({ query: { ...signedQuery(echo), echostr: echo } });
|
|
392
|
+
await (await api()).event.get();
|
|
393
|
+
expect(out.status).toBe(200);
|
|
394
|
+
expect(out.contentType).toContain("text/plain");
|
|
395
|
+
expect(out.text).toBe("echo_bootstrap");
|
|
396
|
+
});
|
|
397
|
+
|
|
387
398
|
it("签名不符 → 400(不给探测者任何比对材料)", async () => {
|
|
388
399
|
const echo = await seal("echo_12345");
|
|
389
400
|
request({ query: { timestamp: TS, nonce: NONCE, msg_signature: "bad", echostr: echo } });
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
"react-router": "7.18.2",
|
|
9
9
|
"react-router/dom": "7.18.2",
|
|
10
10
|
"@tanstack/react-query": "5.102.8",
|
|
11
|
-
"@oj-module/runtime": "0.2.
|
|
12
|
-
"@oj-module/runtime/contract": "0.2.
|
|
13
|
-
"@oj-module/runtime/contract/errors": "0.2.
|
|
11
|
+
"@oj-module/runtime": "0.2.47",
|
|
12
|
+
"@oj-module/runtime/contract": "0.2.47",
|
|
13
|
+
"@oj-module/runtime/contract/errors": "0.2.47",
|
|
14
14
|
"@plane/kit": "0.1.20",
|
|
15
15
|
"@plane/editor": "0.1.20",
|
|
16
16
|
"antd": "6.6.1",
|