@oj-module/cli 0.2.45 → 0.2.46

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.
@@ -52,16 +52,28 @@
52
52
  ### 2.1 六项部署常量(全部填在工程根 `config.yaml` 的 `vars:` 段)
53
53
 
54
54
  一个部署 = 一套代开发模板,所以这六项是**部署维度**的常量,不进数据库。
55
- **六项任一为空 → 企微登录整体 400 拒绝服务**(宁可不登录,也不明文存 `permanent_code`)。
56
55
 
57
- | # | 变量 | 从哪取 | 格式 / 校验 | 配错或缺失的后果 |
58
- |---|---|---|---|---|
59
- | 1 | `wecom_suite_id` | 企微服务商后台 → 应用管理 → 代开发应用 → 模板详情,即 **SuiteID**(形如 `ww…` / `wwb…`) | 非空字符串 | 400 `企业微信未配置:config vars 缺少 wecom_suite_id`;回调事件还会被判 `SuiteId 与本部署配置不一致` |
60
- | 2 | `wecom_suite_secret` | 同页 **SuiteSecret**(可重置) | 非空字符串;重置后旧值立即失效 | 换不出 `suite_access_token` → 502 `企微 get_suite_token 失败:errcode=40001 …`;开通链接生成也失败 |
61
- | 3 | `wecom_callback_token` | 同页 **Token**(字母数字,3–32 位) | 与企微后台**逐字一致** | 保存回调 URL 时 `echostr` 解不解得开;事件被判验签失败,企微持续重推 |
62
- | 4 | `wecom_encoding_aes_key` | 同页 **EncodingAESKey** | **43 字符** | 同上:回调报文解不开,`suite_ticket` 永远收不到 |
63
- | 5 | `wecom_enc_key` | **自己生成**,见 §2.4 | hex 32 字符(16 字节)或 64 字符(32 字节),或 base64 解码后 16 / 32 字节。**AES-192(24 字节)不支持** | 400 `企业微信配置错误:wecom_enc_key 必须是 16 或 32 字节的 hex(32/64 字符)或 base64` |
64
- | 6 | `wecom_base_url` | 你站点的对外地址 + `/api/auth/wecom` | **绝对地址、无尾斜杠、必须以 `/api/auth/wecom` 结尾** | 400 `wecom_base_url 必须是绝对地址` 或 `必须以 /api/auth/wecom 结尾`;配错还会让登录回跳落到别的应用或 404 |
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
+
65
+ Tier A 任一为空 → 服务启动即 400 拒绝(宁可不登录,也不明文存 `permanent_code`)。
66
+ Tier B 为空时服务仍可启动并响应 echostr,但 suite 流程会 400——先配 Tier A 把回调验证跑通、
67
+ 创建模版、拿到 suite 凭据,再补全 Tier B 即可,无需鸡生蛋。
68
+
69
+ | # | 变量 | 层级 | 从哪取 | 格式 / 校验 | 配错或缺失的后果 |
70
+ |---|---|---|---|---|---|
71
+ | 1 | `wecom_suite_id` | Tier B | 企微服务商后台 → 应用管理 → 代开发应用 → 模板详情(**创建成功后**才有),即 **SuiteID**(形如 `ww…` / `wwb…`) | 非空字符串 | Tier A 配齐时服务可启动并验证回调;此项空 → login/callback/create_auth 等 suite 流程 400 `config vars 缺少 wecom_suite_id`;`event.post` 还会判 `SuiteId 与本部署配置不一致` |
72
+ | 2 | `wecom_suite_secret` | Tier B | 同页 **SuiteSecret**(可重置) | 非空字符串;重置后旧值立即失效 | 换不出 `suite_access_token` → 502 `企微 get_suite_token 失败:errcode=40001 …`;开通链接生成也失败 |
73
+ | 3 | `wecom_callback_token` | Tier A | 同页 **Token**(自设,字母数字 3–32 位) | 与企微后台**逐字一致** | 保存回调 URL 时 `echostr` 解不解得开;事件被判验签失败,企微持续重推 |
74
+ | 4 | `wecom_encoding_aes_key` | Tier A | 同页 **EncodingAESKey**(自设) | **43 字符** | 同上:回调报文解不开,`suite_ticket` 永远收不到 |
75
+ | 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` |
76
+ | 6 | `wecom_base_url` | Tier A | 你站点的对外地址 + `/api/auth/wecom` | **绝对地址、无尾斜杠、必须以 `/api/auth/wecom` 结尾** | 400 `wecom_base_url 必须是绝对地址` 或 `必须以 /api/auth/wecom 结尾`;配错还会让登录回跳落到别的应用或 404 |
65
77
 
66
78
  `wecom_base_url` 是唯一「配一处、派生两处」的值——它同时决定:
67
79
 
@@ -119,20 +131,34 @@ openssl rand -hex 32 # 输出 64 个 hex 字符,直接贴进 wecom_enc_
119
131
 
120
132
  ## 4. 首次开通:顺序错了一定卡在 503
121
133
 
122
- 这七步有硬依赖,**不能跳序**:
134
+ 开通分两个阶段,两阶段之间由「代开发模版创建成功、拿到 suite 凭据」衔接——这正是
135
+ 破解鸡生蛋死锁的关键(详见 plan §18):**回调验证只需 Tier A,不必等 suite**。
136
+
137
+ ### 阶段一:配 Tier A + 跑通回调验证(此时还没有 suite)
138
+
139
+ | 步 | 动作 | 为什么 | 错序的症状 |
140
+ |---|---|---|---|
141
+ | 1 | 配好 §2.1 的 **Tier A 四项**(`callback_token` / `encoding_aes_key` / `enc_key` / `base_url`),重启服务 | 这四项本地生成,服务凭它们即可启动并响应 echostr | 400 `企业微信未配置:config vars 缺少 …` |
142
+ | 2 | 站点对外 https 可达;把指令回调 URL `<base>/event` 填进企微后台并保存 | 保存动作本身就是第一次验签(GET `echostr`)——而 echostr 只依赖 Tier A | 保存失败:`Token` / `EncodingAESKey` 对不上 |
143
+
144
+ > 阶段一**不需要** `suite_id` / `suite_secret`:企微在「模版创建成功」后才下发它们,而 echostr
145
+ > 验证在创建期就能跑通(receiveid 校验在 suite 未下发时自动放宽,签名仍是唯一鉴权闸门)。
146
+
147
+ ### 阶段二:拿 suite 凭据,再走 suite 流程
123
148
 
124
- | 步 | 动作 | 为什么必须在这一步 | 跳过的症状 |
149
+ | 步 | 动作 | 为什么必须在这一步 | 错序的症状 |
125
150
  |---|---|---|---|
126
- | 1 | 填好 §2.1 六项,重启服务 | 后面每一步都要读这些值 | 400 `企业微信未配置:config vars 缺少 …` |
127
- | 2 | 站点对外可访问(https),把指令回调 URL 填进企微后台并保存 | 保存动作本身就是第一次验签(GET `echostr`) | 保存失败,`Token`/`EncodingAESKey` 对不上 |
128
- | 3 | **等 `suite_ticket` 到手**(企微每 10 分钟推一次) | 换 `suite_access_token` 必须有票据,而票据只能由回调推来 | 503 `企微套件票据(suite_ticket)尚未收到:回调 URL 未打通或企微尚未推送` |
129
- | 4 | 用超级管理员登录,进 `/system/wecom` → 「生成开通链接」 | 开通链接要先换 `pre_auth_code`,而它依赖第 3 步 | 同上 503;或链接生成失败 |
130
- | 5 | 把链接/二维码发给企业管理员,由其确认授权 | 只有企业管理员能授权;确认后企微推 `create_auth` | 企业侧没有应用;本站无该企业记录 |
131
- | 6 | 进 `/system/settings` 打开「启用企业微信登录」 | `wecom.enabled` 默认 **关**,只是「发现登录方式」的开关 | 登录页根本看不到「企业微信」页签 |
132
- | 7 | 企业员工从企微工作台/扫码/专属链接进入 | 首次登录自动建档 | 401 `登录凭据无效或不在应用可见范围内`(多半是可见范围没配) |
133
-
134
- > **为什么第 3 步容易漏**:`suite_ticket` 是被推送的,不是拉取的。第一次部署时如果回调
151
+ | 3 | 模版创建成功后,从模版详情拿 **SuiteID / SuiteSecret**,填入 §2.1 的 **Tier B**,重启服务 | 否则 `suite_ticket` 推来时 `event.post` 因缺 suite 凭据 400,票据永远收不到 | 日志看不到 `wecom suite_ticket updated`;`/system/wecom` 生成链接报 503 |
152
+ | 4 | **等 `suite_ticket` 到手**(企微每 10 分钟推一次) | 换 `suite_access_token` 必须有票据,而票据只能由回调推来 | 503 `suite_ticket 尚未收到:回调 URL 未打通或企微尚未推送` |
153
+ | 5 | 用超级管理员登录,进 `/system/wecom` → 「生成开通链接」 | 开通链接要先换 `pre_auth_code`,而它依赖第 4 步 | 同上 503;或链接生成失败 |
154
+ | 6 | 把链接/二维码发给企业管理员,由其确认授权 | 只有企业管理员能授权;确认后企微推 `create_auth` | 企业侧没有应用;本站无该企业记录 |
155
+ | 7 | 进 `/system/settings` 打开「启用企业微信登录」 | `wecom.enabled` 默认 **关**,只是「发现登录方式」的开关 | 登录页根本看不到「企业微信」页签 |
156
+ | 8 | 企业员工从企微工作台/扫码/专属链接进入 | 首次登录自动建档 | 401 `登录凭据无效或不在应用可见范围内`(多半是可见范围没配) |
157
+
158
+ > **为什么第 4 步容易漏**:`suite_ticket` 是被推送的,不是拉取的。第一次部署时如果回调
135
159
  > URL 保存得晚,就要等下一个 10 分钟周期。看到 503 不必怀疑代码——先确认回调真的通。
160
+ > **阶段二衔接的新坑**:若第 3 步的 suite 凭据还没填、企微就推来了 `suite_ticket`,那几条会被
161
+ > 400 拒掉(属预期),填好 Tier B 重启后下一个推送周期会自动补上,不必手动回放。
136
162
 
137
163
  ---
138
164
 
@@ -295,7 +321,9 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
295
321
 
296
322
  ### 7.1 上线前 checklist
297
323
 
298
- - [ ] 服务商账号已认证,代开发应用模板已创建(拿到 SuiteID / SuiteSecret / Token / EncodingAESKey)
324
+ - [ ] 服务商账号已认证,代开发应用模板已创建
325
+ - [ ] Tier A 四项已生成并填好(`wecom_callback_token` / `wecom_encoding_aes_key` 在企微后台同页自设;`wecom_enc_key` 见 §2.4 本地生成;`wecom_base_url` 为站点对外地址)
326
+ - [ ] 模版创建成功后拿到 **SuiteID / SuiteSecret**(Tier B),已填入配置并重启(详见 §4 阶段二)
299
327
  - [ ] 站点已用 **https** 对外,反向代理放行 `POST /api/auth/wecom/event`(含原始 XML 请求体,不要改写)
300
328
  - [ ] 可信域名已备案(含校验文件)
301
329
  - [ ] 可信 IP 已列全所有出口 IP
@@ -306,16 +334,17 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
306
334
 
307
335
  ### 7.2 上线顺序
308
336
 
309
- 照 §4 的七步走。每步的验收点:
337
+ 照 §4 的两阶段八步走。每步的验收点:
310
338
 
311
339
  | 步 | 验收 |
312
340
  |---|---|
313
- | 1–2 | 企微后台保存回调 URL 成功(说明验签与解密都对) |
314
- | 3 | 服务日志出现 `wecom suite_ticket updated` |
315
- | 4 | `/system/wecom` 能出链接,且显示的回调地址与企微后台一致 |
316
- | 5 | 列表出现该企业;日志出现 `wecom auth applied … agents=…` |
317
- | 6 | 登录页出现「企业微信」页签 |
318
- | 7 | 用一个**普通员工**账号登录成功(不要只用管理员自己的号) |
341
+ | 1–2(阶段一) | 企微后台保存回调 URL 成功(说明 Tier A 的验签与解密都对——此时还没有 suite 凭据也照常通) |
342
+ | 3(阶段二) | 填好 Tier B 并重启成功 |
343
+ | 4 | 服务日志出现 `wecom suite_ticket updated` |
344
+ | 5 | `/system/wecom` 能出链接,且显示的回调地址与企微后台一致 |
345
+ | 6 | 列表出现该企业;日志出现 `wecom auth applied … agents=…` |
346
+ | 7 | 登录页出现「企业微信」页签 |
347
+ | 8 | 用一个**普通员工**账号登录成功(不要只用管理员自己的号) |
319
348
 
320
349
  ### 7.3 启停与解绑
321
350
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/cli",
3
3
  "type": "module",
4
- "version": "0.2.45",
4
+ "version": "0.2.46",
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.45",
52
+ "@oj-module/runtime": "0.2.46",
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.45",
307
+ "version": "0.2.46",
308
308
  "license": "MIT"
309
309
  },
310
- "lastBuildTime": "2026-10-31 01:51:27"
310
+ "lastBuildTime": "2026-10-31 09:51:01"
311
311
  };
312
312
  }
313
313
  var init_get_app_info = __esmMin((() => {}));
@@ -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-IiFAOepBufxbhXdsYRrEMA=='; 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" />
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-OppgUj8nzdkICPflvWyj9w=='; 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="IiFAOepBufxbhXdsYRrEMA==">{
10
+ <script type="importmap" nonce="OppgUj8nzdkICPflvWyj9w==">{
11
11
  "imports": {
12
12
  "react": "/assets/react.js",
13
13
  "react/jsx-runtime": "/assets/jsx-runtime.js",
@@ -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.45",
11
- "@oj-module/runtime/contract": "0.2.45",
12
- "@oj-module/runtime/contract/errors": "0.2.45",
10
+ "@oj-module/runtime": "0.2.46",
11
+ "@oj-module/runtime/contract": "0.2.46",
12
+ "@oj-module/runtime/contract/errors": "0.2.46",
13
13
  "@plane/kit": "0.1.20",
14
14
  "@plane/editor": "0.1.20",
15
15
  "antd": "6.6.1",
@@ -10,10 +10,13 @@
10
10
 
11
11
  import { b64ToBytes } from "./crypt";
12
12
 
13
- export interface WecomConfig {
14
- /** 代开发应用模版 id(企微侧即 suite_id),也是回调明文里的 receiveid */
15
- suiteId: string
16
- suiteSecret: string
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,43 @@ export interface WecomConfig {
29
32
  baseUrl: string
30
33
  }
31
34
 
32
- const REQUIRED: Array<[keyof WecomConfig, string]> = [
33
- ["suiteId", "wecom_suite_id"],
34
- ["suiteSecret", "wecom_suite_secret"],
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
+
46
+ // Tier A:部署方本地生成,创建模版前即可配齐(token / EncodingAESKey / 落库密钥 / 基址)。
47
+ const REQUIRED_CALLBACK: Array<[keyof WecomCallbackConfig, string]> = [
35
48
  ["token", "wecom_callback_token"],
36
49
  ["encodingAesKey", "wecom_encoding_aes_key"],
37
50
  ["encKey", "wecom_enc_key"],
38
51
  ["baseUrl", "wecom_base_url"],
39
52
  ];
40
53
 
54
+ // Tier B:suite 凭据,企业微信在「代开发模版创建成功」后下发(模版详情页可查)。
55
+ const REQUIRED_SUITE: Array<[keyof WecomConfig, string]> = [
56
+ ["suiteId", "wecom_suite_id"],
57
+ ["suiteSecret", "wecom_suite_secret"],
58
+ ];
59
+
60
+ /** 一次性读出六处部署常量(未 trim 的尾斜杠在此归一) */
61
+ function readWecomVars(): WecomConfig {
62
+ return {
63
+ token: vars.get("wecom_callback_token") ?? "",
64
+ encodingAesKey: vars.get("wecom_encoding_aes_key") ?? "",
65
+ encKey: vars.get("wecom_enc_key") ?? "",
66
+ baseUrl: (vars.get("wecom_base_url") ?? "").replace(/\/+$/, ""),
67
+ suiteId: vars.get("wecom_suite_id") ?? "",
68
+ suiteSecret: vars.get("wecom_suite_secret") ?? "",
69
+ };
70
+ }
71
+
41
72
  /**
42
73
  * 落库密钥格式校验(fail-fast 在**读配置**处,而不是等 create_auth 时在 crypto 里炸):
43
74
  * oj 的 aesGcmEncrypt 只收「16 或 32 字节的 hex/base64」,AES-192(24 字节)明确不支持。
@@ -60,26 +91,38 @@ export function assertEncKey(encKey: string): void {
60
91
  }
61
92
 
62
93
  /**
63
- * 读部署常量。缺任一必需项即 400(「未配置」是运维事实,不是服务端缺陷):
64
- * 前半段(suite 凭据)缺失 → 企微登录整体不可用;encKey 缺失则 permanent_code 无法安全落库,
65
- * 宁可拒服务,也不退化成明文存储(§7.5 数据合规红线)。
94
+ * 读 Tier A 凭据(token / aeskey / encKey / baseUrl)。**不依赖 suite 凭据**:这是 echostr
95
+ * URL 验证与永久码落库加密所需的全部东西,部署方本地即可生成。引导期(suite_id 尚未下发)
96
+ * 服务也能靠它启动并响应企微的回调验证(plan §18)。
66
97
  */
67
- export function readWecomConfig(): WecomConfig {
68
- const cfg: WecomConfig = {
69
- suiteId: vars.get("wecom_suite_id") ?? "",
70
- suiteSecret: vars.get("wecom_suite_secret") ?? "",
71
- token: vars.get("wecom_callback_token") ?? "",
72
- encodingAesKey: vars.get("wecom_encoding_aes_key") ?? "",
73
- encKey: vars.get("wecom_enc_key") ?? "",
74
- baseUrl: (vars.get("wecom_base_url") ?? "").replace(/\/+$/, ""),
75
- };
76
- const missing = REQUIRED.filter(([field]) => !cfg[field]).map(([, name]) => name);
98
+ export function readCallbackConfig(): WecomCallbackConfig {
99
+ const v = readWecomVars();
100
+ const missing = REQUIRED_CALLBACK.filter(([field]) => !v[field]).map(([, name]) => name);
77
101
  if (missing.length)
78
102
  throw Object.assign(new Error(`企业微信未配置:config vars 缺少 ${missing.join(" / ")}`), { status: 400 });
79
- if (!/^https?:\/\//.test(cfg.baseUrl))
103
+ if (!/^https?:\/\//.test(v.baseUrl))
104
+ throw Object.assign(new Error("企业微信配置错误:wecom_base_url 必须是绝对地址(企微回调与 redirect_uri 都要求)"), { status: 400 });
105
+ assertEncKey(v.encKey);
106
+ return { token: v.token, encodingAesKey: v.encodingAesKey, encKey: v.encKey, baseUrl: v.baseUrl };
107
+ }
108
+
109
+ /**
110
+ * 读完整部署常量。缺任一必需项即 400(「未配置」是运维事实,不是服务端缺陷)。
111
+ * 一次报全 Tier A + Tier B 所有缺失项,省掉逐轮补配置的往返;suite 凭据(Tier B)缺失时
112
+ * 文案点明「由代开发模版创建成功后下发」,避免运维误以为是本地漏配。
113
+ *
114
+ * 注:Tier B 缺失只影响 login/callback/create_auth 等 suite 流程;echostr 验证走
115
+ * `readCallbackConfig()`,不受此约束(见 api.ts::event.get)。
116
+ */
117
+ export function readWecomConfig(): WecomConfig {
118
+ const v = readWecomVars();
119
+ const missing = [...REQUIRED_CALLBACK, ...REQUIRED_SUITE].filter(([field]) => !v[field]).map(([, name]) => name);
120
+ if (missing.length)
121
+ throw Object.assign(new Error(`企业微信未配置:config vars 缺少 ${missing.join(" / ")}(suite 凭据由代开发模版创建成功后下发)`), { status: 400 });
122
+ if (!/^https?:\/\//.test(v.baseUrl))
80
123
  throw Object.assign(new Error("企业微信配置错误:wecom_base_url 必须是绝对地址(企微回调与 redirect_uri 都要求)"), { status: 400 });
81
- assertEncKey(cfg.encKey);
82
- return cfg;
124
+ assertEncKey(v.encKey);
125
+ return v;
83
126
  }
84
127
 
85
128
  /** 企微后台「指令回调 URL」:原样填这个(base + /event) */
@@ -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
- if (receiver !== expectedReceiver)
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
  }
@@ -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 { plain } = await decryptVerified(query("echostr"));
199
- plainText(plain);
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);
@@ -21,6 +21,7 @@ import {
21
21
  eventUrlOf,
22
22
  installUrlOf,
23
23
  loginRedirectOf,
24
+ readCallbackConfig,
24
25
  readWecomConfig,
25
26
  siteRootOf,
26
27
  } from "../src/auth/api/_shared/wecom/config";
@@ -620,7 +621,36 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
620
621
 
621
622
  it("缺项一次报全(不是报第一个就返回,省掉逐轮补配置的往返)", () => {
622
623
  installVars({ wecom_suite_id: "", wecom_callback_token: "", wecom_enc_key: "" });
623
- expect(() => readWecomConfig()).toThrow(/wecom_suite_id.*wecom_callback_token.*wecom_enc_key/);
624
+ const msg = (() => {
625
+ try {
626
+ readWecomConfig();
627
+ return "";
628
+ }
629
+ catch (e) {
630
+ return (e as Error).message;
631
+ }
632
+ })();
633
+ for (const name of ["wecom_suite_id", "wecom_callback_token", "wecom_enc_key"])
634
+ expect(msg).toMatch(new RegExp(name));
635
+ });
636
+
637
+ it("readCallbackConfig 只校验 Tier A(token/aeskey/encKey/baseUrl),不依赖 suite 凭据——引导期可独立运行", () => {
638
+ // 即使 suite 两项全空,只要 Tier A 齐,readCallbackConfig 就不抛(供 echostr 验证用)
639
+ installVars({ wecom_suite_id: "", wecom_suite_secret: "" });
640
+ expect(() => readCallbackConfig()).not.toThrow();
641
+ // Tier A 缺项仍一次报全
642
+ installVars({ wecom_suite_id: "", wecom_suite_secret: "", wecom_callback_token: "", wecom_enc_key: "" });
643
+ const msg = (() => {
644
+ try {
645
+ readCallbackConfig();
646
+ return "";
647
+ }
648
+ catch (e) {
649
+ return (e as Error).message;
650
+ }
651
+ })();
652
+ for (const name of ["wecom_callback_token", "wecom_enc_key"])
653
+ expect(msg).toMatch(new RegExp(name));
624
654
  });
625
655
 
626
656
  it("baseUrl 去尾斜杠后拼出两个企微侧地址(回调 URL 要原样填进后台)", () => {
@@ -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.45",
12
- "@oj-module/runtime/contract": "0.2.45",
13
- "@oj-module/runtime/contract/errors": "0.2.45",
11
+ "@oj-module/runtime": "0.2.46",
12
+ "@oj-module/runtime/contract": "0.2.46",
13
+ "@oj-module/runtime/contract/errors": "0.2.46",
14
14
  "@plane/kit": "0.1.20",
15
15
  "@plane/editor": "0.1.20",
16
16
  "antd": "6.6.1",