@oj-module/cli 0.2.49 → 0.2.53

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 (75) 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 +750 -0
  6. package/package.json +2 -2
  7. package/shell-dist/assets/{index-FYt9CjFd.js → index-e_Mp2mb5.js} +5 -1
  8. package/shell-dist/assets/runtime.js +24 -3
  9. package/shell-dist/index.html +9 -12
  10. package/shell-dist/versions.json +3 -3
  11. package/src/build.ts +19 -2
  12. package/src/init.ts +5 -4
  13. package/src/shared-deps.ts +27 -9
  14. package/src/site-base.ts +1 -1
  15. package/templates/config.yaml +28 -6
  16. package/templates/init.include.yaml +4 -7
  17. package/templates/openapi.json +2636 -2356
  18. package/templates/src/_platform/api/_shared/service/domain/users.ts +40 -4
  19. package/templates/src/auth/api/_shared/wecom/admin.ts +91 -7
  20. package/templates/src/auth/api/_shared/wecom/client.ts +9 -2
  21. package/templates/src/auth/api/_shared/wecom/config.ts +256 -46
  22. package/templates/src/auth/api/_shared/wecom/contacts.ts +7 -1
  23. package/templates/src/auth/api/_shared/wecom/events.ts +28 -6
  24. package/templates/src/auth/api/_shared/wecom/login.ts +21 -13
  25. package/templates/src/auth/api/_shared/wecom/store.ts +61 -12
  26. package/templates/src/auth/api/_shared/wecom/sync.ts +11 -1
  27. package/templates/src/auth/api/_shared/wecom/tenant-admin.ts +194 -0
  28. package/templates/src/auth/api/migrations/0003__credential_kind.mysql.sql +3 -0
  29. package/templates/src/auth/api/migrations/0003__credential_kind.pg.sql +3 -0
  30. package/templates/src/auth/api/migrations/0003__credential_kind.sql +9 -0
  31. package/templates/src/auth/api/schema.yaml +2 -0
  32. package/templates/src/auth/api/wecom/admin/api.ts +112 -11
  33. package/templates/src/auth/api/wecom/admin/tenant-admin/api.ts +41 -0
  34. package/templates/src/auth/api/wecom/admin/tenant-admin/password/api.ts +66 -0
  35. package/templates/src/auth/api/wecom/api.ts +102 -48
  36. package/templates/src/auth/api/wecom/sync/api.ts +21 -5
  37. package/templates/src/auth/web/__client/api.schemas.ts +22 -1
  38. package/templates/src/auth/web/__client/api.ts +47 -1
  39. package/templates/src/login/web/pages/login.tsx +54 -24
  40. package/templates/src/system/api/settings/api.ts +3 -3
  41. package/templates/src/system/web/entry.ts +4 -4
  42. package/templates/src/system/web/locales/en-US.json +38 -1
  43. package/templates/src/system/web/locales/zh-CN.json +38 -1
  44. package/templates/src/system/web/pages/departments/sync-bar.tsx +32 -7
  45. package/templates/src/system/web/pages/users/index.tsx +7 -4
  46. package/templates/src/system/web/pages/users/roles.tsx +14 -3
  47. package/templates/src/system/web/pages/wecom/index.tsx +292 -45
  48. package/templates/tests/wecom-admin.spec.ts +7 -7
  49. package/templates/tests/wecom-connector.spec.ts +7 -6
  50. package/templates/tests/wecom-corp.spec.ts +523 -0
  51. package/templates/tests/wecom-endpoints.spec.ts +10 -4
  52. package/templates/tests/wecom-org-sync.spec.ts +7 -1
  53. package/templates/tsconfig.paths.json +0 -1
  54. package/templates/web.config.ts +1 -2
  55. package/vendor/host-versions.json +3 -3
  56. package/shell-dist/assets/echarts-charts.js +0 -58571
  57. package/shell-dist/assets/echarts-features.js +0 -24057
  58. package/shell-dist/assets/echarts-for-react.js +0 -704
  59. package/shell-dist/assets/echarts.js +0 -82916
  60. package/shell-dist/assets/editor.js +0 -161457
  61. package/shell-dist/assets/vditor-index-css.js +0 -7
  62. package/shell-dist/assets/vditor.js +0 -15223
  63. package/templates/src/demo/api/manifest.yaml +0 -5
  64. package/templates/src/demo/api/migrations/0001__create_todos.sql +0 -9
  65. package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +0 -2
  66. package/templates/src/demo/api/migrations/0002__tenant.sql +0 -2
  67. package/templates/src/demo/api/schema.yaml +0 -12
  68. package/templates/src/demo/api/seed.sql +0 -5
  69. package/templates/src/demo/api/todos/api.ts +0 -59
  70. package/templates/src/demo/web/__client/api.schemas.ts +0 -20
  71. package/templates/src/demo/web/__client/api.ts +0 -89
  72. package/templates/src/demo/web/entry.ts +0 -42
  73. package/templates/src/demo/web/locales/en-US.json +0 -10
  74. package/templates/src/demo/web/locales/zh-CN.json +0 -10
  75. package/templates/src/demo/web/pages/index.tsx +0 -45
