@oj-module/cli 0.2.66 → 0.2.68

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 (200) hide show
  1. package/bin/.oj-version +1 -0
  2. package/bin/oj +4 -0
  3. package/devkit/README.md +15 -7
  4. package/devkit/SKILL.md +38 -4
  5. package/devkit/framework-dev.md +10 -4
  6. package/devkit/manual.md +146 -24
  7. package/devkit/menu-rules.md +19 -4
  8. package/devkit/module-dev-and-cdn-playground.md +4 -0
  9. package/devkit/publish-walkthrough.md +132 -37
  10. package/devkit/wecom-internal-app.md +8 -3
  11. package/devkit/wecom-multi-corp-links.md +163 -0
  12. package/package.json +2 -2
  13. package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
  14. package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.1xzahckt10.js} +4863 -4847
  15. package/shell-dist/index.html +4 -4
  16. package/shell-dist/versions.json +3 -3
  17. package/src/__tests__/cli.test.ts +31 -0
  18. package/src/__tests__/new-scaffold.test.ts +13 -0
  19. package/src/__tests__/publish-keygen.test.ts +157 -0
  20. package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
  21. package/src/__tests__/templates-schema-columns.test.ts +15 -2
  22. package/src/__tests__/templates-tenant-scope.test.ts +6 -2
  23. package/src/build/module-def.ts +1 -0
  24. package/src/builtin.ts +3 -2
  25. package/src/bundle.ts +8 -3
  26. package/src/cli.ts +21 -5
  27. package/src/dev.ts +15 -10
  28. package/src/init.ts +47 -41
  29. package/src/install.ts +11 -4
  30. package/src/keygen.ts +54 -0
  31. package/src/lifecycle/__tests__/bundle.test.ts +21 -3
  32. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
  33. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
  34. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
  35. package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
  36. package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
  37. package/src/lifecycle/__tests__/smoke.test.ts +2 -2
  38. package/src/manifest-contract.ts +91 -8
  39. package/src/market/bootstrap.ts +26 -2
  40. package/src/market/command.ts +1 -0
  41. package/src/market/publish.ts +8 -1
  42. package/src/market/walkthrough.ts +24 -8
  43. package/src/new.ts +2 -0
  44. package/src/scaffold-guard.ts +227 -0
  45. package/src/site.ts +2 -0
  46. package/src/usage.ts +13 -9
  47. package/src/vendor.ts +5 -1
  48. package/templates/config.yaml +4 -2
  49. package/templates/init.include.yaml +10 -3
  50. package/templates/openapi.json +460 -1
  51. package/templates/src/README.md +37 -0
  52. package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
  53. package/templates/src/app/api/_platform/_lifecycle/installer.ts +78 -25
  54. package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
  55. package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
  56. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
  57. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
  58. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
  59. package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
  60. package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
  61. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
  62. package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
  63. package/templates/src/app/api/_platform/_shared/service/domain/solution-perms.ts +25 -10
  64. package/templates/src/app/api/_platform/manifest.yaml +1 -0
  65. package/templates/src/app/api/_platform/market/api.ts +235 -10
  66. package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
  67. package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
  68. package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
  69. package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
  70. package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
  71. package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
  72. package/templates/src/app/api/_platform/schema.yaml +18 -0
  73. package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
  74. package/templates/src/app/api/_platform/seed.pg.sql +10 -0
  75. package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
  76. package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
  77. package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
  78. package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
  79. package/templates/src/app/api/home/seed.mysql.sql +71 -0
  80. package/templates/src/app/api/home/seed.pg.sql +71 -0
  81. package/templates/src/app/api/notification/_shared/notify.ts +6 -6
  82. package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
  83. package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
  84. package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
  85. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
  86. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
  87. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
  88. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
  89. package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
  90. package/templates/src/app/api/notification/seed.mysql.sql +8 -0
  91. package/templates/src/app/api/notification/seed.pg.sql +11 -0
  92. package/templates/src/app/api/publish/_shared/identity.ts +69 -0
  93. package/templates/src/app/api/publish/api.ts +349 -0
  94. package/templates/src/app/api/publish/manifest.yaml +8 -0
  95. package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
  96. package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
  97. package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
  98. package/templates/src/app/api/publish/schema.yaml +33 -0
  99. package/templates/src/app/api/system/login-methods/api.ts +2 -1
  100. package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
  101. package/templates/src/app/web/_platform/__client/index.ts +25 -1
  102. package/templates/src/app/web/entry.ts +9 -5
  103. package/templates/src/app/web/home/routes.ts +5 -0
  104. package/templates/src/app/web/login/locales/en-US.json +7 -1
  105. package/templates/src/app/web/login/locales/zh-CN.json +7 -1
  106. package/templates/src/app/web/login/pages/login.tsx +79 -86
  107. package/templates/src/app/web/login/routes.ts +54 -1
  108. package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
  109. package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
  110. package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
  111. package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
  112. package/templates/src/app/web/system/locales/en-US.json +1 -8
  113. package/templates/src/app/web/system/locales/zh-CN.json +1 -8
  114. package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
  115. package/templates/src/app/web/system/routes.ts +0 -6
  116. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
  117. package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
  118. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
  119. package/templates/src/oj-market/web/api/client.ts +3 -0
  120. package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
  121. package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
  122. package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
  123. package/templates/src/oj-market/web/entry.ts +31 -2
  124. package/templates/src/oj-market/web/locales/en-US.json +5 -1
  125. package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
  126. package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
  127. package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
  128. package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
  129. package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
  130. package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
  131. package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
  132. package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
  133. package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
  134. package/templates/src/oj-market/web/publish/routes.ts +41 -0
  135. package/templates/src/oj-market/web/types.ts +30 -0
  136. package/templates/tests/lifecycle/compensate.spec.ts +207 -0
  137. package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
  138. package/templates/tests/lifecycle/installer.spec.ts +28 -9
  139. package/templates/tests/lifecycle/market-api.spec.ts +164 -12
  140. package/templates/tests/lifecycle/preflight.spec.ts +68 -0
  141. package/templates/tests/lifecycle/register.spec.ts +22 -0
  142. package/templates/tests/lifecycle/role-title-translate.spec.ts +4 -34
  143. package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
  144. package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
  145. package/templates/tests/publish-credential.spec.ts +354 -0
  146. package/templates/tests/publish-identity.spec.ts +44 -0
  147. package/templates/tests/rbac-solution-perms.spec.ts +48 -3
  148. package/templates/tests/wecom-client-timeout.spec.ts +33 -3
  149. package/templates/tests/wecom-landing.spec.ts +63 -1
  150. package/templates/tsconfig.paths.json +1 -0
  151. package/templates/web.config.ts +8 -4
  152. package/vendor/host-versions.json +3 -3
  153. package/templates/seed.mysql.sql +0 -6
  154. package/templates/seed.pg.sql +0 -6
  155. package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
  156. package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
  157. package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
  158. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
  159. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
  160. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
  161. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
  162. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
  163. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
  164. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
  165. package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
  166. package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
  167. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
  168. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
  169. package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
  170. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
  171. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
  172. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
  173. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
  174. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
  175. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
  176. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
  177. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
  178. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
  179. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
  180. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
  181. package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
  182. package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
  183. package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
  184. package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
  185. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
  186. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
  187. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
  188. package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
  189. package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
  190. package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
  191. package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
  192. package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
  193. package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
  194. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
  195. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
  196. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
  197. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
  198. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
  199. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
  200. package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
