@oj-module/cli 0.2.43 → 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.
Files changed (96) hide show
  1. package/devkit/README.md +7 -5
  2. package/devkit/SKILL.md +22 -2
  3. package/devkit/manual.md +5 -0
  4. package/devkit/wecom-daikaifa.md +421 -0
  5. package/package.json +2 -2
  6. package/shell-dist/assets/runtime.js +102 -39
  7. package/shell-dist/index.html +2 -2
  8. package/shell-dist/versions.json +3 -3
  9. package/src/__tests__/manifest-routes-derive.test.ts +22 -0
  10. package/src/__tests__/site-tenant-header.test.ts +54 -0
  11. package/src/__tests__/templates-tenant-scope.test.ts +139 -0
  12. package/src/build.ts +9 -0
  13. package/src/init.ts +4 -1
  14. package/src/install.ts +2 -2
  15. package/src/market/bootstrap.ts +15 -0
  16. package/src/market/setup.ts +3 -3
  17. package/src/market/walkthrough.ts +35 -29
  18. package/src/site.ts +17 -1
  19. package/templates/config.yaml +72 -2
  20. package/templates/openapi.json +319 -0
  21. package/templates/src/_platform/api/_lifecycle/steps/download.ts +6 -2
  22. package/templates/src/_platform/api/_lifecycle/steps/smoke.ts +17 -9
  23. package/templates/src/_platform/api/_shared/respond.ts +11 -6
  24. package/templates/src/_platform/api/_shared/service/domain/settings.ts +5 -3
  25. package/templates/src/_platform/api/_shared/service/domain/users.ts +9 -3
  26. package/templates/src/_platform/api/_shared/service/infra/access.ts +59 -4
  27. package/templates/src/_platform/api/_shared/service/infra/query.ts +5 -1
  28. package/templates/src/_platform/api/contacts/api.ts +4 -1
  29. package/templates/src/_platform/api/contacts/detail/api.ts +3 -0
  30. package/templates/src/_platform/api/migrations/0015__tenant.mysql.sql +12 -0
  31. package/templates/src/_platform/api/migrations/0015__tenant.pg.sql +11 -0
  32. package/templates/src/_platform/api/migrations/0015__tenant.sql +44 -0
  33. package/templates/src/_platform/api/schema.yaml +28 -0
  34. package/templates/src/_platform/api/seed.sql +3 -0
  35. package/templates/src/auth/api/_shared/wecom/admin.ts +112 -0
  36. package/templates/src/auth/api/_shared/wecom/client.ts +243 -0
  37. package/templates/src/auth/api/_shared/wecom/config.ts +176 -0
  38. package/templates/src/auth/api/_shared/wecom/crypt.ts +317 -0
  39. package/templates/src/auth/api/_shared/wecom/events.ts +127 -0
  40. package/templates/src/auth/api/_shared/wecom/login.ts +137 -0
  41. package/templates/src/auth/api/_shared/wecom/replay.ts +66 -0
  42. package/templates/src/auth/api/_shared/wecom/store.ts +230 -0
  43. package/templates/src/auth/api/_shared/wecom/xml.ts +36 -0
  44. package/templates/src/auth/api/manifest.yaml +6 -0
  45. package/templates/src/auth/api/migrations/0001__wecom.mysql.sql +35 -0
  46. package/templates/src/auth/api/migrations/0001__wecom.pg.sql +33 -0
  47. package/templates/src/auth/api/migrations/0001__wecom.sql +47 -0
  48. package/templates/src/auth/api/schema.yaml +48 -0
  49. package/templates/src/auth/api/wecom/admin/api.ts +121 -0
  50. package/templates/src/auth/api/wecom/api.ts +246 -0
  51. package/templates/src/auth/web/__client/api.schemas.ts +36 -0
  52. package/templates/src/auth/web/__client/api.ts +148 -1
  53. package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +2 -0
  54. package/templates/src/demo/api/migrations/0002__tenant.sql +2 -0
  55. package/templates/src/demo/api/schema.yaml +1 -0
  56. package/templates/src/home/api/migrations/0002__tenant.mysql.sql +3 -0
  57. package/templates/src/home/api/migrations/0002__tenant.sql +3 -0
  58. package/templates/src/home/api/schema.yaml +2 -0
  59. package/templates/src/login/web/auth-provider.ts +48 -0
  60. package/templates/src/login/web/entry.ts +21 -1
  61. package/templates/src/login/web/locales/en-US.json +11 -1
  62. package/templates/src/login/web/locales/zh-CN.json +11 -1
  63. package/templates/src/login/web/pages/login.tsx +154 -25
  64. package/templates/src/login/web/wecom.ts +51 -0
  65. package/templates/src/notification/api/_shared/notify.ts +9 -5
  66. package/templates/src/notification/api/api.ts +25 -0
  67. package/templates/src/notification/api/del/api.ts +5 -0
  68. package/templates/src/notification/api/manifest.yaml +3 -0
  69. package/templates/src/notification/api/migrations/0005__tenant.mysql.sql +6 -0
  70. package/templates/src/notification/api/migrations/0005__tenant.sql +6 -0
  71. package/templates/src/notification/api/schema.yaml +4 -0
  72. package/templates/src/personal-center/api/profile/api.ts +13 -0
  73. package/templates/src/personal-center/api/upload/api.ts +8 -1
  74. package/templates/src/system/web/entry.ts +8 -0
  75. package/templates/src/system/web/locales/en-US.json +33 -2
  76. package/templates/src/system/web/locales/zh-CN.json +33 -2
  77. package/templates/src/system/web/pages/login-methods/index.tsx +2 -1
  78. package/templates/src/system/web/pages/users/index.tsx +1 -1
  79. package/templates/src/system/web/pages/wecom/index.tsx +205 -0
  80. package/templates/tests/fixture.ts +91 -11
  81. package/templates/tests/guard.spec.ts +9 -3
  82. package/templates/tests/lifecycle/deploy-smoke.spec.ts +8 -6
  83. package/templates/tests/lifecycle/installer.spec.ts +76 -0
  84. package/templates/tests/lifecycle/trust.spec.ts +1 -1
  85. package/templates/tests/query-utils.spec.ts +12 -9
  86. package/templates/tests/rbac-guard.spec.ts +18 -1
  87. package/templates/tests/tenant-access-cache.spec.ts +99 -0
  88. package/templates/tests/tenant-forgery.spec.ts +152 -0
  89. package/templates/tests/tenant-isolation.spec.ts +133 -0
  90. package/templates/tests/username-namespace.spec.ts +118 -0
  91. package/templates/tests/wecom-admin.spec.ts +386 -0
  92. package/templates/tests/wecom-connector.spec.ts +701 -0
  93. package/templates/tests/wecom-crypt.spec.ts +155 -0
  94. package/templates/tests/wecom-endpoints.spec.ts +468 -0
  95. package/templates/tests/wecom-landing.spec.ts +54 -0
  96. package/vendor/host-versions.json +3 -3
