@lark-apaas/coding-steering 0.1.14 → 0.1.15-alpha.0
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/package.json +1 -1
- package/steering/nestjs-react-fullstack/skills/anycross-forward/SKILL.md +180 -0
- package/steering/nestjs-react-fullstack/skills/app-init-feasibility-guide/SKILL.md +124 -0
- package/steering/nestjs-react-fullstack/skills/authn-guide/SKILL.md +3 -0
- package/steering/nestjs-react-fullstack/skills/authz-guide/SKILL.md +11 -2
- package/steering/nestjs-react-fullstack/skills/authz-guide/references/dynamic-permission-guide.md +0 -6
- package/steering/nestjs-react-fullstack/skills/authz-guide/references/management-page-spec.md +1 -4
- package/steering/nestjs-react-fullstack/skills/authz-guide/references/sdk-examples.md +5 -3
- package/steering/nestjs-react-fullstack/skills/authz-guide/references/sdk-types.md +17 -4
- package/steering/nestjs-react-fullstack/skills/client-builtins-file-storage-service/SKILL.md +4 -31
- package/steering/nestjs-react-fullstack/skills/client-builtins-user-service/SKILL.md +226 -116
- package/steering/nestjs-react-fullstack/skills/code-fix/SKILL.md +319 -0
- package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +666 -0
- package/steering/nestjs-react-fullstack/skills/contacts-service/SKILL.md +222 -0
- package/steering/nestjs-react-fullstack/skills/openapi-guide/SKILL.md +0 -1
- package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +87 -36
- package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md +1 -4
- package/steering/nestjs-react-fullstack/skills/plugin-guide/references/table.md +5 -6
- package/steering/nestjs-react-fullstack/skills/react-hook-best-practices/SKILL.md +5 -4
- package/steering/nestjs-react-fullstack/skills/trigger-guide/SKILL.md +0 -1
- package/steering/nestjs-react-fullstack/skills/user-identity/SKILL.md +3 -2
- package/steering/nestjs-react-fullstack/skills_local/coding-guide/SKILL.md +86 -87
- package/steering/nestjs-react-fullstack/skills_local/openapi-guide/SKILL.md +262 -0
- package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +580 -0
- package/steering/nestjs-react-fullstack/skills_local/plugin-guide/references/plugin-coding-guide.md +322 -0
- package/steering/nestjs-react-fullstack/skills_local/plugin-guide/scripts/plugin-hydrate.js +152 -0
|
@@ -43,6 +43,8 @@ match-template-name: nestjs-react-fullstack
|
|
|
43
43
|
> (范围限定:上一条只约束 `useCurrentUserProfile()` 的消费代码。**项目其他场景** —— 例如调 spark `id_convert`、调飞书通讯录 API —— 出现 `open_id` / `union_id` 是正常且必要的,不在禁止之列。)
|
|
44
44
|
>
|
|
45
45
|
> **严禁**:当 `lark_user_id` 为空时回退到 `user_id` 展示。两套 ID 含义完全不同,回退会误导用户。正确做法是条件渲染:有值则展示,无值则不展示或展示"未关联飞书账号"。
|
|
46
|
+
>
|
|
47
|
+
> **严禁(大整数精度)**:妙搭 `user_id` 与飞书 `lark_user_id` / `employee_id` 虽是"纯数字字符串",但实为 **I64 大整数(可超 9e15,超出 JS `Number.MAX_SAFE_INTEGER` 53 位安全整数上限)**。跨 JS ↔ SQL / Dataloom / 飞书插件边界一律**按 string 透传**,严禁 `Number()` / `parseInt()` / `+id` 转换——会静默丢精度,把通知/查询打到错误用户。
|
|
46
48
|
|
|
47
49
|
---
|
|
48
50
|
|
|
@@ -81,7 +83,7 @@ match-template-name: nestjs-react-fullstack
|
|
|
81
83
|
└─ 是 ──→ 注入 AuthNPaasService 编写 Controller(第四节,仅适用于 user_id)
|
|
82
84
|
```
|
|
83
85
|
|
|
84
|
-
> **相关技能**:接口认证/守卫/401 参见 `authn-guide`;登录/登出/获取用户信息等 Dataloom SDK 操作参见 `client-builtins-user-service`;**"用户能做什么"**(角色/权限点位鉴权)参见 `authz-guide`。本 skill 只解决**"用户是谁"
|
|
86
|
+
> **相关技能**:接口认证/守卫/401 参见 `authn-guide`;登录/登出/获取用户信息等 Dataloom SDK 操作参见 `client-builtins-user-service`;**搜人 / 人员·部门·群组选择器返回的 ID 处理与字段获取**(employee_id / open_department_id、禁用字段、id_convert)参见 `contacts-service`;**"用户能做什么"**(角色/权限点位鉴权)参见 `authz-guide`。本 skill 只解决**"用户是谁"**(当前登录用户),其 `lark_user_id` == `employee_id`。
|
|
85
87
|
|
|
86
88
|
---
|
|
87
89
|
|
|
@@ -208,7 +210,6 @@ const UserInfoPanel = () => {
|
|
|
208
210
|
```
|
|
209
211
|
|
|
210
212
|
**注意**:
|
|
211
|
-
|
|
212
213
|
- `lark_user_id` 通过额外异步请求获取,可能晚于 `user_id` 等基础字段就绪
|
|
213
214
|
- 请求失败或用户无对应飞书账号时值为 `undefined`,**必须用条件渲染**
|
|
214
215
|
- `useCurrentUserProfile()` 的返回值里**不存在** `open_id`、`feishu_id`、`openId` 字段——在这个 Hook 的消费代码里飞书 ID 唯一字段名是 `lark_user_id`(仅约束本 Hook;项目其他场景调 spark `id_convert` / 通讯录 API 出现 `open_id` 是正常的)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: coding-guide
|
|
3
|
-
description:
|
|
3
|
+
description: '项目全局编码规范,必须在任何代码编写、阅读、修改、排查前加载。覆盖:项目结构、目录组织、文件命名、NestJS 后端(MVCS/Drizzle ORM/API 规范/异常处理/用户上下文)、React 19 前端(shadcn/ui/Tailwind/路由/样式/组件规范)、前后端联调(shared 类型/axiosForBackend)、数据库操作、质量保障流程、日志规范。Use when: 编写任意前端或后端代码、新建页面或模块、修改接口或数据库操作、排查编译错误或运行时问题、代码审查、理解项目架构。'
|
|
4
4
|
---
|
|
5
|
+
|
|
5
6
|
# 项目结构
|
|
6
7
|
|
|
7
8
|
## 根目录组织
|
|
@@ -96,7 +97,7 @@ shared/ # 前后端共享的目录
|
|
|
96
97
|
```typescript
|
|
97
98
|
// ❌ 错误:变量无类型注解 → 回调参数隐式 any → TS7006
|
|
98
99
|
const items = await this.service.getItems();
|
|
99
|
-
items.map(item => item.name);
|
|
100
|
+
items.map((item) => item.name); // TS7006: item 隐式具有 any 类型
|
|
100
101
|
|
|
101
102
|
// ✅ 正确:变量显式类型 + 回调参数显式类型
|
|
102
103
|
const items: Item[] = await this.service.getItems();
|
|
@@ -177,7 +178,6 @@ shared/ # 前后端共享的目录
|
|
|
177
178
|
项目有三个**聚合文件**:`server/app.module.ts`、`client/src/app.tsx`、`client/src/api/index.ts`。它们是所有业务模块的汇聚入口,**必须由主 agent 在派发任何业务模块任务之前一次性串行预填**,业务模块任务(可能并行执行)**不得编辑**这三个文件。多方并发编辑同一聚合文件会触发 `old_string` mismatch 和 LLM 兜底重写,显著拖慢生成。
|
|
178
179
|
|
|
179
180
|
**主 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,7 +186,6 @@ shared/ # 前后端共享的目录
|
|
|
186
186
|
- 预填完成后才派发业务模块任务;聚合文件引用的符号(`UsersModule` / `UsersPage` / `./users`)在任务产出前暂时悬空,任务完成后自然对齐
|
|
187
187
|
|
|
188
188
|
**业务模块任务的工作边界**:
|
|
189
|
-
|
|
190
189
|
- 产出只能落在以下目录之一:`server/modules/<name>/`(Module / Controller / Service / DTO)、`client/src/pages/<name>/`、`client/src/api/<name>/`
|
|
191
190
|
- **禁止编辑** `server/app.module.ts` / `client/src/app.tsx` / `client/src/api/index.ts`(已由主 agent 预填)
|
|
192
191
|
- 跨模块依赖(不涉及聚合文件):在源 Module 的 `exports` 中导出即可
|
|
@@ -194,8 +193,8 @@ shared/ # 前后端共享的目录
|
|
|
194
193
|
|
|
195
194
|
```yaml
|
|
196
195
|
cross_module_needs:
|
|
197
|
-
- type: extra_module
|
|
198
|
-
reason:
|
|
196
|
+
- type: extra_module # 或 extra_route / extra_provider
|
|
197
|
+
reason: '需要 SharedAuthModule 让 UsersController 使用 @Auth'
|
|
199
198
|
module_class: SharedAuthModule
|
|
200
199
|
module_file: server/modules/shared-auth/shared-auth.module.ts
|
|
201
200
|
```
|
|
@@ -218,7 +217,7 @@ shared/ # 前后端共享的目录
|
|
|
218
217
|
**必须使用注入的 Drizzle 实例**,禁止自建连接:
|
|
219
218
|
|
|
220
219
|
```typescript
|
|
221
|
-
import { DRIZZLE_DATABASE, type PostgresJsDatabase } from
|
|
220
|
+
import { DRIZZLE_DATABASE, type PostgresJsDatabase } from '@lark-apaas/fullstack-nestjs-core';
|
|
222
221
|
|
|
223
222
|
@Injectable()
|
|
224
223
|
export class TestService {
|
|
@@ -234,21 +233,30 @@ export class TestService {
|
|
|
234
233
|
// ✅ 条件查询
|
|
235
234
|
const conditions = [];
|
|
236
235
|
if (status) conditions.push(eq(users.status, status));
|
|
237
|
-
const query =
|
|
238
|
-
|
|
239
|
-
|
|
236
|
+
const query =
|
|
237
|
+
conditions.length > 0
|
|
238
|
+
? db
|
|
239
|
+
.select()
|
|
240
|
+
.from(users)
|
|
241
|
+
.where(and(...conditions))
|
|
242
|
+
: db.select().from(users);
|
|
240
243
|
```
|
|
241
244
|
|
|
242
245
|
- Count 查询:
|
|
243
246
|
|
|
244
247
|
```typescript
|
|
245
248
|
// 简单 count
|
|
246
|
-
const result = await this.db
|
|
249
|
+
const result = await this.db
|
|
250
|
+
.select({ count: count() })
|
|
251
|
+
.from(users)
|
|
252
|
+
.where(eq(users.status, 'active'));
|
|
247
253
|
// 子查询 count(关联计数)
|
|
248
|
-
const users = await this.db
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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);
|
|
252
260
|
```
|
|
253
261
|
|
|
254
262
|
### userProfile 自定义类型
|
|
@@ -257,11 +265,11 @@ const users = await this.db.select({
|
|
|
257
265
|
|
|
258
266
|
```typescript
|
|
259
267
|
// schema 示例
|
|
260
|
-
export const mockTable = pgTable(
|
|
261
|
-
adminUser: userProfile(
|
|
268
|
+
export const mockTable = pgTable('mock_table', {
|
|
269
|
+
adminUser: userProfile('admin_user'), // custom_type, TS 中为 string
|
|
262
270
|
});
|
|
263
271
|
// 使用示例
|
|
264
|
-
const userId: string =
|
|
272
|
+
const userId: string = 'user123'; // ✅ 显式注解
|
|
265
273
|
await db.insert(users).values({ adminUser: userId });
|
|
266
274
|
await db.select().from(users).where(eq(users.adminUser, userId));
|
|
267
275
|
```
|
|
@@ -273,7 +281,7 @@ await db.select().from(users).where(eq(users.adminUser, userId));
|
|
|
273
281
|
- **UUID 列 `inArray` 必须显式 `::uuid[]`**:标准 `inArray(col, ids)` 会运行时报 `42809: op ANY/ALL (array) requires array on right side`。
|
|
274
282
|
|
|
275
283
|
```typescript
|
|
276
|
-
where: sql`${tasks.id} = ANY(${ids}::uuid[])
|
|
284
|
+
where: sql`${tasks.id} = ANY(${ids}::uuid[])`;
|
|
277
285
|
```
|
|
278
286
|
|
|
279
287
|
- **`customTimestamptz` 跨网络后是 string**:service 内查询返回 `Date`,但 API 响应经 JSON 序列化后前端拿到 ISO string。`shared/api.interface.ts` 中 timestamptz 字段声明为 `string`,前端需要 Date 时手动 `new Date(value)`。
|
|
@@ -312,9 +320,9 @@ await db.select().from(users).where(eq(users.adminUser, userId));
|
|
|
312
320
|
|
|
313
321
|
## 异常处理
|
|
314
322
|
|
|
315
|
-
| 分层
|
|
316
|
-
|
|
317
|
-
| service
|
|
323
|
+
| 分层 | 目标 |
|
|
324
|
+
| ---------- | ------------------------------- |
|
|
325
|
+
| service | 抛出业务异常 |
|
|
318
326
|
| controller | 不处理异常,交全局 Error Filter |
|
|
319
327
|
|
|
320
328
|
## 当前用户信息
|
|
@@ -336,15 +344,6 @@ async createArticle(@Req() req: Request, @Body() dto: CreateArticleDto) {
|
|
|
336
344
|
}
|
|
337
345
|
```
|
|
338
346
|
|
|
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
|
-
|
|
348
347
|
## 自动化任务
|
|
349
348
|
|
|
350
349
|
需要设计自动化任务配置与开发时,请调用文档工具召回自动化任务配置与代码编写文档
|
|
@@ -371,11 +370,11 @@ async createArticle(@Req() req: Request, @Body() dto: CreateArticleDto) {
|
|
|
371
370
|
|
|
372
371
|
本地 dev 跑起来有**两个端口 + 一个前缀**,都由 `.env.local` 里的 env 控制:
|
|
373
372
|
|
|
374
|
-
| Env
|
|
375
|
-
|
|
376
|
-
| `CLIENT_DEV_PORT`
|
|
377
|
-
| `SERVER_PORT`
|
|
378
|
-
| `CLIENT_BASE_PATH` | 前端路由 base + 后端 routes.json serve 路径 | `/`
|
|
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/*` 也必须带这个前缀 |
|
|
379
378
|
|
|
380
379
|
完整入口形态 = `http://localhost:<CLIENT_DEV_PORT><CLIENT_BASE_PATH>/...`。启动日志里
|
|
381
380
|
vite/rspack 打印的 `Local: http://localhost:<port>/...` 才是入口,具体值以 `.env.local`
|
|
@@ -413,13 +412,13 @@ NestJS 自己不读 env。直连 NestJS 端口 → header 缺失 → `req.userCo
|
|
|
413
412
|
和 header,值字面相等才放行。任意非空相等值都行(注意打**client dev port**,不是
|
|
414
413
|
NestJS 端口):
|
|
415
414
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
+
```
|
|
423
422
|
|
|
424
423
|
**不要**因为撞 403 就去改后端 csrf 中间件或者怀疑业务逻辑 —— csrf 保护是有意的,
|
|
425
424
|
业务本身没问题。
|
|
@@ -480,20 +479,20 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
|
|
|
480
479
|
|
|
481
480
|
## 官方内置组件
|
|
482
481
|
|
|
483
|
-
| 组件
|
|
484
|
-
|
|
485
|
-
| Table
|
|
486
|
-
| UserSelect/UserDisplay/UserProfile/DepartmentSelect | `business-ui/*`
|
|
487
|
-
| TiptapEditorComplete
|
|
488
|
-
| Streamdown
|
|
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/流式渲染 |
|
|
489
488
|
|
|
490
489
|
### 组件 Skill 召回规则(强制执行)
|
|
491
490
|
|
|
492
|
-
| 场景
|
|
493
|
-
|
|
494
|
-
| 表单、Form、Zod
|
|
495
|
-
| 图表、Chart、ECharts | `/charts-skill` | shadcn/ui + ReactECharts
|
|
496
|
-
| 表格、Table
|
|
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 |
|
|
497
496
|
|
|
498
497
|
禁止未调用 Skill 直接编写表单/图表/表格代码。
|
|
499
498
|
|
|
@@ -511,15 +510,15 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
|
|
|
511
510
|
|
|
512
511
|
**零假设原则**:绝不基于假设使用任何子模块。使用任何 `@lark-apaas/client-toolkit` 子模块前**必须**:① 先查询本地已加载的 Skills 或对应包文档 → ② 严格按文档编码。**禁止直接调用 `@lark-apaas/client-toolkit` 任何函数/方法**(不基于查询到的文档)。该库**仅可在前端代码中使用,禁止在后端代码中引用**。
|
|
513
512
|
|
|
514
|
-
| 功能
|
|
515
|
-
|
|
516
|
-
| 插件调用(Client) | `import { capabilityClient } from "@lark-apaas/client-toolkit"`
|
|
517
|
-
| 日志
|
|
518
|
-
| 文件上传下载
|
|
519
|
-
| 应用信息
|
|
520
|
-
| 当前用户
|
|
521
|
-
| URL 解析
|
|
522
|
-
| 批量用户信息
|
|
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"` |
|
|
523
522
|
|
|
524
523
|
**useCurrentUserProfile** 返回 `Partial<IUserProfile>`,初始为空对象:访问字段用 `?.`,判断加载完成用 `if (!userInfo?.user_id)`。
|
|
525
524
|
|
|
@@ -569,9 +568,9 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
|
|
|
569
568
|
|
|
570
569
|
```typescript
|
|
571
570
|
import { resolveAppUrl } from '@lark-apaas/client-toolkit/utils/resolveAppUrl';
|
|
572
|
-
const shareUrl = resolveAppUrl(`/detail/${id}`);
|
|
571
|
+
const shareUrl = resolveAppUrl(`/detail/${id}`); // ✅ 路由路径→完整 URL
|
|
573
572
|
const fixedUrl = resolveAppUrl(`${origin}/detail/${id}`); // ✅ 自动修正
|
|
574
|
-
resolveAppUrl('https://other-site.com/page');
|
|
573
|
+
resolveAppUrl('https://other-site.com/page'); // ✅ 外部链接原样返回
|
|
575
574
|
// ❌ 严禁自行拼接:`${window.location.origin}/detail/${id}`
|
|
576
575
|
```
|
|
577
576
|
|
|
@@ -605,11 +604,11 @@ return <h1>{data?.title || '未知标题'}</h1>;
|
|
|
605
604
|
|
|
606
605
|
为特定功能的顶层容器添加 `data-ai-section-type` 属性:
|
|
607
606
|
|
|
608
|
-
| 值
|
|
609
|
-
|
|
610
|
-
| `card-stat` | 横向指标卡容器
|
|
611
|
-
| `card-list` | Card 组件的 flex 列表
|
|
612
|
-
| `button`
|
|
607
|
+
| 值 | 场景 |
|
|
608
|
+
| ----------- | ------------------------- |
|
|
609
|
+
| `card-stat` | 横向指标卡容器 |
|
|
610
|
+
| `card-list` | Card 组件的 flex 列表 |
|
|
611
|
+
| `button` | 任意 Button |
|
|
613
612
|
| `card-menu` | Card 组件的 grid 网格菜单 |
|
|
614
613
|
|
|
615
614
|
## 图片规范
|
|
@@ -628,23 +627,23 @@ return <h1>{data?.title || '未知标题'}</h1>;
|
|
|
628
627
|
|
|
629
628
|
## 核心功能依赖
|
|
630
629
|
|
|
631
|
-
| 类别
|
|
632
|
-
|
|
633
|
-
| 动画
|
|
634
|
-
| 日期
|
|
635
|
-
| 日期选择器 | shadcn Calendar + Popover(禁止 input[type="date"])
|
|
636
|
-
| 验证
|
|
637
|
-
| 工具函数
|
|
638
|
-
| 样式
|
|
639
|
-
| Excel
|
|
640
|
-
| PDF 导出
|
|
641
|
-
| 文件上传
|
|
642
|
-
| 二维码
|
|
643
|
-
| 用户反馈
|
|
644
|
-
| 拖拽
|
|
645
|
-
| 数字动画
|
|
646
|
-
| Base64
|
|
647
|
-
| 3D 场景
|
|
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 |
|
|
648
647
|
|
|
649
648
|
## 滚动分页最佳实践
|
|
650
649
|
|
|
@@ -0,0 +1,262 @@
|
|
|
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 数量方便和修改前做比对,防止误删。
|