@@ -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 };
@@ -0,0 +1,41 @@
1
+ // GET /api/auth/wecom/admin/tenant-admin?corpId= —— 查某企业的租户管理员状态。
2
+ //
3
+ // 权限:仅 superadmin。这是**跨租户**的平台级视图(查的是别家企业的管理员有没有开通),
4
+ // 与同目录其它端点一致。只读,不存在也不建——建号由开通流程与 password 端点负责。
5
+ import { assertSuperAdmin } from "#/_platform/_shared/service/infra/access";
6
+ import { respond } from "#/_platform/_shared/respond";
7
+ import { tenantAdminState } from "../../../_shared/wecom/tenant-admin";
8
+
9
+ async function get() {
10
+ await respond(async () => {
11
+ await assertSuperAdmin();
12
+ const corpId = String(((http.query ?? {}) as Record<string, unknown>).corpId ?? "").trim();
13
+ if (!corpId)
14
+ throw Object.assign(new Error("缺少企业代码"), { status: 400 });
15
+ return tenantAdminState(corpId);
16
+ });
17
+ }
18
+
19
+ get.schema = {
20
+ query: {
21
+ type: "object",
22
+ required: ["corpId"],
23
+ properties: { corpId: { type: "string", minLength: 1 } },
24
+ },
25
+ };
26
+
27
+ get.response = {
28
+ type: "object",
29
+ required: ["corpId", "username", "exists", "mustChange"],
30
+ properties: {
31
+ corpId: { type: "string" },
32
+ // 固定账号名:每租户各一个(users 唯一键含 tenant_id,互不影响)
33
+ username: { type: "string" },
34
+ // 该租户是否已有一个握着 admin 角色的账号
35
+ exists: { type: "boolean" },
36
+ // 是否须改密(开通后恒 true,直到本人改过一次)
37
+ mustChange: { type: "boolean" },
38
+ },
39
+ };
40
+
41
+ export default { get };
@@ -0,0 +1,66 @@
1
+ // POST /api/auth/wecom/admin/tenant-admin/password —— 重设某企业的租户管理员口令。
2
+ //
3
+ // 权限:仅 superadmin(跨租户的平台级动作)。
4
+ //
5
+ // 为什么做成「superadmin 自己输入新口令」而不是「系统生成后回显」:
6
+ // 口令只以 bcrypt 落库,**没有任何可逆存储**,所以系统无法回显一个已存在的口令。
7
+ // 让 superadmin 自己输入,他知道这个值 → 直接告知租户管理员即可。代价为零,
8
+ // 收益是全系统不存在静态可读的口令副本。
9
+ //
10
+ // 副作用(都是必需的):
11
+ // · must_change = 1 —— 管理员首次登录必须改掉这个临时口令;
12
+ // · 级联删 refresh —— 改密后旧会话立即失效,避免旧 token 继续可用。
13
+ import { assertSuperAdmin } from "#/_platform/_shared/service/infra/access";
14
+ import { recordOp } from "#/_platform/_shared/service/infra/audit";
15
+ import { respond } from "#/_platform/_shared/respond";
16
+ import { setTenantAdminPassword } from "../../../../_shared/wecom/tenant-admin";
17
+
18
+ async function post() {
19
+ await respond(async () => {
20
+ await assertSuperAdmin();
21
+ const body = (http.body ?? {}) as { corpId?: unknown, password?: unknown };
22
+ const corpId = String(body.corpId ?? "").trim();
23
+ const password = String(body.password ?? "");
24
+ if (!corpId)
25
+ throw Object.assign(new Error("缺少企业代码"), { status: 400 });
26
+ // 与 users/password 同下限(6 位):太短的口令在交接环节泄露风险更高
27
+ if (password.length < 6)
28
+ throw Object.assign(new Error("口令至少 6 位"), { status: 400 });
29
+
30
+ const state = await setTenantAdminPassword(corpId, password);
31
+ await recordOp({
32
+ action: "wecom.admin.tenant_admin_password",
33
+ module: "auth",
34
+ target: corpId,
35
+ // 不记口令本身(连脱敏都不记):审计只要能回答「谁在何时给哪个企业重设过」
36
+ detail: { username: state.username },
37
+ // 重设他人管理员口令=接管该租户的最高权限,按安全事件留痕
38
+ level: 4,
39
+ });
40
+ return state;
41
+ });
42
+ }
43
+
44
+ post.schema = {
45
+ body: {
46
+ type: "object",
47
+ required: ["corpId", "password"],
48
+ properties: {
49
+ corpId: { type: "string", minLength: 1 },
50
+ password: { type: "string", minLength: 6 },
51
+ },
52
+ },
53
+ };
54
+
55
+ post.response = {
56
+ type: "object",
57
+ required: ["corpId", "username", "exists", "mustChange"],
58
+ properties: {
59
+ corpId: { type: "string" },
60
+ username: { type: "string" },
61
+ exists: { type: "boolean" },
62
+ mustChange: { type: "boolean" },
63
+ },
64
+ };
65
+
66
+ export default { post };
@@ -1,4 +1,5 @@
1
- import type { WecomConfig } from "../_shared/wecom/config";
1
+ import type { CallbackCreds, WecomKind } from "../_shared/wecom/config";
2
+ import type { SelfApp } from "../_shared/wecom/login";
2
3
  import type { CallbackFields } from "../_shared/wecom/payload";
