@oj-module/cli 0.2.48 → 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 (45) hide show
  1. package/devkit/README.md +9 -7
  2. package/devkit/SKILL.md +53 -14
  3. package/devkit/manual.md +203 -3
  4. package/devkit/wecom-daikaifa.md +100 -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/__tests__/build-backend.test.ts +80 -0
  11. package/src/build.ts +50 -0
  12. package/src/init.ts +5 -4
  13. package/templates/config.yaml +28 -6
  14. package/templates/openapi.json +174 -2
  15. package/templates/src/auth/api/_shared/wecom/admin.ts +87 -7
  16. package/templates/src/auth/api/_shared/wecom/client.ts +42 -6
  17. package/templates/src/auth/api/_shared/wecom/config.ts +256 -46
  18. package/templates/src/auth/api/_shared/wecom/contacts.ts +7 -1
  19. package/templates/src/auth/api/_shared/wecom/events.ts +25 -6
  20. package/templates/src/auth/api/_shared/wecom/login.ts +44 -4
  21. package/templates/src/auth/api/_shared/wecom/replay.ts +44 -3
  22. package/templates/src/auth/api/_shared/wecom/store.ts +61 -12
  23. package/templates/src/auth/api/_shared/wecom/sync.ts +25 -1
  24. package/templates/src/auth/api/migrations/0003__credential_kind.mysql.sql +3 -0
  25. package/templates/src/auth/api/migrations/0003__credential_kind.pg.sql +3 -0
  26. package/templates/src/auth/api/migrations/0003__credential_kind.sql +9 -0
  27. package/templates/src/auth/api/schema.yaml +2 -0
  28. package/templates/src/auth/api/wecom/admin/api.ts +112 -11
  29. package/templates/src/auth/api/wecom/api.ts +151 -59
  30. package/templates/src/auth/api/wecom/sync/api.ts +21 -5
  31. package/templates/src/auth/web/__client/api.schemas.ts +22 -1
  32. package/templates/src/auth/web/__client/api.ts +47 -1
  33. package/templates/src/login/web/locales/en-US.json +2 -0
  34. package/templates/src/login/web/locales/zh-CN.json +2 -0
  35. package/templates/src/login/web/pages/login.tsx +39 -3
  36. package/templates/src/system/web/locales/en-US.json +24 -1
  37. package/templates/src/system/web/locales/zh-CN.json +24 -1
  38. package/templates/src/system/web/pages/departments/sync-bar.tsx +32 -7
  39. package/templates/src/system/web/pages/wecom/index.tsx +164 -46
  40. package/templates/tests/wecom-admin.spec.ts +7 -7
  41. package/templates/tests/wecom-connector.spec.ts +38 -8
  42. package/templates/tests/wecom-corp.spec.ts +523 -0
  43. package/templates/tests/wecom-endpoints.spec.ts +73 -7
  44. package/templates/tests/wecom-org-sync.spec.ts +7 -1
  45. package/vendor/host-versions.json +3 -3
@@ -66,8 +66,13 @@ export default function LoginPage() {
66
66
  const current = useTenantStore.getState().tenant;
67
67
  return current === DEFAULT_TENANT ? "" : current;
68
68
  });
69
- // 企微 tab 的企业代码(优先用已持久化的值,单租户的 default 不预填)
70
- const [corp, setCorp] = useState(() => tenant);
69
+ // 企微深链入口:应用「主页」配成 /login?corp=<corpId>&agent=<agentId>,
70
+ // 成员从企微内点开即带参到达——据此预填企业代码/应用,并由下方 effect 自动发起授权。
71
+ const urlCorp = new URLSearchParams(search).get("corp")?.trim() ?? "";
72
+ const urlAgent = new URLSearchParams(search).get("agent")?.trim() ?? "";
73
+ // 企微 tab 的企业代码(优先用 URL 深链带的,其次已持久化的值,单租户的 default 不预填)
74
+ const [corp, setCorp] = useState(() => urlCorp || tenant);
75
+ const [agent, setAgent] = useState(() => urlAgent);
71
76
 
