@lark-apaas/coding-steering 0.1.32-dev.b12eab4 → 0.1.32-dev.c8cc2b5

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 (33) hide show
  1. package/package.json +1 -1
  2. package/steering/design-html/skills/charts/SKILL.md +11 -4
  3. package/steering/design-html/skills/data-viz/SKILL.md +1 -1
  4. package/steering/design-html/skills/hi-fi-design/SKILL.md +1 -1
  5. package/steering/design-html/skills/interactive-prototype/SKILL.md +1 -17
  6. package/steering/design-html/skills/slide-deck/SKILL.md +5 -3
  7. package/steering/design-html/skills/social-card/SKILL.md +263 -0
  8. package/steering/nestjs-react-fullstack/skills/app-init-feasibility-guide/SKILL.md +3 -0
  9. package/steering/nestjs-react-fullstack/skills/client-builtins-file-storage-service/SKILL.md +1 -0
  10. package/steering/nestjs-react-fullstack/skills/client-builtins-user-service/SKILL.md +2 -0
  11. package/steering/nestjs-react-fullstack/skills/code-fix/SKILL.md +6 -0
  12. package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +56 -14
  13. package/steering/nestjs-react-fullstack/skills/contacts-service/SKILL.md +67 -4
  14. package/steering/nestjs-react-fullstack/skills/nestjs-cache/SKILL.md +40 -51
  15. package/steering/nestjs-react-fullstack/skills/openapi-guide/SKILL.md +75 -0
  16. package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +10 -4
  17. package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md +57 -14
  18. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/SKILL.md +78 -0
  19. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/client-onboarding.md +90 -0
  20. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/debugging.md +81 -0
  21. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/mcp-apps.md +210 -0
  22. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/tool-authoring.md +245 -0
  23. package/steering/nestjs-react-fullstack/skills_common/user-identity/SKILL.md +3 -1
  24. package/steering/nestjs-react-fullstack/skills_local/client-builtins-user-service/SKILL.md +2 -0
  25. package/steering/nestjs-react-fullstack/skills_local/code-fix/SKILL.md +6 -0
  26. package/steering/nestjs-react-fullstack/skills_local/coding-guide/SKILL.md +49 -22
  27. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +7 -3
  28. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/references/plugin-coding-guide.md +57 -14
  29. package/steering/vite-react/skills/plugin-guide/SKILL.md +1 -0
  30. package/steering/vite-react/skills/react-three-fiber/SKILL.md +1 -0
  31. package/steering/vite-react/tech.md +3 -0
  32. package/steering/design-html/skills/preflight/SKILL.md +0 -51
  33. package/steering/design-html/skills/preflight/scripts/probe.sh +0 -108
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: contacts-service
3
- description: "Use when 搜人/选人/人员选择器/部门选择器/群组选择、获取或展示用户与部门信息、把用户/部门/群组 ID 传给飞书内置插件或飞书开放平台 API、人员字段入库或导出。统一妙搭 ID 体系(miaoda_user_id / employee_id / open_department_id / open_chat_id;lark_* 内部 ID 禁用)、字段获取分级与权限引导。触发词:搜人, 选人, 人员选择器, 部门选择器, 群组选择, UserSelect, DepartmentSelect, ChatSelect, 获取用户信息, 用户字段, 部门信息, 群组信息, employee_id, open_id, union_id, open_department_id, open_chat_id, miaoda_user_id, lark_id, 飞书用户ID, 飞书部门ID, 外部用户, 工号, 手机号, 直属上级, 人员入库, 人员导出, id_convert, 通讯录, 选负责人, 选审批人, 传插件, 传开放平台, 选中的人传给, 把选中的人"
3
+ description: "Use when 搜人/选人/人员选择器/部门选择器/群组选择、获取或展示用户与部门信息、把用户/部门/群组 ID 传给飞书内置插件或飞书开放平台 API、人员字段入库或导出,以及在产物服务端 / OpenAPI 接口里按 ID 查用户信息。统一妙搭 ID 体系(miaoda_user_id / employee_id / open_department_id / open_chat_id;lark_* 内部 ID 禁用)、字段获取分级与权限引导、服务端可用与不可用的通讯录能力边界。触发词:搜人, 选人, 人员选择器, 部门选择器, 群组选择, UserSelect, DepartmentSelect, ChatSelect, 获取用户信息, 用户字段, 部门信息, 群组信息, employee_id, open_id, union_id, open_department_id, open_chat_id, miaoda_user_id, lark_id, 飞书用户ID, 飞书部门ID, 外部用户, 工号, 手机号, 直属上级, 人员入库, 人员导出, id_convert, 通讯录, 选负责人, 选审批人, 传插件, 传开放平台, 选中的人传给, 把选中的人, 服务端查用户, 后端查用户, OpenAPI 查用户, 批量查用户, 按ID查人, AuthNPaasService, listUsersByIds"
4
4
  steering: true
5
5
  steering-topic: contacts_service
6
6
  match-template-name: nestjs-react-fullstack
@@ -10,7 +10,9 @@ match-template-name: nestjs-react-fullstack
10
10
 
11
11
  > 本 Skill 定义妙搭 Agent 处理**用户 / 部门 / 群组信息**的完整规范:ID 体系、接口返回结构、ID 选择决策、字段获取分级与权限引导。
12
12
  >