@@ -1,8 +1,7 @@
1
1
  # 方案包发布全流程——新手照做版
2
2
 
3
- 适用 ojm ≥ 0.2.22(写作时 0.2.56)。本文自包含:从「代码写完了」到「装进站点」一条命令链照着敲,每步附期望输出与通过判据。
4
- 字段规范与错误码全口径见 `manual.md` §11/§12;仓内深度过程视角另见
5
- `docs/guides/solution-dev-build-publish.md`(不随 cli 包发货)。
3
+ 适用 ojm ≥ 0.2.22(写作时 0.2.66)。本文自包含:从「代码写完了」到「装进站点」一条命令链照着敲,每步附期望输出与通过判据。
4
+ 字段规范与错误码全口径见同目录 `manual.md` §11/§12——**本文不引用包外文件**,照着读完即可完成发布。
6
5
 
7
6
  ---
8
7
 
@@ -11,7 +10,7 @@
11
10
  ```mermaid
12
11
  flowchart LR
13
12
  A["① 构建 ojm build"] --> B["② 打包 ojm build --bundle"]
14
- B --> C["③ 身份 ojm keygen(一次性)"]
13
+ B --> C["③ 身份 方案市场→发布管理 申请凭证 → ojm keygen --credential"]
15
14
  C --> D["④ 签名 ojm sign --check"]
16
15
  D --> E["⑤ 发布 ojm market publish"]
17
16
  E --> F["⑥ 重建索引 ojm market build-index"]
@@ -20,18 +19,25 @@ flowchart LR
20
19
  ```
21
20
 
22
21
  信任链硬 fail-closed:未签、签错、key 不被信任的包一律拒装,没有「先装了再说」的开关。
23
- **开始前确认三件事**(缺一件打包就过不去):
22
+ **开始前确认三件事**:
24
23
 
25
24
  1. 方案契约在位:`src/<模块>/solution.yaml`(`name` / `version` / `summary`;纯前端可省 `api` 段,纯后端可省 `web` 段);
26
- 2. `web.config.ts` 的 `modules` 数组已登记该模块(否则构建不产出站点产物,打包报「缺少本方案的站点产物」);
27
- 3. 双语 locale 在位:`web/src/.../locales/zh-CN.json` 与 `en-US.json`。
25
+ 2. 前端入口在位:`src/<模块>/web/entry.ts`。module-root 布局(0.2.20+)以**扫描**为准,
26
+ `web.config.ts` 的 `modules` 登记只影响加载顺序,漏登记**不会**漏构建(登记了不存在的 entry 才会报错);
27
+ 3. 双语 locale 在位:`src/<模块>/web/locales/{zh-CN,en-US}.json`(key 必须嵌套,如 `{"menu":{"main":"…"}}`);
28
+ 4. **匿名端点已在契约声明**:凡不带 token 可访问的端点(`ojm new` 骨架的 `ping` 就是),必须在
29
+ `solution.yaml` 的 `api.anonymous` 里列出(如 `anonymous: ["<模块>/ping"]`,模块相对路径)——
30
+ 漏声明安装预检 `anonymous_residual` 直接拒装(0.2.68 起 `ojm new --api` 骨架已自带这行;
31
+ 自己加端点时记得同步维护这份清单)。
32
+ 5. **别把方案建进保护根**:`src/app`、`src/oj-market` 受完整性门禁保护(0.2.68+,见 `src/README.md`),
33
+ `ojm build` 会先比对上游模板、有漂移即拒;用户方案放 `src/<你的名字>/`。
28
34
 
29
35
  ---
30
36
 
31
37
  ## 1. 构建与打包
32
38
 
33
39
  ```bash
34
- ojm build # ① 站点产物 web/dist/ + 后端产物 api/dist/
40
+ ojm build # ① 站点产物 dist/web/ + 后端产物 dist/api/
35
41
  ojm build --bundle # ② 打包全部用户方案 → dist/<name>-<version>.tgz
36
42
  ojm build --bundle crm # 只打 src/crm/ 这一份(多方案工程用)
37
43
  ```
@@ -40,7 +46,7 @@ ojm build --bundle crm # 只打 src/crm/ 这一份(多方案工程用)
40
46
 
41
47
  ```
42
48
  [ojm] 方案包已生成 → dist/<name>-<version>.tgz
43
- [ojm] sha256:…(确定性打包:同源同字节)
49
+ [ojm] sha256:…(N 字节;确定性打包:同输入两次打包哈希一致)
44
50
  ```
45
51
 
46
52
  三个要点:
@@ -53,19 +59,58 @@ ojm build --bundle crm # 只打 src/crm/ 这一份(多方案工程用)
53
59
 
54
60
  ---
55
61
 
56
- ## 2. 一次性:生成发布身份(keygen)
62
+ ## 2. 一次性:拿发布身份(站点凭证)
57
63
 
58
- 每个开发者/每个环境**各自生成一把** Ed25519 密钥对,只需做一次:
64
+ 发布身份 = 一把 Ed25519 密钥 + 一个**站点认可**的身份名(`<产品名>.<账号>`)。
65
+ 身份名不自拟:站点要靠它在「发布管理」页留档,说清「谁、为哪个产品」发的包。
66
+ 所以主流程是「先申请凭证,再在本机兑换」:
67
+
68
+ > 站点保证的是 **key_id 唯一**(同一产品名 + 同一账号 → 同一个身份,同一身份换机重申请是允许的),
69
+ > 不是产品名独占:不同账号申请同名产品会各自得到 `<产品名>.<自己的账号>`。
70
+ > 所以「谁有资格发这个方案」仍靠团队约定与市场侧的包名/版本冲突拦截,不能指望站点替你裁决。
71
+
72
+ > 谁能申请:**任何登录用户**,不要求超管。但新建账号首次登录处于「必须先改密」状态,
73
+ > 此时除改密外的写接口一律 `403 password change required`——先在页面或 `PUT /api/auth/password`
74
+ > 改掉临时口令,再申请凭证。
75
+
76
+ 1. 浏览器进 **方案市场 → 发布管理 → 申请发布凭证**,填产品名(如 `demo-solution`);
77
+ 2. 复制弹窗里的凭证或整条命令(**只显示这一次**,关闭后只能撤销重申请);
78
+ 3. 本机执行其中一条:
79
+
80
+ ```bash
81
+ ojm keygen --credential <token>
82
+ OJM_PUBLISH_CREDENTIAL=<token> ojm keygen # 或走环境变量,避免令牌进 shell 历史
83
+ ```
84
+
85
+ - key_id 由站点按 `<产品名>.<编码后用户名>` 算好下发(例:`demo-solution.wecom-everpan`),
86
+ CLI 不做任何推导;落点 `~/.ojm/keys/<key_id>/`:`private.pem`(0600)+ `public.hex`。
87
+ - 期望输出里有一行 **`指纹(sha256(publicKey),公布给宿主核对):…`**——抄下来,首装核对要用(§6)。
88
+ - 凭证**一次性**:兑换成功即作废;兑换失败(已用/已过期/已撤销/网络不通)**不写入任何密钥文件**。
89
+ - 站点地址:`--site-url` > `OJM_SITE_URL` > `http://127.0.0.1:9778`;兑换端点匿名可达,不需要 `--user/--pass`。
90
+ - `--credential` 与 `--id` 互斥(同时给 exit 2);`--out` / `--yes` 仍按离线版规则生效
91
+ (`--out <dir>` 相对路径按**进程 cwd** 解析,指进当前工程树会要求 `--yes`——私钥入仓即泄露)。
92
+ - **它不替你建信任**:公钥是本地生成的,站点在兑换时拿不到,所以首装仍是陌生 key →
93
+ `needs_trust`,仍要按 §6 逐字核对指纹后 `trust confirm`。凭证解决的是「身份命名与归属」,
94
+ 不是「信任免检」。
95
+ - 兑换完成后,「方案市场 → 发布管理」该条记录变为**已兑换**并显示 key_id。
96
+ - 发版之后,「方案市场 → 发布历史」(`/oj-market/history`)按发布时间倒序列出所有产品/版本:
97
+ 数据由站点从签名索引对账落库,**不需要 CLI 额外上报**;发布时间优先取分发包落 CDN 的
98
+ `Last-Modified`,源不提供时回落索引生成时间并标「索引时间」标签。
99
+
100
+ ### 2.1 没有站点时:离线自建身份(旁路)
101
+
102
+ 站点不可达、或你维护的是纯自建/离线 CDN(根本没有「发布管理」页)时,才自己起 key_id:
59
103
 
