@lark-apaas/coding-steering 0.1.32-beta.0 → 0.1.32-dev.0f5e9dd

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 (82) hide show
  1. package/package.json +6 -6
  2. package/steering/design-html/skills/charts/SKILL.md +15 -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/pptx-style-extract/SKILL.md +71 -26
  7. package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
  8. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +26 -14
  9. package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
  10. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +2831 -293
  11. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +544 -22
  12. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +19 -2
  13. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +991 -165
  14. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +22 -3
  15. package/steering/design-html/skills/pptx-style-extract/scripts/query.py +4 -9
  16. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +20 -12
  17. package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +556 -0
  18. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +364 -0
  19. package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
  20. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +76 -0
  21. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +528 -0
  22. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1814 -0
  23. package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +600 -0
  24. package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
  25. package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +315 -0
  26. package/steering/design-html/skills/pptx-style-extract/scripts/verify_layout_assets.py +421 -0
  27. package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +12 -0
  28. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +27 -15
  29. package/steering/design-html/skills/slide-deck/SKILL.md +5 -3
  30. package/steering/design-html/skills/social-card/SKILL.md +263 -0
  31. package/steering/nestjs-react-fullstack/skills/app-init-feasibility-guide/SKILL.md +4 -0
  32. package/steering/nestjs-react-fullstack/skills/authn-guide/SKILL.md +6 -0
  33. package/steering/nestjs-react-fullstack/skills/authz-guide/SKILL.md +5 -5
  34. package/steering/nestjs-react-fullstack/skills/authz-guide/references/dynamic-permission-guide.md +1 -1
  35. package/steering/nestjs-react-fullstack/skills/client-builtins-file-storage-service/SKILL.md +38 -113
  36. package/steering/nestjs-react-fullstack/skills/client-builtins-user-service/SKILL.md +15 -2
  37. package/steering/nestjs-react-fullstack/skills/code-fix/SKILL.md +13 -7
  38. package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +205 -38
  39. package/steering/nestjs-react-fullstack/skills/connections-sdk/SKILL.md +202 -0
  40. package/steering/nestjs-react-fullstack/skills/contacts-service/SKILL.md +67 -4
  41. package/steering/nestjs-react-fullstack/skills/nestjs-cache/SKILL.md +254 -0
  42. package/steering/nestjs-react-fullstack/skills/openapi-guide/SKILL.md +75 -0
  43. package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +164 -543
  44. package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md +72 -15
  45. package/steering/nestjs-react-fullstack/skills/plugin-guide/references/table.md +30 -14
  46. package/steering/nestjs-react-fullstack/skills/raw-sql-boundary-audit/SKILL.md +63 -0
  47. package/steering/nestjs-react-fullstack/skills/server-builtins-file-storage-service/SKILL.md +1 -1
  48. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/SKILL.md +78 -0
  49. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/assets/eslint.mcp-ui.config.cjs +19 -0
  50. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/assets/ui-tsconfig.json +19 -0
  51. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/client-onboarding.md +90 -0
  52. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/debugging.md +81 -0
  53. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/mcp-apps.md +197 -0
  54. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/tool-authoring.md +248 -0
  55. package/steering/nestjs-react-fullstack/skills_common/trigger-guide/SKILL.md +284 -12
  56. package/steering/nestjs-react-fullstack/skills_common/user-identity/SKILL.md +3 -1
  57. package/steering/nestjs-react-fullstack/skills_local/client-builtins-user-service/SKILL.md +2 -0
  58. package/steering/nestjs-react-fullstack/skills_local/code-fix/SKILL.md +6 -0
  59. package/steering/nestjs-react-fullstack/skills_local/coding-guide/SKILL.md +49 -22
  60. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +11 -3
  61. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/references/plugin-coding-guide.md +57 -14
  62. package/steering/vite-react/skills/plugin-guide/SKILL.md +4 -1
  63. package/steering/vite-react/skills/react-three-fiber/SKILL.md +5 -0
  64. package/steering/vite-react/tech.md +3 -0
  65. package/steering/design-html/skills/preflight/SKILL.md +0 -51
  66. package/steering/design-html/skills/preflight/scripts/probe.sh +0 -108
  67. package/steering/nestjs-react-fullstack/skills/client-add-aily-web-chat/SKILL.md +0 -139
  68. package/steering/nestjs-react-fullstack/skills/feishu/SKILL.md +0 -269
  69. package/steering/nestjs-react-fullstack/skills/feishu/references/approval.md +0 -214
  70. package/steering/nestjs-react-fullstack/skills/feishu/references/attendance.md +0 -163
  71. package/steering/nestjs-react-fullstack/skills/feishu/references/bitable.md +0 -311
  72. package/steering/nestjs-react-fullstack/skills/feishu/references/calendar.md +0 -190
  73. package/steering/nestjs-react-fullstack/skills/feishu/references/contacts.md +0 -160
  74. package/steering/nestjs-react-fullstack/skills/feishu/references/doc.md +0 -257
  75. package/steering/nestjs-react-fullstack/skills/feishu/references/drive.md +0 -104
  76. package/steering/nestjs-react-fullstack/skills/feishu/references/events.md +0 -199
  77. package/steering/nestjs-react-fullstack/skills/feishu/references/id-convert.md +0 -128
  78. package/steering/nestjs-react-fullstack/skills/feishu/references/messaging.md +0 -207
  79. package/steering/nestjs-react-fullstack/skills/feishu/references/oauth.md +0 -165
  80. package/steering/nestjs-react-fullstack/skills/feishu/references/perm.md +0 -91
  81. package/steering/nestjs-react-fullstack/skills/feishu/references/wiki.md +0 -165
  82. package/steering/nestjs-react-fullstack/skills_common/trigger-guide/references/trigger-lifecycle.md +0 -301
