FitMall-运动优选项目博客
FitMall 运动优选:一个完整健身电商平台的技术全解析
从零搭建的全栈健身电商平台,涵盖商品交易、AI 智能助手、社区互动、训练管理四大核心模块。本文将拆解其技术选型、架构设计、关键实现细节与工程经验。
目录
1.项目概览
FitMall 是一个面向运动健身人群的全栈 Web 应用,承载了电商交易、课程学习、社区互动、训练管理、AI 智能助手五大业务线。项目采用前后端分离架构,通过 Docker Compose 一键部署。
核心数据流:
浏览器 → Nginx (80) → Vue 3 SPA
→ /api/* → FastAPI (8000) → MySQL / Redis / ES / RabbitMQ
→ Dify AI (云端)
2.技术栈一览
后端
| 组件 | 技术 | 说明 |
|---|---|---|
| Web 框架 | FastAPI 0.138 + Uvicorn | 全异步,支持 SSE 流式响应 |
| 数据库 | MySQL 8.0 + SQLAlchemy 2.0 | 异步引擎 + aiomysql 驱动 |
| 缓存 / 会话 | Redis 7 | 会话存储、验证码、限流、分布式锁 |
| 搜索引擎 | Elasticsearch 8.12 + IK 分词器 | 商品/课程/帖子/用户四个索引 |
| 消息队列 | RabbitMQ 3.13 | ES 异步同步、订单超时、训练任务 |
| 认证 | JWT + HTTP-Only Cookie | 双通道鉴权,Redis Session 存储 |
| 支付 | 支付宝沙箱(RSA2-SHA256) | 自签名实现,不依赖官方 SDK |
| 存储 | 阿里云 OSS | 带重试和预签名 URL 降级 |
| 短信 | 阿里云 Dypnsapi | Redis 存验证码,5 分钟过期 |
| 配置管理 | Pydantic Settings | 40+ 环境变量,dev / prod 分离 |
| ID 生成 | 自研 Snowflake | 41 位毫秒时间戳 + 10 位机器 + 12 位序列 |
2.1 前端
| 组件 | 技术 | 说明 |
|---|---|---|
| 框架 | Vue 3.5 + Composition API + TypeScript | <script setup> 语法 |
| 构建 | Vite 8 | 秒级热更新 |
| 状态管理 | Pinia 3 | user / admin / cart 三个 Store |
| 路由 | Vue Router 4 | 嵌套路由 + 权限守卫 |
| 样式 | TailwindCSS 3 | 自定义 “Iron & Chalk” 设计系统 |
| HTTP | Axios + 拦截器 | Cookie 自动携带 + 并发刷新拦截 |
| 图表 | Chart.js | 体重趋势 / 训练统计 |
| WebSocket | 原生 WebSocket + 自封装 Composable | 实时聊天 + 在线状态 |
2.2 基础设施
| 组件 | 技术 | 说明 |
|---|---|---|
| 容器化 | Docker Compose | 6 个服务一键编排 |
| Web 服务器 | Nginx | 静态文件 + API 反向代理 |
| 内网穿透 | cpolar | 支付宝回调 / 开发调试 |
| 镜像加速 | 阿里云镜像 | apt / pip / npm 三端加速 |
3.架构设计
3.1 领域驱动模块划分
后端采用按业务域垂直拆分的模块化结构,每个模块内部遵循统一的四层范式:
module/
├── models.py # SQLAlchemy 数据模型(与数据库表一一对应)
├── schemas.py # Pydantic 请求/响应 Schema(API 契约)
├── router.py # FastAPI 路由定义(薄层,纯路由注册)
└── service.py # 业务逻辑(核心,所有复杂逻辑在这里)
全局共 12 个业务域:
auth goods course training community cart order ai admin user shared middleware
每个域独立演进,通过 shared/ 层的公共组件(数据库会话、Redis 客户端、ES 客户端、统一响应格式)进行横向连接。
3.2 请求生命周期
HTTP 请求
→ CORS 中间件(跨域白名单校验)
→ Request-ID 中间件(注入 UUID 追踪链路)
→ 日志中间件(记录 method/path/status/duration)
→ 路由匹配
→ 依赖注入(DB Session / Redis / Auth User)
→ Service 层(业务逻辑 + 外部调用)
→ 统一响应格式封装 → {"code": 1, "data": {...}, "message": "success"}
→ 异常处理器兜底(6 种自定义异常 → HTTP 状态码映射)
4.那些有意思的技术细节
4.1 HTTP-Only Cookie 会话认证:用 Redis 替代 JWT
这是整个项目最具辨识度的架构决策。
传统 JWT 方案的痛点:Token 存在 localStorage 中,任何注入的 XSS 脚本都能轻松窃取。一旦 Token 泄露,在过期前无法主动撤销。
FitMall 的方案:
登录 → 生成 Snowflake Session Token
→ 将用户信息存入 Redis(key: session:{token}, TTL: 24h)
→ 将 Token 写入 HTTP-Only Cookie(前端 JS 不可读)
→ 浏览器每次请求自动携带 Cookie
→ 后端解析 Cookie → Redis 查找 → 注入当前用户到请求上下文
为什么比 JWT 更安全?
- 防 XSS:HTTP-Only Cookie 对 JavaScript 完全不可见,即使页面注入恶意脚本也无法窃取 Token
- 即时失效:删除 Redis 中的 Session Key 即可让任意 Token 立即失效,无需等到过期
- 用户/管理端隔离:两套完全独立的 Cookie 名(
fitmall_sessionvsfitmall_admin_session)和 Redis Key 前缀,杜绝越权 - 自动续期:每次活跃请求自动刷新 Redis TTL,只有真正离线的用户才会过期
同时保留了 JWT 作为 WebSocket 的鉴权通道(浏览器 WebSocket API 无法自定义 Cookie Header),实现双通道鉴权。
# dependencies.py — 双通道鉴权的核心实现
async def get_current_user(
request: Request,
db: AsyncSession = Depends(get_db),
) -> dict:
# 通道 1:Cookie Session(主通道)
token = request.cookies.get(settings.SESSION_COOKIE_NAME)
if token:
user_data = await redis_client.get(f"session:{token}")
if user_data:
await redis_client.expire(f"session:{token}", settings.SESSION_EXPIRE_HOURS * 3600)
return json.loads(user_data)
# 通道 2:Authorization Bearer(WebSocket 等场景)
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
return verify_jwt_token(auth_header[7:])
raise UnauthorizedException("请先登录")
4.2 手搓 Snowflake 分布式 ID 生成器
不满足于 pip install snowflake-id,项目从零实现了一个符合 Twitter Snowflake 规范的 ID 生成器。
64 位结构:
┌─┬──────────────────────────┬──────────────┬──────────────┐
│0│ 41-bit 时间戳 (ms) │ 10-bit 机器码 │ 12-bit 序列号 │
└─┴──────────────────────────┴──────────────┴──────────────┘
↑ ↑ ↑
保留位 数据中心(5)+节点(5) 4096/ms
实现亮点:
- 自定义纪元
2024-01-01,而非 Twitter 的2010-11-04,使 ID 可用到 2094 年 - 时钟回拨容错:最多容忍 5ms 的时钟回拨,超时则抛异常
- 线程安全:
threading.Lock保护序列号递增 - 双重输出:
generate_id()返回整数(存数据库),generate_id_str()返回 Hex 字符串(用于 URL)
所有 Session Token、商品 SN 编号、订单号都通过 Snowflake 生成,保证全局唯一且趋势递增。
4.3 双通道 ES 同步:直写 + 消息队列兜底
当管理员创建/修改一个商品时,搜索索引如何保持最新?
方案:双通道写入
┌─ 通道 1: 直接写入 ES(用户立即搜到)
CRUD 操作 ──────────┤
└─ 通道 2: 发布 RabbitMQ 消息 → 消费端异步写入
↑
兜底:直写失败时由消息队列重试
为什么不做纯异步?
如果管理员刚上架一个商品就去前台搜索,纯异步方案可能因为消息延迟导致搜不到。直写方案用户立即可见,消息队列作为 Eventual Consistency 的保障层:即使 ES 临时不可用,消息也会被持久化并在 ES 恢复后重放。
消费端采用指数退避重试(5s → 10s → 20s → 40s → 60s),最多重试 5 次。
# es_sync_tasks.py — 消费端重试逻辑
retry_count = 0
while retry_count <= MAX_RETRIES:
try:
if action == "index":
await index_document(index_name, str(doc_id), body)
elif action == "delete":
await delete_document(index_name, str(doc_id))
break
except Exception as e:
retry_count += 1
if retry_count > MAX_RETRIES:
logger.error("ES 同步最终失败")
break
delay = min(INITIAL_RETRY_DELAY * (2 ** (retry_count - 1)), MAX_RETRY_DELAY)
await asyncio.sleep(delay)
4.4 IK 分词器的双分析器策略
Elasticsearch 默认分词器对中文很不友好(逐字拆分)。项目使用了 IK 中文分词器,并采用了索引与搜索分析器分离的策略:
{
"analysis": {
"analyzer": {
"ik_index_analyzer": { "tokenizer": "ik_max_word" }, // 索引用:细粒度,高召回
"ik_search_analyzer": { "tokenizer": "ik_smart" } // 搜索用:粗粒度,高精度
}
}
}
ik_max_word:将文本切分为尽可能多的词条,索引阶段用,保证"增肌蛋白粉"能被"蛋白粉"、“增肌”、“蛋白”、"粉"等任意组合命中ik_smart:做最粗粒度的语义切分,搜索阶段用,避免"苹果手机"被拆成"苹果"+"手机"后匹配到水果
效果对比:
| 搜索词 | ik_max_word 搜索 | ik_smart 搜索 |
|---|---|---|
| “蛋白粉” | 返回蛋白粉 + 含有"蛋白"或"粉"的商品 | 只返回真正匹配"蛋白粉"的商品 |
| “瑜伽垫” | 返回瑜伽垫 + 瑜伽相关商品 | 精确匹配瑜伽垫 |
此外,ES 兼容性处理值得一提:elasticsearch-py 8.19+ 默认发送 compatible-with=9 头,而服务器 ES 8.14 只接受 7 或 8,通过 monkey-patch 固定为 compatible-with=8 解决。
4.5 AI 三端架构与优雅降级
项目集成了 Dify 平台的三个独立 AI 应用:
| 应用 | API 类型 | 降级策略 |
|---|---|---|
| AI 对话助手 | Chat Streaming (SSE) | ❌ 无降级(正在添加) |
| 训练计划生成 | Workflow (Blocking) | ✅ 模板计划(4 种目标类型) |
| 训练建议 | Workflow (Blocking) | ✅ BMI 规则引擎(5 级分类) |
SSE 流式对话实现:
# dify_client.py — 流式消费 Dify SSE
async for line in response.aiter_lines():
if not line.startswith("data:"):
continue
data = json.loads(line[5:].strip())
event = data.get("event", "")
if event == "message":
yield {"content": data["answer"], "conversation_id": data["conversation_id"]}
elif event == "error":
raise DifyClientError(f"Dify 流式错误: {data['message']}")
每条消息增量推送给前端(SSE 格式),前端实时渲染打字效果。
优雅降级模式:
# plan/service.py — 先调 AI,失败则用模板
try:
client = _get_dify_client()
result = await client.run_workflow(inputs={...})
weekly_plan = result.get("weekly_plan")
except DifyClientError as e:
logger.warning("Dify 计划生成失败,使用模板计划: %s", e)
if weekly_plan is None:
weekly_plan = _template_plan(target=data.target, ...) # 本地模板
这意味着即使 Dify 宕机或 Token 过期,用户依然能得到一份基于目标类型(减脂/增肌/塑形/保持)的可用训练计划,只是缺少 AI 的个性化优化。
4.6 Dify Chatflow 工作流:从意图识别到商品推荐
最新为 AI 助手设计的 Chatflow 工作流,实现了意图识别 → 智能搜索 → 格式化推荐的完整链路:
用户输入"想买蛋白粉"
→ LLM 意图识别 → {"category": "goods", "keyword": "蛋白粉"}
→ HTTP 节点 → GET /api/v1/ai/search?q=蛋白粉&type=goods
→ ES 返回 [{id, name, price_text, image, badge, url}, ...]
→ LLM 格式化 → 推荐文案(含 product://123 特殊链接)
→ 前端渲染 → 可点击的商品卡片
关键设计点:
- 意图分类用 LLM 而非关键词匹配:高容错,能理解"最近跑步膝盖疼,有什么护具推荐吗" = goods + “跑步护具”
product://协议链接:不直接用真实 URL,而是用自定义协议让前端拦截并渲染成卡片,避免 Dify 输出裸露的链接不好看- HTTP 节点直接调公网 API:
http://xxx.xxx.xxx.xxx/api/v1/ai/search,Dify Cloud 可直达服务器
4.7 订单超时取消与 Redis 分布式锁
用户下单后 30 分钟内未支付,系统自动取消并回滚库存。
实现方式:
# 1. 下单时投递延迟消息
publish_message(ORDER_TIMEOUT_QUEUE, {
"order_id": order.id,
"created_at": now.isoformat()
})
# 2. 消费端:计算剩余时间,sleep 后检查
remaining = 30 * 60 - elapsed_seconds
if remaining > 0:
time.sleep(remaining)
# 3. Redis 分布式锁防止多实例重复处理
lock_key = f"order:timeout:lock:{order_id}"
acquired = redis_client.set(lock_key, "1", nx=True, ex=120) # SET NX EX
if not acquired:
return # 其他实例已在处理
# 4. 检查订单状态 + 回滚库存 + 关闭订单
为什么用 time.sleep 而不用 RabbitMQ TTL + 死信队列?
简单直接:一条消息进入队列,消费者收到后自己算时间睡觉。避免了死信队列的复杂配置,也避免了秒级 TTL 不准的问题(RabbitMQ 的 TTL 到秒级才有保证)。
不过缺点也很明显:sleep 期间消费者线程被占用。小规模场景(单实例、单线程消费)没问题,大规模需要换方案。
4.8 运行时数据库迁移
不用 Alembic 生成迁移脚本,而是在应用启动时直接跑增量 SQL:
# database.py
async def _run_migrations(connection):
migrations = [
"ALTER TABLE users ADD COLUMN IF NOT EXISTS height INT DEFAULT NULL",
"ALTER TABLE spu ADD COLUMN IF NOT EXISTS original_price INT DEFAULT NULL",
# ...
]
for sql in migrations:
try:
await connection.execute(text(sql))
except Exception:
pass # 幂等:列已存在则跳过
适用场景:小型团队快速迭代,避免了编写、review、执行 Alembic 迁移的繁琐流程。每行 SQL 都是幂等的(IF NOT EXISTS / try-catch),多次执行也不会出错。
不适用场景:大型团队需要严格 review 每次数据库变更,或有回滚需求。此时应回归 Alembic 版本管理。
4.9 WebSocket 实时聊天与在线状态追踪
社区的私信功能基于 WebSocket 实现,采用双通道保底:
- WebSocket 通道:实时双向通信,在线状态推送
- HTTP 降级通道:当 WebSocket 连接失败时,自动 fallback 到 HTTP POST 发消息
在线状态追踪的设计:
# 在内存中维护(当前进程)
active_connections: dict[int, WebSocket] = {}
# 在 Redis 中维护(跨进程 / 重启恢复)
await redis_client.sadd("chat:online_users", str(user_id))
亮点:用户上线时,先用 JWT(WebSocket 连接时携带的短效 Token)鉴权,然后将 WebSocket 对象注册到内存字典 + Redis 集合。下线或断开时同步清理两端。
未读消息计数存储在 Redis 中,每次用户连接时推送未读数,读到消息后实时更新。
5.总结与思考
5.1 适合学习的技术点
- HTTP-Only Cookie + Redis Session 的认证方案,比传统 JWT 更安全,值得在生产环境采用
- 自研 Snowflake 是一个很好的分布式系统入门实践,理解 64 位 ID 的每一位含义
- ES 双分析器策略 是中文搜索的标配方案,IK 分词器的选择直接影响搜索体验
- AI 优雅降级 是 AI 集成的最佳实践——AI 是锦上添花,不是核心链路
5.2 可以改进的地方
- 订单超时的
time.sleep方案在大流量下会成为瓶颈,后期应改用 RabbitMQ 死信队列 - 运行时迁移适合当前阶段,但随着团队扩大应切换回 Alembic 版本管理
- ES 双通道同步存在极小概率的不一致,关键场景(如库存扣减)应优先查 MySQL
- AI Chat 目前无降级,Dify 不可用时会直接报错,应添加兜底回复
5.3 项目数据
- 后端:~8000 行 Python,12 个业务模块
- 前端:~40 个 Vue 组件,3 个 Pinia Store
- 基础设施:6 个 Docker 容器,4 个 ES 索引
- 外部集成:Dify AI、支付宝、阿里云 OSS、阿里云短信、Elasticsearch、RabbitMQ、Redis
5.4 后期优化
基础设施升级方向:Redis-分片集群、分布式锁、缓存、MySQL-主从读写分离、分库分表、MGR 多主、Elasticsearch-分片 + 副本自动分布式、RabbitMQ-镜像队列集群。
6. 项目演示
(由于图片没有找,所以看起来会奇怪一些,目前只是demo阶段)
C端:







B端:



其他页面就不展示了,随便展示了一些
项目持续迭代中,代码开源维护。如有问题或建议,欢迎通过 Issue 或 PR 交流。
更多推荐




所有评论(0)