锚点项目:FastAPI + asyncio 异步管线、Express/Nest.js 通用服务、Tauri/Rust 桌面端
一、Python 后端
Q1:FastAPI 你最喜欢的几个特性?
标准答案:
- Pydantic 模型:请求/响应类型校验自动化,错误信息结构化;
- 依赖注入:
Depends()解耦中间件、鉴权、DB 会话; - 异步原生:
async def端点 +httpx+asyncpg,IO 密集场景吞吐高; - OpenAPI 自动生成:Swagger / ReDoc 零配置;
- WebSocket 支持:和 LLM 流式输出天然契合。
核心代码骨架:
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated
app = FastAPI()
# 1. Pydantic 模型
class DeviceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
type: str
power_kw: float = Field(..., gt=0, le=10000)
class DeviceOut(BaseModel):
id: int
name: str
type: str
model_config = {"from_attributes": True} # 允许从 ORM 转
# 2. 依赖注入:DB 会话
async def get_db() -> AsyncSession:
async with SessionLocal() as session:
yield session
# 3. 鉴权依赖
async def current_user(token: str = Depends(oauth2_scheme), db = Depends(get_db)):
user = await auth_service.verify(token, db)
if not user:
raise HTTPException(401, "invalid token")
return user
DB = Annotated[AsyncSession, Depends(get_db)]
User = Annotated[UserModel, Depends(current_user)]
# 4. 路由
@app.post("/devices", response_model=DeviceOut, status_code=201)
async def create_device(body: DeviceCreate, db: DB, user: User):
return await device_service.create(db, body, owner_id=user.id)
追问应对:
- Q:Pydantic v1 / v2 你用哪个?
v2,性能比 v1 提升 5-50x(核心用 Rust 重写)。迁移成本主要是
.dict()→.model_dump()、Config→model_config、validator 装饰器变了。新项目直接 v2。
Q2:asyncio 你踩过什么坑?
标准答案:
- 同步阻塞混入:在 async 函数里调同步重活(如 cv 处理、PIL)→ 阻塞 event loop。解法:
asyncio.to_thread()或loop.run_in_executor; - 不 await 的 Task:
asyncio.create_task后不 await,异常被吞,task 也可能被 GC。解法:用 task set 持有引用 +add_done_callback; - gather 部分失败:默认一个失败全部取消。用
return_exceptions=True收集所有结果再统一处理; - 超时取消传播:CancelledError 必须重新抛出,吞掉会导致取消失效;
- 资源泄漏:HTTP 连接、文件句柄、DB 连接必须用 async with;
- 嵌套 loop:在 Jupyter / 同步代码里用 asyncio.run 会冲突,用
nest_asyncio或asyncio.new_event_loop。
核心代码骨架:
import asyncio
from PIL import Image
# 错误:阻塞 event loop
async def bad_resize(path: str):
img = Image.open(path) # 同步阻塞
img.thumbnail((100, 100)) # 同步阻塞
img.save(f"{path}.thumb.jpg")
# 正确:丢给线程池
async def good_resize(path: str):
def _do():
img = Image.open(path)
img.thumbnail((100, 100))
img.save(f"{path}.thumb.jpg")
await asyncio.to_thread(_do)
# 错误:fire-and-forget 异常被吞
async def bad_background():
asyncio.create_task(some_task()) # task 没被持有,异常没人看
# 正确:持有引用 + 错误回调
_bg_tasks: set[asyncio.Task] = set()
def safe_background(coro):
task = asyncio.create_task(coro)
_bg_tasks.add(task)
task.add_done_callback(lambda t: (_bg_tasks.discard(t), t.exception() and log.exception("bg error", exc_info=t.exception())))
return task
# 错误:吞掉 CancelledError
async def bad_handle():
try:
await long_op()
except Exception: # ⚠️ Exception 包含 CancelledError (3.7) 或不包含 (3.8+)
pass
# 正确:CancelledError 必须 re-raise
async def good_handle():
try:
await long_op()
except asyncio.CancelledError:
# 清理资源
await cleanup()
raise
except Exception as e:
log.exception(e)
追问应对:
- Q:asyncio.to_thread 和 ProcessPoolExecutor 怎么选?
IO 密集 / 小 CPU 任务用 to_thread(线程池,GIL 不放但 IO 时会让出);CPU 重活(图像处理、ML 推理)必须 ProcessPoolExecutor 绕开 GIL,但要注意 fork/spawn 和参数 pickle 开销。
Q3:你说的"async generator 流式产出"怎么落地?
标准答案:
- 上游用
httpx.AsyncClient.stream而不是普通get; - yield 出来的每个 chunk 立刻通过 ws 推送;
- 客户端断开 → WebSocketDisconnect 捕获 → 上游 context 自动退出释放连接。
核心代码骨架(已在 02 RAG 节展开,这里给纯 Python 版的不同侧重):
from typing import AsyncIterator
import httpx, json
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
async def stream_llm(prompt: str) -> AsyncIterator[str]:
"""async generator:边收边吐"""
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, read=None)) as client:
async with client.stream(
"POST", LLM_URL,
headers={"Authorization": f"Bearer {API_KEY}"},
json={"model": "gpt-4", "messages": [{"role": "user", "content": prompt}], "stream": True}
) as resp:
async for line in resp.aiter_lines():
if not line.startswith("data:"): continue
payload = line[5:].strip()
if payload == "[DONE]": break
try:
delta = json.loads(payload)["choices"][0]["delta"].get("content")
if delta: yield delta
except (json.JSONDecodeError, KeyError):
continue
# 也可作为 HTTP SSE 端点
from fastapi.responses import StreamingResponse
@app.get("/stream")
async def sse_endpoint(q: str):
async def event_source():
async for chunk in stream_llm(q):
yield f"data: {json.dumps({'chunk': chunk}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_source(), media_type="text/event-stream")
@app.websocket("/ws/chat")
async def ws_chat(ws: WebSocket):
await ws.accept()
try:
data = await ws.receive_json()
async for chunk in stream_llm(data["q"]):
await ws.send_json({"delta": chunk})
await ws.send_json({"done": True})
except WebSocketDisconnect:
pass # httpx context 自动关闭释放上游连接
追问应对:
- Q:FastAPI SSE 和 WebSocket 怎么选?
单向流式输出(LLM 答案)→ SSE 简单可靠、能走 HTTP/2、浏览器原生 EventSource;双向(聊天、协同)→ WebSocket。我们 LLM 默认走 WebSocket 是因为前端要发取消信号。
Q4:信号量 + 超时取消怎么写?
标准答案:
- 并发限制:
asyncio.Semaphore; - 超时:
asyncio.wait_for(3.10-) 或asyncio.timeout(3.11+); - 取消传播:捕获 CancelledError 后必须 re-raise,否则取消失效。
核心代码骨架:
import asyncio
class LLMPool:
def __init__(self, max_concurrency=20):
self.sem = asyncio.Semaphore(max_concurrency)
async def call(self, prompt: str, timeout: float = 30):
async with self.sem:
try:
# 3.11+ 推荐用 asyncio.timeout,更精确
async with asyncio.timeout(timeout):
return await self._do_call(prompt)
except TimeoutError:
log.warning("llm timeout: %s", prompt[:50])
raise
except asyncio.CancelledError:
log.info("llm cancelled by client")
# 清理资源
raise
async def _do_call(self, prompt):
async with httpx.AsyncClient() as client:
r = await client.post(LLM_URL, json={"prompt": prompt})
r.raise_for_status()
return r.json()
pool = LLMPool(max_concurrency=20)
追问应对:
- Q:信号量数怎么定?
压测出上游单 key 不限流的并发上限,留 20% 余量。线上发现限流频繁就调小或加 key。终极方案是用 token bucket 按 RPM/TPM 限流,比信号量更精确,对应
aiolimiter库。
Q5:Celery / RQ / Dramatiq 怎么选?
标准答案:
| 维度 | Celery | RQ | Dramatiq |
|---|---|---|---|
| 复杂度 | 高(功能全) | 低(基于 Redis) | 中 |
| 生态 | 最成熟 | 简单 | 现代 |
| 路由/优先级 | 强 | 弱 | 中 |
| 适合 | 大型项目 | 中小项目 | 喜欢简洁的中型项目 |
DeepPark 用 Celery:文档解析 / 向量入库异步化,Beat 调度定时清理任务。
核心代码骨架(Celery 实战):
# celery_app.py
from celery import Celery
celery_app = Celery(
"deeppark",
broker="redis://localhost:6379/0",
backend="redis://localhost:6379/1",
include=["tasks.doc", "tasks.embed"],
)
celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
timezone="Asia/Shanghai",
enable_utc=False,
task_acks_late=True, # 任务完成后再 ack,防止 worker 崩溃丢任务
task_reject_on_worker_lost=True,
worker_prefetch_multiplier=1, # 长任务防止单 worker 囤积
beat_schedule={
"cleanup-temp-files": {
"task": "tasks.doc.cleanup",
"schedule": 3600.0,
}
}
)
# tasks/doc.py
@celery_app.task(bind=True, max_retries=3, default_retry_delay=60)
def parse_document(self, doc_id: int):
try:
doc = db.fetch_doc(doc_id)
chunks = chunker.chunk(doc.content, meta={"doc_id": doc_id})
# 链式:解析完触发 embedding
embed_chunks.delay([c["text"] for c in chunks], doc_id)
except TransientError as e:
raise self.retry(exc=e)
# 调用
parse_document.delay(doc_id=42)
# 或链式调用
from celery import chain
chain(parse_document.s(42), notify_user.s()).apply_async()
追问应对:
- Q:Celery 任务幂等怎么保证?
任务 ID 用业务唯一键(如
doc:{id}:parse),开始时 Redis SETNX 占位,结束时 DEL。重复执行直接跳过。task_acks_late + 业务幂等两道防线。
Q6:FastAPI 项目目录怎么组织?
标准答案:
app/
api/v1/ # 路由
core/ # 配置、日志、安全
models/ # SQLAlchemy ORM
schemas/ # Pydantic 模型
services/ # 业务逻辑
repositories/ # 数据访问层
tasks/ # Celery 任务
deps.py # 依赖注入
main.py
tests/
alembic/ # 迁移
原则:路由薄、service 厚、repository 隔离 DB;schemas 和 models 严格分离(防把 ORM 漏到 API 层)。
核心代码骨架(分层示例):
# models/device.py (ORM)
from sqlalchemy.orm import Mapped, mapped_column
class Device(Base):
__tablename__ = "device"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
# schemas/device.py (Pydantic)
class DeviceCreate(BaseModel):
name: str
# repositories/device.py
class DeviceRepo:
def __init__(self, db: AsyncSession): self.db = db
async def create(self, name: str) -> Device:
d = Device(name=name)
self.db.add(d); await self.db.commit(); await self.db.refresh(d)
return d
async def get(self, id: int) -> Device | None:
return await self.db.get(Device, id)
# services/device.py
class DeviceService:
def __init__(self, repo: DeviceRepo, kafka: KafkaProducer):
self.repo, self.kafka = repo, kafka
async def create(self, body: DeviceCreate, owner_id: int) -> Device:
d = await self.repo.create(body.name)
await self.kafka.send("device.created", {"id": d.id, "owner": owner_id})
return d
# api/v1/device.py (薄路由)
@router.post("/devices", response_model=DeviceOut, status_code=201)
async def create(body: DeviceCreate,
svc: DeviceService = Depends(get_device_service),
user = Depends(current_user)):
return await svc.create(body, owner_id=user.id)
追问应对:
- Q:service 之间怎么调用?
跨 service 调用通过依赖注入传入。避免循环依赖(A 调 B、B 调 A)——出现循环说明业务边界划错了,提取公共部分到第三个 service 或者下沉到 repo 层。
二、Node.js / Nest.js
Q7:Express vs Nest 怎么选?
标准答案:
- Express:极简、自由、适合小型 API 或 Mock 服务;
- Nest:约定大于配置(模块化 + 依赖注入 + 装饰器),适合中大型项目和有 Spring/Angular 背景的团队;
- 配套:Nest 自带 Swagger、Validation、Pipe / Guard / Interceptor,企业级开箱即用。
核心代码骨架(Nest 完整 CRUD):
// device.module.ts
@Module({
imports: [TypeOrmModule.forFeature([Device])],
controllers: [DeviceController],
providers: [DeviceService],
})
export class DeviceModule {}
// device.dto.ts
export class CreateDeviceDto {
@IsString() @MinLength(1) @MaxLength(50)
name: string
@IsNumber() @Min(0) @Max(10000)
powerKw: number
}
// device.service.ts
@Injectable()
export class DeviceService {
constructor(
@InjectRepository(Device) private repo: Repository<Device>,
@Inject('KAFKA') private kafka: KafkaProducer,
) {}
async create(dto: CreateDeviceDto, userId: number) {
const device = await this.repo.save({ ...dto, ownerId: userId })
await this.kafka.send('device.created', { id: device.id })
return device
}
}
// device.controller.ts
@Controller('devices')
@UseGuards(JwtAuthGuard)
export class DeviceController {
constructor(private svc: DeviceService) {}
@Post()
@ApiResponse({ status: 201, type: DeviceOut })
create(@Body() dto: CreateDeviceDto, @CurrentUser() user: User) {
return this.svc.create(dto, user.id)
}
}
追问应对:
- Q:Nest 装饰器和 Spring 注解像在哪?不像在哪?
像:DI、模块化、声明式路由 / 校验。不像:Nest 基于 TS 装饰器(运行时元数据,依赖 reflect-metadata),编译时类型擦除后实际是字符串 token,没 Spring 那么强的类型保障;启动速度比 Spring 快很多(无类扫描)。
Q8:Node 后端常见的内存泄漏来源?
标准答案:
- 全局/模块级缓存无限增长 → 用 LRU;
- EventEmitter 不解绑 → 用
once或显式off; - 闭包持有大对象 → 注意作用域;
- 未关闭的 stream / connection;
- 大文件
fs.readFile一次性读 → 用 stream; - 监控:
node --inspect+ Chrome DevTools /clinic.js。
核心代码骨架:
import { LRUCache } from 'lru-cache'
// ❌ 无界 Map
const cache = new Map<string, BigData>()
// ✅ LRU 有上限 + TTL
const cache = new LRUCache<string, BigData>({
max: 1000,
ttl: 1000 * 60 * 30, // 30 分钟
updateAgeOnGet: false,
})
// ❌ 监听不解绑
class Worker {
start(emitter: EventEmitter) {
emitter.on('msg', this.handle)
}
handle(msg) { /* ... */ }
}
// ✅ 用 once 或 AbortController
class Worker {
controller = new AbortController()
start(emitter: EventEmitter) {
emitter.on('msg', this.handle, { signal: this.controller.signal })
}
stop() { this.controller.abort() }
}
// ❌ 大文件一次性读
const buf = await fs.readFile('huge.csv') // OOM
// ✅ 流式
import { createReadStream } from 'fs'
import { pipeline } from 'stream/promises'
import { parse } from 'csv-parse'
await pipeline(
createReadStream('huge.csv'),
parse({ columns: true }),
async function* (source) {
for await (const row of source) {
await processRow(row)
}
}
)
追问应对:
- Q:怎么排查到具体的泄漏代码?
Chrome DevTools 连 Node 拍两次 heap snapshot(间隔几分钟),看 Comparison 视图哪类对象增长最多,点进去看 retainers 链找到根。
clinic doctor自动诊断更友好但精度差一些。生产挂--heap-prof周期性导出 heap profile。
Q9:WebSocket 在 Node 里怎么做?
标准答案:
- 轻量:
ws库; - 全功能:
socket.io(自动 fallback、room、ack); - 多实例广播:Redis adapter(pub/sub);
- 心跳:定期 ping/pong,超时清理;
- 鉴权:握手阶段在 query 或 cookie 里带 token,后端校验后挂用户上下文。
核心代码骨架:
import { WebSocketServer, WebSocket } from 'ws'
import jwt from 'jsonwebtoken'
const wss = new WebSocketServer({ port: 8080 })
// 鉴权
wss.on('connection', (ws, req) => {
const token = new URL(req.url!, 'http://x').searchParams.get('token')
let user
try { user = jwt.verify(token!, process.env.JWT_SECRET!) }
catch { return ws.close(4401, 'unauthorized') }
;(ws as any).user = user
// 心跳
;(ws as any).isAlive = true
ws.on('pong', () => ((ws as any).isAlive = true))
ws.on('message', async (data) => {
const msg = JSON.parse(data.toString())
await handleMessage(ws, msg)
})
})
// 心跳清理
setInterval(() => {
wss.clients.forEach(ws => {
if (!(ws as any).isAlive) return ws.terminate()
;(ws as any).isAlive = false
ws.ping()
})
}, 30000)
// 多实例广播 (socket.io 版)
import { Server } from 'socket.io'
import { createAdapter } from '@socket.io/redis-adapter'
import { createClient } from 'redis'
const io = new Server(httpServer)
const pubClient = createClient({ url: 'redis://localhost' })
const subClient = pubClient.duplicate()
await Promise.all([pubClient.connect(), subClient.connect()])
io.adapter(createAdapter(pubClient, subClient))
// 任意实例 emit,所有实例的客户端都收到
io.to('room-1').emit('update', payload)
追问应对:
- Q:ws 库为啥不自带 reconnect?
ws 定位是底层,留给上层决策。socket.io 有自动重连 + 消息缓冲。我们的策略:浏览器端用
reconnecting-websocket包装 + 指数退避;带消息恢复的场景用 socket.io 或自实现序列号 + ack。
三、Rust(了解级别)
Q10:你为什么了解 Rust?
标准答案:
- 个人开源项目尝试用 Tokio + Axum 写过 API;
- 评估 Tauri 时学过 Rust 基础(所有权、生命周期、async trait);
- 看好 Rust 在系统级 / 性能敏感后端的未来(如向量数据库、Agent runtime)。
核心代码骨架(Axum 最小 API):
use axum::{routing::get, Json, Router, extract::State};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
#[derive(Clone)]
struct AppState { db: PgPool }
#[derive(Serialize, sqlx::FromRow)]
struct Device { id: i64, name: String }
async fn list_devices(State(state): State<Arc<AppState>>) -> Json<Vec<Device>> {
let rows = sqlx::query_as::<_, Device>("SELECT id, name FROM device")
.fetch_all(&state.db).await.unwrap();
Json(rows)
}
#[tokio::main]
async fn main() {
let db = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()).await.unwrap();
let state = Arc::new(AppState { db });
let app = Router::new()
.route("/devices", get(list_devices))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
追问应对:
- Q:所有权 / 借用大概怎么理解?
每个值有唯一 owner,owner 出作用域值就 drop;借用分
&T(不可变,多份共存)和&mut T(可变,独占);二者不能同时存在 → 编译期防 data race;生命周期标注'a告诉编译器引用的存活范围。
四、接口设计
Q11:RESTful / GraphQL / gRPC 怎么选?
标准答案:
| 维度 | REST | GraphQL | gRPC |
|---|---|---|---|
| 协议 | HTTP/JSON | HTTP/JSON | HTTP2/Protobuf |
| 类型契约 | OpenAPI | Schema | proto |
| 前端友好 | 高 | 极高 | 一般 |
| 性能 | 一般 | 一般 | 高 |
| 服务间通信 | OK | 较少 | 主流 |
| 适合 | 通用 API | 复杂前端聚合 | 微服务内部 |
核心代码骨架(gRPC 服务端):
// device.proto
syntax = "proto3";
package device.v1;
service DeviceService {
rpc Get(GetReq) returns (Device);
rpc Stream(StreamReq) returns (stream Device); // 服务端流
}
message GetReq { int64 id = 1; }
message Device { int64 id = 1; string name = 2; double power_kw = 3; }
# Python gRPC 服务端
import grpc
from concurrent import futures
from device_pb2 import Device
from device_pb2_grpc import DeviceServiceServicer, add_DeviceServiceServicer_to_server
class DeviceService(DeviceServiceServicer):
async def Get(self, request, context):
d = await db.fetch_device(request.id)
return Device(id=d.id, name=d.name, power_kw=d.power_kw)
async def Stream(self, request, context):
async for d in db.stream_devices():
if context.cancelled(): break
yield Device(id=d.id, name=d.name, power_kw=d.power_kw)
server = grpc.aio.server()
add_DeviceServiceServicer_to_server(DeviceService(), server)
server.add_insecure_port("[::]:50051")
追问应对:
- Q:BFF(Backend For Frontend)适合 GraphQL 吗?
非常适合。BFF 的核心痛点是"前端聚合多个微服务、字段精挑",正是 GraphQL 强项。Apollo / Yoga 都是好选择。但内部微服务通信仍用 gRPC 性能好。
Q12:接口版本怎么管?
标准答案:
- URI 版本:
/api/v1/xxx(最常见,直观); - Header 版本:
Accept: application/vnd.app.v1+json; - 兼容策略:新字段加 optional 不破坏旧客户端;删字段先标 deprecated → 一段时间后移除;
- 文档同步:OpenAPI 自动生成 changelog。
核心代码骨架(FastAPI 多版本路由):
from fastapi import FastAPI, APIRouter
app = FastAPI()
v1 = APIRouter(prefix="/api/v1", tags=["v1"])
v2 = APIRouter(prefix="/api/v2", tags=["v2"])
@v1.get("/devices/{id}")
async def get_device_v1(id: int):
d = await repo.get(id)
return {"id": d.id, "name": d.name}
@v2.get("/devices/{id}")
async def get_device_v2(id: int):
d = await repo.get(id)
# v2 增加更多字段 + 改了结构
return {
"id": d.id,
"name": d.name,
"metadata": {"created_at": d.created_at, "tags": d.tags}
}
app.include_router(v1)
app.include_router(v2)
追问应对:
- Q:v1 什么时候下线?
流程:① v2 上线时 v1 标 Deprecated(response header
Deprecation: true);② 监控 v1 流量 + 通知接入方迁移;③ 流量降到 < 1% 维持 30 天;④ 删除。一般周期 3-6 个月,看接入方协作意愿。
Q13:幂等性怎么保证?
标准答案:
- GET / PUT / DELETE 天然幂等,POST 需要业务保证;
- 方法:
- 客户端生成
Idempotency-Key(UUID),服务端用 Redis 记录"key → result",重试直接返回; - 业务唯一索引:如订单号唯一,重复插入触发约束;
- 状态机:操作前检查当前状态,已完成的不再执行。
- 客户端生成
核心代码骨架:
from fastapi import Header, HTTPException
import hashlib
async def idempotent(
request: Request,
idempotency_key: str | None = Header(None, alias="Idempotency-Key"),
):
if not idempotency_key:
return None # 不要求幂等
body = await request.body()
cache_key = f"idem:{idempotency_key}:{hashlib.md5(body).hexdigest()}"
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
return cache_key # 业务处理后用这个 key 写入
@app.post("/orders")
async def create_order(body: OrderCreate, request: Request,
cache_key = Depends(idempotent)):
if isinstance(cache_key, dict):
return cache_key # 已处理过直接返回
result = await order_service.create(body)
if cache_key:
await redis.setex(cache_key, 86400, json.dumps(result))
return result
追问应对:
- Q:相同 Idempotency-Key 但 body 不同怎么办?
视为攻击或客户端 bug。我们把 body hash 进 cache key,body 变了 cache 不命中走新逻辑,但同时记录告警。严格做法是拒绝 + 返回 422。
五、安全
Q14:API 安全你关注哪些?
标准答案:
- 鉴权:JWT / Session,refresh token 机制;
- 授权:RBAC / ABAC,按资源 + 操作粒度;
- 输入校验:Pydantic / class-validator,永不信前端;
- SQL 注入:用参数化查询 / ORM;
- XSS:响应数据转义、CSP;
- CSRF:token + SameSite cookie;
- 限流:用户级 / IP 级 / 路由级;
- 审计日志:写操作落日志,可追溯;
- HTTPS:强制 TLS 1.2+。
核心代码骨架(RBAC + 限流):
from functools import wraps
from fastapi import HTTPException, Request
def require_perms(*perms: str):
def deco(fn):
@wraps(fn)
async def wrapper(*args, current_user, **kwargs):
user_perms = set(current_user.permissions)
if not all(p in user_perms for p in perms):
raise HTTPException(403, f"missing: {perms}")
return await fn(*args, current_user=current_user, **kwargs)
return wrapper
return deco
@app.post("/devices/{id}/control")
@require_perms("device:control")
async def control(id: int, body: ControlBody, current_user = Depends(get_user)):
return await device_service.control(id, body, op=current_user.id)
# 限流:用 slowapi
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=lambda req: get_user_id_or_ip(req))
app.state.limiter = limiter
@app.post("/chat")
@limiter.limit("30/minute")
async def chat(request: Request, body: ChatBody):
return await chat_service.handle(body)
追问应对:
- Q:JWT 怎么主动失效?
JWT 本质无状态,主动失效要引入黑名单。Redis 维护"已撤销 jti"列表,每次校验查一次。或者用 access + refresh 模式,access 短期(15min)自然失效,refresh 撤销时 Redis 拉黑。
Q15:JWT 你怎么用?刷新怎么做?
标准答案:
- access token 短期(15min),refresh token 长期(7d);
- access 放内存 / Authorization header,refresh 放 HttpOnly cookie;
- access 过期 → 前端用 refresh 换新 access;
- refresh 一次性使用(rotation),换出新对,旧的拉黑;
- 登出:把 refresh 拉黑(Redis 黑名单)+ 清前端存储。
核心代码骨架:
import jwt
from datetime import datetime, timedelta, timezone
from uuid import uuid4
SECRET = "..."
ACCESS_TTL = timedelta(minutes=15)
REFRESH_TTL = timedelta(days=7)
def issue_tokens(user_id: int):
now = datetime.now(timezone.utc)
jti = str(uuid4())
access = jwt.encode({
"sub": str(user_id), "type": "access", "exp": now + ACCESS_TTL
}, SECRET, algorithm="HS256")
refresh = jwt.encode({
"sub": str(user_id), "type": "refresh", "jti": jti, "exp": now + REFRESH_TTL
}, SECRET, algorithm="HS256")
# 记录有效 refresh jti
redis.setex(f"refresh:{jti}", REFRESH_TTL, "1")
return access, refresh
@app.post("/auth/refresh")
async def refresh_token(refresh: str = Cookie(...)):
try:
payload = jwt.decode(refresh, SECRET, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise HTTPException(401, "refresh expired")
if payload["type"] != "refresh":
raise HTTPException(401, "bad type")
# 检查是否被撤销 / 是否已用过(rotation)
if not await redis.get(f"refresh:{payload['jti']}"):
# refresh 被复用 → 视为攻击,撤销整个用户所有 refresh
await redis.delete(f"refresh:{payload['jti']}")
await revoke_all_user_refresh(payload["sub"])
raise HTTPException(401, "refresh reused, all sessions revoked")
await redis.delete(f"refresh:{payload['jti']}") # 旧的作废
new_access, new_refresh = issue_tokens(int(payload["sub"]))
response = JSONResponse({"access": new_access})
response.set_cookie("refresh", new_refresh, httponly=True, secure=True, samesite="strict")
return response
追问应对:
- Q:为什么 refresh token 要 rotation?
防止 refresh 泄露后长期被滥用。每次刷新都换新的,旧的立即失效。如果同一个旧 refresh 被用第二次,说明可能被盗,立刻撤销该用户所有 session 强制重登。
六、可能被追问
-
Q:你说 DeepPark 信号量限并发,怎么定的 20?
上线前压测出 LLM 厂商单 key 的稳定并发上限大概 25 不限流,留 20% 余量取 20。后期切多 key 后改用 token bucket,按 key 分流。
-
Q:FastAPI WebSocket 怎么做横向扩展?
单实例只能撑几千连接。横向扩展用 Redis pub/sub 做实例间消息广播,sticky session(按用户 hash)让同一用户固定到同一实例;如果实例数多,引入 Centrifugo / Soketi 这类专门的实时消息层。
-
Q:你为什么从 Express 转向 FastAPI?
主要是 AI 项目 Python 生态更全(LangChain / 模型库 / 向量库都首选 Python)。Express / Nest 我做通用业务 API 时还在用。语言选择跟着业务走。