@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
@@ -0,0 +1,354 @@
1
+ // BDD:发布凭证端点族(申请 / 列表 / 撤销 / 兑换)。
2
+ //
3
+ // 这一层要钉的是**跨请求才看得见的语义**,纯函数测不到:
4
+ // · 一次性:兑换成功后同票再兑必拒(并发双花只有一个赢家)
5
+ // · 时效:过期即拒,且拒绝发生在「已经拿到身份」之前
6
+ // · 隔离:普通用户看不到别人的凭证,超管看得到;归属租户来自 users 行而非请求头
7
+ // · 不留痕:明文只在申请响应里出现一次,库里只有 sha256
8
+ /* eslint-disable test/prefer-lowercase-title -- 用例标题带端点路径,保留原样更易检索 */
9
+ import { afterEach, beforeAll, describe, expect, it } from "vitest";
10
+ import { invalidateUser } from "../src/app/api/_platform/_shared/service/infra/access";
11
+ import { CREDENTIAL_TTL_SECS } from "../src/app/api/publish/_shared/identity";
12
+ import { createPublishApi } from "../src/app/api/publish/api";
13
+ import { installDb } from "./fixture";
14
+
15
+ const g = globalThis as any;
16
+ let dbo: ReturnType<typeof installDb>;
17
+
18
+ interface Captured { status: number | null, body: any }
19
+ let out: Captured;
20
+
21
+ /** 固定令牌:必须是 64 位 hex,才过得了兑换端点的形状闸门 */
22
+ const TOKEN_1 = "a".repeat(64);
23
+ const TOKEN_2 = "b".repeat(64);
24
+
25
+ let clock: number;
26
+
27
+ /** 假摘要:短、确定性、且**不含原文**(否则「明文不落库」的断言会被夹具自己污染) */
28
+ function digest(input: string): string {
29
+ let acc = 7;
30
+ for (const ch of input)
31
+ acc = (acc * 33 + ch.charCodeAt(0)) >>> 0;
32
+ return `h${acc.toString(16)}`;
33
+ }
34
+
35
+ function installJson(): void {
36
+ out = { status: null, body: null };
37
+ g.json = {
38
+ ok: (data?: unknown) => {
39
+ out.status = 200;
40
+ out.body = data;
41
+ },
42
+ fail: (code: number, msg: string, extra?: unknown) => {
43
+ out.status = code;
44
+ out.body = { code, msg, extra };
45
+ },
46
+ };
47
+ }
48
+
49
+ function api(tokens: string[] = [TOKEN_1, TOKEN_2]) {
50
+ const queue = [...tokens];
51
+ return createPublishApi({
52
+ now: () => clock,
53
+ randomHex: () => queue.shift() ?? "c".repeat(64),
54
+ sha256Hex: digest,
55
+ });
56
+ }
57
+
58
+ function mkUser(username: string, opts: { tenant?: string, displayName?: string, superadmin?: boolean } = {}): number {
59
+ const tenant = opts.tenant ?? "default";
60
+ dbo.prepare("INSERT INTO users (username, password_hash, display_name, tenant_id) VALUES (?, 'x', ?, ?)")
61
+ .run(username, opts.displayName ?? null, tenant);
62
+ const uid = (dbo.prepare("SELECT id FROM users WHERE username = ? AND tenant_id = ?").get(username, tenant) as { id: number }).id;
63
+ if (opts.superadmin)
64
+ dbo.prepare("INSERT INTO sys_user_role (user_id, role_name, tenant_id) VALUES (?, 'superadmin', ?)").run(uid, tenant);
65
+ return uid;
66
+ }
67
+
68
+ /** 以某个登录用户身份发请求 */
69
+ function as(uid: number, extra: Record<string, unknown> = {}): void {
70
+ g.http = { user: { id: uid }, tenantId: "default", headers: { "x-real-ip": "10.0.0.1" }, ...extra };
71
+ }
72
+
73
+ function rowOf(token: string): Record<string, unknown> | undefined {
74
+ return dbo.prepare("SELECT * FROM publish_credential WHERE token_hash = ?").get(digest(token)) as Record<string, unknown> | undefined;
75
+ }
76
+
77
+ beforeAll(() => {
78
+ dbo = installDb();
79
+ });
80
+
81
+ afterEach(() => {
82
+ g.http = {};
83
+ clock = 1_700_000_000;
84
+ dbo.exec("DELETE FROM publish_credential");
85
+ });
86
+
87
+ describe("POST /api/publish/credential(申请)", () => {
88
+ it("given 登录用户填合法产品名 When 申请 Then 返回 64 位明文、身份已定,库里只有 hash", async () => {
89
+ installJson();
90
+ clock = 1_700_000_000;
91
+ const uid = mkUser("p_apply", { displayName: "张三" });
92
+ as(uid, { body: { product: "demo-solution" } });
93
+
94
+ await api().credential.post();
95
+
96
+ expect(out.status).toBe(200);
97
+ expect(out.body.token).toBe(TOKEN_1);
98
+ expect(out.body.key_id).toBe("demo-solution.p-apply");
99
+ expect(out.body.expires_at).toBe(clock + CREDENTIAL_TTL_SECS);
100
+ expect(out.body.token_hint).toBe(TOKEN_1.slice(0, 8));
101
+
102
+ const row = rowOf(TOKEN_1)!;
103
+ expect(row.token_hash).toBe(digest(TOKEN_1));
104
+ expect(row.display_name).toBe("张三");
105
+ expect(row.owner_tenant).toBe("default");
106
+ // 明文绝不落库
107
+ expect(JSON.stringify(row)).not.toContain(TOKEN_1);
108
+ });
109
+
110
+ it("given 产品名是保留名 When 申请 Then 400 且不产生记录", async () => {
111
+ installJson();
112
+ const uid = mkUser("p_bad");
113
+ as(uid, { body: { product: "market" } });
114
+
115
+ await api().credential.post();
116
+
117
+ expect(out.status).toBe(400);
118
+ expect(out.body.msg).toContain("保留名");
119
+ expect(dbo.prepare("SELECT count(*) AS c FROM publish_credential").get()).toEqual({ c: 0 });
120
+ });
121
+
122
+ it("given 同一产品名已被他人占用 When 另一用户申请 Then 409(同一人重复申请放行)", async () => {
123
+ installJson();
124
+ clock = 1_700_000_000;
125
+
126
+ // 同名账号在不同租户:唯一键是 (tenant_id, username),故可共存
127
+ const a = mkUser("p_dup", { tenant: "default" });
128
+ const b = mkUser("p_dup", { tenant: "t2" });
129
+
130
+ as(a, { body: { product: "demo-solution" } });
131
+ await api([TOKEN_1]).credential.post();
132
+ expect(out.status).toBe(200);
133
+
134
+ as(b, { tenantId: "t2", body: { product: "demo-solution" } });
135
+ await api([TOKEN_2]).credential.post();
136
+ expect(out.status).toBe(409);
137
+ expect(out.body.msg).toContain("已被其他用户占用");
138
+ });
139
+ });
140
+
141
+ describe("POST /api/publish/credential/exchange(兑换)", () => {
142
+ it("given 有效凭证 When 兑换 Then 返回申请时定下的 key_id 且该票作废", async () => {
143
+ installJson();
144
+ clock = 1_700_000_000;
145
+ const uid = mkUser("p_ex1");
146
+ as(uid, { body: { product: "demo-solution" } });
147
+ const p = api([TOKEN_1]);
148
+ await p.credential.post();
149
+ const issued = out.body;
150
+
151
+ // 兑换是匿名的:不带 user
152
+ g.http = { headers: { "x-real-ip": "10.0.0.9" }, body: { token: issued.token } };
153
+ await p.credential.exchange.post();
154
+
155
+ expect(out.status).toBe(200);
156
+ expect(out.body).toMatchObject({ key_id: issued.key_id, product: "demo-solution", username: "p_ex1" });
157
+
158
+ const row = rowOf(TOKEN_1)!;
159
+ expect(Number(row.used)).toBe(1);
160
+ expect(Number(row.used_at)).toBe(clock);
161
+
162
+ // 再兑:同票必拒
163
+ g.http = { headers: { "x-real-ip": "10.0.0.9" }, body: { token: issued.token } };
164
+ await p.credential.exchange.post();
165
+ expect(out.status).toBe(400);
166
+ expect(out.body.msg).toContain("发布管理");
167
+ });
168
+
169
+ it("given 凭证已过期 When 兑换 Then 400(过期判定在签发身份之前)", async () => {
170
+ installJson();
171
+ clock = 1_700_000_000;
172
+ const uid = mkUser("p_ex2");
173
+ as(uid, { body: { product: "demo-solution" } });
174
+ const p = api([TOKEN_1]);
175
+ await p.credential.post();
176
+
177
+ clock += CREDENTIAL_TTL_SECS + 1;
178
+ g.http = { headers: { "x-real-ip": "10.0.0.9" }, body: { token: TOKEN_1 } };
179
+ await p.credential.exchange.post();
180
+
181
+ expect(out.status).toBe(400);
182
+ expect(Number(rowOf(TOKEN_1)!.used)).toBe(0);
183
+ });
184
+
185
+ it("given 凭证已撤销 When 兑换 Then 400", async () => {
186
+ installJson();
187
+ clock = 1_700_000_000;
188
+ const uid = mkUser("p_ex3");
189
+ as(uid, { body: { product: "demo-solution" } });
190
+ const p = api([TOKEN_1]);
191
+ await p.credential.post();
192
+ const id = Number(rowOf(TOKEN_1)!.id);
193
+
194
+ as(uid, { body: { id } });
195
+ await p.credential.revoke.post();
196
+ expect(out.status).toBe(200);
197
+
198
+ g.http = { headers: { "x-real-ip": "10.0.0.9" }, body: { token: TOKEN_1 } };
199
+ await p.credential.exchange.post();
200
+ expect(out.status).toBe(400);
201
+ });
202
+
203
+ it("given 同一张票连续兑换 3 次 When 依次到达 Then 只有第一次成功,used_at 停在首次时间", async () => {
204
+ installJson();
205
+ clock = 1_700_000_000;
206
+ const uid = mkUser("p_ex4");
207
+ as(uid, { body: { product: "demo-solution" } });
208
+ const p = api([TOKEN_1]);
209
+ await p.credential.post();
210
+
211
+ // 原子性来自那一条 `update ... where used = 0`:并发双花只可能有一个 changes=1。
212
+ // Node 侧 sqlite 是同步的,真并发无法复现,故钉「重复到达只有一次生效」这一可观测面。
213
+ const firstAt = clock;
214
+ const statuses: number[] = [];
215
+ for (let i = 0; i < 3; i++) {
216
+ g.http = { headers: { "x-real-ip": `10.0.0.${i + 1}` }, body: { token: TOKEN_1 } };
217
+ await p.credential.exchange.post();
218
+ statuses.push(out.status!);
219
+ clock += 5;
220
+ }
221
+
222
+ expect(statuses).toEqual([200, 400, 400]);
223
+ expect(Number(rowOf(TOKEN_1)!.used_at)).toBe(firstAt);
224
+ });
225
+
226
+ it("given 令牌形状不对 When 兑换 Then 400 且不触库(不打日志也不落审计)", async () => {
227
+ installJson();
228
+ g.http = { headers: { "x-real-ip": "10.0.0.6" }, body: { token: "not-a-token" } };
229
+ await api().credential.exchange.post();
230
+ expect(out.status).toBe(400);
231
+ expect(out.body.msg).toContain("重新申请");
232
+ });
233
+ });
234
+
235
+ describe("GET /api/publish/credential(列表)", () => {
236
+ it("given 普通用户 When 查列表 Then 只看到自己的且响应不含 token/hash", async () => {
237
+ installJson();
238
+ clock = 1_700_000_000;
239
+ const a = mkUser("p_list_a");
240
+ const b = mkUser("p_list_b");
241
+
242
+ as(a, { body: { product: "demo-solution" } });
243
+ await api([TOKEN_1]).credential.post();
244
+ as(b, { body: { product: "other-solution" } });
245
+ await api([TOKEN_2]).credential.post();
246
+
247
+ as(a);
248
+ await api().credential.get();
249
+
250
+ expect(out.status).toBe(200);
251
+ expect(out.body.total).toBe(1);
252
+ expect(out.body.list[0]).toMatchObject({ product: "demo-solution", key_id: "demo-solution.p-list-a", status: "active" });
253
+ // 明文与哈希都不出现在列表里
254
+ const serialized = JSON.stringify(out.body);
255
+ expect(serialized).not.toContain(TOKEN_1);
256
+ expect(serialized).not.toContain("token_hash");
257
+ expect(out.body.list[0].owner_tenant).toBeUndefined();
258
+ });
259
+
260
+ it("given 超管 When 查列表 Then 能看到全部并带归属租户", async () => {
261
+ installJson();
262
+ clock = 1_700_000_000;
263
+ const a = mkUser("p_list_c");
264
+ const boss = mkUser("p_boss", { superadmin: true });
265
+
266
+ as(a, { body: { product: "demo-solution" } });
267
+ await api([TOKEN_1]).credential.post();
268
+
269
+ as(boss);
270
+ await api().credential.get();
271
+
272
+ expect(out.status).toBe(200);
273
+ expect(out.body.total).toBe(1);
274
+ expect(out.body.list[0].owner_tenant).toBe("default");
275
+ });
276
+
277
+ it("given 状态演进 When 兑换/撤销后查列表 Then 状态字段随之变化", async () => {
278
+ installJson();
279
+ clock = 1_700_000_000;
280
+ const uid = mkUser("p_list_d");
281
+ as(uid, { body: { product: "demo-solution" } });
282
+ const p = api([TOKEN_1]);
283
+ await p.credential.post();
284
+
285
+ g.http = { headers: { "x-real-ip": "10.0.0.5" }, body: { token: TOKEN_1 } };
286
+ await p.credential.exchange.post();
287
+
288
+ as(uid);
289
+ await p.credential.get();
290
+ expect(out.body.list[0].status).toBe("used");
291
+ expect(out.body.list[0].used_at).toBe(clock);
292
+ });
293
+ });
294
+
295
+ describe("POST /api/publish/credential/revoke(撤销)", () => {
296
+ it("given 别人的凭证 When 撤销 Then 404(与不存在同一句话,不泄露存在性)", async () => {
297
+ installJson();
298
+ clock = 1_700_000_000;
299
+ const owner = mkUser("p_rev_a");
300
+ const stranger = mkUser("p_rev_b");
301
+
302
+ as(owner, { body: { product: "demo-solution" } });
303
+ await api([TOKEN_1]).credential.post();
304
+ const id = Number(rowOf(TOKEN_1)!.id);
305
+
306
+ as(stranger, { body: { id } });
307
+ await api().credential.revoke.post();
308
+
309
+ expect(out.status).toBe(404);
310
+ expect(Number(rowOf(TOKEN_1)!.revoked)).toBe(0);
311
+ });
312
+
313
+ it("given 已兑换的凭证 When 撤销 Then 400 并说明发布身份不能撤回", async () => {
314
+ installJson();
315
+ clock = 1_700_000_000;
316
+ const uid = mkUser("p_rev_c");
317
+ as(uid, { body: { product: "demo-solution" } });
318
+ const p = api([TOKEN_1]);
319
+ await p.credential.post();
320
+ const id = Number(rowOf(TOKEN_1)!.id);
321
+
322
+ g.http = { headers: { "x-real-ip": "10.0.0.4" }, body: { token: TOKEN_1 } };
323
+ await p.credential.exchange.post();
324
+
325
+ as(uid, { body: { id } });
326
+ await p.credential.revoke.post();
327
+ expect(out.status).toBe(400);
328
+ expect(out.body.msg).toContain("发布身份");
329
+ });
330
+ });
331
+
332
+ describe("门禁", () => {
333
+ it("given 未登录 When 访问列表/申请 Then 403", async () => {
334
+ installJson();
335
+ as(0);
336
+ g.http = {};
337
+ await api().credential.get();
338
+ expect(out.status).toBe(403);
339
+
340
+ installJson();
341
+ g.http = { body: { product: "demo-solution" } };
342
+ await api().credential.post();
343
+ expect(out.status).toBe(403);
344
+ });
345
+
346
+ it("given 伪造租户头 When 访问列表 Then 403(sub 在当前租户查无此人)", async () => {
347
+ installJson();
348
+ const uid = mkUser("p_forge");
349
+ invalidateUser(uid);
350
+ g.http = { user: { id: uid }, tenantId: "other-tenant" };
351
+ await api().credential.get();
352
+ expect(out.status).toBe(403);
353
+ });
354
+ });
@@ -0,0 +1,44 @@
1
+ // BDD:发布身份推导规则(publish/_shared/identity.ts)。
2
+ // 这条规则同时决定 ~/.ojm/keys/<id>/ 的目录名、.sig 的 key_id 与市场卡片上的「发布者」,
3
+ // 一旦漂移就是「签出来的身份查不到凭证」这类跨端问题,故用固定用例表钉死。
4
+ import { describe, expect, it } from "vitest";
5
+ import { assertKeyId, assertProduct, deriveKeyId, encodePerson, KEY_ID_RE } from "../src/app/api/publish/_shared/identity";
6
+
7
+ describe("发布身份推导", () => {
8
+ it("given 用户名 wecom:everpan 且产品 demo-solution When 推导 Then key_id 用连字符替换冒号", () => {
9
+ expect(deriveKeyId({ product: "demo-solution", username: "wecom:everpan", uid: 7 }))
10
+ .toBe("demo-solution.wecom-everpan");
11
+ });
12
+
13
+ it("given 用户名含大写/下划线/连续符号 When 推导 Then 全小写、非白名单字符替换为 - 并折叠", () => {
14
+ expect(encodePerson("WeCom:Ever_Pan", 7)).toBe("wecom-ever-pan");
15
+ expect(encodePerson("--a__b--", 7)).toBe("a-b");
16
+ });
17
+
18
+ it("given 用户名归一化后为空(纯中文)When 推导 Then 退化为 u<uid> 且仍是合法 key_id", () => {
19
+ const keyId = deriveKeyId({ product: "demo-solution", username: "张三", uid: 42 });
20
+ expect(keyId).toBe("demo-solution.u42");
21
+ expect(KEY_ID_RE.test(keyId)).toBe(true);
22
+ });
23
+
24
+ it("given 产品名为保留名 When 校验 Then 拒绝并提示换成自己的解决方案名", () => {
25
+ for (const reserved of ["oj", "market", "system", "registry"])
26
+ expect(() => assertProduct(reserved)).toThrowError(/保留名/);
27
+ });
28
+
29
+ it("given 产品名形状非法 When 校验 Then 拒绝并给出可用示例", () => {
30
+ for (const bad of ["", " ", "Demo", "1demo", "demo_solution", "demo solution", "a".repeat(65)])
31
+ expect(() => assertProduct(bad)).toThrowError(/400|产品名/);
32
+ });
33
+
34
+ it("given 合法产品名带首尾空格 When 校验 Then 去空格后通过", () => {
35
+ expect(assertProduct(" demo-solution ")).toBe("demo-solution");
36
+ });
37
+
38
+ it("given 推导结果 When 自检 Then 满足 C2-37 的 key_id 形状(≥2 段、小写、仅 [a-z0-9-])", () => {
39
+ const keyId = assertKeyId(deriveKeyId({ product: "demo-solution", username: "wecom:everpan", uid: 7 }));
40
+ expect(keyId.split(".")).toHaveLength(2);
41
+ expect(KEY_ID_RE.test(keyId)).toBe(true);
42
+ expect(() => assertKeyId("nodot")).toThrowError(/不符合 key_id 规则/);
43
+ });
44
+ });
@@ -132,6 +132,51 @@ describe("AC2-3/4:声明消失与重命名 → deprecated 保留(判决不
132
132
  });