72
77
  // 企微 302 落地参数:只在首次渲染解析一次(ticket 一次性,解析结果必须稳定)
73
78
  const [landing] = useState(() => parseWecomLanding(search));
@@ -110,6 +115,23 @@ export default function LoginPage() {
110
115
  })();
111
116
  }, [landing, login, navigate, search]);
112
117
 
118
+ // 企微深链自动入口:带 ?corp= 到达(且非回跳落地 / 非出错)且企微方式可用时,
119
+ // 直接整页发起授权——「点开即登录」,无需手动点「企业微信」tab。
120
+ const autoFiredRef = useRef(false);
121
+ useEffect(() => {
122
+ const c = new URLSearchParams(search).get("corp")?.trim() ?? "";
123
+ // 企微内(wxwork UA)从「应用主页」点开即自动发起授权;带 ?corp= 的深链同样自动发起。
124
+ const inWecom = typeof navigator !== "undefined" && /wxwork/i.test(navigator.userAgent);
125
+ if ((!c && !inWecom) || landing || autoFiredRef.current)
126
+ return;
127
+ if (new URLSearchParams(search).get("error"))
128
+ return;
129
+ if (!methods.includes("wecom"))
130
+ return;
131
+ autoFiredRef.current = true;
132
+ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp, agent)}`);
133
+ }, [landing, methods, search, corp, agent]);
134
+
113
135
  async function onFinish(values: LoginFormValues, method: LoginMethod) {
114
136
  setLoading(true);
115
137
  setError(undefined);
@@ -144,7 +166,7 @@ export default function LoginPage() {
144
166
  // 必须拼 API base(部署期可为 /api 或网关前缀);拼站点 base 只会落到
145
167
  // SPA 兜底页——点击后页面原地重载、一个 auth 请求都不发。
146
168
  // 企业代码不在这里写 store——只有服务端回跳带的 tenant 才是权威值。
147
- window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
169
+ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp, agent)}`);
148
170
  }
149
171
 
150
172
  const showTabs = methods.length > 1;
@@ -180,6 +202,7 @@ export default function LoginPage() {
180
202
  // 受控值不走 antd 表单(Form.Item 无 name),故显式给 id/htmlFor:
181
203
  // 少了这层关联,标签只是视觉文本,读屏与 getByLabel 都取不到输入框。
182
204
  const wecomCorpId = "wecom-corp";
205
+ const wecomAgentId = "wecom-agent";
183
206
  const wecomPanel = (
184
207
  <Form layout="vertical" onFinish={wecomLogin}>
185
208
  <Form.Item
@@ -195,6 +218,19 @@ export default function LoginPage() {
195
218
  onChange={e => setCorp(e.target.value)}
196
219
  />
197
220
  </Form.Item>
221
+ <Form.Item
222
+ label={t("login:method.wecom.agent")}
223
+ htmlFor={wecomAgentId}
224
+ extra={t("login:method.wecom.agentHint")}
225
+ >
226
+ <Input
227
+ id={wecomAgentId}
228
+ autoComplete="off"
229
+ placeholder="1000..."
230
+ value={agent}
231
+ onChange={e => setAgent(e.target.value)}
232
+ />
233
+ </Form.Item>
198
234
  <Button type="primary" htmlType="submit" block>
199
235
  {t("login:method.wecom.action")}
200
236
  </Button>
@@ -141,7 +141,8 @@
141
141
  "status": "Status",
142
142
  "installedAt": "Onboarded at",
143
143
  "action": "Actions",
144
- "authMode": "Auth mode"
144
+ "authMode": "Auth mode",
145
+ "source": "Source"
145
146
  },
146
147
  "statusActive": "Active",
147
148
  "statusDisabled": "Disabled",
@@ -205,6 +206,28 @@
205
206
  "hint": "Contact the service provider to investigate, then retry."
206
207
  }
207
208
  }
