@oj-module/cli 0.2.49 → 0.2.50

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 (38) hide show
  1. package/devkit/README.md +8 -6
  2. package/devkit/SKILL.md +32 -12
  3. package/devkit/manual.md +6 -3
  4. package/devkit/wecom-daikaifa.md +95 -1
  5. package/devkit/wecom-internal-app.md +643 -0
  6. package/package.json +2 -2
  7. package/shell-dist/assets/runtime.js +2 -2
  8. package/shell-dist/index.html +2 -2
  9. package/shell-dist/versions.json +3 -3
  10. package/src/init.ts +5 -4
  11. package/templates/config.yaml +28 -6
  12. package/templates/openapi.json +174 -2
  13. package/templates/src/auth/api/_shared/wecom/admin.ts +87 -7
  14. package/templates/src/auth/api/_shared/wecom/config.ts +256 -46
  15. package/templates/src/auth/api/_shared/wecom/contacts.ts +7 -1
  16. package/templates/src/auth/api/_shared/wecom/events.ts +25 -6
  17. package/templates/src/auth/api/_shared/wecom/login.ts +21 -13
  18. package/templates/src/auth/api/_shared/wecom/store.ts +61 -12
  19. package/templates/src/auth/api/_shared/wecom/sync.ts +11 -1
  20. package/templates/src/auth/api/migrations/0003__credential_kind.mysql.sql +3 -0
  21. package/templates/src/auth/api/migrations/0003__credential_kind.pg.sql +3 -0
  22. package/templates/src/auth/api/migrations/0003__credential_kind.sql +9 -0
  23. package/templates/src/auth/api/schema.yaml +2 -0
  24. package/templates/src/auth/api/wecom/admin/api.ts +112 -11
  25. package/templates/src/auth/api/wecom/api.ts +102 -48
  26. package/templates/src/auth/api/wecom/sync/api.ts +21 -5
  27. package/templates/src/auth/web/__client/api.schemas.ts +22 -1
  28. package/templates/src/auth/web/__client/api.ts +47 -1
  29. package/templates/src/system/web/locales/en-US.json +24 -1
  30. package/templates/src/system/web/locales/zh-CN.json +24 -1
  31. package/templates/src/system/web/pages/departments/sync-bar.tsx +32 -7
  32. package/templates/src/system/web/pages/wecom/index.tsx +164 -46
  33. package/templates/tests/wecom-admin.spec.ts +7 -7
  34. package/templates/tests/wecom-connector.spec.ts +7 -6
  35. package/templates/tests/wecom-corp.spec.ts +523 -0
  36. package/templates/tests/wecom-endpoints.spec.ts +10 -4
  37. package/templates/tests/wecom-org-sync.spec.ts +7 -1
  38. package/vendor/host-versions.json +3 -3
