@oj-module/cli 0.2.47 → 0.2.48

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 (94) hide show
  1. package/devkit/SKILL.md +1 -1
  2. package/devkit/framework-dev.md +2 -2
  3. package/devkit/manual.md +24 -14
  4. package/devkit/module-dev-and-cdn-playground.md +2 -2
  5. package/devkit/wecom-daikaifa.md +237 -14
  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__/templates-tenant-scope.test.ts +7 -2
  11. package/src/init.ts +1 -1
  12. package/src/market/__tests__/bootstrap.test.ts +1 -1
  13. package/src/market/__tests__/setup.test.ts +7 -7
  14. package/src/market/bootstrap.ts +1 -1
  15. package/templates/README.md +1 -1
  16. package/templates/init.include.yaml +12 -0
  17. package/templates/openapi.json +2863 -549
  18. package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
  19. package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
  20. package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
  21. package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
  22. package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
  23. package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
  24. package/templates/src/_platform/api/manifest.yaml +3 -0
  25. package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
  26. package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
  27. package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
  28. package/templates/src/_platform/api/org/departments/api.ts +51 -0
  29. package/templates/src/_platform/api/org/is-member/api.ts +33 -0
  30. package/templates/src/_platform/api/org/members/api.ts +55 -0
  31. package/templates/src/_platform/api/org/picker/api.ts +59 -0
  32. package/templates/src/_platform/api/org/resolve/api.ts +38 -0
  33. package/templates/src/_platform/api/org/tree/api.ts +37 -0
  34. package/templates/src/_platform/api/org/user/api.ts +46 -0
  35. package/templates/src/_platform/api/schema.yaml +48 -0
  36. package/templates/src/_platform/api/seed.mysql.sql +10 -5
  37. package/templates/src/_platform/api/seed.pg.sql +10 -5
  38. package/templates/src/_platform/api/seed.sql +13 -5
  39. package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
  40. package/templates/src/_platform/web/__client/api.ts +170 -1
  41. package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
  42. package/templates/src/auth/api/_shared/session.ts +5 -1
  43. package/templates/src/auth/api/_shared/wecom/admin.ts +3 -1
  44. package/templates/src/auth/api/_shared/wecom/client.ts +243 -31
  45. package/templates/src/auth/api/_shared/wecom/config.ts +12 -1
  46. package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
  47. package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
  48. package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
  49. package/templates/src/auth/api/_shared/wecom/login.ts +152 -1
  50. package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
  51. package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
  52. package/templates/src/auth/api/_shared/wecom/sync.ts +487 -0
  53. package/templates/src/auth/api/manifest.yaml +3 -0
  54. package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
  55. package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
  56. package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
  57. package/templates/src/auth/api/schema.yaml +31 -0
  58. package/templates/src/auth/api/wecom/admin/api.ts +3 -1
  59. package/templates/src/auth/api/wecom/api.ts +100 -18
  60. package/templates/src/auth/api/wecom/sync/api.ts +199 -0
  61. package/templates/src/auth/web/__client/api.schemas.ts +97 -0
  62. package/templates/src/auth/web/__client/api.ts +73 -1
  63. package/templates/src/notification/api/seed.sql +1 -1
  64. package/templates/src/system/api/departments/api.ts +142 -0
  65. package/templates/src/system/api/departments/detach/api.ts +34 -0
  66. package/templates/src/system/api/departments/members/api.ts +142 -0
  67. package/templates/src/system/api/departments/move/api.ts +39 -0
  68. package/templates/src/system/api/users/api.ts +45 -5
  69. package/templates/src/system/api/users/profile/api.ts +44 -0
  70. package/templates/src/system/web/__client/api.schemas.ts +143 -0
  71. package/templates/src/system/web/__client/api.ts +293 -1
  72. package/templates/src/system/web/entry.ts +6 -0
  73. package/templates/src/system/web/locales/en-US.json +112 -6
  74. package/templates/src/system/web/locales/zh-CN.json +112 -6
  75. package/templates/src/system/web/pages/departments/index.tsx +346 -0
  76. package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
  77. package/templates/src/system/web/pages/departments/tree.ts +66 -0
  78. package/templates/src/system/web/pages/errors.ts +1 -1
  79. package/templates/src/system/web/pages/users/index.tsx +49 -7
  80. package/templates/src/system/web/pages/wecom/index.tsx +14 -0
  81. package/templates/tests/l1-builder.test.ts +1 -1
  82. package/templates/tests/org-departments.spec.ts +311 -0
  83. package/templates/tests/org-read.spec.ts +469 -0
  84. package/templates/tests/rbac-contacts.spec.ts +13 -3
  85. package/templates/tests/rbac-seed.spec.ts +11 -0
  86. package/templates/tests/seed-role-accounts.spec.ts +6 -2
  87. package/templates/tests/wecom-admin.spec.ts +1 -0
  88. package/templates/tests/wecom-connector.spec.ts +115 -8
  89. package/templates/tests/wecom-crypt.spec.ts +12 -3
  90. package/templates/tests/wecom-endpoints.spec.ts +182 -14
  91. package/templates/tests/wecom-org-sync.spec.ts +598 -0
  92. package/templates/tests/wecom-sync.spec.ts +286 -0
  93. package/vendor/host-versions.json +3 -3
  94. package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