60
104
  ```bash
61
105
  ojm keygen --id acme.alice.macbook # 例:<团队/产品>.<人名>.<环境>
62
106
  ```
63
107
 
64
- - 期望输出里有一行 **fingerprint**(`sha256(公钥)`)——**抄下来发给运维**,TOFU 核对要用(§8)。
65
- - 落点 `~/.ojm/keys/<id>/`:`private.pem`(权限 0600)+ `public.hex`。
66
- - key_id 必须**全小写、至少两段**(`<publisher>.<id>`),禁裸通用名如 `mykey`——违约包能签出来但装不进(`sig_key` 拒装)。
108
+ - **站点不知道这个身份**:发布管理页没有它的记录,产品归属与申请人无从追溯;能不能装,
109
+ 完全取决于超管愿不愿意为这枚指纹手工放行。有站点的市场请走 §2 凭证,不要自建。
110
+ - 其余规则与凭证通道一致:key_id 必须**全小写、至少两段**(`<publisher>.<id>`),
111
+ 禁裸通用名如 `mykey`——违约包能签出来但装不进(`sig_key` 拒装)。
67
112
 
68
- 私钥纪律(逐条照做,违者 = 任何人都能以你的身份发包):
113
+ 私钥纪律(两条路都适用,逐条照做,违者 = 任何人都能以你的身份发包):
69
114
 
70
115
  | 纪律 | 原因 |
71
116
  |---|---|
@@ -80,12 +125,15 @@ ojm keygen --id acme.alice.macbook # 例:<团队/产品>.<人名>.<环境>
80
125
 
81
126
  ```bash
82
127
  ojm sign dist/<name>-<version>.tgz \
83
- --id acme.alice.macbook \
84
- --key ~/.ojm/keys/acme.alice.macbook/private.pem \
128
+ --id <key_id> \
85
129
  --registry-id oj-registry \
86
130
  --check
87
131
  ```
88
132
 
133
+ - `<key_id>` 就是上一步拿到的身份:走凭证是站点下发的 `demo-solution.xxx`,走离线旁路是
134
+ §2.1 里 `--id` 填的那个。私钥缺省取 `~/.ojm/keys/<key_id>/private.pem`;只有把 key 放到了
135
+ 别处(如 `--out ./keys`)才需要再加 `--key <path>/private.pem`,且此时 `--id` 仍必须给
136
+ (`.sig` 按它记发布方)。
89
137
  - `--registry-id` **必填**:发布仓库的自拟标识(同一市场的所有发布方用同一个值,如 `oj-registry`)。它进签名规范串防「把 A 仓库的包重放到 B 仓库」;站点若配置了自身 registry 身份则必须与之一致(默认模板未配置,不比对)。
90
138
  - 产物 = 同目录 `<tgz>.sig` 侧车文件。**签了再改包 = 签名作废**;重打包必须重签。
91
139
  - `--check` 离线自检(本地验一遍),不必等安装失败才发现签错。期望输出:`[ojm] 自检通过…`。
@@ -100,13 +148,20 @@ CDN 就是**一个装着固定命名文件的目录**:`<name>-<version>.tgz`
100
148
 
101
149
  ```bash
102
150
  ojm market publish --real . \
103
- --id acme.alice.macbook \
104
- --key ~/.ojm/keys/acme.alice.macbook/private.pem \
151
+ --id <key_id> \
105
152
  --registry-id oj-registry \
106
153
  --rebuild-index
107
154
  ```
108
155
 
109
- 期望输出:`[ojm] 包已落 CDN → cdn/<name>-<version>.tgz(N 字节)` + 发布方指纹。`cdn/` 下落齐六件:tgz / .tgz.sig / 索引两份 yaml(+ 各自 .sig)。
156
+ 期望输出:`[ojm] 包已落 CDN → cdn/<name>-<version>.tgz(N 字节)` + 发布方指纹。
157
+ `cdn/` 是**三级索引**布局:`catalog.yaml`(+`.sig`)、`shards/<n>.yaml`(+`.sig`)、
158
+ `<name>-<version>.tgz`(+`.tgz.sig`);分片数随方案数增长,不要按固定文件数核对。
159
+
160
+ > **`--real` 仅限单方案工程**(`src/` 下只有一份 `solution.yaml`);`ojm new` 生成的方案长在宿主工程里
161
+ > (宿主还自带 `app`/`oj-market`),多方案工程走 §4.2 显式四步。
162
+ > **`--fixture` 不是发布通道**:`ojm market publish --fixture <name>@<ver>` / `trial` / `walkthrough`
163
+ > 里的 fixture 是**现场合成的最小合法测试包**(固定 ping 形状),专供演练与隔离验证——
164
+ > 它不会打包你的真实代码,别拿它发真方案。
110
165
 
111
166
  > 引用式方案(契约 `api` 段省略 `tables`/`deps`)会打印「引用式方案…非漏配」提示——那是合法形态,不是错误。
112
167
 
@@ -116,9 +171,8 @@ ojm market publish --real . \
116
171
 