package/devkit/README.md CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  面向用 `ojm` 开发前后端一体工程的开发者与 AI agent。本目录是**源文件**,
4
4
  发布时由 cli 的 `prepack`(`packages/cli/scripts/sync-devkit.mjs`)把其中
5
- **ojm 侧的 6 个文件**同步进 `packages/cli/devkit/`,随 `@oj-module/cli`
5
+ **ojm 侧的 8 个文件**同步进 `packages/cli/devkit/`,随 `@oj-module/cli`
6
6
  发布,再由 `ojm init` 分发到工程内。
7
7
 
8
8
  整棵树覆盖前后端两侧,但仍是**两套独立分发的 skill**(同名不同物,别混):
9
9
 
10
10
  | 侧 | 本仓位置 | 用户工程里的落点 | 分发链 |
11
11
  |---|---|---|---|
12
- | ojm 模块开发(前端模块、契约、前后端对接) | 本目录顶层 6 个文件 | `.claude/skills/ojm-module-dev/` | `@oj-module/cli` 包 → `ojm init` 第 4b 步 |
12
+ | ojm 模块开发(前端模块、契约、前后端对接) | 本目录顶层 8 个文件 | `.claude/skills/ojm-module-dev/` | `@oj-module/cli` 包 → `ojm init` 第 4b 步 |
13
13
  | oj API 开发(后端 handler、全局对象、配置、测试) | `oj-api-dev/` 子目录(快照) | `.claude/skills/oj-api-dev/` | **oj 二进制自带**(`bin/devkit/`)→ `ojm init` 第 4 步 |
14
14
 
15
15
  > **`oj-api-dev/` 子目录是快照,不是本仓维护的文档**:它与本机 oj 的
@@ -28,6 +28,8 @@
28
28
  | `framework-dev.md` | 框架贡献者路线(**仅框架仓库**):改 `packages/runtime`、`packages/cli` 时的入口与红线 |
29
29
  | `module-dev-and-cdn-playground.md` | 教学文档:开发态 ≠ 分发态、「怎么安装自己的模块 / 会不会重复」、本地 CDN 市场链路五步跑通(setup → publish → build-index → verify → 安装) |
30
30
  | `sql-builder-missing-capabilities-pr.md` | 构造器能力缺口 PR 参考(**仅框架仓库**):7 项诉求的闭环记录 |
31
+ | `menu-rules.md` | 方案菜单归组规则专题:单根模块约定、自定义根路径 `web.route_prefix`、`category` 三值语义、admin 重写与视觉归组(手册 §13 的展开版) |
32
+ | `wecom-daikaifa.md` | 企业微信代开发集成:**凭据清单**(6 项 `vars` + 可信域名/可信 IP 备案)、首次开通顺序(含 `suite_ticket` 依赖)、**用户 / 开发 / 部署三角色手册**、错误文案速查 |
31
33
 
32
34
  后端 handler 本身的写法(`json` 信封、`db` 查询、`manifest.yaml`、`oj test`)、
33
35
  站点挂载(`mounts`)、密钥信封(`secrets`)、vars、cors、schema 校验、broker/tasks/oidc/ws 等
@@ -69,7 +71,7 @@
69
71
  ```sh
70
72
  # 本 devkit(ojm 模块开发):拷入工程的 Claude Code skill 目录
71
73
  mkdir -p .claude/skills/ojm-module-dev
72
- cp SKILL.md manual.md README.md .claude/skills/ojm-module-dev/
74
+ cp SKILL.md manual.md README.md wecom-daikaifa.md .claude/skills/ojm-module-dev/
73
75
 
74
76
  # 后端 API 开发另装 oj 自带的 devkit(独立 skill):
75
77
  # 权威源随 oj 二进制分发——本机安装在 ~/.local/share/oj/devkit/,
@@ -89,7 +91,7 @@ cp ~/.local/share/oj/devkit/SKILL.md ~/.local/share/oj/devkit/api-manual.md .cla
89
91
  `api-manual.md` + 有则拷的 `sql-builder-guide.md` / `oj-test-guide.md` +
90
92
  `global.d.ts` → `.claude/skills/oj-api-dev/`;
91
93
  - **第 4b 步**:从 cli 包内置 `devkit/`(prepack 由本目录顶层同步)拷
92
- `SKILL.md` / `manual.md` / `README.md` → `.claude/skills/ojm-module-dev/`。
94
+ `SKILL.md` / `manual.md` / `README.md` / `wecom-daikaifa.md` → `.claude/skills/ojm-module-dev/`。
93
95
 
94
96
  ```mermaid
95
97
  flowchart LR
@@ -108,6 +110,6 @@ flowchart LR
108
110
  框架贡献者要把本仓快照同步到新版:从 `~/.local/share/oj/devkit/` 整目录覆盖
109
111
  `docs/devkit/oj-api-dev/`(10 个文件,保持逐字节一致以便对账)。