13
- > **相关 skill**:当前登录用户("我是谁"、useCurrentUserProfile、req.userContext)见 [`user-identity`](../user-identity/SKILL.md);选择器/展示组件的 props 与用法见 [`client-builtins-user-service`](../client-builtins-user-service/SKILL.md);飞书原生接口(通讯录 API、id_convert)见 [`feishu`](../../../feishu/SKILL.md)。
13
+ > **相关 skill**:当前登录用户("我是谁"、useCurrentUserProfile、req.userContext)见 [`user-identity`](../user-identity/SKILL.md);选择器/展示组件的 props 与用法见 [`client-builtins-user-service`](../client-builtins-user-service/SKILL.md);飞书原生接口(通讯录 API、id_convert)见 [`feishu`](../../../feishu/SKILL.md);对外开放接口的编码规范见 [`openapi-guide`](../openapi-guide/SKILL.md)。
14
+ >
15
+ > **⚠️ 先分流**:一~五节讲的是**浏览器里**的搜人 / 选择器口径。代码跑在**产物服务端**(NestJS service / controller,尤其 `*.openapi.controller.ts`)时,能力边界完全不同——直接看[第六节](#六服务端--openapi-态怎么查通讯录)。
14
16
 
15
17
  ## 命名约定(必读)
16
18
 
@@ -198,7 +200,68 @@ match-template-name: nestjs-react-fullstack
198
200
 
199
201
  ---
200
202
 
201
- ## 六、禁止行为
203
+ ## 六、服务端 / OpenAPI 态怎么查通讯录
204
+
205
+ ### 6.1 为什么服务端不一样
206
+
207
+ 前端靠 cookie,网关校验登录态后注入 `x-larkgw-suda-webuser`;OpenAPI 请求**没有 cookie**,`req.userContext.userId` 恒为空。所以判据很简单:**「返回什么取决于调用者是谁」的能力在 OpenAPI 态一律不可用,「给定 ID 取数据」的可用**。
208
+
209
+ ### 6.2 服务端能做什么
210
+
211
+ > **强制规则**:服务端(尤其 `/openapi`)查用户信息,**唯一允许的实现是 `AuthNPaasService.listUsersByIds`**(禁止项见 6.4.0)。`import` 不到说明 core 版本没跟上,**报告阻塞**,不要加直接依赖绕过、也不要自实现。
212
+
213
+ **按 ID 批量查人** —— 从项目统一入口 `@lark-apaas/fullstack-nestjs-core` 取 `AuthNPaasService`。`PlatformModule.forRoot()` 已注册 global 的 `AuthNPaasModule`,**不需要**在业务 module 加 `providers`,也**不需要**加直接依赖:
214
+
215
+ ```ts
216
+ import { AuthNPaasService } from '@lark-apaas/fullstack-nestjs-core';
217
+
218
+ @Injectable()
219
+ export class TicketService {
220
+ constructor(private readonly authn: AuthNPaasService) {}
221
+
222
+ async attachAssignees(ids: string[]) {
223
+ // 返回与入参等长同序,未命中的位置是 null
224
+ const users = await this.authn.listUsersByIds(ids);
225
+ return users.map((u, i) => ({
226
+ miaoda_user_id: ids[i],
227
+ name: u?.name?.zh_cn ?? u?.name?.en_us ?? '', // name 是 I18nText,不是字符串
228
+ avatar: u?.avatar?.image?.large ?? '', // 头像 URL 在 image.large 上
229
+ }));
230
+ }
231
+ }
232
+ ```
233
+
234
+ - 入参是 **miaoda_user_id**,单次最多 **100** 个;返回 `(MiaodaUserInfo | null)[]`,与入参等长同序,不丢项也不错位
235
+ - 只有 `miaodaUserID` / `name` / `avatar` 三个字段——**没有**邮箱、手机号、工号、部门
236
+ - 平台出错抛 `HttpException`(502),**不会**静默返回空数组——**不要 try/catch 吞掉**,「平台挂了」和「查无此人」必须能区分
237
+ - 服务端拿不到的字段(部门 / 邮箱 / 工号 / 职位)**从响应类型里删掉**,别用 `''` 占位骗调用方;**并告诉用户**这些字段要走飞书开放平台通讯录 API(见 6.3),不是没实现
238
+
239
+ **妙搭 ↔ 飞书 ID 转换** —— 同一个 `AuthNPaasService` 上的 `getBatchLarkUserIds()` / `getBatchMiaodaUserIds()`(双向批量,不依赖调用者身份)。**不要拿它判断用户是否存在**——外部用户本来就没有 employeeId,会被误判成「用户不存在」。判存在性用 `listUsersByIds` 的返回是不是 `null`。
240
+
241
+ ### 6.3 服务端做不到什么,改走哪里
242
+
243
+ | 想做的事 | 服务端 | 替代路径 |
244
+ |---|:---:|---|
245
+ | 按 ID 批量查人 | ✅ | `AuthNPaasService.listUsersByIds` |
246
+ | 妙搭 ↔ 飞书 ID 转换 | ✅ | `AuthNPaasService.getBatchLarkUserIds` / `getBatchMiaodaUserIds` |
247
+ | 搜人 / 搜部门 / 搜群(按关键词) | ❌ | 飞书开放平台通讯录 API(应用身份 + 显式授权的通讯录范围) |
248
+ | 取手机号 / 职位 / 工号 / 直属上级 | ❌ | 同上,字段 ↔ 权限映射见 [`feishu` › contacts.md](../../../feishu/references/contacts.md) |
249
+ | 知道「当前调用者是谁」 | ❌ | OpenAPI 语义上没有当前用户;内部 `/api` 路由才有 `req.userContext.userId` |
250
+ | 按 ID 批量查群 | ❌ | 暂无服务端能力;群信息在前端取,或走开放平台 IM API |
251
+
252
+ **为什么搜索类不给服务端**:结果取决于「谁在搜」,而 OpenAPI 没有登录用户。若平台此时按租户全量返回,外部系统就能通过产物 OpenAPI 把整个企业通讯录搜穿。开放平台那条路的可见范围由租户授权决定,语义上正好匹配。
253
+
254
+ ### 6.4 服务端禁止行为
255
+
256
+ 0. **禁止用 `AuthNPaasService.listUsersByIds` 之外的任何方式在服务端查用户信息** —— 包括:复用应用内已有的用户 / 用户资料 service、自己写 HTTP 裸调平台通讯录接口、从数据库表 join 出人名头像。这条优先级最高,与下面各条冲突时以本条为准
257
+ 1. **禁止在 `*.openapi.controller.ts` 里依赖 `req.userContext.userId`** —— OpenAPI 态恒为空
258
+ 2. **禁止用 `getCurrentUserLarkUserId()`** —— 它读 `userId`,取不到只打日志返回 `null`、**不报错**,静默失效。要飞书 ID 一律用 `getBatchLarkUserIds()`
259
+ 3. **禁止把 `name` 当字符串**渲染或入库 —— 它是 `I18nText`,直接用会得到 `[object Object]`
260
+ 4. **禁止 catch 掉 SDK 抛的平台错误当空结果** —— 「平台挂了」和「查无此人」必须区分开
261
+
262
+ ---
263
+
264
+ ## 七、禁止行为
202
265
  1. **禁用兼容字段**:任何调用不得用 lark_id / lark_department_id / lark_chat_id
203
266
  2. **禁止混用废弃名**:不得用 userID / suda_user_id / larkUserId 等旧名
204
267
  3. **禁止给飞书内置插件传 employee_id**:插件入参必须是 miaoda_user_id
@@ -208,7 +271,7 @@ match-template-name: nestjs-react-fullstack
208
271
 
209
272
  ---
210
273
 
211
- ## 七、错误处理
274
+ ## 八、错误处理
212
275
  | 错误场景 | Agent 行为 |
213
276
  |---|---|
214
277
  | 用户说"获取 user_id" | 追问:妙搭 miaoda_user_id 还是飞书 employee_id? |
@@ -4,12 +4,11 @@ description: Use when planning, adding, debugging, or reviewing NestJS cache log
4
4
  steering: true
5
5
  steering-topic: nestjs_cache
6
6
  match-template-name: nestjs-react-fullstack
7
- control-by-feature-ab: true
8
7
  ---
9
8
 
10
9
  # NestJS Caching 使用指南
11
10
 
12
- 本 Skill 用于妙搭 NestJS 后端的缓存评估、接入、优化、审查和排障。先判断数据能否复用、谁可以读取、允许多旧,再确定 Key、TTL、失效方式和实现。平台已注入 `CACHE_MANAGER`,业务直接使用。触发本 Skill 只表示需要评估缓存;收益不足或正确性条件不满足时,说明原因并给出合适的处理方向。
11
+ 在 NestJS 后端为读多写少路径加缓存。平台已注入 `CACHE_MANAGER`,业务只需要:**判断该不该缓存 → 选一种使用姿势 → 遵守 Key 命名和 TTL 规范**。
13
12
 
14
13
  ## 前置约束:不要自己装配 `CacheModule`
15
14
 
@@ -47,41 +46,37 @@ export class BookModule {}
47
46
  │ c) 读得贵(触发外部收费 API / 高负载 RPC / 大结果集 SQL)
48
47
  │ ├─ 都不满足 → 不加。加了维护成本 > 收益
49
48
  │ └─ 满足任一 → 下一步
50
- ├─ 返回数据是否受身份或权限影响?
51
- │ ├─ 否 → 下一步
52
- │ └─ 是 → 每次请求仍须执行必要鉴权;缓存命中不能跳过方法内的权限检查
53
- │ 身份相关结果必须隔离,权限变化后旧结果不得越权访问
54
- │ 无法满足时 → 不缓存该结果;继续评估可独立复用的公共数据
49
+ ├─ 是否是**授权敏感路径**?
50
+ │ a) 该 handler / 方法被 `@CanRole` / `@Can` 装饰
51
+ │ b) 返回值由 PostgreSQL RLS(Row Level Security)过滤(登录身份不同结果不同)
52
+ │ ├─ 是 → 不加。授权决策 / RLS 过滤后的结果与身份强绑定,
53
+ │ │ 缓存 = 越权(陈旧 policy)或串号(不同用户读同一 key)
54
+ │ └─ 否 → 下一步
55
55
  ├─ 业务可容忍 TTL 期内数据轻微陈旧(读到几秒前的旧值不影响正确性)?