117
172
  ```bash
118
173
  ojm build --bundle crm # ① 打包指定方案
119
- ojm sign dist/crm-1.0.0.tgz --id acme.alice.macbook \
120
- --key ~/.ojm/keys/acme.alice.macbook/private.pem \
121
- --registry-id oj-registry --check # ② 签名
174
+ ojm sign dist/crm-1.0.0.tgz --id <key_id> \
175
+ --registry-id oj-registry --check # ② 签名(私钥缺省取 ~/.ojm/keys/<key_id>/)
122
176
  cp dist/crm-1.0.0.tgz dist/crm-1.0.0.tgz.sig cdn/ # ③ tgz + .sig 放进 cdn/
123
177
  ojm market build-index --id <维护者key> --check # ④ 重建索引(见 §4.3)
124
178
  ```
@@ -137,11 +191,16 @@ ojm market build-index --id <维护者key> --check # ④ 重
137
191
  ## 5. 验证发布(不起站点)
138
192
 
139
193
  ```bash
140
- ojm market verify --rebuild-index # 五项自检:完整性/包级签/索引签×2/manifest 结构,全 ✓ 才算过
194
+ ojm market verify --rebuild-index # 逐项验:完整性 / 包级签 / manifest 结构 / 索引签(catalog+分片)/ 索引同代 / 索引时效 / 迁移一致性
141
195
  ojm market trial --fixture <name>@<version> # 隔离站点跑完整安装,跑完即毁,不污染工程
142
- ojm market walkthrough --fixture <name>@<version> # 14 项 AC:主链 9 + 负向 5;报告落 logs/walkthrough-<ts>.md
196
+ ojm market walkthrough --fixture <name>@0.1.0 # 14 项 AC:主链 9 + 负向 5;报告落 logs/walkthrough-<ts>.md
143
197
  ```
144
198
 
199
+ - `verify` 的检查项数随包数与分片数变化,判据是**末行「全部校验通过(N 项)」且无未通过项**,
200
+ 不要按固定条数核对。
201
+ - `walkthrough` 的 fixture **必须是 `@0.1.0`**:主链的 upgrade/rollback 硬编码 0.1.0→0.2.0,
202
+ 给别的版本这两项会 FAIL(是 fixture 选错,不是框架缺陷)。
203
+
145
204
  walkthrough 期望 `14 PASS / 0 FAIL`。负向 5 项是**预期失败口径**:防线没拦住(恶意包装成了)才算 FAIL。
146
205
 
147
206
  ---
@@ -149,24 +208,47 @@ walkthrough 期望 `14 PASS / 0 FAIL`。负向 5 项是**预期失败口径**:
149
208
  ## 6. 装到站点(运维侧)
150
209
 
151
210
  ```bash
211
+ # ⓪ 先构建:release 形态服务的是产物,不先 build 会缺 dist/api(或装到旧产物)
212
+ ojm build
213
+
152
214
  # ① 站点以 release 形态起(dev/preview 完不成安装的热更段)
215
+ # 先把 config.yaml 的 platform.root 从 src 改成 dist/api——必须与 --api-path 同源,
216
+ # 否则 oj serve 直接拒启:platform.root (...) must be mounted as an api mount
217
+ # 再配 vars.canonical_origin(本地演练填 http://127.0.0.1:9778):市场基址 /cdn 是相对路径,
218
+ # 服务端要靠它补全成绝对 URL;缺了安装直接 503(REG-CS6 不读 Host 头,不会替你猜)
153
219
  oj serve -c config.yaml -b /api --api-path dist/api
154
220
 
155
- # ② 市场地址指向站点自供的 /cdn(默认即如此;外部真 CDN 才需要 setup 写绝对地址)
156
- ojm market setup --site-url http://127.0.0.1:9778 --user superadmin --pass … --new-pass <新口令>
221
+ # ② 市场地址:本地自供 /cdn 是模板默认,**不需要 setup**;
222
+ # 只有指向外部真 CDN 才要执行。注意 setup 不一定改密:账号不在「必须改密」悬挂期时
223
+ # (当前 seed 的 superadmin 就是),--new-pass 不生效、原口令继续可用——
224
+ # 2026-11-06 演练实踩:setup 后拿新口令登录得 401。真触发了改密闭环才用新口令:
225
+ # ojm market setup --site-url http://127.0.0.1:9778 --user superadmin --pass <当前口令> --new-pass <新口令>
157
226
 
158
227
  # ③ 安装(从 CDN 拉:name[@version];直传文件则给 tgz 路径,.sig 同目录自动随包)
159
- ojm install crm@1.0.0 --site-url http://127.0.0.1:9778 --user superadmin --pass <新口令>
228
+ # 只对 release 站点装;别对着 `ojm dev` 装——dev 的文件监听会在安装器 reload 之前
229
+ # 先吃掉 config 改动,安装器自己再 reload 时得到 unchanged,判 partial(reloading)
230
+ ojm install crm@1.0.0 --site-url http://127.0.0.1:9778 --user superadmin --pass <口令>
160
231
 
161
232
  # ④ 首装陌生 key 必停在 needs_trust:响应 trust.fingerprint 与 §2 记下的指纹【逐字】核对
162
233
  # 核对通道用当面/电话/既有加密聊天——不要用 CDN 页面上的指纹(那正是攻击者能改的地方)
163
- ojm trust confirm --task <task-id> --site-url http://127.0.0.1:9778 --user superadmin --pass <新口令>
164
- ojm install crm@1.0.0 --site-url http://127.0.0.1:9778 --user superadmin --pass <新口令> # 重跑续跑
234
+ ojm trust confirm --task <task-id> --site-url http://127.0.0.1:9778 --user superadmin --pass <口令>
235
+ ojm install crm@1.0.0 --site-url http://127.0.0.1:9778 --user superadmin --pass <口令> # 重跑续跑
165
236
  ```
166
237
 
167
- 通过判据:`dist/api/manifests.yaml` 出现 `<name>: <version>`;**重启一次 `oj serve`**(新方案路由要重启才注册,热更只覆盖 vars)后 `curl /api/<name>/ping` 返回 `code:0`。
238
+ `<口令>` 就是登录站点用的那个:没跑过 `market setup` 时是脚手架种子口令(`superadmin / aA123456`);
239
+ 跑过 setup 也不一定变——只在真触发了「必须改密」闭环时才用 `--new-pass` 的新口令(见 ②)。
240
+
241
+ **首装终态两段式(oj 0.1.122+ 正常语义)**:install 走完 11 段全 ok 但 `status=partial`——
242
+ message 明示 `restart_pending`(新模块的路由/模块注册要宿主重启才生效,如「待重启段 [auth]」)。
243
+ **重启一次 `oj serve`** 后再 retry 同一条 install → `status=done`、冒烟三项(探活/完整性/注册一致性)全过。
244
+
245
+ 通过判据:`dist/api/manifests.yaml` 出现 `<name>: <version>`;重启后
246
+ `curl -H 'X-TENANT-ID: default' /api/<name>/ping` 返回 `code:0`——**不带租户头会 400 `missing tenant header`**,
247
+ 那是缺参数,不是装失败。
168
248
 