@@ -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? |
@@ -0,0 +1,254 @@
1
+ ---
2
+ name: nestjs-cache
3
+ description: Use when planning, adding, debugging, or reviewing NestJS cache logic, especially for performance optimization, repeated data access, external API or database load reduction, multi-instance FaaS cache architecture, or shared versus in-memory cache choices. 触发词:缓存方案, 缓存设计, 性能优化, 重复数据访问, 多实例缓存, FaaS 缓存, 共享缓存, 内存缓存, cache architecture, cache, caching, cache-manager, CacheInterceptor, CACHE_MANAGER, cache.wrap, CacheKey, CacheTTL, TTL, 缓存穿透, 缓存击穿, cache miss, cache stampede, single-flight, 热点缓存
4
+ steering: true
5
+ steering-topic: nestjs_cache
6
+ match-template-name: nestjs-react-fullstack
7
+ ---
8
+
9
+ # NestJS Caching 使用指南
10
+
11
+ 在 NestJS 后端为读多写少路径加缓存。平台已注入 `CACHE_MANAGER`,业务只需要:**判断该不该缓存 → 选一种使用姿势 → 遵守 Key 命名和 TTL 规范**。
12
+
13
+ ## 前置约束:不要自己装配 `CacheModule`
14
+
15
+ 平台已经在根级注册好全局 `CacheModule` 并暴露 `CACHE_MANAGER` provider,业务模块**直接 `@Inject(CACHE_MANAGER)` 即可**。
16
+
17
+ **不要**在业务 `Module` 里再 `imports: [CacheModule.register()]` 或 `CacheModule.registerAsync(...)`:
18
+
19
+ ```typescript
20
+ // BAD — 会创建新的独立 store,不共享连接池 / token / fail-open 保护
21
+ @Module({
22
+ imports: [CacheModule.register()], // ← 删掉;直接 @Inject(CACHE_MANAGER) 即可(见下方姿势 A)
23
+ controllers: [BookController],
24
+ providers: [BookService],
25
+ })
26
+ export class BookModule {}
27
+ ```
28
+
29
+ 自己 `register()` 出的 store 是内存 store 且脱离平台管理链路,**不走 Redis、不受 fail-open 保护、不上报可观测**——症状是"本地看着 hit 都对,上线后集群不共享 / 重启就丢"。
30
+
31
+ ## 禁止使用内存缓存(CRITICAL)
32
+
33
+ 应用采用多 FaaS 实例部署,进程内缓存只对当前实例生效,无法被其他实例共享。因此,**禁止**使用 `new Map()`、模块级变量、全局单例或自行注册的内存 store 实现缓存;统一使用平台注入的 `CACHE_MANAGER`。
34
+
35
+ ## 依赖版本(CRITICAL)
36
+
37
+ **必须使用 `cache-manager` v6**,禁止安装或按照 v4 / v5 / v7 的 API 语义编写缓存代码。
38
+
39
+ ## 什么时候该加缓存(先决策再动手)
40
+
41
+ ```
42
+ 决策树:加缓存吗?
43
+ ├─ 是否满足以下任一"值得缓存"信号?
44
+ │ a) 读得频繁(同一份数据反复被查,非一次性接口)
45
+ │ b) 读得慢(源侧要跨服务/跨库/跑聚合,用户能感觉到等待)
46
+ │ c) 读得贵(触发外部收费 API / 高负载 RPC / 大结果集 SQL)
47
+ │ ├─ 都不满足 → 不加。加了维护成本 > 收益
48
+ │ └─ 满足任一 → 下一步
49
+ ├─ 是否是**授权敏感路径**?
50
+ │ a) 该 handler / 方法被 `@CanRole` / `@Can` 装饰
51
+ │ b) 返回值由 PostgreSQL RLS(Row Level Security)过滤(登录身份不同结果不同)
52
+ │ ├─ 是 → 不加。授权决策 / RLS 过滤后的结果与身份强绑定,
53
+ │ │ 缓存 = 越权(陈旧 policy)或串号(不同用户读同一 key)
54
+ │ └─ 否 → 下一步
55
+ ├─ 业务可容忍 TTL 期内数据轻微陈旧(读到几秒前的旧值不影响正确性)?
56
+ │ ├─ 否(要强一致,如库存/余额/权限当次判定)→ 不加
57
+ │ └─ 是 → 下一步
58
+ ├─ 数据是"读多写少"(读远多于写,写触发时能明确 invalidate 哪些 key)?
59
+ │ ├─ 否(写路径同样频繁、或 invalidate 范围爆炸)→ 不加,命中率上不去
60
+ │ └─ 是 → 加缓存,选一种姿势(见下方)
61
+ └─ 是否可能出现热点 key(一份数据被大量请求同时命中,如首页配置、公共字典)?
62
+ └─ 是 → 必须用 `cache.wrap` 拿 single-flight 语义,防击穿
63
+ ```
64
+
65
+ **判断技巧**:拿不准就问自己——"如果同一个用户 1 秒内连点 5 次,我希望后 4 次直接返回上次结果吗?" 是 → 加;否(每次必须重算)→ 不加。
66
+
67
+ **典型该加**:外部 open API 元数据 / 长尾字典表 / 用户 profile / 频繁读取的配置 / 慢报表聚合结果。
68
+
69
+ **典型不该加**:金融流水、库存扣减、鉴权决策、事务型写路径、每次都不同参数的即席查询。
70
+
71
+ ## Quick Reference
72
+
73
+ | 场景 | 用法 |
74
+ |------|------|
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 起) |
80
+
81
+ ## 三种使用姿势
82
+
83
+ ### A. 手动 `CACHE_MANAGER`(最常用,最灵活)
84
+
85
+ ```typescript
86
+ import { Inject, Injectable } from '@nestjs/common';
87
+ import { CACHE_MANAGER } from '@nestjs/cache-manager';
88
+ import type { Cache } from 'cache-manager';
89
+
90
+ @Injectable()
91
+ export class ProfileService {
92
+ constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
93
+
94
+ async getProfile(userId: string) {
95
+ const key = `profile:${userId}`;
96
+ const hit = await this.cache.get<Profile>(key);
97
+ if (hit !== null) return hit; // falsy 值(0/''/false)也是有效命中,别用 if (hit)
98
+
99
+ const fresh = await this.fetchFromRemote(userId);
100
+ await this.cache.set(key, fresh, 60_000); // TTL ms
101
+ return fresh;
102
+ }
103
+
104
+ async updateProfile(userId: string, patch: Partial<Profile>) {
105
+ const next = await this.saveToRemote(userId, patch);
106
+ await this.cache.del(`profile:${userId}`); // 写路径主动失效
107
+ return next;
108
+ }
109
+ }
110
+ ```
111
+
112
+ ### B. `cache.wrap`(防击穿 / single-flight)
113
+
114
+ 热点 key 同时被 N 个请求命中时,只有一个 loader 会真正执行,其他并发请求等它结果。冷启动场景必须用这个而不是 A 方案。
115
+
116
+ > **限定**:`wrap` 的 single-flight 是**单实例进程内**去重(v6 内嵌 `promise-coalesce`,本质是内存 Map)。多 pod 部署时每个实例的首个请求各自触发一次 loader → 最多 N 个 pod 并发回源 N 次,而非全局 1 次。要做真正的全局防击穿需额外配分布式锁。
117
+
118
+ ```typescript
119
+ async getConfig(appApiName: string) {
120
+ return this.cache.wrap(
121
+ `app-config:${appApiName}`,
122
+ () => this.loadFromDB(appApiName),
123
+ 5 * 60_000, // TTL 5min
124
+ );
125
+ }
126
+ ```
127
+
128
+ ### C. `CacheInterceptor` + 装饰器(Controller 方法级)
129
+
130
+ 只适合**幂等 GET** 且返回值 JSON-safe。写请求、带循环引用 / Buffer / Date 的返回值、需要按登录用户维度分 key 的场景,不要用它——用 A 方案。
131
+
132
+ **`@CacheKey` 是静态常量**:所有请求都命中同一条缓存,会忽略路由参数 / query。带路由参数(如 `:apiName`)时**不要写 `@CacheKey`**,让 interceptor 按完整 URL 自动分 key;否则 app A 的结果会被返回给 app B(串号)。
133
+
134
+ ```typescript
135
+ import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager';
136
+
137
+ @Controller('apps')
138
+ @UseInterceptors(CacheInterceptor)
139
+ export class AppsController {
140
+ // 不写 @CacheKey:按 GET /apps/:apiName/meta 完整 URL 自动分 key
141
+ @Get(':apiName/meta')
142
+ @CacheTTL(60_000) // ms
143
+ getMeta(@Param('apiName') apiName: string) { /* ... */ }
144
+ }
145
+ ```
146
+
147
+ ## Key 命名约定
148
+
149
+ | 规则 | 例子 | 理由 |
150
+ |------|------|------|
151
+ | 业务前缀必需 | `profile:${userId}` | 避免多应用共用 Redis 时 key 冲突 |
152
+ | 冒号分层 | `app:${apiName}:config:${env}` | 便于排障时 SCAN |
153
+ | 参数拼接稳定 | 多参数按字典序拼接 | 避免 `?a=1&b=2` vs `?b=2&a=1` 命中不同 key |
154
+ | 禁用大对象作 key | 不要 `JSON.stringify(user)` 当 key | key 过长 / 特殊字符转义踩坑 |
155
+ | Key 长度硬上限 | 必须 ≤ 512 字符 | 平台校验,超过直接跳过缓存 |
156
+
157
+ ## 平台校验限制(硬门槛)
158
+
159
+ 平台会在缓存读写时做以下校验。**超过限制不会抛异常,而是本次请求跳过缓存 → 原读取逻辑正常执行 → Trace 记录 error_code**。写代码时按这套边界规划 key / TTL / value:
160
+
161
+ | 限制项 | 边界 | 超限行为 | error_code |
162
+ |--------|------|----------|------------|
163
+ | 业务 key 为空 | 不能是空串 | 本次不用缓存,直接跑原逻辑 | `CACHE_KEY_INVALID` |
164
+ | 业务 key 长度 | ≤ 512 字符 | 本次不用缓存,直接跑原逻辑 | `CACHE_KEY_TOO_LONG` |
165
+ | TTL 范围 | **30 秒 - 24 小时**(30_000 - 86_400_000 ms) | 本次不用缓存,直接跑原逻辑 | `CACHE_TTL_OUT_OF_RANGE` |
166
+ | 单应用单环境 key 数量 | ≤ 10,000 个有效 key | 已存在的 key 可正常刷新;新 key 不再写入 | `CACHE_KEY_QUOTA_EXCEEDED` |
167
+ | 单 key value 大小 | 序列化后 ≤ 1 MB | 不写入缓存,原读取结果照常返回 | `CACHE_VALUE_TOO_LARGE` |
168
+
169
+ **写代码前的最小心智模型**:把这 5 条边界当作**写 key / 传 TTL / set value 前的心跳检查**——具体反例见文末 [Common Mistakes](#common-mistakes) 表格。
170
+
171
+ ## Fail-Open 语义(重要)
172
+
173
+ **缓存不可用不应该让业务请求失败**,平台已保证:
174
+
175
+ ```
176
+ cache.get(key) 场景
177
+ ├─ 缓存正常 + hit → 返回值
178
+ ├─ 缓存正常 + miss → 返回 null
179
+ ├─ 缓存后端不可达 → 返回 null(不抛异常)
180
+ └─ 短暂网络抖动 → 返回 null,业务侧当 miss 处理即可
181
+ ```
182
+
183
+ 业务代码永远只需要 `if (hit === null) { fetch fresh }`(**别用 `if (!hit)`**——缓存值为 `0` / `''` / `false` 时会被误判成 miss)。**不要包 try-catch**——反例见 [Common Mistakes](#common-mistakes)。
184
+
185
+ ## Common Mistakes
186
+
187
+ 按主题分组,涵盖 API 姿势、Key、TTL、Value、防御式代码 5 类。
188
+
189
+ ### Key 相关
190
+
191
+ | 错误 | 正确做法 |
192
+ |------|----------|
193
+ | Key 不加业务前缀 | 必须 `${业务域}:${resource}:${id}` 分层,避免多应用 / 多资源撞车 |
194
+ | 空字符串 / `null` / `undefined` 当 key | 平台校验空 key,本次跳过缓存(`CACHE_KEY_INVALID`);调用前先判空 |
195
+ | 用户输入直接拼 key(`search:${rawQuery}`) | rawQuery 可能超 512 字符(`CACHE_KEY_TOO_LONG`);先 hash 或截断 |
196
+ | 把大对象 / JSON / SQL 塞进 key | 长度爆炸 + 特殊字符转义踩坑;用结构化 id 或摘要 |
197
+ | Key 里放 traceId / timestamp / requestId | key 空间爆炸,撞 10,000 上限后新 key 不再写入(`CACHE_KEY_QUOTA_EXCEEDED`) |
198
+ | 多参数拼接顺序不稳定 | 多参数按字典序拼接,避免 `?a=1&b=2` vs `?b=2&a=1` 命中不同 key |
199
+
200
+ ### TTL 相关(v6 单位是**毫秒**)
201
+
202
+ | 错误 | 正确做法 |
203
+ |------|----------|
204
+ | TTL 传秒(v5 旧思维,`60` = 想要 1 分钟) | v6 起是毫秒,`60_000` 才是 1 分钟;且 `60` = 60ms 会被平台拒(`CACHE_TTL_OUT_OF_RANGE`) |
205
+ | TTL 传 `0` 想表达永久 | v6 `0` = 不过期,但平台也拒;接近永久用 `24 * 3600_000`(24h 上限) |
206
+ | TTL 短于 30s | 平台拒(`CACHE_TTL_OUT_OF_RANGE`),本次不缓存;实际范围 `30_000 - 86_400_000` |
207
+
208
+ ### Value 相关
209
+
210
+ | 错误 | 正确做法 |
211
+ |------|----------|
212
+ | 缓存 Buffer / Date / 循环引用对象 | 序列化只保证 JSON-safe;复杂对象手动 toDTO |
213
+ | 缓存 `null` 当"存在" | miss 就是 `null`,两者不可区分;表达"值就是空"用哨兵字符串(如 `'__EMPTY__'`)读到再转回 `null` |
214
+ | 单 value 接近或超过 1 MB | 平台拒写(`CACHE_VALUE_TOO_LARGE`);先投影裁剪或按分页 key 拆开;大 blob 用 CDN / 对象存储 |
215
+
216
+ ### API 姿势相关
217
+
218
+ | 错误 | 正确做法 |
219
+ |------|----------|
220
+ | 业务模块里再 `CacheModule.register()` / `registerAsync(...)` | 平台已全局注册,直接 `@Inject(CACHE_MANAGER)`;重复注册会创建独立内存 store,不走 Redis、不 fail-open |
221
+ | 用 `CacheInterceptor` 缓存写请求 | Interceptor 只适合幂等 GET;写请求用 A 方案,配 `cache.del(key)` |
222
+ | `@CacheKey` 静态字符串包含用户维度,或带路由参数却写死 key | Interceptor 不感知登录态、静态 key 忽略路由参数/query → 串号;带用户或路由维度改用 A 方案手动拼 key |
223
+ | 高 QPS 热点用 A 方案而非 `wrap` | 击穿风险,冷启动 loader 会被并发触发 N 次;换 B 方案拿 single-flight(注意仍是单实例内去重) |
224
+ | 用 `cache.wrap` 包写路径 | wrap 是"读 + 回填"模型;写路径 `.set()` 后再 `.del()` |
225
+ | `set` 之后立刻 `get` 期望读到 | 平台侧写入 fire-and-forget,**即便 `await set` 也不保证同请求/下一 tick 可见**;写完后直接返回本地变量 |
226
+
227
+ ### 授权 / 身份相关(可能导致越权)
228
+
229
+ | 错误 | 正确做法 |
230
+ |------|----------|
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 上限 |
234
+
235
+ ### 防御式代码
236
+
237
+ | 错误 | 正确做法 |
238
+ |------|----------|
239
+ | Try-catch 包 `cache.get` / `cache.set` | 平台已 fail-open,`get` 不抛、`set` 失败原逻辑照走;try-catch 会掩盖真实业务错误 |
240
+ | 手动检测缓存后端是否可达再决定 get | 直接调;miss / 后端挂了都返回 `null`,业务侧按 miss 处理 |
241
+
242
+ ## 排障速查
243
+
244
+ | 症状 | 优先怀疑 | 验证方法 |
245
+ |------|----------|----------|
246
+ | 命中率长期 0 | Key 里含请求 ID / 时间戳,每次都新 key | 打印 key 名,对齐同一业务参数应得同 key |
247
+ | 本地测缓存正常,线上不共享 / 重启就丢 | 业务模块里自己 `CacheModule.register()` 了,跑的是内存 store | 检查 `imports:` 是否含 `CacheModule.*`;删掉,直接 `@Inject(CACHE_MANAGER)` |
248
+ | Interceptor 不生效 | 忘 `@UseInterceptors(CacheInterceptor)` 或 Controller 没注册 | 在 loader 加日志,观察是否每次请求都被调 |
249
+ | TTL "不生效" | 传成秒、传 `0`、或超出 `30s - 24h` 平台范围 | Trace 里出现 `CACHE_TTL_OUT_OF_RANGE` 就是超范围 |
250
+ | 数据陈旧 | 写路径没 `cache.del` 主动失效 | 更新时 log 出 del 的 key,比对读路径的 key |
251
+ | Trace 出现 `CACHE_KEY_TOO_LONG` | key 里塞了整段 SQL / 用户输入 / JSON | 用 hash 或结构化 id 替换 |
252
+ | Trace 出现 `CACHE_VALUE_TOO_LARGE` | 整表 / 大 list 直接缓存 | 分页缓存或投影裁剪,把单个 value 压到 1 MB 以内 |
253
+ | Trace 出现 `CACHE_KEY_QUOTA_EXCEEDED` | key 空间失控(含 traceId / timestamp / 用户输入) | 收敛 key 模板,用有限的枚举维度 |
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` 区分。