@oj-module/cli 0.2.48 → 0.2.49

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.
package/devkit/README.md CHANGED
@@ -24,7 +24,7 @@
24
24
  | 文件 | 用途 |
25
25
  |---|---|
26
26
  | `SKILL.md` | Claude Code 等 agent 的 skill 入口:工作流、红线、新模块 checklist、陷阱速查,按章节号引用手册 |
27
- | `manual.md` | 模块开发手册(13 章):工程结构(module-root 单树 + 两处产物根;含 §1.7 handler 四红线——oj 运行时实测钉出,后端必读;§1.8 `#` 别名与 TS 投影)、写模块、契约与代码生成(含 §3.10 旧 contract.ts 工程迁移指南)、provider 接管(覆盖框架内置功能)、布局、i18n、构建发布、排查、CLI 子命令参考(ojm 原生命令 vs oj 透传命令)、方案包打包与分发(含 §11.1.1 引用式方案)、签名与信任;含 §13.1.1 web.route_prefix 自定义根路径 |
27
+ | `manual.md` | 模块开发手册(14 章):工程结构(module-root 单树 + 两处产物根;含 §1.7 handler 四红线——oj 运行时实测钉出,后端必读;§1.8 `#` 别名与 TS 投影)、写模块、契约与代码生成(含 §3.10 旧 contract.ts 工程迁移指南)、provider 接管(覆盖框架内置功能)、布局、i18n、构建发布、排查、CLI 子命令参考(ojm 原生命令 vs oj 透传命令)、方案包打包与分发(含 §11.1.1 引用式方案)、签名与信任、§13.1.1 web.route_prefix 自定义根路径、**§14 组织架构与通讯录**(内置部门/成员底座 + 共享读面 7 端点 + `scope` 两级门禁 + 归属权四条规则 + 源适配器接点,选人与按部门算人都读它) |
28
28
  | `framework-dev.md` | 框架贡献者路线(**仅框架仓库**):改 `packages/runtime`、`packages/cli` 时的入口与红线 |
29
29
  | `module-dev-and-cdn-playground.md` | 教学文档:开发态 ≠ 分发态、「怎么安装自己的模块 / 会不会重复」、本地 CDN 市场链路五步跑通(setup → publish → build-index → verify → 安装) |
30
30
  | `sql-builder-missing-capabilities-pr.md` | 构造器能力缺口 PR 参考(**仅框架仓库**):7 项诉求的闭环记录 |
package/devkit/SKILL.md CHANGED
@@ -17,7 +17,8 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
17
17
 
18
18
  1. **读章**:建模块 → 手册 §2;加接口(handler .schema → oj openapi → 生成 → 对接)→ §3;
19
19
  接管通知/上传/菜单 → §4;换布局或加插槽 → §5;排查 → §8;
20
- **接通企业微信代开发(企微登录 / 多企业租户 / 代开发后台)→ `wecom-daikaifa.md`**。
20
+ **组织架构 / 通讯录(选人、按部门算收件人、接 LDAP 等新来源)→ §14**;
21
+ **接通企业微信代开发(企微登录 / 多企业租户 / 代开发后台 / 通讯录同步)→ `wecom-daikaifa.md`**。
21
22
  2. **建目录**:一个目录 = 一个模块——前端一侧 `src/<模块名>/web/`(`entry.ts` 必备,
22
23
  `pages/`、`locales/` 按需),后端一侧 `src/<模块名>/api/`(目录镜像路由的 `api.ts`,
23
24
  动词挂 `.schema`)。两侧可只存在一侧。
@@ -73,6 +74,12 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
73
74
  **拿用户/角色等信息:读走 `useUserStore` / `useAuthStore` / `useAccess`(`AccessControl` 门禁),
74
75
  写走契约 client(`ojm api` 生成模块自有 `__client`,或复用 runtime 导出的 `api/rbac` / `api/user`),
75
76
  绝不 import 框架 `#src/*`**——设计动机与红线见手册 §2.8。
77
+ - **组织架构/通讯录是内置底座,别自己搭**:部门与成员是 `_platform` 的两张租户表
78
+ (`sys_department` / `sys_dept_member`),外部来源(企微 / LDAP)只是往这套模型灌数据的**适配器**。
79
+ 要「谁在哪个部门」用共享读面 `/api/_platform/org/*` 或后端直调
80
+ `#/_platform/_shared/service/domain/org-read`(`resolveReadFace` 一次做完守卫);要「某部门含下级的所有人」
81
+ 用 `org/resolve`。**别自己建部门表、别自己调企微通讯录接口、别拉全树在 JS 里递归**。
82
+ 跨模块别名须在自己模块 `manifest.yaml` 的 `deps` 里声明 `_platform: ^0.1.0`。详见手册 §14。
76
83
  - **后端方法名是 `del`,不是 `delete`**(写成 `delete` 返回 405)。
77
84
  - **加一张新表是三件套,不是只写 `schema.yaml`**:`src/<模块>/api/migrations/{seq:04}__*.sql`(DDL 的真正定义(实际 SQL),
78
85
  DDL 只进 migrations)+ `schema.yaml`(声明,与 migration 逐字段一致)+ `manifest.yaml` 的 `tables:`
@@ -109,6 +116,11 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
109
116
  同企业测试+正式两个应用是两行(主键含 `agent_id`),此时登录链接必须带 `&agent=`。
110
117
  - **整页跳转拼 API base**:`window.location.assign(resolveApiBase() + wecomAuthorizePath(corp))`;
111
118
  拼站点 base 会被 SPA 兜底页吞掉(页面原地重载、一个请求都不发,控制台无报错)。
119
+ - **通讯录同步是「组织架构底座 + 企微适配器」,不是企微专属功能**:模板的
120
+ `src/auth/api/wecom/{sync,contacts}.ts` 只负责「取快照 → 喂给领域层」,模型与读面在 `_platform`
121
+ (手册 §14)。要按部门算人、要选人、要给组织架构接 LDAP,一律读 §14,不要在企微代码里加分支。
122
+ 同步/事件增量与回调解密是**两条独立纪律**:回调必须回 `success`,而事件处理失败**也照样回 `success`**
123
+ (重推补不回已判死的失败,兜底是下一轮全量同步)。
112
124
 
113
125
  ## 新模块 checklist
114
126
 
@@ -172,14 +184,21 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
172
184
  | 限速/节流/计数真实请求下永不累积(测试全绿) | 模块级 `Map`/`let` 不跨请求存活(每请求重建模块) | 状态落库(模板迁移 0009 先例)或 oj 持久面 |
173
185
  | `ojm build` 报 `introspect api.ts: jwt not configured` | 模块级语句碰运行时全局,introspect 空沙箱炸 | 移进 handler 函数体内(manual §1.7-4) |
174
186
  | 页签怎么点都关不掉、无报错提示 | `beforeClose` 守卫 fail-safe 否决(守卫抛错按取消处理) | 先查控制台 `[tabs] beforeClose` 报错;确认弹窗逻辑与 `resolve(true/false)` 收口(manual §6.4) |
187
+ | 组织架构读面 403「该部门不在可见范围内」 | `scope` 默认 `self`,只看得到自己那条部门路径 | 换 `scope=all`(需权限点 `platform.org.read.all`),或改问自己所在部门(manual §14.2) |
188
+ | 组织架构读面 400「只展开自己所在部门」 | `includeSub` 用在了祖先/兄弟部门上(self 档只允许展开自己所在部门) | 收窄到「我所在部门」,或申请 `scope=all`(manual §14.2) |
189
+ | `oj build` 报 `#/_platform/...` 找不到模块 | 跨模块别名没在 `manifest.yaml` 的 `deps` 里声明 | 加 `_platform: ^0.1.0`(manual §14.2) |
190
+ | 同步一轮之后本地手建的部门消失了 | 适配器漏了 `origin` 条件(同步与回收都必须只动自己的行) | `markSeen` / `reconcileHidden` 带 `origin`;回收只在「本轮完整零跳过」时执行(manual §14.5) |
191
+ | 组织架构里有这个人,通讯录搜不到 | 他是同步建档(`activated=0`,从没登录过本站),通讯录只出活人 | 正确行为;让他登录一次,或走组织架构读面(manual §14.6) |
175
192
 
176
193
  ## 手册
177
194
 