169
- 第一方方案走模板内置公钥(bootstrap key),首装免 TOFU;第三方走上面的人工核对。key 轮换 = 新 key_id 重发 + 站点 `trust add` 新公钥 + `trust revoke` 旧 key(吊销只阻断新装,不动已装方案)。
249
+ TOFU 没有免检通道:**只有模板内置的 oj-market(第一方包)用内置 bootstrap 公钥首装免核对**;
250
+ 你自己 keygen 出来的任何身份——包括站点凭证下发的——首装都要人工核指纹。key 轮换 = 新 key_id
251
+ 重发 + 站点 `trust add` 新公钥 + `trust revoke` 旧 key(吊销只阻断新装,不动已装方案)。
170
252
 
171
253
  ---
172
254
 
@@ -176,15 +258,26 @@ ojm install crm@1.0.0 --site-url http://127.0.0.1:9778 --user superadmin --pass
176
258
  |---|---|---|
177
259
  | `sig_missing` | 没签,或 `.sig` 没随包在同目录 | `ojm sign --check` 后确认 `.sig` 与 tgz 同目录再装 |
178
260
  | `tampered` | 签完又动过包,或传输损坏 | 重打包重签;核对 sha256 |
179
- | `sig_key` | key_id 不是 `<publisher>.<id>` 全小写两段式 | 重新 keygen 合规 id,用新 key 重签 |
261
+ | `sig_key` | key_id 不是 `<publisher>.<id>` 全小写两段式 | 凭证通道回「发布管理」重申请(产品名须小写合规);离线旁路换合规 id 重 keygen 再重签 |
262
+ | `keygen --credential` 报「发布凭证无效或已失效」 | 一次性令牌已兑换/已过期/已撤销,或站点地址不对 | 回「发布管理」重新申请;同一 token 不能给第二台机器 |
263
+ | 申请凭证 403 `password change required` | 新建账号首次登录还在「必须先改密」悬挂期 | 先改密(页面或 `PUT /api/auth/password`),再申请 |
264
+ | 安装 503 `安装需要站点 canonical origin` | `config.yaml` 的 `vars.canonical_origin` 没配,而市场基址是相对路径 `/cdn` | 配成站点对外地址(本地演练 `http://127.0.0.1:9778`)后重启 |
265
+ | 安装 partial(stage `reloading`,`reloadConfig 返回 unchanged`) | 对着 `ojm dev` 装:dev 的文件监听先吃掉了 config 改动 | 换成 `oj serve` release 形态再装;别在 dev 上验证安装 |
266
+ | 安装 partial(11 段全 ok,message 含 `restart_pending` /「请重启 oj serve」) | **正常语义**(oj 0.1.122+):新模块的路由/模块注册要宿主重启 | 重启 `oj serve` 后 retry 同一条 install → `done`(§6 首装终态两段式) |
267
+ | 安装拒 `anonymous_residual`(preflight,不可自动重试) | 有端点实际匿名可达,但 `solution.yaml` 的 `api.anonymous` 没声明(换包时含上一版声明了、新包漏声明的残留比对) | 把该端点补进 `anonymous: ["<模块>/<路由>"]`(模块相对路径),重打包重签重发;`ojm new --api` 骨架 0.2.68 起自带 ping 声明 |
268
+ | 安装拒「包内原生清单含白名单外键 `permissions、roles`…」(extracting 段,不可自动重试) | 角色/权限声明写进了 `api/manifest.yaml`(oj 原生七键清单) | 声明挪到方案契约 `solution.yaml`(上级目录那一份);`api/manifest.yaml` 只留 oj 原生七键(2026-11-06 演练实踩) |
269
+ | 安装 preflight 拒 RBAC 声明 | 声明形状不符:权限 code / 角色 name 没带 `<name>.` 前缀、`title_key` 缺失或 ns 不符、角色引用了未声明的权限(悬空) | 按报错条款改 `solution.yaml` 的 `permissions[]` / `roles[]`(字段细则见 devkit 手册 §11.5) |
270
+ | 装完 `curl /api/<name>/ping` 返回 400 `missing tenant header` | 少了 `X-TENANT-ID` 头 | 加 `-H 'X-TENANT-ID: default'` 再探 |
180
271
  | `sig_alg` / `sig_format` | 签名算法/.sig 结构不符 | 用当前 ojm 重签(旧版本工具产物) |
181
272
  | `needs_trust`(第五终态,非成功) | 站点第一次见这个 key | 与发布方逐字核对指纹 → `trust confirm --task <id>` → 重跑 install |
182
273
  | publish 报「包冲突:…字节不一致」 | 同 name@version 已有字节不同的包 | **升 version 重发**;确需覆盖先删 CDN 旧 tgz |
183
274
  | publish 报「一次只发布一份方案」 | 多方案工程走了自动链路 | §4.2 显式四步 |
184
- | 打包报「缺少本方案的站点产物」 | `web.config.ts` 没登记 / 没先 `ojm build` | 登记后重跑构建再打包 |
275
+ | 打包报「缺少本方案的站点产物」 | 没先 `ojm build`;或**卸载方案时把 `dist/api/<name>-<ver>` 一并移除了**(2026-11-06 实测:卸载四同删含版本目录,重发同版本前必须重建);或前端入口不在 `src/<模块>/web/entry.ts` | 先 `ojm build` 再 `--bundle`;补 entry |
185
276
  | 打包报「缺少 bundle 级契约」 | 没有 `solution.yaml` | 补契约(§0 前提 1) |
186
- | 装不到刚发的包 | 索引没重建 | `ojm market build-index`(§4.3 纪律 2),确认 CDN 六文件同步 |
277
+ | 装不到刚发的包 | 索引没重建 | `ojm market build-index`(§4.3 纪律 2),确认 `cdn/` 的 catalog + 分片 + tgz(+sig) 已同步 |
187
278
  | 装成后 404 | 方案路由没注册 | 重启 `oj serve`(§6 通过判据) |
279
+ | `oj serve` 报 `platform.root (...) must be mounted as an api mount` | release 起站了,但 config 的 `platform.root` 还是 `src` | 改成 `dist/api`(与 `--api-path dist/api` 同源)后重启 |
280
+ | `oj serve` 报 `lock points to missing version dir: dist/api/<name>-<ver>` | 锁文件记着某个已装版本,但那个版本目录不在了(清过 dist / 换过机器) | 先 `ojm build`;锁与目录对不上时按站点安装记录重装该方案,别手改锁 |
188
281
  | 私钥误入工程 | 打包自检拦下 / 被外部拷走 | 立即 `trust revoke` 该 key → 换新 key_id 重发 |
189
282
 
190
283
  ---
@@ -194,6 +287,8 @@ ojm install crm@1.0.0 --site-url http://127.0.0.1:9778 --user superadmin --pass
194
287
  - [ ] `solution.yaml` 的 `version` 已升,与文件名一致;
195
288
  - [ ] `ojm build` + `ojm build --bundle` 均通过,sha256 记进发布单;
196
289
  - [ ] `ojm sign … --check` 通过,`.sig` 与 tgz 同目录;
