@oj-module/cli 0.2.47 → 0.2.48

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 (94) hide show
  1. package/devkit/SKILL.md +1 -1
  2. package/devkit/framework-dev.md +2 -2
  3. package/devkit/manual.md +24 -14
  4. package/devkit/module-dev-and-cdn-playground.md +2 -2
  5. package/devkit/wecom-daikaifa.md +237 -14
  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__/templates-tenant-scope.test.ts +7 -2
  11. package/src/init.ts +1 -1
  12. package/src/market/__tests__/bootstrap.test.ts +1 -1
  13. package/src/market/__tests__/setup.test.ts +7 -7
  14. package/src/market/bootstrap.ts +1 -1
  15. package/templates/README.md +1 -1
  16. package/templates/init.include.yaml +12 -0
  17. package/templates/openapi.json +2863 -549
  18. package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
  19. package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
  20. package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
  21. package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
  22. package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
  23. package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
  24. package/templates/src/_platform/api/manifest.yaml +3 -0
  25. package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
  26. package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
  27. package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
  28. package/templates/src/_platform/api/org/departments/api.ts +51 -0
  29. package/templates/src/_platform/api/org/is-member/api.ts +33 -0
  30. package/templates/src/_platform/api/org/members/api.ts +55 -0
  31. package/templates/src/_platform/api/org/picker/api.ts +59 -0
  32. package/templates/src/_platform/api/org/resolve/api.ts +38 -0
  33. package/templates/src/_platform/api/org/tree/api.ts +37 -0
  34. package/templates/src/_platform/api/org/user/api.ts +46 -0
  35. package/templates/src/_platform/api/schema.yaml +48 -0
  36. package/templates/src/_platform/api/seed.mysql.sql +10 -5
  37. package/templates/src/_platform/api/seed.pg.sql +10 -5
  38. package/templates/src/_platform/api/seed.sql +13 -5
  39. package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
  40. package/templates/src/_platform/web/__client/api.ts +170 -1
  41. package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
  42. package/templates/src/auth/api/_shared/session.ts +5 -1
  43. package/templates/src/auth/api/_shared/wecom/admin.ts +3 -1
  44. package/templates/src/auth/api/_shared/wecom/client.ts +243 -31
  45. package/templates/src/auth/api/_shared/wecom/config.ts +12 -1
  46. package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
  47. package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
  48. package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
  49. package/templates/src/auth/api/_shared/wecom/login.ts +152 -1
  50. package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
  51. package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
  52. package/templates/src/auth/api/_shared/wecom/sync.ts +487 -0
  53. package/templates/src/auth/api/manifest.yaml +3 -0
  54. package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
  55. package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
  56. package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
  57. package/templates/src/auth/api/schema.yaml +31 -0
  58. package/templates/src/auth/api/wecom/admin/api.ts +3 -1
  59. package/templates/src/auth/api/wecom/api.ts +100 -18
  60. package/templates/src/auth/api/wecom/sync/api.ts +199 -0
  61. package/templates/src/auth/web/__client/api.schemas.ts +97 -0
  62. package/templates/src/auth/web/__client/api.ts +73 -1
  63. package/templates/src/notification/api/seed.sql +1 -1
  64. package/templates/src/system/api/departments/api.ts +142 -0
  65. package/templates/src/system/api/departments/detach/api.ts +34 -0
  66. package/templates/src/system/api/departments/members/api.ts +142 -0
  67. package/templates/src/system/api/departments/move/api.ts +39 -0
  68. package/templates/src/system/api/users/api.ts +45 -5
  69. package/templates/src/system/api/users/profile/api.ts +44 -0
  70. package/templates/src/system/web/__client/api.schemas.ts +143 -0
  71. package/templates/src/system/web/__client/api.ts +293 -1
  72. package/templates/src/system/web/entry.ts +6 -0
  73. package/templates/src/system/web/locales/en-US.json +112 -6
  74. package/templates/src/system/web/locales/zh-CN.json +112 -6
  75. package/templates/src/system/web/pages/departments/index.tsx +346 -0
  76. package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
  77. package/templates/src/system/web/pages/departments/tree.ts +66 -0
  78. package/templates/src/system/web/pages/errors.ts +1 -1
  79. package/templates/src/system/web/pages/users/index.tsx +49 -7
  80. package/templates/src/system/web/pages/wecom/index.tsx +14 -0
  81. package/templates/tests/l1-builder.test.ts +1 -1
  82. package/templates/tests/org-departments.spec.ts +311 -0
  83. package/templates/tests/org-read.spec.ts +469 -0
  84. package/templates/tests/rbac-contacts.spec.ts +13 -3
  85. package/templates/tests/rbac-seed.spec.ts +11 -0
  86. package/templates/tests/seed-role-accounts.spec.ts +6 -2
  87. package/templates/tests/wecom-admin.spec.ts +1 -0
  88. package/templates/tests/wecom-connector.spec.ts +115 -8
  89. package/templates/tests/wecom-crypt.spec.ts +12 -3
  90. package/templates/tests/wecom-endpoints.spec.ts +182 -14
  91. package/templates/tests/wecom-org-sync.spec.ts +598 -0
  92. package/templates/tests/wecom-sync.spec.ts +286 -0
  93. package/vendor/host-versions.json +3 -3
  94. package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
package/devkit/SKILL.md CHANGED
@@ -39,7 +39,7 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
39
39
  以扫描 `src/*/web/entry.ts` 为真源,登记项排前面、其余按扫描顺序追加;历史布局则必须登记)。
40
40
  **要「不构建」写 `enabled: false`——注释掉无效。**
41
41
  5. **验证**:`pnpm dev`(前端 devServer **5174** + oj 后端 **9778**,端口以工程根 `config.yaml` 为准)
42
- → 登录 `admin / 123456` → 按 §8.1 五步清单核对。
42
+ → 登录 `admin / aA123456` → 按 §8.1 五步清单核对。
43
43
 
44
44
  ## 红线(不可违反)
45
45
 
@@ -21,7 +21,7 @@
21
21
  > ⚠️ 那两份 guide 有**已知陈旧处**,以源码为准:
22
22
  > dev 端口实际是 **3333**(guide 写的 5173 已过时;注意与 `ojm dev` 的前端 devServer
23
23
  > **5174** 不是一回事——前者是本仓 vite,后者是业务工程 devServer);包版本以
24
- > `packages/*/package.json` 为准(撰写时实际 **0.2.20**,仅供参考,以当前 package.json 为准)。
24
+ > `packages/*/package.json` 为准(撰写时实际 **0.2.47**,仅供参考,以当前 package.json 为准)。
25
25
 
