@oj-module/cli 0.2.47 → 0.2.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/README.md +1 -1
- package/devkit/SKILL.md +23 -4
- package/devkit/framework-dev.md +2 -2
- package/devkit/manual.md +221 -14
- package/devkit/module-dev-and-cdn-playground.md +2 -2
- package/devkit/wecom-daikaifa.md +242 -14
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/build-backend.test.ts +80 -0
- package/src/__tests__/templates-tenant-scope.test.ts +7 -2
- package/src/build.ts +50 -0
- package/src/init.ts +1 -1
- package/src/market/__tests__/bootstrap.test.ts +1 -1
- package/src/market/__tests__/setup.test.ts +7 -7
- package/src/market/bootstrap.ts +1 -1
- package/templates/README.md +1 -1
- package/templates/init.include.yaml +12 -0
- package/templates/openapi.json +2863 -549
- package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
- package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
- package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
- package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
- package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
- package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
- package/templates/src/_platform/api/manifest.yaml +3 -0
- package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
- package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
- package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
- package/templates/src/_platform/api/org/departments/api.ts +51 -0
- package/templates/src/_platform/api/org/is-member/api.ts +33 -0
- package/templates/src/_platform/api/org/members/api.ts +55 -0
- package/templates/src/_platform/api/org/picker/api.ts +59 -0
- package/templates/src/_platform/api/org/resolve/api.ts +38 -0
- package/templates/src/_platform/api/org/tree/api.ts +37 -0
- package/templates/src/_platform/api/org/user/api.ts +46 -0
- package/templates/src/_platform/api/schema.yaml +48 -0
- package/templates/src/_platform/api/seed.mysql.sql +10 -5
- package/templates/src/_platform/api/seed.pg.sql +10 -5
- package/templates/src/_platform/api/seed.sql +13 -5
- package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
- package/templates/src/_platform/web/__client/api.ts +170 -1
- package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
- package/templates/src/auth/api/_shared/session.ts +5 -1
- package/templates/src/auth/api/_shared/wecom/admin.ts +3 -1
- package/templates/src/auth/api/_shared/wecom/client.ts +280 -32
- package/templates/src/auth/api/_shared/wecom/config.ts +12 -1
- package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
- package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
- package/templates/src/auth/api/_shared/wecom/login.ts +188 -5
- package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
- package/templates/src/auth/api/_shared/wecom/replay.ts +44 -3
- package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
- package/templates/src/auth/api/_shared/wecom/sync.ts +501 -0
- package/templates/src/auth/api/manifest.yaml +3 -0
- package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
- package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
- package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
- package/templates/src/auth/api/schema.yaml +31 -0
- package/templates/src/auth/api/wecom/admin/api.ts +3 -1
- package/templates/src/auth/api/wecom/api.ts +155 -35
- package/templates/src/auth/api/wecom/sync/api.ts +199 -0
- package/templates/src/auth/web/__client/api.schemas.ts +97 -0
- package/templates/src/auth/web/__client/api.ts +73 -1
- package/templates/src/login/web/locales/en-US.json +2 -0
- package/templates/src/login/web/locales/zh-CN.json +2 -0
- package/templates/src/login/web/pages/login.tsx +39 -3
- package/templates/src/notification/api/seed.sql +1 -1
- package/templates/src/system/api/departments/api.ts +142 -0
- package/templates/src/system/api/departments/detach/api.ts +34 -0
- package/templates/src/system/api/departments/members/api.ts +142 -0
- package/templates/src/system/api/departments/move/api.ts +39 -0
- package/templates/src/system/api/users/api.ts +45 -5
- package/templates/src/system/api/users/profile/api.ts +44 -0
- package/templates/src/system/web/__client/api.schemas.ts +143 -0
- package/templates/src/system/web/__client/api.ts +293 -1
- package/templates/src/system/web/entry.ts +6 -0
- package/templates/src/system/web/locales/en-US.json +112 -6
- package/templates/src/system/web/locales/zh-CN.json +112 -6
- package/templates/src/system/web/pages/departments/index.tsx +346 -0
- package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
- package/templates/src/system/web/pages/departments/tree.ts +66 -0
- package/templates/src/system/web/pages/errors.ts +1 -1
- package/templates/src/system/web/pages/users/index.tsx +49 -7
- package/templates/src/system/web/pages/wecom/index.tsx +14 -0
- package/templates/tests/l1-builder.test.ts +1 -1
- package/templates/tests/org-departments.spec.ts +311 -0
- package/templates/tests/org-read.spec.ts +469 -0
- package/templates/tests/rbac-contacts.spec.ts +13 -3
- package/templates/tests/rbac-seed.spec.ts +11 -0
- package/templates/tests/seed-role-accounts.spec.ts +6 -2
- package/templates/tests/wecom-admin.spec.ts +1 -0
- package/templates/tests/wecom-connector.spec.ts +146 -10
- package/templates/tests/wecom-crypt.spec.ts +12 -3
- package/templates/tests/wecom-endpoints.spec.ts +247 -19
- package/templates/tests/wecom-org-sync.spec.ts +598 -0
- package/templates/tests/wecom-sync.spec.ts +286 -0
- package/vendor/host-versions.json +3 -3
- package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
package/devkit/wecom-daikaifa.md
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
|
|
13
13
|
| 你是 | 读 | 大约要做什么 |
|
|
14
14
|
|---|---|---|
|
|
15
|
-
| 部署 / 服务商运营 | §2 §3 §4 §7 §8 |
|
|
15
|
+
| 部署 / 服务商运营 | §2 §3 §4 §7 §8 | 备凭据、填后台、按顺序开通、日常启停与轮换;真机验收照 **§7.6** 打勾 |
|
|
16
16
|
| 本工程开发者 | §1 §6 §8 | 本地联调、加企业、改前端、写没有真实套件的自动化测试 |
|
|
17
|
+
| 企业管理员(客户的 IT) | §2.5 §2.6 §6.8 §7.6-B §8 | 开四项通讯录权限、选对授权模式、读懂同步页面上的卡片 |
|
|
17
18
|
| 企业员工(用户) | §5 | 怎么进、要不要注册、进不去怎么办 |
|
|
18
19
|
|
|
19
20
|
---
|
|
@@ -45,6 +46,24 @@
|
|
|
45
46
|
- 「哪个企业装了哪个应用」**不写在配置文件里**:新增企业只是多一行数据,不改配置、不重启。
|
|
46
47
|
- JIT(Just-In-Time)建档:员工第一次登录时自动建本地账号,用户名 `wecom:<企微userid>`。
|
|
47
48
|
|
|
49
|
+
第四条链路是**组织架构同步**(前三条是登录,这条是通讯录);它同样是「授权一次、之后自动跑」:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
企业管理员授权(auth_mode = 管理员授权,且四项通讯录权限已生效,§2.5)
|
|
53
|
+
│
|
|
54
|
+
├─ 轨一 登录自更新:某员工登录时顺手 user/get 一次(24h 节流)→ 补姓名/职务/部门
|
|
55
|
+
├─ 轨二 全量同步:/system/departments 点「立即同步」→ department/simplelist + user/list
|
|
56
|
+
│ └─ 分片推进(一片 12 秒)+ 调用方轮询 → 进度落在 wecom_sync_job
|
|
57
|
+
└─ 轨三 事件增量:change_contact(成员/部门增删改即时落库;失败只记日志,等轨二兜底)
|
|
58
|
+
↓
|
|
59
|
+
sys_department / sys_dept_member(origin='wecom')+ users(activated=0)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- **归属权**(`origin`)决定谁能改:`local` 的行同步一根毛都不碰;`wecom` 的行管理员只能「转为本地维护」
|
|
63
|
+
之后才改得动,改了之后同步也不再覆盖。
|
|
64
|
+
- **可见范围永远是子集**:代开发只能拉到「本应用可见范围内」的部门,授权也解不开(§2.4)——
|
|
65
|
+
页面上「N 个部门因权限被跳过」通常就是它。
|
|
66
|
+
|
|
48
67
|
---
|
|
49
68
|
|
|
50
69
|
## 2. 凭据清单
|
|
@@ -82,7 +101,7 @@ Tier B 为空时服务仍可启动并响应 echostr,但 suite 流程会 400—
|
|
|
82
101
|
| 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
102
|
| 2 | `wecom_suite_secret` | Tier B | 同页「基本信息」标作「**模板Secret**」,即 **SuiteSecret**(可重置) | 非空字符串;重置后旧值立即失效 | 换不出 `suite_access_token` → 502 `企微 get_suite_token 失败:errcode=40001 …`;开通链接生成也失败 |
|
|
84
103
|
| 3 | `wecom_callback_token` | Tier A | 同页 **Token**(自设,字母数字 3–32 位) | 与企微后台**逐字一致** | 保存回调 URL 时 `echostr` 解不解得开;事件被判验签失败,企微持续重推 |
|
|
85
|
-
| 4 | `wecom_encoding_aes_key` | Tier A | 同页 **EncodingAESKey
|
|
104
|
+
| 4 | `wecom_encoding_aes_key` | Tier A | 同页 **EncodingAESKey**(自设,43 字符) | **43 字符**,且与企微后台**逐字一致**(差一个字符就是 `pkcs7: bad padding`) | 回调报文解不开、`suite_ticket` 永远收不到;企业管理员扫码后提示「应用安装失败」 |
|
|
86
105
|
| 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
106
|
| 6 | `wecom_base_url` | Tier A | **基址**:你站点的对外地址 + `/api/auth/wecom`(注意这是基址,**不是**企微后台要填的回调 URL) | **绝对地址、无尾斜杠、必须以 `/api/auth/wecom` 结尾** | 400 `wecom_base_url 必须是绝对地址` 或 `必须以 /api/auth/wecom 结尾`;配错还会让登录回跳落到别的应用或 404 |
|
|
88
107
|
| 7 | `wecom_provider_id` | Tier C | 服务商后台 → **服务商信息**页显示的 CorpID(服务商自己的 corpid,不是企业 corpid,也不是 suite_id) | 非空字符串 | 只影响「生成开通链接」:`get_provider_token` 换不出 `provider_access_token` → 生成链接 502;此项为空 → 生成链接 400 `config vars 缺少 wecom_provider_id` |
|
|
@@ -96,9 +115,15 @@ Tier B 为空时服务仍可启动并响应 echostr,但 suite 流程会 400—
|
|
|
96
115
|
`https://example.com/oj/api/auth/wecom` → 回跳 `https://example.com/oj/login`)
|
|
97
116
|
|
|
98
117
|
> ⚠️ **`wecom_base_url` 不是回调 URL 本身,别把它直接填进企微后台。** 企微后台要填的
|
|
99
|
-
> 「指令回调 URL」是 `<wecom_base_url>/event`(**必须带 `/event
|
|
100
|
-
> 没有任何 handler,企微验签时会打到它 → **404 → 验证失败、模版保存不了**。
|
|
118
|
+
> 「指令回调 URL / 应用回调 URL」是 `<wecom_base_url>/event`(**必须带 `/event`**)。
|
|
119
|
+
> 裸的 `/api/auth/wecom` 没有任何 handler,企微验签时会打到它 → **404 → 验证失败、模版保存不了**。
|
|
101
120
|
> 派生关系如上:回调 URL = base + `/event`,网页授权回跳 = base + `/callback`。
|
|
121
|
+
>
|
|
122
|
+
> **别给这个 URL 加 `?callback_format=json`。** 这个参数只在「对外收款」的指令回调文档(97478)里有说明;
|
|
123
|
+
> 代开发 / 第三方应用所属的**通用回调配置**(96238 §3.2)只写 XML 信封,「部门通知事件」(92655)
|
|
124
|
+
> 虽然给了 JSON 示例、但示例地址同样不带它 —— 也就是说**我们这类回调没有文档依据**。
|
|
125
|
+
> 服务端两种包体都收(归一化在 `payload.ts`,XML / JSON 谁先来都不影响),所以哪天上游默认改 JSON
|
|
126
|
+
> 也不用动这里;但在文档给出依据前,地址就照文档写,别自己加参数。
|
|
102
127
|
|
|
103
128
|
### 2.2 两项备案(都在企微后台,不写在代码里)
|
|
104
129
|
|
|
@@ -131,14 +156,48 @@ openssl rand -hex 32 # 输出 64 个 hex 字符,直接贴进 wecom_enc_
|
|
|
131
156
|
用新 key 重写。轮换前后要留旧 key 用于读取,直到所有企业都重授权完毕。
|
|
132
157
|
- 与数据库分开保管:`db.sqlite` 备份 + `wecom_enc_key` 备份,**缺任何一个都等于全部企业要重新授权**。
|
|
133
158
|
|
|
159
|
+
### 2.5 组织架构四项权限(同步能不能拿到「人话」全看这四项)
|
|
160
|
+
|
|
161
|
+
组织架构同步读的是通讯录接口,能不能读到**姓名 / 部门名 / 职务 / 完整部门树**,由四项权限决定。
|
|
162
|
+
官方把它们分成两类,处置方式完全不同:
|
|
163
|
+
|
|
164
|
+
| # | 权限 | 类型 | 没申请的症状 |
|
|
165
|
+
|---|---|---|---|
|
|
166
|
+
| ① | 成员基本信息 → **姓名** | 基础 | 满屏工号(`name` 回成 userid) |
|
|
167
|
+
| ② | 成员基本信息 → **部门名** | 基础 | **一棵全无名节点的树**——注意它**不在**「组织架构」权限下,是最容易漏的一项 |
|
|
168
|
+
| ③ | 成员基本信息 → **职务** | 自定义 | `users.position` 一直是空 |
|
|
169
|
+
| ④ | 组织架构 → **部门及父部门 ID、部门负责人** | 自定义 | `department[]` 不完整,只拿得到可见范围内的部门 id |
|
|
170
|
+
|
|
171
|
+
- **基础**权限扫码即得、不可编辑;**自定义**权限每次编辑保存会向企业推一条权限变更消息(`97201`),
|
|
172
|
+
中间存在「**待企业客户确认**」状态,**企业没确认就不生效**。
|
|
173
|
+
- 重发开通链接只是**幂等 upsert**,**补不了权限**——必须企业管理员在企微后台确认。
|
|
174
|
+
- 本系统**不画**「未申请 / 待企业确认 / 已生效」三态:企微没有开放查询权限授予状态的接口,
|
|
175
|
+
画出来的三态只能靠猜。替代做法是**按症状反推**——同步完成或失败时页面会给自助卡片
|
|
176
|
+
(见 §6.8 与 §8),卡片直说「缺少通讯录权限」,对照上表自查即可。这是取舍,不是遗漏。
|
|
177
|
+
|
|
178
|
+
### 2.6 成员授权模式(阻断级:这种企业装上了也同步不了)
|
|
179
|
+
|
|
180
|
+
企业授权时选的是「**管理员授权**」还是「**成员授权**」,`get_permanent_code` 的
|
|
181
|
+
`auth_info.agent[].auth_mode` 会把它带回来(0=管理员 / 1=成员)。**成员授权模式下应用没有任何部门权限**,
|
|
182
|
+
`department/*`、`user/*` 一律 `60011`——跑同步只会得到一片无权限错误。
|
|
183
|
+
|
|
184
|
+
本系统把它落进 `wecom_app_profile.auth_mode`,并在三处用上:
|
|
185
|
+
|
|
186
|
+
- `POST /auth/wecom/sync/start`:`auth_mode=1` → **400 明确文案,一个上游请求都不发**;
|
|
187
|
+
- 组织架构页:直接给「该企业为成员授权模式」卡片,**不给同步按钮**(点了必然是 400);
|
|
188
|
+
- 代开发后台清单:「授权模式」列让服务商一眼看出哪家企业「装了但同步不了」。
|
|
189
|
+
|
|
190
|
+
企业管理员在企微后台把模式改回「管理员授权」后,企微只推一个 `change_auth`(**不带 AuthCode**),
|
|
191
|
+
本系统会用已存的 `permanent_code` 反查 `get_auth_info` 刷新 `auth_mode`,不需要重新开通。
|
|
192
|
+
|
|
134
193
|
---
|
|
135
194
|
|
|
136
195
|
## 3. 要填进企微后台的地址(逐字)
|
|
137
196
|
|
|
138
197
|
| 企微后台位置 | 值 | 说明 |
|
|
139
198
|
|---|---|---|
|
|
140
|
-
|
|
|
141
|
-
|
|
|
199
|
+
| **模板**回调 URL(指令回调 / 事件接收) | `https://<站点>/api/auth/wecom/event` | 点保存时企微会 GET 一次带 `echostr`:必须**当次就通**,否则保存不了。`suite_ticket` 与 `create_auth` / `change_auth` / `cancel_auth` 都走这里 |
|
|
200
|
+
| **应用**回调 URL(数据回调 / 通讯录变更) | **同上** `https://<站点>/api/auth/wecom/event` | 一个端点吃两种回调,不用再开路由。`change_contact`(成员/部门增删改)走的就是这里,收到即落库、**不必等下一次全量同步**;处理失败只记一条 `wecom change_contact ignored …` 并照旧回 `success`(否则企微重推三次) |
|
|
142
201
|
| 可信域名 | `https://<站点域名>` | 不含路径 |
|
|
143
202
|
| 网页授权 `redirect_uri` | **不用手填**:后端按 `wecom_base_url` 拼出 `https://<站点>/api/auth/wecom/callback` | 企业的授权页里的域名必须与可信域名一致 |
|
|
144
203
|
|
|
@@ -178,7 +237,7 @@ openssl rand -hex 32 # 输出 64 个 hex 字符,直接贴进 wecom_enc_
|
|
|
178
237
|
> 的 `roles.includes("superadmin")`)与后端 `auth/wecom/admin/*`(每个端点 `assertSuperAdmin()`)
|
|
179
238
|
> 都只放行 `superadmin`。模板 seed 已把 `admin` 从 `superadmin` 角色解绑(收敛为「账号名 = 角色名」,
|
|
180
239
|
> 见 `_platform/api/seed.sql` 的 `DELETE … WHERE role_name='superadmin' AND username='admin'`),
|
|
181
|
-
> 所以拿 `admin` 登录会直接显示「无权限」。请用 `superadmin` 登录(seed 口令 `
|
|
240
|
+
> 所以拿 `admin` 登录会直接显示「无权限」。请用 `superadmin` 登录(seed 口令 `aA123456`,
|
|
182
241
|
> **登录后立即改密**);若坚持让 `admin` 也能进,由 `superadmin` 在 `/system/roles` 把 `admin`
|
|
183
242
|
> 绑到 `superadmin`,但不推荐——这会取消「日常管理员 vs 跨租户运营面」的隔离。
|
|
184
243
|
|
|
@@ -269,31 +328,65 @@ pnpm dev
|
|
|
269
328
|
- 开通链接支持环境标记:`state` 传 `env=test`(或 `test` / `staging` / `dev`)会落到
|
|
270
329
|
profile 的 `env` 列,缺省 `prod`。同一套代码区分「测试企业」与「正式企业」就靠它。
|
|
271
330
|
|
|
272
|
-
### 6.3 数据面:三张共享表 +
|
|
331
|
+
### 6.3 数据面:三张共享表 + 五张租户表
|
|
332
|
+
|
|
333
|
+
> **模型归 `_platform`,企微只是适配器**:`sys_department` / `sys_dept_member` 与共享读面
|
|
334
|
+
> `/api/_platform/org/*` 属于**组织架构底座**——不接企微的部署也有它,接 LDAP 也只是换一个喂数据的源。
|
|
335
|
+
> 要选人、要按部门(含下级)算收件人、要给组织架构加新来源,读 `manual.md` 的 **§14**;
|
|
336
|
+
> 本节只讲企微这一半的落库事实与坑。
|
|
273
337
|
|
|
274
338
|
| 表 | 是否租户表 | 存什么 | 为什么这样放 |
|
|
275
339
|
|---|---|---|---|
|
|
276
|
-
| `wecom_app_profile` | **共享**(`tenant: false`) | 企业 × 应用 → 密文 `permanent_code`、`corp_name`、`env`、`status` | 它是「corp → 租户」映射的**权威表**;登录前还没确定租户,必须是部署级 |
|
|
340
|
+
| `wecom_app_profile` | **共享**(`tenant: false`) | 企业 × 应用 → 密文 `permanent_code`、`corp_name`、`env`、`status`、`auth_mode` | 它是「corp → 租户」映射的**权威表**;登录前还没确定租户,必须是部署级 |
|
|
277
341
|
| `wecom_suite_ticket` | **共享** | 每个部署一条 `suite_ticket` + 过期时刻 | 套件凭据属于整个部署,不属于某个企业 |
|
|
278
342
|
| `wecom_login_ticket` | **共享** | 一次性登录票据的 **sha256** + 归属租户 + `used` | 落地换票发生在「已知租户但尚未建会话」的瞬间;只存哈希,泄库不可重放 |
|
|
279
|
-
| `
|
|
343
|
+
| `wecom_sync_job` | **租户** | 同步任务的状态 / 阶段 / 游标 / 计数 / 脱敏错码 | 同步只在**调用方自己的租户**里跑(D7),跨租户读元数据要走显式 `tenant_id` 的裸 SQL |
|
|
344
|
+
| `wecom_sync_lock` | **租户** | 抢锁(`tenant_id + agent_id` 复合主键) | 同上;「插入撞主键」是唯一可靠的原子判据 |
|
|
345
|
+
| `sys_department` / `sys_dept_member` | **租户** | 部门树 / 部门成员(`origin` 标归属权) | 组织架构属于某个企业;`origin='wecom'` 的行归同步管 |
|
|
346
|
+
| `users` | **租户** | JIT 建的 `wecom:<userid>` 账号(`activated=0`) | 账号天然属于某个企业,`tenant_id = corp_id` |
|
|
280
347
|
|
|
281
348
|
三张共享表已列进 `config.yaml` 的 `tenant.shared_allow`。**不要**把它们改成租户表:
|
|
282
349
|
登录路径在 `ensureTenant` 之前就要读 profile,改成租户表会在守卫那里直接 403。
|
|
350
|
+
反之,两张同步表**不要**加进 `shared_allow`——它们存的是租户业务状态(进度、跳过计数),
|
|
351
|
+
共享化等于让所有企业的同步进度混在一张无人守卫的表里。
|
|
283
352
|
|
|
284
353
|
票据换会话的顺序是刚性的:**先绑租户,再抢票据(`used` 0→1 原子)**,然后才校验过期——
|
|
285
354
|
重放与并发双花都只会成功一次。
|
|
286
355
|
|
|
356
|
+
### 6.3.1 同步怎么跑:分片 + 轮询(不是后台长任务)
|
|
357
|
+
|
|
358
|
+
服务端 isolate **没有定时器**,路由也有超时(全局 `server.timeout` 缺省 30 秒),所以同步被切成
|
|
359
|
+
「一片一片推」:
|
|
360
|
+
|
|
361
|
+
```text
|
|
362
|
+
POST /auth/wecom/sync/start 一次 = 推一片(预算 12 秒)→ 返回当前进度
|
|
363
|
+
GET /auth/wecom/sync/status 只读本地行,不打上游(页面 1~2 秒轮询一次)
|
|
364
|
+
POST /auth/wecom/sync/cancel 置 cancelled 并清游标;**不回滚**已写入的数据(同步幂等,重跑即覆盖)
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
- 三个阶段固定顺序:`departments`(拉全量部门,按父子序落库)→ `members`(逐部门拉人)→
|
|
368
|
+
`reconcile`(墓碑回收)。游标存在 `wecom_sync_job.cursor_text`,**续跑就是再调一次 start**。
|
|
369
|
+
- 状态四态:`running`(还有下一片)/ `done` / `failed`(连续 10 次致命错熔断)/ `cancelled`。
|
|
370
|
+
页面看到 `running` 就继续调 start,看到其余三个就停。
|
|
371
|
+
- **限速 10 次/分**(`orgsync:<uid>`)与「一片 12 秒」是配对的:串行驱动天然 5 次/分,
|
|
372
|
+
10 次是不挡正常推进的余量。改一个必须看另一个。
|
|
373
|
+
- 抢锁:同一企业同一应用同时只允许一个同步,重复 start 会 409。
|
|
374
|
+
|
|
287
375
|
### 6.4 代开发后台(`/system/wecom`,仅超级管理员可见)
|
|
288
376
|
|
|
289
377
|
三件事,各对应一个端点(都在 `src/auth/api/wecom/admin/`):
|
|
290
378
|
|
|
291
379
|
| 界面动作 | 端点 | 说明 |
|
|
292
380
|
|---|---|---|
|
|
293
|
-
| 已开通企业清单 | `GET /auth/wecom/admin/apps` | **不返回** `permanent_code`,最多到 `corp_name` |
|
|
381
|
+
| 已开通企业清单 | `GET /auth/wecom/admin/apps` | **不返回** `permanent_code`,最多到 `corp_name`;带 `auth_mode`(授权模式列) |
|
|
294
382
|
| 启用 / 停用某企业某应用 | `POST /auth/wecom/admin/status` | 停用是安全动作:**只在该企业没有其它 active 应用时**才吊销该租户会话 |
|
|
295
383
|
| 生成开通链接 | `GET /auth/wecom/admin/install` | 返回 `{installUrl, callbackUrl, expiresIn}`;`installUrl` 是代开发 `get_customized_auth_url` 返回的 `qrcode_url`(企业管理员扫/开即授权),`callbackUrl` 就是要逐字填进企微后台的那个值 |
|
|
296
384
|
|
|
385
|
+
> 清单里的「授权模式」列是**只读事实**:`成员授权` 意味着这家企业的应用做不了组织架构同步(§2.6)。
|
|
386
|
+
> 改模式只能由**企业管理员**在企微后台改,服务商这边没有接口能替他改。
|
|
387
|
+
> 同理,**跨租户手动触发同步**(superadmin 替某家企业点同步)目前**没有做**:它需要绕过租户守卫
|
|
388
|
+
> 并双写审计,属于后续排期;现在的同步入口只同步「调用方自己所在的那家企业」。
|
|
389
|
+
|
|
297
390
|
开通链接由企微 `get_customized_auth_url` 生成,**有效期以企微返回为准**(文档口径可达 10 天,实测可能较短),
|
|
298
391
|
过期后界面上的二维码会置灰,重新点一次即可(每次点都重新换 `provider_access_token` + 调
|
|
299
392
|
`get_customized_auth_url`,不做缓存)。代开发**不支持**第三方应用的 `get_pre_auth_code`——本接口
|
|
@@ -312,9 +405,14 @@ pnpm dev
|
|
|
312
405
|
(带 `owner_tenant`、未使用、未过期)。
|
|
313
406
|
4. 造「员工」:往 `users` 插 `wecom:<userid>`(`source='wecom'`,口令位 `!wecom`)。
|
|
314
407
|
5. 前端直接落在 `/login?wecom_ticket=<raw>&tenant=<corp>` → 走换票 → 断言进入首页。
|
|
408
|
+
6. 造「组织架构 / 同步状态」(**不碰企微上游**):`wecom_app_profile` 的 `auth_mode` 决定页面给哪张卡,
|
|
409
|
+
`wecom_sync_job` 的一行决定状态条文案。两个都造就能覆盖「成员授权 / 已取消授权 / 从没同步过 /
|
|
410
|
+
同步过且 N 个部门跳过」四种界面,全部可离线跑。仓库里 `tests/e2e/templates/wecom-org-sync.spec.ts`
|
|
411
|
+
就是这么做的(5 例),要改文案先看它。
|
|
315
412
|
|
|
316
413
|
纯函数也值得单测:`src/login/web/wecom.ts` 的 `parseWecomLanding()` 与 `wecomAuthorizePath()`
|
|
317
|
-
(无 react / 无 request,node
|
|
414
|
+
(无 react / 无 request,node 侧可直接跑);同步器的错码三分、分片、墓碑回收在
|
|
415
|
+
`packages/cli/templates/tests/wecom-org-sync.spec.ts`(23 例)与 `wecom-sync.spec.ts`(15 例)。
|
|
318
416
|
|
|
319
417
|
### 6.6 前端红线:整页跳转要拼 **API base**
|
|
320
418
|
|
|
@@ -343,6 +441,36 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
|
|
|
343
441
|
- `suite_ticket` 的真实推送节奏(10 分钟)
|
|
344
442
|
- 企业管理员确认授权的交互,以及「点了取消」时企微只回 `state` 不回 `code`
|
|
345
443
|
- 通讯录权限导致的兜底:拿不到姓名时显示名落回工号
|
|
444
|
+
- **四项权限的「三态」**(未申请 / 待企业确认 / 已生效):企微没有开放查询接口,只能看症状——
|
|
445
|
+
名字是工号 ⇒ 缺 ①;树节点无名 ⇒ 缺 ②;职务空 ⇒ 缺 ③;部门少 ⇒ 可能是 ④ 或可见范围窄。
|
|
446
|
+
这几条**只能真机验**,单测里全部是桩。
|
|
447
|
+
- **`auth_mode` 的真实取值**:`get_permanent_code` 回包里 `auth_info.agent[].auth_mode` 的形态
|
|
448
|
+
(是数组逐应用、还是回落到 `auth_info.auth_mode`)来自官方文档,本地只有桩;
|
|
449
|
+
第一次真机开通后请确认后台清单里的「授权模式」与实际授权方式一致。
|
|
450
|
+
|
|
451
|
+
### 6.8 组织架构页:文案与降级卡片(企业管理员看的)
|
|
452
|
+
|
|
453
|
+
页面在 `/system/departments`,顶部**常驻**一条状态条(不是弹窗、不是只在出错时出现)。
|
|
454
|
+
文案刻意不暴露内部名词(分片 / 三轨 / 适配器):
|
|
455
|
+
|
|
456
|
+
> 企微组织架构更新于 **X 分钟前** · 自动同步 · [立即同步]
|
|
457
|
+
|
|
458
|
+
四态与对应卡片,改文案前先确认这四条都还在:
|
|
459
|
+
|
|
460
|
+
| 事实 | 页面给什么 | 为什么 |
|
|
461
|
+
|---|---|---|
|
|
462
|
+
| 本站没装企微(无授权记录) | **整条不出现** | 纯 CRUD 站点不该看到企微的任何痕迹;「没装」不是错误,不画红叉 |
|
|
463
|
+
| `auth_mode=1`(成员授权) | 说明卡 + **不给同步按钮** | 点了必然是 400;不如直接说清怎么改 |
|
|
464
|
+
| `status=disabled`(取消授权/停用) | 冻结黄条 + 不给按钮,数据保留 | 「以下为最后一次同步的快照」 |
|
|
465
|
+
| 正常 | 「更新于 X · 自动同步 · [立即同步]」+ `N 个部门因权限被跳过` | 跳过是可解释的常态(可见范围外的部门),不是错误 |
|
|
466
|
+
|
|
467
|
+
失败时按错码给**自助卡片**(不是把 `errcode` 原文甩给人):`60011`/`48002` → 去企微后台开通讯录权限;
|
|
468
|
+
`60020` → 找服务商运维加可信 IP;`45009`/`45033` → 已自动退避,稍后点「继续同步」;
|
|
469
|
+
`timeout`/`network` → 查服务器出网。**卡片里永远不出现异常原文**:`client.ts` 把 `access_token`
|
|
470
|
+
拼在 URL query 里,异常消息可能夹带凭证,落库前统一只存 `{errcode, phase, deptId, at}`。
|
|
471
|
+
|
|
472
|
+
同步一个企业大概要几次点击?正常规模(几十个部门)一次点击会在内部串行推完所有分片再停;
|
|
473
|
+
大企业中途会显示进度条。**离开页面即停**(前端不续推),回来点「继续同步」从游标接着走,不会从零重来。
|
|
346
474
|
|
|
347
475
|
---
|
|
348
476
|
|
|
@@ -353,13 +481,17 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
|
|
|
353
481
|
- [ ] 服务商账号已认证,代开发应用模板已创建
|
|
354
482
|
- [ ] Tier A 四项已生成并填好(`wecom_callback_token` / `wecom_encoding_aes_key` 在企微后台同页自设;`wecom_enc_key` 见 §2.4 本地生成;`wecom_base_url` 为站点对外地址)
|
|
355
483
|
- [ ] 模版创建成功后拿到 **SuiteID / SuiteSecret**(Tier B),已填入配置并重启(详见 §4 阶段二)
|
|
356
|
-
- [ ] 站点已用 **https** 对外,反向代理放行 `POST /api/auth/wecom/event
|
|
484
|
+
- [ ] 站点已用 **https** 对外,反向代理放行 `POST /api/auth/wecom/event`(**请求体原样转发**:默认是 JSON,老地址可能是 XML,都不要改写或按表单解析)
|
|
357
485
|
- [ ] 可信域名已备案(含校验文件)
|
|
358
486
|
- [ ] 可信 IP 已列全所有出口 IP
|
|
359
487
|
- [ ] 服务器时间已 NTP 同步:回调只接受 ±5 分钟内的 `timestamp`,偏得多了**所有**指令都会被拒
|
|
360
488
|
- [ ] `wecom_enc_key` 已生成并**离线备份**(与数据库分开)
|
|
361
489
|
- [ ] 测试企业已准备好,且你自己是它的管理员
|
|
362
490
|
- [ ] 一个能登录本站的**超级管理员**账号(生成开通链接用)
|
|
491
|
+
- [ ] 部署后先跑 `oj migrate`:组织架构与同步需要 `0016__org`、`auth/0002__org_sync` 两组迁移
|
|
492
|
+
- [ ] 后台清单里「授权模式」是**管理员授权**(成员授权的企业同步不了,§2.6)
|
|
493
|
+
- [ ] 组织架构四项权限已核对(§2.5);同步一次后页面**没有**「N 个部门因权限被跳过」以外的告警
|
|
494
|
+
- [ ] 组织架构同步已照 **§7.6** 逐条打勾(含最后一行的「事件增量」验收),四个数已回填验收记录
|
|
363
495
|
|
|
364
496
|
### 7.2 上线顺序
|
|
365
497
|
|
|
@@ -403,6 +535,87 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
|
|
|
403
535
|
| `wecom.auth.apply` | `create_auth` / `reset_permanent_code` | 普通 |
|
|
404
536
|
| `wecom.auth.cancel` | 企业取消授权 | 安全事件 |
|
|
405
537
|
| `wecom.admin.enable` / `wecom.admin.disable` | 后台启停 | 停用按安全事件记 |
|
|
538
|
+
| `wecom.org.sync` | 组织架构同步(每次 start 一片一条,带 status/phase/stats) | 普通 |
|
|
539
|
+
| `wecom.org.sync.cancel` | 取消同步 | 安全事件(级别 4:中断了一次全量覆盖) |
|
|
540
|
+
|
|
541
|
+
同步的审计落在**本租户**(同步只动自己那家企业的数据),所以企业管理员在自己的操作日志里
|
|
542
|
+
就能看到「谁在什么时候同步过、同步到哪一步、跳过了几个部门」。
|
|
543
|
+
|
|
544
|
+
### 7.6 组织架构同步:真机验收清单
|
|
545
|
+
|
|
546
|
+
单测只能证明「拿到这样的上游回包时我们这样写库」,证明不了「上游真的这样回」——后者在这里收口(§6.7 列的就是这份清单要验的东西)。每条都写清 **谁点、点什么、看到什么算过、不对去看哪一行**,打勾时请把观测到的数字抄进 §7.6-D。
|
|
547
|
+
|
|
548
|
+
#### A. 一次性前置(服务商运维,约 10 分钟)
|
|
549
|
+
|
|
550
|
+
| 项 | 怎么验 | 不过怎么办 |
|
|
551
|
+
|---|---|---|
|
|
552
|
+
| 两组迁移 | `sqlite3 db.sqlite ".tables"` 里有 `wecom_sync_job` 与 `wecom_sync_lock` | 部署后漏跑 `oj migrate`;补跑再起服 |
|
|
553
|
+
| 两个回调地址 | 企微后台「模板回调 URL」与「应用回调 URL」**都**保存成功(两处都填 `/api/auth/wecom/event`,见 §3) | 只填了模板那个:同步与登录都能用,但通讯录变更事件收不到(事件增量那条验收需要它)。补填应用回调需要企业管理员配合 |
|
|
554
|
+
| 四项权限 | 企微后台权限列表里 §2.5 的 ①~④ 都在,且没有一条停在「待企业确认」 | 重发开通链接**补不了**权限(幂等 upsert,不带权限);只能让客户管理员在企微里确认那条权限变更消息 |
|
|
555
|
+
| 授权模式 | `/system/wecom` 清单里「授权模式」= 管理员授权 | 成员授权(§2.6):请企业管理员改,改完企微推 `change_auth`,本系统自动刷新,不用重新开通 |
|
|
556
|
+
| 时间与出网 | §7.1 的 NTP 与可信 IP 两项 | 时钟偏 >5 分钟 → 所有回调被拒;出口 IP 不在白名单 → 同步第 2 步就 60020 |
|
|
557
|
+
|
|
558
|
+
#### B. 企业管理员侧(客户的 IT,一次性)
|
|
559
|
+
|
|
560
|
+
| 动作 | 为什么只能本人做 |
|
|
561
|
+
|---|---|
|
|
562
|
+
| 扫码 / 打开开通链接完成授权 | `permanent_code` 只在 `create_auth` 时一次性下发,服务商后台不显示 |
|
|
563
|
+
| 在企微里点「确认权限变更」 | 不确认 = 权限永远「待企业确认」,同步能跑但拿不到姓名与部门名(满屏工号) |
|
|
564
|
+
| (若为成员授权)把授权模式改成管理员授权 | 成员授权下应用没有任何通讯录权限,同步 100% 失败 |
|
|
565
|
+
|
|
566
|
+
#### C. 逐项验收(每条独立打勾)
|
|
567
|
+
|
|
568
|
+
| # | 操作 | 期望观测 | 不符时先看 |
|
|
569
|
+
|---|---|---|---|
|
|
570
|
+
| 1 | 组织架构页点「立即同步」,等状态条停下 | 文案变「企微组织架构更新于 **刚刚** · 自动同步」;`SELECT status, stats_text FROM wecom_sync_job` → `done`,`stats.departments` / `stats.members` 都 > 0 | `status=paused` → 再点「继续同步」(分片没推完,不是失败);`err_code` 非空 → §6.8 的卡片对应项 |
|
|
571
|
+
| 2 | 抽 3 个部门核对人数与层级 | 每个部门的人数与企微后台一致;层级正确;**主部门唯一**(不是所有人都在「总公司」) | 人数全为 0 → 可见范围为「仅部分成员」;层级全平 → 缺 ④ 的父部门 ID |
|
|
572
|
+
| 3 | 看人名 | 显示名是人名(`display_name` 有值) | 满屏 `wecom:<工号>` → 缺 §2.5 ① |
|
|
573
|
+
| 4 | 看职务列 | 有职务 | 全空 → 缺 §2.5 ③ |
|
|
574
|
+
| 5 | 看状态条的跳过计数 | 「N 个部门因权限被跳过」,且 N **远小于**部门总数 | N 与总数接近 → 先查 §2.5 ④ 与可见范围,再查权限是否卡在待确认 |
|
|
575
|
+
| 6 | 让一个**普通员工**登录一次 | 该员工 `users.display_name` / `position` 被回写,`sys_dept_member` 与企微一致 | 登录本身失败看 §8;登录成功但资料没变 → 该员工不在可见范围,或 24 小时内已自更新过(缓存键 `wecom:self:<corp>:<userid>`) |
|
|
576
|
+
| 7 | 先在本地建一个部门,再点一次同步 | 本地部门**不消失、不改名**(数据源只管辖它自己带来的行) | 本地部门被覆盖 → 严重缺陷,保留现场(`SELECT origin …`)再报 |
|
|
577
|
+
| 8 | 企微后台取消授权 | 组织架构页变冻结黄条(数据保留);该租户 `sys_refresh_token` 清零 | 页面仍显示可同步 → `wecom_app_profile.status` 没落 `disabled`,查 `cancel_auth` 日志 |
|
|
578
|
+
| 9 | 重新扫码授权 | 清单该行恢复 `active`,`permanent_code_enc` 被重写(密文变化即可) | 仍是 `disabled` → 事件没到,回 A 表查回调地址 |
|
|
579
|
+
| 10 | **事件增量(轨三)**:请客户在企微后台改一个部门名,或加/删一个人 | 服务端日志出现 `wecom change_contact applied … changeType=…`;**不点「立即同步」**,刷新组织架构页也能看到变化 | 见下 |
|
|
580
|
+
|
|
581
|
+
最后一行的几种结果,一次就能分清「事件到了没 / 处理成没成 / 是哪种投递形态」:
|
|
582
|
+
|
|
583
|
+
| 日志 | 说明 | 下一步 |
|
|
584
|
+
|---|---|---|
|
|
585
|
+
| `wecom change_contact applied … changeType=create_user`(`msgType=event`、`event=change_contact`) | 数据回调(**应用侧形态**:`ToUserName` / `MsgType=event` / `Event`)收到了,且落库成功 | 正常。`fields` 之类不再需要抄,日志里 `changeType` 与 `corpId` 就够定位 |
|
|
586
|
+
| `wecom change_contact applied … changeType=update_party`(`infoType=change_contact`、`event=` 为空) | 同一条变更走的是**模板指令回调**(代开发形态:`SuiteId` / `AuthCorpId` / `InfoType`)。两种形态都会到,`event=` 空不代表事件没到 | 同样正常。两种形态由同一个端点吃下,不需要为它单独配地址或凭据 |
|
|
587
|
+
| `wecom change_contact ignored … reason=该企业未授权本站(wecom_app_profile 无记录)` | 报文到了、也解密了,但 `wecom_app_profile` 里没有这个企业 | 回 §7.6-A 核对:这家企业的开通流程是否真的走完(清单里要有行、`status=active`) |
|
|
588
|
+
| `wecom change_contact ignored … reason=企微事件 change_contact 缺少 UserID` / `…缺少 Id` | 明文里没有该有的字段:报文形态与文档不符,或被中间件改写 | 把 `changeType` 与原始明文交给开发;**别**靠重推解决(重推是同一份报文) |
|
|
589
|
+
| `wecom change_contact ignored … reason=部门事件既没有名字、本地也没有这一行:不建无名部门` | 代开发形态只给部门 `Id`,而回源 `department/get` 又拿不到(无通讯录读权限) | 补 §2.5 的 ①/④;补齐后点一次「立即同步」即可补上,不用等事件 |
|
|
590
|
+
| `wecom change_contact not consumed … changeType=update_tag` | 标签变更等本系统不消费的类型 | 正常,无需处理 |
|
|
591
|
+
| `wecom callback decrypt failed … reason=bad padding` | 签名过了但**钥匙**不对 → 数据回调用的是**另一套**凭据 | 记下同一行的 `envelopeToUser` 与 `aesKeyFp`:前者告诉你报文来自哪个套件/模板,后者证明本进程加载的是哪把钥匙。结论写进 §2.1 的缺口,再决定是否要单独配一套应用侧凭据 |
|
|
592
|
+
| `wecom callback decrypt failed … reason=receiver mismatch` | 钥匙对、但报文自称的身份(`receiveid`)既不等于配置的 `suite_id`、也不等于信封里的 `ToUserName` | 多见于信封缺 `ToUserName`;先确认服务端不是 0.2.48 或更早(那些版本只认 `suite_id`,所有数据回调都会这样,见下方注) |
|
|
593
|
+
| 一条日志都没有 | 事件根本没进本站 | 应用回调 URL 没填(A 表第 2 项),或反代挡了 POST |
|
|
594
|
+
|
|
595
|
+
> **别拿 `envelopeToUser` 和 `configuredSuite` 比大小**。`receiveid` 的含义随场景变(企微 90968 附注):
|
|
596
|
+
> 第三方事件的回调是 suite_id(`suite_ticket` / `create_auth`),**企业应用(含代开发应用的数据回调)是
|
|
597
|
+
> corpid**。所以数据回调那一行的 `envelopeToUser` 本来就是企业代码、与 `configuredSuite` 不同——服务端
|
|
598
|
+
> 0.2.48 之后的版本就是按「两个候选都试」解密的,这个差异本身不是故障。
|
|
599
|
+
|
|
600
|
+
日志在工程根 `logs/server-*.log`:
|
|
601
|
+
|
|
602
|
+
```bash
|
|
603
|
+
grep -h "wecom change_contact\|wecom event ignored\|callback decrypt failed" logs/server-*.log | tail -20
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
#### D. 验收记录(请回填,别只写「看起来正常」)
|
|
607
|
+
|
|
608
|
+
| 字段 | 值 |
|
|
609
|
+
|---|---|
|
|
610
|
+
| 企业代码 / 应用 | |
|
|
611
|
+
| 四项权限状态(①~④) | |
|
|
612
|
+
| 部门数 / 人数 / 跳过数 | |
|
|
613
|
+
| 单次全量耗时(`finished_at - started_at`) | |
|
|
614
|
+
| 事件增量行日志原文(`event=` 与 `tags=`) | |
|
|
615
|
+
| 结论 | 通过 / 不通过(不通过项编号) |
|
|
616
|
+
|
|
617
|
+
> 判定口径是**数字**:部门数、人数、跳过数、耗时四个数写不出来,就等于没验。
|
|
618
|
+
> 跳过数不为 0 不是失败(可见范围外是硬口径);但「跳过数接近总数」或「主部门不唯一」必须判不通过。
|
|
406
619
|
|
|
407
620
|
---
|
|
408
621
|
|
|
@@ -420,9 +633,23 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
|
|
|
420
633
|
| 400 `回调 timestamp 超出允许窗口:请检查服务器时间,或该请求为历史回调的重放` | 服务器时钟与真实时间偏差超过 5 分钟,或这条回调是被人回放的旧报文 | 校服务器时间(NTP);校正后企微的重推会自动成功 |
|
|
421
634
|
| 400 `回调 nonce 重复:同一条指令回调已被处理过(重放请求已拒绝)` | 同一条指令被投递了两次(企微重试、或反代重复转发) | 首次生效、重放被拒属预期保护;若常态出现,查反代是否重复转发 POST |
|
|
422
635
|
| 企微后台保存回调 URL 失败 | `Token` / `EncodingAESKey` 与 `config.yaml` 不一致 | 逐字对齐(43 字符别多空格) |
|
|
636
|
+
| 日志 `wecom callback decrypt failed … reason=pkcs7: bad padding` | 签名已经过了(说明后台 Token 与本部署一致),所以**钥匙不对**只剩两种:**EncodingAESKey 与后台不一致**(后台点过「随机生成」、或抄错/抄多一个字符),或这条推送其实来自**另一个模板**(那把钥匙解不开这份密文) | 先看 `aesKeyFp` 有没有变:改完 config 对不上 = 新配置没生效(没重启 / 改的不是运行中那份 config),重抄 43 字符到 `vars.wecom_encoding_aes_key`、**重启生效**,再回后台点一次「保存」触发 echostr 验证(页面会当场报验证失败)。`aesKeyFp` 没变仍 `bad padding` → 这条推送不是本套件的,`envelopeToUser` 告诉你它自称是谁(**它是 corpid 也正常**,见 §7.6-C 表最后一行下方那条注),去「应用与模板上线」核对回调地址填在哪 |
|
|
637
|
+
| 日志 `wecom callback decrypt failed … reason=receiver mismatch` | 钥匙对、但报文自称的身份两边都对不上(配置的 `suite_id` 与信封 `ToUserName` 都不是它) | 信封缺 `ToUserName` 时会这样;先确认服务端不是 0.2.48 或更早(那些版本只认 `suite_id`)。仍复现就把整行日志(含 `envelopeToUser` / `configuredSuite` / `aesKeyFp`)留档 |
|
|
638
|
+
| 企业管理员扫码/打开开通链接后提示「**应用安装失败,联系服务商**」 | 企微推 `create_auth` 后**约 1 秒内**没拿到纯文本 `success`(它只看 body 是不是 `success`,不看 HTTP 200)。三类原因:① 事件没到(指令回调 URL 不是当前地址 / 改过没重新上线);② 处理里同步出网超预算(`get_suite_token` + `get_permanent_code` 是两次往返);③ 处理过程抛错(如审计表在租户守卫下被拒) | 先翻服务日志:有 `wecom event failed … reason=<文案>` → 按文案修(缺配置/验签/解密各自有明确文案);**一条日志都没有** → 事件根本没到,回 §4 第 2 步验回调,并到「应用与模板上线」核对指令回调 URL 是否就是当前地址。若后台清单里**已有该企业且 status=active** → 授权其实成功了(失败发生在 profile 落库之后),让企业管理员再扫一次即可 |
|
|
423
639
|
| 400 `企业代码无效或应用未开通` | corp_id 打错,或该企业没装本应用 | 核对 corp_id(`ww` 开头);确认已在后台清单里 |
|
|
424
640
|
| 400 `该企业开通了多个应用,登录链接必须带 agent 参数` | 同企业装了测试+正式两个应用 | 链接加 `&agent=<agentid>` |
|
|
425
641
|
| 401 `登录凭据无效或不在应用可见范围内` | code 无效 / 员工不在应用可见范围 / 非本企业成员 | 企微后台检查应用可见范围 |
|
|
642
|
+
| 400 `本站尚未开通企业微信:没有可同步的授权记录` | 该租户在 `wecom_app_profile` 里没有行(这会出现在组织架构页的同步入口,不是登录) | 先走开沟通道(§4);单租户部署下「租户」= 企业代码 |
|
|
643
|
+
| 400 `该企业为「成员授权」模式,应用没有通讯录权限 …` | `auth_mode=1`(§2.6),此时任何通讯录接口都是 `60011` | **企业管理员**在企微后台把授权模式改为「管理员授权」;企微会推 `change_auth`,本系统自动刷新 `auth_mode`,无需重新开通 |
|
|
644
|
+
| 400 `该企业已取消授权或应用已停用:组织架构同步已冻结 …` | `status=disabled`(企业取消授权,或后台点了停用) | 服务商重新发开通链接;已同步的组织数据保留,可当快照用 |
|
|
645
|
+
| 组织架构页卡片「应用缺少通讯录权限(错误码 60011 / 48002)」 | 缺 §2.5 的 ①/②/④(自定义权限还卡在「待企业确认」也会这样) | 企业管理员:企微后台 → 应用管理 → 本应用 → 权限,勾选后提交,并**在企微里确认**那条权限变更消息 |
|
|
646
|
+
| 卡片「企微接口限流(错误码 45009 / 45033)」 | 触发企微频率/并发限制(大企业全量同步常见) | 已自动退避三次;仍失败就点「继续同步」,游标停在原部门,不会丢进度 |
|
|
647
|
+
| 卡片「服务器出口 IP 不在企微白名单(60020)」 | 同上方 60020 行 | 同上方处置(加可信 IP) |
|
|
648
|
+
| 状态条「N 个部门因权限被跳过」 | 这些部门在应用**可见范围外**(或负责人/父部门权限缺失) | 常态可忽略;若 N 与总部门数接近,先查 §2.5 ④ 与可见范围(§2.4 的话术可以直接发给企业管理员) |
|
|
649
|
+
| 企微后台改完通讯录,页面**没变**(也没点过同步) | 事件没到,或到了但处理失败。事件是加速器不是权威:失败**不会**报错、也不会重推成功 | `grep "wecom change_contact" logs/server-*.log \| tail`:`applied` = 已落库(刷新页面即可见);`ignored … reason=…` = 按 reason 修(未授权 / 缺字段 / 无通讯录读权限);一条都没有 = 应用回调 URL 没填(§3 第 2 项)。急着要数据就先点一次「立即同步」,全量同步是权威 |
|
|
650
|
+
| 409 `同步正在进行,请稍后再试` | 同一企业同一应用已有同步在跑(另一个标签页 / 上一次请求崩了留下锁) | 等一次轮询周期再点;崩溃残留的锁 120 秒后可被抢占,不用人工清 |
|
|
651
|
+
| 429 `rate limited` | `start`/`cancel` 限速 10 次/分(同一账号在两个页面同时驱动,或手动连点) | 等一分钟再点;进度已落库,不需要从头重来 |
|
|
652
|
+
| 点「立即同步」后按钮立刻回到可点、进度没动 | 这一片被限流或上游抖动提前收手(状态仍是 `running`) | 正常现象:再点「继续同步」;页面每 2 秒会自己刷新状态 |
|
|
426
653
|
| 403 `账号已被停用,请联系管理员` | 本站把该账号 `is_forbidden=1` | 后台启用该账号 |
|
|
427
654
|
| 401 `登录票据无效或已使用` / `登录票据已过期,请重新登录` | 票据被重放,或 180 秒没换 | 重新从入口进 |
|
|
428
655
|
| 400 `授权未完成:企业微信未返回 code` | 用户在授权页点了取消 | 重新授权 |
|
|
@@ -446,8 +673,9 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
|
|
|
446
673
|
[ ] 八个 vars 非空(Tier A/B/C),wecom_base_url 以 /api/auth/wecom 结尾
|
|
447
674
|
[ ] 企微后台的指令回调 URL == <wecom_base_url>/event(逐字)
|
|
448
675
|
[ ] 服务日志有 wecom suite_ticket updated(近期)
|
|
449
|
-
[ ] /system/wecom 列表里有目标企业,status=active
|
|
676
|
+
[ ] /system/wecom 列表里有目标企业,status=active,授权模式=管理员授权
|
|
450
677
|
[ ] /system/settings 的「启用企业微信登录」= 开
|
|
451
678
|
[ ] 用普通员工账号(非管理员)完整走过一次登录
|
|
679
|
+
[ ] /system/departments 顶部状态条有「更新于 X」;树有名字、人挂在正确部门
|
|
452
680
|
[ ] db.sqlite 与 wecom_enc_key 各自有备份
|
|
453
681
|
```
|
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.49",
|
|
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.49",
|
|
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.49",
|
|
308
308
|
"license": "MIT"
|
|
309
309
|
},
|
|
310
|
-
"lastBuildTime": "2026-
|
|
310
|
+
"lastBuildTime": "2026-11-01 17:14:09"
|
|
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-E48VJYAFCfSkkLa0nv6GVw=='; 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="E48VJYAFCfSkkLa0nv6GVw==">{
|
|
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.49",
|
|
11
|
+
"@oj-module/runtime/contract": "0.2.49",
|
|
12
|
+
"@oj-module/runtime/contract/errors": "0.2.49",
|
|
13
13
|
"@plane/kit": "0.1.20",
|
|
14
14
|
"@plane/editor": "0.1.20",
|
|
15
15
|
"antd": "6.6.1",
|
|
@@ -116,3 +116,83 @@ describe("given module-root 布局 When ojm build 后端", () => {
|
|
|
116
116
|
expect(existsSync(join(root, "dist", "api"))).toBe(false);
|
|
117
117
|
});
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 假 oj:`secret open` 按 openOk 成败(失败时复述 oj 的真实 stderr),`build` 记 argv 后成功。
|
|
122
|
+
* 只记非 secret 调用,便于断言「预检之后 build 是否真的跑了」。
|
|
123
|
+
*/
|
|
124
|
+
function fakeOjSeal(projectRoot: string, openOk: boolean): void {
|
|
125
|
+
mkdirSync(join(projectRoot, "bin"), { recursive: true });
|
|
126
|
+
const bin = join(projectRoot, "bin", "oj");
|
|
127
|
+
writeFileSync(bin, [
|
|
128
|
+
"#!/bin/sh",
|
|
129
|
+
"if [ \"$1\" = \"secret\" ]; then",
|
|
130
|
+
...(openOk ? [] : [" echo 'oj secret open: aes-gcm open failed (sealed value tampered?): aead::Error' >&2"]),
|
|
131
|
+
` exit ${openOk ? 0 : 1}`,
|
|
132
|
+
"fi",
|
|
133
|
+
"printf '%s\\n' \"$*\" >> \"$(dirname \"$0\")/invoked\"",
|
|
134
|
+
"exit 0",
|
|
135
|
+
"",
|
|
136
|
+
].join("\n"), { mode: 0o755 });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** flat 布局 + 已密封的 config(路径照 ojm init 的产物写) */
|
|
140
|
+
function newSealedProject(): string {
|
|
141
|
+
const root = newProject();
|
|
142
|
+
writeFileSync(
|
|
143
|
+
join(root, "api", "config.yaml"),
|
|
144
|
+
"auth:\n jwt_secret: \"ENC[AgE0ZMU2aDKo5CHab4Kl_TtJduXQLwheLVczTPcvm2g]\"\n"
|
|
145
|
+
+ "secrets:\n private_key_path: ./config/secrets-private.pem\n",
|
|
146
|
+
"utf-8",
|
|
147
|
+
);
|
|
148
|
+
return root;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
describe("given config 含密封值 When ojm build 后端", () => {
|
|
152
|
+
// oj build 读不动 config 时只 warn 两行、退出码 0,继续出包时 sql_guard 是关掉的。
|
|
153
|
+
// ojm 必须在把 oj 叫起来之前就停下,否则「没跑过 tenant 校验的包」会流到部署机上。
|
|
154
|
+
it("then 解不开就停在出包前,不当成一次成功构建", async () => {
|
|
155
|
+
const root = newSealedProject();
|
|
156
|
+
fakeOjSeal(root, false);
|
|
157
|
+
|
|
158
|
+
await expect(buildBackend(root)).rejects.toThrow(/密封值解不开/);
|
|
159
|
+
await expect(buildBackend(root)).rejects.toThrow(/secrets-private\.pem/);
|
|
160
|
+
expect(existsSync(join(root, "bin", "invoked"))).toBe(false); // 没跑 oj build
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("then 解不开时把 oj 的原话带上(排障不用再跑一遍)", async () => {
|
|
164
|
+
const root = newSealedProject();
|
|
165
|
+
fakeOjSeal(root, false);
|
|
166
|
+
|
|
167
|
+
await expect(buildBackend(root)).rejects.toThrow(/aes-gcm open failed/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("then 能解开就照常出包", async () => {
|
|
171
|
+
const root = newSealedProject();
|
|
172
|
+
fakeOjSeal(root, true);
|
|
173
|
+
|
|
174
|
+
expect(await buildBackend(root)).toBe(true);
|
|
175
|
+
expect(readFileSync(join(root, "bin", "invoked"), "utf-8")).toContain("build -c ");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("then config 无密封值时不额外调用 oj(预检零成本)", async () => {
|
|
179
|
+
const root = newProject(); // config 只有 server.port
|
|
180
|
+
fakeOjSeal(root, false);
|
|
181
|
+
|
|
182
|
+
expect(await buildBackend(root)).toBe(true);
|
|
183
|
+
expect(readFileSync(join(root, "bin", "invoked"), "utf-8")).not.toContain("secret");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("then 注释掉的旧密文不算数(不因一行 `#` 挡住构建)", async () => {
|
|
187
|
+
const root = newProject();
|
|
188
|
+
writeFileSync(
|
|
189
|
+
join(root, "api", "config.yaml"),
|
|
190
|
+
"# auth:\n# jwt_secret: \"ENC[AgE0ZMU2aDKo5CHab4Kl_TtJduXQLwheLVczTPcvm2g]\"\nserver:\n port: 9778\n",
|
|
191
|
+
"utf-8",
|
|
192
|
+
);
|
|
193
|
+
fakeOjSeal(root, false);
|
|
194
|
+
|
|
195
|
+
expect(await buildBackend(root)).toBe(true);
|
|
196
|
+
expect(readFileSync(join(root, "bin", "invoked"), "utf-8")).not.toContain("secret");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -115,11 +115,16 @@ describe("templates 租户定性(A 租户表 / B 共享表)", () => {
|
|
|
115
115
|
expect(unknown).toEqual([]);
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
it("t-4
|
|
118
|
+
it("t-4 28 张表的 A/B 分组与方案 v3 §5.2 一致(新增表须先定性)", () => {
|
|
119
119
|
const { tables } = loadSchemaTables();
|
|
120
120
|
const shared = [...tables].filter(([, d]) => d.tenant === false).map(([n]) => n).sort();
|
|
121
121
|
// Phase 0 落地 21 张(11 A + 10 B);Phase 1 加企微三表,全部 B 组(D9:查它们时必然还没有租户上下文)
|
|
122
|
-
|
|
122
|
+
// 0016 组织架构加 sys_department / sys_dept_member 两张,均 A 组租户表(组织架构天然按租户隔离,
|
|
123
|
+
// 企微部署下 tenant_id = corp_id;只在有租户上下文时被读写,与 users / sys_oplog 同口径)。
|
|
124
|
+
// auth/0002 加 wecom_sync_job / wecom_sync_lock 两张,同属 A 组:同步只在调用方自己的租户内跑
|
|
125
|
+
// (D7 不接受 corpId 参数),故不进 shared_allow——跨租户读元数据那条另走显式 tenant_id 的裸 SQL。
|
|
126
|
+
// 改这张表清单 = 改租户隔离边界,必须同步 config.yaml 的 tenant.shared_allow(t-2/t-3 会把关)。
|
|
127
|
+
expect(tables.size).toBe(28);
|
|
123
128
|
expect(shared).toEqual([
|
|
124
129
|
"_oj_migrations",
|
|
125
130
|
"market_history",
|