197
- - [ ] 新 key 首次发布:fingerprint 已提前交给运维(免得卡在 needs_trust);
198
- - [ ] `ojm market verify` 五项全 ✓;
290
+ - [ ] 发布身份来自站点凭证(有发布管理页时),「方案市场 → 发布管理」页该条已变**已兑换**;
291
+ - [ ] 发版后「方案市场 → 发布历史」能看到这次的 产品 / 版本 / 发布时间(时间来源为索引兜底时会标「索引时间」);
292
+ - [ ] 新 key 首次发布:指纹已提前交给运维(免得卡在 needs_trust);
293
+ - [ ] `ojm market verify` 末行「全部校验通过(N 项)」,无未通过项;
199
294
  - [ ] 多人团队:索引重建触发点已约定(维护者或 CI)。
@@ -124,14 +124,19 @@
124
124
  > 通用回调配置(96238 §3.2)与「通讯录变更事件」(90967)都没写;服务端两种包体都收(XML / JSON
125
125
  > 都能归一),所以地址照文档写,不加参数。
126
126
 
127
- ### 2.3 两项备案(都在企微后台,不写在代码里)
127
+ ### 2.3 三项备案(都在企微后台,不写在代码里)
128
128
 
129
129
  | 备案项 | 填在哪 | 填什么 | 不做的症状 |
130
130
  |---|---|---|---|
131
131
  | **可信域名**(网页授权 / JS-SDK) | 应用详情 → **网页授权及JS-SDK** | 站点域名(如 `example.com`),按企微要求上传校验文件到该域名根路径 | 企微授权页直接拒绝:`redirect_uri 需使用应用可信域名`;员工点了登录弹不出来 |
132
+ | **授权完成回调域名**(扫码登录 / 企业微信授权登录) | 应用详情 → **企业微信授权登录** → 授权回调域 | **纯域名**(如 `example.com`):不带 `https://`、不带路径、不带端口、不带尾斜杠 | 普通浏览器点「一键登录」后企微报 `redirect_uri 与配置的授权完成回调域名不一致`;**企微工作台内的静默登录不受影响** |
132
133
  | **可信 IP**(API 调用白名单) | 应用详情 → **企业可信IP** | 服务器的**出口**公网 IP(多机部署要全列) | 上游接口报 `errcode=60020 not allow to access from your ip` → 本站回 502 并把 errcode 透出 |
133
134
 
134
- > 菜单名随企微版本会变;认准**可信域名**与**企业可信IP**这两个词即可。
135
+ > 菜单名随企微版本会变;认准**可信域名**、**授权完成回调域名**、**企业可信IP**这三处。
136
+ > **可信域名 ≠ 授权完成回调域名**:前者给企微客户端内的 `snsapi_base` 静默授权用,后者给外部浏览器的
137
+ > `qrConnect` 扫码登录用,是两处独立配置。工作台能登录只说明前者已配好,扫码是第一次真正走到后者。
138
+ > 本站发出的 `redirect_uri` 由 `wecom_base_url` 派生,形如 `https://<站点>/api/auth/wecom/callback`,
139
+ > 校验时只比对其中的域名部分,不要把这个完整地址填进回调域。
135
140
  > 回调与网页授权都要求 **https**:本地开发必须用内网穿透(见 §6.5),企微不会回调 http。
136
141
 
137
142
  还有一项**不是备案、但决定同步能拿到多少人**:
@@ -610,7 +615,7 @@ superadmin 是平台级的、只留一个;admin 是租户级的、每租户一
610
615
 
611
616
  | 表 | 是否带 `tenant_id` | 含义 |
612
617
  |---|---|---|
613
- | `sys_role` / `sys_permission` / `sys_role_permission` | **无** | 全站只有一套角色定义:superadmin / admin / editor / viewer(`framework` 内置) |
618
+ | `sys_role` / `sys_permission` / `sys_role_permission` | **无** | 角色定义全站一套,不按租户分:内置四角色(superadmin / admin / editor / viewer)+ 各方案声明安装注册的角色(`solution.yaml` 的 `roles[]`),同池共存 |
614
619
  | `sys_user_role` | **有** | 授予是**按租户**的 |
615
620
 
616
621
  也就是说:不可能「每租户定义一套角色」,但可以「每租户各授一个 superadmin」——**但不要这么做**,理由见下。