3
4
  import type { ReplayClaim } from "../_shared/wecom/replay";
4
5
  import type { WecomStoreDeps } from "../_shared/wecom/store";
@@ -6,7 +7,7 @@ import type { WecomStoreDeps } from "../_shared/wecom/store";
6
7
  // GET /api/auth/wecom/callback?code=&state= —— 企微回跳:code→userid→JIT→一次性票据→302 SPA
7
8
  // POST /api/auth/wecom/ticket {ticket} —— 一次性票据换 TokenBundle(token 不进 URL)
8
9
  // GET /api/auth/wecom/event —— 企微回调 URL 验证(echostr 解出后原样回)
9
- // POST /api/auth/wecom/event —— 企微指令回调(suite_ticket / 授权 / 解绑)
10
+ // POST /api/auth/wecom/event —— 企微回调(代开发指令事件 / 自建应用通讯录变更)
10
11
  //
11
12
  // 三处纪律:
12
13
  // 1. 两个 302 端点**不能**用 respond(它末尾无脑 json.ok,会把 302 覆盖成 200 信封)——
@@ -19,25 +20,36 @@ import { failWith, respond } from "#/_platform/_shared/respond";
19
20
  import { clientIp, guardLoginThrottle, noteLoginFailed, noteLoginSucceeded } from "../_shared/login-throttle";
20
21
  import { createSession } from "../_shared/session";
21
22
  import { createWecomClient, runtimeCache, runtimeHttp } from "../_shared/wecom/client";
22
- import { loginRedirectOf, readCallbackConfig, readWecomConfig, siteRootOf } from "../_shared/wecom/config";
23
+ import {
24
+
25
+ callbackCreds,
26
+ loginRedirectOf,
27
+ readBase,
28
+ readCorpConfig,
29
+ readSuiteConfig,
30
+ siteRootOf,
31
+ wecomKinds,
32
+ } from "../_shared/wecom/config";
33
+ import { handleContactChange } from "../_shared/wecom/contacts";
23
34
  import { sha1Hex, unwrapWecomMessage, wecomSignature } from "../_shared/wecom/crypt";