56
56
  │ ├─ 否(要强一致,如库存/余额/权限当次判定)→ 不加
57
57
  │ └─ 是 → 下一步
58
- ├─ 数据复用收益是否高于缓存维护成本?
59
- │ 结合重复读取量、更新频率、允许的陈旧时间和失效成本判断;读多写少是优先信号
60
- │ ├─ 否,或无法满足一致性要求 → 不加
61
- │ └─ 是 → 可加;写入较频繁时,仍需确认失效方式可维护、预期命中率有价值
62
- └─ 是否存在同一 Key 的并发回源?
63
- └─ 是 → 使用 `cache.wrap` 合并单进程内并发;多实例仍可能分别回源,继续评估源侧承载能力
58
+ ├─ 数据是"读多写少"(读远多于写,写触发时能明确 invalidate 哪些 key)?
59
+ │ ├─ 否(写路径同样频繁、或 invalidate 范围爆炸)→ 不加,命中率上不去
60
+ │ └─ 是 → 加缓存,选一种姿势(见下方)
61
+ └─ 是否可能出现热点 key(一份数据被大量请求同时命中,如首页配置、公共字典)?
62
+ └─ 是 → 必须用 `cache.wrap` 拿 single-flight 语义,防击穿
64
63
  ```
65
64
 
66
- **判断技巧**:先确认相同输入是否会重复读取,再检查身份隔离、允许的陈旧时间和失效方式。连续请求可以返回相同结果,只是复用信号,不能替代这些检查。接口慢时也要检查索引、重复查询等源侧问题。
65
+ **判断技巧**:拿不准就问自己——"如果同一个用户 1 秒内连点 5 次,我希望后 4 次直接返回上次结果吗?" 是 → 加;否(每次必须重算)→ 不加。
67
66
 
68
- **优先评估**:外部 API 元数据、公共字典、配置、热点详情、允许陈旧的报表,以及满足身份隔离和权限变化要求的用户资料。一个接口包含多步读取时,分别评估各步骤。
67
+ **典型该加**:外部 open API 元数据 / 长尾字典表 / 用户 profile / 频繁读取的配置 / 慢报表聚合结果。
69
68
 
70
- **不使用缓存替代**:库存扣减、余额校验、授权决定等当次权威判断,以及事务型写入。历史记录和统计展示可单独评估;参数不重复、复用收益低的查询通常不加。
69
+ **典型不该加**:金融流水、库存扣减、鉴权决策、事务型写路径、每次都不同参数的即席查询。
71
70
 
72
71
  ## Quick Reference
73
72
 
74
73
  | 场景 | 用法 |
75
74
  |------|------|
76
- | 单次读缓存、未命中回源 | `get` → 未命中时读取源数据 → `set`;需并发合并时用 `wrap` |
77
- | 公共配置、字典、热点数据 | 使用平台共享缓存;`wrap` 仅合并单进程内对同一 Key 的并发读取 |
78
- | 可缓存的 GET 返回值 | `CacheInterceptor` + `CacheTTL`;静态 Key 仅用于所有相关请求结果相同的场景 |
79
- | 用户或租户维度的数据 | 先确认鉴权、身份隔离和权限变化要求,再选择手动缓存或受支持的动态 Key |
80
- | 列表、筛选、分页、报表 | Key 纳入影响结果的身份与有效查询参数;估算容量并明确失效方式 |
81
- | 反复读取不存在的资源 | 满足新数据可见性要求时缓存带类型标记的空结果;不缓存暂时故障 |
82
- | 一个接口包含多步读取 | 分别评估公共数据、用户数据和计算结果,只缓存满足条件的部分 |
83
- | 写后失效 | 业务数据提交成功后 `del` 相关 Key,并评估并发旧值回填 |
84
- | TTL 单位 | 毫秒;同时遵循平台允许的范围 |
75
+ | 读一次+回填 | `await cache.get(key)` → miss 时 fetch → `cache.set(key, val, ttlMs)` |
76
+ | 热点 key 防击穿 | `await cache.wrap(key, () => loader(), ttlMs)` |
77
+ | 幂等 GET 接口一键缓存 | `@UseInterceptors(CacheInterceptor)` + `@CacheKey` + `@CacheTTL` |
78
+ | 写路径失效缓存 | `await cache.del(key)` |
79
+ | TTL 单位 | 毫秒(cache-manager v6 起) |
85
80
 
86
81
  ## 三种使用姿势
87
82
 
@@ -116,9 +111,9 @@ export class ProfileService {
116
111
 
117
112
  ### B. `cache.wrap`(防击穿 / single-flight)
118
113
 
119
- 对同一缓存实例和 Key,`cache.wrap` 会合并同一进程内重叠的读取:只有一个 loader 执行,其他请求等待结果。热点或冷启动出现并发回源时优先使用。
114
+ 热点 key 同时被 N 个请求命中时,只有一个 loader 会真正执行,其他并发请求等它结果。冷启动场景必须用这个而不是 A 方案。
120
115
 
121
- > **边界**:不同 FaaS 实例各自合并,`wrap` 不保证全局只回源一次。若源侧仍无法承受多实例并发,应根据平台已有能力选择进一步措施;不要假设 `CACHE_MANAGER` 自带分布式锁。
116
+ > **限定**:`wrap` 的 single-flight 是**单实例进程内**去重(v6 内嵌 `promise-coalesce`,本质是内存 Map)。多 pod 部署时每个实例的首个请求各自触发一次 loader → 最多 N 个 pod 并发回源 N 次,而非全局 1 次。要做真正的全局防击穿需额外配分布式锁。
122
117
 
123
118
  ```typescript
