@oj-module/cli 0.2.43 → 0.2.46

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 (96) hide show
  1. package/devkit/README.md +7 -5
  2. package/devkit/SKILL.md +22 -2
  3. package/devkit/manual.md +5 -0
  4. package/devkit/wecom-daikaifa.md +421 -0
  5. package/package.json +2 -2
  6. package/shell-dist/assets/runtime.js +102 -39
  7. package/shell-dist/index.html +2 -2
  8. package/shell-dist/versions.json +3 -3
  9. package/src/__tests__/manifest-routes-derive.test.ts +22 -0
  10. package/src/__tests__/site-tenant-header.test.ts +54 -0
  11. package/src/__tests__/templates-tenant-scope.test.ts +139 -0
  12. package/src/build.ts +9 -0
  13. package/src/init.ts +4 -1
  14. package/src/install.ts +2 -2
  15. package/src/market/bootstrap.ts +15 -0
  16. package/src/market/setup.ts +3 -3
  17. package/src/market/walkthrough.ts +35 -29
  18. package/src/site.ts +17 -1
  19. package/templates/config.yaml +72 -2
  20. package/templates/openapi.json +319 -0
  21. package/templates/src/_platform/api/_lifecycle/steps/download.ts +6 -2
  22. package/templates/src/_platform/api/_lifecycle/steps/smoke.ts +17 -9
  23. package/templates/src/_platform/api/_shared/respond.ts +11 -6
  24. package/templates/src/_platform/api/_shared/service/domain/settings.ts +5 -3
  25. package/templates/src/_platform/api/_shared/service/domain/users.ts +9 -3
  26. package/templates/src/_platform/api/_shared/service/infra/access.ts +59 -4
  27. package/templates/src/_platform/api/_shared/service/infra/query.ts +5 -1
  28. package/templates/src/_platform/api/contacts/api.ts +4 -1
  29. package/templates/src/_platform/api/contacts/detail/api.ts +3 -0
  30. package/templates/src/_platform/api/migrations/0015__tenant.mysql.sql +12 -0
  31. package/templates/src/_platform/api/migrations/0015__tenant.pg.sql +11 -0
  32. package/templates/src/_platform/api/migrations/0015__tenant.sql +44 -0
  33. package/templates/src/_platform/api/schema.yaml +28 -0
  34. package/templates/src/_platform/api/seed.sql +3 -0
  35. package/templates/src/auth/api/_shared/wecom/admin.ts +112 -0
  36. package/templates/src/auth/api/_shared/wecom/client.ts +243 -0
  37. package/templates/src/auth/api/_shared/wecom/config.ts +176 -0
  38. package/templates/src/auth/api/_shared/wecom/crypt.ts +317 -0
  39. package/templates/src/auth/api/_shared/wecom/events.ts +127 -0
  40. package/templates/src/auth/api/_shared/wecom/login.ts +137 -0
  41. package/templates/src/auth/api/_shared/wecom/replay.ts +66 -0
  42. package/templates/src/auth/api/_shared/wecom/store.ts +230 -0
  43. package/templates/src/auth/api/_shared/wecom/xml.ts +36 -0
  44. package/templates/src/auth/api/manifest.yaml +6 -0
  45. package/templates/src/auth/api/migrations/0001__wecom.mysql.sql +35 -0
  46. package/templates/src/auth/api/migrations/0001__wecom.pg.sql +33 -0
  47. package/templates/src/auth/api/migrations/0001__wecom.sql +47 -0
  48. package/templates/src/auth/api/schema.yaml +48 -0
  49. package/templates/src/auth/api/wecom/admin/api.ts +121 -0
  50. package/templates/src/auth/api/wecom/api.ts +246 -0
  51. package/templates/src/auth/web/__client/api.schemas.ts +36 -0
  52. package/templates/src/auth/web/__client/api.ts +148 -1
  53. package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +2 -0
  54. package/templates/src/demo/api/migrations/0002__tenant.sql +2 -0
  55. package/templates/src/demo/api/schema.yaml +1 -0
  56. package/templates/src/home/api/migrations/0002__tenant.mysql.sql +3 -0
  57. package/templates/src/home/api/migrations/0002__tenant.sql +3 -0
  58. package/templates/src/home/api/schema.yaml +2 -0
  59. package/templates/src/login/web/auth-provider.ts +48 -0
  60. package/templates/src/login/web/entry.ts +21 -1
  61. package/templates/src/login/web/locales/en-US.json +11 -1
  62. package/templates/src/login/web/locales/zh-CN.json +11 -1
  63. package/templates/src/login/web/pages/login.tsx +154 -25
  64. package/templates/src/login/web/wecom.ts +51 -0
  65. package/templates/src/notification/api/_shared/notify.ts +9 -5
  66. package/templates/src/notification/api/api.ts +25 -0
  67. package/templates/src/notification/api/del/api.ts +5 -0
  68. package/templates/src/notification/api/manifest.yaml +3 -0
  69. package/templates/src/notification/api/migrations/0005__tenant.mysql.sql +6 -0
  70. package/templates/src/notification/api/migrations/0005__tenant.sql +6 -0
  71. package/templates/src/notification/api/schema.yaml +4 -0
  72. package/templates/src/personal-center/api/profile/api.ts +13 -0
  73. package/templates/src/personal-center/api/upload/api.ts +8 -1
  74. package/templates/src/system/web/entry.ts +8 -0
  75. package/templates/src/system/web/locales/en-US.json +33 -2
  76. package/templates/src/system/web/locales/zh-CN.json +33 -2
  77. package/templates/src/system/web/pages/login-methods/index.tsx +2 -1
  78. package/templates/src/system/web/pages/users/index.tsx +1 -1
  79. package/templates/src/system/web/pages/wecom/index.tsx +205 -0
  80. package/templates/tests/fixture.ts +91 -11
  81. package/templates/tests/guard.spec.ts +9 -3
  82. package/templates/tests/lifecycle/deploy-smoke.spec.ts +8 -6
  83. package/templates/tests/lifecycle/installer.spec.ts +76 -0
  84. package/templates/tests/lifecycle/trust.spec.ts +1 -1
  85. package/templates/tests/query-utils.spec.ts +12 -9
  86. package/templates/tests/rbac-guard.spec.ts +18 -1
  87. package/templates/tests/tenant-access-cache.spec.ts +99 -0
  88. package/templates/tests/tenant-forgery.spec.ts +152 -0
  89. package/templates/tests/tenant-isolation.spec.ts +133 -0
  90. package/templates/tests/username-namespace.spec.ts +118 -0
  91. package/templates/tests/wecom-admin.spec.ts +386 -0
  92. package/templates/tests/wecom-connector.spec.ts +701 -0
  93. package/templates/tests/wecom-crypt.spec.ts +155 -0
  94. package/templates/tests/wecom-endpoints.spec.ts +468 -0
  95. package/templates/tests/wecom-landing.spec.ts +54 -0
  96. package/vendor/host-versions.json +3 -3
