@oj-module/cli 0.2.48 → 0.2.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/devkit/README.md +9 -7
  2. package/devkit/SKILL.md +53 -14
  3. package/devkit/manual.md +203 -3
  4. package/devkit/wecom-daikaifa.md +100 -1
  5. package/devkit/wecom-internal-app.md +643 -0
  6. package/package.json +2 -2
  7. package/shell-dist/assets/runtime.js +2 -2
  8. package/shell-dist/index.html +2 -2
  9. package/shell-dist/versions.json +3 -3
  10. package/src/__tests__/build-backend.test.ts +80 -0
  11. package/src/build.ts +50 -0
  12. package/src/init.ts +5 -4
  13. package/templates/config.yaml +28 -6
  14. package/templates/openapi.json +174 -2
  15. package/templates/src/auth/api/_shared/wecom/admin.ts +87 -7
  16. package/templates/src/auth/api/_shared/wecom/client.ts +42 -6
  17. package/templates/src/auth/api/_shared/wecom/config.ts +256 -46
  18. package/templates/src/auth/api/_shared/wecom/contacts.ts +7 -1
  19. package/templates/src/auth/api/_shared/wecom/events.ts +25 -6
  20. package/templates/src/auth/api/_shared/wecom/login.ts +44 -4
  21. package/templates/src/auth/api/_shared/wecom/replay.ts +44 -3
  22. package/templates/src/auth/api/_shared/wecom/store.ts +61 -12
  23. package/templates/src/auth/api/_shared/wecom/sync.ts +25 -1
  24. package/templates/src/auth/api/migrations/0003__credential_kind.mysql.sql +3 -0
  25. package/templates/src/auth/api/migrations/0003__credential_kind.pg.sql +3 -0
  26. package/templates/src/auth/api/migrations/0003__credential_kind.sql +9 -0
  27. package/templates/src/auth/api/schema.yaml +2 -0
  28. package/templates/src/auth/api/wecom/admin/api.ts +112 -11
  29. package/templates/src/auth/api/wecom/api.ts +151 -59
  30. package/templates/src/auth/api/wecom/sync/api.ts +21 -5
  31. package/templates/src/auth/web/__client/api.schemas.ts +22 -1
  32. package/templates/src/auth/web/__client/api.ts +47 -1
  33. package/templates/src/login/web/locales/en-US.json +2 -0
  34. package/templates/src/login/web/locales/zh-CN.json +2 -0
  35. package/templates/src/login/web/pages/login.tsx +39 -3
  36. package/templates/src/system/web/locales/en-US.json +24 -1
  37. package/templates/src/system/web/locales/zh-CN.json +24 -1
  38. package/templates/src/system/web/pages/departments/sync-bar.tsx +32 -7
  39. package/templates/src/system/web/pages/wecom/index.tsx +164 -46
  40. package/templates/tests/wecom-admin.spec.ts +7 -7
  41. package/templates/tests/wecom-connector.spec.ts +38 -8
  42. package/templates/tests/wecom-corp.spec.ts +523 -0
  43. package/templates/tests/wecom-endpoints.spec.ts +73 -7
  44. package/templates/tests/wecom-org-sync.spec.ts +7 -1
  45. package/vendor/host-versions.json +3 -3