110
112
 
111
- > 本目录顶层 6 个文件是**自包含**的:业务工程拿到即可读懂,不需要工程仓库里的
113
+ > 本目录顶层 8 个文件是**自包含**的:业务工程拿到即可读懂,不需要工程仓库里的
112
114
  > 其它文档。`oj-api-dev/` 子目录是 oj 侧快照,同样自包含(其自身 README 即索引),
113
115
  > 但不随 cli 包发货、仅本仓参考。维护者视角的内容来源整理在 `framework-dev.md`(仅本仓)。
package/devkit/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ojm-module-dev
3
- description: 用 ojm 开发前后端一体工程时使用——新增/修改前端模块(entry.ts、pages、locales)、写 oj handler 的 .schema 与业务逻辑、跑 oj openapi + ojm api 生成 client、注册 provider/布局/插槽、改 web.config.ts,或排查「模块没加载 / 注册不生效 / 请求未绑定 / 铃铛按钮灰」时。触发场景:建模块、加页面、加接口、接管通知或上传、换布局、ojm dev 起不来。
3
+ description: 用 ojm 开发前后端一体工程时使用——新增/修改前端模块(entry.ts、pages、locales)、写 oj handler 的 .schema 与业务逻辑、跑 oj openapi + ojm api 生成 client、注册 provider/布局/插槽、改 web.config.ts,或排查「模块没加载 / 注册不生效 / 请求未绑定 / 铃铛按钮灰」时。触发场景:建模块、加页面、加接口、接管通知或上传、换布局、ojm dev 起不来、接通企业微信代开发(企微登录、多 corp 租户隔离、代开发后台、suite_ticket 收不到)。
4
4
  ---
5
5
 
6
6
  # ojm 模块开发
@@ -16,7 +16,8 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
16
16
  ## 工作流
17
17
 
18
18
  1. **读章**:建模块 → 手册 §2;加接口(handler .schema → oj openapi → 生成 → 对接)→ §3;
19
- 接管通知/上传/菜单 → §4;换布局或加插槽 → §5;排查 → §8。
19
+ 接管通知/上传/菜单 → §4;换布局或加插槽 → §5;排查 → §8;
20
+ **接通企业微信代开发(企微登录 / 多企业租户 / 代开发后台)→ `wecom-daikaifa.md`**。
20
21
  2. **建目录**:一个目录 = 一个模块——前端一侧 `src/<模块名>/web/`(`entry.ts` 必备,
21
22
  `pages/`、`locales/` 按需),后端一侧 `src/<模块名>/api/`(目录镜像路由的 `api.ts`,
22
23
  动词挂 `.schema`)。两侧可只存在一侧。
@@ -93,6 +94,22 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
93
94
  | 改 `schema.yaml` / `migrations/` / `config.yaml` | **必须重启** |
94
95
  | 前端 `src/<模块>/web/**` | 模块重建 + 浏览器自动刷新 |
95
96
 
97
+ ## 企业微信代开发(接通真实套件)
98
+
99
+ 模板已内置企微登录(`src/auth/api/wecom/` + 登录页「企业微信」页签 + `/system/wecom` 代开发后台)。
100
+ 接真实套件的**凭据清单、开通顺序、用户/开发/部署三视角、错误文案速查**全在
101
+ `wecom-daikaifa.md`(同目录),这里只列最容易踩的四条:
102
+
103
+ - **六项 `vars` 任一为空 → 企微登录整体 400**(`wecom_suite_id` / `wecom_suite_secret` /
104
+ `wecom_callback_token` / `wecom_encoding_aes_key` / `wecom_enc_key` / `wecom_base_url`)。
105
+ `wecom_base_url` 必须以 `/api/auth/wecom` 结尾,它派生 `<base>/event`(指令回调 URL)与 `<base>/callback`。
106
+ - **`suite_ticket` 是被企微推来的**(每 10 分钟一次、30 分钟过期),所以「回调 URL 打通」必须
107
+ 早于「生成开通链接」,否则 503 且看起来像代码坏了。
108
+ - **新增企业不改配置、不重启**:企业管理员确认授权 → `create_auth` → `wecom_app_profile`。
109
+ 同企业测试+正式两个应用是两行(主键含 `agent_id`),此时登录链接必须带 `&agent=`。
110
+ - **整页跳转拼 API base**:`window.location.assign(resolveApiBase() + wecomAuthorizePath(corp))`;
111
+ 拼站点 base 会被 SPA 兜底页吞掉(页面原地重载、一个请求都不发,控制台无报错)。
112
+
96
113
  ## 新模块 checklist
97
114
 
98
115
  - [ ] `src/<模块>/web/entry.ts` 默认导出 `defineModule({ name, version, routes, i18n?, lifecycle? })`
@@ -163,3 +180,6 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
163
180
  3 契约与代码生成 / 4 接管框架内置功能(provider)/ 5 布局与插槽 / 6 i18n 与样式 / 7 构建与发布 /
164
181
  8 排查 / 9 参考 / 10 CLI 子命令参考(ojm 原生命令 vs oj 透传命令的差异区分)/
165
182
  11 方案包打包与分发 / 12 签名与信任 / 13 方案菜单归组规则。
