@lark-apaas/coding-steering 0.1.13 → 0.1.14

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.
@@ -1,8 +1,7 @@
1
1
  ---
2
2
  name: coding-guide
3
- description: '项目全局编码规范,必须在任何代码编写、阅读、修改、排查前加载。覆盖:项目结构、目录组织、文件命名、NestJS 后端(MVCS/Drizzle ORM/API 规范/异常处理/用户上下文)、React 19 前端(shadcn/ui/Tailwind/路由/样式/组件规范)、前后端联调(shared 类型/axiosForBackend)、数据库操作、质量保障流程、日志规范。Use when: 编写任意前端或后端代码、新建页面或模块、修改接口或数据库操作、排查编译错误或运行时问题、代码审查、理解项目架构。'
3
+ description: "项目全局编码规范,必须在任何代码编写、阅读、修改、排查前加载。覆盖:项目结构、目录组织、文件命名、NestJS 后端(MVCS/Drizzle ORM/API 规范/异常处理/用户上下文)、React 19 前端(shadcn/ui/Tailwind/路由/样式/组件规范)、前后端联调(shared 类型/axiosForBackend)、数据库操作、质量保障流程、日志规范。Use when: 编写任意前端或后端代码、新建页面或模块、修改接口或数据库操作、排查编译错误或运行时问题、代码审查、理解项目架构。"
4
4
  ---
5
-
6
5
  # 项目结构
7
6
 
8
7
  ## 根目录组织
@@ -97,7 +96,7 @@ shared/ # 前后端共享的目录
97
96
  ```typescript
98
97
  // ❌ 错误:变量无类型注解 → 回调参数隐式 any → TS7006
99
98
  const items = await this.service.getItems();
100
- items.map((item) => item.name); // TS7006: item 隐式具有 any 类型
99
+ items.map(item => item.name); // TS7006: item 隐式具有 any 类型
101
100
 
102
101
  // ✅ 正确:变量显式类型 + 回调参数显式类型
103
102
  const items: Item[] = await this.service.getItems();
@@ -178,6 +177,7 @@ shared/ # 前后端共享的目录
178
177
  项目有三个**聚合文件**:`server/app.module.ts`、`client/src/app.tsx`、`client/src/api/index.ts`。它们是所有业务模块的汇聚入口,**必须由主 agent 在派发任何业务模块任务之前一次性串行预填**,业务模块任务(可能并行执行)**不得编辑**这三个文件。多方并发编辑同一聚合文件会触发 `old_string` mismatch 和 LLM 兜底重写,显著拖慢生成。
179
178
 
180
179
  **主 agent 预填职责**(规划阶段产出**业务模块清单**后立即执行,任务派发前完成):
180
+
181
181
  - 清单命名约定:目录名 / 路由 / namespace / `api_prefix` 末段统一 `kebab-case`;`module_class` / 页面组件名统一 `PascalCase`;目录名与模块 `name` 一致
182
182
  - **严格串行**编辑以下三个文件(一次一个 tool 调用):
183
183
  - `server/app.module.ts`:在文件顶部追加 `import { UsersModule } from './modules/users/users.module';`,在 `@Module` 的 `imports` 数组里加入 `UsersModule`;`ViewModule` 必须保持 `imports` 数组最后一项(fallback 路由)
@@ -186,6 +186,7 @@ shared/ # 前后端共享的目录
186
186
  - 预填完成后才派发业务模块任务;聚合文件引用的符号(`UsersModule` / `UsersPage` / `./users`)在任务产出前暂时悬空,任务完成后自然对齐
187
187
 
188
188
  **业务模块任务的工作边界**:
189
+
189
190
  - 产出只能落在以下目录之一:`server/modules/<name>/`(Module / Controller / Service / DTO)、`client/src/pages/<name>/`、`client/src/api/<name>/`
190
191
  - **禁止编辑** `server/app.module.ts` / `client/src/app.tsx` / `client/src/api/index.ts`(已由主 agent 预填)
191
192
  - 跨模块依赖(不涉及聚合文件):在源 Module 的 `exports` 中导出即可
@@ -193,8 +194,8 @@ shared/ # 前后端共享的目录
193
194
 
194
195
  ```yaml
195
196
  cross_module_needs:
196
- - type: extra_module # 或 extra_route / extra_provider
197
- reason: '需要 SharedAuthModule 让 UsersController 使用 @Auth'
197
+ - type: extra_module # 或 extra_route / extra_provider
198
+ reason: "需要 SharedAuthModule 让 UsersController 使用 @Auth"
198
199
  module_class: SharedAuthModule
199
200
  module_file: server/modules/shared-auth/shared-auth.module.ts
200
201
  ```
@@ -217,7 +218,7 @@ shared/ # 前后端共享的目录
217
218
  **必须使用注入的 Drizzle 实例**,禁止自建连接:
218
219
 
219
220
  ```typescript