@@ -0,0 +1,643 @@
1
+ # 企业微信企业内部应用(自建)集成:配置与使用手册
2
+
3
+ 本文件覆盖「把模板工程接成一家企业的**企业内部应用(自建应用)**」的全过程:**要准备哪些凭据、
4
+ 填到企微后台哪一处、按什么顺序接入、三种角色各自怎么用**。三种角色指:企业员工(用户)、
5
+ 本工程开发者、部署与运维。
6
+
7
+ 读完本文件即可独立完成对接,不需要其它文档。文中路径都是**你工程里的相对路径**;
8
+ 需要与代开发(服务商模板)同时使用时,另见同目录 `wecom-daikaifa.md` 的「共存」一节。
9
+
10
+ ---
11
+
12
+ ## 0. 谁该读哪几节
13
+
14
+ | 你是 | 读 | 大约要做什么 |
15
+ |---|---|---|
16
+ | 部署 / 运维 | §2 §3 §4 §7 §8 | 备凭据、填 `config.yaml`、在后台点「验证并接入」、日常启停与轮换;真机验收照 **§7.5** 打勾 |
17
+ | 企业管理员(客户 IT) | §2.3 §3 §4.3 §7.5 | 在自家企微后台建自建应用、交出三个值、配可信 IP / 可信域名、把可见范围设成全公司、可选配通讯录事件回调 |
18
+ | 本工程开发者 | §1 §6 §8 | 本地联调、加第二套凭据、写没有真实企业的自动化测试 |
19
+ | 企业员工(用户) | §5 | 怎么进、要不要注册、进不去怎么办 |
20
+
21
+ ---
22
+
23
+ ## 1. 这套东西在做什么
24
+
25
+ **术语先分清(客户侧最容易互相误解):**
26
+
27
+ - **代开发应用**:服务商建模板 → 客户管理员扫码授权。它在客户企微后台里**也显示为一个「自建应用」**,
28
+ 但权限与回调都由服务商的模板决定(见 `wecom-daikaifa.md`)。
29
+ - **企业内部应用(自建应用)**:客户管理员自己在「应用管理 → 自建」里创建,把
30
+ `企业ID / Secret / AgentId` 交给部署方。**本文只讲这一种。**
31
+
32
+ 一句话:**企业管理员在自家企微后台建一个自建应用,把 `企业ID / Secret / AgentId` 交给部署方,
33
+ 部署方填进配置、在后台点一次「验证并接入」,之后该企业员工就能用企微身份直接登录本站、通讯录也能同步**。
34
+
35
+ ### 1.1 与代开发的关系:同一条数据链路,只换凭据来源
36
+
37
+ 代开发拿到的 `permanent_code` 本来就是**当 `corpsecret` 用**的(企微 `gettoken` 对两者是同一个接口)。
38
+ 所以本系统对企业侧的所有能力——换企业 token、读通讯录、网页授权换 userid、事件落库——
39
+ **两种来源共用同一份实现**。差别只在「凭据从哪来」和「有没有套件流程」:
40
+
41
+ | 维度 | 代开发(服务商模板) | 企业内部应用(自建) |
42
+ |---|---|---|
43
+ | 谁创建 | 服务商建模板,企业管理员扫码授权 | 企业管理员自建,人工交付 3 个值 |
44
+ | 凭据来源 | `suite_ticket + suite_secret → auth_code → permanent_code` | 应用详情页「Secret」直接给 |
45
+ | 换企业 token | `gettoken(corpid, permanent_code)` | `gettoken(corpid, corpsecret)` —— 同一个接口 |
46
+ | `suite_ticket` | 必需,每 10 分钟一推,断则登录 503 | **没有**(少一个常驻故障源) |
47
+ | 开通 / 续期 | 开通链接 + `create_auth` / `reset_permanent_code` 回调 | 没有。换 Secret 就得重新「验证并接入」 |
48
+ | 取消授权 | `cancel_auth` 回调 → 停用 + 吊销会话 | **没有事件**,只能从登录 / 同步失败发现 |
49
+ | 授权模式 | `auth_mode` 0/1,成员授权下通讯录接口全 `60011` | **没有这个概念**(同步闸门恒通过) |
50
+ | 成员变更事件 | 默认推到代开发回调 URL | **收不到**,只能另配「通讯录同步」回调(§2.4) |
51
+ | 多企业 | 一套部署服务 N 家(扫码即加,不改配置) | **一套部署一套凭据**(一家企业) |
52
+
53
+ 一句话:**自建省掉了套件的复杂度,代价是省掉了套件带来的「零配置开通」和「默认事件推送」。**
54
+
55
+ ### 1.2 一条必须先接受的硬约束:自建应用收不到成员变更事件
56
+
57
+ 官方《成员变更通知》(文档号 90970)对「新增 / 更新 / 删除成员」写的是同一句:
58
+
59
+ > 该事件会回调给**通讯录同步助手、代开发自建应用**以及上游企业共享的应用。
60
+
61
+ **普通自建应用不在回调对象里。** 想保留「事件增量」(改一个人不点同步也立刻能看到),
62
+ 唯一路径是让企业管理员在 **管理工具 → 通讯录同步 → 设置接收事件服务器** 填我们的 URL;
63
+ 而《接收通讯录变更事件》(90967)对该 URL 有一句硬要求:
64
+
65
+ > 为保证企业数据安全,URL 需要配置**本企业主体的域名**链接。
66
+
67
+ **推论(决定了适用边界)**:
68
+
69
+ | 部署形态 | 事件增量 |
70
+ |---|---|
71
+ | 单企业私有化部署(站点就在客户自己的域名下) | **可用**(客户配通讯录同步回调即可) |
72
+ | 一套部署服务多家企业(SaaS) | **基本不可用**(每家都要把 URL 指向自己主体域名) |
73
+ | 代开发(服务商模板) | 默认可用(企微主动推) |
74
+
75
+ 所以:**要 SaaS 多企业,走代开发;要单企业私有化,自建应用最省事。**
76
+ 不配通讯录回调也不是不能跑——全量同步 + 登录自更新照常工作,只是少了事件加速。
77
+
78
+ ### 1.3 一条安全底线:只要应用 Secret,不要「通讯录同步 Secret」
79
+
80
+ 自建应用用**应用 Secret** 读通讯录,拿到的同样只是「应用可见范围内」的部门与成员。
81
+ 要全量,最省事也最安全的做法是让管理员把**应用可见范围设为全公司**——一次点击,零密钥扩散。
82
+
83
+ **不要**让客户把「通讯录同步」的 Secret 也交出来:那把钥匙能**写**全公司通讯录,
84
+ 风险与收益完全不匹配。本方案只收一个 secret。
85
+
86
+ ---
87
+
88
+ ## 2. 凭据清单
89
+
90
+ ### 2.1 五项本企业凭据(填在工程根 `config.yaml` 的 `vars:` 段)
91
+
92
+ 一套部署 = 一家企业,所以这五项是**部署维度**的常量,不进数据库。
93
+
94
+ | # | 变量 | 必填 | 从哪取 | 格式 / 校验 | 配错或缺失的后果 |
95
+ |---|---|---|---|---|---|
96
+ | 1 | `wecom_corp_id` | 是 | 企微后台 → **我的企业 → 企业信息 → 企业ID**(形如 `ww…`) | 非空字符串;**同时是租户键** | 400 `config vars 缺少 wecom_corp_id / wecom_corp_secret / wecom_corp_agent_id`;登录与同步都会 400 |
97
+ | 2 | `wecom_corp_secret` | 是 | 企微后台 → **应用管理 → 自建 → 你的应用 → Secret**(点「查看」,可重置) | 非空;重置后旧值立即失效 | 换不出企业 token → 502 `企微 gettoken 失败:errcode=40001 …`;**换过 Secret 必须重新「验证并接入」** |
98
+ | 3 | `wecom_corp_agent_id` | 是 | 同页 **AgentId**(形如 `1000002`) | 非空字符串 | 登录授权 URL 拼不出 `agentid`、token 缓存键缺失;启动/首个请求即 400 |
99
+ | 4 | `wecom_corp_callback_token` | 否 | 企微后台 → **管理工具 → 通讯录同步 → 设置接收事件服务器 → Token**(点「随机获取」) | 与企微后台**逐字一致** | 不配 = 没有事件增量(全量同步照常);配错 = 保存回调 URL 时验签失败、企微持续重推 |
100
+ | 5 | `wecom_corp_encoding_aes_key` | 否 | 同上 → **EncodingAESKey**(43 字符) | **43 字符**,与企微后台逐字一致 | 同上;差一个字符就是 `pkcs7: bad padding` |
101
+
102
+ > 📌 **`wecom_corp_*` 与 `wecom_*` 是两套,别串台。** `wecom_callback_token` /
103
+ > `wecom_encoding_aes_key`(无 `corp_`)是**代开发指令回调**那一组;`wecom_corp_callback_token` /
104
+ > `wecom_corp_encoding_aes_key` 是**自建应用的通讯录同步回调**那一组。两组不同源,不能互相顶替。
105
+ > 只做自建应用的部署,请把这四项代开发变量(含 `wecom_suite_*` / `wecom_provider_*`)**全部留空**。
106
+
107
+ ### 2.2 两项复用凭据(与代开发完全相同)
108
+
109
+ | # | 变量 | 必填 | 说明 |
110
+ |---|---|---|---|
111
+ | 6 | `wecom_enc_key` | 是 | **落库加密密钥**:`wecom_app_profile.permanent_code_enc` 里存的应用 Secret 用它加密。hex 32/64 字符,或 base64 解码后 16/32 字节;**AES-192(24 字节)不支持**。见 §2.5 |
112
+ | 7 | `wecom_base_url` | 是 | **连接器基址**(注意:是基址,**不是**企微后台要填的回调 URL):`https://<站点>/api/auth/wecom`,绝对地址、无尾斜杠、**必须以 `/api/auth/wecom` 结尾** |
113
+
114
+ `wecom_base_url` 一处配、派生三处:
115
+
116
+ - 回调 URL(填进企微「通讯录同步 → 接收事件服务器」)= `<wecom_base_url>/event`
117
+ - 网页授权 `redirect_uri` = `<wecom_base_url>/callback`
118
+ - 登录成功后的站点回跳 = 去掉 `/api/auth/wecom` 的那部分(子路径部署也正确,例如
119
+ `https://example.com/oj/api/auth/wecom` → 回跳 `https://example.com/oj/login`)
120
+
121
+ > ⚠️ **别把 `wecom_base_url` 直接填进企微后台。** 企微后台要的是 `<base>/event`(必须带 `/event`)。
122
+ > 裸的 `/api/auth/wecom` 没有 handler,企微保存时会打到它 → **404 → 验证失败、保存不了**。
123
+ > 也别给它加 `?callback_format=json`:该参数只在「对外收款」的指令回调文档(97478)里有说明,
124
+ > 通用回调配置(96238 §3.2)与「通讯录变更事件」(90967)都没写;服务端两种包体都收(XML / JSON
125
+ > 都能归一),所以地址照文档写,不加参数。
126
+
127
+ ### 2.3 两项备案(都在企微后台,不写在代码里)
128
+
129
+ | 备案项 | 填在哪 | 填什么 | 不做的症状 |
130
+ |---|---|---|---|
131
+ | **可信域名**(网页授权 / JS-SDK) | 应用详情 → **网页授权及JS-SDK** | 站点域名(如 `example.com`),按企微要求上传校验文件到该域名根路径 | 企微授权页直接拒绝:`redirect_uri 需使用应用可信域名`;员工点了登录弹不出来 |
132
+ | **可信 IP**(API 调用白名单) | 应用详情 → **企业可信IP** | 服务器的**出口**公网 IP(多机部署要全列) | 上游接口报 `errcode=60020 not allow to access from your ip` → 本站回 502 并把 errcode 透出 |
133
+
134
+ > 菜单名随企微版本会变;认准**可信域名**与**企业可信IP**这两个词即可。
135
+ > 回调与网页授权都要求 **https**:本地开发必须用内网穿透(见 §6.5),企微不会回调 http。
136
+
137
+ 还有一项**不是备案、但决定同步能拿到多少人**:
138
+
139
+ | 设置 | 填什么 | 为什么 |
140
+ |---|---|---|
141
+ | **应用可见范围** | 应用详情 → **可见范围** → 设成**全公司** | 自建应用读通讯录,只能读到可见范围内的人。设成「部分成员」时,同步只会有子集——页面上「N 个部门因权限被跳过」通常就是它。**不需要**为此交出通讯录同步 Secret |
142
+
143
+ ### 2.4 可选:事件增量那一组(只对单企业私有化部署有意义)
144
+
145
+ 自建应用本身收不到成员变更事件(§1.2)。要事件增量,让企业管理员在
146
+ **管理工具 → 通讯录同步 → 设置接收事件服务器** 里填:
147
+
148
+ | 字段 | 填什么 |
149
+ |---|---|
150
+ | URL | `https://<站点>/api/auth/wecom/event`(= `<wecom_base_url>/event`,**逐字**) |
151
+ | Token | 点「随机获取」,复制到 `vars.wecom_corp_callback_token` |
152
+ | EncodingAESKey | 点「随机获取」,复制到 `vars.wecom_corp_encoding_aes_key` |
153
+
154
+ 三条要点:
155
+
156
+ 1. **同一个 `/event` 端点吃两套回调**(代开发的指令回调 / 自建应用的通讯录同步回调),
157
+ 靠「逐个 Token 试签」分辨——所以两套回调凭据可以同时存在,地址也只有一个。
158
+ 2. 企微保存时会先 GET 一次带 `echostr` 验签,**必须当次就通**,否则保存不了(§4 第 1 步)。
159
+ 3. **URL 必须落在企业主体域名下**(90967 原文)。站点不在客户自己域名上(SaaS 形态)就不要配,
160
+ 配了也过不了企微的保存校验——改用全量同步 + 登录自更新即可。
161
+
162
+ ### 2.5 `wecom_enc_key`:怎么生成、怎么轮换
163
+
164
+ ```sh
165
+ openssl rand -hex 32 # 输出 64 个 hex 字符,直接贴进 wecom_enc_key
166
+ ```
167
+
168
+ - 只用 hex 或 base64;**不要**随手敲 32 个字符——它会被当 base64 解成 24 字节,
169
+ 触发 `wecom_enc_key 必须是 16 或 32 字节…` 的报错(最常见的配置事故)。
170
+ - 轮换:新 key **只能加密新写入**,旧密文用新 key 解不开。轮换 = 用新 key 重新点一次
171
+ 「验证并接入」(自建应用重写一行;代开发要企业重新授权)。
172
+ - 与数据库分开保管:`db.sqlite` 备份 + `wecom_enc_key` 备份。**缺任何一个都等于要重新接入 / 重新授权。**
173
+
174
+ ---
175
+
176
+ ## 3. 要填进企微后台的地址与开关(逐字)
177
+
178
+ | 企微后台位置 | 填什么 | 说明 |
179
+ |---|---|---|
180
+ | **管理工具 → 通讯录同步 → 设置接收事件服务器** | `https://<站点>/api/auth/wecom/event` | **自建应用的回调只填这一处。** 保存时企微 GET 一次带 `echostr`,必须当次就通。不填 = 没有事件增量,其余一切照常 |
181
+ | **应用详情 → 接收消息 → 设置API接收** | **留空** | 应用自身的「接收消息」用的是另一组 Token,填到同一个 URL 会让一半报文验签失败。本系统不需要它 |
182
+ | 应用详情 → 网页授权及JS-SDK | 站点域名 + 校验文件 | 员工扫码 / 静默授权要用;缺它授权页直接拒绝 |
183
+ | 应用详情 → 企业可信IP | 服务器出口 IP | 缺它所有上游接口 `60020` |
184
+ | 应用详情 → 可见范围 | **全公司** | 决定同步能拿到多少部门与成员 |
185
+ | 应用详情 → 应用主页(可选) | `https://<站点>/login` | 员工在企微工作台点开即进入本站,企微内会用静默授权(UA 含 `wxwork` 时自动发起) |
186
+
187
+ 后台页 `/system/wecom` 的「企业内部应用(自建)」卡片会把**当前应填的回调地址**显示出来并支持复制
188
+ (`GET /auth/wecom/admin/overview` 的 `callbackUrl`)——上线时以它为准,避免「配置改了、企微后台忘了改」。
189
+
190
+ > **不需要**把回调地址填到「应用详情 → 接收消息」里,也不要为它单独开一条路由。
191
+ > 一个 `/event` 同时接代开发指令回调与自建应用通讯录回调,签名命中哪套就用哪套钥匙解密。
192
+
193
+ ---
194
+
195
+ ## 4. 首次接入:五步
196
+
197
+ ### 4.1 顺序表
198
+
199
+ | 步 | 动作 | 为什么必须在这一步 | 错序的症状 |
200
+ |---|---|---|---|
201
+ | 1 | 配好 §2.1 的三项 `wecom_corp_*` + §2.2 两项(`wecom_enc_key` / `wecom_base_url`),**重启服务** | 自建模式没有套件流程,配置齐了才有东西可校验 | 400 `config vars 缺少 wecom_corp_id / wecom_corp_secret / wecom_corp_agent_id`;或 `wecom_enc_key` / `wecom_base_url` 格式类 400 |
202
+ | 2 | 企微侧三项备案(可信域名 / 企业可信IP / 可见范围全公司) | 「验证并接入」会真的去换 token、读部门、读人;少任何一项都会在上游被打回 | 502 `errcode=60020`(可信 IP);`60011` / `48002`(可见范围或权限) |
203
+ | 3 | 用 **`superadmin`** 登录本站 → `/system/wecom` → 「企业内部应用(自建)」卡片 → **验证并接入** | 这是自建模式唯一的档案写入入口:没有 `create_auth` 回调,档案行必须由人点一次(§4.2) | 卡片不出现 = `wecom_corp_*` 没配齐(页面按「凭据齐不齐」决定画不画这张卡) |
204
+ | 4 | 进 `/system/settings` 打开「启用企业微信登录」(`wecom.enabled`) | 默认**关**,它只负责「登录页要不要显示企业微信这种方式」 | 登录页看不到「企业微信」页签 |
205
+ | 5 | 员工从企微工作台 / 扫码 / 专属链接进入 | 首次登录自动建档 | 401 `登录凭据无效或不在应用可见范围内`(多半是可见范围没配) |
206
+
207
+ > 🔐 **进 `/system/wecom` 必须用 `superadmin`,不是 `admin`。** 这张卡读的是**部署级配置**
208
+ > (企业ID / AgentId / 回调地址),不是某个租户的业务数据;前端与后端(`auth/wecom/admin/*`
209
+ > 每个端点 `assertSuperAdmin()`)都只放行 `superadmin`。模板 seed 已把 `admin` 从 `superadmin`
210
+ > 角色解绑,拿 `admin` 登录会直接显示「无权限」。请用 `superadmin` 登录(seed 口令 `aA123456`,
211
+ > **登录后立即改密**)。
212
+
213
+ ### 4.2 「验证并接入」到底做了什么(四步,全过才落库)
214
+
215
+ | 序 | 校验 | 它对应的配置错误 |
216
+ |---|---|---|
217
+ | 0 | 先**作废该应用的 token 缓存** | 换过 Secret 的部署若命中旧缓存,校验会「假装通过」,真错(旧 secret 已失效)会在下一个登录请求上才以 `40001` 冒出来 |
218
+ | 1 | `gettoken(corpid, secret)` | Secret 抄错 / 已重置 → `40001` |
219
+ | 2 | `department/simplelist` | 应用可见范围为空、或没有通讯录读权限 → `60011` / `48002`;返回空列表 → 400 `接入校验失败:应用可见范围里没有任何部门(把「应用可见范围」设为全公司,或至少包含一个部门)` |
220
+ | 3 | `user/simplelist`(按部门取人,最多探 10 个部门) | 可见范围内只有空部门 → 400 `接入校验失败:可见范围内读不到成员(请把应用可见范围扩大到至少一名成员)` |
221
+ | 4 | 写 `wecom_app_profile` 一行 | 写入 `suite_id=''`、`credential_kind='corp'`、Secret 密文、`auth_mode=0`、`status=active`;并把**同企业其它 AgentId 的旧 `corp` 行置为停用**(换过 AgentId 的部署不会留下两条活档案) |
222
+
223
+ - **任何一步失败都不落库**,也不会写半行——所以失败时可以放心重试。
224
+ - 界面成功文案带两个数:`接入成功:可见范围内 N 个部门,示例部门 M 人`。**N 远小于企业实际部门数
225
+ = 可见范围没设全公司**。
226
+ - 重复点 = 重写密文 + 清 token 缓存,**幂等**。
227
+
228
+ > 为什么不「启动时自动写一行」:自检失败会变成启动失败,而自建模式最常见的失败是客户还没加可信 IP、
229
+ > 可见范围没配——那是运营动作,不该让服务起不来。所以这一步放在人点的按钮后面。
230
+
231
+ ### 4.3 失败对照
232
+
233
+ | 现象 | 病根 | 处置 |
234
+ |---|---|---|
235
+ | 卡片里「通讯录事件回调凭据」显示**未配置** | `wecom_corp_callback_token` / `wecom_corp_encoding_aes_key` 为空 | 只是没有事件增量。要增量就照 §2.4 填,填完重启 |
236
+ | 点「验证并接入」400 `本部署未配置企业内部应用凭据…` | `wecom_corp_*` 三项没配齐(或只配了一项) | 补全 §2.1,重启 |
237
+ | 400 `接入校验失败:应用可见范围里没有任何部门…` | 可见范围是空的,或 Secret 对应的是另一个应用 | 企微后台把可见范围设为全公司;核对 AgentId 与 Secret 是否同一应用 |
238
+ | 400 `接入校验失败:可见范围内读不到成员…` | 可见范围内只有空部门 | 把可见范围扩大到至少一名成员 |
239
+ | 502 `errcode=60020` | 出口 IP 不在企业可信IP | 加上报错里那个 `client ip`(**公网出口**,不是内网 IP);多出口就加 CIDR |
240
+ | 502 `errcode=40001` 反复 | Secret 错 / 已重置 / 改完配置没重启 | 重新复制 Secret 到 `wecom_corp_secret`,重启,再点一次「验证并接入」 |
241
+ | 点「验证并接入」按钮不出现 | 那三项没配齐(页面按凭据齐不齐决定画不画卡片) | 补全后重启;刷新页面看顶部有没有「部署配置自检未通过」红条 |
242
+
243
+ ### 4.4 换 Secret / 换 AgentId / 换企业
244
+
245
+ | 动作 | 必须做什么 | 不做的症状 |
246
+ |---|---|---|
247
+ | 重置应用 Secret | 改 `wecom_corp_secret` → **重启** → 再点一次「验证并接入」 | 所有调用 `40001`;登录与同步同时挂 |
248
+ | 换 AgentId(换了一个自建应用) | 改 `wecom_corp_agent_id`(Secret 一并改)→ 重启 → 再点「验证并接入」 | 旧 `corp` 行会被本次接入自动停用;不点则登录仍解析到旧应用 → `40001` / 权限错 |
249
+ | 换企业 | 五项一起换 → 重启 → 点「验证并接入」 | 旧企业的档案行仍在(不同 `corp_id`,互不影响);确认旧行没人用后可在清单里停用它 |
250
+ | 在企微侧删掉应用 | 无事件、无回调 | 表现为登录 400 / 同步 `40001`;重新建应用后走完 §4.1 |
251
+
252
+ ---
253
+
254
+ ## 5. 用户手册(企业员工)
255
+
256
+ ### 5.1 三个入口
257
+
258
+ | 入口 | 怎么用 | 适用 |
259
+ |---|---|---|
260
+ | A 企微客户端内 | 工作台里点该应用(应用主页配成 `https://<站点>/login` 时),或从会话消息进入 | 手机 / 桌面企微,**静默登录**,不弹授权页 |
261
+ | B 普通浏览器扫码 | 打开本站登录页 → 选「企业微信」→ 企业代码可留空(单企业部署会自动解析)→ 扫码 | 首次在陌生电脑上用 |
262
+ | C 专属链接 | `https://<站点>/login?corp=ww…&agent=1000…` | 新人入职通知、内部公告;带 `corp` 参数时页面会**自动发起授权** |
263
+
264
+ 企业代码就是 `ww` 开头的企业ID,由管理员提供。**填错的表现**是 400 `企业代码无效或应用未开通`——
265
+ 不是你的问题,找管理员。
266
+
267
+ ### 5.2 不需要注册,也不需要设密码
268
+
269
+ - 第一次登录时系统**自动给你建账号**:用户名是 `wecom:<你的企微账号>`,显示名优先用企微里的姓名
270
+ (取不到就用账号工号兜底)。
271
+ - 这个账号**没有本地密码**(口令位是占位符,任何密码都验不过),所以不能用「账号 + 密码」登录,
272
+ 也不能给它改密码来登录。
273
+ - 权限与角色仍由本站管理员分配,跟企微里的身份是两件事。
274
+
275
+ ### 5.3 进不去时先看提示
276
+
277
+ | 提示 | 含义 | 你该做什么 |
278
+ |---|---|---|
279
+ | `账号已被停用,请联系管理员` | 本站把该账号停用了(企微那边还有你) | 找本站管理员启用 |
280
+ | `账号在企业微信中已被停用或已退出企业,请联系企业管理员` | 企微侧已离职 / 已禁用 | 找企业管理员 |
281
+ | `登录凭据无效或不在应用可见范围内` | 你不在该应用的可见范围,或不是该企业成员 | 找企业管理员把你加进应用可见范围 |
282
+ | `登录状态已失效,请从企业微信重新进入` | 落地链接过期 / 被复用(票据 180 秒、一次) | 重新从入口进 |
283
+ | `授权未完成:企业微信未返回 code` | 授权页上点了取消 | 重新点一次并同意 |
284
+ | `尚未完成企业微信接入(无可用应用档案)…请在后台点「验证并接入」` | 管理员还没点「验证并接入」,或点了失败 | 找管理员(§4.2) |
285
+ | `本次部署配置的企业内部应用尚未接入:请在后台点「验证并接入」` | 同上,只是这条出在「配置了 corp 凭据但接入还没成功」时 | 找管理员点一次「验证并接入」 |
286
+
287
+ ### 5.4 换设备 / 退出
288
+
289
+ - 退出后再次进入会**重新静默登录**,不需要输密码。
290
+ - 在企微客户端里换手机、换电脑后,从工作台进入即可,账号是同一个。
291
+ - 一个浏览器同一时间只归属一个企业(租户)。单企业部署下不需要关心这件事。
292
+
293
+ ---
294
+
295
+ ## 6. 开发手册(本工程开发者)
296
+
297
+ ### 6.1 配置读取:三个读取器,别混用
298
+
299
+ `src/auth/api/_shared/wecom/config.ts` 刻意把「读配置」拆成三个入口,按需要取,**不要图省事用最全的那个**:
300
+
301
+ | 读取器 | 返回 | 谁该用 |
302
+ |---|---|---|
303
+ | `readBase()` | `encKey` / `baseUrl` | 登录、302 回跳、同步、票据换会话——**这些路径与来源无关**,读整套 suite 配置会把一条正常的自建登录打成 400 |
304
+ | `readSuiteConfig()` | Tier A + suite 凭据 + provider 凭据 | 只有 suite 流程:指令事件处理、生成开通链接 |
305
+ | `readCorpConfig()` | `corpId` / `corpSecret` / `agentId` / 可选回调凭据 | 只有「验证并接入」 |
306
+
307
+ 「启用哪几种来源」的判定只有一个口径:**凭据填齐了没有**(`wecomKinds()`)。
308
+ 没有 `wecom_mode` 变量——「变量填了没有」是客观事实,不会与运行期实际能力漂移。
309
+ 两套都填齐时必须显式 `wecom_allow_coexist: 'true'`(取值 `1` / `true` / `yes`),否则 fail-fast:
310
+ 两套凭据同时生效会让回调多一次试签、档案里多一类行,配置事故应当被看见而不是被静默择一。
311
+
312
+ **例外(务必保留)**:`callbackCreds()` 刻意**不经过** `wecomKinds()`。它列的是「回调钥匙」而不是
313
+ 「启用了哪几种来源」,两者在引导期不重合——代开发模板还没建、`suite_id` 为空,但 Token / EncodingAESKey
314
+ 已经能填,企微正在该 URL 上做保存校验。按来源过滤会把 URL 验证打成 400,把「先有回调还是先有套件」
315
+ 的死锁又装回去。判据只有一条:**这一组的 token 与 aeskey 是否都填了**。
316
+
317
+ ### 6.2 档案行:`credential_kind` 是唯一的分辨锚点
318
+
319
+ `wecom_app_profile` 主键是 `(suite_id, corp_id, agent_id)`,并额外有 `credential_kind`(`suite` / `corp`):
320
+
321
+ | 来源 | `suite_id` | `credential_kind` | `permanent_code_enc` 里存什么 | `auth_mode` |
322
+ |---|---|---|---|---|
323
+ | 代开发 | 真 suite_id | `suite` | `permanent_code` | 0 或 1(成员授权) |
324
+ | 自建 | **空串** | `corp` | **应用 Secret** | 恒 0 |
325
+
326
+ - 自建行由「验证并接入」写入,**没有 `create_auth`**。
327
+ - 读档案的三个函数(`findProfile` / `probeProfile` / `findCorpAuth`)都有可选的 `kind` 参数:
328
+ **不传 = 不按来源收窄**。事件入口必须把「本次验签命中的是哪套凭据」传下去,
329
+ 否则同一家企业有两行时会任取一行(拿到别人的 secret)——这是共存下最容易踩的坑(§8.4)。
330
+ - `disableCorp`(代开发的 `cancel_auth`)**只停 `credential_kind='suite'`** 的行,
331
+ 且「该企业还有其它活跃应用时不吊销会话」——代开发取消授权不该把同一家企业的自建应用一起停掉。
332
+
333
+ ### 6.3 事件入口:一个 `/event`,按命中凭据分派
334
+
335
+ ```text
336
+ POST /api/auth/wecom/event
337
+ ├─ 逐个 Token 试签(SHA1 一次,成本可忽略)→ 命中哪套凭据用它解密
338
+ ├─ kind = corp → handleContactChange(..., { credentialKind: "corp" })
339
+ │ 只可能是通讯录变更;没有套件事件这回事,不进 handleSuiteEvent 的 switch
340
+ └─ kind = suite → handleSuiteEvent(cfg, ...)(suite_ticket / create_auth / change_auth / cancel_auth)
341
+ ```
342
+
343
+ - 解不开时日志会带 `kind` / `envelopeToUser` / `configuredReceiver` / `aesKeyFp`:
344
+ `aesKeyFp` 是本进程**实际加载**那把钥匙的指纹,改完配置对不上就是「没生效(没重启 / 改错文件)」。
345
+ - `receiveid` 的含义随场景变(90968 附注):**第三方事件回调是 suite_id**,
346
+ **企业应用(含代开发数据回调、自建通讯录事件)是 corpid**。所以解密时按
347
+ 「凭据自己的 receiver + 信封 `ToUserName`」两个候选依次试,`bad padding` 不换候选(钥匙就不对)。
348
+ - 回包必须是**纯文本** `success`(信封会被企微判成失败并重推三次);处理失败要**放回 nonce**,
349
+ 否则企微的重推会被自己的重放守卫挡成 400,瞬态故障的恢复路径当场断掉。
350
+
351
+ ### 6.4 登录:零改动,但入口有个兜底
352
+
353
+ - 授权 URL 是 `appid=<corpid>&agentid=<agentId>`,对企业内部应用同样成立;企微内用
354
+ `snsapi_base` 静默拿 code,普通浏览器走 `qrConnect` 扫码。
355
+ - 登录链接**可以不带 `corp`**:`resolveWecomProfile()` 在只有一家企业时自动解析。自建部署还多一层兜底
356
+ `corpSelf()`——配了 `wecom_corp_*` 就把候选范围**限定在本部署那家企业**(agent 也取配置里的),
357
+ 所以 `/login` 永远有确定的默认目标,不用去猜档案里哪一行是「本站自己」。
358
+ 代价是:配了 corp 凭据但还没点「验证并接入」时,不带 `corp` 的登录会 400
359
+ `本次部署配置的企业内部应用尚未接入:请在后台点「验证并接入」`——这是提示,不是缺陷。
360
+ - 换 userid、JIT 建档、登录自更新、一次性票据、302 落地**全复用**,与来源无关。
361
+
362
+ ### 6.5 本地联调
363
+
364
+ 企微只回调**公网 https**,所以本地必须内网穿透。最小闭环:
365
+
366
+ ```sh
367
+ # 1) §2.1 三项 + §2.2 两项填进工程根 config.yaml 的 vars:
368
+ # wecom_base_url 先写成穿透地址:https://<穿透域名>/api/auth/wecom
369
+ # 2) 起前后端
370
+ pnpm dev
371
+ # 3) 把本地 9778(后端)暴露成 https(任意内网穿透工具)
372
+ # 4) 企微后台 → 管理工具 → 通讯录同步 → 接收事件服务器改成 https://<穿透域名>/api/auth/wecom/event
373
+ # 5) /system/wecom 点「验证并接入」
374
+ ```
375
+
376
+ 三个常见坑:
377
+
378
+ - **改了 `config.yaml` 必须重启**(`ojm dev` 不会热更配置);改前端 `src/**/web/**` 会自动重建。
379
+ - **匿名路径**:`/auth/wecom/{login,callback,ticket,event}` 必须同时出现在 `config.yaml` 的
380
+ `auth.anonymous_paths` 与 `tenant.anonymous_paths` 里(模板工程已配好)。漏了会 401,
381
+ 而且报错看起来像「登录失败」,不像「未匿名」。
382
+ - **穿透地址会变**:企微后台保存的 URL、`wecom_base_url`、可信域名三处要一起改,漏一处就是
383
+ 「验证失败」或「redirect_uri 不在可信域名」。
384
+
385
+ ### 6.6 没有真实企业时怎么写测试
386
+
387
+ 企微侧不可自动化(需要管理员点按钮、配 IP),但**接入之后的一切都可以离线造**:
388
+
389
+ 1. 打开开关:`sys_setting` 里 `wecom.enabled = '1'`。
390
+ 2. 造档案行:往 `wecom_app_profile` 插一行 `suite_id=''`、`credential_kind='corp'`、
391
+ `corp_id` / `agent_id` / `auth_mode=0` / `status='active'`,`permanent_code_enc` 用 `wecom_enc_key` 加密。
392
+ 3. 造员工:`users` 里插 `wecom:<userid>`(`source='wecom'`,口令位 `!wecom`)。
393
+ 4. 造事件:按企微报文形态(XML 或 JSON 外层 + `Encrypt`)用 `wecom_corp_encoding_aes_key` 加密,
394
+ 签名用 `wecom_corp_callback_token` 算 SHA1 —— 注意 **`timestamp` 必须在 ±5 分钟内**,
395
+ 用 `Math.floor(Date.now()/1000)`,不要用固定常量(重放守卫会拒)。
396
+ 5. 造同步状态:`wecom_sync_job` 插一行,页面四态(正常 / 暂停 / 失败卡片 / 冻结黄条)都能离线覆盖。
397
+
398
+ 仓库里 `tests/wecom-corp.spec.ts` 就是这套做法(22 例,覆盖来源判定、双应用档案隔离、
399
+ 「验证并接入」四步、后台端点、事件分派、登录入口)。改文案先看它。
400
+
401
+ ### 6.7 前端红线:整页跳转要拼 **API base**
402
+
403
+ 授权入口是**后端端点**,不是前端路由:
404
+
405
+ ```ts
406
+ // 对:API base(部署期可能是 /api,也可能是网关前缀)
407
+ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp, agent)}`);
408
+ ```
409
+
410
+ 拼站点 base 是最隐蔽的错误:根部署下会得到 `/auth/wecom/login`,被 SPA 兜底页吞掉——
411
+ **页面原地重载、一个 auth 请求都不发**,控制台无报错。单测与类型检查都发现不了,只有 e2e 或真机能发现。
412
+
413
+ 同一口径适用于每一个走整页跳转的后端端点(例如 SSO 的 `GET /auth/sso`)。
414
+ 另外,企微面板里受控的输入框(`Form.Item` 无 `name`)必须显式写 `htmlFor` + `id`,
415
+ 否则标签只是视觉文本,读屏与 `getByLabel` 都取不到。
416
+
417
+ ---
418
+
419
+ ## 7. 部署 / 运维手册
420
+
421
+ ### 7.1 上线前 checklist
422
+
423
+ - [ ] 企业在企微后台已创建**自建应用**,拿到 `企业ID` / `Secret` / `AgentId`
424
+ - [ ] `vars` 五项已填(`wecom_corp_id` / `_secret` / `_agent_id` / `_callback_token` / `_encoding_aes_key`),两项复用已填(`wecom_enc_key` / `wecom_base_url`),代开发那几项**留空**
425
+ - [ ] 站点已用 **https** 对外,反代放行 `POST /api/auth/wecom/event`(**请求体原样转发**,不要改写或按表单解析)
426
+ - [ ] 可信域名已备案(含校验文件),企业可信IP 已列全所有出口 IP
427
+ - [ ] 应用可见范围 = **全公司**
428
+ - [ ] 服务器时间已 NTP 同步:回调只接受 ±5 分钟内的 `timestamp`
429
+ - [ ] `wecom_enc_key` 已生成并**离线备份**(与数据库分开)
430
+ - [ ] 部署后跑过 `oj migrate`(需要 `auth/0003__credential_kind` 与 `auth/0002__org_sync` 两组迁移)
431
+ - [ ] `/system/wecom` 点过「验证并接入」,清单里出现该企业、来源 =「企业内部应用」、状态 = 启用
432
+ - [ ] `/system/settings` 的「启用企业微信登录」= 开
433
+ - [ ] 用一个**普通员工**账号完整走过一次登录(不要只用管理员自己的号)
434
+ - [ ] 组织架构同步照 **§7.5** 逐条打勾,四个数已回填验收记录
435
+
436
+ ### 7.2 上线顺序与验收点
437
+
438
+ 照 §4.1 五步走。每步的验收点:
439
+
440
+ | 步 | 验收 |
441
+ |---|---|
442
+ | 1 | 服务能启动(**缺 `vars` 不会阻止启动**,配置是按请求读的:别拿「启动成功」当配置正确),且 `/system/wecom` 能看到「企业内部应用(自建)」卡片 |
443
+ | 2 | 企微后台保存「接收事件服务器」成功(说明 Token / EncodingAESKey 与 `vars` 逐字一致) |
444
+ | 3 | 点「验证并接入」成功,提示 `接入成功:可见范围内 N 个部门,示例部门 M 人`;清单出现该企业 |
445
+ | 4 | 登录页出现「企业微信」页签 |
446
+ | 5 | 普通员工登录成功;`users.display_name` 有名字(不是工号) |
447
+
448
+ ### 7.3 启停与解绑
449
+
450
+ | 动作 | 效果 |
451
+ |---|---|
452
+ | 后台「停用」 | 该行 `status=disabled`,企业 token 缓存作废;**仅当该企业没有其它 active 应用时**才吊销该租户全部会话 |
453
+ | 后台「启用」 | 恢复登录;不发会话(用户下次进入重新登录) |
454
+ | 自建应用没有「取消授权」事件 | 客户在企微侧删应用不会通知本站:症状是登录 400 / 同步 `40001`。处置:重新建应用 → 改配置 → 重启 → 重「验证并接入」 |
455
+
456
+ > 停用**不会**删除账号与业务数据,只是拦住新登录并(在无其它应用时)断掉已有会话。
457
+
458
+ ### 7.4 密钥与备份
459
+
460
+ - 应用 Secret 只以密文存在 `wecom_app_profile.permanent_code_enc`;任何接口(含后台清单)都不返回明文,日志里也不打。
461
+ - 备份必须是**两份**:`db.sqlite` + `wecom_enc_key`。只备份数据库 → 换机后要重新「验证并接入」。
462
+ - 重置应用 Secret 会让当前企业 token 缓存失效 → 下一次调用自动重换;但**要同步改 `config.yaml` 并重启**,
463
+ 否则 `40001`。
464
+
465
+ ### 7.5 组织架构同步:真机验收清单
466
+
467
+ 单测只能证明「拿到这样的上游回包时我们这样写库」,证明不了「上游真的这样回」——后者在这里收口。
468
+ 判定口径是**数字**:部门数、人数、跳过数、耗时四个数写不出来,就等于没验。
469
+
470
+ | # | 操作 | 期望观测 | 不符时先看 |
471
+ |---|---|---|---|
472
+ | 1 | `/system/departments` 点「立即同步」,等状态条停下 | 「企微组织架构更新于 **刚刚** · 自动同步」;`SELECT status, stats_text FROM wecom_sync_job` → `done`,`stats.departments` / `stats.members` 都 > 0 | `status=paused` → 再点「继续同步」(分片没推完,不是失败);`err_code` 非空 → 页面卡片对应项 |
473
+ | 2 | 抽 3 个部门核对人数与层级 | 人数与企微后台一致;层级正确;**主部门唯一** | 人数全为 0 → 可见范围为「仅部分成员」;层级全平 → 部门权限缺「部门及父部门 ID」 |
474
+ | 3 | 看人名 | 显示名是人名(`display_name` 有值) | 满屏 `wecom:<工号>` → 缺「成员姓名」权限 |
475
+ | 4 | 看职务列 | 有职务 | 全空 → 缺「职务」权限 |
476
+ | 5 | 看状态条的跳过计数 | 「N 个部门因权限被跳过」,且 N **远小于**部门总数 | N 与总数接近 → 可见范围没设全公司 |
477
+ | 6 | 让一个**普通员工**登录一次 | 该员工 `display_name` / `position` 被回写,`sys_dept_member` 与企微一致 | 登录成功但资料没变 → 不在可见范围,或 24 小时内已自更新过 |
478
+ | 7 | 先在本地建一个部门,再点一次同步 | 本地部门**不消失、不改名**(数据源只管辖它自己带来的行) | 被覆盖 → 严重缺陷,保留现场再报 |
479
+ | 8 | 后台「停用」该应用后刷新组织架构页 | 冻结黄条,数据保留;不给同步按钮 | 仍能同步 → `status` 没落 `disabled` |
480
+ | 9 | **事件增量**:请客户改一个部门名,或加/删一个人 | 服务端日志 `wecom change_contact applied … changeType=…`;**不点同步**刷新页面也能看到变化 | 见下表 |
481
+ | 10 | 在企微侧重置应用 Secret | 下一次登录 / 同步报 `40001` | 若仍正常 → 还在用缓存 token,属正常窗口;重启后应报错 |
482
+
483
+ 第 9 行的几种结果,一次就能分清「事件到了没 / 处理成没成」:
484
+
485
+ | 日志 | 说明 | 下一步 |
486
+ |---|---|---|
487
+ | `wecom change_contact applied … changeType=create_user` | 通讯录同步回调到了,且落库成功 | 正常 |
488
+ | `wecom change_contact ignored … reason=该企业未授权本站(wecom_app_profile 无记录)` | 报文到了、也解密了,但档案表里没有这家企业(或 `credential_kind` 不匹配) | 先确认「验证并接入」真的成功过、`status=active` |
489
+ | `wecom change_contact ignored … reason=企微事件 change_contact 缺少 UserID` | 明文里缺字段:报文形态与文档不符,或被中间件改写 | 把 `changeType` 与原始明文交给开发;重推解决不了 |
490
+ | `wecom callback decrypt failed … reason=bad padding` | 签名过了但**钥匙**不对 → 这条推送不属于这套凭据 | 看日志里的 `kind` 与 `aesKeyFp`:`kind=suite` 说明它其实命中/错配了代开发那套 |
491
+ | 一条日志都没有 | 事件根本没进本站 | 「接收事件服务器」URL 没配 / 反代挡了 POST |
492
+
493
+ ```bash
494
+ grep -h "wecom change_contact\|wecom event failed\|callback decrypt failed" logs/server-*.log | tail -20
495
+ ```
496
+
497
+ **验收记录(请回填,别只写「看起来正常」)**
498
+
499
+ | 字段 | 值 |
500
+ |---|---|
501
+ | 企业代码 / AgentId | |
502
+ | 部门数 / 人数 / 跳过数 | |
503
+ | 单次全量耗时(`finished_at - started_at`) | |
504
+ | 事件增量那行日志原文(`changeType=`) | |
505
+ | 结论 | 通过 / 不通过(不通过项编号) |
506
+
507
+ > 跳过数不为 0 不是失败(可见范围外是硬口径);但「跳过数接近总数」或「主部门不唯一」必须判不通过。
508
+ > 自建模式下这两条多半就是「可见范围没设全公司」。
509
+
510
+ ---
511
+
512
+ ## 8. 与代开发共存(一个部署同时服务两种应用)
513
+
514
+ ### 8.1 两种共存,代价完全不同
515
+
516
+ | 场景 | 代价 | 说明 |
517
+ |---|---|---|
518
+ | **不同企业共存**(本站自己用自建应用 + 子公司 / 客户走代开发) | 几乎为零 | 所有运行时路径都以 `corp_id` 为租户键,两套档案天然隔离 |
519
+ | **同一企业双应用共存**(同一家既有代开发应用又有自建应用,常见于迁移期) | 六处「把应用当成唯一」的代码要按 `credential_kind` 收口(已实现,见 §8.4) | 需要清单里能分辨、事件能选对密钥、同步能带 agent |
520
+
521
+ 身份层天然合并:JIT 建档的用户名是 `wecom:<userid>`,与凭据来源无关——同一个人在两条链路下
522
+ 登录的是**同一个账号**。
523
+
524
+ ### 8.2 配置:三段
525
+
526
+ ```yaml
527
+ # 代开发那套(已有则不动)
528
+ wecom_suite_id: 'ww…'
529
+ wecom_suite_secret: '…'
530
+ wecom_callback_token: '…'
531
+ wecom_encoding_aes_key: '…'
532
+
533
+ # 自建那套
534
+ wecom_corp_id: 'ww…'
535
+ wecom_corp_secret: '…'
536
+ wecom_corp_agent_id: '1000002'
537
+ wecom_corp_callback_token: '' # 可选
538
+ wecom_corp_encoding_aes_key: '' # 可选
539
+
540
+ # 两套都填齐时必须显式开
541
+ wecom_allow_coexist: 'true' # 取值 1 / true / yes
542
+ ```
543
+
544
+ 不开 `wecom_allow_coexist` 而两套凭据都齐 → 首个请求 400:
545
+ `企业微信未配置:config vars 缺少 wecom_allow_coexist: 'true'(检测到代开发与自建应用两套凭据同时配置;确认要共存请显式开启)`。
546
+ 这是**故意的**:配置事故(比如从代开发迁到自建却忘了清旧变量)应当被看见,而不是被静默择一。
547
+
548
+ ### 8.3 页面长什么样
549
+
550
+ - `/system/wecom` 两块卡片按「该类凭据是否齐」各自显示:代开发 → 「生成开通链接」;
551
+ 自建 → 「企业内部应用(自建)」+「验证并接入」。两块同时存在时页面顶部有一条「本部署同时启用」的提示。
552
+ - 清单表新增**来源**列(代开发 / 企业内部应用),「授权模式」列对自建行显示 `—`
553
+ (自建没有「管理员授权 / 成员授权」这回事,同步闸门恒通过)。
554
+ - 部署配置自检未通过时,页面顶部红条直接说出缺什么(`GET /auth/wecom/admin/overview` 的 `configError`),
555
+ 而不是整页失败。
556
+
557
+ ### 8.4 六处「把应用当成唯一」的地方(已收口,改代码时别拆掉)
558
+
559
+ | 语义 | 现在的行为 |
560
+ |---|---|
561
+ | 解析档案 | `findProfile` / `probeProfile` 带可选 `kind`;不带时才可能因多行报「必须带 agent」 |
562
+ | 登录入口兜底 | 不带 `corp` 时优先用配置里的「本站自建应用」;没有则按已有档案解析(不再有硬编码的默认 AgentId) |
563
+ | 取企业密钥 | `findCorpAuth(corpId, deps, kind)`:**事件入口必须传本次验签命中的来源**,否则同企业两行会任取一行 |
564
+ | 取消授权 | `cancel_auth` **只停 `credential_kind='suite'`**;且该企业还有其它活跃应用时不吊销会话 |
565
+ | 授权模式复核 | 只遍历 suite 行(自建行没有 `auth_mode` 概念) |
566
+ | 组织架构同步 | 状态视图返回 `agentId`,前端 start / cancel / status 原样回传;同企业双应用不传 agent 就会 400 |
567
+
568
+ ### 8.5 两组回调凭据不能互相顶替
569
+
570
+ | 位置 | 用哪组凭据 | 谁填 |
571
+ |---|---|---|
572
+ | 代开发「指令回调 URL」 | `wecom_callback_token` / `wecom_encoding_aes_key` | 服务商在代开发模板详情页填 |
573
+ | 自建「通讯录同步 → 接收事件服务器」 | `wecom_corp_callback_token` / `wecom_corp_encoding_aes_key` | 企业管理员在自己后台填 |
574
+
575
+ **地址都是同一个 `<base>/event`**,靠逐个试签分辨;两组凭据不同源,把代开发那组填进自建的位置
576
+ (或反之)只会在保存 URL 时验签失败,不会「将就着能用」。
577
+
578
+ ### 8.6 从代开发迁到自建
579
+
580
+ 1. 备好自建应用的三项 + 可选的通讯录回调两项,填进 `vars`;
581
+ 2. 重启 → 点「验证并接入」(此时会多出一行 `credential_kind=corp`);
582
+ 3. 员工先验证一次登录(`wecom:<userid>` 账号是同一个,数据不丢);
583
+ 4. 确认无误后,把代开发的 `wecom_suite_*` / `wecom_provider_*` / `wecom_callback_token` /
584
+ `wecom_encoding_aes_key` 清空,并去掉 `wecom_allow_coexist`,重启;
585
+ 5. 清单里代开发那几行可以停用(不删,留作回滚)。
586
+
587
+ > 反过来(自建 → 代开发)同理:先配代开发、开通、验证登录,再清自建变量。
588
+ > 迁移期两套并存需要 `wecom_allow_coexist: 'true'`。
589
+
590
+ ---
591
+
592
+ ## 9. 故障速查
593
+
594
+ | 现象 / 文案 | 病根 | 处置 |
595
+ |---|---|---|
596
+ | 400 `企业微信未配置:config vars 缺少 wecom_corp_id / wecom_corp_secret / wecom_corp_agent_id(自建应用:企业ID / 应用Secret / AgentId,均在企微管理后台取)` | 三项没配齐 | 补 §2.1,重启 |
597
+ | 400 `企业微信未配置:config vars 缺少 wecom_enc_key / wecom_base_url` | 两项复用凭据为空 | 补 §2.2;注意它们**两种来源都要** |
598
+ | 400 `wecom_base_url 必须以 /api/auth/wecom 结尾` | 只写了域名,或写了别的路径 | 改成 `https://<站点>/api/auth/wecom`(无尾斜杠) |
599
+ | 400 `wecom_enc_key 必须是 16 或 32 字节 …` | 随手敲的 32 字符被按 base64 解成 24 字节 | `openssl rand -hex 32` 重新生成(§2.5) |
600
+ | 400 `config vars 缺少 wecom_allow_coexist: 'true'` | 两套凭据都填了但没显式开共存 | 要么清掉一套,要么加 `wecom_allow_coexist: 'true'` |
601
+ | 400 `本部署未配置企业内部应用凭据(wecom_corp_id / wecom_corp_secret / wecom_corp_agent_id)` | 卡片不该出现却点了接口 / 配置没生效 | 补全三项并重启;刷新页面看顶部红条 |
602
+ | 400 `接入校验失败:应用可见范围里没有任何部门…` | 可见范围空 / Secret 与 AgentId 不是同一个应用 | 设为全公司;核对三项来自同一个应用 |
603
+ | 400 `接入校验失败:可见范围内读不到成员…` | 可见范围内只有空部门 | 扩大到至少一名成员 |
604
+ | 502 `企微 gettoken 失败:errcode=40001` | Secret 错 / 已重置 / 改完没重启 | 重新复制 Secret → 重启 → 重「验证并接入」 |
605
+ | 502 `errcode=60020 not allow to access from your ip` | 出口 IP 不在企业可信IP | 加上报错里的 `client ip`(公网出口);多出口加 CIDR |
606
+ | 502 `errcode=60011` / `48002` | 没有通讯录读权限(可见范围为空 / 权限没开) | 可见范围设全公司 |
607
+ | 企微后台保存「接收事件服务器」失败 | Token / EncodingAESKey 与 `vars` 不一致 | 逐字对齐(43 字符别多空格),改完**重启**再保存 |
608
+ | 日志 `wecom callback decrypt failed … reason=bad padding` | 签名过了、钥匙不对:EncodingAESKey 抄错,或这条推送属于另一套凭据 | 看 `kind` / `aesKeyFp`:改完 config 指纹没变 = 没重启 / 改错文件 |
609
+ | 日志 `wecom callback decrypt failed … reason=receiver mismatch` | 钥匙对、报文自称的身份两边都不匹配 | 信封缺 `ToUserName` 时会出现;确认接收事件服务器填的是本部署地址 |
610
+ | 400 `回调签名校验失败` | 没有任何一套凭据的 token 能验过这条报文 | 核对两边 Token;共存时确认填的是「通讯录同步」那组 |
611
+ | 400 `回调 timestamp 超出允许窗口…` | 服务器时钟偏差 > 5 分钟,或旧报文被回放 | 校 NTP;校正后企微重推会自动成功 |
612
+ | 400 `企业代码无效或应用未开通` | `corp` 参数打错,或档案行不存在 | 核对企业ID;确认「验证并接入」成功过 |
613
+ | 400 `尚未完成企业微信接入(无可用应用档案)…请在后台点「验证并接入」` | 档案表里没有任何活跃行 | 点「验证并接入」(§4.2) |
614
+ | 400 `本次部署配置的企业内部应用尚未接入:请在后台点「验证并接入」` | 配了 `wecom_corp_*` 但还没成功接入过(登录候选范围被限定在这家企业) | 点「验证并接入」;接入成功前要么用带 `corp` 的链接登别家企业,要么先完成接入 |
615
+ | 401 `登录凭据无效或不在应用可见范围内` | code 无效 / 员工不在可见范围 / 非本企业成员 | 企微后台检查可见范围 |
616
+ | 401 `登录状态已失效,请从企业微信重新进入` | `state` 过期(10 分钟)/ 已用 / 换过部署 | 重新进 |
617
+ | 403 `账号在企业微信中已被停用或已退出企业` | 企微侧离职 / 禁用 | 企业管理员处理;本站不反向覆盖自己的 `is_forbidden` |
618
+ | 400 `本站尚未开通企业微信:没有可同步的授权记录` | 档案表里没有该租户的行 | 走 §4.1;自建模式下「租户」= 企业ID |
619
+ | 400 `该企业已取消授权或应用已停用:组织架构同步已冻结…` | `status=disabled`(后台点了停用) | 后台启用;数据保留可当快照 |
620
+ | 组织架构页卡片「应用缺少通讯录权限(60011 / 48002)」 | 可见范围过窄,或权限没配 | 可见范围设全公司 |
621
+ | 组织架构页卡片「企微接口限流(45009 / 45033)」 | 触发频率 / 并发限制(大企业全量常见) | 已自动退避三次;仍失败点「继续同步」,游标不丢 |
622
+ | 状态条「N 个部门因权限被跳过」 | 这些部门在应用可见范围外 | 常态可忽略;N 接近总数 → 可见范围没设全公司 |
623
+ | 409 `同步正在进行,请稍后再试` | 同一企业同一应用已有同步在跑 | 等一个轮询周期;崩溃残留的锁 120 秒后可被抢占 |
624
+ | 429 `rate limited` | `start` / `cancel` 限速 10 次/分 | 等一分钟;进度已落库,不用从头来 |
625
+ | 点登录页「企业微信」按钮页面只闪一下、无请求 | 跳转拼了站点 base 而不是 API base | 见 §6.7 |
626
+ | 登录页没有「企业微信」页签 | `wecom.enabled` 未开 | `/system/settings` 打开 |
627
+ | 换过 Secret 后所有调用都 `40001` | 没有重新「验证并接入」 | 点一次即可(§4.4) |
628
+
629
+ ---
630
+
631
+ ## 10. 一分钟自检
632
+
633
+ ```text
634
+ [ ] 五项 wecom_corp_* + wecom_enc_key + wecom_base_url 非空;代开发那几项留空(或显式开共存)
635
+ [ ] wecom_base_url 以 /api/auth/wecom 结尾,且 https 可达
636
+ [ ] 企微「通讯录同步 → 接收事件服务器」URL == <wecom_base_url>/event(逐字);应用「接收消息」留空
637
+ [ ] 企微侧:可信域名 / 企业可信IP / 可见范围=全公司 三项都已配
638
+ [ ] /system/wecom 清单里有该企业,来源=企业内部应用,状态=启用
639
+ [ ] /system/settings 的「启用企业微信登录」= 开
640
+ [ ] 用普通员工账号(非管理员)完整走过一次登录,显示名是人名
641
+ [ ] /system/departments 顶部状态条有「更新于 X」;树有名字、人挂在正确部门
642
+ [ ] db.sqlite 与 wecom_enc_key 各自有备份
643
+ ```