@@ -0,0 +1,243 @@
1
+ // 企微 API 客户端(代开发三级凭证链 + 网页授权换 userid)。
2
+ //
3
+ // 凭证链(devkit 未覆盖,见 docs/plans/20261030-1845-wecom-daikaifa-login-plan.md §5.4):
4
+ // suite_ticket(企微推) + suite_secret → suite_access_token(套件级,2h)
5
+ // suite_access_token + auth_code → permanent_code(= 代开发应用的 secret,属于 (suite, corp))
6
+ // permanent_code + corpid → corp access_token(**与自建应用同一个 gettoken 接口**,2h)
7
+ // corp access_token + code → userid(网页授权 snsapi_base,与自建应用同一个 auth/getuserinfo)
8
+ // 代开发与第三方应用的分叉点只有两处:取永久授权码走 `/service/v2/get_permanent_code`;
9
+ // 企业 token 走 `gettoken` 而不是 `service/get_corp_token`。
10
+ //
11
+ // 缓存:两段 token 都进 kv(oj 有 Redis 就跨进程,没有就进程内),TTL = expires_in-300。
12
+ // 失效:40001/42001 视为 token 失效 → 删缓存重取一次(企微侧提前失效是常态,不重试会把登录打成 502)。
13
+ // 错误两态红线(与 ldap-login 同):上游网络/协议/企微错误码 → 502(可重试语义);
14
+ // 用户凭据层面(code 无效/不在可见范围)→ 401(不区分原因,防探测)。
15
+
16
+ const API_BASE = "https://qyapi.weixin.qq.com";
17
+ const TIMEOUT_MS = 5000;
18
+ const TOKEN_SKEW_SECS = 300;
19
+
20
+ export interface WecomHttp {
21
+ getJson: (url: string) => Promise<Record<string, unknown>>
22
+ postJson: (url: string, body: unknown) => Promise<Record<string, unknown>>
23
+ }
24
+
25
+ export interface WecomCache {
26
+ get: (key: string) => Promise<string | null>
27
+ set: (key: string, value: string, ttlSecs: number) => Promise<unknown>
28
+ del: (key: string) => Promise<unknown>
29
+ }
30
+
31
+ export interface WecomClientDeps {
32
+ http: WecomHttp
33
+ cache: WecomCache
34
+ }
35
+
36
+ export function runtimeHttp(): WecomHttp {
37
+ async function call(url: string, init: RequestInit): Promise<Record<string, unknown>> {
38
+ let res: Response;
39
+ try {
40
+ res = await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
41
+ }
42
+ catch (e) {
43
+ throw upstream(`企微接口不可达:${(e as Error).message}`);
44
+ }
45
+ if (!res.ok)
46
+ throw upstream(`企微接口 HTTP ${res.status}`);
47
+ try {
48
+ return await res.json() as Record<string, unknown>;
49
+ }
50
+ catch {
51
+ throw upstream("企微接口返回非 JSON");
52
+ }
53
+ }
54
+ return {
55
+ getJson: url => call(url, { method: "GET" }),
56
+ postJson: (url, body) => call(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body ?? {}) }),
57
+ };
58
+ }
59
+
60
+ export function runtimeCache(): WecomCache {
61
+ return {
62
+ get: key => kv.get(key),
63
+ // kv 没有「带 TTL 的写」,只能写值再设过期——两步之间进程退出、或 expire 失败,
64
+ // 这个键就变成「永不过期」:corp secret 轮换后旧 token 仍被当作新鲜的,
65
+ // 表现是长时间 502 而日志一片安静。故显式兜底:设不上过期就删键(宁可每请求回源)。
66
+ set: async (key, value, ttlSecs) => {
67
+ await kv.set(key, value);
68
+ if (!(await kv.expire(key, ttlSecs))) {
69
+ log.warn("wecom token cached without ttl", "key", key);
70
+ await kv.del(key);
71
+ }
72
+ },
73
+ del: key => kv.del(key),
74
+ };
75
+ }
76
+
77
+ function upstream(message: string): Error {
78
+ return Object.assign(new Error(message), { status: 502 });
79
+ }
80
+
81
+ /** code 无效 / 不在应用可见范围 / 非企业成员:统一 401(不泄露具体原因,§5.3 枚举风险) */
82
+ function rejectCode(): never {
83
+ throw Object.assign(new Error("登录凭据无效或不在应用可见范围内"), { status: 401 });
84
+ }
85
+
86
+ /** 企微错误码 → 异常。`errcode` 缺失视为成功(get_permanent_code 的历史行为)。 */
87
+ function assertOk(res: Record<string, unknown>, what: string): void {
88
+ const code = res.errcode;
89
+ if (code == null || Number(code) === 0)
90
+ return;
91
+ throw upstream(`企微 ${what} 失败:errcode=${String(code)} ${String(res.errmsg ?? "")}`.trimEnd());
92
+ }
93
+
94
+ const TOKEN_EXPIRED = new Set([40001, 40014, 42001]);
95
+
96
+ export function createWecomClient(deps: WecomClientDeps) {
97
+ const { http, cache } = deps;
98
+
99
+ async function cachedToken(key: string, load: () => Promise<{ token: string, expiresIn: number }>): Promise<string> {
100
+ const hit = await cache.get(key);
101
+ if (hit)
102
+ return hit;
103
+ const { token, expiresIn } = await load();
104
+ const ttl = Math.max(60, Math.floor(expiresIn) - TOKEN_SKEW_SECS);
105
+ await cache.set(key, token, ttl);
106
+ return token;
107
+ }
108
+
109
+ /** 套件 access_token:需要 suite_ticket(过期/缺失由调用方先拦) */
110
+ async function suiteAccessToken(suiteId: string, suiteSecret: string, suiteTicket: string): Promise<string> {
111
+ return cachedToken(`wecom:suite_token:${suiteId}`, async () => {
112
+ const res = await http.postJson(`${API_BASE}/cgi-bin/service/get_suite_token`, {
113
+ suite_id: suiteId,
114
+ suite_secret: suiteSecret,
115
+ suite_ticket: suiteTicket,
116
+ });
117
+ assertOk(res, "get_suite_token");
118
+ if (!res.suite_access_token)
119
+ throw upstream("企微 get_suite_token 未返回 suite_access_token");
120
+ return { token: String(res.suite_access_token), expiresIn: Number(res.expires_in ?? 7200) };
121
+ });
122
+ }
123
+
124
+ /**
125
+ * auth_code → permanent_code(+ 企业信息 + 应用列表)。
126
+ * 代开发用 `/service/v2/get_permanent_code`:返回 is_customized_app=true,且**不返回 access_token**。
127
+ * auth_code 一次有效(10 分钟内),重放只会拿到企微错误码 → 502,由调用方丢弃本条事件。
128
+ */
129
+ async function permanentCodeByAuthCode(suiteAccessToken: string, authCode: string): Promise<{
130
+ permanentCode: string
131
+ corpId: string
132
+ corpName: string | null
133
+ agentIds: string[]
134
+ state: string | null
135
+ }> {
136
+ const res = await http.postJson(`${API_BASE}/cgi-bin/service/v2/get_permanent_code?suite_access_token=${encodeURIComponent(suiteAccessToken)}`, { auth_code: authCode });
137
+ assertOk(res, "get_permanent_code");
138
+ const authCorp = (res.auth_corp_info ?? {}) as Record<string, unknown>;
139
+ const agentList = ((res.auth_info ?? {}) as Record<string, unknown>).agent;
140
+ const agentIds = Array.isArray(agentList)
141
+ ? agentList.map(a => String((a as Record<string, unknown>).agentid)).filter(Boolean)
142
+ : [];
143
+ if (!res.permanent_code || !authCorp.corpid)
144
+ throw upstream("企微 get_permanent_code 返回缺少 permanent_code / corpid");
145
+ if (agentIds.length === 0)
146
+ throw upstream("企微 get_permanent_code 未返回授权应用(auth_info.agent 为空)");
147
+ return {
148
+ permanentCode: String(res.permanent_code),
149
+ corpId: String(authCorp.corpid),
150
+ corpName: authCorp.corp_name == null ? null : String(authCorp.corp_name),
151
+ agentIds,
152
+ state: res.state == null ? null : String(res.state),
153
+ };
154
+ }
155
+
156
+ /**
157
+ * 预授权码(开通入口用):套件级、10 分钟、一次有效,拼进 install URL 交给企业管理员点开。
158
+ * 与 permanent_code 无关——它只换「安装向导里那一屏同意」,真正的永久码仍由 create_auth 回调给。
159
+ */
160
+ async function preAuthCode(suiteAccessToken: string): Promise<{ code: string, expiresIn: number }> {
161
+ const res = await http.postJson(`${API_BASE}/cgi-bin/service/get_pre_auth_code?suite_access_token=${encodeURIComponent(suiteAccessToken)}`, {});
162
+ assertOk(res, "get_pre_auth_code");
163
+ if (!res.pre_auth_code)
164
+ throw upstream("企微 get_pre_auth_code 未返回 pre_auth_code");
165
+ return { code: String(res.pre_auth_code), expiresIn: Number(res.expires_in ?? 600) };
166
+ }
167
+
168
+ /** 企业 access_token(代开发 = 自建应用的 gettoken,corpsecret 就是 permanent_code) */
169
+ async function corpAccessToken(corpId: string, agentId: string, secret: string): Promise<string> {
170
+ return cachedToken(`wecom:corp_token:${corpId}:${agentId}`, async () => {
171
+ const res = await http.getJson(`${API_BASE}/cgi-bin/gettoken?corpid=${encodeURIComponent(corpId)}&corpsecret=${encodeURIComponent(secret)}`);
172
+ assertOk(res, "gettoken");
173
+ if (!res.access_token)
174
+ throw upstream("企微 gettoken 未返回 access_token");
175
+ return { token: String(res.access_token), expiresIn: Number(res.expires_in ?? 7200) };
176
+ });
177
+ }
178
+
179
+ async function invalidateCorpToken(corpId: string, agentId: string): Promise<void> {
180
+ await cache.del(`wecom:corp_token:${corpId}:${agentId}`);
181
+ }
182
+
183
+ /**
184
+ * 成员姓名(best-effort):只用于把 JIT 号的 `display_name` 从工号变成真名。
185
+ *
186
+ * 三类失败一律返回 null,绝不抛出:套件没有通讯录读权限(60011/48002)、成员不在可见范围、
187
+ * 上游抖动。补名是体验优化,不能因为拿不到名字把登录打成 5xx——
188
+ * 调用方拿到 null 就用工号兜底。
189
+ * token 复用 `userIdByCode` 刚取的那份缓存(同请求内通常 0 次额外换票)。
190
+ */
191
+ async function userNameById(corpId: string, agentId: string, secret: string, userid: string): Promise<string | null> {
192
+ for (let attempt = 0; attempt < 2; attempt++) {
193
+ let token: string;
194
+ try {
195
+ token = await corpAccessToken(corpId, agentId, secret);
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ let res: Record<string, unknown>;
201
+ try {
202
+ res = await http.getJson(`${API_BASE}/cgi-bin/user/get?access_token=${encodeURIComponent(token)}&userid=${encodeURIComponent(userid)}`);
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ const errcode = Number(res.errcode ?? 0);
208
+ if (TOKEN_EXPIRED.has(errcode) && attempt === 0) {
209
+ await invalidateCorpToken(corpId, agentId);
210
+ continue;
211
+ }
212
+ if (errcode !== 0)
213
+ return null;
214
+ const name = res.name == null ? "" : String(res.name).trim();
215
+ return name || null;
216
+ }
217
+ return null;
218
+ }
219
+
220
+ /** 网页授权 code → userid(snsapi_base;非企业成员/超出可见范围 → 401) */
221
+ async function userIdByCode(corpId: string, agentId: string, secret: string, code: string): Promise<string> {
222
+ for (let attempt = 0; attempt < 2; attempt++) {
223
+ const token = await corpAccessToken(corpId, agentId, secret);
224
+ const res = await http.getJson(`${API_BASE}/cgi-bin/auth/getuserinfo?access_token=${encodeURIComponent(token)}&code=${encodeURIComponent(code)}`);
225
+ const errcode = Number(res.errcode ?? 0);
226
+ if (TOKEN_EXPIRED.has(errcode) && attempt === 0) {
227
+ await invalidateCorpToken(corpId, agentId);
228
+ continue;
229
+ }
230
+ if (errcode !== 0)
231
+ rejectCode();
232
+ const userid = res.userid == null ? "" : String(res.userid);
233
+ if (!userid)
234
+ rejectCode(); // 非企业成员(外部联系人)时企微只给 openid,不认作本应用用户
235
+ return userid;
236
+ }
237
+ throw upstream("企微 token 反复失效(40001/42001)");
238
+ }
239
+
240
+ return { suiteAccessToken, permanentCodeByAuthCode, preAuthCode, corpAccessToken, invalidateCorpToken, userIdByCode, userNameById };
241
+ }
242
+
243
+ export type WecomClient = ReturnType<typeof createWecomClient>;
@@ -0,0 +1,176 @@
1
+ // 企微代开发「服务商侧」部署常量(config `vars:` 段,不进 DB、不进 sys_setting)。
2
+ //
3
+ // 多租户动态性边界:**套件(suite)是部署级的**——一个部署 = 一个服务商账号下的一套代开发模板
4
+ // (suite_id/suite_secret/callback token/EncodingAESKey 全在这套模板的后台里)。
5
+ // 「哪个企业装了哪个应用」不写配置:企微 create_auth 回调推 AuthCode → 换 permanent_code →
6
+ // 落 wecom_app_profile,新增企业只需企业管理员扫码授权,**不改配置、不重启**。
7
+ // 同一企业两个应用(测试/正式)也无需改配置:profile 主键含 agent_id(D5)。
8
+ //
9
+ // 与 ldap 同口径:启用开关走 sys_setting(`wecom.enabled`),凭据走部署常量。
10
+
11
+ import { b64ToBytes } from "./crypt";
12
+
13
+ /**
14
+ * 回调引导配置(Tier A):echostr URL 验证 + 永久码落库加密所需的全部凭据。
15
+ * 这四项**全部由部署方在本地生成**,与企业微信无关——所以可以在「创建代开发模版 / 拿到
16
+ * suite_id」之前就配齐并启动服务、响应企微的 URL 验证。这是打破「先有 suite 还是先有回调」
17
+ * 死锁的关键(见 plan §18):鸡生蛋问题里,这四项属于「蛋还没孵出来前就能自己备齐的料」。
18
+ */
19
+ export interface WecomCallbackConfig {
20
+ /** 回调签名 token(企微后台「Token」) */
21
+ token: string
22
+ /** 回调加密密钥(43 字符 EncodingAESKey) */
23
+ encodingAesKey: string
24
+ /** AES-GCM 落库密钥(hex/base64,16 或 32 字节):加密 permanent_code/secret */
25
+ encKey: string
26
+ /**
27
+ * 连接器基址(绝对地址,无尾斜杠),如 `https://site.example.com/api/auth/wecom`。
28
+ * 两个派生地址都由它拼出,避免「同一件事配两处、改一处漏一处」:
29
+ * · 指令回调 URL = `<base>/event` → **原样填进企微后台**(须逐字一致,含协议与路径)
30
+ * · 网页授权 redirect_uri = `<base>/callback`
31
+ */
32
+ baseUrl: string
33
+ }
34
+
35
+ /**
36
+ * 完整配置(Tier A + Tier B):login/callback/create_auth 等 suite 流程额外需要
37
+ * `suiteId` 与 `suiteSecret`——它们由企业微信在「代开发模版创建成功」后下发(模版详情页可见),
38
+ * 缺失只影响 suite 流程,不影响回调 URL 验证(后者只需 Tier A)。
39
+ */
40
+ export interface WecomConfig extends WecomCallbackConfig {
41
+ /** 代开发应用模版 id(企微侧即 suite_id),也是回调明文里的 receiveid */
42
+ suiteId: string
43
+ suiteSecret: string
44
+ }
45
+
46
+ // Tier A:部署方本地生成,创建模版前即可配齐(token / EncodingAESKey / 落库密钥 / 基址)。
47
+ const REQUIRED_CALLBACK: Array<[keyof WecomCallbackConfig, string]> = [
48
+ ["token", "wecom_callback_token"],
49
+ ["encodingAesKey", "wecom_encoding_aes_key"],
50
+ ["encKey", "wecom_enc_key"],
51
+ ["baseUrl", "wecom_base_url"],
52
+ ];
53
+
54
+ // Tier B:suite 凭据,企业微信在「代开发模版创建成功」后下发(模版详情页可查)。
55
+ const REQUIRED_SUITE: Array<[keyof WecomConfig, string]> = [
56
+ ["suiteId", "wecom_suite_id"],
57
+ ["suiteSecret", "wecom_suite_secret"],
58
+ ];
59
+
60
+ /** 一次性读出六处部署常量(未 trim 的尾斜杠在此归一) */
61
+ function readWecomVars(): WecomConfig {
62
+ return {
63
+ token: vars.get("wecom_callback_token") ?? "",
64
+ encodingAesKey: vars.get("wecom_encoding_aes_key") ?? "",
65
+ encKey: vars.get("wecom_enc_key") ?? "",
66
+ baseUrl: (vars.get("wecom_base_url") ?? "").replace(/\/+$/, ""),
67
+ suiteId: vars.get("wecom_suite_id") ?? "",
68
+ suiteSecret: vars.get("wecom_suite_secret") ?? "",
69
+ };
70
+ }
71
+
72
+ /**
73
+ * 落库密钥格式校验(fail-fast 在**读配置**处,而不是等 create_auth 时在 crypto 里炸):
74
+ * oj 的 aesGcmEncrypt 只收「16 或 32 字节的 hex/base64」,AES-192(24 字节)明确不支持。
75
+ * 最常见的配置事故是把 32 个「随意字符」当密钥——它按 base64 解出 24 字节,加密时才报错,
76
+ * 而那时已经是企业授权回调,排查成本最高。
77
+ */
78
+ export function assertEncKey(encKey: string): void {
79
+ const hex = /^[0-9a-f]+$/i.test(encKey) && [32, 64].includes(encKey.length);
80
+ let ok = hex;
81
+ if (!ok) {
82
+ try {
83
+ ok = [16, 32].includes(b64ToBytes(encKey).length);
84
+ }
85
+ catch {
86
+ ok = false;
87
+ }
88
+ }
89
+ if (!ok)
90
+ throw Object.assign(new Error("企业微信配置错误:wecom_enc_key 必须是 16 或 32 字节的 hex(32/64 字符)或 base64"), { status: 400 });
91
+ }
92
+
93
+ /**
94
+ * 读 Tier A 凭据(token / aeskey / encKey / baseUrl)。**不依赖 suite 凭据**:这是 echostr
95
+ * URL 验证与永久码落库加密所需的全部东西,部署方本地即可生成。引导期(suite_id 尚未下发)
96
+ * 服务也能靠它启动并响应企微的回调验证(plan §18)。
97
+ */
98
+ export function readCallbackConfig(): WecomCallbackConfig {
99
+ const v = readWecomVars();
100
+ const missing = REQUIRED_CALLBACK.filter(([field]) => !v[field]).map(([, name]) => name);
101
+ if (missing.length)
102
+ throw Object.assign(new Error(`企业微信未配置:config vars 缺少 ${missing.join(" / ")}`), { status: 400 });
103
+ if (!/^https?:\/\//.test(v.baseUrl))
104
+ throw Object.assign(new Error("企业微信配置错误:wecom_base_url 必须是绝对地址(企微回调与 redirect_uri 都要求)"), { status: 400 });
105
+ assertEncKey(v.encKey);
106
+ return { token: v.token, encodingAesKey: v.encodingAesKey, encKey: v.encKey, baseUrl: v.baseUrl };
107
+ }
108
+
109
+ /**
110
+ * 读完整部署常量。缺任一必需项即 400(「未配置」是运维事实,不是服务端缺陷)。
111
+ * 一次报全 Tier A + Tier B 所有缺失项,省掉逐轮补配置的往返;suite 凭据(Tier B)缺失时
112
+ * 文案点明「由代开发模版创建成功后下发」,避免运维误以为是本地漏配。
113
+ *
114
+ * 注:Tier B 缺失只影响 login/callback/create_auth 等 suite 流程;echostr 验证走
115
+ * `readCallbackConfig()`,不受此约束(见 api.ts::event.get)。
116
+ */
117
+ export function readWecomConfig(): WecomConfig {
118
+ const v = readWecomVars();
119
+ const missing = [...REQUIRED_CALLBACK, ...REQUIRED_SUITE].filter(([field]) => !v[field]).map(([, name]) => name);
120
+ if (missing.length)
121
+ throw Object.assign(new Error(`企业微信未配置:config vars 缺少 ${missing.join(" / ")}(suite 凭据由代开发模版创建成功后下发)`), { status: 400 });
122
+ if (!/^https?:\/\//.test(v.baseUrl))
123
+ throw Object.assign(new Error("企业微信配置错误:wecom_base_url 必须是绝对地址(企微回调与 redirect_uri 都要求)"), { status: 400 });
124
+ assertEncKey(v.encKey);
125
+ return v;
126
+ }
127
+
128
+ /** 企微后台「指令回调 URL」:原样填这个(base + /event) */
129
+ export function eventUrlOf(cfg: WecomConfig): string {
130
+ return `${cfg.baseUrl}/event`;
131
+ }
132
+
133
+ /** 网页授权 redirect_uri(base + /callback) */
134
+ export function loginRedirectOf(cfg: WecomConfig): string {
135
+ return `${cfg.baseUrl}/callback`;
136
+ }
137
+
138
+ /** 连接器在站点里的固定挂载后缀(模块 apiPrefix `/auth` + 平台 API 前缀 `/api`):`siteRootOf` 靠它剥离 */
139
+ const CONNECTOR_SUFFIX = "/api/auth/wecom";
140
+
141
+ /**
142
+ * 站点根(origin + 子路径前缀,无尾斜杠):302 回跳落到 SPA 时用。
143
+ *
144
+ * 不能只取 origin:子路径部署(oj `mounts: [{ prefix: /oj }]`)时 SPA 的登录页在 `/oj/login`,
145
+ * 回跳到 `/login` 会打到宿主机根的别的应用(或 404)。前缀不另配一个变量——它就等于
146
+ * `wecom_base_url` 去掉本连接器的固定后缀,配一处即可,且不匹配时**报错**而不是猜。
147
+ */
148
+ export function siteRootOf(cfg: WecomConfig): string {
149
+ const url = new URL(cfg.baseUrl);
150
+ const path = url.pathname.replace(/\/+$/, "");
151
+ if (!path.endsWith(CONNECTOR_SUFFIX))
152
+ throw Object.assign(new Error(`企业微信配置错误:wecom_base_url 必须以 ${CONNECTOR_SUFFIX} 结尾(如 https://site.example.com/oj${CONNECTOR_SUFFIX})`), { status: 400 });
153
+ return `${url.origin}${path.slice(0, -CONNECTOR_SUFFIX.length)}`;
154
+ }
155
+
156
+ /**
157
+ * 代开发「开通入口」:企业管理员点开 → 企微授权安装页。
158
+ * `redirect_uri` 落在站点根下的登录页(安装完回站点,指令回调已把 profile 落好)。
159
+ */
160
+ export function installUrlOf(cfg: WecomConfig, preAuthCode: string, state: string): string {
161
+ const redirect = `${siteRootOf(cfg)}/login?wecom_installed=1`;
162
+ const q = new URLSearchParams({
163
+ suite_id: cfg.suiteId,
164
+ pre_auth_code: preAuthCode,
165
+ redirect_uri: redirect,
166
+ });
167
+ if (state)
168
+ q.set("state", state);
169
+ return `https://open.work.weixin.qq.com/3rdapp/install?${q.toString()}`;
170
+ }
171
+
172
+ /** 登录方式发现用:只看开关,不读凭据(登录页要先知道「有没有这个方式」) */
173
+ export async function wecomEnabled(): Promise<boolean> {
174
+ const rows = await db.table("sys_setting").select(["value"]).where({ field: "key", op: "eq", value: "wecom.enabled" }).all() as Array<{ value: string | null }>;
175
+ return rows[0]?.value === "1";
176
+ }