26
26
  ## §2 两包结构与依赖方向
27
27
 
@@ -139,7 +139,7 @@ pnpm test tests/shell # 宿主全量
139
139
  **一键发布**:`node scripts/publish-packages.mjs`(lockstep 门禁 → 构建 → 守卫测试 →
140
140
  runtime → cli → registry 复核;`--dry-run` 只演练)。下面的手动步骤是它的展开参考。
141
141
 
142
- - **两包**同版本号(`@oj-module/runtime` + `@oj-module/cli`,当前 `0.2.20`),后续发版保持
142
+ - **两包**同版本号(`@oj-module/runtime` + `@oj-module/cli`,当前 `0.2.47`),后续发版保持
143
143
  lockstep;`@plane/kit` / `@plane/editor` 不在发布集内(脚本里已注释)。
144
144
  - **必须 `pnpm publish`**——用 `npm publish` 会漏出 `workspace:*` / `catalog:` 字面量。
145
145
  - **顺序:先 `runtime` 后 `cli`**。`runtime` 未公开时,新 `cli` 的精确依赖会悬空,
package/devkit/manual.md CHANGED
@@ -13,8 +13,9 @@
13
13
  ### 1.1 脚手架产物
14
14
 
15
15
  `ojm init <目录>` 生成(幂等补缺,重跑只补缺失文件,**永不覆盖已有文件**)。
16
- 模板拷贝按 `packages/cli/templates/init.include.yaml` **白名单**执行——未收录的
17
- 路径一律不拷(demo-only 演示模块、模板自运行产物都在清单外)。
16
+ 模板拷贝按框架内置的 **`init.include.yaml` 白名单**执行——未收录的路径一律不拷
17
+ (demo-only 演示模块、模板自运行产物都在清单外)。该清单是**框架侧文件**,`ojm init`
18
+ 从 cli 包内读取,**不在你的工程里**——你在工程里找不到它,也改不到它。
18
19
 
19
20
  布局是 **module-root:一个目录 = 一个模块**,前后端同处 `src/<name>/`,各占一侧
20
21
  (`api/` 与 `web/` 可以只存在一侧,两侧皆无则不是模块):
@@ -60,7 +61,7 @@
60
61
  - **`config.yaml` 在工程根**,不是 `api/config.yaml`。oj 把「项目根」锚定在 **config
61
62
  所在目录**,所有 `fs.*` 相对路径、`db.default`、`platform.root`、`mounts[].web`
62
63
  都相对它解析;证书与密钥落在同级的 `config/`(`ojm` 侧由 `resolveConfigPath()` /
63
- `resolveCertDir()` 单点派生,见 `packages/cli/src/layout.ts`)。
64
+ `resolveCertDir()` 单点派生,你不需要自己拼路径)。
64
65
  - **产物有两个根**:后端 `api/dist`(oj 扁平 release,`<name>-<ver>/` 与
65
66
  `manifests.yaml` 同层),站点 `web/dist`(`modules/` + `modules.json`)。
66
67
  `config.yaml` 上移到工程根后,两者不再同名 `dist`。
@@ -75,7 +76,7 @@
75
76
  > **历史布局仍被识别**:`web/src/<name>`(uni-dev)与 `web/<name>`(框架根仓)照旧可用,
76
77
  > 只是 `ojm init` 不再产出。两套源码树**互斥**——同时存在时 `ojm` 直接报错,而不是静默选一个。
77
78
 
78
- 登录账号:**账号名=内置角色名**一一对应,口令均 `123456`,且全部**免首登改密**(must_change=0,纯开发便利,勿用于生产):
79
+ 登录账号:**账号名=内置角色名**一一对应,口令均 `aA123456`,且全部**免首登改密**(must_change=0,纯开发便利,勿用于生产):
79
80
 
80
81
  | 账号 | 角色 | 能干什么 |
81
82
  |---|---|---|
@@ -86,6 +87,13 @@
86
87
 
87
88
  > 旧脚手架库(admin 绑 superadmin / must_change=1)**无需手工迁移**:seed 每次启动幂等重放,
88
89
  > 自动收敛为账号名=角色名并清零 must_change(`DELETE` 旧绑定 + `UPDATE`,见 `_platform/api/seed.sql`)。
90
+ >
91
+ > ⚠️ **但口令不会被 seed 改**:建号语句是 `INSERT OR IGNORE`,已存在的账号行原样保留。
92
+ > 口令在 2026-11-01 由 `123456` 改为 `aA123456`(bcrypt cost=10、`$2y$` 前缀),
93
+ > **更早建好的 dev 库里仍是旧口令**——拿 `aA123456` 登不上时先怀疑这个,别去查登录链路。
94
+ > 要统一:删 `db.sqlite` 重跑 `oj migrate`(seed 随服务启动重放),或直接
95
+ > `UPDATE users SET password_hash = '<aA123456 的哈希>' WHERE username IN ('superadmin','admin','editor','viewer');`。
96
+ > 哈希现成值从 `src/_platform/api/seed.sql` 里 grep `$2y$10$` 即可。
89
97
 
90
98
  端口:前端 devServer **5174**,oj 后端 **9778**
91
99
  (以工程根 `config.yaml` 的 `server.port` 为准)。
