@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
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // permanent_code(= 代开发应用的 secret,全 corp 数据权限)只以 AES-GCM 密文落库(§7.5),
9
9
  // 加解密经 deps 注入:Node 侧测试用假实现,运行时走 crypto.aesGcmEncrypt/Decrypt。
10
- import type { WecomConfig } from "./config";
10
+ import type { WecomBase, WecomKind } from "./config";
11
11
 
12
12
  export interface WecomStoreDeps {
13
13
  encrypt: (plain: string) => string
@@ -26,6 +26,13 @@ export interface AppProfile {
26
26
  env: string
27
27
  corpName: string | null
28
28
  status: "active" | "disabled"
29
+ /**
30
+ * 这一行的凭据来源(0003 列)。**不是标签,是语义**:
31
+ * · suite 行由 create_auth 写入,permanent_code 里是代开发换来的 secret;
32
+ * · corp 行由「验证并接入」写入,里面是应用详情页的 Secret(suite_id 为空串)。
33
+ * 同一 corp_id 两行共存时,靠它决定「取消代开发授权该停哪一行」。
34
+ */
35
+ credentialKind: WecomKind
29
36
  permanentCode: string
30
37
  /** 0=管理员授权 / 1=成员授权(成员授权下应用无任何部门权限,组织架构同步必然 60011,§2.3) */
31
38
  authMode: number
@@ -92,6 +99,30 @@ export function corpAgentScope(corpId: string, agentId: string) {
92
99
  };
93
100
  }
94
101
 
102
+ /**
103
+ * 企业范围 + 可选的来源过滤。
104
+ *
105
+ * 共存部署里**同一家**企业可能同时有代开发行与自建行(迁移期最常见),而「取消代开发授权」
106
+ * 「复核授权模式」「找 secret」这三件事都只对 suite 行成立。不传 kind = 不过滤(保持
107
+ * 单来源部署的原行为,`suite_id` 主键天然只有一类行)。
108
+ */
109
+ function corpKindScope(corpId: string, kind?: WecomKind) {
110
+ const base = corpScope(corpId);
111
+ if (!kind)
112
+ return base;
113
+ return {
114
+ and: [
115
+ base,
116
+ { field: "credential_kind", op: "eq", value: kind },
117
+ ],
118
+ };
119
+ }
120
+
121
+ /** 行 → 来源(库里的列是 text;只有明确写了 corp 才算自建,其余按代开发) */
122
+ function kindOfRow(r: Record<string, unknown>): WecomKind {
123
+ return String(r.credential_kind) === "corp" ? "corp" : "suite";
124
+ }
125
+
95
126
  /**
96
127
  * 吊销某租户的全部会话(refresh token)。
97
128
  *
@@ -121,6 +152,9 @@ export async function saveProfile(p: Omit<AppProfile, "status"> & { status?: App
121
152
  env: p.env,
122
153
  corp_name: p.corpName,
123
154
  status: p.status ?? "active",
155
+ // 来源同时落在两处:主键里的 suite_id(corp 行为空串)与这一列(可读、可过滤)。
156
+ // 更新路径也写:存量 suite 行被 corp 接入覆盖时(人工导错)不该留着旧标注。
157
+ credential_kind: p.credentialKind,
124
158
  permanent_code_enc: deps.encrypt(p.permanentCode),
125
159
  // 授权模式是「该企业应用能不能做组织架构同步」的判据(§2.3);重授权时必须跟着刷新
126
160
  auth_mode: Number(p.authMode ?? 0),
@@ -146,9 +180,12 @@ export async function saveProfile(p: Omit<AppProfile, "status"> & { status?: App
146
180
  /**
147
181
  * 按企业代码找应用。`agentId` 为空时要求该企业只有一个启用中的应用。
148
182
  * 「企业不存在」与「已停用」返回**同一条文案**(§5.3 枚举风险:不让外部探测哪些企业装过)。
183
+ *
184
+ * `agentId` 已唯一确定一行(主键第三段),所以 `kind` 只是**加一层保险**:
185
+ * 事件路径知道自己验签过的是哪套凭据,带上它就不会在同名 agent 撞车时取错 secret。
149
186
  */
150
- export async function findProfile(corpId: string, agentId: string | null, deps: WecomStoreDeps): Promise<AppProfile> {
151
- const rows = await db.table("wecom_app_profile").select(["*"]).where(corpScope(corpId)).all() as Array<Record<string, unknown>>;
187
+ export async function findProfile(corpId: string, agentId: string | null, deps: WecomStoreDeps, kind?: WecomKind): Promise<AppProfile> {
188
+ const rows = await db.table("wecom_app_profile").select(["*"]).where(corpKindScope(corpId, kind)).all() as Array<Record<string, unknown>>;
152
189
  const active = rows.filter(r => String(r.status) === "active");
153
190
  const picked = agentId ? active.filter(r => String(r.agent_id) === agentId) : active;
154
191
  if (picked.length === 0)
@@ -157,13 +194,14 @@ export async function findProfile(corpId: string, agentId: string | null, deps:
157
194
  fail(400, "该企业开通了多个应用,登录链接必须带 agent 参数");
158
195
  const r = picked[0]!;
159
196
  return {
160
- suiteId: String(r.suite_id),
197
+ suiteId: String(r.suite_id ?? ""),
161
198
  corpId: String(r.corp_id),
162
199
  authCorpId: r.auth_corp_id == null ? String(r.corp_id) : String(r.auth_corp_id),
163
200
  agentId: String(r.agent_id),
164
201
  env: r.env == null ? "prod" : String(r.env),
165
202
  corpName: r.corp_name == null ? null : String(r.corp_name),
166
203
  status: String(r.status) === "disabled" ? "disabled" : "active",
204
+ credentialKind: kindOfRow(r),
167
205
  permanentCode: deps.decrypt(String(r.permanent_code_enc)),
168
206
  authMode: Number(r.auth_mode ?? 0),
169
207
  };
@@ -174,6 +212,8 @@ export interface ProfileProbe {
174
212
  agentId: string
175
213
  authMode: number
176
214
  status: AppProfile["status"]
215
+ /** 来源:corp 行没有「授权模式」概念,页面不该给它画成员授权卡片 */
216
+ credentialKind: WecomKind
177
217
  }
178
218
 
179
219
  /**
@@ -186,8 +226,8 @@ export interface ProfileProbe {
186
226
  * 选中口径与 `findProfile` 对齐(优选启用中的应用;全停用时退回停用行,让页面能画
187
227
  * 「已取消授权」冻结态),只多一条:没有启用应用时也返回,而不是 400。
188
228
  */
189
- export async function probeProfile(corpId: string, agentId: string | null): Promise<ProfileProbe | null> {
190
- const rows = await db.table("wecom_app_profile").select(["agent_id", "auth_mode", "status"]).where(corpScope(corpId)).all() as Array<Record<string, unknown>>;
229
+ export async function probeProfile(corpId: string, agentId: string | null, kind?: WecomKind): Promise<ProfileProbe | null> {
230
+ const rows = await db.table("wecom_app_profile").select(["agent_id", "auth_mode", "status", "credential_kind"]).where(corpKindScope(corpId, kind)).all() as Array<Record<string, unknown>>;
191
231
  if (rows.length === 0)
192
232
  return null;
193
233
  const active = rows.filter(r => String(r.status) === "active");
@@ -202,12 +242,17 @@ export async function probeProfile(corpId: string, agentId: string | null): Prom
202
242
  agentId: String(r.agent_id),
203
243
  authMode: Number(r.auth_mode ?? 0),
204
244
  status: String(r.status) === "disabled" ? "disabled" : "active",
245
+ credentialKind: kindOfRow(r),
205
246
  };
206
247
  }
207
248
 
208
- /** 停用/解绑:只改状态,不删行(取消授权也要留痕,§7.5 审计) */
209
- export async function setProfileStatus(corpId: string, status: AppProfile["status"], deps: WecomStoreDeps): Promise<number> {
210
- return Number(await db.table("wecom_app_profile").update({ status, update_time: deps.now() }).where(corpScope(corpId)).run());
249
+ /**
250
+ * 停用/解绑:只改状态,不删行(取消授权也要留痕,§7.5 审计)。
251
+ * `kind` 缺省 = 该企业的全部行(单来源部署的原行为);传 `suite` 才是 cancel_auth 该做的——
252
+ * 代开发被取消授权时不能顺手把同一家企业的自建应用一起停掉。
253
+ */
254
+ export async function setProfileStatus(corpId: string, status: AppProfile["status"], deps: WecomStoreDeps, kind?: WecomKind): Promise<number> {
255
+ return Number(await db.table("wecom_app_profile").update({ status, update_time: deps.now() }).where(corpKindScope(corpId, kind)).run());
211
256
  }
212
257
 
213
258
  /**
@@ -217,15 +262,19 @@ export async function setProfileStatus(corpId: string, status: AppProfile["statu
217
262
  *
218
263
  * 一并返回 `corpId`(真实企业 ID):事件里给的可能是 `AuthCorpId`(第三方体系下是 open_corpid),
219
264
  * 而租户键只能用真实 corp_id(D10),故由这里统一把「事件身份 → 租户身份」的翻译交出来。
265
+ *
266
+ * `kind` 由**事件入口**传下来(本次验签命中的是哪套凭据):同一家企业双应用时,
267
+ * 取错 kind 就会拿另一套 secret 去换 token,症状是 40001 而不是「配置错了」。
220
268
  */
221
- export async function findCorpAuth(corpId: string, deps: WecomStoreDeps): Promise<{ corpId: string, permanentCode: string, agentIds: string[] } | null> {
222
- const rows = await db.table("wecom_app_profile").select(["corp_id", "agent_id", "permanent_code_enc"]).where(corpScope(corpId)).all() as Array<Record<string, unknown>>;
269
+ export async function findCorpAuth(corpId: string, deps: WecomStoreDeps, kind?: WecomKind): Promise<{ corpId: string, permanentCode: string, agentIds: string[], credentialKind: WecomKind } | null> {
270
+ const rows = await db.table("wecom_app_profile").select(["corp_id", "agent_id", "permanent_code_enc", "credential_kind"]).where(corpKindScope(corpId, kind)).all() as Array<Record<string, unknown>>;
223
271
  if (rows.length === 0)
224
272
  return null;
225
273
  return {
226
274
  corpId: String(rows[0]!.corp_id),
227
275
  permanentCode: deps.decrypt(String(rows[0]!.permanent_code_enc)),
228
276
  agentIds: rows.map(r => String(r.agent_id)),
277
+ credentialKind: kindOfRow(rows[0]!),
229
278
  };
230
279
  }
231
280
 
@@ -286,7 +335,7 @@ export async function claimLoginTicket(ticketHash: string, deps: WecomStoreDeps)
286
335
  }
287
336
 
288
337
  /** 运行时依赖:密钥取自部署常量,加密原语取自 oj 运行时全局 */
289
- export function runtimeStoreDeps(cfg: WecomConfig): WecomStoreDeps {
338
+ export function runtimeStoreDeps(cfg: WecomBase): WecomStoreDeps {
290
339
  return {
291
340
  encrypt: (plain: string) => crypto.aesGcmEncrypt(plain, cfg.encKey),
292
341
  decrypt: (blob: string) => crypto.aesGcmDecrypt(blob, cfg.encKey),
@@ -15,6 +15,7 @@
15
15
  // 致命类计入熔断(连续 FATAL_LIMIT 次 → job failed);
16
16
  // · 错误落库只存脱敏结构(`{errcode, phase, deptId, at}`):异常原文里可能夹带 URL 上的 token。
17
17
  import type { WecomClient, WecomMember } from "./client";
18
+ import type { WecomKind } from "./config";
18
19
  import type { ProfileProbe } from "./store";
19
20
 
20
21
  import { markSeen, reconcileHidden, upsertDepartment } from "#/_platform/_shared/service/domain/org";
@@ -75,10 +76,17 @@ export interface SyncJobView {
75
76
  export interface SyncView extends SyncJobView {
76
77
  /** 本站是否装过企微(有授权记录,含已停用)——false 时页面整条不显示 */
77
78
  connected: boolean
78
- /** 0=管理员授权 / 1=成员授权(1 时任何通讯录调用都是 60011,同步入口直接拒绝) */
79
+ /** 0=管理员授权 / 1=成员授权(1 时任何通讯录调用都是 60011,同步入口直接拒绝);自建行恒 0 */
79
80
  authMode: number
80
81
  /** 授权记录的应用状态;disabled = 已取消授权/被停用,同步冻结但数据保留(§7 黄条) */
81
82
  profileStatus: "active" | "disabled" | null
83
+ /**
84
+ * 本次解析到的应用 id:**多应用/共存企业的分辨锚点**。
85
+ * 前端 start / cancel / status 必须原样回传,否则同一家企业两行档案时后端只能 400「必须带 agent」。
86
+ */
87
+ agentId: string | null
88
+ /** 凭据来源:corp 行没有「授权模式」概念,页面不该给它画成员授权卡片 */
89
+ credentialKind: WecomKind | null
82
90
  }
83
91
 
84
92
  export interface SyncDeps {
@@ -191,6 +199,8 @@ export function attachProfile(view: SyncJobView, probe: ProfileProbe | null): Sy
191
199
  connected: probe !== null,
192
200
  authMode: probe?.authMode ?? 0,
193
201
  profileStatus: probe?.status ?? null,
202
+ agentId: probe?.agentId ?? null,
203
+ credentialKind: probe?.credentialKind ?? null,
194
204
  };
195
205
  }
196
206
 
@@ -0,0 +1,3 @@
1
+ -- 应用来源(0003)mysql 方言覆盖(方言覆盖必胜,通用兜底;oj-bin docs/migration.md §3.1)。
2
+ -- 差异:MySQL 的 TEXT 不能带 DEFAULT,故收窄为 VARCHAR(16)。
3
+ ALTER TABLE wecom_app_profile ADD COLUMN credential_kind VARCHAR(16) NOT NULL DEFAULT 'suite';
@@ -0,0 +1,3 @@
1
+ -- 应用来源(0003)pg 方言覆盖(方言覆盖必胜,通用兜底;oj-bin docs/migration.md §3.1)。
2
+ -- 差异:TEXT 在 pg 合法且可带 DEFAULT,与通用文件同形。
3
+ ALTER TABLE wecom_app_profile ADD COLUMN credential_kind TEXT NOT NULL DEFAULT 'suite';
@@ -0,0 +1,9 @@
1
+ -- 应用来源(0003):suite = 代开发(服务商模板授权) / corp = 企业内部应用(自建应用)。
2
+ --
3
+ -- 为什么必须有这一列,而不是靠 `suite_id = ''` 推断:
4
+ -- · 查询要按来源筛(取消代开发授权不能顺手停掉同一家企业自建应用的档案)、后台要按来源展示;
5
+ -- · 空串是「主键里塞了一个隐式语义」,任何一次手写 SQL 漏判都会把两类行混在一起;
6
+ -- · 将来若做「同一企业双应用」,区分只能靠列,不能靠约定。
7
+ --
8
+ -- 现有行全部是代开发授权产生的,故 DEFAULT 'suite' 即正确的历史归属。
9
+ ALTER TABLE wecom_app_profile ADD COLUMN credential_kind TEXT NOT NULL DEFAULT 'suite';
@@ -23,6 +23,8 @@ tables:
23
23
  status: {type: text, null: false}
24
24
  # permanent_code(= 代开发应用的 secret)的 AES-GCM 密文,禁止明文落库
25
25
  permanent_code_enc: {type: text, null: false}
26
+ # 应用来源(0003):suite=代开发 / corp=企业内部应用(自建)。取消代开发授权只停 suite 行
27
+ credential_kind: {type: text, null: false}
26
28
  # 0=管理员授权 / 1=成员授权(0002):成员授权下应用无任何部门权限,组织架构同步必然 60011
27
29
  auth_mode: {type: integer, null: false}
28
30
  installed_at: {type: integer, null: false}
@@ -1,17 +1,20 @@
1
- // 代开发后台(服务商运营面):GET /auth/wecom/admin/apps · POST /auth/wecom/admin/status · GET /auth/wecom/admin/install
1
+ // 企微后台:GET /auth/wecom/admin/apps · POST /auth/wecom/admin/status · GET /auth/wecom/admin/install
2
+ // · GET /auth/wecom/admin/overview · POST /auth/wecom/admin/connect
2
3
  //
3
- // 权限:仅超级管理员(`assertSuperAdmin`)。这三件事都是**跨租户**的站点级操作
4
- // (看所有已授权企业、停用某企业、生成安装链接),不是某个租户的管理员能做的;
5
- // 用 `assertSystemAdmin` 会把 admin 也放进来,而 admin 属于某个租户,等于让他看别家企业的 corp_id。
4
+ // 权限:仅超级管理员(`assertSuperAdmin`)。这几件事都是**跨租户**的站点级操作
5
+ // (看所有已授权企业、停用某企业、生成安装链接、把本企业自建应用接进来),不是某个租户的
6
+ // 管理员能做的;用 `assertSystemAdmin` 会把 admin 也放进来,而 admin 属于某个租户,
7
+ // 等于让他看别家企业的 corp_id。
6
8
  //
7
- // 三个端点都不挂在 `tenant.anonymous_paths` 里:它们是正常登录后的管理面,必须带租户头,
9
+ // 这些端点都不挂在 `tenant.anonymous_paths` 里:它们是正常登录后的管理面,必须带租户头,
8
10
  // 也就不会走 `db.asTenant`(见 _shared/wecom/admin.ts 头注释第 2 点)。
9
11
  import { respond } from "#/_platform/_shared/respond";
10
12
  import { assertSuperAdmin } from "#/_platform/_shared/service/infra/access";
11
13
  import { recordOp } from "#/_platform/_shared/service/infra/audit";
12
- import { createInstall, listApps, setAppStatus } from "../../_shared/wecom/admin";
14
+ import { connectCorp, createInstall, listApps, setAppStatus } from "../../_shared/wecom/admin";
13
15
  import { createWecomClient, runtimeCache, runtimeHttp } from "../../_shared/wecom/client";
14
- import { readWecomConfig } from "../../_shared/wecom/config";
16
+ import { configuredKinds, corpIdentity, eventUrlOf, readBase, readCorpConfig, readSuiteConfig, wecomKinds } from "../../_shared/wecom/config";
17
+ import { runtimeStoreDeps } from "../../_shared/wecom/store";
15
18
 
16
19
  /** 清单:已授权企业 × 应用(不含 permanent_code,secret 不出库) */
17
20
  async function get() {
@@ -24,7 +27,7 @@ get.response = {
24
27
  type: "array",
25
28
  items: {
26
29
  type: "object",
27
- required: ["suite_id", "corp_id", "agent_id", "auth_corp_id", "env", "status", "auth_mode", "installed_at", "update_time"],
30
+ required: ["suite_id", "corp_id", "agent_id", "auth_corp_id", "env", "status", "credential_kind", "auth_mode", "installed_at", "update_time"],
28
31
  properties: {
29
32
  suite_id: { type: "string" },
30
33
  corp_id: { type: "string" },
@@ -33,7 +36,9 @@ get.response = {
33
36
  env: { type: "string" },
34
37
  corp_name: { type: ["string", "null"] },
35
38
  status: { type: "string", enum: ["active", "disabled"] },
36
- // 权限三态(管理员授权 / 成员授权)——服务商一眼看出哪家企业「装了但同步不了」
39
+ // 来源:代开发 / 企业内部应用(自建)。同一家企业可能两行都有(迁移期)
40
+ credential_kind: { type: "string", enum: ["suite", "corp"] },
41
+ // 权限三态(管理员授权 / 成员授权)——服务商一眼看出哪家企业「装了但同步不了」;自建行恒 0
37
42
  auth_mode: { type: "number" },
38
43
  installed_at: { type: "number" },
39
44
  update_time: { type: "number" },
@@ -41,6 +46,62 @@ get.response = {
41
46
  },
42
47
  };
43
48
 
49
+ const overview = {
50
+ /**
51
+ * 部署概览:后台页据此决定画哪几张卡片、回调地址填什么。
52
+ *
53
+ * `configError` 而不是直接 400:配错时最需要页面把「配了什么、缺什么」摆出来,
54
+ * 抛错只会让管理员看到一片空白加一行红字。
55
+ *
56
+ * 形态必须是 `{ get }` 组而不是裸函数:oj 的路由表按「组名 = 路径段 + 动词」生成,
57
+ * 根层裸函数不产生路由(`ojm api --check` 会报「oj 树缺少 handler」)。
58
+ */
59
+ async get() {
60
+ await respond(async () => {
61
+ await assertSuperAdmin();
62
+ let configError: string | null = null;
63
+ try {
64
+ wecomKinds();
65
+ }
66
+ catch (e) {
67
+ configError = (e as Error).message;
68
+ }
69
+ let callbackUrl: string | null = null;
70
+ try {
71
+ callbackUrl = eventUrlOf(readBase());
72
+ }
73
+ catch {
74
+ // 基址没配:页面要能把「还差 wecom_base_url」显示出来,而不是整页失败
75
+ callbackUrl = null;
76
+ }
77
+ const self = corpIdentity();
78
+ return {
79
+ kinds: configuredKinds(),
80
+ configError,
81
+ callbackUrl,
82
+ corpId: self?.corpId ?? null,
83
+ agentId: self?.agentId ?? null,
84
+ corpCallbackConfigured: self?.hasCallback ?? false,
85
+ };
86
+ });
87
+ },
88
+ };
89
+ overview.get.response = {
90
+ type: "object",
91
+ required: ["kinds", "configError", "callbackUrl", "corpId", "agentId", "corpCallbackConfigured"],
92
+ properties: {
93
+ kinds: { type: "array", items: { type: "string", enum: ["suite", "corp"] } },
94
+ // 部署配置自检的结论(null = 无问题):共存没显式开启、两套都缺等
95
+ configError: { type: ["string", "null"] },
96
+ // 回调地址:代开发填「指令回调」,自建填「通讯录同步 → 接收事件服务器」,同一个 URL
97
+ callbackUrl: { type: ["string", "null"] },
98
+ corpId: { type: ["string", "null"] },
99
+ agentId: { type: ["string", "null"] },
100
+ // 自建应用的回调凭据是否配齐(不配 = 没有事件增量,全量同步照常)
101
+ corpCallbackConfigured: { type: "boolean" },
102
+ },
103
+ };
104
+
44
105
  const status = {
45
106
  // 启停某个企业的一个应用。停用是安全动作:企业整体停用时同步吊销其会话(见 admin.ts)。
46
107
  async post() {
@@ -100,7 +161,7 @@ const install = {
100
161
  async get() {
101
162
  await respond(async () => {
102
163
  await assertSuperAdmin();
103
- const cfg = readWecomConfig();
164
+ const cfg = readSuiteConfig();
104
165
  const state = String((http.query as Record<string, unknown>)?.state ?? "").trim();
105
166
  return createInstall(
106
167
  cfg,
@@ -120,4 +181,44 @@ install.get.response = {
120
181
  },
121
182
  };
122
183
 
123
- export default { apps: { get }, status, install };
184
+ const connect = {
185
+ // 自建应用没有 `create_auth` 回调:档案行只能由这里显式写入(四步校验全过才落库,见 admin.ts)。
186
+ // 幂等:重复点 = 重写密文 + 清 token 缓存——**换过 Secret 必须重新点**,页面要把这句写出来。
187
+ // 没配 corp 凭据的部署点这里直接 400:不写「空 suite_id 的怪档案」,那会让清单多一行查不出用途的行。
188
+ async post() {
189
+ await respond(async () => {
190
+ await assertSuperAdmin();
191
+ if (!configuredKinds().includes("corp"))
192
+ throw Object.assign(new Error("本部署未配置企业内部应用凭据(wecom_corp_id / wecom_corp_secret / wecom_corp_agent_id)"), { status: 400 });
193
+ const cfg = readCorpConfig();
194
+ const result = await connectCorp(
195
+ cfg,
196
+ createWecomClient({ http: runtimeHttp(), cache: runtimeCache() }),
197
+ runtimeStoreDeps(cfg),
198
+ );
199
+ await recordOp({
200
+ action: "wecom.admin.connect",
201
+ module: "auth",
202
+ target: result.corpId,
203
+ detail: { agentId: result.agentId, departments: result.departments, members: result.members },
204
+ // 接入 = 建立一条新的身份来源,属站点级配置变更
205
+ level: 2,
206
+ });
207
+ return result;
208
+ });
209
+ },
210
+ };
211
+ connect.post.response = {
212
+ type: "object",
213
+ required: ["corpId", "agentId", "departments", "members"],
214
+ properties: {
215
+ corpId: { type: "string" },
216
+ agentId: { type: "string" },
217
+ // 可见范围内的部门数:远小于预期 = 应用可见范围没设全公司
218
+ departments: { type: "number" },
219
+ // 示例部门读到的人数:0 不会返回(校验失败),>0 才是「通讯录权限真的通了」
220
+ members: { type: "number" },
221
+ },
222
+ };
223
+
224
+ export default { apps: { get }, overview, status, install, connect };