@@ -2,7 +2,7 @@ import type { WecomClient } from "./client";
2
2
  import type { WecomConfig } from "./config";
3
3
  import type { WecomStoreDeps } from "./store";
4
4
  // 企微「指令回调」事件处理:suite_ticket / create_auth / change_auth / cancel_auth /
5
- // reset_permanent_code(代开发重置 secret 走后者)。
5
+ // reset_permanent_code(代开发重置 secret 走后者),以及轨三的通讯录变更(contacts.ts)。
6
6
  //
7
7
  // 为什么这几件事必须由回调驱动(而不是管理端手工录):
8
8
  // · suite_ticket 每 10 分钟推一次、30 分钟过期,拿不到就换不出 suite_access_token,
@@ -12,13 +12,16 @@ import type { WecomStoreDeps } from "./store";
12
12
  // · 企业解绑/取消授权只在 cancel_auth 出现。
13
13
  // 因此这里不是「可选的后台功能」,是代开发模式的下限能力。
14
14
  //
15
- // 响应纪律:处理完返回(路由回纯文本 success)。企微 5 秒内拿不到 success 会重推三次;
16
- // 重推对我们是幂等的(profile upsert / ticket 覆盖),但 AuthCode 一次有效,重推时已换取过 → 502,
15
+ // 响应纪律:处理完返回(路由回纯文本 success)。企微对 `create_auth` 的窗口是 **约 1 秒**
16
+ // (官方文档写 5 秒,实际按 1 秒卡;超时/非 success 一律在企业管理员浏览器上显示
17
+ // 「应用安装失败,联系服务商」)。拿不到 success 会重推三次;重推对我们是幂等的
18
+ // (profile upsert / ticket 覆盖),但 AuthCode 一次有效,重推时已换取过 → 502,
17
19
  // 属预期(日志留痕即可,不影响已落库的 profile)。
18
20
  import { invalidateAllUsers } from "#/_platform/_shared/service/infra/access";
19
21
  import { recordOp } from "#/_platform/_shared/service/infra/audit";
20
- import { corpScope, getSuiteTicket, putSuiteTicket, revokeTenantSessions, saveProfile, setProfileStatus, tenantOfCorp } from "./store";
21
- import { xmlField } from "./xml";
22
+ import { handleContactChange } from "./contacts";
23
+ import { callbackFields, field, requireField } from "./payload";
24
+ import { corpScope, ensureTenant, findCorpAuth, getSuiteTicket, putSuiteTicket, revokeTenantSessions, saveProfile, setAuthMode, setProfileStatus, tenantOfCorp } from "./store";
22
25
 
23
26
  /** suite_ticket 官方有效期 30 分钟(10 分钟推一次);本地按 30 分钟作废,宁可拒一次也不拿过期票打企微 */
24
27
  const SUITE_TICKET_TTL_SECS = 30 * 60;
@@ -40,15 +43,27 @@ export function envOfState(state: string | null): string {
40
43
  return /^(?:prod|test|staging|dev)$/i.test(trimmed) ? trimmed.toLowerCase() : "prod";
41
44
  }
42
45
 