178
- `manual.md`(同目录)共 13 章:1 工程结构(module-root 单树 / 两处产物根 / **§1.7 handler 四红线** /
195
+ `manual.md`(同目录)共 14 章:1 工程结构(module-root 单树 / 两处产物根 / **§1.7 handler 四红线** /
179
196
  **§1.8 `#` 别名与 TS 投影**——oj 运行时实测钉出,后端 handler 必读)/ 2 写前端模块 /
180
197
  3 契约与代码生成 / 4 接管框架内置功能(provider)/ 5 布局与插槽 / 6 i18n 与样式 / 7 构建与发布 /
181
198
  8 排查 / 9 参考 / 10 CLI 子命令参考(ojm 原生命令 vs oj 透传命令的差异区分)/
182
- 11 方案包打包与分发 / 12 签名与信任 / 13 方案菜单归组规则。
199
+ 11 方案包打包与分发 / 12 签名与信任 / 13 方案菜单归组规则 /
200
+ 14 组织架构与通讯录(共享读面 7 端点、`scope` 两级门禁、归属权与「转为本地维护」、
201
+ 源适配器接点与三条禁令——选人 / 按部门算人 / 接 LDAP 都读它)。
183
202
 
184
203
  另有 `wecom-daikaifa.md`(同目录):企业微信代开发集成的凭据清单与三角色手册——凭据从哪取、
185
204
  怎么填、按什么顺序开通、用户/开发/部署各自怎么做、错误文案怎么查。**接企微时先读它**。
package/devkit/manual.md CHANGED
@@ -2126,6 +2126,202 @@ const paths = createWikiPaths(getModuleRoutePrefix("wiki"));
2126
2126
 
2127
2127
  ---
2128
2128
 
2129
+ ## §14 组织架构与通讯录(直接用现成底座,别自己搭)
2130
+
2131
+ 站点内置一套组织架构底座:**部门 / 成员 / 归属权**由 `_platform` 内核负责,外部通讯录
2132
+ (企业微信,将来 LDAP)只是往这套底座灌数据的**适配器**。模块的活儿只有两种:
2133
+ **读**(谁在哪个部门)或**算**(某部门含下级一共有谁)。
2134
+
2135
+ 命中任一条就来本章,别另起一套:
2136
+
2137
+ | 需求 | 该用 | 别做 |
2138
+ |---|---|---|
2139
+ | 页面要选人 / 选部门 | 共享读面 `/api/_platform/org/*`(§14.2) | 自己查 `users` 全表、前端自己拼树 |
2140
+ | 通知 / 审批要「某部门及其下级的所有人」 | `POST /api/_platform/org/resolve`(或后端直调 `resolveUserIds`) | 前端拉全树、在 JS 里递归 |
2141
+ | 只是「找个人」 | 通讯录 `searchContacts`(§14.6) | 拿组织架构树当通讯录用 |
2142
+ | 要给组织架构接一个新来源(LDAP / 钉钉 / HR) | 领域层适配器接点(§14.5) | 另建一套表;或把外部字段写进 `sys_department` |
2143
+
2144
+ ### 14.1 数据在哪:两张租户表 + `users` 加三列
2145
+
2146
+ | 表 | 关键列 | 语义 |
2147
+ |---|---|---|
2148
+ | `sys_department` | `id` / `parent_id` / `name` / `name_en` / `order_no` | 结构:**`parent_id` 是本地 id,不是外部 id** |
2149
+ | | `origin` / `origin_id` | 归属权 + 外部主键。唯一约束是 `(tenant_id, origin, origin_id)` |
2150
+ | | `sync_visible` / `deleted` / `seen_job_id` | 同步态:可见性只由同步轮次维护;软删只由外部源的删除事件置;`seen_job_id` 是墓碑回收的正面证据 |
2151
+ | `sys_dept_member` | `user_id` / `dept_id` / `order_no` / `is_leader` / `is_main` | 多对多。**全是本地 id,与来源无关**——接任何适配器都不用动这张表 |
2152
+ | `users` | `activated` / `position` / `external_status` | 见下方第二段 |
2153
+
2154
+ 两条必须记住的语义:
2155
+
2156
+ 1. **`parent_id` 是本地 id**。本地手建的部门可以和同步来的部门混挂在一棵树上,外部重构层级
2157
+ 也不会打乱你本地的结构。
2158
+ 2. **`origin` 是归属权,不是「来源标签」**。`local` = 本站管理员全权 CRUD;
2159
+ `wecom` / `ldap` / `sso` = 对应适配器管辖。**任何同步与回收只作用于自己 `origin` 的行**——
2160
+ 这是「本地建的部门不会被第一轮同步抹掉」的根本机制,也是适配器可插拔与本地 CRUD 能共存的唯一原因。
2161
+
2162
+ `users` 那三列的用途别搞混:`activated` 是「**这个号被真人用过**」的证据(管理员建号 / 各来源登录
2163
+ 建档置 1,**同步建档默认 0**);`position` 是职务;`external_status` 是外部源的在职状态快照
2164
+ (**绝不自动改写 `is_forbidden`**,否则会和本站管理员的手动启停互相覆盖)。
2165
+
2166
+ ### 14.2 后端复用:共享读面(推荐直调领域函数)
2167
+
2168
+ 7 个端点,全在 `_platform` 模块下(前缀 `/api/_platform`):
2169
+
2170
+ | 端点 | 出什么 | 关键参数 |
2171
+ |---|---|---|
2172
+ | `GET /org/tree` | 部门**平表**(前端按 `parentId` 组装)+ 成员数 | `scope` |
2173
+ | `GET /org/departments` | 部门平铺分页(大企业别一次拉全树) | `scope`、`parent`(`0` = 只取根层)、`keyword`、`page`、`size`≤200 |
2174
+ | `GET /org/members` | 成员分页 + 部门名路径 | `scope`、`dept`、`includeSub`、`keyword`、`page`、`size` |
2175
+ | `GET /org/user` | 某人的部门路径 / 主部门 / 职务 | `scope`、`uid`(不给即自己) |
2176
+ | `GET /org/picker` | 人 + 部门混合(选人组件一次拿全) | `keyword`(**必填且 ≥2**)、`limit`≤50 |
2177
+ | `POST /org/resolve` | 部门(含子部门)→ `userIds`(去重升序) | body `deptIds` / `userIds` / `includeSub` |
2178
+ | `GET /org/is-member` | 某人是否在某部门(含子部门)下 | `dept` 必填、`uid`(不给即自己) |
2179
+
2180
+ **两级门禁**(默认最小集,要全量请申请权限点):
2181
+
2182
+ - `scope=self`(默认):只看「我在哪」——我所在部门 + 它的祖先链,以及这些部门里的直属成员。
2183
+ **不含兄弟部门**:组织架构含层级 + 人名,比通讯录敏感得多,且同步会把「从未登录本站、
2184
+ 无从同意」的全员灌进来。
2185
+ - `scope=all`:全量,需权限点 `platform.org.read.all`(`superadmin` / `admin` 天然通过)。
2186
+
2187
+ 三条硬限制(踩了只会觉得「接口坏了」):
2188
+
2189
+ - 限速 60 次/分/人(键 `org:<uid>`);批量场景用 `resolve` / 分页,别循环打单条。
2190
+ - `keyword` 一律 ≥2 字:读面是**枚举面**,单字能把整份名册拖走——与通讯录同一条红线。
2191
+ - `includeSub` 在 `self` 档**只对「我所在部门」生效**:对祖先部门请求展开会 **400**。
2192
+ 这是刻意的——祖先一展开就是整棵子树,等于绕过自身路径限制;宁可报错也不静默截断成
2193
+ 「看着对其实少人」的结果。
2194
+
2195
+ 字段口径:读面**一律不出** `origin` / `is_leader` / `external_status`,且**返回字段与 `scope` 无关**。
2196
+ 消费方不用按档位分支解析——归属与在职是管理语义,不归消费面。
2197
+
2198
+ **后端直调**(同进程内,不走 HTTP,省一次序列化与鉴权):
2199
+
2200
+ ```ts
2201
+ import { resolveReadFace, resolveUserIds } from "#/_platform/_shared/service/domain/org-read";
2202
+
2203
+ async function post() {
2204
+ await respond(async () => {
2205
+ // 登录态 → 租户自洽 → 限速 → scope 解析 → all 档验权限 → 算可见集,一次做完
2206
+ const reader = await resolveReadFace((http.query as Record<string, unknown>).scope);
2207
+ const userIds = await resolveUserIds(reader, { deptIds: [42], includeSub: true });
2208
+ return { userIds };
2209
+ });
2210
+ }
2211
+ ```
2212
+
2213
+ `resolveReadFace` 是那 7 个端点**共用的守卫入口**——自己写 handler 也用它,别重抄一遍守卫
2214
+ (重抄必然长歪;它内部的顺序是「先租户自洽再限速」,伪造租户头的人不该消耗别人的限速窗口)。
2215
+
2216
+ > ⚠️ 跨模块别名要在**自己模块** `manifest.yaml` 的 `deps` 里声明 `_platform: ^0.1.0`,
2217
+ > 否则 `oj build` 构建期直接报错(§1.8)。这是「跨模块依赖」的门禁,不是可选的注释。
2218
+
2219
+ ### 14.3 前端怎么用:给自己的模块出端点,别借别人的 client
2220
+
2221
+ 前端模块能 import 的只有三类(§2.6),**别人的 `__client` 与 `#/_platform` 都不在其中**。
2222
+ 所以前端消费组织架构的唯一正路是「借壳」:
2223
+
2224
+ 1. 在**自己模块**的 handler 里调 `resolveReadFace` + 需要的那几个读函数(§14.2);
2225
+ 2. 跑 `oj openapi` + `ojm api`,生成本模块的 `src/<模块>/web/__client/api.ts`;
2226
+ 3. 页面用本模块 client 调。
2227
+
2228
+ 多一层 handler 换来三件事:接口形状由**你自己的契约**决定(只出页面要的字段)、框架内部实现不进你的
2229
+ bundle、契约变更走你自己的版本号。仓库里 `src/system/web/pages/departments` 的组织架构页就是这条路的
2230
+ 另一个方向(它消费的是管理端 `/api/system/departments`,见 §14.4)。
2231
+
2232
+ ### 14.4 管理端 CRUD:只有「管组织架构」的模块才该碰
2233
+
2234
+ 管理端在 `system` 模块下,一律 `assertSystemAdmin` 门禁:
2235
+
2236
+ | 端点 | 用途 |
2237
+ |---|---|
2238
+ | `GET /api/system/departments` | 部门平表 + 成员数(**含**不可见与软删壳,管理员要看全量) |
2239
+ | `POST` / `PUT` / `DELETE /api/system/departments` | 增 / 改 / 删(本地部门才可删;有子部门时**拒绝**而不是级联删) |
2240
+ | `PUT /api/system/departments/move` | 改父节点 |
2241
+ | `POST /api/system/departments/detach?id=` | **「转为本地维护」**:`origin` 改为 `local`,此后同步不再改写这一行 |
2242
+ | `GET` / `POST` / `PUT` / `DELETE /api/system/departments/members?id=` | 部门成员的增删查改 |
2243
+
2244
+ 四条归属规则(写在领域层,绕不过去):
2245
+
2246
+ 1. **本地的行**(`origin='local'`):全权 CRUD。
2247
+ 2. **外部源的行**:结构字段(名称 / 层级 / 删除)**不可本地改**——改了下一轮同步就被覆盖,
2248
+ 所以直接拒。加人/移人不受限:「谁在这个部门」本站始终可调。
2249
+ 3. **要接管某一行**就用「转为本地维护」(`detach`);反向操作(转回外部源)**不提供**——
2250
+ 转回去后下一次同步立刻按源口径改写,语义不可控。
2251
+ 4. **删本地部门是软删壳还是真删**:本地删除走真删(有子部门则拒绝);`deleted=1` 只由外部源的
2252
+ 删除事件置,两者别混用——「看不见」和「没了」不是一回事。
2253
+
2254
+ ### 14.5 自己写一个源适配器(企微是现成范本)
2255
+
2256
+ 领域层**完全不认企微**:所有写路径都以「源」为参数。新适配器只需实现「取快照 → 喂给这几个函数」,
2257
+ 领域层与读面一行都不用改。
2258
+
2259
+ | 接点(`#/_platform/_shared/service/domain/...`) | 用途 | 纪律 |
2260
+ |---|---|---|
2261
+ | `org::upsertDepartment({ origin, originId, parentId, name, orderNo })` | 按 `(origin, origin_id)` 幂等写部门,返回本地 id | 只写结构字段;**不动** `sync_visible` / `deleted` / `origin` |
2262
+ | `org::upsertMember({ userId, deptId, ... })` | 按 `(user_id, dept_id)` 幂等写成员关联 | |
2263
+ | `org::markSeen(origin, deptIds, jobId, deps)` | 标记「本轮同步命中」,并把 `sync_visible` 拉回 1 | 必带 `origin`,否则会误伤本地行 |
2264
+ | `org::reconcileHidden(origin, jobId, deps)` | 墓碑回收:上一轮见过、这一轮没见 → 置不可见 | **只在调用方确认本轮完整(零跳过)后调用** |
2265
+ | `org::markDeleted(origin, originId, deps)` | 外部源侧的删除事件 → `deleted=1`,**级联整棵子树** | 这是 `deleted=1` 的唯一写入点 |
2266
+ | `users::upsertSourceUser({ username, displayName, source, corpId })` | 外部源建档,返回 uid | `activated=0`(目录档);已「转为本地维护」的行一个字段都不改 |
2267
+
2268
+ **一次全量同步的顺序**(照抄即可):
2269
+
2270
+ ```
2271
+ 抢锁(本地租约,可抢回崩溃残留)
2272
+ → 遍历部门:upsertDepartment → markSeen
2273
+ → 遍历成员:upsertSourceUser → applyMemberDetail(写姓名/职务/在职 + 部门关联)
2274
+ → 只有「本轮完整且零跳过」才 reconcileHidden
2275
+ → 放锁,记录统计
2276
+ ```
2277
+
2278
+ 三条禁令(每一条都对应一次真实事故形状):
2279
+
2280
+ 1. **墓碑回收前必须确认本轮完整**。上游抖一下就被读成「这些部门被删了」,表现是组织架构大面积消失。
2281
+ 权限类错误(接口无权限)要**计数并跳过**、限流类要退避重试,别混进「完整」的判据里。
2282
+ 2. **事件增量是加速器,不是权威**。事件处理**任何失败都不回非 `success`**——上游拿不到 `success`
2283
+ 会重推三次,而重推补不回一次已经判死的失败,只会让企业管理员在后台看到「应用安装失败」;
2284
+ 真正的兜底永远是下一轮全量同步。这条与「回调必须回 `success`」是一体两面。
2285
+ 3. **别复用别家的任务表**。企微那对 `wecom_sync_job` / `wecom_sync_lock` 带 `corp_id` / `agent_id` 列,
2286
+ LDAP 直接复用会被这两个字段绑死——**先另建 `ldap_sync_*` 把模型跑通**,两个源都成立再谈泛化
2287
+ (泛化要改已发布的迁移,代价远大于两张小表)。
2288
+
2289
+ **接 LDAP 前必须先回答的问题**:oj 的 `axis("ldap")` 有没有等价 `ldapsearch` 的遍历能力。
2290
+ 现状 `auth/.../ldap-login` 只有**认证**、没有目录同步;没有 search 就只能裸连或自带 client。
2291
+ 这是开工第一天的第一个问题,别边写边猜。
2292
+
2293
+ **验收**(这组断言能同时证明「适配器隔离」与「本地 CRUD 保底」):
2294
+ LDAP 侧改名 / 删人后,`origin='ldap'` 的行跟着变,而 `origin='wecom'` 的行与本地手建的行
2295
+ **一行不动**;本地手建的部门没有被同步覆盖。
2296
+
2297
+ ### 14.6 通讯录 ≠ 组织架构
2298
+
2299
+ | | 通讯录 `searchContacts` / `contactDetail` | 组织架构读面 `/org/*` |
2300
+ |---|---|---|
2301
+ | 形状 | 扁平(id / username / 显示名;详情才给 email / 头像) | 层级(部门树 + 成员归属 + 部门路径) |
2302
+ | 谁可见 | 登录即可,`keyword` ≥2、限速 | 登录 + `scope` 门禁(默认只看自己那条路径) |
2303
+ | **只出活人** | ✅ 只出 `activated=1` | 出全部可见成员(含同步灌进来的目录档) |
2304
+ | 典型用途 | 「找个人」「选人」 | 「算收件人」「按部门做访问控制」 |
2305
+
2306
+ 通讯录那条「只出 `activated=1`」不是优化而是**红线**:同步会把从未登录本站的全员建档,
2307
+ 若通讯录也出这批号,任何登录账号都能用 `keyword`(60 次/分)把整份企业名册遍历走。
2308
+ 所以「同步来的号不出现在通讯录里」是**正确行为**,不是 bug——要全员可见,让那个人先登录一次,
2309
+ 或者走组织架构读面(有 `scope` 门禁)。
2310
+
2311
+ ### 14.7 症状 → 病根 → 药方
2312
+
2313
+ | 症状 | 病根 | 药方 |
2314
+ |---|---|---|
2315
+ | 读面 403「该部门不在可见范围内」 | `scope` 是默认的 `self`,那部门不在你自己那条路径上 | 换成 `scope=all`(需 `platform.org.read.all`),或改问自己所在部门 |
2316
+ | 读面 400「只展开自己所在部门」 | `includeSub` 撞上了祖先部门或兄弟部门 | 只在「我所在部门」上展开;要全量就申请权限点 |
2317
+ | `oj build` 报 `#/_platform/...` 找不到模块 | `manifest.yaml` 的 `deps` 没声明 `_platform` | 加 `_platform: ^0.1.0` |
2318
+ | 同步后本地手建的部门「不见了」 | 适配器漏了 `origin` 条件(`markSeen` / `reconcileHidden` 都必带) | 带上 `origin`;回收只在完整轮次执行 |
2319
+ | 管理员改不动某个部门(400) | 该行 `origin` 不是 `local` | 先「转为本地维护」(`POST /api/system/departments/detach?id=`) |
2320
+ | 组织架构页有人、通讯录搜不到他 | 那人是同步建档(`activated=0`),从没登录过本站 | 正确行为,见 §14.6 |
2321
+ | 同步进度条一开就 429 | 限速按「单片 12 秒打满 → 天然 5 次/分」配平(给 10 次余量),自己并发打就超了 | start/cancel 串行驱动;status 不限速,可 1~2 秒轮询 |
2322
+
2323
+ ---
2324
+
2129
2325
  ## 附录:章节目录
2130
2326
 
2131
2327
  | 章 | 内容 | 什么时候读 |
@@ -2143,3 +2339,4 @@ const paths = createWikiPaths(getModuleRoutePrefix("wiki"));
2143
2339
  | §11 方案包:打包与分发 | build --bundle、Form A/B、分发源、索引规范、引用式方案(api 段省略 tables/deps,引用包内原生 manifest) | 发布方案包 |
2144
2340
  | §12 签名与信任 | keygen/sign/install 发布流程、信任库、TOFU、bootstrap key | 首次签名发布;管理信任 |
2145
2341
  | §13 方案菜单归组规则 | 单根模块约定(根路由=第一级菜单)、自定义根路径 `web.route_prefix`(构建写 modules.json、运行时只改根段、getModuleRoutePrefix 单点消费)、category 三值(普通/hidden/admin)、admin 真重写 `/system/<模块 root>/*` + 视觉归组、menu.main 统一、模块内路径助手、开发者 DO/DON'T | 写方案要让菜单按模块分组、admin 归系统设置 |
2342
+ | §14 组织架构与通讯录 | 内置底座(两张租户表 + `users` 三列)、共享读面 7 端点与两级 `scope` 门禁、前端怎么「借壳」出自己端点、归属权四条规则与管理端 CRUD、源适配器接点与三条禁令(企微为范本,LDAP 前置)、通讯录与组织架构的分工 | 页面要选人/选部门、通知要按部门算收件人、要接新的组织数据来源 |
@@ -330,6 +330,11 @@ pnpm dev
330
330
 
331
331
  ### 6.3 数据面:三张共享表 + 五张租户表
332
332
 
333
+ > **模型归 `_platform`,企微只是适配器**:`sys_department` / `sys_dept_member` 与共享读面
334
+ > `/api/_platform/org/*` 属于**组织架构底座**——不接企微的部署也有它,接 LDAP 也只是换一个喂数据的源。
335
+ > 要选人、要按部门(含下级)算收件人、要给组织架构加新来源,读 `manual.md` 的 **§14**;
336
+ > 本节只讲企微这一半的落库事实与坑。
337
+
333
338
  | 表 | 是否租户表 | 存什么 | 为什么这样放 |
334
339
  |---|---|---|---|
335
340
  | `wecom_app_profile` | **共享**(`tenant: false`) | 企业 × 应用 → 密文 `permanent_code`、`corp_name`、`env`、`status`、`auth_mode` | 它是「corp → 租户」映射的**权威表**;登录前还没确定租户,必须是部署级 |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/cli",
3
3
  "type": "module",
4
- "version": "0.2.48",
4
+ "version": "0.2.49",
5
5
  "description": "React Antd 模块工程命令行工具(含预构建宿主 shell-dist)",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -49,7 +49,7 @@
49
49
  ],
