@oj-module/cli 0.2.66 → 0.2.67

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 (187) 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 +30 -1
  5. package/devkit/framework-dev.md +10 -4
  6. package/devkit/manual.md +38 -12
  7. package/devkit/publish-walkthrough.md +110 -37
  8. package/devkit/wecom-internal-app.md +7 -2
  9. package/devkit/wecom-multi-corp-links.md +163 -0
  10. package/package.json +2 -2
  11. package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
  12. package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.2dgstpjsu0.js} +3449 -3449
  13. package/shell-dist/index.html +4 -4
  14. package/shell-dist/versions.json +3 -3
  15. package/src/__tests__/publish-keygen.test.ts +157 -0
  16. package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
  17. package/src/__tests__/templates-schema-columns.test.ts +15 -2
  18. package/src/__tests__/templates-tenant-scope.test.ts +6 -2
  19. package/src/bundle.ts +8 -3
  20. package/src/cli.ts +17 -5
  21. package/src/dev.ts +15 -10
  22. package/src/install.ts +11 -4
  23. package/src/keygen.ts +54 -0
  24. package/src/lifecycle/__tests__/bundle.test.ts +21 -3
  25. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
  26. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
  27. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
  28. package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
  29. package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
  30. package/src/lifecycle/__tests__/smoke.test.ts +2 -2
  31. package/src/manifest-contract.ts +91 -8
  32. package/src/market/bootstrap.ts +26 -2
  33. package/src/market/command.ts +1 -0
  34. package/src/market/publish.ts +5 -1
  35. package/src/market/walkthrough.ts +24 -8
  36. package/src/site.ts +2 -0
  37. package/src/usage.ts +8 -4
  38. package/src/vendor.ts +5 -1
  39. package/templates/config.yaml +4 -2
  40. package/templates/init.include.yaml +8 -3
  41. package/templates/openapi.json +460 -1
  42. package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
  43. package/templates/src/app/api/_platform/_lifecycle/installer.ts +71 -22
  44. package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
  45. package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
  46. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
  47. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
  48. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
  49. package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
  50. package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
  51. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
  52. package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
  53. package/templates/src/app/api/_platform/manifest.yaml +1 -0
  54. package/templates/src/app/api/_platform/market/api.ts +235 -10
  55. package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
  56. package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
  57. package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
  58. package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
  59. package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
  60. package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
  61. package/templates/src/app/api/_platform/schema.yaml +18 -0
  62. package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
  63. package/templates/src/app/api/_platform/seed.pg.sql +10 -0
  64. package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
  65. package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
  66. package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
  67. package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
  68. package/templates/src/app/api/home/seed.mysql.sql +71 -0
  69. package/templates/src/app/api/home/seed.pg.sql +71 -0
  70. package/templates/src/app/api/notification/_shared/notify.ts +6 -6
  71. package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
  72. package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
  73. package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
  74. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
  75. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
  76. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
  77. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
  78. package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
  79. package/templates/src/app/api/notification/seed.mysql.sql +8 -0
  80. package/templates/src/app/api/notification/seed.pg.sql +11 -0
  81. package/templates/src/app/api/publish/_shared/identity.ts +69 -0
  82. package/templates/src/app/api/publish/api.ts +349 -0
  83. package/templates/src/app/api/publish/manifest.yaml +8 -0
  84. package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
  85. package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
  86. package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
  87. package/templates/src/app/api/publish/schema.yaml +33 -0
  88. package/templates/src/app/api/system/login-methods/api.ts +2 -1
  89. package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
  90. package/templates/src/app/web/_platform/__client/index.ts +25 -1
  91. package/templates/src/app/web/entry.ts +9 -5
  92. package/templates/src/app/web/home/routes.ts +5 -0
  93. package/templates/src/app/web/login/locales/en-US.json +7 -1
  94. package/templates/src/app/web/login/locales/zh-CN.json +7 -1
  95. package/templates/src/app/web/login/pages/login.tsx +79 -86
  96. package/templates/src/app/web/login/routes.ts +54 -1
  97. package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
  98. package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
  99. package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
  100. package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
  101. package/templates/src/app/web/system/locales/en-US.json +1 -8
  102. package/templates/src/app/web/system/locales/zh-CN.json +1 -8
  103. package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
  104. package/templates/src/app/web/system/routes.ts +0 -6
  105. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
  106. package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
  107. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
  108. package/templates/src/oj-market/web/api/client.ts +3 -0
  109. package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
  110. package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
  111. package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
  112. package/templates/src/oj-market/web/entry.ts +31 -2
  113. package/templates/src/oj-market/web/locales/en-US.json +5 -1
  114. package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
  115. package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
  116. package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
  117. package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
  118. package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
  119. package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
  120. package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
  121. package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
  122. package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
  123. package/templates/src/oj-market/web/publish/routes.ts +41 -0
  124. package/templates/src/oj-market/web/types.ts +30 -0
  125. package/templates/tests/lifecycle/compensate.spec.ts +207 -0
  126. package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
  127. package/templates/tests/lifecycle/installer.spec.ts +28 -9
  128. package/templates/tests/lifecycle/market-api.spec.ts +164 -12
  129. package/templates/tests/lifecycle/preflight.spec.ts +68 -0
  130. package/templates/tests/lifecycle/role-title-translate.spec.ts +2 -32
  131. package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
  132. package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
  133. package/templates/tests/publish-credential.spec.ts +354 -0
  134. package/templates/tests/publish-identity.spec.ts +44 -0
  135. package/templates/tests/wecom-client-timeout.spec.ts +33 -3
  136. package/templates/tests/wecom-landing.spec.ts +63 -1
  137. package/templates/tsconfig.paths.json +1 -0
  138. package/templates/web.config.ts +6 -4
  139. package/vendor/host-versions.json +3 -3
  140. package/templates/seed.mysql.sql +0 -6
  141. package/templates/seed.pg.sql +0 -6
  142. package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
  143. package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
  144. package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
  145. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
  146. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
  147. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
  148. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
  149. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
  150. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
  151. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
  152. package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
  153. package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
  154. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
  155. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
  156. package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
  157. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
  158. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
  159. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
  160. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
  161. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
  162. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
  163. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
  164. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
  165. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
  166. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
  167. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
  168. package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
  169. package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
  170. package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
  171. package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
  172. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
  173. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
  174. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
  175. package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
  176. package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
  177. package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
  178. package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
  179. package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
  180. package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
  181. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
  182. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
  183. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
  184. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
  185. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
  186. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
  187. package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