@@ -343,7 +351,7 @@ export default defineModule({
343
351
  `defineModule` 字段:`name`(= 目录名)、`description`、`version`、
344
352
  `peerRuntime`、`routes`、`i18n?`、`lifecycle?`。
345
353
 
346
- > 页面组件用**静态 import 或 `lazy()` 都行**:模板(`packages/cli/templates/src/*/web/entry.ts`)
354
+ > 页面组件用**静态 import 或 `lazy()` 都行**:模板自带模块(`src/home/web/entry.ts` 等)
347
355
  > 用静态 import,多页面模块可对非首屏页面用 `lazy(() => import("./pages/x"))` 做代码分割。
348
356
  > 单个页面就用静态 import,少一层间接。
349
357
 
@@ -398,10 +406,11 @@ export default defineModule({
398
406
  可服务的同源路径)。直接 `import "vditor/dist/..."` 其它深路径会命中前缀 external 但无
399
407
  importmap 键,构建期报错——需要新深路径请联系框架方在共享表登记。
400
408
 
401
- ### 2.7 本版本共享依赖钉版(0.2.20 快照)
409
+ ### 2.7 本版本共享依赖钉版(0.2.47 快照)
402
410
 
403
- 宿主经 importmap 提供以下单例(模块 `devDependencies` 须对齐,供 `tsc` 用);完整 68 项矩阵以
404
- `pnpm exec ojm info` 或 `packages/cli/shell-dist/versions.json`(构建期生成)为准。
411
+ 宿主经 importmap 提供以下单例(模块 `devDependencies` 须对齐,供 `tsc` 用);完整矩阵以
412
+ `pnpm exec ojm info` 为准——**别抄本节表格当真相**,本表是撰写时的快照,升级 cli 后
413
+ 以 `ojm info` 输出为准。
405
414
 
406
415
  | 包 | 钉版 |
407
416
  | --- | --- |
@@ -427,7 +436,8 @@ importmap 键,构建期报错——需要新深路径请联系框架方在共
427
436
  | `pinyin-pro` | 3.29.3 |
428
437
  | `vditor` | 4.0.0 |
429
438
 
430
- 框架包版本:`@oj-module/runtime` / `@oj-module/cli` 为 `0.2.20`(**双包 lockstep**);
439
+ 框架包版本:`@oj-module/runtime` / `@oj-module/cli` 为 `0.2.47`(**双包 lockstep**,
440
+ 以 `packages/*/package.json` 或 `ojm info` 为准——本表是撰写时快照);
431
441
  `@plane/kit` / `@plane/editor` 是独立版本线(当前 `0.1.20`,随宿主 importmap 提供,
432
442
  不参与 lockstep)。`oj` 二进制最低版本门禁 `MIN_OJ_VERSION = 0.1.85`——这个版本带来
433
443
  `--module-root`(`src/<name>/{api,web}` 布局的源码树根,与 `-d/--api-path` **互斥**);
@@ -988,7 +998,7 @@ export default {
988
998
  - **归属校验返回 403**:跨用户 `del` / `read` 他人按人项,原语返回 `false`,端点 `json.fail(403, "forbidden")`——
989
999
  与「按人隔离」对称(A 推给 B 后 A 不可见、B 不可删/读 A 的)。
990
1000
 
991
- **验证**:`pnpm dev` → `admin / 123456` 登录 → 右上角铃铛有蓝点 → 点开 →
1001
+ **验证**:`pnpm dev` → `admin / aA123456` 登录 → 右上角铃铛有蓝点 → 点开 →
992
1002
  点任意一条蓝点消失 → 「全部已读」全清 → 「清空」列表变空。
993
1003
 
994
1004
  **加接口的通用流水线**:
@@ -1569,11 +1579,11 @@ export default async (path: string, html: string) => {
1569
1579
 
1570
1580
  | 想抄什么 | 抄这里 |
1571
1581
  |---|---|
1572
- | 带页面的模块(路由 / i18n / order) | `src/home/web/entry.ts`、`src/demo/web/entry.ts`(模板 `packages/cli/templates/src/*/web/`) |
1582
+ | 带页面的模块(路由 / i18n / order) | `src/home/web/entry.ts`、`src/demo/web/entry.ts` |
1573
1583
  | 接管通知 provider(四方法全量) | `src/notification/web/entry.ts` |
1574
1584
  | 上传 provider(action + headers 方法) | `src/personal-center/web/entry.ts` |
1575
1585
  | 无页面模块(`routes: []`) | 同 `src/notification/web/entry.ts` |
1576
- | handler `.schema` 入参 + 平级 `.response` 出参写法(GET + POST、参数段) | `src/notification/api/api.ts`、模板 `packages/cli/templates/src/notification/api/api.ts` |
1586
+ | handler `.schema` 入参 + 平级 `.response` 出参写法(GET + POST、参数段) | `src/notification/api/api.ts` |
1577
1587
  | handler 写法(嵌套组列表 / 写入 / 403 归属) | `src/notification/api/api.ts`(模块根单文件,`read`/`read-all`/`clear` 子组) |
1578
1588
  | 统一异常出口 `respond()`(service 抛 `Error & {status}` → 信封 `json.fail`) | 模板 `src/_platform/api/_shared/respond.ts` + 各 handler 用法(§1.7-1) |
1579
1589
  | 生成物长什么样(含工厂) | `src/notification/web/__client/api.ts` |
@@ -1632,8 +1642,8 @@ export default async (path: string, html: string) => {
1632
1642
  产物见 §1.1(工程根 `config.yaml` + `config/` 证书 + `src/<模块>/{api,web}` 单树 + `bin/`、
1633
1643
  `web.config.ts`、两份 tsconfig 等)。`tsconfig.paths.json` 与 `package.json` /
1634
1644
  `pnpm-workspace.yaml` / `.gitignore` 由 init **现场生成**(不进白名单,模板目录里那份只服务模板自身)。
1635
- 模板拷贝按 `packages/cli/templates/init.include.yaml` 白名单——demo-only 演示模块
1636
- 与模板自运行产物不在清单内,不会进工程(§1.1)。
1645
+ 模板拷贝按框架内置 `init.include.yaml` 白名单——demo-only 演示模块
1646
+ 与模板自运行产物不在清单内,不会进工程(清单是框架侧文件,工程里看不到,见 §1.1)。
1637
1647
  `bin/oj` 缺失时自动经 npm 包 `@oj-bin/oj` 下载(与 `ojm vendor` 同款,受
1638
1648
  `MIN_OJ_VERSION` 门禁约束);下载失败即报错——可先 `ojm vendor`,或把本机 oj
1639
1649
  预置进 `bin/` 后重跑(`bin/oj` 已在则跳过下载,init 幂等)。
@@ -76,7 +76,7 @@ ojm dev
76
76
 
77
77
  # 1) 【免 setup】脚手架 seed 的 framework.market_cdn_url 缺省即 /cdn(本站点自供,
78
78
  # dev 形态 ojm 已把 /cdn 反代给 oj 子进程)。只有指向外部真 CDN 时才需要:
79
- # ojm market setup --user superadmin --pass 123456 --new-pass <你的口令>
79
+ # ojm market setup --user superadmin --pass aA123456 --new-pass <你的口令>
80
80
 
81
81
  # 2) 发包:合成 fixture(或 --real 走 ojm build --bundle)→ 确定性 tgz → 包级签名
82
82
  # --rebuild-index 顺带重建索引
@@ -165,7 +165,7 @@ node "$CLI_PKG/bin/ojm.mjs" market verify --rebuild-index
165
165
  node "$CLI_PKG/bin/ojm.mjs" build # 生成 api/dist + web/dist(含 api/dist/versions.json)
166
166
  # config:platform.root 改成 api/dist(安装目标恒为 release 部署,root 必须与 --api-path 同源)
167
167
  (bin/oj serve -c config.yaml -b /api --api-path api/dist --console-log &)
168
- node "$CLI_PKG/bin/ojm.mjs" market setup --site-url http://127.0.0.1:9778 --user superadmin --pass 123456 --new-pass <新口令>
168
+ node "$CLI_PKG/bin/ojm.mjs" market setup --site-url http://127.0.0.1:9778 --user superadmin --pass aA123456 --new-pass <新口令>
169
169
  # 市场页点安装 → 首次停在「待信任」→ 核对指纹 → ojm trust confirm --task <id> → 重试 → done
170
170
  ```
171
171
 
@@ -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**(自设) | **43 字符** | 同上:回调报文解不开,`suite_ticket` 永远收不到 |
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`**)。裸的 `/api/auth/wecom`
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
- | 指令回调 URL | `https://<站点>/api/auth/wecom/event` | 点保存时企微会 GET 一次带 `echostr`:必须**当次就通**,否则保存不了 |
141
- | 数据回调 URL(若模板要求) | 同上 | 本连接器不消费通讯录变更类事件,收到也只记一条日志并回 `success` |
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 口令 `123456`,
240
+ > 所以拿 `admin` 登录会直接显示「无权限」。请用 `superadmin` 登录(seed 口令 `aA123456`,
182
241
  > **登录后立即改密**);若坚持让 `admin` 也能进,由 `superadmin` 在 `/system/roles` 把 `admin`
183
242
  > 绑到 `superadmin`,但不推荐——这会取消「日常管理员 vs 跨租户运营面」的隔离。
184
243
 
@@ -269,31 +328,60 @@ pnpm dev
269
328
  - 开通链接支持环境标记:`state` 传 `env=test`(或 `test` / `staging` / `dev`)会落到
270
329
  profile 的 `env` 列,缺省 `prod`。同一套代码区分「测试企业」与「正式企业」就靠它。
271
330
 
272
- ### 6.3 数据面:三张共享表 + 一张租户表
331
+ ### 6.3 数据面:三张共享表 + 五张租户表
273
332
 
274
333
  | 表 | 是否租户表 | 存什么 | 为什么这样放 |
275
334
  |---|---|---|---|
276
- | `wecom_app_profile` | **共享**(`tenant: false`) | 企业 × 应用 → 密文 `permanent_code`、`corp_name`、`env`、`status` | 它是「corp → 租户」映射的**权威表**;登录前还没确定租户,必须是部署级 |
335
+ | `wecom_app_profile` | **共享**(`tenant: false`) | 企业 × 应用 → 密文 `permanent_code`、`corp_name`、`env`、`status`、`auth_mode` | 它是「corp → 租户」映射的**权威表**;登录前还没确定租户,必须是部署级 |
277
336
  | `wecom_suite_ticket` | **共享** | 每个部署一条 `suite_ticket` + 过期时刻 | 套件凭据属于整个部署,不属于某个企业 |
278
337
  | `wecom_login_ticket` | **共享** | 一次性登录票据的 **sha256** + 归属租户 + `used` | 落地换票发生在「已知租户但尚未建会话」的瞬间;只存哈希,泄库不可重放 |
279
- | `users` | 租户表 | JIT 建的 `wecom:<userid>` 账号 | 账号天然属于某个企业,`tenant_id = corp_id` |
338
+ | `wecom_sync_job` | **租户** | 同步任务的状态 / 阶段 / 游标 / 计数 / 脱敏错码 | 同步只在**调用方自己的租户**里跑(D7),跨租户读元数据要走显式 `tenant_id` 的裸 SQL |
339
+ | `wecom_sync_lock` | **租户** | 抢锁(`tenant_id + agent_id` 复合主键) | 同上;「插入撞主键」是唯一可靠的原子判据 |
340
+ | `sys_department` / `sys_dept_member` | **租户** | 部门树 / 部门成员(`origin` 标归属权) | 组织架构属于某个企业;`origin='wecom'` 的行归同步管 |
341
+ | `users` | **租户** | JIT 建的 `wecom:<userid>` 账号(`activated=0`) | 账号天然属于某个企业,`tenant_id = corp_id` |
280
342
 
281
343
  三张共享表已列进 `config.yaml` 的 `tenant.shared_allow`。**不要**把它们改成租户表:
282
344
  登录路径在 `ensureTenant` 之前就要读 profile,改成租户表会在守卫那里直接 403。
345
+ 反之,两张同步表**不要**加进 `shared_allow`——它们存的是租户业务状态(进度、跳过计数),
346
+ 共享化等于让所有企业的同步进度混在一张无人守卫的表里。
283
347
 
284
348
  票据换会话的顺序是刚性的:**先绑租户,再抢票据(`used` 0→1 原子)**,然后才校验过期——
285
349
  重放与并发双花都只会成功一次。
286
350
 
351
+ ### 6.3.1 同步怎么跑:分片 + 轮询(不是后台长任务)
352
+
353
+ 服务端 isolate **没有定时器**,路由也有超时(全局 `server.timeout` 缺省 30 秒),所以同步被切成
354
+ 「一片一片推」:
355
+
356
+ ```text
357
+ POST /auth/wecom/sync/start 一次 = 推一片(预算 12 秒)→ 返回当前进度
358
+ GET /auth/wecom/sync/status 只读本地行,不打上游(页面 1~2 秒轮询一次)
359
+ POST /auth/wecom/sync/cancel 置 cancelled 并清游标;**不回滚**已写入的数据(同步幂等,重跑即覆盖)
360
+ ```
361
+
362
+ - 三个阶段固定顺序:`departments`(拉全量部门,按父子序落库)→ `members`(逐部门拉人)→
363
+ `reconcile`(墓碑回收)。游标存在 `wecom_sync_job.cursor_text`,**续跑就是再调一次 start**。
364
+ - 状态四态:`running`(还有下一片)/ `done` / `failed`(连续 10 次致命错熔断)/ `cancelled`。
365
+ 页面看到 `running` 就继续调 start,看到其余三个就停。
366
+ - **限速 10 次/分**(`orgsync:<uid>`)与「一片 12 秒」是配对的:串行驱动天然 5 次/分,
367
+ 10 次是不挡正常推进的余量。改一个必须看另一个。
368
+ - 抢锁:同一企业同一应用同时只允许一个同步,重复 start 会 409。
369
+
287
370
  ### 6.4 代开发后台(`/system/wecom`,仅超级管理员可见)
288
371
 
289
372
  三件事,各对应一个端点(都在 `src/auth/api/wecom/admin/`):
290
373
 
291
374
  | 界面动作 | 端点 | 说明 |
292
375
  |---|---|---|
293
- | 已开通企业清单 | `GET /auth/wecom/admin/apps` | **不返回** `permanent_code`,最多到 `corp_name` |
376
+ | 已开通企业清单 | `GET /auth/wecom/admin/apps` | **不返回** `permanent_code`,最多到 `corp_name`;带 `auth_mode`(授权模式列) |
294
377
  | 启用 / 停用某企业某应用 | `POST /auth/wecom/admin/status` | 停用是安全动作:**只在该企业没有其它 active 应用时**才吊销该租户会话 |
295
378
  | 生成开通链接 | `GET /auth/wecom/admin/install` | 返回 `{installUrl, callbackUrl, expiresIn}`;`installUrl` 是代开发 `get_customized_auth_url` 返回的 `qrcode_url`(企业管理员扫/开即授权),`callbackUrl` 就是要逐字填进企微后台的那个值 |
296
379
 
380
+ > 清单里的「授权模式」列是**只读事实**:`成员授权` 意味着这家企业的应用做不了组织架构同步(§2.6)。
381
+ > 改模式只能由**企业管理员**在企微后台改,服务商这边没有接口能替他改。
382
+ > 同理,**跨租户手动触发同步**(superadmin 替某家企业点同步)目前**没有做**:它需要绕过租户守卫
383
+ > 并双写审计,属于后续排期;现在的同步入口只同步「调用方自己所在的那家企业」。
384
+
297
385
  开通链接由企微 `get_customized_auth_url` 生成,**有效期以企微返回为准**(文档口径可达 10 天,实测可能较短),
298
386
  过期后界面上的二维码会置灰,重新点一次即可(每次点都重新换 `provider_access_token` + 调
299
387
  `get_customized_auth_url`,不做缓存)。代开发**不支持**第三方应用的 `get_pre_auth_code`——本接口
@@ -312,9 +400,14 @@ pnpm dev
312
400
  (带 `owner_tenant`、未使用、未过期)。
313
401
  4. 造「员工」:往 `users` 插 `wecom:<userid>`(`source='wecom'`,口令位 `!wecom`)。
314
402
  5. 前端直接落在 `/login?wecom_ticket=<raw>&tenant=<corp>` → 走换票 → 断言进入首页。
403
+ 6. 造「组织架构 / 同步状态」(**不碰企微上游**):`wecom_app_profile` 的 `auth_mode` 决定页面给哪张卡,
404
+ `wecom_sync_job` 的一行决定状态条文案。两个都造就能覆盖「成员授权 / 已取消授权 / 从没同步过 /
405
+ 同步过且 N 个部门跳过」四种界面,全部可离线跑。仓库里 `tests/e2e/templates/wecom-org-sync.spec.ts`
406
+ 就是这么做的(5 例),要改文案先看它。
315
407
 
316
408
  纯函数也值得单测:`src/login/web/wecom.ts` 的 `parseWecomLanding()` 与 `wecomAuthorizePath()`
317
- (无 react / 无 request,node 侧可直接跑)。
409
+ (无 react / 无 request,node 侧可直接跑);同步器的错码三分、分片、墓碑回收在
410
+ `packages/cli/templates/tests/wecom-org-sync.spec.ts`(23 例)与 `wecom-sync.spec.ts`(15 例)。
318
411
 
319
412
  ### 6.6 前端红线:整页跳转要拼 **API base**
320
413
 
@@ -343,6 +436,36 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
343
436
  - `suite_ticket` 的真实推送节奏(10 分钟)
344
437
  - 企业管理员确认授权的交互,以及「点了取消」时企微只回 `state` 不回 `code`
345
438
  - 通讯录权限导致的兜底:拿不到姓名时显示名落回工号
439
+ - **四项权限的「三态」**(未申请 / 待企业确认 / 已生效):企微没有开放查询接口,只能看症状——
440
+ 名字是工号 ⇒ 缺 ①;树节点无名 ⇒ 缺 ②;职务空 ⇒ 缺 ③;部门少 ⇒ 可能是 ④ 或可见范围窄。
441
+ 这几条**只能真机验**,单测里全部是桩。
442
+ - **`auth_mode` 的真实取值**:`get_permanent_code` 回包里 `auth_info.agent[].auth_mode` 的形态
443
+ (是数组逐应用、还是回落到 `auth_info.auth_mode`)来自官方文档,本地只有桩;
444
+ 第一次真机开通后请确认后台清单里的「授权模式」与实际授权方式一致。
445
+
446
+ ### 6.8 组织架构页:文案与降级卡片(企业管理员看的)
447
+
448
+ 页面在 `/system/departments`,顶部**常驻**一条状态条(不是弹窗、不是只在出错时出现)。
449
+ 文案刻意不暴露内部名词(分片 / 三轨 / 适配器):
450
+
451
+ > 企微组织架构更新于 **X 分钟前** · 自动同步 · [立即同步]
452
+
453
+ 四态与对应卡片,改文案前先确认这四条都还在:
454
+
455
+ | 事实 | 页面给什么 | 为什么 |
456
+ |---|---|---|
457
+ | 本站没装企微(无授权记录) | **整条不出现** | 纯 CRUD 站点不该看到企微的任何痕迹;「没装」不是错误,不画红叉 |
458
+ | `auth_mode=1`(成员授权) | 说明卡 + **不给同步按钮** | 点了必然是 400;不如直接说清怎么改 |
459
+ | `status=disabled`(取消授权/停用) | 冻结黄条 + 不给按钮,数据保留 | 「以下为最后一次同步的快照」 |
460
+ | 正常 | 「更新于 X · 自动同步 · [立即同步]」+ `N 个部门因权限被跳过` | 跳过是可解释的常态(可见范围外的部门),不是错误 |
461
+
462
+ 失败时按错码给**自助卡片**(不是把 `errcode` 原文甩给人):`60011`/`48002` → 去企微后台开通讯录权限;
463
+ `60020` → 找服务商运维加可信 IP;`45009`/`45033` → 已自动退避,稍后点「继续同步」;
464
+ `timeout`/`network` → 查服务器出网。**卡片里永远不出现异常原文**:`client.ts` 把 `access_token`
465
+ 拼在 URL query 里,异常消息可能夹带凭证,落库前统一只存 `{errcode, phase, deptId, at}`。
466
+
467
+ 同步一个企业大概要几次点击?正常规模(几十个部门)一次点击会在内部串行推完所有分片再停;
468
+ 大企业中途会显示进度条。**离开页面即停**(前端不续推),回来点「继续同步」从游标接着走,不会从零重来。
346
469
 
347
470
  ---
348
471
 
@@ -353,13 +476,17 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
353
476
  - [ ] 服务商账号已认证,代开发应用模板已创建
354
477
  - [ ] Tier A 四项已生成并填好(`wecom_callback_token` / `wecom_encoding_aes_key` 在企微后台同页自设;`wecom_enc_key` 见 §2.4 本地生成;`wecom_base_url` 为站点对外地址)
355
478
  - [ ] 模版创建成功后拿到 **SuiteID / SuiteSecret**(Tier B),已填入配置并重启(详见 §4 阶段二)
356
- - [ ] 站点已用 **https** 对外,反向代理放行 `POST /api/auth/wecom/event`(含原始 XML 请求体,不要改写)
479
+ - [ ] 站点已用 **https** 对外,反向代理放行 `POST /api/auth/wecom/event`(**请求体原样转发**:默认是 JSON,老地址可能是 XML,都不要改写或按表单解析)
357
480
  - [ ] 可信域名已备案(含校验文件)
358
481
  - [ ] 可信 IP 已列全所有出口 IP
359
482
  - [ ] 服务器时间已 NTP 同步:回调只接受 ±5 分钟内的 `timestamp`,偏得多了**所有**指令都会被拒
360
483
  - [ ] `wecom_enc_key` 已生成并**离线备份**(与数据库分开)
361
484
  - [ ] 测试企业已准备好,且你自己是它的管理员
362
485
  - [ ] 一个能登录本站的**超级管理员**账号(生成开通链接用)
486
+ - [ ] 部署后先跑 `oj migrate`:组织架构与同步需要 `0016__org`、`auth/0002__org_sync` 两组迁移
487
+ - [ ] 后台清单里「授权模式」是**管理员授权**(成员授权的企业同步不了,§2.6)
488
+ - [ ] 组织架构四项权限已核对(§2.5);同步一次后页面**没有**「N 个部门因权限被跳过」以外的告警
489
+ - [ ] 组织架构同步已照 **§7.6** 逐条打勾(含最后一行的「事件增量」验收),四个数已回填验收记录
363
490
 
364
491
  ### 7.2 上线顺序
365
492
 
@@ -403,6 +530,87 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
403
530
  | `wecom.auth.apply` | `create_auth` / `reset_permanent_code` | 普通 |
404
531
  | `wecom.auth.cancel` | 企业取消授权 | 安全事件 |
405
532
  | `wecom.admin.enable` / `wecom.admin.disable` | 后台启停 | 停用按安全事件记 |
533
+ | `wecom.org.sync` | 组织架构同步(每次 start 一片一条,带 status/phase/stats) | 普通 |
534
+ | `wecom.org.sync.cancel` | 取消同步 | 安全事件(级别 4:中断了一次全量覆盖) |
535
+
536
+ 同步的审计落在**本租户**(同步只动自己那家企业的数据),所以企业管理员在自己的操作日志里
537
+ 就能看到「谁在什么时候同步过、同步到哪一步、跳过了几个部门」。
538
+
539
+ ### 7.6 组织架构同步:真机验收清单
540
+
541
+ 单测只能证明「拿到这样的上游回包时我们这样写库」,证明不了「上游真的这样回」——后者在这里收口(§6.7 列的就是这份清单要验的东西)。每条都写清 **谁点、点什么、看到什么算过、不对去看哪一行**,打勾时请把观测到的数字抄进 §7.6-D。
542
+
543
+ #### A. 一次性前置(服务商运维,约 10 分钟)
544
+
545
+ | 项 | 怎么验 | 不过怎么办 |
546
+ |---|---|---|
547
+ | 两组迁移 | `sqlite3 db.sqlite ".tables"` 里有 `wecom_sync_job` 与 `wecom_sync_lock` | 部署后漏跑 `oj migrate`;补跑再起服 |
548
+ | 两个回调地址 | 企微后台「模板回调 URL」与「应用回调 URL」**都**保存成功(两处都填 `/api/auth/wecom/event`,见 §3) | 只填了模板那个:同步与登录都能用,但通讯录变更事件收不到(事件增量那条验收需要它)。补填应用回调需要企业管理员配合 |
549
+ | 四项权限 | 企微后台权限列表里 §2.5 的 ①~④ 都在,且没有一条停在「待企业确认」 | 重发开通链接**补不了**权限(幂等 upsert,不带权限);只能让客户管理员在企微里确认那条权限变更消息 |
550
+ | 授权模式 | `/system/wecom` 清单里「授权模式」= 管理员授权 | 成员授权(§2.6):请企业管理员改,改完企微推 `change_auth`,本系统自动刷新,不用重新开通 |
551
+ | 时间与出网 | §7.1 的 NTP 与可信 IP 两项 | 时钟偏 >5 分钟 → 所有回调被拒;出口 IP 不在白名单 → 同步第 2 步就 60020 |
552
+
553
+ #### B. 企业管理员侧(客户的 IT,一次性)
554
+
555
+ | 动作 | 为什么只能本人做 |
556
+ |---|---|
557
+ | 扫码 / 打开开通链接完成授权 | `permanent_code` 只在 `create_auth` 时一次性下发,服务商后台不显示 |
558
+ | 在企微里点「确认权限变更」 | 不确认 = 权限永远「待企业确认」,同步能跑但拿不到姓名与部门名(满屏工号) |
559
+ | (若为成员授权)把授权模式改成管理员授权 | 成员授权下应用没有任何通讯录权限,同步 100% 失败 |
560
+
561
+ #### C. 逐项验收(每条独立打勾)
562
+
563
+ | # | 操作 | 期望观测 | 不符时先看 |
564
+ |---|---|---|---|
565
+ | 1 | 组织架构页点「立即同步」,等状态条停下 | 文案变「企微组织架构更新于 **刚刚** · 自动同步」;`SELECT status, stats_text FROM wecom_sync_job` → `done`,`stats.departments` / `stats.members` 都 > 0 | `status=paused` → 再点「继续同步」(分片没推完,不是失败);`err_code` 非空 → §6.8 的卡片对应项 |
566
+ | 2 | 抽 3 个部门核对人数与层级 | 每个部门的人数与企微后台一致;层级正确;**主部门唯一**(不是所有人都在「总公司」) | 人数全为 0 → 可见范围为「仅部分成员」;层级全平 → 缺 ④ 的父部门 ID |
567
+ | 3 | 看人名 | 显示名是人名(`display_name` 有值) | 满屏 `wecom:<工号>` → 缺 §2.5 ① |
568
+ | 4 | 看职务列 | 有职务 | 全空 → 缺 §2.5 ③ |
569
+ | 5 | 看状态条的跳过计数 | 「N 个部门因权限被跳过」,且 N **远小于**部门总数 | N 与总数接近 → 先查 §2.5 ④ 与可见范围,再查权限是否卡在待确认 |
570
+ | 6 | 让一个**普通员工**登录一次 | 该员工 `users.display_name` / `position` 被回写,`sys_dept_member` 与企微一致 | 登录本身失败看 §8;登录成功但资料没变 → 该员工不在可见范围,或 24 小时内已自更新过(缓存键 `wecom:self:<corp>:<userid>`) |
571
+ | 7 | 先在本地建一个部门,再点一次同步 | 本地部门**不消失、不改名**(数据源只管辖它自己带来的行) | 本地部门被覆盖 → 严重缺陷,保留现场(`SELECT origin …`)再报 |
572
+ | 8 | 企微后台取消授权 | 组织架构页变冻结黄条(数据保留);该租户 `sys_refresh_token` 清零 | 页面仍显示可同步 → `wecom_app_profile.status` 没落 `disabled`,查 `cancel_auth` 日志 |
573
+ | 9 | 重新扫码授权 | 清单该行恢复 `active`,`permanent_code_enc` 被重写(密文变化即可) | 仍是 `disabled` → 事件没到,回 A 表查回调地址 |
574
+ | 10 | **事件增量(轨三)**:请客户在企微后台改一个部门名,或加/删一个人 | 服务端日志出现 `wecom change_contact applied … changeType=…`;**不点「立即同步」**,刷新组织架构页也能看到变化 | 见下 |
575
+
576
+ 最后一行的几种结果,一次就能分清「事件到了没 / 处理成没成 / 是哪种投递形态」:
577
+
578
+ | 日志 | 说明 | 下一步 |
579
+ |---|---|---|
580
+ | `wecom change_contact applied … changeType=create_user`(`msgType=event`、`event=change_contact`) | 数据回调(**应用侧形态**:`ToUserName` / `MsgType=event` / `Event`)收到了,且落库成功 | 正常。`fields` 之类不再需要抄,日志里 `changeType` 与 `corpId` 就够定位 |
581
+ | `wecom change_contact applied … changeType=update_party`(`infoType=change_contact`、`event=` 为空) | 同一条变更走的是**模板指令回调**(代开发形态:`SuiteId` / `AuthCorpId` / `InfoType`)。两种形态都会到,`event=` 空不代表事件没到 | 同样正常。两种形态由同一个端点吃下,不需要为它单独配地址或凭据 |
582
+ | `wecom change_contact ignored … reason=该企业未授权本站(wecom_app_profile 无记录)` | 报文到了、也解密了,但 `wecom_app_profile` 里没有这个企业 | 回 §7.6-A 核对:这家企业的开通流程是否真的走完(清单里要有行、`status=active`) |
583
+ | `wecom change_contact ignored … reason=企微事件 change_contact 缺少 UserID` / `…缺少 Id` | 明文里没有该有的字段:报文形态与文档不符,或被中间件改写 | 把 `changeType` 与原始明文交给开发;**别**靠重推解决(重推是同一份报文) |
584
+ | `wecom change_contact ignored … reason=部门事件既没有名字、本地也没有这一行:不建无名部门` | 代开发形态只给部门 `Id`,而回源 `department/get` 又拿不到(无通讯录读权限) | 补 §2.5 的 ①/④;补齐后点一次「立即同步」即可补上,不用等事件 |
585
+ | `wecom change_contact not consumed … changeType=update_tag` | 标签变更等本系统不消费的类型 | 正常,无需处理 |
586
+ | `wecom callback decrypt failed … reason=bad padding` | 签名过了但**钥匙**不对 → 数据回调用的是**另一套**凭据 | 记下同一行的 `envelopeToUser` 与 `aesKeyFp`:前者告诉你报文来自哪个套件/模板,后者证明本进程加载的是哪把钥匙。结论写进 §2.1 的缺口,再决定是否要单独配一套应用侧凭据 |
587
+ | `wecom callback decrypt failed … reason=receiver mismatch` | 钥匙对、但报文自称的身份(`receiveid`)既不等于配置的 `suite_id`、也不等于信封里的 `ToUserName` | 多见于信封缺 `ToUserName`;先确认服务端不是 0.2.48 或更早(那些版本只认 `suite_id`,所有数据回调都会这样,见下方注) |
588
+ | 一条日志都没有 | 事件根本没进本站 | 应用回调 URL 没填(A 表第 2 项),或反代挡了 POST |
589
+
590
+ > **别拿 `envelopeToUser` 和 `configuredSuite` 比大小**。`receiveid` 的含义随场景变(企微 90968 附注):
591
+ > 第三方事件的回调是 suite_id(`suite_ticket` / `create_auth`),**企业应用(含代开发应用的数据回调)是
592
+ > corpid**。所以数据回调那一行的 `envelopeToUser` 本来就是企业代码、与 `configuredSuite` 不同——服务端
593
+ > 0.2.48 之后的版本就是按「两个候选都试」解密的,这个差异本身不是故障。
594
+
595
+ 日志在工程根 `logs/server-*.log`:
596
+
597
+ ```bash
598
+ grep -h "wecom change_contact\|wecom event ignored\|callback decrypt failed" logs/server-*.log | tail -20
599
+ ```
600
+
601
+ #### D. 验收记录(请回填,别只写「看起来正常」)
602
+
603
+ | 字段 | 值 |
604
+ |---|---|
605
+ | 企业代码 / 应用 | |
606
+ | 四项权限状态(①~④) | |
607
+ | 部门数 / 人数 / 跳过数 | |
608
+ | 单次全量耗时(`finished_at - started_at`) | |
609
+ | 事件增量行日志原文(`event=` 与 `tags=`) | |
610
+ | 结论 | 通过 / 不通过(不通过项编号) |
611
+
612
+ > 判定口径是**数字**:部门数、人数、跳过数、耗时四个数写不出来,就等于没验。
613
+ > 跳过数不为 0 不是失败(可见范围外是硬口径);但「跳过数接近总数」或「主部门不唯一」必须判不通过。
406
614
 
407
615
  ---
408
616
 
@@ -420,9 +628,23 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
420
628
  | 400 `回调 timestamp 超出允许窗口:请检查服务器时间,或该请求为历史回调的重放` | 服务器时钟与真实时间偏差超过 5 分钟,或这条回调是被人回放的旧报文 | 校服务器时间(NTP);校正后企微的重推会自动成功 |
421
629
  | 400 `回调 nonce 重复:同一条指令回调已被处理过(重放请求已拒绝)` | 同一条指令被投递了两次(企微重试、或反代重复转发) | 首次生效、重放被拒属预期保护;若常态出现,查反代是否重复转发 POST |
422
630
  | 企微后台保存回调 URL 失败 | `Token` / `EncodingAESKey` 与 `config.yaml` 不一致 | 逐字对齐(43 字符别多空格) |
631
+ | 日志 `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 表最后一行下方那条注),去「应用与模板上线」核对回调地址填在哪 |
632
+ | 日志 `wecom callback decrypt failed … reason=receiver mismatch` | 钥匙对、但报文自称的身份两边都对不上(配置的 `suite_id` 与信封 `ToUserName` 都不是它) | 信封缺 `ToUserName` 时会这样;先确认服务端不是 0.2.48 或更早(那些版本只认 `suite_id`)。仍复现就把整行日志(含 `envelopeToUser` / `configuredSuite` / `aesKeyFp`)留档 |
633
+ | 企业管理员扫码/打开开通链接后提示「**应用安装失败,联系服务商**」 | 企微推 `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
634
  | 400 `企业代码无效或应用未开通` | corp_id 打错,或该企业没装本应用 | 核对 corp_id(`ww` 开头);确认已在后台清单里 |
424
635
  | 400 `该企业开通了多个应用,登录链接必须带 agent 参数` | 同企业装了测试+正式两个应用 | 链接加 `&agent=<agentid>` |
425
636
  | 401 `登录凭据无效或不在应用可见范围内` | code 无效 / 员工不在应用可见范围 / 非本企业成员 | 企微后台检查应用可见范围 |
637
+ | 400 `本站尚未开通企业微信:没有可同步的授权记录` | 该租户在 `wecom_app_profile` 里没有行(这会出现在组织架构页的同步入口,不是登录) | 先走开沟通道(§4);单租户部署下「租户」= 企业代码 |
638
+ | 400 `该企业为「成员授权」模式,应用没有通讯录权限 …` | `auth_mode=1`(§2.6),此时任何通讯录接口都是 `60011` | **企业管理员**在企微后台把授权模式改为「管理员授权」;企微会推 `change_auth`,本系统自动刷新 `auth_mode`,无需重新开通 |
639
+ | 400 `该企业已取消授权或应用已停用:组织架构同步已冻结 …` | `status=disabled`(企业取消授权,或后台点了停用) | 服务商重新发开通链接;已同步的组织数据保留,可当快照用 |
640
+ | 组织架构页卡片「应用缺少通讯录权限(错误码 60011 / 48002)」 | 缺 §2.5 的 ①/②/④(自定义权限还卡在「待企业确认」也会这样) | 企业管理员:企微后台 → 应用管理 → 本应用 → 权限,勾选后提交,并**在企微里确认**那条权限变更消息 |
641
+ | 卡片「企微接口限流(错误码 45009 / 45033)」 | 触发企微频率/并发限制(大企业全量同步常见) | 已自动退避三次;仍失败就点「继续同步」,游标停在原部门,不会丢进度 |
642
+ | 卡片「服务器出口 IP 不在企微白名单(60020)」 | 同上方 60020 行 | 同上方处置(加可信 IP) |
643
+ | 状态条「N 个部门因权限被跳过」 | 这些部门在应用**可见范围外**(或负责人/父部门权限缺失) | 常态可忽略;若 N 与总部门数接近,先查 §2.5 ④ 与可见范围(§2.4 的话术可以直接发给企业管理员) |
644
+ | 企微后台改完通讯录,页面**没变**(也没点过同步) | 事件没到,或到了但处理失败。事件是加速器不是权威:失败**不会**报错、也不会重推成功 | `grep "wecom change_contact" logs/server-*.log \| tail`:`applied` = 已落库(刷新页面即可见);`ignored … reason=…` = 按 reason 修(未授权 / 缺字段 / 无通讯录读权限);一条都没有 = 应用回调 URL 没填(§3 第 2 项)。急着要数据就先点一次「立即同步」,全量同步是权威 |
645
+ | 409 `同步正在进行,请稍后再试` | 同一企业同一应用已有同步在跑(另一个标签页 / 上一次请求崩了留下锁) | 等一次轮询周期再点;崩溃残留的锁 120 秒后可被抢占,不用人工清 |
646
+ | 429 `rate limited` | `start`/`cancel` 限速 10 次/分(同一账号在两个页面同时驱动,或手动连点) | 等一分钟再点;进度已落库,不需要从头重来 |
647
+ | 点「立即同步」后按钮立刻回到可点、进度没动 | 这一片被限流或上游抖动提前收手(状态仍是 `running`) | 正常现象:再点「继续同步」;页面每 2 秒会自己刷新状态 |
426
648
  | 403 `账号已被停用,请联系管理员` | 本站把该账号 `is_forbidden=1` | 后台启用该账号 |
427
649
  | 401 `登录票据无效或已使用` / `登录票据已过期,请重新登录` | 票据被重放,或 180 秒没换 | 重新从入口进 |
428
650
  | 400 `授权未完成:企业微信未返回 code` | 用户在授权页点了取消 | 重新授权 |
@@ -446,8 +668,9 @@ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
446
668
  [ ] 八个 vars 非空(Tier A/B/C),wecom_base_url 以 /api/auth/wecom 结尾
447
669
  [ ] 企微后台的指令回调 URL == <wecom_base_url>/event(逐字)
448
670
  [ ] 服务日志有 wecom suite_ticket updated(近期)
449
- [ ] /system/wecom 列表里有目标企业,status=active
671
+ [ ] /system/wecom 列表里有目标企业,status=active,授权模式=管理员授权
450
672
  [ ] /system/settings 的「启用企业微信登录」= 开
451
673
  [ ] 用普通员工账号(非管理员)完整走过一次登录
674
+ [ ] /system/departments 顶部状态条有「更新于 X」;树有名字、人挂在正确部门
452
675
  [ ] db.sqlite 与 wecom_enc_key 各自有备份
453
676
  ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/cli",
3
3
  "type": "module",
4
- "version": "0.2.47",
4
+ "version": "0.2.48",
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.47",
52
+ "@oj-module/runtime": "0.2.48",
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.47",
307
+ "version": "0.2.48",
308
308
  "license": "MIT"
309
309
  },
310
- "lastBuildTime": "2026-10-31 16:11:28"
310
+ "lastBuildTime": "2026-11-01 10:19:28"
311
311
  };
312
312
  }
313
313
  var init_get_app_info = __esmMin((() => {}));
@@ -5,9 +5,9 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" href="/favicon.ico" />
7
7
  <title>React Antd Admin · Shell</title>
8
- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-2NkaM2/61JPKHd1FH61mYw=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-3nM+tS10yKYnXNwkhSrSkw=='; 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="2NkaM2/61JPKHd1FH61mYw==">{
10
+ <script type="importmap" nonce="3nM+tS10yKYnXNwkhSrSkw==">{
11
11
  "imports": {
12
12
  "react": "/assets/react.js",
13
13
  "react/jsx-runtime": "/assets/jsx-runtime.js",