183
+
184
+ 另有 `wecom-daikaifa.md`(同目录):企业微信代开发集成的凭据清单与三角色手册——凭据从哪取、
185
+ 怎么填、按什么顺序开通、用户/开发/部署各自怎么做、错误文案怎么查。**接企微时先读它**。
package/devkit/manual.md CHANGED
@@ -1558,6 +1558,8 @@ export default async (path: string, html: string) => {
1558
1558
  | dev 起不来,日志一堆 route error(Cannot find module) | handler 相对 import 多写了一层——按**目录**解析不是按文件(§1.7-2) | 按 §1.7 深度表改 |
1559
1559
  | 限速/节流/去重窗口在真实请求下永不累积(测试却全绿) | 模块级 `Map`/`let` 不跨请求存活——oj 每请求重建模块(§1.7-3) | 状态落库(模板 0009 先例)或走 oj 持久面 |
1560
1560
  | `ojm build` 报 `introspect api.ts: Error: jwt not configured`(dev 模式正常) | 模块级语句触碰运行时全局,introspect 空沙箱里炸(§1.7-4) | 移进 handler 函数体内按需执行 |
1561
+ | 点登录页「企业微信」后页面只闪一下、Network 里一个 auth 请求都没有 | 整页跳转拼成了**站点 base**(根部署下得到 `/auth/wecom/login`,被 SPA 兜底页吞掉) | 改用 `resolveApiBase()` 拼后端端点:`window.location.assign(resolveApiBase() + wecomAuthorizePath(corp))` |
1562
+ | 企微登录恒 400 `企业微信未配置:config vars 缺少 …` | 部署常量没填全(六项任一为空即整体拒服务) | 见 `wecom-daikaifa.md` §2.1 的凭据表与缺项后果 |
1561
1563
 
1562
1564
  ---
1563
1565
 
@@ -1585,6 +1587,9 @@ export default async (path: string, html: string) => {
1585
1587
  写 handler 遇到 `json` / `db` / `kv` / `jwt` / 租户 / 测试的问题查它;
1586
1588
  站点挂载(`mounts`)、密钥信封(`secrets`)、配置变量(`vars`)、跨域(`cors`)、
1587
1589
  schema 校验(`schema_validation`)、`broker` / `tasks` / `oidc` / `ws` 等 oj 侧能力也都在这套手册里。
1590
+ - **企业微信代开发对接**:本目录 `wecom-daikaifa.md`——六项部署常量从哪取/怎么填、
1591
+ 可信域名与可信 IP 备案、首次开通顺序(`suite_ticket` 依赖回调先通)、
1592
+ 用户 / 开发 / 部署三种角色各自的操作、按错误文案索引的故障速查。接企微时先读它。
1588
1593
  - **框架团队内部**:本目录 `framework-dev.md`(改 `packages/runtime`、`packages/cli` 用,
1589
1594
  业务工程不适用)。
1590
1595
 
@@ -0,0 +1,421 @@
1
+ # 企业微信代开发集成:凭据清单与三角色手册
2
+
3
+ 本文件覆盖「把模板工程接到真实企业微信代开发套件」这件事的全过程:**要准备哪些凭据、
4
+ 填到哪里、按什么顺序开通、三种角色各自怎么用**。三种角色指:企业员工(用户)、
5
+ 本工程开发者、部署与运营(服务商侧运维)。
6
+
7
+ 读完本文件即可独立完成对接,不需要其它文档。文中路径都是**你工程里的相对路径**。
8
+
9
+ ---
10
+
11
+ ## 0. 谁该读哪几节
12
+
13
+ | 你是 | 读 | 大约要做什么 |
14
+ |---|---|---|
15
+ | 部署 / 服务商运营 | §2 §3 §4 §7 §8 | 备凭据、填后台、按顺序开通、日常启停与轮换 |
16
+ | 本工程开发者 | §1 §6 §8 | 本地联调、加企业、改前端、写没有真实套件的自动化测试 |
17
+ | 企业员工(用户) | §5 | 怎么进、要不要注册、进不去怎么办 |
18
+
19
+ ---
20
+
21
+ ## 1. 这套东西在做什么
22
+
23
+ 一句话:**企业管理员在自己企业里「授权安装」一次,之后该企业员工就能用企微身份直接登录
24
+ 本站,不需要注册**。本站同时服务多个企业,靠租户列隔离数据。
25
+
26
+ 四条链路,看懂这张图就够排查九成问题:
27
+
28
+ ```text
29
+ 企微服务商后台(一套代开发模板 = 一个部署)
30
+ │
31
+ ├─ suite_ticket ── 每 10 分钟推一次,30 分钟不用即作废 ──▶ wecom_suite_ticket(共享表)
32
+ │
33
+ └─ 指令回调 URL ──▶ POST <wecom_base_url>/event
34
+ │
35
+ ├─ ① suite_ticket + suite_secret ─▶ suite_access_token(缓存,提前 5 分钟过期)
36
+ │ ├─ get_permanent_code ◀── auth_code(企业管理员确认授权时推 create_auth)
37
+ │ └─ pre_auth_code ─▶ 开通链接(10 分钟内有效、一次性)
38
+ │
39
+ └─ ② permanent_code + corp_id ─▶ 企业 access_token(缓存)
40
+ └─ ③ code(网页授权或扫码回跳)─▶ userid ─▶ ④ 自动建档 ─▶ 一次性票据 ─▶ 会话
41
+ ```
42
+
43
+ - `permanent_code` 等价于「该企业这个应用的全部数据权限」,**只以 AES-GCM 密文落库**
44
+ (列名 `permanent_code_enc`),任何接口都不回传明文。
45
+ - 「哪个企业装了哪个应用」**不写在配置文件里**:新增企业只是多一行数据,不改配置、不重启。
46
+ - JIT(Just-In-Time)建档:员工第一次登录时自动建本地账号,用户名 `wecom:<企微userid>`。
47
+
48
+ ---
49
+
50
+ ## 2. 凭据清单
51
+
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
+
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 |
77
+
78
+ `wecom_base_url` 是唯一「配一处、派生两处」的值——它同时决定:
79
+
80
+ - 指令回调 URL = `<wecom_base_url>/event`
81
+ - 网页授权 `redirect_uri` = `<wecom_base_url>/callback`
82
+ - 登录成功后的站点回跳 = 去掉 `/api/auth/wecom` 的那部分(子路径部署也正确,例如
83
+ `https://example.com/oj/api/auth/wecom` → 回跳 `https://example.com/oj/login`)
84
+
85
+ ### 2.2 两项备案(都在企微后台,不写在代码里)
86
+
87
+ | 备案项 | 填什么 | 不做的症状 |
88
+ |---|---|---|
89
+ | **可信域名**(网页授权 / JS-SDK) | 站点域名(如 `example.com`),一般还需按企微要求上传校验文件到该域名根路径 | 企微授权页直接拒绝:`redirect_uri 需使用应用可信域名` |
90
+ | **可信 IP / API 调用白名单** | 服务商服务器的**出口** IP(多机部署要全列) | 上游接口报 `errcode=60020 not allow to access from your ip` → 本站回 502 并把 errcode 透出 |
91
+
92
+ > 两项都在服务商后台的「开发配置」一类的页面里,菜单名随企微版本会变;
93
+ > 认准**可信域名**与**可信 IP**这两个词即可。
94
+ > 回调与网页授权都要求 **https**:本地开发必须用内网穿透(见 §6.1),企微不会回调 http。
95
+
96
+ ### 2.3 一个用于验收的企业
97
+
98
+ 建议单独准备一个**测试企业**(企微可免费创建),把模板先装进测试企业跑通,
99
+ 再给真实客户开通。理由很实际:企业管理员确认授权这一步需要人操作,测试企业里你自己就是管理员。
100
+
101
+ ### 2.4 `wecom_enc_key`:怎么生成、怎么轮换
102
+
103
+ 生成(32 字节,推荐):
104
+
105
+ ```sh
106
+ openssl rand -hex 32 # 输出 64 个 hex 字符,直接贴进 wecom_enc_key
107
+ ```
108
+
109
+ - 只用 hex 或 base64;**不要**随手敲 32 个字符——它会被当 base64 解成 24 字节,
110
+ 触发 AES-192 不支持的报错(这正是最常见的配置事故)。
111
+ - 轮换:新 key **只能加密新写入**,旧密文用新 key 解不开。轮换 = 让企业重新授权一次
112
+ (企微侧会推 `reset_permanent_code`,或重新走开通链接 → `create_auth`),把 `permanent_code`
113
+ 用新 key 重写。轮换前后要留旧 key 用于读取,直到所有企业都重授权完毕。
114
+ - 与数据库分开保管:`db.sqlite` 备份 + `wecom_enc_key` 备份,**缺任何一个都等于全部企业要重新授权**。
115
+
116
+ ---
117
+
118
+ ## 3. 要填进企微后台的地址(逐字)
119
+
120
+ | 企微后台位置 | 值 | 说明 |
121
+ |---|---|---|
122
+ | 指令回调 URL | `https://<站点>/api/auth/wecom/event` | 点保存时企微会 GET 一次带 `echostr`:必须**当次就通**,否则保存不了 |
123
+ | 数据回调 URL(若模板要求) | 同上 | 本连接器不消费通讯录变更类事件,收到也只记一条日志并回 `success` |
124
+ | 可信域名 | `https://<站点域名>` | 不含路径 |
125
+ | 网页授权 `redirect_uri` | **不用手填**:后端按 `wecom_base_url` 拼出 `https://<站点>/api/auth/wecom/callback` | 企业的授权页里的域名必须与可信域名一致 |
126
+
127
+ 代开发后台(`/system/wecom`,见 §6.4)点「生成开通链接」时会把**当前应填的回调地址**
128
+ 一并显示出来——上线时以它为准,避免「配置文件改了、企微后台忘了改」。
129
+
130
+ ---
131
+
132
+ ## 4. 首次开通:顺序错了一定卡在 503
133
+
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 流程
148
+
149
+ | 步 | 动作 | 为什么必须在这一步 | 错序的症状 |
150
+ |---|---|---|---|
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` 是被推送的,不是拉取的。第一次部署时如果回调
159
+ > URL 保存得晚,就要等下一个 10 分钟周期。看到 503 不必怀疑代码——先确认回调真的通。
160
+ > **阶段二衔接的新坑**:若第 3 步的 suite 凭据还没填、企微就推来了 `suite_ticket`,那几条会被
161
+ > 400 拒掉(属预期),填好 Tier B 重启后下一个推送周期会自动补上,不必手动回放。
162
+
163
+ ---
164
+
165
+ ## 5. 用户手册(企业员工)
166
+
167
+ ### 5.1 三个入口,随便哪个都行
168
+
169
+ | 入口 | 怎么用 | 适用 |
170
+ |---|---|---|
171
+ | A 企微客户端内 | 工作台里点该应用,或从会话里的应用消息进入 | 手机 / 桌面企微,**静默登录**,不弹授权页 |
172
+ | B 普通浏览器扫码 | 打开本站登录页 → 选「企业微信」→ 填企业代码(`ww` 开头的 corp_id)→ 扫码 | 首次在陌生电脑上用 |
173
+ | C 专属链接 | 管理员/HR 发来的链接,点开即落到你们企业 | 新人入职通知、内部公告 |
174
+
175
+ 企业代码由管理员提供。**填错的表现**是 400 `企业代码无效或应用未开通`——不是你的问题,找管理员。
176
+
177
+ > 从专属链接(带 `?tenant=`)进入登录页时,「企业代码」输入框会**自动展开并预填**——
178
+ > 它决定你的账号落在哪家企业,不该看不见。要改用别家企业,清空它再登录即可;
179
+ > 留着旧值会让这次登录仍然算在那家企业名下。
180
+
181
+ ### 5.2 不需要注册,也不需要设密码
182
+
183
+ - 第一次登录时系统**自动给你建账号**:用户名是 `wecom:<你的企微账号>`,显示名优先用企微里的姓名
184
+ (取不到就用账号工号兜底)。
185
+ - 这个账号**没有本地密码**(口令位是占位符,任何密码都验不过),所以:
186
+ - 不能用「账号 + 密码」在登录页登录;
187
+ - 不能给它改密码来登录。
188
+ - 权限与角色仍由本站管理员分配,跟企微里的身份是两件事。
189
+
190
+ ### 5.3 进不去时先看提示
191
+
192
+ | 提示 | 含义 | 你该做什么 |
193
+ |---|---|---|
194
+ | `账号已被停用,请联系管理员` | 本站把你的账号停用了(企微那边还有你) | 找本站管理员启用 |
195
+ | `登录凭据无效或不在应用可见范围内` | 你不在该应用的可见范围,或不是该企业成员 | 找企业管理员把你加进应用可见范围 |
196
+ | `登录状态已失效,请从企业微信重新进入` | 落地链接过期/被复用(票据 180 秒、一次) | 重新从入口进 |
197
+ | `授权未完成:企业微信未返回 code` | 授权页上点了取消 | 重新点一次并同意 |
198
+
199
+ ### 5.4 换企业 / 换设备 / 退出
200
+
201
+ - 一个浏览器同一时间只归属**一个企业**(租户)。要切到另一家企业,直接用那家企业的入口登录。
202
+ - 退出后再次进入会**重新静默登录**,不需要输密码。
203
+ - 在企微客户端里换手机、换电脑后,从工作台进入即可,账号是同一个。
204
+
205
+ ---
206
+
207
+ ## 6. 开发手册(本工程开发者)
208
+
209
+ ### 6.1 本地联调
210
+
211
+ 企微只回调**公网 https**,所以本地必须内网穿透。最小闭环:
212
+
213
+ ```sh
214
+ # 1) 六个 vars 填进工程根 config.yaml 的 vars:(见 §2.1)
215
+ # 其中 wecom_base_url 先写成穿透地址:https://<穿透域名>/api/auth/wecom
216
+ # 2) 起前后端
217
+ pnpm dev
218
+ # 3) 把本地 9778(后端)暴露成 https(任意内网穿透工具)
219
+ # 4) 企微后台 → 指令回调 URL 改成 https://<穿透域名>/api/auth/wecom/event,保存
220
+ # 5) 等一个推送周期(≤10 分钟)→ 进 /system/wecom 生成开通链接
221
+ ```
222
+
223
+ 三个常见坑:
224
+
225
+ - **回调 URL 是「一个模板一套」**:本地改了,生产那套就收不到 `suite_ticket`(30 分钟后过期)。
226
+ 正确做法是给开发/测试单独建一个代开发模板(另一套 SuiteID),而不是跟生产抢同一个。
227
+ - **匿名路径**:`/auth/wecom/{login,callback,ticket,event}` 必须同时出现在
228
+ `config.yaml` 的 `auth.anonymous_paths` 与 `tenant.anonymous_paths` 里(模板工程已配好)。
229
+ 漏了会 401,而且报错看起来像「登录失败」,不像「未匿名」。
230
+ - **改了 `config.yaml` 必须重启**(`ojm dev` 不会热更配置),改前端 `src/**/web/**` 会自动重建。
231
+
232
+ ### 6.2 加一家企业:不改配置、不重启
233
+
234
+ 企业管理员确认授权 → 企微推 `create_auth` → 本站用 `auth_code` 换 `permanent_code` →
235
+ 写 `wecom_app_profile`。**新增企业的唯一操作就是「发一条开通链接」**。
236
+
237
+ - 同一企业可以并行装**两个应用**(比如测试与正式各一个):`wecom_app_profile` 主键含
238
+ `agent_id`,天然两行。
239
+ - 这时登录链接**必须带 agent**,否则 400
240
+ `该企业开通了多个应用,登录链接必须带 agent 参数`:
241
+ `/auth/wecom/login?corp=ww…&agent=1000011`
242
+ - 开通链接支持环境标记:`state` 传 `env=test`(或 `test` / `staging` / `dev`)会落到
243
+ profile 的 `env` 列,缺省 `prod`。同一套代码区分「测试企业」与「正式企业」就靠它。
244
+
245
+ ### 6.3 数据面:三张共享表 + 一张租户表
246
+
247
+ | 表 | 是否租户表 | 存什么 | 为什么这样放 |
248
+ |---|---|---|---|
249
+ | `wecom_app_profile` | **共享**(`tenant: false`) | 企业 × 应用 → 密文 `permanent_code`、`corp_name`、`env`、`status` | 它是「corp → 租户」映射的**权威表**;登录前还没确定租户,必须是部署级 |
250
+ | `wecom_suite_ticket` | **共享** | 每个部署一条 `suite_ticket` + 过期时刻 | 套件凭据属于整个部署,不属于某个企业 |
251
+ | `wecom_login_ticket` | **共享** | 一次性登录票据的 **sha256** + 归属租户 + `used` | 落地换票发生在「已知租户但尚未建会话」的瞬间;只存哈希,泄库不可重放 |
252
+ | `users` | 租户表 | JIT 建的 `wecom:<userid>` 账号 | 账号天然属于某个企业,`tenant_id = corp_id` |
253
+
254
+ 三张共享表已列进 `config.yaml` 的 `tenant.shared_allow`。**不要**把它们改成租户表:
255
+ 登录路径在 `ensureTenant` 之前就要读 profile,改成租户表会在守卫那里直接 403。
256
+
257
+ 票据换会话的顺序是刚性的:**先绑租户,再抢票据(`used` 0→1 原子)**,然后才校验过期——
258
+ 重放与并发双花都只会成功一次。
259
+
260
+ ### 6.4 代开发后台(`/system/wecom`,仅超级管理员可见)
261
+
262
+ 三件事,各对应一个端点(都在 `src/auth/api/wecom/admin/`):
263
+
264
+ | 界面动作 | 端点 | 说明 |
265
+ |---|---|---|
266
+ | 已开通企业清单 | `GET /auth/wecom/admin/apps` | **不返回** `permanent_code`,最多到 `corp_name` |
267
+ | 启用 / 停用某企业某应用 | `POST /auth/wecom/admin/status` | 停用是安全动作:**只在该企业没有其它 active 应用时**才吊销该租户会话 |
268
+ | 生成开通链接 | `GET /auth/wecom/admin/install` | 返回 `{installUrl, callbackUrl, expiresIn}`;`callbackUrl` 就是要逐字填进企微后台的那个值 |
269
+
270
+ 开通链接**10 分钟有效且一次性**:过期后界面上的二维码会置灰,重新点一次即可(每次点都重新换
271
+ `pre_auth_code`,不做缓存)。
272
+
273
+ 权限是**超级管理员**而不是站点 admin:这三件事跨租户(能看别家企业的 corp_id)。
274
+
275
+ ### 6.5 没有真实套件时怎么写测试
276
+
277
+ 企微侧不可自动化(需要企业管理员点确认),但**登录后半程可以完全自动化**——
278
+ 这也是本地与 CI 里的推荐做法:
279
+
280
+ 1. 打开开关:`sys_setting` 里 `wecom.enabled = '1'`(默认 `0`)。
281
+ 2. 造「企业已开通」:往 `wecom_app_profile` 插一行(`suite_id / corp_id / agent_id / env / status`)。
282
+ 3. 造「票据」:自己生成一串随机串当 raw ticket,库里只存它的 sha256 到 `wecom_login_ticket`
283
+ (带 `owner_tenant`、未使用、未过期)。
284
+ 4. 造「员工」:往 `users` 插 `wecom:<userid>`(`source='wecom'`,口令位 `!wecom`)。
285
+ 5. 前端直接落在 `/login?wecom_ticket=<raw>&tenant=<corp>` → 走换票 → 断言进入首页。
286
+
287
+ 纯函数也值得单测:`src/login/web/wecom.ts` 的 `parseWecomLanding()` 与 `wecomAuthorizePath()`
288
+ (无 react / 无 request,node 侧可直接跑)。
289
+
290
+ ### 6.6 前端红线:整页跳转要拼 **API base**
291
+
292
+ 授权入口是**后端端点**,不是前端路由:
293
+
294
+ ```ts
295
+ // 对:API base(部署期可能是 /api,也可能是网关前缀)
296
+ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
297
+ ```
298
+
299
+ 拼站点 base 是最隐蔽的错误:根部署下会得到 `/auth/wecom/login`,被 SPA 兜底页吞掉——
300
+ **页面原地重载、一个 auth 请求都不发**,控制台无报错。单元测试与类型检查都发现不了,
301
+ 只有 e2e 或真机能发现。
302
+
303
+ 同一口径适用于**每一个走整页跳转的后端端点**:SSO 的 `GET /auth/sso` 同样是后端端点,
304
+ 也必须拼 `resolveApiBase()`。整页跳转只允许这一种拼法——多一种就多一处「点了没反应」的角落
305
+ (曾因一处拼 API base、一处拼站点 base,子路径部署下 SSO 按钮像是坏了)。
306
+
307
+ 同理,企微面板里的「企业代码」输入框如果是受控值(`Form.Item` 没有 `name`),
308
+ 必须显式写 `htmlFor` + `id`,否则标签只是视觉文本:读屏取不到,`getByLabel` 也取不到。
309
+
310
+ ### 6.7 只能真机验的部分
311
+
312
+ - 扫码 / 企微内静默授权的真实跳转(浏览器 UA 分支、`snsapi_base` 行为)
313
+ - 可信域名备案与 https 回调
314
+ - `suite_ticket` 的真实推送节奏(10 分钟)
315
+ - 企业管理员确认授权的交互,以及「点了取消」时企微只回 `state` 不回 `code`
316
+ - 通讯录权限导致的兜底:拿不到姓名时显示名落回工号
317
+
318
+ ---
319
+
320
+ ## 7. 部署 / 运维手册(服务商侧)
321
+
322
+ ### 7.1 上线前 checklist
323
+
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 阶段二)
327
+ - [ ] 站点已用 **https** 对外,反向代理放行 `POST /api/auth/wecom/event`(含原始 XML 请求体,不要改写)
328
+ - [ ] 可信域名已备案(含校验文件)
329
+ - [ ] 可信 IP 已列全所有出口 IP
330
+ - [ ] 服务器时间已 NTP 同步:回调只接受 ±5 分钟内的 `timestamp`,偏得多了**所有**指令都会被拒
331
+ - [ ] `wecom_enc_key` 已生成并**离线备份**(与数据库分开)
332
+ - [ ] 测试企业已准备好,且你自己是它的管理员
333
+ - [ ] 一个能登录本站的**超级管理员**账号(生成开通链接用)
334
+
335
+ ### 7.2 上线顺序
336
+
337
+ 照 §4 的两阶段八步走。每步的验收点:
338
+
339
+ | 步 | 验收 |
340
+ |---|---|
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 | 用一个**普通员工**账号登录成功(不要只用管理员自己的号) |
348
+
349
+ ### 7.3 启停与解绑
350
+
351
+ | 动作 | 效果 |
352
+ |---|---|
353
+ | 后台「停用」某企业某应用 | 该行 `status=disabled`,企业 token 缓存作废;**仅当该企业没有其它 active 应用时**才吊销该租户全部会话 |
354
+ | 后台「启用」 | 恢复登录;不发会话(用户下次进入重新登录) |
355
+ | 企业在企微侧取消授权 | 收到 `cancel_auth` → 该 corp 全部应用置 `disabled` + 作废 token 缓存 + **清空该租户全部 refresh token** |
356
+ | 企业重新授权 | `create_auth` / `reset_permanent_code` 幂等 upsert,重写密文与状态 |
357
+
358
+ > 停用某企业**不会**删除它的账号与业务数据,只是拦住新登录并(在无其它应用时)断掉已有会话。
359
+ > 要「彻底下线」得另外清理该租户数据,属于业务决策,不在本连接器范围。
360
+
361
+ ### 7.4 密钥与备份
362
+
363
+ - `permanent_code` 只以密文存在 `wecom_app_profile.permanent_code_enc`;
364
+ 任何接口(含后台清单)都不返回明文,日志里也不打。
365
+ - 备份必须是**两份**:`db.sqlite` + `wecom_enc_key`。只备份数据库 → 换机后全部企业要重授权。
366
+ - 轮换 `wecom_enc_key`:见 §2.4(新 key 只能写新数据,旧企业要重授权)。
367
+ - 重置 SuiteSecret(企微后台)会让当前的 `suite_access_token` 缓存失效 → 下一次调用自动重换;
368
+ 但**要同步改 `config.yaml`**,否则 40001。
369
+
370
+ ### 7.5 留痕(审计日志里能看到)
371
+
372
+ | action | 触发 | 级别 |
373
+ |---|---|---|
374
+ | `wecom.auth.apply` | `create_auth` / `reset_permanent_code` | 普通 |
375
+ | `wecom.auth.cancel` | 企业取消授权 | 安全事件 |
376
+ | `wecom.admin.enable` / `wecom.admin.disable` | 后台启停 | 停用按安全事件记 |
377
+
378
+ ---
379
+
380
+ ## 8. 故障速查
381
+
382
+ 按「看到的文案」查,快过按代码查。
383
+
384
+ | 现象 / 文案 | 病根 | 处置 |
385
+ |---|---|---|
386
+ | 400 `企业微信未配置:config vars 缺少 …` | `vars:` 有项为空 | 补 §2.1 对应项,重启 |
387
+ | 400 `wecom_base_url 必须以 /api/auth/wecom 结尾` | 只写了域名,或写了别的路径 | 改成 `https://<站点>/api/auth/wecom`(无尾斜杠) |
388
+ | 400 `wecom_enc_key 必须是 16 或 32 字节 …` | 随手敲的 32 字符被按 base64 解成 24 字节 | `openssl rand -hex 32` 重新生成 |
389
+ | 503 `suite_ticket 尚未收到:回调 URL 未打通或企微尚未推送` | 回调没通,或刚保存还没到推送周期 | 先验回调(§4 第 2 步),再等 ≤10 分钟 |
390
+ | 503 `suite_ticket 已过期:企微每 10 分钟推送一次` | 回调曾经通、后来断了(换域名/反代挡了 POST) | 修回调;企微重推三次也救不回来时,等下一周期 |
391
+ | 400 `回调 timestamp 超出允许窗口:请检查服务器时间,或该请求为历史回调的重放` | 服务器时钟与真实时间偏差超过 5 分钟,或这条回调是被人回放的旧报文 | 校服务器时间(NTP);校正后企微的重推会自动成功 |
392
+ | 400 `回调 nonce 重复:同一条指令回调已被处理过(重放请求已拒绝)` | 同一条指令被投递了两次(企微重试、或反代重复转发) | 首次生效、重放被拒属预期保护;若常态出现,查反代是否重复转发 POST |
393
+ | 企微后台保存回调 URL 失败 | `Token` / `EncodingAESKey` 与 `config.yaml` 不一致 | 逐字对齐(43 字符别多空格) |
394
+ | 400 `企业代码无效或应用未开通` | corp_id 打错,或该企业没装本应用 | 核对 corp_id(`ww` 开头);确认已在后台清单里 |
395
+ | 400 `该企业开通了多个应用,登录链接必须带 agent 参数` | 同企业装了测试+正式两个应用 | 链接加 `&agent=<agentid>` |
396
+ | 401 `登录凭据无效或不在应用可见范围内` | code 无效 / 员工不在应用可见范围 / 非本企业成员 | 企微后台检查应用可见范围 |
397
+ | 403 `账号已被停用,请联系管理员` | 本站把该账号 `is_forbidden=1` | 后台启用该账号 |
398
+ | 401 `登录票据无效或已使用` / `登录票据已过期,请重新登录` | 票据被重放,或 180 秒没换 | 重新从入口进 |
399
+ | 400 `授权未完成:企业微信未返回 code` | 用户在授权页点了取消 | 重新授权 |
400
+ | 400 `登录状态已失效,请从企业微信重新进入` | `state` 过期(10 分钟)/ 已用 / 换过部署 | 重新进;跨部署时正常现象 |
401
+ | 502 `企微 … 失败:errcode=60020 …` | 服务器出口 IP 不在可信 IP | 补齐白名单 |
402
+ | 502 `企微 … 失败:errcode=40001/40014/42001` | 凭据错或 token 过期(会自动重换一次) | 若持续:核对 SuiteSecret / 是否重置过 |
403
+ | 502 `企微接口不可达` / `HTTP 5xx` | 出网被墙、DNS、代理 | 查机器出网与 DNS |
404
+ | 点登录页「企业微信」按钮页面只闪一下、无请求 | 跳转拼了站点 base 而不是 API base | 见 §6.6 |
405
+ | 登录回跳落到 404 或别的应用 | `wecom_base_url` 的站点前缀与实际部署不一致 | 用 `/system/wecom` 显示的回调地址反推 |
406
+ | 登录页没有「企业微信」页签 | `wecom.enabled` 未开 | `/system/settings` 打开 |
407
+ | 后台清单为空但企业说已授权 | `create_auth` 回调没收到(回调地址/验签) | 查服务日志有无 `wecom auth applied`;让企业重新授权一次 |
408
+
409
+ ---
410
+
411
+ ## 9. 一分钟自检
412
+
413
+ ```text
414
+ [ ] 六个 vars 非空,wecom_base_url 以 /api/auth/wecom 结尾
415
+ [ ] 企微后台的指令回调 URL == <wecom_base_url>/event(逐字)
416
+ [ ] 服务日志有 wecom suite_ticket updated(近期)
417
+ [ ] /system/wecom 列表里有目标企业,status=active
418
+ [ ] /system/settings 的「启用企业微信登录」= 开
419
+ [ ] 用普通员工账号(非管理员)完整走过一次登录
420
+ [ ] db.sqlite 与 wecom_enc_key 各自有备份
421
+ ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/cli",
3
3
  "type": "module",
4
- "version": "0.2.43",
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.43",
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",