124
119
  async getConfig(appApiName: string) {
@@ -132,9 +127,9 @@ async getConfig(appApiName: string) {
132
127
 
133
128
  ### C. `CacheInterceptor` + 装饰器(Controller 方法级)
134
129
 
135
- 仅用于可缓存的 GET,返回值须为 JSON-safe,缓存命中不能跳过必要鉴权或方法内的权限检查。默认按 URL 生成 Key,不会自动包含登录身份。身份相关结果须满足前述隔离与权限变化要求,再选择手动缓存或动态 Key。
130
+ 只适合**幂等 GET** 且返回值 JSON-safe。写请求、带循环引用 / Buffer / Date 的返回值、需要按登录用户维度分 key 的场景,不要用它——用 A 方案。
136
131
 
137
- 当前 `@nestjs/cache-manager@3.1.3` 的 `@CacheKey` 支持字符串和 Key 工厂函数。字符串 Key 是固定值,仅适用于相关请求结果相同的场景;动态 Key 应包含影响结果的路由、查询与身份维度。无身份等额外差异时,可不写 `@CacheKey` 并沿用 URL Key。已有项目先确认版本支持;示例中的 URL 方案仍可使用。
132
+ **`@CacheKey` 是静态常量**:所有请求都命中同一条缓存,会忽略路由参数 / query。带路由参数(如 `:apiName`)时**不要写 `@CacheKey`**,让 interceptor 按完整 URL 自动分 key;否则 app A 的结果会被返回给 app B(串号)。
138
133
 
139
134
  ```typescript
140
135
  import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager';
@@ -155,13 +150,13 @@ export class AppsController {
155
150
  |------|------|------|
156
151
  | 业务前缀必需 | `profile:${userId}` | 避免多应用共用 Redis 时 key 冲突 |
157
152
  | 冒号分层 | `app:${apiName}:config:${env}` | 便于排障时 SCAN |
158
- | 参数拼接稳定且完整 | 筛选、排序、分页等有效参数按固定字段顺序编码;按需加入可信的身份维度 | 相同条件得到相同 Key,不同结果不能因遗漏参数而共用 Key |
153
+ | 参数拼接稳定 | 多参数按字典序拼接 | 避免 `?a=1&b=2` vs `?b=2&a=1` 命中不同 key |
159
154
  | 禁用大对象作 key | 不要 `JSON.stringify(user)` 当 key | key 过长 / 特殊字符转义踩坑 |
160
155
  | Key 长度硬上限 | 必须 ≤ 512 字符 | 平台校验,超过直接跳过缓存 |
161
156
 
162
157
  ## 平台校验限制(硬门槛)
163
158
 
164
- 以下是当前平台规定的 Key、TTL、数量和 Value 边界,业务代码按这些限制设计。缓存失败或拒写时,业务应使用正常回源结果;具体拒绝行为和 Trace 字段以部署版本的 SDK 与服务端契约为准。排障时核对实际错误,不仅凭本表推断原因。
159
+ 平台会在缓存读写时做以下校验。**超过限制不会抛异常,而是本次请求跳过缓存 → 原读取逻辑正常执行 → Trace 记录 error_code**。写代码时按这套边界规划 key / TTL / value:
165
160
 
166
161
  | 限制项 | 边界 | 超限行为 | error_code |
167
162
  |--------|------|----------|------------|
@@ -197,16 +192,16 @@ cache.get(key) 场景
197
192
  |------|----------|
198
193
  | Key 不加业务前缀 | 必须 `${业务域}:${resource}:${id}` 分层,避免多应用 / 多资源撞车 |
199
194
  | 空字符串 / `null` / `undefined` 当 key | 平台校验空 key,本次跳过缓存(`CACHE_KEY_INVALID`);调用前先判空 |
200
- | 用户输入直接拼 Key,或截断后作为 Key | 先规范化影响结果的完整参数,再计算摘要;不要用截断代替摘要。摘要只缩短 Key,不减少不同参数组合的数量,仍需评估平台配额 |
195
+ | 用户输入直接拼 key(`search:${rawQuery}`) | rawQuery 可能超 512 字符(`CACHE_KEY_TOO_LONG`);先 hash 或截断 |
201
196
  | 把大对象 / JSON / SQL 塞进 key | 长度爆炸 + 特殊字符转义踩坑;用结构化 id 或摘要 |
202
197
  | Key 里放 traceId / timestamp / requestId | key 空间爆炸,撞 10,000 上限后新 key 不再写入(`CACHE_KEY_QUOTA_EXCEEDED`) |
203
- | 多参数拼接顺序不稳定或遗漏有效参数 | 固定字段顺序,包含所有影响结果的参数;规范化不能改变查询含义,例如有顺序语义的数组不能随意排序 |
198
+ | 多参数拼接顺序不稳定 | 多参数按字典序拼接,避免 `?a=1&b=2` vs `?b=2&a=1` 命中不同 key |
204
199
 
205
200
  ### TTL 相关(v6 单位是**毫秒**)
206
201
 
207
202
  | 错误 | 正确做法 |
208
203
  |------|----------|
209
- | TTL 单位误传为秒,例如用 `60` 表示 1 分钟 | `cache-manager` v5、v6 的 TTL 均以毫秒计,1 分钟应传 `60_000`。当前平台仍要求 TTL 在 `30_000 - 86_400_000` ms 内,`60` 不在允许范围内 |
204
+ | TTL 传秒(v5 旧思维,`60` = 想要 1 分钟) | v6 起是毫秒,`60_000` 才是 1 分钟;且 `60` = 60ms 会被平台拒(`CACHE_TTL_OUT_OF_RANGE`) |
210
205
  | TTL 传 `0` 想表达永久 | v6 `0` = 不过期,但平台也拒;接近永久用 `24 * 3600_000`(24h 上限) |
211
206
  | TTL 短于 30s | 平台拒(`CACHE_TTL_OUT_OF_RANGE`),本次不缓存;实际范围 `30_000 - 86_400_000` |
212
207
 
@@ -214,9 +209,9 @@ cache.get(key) 场景
214
209
 
215
210
  | 错误 | 正确做法 |
216
211
  |------|----------|
217
- | 直接缓存 Buffer / Date / 循环引用对象 | 转成 JSON-safe DTO,并明确缓存命中与回源返回相同的数据结构;日期可转为约定的字符串,二进制使用文件地址 |
218
- | 缓存 `null` 表示“资源不存在” | 用带类型标记的结构,如 `{ kind: 'not-found' }`,与 miss 区分。只有允许延迟发现新数据时才缓存;TTL 遵循平台范围,超时和服务错误不能写成“不存在” |
219
- | 单 value 接近或超过 1 MB | 先裁剪字段或按分页 Key 拆分;大文件用 CDN / 对象存储。分页 Key 仍需纳入查询维度并估算数量 |
212
+ | 缓存 Buffer / Date / 循环引用对象 | 序列化只保证 JSON-safe;复杂对象手动 toDTO |
213
+ | 缓存 `null` 当"存在" | miss 就是 `null`,两者不可区分;表达"值就是空"用哨兵字符串(如 `'__EMPTY__'`)读到再转回 `null` |
214
+ | 单 value 接近或超过 1 MB | 平台拒写(`CACHE_VALUE_TOO_LARGE`);先投影裁剪或按分页 key 拆开;大 blob 用 CDN / 对象存储 |
220
215
 
221
216
  ### API 姿势相关
222
217
 
@@ -224,18 +219,18 @@ cache.get(key) 场景
224
219
  |------|----------|
225
220
  | 业务模块里再 `CacheModule.register()` / `registerAsync(...)` | 平台已全局注册,直接 `@Inject(CACHE_MANAGER)`;重复注册会创建独立内存 store,不走 Redis、不 fail-open |
226
221
  | 用 `CacheInterceptor` 缓存写请求 | Interceptor 只适合幂等 GET;写请求用 A 方案,配 `cache.del(key)` |
227
- | 不同路由、查询或身份结果共用一个静态 `@CacheKey` | 静态 Key 不会区分这些结果;无额外身份差异时可用 URL Key,否则使用包含必要维度的动态 Key 或手动缓存,并满足鉴权要求 |
222
+ | `@CacheKey` 静态字符串包含用户维度,或带路由参数却写死 key | Interceptor 不感知登录态、静态 key 忽略路由参数/query → 串号;带用户或路由维度改用 A 方案手动拼 key |
228
223
  | 高 QPS 热点用 A 方案而非 `wrap` | 击穿风险,冷启动 loader 会被并发触发 N 次;换 B 方案拿 single-flight(注意仍是单实例内去重) |
229
- | 用 `cache.wrap` 包业务写入 | `wrap` 仅用于读取和回填。业务数据提交成功后,再 `cache.del(key)` 失效相关缓存;涉及详情和列表时分别确定相关 Key。删除缓存不能保证与并发读取强一致 |
230
- | 把 `await cache.set()` 等同于业务数据提交,或默认它一定不等待写入 | 当前锁定 SDK 会等待底层 `set`;服务端写入可见性取决于后端契约。缓存不能作为业务提交依据;写后直接使用本次计算或保存得到的结果,不用立即 `get` 来证明业务成功 |
224
+ | 用 `cache.wrap` 包写路径 | wrap 是"读 + 回填"模型;写路径 `.set()` 后再 `.del()` |
225
+ | `set` 之后立刻 `get` 期望读到 | 平台侧写入 fire-and-forget,**即便 `await set` 也不保证同请求/下一 tick 可见**;写完后直接返回本地变量 |
231
226
 
232
227
  ### 授权 / 身份相关(可能导致越权)
233
228
 
234
229
  | 错误 | 正确做法 |
235
230
  |------|----------|
236
- | 看到 `@CanRole` / `@Can` 就直接拒绝缓存 | 每次请求保留必要鉴权;按返回数据的共享范围评估,命中不能跳过方法内的权限检查 |
237
- | 用默认 URL Key 缓存按当前身份过滤的结果 | URL 不能表达身份;需从可信请求上下文取得租户、用户等必要维度,隔离结果并满足权限变化要求 |
238
- | 认为 Key 带 `tenantId` / `userId` 就足够安全 | 身份隔离不能解决权限撤销;无法确保旧结果仍有权访问时,不缓存该结果。Key 数量仍须遵循平台配额 |
231
+ | 缓存 `@CanRole` / `@Can` 装饰的 handler 返回值 | 授权决策与登录身份耦合,缓存陈旧 → 该拒的放行 = 越权;直接不加缓存 |
232
+ | 用 `CacheInterceptor` 缓存"读取当前登录用户可见列表"(背后走 PostgreSQL RLS 过滤) | RLS 按 `current_user` / `set_config` 注入的租户/身份过滤,Interceptor 不感知身份 → 用户 A 的结果被用户 B 读到 = 越权串号;这类接口不加缓存,或用 A 方案手动把 `userId` / `tenantId` 拼进 key |
233
+ | 手动缓存 RLS 保护表的查询结果,key 里没带身份维度(如 `list:tasks`) | 同上,多用户共享同一 key → 串号;key 必须至少含 `${tenantId}:${userId}`,且注意 key 空间是否会撞 10,000 上限 |
239
234
 
240
235
  ### 防御式代码
241
236
 
@@ -252,14 +247,8 @@ cache.get(key) 场景
252
247
  | 本地测缓存正常,线上不共享 / 重启就丢 | 业务模块里自己 `CacheModule.register()` 了,跑的是内存 store | 检查 `imports:` 是否含 `CacheModule.*`;删掉,直接 `@Inject(CACHE_MANAGER)` |
253
248
  | Interceptor 不生效 | 忘 `@UseInterceptors(CacheInterceptor)` 或 Controller 没注册 | 在 loader 加日志,观察是否每次请求都被调 |
254
249
  | TTL "不生效" | 传成秒、传 `0`、或超出 `30s - 24h` 平台范围 | Trace 里出现 `CACHE_TTL_OUT_OF_RANGE` 就是超范围 |
255
- | 数据陈旧 | 写后未失效、删除失败、遗漏相关 Key,或旧的并发读取在删除后又回填 | 核对写入提交、删除和回填的先后顺序,检查缓存错误与 Key 覆盖范围;需要强一致的读取不要依赖缓存 |
250
+ | 数据陈旧 | 写路径没 `cache.del` 主动失效 | 更新时 log 出 del 的 key,比对读路径的 key |
256
251
  | Trace 出现 `CACHE_KEY_TOO_LONG` | key 里塞了整段 SQL / 用户输入 / JSON | 用 hash 或结构化 id 替换 |
257
252
  | Trace 出现 `CACHE_VALUE_TOO_LARGE` | 整表 / 大 list 直接缓存 | 分页缓存或投影裁剪,把单个 value 压到 1 MB 以内 |
258
253
  | Trace 出现 `CACHE_KEY_QUOTA_EXCEEDED` | key 空间失控(含 traceId / timestamp / 用户输入) | 收敛 key 模板,用有限的枚举维度 |
259
- | 并发压测下源侧被打爆 | 同一 Key 并发回源、Key 不稳定、缓存不可用或多实例同时冷启动 | 先核对 Key、命中与实例数;`wrap` 只合并单进程内并发,多实例压力需结合平台能力另行处理 |
260
-
261
- ## 验证缓存行为
262
-
263
- 按本次改动验证相关场景:命中时减少回源,未命中正常读取,`0` / `false` / 空字符串不被误判;影响结果的参数和身份维度正确隔离;写入后按约定失效;缓存不可用时正常回源且业务错误仍能暴露。
264
-
265
- 涉及身份数据时,验证缓存命中不会跳过鉴权,并检查权限撤销后旧结果不可越权访问。涉及热点时区分单进程并发合并与多实例分别回源。无法运行的检查应说明原因,不将方案预期写成已验证结果。
254
+ | 并发压测下源侧被打爆 | 热点未用 `wrap` | 换 A → B,加 single-flight |
@@ -17,6 +17,7 @@ file-match-pattern:
17
17
  |------|----------------------|--------------------------|
18
18
  | 鉴权 | 写操作加 `@NeedLogin()` | **不加** `@NeedLogin()`,鉴权在网关层通过 API Key 完成 |
19
19
  | 用户身份 | `req.userContext.userId` 区分用户 | 统一走系统身份,不依赖 `userId` 做业务区分 |
20
+ | 通讯录 | 前端搜人 / 选择器随便用 | **必须**用 `AuthNPaasService.listUsersByIds`(见下文「用户信息」强制规则),搜索类不可用 |
20
21
  | Controller 文件 | `xxx.controller.ts` | `xxx.openapi.controller.ts`,放在同一 module 下 |
21
22
  | OpenAPI 文档 | 不需要 | **必须**同步维护 `docs/openapi.json`(见下文) |
22
23
 
@@ -59,6 +60,80 @@ findAll(@Req() req: Request) {
59
60
  }
60
61
  ```
61
62
 
63
+ ## 用户信息(通讯录)—— 强制规则
64
+
65
+ **`/openapi` 路由下查询用户信息,唯一允许的实现是 `AuthNPaasService.listUsersByIds`。没有例外。**
66
+
67
+ ```typescript
68
+ // ✅ 唯一允许的写法。注意 import 自 fullstack-nestjs-core(项目统一入口)
69
+ import { AuthNPaasService } from '@lark-apaas/fullstack-nestjs-core';
70
+
71
+ @Controller('openapi/xxx')
72
+ export class XxxOpenApiController {
73
+ constructor(private readonly authn: AuthNPaasService) {}
74
+
75
+ @Get(':id')
76
+ async get(@Param('id') id: string) {
77
+ // 不要 try/catch 吞掉——平台故障必须暴露给调用方,别降级成「查无此人」
78
+ const [user] = await this.authn.listUsersByIds([id]);
79
+ if (!user) throw new NotFoundException(`用户 ${id} 不存在`);
80
+ return {
81
+ miaodaUserId: id,
82
+ name: user.name?.zh_cn ?? user.name?.en_us ?? '', // name 是 I18nText,不是字符串
83
+ avatar: user.avatar?.image?.large ?? '', // 头像 URL 在 image.large 上
84
+ };
85
+ }
86
+ }
87
+ ```
88
+
89
+ **不要做这两件多余的事**(`PlatformModule.forRoot()` 已经注册了 global 的 `AuthNPaasModule`):
90
+
91
+ - ❌ 在业务 module 里写 `providers: [AuthNPaasService]` —— 会另建一个实例
92
+ - ❌ 往 `package.json` 加 `@lark-apaas/nestjs-authnpaas` 直接依赖 —— 走 `fullstack-nestjs-core` 即可
93
+
94
+ ### 禁止(以下任一出现即为错误实现)
95
+
96
+ | ❌ 禁止 | 为什么 |
97
+ |---|---|
98
+ | 复用应用里已有的用户 / 用户资料 service | 那些多半是给浏览器写的,依赖登录态;`/openapi` 下没有登录用户,会静默返回空 |
99
+ | 自己写 HTTP 请求裸调平台通讯录接口 | 鉴权 / 重试 / 日志 / trace 都在 SDK 里,裸调必然漏 |
100
+ | 用 `req.userContext.userId` 做用户查询 | `/openapi` 下**恒为空** |
101
+ | 用 `AuthNPaasService.getCurrentUserLarkUserId()` | 它读 `userId`,取不到只打日志返回 `null`、**不报错**,故障静默 |
102
+ | 从数据库表里 join 出人名 / 头像 | 通讯录不是应用数据,会过期且不 follow 权限 |
103
+ | 按关键词搜人 / 搜部门 / 搜群 | 结果取决于「谁在搜」,`/openapi` 下没有调用者视角,**服务端不提供** |
104
+ | `try/catch` 吞掉 `listUsersByIds` 抛的错、返回空值 | 「平台挂了」和「查无此人」是两回事,吞掉就没人知道故障 |
105
+ | 用 `getBatchLarkUserIds` 判断用户是否存在 | 它是 ID 转换,**外部用户本来就没有 employeeId**,会把外部用户误判成不存在 |
106
+ | 服务端拿不到的字段用 `''` 占位塞进响应 | 调用方会以为字段存在只是没值。拿不到就**从响应类型里删掉**,并告诉用户改走飞书开放平台通讯录 API |
107
+
108
+ ```typescript
109
+ // ❌ 错误:包了一层应用现有的用户服务
110
+ const profile = await this.userProfileService.findById(id);
111
+
112
+ // ❌ 错误:OpenAPI 态搜不了人
113
+ const users = await someSearchApi({ query: keyword });
114
+
115
+ // ❌ 错误:读 userId,OpenAPI 态恒为空且失败不报错
116
+ const larkId = await this.authn.getCurrentUserLarkUserId();
117
+
118
+ // ❌ 错误:吞掉平台故障 + 拿不到的字段用空串占位
119
+ let name = '';
120
+ try {
121
+ const [u] = await this.authn.listUsersByIds([id]);
122
+ name = u?.name?.zh_cn ?? '';
123
+ } catch { /* 平台挂了也当查无此人 */ }
124
+ return { userId: id, name, department: '', email: '', jobTitle: '' };
125
+ ```
126
+
127
+ ### `AuthNPaasService` import 不到时
128
+
129
+ 说明当前 `@lark-apaas/fullstack-nestjs-core` 版本还没带上这个能力。**报告阻塞、请人升级 core 版本**——不要加 `@lark-apaas/nestjs-authnpaas` 直接依赖绕过去,也不要退回上面任何一种禁止写法或自己实现替代品。
130
+
131
+ ### 服务端拿不到的字段
132
+
133
+ 手机号 / 职位 / 工号 / 直属上级 / 在职状态、以及按 ID 查群 —— 服务端**没有**这些能力。需要就走飞书开放平台通讯录 API,或把该能力放回前端做,**不要**在 `/openapi` 里自己拼一个。
134
+
135
+ 完整能力边界与字段口径见 [`contacts-service` › 第六节](../contacts-service/SKILL.md)。
136
+
62
137
  ## 模块组织
63
138
 
64
139
  `/openapi` Controller 和 `/api` Controller 放在**同一个 module** 下,共享 Service 层。用文件名 `xxx.openapi.controller.ts` 区分。
@@ -20,8 +20,10 @@ gate-tools:
20
20
  | 获取运行时投影 | 调用 `get_plugin_ai_json(pluginInstanceId)` |
21
21
  | Client 侧调用 | `capabilityClient.load(id).call(actionKey, input)`(流式用 `callStream`) |
22
22
  | Server 侧调用(仅兜底) | `capabilityService.load(id).call(actionKey, input)` |
23
+ | 长耗时 AI 结果 | 大体量/多字段/多份/多语言/文件或多模态串联等结构信号命中时,优先前端 `callStream` 渐进展示;需保存则流式结束后复用 CRUD 落库;仅在 Client 侧无法满足时使用后端任务记录 + 状态查询 + 结果读取;禁止单个 HTTP 请求等待完整结果后才返回 |
23
24
  | capabilityClient 导入 | `import { capabilityClient } from '@lark-apaas/client-toolkit'` |
24
25
  | CapabilityService 导入 | `import { CapabilityService } from '@lark-apaas/fullstack-nestjs-core';` |
26
+ | CapabilityService 注入 | `@Inject(CapabilityService) private readonly capabilityService: CapabilityService`(**禁止空参 `@Inject()`**,会导致启动崩溃 502;业务 Module 无需 imports) |
25
27
 
26
28
  ## 必读 references
27
29
 
@@ -70,6 +72,7 @@ gate-tools:
70
72
  - 必须先调 `get_plugin_ai_json(pluginInstanceId)`,再产出 **Schema 摘录卡**(格式见 `references/plugin-coding-guide.md`);摘录卡字段缺失禁止编码,`output.fields` 必须完整列出且每个输出字段在代码中被消费(持久化或展示)
71
73
  - 按 `actions[].key` 选 actionKey;严格按 `inputSchema` 构造入参(`type: array` 字段必须传数组)、按 `outputSchema` 解析出参——流式 chunk 是**对象**(按字段解构如 `chunk.content`,禁止当字符串拼接),非流式同理按字段名读取;**务必阅读并遵循 `readme`**
72
74
  - 调用侧:优先 Client(`unary` → `call()`,`stream` → `callStream()`);触发器/定时任务、敏感凭证、强事务、结果需落库 → Server 侧
75
+ - 高耗时 AI capability 闸门:若运行时投影或功能设计显示输出规模大、输出字段多、需要多份结果、多语言/长文本、文件或多模态输入后继续生成、多个 capability 串联、或结果需后续查看/落库,禁止把完整生成放进一个同步 HTTP 请求等待;能由前端承接时优先 `callStream` 渐进展示,需保存则流式结束后复用 CRUD 落库;仅在 Client 侧无法满足时后端创建任务记录后快速返回、由前端短轮询状态/结果,或拆成多个独立小调用
73
76
  4. **代码放置**:Client(默认,用户交互触发)→ `client/` 组件/hooks;Server(兜底)→ `server/` Service。
74
77
  5. **真实调用冒烟(完成前必须)**:至少成功调用一次 `call()` 或 `callStream()`(按 outputSchema 读 chunk);失败日志含最小字段(字段清单见 `references/plugin-coding-guide.md`)。无冒烟结果不得宣告完成。
75
78
 
@@ -136,6 +139,9 @@ const structured = await capabilityClient
136
139
  ├── 输出包含多个独立字段(标题+正文+评分等)
137
140
  │ → 拆成多个独立插件并行调用(⭐ 优先)或用 ai-text-to-json
138
141
  │ → ⛔ 禁止用 ai-text-generate + 正则/split 解析多字段
142
+ ├── 输出规模大(多份结果、多语言、长正文、多章节或批量对象)
143
+ │ → 优先拆分为多个独立生成单元;需要汇总持久化时使用任务状态模型承接
144
+ │ → ⛔ 禁止一个后端 HTTP 请求同步等待所有生成结果后才响应
139
145
  └── 输出为单一文本(仅展示,不需解析)→ ai-text-generate
140
146
  ```
141
147
 
@@ -203,9 +209,9 @@ const structured = await capabilityClient
203
209
 
204
210
  1. **禁止静默吞异常**:每个 `catch` 至少满足其一——向用户展示错误(toast/页面状态),或触发补偿机制(重试/降级/记录待处理列表)
205
211
  2. **异步操作必须有终态**:不阻塞主流程的插件调用须在 DB 维护状态(pending → success/failed),前端必须展示 failed,不能永远 loading
206
- 3. **通知类插件失败必须有补偿**:如 `send-feishu-message` 失败,至少记录"待发送"列表或 UI 提示"通知发送失败,请手动联系"
207
- 4. **配置完整性(load 前必查)**:`load(id)` 前确认实例已创建且 id 与代码完全匹配,否则抛 `CapabilityNotFoundError`(开发态 Top 错误);load/call 失败时停止后续请求避免放大错误;**缓存 load 结果**,同一 id 不重复 load
208
- 5. **线上/客户反馈的插件问题先查 runtime log 取证**:用户反馈「线上 / 已发布」的插件问题(飞书消息发不出 / 插件不生效 / capability 调用「用户收不到」)时,**先查线上 runtime log 再定位代码**——用 `miaoda observability log`(必要时 `trace`)看 CapabilityService 真实错误、plugin_key、action、必填参数/输入校验错误,拿到线上 ERROR/WARN 再读代码修复。**即使你已在代码里读到一个疑似原因,也不得据此直接下根因、跳过取证**——静态代码里的可疑点常不是运行时真正的失败点(线上真错误多为运行时入参为空 / 校验失败 / 授权态问题,代码静态看不出),runtime log 是这类问题下结论前的必经步。沙箱 `read_logs` 只覆盖 dev 态,**不能**作为「线上插件无错误」的证据;纯本地 dev 态插件报错仍用 `read_logs`
212
+ 3. **长耗时 AI 生成不得同步等完**:大体量、多字段、多份、多语言、文件/多模态串联或需要持久化的 AI capability 调用,触发接口必须快速返回任务状态或采用前端流式展示;若 `api_request`、浏览器操作或页面请求出现超时/连接断开,不能只凭服务端最终日志或数据库最终写入宣告成功,必须证明用户侧存在可读取的终态结果
213
+ 4. **通知类插件失败必须有补偿**:如 `send-feishu-message` 失败,至少记录"待发送"列表或 UI 提示"通知发送失败,请手动联系"
214
+ 5. **配置完整性(load 前必查)**:`load(id)` 前确认实例已创建且 id 与代码完全匹配,否则抛 `CapabilityNotFoundError`(开发态 Top 错误);load/call 失败时停止后续请求避免放大错误;**缓存 load 结果**,同一 id 不重复 load
209
215
 
210
216
  ## 缓存与幂等性
211
217
 
@@ -240,7 +246,7 @@ const structured = await capabilityClient
240
246
 
241
247
  ### 通知接收人动态解析
242
248
 
243
- 接收人(`receiverUserList`/`receiverGroupList` 等)按角色/条件变化时,必须实时查询角色成员经 `input` 传入(角色/成员的运行时查询写法见 `authz-guide` 技能),禁止硬编码或凭经验拼装 ID。如引入缓存,必须提供显式失效手段(如角色变更时清缓存)并明示 TTL,禁止无失效手段的常驻缓存导致接收人信息过期。通知发送失败的补偿要求见上文「插件调用错误处理」第 3 条铁律,不重复展开。
249
+ 接收人(`receiverUserList`/`receiverGroupList` 等)按角色/条件变化时,必须实时查询角色成员经 `input` 传入(角色/成员的运行时查询写法见 `authz-guide` 技能),禁止硬编码或凭经验拼装 ID。如引入缓存,必须提供显式失效手段(如角色变更时清缓存)并明示 TTL,禁止无失效手段的常驻缓存导致接收人信息过期。通知发送失败的补偿要求见上文「插件调用错误处理」第 4 条铁律,不重复展开。
244
250
 
245
251
  ## 飞书深链 URL 规范
246
252
 
@@ -2,15 +2,19 @@
2
2
 
3
3
  ### 核心原则:根据场景选择调用侧
4
4
 
5
- **默认优先在 Client 侧调用 capabilityClient;但当插件结果需要持久化到数据库时,应考虑在 Server 侧调用或确保前端调用后及时通过后端接口保存。**
5
+ **默认优先在 Client 侧调用 capabilityClient;流式或高耗时 AI 生成优先 `callStream` 渐进展示。结果需要持久化时,优先在流式结束后通过已有后端接口保存;只有 Client 侧无法满足时才切到 Server 侧。**
6
+
7
+ **高耗时 AI capability 先判调用形态**:只要输出规模大、输出字段多、需要多份结果、多语言/长文本、文件或多模态输入后继续生成、多个 capability 串联、或结果需后续查看/落库,就不得把完整 AI 结果塞进一个同步 HTTP 请求等待。能由前端承接时,优先用前端 `callStream` 渐进展示,并在流式结束后按需复用已有 CRUD 接口保存结果;只有 Client 侧无法满足(触发器/敏感凭证/强事务/必须由后端保证落库一致性等)时,才采用后端任务记录 + 状态/结果查询,避免把后台任务作为默认方案。
8
+
6
9
  **严禁** import { capabilityClient } from '@lark-apaas/client-capability'。
7
10
  **唯一指定**的导入方式是 import { capabilityClient } from '@lark-apaas/client-toolkit';
8
11
 
9
12
  | 优先级 | 场景 | 调用方式 |
10
13
  |-------|------|---------|
11
- | **首选** | 绝大多数场景 | `capabilityClient.load(id).call()` |
12
- | **首选** | 流式输出场景 | `capabilityClient.load(id).callStream()` |
13
- | **兜底** | Client 侧无法满足时 | `CapabilityService.load(id).call()` |
14
+ | **首选** | 绝大多数即时展示场景 | `capabilityClient.load(id).call()` |
15
+ | **首选** | 流式输出,或高耗时但可由前端承接的 AI 生成 | `capabilityClient.load(id).callStream()`(流式结束后按需持久化) |
16
+ | **必要时** | 高耗时且 Client 侧无法满足:触发器、敏感凭证、强事务、必须由后端保证落库一致性 | 后端任务记录 + 后台调用 `CapabilityService.load(id).call()` + 前端短轮询状态/结果 |
17
+ | **兜底** | Client 侧无法满足且单次调用可在交互边界内完成 | `CapabilityService.load(id).call()` |
14
18
 
15
19
  #### 什么情况下应使用 Server 侧?
16
20
 
@@ -19,7 +23,7 @@
19
23
  1. **涉及敏感凭证**:调用需要服务端私密 token/secret,不适合暴露给前端
20
24
  2. **必须后端编排**:多个插件调用之间有强事务依赖,需要后端统一编排
21
25
  3. **触发器/定时任务场景**:没有前端上下文,只能由后端发起
22
- 4. **插件结果需要持久化**:调用结果需要保存到数据库,在 Server 侧可以在同一方法中完成调用+落库,保证数据一致性
26
+ 4. **插件结果需要持久化**:调用结果需要保存到数据库;能由前端承接时,优先前端 `callStream` 渐进展示并在结束后复用已有 CRUD 接口保存;只有 Client 侧无法满足或必须由后端保证落库一致性时,才在 Server 侧调用并落库。若此时输出规模大、字段多、结果多份或需多步能力串联,必须改为任务记录 + 后台执行 + 状态/结果查询,避免一个 HTTP 请求等待完整生成
23
27
 
24
28
  > **提示**:如果插件结果不需要存储、仅用于即时展示(如流式生成文本、发送消息),优先在前端调用。但当结果需要保存到数据库时,不要回避使用 Server 侧。
25
29
 
@@ -42,9 +46,13 @@
42
46
 
43
47
  ```
44
48
  插件结果是否需要持久化到数据库?
45
- ├── 否(一次性即时展示)→ Client 侧调用(默认)
46
- └── 是 → 推荐方案 A:Server 侧调用,在 Service 中调用插件并在同一方法中落库
47
- 备选方案 B:Client 侧调用插件 → 成功后通过已有 CRUD 接口保存结果
49
+ ├── 否(一次性即时展示)
50
+ │ ├── `outputMode=stream` 或内容生成较慢 → Client 侧 `callStream()` 渐进展示
51
+ │ └── 单次短输出 → Client 侧 `call()`(默认)
52
+ └── 是
53
+ ├── `outputMode=stream` 且前端可承接 → 推荐方案 A:Client 侧 `callStream()` 渐进展示,成功后通过已有 CRUD 接口保存结果
54
+ ├── 输出较小且可在交互边界内完成、且必须由后端保证一致性 → Server 侧调用并在同一方法中落库
55
+ └── Client 侧无法满足且输出规模大/多字段/多份/多语言/多步骤 → 方案 B:Server 侧创建任务记录,快速返回任务状态;后台调用插件并落库;前端短轮询状态/结果
48
56
  ```
49
57
 
50
58
  | 应避免的做法 | 推荐做法 |
@@ -79,12 +87,18 @@
79
87
  #### call / callStream 函数签名
80
88
 
81
89
  ```typescript
82
- .call(actionKey: string, input: object) // 非流式,返回 Promise<output>
83
- .callStream(actionKey: string, input: object) // 流式,返回 AsyncIterable<chunk>
90
+ // 前端 capabilityClient(@lark-apaas/client-toolkit,实际类型来自 @lark-apaas/client-capability)
91
+ .call<T = unknown>(actionKey: string, params?: Record<string, unknown>): Promise<T> // 非流式
92
+ .callStream<T = unknown>(actionKey: string, params?: Record<string, unknown>): AsyncIterable<T> // 流式
84
93
  ```
85
94
 
86
95
  - **第一个参数 `actionKey`**:必须是字符串,值来自 `get_plugin_ai_json` 返回的 `actions[].key`(如 `'sendFeishuMessage'`、`'textGenerate'`)
87
- - **第二个参数 `input`**:必须是对象,结构符合 `actions[].inputSchema`
96
+ - **第二个参数 `params`**:类型是 `Record<string, unknown>`,结构符合 `actions[].inputSchema`
97
+ - 上面是**前端**签名,带泛型。**服务端 `CapabilityService` 的 `call` / `callStream` / `callStreamWithEvents` 都没有泛型**,两者不通用,详见「Server 侧调用方式」
98
+
99
+ > **`Record<string, unknown>` 不接 `interface` 声明的对象**(interface 没有隐式索引签名),直接传会撞
100
+ > `Argument of type 'XxxInput' is not assignable to parameter of type 'Record<string, unknown>'. Index signature for type 'string' is missing in type 'XxxInput'.`
101
+ > 三种正确写法:① 直接传内联对象字面量;② 入参类型用 `type XxxInput = { ... }` 而非 `interface`(type 别名有隐式索引签名);③ 已有 interface 时在调用点显式 `input as unknown as Record<string, unknown>`。**不要为此改用 `as any`。**
88
102
 
89
103
  ```typescript
90
104
  // ❌ 错误:把参数 JSON.stringify 后当作 actionKey
@@ -271,10 +285,11 @@ function MultiPluginStreamExample({ recordId }: { recordId: string }) {
271
285
  2. 一个 `stream` action 的真实调用结果(chunk 按 `outputSchema` 字段读取)
272
286
  3. 调用失败时的最小日志字段齐全
273
287
  4. 若无法执行真实调用,必须明确写明阻塞原因,禁止直接标记“开发完成”
288
+ 5. 若用户触发 AI 生成的请求超时、连接断开或工具返回超时,必须判定为验收失败;只有触发请求快速返回、随后能通过页面或接口读到明确完成或失败终态和结果,才允许标记通过
274
289
 
275
290
  ### Server 侧调用方式(仅兜底场景)
276
291
 
277
- > 以下场景适合使用 Server 侧调用,特别是涉及数据持久化时不要回避后端。
292
+ > 以下场景适合使用 Server 侧调用;若前端 `callStream` + 既有 CRUD 保存即可满足展示和持久化,不要优先引入后端后台任务。
278
293
 
279
294
  #### 1. 何时适合用 Server 侧?
280
295
 
@@ -285,6 +300,7 @@ function MultiPluginStreamExample({ recordId }: { recordId: string }) {
285
300
  | 敏感凭证调用 | 凭证不能暴露给前端 | 调用需要 admin token 的 API |
286
301
  | 强事务编排 | 多步骤需要原子性 | 创建记录 → 发通知 → 更新状态必须全成功或全回滚 |
287
302
  | 插件结果需持久化 | 调用结果需保存到数据库 | AI 分类/摘要结果需落库、文档解析的结构化数据需入库、图片识别结果需关联业务记录、语音转文字结果需存档等 |
303
+ | 高耗时 AI 生成需后续查看 | 请求不能长期占用用户交互链路 | 批量、多版本、多语言、长文本、多字段结构化生成,或文件/多模态分析后再生成内容 |
288
304
 
289
305
  #### 2. NestJS 注入方式
290
306
 
@@ -296,23 +312,49 @@ import { CapabilityService } from '@lark-apaas/fullstack-nestjs-core';
296
312
  export class XxxService {
297
313
  private readonly logger = new Logger(XxxService.name);
298
314
 
315
+ // 注入 token 必须显式写成 @Inject(CapabilityService)
299
316
  constructor(
300
- @Inject() private readonly capabilityService: CapabilityService,
317
+ @Inject(CapabilityService) private readonly capabilityService: CapabilityService,
301
318
  ) {}
302
319
  }
303
320
  ```
304
321
 
322
+ **禁止空参 `@Inject()`**。本 stack 的 `nest-cli.json` 用 `builder: swc`,空参 `@Inject()` 会把注入 token 置为 undefined,Nest 回退读 `design:type` 拿到 `Function`,服务端启动即崩溃、所有 `/api` 请求返回 502:
323
+
324
+ ```
325
+ UnknownDependenciesException: Nest can't resolve dependencies of the XxxService (?).
326
+ Please make sure that the argument Function at index [0] is available in the XxxModule context.
327
+ ```
328
+
329
+ 见到这段报错先查构造函数有没有空参 `@Inject()`,不要去改 Module 的 `imports` / `providers`。
330
+
331
+ **业务 Module 不需要注册 CapabilityModule**。`PlatformModule` 是 `@Global()` 且已把 `CapabilityModule` 放进 `exports`,`CapabilityService` 全局可注入。不要在业务 Module 里写 `imports: [CapabilityModule.forRoot(...)]` 或 `imports: [PlatformModule.forRoot()]`。
332
+
333
+ 同一规则适用于从 `@lark-apaas/fullstack-nestjs-core` 注入的其他平台服务(`AuthNPaasService`、`FileService` 等)。
334
+
305
335
  #### 3. 调用示例
306
336
 
337
+ 服务端 `CapabilityExecutor` 的**三个方法全都没有泛型参数**(`@lark-apaas/nestjs-capability`):
338
+
339
+ ```typescript
340
+ call(actionName: string, input: unknown, context?: Partial<PluginActionContext>): Promise<unknown>;
341
+ callStream(actionName: string, input: unknown, context?): AsyncIterable<unknown>;
342
+ callStreamWithEvents(actionName: string, input: unknown, context?): AsyncIterable<StreamEvent<unknown>>;
343
+ ```
344
+
345
+ 写 `.call<T>(...)` / `.callStream<T>(...)` / `.callStreamWithEvents<T>(...)` 都会报「应有 0 个类型参数,但获得 1 个」。泛型只存在于前端 `capabilityClient`。注意服务端 `input` 是**必传**(没有 `?`),且类型是 `unknown` 而非前端的 `Record<string, unknown>`。服务端接 `unknown` 后在运行时收窄,禁止 `as any`:
346
+
307
347
  ```typescript
308
348
  const inputParams = {
309
349
  // 严格按 get_plugin_ai_json.actions[].inputSchema 构造
310
350
  };
311
351
 
312
352
  try {
313
- const output = await this.capabilityService
353
+ const output: unknown = await this.capabilityService
314
354
  .load('')
315
355
  .call('', inputParams);
356
+ // 运行时收窄后再取字段,例如:
357
+ // if (typeof output === 'object' && output !== null && 'content' in output) { ... }
316
358
  return output;
317
359
  } catch (error) {
318
360
  this.logger.error('pluginInstance call failed', {
@@ -328,6 +370,7 @@ try {
328
370
 
329
371
  - PluginInstance 调用在 Server 侧通常属于 **外部依赖 / side-effect**
330
372
  - 除非业务明确要求强一致性,**默认不应阻塞主业务流程**
373
+ - 已选择 Server 侧承接的高耗时 AI capability 必须有可观测状态:创建任务时记录处理进度、完成终态、失败终态、输入摘要、错误信息和结果引用;触发接口只返回任务标识与当前状态,前端通过短轮询读取进度和最终结果
331
374
 
332
375
  推荐写法:异步触发 + catch 兜底:
333
376