133
133
  });
134
134
 
135
+ describe("R2/R3:权限点 title_key 落库(多语言展示锚)与重声明复位", () => {
136
+ it("R2 title_key 随声明落库:permissions[{code,title_key}] → sys_permission.title_key(缺省→null)", async () => {
137
+ const r = await upsertSolutionPerms({
138
+ module: "wiki",
139
+ roles: [{ name: "wiki.reader", title: "wiki:role.reader", title_key: "wiki:role.reader", permissions: ["wiki.doc.read"] }],
140
+ permissions: [
141
+ { code: "wiki.doc.read", title_key: "wiki:perm.doc_read" },
142
+ { code: "wiki.doc.export" }, // 无角色引用的独立声明点;title_key 缺省 → null(宽松态合法)
143
+ ],
144
+ });
145
+ expect(r.deprecated).toEqual([]);
146
+ expect(rows("SELECT title_key FROM sys_permission WHERE code = 'wiki.doc.read'")[0].title_key).toBe("wiki:perm.doc_read");
147
+ expect(rows("SELECT title_key FROM sys_permission WHERE code = 'wiki.doc.export'")[0].title_key).toBeNull();
148
+ // 角色侧同口径(既有行为再钉一次,防回归)
149
+ expect(rows("SELECT title_key FROM sys_role WHERE name = 'wiki.reader'")[0].title_key).toBe("wiki:role.reader");
150
+ });
151
+
152
+ it("R3 既有权限点行 title_key 元数据随升级覆盖(新包 locale 改进随升级传播)", async () => {
153
+ await upsertSolutionPerms({ module: "wiki", roles: [], permissions: [{ code: "wiki.doc.read", title_key: "wiki:perm.old" }] });
154
+ await upsertSolutionPerms({ module: "wiki", roles: [], permissions: [{ code: "wiki.doc.read", title_key: "wiki:perm.new" }] });
155
+ expect(rows("SELECT title_key FROM sys_permission WHERE code = 'wiki.doc.read'")[0].title_key).toBe("wiki:perm.new");
156
+ });
157
+
158
+ it("AC2-3 重声明复位 deprecated=0:权限点与角色行回归声明态(对齐 settings/routes 注册面同纪律)", async () => {
159
+ await installWikiV019();
160
+ await upsertSolutionPerms({
161
+ module: "wiki",
162
+ roles: [{ name: "wiki.admin", title: "管理员", permissions: ["wiki.doc.create"] }], // space.create 与 editor 消失
163
+ });
164
+ expect(Number(rows("SELECT deprecated FROM sys_permission WHERE code = 'wiki.space.create'")[0].deprecated)).toBe(1);
165
+ expect(Number(rows("SELECT deprecated FROM sys_role WHERE name = 'wiki.editor'")[0].deprecated)).toBe(1);
166
+ // 下一版本把两者重新声明回来 → deprecated 必须复位,装机告警消停
167
+ const r = await upsertSolutionPerms({
168
+ module: "wiki",
169
+ roles: [
170
+ { name: "wiki.admin", title: "管理员", permissions: ["wiki.doc.create", "wiki.space.create"] },
171
+ { name: "wiki.editor", title: "编辑", permissions: ["wiki.doc.create"] },
172
+ ],
173
+ });
174
+ expect(r.deprecated).toEqual([]);
175
+ expect(Number(rows("SELECT deprecated FROM sys_permission WHERE code = 'wiki.space.create'")[0].deprecated)).toBe(0);
176
+ expect(Number(rows("SELECT deprecated FROM sys_role WHERE name = 'wiki.editor'")[0].deprecated)).toBe(0);
177
+ });
178
+ });
179
+
135
180
  describe("AC2-6:卸载级联清理(唯一减法出口)", () => {
136
181
  it("双维度删除:wiki 角色及其绑定 + 属 wiki 的权限点绑定(含 custom 角色上的);custom 角色本体保留", async () => {
137
182
  await installWikiV019();
@@ -177,7 +222,7 @@ describe("顶层 permissions[]:无角色的权限点也要落库", () => {
177
222
  await upsertSolutionPerms({
178
223
  module: "oj-registry",
179
224
  roles: [],
180
- permissions: ["oj-registry.publish", "oj-registry.admin"],
225
+ permissions: [{ code: "oj-registry.publish" }, { code: "oj-registry.admin", title_key: "oj-registry:perm.admin" }],
181
226
  });
182
227
 
183
228
  expect(rows("SELECT code, module FROM sys_permission ORDER BY code")).toEqual([
@@ -187,7 +232,7 @@ describe("顶层 permissions[]:无角色的权限点也要落库", () => {
187
232
  });
188
233
 
189
234
  it("AC2-6 卸载后无角色的权限点一并摘干净(含 custom 角色上的绑定)", async () => {
190
- await upsertSolutionPerms({ module: "oj-registry", roles: [], permissions: ["oj-registry.publish"] });
235
+ await upsertSolutionPerms({ module: "oj-registry", roles: [], permissions: [{ code: "oj-registry.publish" }] });
191
236
  dbo.exec("INSERT INTO sys_role (name, module, title) VALUES ('custom.dev', 'custom', '开发者')");
192
237
  dbo.exec("INSERT INTO sys_role_permission (role_name, permission_code) VALUES ('custom.dev', 'oj-registry.publish')");
193
238
 
@@ -202,7 +247,7 @@ describe("顶层 permissions[]:无角色的权限点也要落库", () => {
202
247
  it("G7 攻击者视角:声明权限点不等于拿到权限——落库不产生任何绑定", async () => {
203
248
  // 使坏路径:以为「把 code 写进 manifest 就自动获权」。落库的只是权限点标签行,
204
249
  // 判定仍要角色绑定;没有绑定,decideAccess 对该 code 恒 false。
205
- await upsertSolutionPerms({ module: "oj-registry", roles: [], permissions: ["oj-registry.publish"] });
250
+ await upsertSolutionPerms({ module: "oj-registry", roles: [], permissions: [{ code: "oj-registry.publish" }] });
206
251
 
207
252
  expect(rows("SELECT * FROM sys_role_permission WHERE permission_code = 'oj-registry.publish'").length).toBe(0);
208
253
  });
@@ -8,7 +8,10 @@
8
8
  // 1. 无 AbortSignal 时调用照常工作(不抛 `AbortSignal is not defined`);
9
9
  // 2. 不再把 `signal` 传给 fetch(旧 bug 的根);
10
10
  // 3. tasks.sleep 存在 → 慢请求被软超时拦截并抛「超时」文案;
11
- // 4. tasks 缺失 → 安全降级为「不超时」,快速请求正常返回。
11
+ // 4. tasks 缺失 → 安全降级为「不超时」,快速请求正常返回;
12
+ // 5. 软超时只按短步长 sleep(企微工作台 5-6s 白屏回归)——早期实现挂一条
13
+ // sleep(5000) 做竞态,上游秒回时那条 sleep 仍在飞,oj 运行时要等请求上下文里
14
+ // 所有异步任务结束才 flush 响应,于是每个调企微接口的请求都被白拖到 5s。
12
15
 
13
16
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
17
  import { runtimeHttp } from "../src/app/api/auth/_shared/wecom/client";
@@ -26,6 +29,7 @@ beforeEach(() => {
26
29
  });
27
30
 
28
31
  afterEach(() => {
32
+ vi.restoreAllMocks();
29
33
  Object.defineProperty(globalThis, "AbortSignal", { value: savedAbortSignal, configurable: true, writable: true });
30
34
  g.tasks = savedTasks;
31
35
  g.fetch = savedFetch;
@@ -74,8 +78,16 @@ describe("wecom client:无 AbortSignal 的 isolate 不崩(回归)", () =>
74
78
 
75
79
  describe("wecom client:软超时(tasks.sleep)", () => {
76
80
  it("tasks.sleep 存在时,慢请求被软超时拦截并抛出「超时」文案", async () => {
77
- // 用立即 resolve 的 sleep 模拟(不真的等 TIMEOUT_MS,测试秒级完成)
78
- g.tasks = { sleep: () => Promise.resolve() };
81
+ // 假时钟:sleep 不真的等,只推进 Date.now()。软超时按墙钟 deadline 判定、
82
+ // 50ms 一个步长,故 100 步后到点抛超时(测试秒级完成)。
83
+ let now = 0;
84
+ vi.spyOn(Date, "now").mockImplementation(() => now);
85
+ g.tasks = {
86
+ sleep: (ms: number) => {
87
+ now += ms;
88
+ return Promise.resolve();
89
+ },
90
+ };
79
91
  let resolveFetch: (v: unknown) => void = () => {};
80
92
  const fetchMock = vi.fn(() => new Promise<unknown>((resolve) => {
81
93
  resolveFetch = resolve;
@@ -96,4 +108,22 @@ describe("wecom client:软超时(tasks.sleep)", () => {
96
108
  const data = await runtimeHttp().getJson("https://qyapi.weixin.qq.com/x");
97
109
  expect(data.errcode).toBe(0);
98
110
  });
111
+
112
+ it("快速上游只按短步长 sleep,不留 5s 悬挂任务拖住响应(企微工作台白屏回归)", async () => {
113
+ const sleeps: number[] = [];
114
+ g.tasks = {
115
+ sleep: (ms: number) => {
116
+ sleeps.push(ms);
117
+ return Promise.resolve();
118
+ },
119
+ };
120
+ g.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ errcode: 0 }) }));
121
+
122
+ const data = await runtimeHttp().getJson("https://qyapi.weixin.qq.com/x");
123
+ expect(data.errcode).toBe(0);
124
+ // 旧实现这里会是 [5000]:race 由 fetch 赢,但那条 sleep 仍在请求上下文里飞,
125
+ // 响应只能等它走完才发出去。短步长后每次请求都 <=50ms,主链路结束即收尾。
126
+ expect(sleeps.length).toBeGreaterThan(0);
127
+ expect(Math.max(...sleeps)).toBeLessThanOrEqual(50);
128
+ });
99
129
  });
@@ -4,9 +4,27 @@
4
4
  // · 落地参数解析必须区分「企微回跳」与「普通打开登录页」(后者不能触发换票);
5
5
  // · 参数要裁剪、要能容忍少了 tenant(此时沿用本地租户,不是登录失败);
6
6
  // · 授权入口即使没填企业代码也要跳(后端会回 400 说明缺什么,比前端静默不动好排查)。
7
- import { describe, expect, it } from "vitest";
7
+ import { beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { getWecomBootError, onInit } from "../src/app/web/login/routes";
8
9
  import { parseWecomLanding, wecomAuthorizePath } from "../src/app/web/login/wecom";
9
10
 
11
+ // p2-C:企微回跳的换票必须发生在首帧 render 之前,且成功后把 URL 改到首页——
12
+ // 否则宿主 shell(无 AuthGuard)会先把登录页画出来再跳,工作台进入时「闪一下」。
13
+ // 这里把 runtime 全部桩掉,只锁 onInit 的编排:谁被调用、顺序、URL 改没改。
14
+ const mocks = vi.hoisted(() => ({
15
+ login: vi.fn(),
16
+ setTenant: vi.fn(),
17
+ registerAuthProvider: vi.fn(),
18
+ replaceState: vi.fn(),
19
+ }));
20
+
21
+ vi.mock("@oj-module/runtime", () => ({
22
+ useAuthStore: { getState: () => ({ login: mocks.login }), subscribe: vi.fn() },
23
+ useTenantStore: { getState: () => ({ setTenant: mocks.setTenant, clear: vi.fn() }) },
24
+ getRedirectPath: () => "/home",
25
+ resolveSiteBase: () => "/",
26
+ }));
27
+
10
28
  describe("p2-A:回跳落地参数解析", () => {
11
29
  it("带齐 ticket 与 tenant → 解析出两者", () => {
12
30
  expect(parseWecomLanding("?wecom_ticket=tk_1&tenant=ww_corp_a")).toEqual({ ticket: "tk_1", tenant: "ww_corp_a" });
@@ -34,6 +52,50 @@ describe("p2-A:回跳落地参数解析", () => {
34
52
  });
35
53
  });
36
54
 
55
+ describe("p2-C:回跳换票提前到 onInit(登录页不闪)", () => {
56
+ const ctx = { register: { authProvider: mocks.registerAuthProvider } } as never;
57
+ const g = globalThis as any;
58
+
59
+ beforeEach(() => {
60
+ vi.clearAllMocks();
61
+ g.window = { location: { search: "" }, history: { replaceState: mocks.replaceState } };
62
+ });
63
+
64
+ it("无 ticket:不换票、不改 URL", async () => {
65
+ await onInit(ctx);
66
+ expect(mocks.login).not.toHaveBeenCalled();
67
+ expect(mocks.replaceState).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it("带 ticket:首帧前换票成功,URL 改到首页(登录页不挂载)", async () => {
71
+ g.window.location.search = "?wecom_ticket=tk_1&tenant=ww_corp_a";
72
+ mocks.login.mockResolvedValue(undefined);
73
+ await onInit(ctx);
74
+ expect(mocks.setTenant).toHaveBeenCalledWith("ww_corp_a");
75
+ expect(mocks.login).toHaveBeenCalledTimes(1);
76
+ expect(mocks.login).toHaveBeenCalledWith(expect.objectContaining({ method: "wecom", ticket: "tk_1" }));
77
+ expect(mocks.replaceState).toHaveBeenCalledWith(null, "", "/home");
78
+ expect(getWecomBootError()).toBe("");
79
+ });
80
+
81
+ it("换票失败:错误留给登录页首帧显示表单,不改写 URL", async () => {
82
+ g.window.location.search = "?wecom_ticket=bad&tenant=ww_corp_a";
83
+ mocks.login.mockRejectedValue(new Error("登录票据无效或已使用"));
84
+ await onInit(ctx);
85
+ expect(mocks.replaceState).not.toHaveBeenCalled();
86
+ expect(getWecomBootError()).toContain("登录票据无效或已使用");
87
+ });
88
+ });
89
+
90
+ describe("p2-D:一键扫码登录路径(不要求用户填企业代码)", () => {
91
+ it("不带 corp/agent 时是纯入口路径,由后端 corpSelf() 自动解析本企业", () => {
92
+ // 登录页「企业微信一键登录」按钮走这条:点一次即跳,用户不必输企业代码。
93
+ // 带上 corp 就退回「先填企业代码再点」的两步式,锁住这个契约。
94
+ expect(wecomAuthorizePath("", "")).toBe("/auth/wecom/login");
95
+ expect(wecomAuthorizePath("", "")).not.toContain("corp=");
96
+ });
97
+ });
98
+
37
99
  describe("p2-B:授权入口拼装", () => {
38
100
  it("企业代码 → 站内路径(部署前缀由调用方 withSiteBase 拼)", () => {
39
101
  expect(wecomAuthorizePath("ww_corp_a")).toBe("/auth/wecom/login?corp=ww_corp_a");
@@ -11,6 +11,7 @@
11
11
  "#/home/*": ["./src/app/api/home/*"],
12
12
  "#/notification/*": ["./src/app/api/notification/*"],
13
13
  "#/personal-center/*": ["./src/app/api/personal-center/*"],
14
+ "#/publish/*": ["./src/app/api/publish/*"],
14
15
  "#/system/*": ["./src/app/api/system/*"],
15
16
  "#/web/*": ["./src/app/api/web/*"]
16
17
  }
@@ -20,7 +20,9 @@ const allModules = [
20
20
  { name: "app", entry: "src/app/web/entry.ts" },
21
21
  // 解决方案市场:第一方能力(消费 _platform 编排面 /api/_platform/market/*,
22
22
  // R29:第三方方案不得仿照此调用面),默认随脚手架下发。保持独立模块——
23
- // 可选能力,删目录即可下线。
23
+ // 可选能力,不想要就改 `enabled: false`(不要删目录:src/app、src/oj-market
24
+ // 受完整性门禁保护,删改会让 ojm build 失败,见 src/README.md);
25
+ // 发布管理页(/publish)也在这个模块里。
24
26
  { name: "oj-market", entry: "src/oj-market/web/entry.ts" },
25
27
  // #region demo-only
26
28
  // 以下为模板内置的演示模块(about/access/exception/outside/route-nest),
@@ -45,15 +47,17 @@ export default {
45
47
  /**
46
48
  * 生成 client 的归属模块:`api 前缀 → 承载其 __client 的 web 模块`。
47
49
  *
48
- * 框架内置后端全部收在 `src/app/api/<前缀>`(模块名即 URL 前缀),前端页面收在
49
- * `src/app/web`;生成物统一落 `src/app/web/<api 前缀>/__client`,页面相对导入不变。
50
- * 不配则回落同名模块(`src/<api 前缀>/web/__client`,历史行为)。
50
+ * 框架内置后端收在 `src/app/api/<前缀>`(模块名即 URL 前缀),页面多数收在
51
+ * `src/app/web`;发布管理页已迁到方案市场,故 `publish` 的生成物跟页面一起落
52
+ * `src/oj-market/web/publish/__client`。不配则回落同名模块
53
+ * (`src/<api 前缀>/web/__client`,历史行为)。
51
54
  */
52
55
  clientOwners: {
53
56
  "_platform": "app",
54
57
  "auth": "app",
55
58
  "home": "app",
56
59
  "personal-center": "app",
60
+ "publish": "oj-market",
57
61
  "notification": "app",
58
62
  "system": "app",
59
63
  "web": "app",
@@ -8,9 +8,9 @@
8
8
  "react-router": "7.18.2",
9
9
  "react-router/dom": "7.18.2",
10
10
  "@tanstack/react-query": "5.102.8",
11
- "@oj-module/runtime": "0.2.66",
12
- "@oj-module/runtime/contract": "0.2.66",
13
- "@oj-module/runtime/contract/errors": "0.2.66",
11
+ "@oj-module/runtime": "0.2.68",
12
+ "@oj-module/runtime/contract": "0.2.68",
13
+ "@oj-module/runtime/contract/errors": "0.2.68",
14
14
  "@plane/kit": "0.1.20",
15
15
  "@plane/editor": "0.1.20",
16
16
  "antd": "6.6.1",