@@ -0,0 +1,163 @@
1
+ ---
2
+ title: 多企业专属登录链接运维清单
3
+ audience: 平台运维 / 服务商运营 / 企业管理员
4
+ ---
5
+
6
+ # 多企业专属登录链接运维清单
7
+
8
+ > 适用:**一套部署服务 N 家企业**(代开发模板),每家企业用自己的专属链接进入。
9
+ > 本方案**不需要业务工程改代码**:所有动作都在企微后台与本站管理面完成;
10
+ > 宿主早跳透传 `corp` / `agent` 的修复在框架侧(`@oj-module/cli` 的 shell,见 §0)。
11
+ > 所有 URL / 参数按本表逐字填,不要 URL 编码、不要加空格。
12
+ >
13
+ > 当前部署基线(下称 `<站点>` = `https://exp.wiz.top`):
14
+ > 模板回调 `<站点>/api/auth/wecom/event`,授权回调域与可信域名均为 `exp.wiz.top`,
15
+ > `wecom_base_url = <站点>/api/auth/wecom`,网页授权 `redirect_uri = <站点>/api/auth/wecom/callback`(后端自动派生,不用手填)。
16
+
17
+ ---
18
+
19
+ ## 0. 先读:能做什么
20
+
21
+ | 入口 | 是否可用 | 行为 |
22
+ |---|---|---|
23
+ | 外部浏览器 / 企微会话消息 / 邮件 / 公告里的**专属链接** | ✅ 完整可用(实测) | 点开自动跳**该企业**的 `qrConnect` 扫码页 → 企微 App 扫码 → 回跳换票 → 首页 |
24
+ | 登录页「其他企业」折叠里手填企业代码 | ✅ 可用 | 等价于专属链接,适合链接丢失时兜底 |
25
+ | 企微工作台**应用主页** | ✅ 可用(需含修复版 shell 的 cli) | 宿主 P0 早跳把 `?corp=` / `?agent=` 白名单透传到 `/auth/wecom/login`;每家企业把应用主页填成自己的专属链接即可,企微内静默授权、零点击 |
26
+
27
+ 因此:**每家企业发一条专属链接**,员工从专属链接进入,企微工作台应用主页也填各自的专属链接。
28
+
29
+ > 宿主早跳此前会丢掉 `?corp=`(只服务本企业),已修复:
30
+ > `packages/cli/shell/scripts/build.mts` 的 BOOT_JS 与 `shell/src/host.tsx` 的 P0 早跳
31
+ > 均按白名单 `corp` / `agent` 透传,票据 / `redirect` / `error` 一律不带。
32
+ > **部署侧必须重新构建 cli 的 shell-dist 并 `ojm build`**;仍用旧版 cli 的话,
33
+ > 外企业员工从工作台进入会被 `corpSelf()` 吸到本企业(或报「已授权多家企业」)。
34
+
35
+ > 为什么能一套部署服务多家:`wecom_app_profile` 主键含 `(corp_id, agent_id, suite_id)`,
36
+ > 每家企业授权后由 `create_auth` 自动落行;租户键 = `corp_id`,回跳 URL 带 `tenant`,
37
+ > 数据按租户列隔离;JIT 建档用户名 `wecom:<userid>`,跨企业不冲突。
38
+ > `qrConnect` 的 `appid` / `agentid` 是按 `corp` 从档案表查出来的,**不是写死**——
39
+ > 带 `?corp=A` 就跳 A 企业的授权页,带不存在的 `corp` 直接 400。
40
+
41
+ ---
42
+
43
+ ## 1. 前置检查(一次性,平台侧)
44
+
45
+ | # | 项 | 期望值 / 检查方法 | 不通过的后果 |
46
+ |---|---|---|---|
47
+ | 1 | 代开发模板凭据 | `config.yaml` 的 `wecom_suite_id` / `wecom_suite_secret` / `wecom_callback_token` / `wecom_encoding_aes_key` / `wecom_enc_key` / `wecom_base_url` / `wecom_provider_id` / `wecom_provider_secret` 八项齐 | 服务起不来或换不出套件 token |
48
+ | 2 | 模板**指令回调 URL** | 服务商后台 → 模板 → 回调 URL:`<站点>/api/auth/wecom/event`(逐字;保存时企微会 GET 一次带 `echostr`,必须当次通过) | `suite_ticket` 收不到 → 后续所有授权全断 |
49
+ | 3 | 模板**可信域名** | `exp.wiz.top`(纯域名),按企微要求上传校验文件到域名根路径 | 授权页报 `redirect_uri 需使用应用可信域名` |
50
+ | 4 | 模板**授权完成回调域名**(有的版本叫「授权回调域」) | `exp.wiz.top` —— **纯域名,不带 `https://`、不带路径、不带端口、不带尾斜杠** | 外部浏览器扫码报 `redirect_uri 与配置的授权完成回调域名不一致` |
51
+ | 5 | 组织架构四项权限 + 成员授权模式 | 见 `wecom-daikaifa.md` §2.5 / §2.6 | 通讯录同步 `60011` / `48002` |
52
+ | 6 | 登录方式开关 | `/system/settings` → 启用企业微信登录;`curl <站点>/api/system/login-methods` 返回含 `wecom` | 登录页看不到「企业微信」页签 |
53
+ | 7 | 运营账号 | 能登 `/system/wecom` 的 **`superadmin`**(注意 `admin` 不是 superadmin) | 生成不了开通链接 |
54
+
55
+ > 第 3、4 项是**两处独立配置**:可信域名给企微客户端内的 `snsapi_base` 静默授权用,
56
+ > 授权完成回调域名给外部浏览器的 `qrConnect` 扫码用。工作台能登录只说明前者已配好。
57
+ > 用**自建应用**接 N 家企业时这两项要每家各配一遍;走**代开发模板**只需在模板上配一次,
58
+ > 所有企业共用——这正是多企业必须走代开发的原因(`wecom-internal-app.md` §1.1)。
59
+
60
+ ---
61
+
62
+ ## 2. 每家企业接入(逐家执行)
63
+
64
+ 1. `superadmin` 登 `<站点>/system/wecom` → 代开发卡片 → 点「**生成开通链接**」
65
+ (每次点都重新向企微换,链接有效期以企微返回为准,不要缓存;可选 `state=prod` 指定环境标签)。
66
+ 2. 把链接 / 二维码发给**该企业管理员**(只有企业管理员能授权)。
67
+ 3. 管理员扫码或打开链接完成授权 → 企微推 `create_auth` → 本站自动完成:
68
+ - 按 `agentIds` 逐个写 `wecom_app_profile`(**同一企业多个应用各一行**);
69
+ - 建租户(键 = `corp_id`);
70
+ - 建该租户管理员 `admin`(`provisionTenantAdmin`,尽力而为,失败可补)。
71
+ 4. 回到 `/system/wecom` 清单确认该企业出现、`status=active`,**记下 `corp_id` 与 `agent_id`**。
72
+ - `corp_id` 是 `ww` 开头的**真实企业 ID**;不要抄成企微后台的 `open_corpid`。
73
+ - 从清单页复制,不要凭记忆手打;也可查库 `select corp_id, agent_id from wecom_app_profile where status='active'`。
74
+ 5. 同页「**重设租户管理员口令**」给该企业设初始口令(落库只有 bcrypt,置 `must_change=1`,首次登录必须改)。
75
+ 把「**企业代码 + 初始口令**」交给该企业管理员。
76
+ 6. 按 §3 生成专属链接,发给该企业(公告 / 入职通知 / 会话消息 / 应用主页均可,但见 §0 边界)。
77
+
78
+ > 新增企业**不需要改配置、不需要重启**。停用企业在清单里点「停用」,会级联吊销该租户会话。
79
+
80
+ ---
81
+
82
+ ## 3. 生成专属链接
83
+
84
+ ```
85
+ <站点>/login?corp=<corp_id>&agent=<agent_id>
86
+ ```
87
+
88
+ 示例(本企业自建应用):
89
+
90
+ ```
91
+ https://exp.wiz.top/login?corp=wwd2d00dedd5c84818&agent=1000051
92
+ ```
93
+
94
+ | 参数 | 必填 | 从哪来 | 说明 |
95
+ |---|---|---|---|
96
+ | `corp` | **是** | `/system/wecom` 清单的 `corp_id` | 决定落在哪个租户、`qrConnect` 的 `appid` 用谁。填错 → `400 企业代码无效或应用未开通` |
97
+ | `agent` | **建议必填** | 同清单的 `agent_id` | 同企业装了多个应用时,不填会 `400 该企业开通了多个应用,登录链接必须带 agent 参数` |
98
+ | `redirect` | 否 | 站内路径 | 登录后跳到指定页;只放行 `/` 开头,拒绝 `//` 协议相对地址,非法值回落首页 |
99
+ | `tenant` | **不要填** | — | 由服务端 302 回跳时带,前端据此注入 `X-TENANT-ID`;手填会与票据租户冲突 |
100
+
101
+ 三条红线:
102
+
103
+ - 链接里**只放 `corp` / `agent`**,绝不放 `access_token`、`wecom_ticket` 或任何票据。
104
+ 一次性票据由后端 302 生成、180 秒有效、单次消费,用完即废。
105
+ - `corp` 不是机密(企业代码本就公开),但**别把 A 企业的链接发给 B 企业**——登录会落进 A 的租户。
106
+ - 逐字填写,不要 URL 编码、不要加空格、不要带尾斜杠。
107
+
108
+ ---
109
+
110
+ ## 4. 员工入口
111
+
112
+ | 入口 | 位置 / 链接 | 行为 |
113
+ |---|---|---|
114
+ | A 专属链接 | §3 的链接 | 自动发起授权:外部浏览器 → 企微 `qrConnect` 扫码页;企微内 → `snsapi_base` 静默。扫码 / 静默后回跳换票 → 首页 |
115
+ | B 登录页手填 | `<站点>/login` → 「企业微信」页签 →「其他企业?使用企业代码登录」 | 填 `corp` / `agent` 后跳;链接丢失时兜底 |
116
+ | C 不带参数的一键按钮 | `<站点>/login` 默认「使用企业微信一键登录」 | **只应给本企业用**:不带 `corp` 时后端 `corpSelf()` 从 `wecom_corp_*` 锁定本企业 |
117
+ | D 企微工作台应用主页 | 每家填自己的专属链接(§3) | 企微内 `snsapi_base` 静默授权、零点击;宿主早跳透传 `corp` / `agent` 落到正确企业 |
118
+
119
+ > 扫码那一步是企微在外部浏览器确认身份的动作,省不掉;本方案省的是「输账号密码 / 填企业代码 / 选页签」。
120
+ > 企微工作台内本来就是零点击静默,见 §0 边界。
121
+
122
+ ---
123
+
124
+ ## 5. 验收(每家企业逐项打勾)
125
+
126
+ - [ ] `/system/wecom` 清单出现该企业,`status=active`,`corp_id` / `agent_id` 已记录
127
+ - [ ] 拼链接自检(把 `<corp>` / `<agent>` 换成实际值):
128
+ `curl -s -D - -o /dev/null '<站点>/api/auth/wecom/login?corp=<corp>&agent=<agent>' -A 'Mozilla/5.0 Chrome/130'`
129
+ → `302` 的 `location` 里 `appid=<该企业 corp_id>`、`agentid=<agent>`、
130
+ `redirect_uri` 指向 `<站点>/api/auth/wecom/callback`
131
+ - [ ] 外部浏览器打开专属链接 → 出现「请使用企业微信扫描二维码登录 <企业名>」,
132
+ **不是** `redirect_uri 与配置的授权完成回调域名不一致`
133
+ - [ ] 企微 App 扫码确认 → 落到 `<站点>/home`,地址栏里 **没有** `wecom_ticket`
134
+ - [ ] 该企业 `admin` 用初始口令登录 → 强制改密 → 能进管理面,看不到别家企业的数据
135
+ - [ ] `GET <站点>/api/system/login-methods` 返回含 `wecom`
136
+
137
+ ---
138
+
139
+ ## 6. 故障速查
140
+
141
+ | 现象 / 文案 | 原因 | 处理 |
142
+ |---|---|---|
143
+ | `redirect_uri 与配置的授权完成回调域名不一致` | 模板「授权完成回调域名」没填 `exp.wiz.top`,或填成了完整 URL / 带路径 | 改成纯域名,等几分钟生效(§1 第 4 项) |
144
+ | `redirect_uri 需使用应用可信域名` | 模板可信域名未备案 / 校验文件没传 | §1 第 3 项 |
145
+ | `企业代码无效或应用未开通` | `corp` / `agent` 抄错,或 `create_auth` 没落库 | 从 `/system/wecom` 复制;查日志 `wecom.auth.apply` |
146
+ | `该企业开通了多个应用,登录链接必须带 agent 参数` | 该企业多个应用,链接没带 `agent` | 补 `agent`(§3) |
147
+ | `已授权多家企业,请在登录链接带 corp 参数以区分` | 链接丢了 `corp`(旧版 cli 的企微内早跳会丢;修复版已透传) | 用 §3 完整链接;若从工作台进仍复现,确认 cli 的 shell-dist 已含修复并重新 `ojm build` |
148
+ | `应用安装失败,联系服务商` | `create_auth` 回调 1 秒窗口超时 | 看服务端日志;重推幂等已处理,已落库的档案不会重复 |
149
+ | `errcode=60020 not allow to access from your ip` | 企业可信 IP 没列全出口 IP | 模板 / 应用后台补齐所有出口 IP |
150
+ | `errcode=60011` / `48002` | 可见范围、四项权限或成员授权模式 | `wecom-daikaifa.md` §2.5 / §2.6 |
151
+ | `登录凭据无效或不在应用可见范围内` | 该员工不在应用可见范围 | 把应用可见范围设为全公司 |
152
+ | `40001` / `42001` gettoken 失败 | 应用 Secret 轮换过 | 重新授权,或重置后重新「验证并接入」 |
153
+
154
+ ---
155
+
156
+ ## 7. 日常运维
157
+
158
+ - **加企业**:重复 §2,零配置变更、零重启。
159
+ - **停企业**:清单里点「停用」→ 级联吊销会话;`cancel_auth` 事件也会停用 + 吊销。
160
+ - **换 Secret / 重置租户管理员口令**:`/system/wecom` 同页操作;重置会置 `must_change=1` 并吊销旧会话。
161
+ - **租户与数据**:租户键就是 `corp_id`,所有运行时路径按它隔离;同一企业多个应用共用一个租户管理员。
162
+ - **相关文档**:`wecom-daikaifa.md`(凭据清单、首次开通顺序、三角色手册)、
163
+ `wecom-internal-app.md`(自建应用与共存、三项备案)。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/cli",
3
3
  "type": "module",
