npx skills add https://github.com/minimax-ai/skills --skill fullstack-dev当此技能被触发时,在编写任何代码之前,您必须遵循此工作流程。
在搭建任何东西之前,请要求用户澄清(或根据上下文推断):
如果用户已在请求中指定了这些内容,则跳过询问并继续。
根据需求,在编码之前做出并说明以下决策:
| 决策 | 选项 | 参考 |
|---|---|---|
| 项目结构 | 功能优先(推荐) vs 分层优先 | 第 1 节 |
| API 客户端方法 | 类型化 fetch / React Query / tRPC / OpenAPI 代码生成 | 第 5 节 |
| 认证策略 | JWT + 刷新令牌 / 会话 / 第三方 |
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
| 第 6 节 |
| 实时方法 | 轮询 / SSE / WebSocket | 第 11 节 |
| 错误处理 | 类型化错误层次结构 + 全局处理器 | 第 3 节 |
简要解释每个选择(每个决策一句话)。
使用下面适当的清单。确保实现所有勾选的项目 — 不要跳过任何一项。
按照本文档中的模式编写代码。在实现每个部分时参考特定章节。
实现后,在声明完成之前运行以下检查:
构建检查:确保后端和前端都能无错误编译
cd server && npm run build
# Frontend
cd client && npm run build
2. 启动与冒烟测试:启动服务器,验证关键端点返回预期响应
# Start server, then test
curl http://localhost:3000/health
curl http://localhost:3000/api/<resource>
3. 集成检查:验证前端可以连接到后端(CORS、API 基础 URL、认证流程) 4. 实时检查(如果适用):打开两个浏览器标签页,验证更改同步
如果任何检查失败,请在继续之前修复问题。
向用户提供简要摘要:
在以下情况使用此技能:
不适用于:
Error)/health、/ready)*).env.example(无真实密钥)*)| 需要… | 跳转到 |
|---|---|
| 组织项目文件夹 | 1. 项目结构 |
| 管理配置 + 密钥 | 2. 配置 |
| 正确处理错误 | 3. 错误处理 |
| 编写数据库代码 | 4. 数据库访问模式 |
| 从前端设置 API 客户端 | 5. API 客户端模式 |
| 添加认证中间件 | 6. 认证与中间件 |
| 设置日志记录 | 7. 日志记录与可观测性 |
| 添加后台作业 | 8. 后台作业 |
| 实现缓存 | 9. 缓存 |
| 上传文件(预签名 URL、多部分) | 10. 文件上传模式 |
| 添加实时功能(SSE、WebSocket) | 11. 实时模式 |
| 在前端 UI 中处理 API 错误 | 12. 跨边界错误处理 |
| 为生产环境加固 | 13. 生产环境加固 |
| 设计 API 端点 | API 设计 |
| 设计数据库模式 | 数据库模式 |
| 认证流程(JWT、刷新、Next.js SSR、RBAC) | references/auth-flow.md |
| CORS、环境变量、环境管理 | references/environment-management.md |
1. ✅ 按功能组织,而非按技术层
2. ✅ 控制器绝不包含业务逻辑
3. ✅ 服务绝不导入 HTTP 请求/响应类型
4. ✅ 所有配置来自环境变量,启动时验证,快速失败
5. ✅ 每个错误都是类型化的、已记录的,并返回一致的格式
6. ✅ 所有输入在边界处验证 — 不信任来自客户端的任何内容
7. ✅ 结构化 JSON 日志记录附带请求 ID — 非 console.log
✅ Feature-first ❌ Layer-first
src/ src/
orders/ controllers/
order.controller.ts order.controller.ts
order.service.ts user.controller.ts
order.repository.ts services/
order.dto.ts order.service.ts
order.test.ts user.service.ts
users/ repositories/
user.controller.ts ...
user.service.ts
shared/
database/
middleware/
Controller (HTTP) → Service (Business Logic) → Repository (Data Access)
| 层 | 职责 | ❌ 绝不 |
|---|---|---|
| 控制器 | 解析请求、验证、调用服务、格式化响应 | 业务逻辑、数据库查询 |
| 服务 | 业务规则、编排、事务管理 | HTTP 类型(请求/响应)、直接数据库操作 |
| 仓库 | 数据库查询、外部 API 调用 | 业务逻辑、HTTP 类型 |
TypeScript:
class OrderService {
constructor(
private readonly orderRepo: OrderRepository, // ✅ 注入的接口
private readonly emailService: EmailService,
) {}
}
Python:
class OrderService:
def __init__(self, order_repo: OrderRepository, email_service: EmailService):
self.order_repo = order_repo # ✅ 注入的
self.email_service = email_service
Go:
type OrderService struct {
orderRepo OrderRepository // ✅ 接口
emailService EmailService
}
func NewOrderService(repo OrderRepository, email EmailService) *OrderService {
return &OrderService{orderRepo: repo, emailService: email}
}
TypeScript:
const config = {
port: parseInt(process.env.PORT || '3000', 10),
database: { url: requiredEnv('DATABASE_URL'), poolSize: intEnv('DB_POOL_SIZE', 10) },
auth: { jwtSecret: requiredEnv('JWT_SECRET'), expiresIn: process.env.JWT_EXPIRES_IN || '1h' },
} as const;
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing required env var: ${name}`); // 快速失败
return value;
}
Python:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str # 必需 — 没有它应用无法启动
jwt_secret: str # 必需
port: int = 3000 # 可选,带默认值
db_pool_size: int = 10
class Config:
env_file = ".env"
settings = Settings() # 如果 DATABASE_URL 缺失则快速失败
✅ 所有配置通过环境变量(十二要素应用)
✅ 启动时验证必需变量 — 快速失败
✅ 在配置层进行类型转换,而非在使用处
✅ 提交带虚拟值的 .env.example
❌ 绝不硬编码密钥、URL 或凭据
❌ 绝不提交 .env 文件
❌ 绝不将 process.env / os.environ 分散在代码各处
// Base (TypeScript)
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number,
public readonly isOperational: boolean = true,
) { super(message); }
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, 'NOT_FOUND', 404);
}
}
class ValidationError extends AppError {
constructor(public readonly errors: FieldError[]) {
super('Validation failed', 'VALIDATION_ERROR', 422);
}
}
# Base (Python)
class AppError(Exception):
def __init__(self, message: str, code: str, status_code: int):
self.message, self.code, self.status_code = message, code, status_code
class NotFoundError(AppError):
def __init__(self, resource: str, id: str):
super().__init__(f"{resource} not found: {id}", "NOT_FOUND", 404)
// TypeScript (Express)
app.use((err, req, res, next) => {
if (err instanceof AppError && err.isOperational) {
return res.status(err.statusCode).json({
title: err.code, status: err.statusCode,
detail: err.message, request_id: req.id,
});
}
logger.error('Unexpected error', { error: err.message, stack: err.stack, request_id: req.id });
res.status(500).json({ title: 'Internal Error', status: 500, request_id: req.id });
});
✅ 类型化、领域特定的错误类
✅ 全局错误处理器捕获一切
✅ 操作错误 → 结构化响应
✅ 编程错误 → 日志记录 + 通用 500 错误
✅ 使用指数退避重试瞬时故障
❌ 绝不捕获并静默忽略错误
❌ 绝不向客户端返回堆栈跟踪
❌ 绝不抛出通用 Error('something')
# TypeScript (Prisma) # Python (Alembic) # Go (golang-migrate)
npx prisma migrate dev alembic revision --autogenerate migrate -source file://migrations
npx prisma migrate deploy alembic upgrade head migrate -database $DB up
✅ 通过迁移进行模式更改,绝不手动 SQL
✅ 迁移必须可逆
✅ 生产环境前审查迁移 SQL
❌ 绝不手动修改生产环境模式
// ❌ N+1: 1 次查询 + N 次查询
const orders = await db.order.findMany();
for (const o of orders) { o.items = await db.item.findMany({ where: { orderId: o.id } }); }
// ✅ 单次 JOIN 查询
const orders = await db.order.findMany({ include: { items: true } });
await db.$transaction(async (tx) => {
const order = await tx.order.create({ data: orderData });
await tx.inventory.decrement({ productId, quantity });
await tx.payment.create({ orderId: order.id, amount });
});
池大小 = (CPU 核心数 × 2) + spindle_count(从 10-20 开始)。始终设置连接超时。对于无服务器环境使用 PgBouncer。
前端和后端之间的“粘合层”。选择适合您团队和技术栈的方法。
// lib/api-client.ts
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
class ApiError extends Error {
constructor(public status: number, public body: any) {
super(body?.detail || body?.message || `API error ${status}`);
}
}
async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getAuthToken(); // 来自 cookie / 内存 / 上下文
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new ApiError(res.status, body);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const apiClient = {
get: <T>(path: string) => api<T>(path),
post: <T>(path: string, data: unknown) => api<T>(path, { method: 'POST', body: JSON.stringify(data) }),
put: <T>(path: string, data: unknown) => api<T>(path, { method: 'PUT', body: JSON.stringify(data) }),
patch: <T>(path: string, data: unknown) => api<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
delete: <T>(path: string) => api<T>(path, { method: 'DELETE' }),
};
// hooks/use-orders.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
interface Order { id: string; total: number; status: string; }
interface CreateOrderInput { items: { productId: string; quantity: number }[] }
export function useOrders() {
return useQuery({
queryKey: ['orders'],
queryFn: () => apiClient.get<{ data: Order[] }>('/api/orders'),
staleTime: 1000 * 60, // 1 分钟
});
}
export function useCreateOrder() {
const queryClient = use QueryClient();
return useMutation({
mutationFn: (data: CreateOrderInput) =>
apiClient.post<{ data: Order }>('/api/orders', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['orders'] });
},
});
}
// 在组件中使用:
function OrdersPage() {
const { data, isLoading, error } = useOrders();
const createOrder = useCreateOrder();
if (isLoading) return <Skeleton />;
if (error) return <ErrorBanner error={error} />;
// ...
}
// server: trpc/router.ts
export const appRouter = router({
orders: router({
list: publicProcedure.query(async () => {
return db.order.findMany({ include: { items: true } });
}),
create: protectedProcedure
.input(z.object({ items: z.array(orderItemSchema) }))
.mutation(async ({ input, ctx }) => {
return orderService.create(ctx.user.id, input);
}),
}),
});
export type AppRouter = typeof appRouter;
// client: 自动类型安全,无需代码生成
const { data } = trpc.orders.list.useQuery();
const createOrder = trpc.orders.create.useMutation();
npx openapi-typescript-codegen \
--input http://localhost:3001/api/openapi.json \
--output src/generated/api \
--client axios
| 方法 | 适用场景 | 类型安全 | 工作量 |
|---|---|---|---|
| 类型化 fetch 包装器 | 简单应用,小团队 | 手动类型 | 低 |
| React Query + fetch | React 应用,服务器状态 | 手动类型 | 中 |
| tRPC | 同一团队,两端都是 TypeScript | 自动 | 低 |
| OpenAPI 生成 | 公共 API,多消费者 | 自动 | 中 |
| GraphQL 代码生成 | GraphQL API | 自动 | 中 |
完整参考: references/auth-flow.md — JWT 持有者流程、自动令牌刷新、Next.js 服务器端认证、RBAC 模式、后端中间件顺序。
Request → 1.RequestID → 2.Logging → 3.CORS → 4.RateLimit → 5.BodyParse
→ 6.Auth → 7.Authz → 8.Validation → 9.Handler → 10.ErrorHandler → Response
✅ 短过期访问令牌(15分钟)+ 刷新令牌(服务器存储)
✅ 最小声明:userId、角色(非整个用户对象)
✅ 定期轮换签名密钥
❌ 绝不将令牌存储在 localStorage 中(XSS 风险)
❌ 绝不在 URL 查询参数中传递令牌
function authorize(...roles: Role[]) {
return (req, res, next) => {
if (!req.user) throw new UnauthorizedError();
if (!roles.some(r => req.user.roles.includes(r))) throw new ForbiddenError();
next();
};
}
router.delete('/users/:id', authenticate, authorize('admin'), deleteUser);
// lib/api-client.ts — 401 时透明刷新
async function apiWithRefresh<T>(path: string, options: RequestInit = {}): Promise<T> {
try {
return await api<T>(path, options);
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
const refreshed = await api<{ accessToken: string }>('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // 发送 httpOnly cookie
});
setAuthToken(refreshed.accessToken);
return api<T>(path, options); // 重试
}
throw err;
}
}
// ✅ 结构化 — 可解析、可过滤、可告警
logger.info('Order created', {
orderId: order.id, userId: user.id, total: order.total,
items: order.items.length, duration_ms: Date.now() - startTime,
});
// 输出: {"level":"info","msg":"Order created","orderId":"ord_123",...}
// ❌ 非结构化 — 大规模时无用
console.log(`Order created for user ${user.id} with total ${order.total}`);
| 级别 | 适用场景 | 生产环境? |
|---|---|---|
| error | 需要立即关注 | ✅ 始终 |
| warn | 意外但已处理 | ✅ 始终 |
| info | 正常操作,审计追踪 | ✅ 始终 |
| debug | 开发故障排除 | ❌ 仅开发 |
✅ 每个日志条目都包含请求 ID(通过中间件传播)
✅ 在层边界记录日志(请求进入、响应发出、外部调用)
❌ 绝不记录密码、令牌、PII 或密钥
❌ 绝不在生产代码中使用 console.log
✅ 所有作业必须是幂等的(同一作业运行两次 = 相同结果)
✅ 失败的作业 → 重试(最多 3 次)→ 死信队列 → 告警
✅ 工作进程作为独立进程运行(非 API 服务器中的线程)
❌ 绝不在请求处理器中放置长时间运行的任务
❌ 绝不假设作业恰好运行一次
async function processPayment(data: { orderId: string }) {
const order = await orderRepo.findById(data.orderId);
if (order.paymentStatus === 'completed') return; // 已处理
await paymentGateway.charge(order);
await orderRepo.updatePaymentStatus(order.id, 'completed');
}
async function getUser(id: string): Promise<User> {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await userRepo.findById(id);
if (!user) throw new NotFoundError('User', id);
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 900); // 15分钟 TTL
return user;
}
✅ 始终设置 TTL — 绝不缓存无过期时间的数据
✅ 写入时失效(更新后删除缓存键)
✅ 缓存用于读取,绝不用于权威状态
❌ 绝不缓存无 TTL 的数据(陈旧数据比慢数据更糟糕)
| 数据类型 | 建议 TTL |
|---|---|
| 用户资料 | 5-15 分钟 |
| 产品目录 | 1-5 分钟 |
| 配置 / 功能标志 | 30-60 秒 |
| 会话 | 匹配会话持续时间 |
Client → GET /api/uploads/presign?filename=photo.jpg&type=image/jpeg
Server → { uploadUrl: "https://s3.../presigned", fileKey: "uploads/abc123.jpg" }
Client → PUT uploadUrl (直接到 S3,绕过您的服务器)
Client → POST /api/photos { fileKey: "uploads/abc123.jpg" } (保存引用)
后端:
app.get('/api/uploads/presign', authenticate, async (req, res) => {
const { filename, type } = req.query;
const key = `uploads/${crypto.randomUUID()}-${filename}`;
const url = await s3.getSignedUrl('putObject', {
Bucket: process.env.S3_BUCKET, Key: key,
ContentType: type, Expires: 300, // 5 分钟
});
res.json({ uploadUrl: url, fileKey: key });
});
前端:
async function uploadFile(file: File) {
const { uploadUrl, fileKey } = await apiClient.get<PresignResponse>(
`/api/uploads/presign?filename=${file.name}&type=${file.type}`
);
await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } });
return apiClient.post('/api/photos', { fileKey });
}
// Frontend
const formData = new FormData();
formData.append('file', file);
formData.append('description', 'Profile photo');
const res = await fetch('/api/upload', { method: 'POST', body: formData });
// 注意:不要设置 Content-Type 头 — 浏览器会自动设置边界
| 方法 | 文件大小 | 服务器负载 | 复杂度 |
|---|---|---|---|
| 预签名 URL | 任意(推荐 > 5MB) | 无(直接到存储) | 中 |
| 多部分上传 | < 10MB | 高(流经服务器) | 低 |
| 分块 / 可恢复上传 | > 100MB | 中 | 高 |
最佳用于:通知、实时动态、流式 AI 响应。
后端(Express):
app.get('/api/events', authenticate, (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const send = (event: string, data: unknown) => {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
const unsubscribe = eventBus.subscribe(req.user.id, (event) => {
send(event.type, event.payload);
});
req.on('close', () => unsubscribe());
});
前端:
function useServerEvents(userId: string) {
useEffect(() => {
const source = new EventSource(`/api/events?userId=${userId}`);
source.addEventListener('notification', (e) => {
showToast(JSON.parse(e.data).message);
});
source.onerror = () => { source.close(); setTimeout(() => /* reconnect */, 3000); };
return () => source.close();
}, [userId]);
}
最佳用于:聊天、协作编辑、游戏。
后端(ws 库):
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
wss.on('connection', (ws, req) => {
const userId = authenticateWs(req);
if (!userId) { ws.close(4001, 'Unauthorized'); return; }
ws.on('message', (raw) => handleMessage(userId, JSON.parse(raw.toString())));
ws.on('close', () => cleanupUser(userId));
const interval = setInterval(() => ws.ping(), 30000);
ws.on('pong', () => { /* alive */ });
ws.on('close', () => clearInterval(interval));
});
前端:
function useWebSocket(url: string) {
const [ws, setWs] = useState<WebSocket | null>(null);
useEffect(() => {
const socket = new WebSocket(url);
socket.onopen = () => setWs(socket);
socket.onclose = () => setTimeout(() => /* reconnect */, 3000);
return () => socket.close();
}, [url]);
const send = useCallback((data: unknown) => ws?.send(JSON.stringify(data)), [ws]);
return { ws, send };
}
function useOrderStatus(orderId: string) {
return useQuery({
queryKey: ['order-status', orderId],
queryFn: () => apiClient.get<Order>(`/api/orders/${orderId}`),
refetchInterval: (query) => {
if (query.state.data?.status === 'completed') return false;
return 5000;
},
});
}
| 方法 | 方向 | 复杂度 | 适用场景 |
|---|---|---|---|
| 轮询 | 客户端 → 服务器 | 低 | 简单状态检查,< 10 个客户端 |
| SSE | 服务器 → 客户端 | 中 | 通知、动态、AI 流式传输 |
| WebSocket | 双向 | 高 | 聊天、协作、游戏 |
// lib/error-handler.ts
export function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
switch (error.status) {
case 401: return '请登录以继续。';
case 403: return '您没有执行此操作的权限。';
case 404: return '您查找的项不存在。';
case 409: return '此项与现有项冲突。';
case 422:
const fields = error.body?.errors;
if (fields?.length) return fields.map((f: any) => f.message).join('. ');
return '请检查您的输入。';
case 429: return '请求过多。请稍等片刻。';
default: return '出错了。请重试。';
}
}
if (error instanceof TypeError && error.message === 'Failed to fetch') {
return '无法连接到服务器。请检查您的互联网连接。';
}
return '发生意外错误。';
}
const queryClient = new QueryClient({
defaultOptions: {
mutations: { onError: (error) => toast.error(getErrorMessage(error)) },
queries: {
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status < 500) return false;
return failureCount < 3;
},
},
},
});
✅ 将每个 API 错误代码映射到人类可读的消息
✅ 在表单输入旁边显示字段级验证错误
✅ 对 5xx 错误自动重试(最多 3 次,带退避),绝不对 4xx 重试
✅ 401 时重定向到登录(刷新尝试失败后)
✅ 当 fetch 失败并出现 TypeError 时显示“离线”横幅
❌ 绝不向用户显示原始 API 错误消息("NullPointerException")
❌ 绝不静默吞没错误(显示 toast 或记录日志)
❌ 绝不重试 4xx 错误(客户端错误,重试无济于事)
同一团队拥有前端 + 后端?
│
├─ 是,都是 TypeScript
│ └─ tRPC(端到端类型安全,零代码生成)
│
├─ 是,不同语言
│ └─ OpenAPI 规范 → 生成客户端(通过代码生成实现类型安全)
│
├─ 否,公共 API
│ └─ REST + OpenAPI → 为消费者生成 SDK
│
└─ 复杂数据需求,多个前端
└─ GraphQL + 代码生成(每个客户端灵活的查询)
需要实时功能?
│
├─ 仅服务器 → 客户端(通知、动态、AI 流式传输)
│ └─ SSE(最简单,自动重连,可通过代理工作)
│
├─ 双向(聊天、协作)
│ └─ WebSocket(需要心跳 + 重连逻辑)
│
└─ 简单状态轮询(< 10 个客户端)
└─ React Query refetchInterval(无需基础设施)
app.get('/health', (req, res) => res.json({ status: 'ok' })); // 存活探针
app.get('/ready', async (req, res) => { // 就绪探针
const checks = {
database: await checkDb(), redis: await checkRedis(),
};
const ok = Object.values(checks).every(c => c.status === 'ok');
res.status(ok ? 200 : 503).json({ status: ok ? 'ok' : 'degraded', checks });
});
process.on('SIGTERM', async () => {
logger.info('SIGTERM received');
server.close(); // 停止新连接
await drainConnections(); // 完成进行中的请求
await closeDatabase();
process.exit(0);
});
✅ CORS:显式来源(生产环境绝不使用 '*')
✅ 安全头(helmet / 等效物)
✅ 公共端点限流
✅ 所有端点进行输入验证(不信任任何内容)
✅ 强制 HTTPS
❌ 绝不向客户端暴露内部错误
---|---|---
1 | 业务逻辑放在路由/控制器中 | 移动到服务层
2 | process.env 分散在各处 | 集中化类型化配置
3 | 使用 console.log 记录日志 | 结构化 JSON 记录器
4 | 通用 Error('oops') | 类型化错误层次结构
5 | 控制器中直接数据库调用 | 仓库模式
6 | 无输入验证 | 在边界处验证(Zod/Pydantic)
7 | 静默捕获错误 | 记录日志 + 重新抛出或返回错误
8 | 无健康检查端点 | /health + /ready
9 | 硬编码配置/密钥 | 环境变量
10 | 无优雅关闭 | 正确处理 SIGTERM
11 | 在前端硬编码 API URL | 环境变量(NEXT_PUBLIC_API_URL)
12 | 将 JWT 存储在 localStorage 中 | 内存 + httpOnly 刷新 cookie
13 | 向用户显示原始 API
When this skill is triggered, you MUST follow this workflow before writing any code.
Before scaffolding anything, ask the user to clarify (or infer from context):
If the user has already specified these in their request, skip asking and proceed.
Based on requirements, make and state these decisions before coding:
| Decision | Options | Reference |
|---|---|---|
| Project structure | Feature-first (recommended) vs layer-first | Section 1 |
| API client approach | Typed fetch / React Query / tRPC / OpenAPI codegen | Section 5 |
| Auth strategy | JWT + refresh / session / third-party | Section 6 |
| Real-time method | Polling / SSE / WebSocket | Section 11 |
| Error handling | Typed error hierarchy + global handler | Section 3 |
Briefly explain each choice (1 sentence per decision).
Use the appropriate checklist below. Ensure ALL checked items are implemented — do not skip any.
Write code following the patterns in this document. Reference specific sections as you implement each part.
After implementation, run these checks before claiming completion:
Build check : Ensure both backend and frontend compile without errors
cd server && npm run build
# Frontend
cd client && npm run build
2. Start & smoke test: Start the server, verify key endpoints return expected responses
# Start server, then test
curl http://localhost:3000/health
curl http://localhost:3000/api/<resource>
3. Integration check : Verify frontend can connect to backend (CORS, API base URL, auth flow) 4. Real-time check (if applicable): Open two browser tabs, verify changes sync
If any check fails, fix the issue before proceeding.
Provide a brief summary to the user:
USE this skill when:
NOT for:
Error)/health, /ready)*).env.example committed (no real secrets)* in production)| Need to… | Jump to |
|---|---|
| Organize project folders | 1. Project Structure |
| Manage config + secrets | 2. Configuration |
| Handle errors properly | 3. Error Handling |
| Write database code | 4. Database Access Patterns |
| Set up API client from frontend | 5. API Client Patterns |
| Add auth middleware | 6. Auth & Middleware |
| Set up logging | 7. Logging & Observability |
| Add background jobs | 8. Background Jobs |
| Implement caching | 9. Caching |
| Upload files (presigned URL, multipart) | 10. File Upload Patterns |
| Add real-time features (SSE, WebSocket) | 11. Real-Time Patterns |
| Handle API errors in frontend UI | 12. Cross-Boundary Error Handling |
| Harden for production | 13. Production Hardening |
1. ✅ Organize by FEATURE, not by technical layer
2. ✅ Controllers never contain business logic
3. ✅ Services never import HTTP request/response types
4. ✅ All config from env vars, validated at startup, fail fast
5. ✅ Every error is typed, logged, and returns consistent format
6. ✅ All input validated at the boundary — trust nothing from client
7. ✅ Structured JSON logging with request ID — not console.log
✅ Feature-first ❌ Layer-first
src/ src/
orders/ controllers/
order.controller.ts order.controller.ts
order.service.ts user.controller.ts
order.repository.ts services/
order.dto.ts order.service.ts
order.test.ts user.service.ts
users/ repositories/
user.controller.ts ...
user.service.ts
shared/
database/
middleware/
Controller (HTTP) → Service (Business Logic) → Repository (Data Access)
| Layer | Responsibility | ❌ Never |
|---|---|---|
| Controller | Parse request, validate, call service, format response | Business logic, DB queries |
| Service | Business rules, orchestration, transaction mgmt | HTTP types (req/res), direct DB |
| Repository | Database queries, external API calls | Business logic, HTTP types |
TypeScript:
class OrderService {
constructor(
private readonly orderRepo: OrderRepository, // ✅ injected interface
private readonly emailService: EmailService,
) {}
}
Python:
class OrderService:
def __init__(self, order_repo: OrderRepository, email_service: EmailService):
self.order_repo = order_repo # ✅ injected
self.email_service = email_service
Go:
type OrderService struct {
orderRepo OrderRepository // ✅ interface
emailService EmailService
}
func NewOrderService(repo OrderRepository, email EmailService) *OrderService {
return &OrderService{orderRepo: repo, emailService: email}
}
TypeScript:
const config = {
port: parseInt(process.env.PORT || '3000', 10),
database: { url: requiredEnv('DATABASE_URL'), poolSize: intEnv('DB_POOL_SIZE', 10) },
auth: { jwtSecret: requiredEnv('JWT_SECRET'), expiresIn: process.env.JWT_EXPIRES_IN || '1h' },
} as const;
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing required env var: ${name}`); // fail fast
return value;
}
Python:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str # required — app won't start without it
jwt_secret: str # required
port: int = 3000 # optional with default
db_pool_size: int = 10
class Config:
env_file = ".env"
settings = Settings() # fails fast if DATABASE_URL missing
✅ All config via environment variables (Twelve-Factor)
✅ Validate required vars at startup — fail fast
✅ Type-cast at config layer, not at usage sites
✅ Commit .env.example with dummy values
❌ Never hardcode secrets, URLs, or credentials
❌ Never commit .env files
❌ Never scatter process.env / os.environ throughout code
// Base (TypeScript)
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number,
public readonly isOperational: boolean = true,
) { super(message); }
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, 'NOT_FOUND', 404);
}
}
class ValidationError extends AppError {
constructor(public readonly errors: FieldError[]) {
super('Validation failed', 'VALIDATION_ERROR', 422);
}
}
# Base (Python)
class AppError(Exception):
def __init__(self, message: str, code: str, status_code: int):
self.message, self.code, self.status_code = message, code, status_code
class NotFoundError(AppError):
def __init__(self, resource: str, id: str):
super().__init__(f"{resource} not found: {id}", "NOT_FOUND", 404)
// TypeScript (Express)
app.use((err, req, res, next) => {
if (err instanceof AppError && err.isOperational) {
return res.status(err.statusCode).json({
title: err.code, status: err.statusCode,
detail: err.message, request_id: req.id,
});
}
logger.error('Unexpected error', { error: err.message, stack: err.stack, request_id: req.id });
res.status(500).json({ title: 'Internal Error', status: 500, request_id: req.id });
});
✅ Typed, domain-specific error classes
✅ Global error handler catches everything
✅ Operational errors → structured response
✅ Programming errors → log + generic 500
✅ Retry transient failures with exponential backoff
❌ Never catch and ignore errors silently
❌ Never return stack traces to client
❌ Never throw generic Error('something')
# TypeScript (Prisma) # Python (Alembic) # Go (golang-migrate)
npx prisma migrate dev alembic revision --autogenerate migrate -source file://migrations
npx prisma migrate deploy alembic upgrade head migrate -database $DB up
✅ Schema changes via migrations, never manual SQL
✅ Migrations must be reversible
✅ Review migration SQL before production
❌ Never modify production schema manually
// ❌ N+1: 1 query + N queries
const orders = await db.order.findMany();
for (const o of orders) { o.items = await db.item.findMany({ where: { orderId: o.id } }); }
// ✅ Single JOIN query
const orders = await db.order.findMany({ include: { items: true } });
await db.$transaction(async (tx) => {
const order = await tx.order.create({ data: orderData });
await tx.inventory.decrement({ productId, quantity });
await tx.payment.create({ orderId: order.id, amount });
});
Pool size = (CPU cores × 2) + spindle_count (start with 10-20). Always set connection timeout. Use PgBouncer for serverless.
The "glue layer" between frontend and backend. Choose the approach that fits your team and stack.
// lib/api-client.ts
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
class ApiError extends Error {
constructor(public status: number, public body: any) {
super(body?.detail || body?.message || `API error ${status}`);
}
}
async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getAuthToken(); // from cookie / memory / context
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new ApiError(res.status, body);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const apiClient = {
get: <T>(path: string) => api<T>(path),
post: <T>(path: string, data: unknown) => api<T>(path, { method: 'POST', body: JSON.stringify(data) }),
put: <T>(path: string, data: unknown) => api<T>(path, { method: 'PUT', body: JSON.stringify(data) }),
patch: <T>(path: string, data: unknown) => api<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
delete: <T>(path: string) => api<T>(path, { method: 'DELETE' }),
};
// hooks/use-orders.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
interface Order { id: string; total: number; status: string; }
interface CreateOrderInput { items: { productId: string; quantity: number }[] }
export function useOrders() {
return useQuery({
queryKey: ['orders'],
queryFn: () => apiClient.get<{ data: Order[] }>('/api/orders'),
staleTime: 1000 * 60, // 1 min
});
}
export function useCreateOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateOrderInput) =>
apiClient.post<{ data: Order }>('/api/orders', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['orders'] });
},
});
}
// Usage in component:
function OrdersPage() {
const { data, isLoading, error } = useOrders();
const createOrder = useCreateOrder();
if (isLoading) return <Skeleton />;
if (error) return <ErrorBanner error={error} />;
// ...
}
// server: trpc/router.ts
export const appRouter = router({
orders: router({
list: publicProcedure.query(async () => {
return db.order.findMany({ include: { items: true } });
}),
create: protectedProcedure
.input(z.object({ items: z.array(orderItemSchema) }))
.mutation(async ({ input, ctx }) => {
return orderService.create(ctx.user.id, input);
}),
}),
});
export type AppRouter = typeof appRouter;
// client: automatic type safety, no code generation
const { data } = trpc.orders.list.useQuery();
const createOrder = trpc.orders.create.useMutation();
npx openapi-typescript-codegen \
--input http://localhost:3001/api/openapi.json \
--output src/generated/api \
--client axios
| Approach | When | Type Safety | Effort |
|---|---|---|---|
| Typed fetch wrapper | Simple apps, small teams | Manual types | Low |
| React Query + fetch | React apps, server state | Manual types | Medium |
| tRPC | Same team, TypeScript both sides | Automatic | Low |
| OpenAPI generated | Public API, multi-consumer | Automatic | Medium |
| GraphQL codegen | GraphQL APIs | Automatic | Medium |
Full reference: references/auth-flow.md — JWT bearer flow, automatic token refresh, Next.js server-side auth, RBAC pattern, backend middleware order.
Request → 1.RequestID → 2.Logging → 3.CORS → 4.RateLimit → 5.BodyParse
→ 6.Auth → 7.Authz → 8.Validation → 9.Handler → 10.ErrorHandler → Response
✅ Short expiry access token (15min) + refresh token (server-stored)
✅ Minimal claims: userId, roles (not entire user object)
✅ Rotate signing keys periodically
❌ Never store tokens in localStorage (XSS risk)
❌ Never pass tokens in URL query params
function authorize(...roles: Role[]) {
return (req, res, next) => {
if (!req.user) throw new UnauthorizedError();
if (!roles.some(r => req.user.roles.includes(r))) throw new ForbiddenError();
next();
};
}
router.delete('/users/:id', authenticate, authorize('admin'), deleteUser);
// lib/api-client.ts — transparent refresh on 401
async function apiWithRefresh<T>(path: string, options: RequestInit = {}): Promise<T> {
try {
return await api<T>(path, options);
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
const refreshed = await api<{ accessToken: string }>('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // send httpOnly cookie
});
setAuthToken(refreshed.accessToken);
return api<T>(path, options); // retry
}
throw err;
}
}
// ✅ Structured — parseable, filterable, alertable
logger.info('Order created', {
orderId: order.id, userId: user.id, total: order.total,
items: order.items.length, duration_ms: Date.now() - startTime,
});
// Output: {"level":"info","msg":"Order created","orderId":"ord_123",...}
// ❌ Unstructured — useless at scale
console.log(`Order created for user ${user.id} with total ${order.total}`);
| Level | When | Production? |
|---|---|---|
| error | Requires immediate attention | ✅ Always |
| warn | Unexpected but handled | ✅ Always |
| info | Normal operations, audit trail | ✅ Always |
| debug | Dev troubleshooting | ❌ Dev only |
✅ Request ID in every log entry (propagated via middleware)
✅ Log at layer boundaries (request in, response out, external call)
❌ Never log passwords, tokens, PII, or secrets
❌ Never use console.log in production code
✅ All jobs must be IDEMPOTENT (same job running twice = same result)
✅ Failed jobs → retry (max 3) → dead letter queue → alert
✅ Workers run as SEPARATE processes (not threads in API server)
❌ Never put long-running tasks in request handlers
❌ Never assume job runs exactly once
async function processPayment(data: { orderId: string }) {
const order = await orderRepo.findById(data.orderId);
if (order.paymentStatus === 'completed') return; // already processed
await paymentGateway.charge(order);
await orderRepo.updatePaymentStatus(order.id, 'completed');
}
async function getUser(id: string): Promise<User> {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await userRepo.findById(id);
if (!user) throw new NotFoundError('User', id);
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 900); // 15min TTL
return user;
}
✅ ALWAYS set TTL — never cache without expiry
✅ Invalidate on write (delete cache key after update)
✅ Use cache for reads, never for authoritative state
❌ Never cache without TTL (stale data is worse than slow data)
| Data Type | Suggested TTL |
|---|---|
| User profile | 5-15 min |
| Product catalog | 1-5 min |
| Config / feature flags | 30-60 sec |
| Session | Match session duration |
Client → GET /api/uploads/presign?filename=photo.jpg&type=image/jpeg
Server → { uploadUrl: "https://s3.../presigned", fileKey: "uploads/abc123.jpg" }
Client → PUT uploadUrl (direct to S3, bypasses your server)
Client → POST /api/photos { fileKey: "uploads/abc123.jpg" } (save reference)
Backend:
app.get('/api/uploads/presign', authenticate, async (req, res) => {
const { filename, type } = req.query;
const key = `uploads/${crypto.randomUUID()}-${filename}`;
const url = await s3.getSignedUrl('putObject', {
Bucket: process.env.S3_BUCKET, Key: key,
ContentType: type, Expires: 300, // 5 min
});
res.json({ uploadUrl: url, fileKey: key });
});
Frontend:
async function uploadFile(file: File) {
const { uploadUrl, fileKey } = await apiClient.get<PresignResponse>(
`/api/uploads/presign?filename=${file.name}&type=${file.type}`
);
await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } });
return apiClient.post('/api/photos', { fileKey });
}
// Frontend
const formData = new FormData();
formData.append('file', file);
formData.append('description', 'Profile photo');
const res = await fetch('/api/upload', { method: 'POST', body: formData });
// Note: do NOT set Content-Type header — browser sets boundary automatically
| Method | File Size | Server Load | Complexity |
|---|---|---|---|
| Presigned URL | Any (recommended > 5MB) | None (direct to storage) | Medium |
| Multipart | < 10MB | High (streams through server) | Low |
| Chunked / Resumable | > 100MB | Medium | High |
Best for: notifications, live feeds, streaming AI responses.
Backend (Express):
app.get('/api/events', authenticate, (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const send = (event: string, data: unknown) => {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
const unsubscribe = eventBus.subscribe(req.user.id, (event) => {
send(event.type, event.payload);
});
req.on('close', () => unsubscribe());
});
Frontend:
function useServerEvents(userId: string) {
useEffect(() => {
const source = new EventSource(`/api/events?userId=${userId}`);
source.addEventListener('notification', (e) => {
showToast(JSON.parse(e.data).message);
});
source.onerror = () => { source.close(); setTimeout(() => /* reconnect */, 3000); };
return () => source.close();
}, [userId]);
}
Best for: chat, collaborative editing, gaming.
Backend (ws library):
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
wss.on('connection', (ws, req) => {
const userId = authenticateWs(req);
if (!userId) { ws.close(4001, 'Unauthorized'); return; }
ws.on('message', (raw) => handleMessage(userId, JSON.parse(raw.toString())));
ws.on('close', () => cleanupUser(userId));
const interval = setInterval(() => ws.ping(), 30000);
ws.on('pong', () => { /* alive */ });
ws.on('close', () => clearInterval(interval));
});
Frontend:
function useWebSocket(url: string) {
const [ws, setWs] = useState<WebSocket | null>(null);
useEffect(() => {
const socket = new WebSocket(url);
socket.onopen = () => setWs(socket);
socket.onclose = () => setTimeout(() => /* reconnect */, 3000);
return () => socket.close();
}, [url]);
const send = useCallback((data: unknown) => ws?.send(JSON.stringify(data)), [ws]);
return { ws, send };
}
function useOrderStatus(orderId: string) {
return useQuery({
queryKey: ['order-status', orderId],
queryFn: () => apiClient.get<Order>(`/api/orders/${orderId}`),
refetchInterval: (query) => {
if (query.state.data?.status === 'completed') return false;
return 5000;
},
});
}
| Method | Direction | Complexity | When |
|---|---|---|---|
| Polling | Client → Server | Low | Simple status checks, < 10 clients |
| SSE | Server → Client | Medium | Notifications, feeds, AI streaming |
| WebSocket | Bidirectional | High | Chat, collaboration, gaming |
// lib/error-handler.ts
export function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
switch (error.status) {
case 401: return 'Please log in to continue.';
case 403: return 'You don\'t have permission to do this.';
case 404: return 'The item you\'re looking for doesn\'t exist.';
case 409: return 'This conflicts with an existing item.';
case 422:
const fields = error.body?.errors;
if (fields?.length) return fields.map((f: any) => f.message).join('. ');
return 'Please check your input.';
case 429: return 'Too many requests. Please wait a moment.';
default: return 'Something went wrong. Please try again.';
}
}
if (error instanceof TypeError && error.message === 'Failed to fetch') {
return 'Cannot connect to server. Check your internet connection.';
}
return 'An unexpected error occurred.';
}
const queryClient = new QueryClient({
defaultOptions: {
mutations: { onError: (error) => toast.error(getErrorMessage(error)) },
queries: {
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status < 500) return false;
return failureCount < 3;
},
},
},
});
✅ Map every API error code to a human-readable message
✅ Show field-level validation errors next to form inputs
✅ Auto-retry on 5xx (max 3, with backoff), never on 4xx
✅ Redirect to login on 401 (after refresh attempt fails)
✅ Show "offline" banner when fetch fails with TypeError
❌ Never show raw API error messages to users ("NullPointerException")
❌ Never silently swallow errors (show toast or log)
❌ Never retry 4xx errors (client is wrong, retrying won't help)
Same team owns frontend + backend?
│
├─ YES, both TypeScript
│ └─ tRPC (end-to-end type safety, zero codegen)
│
├─ YES, different languages
│ └─ OpenAPI spec → generated client (type safety via codegen)
│
├─ NO, public API
│ └─ REST + OpenAPI → generated SDKs for consumers
│
└─ Complex data needs, multiple frontends
└─ GraphQL + codegen (flexible queries per client)
Real-time needed?
│
├─ Server → Client only (notifications, feeds, AI streaming)
│ └─ SSE (simplest, auto-reconnect, works through proxies)
│
├─ Bidirectional (chat, collaboration)
│ └─ WebSocket (need heartbeat + reconnection logic)
│
└─ Simple status polling (< 10 clients)
└─ React Query refetchInterval (no infrastructure needed)
app.get('/health', (req, res) => res.json({ status: 'ok' })); // liveness
app.get('/ready', async (req, res) => { // readiness
const checks = {
database: await checkDb(), redis: await checkRedis(),
};
const ok = Object.values(checks).every(c => c.status === 'ok');
res.status(ok ? 200 : 503).json({ status: ok ? 'ok' : 'degraded', checks });
});
process.on('SIGTERM', async () => {
logger.info('SIGTERM received');
server.close(); // stop new connections
await drainConnections(); // finish in-flight
await closeDatabase();
process.exit(0);
});
✅ CORS: explicit origins (never '*' in production)
✅ Security headers (helmet / equivalent)
✅ Rate limiting on public endpoints
✅ Input validation on ALL endpoints (trust nothing)
✅ HTTPS enforced
❌ Never expose internal errors to clients
---|---|---
1 | Business logic in routes/controllers | Move to service layer
2 | process.env scattered everywhere | Centralized typed config
3 | console.log for logging | Structured JSON logger
4 | Generic Error('oops') | Typed error hierarchy
5 | Direct DB calls in controllers | Repository pattern
6 | No input validation | Validate at boundary (Zod/Pydantic)
7 | Catching errors silently | Log + rethrow or return error
8 | No health check endpoints | /health + /ready
9 | Hardcoded config/secrets | Environment variables
10 | No graceful shutdown | Handle SIGTERM properly
11 | Hardcode API URL in frontend | Environment variable (NEXT_PUBLIC_API_URL)
12 | Store JWT in localStorage | Memory + httpOnly refresh cookie
13 | Show raw API errors to users | Map to human-readable messages
14 | Retry 4xx errors | Only retry 5xx (server failures)
15 | Skip loading states | Skeleton/spinner while fetching
16 | Upload large files through API server | Presigned URL → direct to S3
17 | Poll for real-time data | SSE or WebSocket
18 | Duplicate types frontend + backend | Shared types, tRPC, or OpenAPI codegen
Rule: If it involves HTTP (request parsing, status codes, headers) → controller. If it involves business decisions (pricing, permissions, rules) → service. If it touches the database → repository.
Symptom: One service file > 500 lines with 20+ methods.
Fix: Split by sub-domain. OrderService → OrderCreationService + OrderFulfillmentService + OrderQueryService. Each focused on one workflow.
Fix: Unit tests mock the repository layer (fast). Integration tests use test containers or transaction rollback (real DB, still fast). Never mock the service layer in integration tests.
This skill includes deep-dive references for specialized topics. Read the relevant reference when you need detailed guidance.
| Need to… | Reference |
|---|---|
| Write backend tests (unit, integration, e2e, contract, performance) | references/testing-strategy.md |
| Validate a release before deployment (6-gate checklist) | references/release-checklist.md |
| Choose a tech stack (language, framework, database, infra) | references/technology-selection.md |
| Build with Django / DRF (models, views, serializers, admin) | references/django-best-practices.md |
| Design REST/GraphQL/gRPC endpoints (URLs, status codes, pagination) | references/api-design.md |
| Design database schema, indexes, migrations, multi-tenancy | references/db-schema.md |
| Auth flow (JWT bearer, token refresh, Next.js SSR, RBAC, middleware order) |
Weekly Installs
126
Repository
GitHub Stars
3.5K
First Seen
6 days ago
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex124
cursor122
opencode122
gemini-cli121
amp121
cline121
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
116,600 周安装
Apollo Client 4.x 指南:GraphQL 状态管理库,支持React 19与TypeScript
1,800 周安装
产品经理工具包:RICE优先级排序、客户访谈分析与PRD模板 - 现代产品管理必备
1,900 周安装
AI架构设计师助手 - 15年经验系统设计专家,提供架构决策与设计模式指导
1,900 周安装
Swift Testing Pro - Swift 6.2+ 代码审查与测试规范检查工具
2,000 周安装
小红书笔记分析器 - 全方位内容优化与SEO分析工具,提升曝光率
1,900 周安装
shadcn/ui 组件安装与自定义指南:React 主题化UI组件库完整教程
2,000 周安装
| Design API endpoints | API Design |
| Design database schema | Database Schema |
| Auth flow (JWT, refresh, Next.js SSR, RBAC) | references/auth-flow.md |
| CORS, env vars, environment management | references/environment-management.md |
| references/auth-flow.md |
| CORS config, env vars per environment, common CORS issues | references/environment-management.md |