50
50
  "dependencies": {
51
51
  "@faker-js/faker": "^10.4.0",
52
- "@oj-module/runtime": "0.2.48",
52
+ "@oj-module/runtime": "0.2.49",
53
53
  "@redocly/cli": "^2.51.0",
54
54
  "@tailwindcss/vite": "^4.3.0",
55
55
  "cac": "^6.7.14",
@@ -304,10 +304,10 @@ function getAppInfo() {
304
304
  return {
305
305
  "pkg": {
306
306
  "name": "@oj-module/runtime",
307
- "version": "0.2.48",
307
+ "version": "0.2.49",
308
308
  "license": "MIT"
309
309
  },
310
- "lastBuildTime": "2026-11-01 10:19:28"
310
+ "lastBuildTime": "2026-11-01 17:14:09"
311
311
  };
312
312
  }
313
313
  var init_get_app_info = __esmMin((() => {}));
@@ -5,9 +5,9 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" href="/favicon.ico" />
7
7
  <title>React Antd Admin · Shell</title>
8
- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-3nM+tS10yKYnXNwkhSrSkw=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-E48VJYAFCfSkkLa0nv6GVw=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
9
9
 
10
- <script type="importmap" nonce="3nM+tS10yKYnXNwkhSrSkw==">{
10
+ <script type="importmap" nonce="E48VJYAFCfSkkLa0nv6GVw==">{
11
11
  "imports": {
12
12
  "react": "/assets/react.js",
13
13
  "react/jsx-runtime": "/assets/jsx-runtime.js",
@@ -7,9 +7,9 @@
7
7
  "react-router": "7.18.2",
8
8
  "react-router/dom": "7.18.2",
9
9
  "@tanstack/react-query": "5.102.8",
10
- "@oj-module/runtime": "0.2.48",
11
- "@oj-module/runtime/contract": "0.2.48",
12
- "@oj-module/runtime/contract/errors": "0.2.48",
10
+ "@oj-module/runtime": "0.2.49",
11
+ "@oj-module/runtime/contract": "0.2.49",
12
+ "@oj-module/runtime/contract/errors": "0.2.49",
13
13
  "@plane/kit": "0.1.20",
14
14
  "@plane/editor": "0.1.20",
15
15
  "antd": "6.6.1",
@@ -116,3 +116,83 @@ describe("given module-root 布局 When ojm build 后端", () => {
116
116
  expect(existsSync(join(root, "dist", "api"))).toBe(false);
117
117
  });
118
118
  });
119
+
120
+ /**
121
+ * 假 oj:`secret open` 按 openOk 成败(失败时复述 oj 的真实 stderr),`build` 记 argv 后成功。
122
+ * 只记非 secret 调用,便于断言「预检之后 build 是否真的跑了」。
123
+ */
124
+ function fakeOjSeal(projectRoot: string, openOk: boolean): void {
125
+ mkdirSync(join(projectRoot, "bin"), { recursive: true });
126
+ const bin = join(projectRoot, "bin", "oj");
127
+ writeFileSync(bin, [
128
+ "#!/bin/sh",
129
+ "if [ \"$1\" = \"secret\" ]; then",
130
+ ...(openOk ? [] : [" echo 'oj secret open: aes-gcm open failed (sealed value tampered?): aead::Error' >&2"]),
131
+ ` exit ${openOk ? 0 : 1}`,
132
+ "fi",
133
+ "printf '%s\\n' \"$*\" >> \"$(dirname \"$0\")/invoked\"",
134
+ "exit 0",
135
+ "",
136
+ ].join("\n"), { mode: 0o755 });
137
+ }
138
+
139
+ /** flat 布局 + 已密封的 config(路径照 ojm init 的产物写) */
140
+ function newSealedProject(): string {
141
+ const root = newProject();
142
+ writeFileSync(
143
+ join(root, "api", "config.yaml"),
144
+ "auth:\n jwt_secret: \"ENC[AgE0ZMU2aDKo5CHab4Kl_TtJduXQLwheLVczTPcvm2g]\"\n"
145
+ + "secrets:\n private_key_path: ./config/secrets-private.pem\n",
146
+ "utf-8",
147
+ );
148
+ return root;
149
+ }
150
+
151
+ describe("given config 含密封值 When ojm build 后端", () => {
152
+ // oj build 读不动 config 时只 warn 两行、退出码 0,继续出包时 sql_guard 是关掉的。
153
+ // ojm 必须在把 oj 叫起来之前就停下,否则「没跑过 tenant 校验的包」会流到部署机上。
154
+ it("then 解不开就停在出包前,不当成一次成功构建", async () => {
155
+ const root = newSealedProject();
156
+ fakeOjSeal(root, false);
157
+
158
+ await expect(buildBackend(root)).rejects.toThrow(/密封值解不开/);
159
+ await expect(buildBackend(root)).rejects.toThrow(/secrets-private\.pem/);
160
+ expect(existsSync(join(root, "bin", "invoked"))).toBe(false); // 没跑 oj build
161
+ });
162
+
163
+ it("then 解不开时把 oj 的原话带上(排障不用再跑一遍)", async () => {
164
+ const root = newSealedProject();
165
+ fakeOjSeal(root, false);
166
+
167
+ await expect(buildBackend(root)).rejects.toThrow(/aes-gcm open failed/);
168
+ });
169
+
170
+ it("then 能解开就照常出包", async () => {
171
+ const root = newSealedProject();
172
+ fakeOjSeal(root, true);
173
+
174
+ expect(await buildBackend(root)).toBe(true);
175
+ expect(readFileSync(join(root, "bin", "invoked"), "utf-8")).toContain("build -c ");
176
+ });
177
+
178
+ it("then config 无密封值时不额外调用 oj(预检零成本)", async () => {
179
+ const root = newProject(); // config 只有 server.port
180
+ fakeOjSeal(root, false);
181
+
182
+ expect(await buildBackend(root)).toBe(true);
183
+ expect(readFileSync(join(root, "bin", "invoked"), "utf-8")).not.toContain("secret");
184
+ });
185
+
186
+ it("then 注释掉的旧密文不算数(不因一行 `#` 挡住构建)", async () => {
187
+ const root = newProject();
188
+ writeFileSync(
189
+ join(root, "api", "config.yaml"),
190
+ "# auth:\n# jwt_secret: \"ENC[AgE0ZMU2aDKo5CHab4Kl_TtJduXQLwheLVczTPcvm2g]\"\nserver:\n port: 9778\n",
191
+ "utf-8",
192
+ );
193
+ fakeOjSeal(root, false);
194
+
195
+ expect(await buildBackend(root)).toBe(true);
196
+ expect(readFileSync(join(root, "bin", "invoked"), "utf-8")).not.toContain("secret");
197
+ });
198
+ });
package/src/build.ts CHANGED
@@ -584,6 +584,9 @@ export async function buildBackend(projectRoot: string): Promise<boolean> {
584
584
  // v0.1.85:`--module-root <dir>` 与 `-d` 互斥(都是「源码树根」)。新布局给 src,
585
585
  // oj 在子树内递归找 manifest.yaml(命中 src/<name>/api/manifest.yaml 即停)。
586
586
  const sourceArgs = layout?.moduleRoot ? ["--module-root", layout.moduleRoot] : ["-d", apiSrc];
587
+ // 出包前先自己确认 config 能读:读不动时 oj build 会静默关掉 sql_guard(见函数注释)
588
+ if (configArgs.length)
589
+ assertSealedValuesOpenable(oj, configPath);
587
590
  execFileSync(oj, [
588
591
  "build",
589
592
  ...configArgs,
@@ -604,6 +607,53 @@ export async function buildBackend(projectRoot: string): Promise<boolean> {
604
607
  return true;
605
608
  }
606
609
 
610
+ /**
611
+ * config 里的密封值(`oj secret seal` 产物)在 build 前试解一把——fail-closed。
612
+ *
613
+ * **为什么不能只信 oj 的 warn**:实测(oj 0.1.115)config 读不动时 `oj build` **退出码仍是 0**,
614
+ * 只往 stderr 甩两行「读配置失败 → 镜像目录回落默认 tasks / 本次 build 按 sql_guard=off 处理」,
615
+ * 然后照常出包。那两行夹在模块清单里极易被忽略,而关掉的 `sql_guard` 正是 tenant 声明校验
616
+ * (跨模块 SQL 的唯一守卫)——包看起来完好,检查其实没跑。
617
+ *
618
+ * 探法就是 oj 自己的排障命令 `oj secret open`:它和 `oj serve` / `oj build` 用同一套密钥解析
619
+ * (`OJ_SECRET_KEY` → `OJ_SECRET_KEY_FILE` → config 的 `secrets.private_key_path`),
620
+ * 解不开就非零退出。明文只可能出现在它的 stdout,这里直接丢弃,不落终端也不落日志。
621
+ */
622
+ function assertSealedValuesOpenable(oj: string, configPath: string): void {
623
+ // 注释行不算:config 里留着注释掉的旧密文很常见,naive 匹配会让 build 因为一行 `#` 而失败
624
+ const live = fs.readFileSync(configPath, "utf-8")
625
+ .split("\n")
626
+ .filter(line => !line.trimStart().startsWith("#"))
627
+ .join("\n");
628
+ const sealed = [...new Set(live.match(/ENC\[[^\]\n]+\]/g) ?? [])];
629
+ for (const value of sealed) {
630
+ try {
631
+ execFileSync(oj, ["secret", "open", "-c", configPath, value], { stdio: ["ignore", "ignore", "pipe"] });
632
+ }
633
+ catch (e) {
634
+ const stderr = String((e as { stderr?: unknown }).stderr ?? "").trim();
635
+ const detail = stderr || (e as Error).message;
636
+ throw new Error(
637
+ `[ojm] config 里的密封值解不开,已停在出包前:${configPath}\n`
638
+ + ` oj 说:${detail}\n`
639
+ + "\n"
640
+ + "不能放行:oj build 遇到读不动的 config 不会失败(退出码 0),只 warn 两行就继续——\n"
641
+ + "tasks 镜像目录回落默认值,sql_guard(tenant 声明校验)直接关掉。那种包看着正常,\n"
642
+ + "跨模块 SQL 的守卫却没跑过。\n"
643
+ + "\n"
644
+ + "两类原因,都修得了:\n"
645
+ + " 1) 本机加载的私钥与密封时用的公钥不是一对——最常见是换机器/换部署目录时只带了\n"
646
+ + " config.yaml,没带 config/secrets-private.pem;\n"
647
+ + " 2) 密文被改过——手工编辑、复制被截断、编辑器把长行折了。\n"
648
+ + "\n"
649
+ + "单独验一条:oj secret open -c <config> '<那个 ENC[...] 值>'(成功=这条没问题,失败=就是它)\n"
650
+ + "修法:把配对私钥带到本机(OJ_SECRET_KEY / OJ_SECRET_KEY_FILE / config 的 secrets.private_key_path\n"
651
+ + " 三选一);密钥确实换了,就在本机重新 oj secret seal 覆写该值(明文走 stdin,别上命令行)。",
652
+ );
653
+ }
654
+ }
655
+ }
656
+
607
657
  /**
608
658
  * 全站合并(仅 `ojm build` 调用,设计 §5):清场后拷 shell dist 全量,
609
659
  * 再由模块构建写入 modules.json 与 modules/。清场防旧哈希资产无限累积
@@ -176,10 +176,18 @@ export interface WecomMember {
176
176
  /**
177
177
  * 授权信息里的应用列表(`auth_info.agent`)——`get_permanent_code` 与 `get_auth_info` 同形,
178
178
  * 抽出来是为了两处对 auth_mode 的读法永远一致(同步闸门认的就是这个值)。
179
+ *
180
+ * **数组与单对象都认**:官方示例是数组,但 2026-11-01 的生产回包在代开发 v2 上给的是空列表
181
+ * (见 permanentCodeByAuthCode 的回源兜底)。单对象形态认下来没有代价,少一次「明明有应用却
182
+ * 判成没授权」的误杀。
179
183
  */
180
184
  function agentListOf(authInfo: unknown): Array<Record<string, unknown>> {
181
185
  const list = (authInfo as Record<string, unknown> | undefined)?.agent;
182
- return Array.isArray(list) ? list.map(a => a as Record<string, unknown>) : [];
186
+ if (Array.isArray(list))
187
+ return list.map(a => a as Record<string, unknown>);
188
+ if (list && typeof list === "object")
189
+ return [list as Record<string, unknown>];
190
+ return [];
183
191
  }
184
192
 
185
193
  /**
@@ -245,18 +253,41 @@ export function createWecomClient(deps: WecomClientDeps) {
245
253
  const res = await http.postJson(`${API_BASE}/cgi-bin/service/v2/get_permanent_code?suite_access_token=${encodeURIComponent(suiteAccessToken)}`, { auth_code: authCode });
246
254
  assertOk(res, "get_permanent_code");
247
255
  const authCorp = (res.auth_corp_info ?? {}) as Record<string, unknown>;
248
- const agents = agentListOf(res.auth_info);
249
- const agentIds = agents.map(a => String(a.agentid)).filter(Boolean);
250
256
  if (!res.permanent_code || !authCorp.corpid)
251
257
  throw upstream("企微 get_permanent_code 返回缺少 permanent_code / corpid");
258
+ let authInfo = res.auth_info;
259
+ let agents = agentListOf(authInfo);
260
+ if (agents.length === 0) {
261
+ // 代开发 v2 出现过「永久码拿到了、auth_info.agent 却是空的」(2026-11-01 生产日志)。
262
+ // 此时别急着判死:永久码已经在手,`get_auth_info` 是同形的复核接口,能补回应用列表。
263
+ // 只记字段名,不记值——回包里没有任何可公开的东西。
264
+ log.warn(
265
+ "wecom agent list missing in get_permanent_code",
266
+ "keys",
267
+ Object.keys(res).join(","),
268
+ "authInfoKeys",
269
+ authInfo && typeof authInfo === "object" ? Object.keys(authInfo as Record<string, unknown>).join(",") : typeof authInfo,
270
+ );
271
+ try {
272
+ authInfo = await authInfoByPermanentCode(suiteAccessToken, String(authCorp.corpid), String(res.permanent_code));
273
+ agents = agentListOf(authInfo);
274
+ }
275
+ catch (e) {
276
+ log.warn("wecom get_auth_info fallback failed", "reason", (e as Error).message);
277
+ }
278
+ }
279
+ const agentIds = agents.map(a => String(a.agentid)).filter(Boolean);
280
+ // 列表有元素却取不到 agentid:字段被改名的信号,记下键名(不记值)省下一次盲猜
281
+ if (agents.length > 0 && agentIds.length === 0)
282
+ log.warn("wecom agent item has no agentid", "keys", Object.keys(agents[0]!).join(","));
252
283
  if (agentIds.length === 0)
253
- throw upstream("企微 get_permanent_code 未返回授权应用(auth_info.agent 为空)");
284
+ throw upstream("企微 get_permanent_code 未返回授权应用(auth_info.agent 为空,get_auth_info 复核也没有)");
254
285
  return {
255
286
  permanentCode: String(res.permanent_code),
256
287
  corpId: String(authCorp.corpid),
257
288
  corpName: authCorp.corp_name == null ? null : String(authCorp.corp_name),
258
289
  agentIds,
259
- authModes: readAuthModes(res.auth_info),
290
+ authModes: readAuthModes(authInfo),
260
291
  state: res.state == null ? null : String(res.state),
261
292
  };
262
293
  }
@@ -268,12 +299,17 @@ export function createWecomClient(deps: WecomClientDeps) {
268
299
  * 不刷新就会一直按「可同步」对待。
269
300
  */
270
301
  async function authModesByPermanentCode(suiteAccessToken: string, corpId: string, permanentCode: string): Promise<Record<string, number>> {
302
+ return readAuthModes(await authInfoByPermanentCode(suiteAccessToken, corpId, permanentCode));
303
+ }
304
+
305
+ /** `get_auth_info` 的原始 `auth_info`:授权模式刷新与「永久码回包缺 agent」的回源兜底共用 */
306
+ async function authInfoByPermanentCode(suiteAccessToken: string, corpId: string, permanentCode: string): Promise<unknown> {
271
307
  const res = await http.postJson(`${API_BASE}/cgi-bin/service/get_auth_info?suite_access_token=${encodeURIComponent(suiteAccessToken)}`, {
272
308
  auth_corpid: corpId,
273
309
  permanent_code: permanentCode,
274
310
  });
275
311
  assertOk(res, "get_auth_info");
276
- return readAuthModes(res.auth_info);
312
+ return res.auth_info;
277
313
  }
278
314
 
279
315
  /**
@@ -44,18 +44,50 @@ export function isWecomUa(ua: string): boolean {
44
44
  */
45
45
  export function buildAuthorizeUrl(opts: {
46
46
  corpId: string
47
- agentId: string
47
+ agentId?: string
48
48
  redirectUri: string
49
49
  state: string
50
50
  inWecom: boolean
51
51
  }): string {
52
52
  const redirect = encodeURIComponent(opts.redirectUri);
53
53
  const appid = encodeURIComponent(opts.corpId);
54
- const agentid = encodeURIComponent(opts.agentId);
54
+ // 多应用未指定 agent 时省略 agentid:以 corp 维度授权(企微回跳只给 userid,不绑定具体应用)
55
+ const agent = opts.agentId ? `&agentid=${encodeURIComponent(opts.agentId)}` : "";
55
56
  if (opts.inWecom) {
56
- return `${AUTHORIZE_IN_APP}?appid=${appid}&redirect_uri=${redirect}&response_type=code&scope=snsapi_base&state=${encodeURIComponent(opts.state)}&agentid=${agentid}#wechat_redirect`;
57
+ return `${AUTHORIZE_IN_APP}?appid=${appid}&redirect_uri=${redirect}&response_type=code&scope=snsapi_base&state=${encodeURIComponent(opts.state)}${agent}#wechat_redirect`;
57
58
  }
58
- return `${AUTHORIZE_QRCODE}?appid=${appid}&agentid=${agentid}&redirect_uri=${redirect}&state=${encodeURIComponent(opts.state)}`;
59
+ return `${AUTHORIZE_QRCODE}?appid=${appid}${agent}&redirect_uri=${redirect}&state=${encodeURIComponent(opts.state)}`;
60
+ }
61
+
62
+ /**
63
+ * 代开发模板常只服务一家企业:登录入口不传 corp 时,从已安装档案自动解析,
64
+ * 让「应用主页」直接填 https://<域名>/login 即可(无需 ${corpid} 占位符,避开企微后台对非法 URL 字符的校验)。
65
+ * agent 未指定且该企业多应用时,取首个活跃应用以 corp 维度授权。
66
+ */
67
+ // 未指定 agent 且多应用时,优先作为默认登录应用的 agent(按部署需要调整)
68
+ const DEFAULT_WECOM_AGENT = "1000059";
69
+
70
+ export async function resolveWecomProfile(agentId: string | null): Promise<{ corpId: string, agentId: string, env: string }> {
71
+ const rows = await db.table("wecom_app_profile")
72
+ .select(["corp_id", "agent_id", "env", "status"])
73
+ .where({ field: "status", op: "eq", value: "active" })
74
+ .all() as Array<{ corp_id: string, agent_id: string, env: string | null, status: string }>;
75
+ const active = rows.filter(r => String(r.status) === "active");
76
+ if (!active.length)
77
+ throw Object.assign(new Error("尚未完成企业微信授权(无已安装应用档案),请先走「生成开通链接」完成授权"), { status: 400 });
78
+ const corps = [...new Set(active.map(r => r.corp_id))];
79
+ if (corps.length > 1 && !agentId)
80
+ throw Object.assign(new Error("已授权多家企业,请在登录链接带 corp 参数以区分"), { status: 400 });
81
+ const corpId = corps[0]!;
82
+ const agents = active.filter(r => r.corp_id === corpId);
83
+ // 指定 agent 就必须命中,未指定则退到默认 agent / 首个活跃应用(agents 非空:active 已非空且
84
+ // 至少有一个属于 corpId——corps 就是按 active 的 corp_id 去重出来的)
85
+ const pick = agentId
86
+ ? agents.find(a => a.agent_id === agentId)
87
+ : (agents.find(a => a.agent_id === DEFAULT_WECOM_AGENT) ?? agents[0]);
88
+ if (!pick)
89
+ throw Object.assign(new Error("指定的 agent 在该企业中不存在"), { status: 400 });
90
+ return { corpId: pick.corp_id, agentId: pick.agent_id, env: pick.env == null ? "prod" : String(pick.env) };
59
91
  }
60
92
 
61
93
  export async function putLoginState(nonce: string, pending: PendingLogin, cache: WecomCache): Promise<void> {
@@ -13,6 +13,11 @@
13
13
  // 2. nonce 幂等:kv.incr 原子计数,首次才写入 TTL。计数而不是读改写,是为了
14
14
  // 「两个并发同 nonce」也只有一个拿到 1(oj 的 kv 在配了 Redis 时是多实例共享的)。
15
15
  //
16
+ // ⚡ 幂等是「处理成功」才算数:拿到的是一次**占用**(ReplayClaim),调用方处理失败必须
17
+ // `release()` 放回。不放回的后果不是理论问题——企微只在「拿不到 success」时重推,而那正是
18
+ // 需要重推的时刻;占用不放,重推会被自己的重放守卫判成 400,恢复路径当场死掉
19
+ // (2026-11-01 生产日志:create_auth 因上游 502 失败 → 重推 400「nonce 重复」)。
20
+ //
16
21
  // 两条都回调 400:它不是「本站要鉴权」的问题,401 会把排查带偏。
17
22
  const REPLAY_WINDOW_SECS = 5 * 60;
18
23
  /** 计数键存活时间:只要盖住重放窗口即可,避免 kv 无界增长 */
@@ -22,6 +27,8 @@ export interface ReplayGate {
22
27
  /** 原子自增并返回新值(kv.incr 同语义) */
23
28
  incr: (key: string) => Promise<number>
24
29
  expire: (key: string, ttlSecs: number) => Promise<unknown>
30
+ get: (key: string) => Promise<string | null>
31
+ set: (key: string, ttlSecs: number) => Promise<unknown>
25
32
  del: (key: string) => Promise<unknown>
26
33
  now: () => number
27
34
  }
@@ -35,16 +42,36 @@ export function runtimeReplayGate(): ReplayGate {
35
42
  return {
36
43
  incr: key => kv.incr(key),
37
44
  expire: (key, ttlSecs) => kv.expire(key, ttlSecs),
45
+ get: key => kv.get(key),
46
+ set: async (key, ttlSecs) => { await kv.set(key, "1"); if (!(await kv.expire(key, ttlSecs))) { log.warn("wecom applied marker ttl not set", "key", key); await kv.del(key); } },
38
47
  del: key => kv.del(key),
39
48
  now: () => Math.floor(Date.now() / 1000),
40
49
  };
41
50
  }
42
51
 
52
+ /** 一次 nonce 占用:处理成功就不必管(TTL 到期自然消失);失败必须 `release()` */
53
+ export interface ReplayClaim {
54
+ release: () => Promise<void>
55
+ }
56
+
57
+ /** 重推且首次已成功落库:抛出后由调用方直接回 success(幂等),不让企微卡在「应用安装失败」。 */
58
+ export class ReplayAlreadyApplied extends Error {
59
+ constructor() { super("nonce 已成功处理(幂等成功)"); }
60
+ }
61
+
62
+ /** 处理成功后调用:标记该 nonce 已落库成功,供重推幂等回 success(避免首次偏慢被企微判超时、重推又被重放守卫挡成 400)。 */
63
+ export async function markApplied(nonce: string, gate: ReplayGate): Promise<void> {
64
+ await gate.set("wecom:applied:" + nonce, NONCE_TTL_SECS);
65
+ }
66
+
43
67
  /**
44
- * 校验一次回调的时效与唯一性。通过 => void;不通过 => 抛 400。
68
+ * 校验一次回调的时效与唯一性。通过 => 返回占用凭据;不通过 => 抛 400。
45
69
  * 调用时机:**签名校验之后、解密之前**(先验签避免给未鉴权的请求写 kv,再拦重放省一次解密)。
70
+ *
71
+ * 占用是「这次报文归我处理」,不是「这条报文已经处理成功」——所以失败路径必须放回:
72
+ * 企微的重推是瞬态故障唯一的恢复手段,被自己的重放守卫挡掉等于把恢复路径拆了。
46
73
  */
47
- export async function guardReplay(timestampRaw: string, nonce: string, gate: ReplayGate): Promise<void> {
74
+ export async function guardReplay(timestampRaw: string, nonce: string, gate: ReplayGate): Promise<ReplayClaim> {
48
75
  const ts = Number(timestampRaw);
49
76
  if (!Number.isInteger(ts) || ts <= 0)
50
77
  fail(400, "回调 timestamp 非法");
@@ -55,12 +82,26 @@ export async function guardReplay(timestampRaw: string, nonce: string, gate: Rep
55
82
 
56
83
  const key = `wecom:nonce:${nonce}`;
57
84
  const seen = Number(await gate.incr(key));
58
- if (seen !== 1)
85
+ if (seen !== 1) {
86
+ // 重推场景:若首次处理已成功落库(标了 applied 标记),直接幂等回 success,
87
+ // 不让企微卡在「应用安装失败」。未标记者说明首推仍在进行或已失败(失败会 release 让重推重放),维持 400。
88
+ const applied = await gate.get("wecom:applied:" + nonce);
89
+ if (applied)
90
+ throw new ReplayAlreadyApplied();
59
91
  fail(400, "回调 nonce 重复:同一条指令回调已被处理过(重放请求已拒绝)");
92
+ }
60
93
  // 首次访问才有资格设 TTL:设不上就删键——留一个没有过期的计数键既让它永久增长,
61
94
  // 也会把未来的同一 nonce(虽概率极低)误判成重放。
62
95
  if (!(await gate.expire(key, NONCE_TTL_SECS))) {
63
96
  log.warn("wecom replay nonce ttl not set", "key", key);
64
97
  await gate.del(key);
65
98
  }
99
+ return {
100
+ // 直接 del,不做「是不是我占的那一次」的判断:oj 的 kv 没有 CAS,incr 的返回值
101
+ // 也已被消费在 seen 上。代价是并发同 nonce 时(同一秒内两次投递)可能多放过一次,
102
+ // 但放回的时机只在「处理失败」,而失败的这一条本来就该让企微重推。
103
+ release: async () => {
104
+ await gate.del(key);
105
+ },
106
+ };
66
107
  }
@@ -385,6 +385,20 @@ async function runSlice(
385
385
  return done({ failed: true, errCode: scrubbed.errCode, errDetail: scrubbed.errDetail });
386
386
  }
387
387
  const ordered = orderTopDown(list);
388
+ // 代开发 simplelist 不带部门名(见 client.ts departmentDetail 注释):逐部门回源 department/get 补名字。
389
+ // 已有名字则跳过(普通企业 simplelist 可能带名字,不重复打上游);单部门失败非致命,留空不影响骨架。
390
+ for (const d of ordered) {
391
+ if (d.name)
392
+ continue;
393
+ try {
394
+ const detail = await deps.client.departmentDetail(corpId, agentId, secret, d.id);
395
+ if (detail.name)
396
+ d.name = detail.name;
397
+ }
398
+ catch {
399
+ // 无权限 / 不在可见范围 / 上游抖动:名字留空,不因单部门失败中断整轮
400
+ }
401
+ }
388
402
  const localByOrigin = new Map<string, number>();
389
403
  const placedIds: number[] = [];
390
404
  for (const d of ordered) {
@@ -1,5 +1,6 @@
1
1
  import type { WecomConfig } from "../_shared/wecom/config";
2
2
  import type { CallbackFields } from "../_shared/wecom/payload";
3
+ import type { ReplayClaim } from "../_shared/wecom/replay";
3
4
  import type { WecomStoreDeps } from "../_shared/wecom/store";
4
5
  // GET /api/auth/wecom/login?corp=&agent=&env= —— 发起企微授权(企微内静默 / 浏览器扫码)
5
6
  // GET /api/auth/wecom/callback?code=&state= —— 企微回跳:code→userid→JIT→一次性票据→302 SPA
@@ -24,6 +25,7 @@ import { handleSuiteEvent } from "../_shared/wecom/events";
24
25
  import {
25
26
  assertUserActive,
26
27
  buildAuthorizeUrl,
28
+ resolveWecomProfile,
27
29
  ensureWecomUser,
28
30
  LOGIN_TICKET_TTL_SECS,
29
31
  loginLandingUrl,
@@ -32,7 +34,7 @@ import {
32
34
  takeLoginState,
33
35
  } from "../_shared/wecom/login";
34
36
  import { callbackFields, field } from "../_shared/wecom/payload";
35
- import { guardReplay, runtimeReplayGate } from "../_shared/wecom/replay";
37
+ import { guardReplay, runtimeReplayGate, ReplayAlreadyApplied, markApplied } from "../_shared/wecom/replay";
36
38
  import { claimLoginTicket, ensureTenant, findProfile, issueLoginTicket, runtimeStoreDeps, tenantOfCorp } from "../_shared/wecom/store";
37
39
 
38
40
  // 模块级禁止 readWecomConfig()/createSession()/runtimeHttp():oj build 的 introspect 在空沙箱里
@@ -51,7 +53,7 @@ function query(name: string): string {
51
53
 
52
54
  /** 纯文本 200(企微事件回执专用;信封会被判失败并触发重推) */
53
55
  function plainText(body: string): void {
54
- const stream = json.stream({ contentType: "text/plain; charset=utf-8" });
56
+ const stream = json.stream({ contentType: "text/plain; charset=utf-8", status: 200 });
55
57
  stream.write(body);
56
58
  stream.end();
57
59
  }
@@ -123,46 +125,66 @@ function unwrapAccepting(encrypted: string, cfg: WecomConfig, candidates: string
123
125
  * 三道顺序不可换:先验签(不给未鉴权请求写 kv)→ 再拦重放(省一次解密,也让
124
126
  * cancel_auth 这种危险性最高的报文止步在落库之前)→ 最后解密。
125
127
  */
126
- async function decryptVerified(encrypted: string, envelope: CallbackFields): Promise<{ cfg: WecomConfig, plain: string }> {
128
+ async function decryptVerified(encrypted: string, envelope: CallbackFields): Promise<{ cfg: WecomConfig, plain: string, claim: ReplayClaim }> {
127
129
  const cfg = readWecomConfig();
128
130
  const expected = wecomSignature(cfg.token, query("timestamp"), query("nonce"), encrypted);
129
131
  if (expected !== query("msg_signature"))
130
132
  throw badRequest("回调签名校验失败");
131
- await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
133
+ const claim = await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
132
134
  try {
133
135
  // 信封的 ToUserName 就是这条报文的身份:套件事件里是 suiteid,应用数据回调里是 corpid,
134
136
  // 与 receiveid 必然一致(两者在同一份密文/明文里,签名已经覆盖)。
135
- return { cfg, plain: unwrapAccepting(encrypted, cfg, [cfg.suiteId, field(envelope, "ToUserName")]) };
137
+ return { cfg, plain: unwrapAccepting(encrypted, cfg, [cfg.suiteId, field(envelope, "ToUserName")]), claim };
136
138
  }
137
139
  catch (e) {
140
+ // 解不开的这一条不能占住 nonce:企微重推是它唯一的恢复路径,占住就把重推挡成 400
141
+ await claim.release();
138
142
  // 解密失败只对外透出「失败」:错钥/错套件/被篡改三者不可区分,也不该让外部据此判断哪一环配错
139
143
  noteDecryptFailure(e, envelope, cfg);
140
144
  throw badRequest("回调消息解密失败");
141
145
  }
142
146
  }
143
147
 
148
+ /** 放回一次 nonce 占用;放回失败只告警——它不该盖住真正的失败原因 */
149
+ async function releaseClaim(claim: ReplayClaim | undefined): Promise<void> {
150
+ if (!claim)
151
+ return;
152
+ try {
153
+ await claim.release();
154
+ }
155
+ catch (e) {
156
+ log.warn("wecom replay nonce release failed", "reason", (e as Error).message);
157
+ }
158
+ }
159
+
144
160
  const login = {
145
161
  // 企微内打开(UA 含 wxwork)→ oauth2 静默授权;普通浏览器 → qrConnect 扫码。
146
162
  // corp 必填(D10:企业代码 = corp_id,服务商后台可生成带码的专属登录链接)。
147
163
  async get() {
148
164
  try {
149
165
  const cfg = readWecomConfig();
166
+ const deps = runtimeStoreDeps(cfg);
167
+
150
168
  const corp = query("corp");
151
169
  const agent = query("agent");
152
- if (!corp)
153
- throw badRequest("缺少企业代码(corp):请使用企业管理员收到的登录链接,或在登录页填写企业代码");
154
170
 
155
- // 企业代码解析是**未登录可调的枚举面**(§5.3):复用登录节流,维度 (IP, 企业代码)
156
171
  const ip = await clientIp();
157
- await guardLoginThrottle(ip, corp);
158
-
159
- const deps = runtimeStoreDeps(cfg);
160
- const profile = await findProfile(corp, agent || null, deps).catch(async (e: Error & { status?: number }) => {
161
- // 只有「企业代码无效/多应用未指定」计失败;配置类错误(缺 vars)不该污染节流计数
162
- if (e.status === 400)
163
- await noteLoginFailed(ip, corp);
164
- throw e;
165
- });
172
+ // 企业代码解析是**未登录可调的枚举面**(§5.3):复用登录节流,维度 (IP, 企业代码)。
173
+ // corp 缺省时(代开发模板常只服务一家企业)自动从已安装档案解析,省去手动填企业代码,
174
+ // 也让「应用主页」可直接填 https://<域名>/login(无需 ${corpid} 占位符,企微后台会因非法 URL 字符拒收)。
175
+ let profile: { corpId: string; agentId: string; env: string };
176
+ if (corp) {
177
+ await guardLoginThrottle(ip, corp);
178
+ profile = await findProfile(corp, agent || null, deps).catch(async (e: Error & { status?: number }) => {
179
+ // 只有「企业代码无效/多应用未指定」计失败;配置类错误(缺 vars)不该污染节流计数
180
+ if (e.status === 400)
181
+ await noteLoginFailed(ip, corp);
182
+ throw e;
183
+ });
184
+ } else {
185
+ profile = await resolveWecomProfile(agent || null);
186
+ await guardLoginThrottle(ip, profile.corpId);
187
+ }
166
188
 
167
189
  const state = crypto.randomHex(16);
168
190
  await putLoginState(state, { corpId: profile.corpId, agentId: profile.agentId, env: profile.env }, runtimeCache());
@@ -278,7 +300,7 @@ const event = {
278
300
  const expected = wecomSignature(cfg.token, query("timestamp"), query("nonce"), encrypted);
279
301
  if (expected !== query("msg_signature"))
280
302
  throw badRequest("回调签名校验失败");
281
- await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
303
+ const claim = await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
282
304
  const suiteId = (vars.get("wecom_suite_id") ?? "").trim();
283
305
  try {
284
306
  // receiveid 一律不比对:GET 只有 echostr、没有报文信封,无从知道该比哪一个——
@@ -288,6 +310,8 @@ const event = {
288
310
  plainText(unwrapWecomMessage(encrypted, cfg.encodingAesKey, suiteId, true));
289
311
  }
290
312
  catch (e) {
313
+ // 验证没通过就放回 nonce:企微后台点「保存」会连着验几次,占住会把后几次都挡成 400
314
+ await claim.release();
291
315
  // 解密失败只透出「失败」:错钥/未下发套件/被篡改三者不可区分,也不该让外部据此判断哪一环配错
292
316
  noteDecryptFailure(e, null, { encodingAesKey: cfg.encodingAesKey, suiteId });
293
317
  throw badRequest("回调消息解密失败");
@@ -304,17 +328,31 @@ const event = {
304
328
  // 包体形态按官方文档是 XML(见 payload.ts 的开头),但归一化那一层两种都认,
305
329
  // 所以本函数只认字段名,不认格式。
306
330
  async post() {
331
+ let claim: ReplayClaim | undefined;
307
332
  try {
308
333
  const envelope = callbackFields(http.body);
309
334
  const encrypted = field(envelope, "Encrypt");
310
335
  if (!encrypted)
311
336
  throw badRequest("回调报文缺少 Encrypt(不是企微回调,或中间件改写了请求体)");
312
- const { cfg, plain } = await decryptVerified(encrypted, envelope);
337
+ const verified = await decryptVerified(encrypted, envelope);
338
+ claim = verified.claim;
339
+
313
340
  const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
314
- await handleSuiteEvent(plain, { cfg, client, store: runtimeStoreDeps(cfg) });
341
+ await handleSuiteEvent(verified.plain, { cfg: verified.cfg, client, store: runtimeStoreDeps(verified.cfg) });
342
+ // 处理成功即标记 nonce 已落库:企微重推时命中即幂等回 success,解开「首推偏慢被企微判超时、
343
+ // 重推又被重放守卫挡 400」的死局——管理员端不再卡在「应用安装失败」。
344
+ await markApplied(query("nonce"), runtimeReplayGate()).catch(() => {});
315
345
  plainText("success");
316
346
  }
317
347
  catch (e) {
348
+ // 重推且首推已成功落库:幂等回 success,管理员端不再卡在「应用安装失败」。
349
+ if (e instanceof ReplayAlreadyApplied) {
350
+ plainText("success");
351
+ return;
352
+ }
353
+ // 处理失败必须放回 nonce:企微只在「拿不到 success」时重推,而那正是需要重推的时刻;
354
+ // 占住不放,重推会被自己的重放守卫判成 400,瞬态故障的恢复路径当场断掉。
355
+ await releaseClaim(claim);
318
356
  // 企微侧只会显示「应用安装失败,联系服务商」,原因一概不透露——这里是唯一能查到
319
357
  // 「哪一环拒了这条事件」的地方(缺这一行,这场失败在运维视角是零信息)。
320
358
  const err = e as Error & { status?: number };
@@ -31,6 +31,8 @@
31
31
  "wecom": "WeCom",
32
32
  "wecom.action": "Sign in with WeCom",
33
33
  "wecom.hint": "Enter the enterprise code (corp_id, visible in the WeCom console), or use the dedicated sign-in link from your admin",
34
+ "wecom.agent": "App ID (agentid)",
35
+ "wecom.agentHint": "Leave empty if the corp has a single app; otherwise use the agentid provided by your admin",
34
36
  "wecom.exchanging": "Completing WeCom sign-in…"
35
37
  }
36
38
  }
@@ -31,6 +31,8 @@
31
31
  "wecom": "企业微信",
32
32
  "wecom.action": "使用企业微信登录",
33
33
  "wecom.hint": "填写企业代码(企微后台可见的 corp_id),或直接使用管理员收到的专属登录链接",
34
+ "wecom.agent": "应用 ID(agentid)",
35
+ "wecom.agentHint": "留空使用该企业下唯一应用;多应用部署请填管理员提供的 agentid",
34
36
  "wecom.exchanging": "正在完成企业微信登录…"
35
37
  }
36
38
  }
@@ -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>
@@ -426,17 +426,46 @@ describe("P1-F:凭证链(client:缓存 + 失效重试 + 错误两态)",
426
426
  await expect(client.userIdByCode("c", "a", "p", "CODE")).rejects.toMatchObject({ status: 502 });
427
427
  });
428
428
 
429
- it("get_permanent_code 缺 corpid / agent 列表为空 → 502(不静默落一行空 profile)", async () => {
429
+ it("get_permanent_code 缺 corpid / agent 列表为空且复核也没有 → 502(不静默落一行空 profile)", async () => {
430
430
  const noAgent = createWecomClient({
431
+ // suite_access_token 由调用方传入,所以这里的应答从 get_permanent_code 开始
431
432
  http: stubHttp([
432
- { suite_access_token: "sat", expires_in: 7200 },
433
433
  { permanent_code: "perm", auth_corp_info: { corpid: "ww_x" }, auth_info: { agent: [] } },
434
+ // 回源复核(get_auth_info)同样没有应用 → 这时才判死
435
+ { auth_info: { agent: [] } },
434
436
  ]),
435
437
  cache: fakeCache(),
436
438
  });
437
439
  await expect(noAgent.permanentCodeByAuthCode("sat", "AUTH")).rejects.toMatchObject({ status: 502 });
438
440
  });
439
441
 
442
+ // 2026-11-01 生产日志:create_auth 拿到永久码,但 auth_info.agent 是空的 → 上一版直接 502,
443
+ // 企业管理员看到「应用安装失败」,而永久码白白作废(auth_code 一次有效)。
444
+ it("get_permanent_code 的 agent 为空 → 拿刚换到的永久码回源 get_auth_info 补齐", async () => {
445
+ const http = stubHttp([
446
+ { permanent_code: "perm", auth_corp_info: { corpid: "ww_x" }, auth_info: { agent: [] } },
447
+ { auth_info: { agent: [{ agentid: 1000012, auth_mode: 1 }] } },
448
+ ]);
449
+ const client = createWecomClient({ http, cache: fakeCache() });
450
+
451
+ const granted = await client.permanentCodeByAuthCode("sat", "AUTH");
452
+
453
+ expect(granted.agentIds).toEqual(["1000012"]);
454
+ expect(granted.authModes["1000012"]).toBe(1);
455
+ expect(http.calls.some(u => u.includes("/service/get_auth_info"))).toBe(true);
456
+ });
457
+
458
+ it("auth_info.agent 给成单个对象(不是数组)也认——少一次「明明有应用却判成没授权」", async () => {
459
+ const client = createWecomClient({
460
+ http: stubHttp([
461
+ { permanent_code: "perm", auth_corp_info: { corpid: "ww_x" }, auth_info: { agent: { agentid: 1000011, auth_mode: 0 } } },
462
+ ]),
463
+ cache: fakeCache(),
464
+ });
465
+
466
+ expect((await client.permanentCodeByAuthCode("sat", "AUTH")).agentIds).toEqual(["1000011"]);
467
+ });
468
+
440
469
  it("userNameById 拿到真名(去首尾空白)", async () => {
441
470
  const http = stubHttp([{ access_token: "t", expires_in: 7200 }, { errcode: 0, name: " 张三 " }]);
442
471
  const client = createWecomClient({ http, cache: fakeCache() });
@@ -187,11 +187,30 @@ describe("P1-I:GET /auth/wecom/login(发起授权)", () => {
187
187
  expect(out.location).toBeNull();
188
188
  });
189
189
 
190
- it("缺企业代码 → 400,引导去登录页填(不猜默认企业)", async () => {
190
+ // 代开发模板常只服务一家企业:corp 缺省时从已安装档案自动解析,好让「应用主页」直接填
191
+ // `https://<域名>/login`(企微后台会因 URL 里的 `${corpid}` 占位符字符拒收)。三种情形分开钉。
192
+ it("缺企业代码且一家都没装 → 400,指向「生成开通链接」(不猜默认企业)", async () => {
191
193
  request({ query: {} });
192
194
  await (await api()).login.get();
193
195
  expect(out.status).toBe(400);
194
- expect(out.body.msg).toMatch(/企业代码/);
196
+ expect(out.body.msg).toMatch(/尚未完成企业微信授权/);
197
+ });
198
+
199
+ it("缺企业代码但只装了一家 → 302 到该企业的授权地址(省掉手填企业代码)", async () => {
200
+ seedCorp();
201
+ request({ query: {}, headers: { "user-agent": "Mozilla/5.0 Chrome/120" } });
202
+ await (await api()).login.get();
203
+ expect(out.status).toBe(302);
204
+ expect(out.location).toContain("appid=ww_corp_a");
205
+ });
206
+
207
+ it("缺企业代码且装了多家 → 400,要求链接里带 corp 参数区分", async () => {
208
+ seedCorp("ww_corp_a");
209
+ seedCorp("ww_corp_b");
210
+ request({ query: {} });
211
+ await (await api()).login.get();
212
+ expect(out.status).toBe(400);
213
+ expect(out.body.msg).toMatch(/corp/);
195
214
  });
196
215
 
197
216
  it("未授权企业 → 400(文案不区分「没装」与「已停用」)", async () => {
@@ -565,6 +584,42 @@ describe("P1-L:GET/POST /auth/wecom/event(企微指令回调)", () => {
565
584
  expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 0 });
566
585
  });
567
586
 
587
+ // 2026-11-01 生产时序:create_auth → 上游 502 → 企微 5 秒后重推 → 被「nonce 重复」挡回。
588
+ // 重推是瞬态故障唯一的恢复路径,让它废在重放守卫上等于把恢复路径拆了。
589
+ it("处理失败的 nonce 会被放回:重推按真实原因重试,不被判成重放", async () => {
590
+ const plain = "<xml><InfoType>suite_ticket</InfoType><SuiteId>other_suite</SuiteId><SuiteTicket>tk_x</SuiteTicket></xml>";
591
+ const encrypted = await seal(plain);
592
+ const query = signedQuery(encrypted, { nonce: "retry_once" });
593
+ const body = `<xml><Encrypt>${encrypted}</Encrypt></xml>`;
594
+
595
+ request({ query, body });
596
+ await (await api()).event.post();
597
+ expect(out.status).toBe(400);
598
+ expect(out.body.msg).toMatch(/SuiteId/);
599
+
600
+ // 同一条报文重推(企微的语义):仍按真实原因失败,而不是 400「nonce 重复」
601
+ request({ query, body });
602
+ await (await api()).event.post();
603
+ expect(out.body.msg).toMatch(/SuiteId/);
604
+ expect(out.body.msg).not.toMatch(/nonce 重复/);
605
+ });
606
+
607
+ // 重放闸门的本体:同 nonce 已被占用、而首推还没标「已落库」——此刻重复投递必须挡下
608
+ // (可能是真重放,也可能是首推仍在处理中;两种都不该并发出第二份副作用)。
609
+ it("同 nonce 已被占用且未标已落库 → 400「nonce 重复」(重放闸门本体)", async () => {
610
+ const encrypted = await seal(TICKET_XML);
611
+ const query = signedQuery(encrypted, { nonce: "inflight" });
612
+ const body = `<xml><Encrypt>${encrypted}</Encrypt></xml>`;
613
+ // 模拟「首推正在处理」:计数已到 1,但没有 applied 标记
614
+ await g.kv.incr("wecom:nonce:inflight");
615
+
616
+ request({ query, body });
617
+ await (await api()).event.post();
618
+ expect(out.status).toBe(400);
619
+ expect(out.body.msg).toMatch(/nonce 重复/);
620
+ expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 0 });
621
+ });
622
+
568
623
  it("EncodingAESKey 与后台不一致(签名过、解密爆 pkcs7)→ 400,日志点名是哪一侧配错", async () => {
569
624
  // 用户实报的原样症状:WARN reason=pkcs7: bad padding。签名先过说明后台 Token 与本部署一致,
570
625
  // 所以只回一句 bad padding 等于没给线索——日志必须带「外层 ToUserName / 配置套件 / 实际加载的密钥指纹」。
@@ -605,19 +660,24 @@ describe("P1-L:GET/POST /auth/wecom/event(企微指令回调)", () => {
605
660
  expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 0 });
606
661
  });
607
662
 
608
- it("同一条回调连发两次(重放)→ 第二次 400 nonce 重复,票据只落一次", async () => {
663
+ // 处理成功后的重推:回 success 而不是 400——企微只在「拿不到 success」时重推,首推若因回包慢
664
+ // 被判超时,重推再被挡成 400,管理员端就会看到「应用安装失败」。副作用仍然只发生一次。
665
+ it("同一条回调连发两次(成功后的重推)→ 第二次幂等回 success,票据只落一次", async () => {
609
666
  const encrypted = await seal(TICKET_XML);
610
667
  const body = `<xml><Encrypt>${encrypted}</Encrypt></xml>`;
611
668
  request({ query: signedQuery(encrypted), body });
612
669
  await (await api()).event.post();
613
670
  expect(out.status).toBe(200);
671
+ expect(out.text).toBe("success");
614
672
 
615
673
  installJson();
616
674
  request({ query: signedQuery(encrypted), body });
617
675
  await (await api()).event.post();
618
- expect(out.status).toBe(400);
619
- expect(out.body.msg).toMatch(/nonce 重复/);
676
+ expect(out.status).toBe(200);
677
+ expect(out.text).toBe("success");
678
+ // 幂等成功不等于重放执行:库里仍是首推那次的值
620
679
  expect(dbo.prepare("SELECT suite_ticket FROM wecom_suite_ticket WHERE suite_id = ?").get(SUITE)).toMatchObject({ suite_ticket: "tk_9" });
680
+ expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 1 });
621
681
  });
622
682
 
623
683
  it("换一个新 nonce 的同内容回调仍能落(重放闸门没把正常重试误伤)", async () => {
@@ -8,9 +8,9 @@
8
8
  "react-router": "7.18.2",
9
9
  "react-router/dom": "7.18.2",
10
10
  "@tanstack/react-query": "5.102.8",
11
- "@oj-module/runtime": "0.2.48",
12
- "@oj-module/runtime/contract": "0.2.48",
13
- "@oj-module/runtime/contract/errors": "0.2.48",
11
+ "@oj-module/runtime": "0.2.49",
12
+ "@oj-module/runtime/contract": "0.2.49",
13
+ "@oj-module/runtime/contract/errors": "0.2.49",
14
14
  "@plane/kit": "0.1.20",
15
15
  "@plane/editor": "0.1.20",
16
16
  "antd": "6.6.1",