4
- "version": "0.2.66",
4
+ "version": "0.2.68",
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.66",
52
+ "@oj-module/runtime": "0.2.68",
53
53
  "@redocly/cli": "^2.51.0",
54
54
  "@tailwindcss/vite": "^4.3.0",
55
55
  "cac": "^6.7.14",
@@ -357,6 +357,26 @@ function ensureI18n() {
357
357
  i18nReady = true;
358
358
  }
359
359
  }
360
+ /**
361
+ * 企微内 P0 早跳的目标 URL。只白名单透传 `corp` / `agent`:多企业专属链接
362
+ * `/login?corp=X&agent=Y` 从工作台打开时,后端 `resolveWecomProfile` 必须拿到
363
+ * 企业代码才能落到正确租户;不带参数会被 `corpSelf()` 吸到本企业,多家档案则
364
+ * 直接 400「已授权多家企业」。票据 / redirect / error 等一律不透传。
365
+ *
366
+ * 与 index.html 内联 BOOT_JS(build.mts)同口径——那道先执行,这里是兜底:
367
+ * 内联脚本被 CSP 拦下或抛异常时,模块加载完仍会走到这里。
368
+ */
369
+ var EARLY_JUMP_PARAMS = ["corp", "agent"];
370
+ function wecomEarlyJumpUrl() {
371
+ const src = new URLSearchParams(location.search);
372
+ const pass = new URLSearchParams();
373
+ for (const key of EARLY_JUMP_PARAMS) {
374
+ const v = src.get(key)?.trim();
375
+ if (v) pass.set(key, v);
376
+ }
377
+ const qs = pass.toString();
378
+ return `${resolveApiBase()}/auth/wecom/login${qs ? `?${qs}` : ""}`;
379
+ }
360
380
  function Boot() {
361
381
  const [router, setRouter] = useState(null);
362
382
  const [error, setError] = useState(null);
@@ -365,7 +385,7 @@ function Boot() {
365
385
  (async () => {
366
386
  try {
367
387
  if (typeof navigator !== "undefined" && /wxwork/i.test(navigator.userAgent) && location.pathname.endsWith("/login") && !new URLSearchParams(location.search).get("wecom_ticket")) {
368
- window.location.assign(`${resolveApiBase()}/auth/wecom/login`);
388
+ window.location.assign(wecomEarlyJumpUrl());
369
389
  return;
370
390
  }
371
391
  ensureI18n();