实战项目:会员管理系统(商业级标准)
🎯 项目定位:用 商业项目的工程标准 实现一个会员管理后台(单体架构),把 FastAPI 业务架构详解 的分层真正落地。
"商业标准"≠ 多租户/微服务,而是:数据库有迁移、API 有版本、操作有审计、并发有保护、测试有覆盖、部署有 CI/CD。这些是学习 demo 和能上生产系统的真正差距,也是面试时最能体现工程素养的部分。
1. 学习版 vs 商业版:差距清单
先看清我们要补什么(✅ = 本项目实现):
| 维度 | 学习 demo | 商业标准 | 本项目 |
|---|---|---|---|
| 表结构变更 | 手动删库重建 | Alembic 迁移脚本,可升级可回滚 | ✅ |
| API 契约 | 想到就改 | 版本化 /api/v1 + OpenAPI 规范 + 统一响应信封 | ✅ |
| 并发安全 | 没想过 | 事务 + 行锁防超卖/重复扣款 | ✅ |
| 操作审计 | 无 | 审计日志表:谁在什么时间改了什么 | ✅ |
| 认证 | 一个 access token | 双 token(access 15min + refresh 7d)+ 注销黑名单 | ✅ |
| 配置管理 | .env 塞所有 | 分环境配置 + secrets 不进 git + 启动时校验 | ✅ |
| 日志 | 结构化日志 + request_id 链路追踪 | ✅ | |
| 测试 | 想到才测 | 测试金字塔 + coverage ≥ 80% 核心业务 | ✅ |
| CI/CD | 本地跑跑 | GitHub Actions:lint → type check → test → Docker 构建 | ✅ |
| 部署 | 裸跑 uvicorn | 多阶段 Docker 构建 + 环境分离 | ✅ |
| 代码规范 | 随手写 | ruff + mypy + pre-commit 强制执行 | ✅ |
| 多租户 | — | 租户隔离、订阅计费 | 演进路线(见 §9) |
2. 功能范围(单租户形态)
| 模块 | 功能 | 商业级要求点 |
|---|---|---|
| 认证授权 | 登录、刷新 token、注销、RBAC(superadmin/operator/viewer) | 双 token、bcrypt、登录失败限流 |
| 会员管理 | CRUD、搜索分页、冻结/注销 | 软删除、注销保留期、手机号唯一 |
| 套餐管理 | 套餐 CRUD、上下架 | 价格用分存储(整数),禁用 float |
| 订单/会员卡 | 购卡、续卡、退卡、流水 | 幂等键、事务+行锁、金额不可变 |
| 到期提醒 | 查询 N 天内到期会员 | 为定时任务/Agent 预留接口 |
| 审计日志 | 所有写操作自动记录 | 中间件级自动采集,业务代码零侵入 |
不做:多租户、前端页面(Swagger UI 即界面)、真实支付、消息通知。
3. 数据库设计(商业版差异)
3.1 ER 图
erDiagram
ADMIN ||--o{ ORDER : operates
ADMIN ||--o{ AUDIT_LOG : "writes 写入"
MEMBER ||--o{ ORDER : has
PLAN ||--o{ ORDER : referenced_by
ORDER ||--o| REFUND : "may_have 退款"
MEMBER ||--o| MEMBER_CARD : holds
ADMIN {
int id PK
string username UK
string password_hash "bcrypt"
string role "superadmin/operator/viewer"
bool is_active "禁用后token立即失效"
datetime created_at
datetime last_login_at
}
MEMBER {
int id PK
string name
string phone UK "唯一索引"
string status "active/frozen/deleted"
datetime deleted_at "软删除时间"
datetime created_at
}
PLAN {
int id PK
string name
int duration_days
int price_cents "★价格用分!整数"
string description
bool is_active
datetime created_at
}
ORDER {
int id PK
string order_no UK "业务单号 O+雪花/时间戳"
string idempotency_key UK "★幂等键"
int member_id FK
int plan_id FK
int admin_id FK
int amount_cents "实付(分)"
string type "purchase/renew/refund"
datetime created_at
}
MEMBER_CARD {
int id PK
int member_id FK "UK 一人一卡"
datetime expire_at
int version "★乐观锁版本号"
datetime updated_at
}
AUDIT_LOG {
int id PK
int admin_id FK
string action "member.update / order.create..."
string resource_type
int resource_id
jsonb changes "before/after 快照"
string request_id "★链路追踪ID"
string ip
datetime created_at
}
3.2 商业级设计决策(每条都是面试题)
| 决策 | 学习版做法 | 商业版做法 | 为什么 |
|---|---|---|---|
| 金额 | float price | int price_cents(分) | float 精度丢失:0.1+0.2≠0.3,财务对账会出人命。TS 里你见过 0.1+0.2===0.30000000000000004 |
| 表结构变更 | 直接改 db.py + 删库 | Alembic 迁移链,每个变更可回滚 | 生产库不能删,必须 alembic upgrade head 增量演进 |
| 并发购卡 | 无保护 | 事务 + SELECT ... FOR UPDATE 行锁(或 version 乐观锁) | 两个操作员同时给同一会员续卡,到期时间会互相覆盖 |
| 重复提交 | 无保护 | 客户端传 Idempotency-Key 头,服务端唯一约束 | 网络超时重试 → 重复扣款,是真实商业事故 Top3 |
| 业务单号 | 数据库自增 id 对外暴露 | order_no 独立业务单号(日期+序列) | 自增 id 泄露经营数据(竞对遍历你的订单量) |
| 审计 | 无 | audit_log 表 + changes JSON 存 before/after | 商业系统必须回答"这数据谁改的" |
| 时间 | datetime.now() 本地时间 | 全库 UTC,展示层转时区 | 服务器时区不同导致订单时间错乱 |
| 约束下沉 | 只靠 Pydantic 校验 | DB 层加 唯一索引/外键/CHECK 约束 | 应用层校验可被绕过(并发窗口、其他写入方) |
3.3 Alembic 迁移工作流
# 初始化(一次性)
.venv\Scripts\python -m alembic init alembic
# 模型变更后的标准流程
.venv\Scripts\python -m alembic revision --autogenerate -m "add audit_log table"
.venv\Scripts\python -m alembic upgrade head # 应用
.venv\Scripts\python -m alembic downgrade -1 # 回滚一步(演练过才算会)
💡 商业铁律
models/db.py 的每次修改,必须配套一个迁移脚本才能合并。直接改生产库结构 = 事故。
4. API 设计规范
4.1 版本化 + 统一响应信封
所有路由挂 /api/v1。商业接口永远留版本逃生门:契约变了就发 /api/v2,老客户端不炸。
统一响应信封(相当于 TS 的 Result<T> 包装):
// 成功
{ "code": 0, "message": "ok", "data": { "items": [...], "total": 42 } }
// 失败 —— code 是业务错误码,不是 HTTP 状态码
{ "code": "ORDER_MEMBER_FROZEN", "message": "会员已冻结,无法购卡", "request_id": "req-8f3a" }
4.2 错误码表(docs/error-codes.md 与代码同源)
| 错误码 | HTTP | 含义 |
|---|---|---|
AUTH_INVALID_CREDENTIALS | 401 | 用户名或密码错误(不区分哪个错,防撞库) |
AUTH_TOKEN_EXPIRED | 401 | access token 过期,用 refresh 换新 |
AUTH_FORBIDDEN | 403 | 角色权限不足 |
MEMBER_PHONE_DUP | 409 | 手机号已注册 |
MEMBER_INVALID | 400 | 会员不存在/已冻结/已注销 |
ORDER_IDEMPOTENT_REPLAY | 200 | 幂等重放,返回原订单(不是错误!) |
ORDER_CARD_CONFLICT | 409 | 并发冲突,请重试 |
4.3 幂等设计(商业系统核心课)
# app/services/order_service.py — 幂等 + 行锁,本项目最值钱的 20 行
from sqlalchemy import select
from app.core.exceptions import BusinessError
async def purchase(self, req: OrderCreate, idempotency_key: str | None) -> OrderOut:
async with self.db.begin(): # 1. 开启事务
# 2. 幂等检查:同 key 直接返回原订单(重试安全)
if idempotency_key:
existing = await self.orders.get_by_idem_key(idempotency_key)
if existing:
return existing # ORDER_IDEMPOTENT_REPLAY
# 3. 行锁:锁定会员卡行,杜绝并发续卡互相覆盖
card = await self.db.execute(
select(MemberCard).where(MemberCard.member_id == req.member_id)
.with_for_update() # ★ SELECT ... FOR UPDATE
)
...
# 4. 事务提交时全部生效;中途任何一步抛错 → 全部回滚
💡 TS 对照
你在大厂前端做过防重复点击(按钮 loading + disable)——但那只能防手抖,防不了网络重试和并发。商业系统的幂等必须在服务端做,前端 loading 只是体验优化。
4.4 审计日志:零侵入采集
# app/core/audit.py — 中间件级审计,业务代码不写一行审计代码
from app.dao.audit_dao import AuditDao
from app.core.context import current_admin, current_request_id
class AuditMiddleware(BaseHTTPMiddleware):
WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
async def dispatch(self, request, call_next):
response = await call_next(request)
if request.method in self.WRITE_METHODS and response.status_code < 400:
admin = current_admin.get(None)
if admin:
await AuditDao.log(
admin_id=admin.id,
action=f"{request.path_params}.{request.method}",
request_id=current_request_id.get(), # 关联日志链路
ip=request.client.host,
changes=getattr(request.state, "audit_changes", None),
)
return response
💡 配合
request_id中间件:每个请求生成唯一 ID,塞进所有日志和审计记录。线上排查问题就是靠它把散落的日志串成一条线(相当于前端的 traceId)。
5. 认证:双 Token + 注销黑名单
学习版一个 access token 用 7 天 = 泄露了就完蛋。商业标准:
| Token | 有效期 | 存哪 | 用途 |
|---|---|---|---|
| access | 15 分钟 | 内存(前端) | 调业务接口 |
| refresh | 7 天 | HttpOnly Cookie / 安全存储 | 只干一件事:换新 access |
# app/routes/auth.py
@router.post("/refresh")
async def refresh(refresh_token: str):
payload = decode_token(refresh_token)
if await is_blacklisted(refresh_token): # 已注销的 refresh 不可再用
raise BusinessError("AUTH_TOKEN_EXPIRED", http_status=401)
return {"access_token": create_access_token(int(payload["sub"]), payload["role"])}
@router.post("/logout")
async def logout(refresh_token: str, admin = Depends(get_current_admin)):
await add_to_blacklist(refresh_token, ttl=7 * 24 * 3600) # 剩余有效期
return {"code": 0, "message": "ok"}
安全清单(上线前逐项自查):
- [ ] 密码 bcrypt(cost ≥ 12),登录错误信息不区分用户名/密码
- [ ] 登录接口限流:同 IP 5 次/分钟(slowapi 库)
- [ ] JWT secret ≥ 32 位随机串,来自环境变量,绝不进 git
- [ ] 管理员禁用(
is_active=False)后,已签发 access token 立即失效(依赖里查库校验) - [ ] 全站 HTTPS;CORS 白名单精确到 origin,不用
* - [ ] Swagger 文档在生产环境关闭(
docs_url=None)
6. 工程化基建
6.1 目录结构(商业版增量用 ★ 标注)
member-system/
├── app/
│ ├── main.py # 入口组装
│ ├── config.py # ★ 分环境配置 + 启动时必填项校验
│ ├── models/
│ │ ├── schemas.py
│ │ └── db.py
│ ├── routes/ # /api/v1/* 薄路由层
│ ├── services/ # 业务规则(幂等、行锁、退卡规则)
│ ├── dao/ # 数据访问
│ ├── core/
│ │ ├── security.py # 双 token、bcrypt
│ │ ├── exceptions.py # BusinessError + 错误码表
│ │ ├── audit.py # ★ 审计中间件
│ │ ├── context.py # ★ request_id / current_admin 上下文
│ │ └── logging.py # ★ 结构化日志(JSON 格式)
│ └── utils/
├── alembic/ # ★ 数据库迁移
│ └── versions/ # 迁移脚本链
├── tests/
│ ├── unit/ # ★ 单元测试(services,Mock dao,快)
│ ├── integration/ # ★ 集成测试(真实 SQLite,走 HTTP 层)
│ └── conftest.py # fixture:测试库、登录态
├── scripts/
│ └── init_superadmin.py # 初始化超管(密码走环境变量)
├── deploy/
│ ├── Dockerfile # ★ 多阶段构建
│ └── docker-compose.yml # ★ 本地一键起 app + 库
├── .github/workflows/ci.yml # ★ CI 流水线
├── .pre-commit-config.yaml # ★ 提交前钩子
├── docs/
│ └── error-codes.md # 错误码表(与代码同源)
├── pyproject.toml # ★ 含 dev 依赖组:pytest/ruff/mypy/coverage
└── .env.example # 环境变量模板(.env 本身 gitignore)
6.2 代码规范强制执行
# .pre-commit-config.yaml — git commit 时自动跑,不合规提交不上去
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.0
hooks:
- id: ruff # lint + import 排序
- id: ruff-format # 格式化(相当于 prettier)
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy # 静态类型检查(相当于 tsc --noEmit)
💡 TS 对照:
ruff ≈ eslint + prettier,mypy ≈ typescript 编译检查,pre-commit ≈ husky + lint-staged。Python 圈 2024 年后的事实标准就是 ruff(一个工具顶过去十几个)。
6.3 配置管理:启动即校验
# app/config.py — 缺配置直接启动失败,而不是跑到一半才炸
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
env: Literal["dev", "staging", "prod"]
jwt_secret: str
jwt_expire_hours: int = 15 / 60 + 1 # prod 用分钟级
database_url: str = "sqlite+aiosqlite:///./data/member.db"
docs_enabled: bool = True # prod 里覆盖为 False
@model_validator(mode="after")
def check_prod(self):
if self.env == "prod":
assert len(self.jwt_secret) >= 32, "prod 环境 jwt_secret 必须 ≥32 位"
return self
settings = Settings() # env=prod 且配置缺失 → 进程启动即报错退出
6.4 CI/CD(GitHub Actions)
# .github/workflows/ci.yml — 每次 push 自动执行
name: CI
on: [push, pull_request]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -e ".[dev]"
- run: ruff check app tests # ① lint
- run: mypy app # ② 类型检查
- run: pytest --cov=app --cov-fail-under=80 # ③ 测试+覆盖率门槛
docker:
needs: quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -f deploy/Dockerfile -t member-system:${{ github.sha }} .
6.5 测试金字塔
| 层级 | 测什么 | 依赖 | 数量目标 |
|---|---|---|---|
| 单元(多) | services 业务规则:幂等重放、冻结会员拒购、退卡折算 | Mock 全部 dao | 核心规则 100% 覆盖 |
| 集成(中) | 路由 → service → 真库:401/403/409 状态码、分页契约 | 真实 SQLite + TestClient | 每个接口正反用例 |
| 并发(少而精) | 两协程同时购同一会员卡 → 断言无覆盖 | 真 SQLite + asyncio.gather | 1-2 个关键用例 |
# tests/integration/test_concurrent_purchase.py — 并发测试,商业级必须有
async def test_concurrent_renew_no_overwrite(client, member_with_card):
async def renew(key):
return await client.post("/api/v1/orders",
json={"member_id": 1, "plan_id": 1},
headers={"Authorization": "...", "Idempotency-Key": key})
r1, r2 = await asyncio.gather(renew("k1"), renew("k2"))
card = await get_card(1)
# 两次续费都成功,到期时间 = 原到期 + 2 × 时长(而不是互相覆盖)
assert card.expire_at == expected_base + timedelta(days=plan_days * 2)
6.6 部署:多阶段 Docker
# deploy/Dockerfile — 多阶段:builder 装依赖,runtime 只带产物(镜像小 60%+)
FROM python:3.12-slim AS builder
WORKDIR /build
COPY pyproject.toml ./
RUN pip install --prefix=/install .
FROM python:3.12-slim
WORKDIR /app
COPY /install /usr/local
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini .
RUN useradd -r appuser && chown -R appuser /app # ★ 不用 root 跑容器
USER appuser
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
💡 注意 CMD:先跑迁移再起服务——商业部署的标准动作,保证库结构永远和代码版本匹配。
7. 分阶段开发计划(商业版,建议 3-4 周)
| 阶段 | 内容 | 验收标准(不达标不进下一阶段) |
|---|---|---|
| P1 地基 | 目录骨架、ruff/mypy/pre-commit 跑通、config 分环境、Alembic 初始迁移、init_superadmin 脚本 | pre-commit run --all-files 全绿;空库 → alembic upgrade head 建表成功 |
| P2 认证 | 双 token、注销黑名单、RBAC、登录限流 | access 过期后 refresh 可换新;viewer 删会员 → 403 |
| P3 会员+套餐 | CRUD、分页搜索、软删除、错误码表、审计中间件 | 任何写操作在 audit_log 有记录(含 request_id) |
| P4 订单(难点) | 幂等键、事务+行锁、退卡折算、并发测试 | 并发双购卡测试通过;重放同 key 返回原订单不重复扣 |
| P5 质量收尾 | coverage ≥ 80%、CI 流水线、Dockerfile、README | GitHub Actions 全绿;docker compose up 一键可跑 |
每个阶段 = 一个 PR + conventional commit(feat(auth): add refresh token rotation),给自己做 code review 的习惯从现在养起。
8. 验收清单(商业级)
架构
- [ ] routes 无业务、services 无 SQL、dao 无 HTTP 概念
- [ ] 表结构变更全部走 Alembic,可 upgrade 也可 downgrade
API
- [ ] 所有接口挂
/api/v1,统一响应信封 + 错误码表 - [ ] 购卡接口支持 Idempotency-Key,重放返回原订单
安全
- [ ] 双 token + 注销黑名单;管理员禁用后 token 即刻失效
- [ ] bcrypt + 登录限流 + prod 关 Swagger + secret 不进 git
- [ ] 审计日志记录所有写操作(谁、何时、改了什么、request_id)
质量
- [ ] ruff / mypy / pre-commit 全绿
- [ ] 核心业务单测 + 接口集成测试 + 并发测试,coverage ≥ 80%
- [ ] GitHub Actions:lint → mypy → pytest(带门槛) → docker build 全通过
部署
- [ ] 多阶段 Docker 构建,非 root 运行,启动自动跑迁移
- [ ] dev / prod 环境分离,配置启动时校验
9. 演进路线(商业标准之上:SaaS 化预演)
按商业标准建好地基后,SaaS 化是增量重构而非推倒重来:
| 步骤 | 改动 | 依赖的地基 |
|---|---|---|
① 租户表 + 全表加 tenant_id | Alembic 迁移 + dao 层自动注入租户过滤 | 迁移链(P1) |
② JWT 加 tenant_id,依赖统一校验归属 | 只改 deps.py + security.py | 依赖注入链(P2) |
③ 套餐→订阅:SUBSCRIPTION 表 + 计费周期任务 | 纯增量,动 services 层 | 错误码/幂等(P4) |
| ④ 支付网关接入 | 新增 services/payment/,订单状态机 | 金额用分、流水不可变(P4) |
| ⑤ SQLite → PostgreSQL | 只改连接串 + 行锁语义验证 | dao 隔离(P1) |
💡 和 Month 3 的呼应:Agent 的 Tool 直接复用本项目的
OrderService(带幂等和行锁的版本)。商业级的地基让 Agent 调用下单时天然防重复扣款——这就是"按商业标准学习"复利最大的一笔投资。