24
35
  import { handleSuiteEvent } from "../_shared/wecom/events";
25
36
  import {
26
37
  assertUserActive,
27
38
  buildAuthorizeUrl,
28
- resolveWecomProfile,
29
39
  ensureWecomUser,
30
40
  LOGIN_TICKET_TTL_SECS,
31
41
  loginLandingUrl,
32
42
  putLoginState,
43
+ resolveWecomProfile,
44
+
33
45
  selfUpdate,
34
46
  takeLoginState,
35
47
  } from "../_shared/wecom/login";
36
48
  import { callbackFields, field } from "../_shared/wecom/payload";
37
- import { guardReplay, runtimeReplayGate, ReplayAlreadyApplied, markApplied } from "../_shared/wecom/replay";
49
+ import { guardReplay, markApplied, ReplayAlreadyApplied, runtimeReplayGate } from "../_shared/wecom/replay";
38
50
  import { claimLoginTicket, ensureTenant, findProfile, issueLoginTicket, runtimeStoreDeps, tenantOfCorp } from "../_shared/wecom/store";
39
51
 
40
- // 模块级禁止 readWecomConfig()/createSession()/runtimeHttp():oj build 的 introspect 在空沙箱里
52
+ // 模块级禁止 readBase()/createSession()/runtimeHttp():oj build 的 introspect 在空沙箱里
41
53
  // 评估模块(无 vars/jwt/fetch),一律在 handler 内按需构造(§9.4)。
42
54
 
43
55
  /** 请求头的读取按大小写不敏感处理(http.headers 的键大小写随客户端) */
@@ -62,6 +74,19 @@ function badRequest(message: string): Error {
62
74
  return Object.assign(new Error(message), { status: 400 });
63
75
  }
64
76
 
77
+ /**
78
+ * 本部署自己那家企业内部应用(配了 `wecom_corp_*` 才有)。
79
+ *
80
+ * 作用是给登录入口一个**确定的默认目标**,而不是去猜档案里哪一行是「本站自己」:共存部署里
81
+ * 档案表还有别家企业通过代开发装进来的行,猜的代价是本站 `/login` 反过来回 400。
82
+ */
83
+ function corpSelf(): SelfApp | undefined {
84
+ if (!wecomKinds().includes("corp"))
85
+ return undefined;
86
+ const c = readCorpConfig();
87
+ return { corpId: c.corpId, agentId: c.agentId };
88
+ }
89
+
65
90
  /**
66
91
  * 解不开时把「哪一侧配错」记进日志——只回一句 `pkcs7: bad padding` 等于没给线索。
67
92
  *
@@ -70,23 +95,26 @@ function badRequest(message: string): Error {
70
95
  * 一个字符),或这条推送其实来自另一个套件/模板(那把钥匙解不开这份密文)。
71
96
  * `aesKeyFp` 是本进程**实际加载**那把钥匙的指纹:改完 config 对不上就说明没生效(没重启/改错文件)。
72
97
  * · `receiver mismatch` → **钥匙对、身份不对**。`receiveid` 已按候选试过(见 unwrapAccepting:
73
- * suite_id + 信封 `ToUserName`),两边都对不上只剩两种:信封里没有 `ToUserName`,或这条推送
74
- * 既不属本部署套件、receiveid 也不是它自己声称的身份(伪造/转发)。
98
+ * 凭据自己的 receiver + 信封 `ToUserName`),两边都对不上只剩两种:信封里没有 `ToUserName`,
99
+ * 或这条推送既不属本部署、receiveid 也不是它自己声称的身份(伪造/转发)。
75
100
  *
76
- * 注意 `envelopeToUser` **不等于** `configuredSuite` 是正常的:数据回调的 `ToUserName` 是 corpid。
101
+ * `kind` 必须记:共存部署里有两套钥匙,日志不写「这次用的是哪套」,排障就退化成猜。
102
+ * 注意 `envelopeToUser` **不等于** `configuredReceiver` 是正常的:数据回调的 `ToUserName` 是 corpid。
77
103
  */