209
+ },
210
+ "source": {
211
+ "suite": "Delegated",
212
+ "corp": "Internal app"
213
+ },
214
+ "authModeNone": "—",
215
+ "configErrorTitle": "Deployment config check failed",
216
+ "coexistHint": "This deployment runs both delegated (suite) and internal (corp) apps (wecom_allow_coexist: 'true').",
217
+ "corp": {
218
+ "title": "Internal app (self-built)",
219
+ "hint": "This deployment connects as an internal app: credentials come from config.yaml (wecom_corp_id / wecom_corp_secret / wecom_corp_agent_id). Fill them in, then click \"Verify and connect\"; the corporation is registered only after all three checks pass.",
220
+ "corpId": "Corporation ID",
221
+ "agentId": "AgentId",
222
+ "callbackUrl": "Contacts event callback URL",
223
+ "callbackHint": "Fill this URL into WeCom → Tools → Contacts sync → Set event receiver URL. Leave the app's own message callback empty: the two use different tokens, and pointing both at one URL makes half the payloads fail signature verification.",
224
+ "callbackConfigured": "Contacts event callback credentials",
225
+ "callbackYes": "Configured (member changes sync in real time)",
226
+ "callbackNo": "Not configured (full sync only; to get increments, fill Token / EncodingAESKey for this URL in the WeCom console)",
227
+ "connect": "Verify and connect",
228
+ "connectConfirm": "Connect checks: whether the app Secret can fetch a token, whether contacts are readable, and whether any member is visible. Re-run it whenever the Corporation ID / AgentId changed or the Secret was rotated.",
229
+ "connectOk": "Connected: {{depts}} departments visible, {{members}} members in the sample department",
230
+ "connectHint": "After rotating the app Secret you must click \"Verify and connect\" again, otherwise every call fails with 40001. How many people get synced depends on the app's visible scope — set it to the whole company for a full sync."
208
231
  }
209
232
  },
210
233
  "err": {
@@ -141,7 +141,8 @@
141
141
  "status": "状态",
142
142
  "installedAt": "开通时间",
143
143
  "action": "操作",
144
- "authMode": "授权模式"
144
+ "authMode": "授权模式",
145
+ "source": "来源"
145
146
  },
146
147
  "statusActive": "启用",
147
148
  "statusDisabled": "停用",
@@ -205,6 +206,28 @@
205
206
  "hint": "请联系服务商排查后重试。"
206
207
  }
207
208
  }
209
+ },
210
+ "source": {
211
+ "suite": "代开发",
212
+ "corp": "企业内部应用"
213
+ },
214
+ "authModeNone": "—",
215
+ "configErrorTitle": "部署配置自检未通过",
216
+ "coexistHint": "本部署同时启用了代开发与企业内部应用(wecom_allow_coexist: 'true')。",
217
+ "corp": {
218
+ "title": "企业内部应用(自建)",
219
+ "hint": "本部署作为企业内部应用接入:凭据来自 config.yaml 的 wecom_corp_id / wecom_corp_secret / wecom_corp_agent_id。填好后点「验证并接入」,三步校验全过才会登记企业。",
220
+ "corpId": "企业ID",
221
+ "agentId": "AgentId",
222
+ "callbackUrl": "通讯录事件回调地址",
223
+ "callbackHint": "把上面这个地址填到企微「管理工具 → 通讯录同步 → 设置接收事件服务器」;应用自身的「接收消息」回调请留空——两处 Token 不同,填同一个 URL 会让一半报文验签失败。",
224
+ "callbackConfigured": "通讯录事件回调凭据",
225
+ "callbackYes": "已配置(成员变更会实时同步)",
226
+ "callbackNo": "未配置(只做全量同步;要增量请在企微后台填 Token / EncodingAESKey 到同一地址)",
227
+ "connect": "验证并接入",
228
+ "connectConfirm": "接入会依次校验:应用 Secret 能否换 token、能否读通讯录、可见范围内能否读到人。改过企业ID / AgentId 或换过 Secret 都必须重新点一次。",
229
+ "connectOk": "接入成功:可见范围内 {{depts}} 个部门,示例部门 {{members}} 人",
230
+ "connectHint": "换过应用 Secret 后必须重新「验证并接入」,否则所有调用都会 40001;同步到多少人取决于「应用可见范围」,要全量请设为全公司。"
208
231
  }
209
232
  },
210
233
  "err": {
@@ -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,