220
- import { DRIZZLE_DATABASE, type PostgresJsDatabase } from '@lark-apaas/fullstack-nestjs-core';
221
+ import { DRIZZLE_DATABASE, type PostgresJsDatabase } from "@lark-apaas/fullstack-nestjs-core";
221
222
 
222
223
  @Injectable()
223
224
  export class TestService {
@@ -233,30 +234,21 @@ export class TestService {
233
234
  // ✅ 条件查询
234
235
  const conditions = [];
235
236
  if (status) conditions.push(eq(users.status, status));
236
- const query =
237
- conditions.length > 0
238
- ? db
239
- .select()
240
- .from(users)
241
- .where(and(...conditions))
242
- : db.select().from(users);
237
+ const query = conditions.length > 0
238
+ ? db.select().from(users).where(and(...conditions))
239
+ : db.select().from(users);
243
240
  ```
244
241
 
245
242
  - Count 查询:
246
243
 
247
244
  ```typescript
248
245
  // 简单 count
249
- const result = await this.db
250
- .select({ count: count() })
251
- .from(users)
252
- .where(eq(users.status, 'active'));
246
+ const result = await this.db.select({ count: count() }).from(users).where(eq(users.status, "active"));
253
247
  // 子查询 count(关联计数)
254
- const users = await this.db
255
- .select({
256
- ...users,
257
- postsCount: this.db.$count(posts, eq(posts.authorId, users.id)),
258
- })
259
- .from(users);
248
+ const users = await this.db.select({
249
+ ...users,
250
+ postsCount: this.db.$count(posts, eq(posts.authorId, users.id)),
251
+ }).from(users);
260
252
  ```
261
253
 
262
254
  ### userProfile 自定义类型
@@ -265,11 +257,11 @@ const users = await this.db
265
257
 
266
258
  ```typescript
267
259
  // schema 示例
268
- export const mockTable = pgTable('mock_table', {
269
- adminUser: userProfile('admin_user'), // custom_type, TS 中为 string
260
+ export const mockTable = pgTable("mock_table", {
261
+ adminUser: userProfile("admin_user"), // custom_type, TS 中为 string
270
262
  });
271
263
  // 使用示例
272
- const userId: string = 'user123'; // ✅ 显式注解
264
+ const userId: string = "user123"; // ✅ 显式注解
273
265
  await db.insert(users).values({ adminUser: userId });
274
266
  await db.select().from(users).where(eq(users.adminUser, userId));
275
267
  ```
@@ -281,7 +273,7 @@ await db.select().from(users).where(eq(users.adminUser, userId));
281
273
  - **UUID 列 `inArray` 必须显式 `::uuid[]`**:标准 `inArray(col, ids)` 会运行时报 `42809: op ANY/ALL (array) requires array on right side`。
282
274
 
283
275
  ```typescript
284
- where: sql`${tasks.id} = ANY(${ids}::uuid[])`;
276
+ where: sql`${tasks.id} = ANY(${ids}::uuid[])`
285
277
  ```
286
278
 
287
279
  - **`customTimestamptz` 跨网络后是 string**:service 内查询返回 `Date`,但 API 响应经 JSON 序列化后前端拿到 ISO string。`shared/api.interface.ts` 中 timestamptz 字段声明为 `string`,前端需要 Date 时手动 `new Date(value)`。
@@ -320,9 +312,9 @@ await db.select().from(users).where(eq(users.adminUser, userId));
320
312
 
321
313
  ## 异常处理
322
314
 
323
- | 分层 | 目标 |
324
- | ---------- | ------------------------------- |
325
- | service | 抛出业务异常 |
315
+ | 分层 | 目标 |
316
+ |------|------|
317
+ | service | 抛出业务异常 |
326
318
  | controller | 不处理异常,交全局 Error Filter |
327
319
 
328
320
  ## 当前用户信息
@@ -344,6 +336,15 @@ async createArticle(@Req() req: Request, @Body() dto: CreateArticleDto) {
344
336
  }
345
337
  ```
346
338
 
339
+ ## 插件能力(Plugin)
340
+
341
+ 本地开发态**暂不支持** PluginInstance / capability 类插件能力的创建与调用 — 这套链路依赖云端 agent 工具(`plugin_instance` / `get_plugin_ai_json`)与平台下发的 `server/capabilities/` 配置,本地没有等价物。
342
+
343
+ 如果业务用到飞书多维表格、AI 生文/翻译/分类、消息推送等"插件能力"覆盖的场景:
344
+
345
+ - **数据存储/查询类**(多维表格 CRUD、列表分页、聚合统计):优先用本应用自带的 Postgres + Drizzle,数据可在云端运行时再同步飞书 Base
346
+ - **AI / 飞书消息类**:在云端发布后由平台插件链路承接;本地开发时可先用接口 mock 桩占位
347
+
347
348
  ## 自动化任务
348
349
 
349
350
  需要设计自动化任务配置与开发时,请调用文档工具召回自动化任务配置与代码编写文档
@@ -370,11 +371,11 @@ async createArticle(@Req() req: Request, @Body() dto: CreateArticleDto) {
370
371
 
371
372
  本地 dev 跑起来有**两个端口 + 一个前缀**,都由 `.env.local` 里的 env 控制:
372
373
 
373
- | Env | 进程 / 用途 | 默认 | 沙箱 env-pull 下发? | 备注 |
374
- | ------------------ | ------------------------------------------- | ------ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
375
- | `CLIENT_DEV_PORT` | vite / rspack dev server | `8080` | ❌ 不下发 | **唯一入口**:serve 前端 + 反代 `/api/*` 给 NestJS + 注入 `x-larkgw-suda-webuser` 模拟登录态。端口被占就 Agent 自己改 `.env.local` |
376
- | `SERVER_PORT` | NestJS | `3000` | ❌ 不下发 | 仅 vite/rspack 反代用,不直接给 agent / curl 用。端口被占就 Agent 自己改 `.env.local` |
377
- | `CLIENT_BASE_PATH` | 前端路由 base + 后端 routes.json serve 路径 | `/` | ✅ `/app/<app_id>` | 浏览器 URL 前缀;agent / curl 调 `/api/*` 也必须带这个前缀 |
374
+ | Env | 进程 / 用途 | 默认 | 沙箱 env-pull 下发? | 备注 |
375
+ |---|---|---|---|---|
376
+ | `CLIENT_DEV_PORT` | vite / rspack dev server | `8080` | ❌ 不下发 | **唯一入口**:serve 前端 + 反代 `/api/*` 给 NestJS + 注入 `x-larkgw-suda-webuser` 模拟登录态。端口被占就 Agent 自己改 `.env.local` |
377
+ | `SERVER_PORT` | NestJS | `3000` | ❌ 不下发 | 仅 vite/rspack 反代用,不直接给 agent / curl 用。端口被占就 Agent 自己改 `.env.local` |
378
+ | `CLIENT_BASE_PATH` | 前端路由 base + 后端 routes.json serve 路径 | `/` | ✅ `/app/<app_id>` | 浏览器 URL 前缀;agent / curl 调 `/api/*` 也必须带这个前缀 |
378
379
 
379
380
  完整入口形态 = `http://localhost:<CLIENT_DEV_PORT><CLIENT_BASE_PATH>/...`。启动日志里
380
381
  vite/rspack 打印的 `Local: http://localhost:<port>/...` 才是入口,具体值以 `.env.local`
@@ -412,13 +413,13 @@ NestJS 自己不读 env。直连 NestJS 端口 → header 缺失 → `req.userCo
412
413
  和 header,值字面相等才放行。任意非空相等值都行(注意打**client dev port**,不是
413
414
  NestJS 端口):
414
415
 
415
- ```bash
416
- # <PORT> = .env.local 里的 CLIENT_DEV_PORT(常见 8080 / 8001)
417
- # <BASE_PATH> = .env.local 里的 CLIENT_BASE_PATH(沙箱下发为 /app/<app_id>,本地裸跑可能为空)
418
- curl -H 'Cookie: suda-csrf-token=x' \
419
- -H 'X-Suda-Csrf-Token: x' \
420
- http://localhost:<PORT><BASE_PATH>/api/notes
421
- ```
416
+ ```bash
417
+ # <PORT> = .env.local 里的 CLIENT_DEV_PORT(常见 8080 / 8001)
418
+ # <BASE_PATH> = .env.local 里的 CLIENT_BASE_PATH(沙箱下发为 /app/<app_id>,本地裸跑可能为空)
419
+ curl -H 'Cookie: suda-csrf-token=x' \
420
+ -H 'X-Suda-Csrf-Token: x' \
421
+ http://localhost:<PORT><BASE_PATH>/api/notes
422
+ ```
422
423
 
423
424
  **不要**因为撞 403 就去改后端 csrf 中间件或者怀疑业务逻辑 —— csrf 保护是有意的,
424
425
  业务本身没问题。
@@ -479,20 +480,20 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
479
480
 
480
481
  ## 官方内置组件
481
482
 
482
- | 组件 | 来源 | 用途 |
483
- | --------------------------------------------------- | --------------------------------------- | --------------------------------- |
484
- | Table | `@lark-apaas/client-toolkit/antd-table` | 数据表格,**先调 `/table-skill`** |
485
- | UserSelect/UserDisplay/UserProfile/DepartmentSelect | `business-ui/*` | 用户/部门选择展示 |
486
- | TiptapEditorComplete | `business-ui/tiptap-editor` | 富文本编辑器 |
487
- | Streamdown | `components/ui/streamdown` | Markdown/流式渲染 |
483
+ | 组件 | 来源 | 用途 |
484
+ |------|------|------|
485
+ | Table | `@lark-apaas/client-toolkit/antd-table` | 数据表格,**先调 `/table-skill`** |
486
+ | UserSelect/UserDisplay/UserProfile/DepartmentSelect | `business-ui/*` | 用户/部门选择展示 |
487
+ | TiptapEditorComplete | `business-ui/tiptap-editor` | 富文本编辑器 |
488
+ | Streamdown | `components/ui/streamdown` | Markdown/流式渲染 |
488
489
 
489
490
  ### 组件 Skill 召回规则(强制执行)
490
491
 
491
- | 场景 | Skill | 说明 |
492
- | -------------------- | --------------- | ----------------------------------- |
493
- | 表单、Form、Zod | `/forms-skill` | Shadcn Form + React Hook Form + Zod |
494
- | 图表、Chart、ECharts | `/charts-skill` | shadcn/ui + ReactECharts |
495
- | 表格、Table | `/table-skill` | antd-table |
492
+ | 场景 | Skill | 说明 |
493
+ |------|-------|------|
494
+ | 表单、Form、Zod | `/forms-skill` | Shadcn Form + React Hook Form + Zod |
495
+ | 图表、Chart、ECharts | `/charts-skill` | shadcn/ui + ReactECharts |
496
+ | 表格、Table | `/table-skill` | antd-table |
496
497
 
497
498
  禁止未调用 Skill 直接编写表单/图表/表格代码。
498
499
 
@@ -510,15 +511,15 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
510
511
 
511
512
  **零假设原则**:绝不基于假设使用任何子模块。使用任何 `@lark-apaas/client-toolkit` 子模块前**必须**:① 先查询本地已加载的 Skills 或对应包文档 → ② 严格按文档编码。**禁止直接调用 `@lark-apaas/client-toolkit` 任何函数/方法**(不基于查询到的文档)。该库**仅可在前端代码中使用,禁止在后端代码中引用**。
512
513
 
513
- | 功能 | 导入路径 |
514
- | ------------------ | ------------------------------------------------------------------------------------------------ |
515
- | 插件调用(Client) | `import { capabilityClient } from "@lark-apaas/client-toolkit"` |
516
- | 日志 | `import { logger } from "@lark-apaas/client-toolkit/logger"` |
517
- | 文件上传下载 | `import { getDataloom } from "@lark-apaas/client-toolkit/dataloom"` |
518
- | 应用信息 | `import { useAppInfo } from "@lark-apaas/client-toolkit/hooks/useAppInfo"` |
519
- | 当前用户 | `import { useCurrentUserProfile } from "@lark-apaas/client-toolkit/hooks/useCurrentUserProfile"` |
520
- | URL 解析 | `import { resolveAppUrl } from "@lark-apaas/client-toolkit/utils/resolveAppUrl"` |
521
- | 批量用户信息 | `import { UserService } from "@lark-apaas/client-toolkit/tools/services"` |
514
+ | 功能 | 导入路径 |
515
+ |------|----------|
516
+ | 插件调用(Client) | `import { capabilityClient } from "@lark-apaas/client-toolkit"` |
517
+ | 日志 | `import { logger } from "@lark-apaas/client-toolkit/logger"` |
518
+ | 文件上传下载 | `import { getDataloom } from "@lark-apaas/client-toolkit/dataloom"` |
519
+ | 应用信息 | `import { useAppInfo } from "@lark-apaas/client-toolkit/hooks/useAppInfo"` |
520
+ | 当前用户 | `import { useCurrentUserProfile } from "@lark-apaas/client-toolkit/hooks/useCurrentUserProfile"` |
521
+ | URL 解析 | `import { resolveAppUrl } from "@lark-apaas/client-toolkit/utils/resolveAppUrl"` |
522
+ | 批量用户信息 | `import { UserService } from "@lark-apaas/client-toolkit/tools/services"` |
522
523
 
523
524
  **useCurrentUserProfile** 返回 `Partial<IUserProfile>`,初始为空对象:访问字段用 `?.`,判断加载完成用 `if (!userInfo?.user_id)`。
524
525
 
@@ -568,9 +569,9 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
568
569
 
569
570
  ```typescript
570
571
  import { resolveAppUrl } from '@lark-apaas/client-toolkit/utils/resolveAppUrl';
571
- const shareUrl = resolveAppUrl(`/detail/${id}`); // ✅ 路由路径→完整 URL
572
+ const shareUrl = resolveAppUrl(`/detail/${id}`); // ✅ 路由路径→完整 URL
572
573
  const fixedUrl = resolveAppUrl(`${origin}/detail/${id}`); // ✅ 自动修正
573
- resolveAppUrl('https://other-site.com/page'); // ✅ 外部链接原样返回
574
+ resolveAppUrl('https://other-site.com/page'); // ✅ 外部链接原样返回
574
575
  // ❌ 严禁自行拼接:`${window.location.origin}/detail/${id}`
575
576
  ```
576
577
 
@@ -604,11 +605,11 @@ return <h1>{data?.title || '未知标题'}</h1>;
604
605
 
605
606
  为特定功能的顶层容器添加 `data-ai-section-type` 属性:
606
607
 
607
- | 值 | 场景 |
608
- | ----------- | ------------------------- |
609
- | `card-stat` | 横向指标卡容器 |
610
- | `card-list` | Card 组件的 flex 列表 |
611
- | `button` | 任意 Button |
608
+ | 值 | 场景 |
609
+ |----|------|
610
+ | `card-stat` | 横向指标卡容器 |
611
+ | `card-list` | Card 组件的 flex 列表 |
612
+ | `button` | 任意 Button |
612
613
  | `card-menu` | Card 组件的 grid 网格菜单 |
613
614
 
614
615
  ## 图片规范
@@ -627,23 +628,23 @@ return <h1>{data?.title || '未知标题'}</h1>;
627
628
 
628
629
  ## 核心功能依赖
629
630
 
630
- | 类别 | 库 |
631
- | ---------- | ------------------------------------------------------------------------ |
632
- | 动画 | Framer Motion, GSAP |
633
- | 日期 | dayjs |
634
- | 日期选择器 | shadcn Calendar + Popover(禁止 input[type="date"]) |
635
- | 验证 | zod |
636
- | 工具函数 | lodash |
637
- | 样式 | clsx |
638
- | Excel | xlsx(**仅前端实现,禁止服务端实现**。解析后将结构化数据传到服务端保存) |
639
- | PDF 导出 | jspdf + html2canvas(**仅前端实现,禁止服务端实现**) |
640
- | 文件上传 | react-dropzone |
641
- | 二维码 | qrcode.react |
642
- | 用户反馈 | sonner |
643
- | 拖拽 | @dnd-kit/core |
644
- | 数字动画 | react-countup |
645
- | Base64 | js-base64 — `import { encode, decode } from 'js-base64'` |
646
- | 3D 场景 | cobe |
631
+ | 类别 | 库 |
632
+ |------|-----|
633
+ | 动画 | Framer Motion, GSAP |
634
+ | 日期 | dayjs |
635
+ | 日期选择器 | shadcn Calendar + Popover(禁止 input[type="date"]) |
636
+ | 验证 | zod |
637
+ | 工具函数 | lodash |
638
+ | 样式 | clsx |
639
+ | Excel | xlsx(**仅前端实现,禁止服务端实现**。解析后将结构化数据传到服务端保存) |
640
+ | PDF 导出 | jspdf + html2canvas(**仅前端实现,禁止服务端实现**) |
641
+ | 文件上传 | react-dropzone |
642
+ | 二维码 | qrcode.react |
643
+ | 用户反馈 | sonner |
644
+ | 拖拽 | @dnd-kit/core |
645
+ | 数字动画 | react-countup |
646
+ | Base64 | js-base64 — `import { encode, decode } from 'js-base64'` |
647
+ | 3D 场景 | cobe |
647
648
 
648
649
  ## 滚动分页最佳实践
649
650
 
@@ -1,262 +0,0 @@
1
- ---
2
- name: openapi-guide
3
- description: "OpenAPI 对外开放接口编码规范 + docs/openapi.json 产物维护。覆盖鉴权、用户身份、模块组织、OpenAPI 3.0 JSON 的结构与写入规则。Use when: 创建或修改 /openapi 路由、编写对外开放接口、维护 docs/openapi.json。触发词:openapi, 开放接口, 对外接口, 对外 API, open api, openapi controller, docs/openapi.json"
4
- ---
5
-
6
- # OpenAPI 对外开放接口编码规范
7
-
8
- 本规范适用于以 `/openapi` 为前缀的对外开放接口。**除下述差异外,均遵循 `/api` 的接口编码规范**。与 `/api` 的差异速查:
9
-
10
- | 维度 | `/api`(内部业务接口) | `/openapi`(对外开放接口) |
11
- |------|----------------------|--------------------------|
12
- | 鉴权 | 写操作加 `@NeedLogin()` | **不加** `@NeedLogin()`,鉴权在网关层通过 API Key 完成 |
13
- | 用户身份 | `req.userContext.userId` 区分用户 | 统一走系统身份,不依赖 `userId` 做业务区分 |
14
- | Controller 文件 | `xxx.controller.ts` | `xxx.openapi.controller.ts`,放在同一 module 下 |
15
- | OpenAPI 文档 | 不需要 | **必须**同步维护 `docs/openapi.json`(见下文) |
16
-
17
- ## 鉴权
18
-
19
- `/openapi` 路由的鉴权完全在网关层通过 API Key 完成,Controller 层不需要任何鉴权相关代码。
20
-
21
- ```typescript
22
- // ✅ 正确:/openapi 路由不加鉴权装饰器
23
- @Controller('openapi/orders')
24
- export class OpenApiOrdersController {
25
- @Post()
26
- create(@Body() body: CreateOrderRequest) { ... }
27
- }
28
-
29
- // ❌ 错误:给 /openapi 路由加 @NeedLogin()
30
- @Controller('openapi/orders')
31
- export class OpenApiOrdersController {
32
- @NeedLogin() // 不需要!
33
- @Post()
34
- create(@Body() body: CreateOrderRequest) { ... }
35
- }
36
- ```
37
-
38
- ## 用户身份
39
-
40
- OpenAPI 场景下不区分用户,统一走系统身份。禁止在 `/openapi` Controller 中使用 `req.userContext.userId` 做业务逻辑区分。
41
-
42
- ```typescript
43
- // ✅ 正确:不依赖用户身份
44
- @Get()
45
- findAll(@Query() query: FindOrdersQuery) {
46
- return this.ordersService.findAll(query);
47
- }
48
-
49
- // ❌ 错误:用 userId 过滤数据
50
- @Get()
51
- findAll(@Req() req: Request) {
52
- return this.ordersService.findByUser(req.userContext.userId); // OpenAPI 下无意义
53
- }
54
- ```
55
-
56
- ## 模块组织
57
-
58
- `/openapi` Controller 和 `/api` Controller 放在**同一个 module** 下,共享 Service 层。用文件名 `xxx.openapi.controller.ts` 区分。
59
-
60
- ```
61
- modules/orders/
62
- ├── orders.module.ts # 同时注册两个 Controller
63
- ├── orders.controller.ts # @Controller('api/orders') — 内部接口
64
- ├── orders.openapi.controller.ts # @Controller('openapi/orders') — 开放接口
65
- └── orders.service.ts # 共享业务逻辑
66
- ```
67
-
68
- 在 `orders.module.ts` 中注册:
69
-
70
- ```typescript
71
- @Module({
72
- controllers: [OrdersController, OpenApiOrdersController],
73
- providers: [OrdersService],
74
- })
75
- export class OrdersModule {}
76
- ```
77
-
78
- ## OpenAPI 文档产物(必须维护)
79
-
80
- 整个应用所有 `/openapi` 路由**汇总在一份** `docs/openapi.json` 文件里(仓库根目录)。
81
-
82
- - 路径 = 仓库根 `docs/openapi.json`
83
- - 所有 `*.openapi.controller.ts` 的 endpoint 共享这一个文件的 `paths`
84
- - 新仓库若没有该文件,Agent 自行创建
85
- - 该文件是对外 OpenAPI 的**权威 spec**(运行时被读取并对外暴露,不是纯文档)——精确性要求高,写错/漏写会直接影响外部调用方
86
- - 内部 `/api` 路由**不**写进来
87
-
88
- ## 何时创建/更新
89
-
90
- | 修改场景 | 文档动作 |
91
- |---|---|
92
- | 新建 `*.openapi.controller.ts` | 在 `docs/openapi.json` 的 `paths` 下新增对应 path 项 |
93
- | 已有 openapi controller 上增删 endpoint、改方法/路径/参数 | 精准增删/改对应 `paths["/openapi/..."]` 条目 |
94
- | 修改 `shared/api.interface.ts` 里被 openapi 路由引用的 interface / type | 同步更新所有受影响 path 的 schema |
95
- | 修改 service 返回值或 drizzle schema,且类型变化反映到 openapi 路由的响应 | 同步更新对应 path 的 responses schema |
96
-
97
- > 编辑 service / interface / schema 时本 skill 不会被 file-match 自动注入;此时 `coding-guide` 的 `## API 规范` 第 8 条会提醒 Agent 主动加载本 skill 完成同步。
98
-
99
- ## 安全更新协议(避免误删其它 path)
100
-
101
- 单文件聚合场景下最大的风险是 Agent 编辑某个 path 时意外丢掉其它 controller 的 path。**必须**按以下步骤:
102
-
103
- 1. **先 Read**:完整读入 `docs/openapi.json`
104
- 2. **再 Edit**:用精确 `Edit` 改特定 `"/openapi/<path>": { ... }` 块,不要 `Write` 全文件
105
- 3. **最后自检**:见末尾「写后自检」小节——确认 `paths` 下所有条目都在、且每条都有 `operationId` + `responses`
106
-
107
- ## 文件格式
108
-
109
- 顶层结构**固定**:
110
-
111
- ```json
112
- {
113
- "paths": {
114
- "/openapi/<feature>": { <pathItem> },
115
- "/openapi/<feature>/{id}": { <pathItem> }
116
- }
117
- }
118
- ```
119
-
120
- 硬性规定:
121
-
122
- - **path 键**写完整绝对路径(含 `/openapi/` 前缀),与 `@Controller(...)` + `@Get/Post/...(...)` 拼接结果一致
123
- - **不**放 `basePath`、**不**放 `components`、**不**放 `info`/`servers`——所有 schema 内联,中间件会补齐其它顶层字段
124
- - 每个 operation 对象**必须**有:`operationId`(= controller 方法名,驼峰)、`summary`(一句中文说明)、`responses`(至少 `"200"`)
125
- - 路径参数、查询参数、请求体、响应体的位置由 controller 装饰器决定(见下)
126
- - 同一 URL 上的多个 HTTP 方法共用一个 path 键(如 `@Controller('openapi/tickets')` 下的 `@Post()` 与 `@Get()` 都映射到 `/openapi/tickets`,共享 pathItem)
127
-
128
- ## Controller 装饰器 → OpenAPI 位置
129
-
130
- **位置看装饰器,类型看 TS**——两者缺一不可。
131
-
132
- | Nest 装饰器 | OpenAPI 位置 | 备注 |
133
- |---|---|---|
134
- | `@Body() body: T` | `requestBody.content['application/json'].schema` = `T` 的 schema | `T` 通常是 `shared/api.interface.ts` 的 interface |
135
- | `@Query('foo') foo: T` | `parameters[]` 一项:`in: 'query'`, `name: 'foo'`, `schema: T`;`?` → `required: false` | 每个 `@Query('x')` 产一项 |
136
- | `@Query() q: T` | `parameters[]` 每字段一项:`in: 'query'`, `name: <field>`, `schema: <field 类型>` | 整个 interface 被摊平到 query params |
137
- | `@Param('id') id: T` | `parameters[]` 一项:`in: 'path'`, `name: 'id'`, **`required: true`**, `schema: T` | path 参数恒 `required: true` |
138
- | `@Headers('x-foo') v: T` | `parameters[]` 一项:`in: 'header'`, `name: 'x-foo'`, `schema: T` | `X-Api-Key` 等鉴权头走网关层,不写进 spec |
139
-
140
- ## Schema 权威来源
141
-
142
- 对外接口的请求/响应类型统一在 `shared/api.interface.ts` 用 TS `interface` / `type` 维护,前后端共享。
143
-
144
- 三层优先级(从高到低):
145
-
146
- 1. **`shared/api.interface.ts` 中的 TS interface** — 请求体类型 / controller 返回值类型的唯一权威。从 controller 方法签名找到对应 interface 名称,再递归展开成 JSON Schema。
147
- 2. **controller 方法签名** — 各参数的 TS 类型与方法返回值类型,就是对应 OpenAPI 位置的 schema 来源(装饰器到 OpenAPI 位置的映射见上表)。类型可以是 `shared/api.interface.ts` 里的 interface、primitive、或 controller 文件内的内联类型声明;方法返回值类型即 OpenAPI 响应 schema 所描述的对象。
148
- 3. **drizzle schema** — 仅作字段底层类型的辅助参考;**绝不**用来扩展 interface 里没有的字段(interface 已显式定义对外字段集合;drizzle 里多出来的内部字段如 `userId`/`internalRemark` 不得出现在 `docs/openapi.json` 里)。
149
-
150
- 若 service 实现与 interface 脱节,属代码 bug;JSON schema 一律以 interface 为准。
151
-
152
- ## TS → JSON Schema 映射
153
-
154
- | TS 写法 | JSON Schema |
155
- |---|---|
156
- | `foo: string` | `{ "type": "string" }`,`"foo"` 进 `required` |
157
- | `foo?: string` / `foo: string \| undefined` | `{ "type": "string" }`,**不**进 `required` |
158
- | `foo: number` | `{ "type": "number" }` 或 `"integer"`(看 drizzle 是 `integer()` 还是 `real()`) |
159
- | `foo: boolean` | `{ "type": "boolean" }` |
160
- | `type X = 'a' \| 'b' \| 'c'` 或直接字面量联合 | `{ "type": "string", "enum": ["a","b","c"] }` |
161
- | `foo: SomeInterface` | 递归展开 `{ "type": "object", "required": [...], "properties": { ... } }` |
162
- | `foo: T[]` | `{ "type": "array", "items": <T 的 schema> }` |
163
- | `foo: Date` | `{ "type": "string", "format": "date-time" }`(序列化多为 ISO 字符串) |
164
- | `foo: T \| null`(可空,非"可选") | 在 `T` 的 schema 上加 `"nullable": true`;`"foo"` 仍进 `required` |
165
-
166
- ## `required` 三个位置
167
-
168
- OpenAPI 3.0 里 `required` 按上下文有三种写法,判定依据**统一是 TS `?` 修饰符**。
169
-
170
- | 上下文 | 写法 | 放在哪 |
171
- |---|---|---|
172
- | Parameter Object(query/path/header) | `"required": true/false` | 参数对象顶层 |
173
- | RequestBody Object(请求体整体) | `"required": true` | requestBody 顶层 |
174
- | Object Schema(对象字段) | `"required": ["field1","field2"]` 字段名数组 | object schema 顶层,**不**在每个 property 内 |
175
-
176
- ## description 与 example
177
-
178
- TS interface 本身不带 `description` 和 `example`。**按字段名语义自行生成**,不改 interface 源码或加 JSDoc hack。
179
-
180
- - `description`:一句中文解释,聚焦业务含义,别复述类型
181
- - `example`:选一个**真实合理**的值(`"13800000000"` 而不是 `"xxx"`)
182
-
183
- ## 深层嵌套省略规则
184
-
185
- 响应/请求结构很深时允许偷懒:
186
-
187
- - **第一层 properties** — 永远不省略
188
- - **深层嵌套对象** — 允许只写 `{ "type": "object" }` 不列 properties,但**必须**在这一层写 `example`(swagger-ui 合成不到深层)
189
- - **展开了 properties 的 object** — 不写顶层 `example`(swagger-ui 会从 property-level 合成)
190
- - **primitive 字段**(string/number/boolean) — 必须写 property-level `example`
191
- - **数组** — `items` 写一个元素的 schema + example,数组外层不写 `example`(由 `items` 合成)
192
-
193
- ## 规则表讲不透的片段示例
194
-
195
- TS interface 怎么翻译、装饰器映射到 parameter 的哪个 `in`、`required` 怎么标——全部看上面各节的表。**本节只示范规则表替代不了的 3 件事**:混用装饰器形成的 parameters 数组、第一层完整展开 vs 深层对象省略的对照、description / example 的风格。
196
-
197
- ```json
198
- {
199
- "paths": {
200
- "/openapi/tickets/{assigneeId}": {
201
- "get": {
202
- "operationId": "listByAssignee",
203
- "summary": "按受理人查询工单列表(游标分页)",
204
- "parameters": [
205
- { "in": "path", "name": "assigneeId", "required": true,
206
- "schema": { "type": "string" }, "description": "受理人 ID", "example": "usr_007" },
207
- { "in": "query", "name": "priority", "required": false,
208
- "schema": { "type": "string", "enum": ["low","normal","urgent"] },
209
- "description": "按优先级过滤", "example": "urgent" },
210
- { "in": "query", "name": "cursor", "required": false,
211
- "schema": { "type": "string" },
212
- "description": "游标(首次请求省略)", "example": "tk_abc" }
213
- ],
214
- "responses": {
215
- "200": {
216
- "description": "ok",
217
- "content": {
218
- "application/json": {
219
- "schema": {
220
- "type": "object",
221
- "required": ["items", "hasMore"],
222
- "properties": {
223
- "items": {
224
- "type": "array",
225
- "items": {
226
- "type": "object",
227
- "description": "Ticket 详情(深层省略,schema 见 example)",
228
- "example": {
229
- "id": "tk_abc", "title": "空调故障报修", "priority": "urgent",
230
- "assigneeCount": 2, "location": { "building": "A 栋", "floor": 3 }
231
- }
232
- }
233
- },
234
- "nextCursor": { "type": "string", "description": "下一页游标(最后一页省略)", "example": "tk_abc" },
235
- "hasMore": { "type": "boolean", "description": "是否还有下一页", "example": true }
236
- }
237
- }
238
- }
239
- }
240
- }
241
- }
242
- }
243
- }
244
- }
245
- }
246
- ```
247
-
248
- 要点:
249
-
250
- - **装饰器混用 → parameters 数组**:`@Param('assigneeId') + @Query('priority') + @Query('cursor')` 合并成 3 项 parameter,path 参数 `required: true`、query 参数 `required: false`
251
- - **第一层完整展开 + 深层对象省略**:响应外层对象展开,`items` / `hasMore` 进 `required` 数组而 `nextCursor` 不进;数组元素是 `Ticket`,这一层用深层省略——`type: 'object'` + `example` 示范元素结构,不重复翻译 Ticket 的 schema
252
- - **description / example 风格**:一句中文语义("按优先级过滤" 而非"过滤参数")、真实合理值(`"usr_007"` / `"tk_abc"` / `"A 栋"`,不是 `"xxx"` / `"foo"`)
253
-
254
- ## 写后自检(强制)
255
-
256
- 每次 Write/Edit 完 `docs/openapi.json` 之后,**必须**立即在项目根执行下面这条自检命令。未通过要当场修到通过为止,才视为本次任务完成:
257
-
258
- ```bash
259
- node -e "const M=['get','post','put','patch','delete','head','options'];const s=require('fs').readFileSync('docs/openapi.json','utf8');const o=JSON.parse(s);if(!o.paths||typeof o.paths!=='object')throw new Error('missing paths');for(const [p,item] of Object.entries(o.paths))for(const [m,op] of Object.entries(item)){if(!M.includes(m))continue;if(!op.operationId||!op.responses)throw new Error(p+' '+m+' missing operationId/responses');}console.log('ok, '+Object.keys(o.paths).length+' paths');"
260
- ```
261
-
262
- 覆盖:JSON 语法错 / 顶层缺 `paths` / 某个 operation 缺 `operationId` 或 `responses`;最后打印 path 数量方便和修改前做比对,防止误删。