78
- function noteDecryptFailure(e: unknown, envelope: CallbackFields | null, cfg: { encodingAesKey: string, suiteId: string }): void {
104
+ function noteDecryptFailure(e: unknown, envelope: CallbackFields | null, creds: CallbackCreds): void {
79
105
  log.warn(
80
106
  "wecom callback decrypt failed",
81
107
  "reason",
82
108
  (e as Error).message,
109
+ "kind",
110
+ creds.kind,
83
111
  "envelopeToUser",
84
112
  // URL 验证(GET)只有 query 里的 echostr,没有报文信封——那一侧记空串
85
113
  envelope ? field(envelope, "ToUserName") : "",
86
- "configuredSuite",
87
- cfg.suiteId,
114
+ "configuredReceiver",
115
+ creds.receiver,
88
116
  "aesKeyFp",
89
- sha1Hex(cfg.encodingAesKey).slice(0, 8),
117
+ sha1Hex(creds.encodingAesKey).slice(0, 8),
90
118
  );
91
119
  }
92
120
 
@@ -94,12 +122,12 @@ function noteDecryptFailure(e: unknown, envelope: CallbackFields | null, cfg: {
94
122
  * 按「可能合法的 receiveid」依次尝试解密。
95
123
  *
96
124
  * receiveid 的含义随场景变(90968 附注):**第三方事件的回调是 suiteid**(suite_ticket / create_auth
97
- * / cancel_auth),**企业应用(含代开发应用的数据回调)是 corpid**(通讯录变更就走这条)。
125
+ * / cancel_auth),**企业应用(含代开发应用的数据回调、自建应用的通讯录事件)是 corpid**。
98
126
  * 我们一个端点吃两种,只认 suiteid 会把所有数据回调判成「解密失败」——而真正的错是「比错了值」。
99
127
  *
100
128
  * 只对 `receiver mismatch` 换下一个候选:`bad padding` 说明密钥就不对,换谁都解不开,直接抛。
101
129
  */
102
- function unwrapAccepting(encrypted: string, cfg: WecomConfig, candidates: string[]): string {
130
+ function unwrapAccepting(encrypted: string, creds: CallbackCreds, candidates: string[]): string {
103
131
  let lastErr: unknown;
104
132
  const tried = new Set<string>();
105
133
  for (const receiver of candidates) {
@@ -107,7 +135,7 @@ function unwrapAccepting(encrypted: string, cfg: WecomConfig, candidates: string
107
135
  continue;
108
136
  tried.add(receiver);
109
137
  try {
110
- return unwrapWecomMessage(encrypted, cfg.encodingAesKey, receiver);
138
+ return unwrapWecomMessage(encrypted, creds.encodingAesKey, receiver);
111
139
  }
112
140
  catch (e) {
113
141
  if (!/receiver mismatch/.test((e as Error).message))
@@ -118,6 +146,14 @@ function unwrapAccepting(encrypted: string, cfg: WecomConfig, candidates: string
118
146
  throw lastErr ?? new Error("wecom: 没有可比对的 receiveid(suite_id 未配置?)");
119
147
  }
120
148
 
149
+ /** 用哪一把钥匙能验过签名:**逐个试**(SHA1 一次,成本可忽略),命中即用它的钥匙解密 */
150
+ function matchCreds(encrypted: string): CallbackCreds | null {
151
+ const sig = query("msg_signature");
152
+ const ts = query("timestamp");
153
+ const nonce = query("nonce");
154
+ return callbackCreds().find(c => wecomSignature(c.token, ts, nonce, encrypted) === sig) ?? null;
155
+ }
156
+
121
157
  /**
122
158
  * 校验企微回调签名并解出明文。签名不合法 → 400(不是 401:调用方是企微服务器,
123
159
  * 401 会被当成「本站需要鉴权」误导排查);信封里的原文不回显,避免给探测者提供比对材料。
@@ -125,22 +161,21 @@ function unwrapAccepting(encrypted: string, cfg: WecomConfig, candidates: string
125
161
  * 三道顺序不可换:先验签(不给未鉴权请求写 kv)→ 再拦重放(省一次解密,也让
126
162
  * cancel_auth 这种危险性最高的报文止步在落库之前)→ 最后解密。
127
163
  */
128
- async function decryptVerified(encrypted: string, envelope: CallbackFields): Promise<{ cfg: WecomConfig, plain: string, claim: ReplayClaim }> {
129
- const cfg = readWecomConfig();
130
- const expected = wecomSignature(cfg.token, query("timestamp"), query("nonce"), encrypted);
131
- if (expected !== query("msg_signature"))
164
+ async function decryptVerified(encrypted: string, envelope: CallbackFields): Promise<{ kind: WecomKind, plain: string, claim: ReplayClaim }> {
165
+ const creds = matchCreds(encrypted);
166
+ if (!creds)
132
167
  throw badRequest("回调签名校验失败");
133
168
  const claim = await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
134
169
  try {
135
170
  // 信封的 ToUserName 就是这条报文的身份:套件事件里是 suiteid,应用数据回调里是 corpid,
136
- // 与 receiveid 必然一致(两者在同一份密文/明文里,签名已经覆盖)。
137
- return { cfg, plain: unwrapAccepting(encrypted, cfg, [cfg.suiteId, field(envelope, "ToUserName")]), claim };
171
+ // 与 receiver 必然一致(两者在同一份密文/明文里,签名已经覆盖)。
172
+ return { kind: creds.kind, plain: unwrapAccepting(encrypted, creds, [creds.receiver, field(envelope, "ToUserName")]), claim };
138
173
  }
139
174
  catch (e) {
140
175
  // 解不开的这一条不能占住 nonce:企微重推是它唯一的恢复路径,占住就把重推挡成 400
141
176
  await claim.release();
142
177
  // 解密失败只对外透出「失败」:错钥/错套件/被篡改三者不可区分,也不该让外部据此判断哪一环配错
143
- noteDecryptFailure(e, envelope, cfg);
178
+ noteDecryptFailure(e, envelope, creds);
144
179
  throw badRequest("回调消息解密失败");
145
180
  }
146
181
  }
@@ -162,8 +197,10 @@ const login = {
162
197
  // corp 必填(D10:企业代码 = corp_id,服务商后台可生成带码的专属登录链接)。
163
198
  async get() {
164
199
  try {
165
- const cfg = readWecomConfig();
166
- const deps = runtimeStoreDeps(cfg);
200
+ // 登录只用「与来源无关」的两项(落库密钥 + 基址):自建模式没有 suite 凭据,
201
+ // 在这里读整套 suite 配置会把一条正常的登录打成 400。
202
+ const base = readBase();
203
+ const deps = runtimeStoreDeps(base);
167
204
 
168
205
  const corp = query("corp");
169
206
  const agent = query("agent");
@@ -172,7 +209,7 @@ const login = {
172
209
  // 企业代码解析是**未登录可调的枚举面**(§5.3):复用登录节流,维度 (IP, 企业代码)。
173
210
  // corp 缺省时(代开发模板常只服务一家企业)自动从已安装档案解析,省去手动填企业代码,
174
211
  // 也让「应用主页」可直接填 https://<域名>/login(无需 ${corpid} 占位符,企微后台会因非法 URL 字符拒收)。
175
- let profile: { corpId: string; agentId: string; env: string };
212
+ let profile: { corpId: string, agentId: string, env: string };
176
213
  if (corp) {
177
214
  await guardLoginThrottle(ip, corp);
178
215
  profile = await findProfile(corp, agent || null, deps).catch(async (e: Error & { status?: number }) => {
@@ -181,8 +218,9 @@ const login = {
181
218
  await noteLoginFailed(ip, corp);
182
219
  throw e;
183
220
  });
184
- } else {
185
- profile = await resolveWecomProfile(agent || null);
221
+ }
222
+ else {
223
+ profile = await resolveWecomProfile(agent || null, corpSelf());
186
224
  await guardLoginThrottle(ip, profile.corpId);
187
225
  }
188
226
 
@@ -191,7 +229,7 @@ const login = {
191
229
  const url = buildAuthorizeUrl({
192
230
  corpId: profile.corpId,
193
231
  agentId: profile.agentId,
194
- redirectUri: loginRedirectOf(cfg),
232
+ redirectUri: loginRedirectOf(base),
195
233
  state,
196
234
  inWecom: /wxwork/i.test(header("user-agent")),
197
235
  });
@@ -207,7 +245,7 @@ const callback = {
207
245
  // 企微回跳:state 一次性(kv 取即删)→ profile → 绑租户 → code 换 userid → JIT 建档 → 发票 → 302。
208
246
  async get() {
209
247
  try {
210
- const cfg = readWecomConfig();
248
+ const base = readBase();
211
249
  const code = query("code");
212
250
  const state = query("state");
213
251
  // 用户在企业微信里点了「拒绝」时企微不回 code,只回 state
@@ -216,7 +254,7 @@ const callback = {
216
254
  if (!state)
217
255
  throw badRequest("登录状态缺失:请从企业微信重新进入");
218
256
 
219
- const deps = runtimeStoreDeps(cfg);
257
+ const deps = runtimeStoreDeps(base);
220
258
  const cache = runtimeCache();
221
259
  const pending = await takeLoginState(state, cache);
222
260
  const profile = await findProfile(pending.corpId, pending.agentId, deps);
@@ -246,7 +284,7 @@ const callback = {
246
284
  await issueLoginTicket(crypto.sha256Hex(ticket), uid, tenant, LOGIN_TICKET_TTL_SECS, deps);
247
285
  await noteLoginSucceeded(await clientIp(), profile.corpId);
248
286
  log.info("wecom login ok", "tenant", tenant, "agentId", profile.agentId);
249
- json.redirect.found(loginLandingUrl({ origin: siteRootOf(cfg), ticket, tenant }));
287
+ json.redirect.found(loginLandingUrl({ origin: siteRootOf(base), ticket, tenant }));
250
288
  }
251
289
  catch (e) {
252
290
  failWith(e);
@@ -286,34 +324,35 @@ ticket.post.schema = {
286
324
  const event = {
287
325
  // 企微后台「保存回调 URL」时会 GET 一次:解出 echostr 后**原样明文回**(不套信封、不加引号)。
288
326
  //
289
- // 只依赖 Tier A 凭据(readCallbackConfig):token/aeskey/baseUrl/encKey 全部部署方本地生成,
290
- // 不需要 suite_id——因此「配齐四项正确即可响应验证」,**不必等代开发模版创建、拿到 suite_id 之后**。
291
- // 这正是打破鸡生蛋死锁(plan §18)的落点:先有可验证的回调,才能创建模版、拿到 suite 凭据。
327
+ // 只依赖回调凭据(`callbackCreds()`):token/aeskey 由部署方与企微后台成对填写,不需要 suite_id——
328
+ // 因此「配齐即可响应验证」,**不必等代开发模版创建、拿到 suite_id 之后**。这正是打破鸡生蛋死锁
329
+ // (plan §18)的落点:先有可验证的回调,才能创建模版、拿到 suite 凭据。
330
+ //
331
+ // 共存部署有**两套**回调凭据(代开发的指令回调 / 自建应用的通讯录同步回调),企微用哪一套签名
332
+ // 取决于管理员在哪个后台点「保存」→ 逐个试签,命中哪套就用哪套钥匙解密。两套都没配才报错。
292
333
  //
293
- // suite_id 在「模版创建成功」后才下发;URL 验证可能发生在拿到 suite_id 之前(创建表单内联校验)。
294
- // 加上「应用回调 URL 验证时 receiveid 是 corpid」这一层,GET 根本无从挑一个值来比 → 一律 tolerant,
295
- // 见下面 try 里的说明(签名是唯一闸门,本端点不落状态)。
334
+ // URL 验证可能发生在拿到 suite_id 之前(创建表单内联校验),加上「应用回调 URL 验证时 receiveid
335
+ // 是 corpid」这一层,GET 根本无从挑一个值来比 → 一律 tolerant,见下面 try 里的说明
336
+ // (签名是唯一闸门,本端点不落状态)。
296
337
  async get() {
297
338
  try {
298
- const cfg = readCallbackConfig();
299
339
  const encrypted = query("echostr");
300
- const expected = wecomSignature(cfg.token, query("timestamp"), query("nonce"), encrypted);
301
- if (expected !== query("msg_signature"))
340
+ const creds = matchCreds(encrypted);
341
+ if (!creds)
302
342
  throw badRequest("回调签名校验失败");
303
343
  const claim = await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
304
- const suiteId = (vars.get("wecom_suite_id") ?? "").trim();
305
344
  try {
306
345
  // receiveid 一律不比对:GET 只有 echostr、没有报文信封,无从知道该比哪一个——
307
- // 「模板/指令回调 URL」验证时 receiveid 是 suiteid,「应用回调 URL」验证时是 corpid。
346
+ // 「模板/指令回调 URL」验证时 receiveid 是 suiteid,「应用回调 URL / 通讯录同步」验证时是 corpid。
308
347
  // 放宽是安全的:签名已过(token 只有本部署与企微知道),回包只是把解出的 echostr 原样返回,
309
348
  // 不写任何状态;真正的 receiveid 比对在 POST 那条路径上(见 unwrapAccepting)。
310
- plainText(unwrapWecomMessage(encrypted, cfg.encodingAesKey, suiteId, true));
349
+ plainText(unwrapWecomMessage(encrypted, creds.encodingAesKey, creds.receiver, true));
311
350
  }
312
351
  catch (e) {
313
352
  // 验证没通过就放回 nonce:企微后台点「保存」会连着验几次,占住会把后几次都挡成 400
314
353
  await claim.release();
315
354
  // 解密失败只透出「失败」:错钥/未下发套件/被篡改三者不可区分,也不该让外部据此判断哪一环配错
316
- noteDecryptFailure(e, null, { encodingAesKey: cfg.encodingAesKey, suiteId });
355
+ noteDecryptFailure(e, null, creds);
317
356
  throw badRequest("回调消息解密失败");
318
357
  }
319
358
  }
@@ -322,8 +361,12 @@ const event = {
322
361
  }
323
362
  },
324
363
 
325
- // 回调(指令 + 应用数据):验签 → 解密 → 分发(suite_ticket / create_auth / change_auth /
326
- // cancel_auth / reset_permanent_code)。回 `success` 才算收下,否则企微 5 秒后重推三次。
364
+ // 回调(指令 + 应用数据 + 通讯录同步):验签 → 解密 → 按**命中凭据的来源**分发。
365
+ // 回 `success` 才算收下,否则企微 5 秒后重推三次。
366
+ //
367
+ // 来源分派是共存的关键:suite 凭据只可能收到套件事件(suite_ticket / create_auth / change_auth /
368
+ // cancel_auth);corp 凭据只可能收到通讯录变更(企业管理员在「通讯录同步 → 接收事件服务器」填的本 URL),
369
+ // **没有**套件事件这回事,所以直接进 change_contact 分支,不进 handleSuiteEvent 的 switch。
327
370
  //
328
371
  // 包体形态按官方文档是 XML(见 payload.ts 的开头),但归一化那一层两种都认,
329
372
  // 所以本函数只认字段名,不认格式。
@@ -338,7 +381,18 @@ const event = {
338
381
  claim = verified.claim;
339
382
 
340
383
  const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
341
- await handleSuiteEvent(verified.plain, { cfg: verified.cfg, client, store: runtimeStoreDeps(verified.cfg) });
384
+ if (verified.kind === "corp") {
385
+ // 自建应用没有套件流程:报文只可能是 change_contact(其余形态由 contacts.ts 记账后放过)
386
+ await handleContactChange(callbackFields(verified.plain), {
387
+ client,
388
+ store: runtimeStoreDeps(readBase()),
389
+ credentialKind: "corp",
390
+ });
391
+ }
392
+ else {
393
+ const cfg = readSuiteConfig();
394
+ await handleSuiteEvent(verified.plain, { cfg, client, store: runtimeStoreDeps(cfg) });
395
+ }
342
396
  // 处理成功即标记 nonce 已落库:企微重推时命中即幂等回 success,解开「首推偏慢被企微判超时、
343
397
  // 重推又被重放守卫挡 400」的死局——管理员端不再卡在「应用安装失败」。
344
398
  await markApplied(query("nonce"), runtimeReplayGate()).catch(() => {});