@@ -0,0 +1,349 @@
1
+ // 发布凭证端点族:/api/publish/credential/*
2
+ //
3
+ // 职责边界(ISP):本文件只做「门禁 + 入参收口 + 一次原子读写」;
4
+ // 身份推导在 _shared/identity.ts(纯函数,无 IO),凭证换身份 = 一条条件 UPDATE 抢所有权。
5
+ //
6
+ // 三个门径的差别是刻意的:
7
+ // · 申请 / 列表 / 撤销:登录态(currentAccess 已含「伪造租户头即查无此人」的拦截)
8
+ // · 兑换:**匿名**。凭证自身就是持有型证明——CLI 可能没有账号密码,
9
+ // 这是它唯一需要的能力。抗穷举靠 256 位随机 + IP 维度限速,不靠登录态。
10
+ //
11
+ // 为什么是工厂(createPublishApi)而不是裸 handler:随机数与时钟必须可注入,
12
+ // 否则「过期」这类用例只能靠 sleep,「并发双花」也无法稳定构造。
13
+ // 与 market/api.ts 的 createMarketApi(marketEnv()) 同一形态。
14
+ import { respond } from "#/_platform/_shared/respond";
15
+ import { currentAccess } from "#/_platform/_shared/service/infra/access";
16
+ import { pagedFactory } from "#/_platform/_shared/service/infra/query";
17
+ import { rateLimit } from "#/_platform/_shared/service/infra/rate";
18
+ import { assertKeyId, assertProduct, CREDENTIAL_TTL_SECS, deriveKeyId } from "./_shared/identity";
19
+
20
+ /** 兑换失败的唯一文案:不区分「不存在 / 已兑换 / 已过期 / 已撤销」,避免探测,且给出下一步 */
21
+ const EXCHANGE_INVALID = "发布凭证无效或已失效(可能已兑换、已过期或已撤销)。请到「发布管理」页重新申请";
22
+
23
+ /** 单 IP 兑换尝试上限(每 10 分钟);256 位随机下正常用户远用不到,超限只可能是扫 */
24
+ const EXCHANGE_RATE_LIMIT = 20;
25
+ const EXCHANGE_RATE_WINDOW_MS = 10 * 60 * 1000;
26
+
27
+ export interface PublishDeps {
28
+ /** 秒级时钟 */
29
+ now: () => number
30
+ randomHex: (bytes: number) => string
31
+ sha256Hex: (input: string) => string
32
+ }
33
+
34
+ function fail(status: number, message: string): never {
35
+ throw Object.assign(new Error(message), { status });
36
+ }
37
+
38
+ /** 客户端 IP:反代场景读 x-forwarded-for 首段(与 login-throttle 同口径) */
39
+ function clientIp(): string {
40
+ const headers = (http.headers ?? {}) as Record<string, string>;
41
+ return String(headers["x-forwarded-for"] ?? headers["x-real-ip"] ?? "unknown").split(",")[0].trim();
42
+ }
43
+
44
+ /** 运行时依赖:oj isolate 的 crypto 增补面(无 randomUUID/sha256 时需要显式报缺,不静默降级) */
45
+ export function runtimePublishDeps(): PublishDeps {
46
+ return {
47
+ now: () => Math.floor(Date.now() / 1000),
48
+ randomHex: (n) => {
49
+ const c = (globalThis as unknown as { crypto?: { randomHex?: (bytes: number) => string } }).crypto;
50
+ if (typeof c?.randomHex !== "function")
51
+ throw new Error("publish: 运行时缺少 crypto.randomHex,无法签发凭证");
52
+ return String(c.randomHex(n));
53
+ },
54
+ sha256Hex: (input) => {
55
+ const c = (globalThis as unknown as { crypto?: { sha256Hex?: (s: string) => string } }).crypto;
56
+ if (typeof c?.sha256Hex !== "function")
57
+ throw new Error("publish: 运行时缺少 crypto.sha256Hex,无法签发凭证");
58
+ return String(c.sha256Hex(input));
59
+ },
60
+ };
61
+ }
62
+
63
+ interface Actor {
64
+ uid: number
65
+ username: string
66
+ displayName: string | null
67
+ tenant: string
68
+ superadmin: boolean
69
+ }
70
+
71
+ interface CredentialRow {
72
+ id: number
73
+ product: string
74
+ key_id: string
75
+ username: string
76
+ display_name: string | null
77
+ owner_tenant: string
78
+ token_hint: string
79
+ expires_at: number
80
+ used: number
81
+ used_at: number | null
82
+ revoked: number
83
+ created_at: number
84
+ }
85
+
86
+ /** 状态由服务端判定(前端各自推导必然分叉) */
87
+ function statusOf(row: CredentialRow, now: number): "active" | "used" | "revoked" | "expired" {
88
+ if (Number(row.used) === 1)
89
+ return "used";
90
+ if (Number(row.revoked) === 1)
91
+ return "revoked";
92
+ if (Number(row.expires_at) <= now)
93
+ return "expired";
94
+ return "active";
95
+ }
96
+
97
+ export function createPublishApi(deps: PublishDeps) {
98
+ /** 当前操作者:身份来自 token,租户与用户名来自**当前租户下**的 users 行(伪造头即查无此行) */
99
+ async function actor(): Promise<Actor> {
100
+ const snap = await currentAccess();
101
+ const uid = Number(http.user?.id);
102
+ const rows = await db.table("users")
103
+ .select(["username", "display_name", "tenant_id"])
104
+ .where({ field: "id", op: "eq", value: uid })
105
+ .all() as Array<Record<string, unknown>>;
106
+ const row = rows[0];
107
+ if (!row)
108
+ fail(403, "forbidden");
109
+ return {
110
+ uid,
111
+ username: String(row.username),
112
+ displayName: row.display_name == null ? null : String(row.display_name),
113
+ tenant: String(row.tenant_id ?? "default"),
114
+ superadmin: snap.roles.includes("superadmin"),
115
+ };
116
+ }
117
+
118
+ // ── GET /api/publish/credential —— 列表(本人;superadmin 可看全部并按租户筛)────────
119
+ const get = async () => {
120
+ await respond(async () => {
121
+ const me = await actor();
122
+ const q = (http.query ?? {}) as Record<string, unknown>;
123
+ const tenantFilter = me.superadmin && q.tenant ? String(q.tenant) : undefined;
124
+
125
+ // 共享表:构造器不会注入租户谓词,隔离条件必须显式写
126
+ const mk = () => {
127
+ const b = db.table("publish_credential")
128
+ .whereIf(tenantFilter, { field: "owner_tenant", op: "eq", value: tenantFilter });
129
+ if (me.superadmin)
130
+ return b;
131
+ return b.where({ field: "owner_tenant", op: "eq", value: me.tenant })
132
+ .where({ field: "uid", op: "eq", value: me.uid });
133
+ };
134
+
135
+ const r = await pagedFactory<CredentialRow>(mk, { page: q.page, size: q.limit });
136
+ const now = deps.now();
137
+ return {
138
+ list: r.list.map(row => ({
139
+ id: Number(row.id),
140
+ product: row.product,
141
+ key_id: row.key_id,
142
+ username: row.username,
143
+ display_name: row.display_name,
144
+ token_hint: row.token_hint,
145
+ status: statusOf(row, now),
146
+ expires_at: Number(row.expires_at),
147
+ used_at: row.used_at == null ? null : Number(row.used_at),
148
+ created_at: Number(row.created_at),
149
+ // 归属租户只对超管有意义
150
+ owner_tenant: me.superadmin ? row.owner_tenant : undefined,
151
+ })),
152
+ total: r.total,
153
+ };
154
+ });
155
+ };
156
+ get.schema = {
157
+ query: {
158
+ type: "object",
159
+ properties: {
160
+ page: { type: "number", minimum: 1 },
161
+ limit: { type: "number", minimum: 1, maximum: 200 },
162
+ tenant: { type: "string" },
163
+ },
164
+ },
165
+ };
166
+ get.response = {
167
+ type: "object",
168
+ required: ["list", "total"],
169
+ properties: {
170
+ list: {
171
+ type: "array",
172
+ items: {
173
+ type: "object",
174
+ required: ["id", "product", "key_id", "username", "token_hint", "status", "expires_at", "created_at"],
175
+ properties: {
176
+ id: { type: "number" },
177
+ product: { type: "string" },
178
+ key_id: { type: "string" },
179
+ username: { type: "string" },
180
+ display_name: { type: ["string", "null"] },
181
+ token_hint: { type: "string" },
182
+ status: { type: "string", enum: ["active", "used", "revoked", "expired"] },
183
+ expires_at: { type: "number" },
184
+ used_at: { type: ["number", "null"] },
185
+ created_at: { type: "number" },
186
+ owner_tenant: { type: "string" },
187
+ },
188
+ },
189
+ },
190
+ total: { type: "number" },
191
+ },
192
+ };
193
+
194
+ // ── POST /api/publish/credential —— 申请(填产品名 → 一次性拿到凭证 + 已定身份)─────
195
+ const post = async () => {
196
+ await respond(async () => {
197
+ const body = (http.body ?? {}) as Record<string, unknown>;
198
+ const product = assertProduct(body.product);
199
+ const me = await actor();
200
+ const keyId = assertKeyId(deriveKeyId({ product, username: me.username, uid: me.uid }));
201
+
202
+ // 撞号:同一 key_id 已被别人占用 → 拒绝。同一人重复申请是允许的
203
+ // (密钥长期有效,换机重建属正常操作),故只拦 uid 不同。
204
+ const dup = await db.table("publish_credential")
205
+ .select(["uid"])
206
+ .where({ field: "key_id", op: "eq", value: keyId })
207
+ .limit(1)
208
+ .all() as Array<Record<string, unknown>>;
209
+ if (dup.length > 0 && Number(dup[0].uid) !== me.uid)
210
+ fail(409, `发布身份 ${keyId} 已被其他用户占用,请换一个产品名`);
211
+
212
+ const token = deps.randomHex(32);
213
+ const now = deps.now();
214
+ const expiresAt = now + CREDENTIAL_TTL_SECS;
215
+ await db.table("publish_credential").insert({
216
+ token_hash: deps.sha256Hex(token),
217
+ token_hint: token.slice(0, 8),
218
+ uid: me.uid,
219
+ username: me.username,
220
+ display_name: me.displayName,
221
+ product,
222
+ key_id: keyId,
223
+ owner_tenant: me.tenant,
224
+ expires_at: expiresAt,
225
+ used: 0,
226
+ revoked: 0,
227
+ created_at: now,
228
+ created_ip: clientIp(),
229
+ }).run();
230
+
231
+ // 明文只在这里出现一次;库里只有 sha256
232
+ return { token, key_id: keyId, product, token_hint: token.slice(0, 8), expires_at: expiresAt };
233
+ });
234
+ };
235
+ post.schema = {
236
+ body: {
237
+ type: "object",
238
+ required: ["product"],
239
+ properties: { product: { type: "string" } },
240
+ },
241
+ };
242
+ post.response = {
243
+ type: "object",
244
+ required: ["token", "key_id", "product", "expires_at"],
245
+ properties: {
246
+ token: { type: "string" },
247
+ key_id: { type: "string" },
248
+ product: { type: "string" },
249
+ token_hint: { type: "string" },
250
+ expires_at: { type: "number" },
251
+ },
252
+ };
253
+
254
+ // ── POST /api/publish/credential/revoke —— 撤销(本人或超管;已兑换的不可撤销)──────
255
+ const revoke = async () => {
256
+ await respond(async () => {
257
+ const body = (http.body ?? {}) as Record<string, unknown>;
258
+ const id = Number(body.id);
259
+ if (!Number.isInteger(id) || id <= 0)
260
+ fail(400, "id 必须是正整数");
261
+ const me = await actor();
262
+
263
+ const rows = await db.table("publish_credential")
264
+ .select(["id", "uid", "owner_tenant", "used", "revoked"])
265
+ .where({ field: "id", op: "eq", value: id })
266
+ .all() as Array<Record<string, unknown>>;
267
+ const row = rows[0];
268
+ // 越权与不存在同一句话:不给探测者区分「存在但不属于你」的材料
269
+ if (!row || (!me.superadmin && (Number(row.uid) !== me.uid || String(row.owner_tenant) !== me.tenant)))
270
+ fail(404, "凭证不存在");
271
+
272
+ if (Number(row.used) === 1)
273
+ fail(400, "该凭证已兑换成发布身份,不能撤销(发布身份长期有效;如需停用请移除对应信任公钥)");
274
+ if (Number(row.revoked) === 1)
275
+ return { ok: true };
276
+
277
+ await db.table("publish_credential").update({ revoked: 1 }).where({ field: "id", op: "eq", value: id }).run();
278
+ return { ok: true };
279
+ });
280
+ };
281
+ revoke.schema = {
282
+ body: { type: "object", required: ["id"], properties: { id: { type: "number" } } },
283
+ };
284
+ revoke.response = { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } };
285
+
286
+ // ── POST /api/publish/credential/exchange —— 兑换(匿名;一次性,原子抢所有权)──────
287
+ const exchange = async () => {
288
+ await respond(async () => {
289
+ // 抗穷举:单 IP 每 10 分钟最多 20 次尝试
290
+ await rateLimit(`publish-exchange:ip:${clientIp()}`, EXCHANGE_RATE_LIMIT, EXCHANGE_RATE_WINDOW_MS);
291
+
292
+ const body = (http.body ?? {}) as Record<string, unknown>;
293
+ const token = String(body.token ?? "").trim();
294
+ // 形状先挡:64 位 hex 以外的输入连查库都不必
295
+ if (!/^[0-9a-f]{64}$/i.test(token))
296
+ fail(400, EXCHANGE_INVALID);
297
+
298
+ const hash = deps.sha256Hex(token);
299
+ const now = deps.now();
300
+ // 一条语句完成「判有效 + 抢所有权」:并发双花只可能有一个 changes=1
301
+ const changed = Number(await db.table("publish_credential")
302
+ .update({ used: 1, used_at: now })
303
+ .where({
304
+ and: [
305
+ { field: "token_hash", op: "eq", value: hash },
306
+ { field: "used", op: "eq", value: 0 },
307
+ { field: "revoked", op: "eq", value: 0 },
308
+ { field: "expires_at", op: "gt", value: now },
309
+ ],
310
+ })
311
+ .run());
312
+ if (changed !== 1)
313
+ fail(400, EXCHANGE_INVALID);
314
+
315
+ const rows = await db.table("publish_credential")
316
+ .select(["key_id", "product", "username", "display_name"])
317
+ .where({ field: "token_hash", op: "eq", value: hash })
318
+ .all() as Array<Record<string, unknown>>;
319
+ const row = rows[0];
320
+ if (!row)
321
+ fail(400, EXCHANGE_INVALID);
322
+ return {
323
+ key_id: String(row.key_id),
324
+ product: String(row.product),
325
+ username: String(row.username),
326
+ display_name: row.display_name == null ? null : String(row.display_name),
327
+ };
328
+ });
329
+ };
330
+ exchange.schema = {
331
+ body: { type: "object", required: ["token"], properties: { token: { type: "string" } } },
332
+ };
333
+ exchange.response = {
334
+ type: "object",
335
+ required: ["key_id", "product", "username"],
336
+ properties: {
337
+ key_id: { type: "string" },
338
+ product: { type: "string" },
339
+ username: { type: "string" },
340
+ display_name: { type: ["string", "null"] },
341
+ },
342
+ };
343
+
344
+ return {
345
+ credential: { get, post, revoke: { post: revoke }, exchange: { post: exchange } },
346
+ };
347
+ }
348
+
349
+ export default createPublishApi(runtimePublishDeps());
@@ -0,0 +1,8 @@
1
+ name: publish
2
+ desc: 发布凭证与发布身份(业务路由):申请 / 列表 / 撤销 / 一次性兑换。凭证兑换即作废,Ed25519 密钥长期有效;身份只由服务端推导,CLI 不重复实现编码规则。
3
+ version: 0.1.0
4
+ tables:
5
+ # 共享表(B 组):兑换发生在 CLI,无业务租户语义;租户归属走 owner_tenant 列
6
+ - publish_credential
7
+ deps: # 判权(currentAccess)、统一信封(respond)、分页与限速都来自平台内核
8
+ _platform: ^0.1.0
@@ -0,0 +1,23 @@
1
+ -- 0001__publish_credential(mysql 方言):列语义同 0001__publish_credential.sql 头注释。
2
+ -- 差异(沿用 _platform 基线的既有惯例):键列/短文本收窄为 VARCHAR;秒级 epoch 取 BIGINT;
3
+ -- 索引写在 CREATE TABLE 内(mysql 无 CREATE INDEX IF NOT EXISTS)。
4
+ CREATE TABLE IF NOT EXISTS publish_credential (
5
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
6
+ token_hash VARCHAR(255) NOT NULL,
7
+ token_hint VARCHAR(64) NOT NULL,
8
+ uid BIGINT NOT NULL,
9
+ username VARCHAR(255) NOT NULL,
10
+ display_name TEXT,
11
+ product VARCHAR(255) NOT NULL,
12
+ key_id VARCHAR(255) NOT NULL,
13
+ owner_tenant VARCHAR(255) NOT NULL,
14
+ expires_at BIGINT NOT NULL,
15
+ used BIGINT NOT NULL DEFAULT 0,
16
+ used_at BIGINT,
17
+ revoked BIGINT NOT NULL DEFAULT 0,
18
+ created_at BIGINT NOT NULL,
19
+ created_ip VARCHAR(64),
20
+ UNIQUE KEY uk_publish_credential_token (token_hash),
21
+ KEY idx_publish_credential_owner (owner_tenant, uid, created_at),
22
+ KEY idx_publish_credential_key (key_id)
23
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,23 @@
1
+ -- 0001__publish_credential(postgres 方言):列语义同 0001__publish_credential.sql 头注释。
2
+ -- 差异(沿用 _platform 基线的既有惯例):TEXT 全保留,秒级 epoch 取 BIGINT。
3
+ CREATE TABLE IF NOT EXISTS publish_credential (
4
+ id BIGSERIAL PRIMARY KEY,
5
+ token_hash TEXT NOT NULL,
6
+ token_hint TEXT NOT NULL,
7
+ uid BIGINT NOT NULL,
8
+ username TEXT NOT NULL,
9
+ display_name TEXT,
10
+ product TEXT NOT NULL,
11
+ key_id TEXT NOT NULL,
12
+ owner_tenant TEXT NOT NULL,
13
+ expires_at BIGINT NOT NULL,
14
+ used INTEGER NOT NULL DEFAULT 0,
15
+ used_at BIGINT,
16
+ revoked INTEGER NOT NULL DEFAULT 0,
17
+ created_at BIGINT NOT NULL,
18
+ created_ip TEXT
19
+ );
20
+ CREATE UNIQUE INDEX IF NOT EXISTS uk_publish_credential_token ON publish_credential (token_hash);
21
+ CREATE INDEX IF NOT EXISTS idx_publish_credential_owner
22
+ ON publish_credential (owner_tenant, uid, created_at);
23
+ CREATE INDEX IF NOT EXISTS idx_publish_credential_key ON publish_credential (key_id);
@@ -0,0 +1,39 @@
1
+ -- 0001__publish_credential(sqlite 方言):发布凭证表。
2
+ --
3
+ -- 定性:**共享表**(schema.yaml `tenant: false` + config.yaml `tenant.shared_allow` 双声明)。
4
+ -- 理由:兑换是 CLI 发起的,那一刻没有业务租户上下文;建成租户表会让 sql_guard: deny 给
5
+ -- 按 token_hash 的查询强加 tenant_id 过滤,换个租户头就查不到。租户归属由 owner_tenant
6
+ -- 显式承载(命名同 wecom_login_ticket:共享表出现 tenant_id 会被定性红线判为自相矛盾)。
7
+ --
8
+ -- 语义:
9
+ -- · 一次性兑换令牌:兑换成功后 used=1,再兑即拒;未兑换前可用 revoked 手动作废
10
+ -- · key_id 在**申请时**就算好落库,兑换只回读——CLI 不实现任何编码规则(单一真源)
11
+ -- · 只存 token_hash(sha256),明文只在申请响应里出现一次
12
+ --
13
+ -- 主键用自增 id 而非 token_hash:列表分页与撤销都按行 id 操作(pagedFactory 也按 id 排序),
14
+ -- 用 hash 做主键会让这两件事都得先算 hash 再反查。token_hash 仍唯一。
15
+ CREATE TABLE IF NOT EXISTS publish_credential (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ token_hash TEXT NOT NULL,
18
+ token_hint TEXT NOT NULL,
19
+ uid INTEGER NOT NULL,
20
+ username TEXT NOT NULL,
21
+ display_name TEXT,
22
+ product TEXT NOT NULL,
23
+ key_id TEXT NOT NULL,
24
+ owner_tenant TEXT NOT NULL,
25
+ expires_at INTEGER NOT NULL,
26
+ used INTEGER NOT NULL DEFAULT 0,
27
+ used_at INTEGER,
28
+ revoked INTEGER NOT NULL DEFAULT 0,
29
+ created_at INTEGER NOT NULL,
30
+ created_ip TEXT
31
+ );
32
+ CREATE UNIQUE INDEX IF NOT EXISTS uk_publish_credential_token ON publish_credential (token_hash);
33
+
34
+ -- 「发布管理」页的主查询:按租户 + 本人 + 时间倒序
35
+ CREATE INDEX IF NOT EXISTS idx_publish_credential_owner
36
+ ON publish_credential (owner_tenant, uid, created_at);
37
+
38
+ -- 撞号检查(同 key_id 是否已属于别人)
39
+ CREATE INDEX IF NOT EXISTS idx_publish_credential_key ON publish_credential (key_id);
@@ -0,0 +1,33 @@
1
+ # 声明式表结构(S006 纪律:结构真源是 migrations/0001__publish_credential*,本文件只负责
2
+ # 「列集/主键/租户定性」的运行时元数据,与迁移逐列对齐;templates-migration-schema-parity 双双守着)。
3
+ #
4
+ # 定性(三处一致:这里 / config.yaml `tenant.shared_allow` / 迁移文件头注释):
5
+ # **共享表**(tenant: false)。凭证在 CLI 兑换那一跳被执行,此刻没有业务租户语义——
6
+ # 若建成租户表,sql_guard: deny 会给按 token_hash 的查询强加 tenant_id 过滤,换个租户头即查不到。
7
+ # 租户归属由 `owner_tenant` 列显式承载(语义同 wecom_login_ticket:共享表出现 tenant_id
8
+ # 会被定性红线判为自相矛盾)。
9
+ tables:
10
+ publish_credential:
11
+ tenant: false
12
+ pk: id
13
+ columns:
14
+ id: {type: integer, null: false, autoincrement: true}
15
+ # 只存 sha256(明文):明文只在申请响应里出现一次
16
+ token_hash: {type: text, null: false}
17
+ # 明文前 8 位,仅供「发布管理」页识别是哪一张,不参与任何校验
18
+ token_hint: {type: text, null: false}
19
+ uid: {type: integer, null: false}
20
+ # 申请时快照:改名不影响已签发的凭证与已推导的 key_id
21
+ username: {type: text, null: false}
22
+ display_name: {type: text}
23
+ # 申请时填写的产品名(NAME_RE 校验,落库即定)
24
+ product: {type: text, null: false}
25
+ # 服务端推导并落库的发布身份;兑换时原样下发,CLI 零推导
26
+ key_id: {type: text, null: false}
27
+ owner_tenant: {type: text, null: false}
28
+ expires_at: {type: integer, null: false}
29
+ used: {type: integer, null: false}
30
+ used_at: {type: integer}
31
+ revoked: {type: integer, null: false}
32
+ created_at: {type: integer, null: false}
33
+ created_ip: {type: text}
@@ -10,9 +10,10 @@ async function get() {
10
10
  }
11
11
  get.response = {
12
12
  type: "object",
13
- required: ["methods"],
13
+ required: ["methods", "site_title"],
14
14
  properties: {
15
15
  methods: { type: "array", items: { type: "string" } },
16
+ site_title: { type: "string" },
16
17
  },
17
18
  };
18
19
 
@@ -124,6 +124,21 @@ export const schemas = {
124
124
  data: z.array(z.object({
125
125
 
126
126
  }).catchall(z.unknown())),
127
+ },
128
+ "platformMarketReleasesGet": {
129
+ query: z.object({
130
+ "page": z.number().min(1).optional(),
131
+ "limit": z.number().min(1).max(200).optional()
132
+ }),
133
+ data: z.object({
134
+ "list": z.array(z.object({
135
+
136
+ }).catchall(z.unknown())),
137
+ "total": z.number(),
138
+ "page": z.number(),
139
+ "size": z.number(),
140
+ "syncError": z.string().nullable().optional()
141
+ }),
127
142
  },
128
143
  "platformOrgDepartmentsGet": {
129
144
  query: z.object({
@@ -36,7 +36,7 @@ export function create_platformClient(ctx: ModuleContext) {
36
36
  }
37
37
  ctx.register.apiPrefix(API_PREFIX);
38
38
  bindRequest(ctx.utils.request);
39
- return { platformContactsGet, platformContactsDetailGet, platformMarketInstallPost, platformMarketInstallCancelPost, platformMarketTaskGet, platformMarketEnablePost, platformMarketMetaGet, platformMarketTrustGet, platformMarketTrustPost, platformMarketTrustKeyPost, platformMarketTrustKeyDelete, platformMarketIndexGet, platformMarketUninstallImpactGet, platformMarketUninstallPost, platformMarketHistoryGet, platformOrgDepartmentsGet, platformOrgIsMemberGet, platformOrgMembersGet, platformOrgPickerGet, platformOrgResolvePost, platformOrgTreeGet, platformOrgUserGet, platformSolutionsRoutesGet, platformSolutionsVerifyPost, platformSolutionsVerifyLastGet };
39
+ return { platformContactsGet, platformContactsDetailGet, platformMarketInstallPost, platformMarketInstallCancelPost, platformMarketTaskGet, platformMarketEnablePost, platformMarketMetaGet, platformMarketTrustGet, platformMarketTrustPost, platformMarketTrustKeyPost, platformMarketTrustKeyDelete, platformMarketIndexGet, platformMarketUninstallImpactGet, platformMarketUninstallPost, platformMarketHistoryGet, platformMarketReleasesGet, platformOrgDepartmentsGet, platformOrgIsMemberGet, platformOrgMembersGet, platformOrgPickerGet, platformOrgResolvePost, platformOrgTreeGet, platformOrgUserGet, platformSolutionsRoutesGet, platformSolutionsVerifyPost, platformSolutionsVerifyLastGet };
40
40
  }
41
41
 
42
42
  /**
@@ -423,6 +423,30 @@ export async function platformMarketHistoryGet(): Promise<PlatformMarketHistoryG
423
423
  }
424
424
  }
425
425
 
426
+ export type PlatformMarketReleasesGetQuery = z.input<(typeof schemas)["platformMarketReleasesGet"]["query"]>;
427
+ export type PlatformMarketReleasesGetData = z.infer<(typeof schemas)["platformMarketReleasesGet"]["data"]>;
428
+
429
+ export async function platformMarketReleasesGet(query: PlatformMarketReleasesGetQuery): Promise<PlatformMarketReleasesGetData> {
430
+ const client = ensureReq();
431
+ try {
432
+ const env = await client.get(`_platform/market/releases`, { searchParams: query as Record<string, string | number | boolean> }).json<OjEnvelope<PlatformMarketReleasesGetData>>();
433
+ // 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
434
+ if (typeof env.code === "number" && env.code !== 0)
435
+ throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
436
+ const data = env.data as PlatformMarketReleasesGetData;
437
+ if (import.meta.env.DEV) {
438
+ const { schemas } = await import("./api.schemas");
439
+ const r = schemas["platformMarketReleasesGet"].data.safeParse(data);
440
+ if (!r.success)
441
+ throw new ContractApiError(-1, `[契约违例] platformMarketReleasesGet 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
442
+ }
443
+ return data;
444
+ }
445
+ catch (e) {
446
+ throw await toApiError(e);
447
+ }
448
+ }
449
+
426
450
  export type PlatformOrgDepartmentsGetQuery = z.input<(typeof schemas)["platformOrgDepartmentsGet"]["query"]>;
427
451
  export type PlatformOrgDepartmentsGetData = z.infer<(typeof schemas)["platformOrgDepartmentsGet"]["data"]>;
428
452
 
@@ -8,13 +8,16 @@ import { onInit as personalCenterOnInit, routes as personalCenterRoutes } from "
8
8
  import { onInit as systemOnInit, routes as systemRoutes } from "./system/routes";
9
9
 
10
10
  /**
11
- * 应用模块(多根聚合):一个 entry 承载六个业务根,各根独立成一级菜单。
11
+ * 应用模块(多根聚合):一个 entry 承载五个业务根,各根独立成一级菜单。
12
12
  *
13
- * 为什么聚合:这六个根(首页/登录/个人中心/通知/系统管理)是脚手架的框架内置页面,
14
- * 同版本发布、同生命周期、缺一不可,拆成六个模块只会把工程目录、web.config 登记、
15
- * modules.json 与首屏模块入口请求都放大六倍。聚合后仍是「一目录一模块」,但模块内
13
+ * 为什么聚合:这五个根(首页/登录/个人中心/通知/系统管理)是脚手架的框架内置页面,
14
+ * 同版本发布、同生命周期、缺一不可,拆成五个模块只会把工程目录、web.config 登记、
15
+ * modules.json 与首屏模块入口请求都放大五倍。聚合后仍是「一目录一模块」,但模块内
16
16
  * 按根分目录(`<root>/routes.ts` + `pages/` + `__client/`),页面相对导入不变。
17
17
  *
18
+ * 发布管理不在这里:它属于方案市场,随 `src/oj-market/web/publish` 走市场模块
19
+ * (`/publish` 仍是独立一级菜单,URL 与菜单 key 不变)。
20
+ *
18
21
  * 结构约定(多根模块):
19
22
  * - 顶层路由首段去重 > 1 → runtime 判为多根(`isMultiRoot`),各根按 entry 里的
20
23
  * **绝对路径**原样注册,不被 `routePrefix` 改写——URL 与菜单 key 都不变;
@@ -24,7 +27,8 @@ import { onInit as systemOnInit, routes as systemRoutes } from "./system/routes"
24
27
  *
25
28
  * i18n 用 `i18nNamespaces` 沿用各根**原命名空间**(home / login / personal-center /
26
29
  * notification / system),因此 `t("system:menu.users")`、`useTranslation("system")`
27
- * 与菜单标题 `<ns>:menu.<key>` 全部零改动。
30
+ * 与菜单标题 `<ns>:menu.<key>` 全部零改动。发布管理的 `publish` 命名空间随页面
31
+ * 在 oj-market 侧登记。
28
32
  */
29
33
  const i18nNamespaces: Record<string, ModuleI18n> = {
30
34
  "home": {
@@ -35,6 +35,11 @@ export const routes: AppRouteRecordRaw[] = [
35
35
  handle: {
36
36
  title: "home:menu.home",
37
37
  icon: createElement(HomeOutlined),
38
+ // 首页是 /home 根的 index 子路由,运行时匹配到的叶子路由 id 为 `/home/`(带尾斜杠),
39
+ // 而菜单项 key 是父级 `/home`,selectedKeys 取叶子 id 导致高亮对不上——
40
+ // 复刻 react-antd-admin 时「首页」菜单项不亮。用 currentActiveMenu 显式声明高亮 /home
41
+ // (框架既定机制:页面指定高亮哪个菜单),对齐 playground / react-antd-admin 的选中态。
42
+ currentActiveMenu: "/home",
38
43
  },
39
44
  },
40
45
  ],
@@ -30,9 +30,15 @@
30
30
  "sso.action": "Sign in with SSO",
31
31
  "wecom": "WeCom",
32
32
  "wecom.action": "Sign in with WeCom",
33
+ "wecomPending": "WeCom login will be available in a later stage",
33
34
  "wecom.hint": "Enter the enterprise code (corp_id, visible in the WeCom console), or use the dedicated sign-in link from your admin",
34
35
  "wecom.agent": "App ID (agentid)",
35
36
  "wecom.agentHint": "Leave empty if the corp has a single app; otherwise use the agentid provided by your admin",
36
- "wecom.exchanging": "Completing WeCom sign-in…"
37
+ "wecom.exchanging": "Completing WeCom sign-in…",
38
+ "wecom.scan.hint": "Open the WeCom app, scan with 「Scan」 and confirm — no username or password needed",
39
+ "wecom.scan.action": "One-tap sign in with WeCom",
40
+ "wecom.otherCorp": "Another enterprise? Sign in with an enterprise code",
41
+ "wecom.otherCorpHide": "Hide enterprise code"
42
+
37
43
  }
38
44
  }
@@ -30,9 +30,15 @@
30
30
  "sso.action": "使用 SSO 登录",
31
31
  "wecom": "企业微信",
32
32
  "wecom.action": "使用企业微信登录",
33
+ "wecomPending": "企业微信登录将在后续阶段提供",
33
34
  "wecom.hint": "填写企业代码(企微后台可见的 corp_id),或直接使用管理员收到的专属登录链接",
34
35
  "wecom.agent": "应用 ID(agentid)",
35
36
  "wecom.agentHint": "留空使用该企业下唯一应用;多应用部署请填管理员提供的 agentid",
36
- "wecom.exchanging": "正在完成企业微信登录…"
37
+ "wecom.exchanging": "正在完成企业微信登录…",
38
+ "wecom.scan.hint": "打开企业微信 App,用「扫一扫」扫码确认后自动进入,无需输入账号密码",
39
+ "wecom.scan.action": "使用企业微信一键登录",
40
+ "wecom.otherCorp": "其他企业?使用企业代码登录",
41
+ "wecom.otherCorpHide": "收起企业代码"
42
+
37
43
  }
38
44
  }