@@ -18,6 +18,10 @@ interface SyncView {
18
18
  connected: boolean
19
19
  authMode: number
20
20
  profileStatus: "active" | "disabled" | null
21
+ /** 已解析到的应用 id:多应用/共存企业的分辨锚点,start / cancel 要原样回传 */
22
+ agentId: string | null
23
+ /** suite=代开发 / corp=企业内部应用(自建):自建应用没有「授权模式」这回事 */
24
+ credentialKind: "suite" | "corp" | null
21
25
  status: "idle" | "running" | "done" | "failed" | "cancelled"
22
26
  phase: "departments" | "members" | "reconcile"
23
27
  stats: { departments: number, members: number, skipped: number, reconciled: boolean }
@@ -36,25 +40,45 @@ const MIN_GAP_MS = 6000;
36
40
  /** 一轮点击最多推多少片(12 秒/片 ≈ 12 分钟):兜底防呆,正常企业两三次就到底 */
37
41
  const MAX_SLICES = 60;
38
42
 
43
+ /**
44
+ * 已解析到的应用 id。同一家企业有两行档案(代开发 + 自建,迁移期常见)时后端要求带 agent,
45
+ * 而这个值只有后端知道:首次 status 不带(后端按「本站自建应用优先」解析),拿到后记住,
46
+ * 后续 start / cancel 原样回传。放模块级是为了跨组件实例复用,不参与渲染。
47
+ */
48
+ let agentParam: string | null = null;
49
+
50
+ function withAgent(url: string): string {
51
+ return agentParam ? `${url}?agent=${encodeURIComponent(agentParam)}` : url;
52
+ }
53
+
54
+ function agentBody(): { agent?: string } {
55
+ return agentParam ? { agent: agentParam } : {};
56
+ }
57
+
58
+ function remember(view: SyncView): SyncView {
59
+ agentParam = view.agentId ?? agentParam;
60
+ return view;
61
+ }
62
+
39
63
  async function fetchStatus(): Promise<SyncView> {
40
- const env = await request.get("auth/wecom/sync/status").json<{ code: number, data?: SyncView }>();
64
+ const env = await request.get(withAgent("auth/wecom/sync/status")).json<{ code: number, data?: SyncView }>();
41
65
  if (!env.data)
42
66
  throw new Error("企业微信同步状态读取失败");
43
- return env.data;
67
+ return remember(env.data);
44
68
  }
45
69
 
46
70
  async function pushSlice(): Promise<SyncView> {
47
- const env = await request.post("auth/wecom/sync/start", { json: {} }).json<{ code: number, data?: SyncView }>();
71
+ const env = await request.post("auth/wecom/sync/start", { json: agentBody() }).json<{ code: number, data?: SyncView }>();
48
72
  if (!env.data)
49
73
  throw new Error("企业微信同步未能启动");
50
- return env.data;
74
+ return remember(env.data);
51
75
  }
52
76
 
53
77
  async function cancelSync(): Promise<SyncView> {
54
- const env = await request.post("auth/wecom/sync/cancel", { json: {} }).json<{ code: number, data?: SyncView }>();
78
+ const env = await request.post("auth/wecom/sync/cancel", { json: agentBody() }).json<{ code: number, data?: SyncView }>();
55
79
  if (!env.data)
56
80
  throw new Error("取消同步失败");
57
- return env.data;
81
+ return remember(env.data);
58
82
  }
59
83
 
60
84
  function sleep(ms: number): Promise<void> {
@@ -180,7 +204,8 @@ export function WecomSyncBar({ onFinished }: { onFinished: () => void }) {
180
204
  if (!view || !view.connected)
181
205
  return null;
182
206
 
183
- if (view.authMode !== 0) {
207
+ // 成员授权卡片只对代开发成立:自建应用没有「授权模式」,authMode 恒 0(双保险,防上游写脏数据)
208
+ if (view.credentialKind !== "corp" && view.authMode !== 0) {
184
209
  return (
185
210
  <Alert
186
211
  type="warning"
@@ -1,6 +1,6 @@
1
1
  import { request, useUserStore } from "@oj-module/runtime";
2
2
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
- import { App, Button, Card, Descriptions, Popconfirm, QRCode, Space, Table, Tag, Typography } from "antd";
3
+ import { Alert, App, Button, Card, Descriptions, Popconfirm, QRCode, Space, Table, Tag, Typography } from "antd";
4
4
  import { useEffect, useState } from "react";
5
5
  import { useTranslation } from "react-i18next";
6
6
 
@@ -16,7 +16,9 @@ interface AppRow {
16
16
  env: string
17
17
  corp_name: string | null
18
18
  status: "active" | "disabled"
19
- /** 0=管理员授权 / 1=成员授权:成员授权下这家企业的应用做不了组织架构同步 */
19
+ /** 应用来源:suite=代开发 / corp=企业内部应用(自建) */
20
+ credential_kind: "suite" | "corp"
21
+ /** 0=管理员授权 / 1=成员授权:成员授权下这家企业的应用做不了组织架构同步;自建行恒 0 */
20
22
  auth_mode: number
21
23
  installed_at: number
22
24
  update_time: number
@@ -28,6 +30,23 @@ interface InstallInfo {
28
30
  expiresIn: number
29
31
  }
30
32
 
33
+ /** GET /auth/wecom/admin/overview:本部署配了哪几套凭据、回调地址填什么 */
34
+ interface Overview {
35
+ kinds: Array<"suite" | "corp">
36
+ configError: string | null
37
+ callbackUrl: string | null
38
+ corpId: string | null
39
+ agentId: string | null
40
+ corpCallbackConfigured: boolean
41
+ }
42
+
43
+ interface ConnectResult {
44
+ corpId: string
45
+ agentId: string
46
+ departments: number
47
+ members: number
48
+ }
49
+
31
50
  /**
32
51
  * 端点不在本模块的生成 client 里(`__client` 按模块切分),故直接用 runtime 的 ky 出口。
33
52
  * 路径不带前导 `/`:与 request 的 baseURL 拼接口径一致(fetchLogin 同款)。
@@ -37,6 +56,11 @@ async function fetchApps(): Promise<AppRow[]> {
37
56
  return env.data ?? [];
38
57
  }
39
58
 
59
+ async function fetchOverview(): Promise<Overview> {
60
+ const env = await request.get("auth/wecom/admin/overview").json<{ code: number, data?: Overview }>();
61
+ return env.data ?? { kinds: [], configError: null, callbackUrl: null, corpId: null, agentId: null, corpCallbackConfigured: false };
62
+ }
63
+
40
64
  async function setStatus(input: { corpId: string, agentId: string, status: "active" | "disabled" }): Promise<{ updated: number, revokedSessions: number }> {
41
65
  const env = await request.post("auth/wecom/admin/status", { json: input }).json<{ code: number, data?: { updated: number, revokedSessions: number } }>();
42
66
  return env.data ?? { updated: 0, revokedSessions: 0 };
@@ -49,12 +73,19 @@ async function createInstall(): Promise<InstallInfo> {
49
73
  return env.data;
50
74
  }
51
75
 
76
+ async function connectCorp(): Promise<ConnectResult> {
77
+ const env = await request.post("auth/wecom/admin/connect", { json: {} }).json<{ code: number, data?: ConnectResult }>();
78
+ if (!env.data)
79
+ throw new Error("企业微信未返回接入结果");
80
+ return env.data;
81
+ }
82
+
52
83
  /**
53
- * 企微代开发后台(服务商运营面,superadmin 独占):
54
- * · 已授权企业 × 应用清单(同一企业测试/正式两个应用各一行);
55
- * · 启停(停用企业最后一个应用时后端同时吊销该企业会话);
56
- * · 开通入口(点「生成开通链接」→ 后端调企微 get_customized_auth_url 换 qrcode_url,
57
- * 企业管理员扫/开即授权,授权成功由 create_auth 回调落库)。
84
+ * 企微后台(superadmin 独占),两块卡片按「本部署配了哪套凭据」决定显不显示:
85
+ * · 代开发:已授权企业 × 应用清单、启停、开通入口(点「生成开通链接」→ 后端调
86
+ * get_customized_auth_url 换 qrcode_url,企业管理员扫/开即授权,由 create_auth 回调落库);
87
+ * · 企业内部应用(自建):本企业的那一份凭据 + 回调地址 + 「验证并接入」。
88
+ * 两套可以同时启用(`wecom_allow_coexist: 'true'`),此时两张卡片并列,清单里按「来源」区分。
58
89
  */
59
90
  export default function WecomAdminPage() {
60
91
  const { t } = useTranslation();
@@ -83,6 +114,14 @@ export default function WecomAdminPage() {
83
114
  retry: false,
84
115
  });
85
116
 
117
+ // 概览读的是**部署常量**而不是库:配置错的时候清单可能是空的,而这里必须还能说清「缺什么」
118
+ const overviewQuery = useQuery({
119
+ queryKey: ["system", "wecom-overview"],
120
+ queryFn: fetchOverview,
121
+ enabled: isSuper,
122
+ retry: false,
123
+ });
124
+
86
125
  const statusMut = useMutation({
87
126
  mutationFn: setStatus,
88
127
  onSuccess: (r) => {
@@ -106,9 +145,24 @@ export default function WecomAdminPage() {
106
145
  onError: e => message.error(explainError(e, t)),
107
146
  });
108
147
 
148
+ const connectMut = useMutation({
149
+ mutationFn: connectCorp,
150
+ onSuccess: (r) => {
151
+ message.success(t("system:wecom.corp.connectOk", { depts: r.departments, members: r.members }));
152
+ queryClient.invalidateQueries({ queryKey: ["system", "wecom-apps"] });
153
+ queryClient.invalidateQueries({ queryKey: ["system", "wecom-overview"] });
154
+ },
155
+ onError: e => message.error(explainError(e, t)),
156
+ });
157
+
109
158
  if (!isSuper)
110
159
  return <ForbiddenPanel t={t} />;
111
160
 
161
+ const overview = overviewQuery.data;
162
+ const kinds = overview?.kinds ?? [];
163
+ const hasSuite = kinds.includes("suite");
164
+ const hasCorp = kinds.includes("corp");
165
+
112
166
  const columns = [
113
167
  {
114
168
  title: t("system:wecom.col.corp"),
@@ -120,16 +174,26 @@ export default function WecomAdminPage() {
120
174
  </div>
121
175
  ),
122
176
  },
177
+ {
178
+ // 来源列:共存部署里清单会同时出现两类行,不标出来就分不清「这行是谁装进来的」
179
+ title: t("system:wecom.col.source"),
180
+ dataIndex: "credential_kind",
181
+ render: (v: AppRow["credential_kind"]) => (v === "corp"
182
+ ? <Tag color="blue">{t("system:wecom.source.corp")}</Tag>
183
+ : <Tag>{t("system:wecom.source.suite")}</Tag>),
184
+ },
123
185
  { title: t("system:wecom.col.agent"), dataIndex: "agent_id" },
124
186
  { title: t("system:wecom.col.env"), dataIndex: "env", render: (v: string) => <Tag>{v}</Tag> },
125
187
  {
126
188
  // 权限三态:成员授权是「装了也同步不了」的硬约束(企微侧通讯录接口一律 60011),
127
- // 摆在清单上,服务商不用点进去试一遍才知道
189
+ // 摆在清单上,服务商不用点进去试一遍才知道。自建应用没有这个概念,显示空值。
128
190
  title: t("system:wecom.col.authMode"),
129
191
  dataIndex: "auth_mode",
130
- render: (v: number) => (v === 0
131
- ? <Tag color="green">{t("system:wecom.authModeAdmin")}</Tag>
132
- : <Tag color="orange">{t("system:wecom.authModeMember")}</Tag>),
192
+ render: (v: number, row: AppRow) => (row.credential_kind === "corp"
193
+ ? <Typography.Text type="secondary">{t("system:wecom.authModeNone")}</Typography.Text>
194
+ : v === 0
195
+ ? <Tag color="green">{t("system:wecom.authModeAdmin")}</Tag>
196
+ : <Tag color="orange">{t("system:wecom.authModeMember")}</Tag>),
133
197
  },
134
198
  {
135
199
  title: t("system:wecom.col.status"),
@@ -166,41 +230,95 @@ export default function WecomAdminPage() {
166
230
 
167
231
  return (
168
232
  <Space direction="vertical" size="middle" style={{ width: "100%" }}>
169
- <Card
170
- title={t("system:wecom.title")}
171
- extra={(
172
- <Button type="primary" loading={installMut.isPending} onClick={() => installMut.mutate()}>
173
- {t("system:wecom.genInstall")}
174
- </Button>
175
- )}
176
- >
177
- <Typography.Paragraph type="secondary">{t("system:wecom.hint")}</Typography.Paragraph>
178
- {install && (
179
- <div style={{ display: "flex", gap: 24, alignItems: "flex-start", flexWrap: "wrap" }}>
180
- {/* 二维码用 antd 内置 QRCode(同一份共享 antd,不额外引二维码依赖):
181
- 企业管理员多半在手机上收通知,扫码比复制链接快 */}
182
- <QRCode
183
- value={install.installUrl}
184
- size={160}
185
- type="svg"
186
- bordered
187
- status={expired ? "expired" : "active"}
188
- onRefresh={() => installMut.mutate()}
189
- />
190
- <Descriptions column={1} size="small" bordered style={{ flex: 1, minWidth: 320 }}>
191
- <Descriptions.Item label={t("system:wecom.installUrl")}>
192
- <Typography.Text copyable={{ text: install.installUrl }}>{install.installUrl}</Typography.Text>
193
- </Descriptions.Item>
194
- <Descriptions.Item label={t("system:wecom.callbackUrl")}>
195
- <Typography.Text copyable={{ text: install.callbackUrl }}>{install.callbackUrl}</Typography.Text>
196
- </Descriptions.Item>
197
- <Descriptions.Item label={t("system:wecom.expiresIn")}>
198
- {t("system:wecom.expiresValue", { count: Math.round(install.expiresIn / 60) })}
199
- </Descriptions.Item>
200
- </Descriptions>
201
- </div>
202
- )}
203
- </Card>
233
+ {overview?.configError && (
234
+ <Alert
235
+ type="error"
236
+ showIcon
237
+ message={t("system:wecom.configErrorTitle")}
238
+ description={overview.configError}
239
+ />
240
+ )}
241
+ {hasSuite && hasCorp && (
242
+ <Alert type="info" showIcon message={t("system:wecom.coexistHint")} />
243
+ )}
244
+ {hasSuite && (
245
+ <Card
246
+ title={t("system:wecom.title")}
247
+ extra={(
248
+ <Button type="primary" loading={installMut.isPending} onClick={() => installMut.mutate()}>
249
+ {t("system:wecom.genInstall")}
250
+ </Button>
251
+ )}
252
+ >
253
+ <Typography.Paragraph type="secondary">{t("system:wecom.hint")}</Typography.Paragraph>
254
+ {install && (
255
+ <div style={{ display: "flex", gap: 24, alignItems: "flex-start", flexWrap: "wrap" }}>
256
+ {/* 二维码用 antd 内置 QRCode(同一份共享 antd,不额外引二维码依赖):
257
+ 企业管理员多半在手机上收通知,扫码比复制链接快 */}
258
+ <QRCode
259
+ value={install.installUrl}
260
+ size={160}
261
+ type="svg"
262
+ bordered
263
+ status={expired ? "expired" : "active"}
264
+ onRefresh={() => installMut.mutate()}
265
+ />
266
+ <Descriptions column={1} size="small" bordered style={{ flex: 1, minWidth: 320 }}>
267
+ <Descriptions.Item label={t("system:wecom.installUrl")}>
268
+ <Typography.Text copyable={{ text: install.installUrl }}>{install.installUrl}</Typography.Text>
269
+ </Descriptions.Item>
270
+ <Descriptions.Item label={t("system:wecom.callbackUrl")}>
271
+ <Typography.Text copyable={{ text: install.callbackUrl }}>{install.callbackUrl}</Typography.Text>
272
+ </Descriptions.Item>
273
+ <Descriptions.Item label={t("system:wecom.expiresIn")}>
274
+ {t("system:wecom.expiresValue", { count: Math.round(install.expiresIn / 60) })}
275
+ </Descriptions.Item>
276
+ </Descriptions>
277
+ </div>
278
+ )}
279
+ </Card>
280
+ )}
281
+ {hasCorp && (
282
+ <Card
283
+ title={t("system:wecom.corp.title")}
284
+ extra={(
285
+ <Popconfirm
286
+ title={t("system:wecom.corp.connectConfirm")}
287
+ onConfirm={() => connectMut.mutate()}
288
+ >
289
+ <Button type="primary" loading={connectMut.isPending}>
290
+ {t("system:wecom.corp.connect")}
291
+ </Button>
292
+ </Popconfirm>
293
+ )}
294
+ >
295
+ <Typography.Paragraph type="secondary">{t("system:wecom.corp.hint")}</Typography.Paragraph>
296
+ <Descriptions column={1} size="small" bordered>
297
+ <Descriptions.Item label={t("system:wecom.corp.corpId")}>
298
+ <Typography.Text>{overview?.corpId ?? "-"}</Typography.Text>
299
+ </Descriptions.Item>
300
+ <Descriptions.Item label={t("system:wecom.corp.agentId")}>
301
+ <Typography.Text>{overview?.agentId ?? "-"}</Typography.Text>
302
+ </Descriptions.Item>
303
+ <Descriptions.Item label={t("system:wecom.corp.callbackUrl")}>
304
+ {overview?.callbackUrl
305
+ ? <Typography.Text copyable={{ text: overview.callbackUrl }}>{overview.callbackUrl}</Typography.Text>
306
+ : "-"}
307
+ </Descriptions.Item>
308
+ <Descriptions.Item label={t("system:wecom.corp.callbackConfigured")}>
309
+ {overview?.corpCallbackConfigured
310
+ ? <Tag color="green">{t("system:wecom.corp.callbackYes")}</Tag>
311
+ : <Tag color="orange">{t("system:wecom.corp.callbackNo")}</Tag>}
312
+ </Descriptions.Item>
313
+ </Descriptions>
314
+ <Typography.Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}>
315
+ {t("system:wecom.corp.callbackHint")}
316
+ </Typography.Paragraph>
317
+ <Typography.Paragraph type="warning" style={{ marginBottom: 0 }}>
318
+ {t("system:wecom.corp.connectHint")}
319
+ </Typography.Paragraph>
320
+ </Card>
321
+ )}
204
322
  <Card title={t("system:wecom.appsTitle")}>
205
323
  <Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
206
324
  {t("system:wecom.authModeHint")}
@@ -15,7 +15,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
15
15
  import { assertSuperAdmin, invalidateAllUsers } from "../src/_platform/api/_shared/service/infra/access";
16
16
  import { listApps, setAppStatus } from "../src/auth/api/_shared/wecom/admin";
17
17
  import { createWecomClient, runtimeCache, runtimeHttp } from "../src/auth/api/_shared/wecom/client";
18
- import { readWecomConfig } from "../src/auth/api/_shared/wecom/config";
18
+ import { readSuiteConfig } from "../src/auth/api/_shared/wecom/config";
19
19
  import { installDb } from "./fixture";
20
20
 
21
21
  const g = globalThis as any;
@@ -123,7 +123,7 @@ function fakeClient(): { client: any, invalidated: string[] } {
123
123
  const deps = { store: { encrypt: (p: string) => `gcm(${p})`, decrypt: (b: string) => b, now: () => NOW } };
124
124
 
125
125
  /** createInstall 的薄包装:省掉动态 import 的样板 */
126
- async function createInstallForTest(cfg: ReturnType<typeof readWecomConfig>, client: ReturnType<typeof createWecomClient>, state: string) {
126
+ async function createInstallForTest(cfg: ReturnType<typeof readSuiteConfig>, client: ReturnType<typeof createWecomClient>, state: string) {
127
127
  const { createInstall } = await import("../src/auth/api/_shared/wecom/admin");
128
128
  return createInstall(cfg, client, state);
129
129
  }
@@ -274,7 +274,7 @@ describe("P3-C:开通入口(createInstall,代开发 get_customized_auth_ur
274
274
 
275
275
  it("provider_token → get_customized_auth_url → qrcode_url(即开通链接),且绝不调 get_pre_auth_code", async () => {
276
276
  const { calls, bodies } = stubUpstream();
277
- const cfg = readWecomConfig();
277
+ const cfg = readSuiteConfig();
278
278
  const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
279
279
 
280
280
  const r = await createInstallForTest(cfg, client, "st_1");
@@ -297,7 +297,7 @@ describe("P3-C:开通入口(createInstall,代开发 get_customized_auth_ur
297
297
 
298
298
  it("调用方没传 state 时仍发默认 state(避免 40058 state missing)", async () => {
299
299
  const { bodies } = stubUpstream();
300
- const cfg = readWecomConfig();
300
+ const cfg = readSuiteConfig();
301
301
  const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
302
302
  await createInstallForTest(cfg, client, "");
303
303
  const body = bodies.find(b => b && b.templateid_list) as any;
@@ -307,14 +307,14 @@ describe("P3-C:开通入口(createInstall,代开发 get_customized_auth_ur
307
307
 
308
308
  it("缺 provider 凭证(wecom_provider_id / wecom_provider_secret)→ 400 并点名缺哪两个 vars", async () => {
309
309
  installVars({ wecom_provider_id: "", wecom_provider_secret: "" });
310
- const cfg = readWecomConfig();
310
+ const cfg = readSuiteConfig();
311
311
  const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
312
312
  await expect(createInstallForTest(cfg, client, "")).rejects.toMatchObject({ status: 400 });
313
313
  });
314
314
 
315
315
  it("上游 errcode(如 provider_secret 错)→ 502(可重试语义,不是 500)", async () => {
316
316
  g.fetch = async () => ({ ok: true, json: async () => ({ errcode: 40001, errmsg: "invalid provider secret" }) });
317
- const cfg = readWecomConfig();
317
+ const cfg = readSuiteConfig();
318
318
  const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
319
319
  await expect(createInstallForTest(cfg, client, "")).rejects.toMatchObject({ status: 502 });
320
320
  });
@@ -330,7 +330,7 @@ describe("P3-C:开通入口(createInstall,代开发 get_customized_auth_ur
330
330
  throw new Error(`stub fetch 未预期:${url}`);
331
331
  },
332
332
  });
333
- const cfg = readWecomConfig();
333
+ const cfg = readSuiteConfig();
334
334
  const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
335
335
  await expect(createInstallForTest(cfg, client, "st_1")).rejects.toMatchObject({
336
336
  status: 502,
@@ -21,7 +21,7 @@ import {
21
21
  eventUrlOf,
22
22
  loginRedirectOf,
23
23
  readCallbackConfig,
24
- readWecomConfig,
24
+ readSuiteConfig,
25
25
  siteRootOf,
26
26
  } from "../src/auth/api/_shared/wecom/config";
27
27
  import { envOfState, handleSuiteEvent } from "../src/auth/api/_shared/wecom/events";
@@ -105,6 +105,7 @@ function profile(suiteCorpAgent: { corp?: string, agent?: string, env?: string,
105
105
  agentId: suiteCorpAgent.agent ?? "100001",
106
106
  env: suiteCorpAgent.env ?? "prod",
107
107
  corpName: "测试企业",
108
+ credentialKind: "suite" as const,
108
109
  permanentCode: suiteCorpAgent.code ?? "perm_a",
109
110
  };
110
111
  }
@@ -757,7 +758,7 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
757
758
  installVars({ wecom_suite_id: "", wecom_callback_token: "", wecom_enc_key: "" });
758
759
  const msg = (() => {
759
760
  try {
760
- readWecomConfig();
761
+ readSuiteConfig();
761
762
  return "";
762
763
  }
763
764
  catch (e) {
@@ -789,7 +790,7 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
789
790
 
790
791
  it("baseUrl 去尾斜杠后拼出两个企微侧地址(回调 URL 要原样填进后台)", () => {
791
792
  installVars();
792
- const cfg = readWecomConfig();
793
+ const cfg = readSuiteConfig();
793
794
  expect(cfg.baseUrl).toBe("https://site.example.com/api/auth/wecom");
794
795
  // 不带 callback_format=json:官方文档只给「对外收款」写了这个参数,代开发的通用回调配置只有 XML
795
796
  expect(eventUrlOf(cfg)).toBe("https://site.example.com/api/auth/wecom/event");
@@ -799,7 +800,7 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
799
800
 
800
801
  it("子路径部署:站点根保留子路径(302 回跳必须落 /oj/login,不是 /login)", () => {
801
802
  installVars({ wecom_base_url: "https://site.example.com/oj/api/auth/wecom" });
802
- const cfg = readWecomConfig();
803
+ const cfg = readSuiteConfig();
803
804
  expect(siteRootOf(cfg)).toBe("https://site.example.com/oj");
804
805
  expect(loginLandingUrl({ origin: siteRootOf(cfg), ticket: "tk", tenant: "ww_corp_a" }))
805
806
  .toBe("https://site.example.com/oj/login?wecom_ticket=tk&tenant=ww_corp_a");
@@ -807,12 +808,12 @@ describe("P1-J:部署常量读取(缺项一次报全 / 密钥格式 fail-fas
807
808
 
808
809
  it("baseUrl 后缀不对 → 400(宁可拒,也不猜站点根拼出错误回跳)", () => {
809
810
  installVars({ wecom_base_url: "https://site.example.com/api/wecom" });
810
- expect(() => siteRootOf(readWecomConfig())).toThrow(/必须以 \/api\/auth\/wecom 结尾/);
811
+ expect(() => siteRootOf(readSuiteConfig())).toThrow(/必须以 \/api\/auth\/wecom 结尾/);
811
812
  });
812
813
 
813
814
  it("baseUrl 非绝对地址 → 400(相对路径会让 redirect_uri 与回调 URL 双双失效)", () => {
814
815
  installVars({ wecom_base_url: "/api/auth/wecom" });
815
- expect(() => readWecomConfig()).toThrow(/绝对地址/);
816
+ expect(() => readSuiteConfig()).toThrow(/绝对地址/);
816
817
  });
817
818
 
818
819
  it("wecom_enc_key 只收 16/32 字节的 hex 或 base64(AES-192 会在加密时才炸,故在读配置处拦住)", () => {