43
- function requireField(xml: string, name: string, infoType: string): string {
44
- const value = xmlField(xml, name);
45
- if (!value)
46
- throw Object.assign(new Error(`企微事件 ${infoType} 缺少 ${name}`), { status: 400 });
47
- return value;
46
+ /**
47
+ * 事件路径的审计。两处纪律:
48
+ * 1. 调用前必须先 `ensureTenant(corp)`——`sys_oplog` 是租户表,本端点又是匿名路径(企微带不了
49
+ * `X-TENANT-ID`),`sql_guard: deny` 下不带租户上下文的 insert 会被守卫直接拒;
50
+ * 2. 审计失败**只告警**:它发生在 profile 已落库之后,一次成功的授权不能因为写日志失败
51
+ * 变成企业管理员看到的「应用安装失败」。
52
+ */
53
+ async function auditAuth(action: string, corpId: string, detail?: unknown, level?: number): Promise<void> {
54
+ try {
55
+ await recordOp({ action, module: "auth", target: corpId, detail, level });
56
+ }
57
+ catch (e) {
58
+ log.warn("wecom auth audit skipped", "action", action, "corpId", corpId, "reason", (e as Error).message);
59
+ }
48
60
  }
49
61
 
50
62
  /** 该 corp 的所有应用都标记为停用,并吊销其租户下的全部会话 */
51
63
  async function disableCorp(corpId: string, deps: SuiteEventDeps): Promise<void> {
64
+ // 取消授权同样是匿名回调:会话清理走裸 SQL,`sql_guard: deny` 下没有租户上下文会被拒——
65
+ // 而这里拒掉的后果是「旧 refresh 串仍能续命」,属安全红线,故必须绑成功再往下走。
66
+ ensureTenant(tenantOfCorp(corpId));
52
67
  const rows = await db.table("wecom_app_profile").select(["agent_id"]).where(corpScope(corpId)).all() as Array<{ agent_id: string }>;
53
68
  for (const r of rows)
54
69
  await deps.client.invalidateCorpToken(corpId, String(r.agent_id));
@@ -67,6 +82,9 @@ async function applyAuthCode(authCode: string, state: string | null, deps: Suite
67
82
  const suiteToken = await client.suiteAccessToken(cfg.suiteId, cfg.suiteSecret, suiteTicket.ticket);
68
83
  const granted = await client.permanentCodeByAuthCode(suiteToken, authCode);
69
84
  const env = envOfState(state ?? granted.state);
85
+ // 拿到企业身份就绑租户:profile 表虽共享,但其后的审计与后续写库都在这个租户下。
86
+ // 顺序不能挪到审计里——那样 saveProfile 之后才发现守卫拒,profile 已落库却回失败。
87
+ ensureTenant(tenantOfCorp(granted.corpId));
70
88
  for (const agentId of granted.agentIds) {
71
89
  await saveProfile({
72
90
  suiteId: cfg.suiteId,
@@ -76,52 +94,119 @@ async function applyAuthCode(authCode: string, state: string | null, deps: Suite
76
94
  env,
77
95
  corpName: granted.corpName,
78
96
  permanentCode: granted.permanentCode,
97
+ authMode: granted.authModes[agentId] ?? 0,
79
98
  }, store);
80
99
  // secret 可能已轮换:旧 token 必须立刻作废,否则下一个登录会带着失效 token 打 userinfo
81
100
  await client.invalidateCorpToken(granted.corpId, agentId);
82
101
  }
83
- await recordOp({ action: "wecom.auth.apply", module: "auth", target: granted.corpId, detail: { agentIds: granted.agentIds, env } });
102
+ await auditAuth("wecom.auth.apply", granted.corpId, { agentIds: granted.agentIds, env });
84
103
  return { corpId: granted.corpId, agentIds: granted.agentIds };
85
104
  }
86
105
 
106
+ /**
107
+ * `change_auth` 后复核授权模式并落库。
108
+ *
109
+ * 失败**只告警**:回调窗口 1 秒,一次上游抖动不该把通知打成失败(重推也补不回被误判的
110
+ * 授权模式,反而让企业管理员看到「配置失败」)。代价是 auth_mode 短期仍是旧值,
111
+ * 同步闸门那侧还有 60011 兜底(见 sync 的 permission 分支)。
112
+ */
113
+ async function refreshAuthModes(corpId: string, deps: SuiteEventDeps): Promise<void> {
114
+ try {
115
+ const auth = await findCorpAuth(corpId, deps.store);
116
+ if (!auth)
117
+ return;
118
+ const ticket = await getSuiteTicket(deps.cfg.suiteId, deps.store);
119
+ const suiteToken = await deps.client.suiteAccessToken(deps.cfg.suiteId, deps.cfg.suiteSecret, ticket.ticket);
120
+ const modes = await deps.client.authModesByPermanentCode(suiteToken, corpId, auth.permanentCode);
121
+ for (const agentId of auth.agentIds) {
122
+ const mode = modes[agentId];
123
+ if (mode == null)
124
+ continue;
125
+ await setAuthMode(corpId, agentId, mode, deps.store);
126
+ log.info("wecom auth_mode refreshed", "corpId", corpId, "agentId", agentId, "authMode", mode);
127
+ }
128
+ }
129
+ catch (e) {
130
+ log.warn("wecom auth_mode refresh skipped", "corpId", corpId, "reason", (e as Error).message);
131
+ }
132
+ }
133
+
87
134
  /** 处理一条已验签并解密的回调明文;返回值仅用于日志(未知 InfoType 也回 success,不触发重推) */
88
- export async function handleSuiteEvent(plaintext: string, deps: SuiteEventDeps): Promise<string> {
89
- const infoType = xmlField(plaintext, "InfoType") ?? "";
90
- const suiteId = xmlField(plaintext, "SuiteId") ?? "";
135
+ export async function handleSuiteEvent(payload: unknown, deps: SuiteEventDeps): Promise<string> {
136
+ // 入参是「原始报文」(对象 / JSON 串 / XML 串都行,见 payload.ts);归一化只在这里做一次,
137
+ // 往下全是字段名,函数体里再也看不到格式。
138
+ const fields = callbackFields(payload);
139
+ const infoType = field(fields, "InfoType");
140
+ const suiteId = field(fields, "SuiteId");
91
141
  if (suiteId && suiteId !== deps.cfg.suiteId)
92
142
  throw Object.assign(new Error("企微事件 SuiteId 与本部署配置不一致"), { status: 400 });
93
143
 
144
+ // 通讯录变更(轨三):**必须在 switch 之前分流**。两种形态都认——
145
+ // 应用侧数据回调是 `MsgType=event` + `Event=change_contact`(InfoType 为空),
146
+ // 代开发指令回调是 `InfoType=change_contact`;后者与 suite_ticket 同用 InfoType 这一列,
147
+ // 落进 default 只会被记成「不消费的事件」。
148
+ // 与下面几个分支的不同:通讯录事件失败**不抛**(事件是加速器,重推补不回来,见 contacts.ts)。
149
+ if (field(fields, "Event") === "change_contact" || infoType === "change_contact")
150
+ return await handleContactChange(fields, deps);
151
+
94
152
  switch (infoType) {
95
153
  case "suite_ticket": {
96
- const ticket = requireField(plaintext, "SuiteTicket", infoType);
154
+ const ticket = requireField(fields, "SuiteTicket", infoType);
97
155
  await putSuiteTicket(deps.cfg.suiteId, ticket, deps.store.now() + SUITE_TICKET_TTL_SECS, deps.store);
98
156
  log.info("wecom suite_ticket updated", "suiteId", deps.cfg.suiteId);
157
+ // 顺势换一次 suite_access_token(命中缓存则 0 次往返):create_auth 只有约 1 秒窗口,
158
+ // 这里先换掉,企业管理员扫码那条路径就只剩 get_permanent_code 一次出网。
159
+ // 副产品:suite_secret 配错会在票据推送时就进日志,而不是等某家企业扫码失败。
160
+ await deps.client.suiteAccessToken(deps.cfg.suiteId, deps.cfg.suiteSecret, ticket)
161
+ .catch((e: Error) => log.warn("wecom suite token warmup failed", "reason", e.message));
99
162
  return infoType;
100
163
  }
101
164
  case "create_auth":
102
165
  case "reset_permanent_code": {
103
- const authCode = requireField(plaintext, "AuthCode", infoType);
104
- const applied = await applyAuthCode(authCode, xmlField(plaintext, "State"), deps);
166
+ const authCode = requireField(fields, "AuthCode", infoType);
167
+ const applied = await applyAuthCode(authCode, field(fields, "State") || null, deps);
105
168
  log.info("wecom auth applied", "event", infoType, "corpId", applied.corpId, "agents", applied.agentIds.join(","));
106
169
  return infoType;
107
170
  }
108
171
  case "change_auth": {
109
- // 可见范围/权限/启停变化:MVP 只做「数据可见性不加码」——不改状态、不动 secret,
110
- // 靠下次登录重新走 userinfo(企微会拒掉已移出可见范围的成员)。停用/解绑走 cancel_auth。
111
- const authCorpId = requireField(plaintext, "AuthCorpId", infoType);
172
+ // 可见范围/权限/启停变化。其中**「管理员授权 ⇄ 成员授权」切换**必须跟:切到成员授权后
173
+ // 部门接口一律 60011,同步闸门还按旧值放行就会一直 502。官方要求服务商自行调
174
+ // `get_auth_info` 复核——通知本身只带 AuthCorpId(没有 AuthCode),只能拿已存永久码反查。
175
+ // 其余变化(可见范围收窄等)仍是「下次登录重新走 userinfo」,不在回调里处理。
176
+ const authCorpId = requireField(fields, "AuthCorpId", infoType);
177
+ ensureTenant(tenantOfCorp(authCorpId));
178
+ await refreshAuthModes(authCorpId, deps);
112
179
  log.info("wecom auth changed", "corpId", authCorpId);
113
180
  return infoType;
114
181
  }
115
182
  case "cancel_auth": {
116
- const authCorpId = requireField(plaintext, "AuthCorpId", infoType);
183
+ const authCorpId = requireField(fields, "AuthCorpId", infoType);
117
184
  await disableCorp(authCorpId, deps);
118
- await recordOp({ action: "wecom.auth.cancel", module: "auth", target: authCorpId, level: 4 });
185
+ await auditAuth("wecom.auth.cancel", authCorpId, undefined, 4);
119
186
  log.warn("wecom auth cancelled", "corpId", authCorpId);
120
187
  return infoType;
121
188
  }
122
- default:
123
- // 通讯录变更等事件本模块不消费:记账即可,回 success 让企微别再推
124
- log.info("wecom event ignored", "infoType", infoType);
125
- return infoType;
189
+ default: {
190
+ // 余下的事件(标签变更、外部联系人等)本模块不消费:记账即可,回 success 让企微别再推。
191
+ //
192
+ // 记账要记够「这条报文能不能用」的量:指令回调(有 InfoType)与数据回调
193
+ // (`MsgType=event` + `Event=xxx`,没有 InfoType)走的是同一个 `/event`,
194
+ // 而两者的 Token/AESKey 未必是同一套。真机验形态时只凭 InfoType 为空无法区分
195
+ // 「收到了一条事件」与「收到了一条别的消息」,所以把 Event 与字段清单一起记下。
196
+ // **只记字段名不记值**:明文里有姓名与工号。
197
+ const eventName = field(fields, "Event");
198
+ log.info(
199
+ "wecom event ignored",
200
+ "infoType",
201
+ infoType,
202
+ "msgType",
203
+ field(fields, "MsgType"),
204
+ "event",
205
+ eventName,
206
+ "fields",
207
+ fields.names.join(","),
208
+ );
209
+ return eventName || infoType;
210
+ }
126
211
  }
127
212
  }
@@ -1,3 +1,5 @@
1
+ import type { WecomCache, WecomClient } from "./client";
2
+ import type { WecomStoreDeps } from "./store";
1
3
  // 企微登录领域服务:授权链接构造、state 一次性票据、code→userid、JIT 建档。
2
4
  //
3
5
  // 两条入口(都落在同一个回调上):
@@ -7,7 +9,14 @@
7
9
  //
8
10
  // JIT(无独立注册):外部身份沿用 `wecom:<userid>` 用户名前缀(D6),口令哈希占位 `!wecom`
9
11
  // (任何口令都验不过,低信任来源不接管本地号,同 ldap 的 `!ldap`);外层租户由 corp_id 决定。
10
- import type { WecomCache } from "./client";
12
+ //
13
+ // 轨一「登录自更新」(方案 §6.1):登录成功后顺手 user/get 一次,补本人资料与所属部门。
14
+ // 四条纪律:① 24h 节流(老用户不重复打上游);② 整体 try/catch,失败不影响登录;
15
+ // ③ **不建占位部门**——只对已知部门写关联(D10,否则任何登录成员都能凭空造出部门节点);
16
+ // ④ 只加不摘:人调到新部门只加新的,旧部门由全量同步/事件摘除。
17
+ // 落库只有**一个**函数(applyMemberDetail):轨二(全量同步)、轨三(事件增量)也调它——
18
+ // 三套写库逻辑必然长歪,而症状只在「某条路径走过来的人组织关系不对」时才暴露。
19
+ import { setMainDepartment, upsertMember } from "#/_platform/_shared/service/domain/org";
11
20
 
12
21
  export const AUTHORIZE_IN_APP = "https://open.weixin.qq.com/connect/oauth2/authorize";
13
22
  export const AUTHORIZE_QRCODE = "https://open.work.weixin.qq.com/wwopen/sso/qrConnect";
@@ -99,6 +108,8 @@ export async function ensureWecomUser(
99
108
  corp_id: corpId,
100
109
  must_change: 0,
101
110
  is_forbidden: 0,
111
+ // 走到这里说明真人正在扫码登录 → 这位是「用过本站」的活人,进通讯录目录
112
+ activated: 1,
102
113
  }).run();
103
114
  }
104
115
  catch (e) {
@@ -135,3 +146,143 @@ export function loginLandingUrl(opts: { origin: string, ticket: string, tenant:
135
146
  const q = new URLSearchParams({ wecom_ticket: opts.ticket, tenant: opts.tenant });
136
147
  return `${opts.origin}/login?${q.toString()}`;
137
148
  }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // 轨一:登录自更新(方案 §6.1)
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /** 节流窗口:一个企业上万员工每天登录都会触发,不节流就是把 user/get 打成常驻流量 */
155
+ export const SELF_UPDATE_TTL_SECS = 24 * 60 * 60;
156
+
157
+ export interface SelfUpdateCtx {
158
+ uid: number
159
+ userid: string
160
+ corpId: string
161
+ agentId: string
162
+ permanentCode: string
163
+ }
164
+
165
+ export interface SelfUpdateDeps {
166
+ client: WecomClient
167
+ cache: WecomCache
168
+ store: WecomStoreDeps
169
+ }
170
+
171
+ /** 企微在职状态里「不该再放进来」的两档:2=已禁用、5=退出企业(4=未激活仍可登录) */
172
+ function isBlockedStatus(status: number | null): boolean {
173
+ return status === 2 || status === 5;
174
+ }
175
+
176
+ async function dbExternalStatus(userid: string): Promise<number | null> {
177
+ const rows = await db.table("users").select(["external_status"]).where({ field: "username", op: "eq", value: externalUsername(userid) }).all() as Array<{ external_status: number | null }>;
178
+ const v = rows[0]?.external_status;
179
+ return v == null ? null : Number(v);
180
+ }
181
+
182
+ /** 一条成员的「外部源口径」快照:登录自更新、全量同步、事件增量三处都写这一个形状 */
183
+ export interface MemberDetail {
184
+ /** undefined = 这条数据源没给名字,保留原值(空串/空白的名字不写) */
185
+ name?: string | null
186
+ /** undefined = 没给,**不写**(别把已知职务清成 null;null = 明确为空,写 null) */
187
+ position?: string | null
188
+ /** 同上:undefined = 不知道在职状态,不能覆盖 */
189
+ status?: number | null
190
+ departments: string[]
191
+ orders?: number[]
192
+ leaderInDept?: number[]
193
+ mainDepartment?: number | null
194
+ }
195
+
196
+ /**
197
+ * 把一条成员详情写回本地:资料(姓名/职务/在职状态)+ 所属部门关联。
198
+ *
199
+ * **三轨共用这一个写入口**(登录自更新 / 全量同步 / 事件增量):两套写库逻辑必然长歪——
200
+ * 一处漏了主部门兜底、一处漏了 D13 归属权,症状都是「某类路径走过来的人组织关系不对」,
201
+ * 而且只在特定组合下才出现。所以这里只认「一条快照」,不认它从哪个轨来。
202
+ *
203
+ * **只加不摘**:人调到新部门只写新部门,旧部门不在这里删——摘除属全量同步/事件增量的职责,
204
+ * 登录路径上删关联会在「企微接口这次只返回部分部门」时把人的组织关系误删。
205
+ * **只写企微拥有的行**(D13):`source != 'wecom'`(管理员已「转为本地维护」)一律不覆盖。
206
+ */
207
+ export async function applyMemberDetail(
208
+ ctx: { uid: number, userid: string },
209
+ detail: MemberDetail,
210
+ opts: {
211
+ now: () => number
212
+ /** 真人刚登录过 → 纳入通讯录目录(§9.1)。同步与事件建档的人**不算**用过本站。 */
213
+ activated?: boolean
214
+ /** 全量同步按片预取的「企微部门 origin_id → 本地 id」;缺省时按本次涉及的部门现查 */
215
+ deptByOrigin?: Map<string, number>
216
+ },
217
+ ): Promise<void> {
218
+ const cols: Record<string, unknown> = {};
219
+ if (detail.position !== undefined)
220
+ cols.position = detail.position;
221
+ if (detail.status !== undefined)
222
+ cols.external_status = detail.status;
223
+ if (opts.activated)
224
+ cols.activated = 1;
225
+ const displayName = detail.name?.trim();
226
+ if (displayName)
227
+ cols.display_name = displayName;
228
+ if (Object.keys(cols).length) {
229
+ await db.table("users").update(cols).where({ and: [
230
+ { field: "username", op: "eq", value: externalUsername(ctx.userid) },
231
+ { field: "source", op: "eq", value: "wecom" },
232
+ ] }).run();
233
+ }
234
+
235
+ if (!detail.departments.length)
236
+ return;
237
+ const byOrigin = opts.deptByOrigin ?? await wecomDeptIndex(detail.departments);
238
+ const orgDeps = { now: opts.now };
239
+ for (const [i, originId] of detail.departments.entries()) {
240
+ const deptId = byOrigin.get(String(originId));
241
+ // D10:未知部门一律跳过,等全量同步补——这里若能建部门,任何登录成员都能伪造组织架构
242
+ if (!deptId)
243
+ continue;
244
+ await upsertMember({
245
+ userId: ctx.uid,
246
+ deptId,
247
+ orderNo: detail.orders?.[i] ?? 0,
248
+ isLeader: (detail.leaderInDept?.[i] ?? 0) === 1,
249
+ }, orgDeps);
250
+ }
251
+ // 主部门可能缺失(仅当应用对主部门有查看权限时才返回)→ 兜底取 department[0]
252
+ const mainOrigin = detail.mainDepartment != null ? String(detail.mainDepartment) : String(detail.departments[0]!);
253
+ const mainDept = byOrigin.get(mainOrigin);
254
+ if (mainDept != null)
255
+ await setMainDepartment(ctx.uid, mainDept, orgDeps);
256
+ }
257
+
258
+ async function wecomDeptIndex(originIds: string[]): Promise<Map<string, number>> {
259
+ const rows = await db.table("sys_department").select(["id", "origin_id"]).where({ and: [
260
+ { field: "origin", op: "eq", value: "wecom" },
261
+ { field: "origin_id", op: "in", value: originIds },
262
+ ] }).all() as Array<{ id: number, origin_id: string }>;
263
+ return new Map(rows.map(r => [String(r.origin_id), Number(r.id)]));
264
+ }
265
+
266
+ /**
267
+ * 登录自更新入口。**永不抛出**:任何失败只告警并放过登录(拿不到就用兜底值)。
268
+ * 返回值 `blocked` 供调用方在换会话前拦「企微侧已离职/禁用」的账号。
269
+ */
270
+ export async function selfUpdate(ctx: SelfUpdateCtx, deps: SelfUpdateDeps): Promise<{ blocked: boolean }> {
271
+ const key = `wecom:self:${ctx.corpId}:${ctx.userid}`;
272
+ try {
273
+ if (await deps.cache.get(key))
274
+ return { blocked: isBlockedStatus(await dbExternalStatus(ctx.userid)) };
275
+ const detail = await deps.client.userDetail(ctx.corpId, ctx.agentId, ctx.permanentCode, ctx.userid);
276
+ // null = 无通讯录权限 / 不在可见范围 / 上游抖:本次不更新,也别写节流键(下次登录重试)
277
+ if (!detail)
278
+ return { blocked: isBlockedStatus(await dbExternalStatus(ctx.userid)) };
279
+ // 真人刚扫码登录过 → 纳入通讯录目录(§9.1);同步/事件建档的人不算
280
+ await applyMemberDetail(ctx, detail, { now: deps.store.now, activated: true });
281
+ await deps.cache.set(key, "1", SELF_UPDATE_TTL_SECS);
282
+ return { blocked: isBlockedStatus(detail.status) };
283
+ }
284
+ catch (e) {
285
+ log.warn("wecom self-update skipped", "userid", ctx.userid, "reason", (e as Error).message);
286
+ return { blocked: false };
287
+ }
288
+ }
@@ -0,0 +1,116 @@
1
+ // 企微回调报文的入口归一化:把两种可能的包体(XML / JSON)折成同一份扁平字段表,
2
+ // 业务代码(`events.ts` / `wecom/api.ts`)只认字段名,不认格式。
3
+ //
4
+ // **按官方文档,我们这类回调收到的是 XML**:代开发 / 第三方应用所属的「通用回调配置」
5
+ // (96238 §3.2)只写 `<xml><ToUserName><Encrypt>`;`callback_format=json` 只在「对外收款」的
6
+ // 指令回调(97478)有说明。所以回调地址不带那个参数(`config.ts::eventUrlOf` 记了这段依据)。
7
+ //
8
+ // 那为什么还收 JSON:解密后的**明文**在多份业务文档里都有 JSON 版(如 92655 的 change_contact),
9
+ // 上游哪天默认切 JSON、或某类事件改发 JSON,这里不用动就能吃下。归一化只此一个入口,多一个分支
10
+ // 的成本;反过来「只吃 XML」意味着那天要回头改事件分发与端点接线。
11
+ //
12
+ // 为什么不用通用 XML 解析器:明文已由 AES-CBC + msg_signature 双重认证(crypt.ts),
13
+ // 这里只取扁平标签,且标签名由调用方常量给出——不解析结构、不处理命名空间,
14
+ // 用正则换掉一个解析器依赖与它的实体展开攻击面。
15
+ //
16
+ // 大小写:同一份报文里信封用小写(`{"tousername","encrypt","agentid"}`),密文里的明文
17
+ // 用大写(`{"SuiteId","InfoType"}`)。查字段一律走小写键,调用方不用关心这件事。
18
+
19
+ const XML_ENTITIES: Record<string, string> = {
20
+ amp: "&",
21
+ lt: "<",
22
+ gt: ">",
23
+ quot: "\"",
24
+ apos: "'",
25
+ };
26
+
27
+ function decodeEntities(s: string): string {
28
+ return s.replace(/&(amp|lt|gt|quot|apos|#\d+|#x[\da-f]+);/gi, (whole, body: string) => {
29
+ if (body[0] === "#") {
30
+ const code = body[1] === "x" || body[1] === "X"
31
+ ? Number.parseInt(body.slice(2), 16)
32
+ : Number.parseInt(body.slice(1), 10);
33
+ return Number.isFinite(code) && code > 0 && code <= 0x10FFFF ? String.fromCodePoint(code) : whole;
34
+ }
35
+ return XML_ENTITIES[body.toLowerCase()] ?? whole;
36
+ });
37
+ }
38
+
39
+ export interface CallbackFields {
40
+ /** 小写键 → 值(查字段只走这里) */
41
+ values: Map<string, string>
42
+ /** 报文里出现过的字段名(原样大小写、去重保序),只用于日志取证 */
43
+ names: string[]
44
+ }
45
+
46
+ /** 取字段;未命中空串(「没这个字段」与「字段为空」在回调处理里不需要区分) */
47
+ export function field(fields: CallbackFields, name: string): string {
48
+ return fields.values.get(name.toLowerCase()) ?? "";
49
+ }
50
+
51
+ /**
52
+ * 必填字段:缺了直接 400。文案是给运维排查用的(企微侧只会显示「应用安装失败」),
53
+ * 不含值——明文字段里有姓名与工号。
54
+ */
55
+ export function requireField(fields: CallbackFields, name: string, infoType: string): string {
56
+ const value = field(fields, name);
57
+ if (!value)
58
+ throw Object.assign(new Error(`企微事件 ${infoType} 缺少 ${name}`), { status: 400 });
59
+ return value;
60
+ }
61
+
62
+ function isCallbackFields(v: unknown): v is CallbackFields {
63
+ return typeof v === "object" && v !== null && (v as CallbackFields).values instanceof Map;
64
+ }
65
+
66
+ // 只取「叶子节点」:非 CDATA 分支明确排除 `<`,于是 `<xml><Encrypt>…</Encrypt></xml>` 里的
67
+ // 根标签匹配不上(它内部以 `<` 开头),正则会继续向内找到 `<Encrypt>`。换成 `[\s\S]*?` 会让
68
+ // 根标签把整段吞成一个字段(名叫 `xml`),内层字段全部丢失——这是最容易写错的一处。
69
+ const XML_FIELD_RE = /<([A-Z_]\w*)>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))<\/\1>/gi;
70
+
71
+ /**
72
+ * 归一回调包体。接受四种输入:已是归一结果的字段表(幂等,直接返回)、oj 已解析好的对象、
73
+ * JSON 字符串、XML 字符串。
74
+ *
75
+ * 都解不出字段时**不在这里抛错**:调用方按「缺 Encrypt」回 400,那条文案比
76
+ * 「JSON 解析失败」更好排查(能直接指向回调地址被中间件改写这种事)。
77
+ */
78
+ export function callbackFields(raw: unknown): CallbackFields {
79
+ if (isCallbackFields(raw))
80
+ return raw;
81
+ const values = new Map<string, string>();
82
+ const names: string[] = [];
83
+ const put = (name: string, value: unknown) => {
84
+ if (!names.includes(name))
85
+ names.push(name);
86
+ values.set(name.toLowerCase(), value == null ? "" : String(value));
87
+ };
88
+
89
+ if (typeof raw === "string") {
90
+ const text = raw.trim();
91
+ if (text.startsWith("{")) {
92
+ try {
93
+ for (const [k, v] of Object.entries(JSON.parse(text) as Record<string, unknown>))
94
+ put(k, v);
95
+ }
96
+ catch {
97
+ return { values, names };
98
+ }
99
+ }
100
+ else {
101
+ for (const m of text.matchAll(XML_FIELD_RE))
102
+ put(m[1]!, decodeEntities((m[2] ?? m[3] ?? "").trim()));
103
+ }
104
+ return { values, names };
105
+ }
106
+
107
+ if (raw && typeof raw === "object") {
108
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
109
+ // 嵌套对象不是企微回调的形态(字段全平铺);遇到就跳过,别把 [object Object] 塞进字段表
110
+ if (v !== null && typeof v === "object")
111
+ continue;
112
+ put(k, v);
113
+ }
114
+ }
115
+ return { values, names };
116
+ }
@@ -27,6 +27,8 @@ export interface AppProfile {
27
27
  corpName: string | null
28
28
  status: "active" | "disabled"
29
29
  permanentCode: string
30
+ /** 0=管理员授权 / 1=成员授权(成员授权下应用无任何部门权限,组织架构同步必然 60011,§2.3) */
31
+ authMode: number
30
32
  }
31
33
 
32
34
  /** 套件票据:企微每 10 分钟推一次,有效期 30 分钟;过期不得发起任何套件级 API 调用 */
@@ -120,6 +122,8 @@ export async function saveProfile(p: Omit<AppProfile, "status"> & { status?: App
120
122
  corp_name: p.corpName,
121
123
  status: p.status ?? "active",
122
124
  permanent_code_enc: deps.encrypt(p.permanentCode),
125
+ // 授权模式是「该企业应用能不能做组织架构同步」的判据(§2.3);重授权时必须跟着刷新
126
+ auth_mode: Number(p.authMode ?? 0),
123
127
  update_time: now,
124
128
  };
125
129
  if (rows.length) {
@@ -161,6 +165,43 @@ export async function findProfile(corpId: string, agentId: string | null, deps:
161
165
  corpName: r.corp_name == null ? null : String(r.corp_name),
162
166
  status: String(r.status) === "disabled" ? "disabled" : "active",
163
167
  permanentCode: deps.decrypt(String(r.permanent_code_enc)),
168
+ authMode: Number(r.auth_mode ?? 0),
169
+ };
170
+ }
171
+
172
+ /** 「这家企业能不能同步」的三个事实:装没装 / 什么授权模式 / 是否还启用 */
173
+ export interface ProfileProbe {
174
+ agentId: string
175
+ authMode: number
176
+ status: AppProfile["status"]
177
+ }
178
+
179
+ /**
180
+ * 授权探针(组织架构同步面的准入用):与 `findProfile` 只差一件事——**查不到返回 null 而不是 400**。
181
+ *
182
+ * 为什么敢把「没有」当正常状态:调用方是本租户管理员在自己的组织架构页自省,`corp_id`
183
+ * 取自租户头而不是外部输入,不存在 `findProfile` 要防的枚举面(§5.3)。「本站没装企微」
184
+ * 是一个要能画成空态的事实,不是错误。
185
+ *
186
+ * 选中口径与 `findProfile` 对齐(优选启用中的应用;全停用时退回停用行,让页面能画
187
+ * 「已取消授权」冻结态),只多一条:没有启用应用时也返回,而不是 400。
188
+ */
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>>;
191
+ if (rows.length === 0)
192
+ return null;
193
+ const active = rows.filter(r => String(r.status) === "active");
194
+ const pool = active.length > 0 ? active : rows;
195
+ const picked = agentId ? pool.filter(r => String(r.agent_id) === agentId) : pool;
196
+ if (picked.length === 0)
197
+ return null;
198
+ if (picked.length > 1)
199
+ fail(400, "该企业开通了多个应用,同步入口必须带 agent 参数");
200
+ const r = picked[0]!;
201
+ return {
202
+ agentId: String(r.agent_id),
203
+ authMode: Number(r.auth_mode ?? 0),
204
+ status: String(r.status) === "disabled" ? "disabled" : "active",
164
205
  };
165
206
  }
166
207
 
@@ -169,6 +210,30 @@ export async function setProfileStatus(corpId: string, status: AppProfile["statu
169
210
  return Number(await db.table("wecom_app_profile").update({ status, update_time: deps.now() }).where(corpScope(corpId)).run());
170
211
  }
171
212
 
213
+ /**
214
+ * 该企业已授权的应用 + 共享的永久授权码。
215
+ * 用于 `change_auth` 复核授权模式:事件只带 AuthCorpId(**没有 AuthCode**),
216
+ * 只能拿已落库的 permanent_code 去 `get_auth_info` 反查。未授权企业返回 null。
217
+ *
218
+ * 一并返回 `corpId`(真实企业 ID):事件里给的可能是 `AuthCorpId`(第三方体系下是 open_corpid),
219
+ * 而租户键只能用真实 corp_id(D10),故由这里统一把「事件身份 → 租户身份」的翻译交出来。
220
+ */
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>>;
223
+ if (rows.length === 0)
224
+ return null;
225
+ return {
226
+ corpId: String(rows[0]!.corp_id),
227
+ permanentCode: deps.decrypt(String(rows[0]!.permanent_code_enc)),
228
+ agentIds: rows.map(r => String(r.agent_id)),
229
+ };
230
+ }
231
+
232
+ /** 授权模式变更:只改 auth_mode,不动 secret / 状态(改状态是 cancel_auth 的事) */
233
+ export async function setAuthMode(corpId: string, agentId: string, authMode: number, deps: WecomStoreDeps): Promise<void> {
234
+ await db.table("wecom_app_profile").update({ auth_mode: Number(authMode), update_time: deps.now() }).where(corpAgentScope(corpId, agentId)).run();
235
+ }
236
+
172
237
  export async function putSuiteTicket(suiteId: string, ticket: string, expiresAt: number, deps: WecomStoreDeps): Promise<void> {
173
238
  const rows = await db.table("wecom_suite_ticket").select(["suite_id"]).where({ field: "suite_id", op: "eq", value: suiteId }).all();
174
239
  const cols = { suite_ticket: ticket, expires_at: expiresAt, update_time: deps.now() };