FitMall 运动优选:一个完整健身电商平台的技术全解析

从零搭建的全栈健身电商平台,涵盖商品交易、AI 智能助手、社区互动、训练管理四大核心模块。本文将拆解其技术选型、架构设计、关键实现细节与工程经验。


目录

  1. 项目概览
  2. 技术栈一览
  3. 架构设计
  4. 那些有意思的技术细节
  5. 总结与思考

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.13ES 异步同步、订单超时、训练任务
认证JWT + HTTP-Only Cookie双通道鉴权,Redis Session 存储
支付支付宝沙箱(RSA2-SHA256)自签名实现,不依赖官方 SDK
存储阿里云 OSS带重试和预签名 URL 降级
短信阿里云 DypnsapiRedis 存验证码,5 分钟过期
配置管理Pydantic Settings40+ 环境变量,dev / prod 分离
ID 生成自研 Snowflake41 位毫秒时间戳 + 10 位机器 + 12 位序列

2.1 前端

组件技术说明
框架Vue 3.5 + Composition API + TypeScript<script setup> 语法
构建Vite 8秒级热更新
状态管理Pinia 3user / admin / cart 三个 Store
路由Vue Router 4嵌套路由 + 权限守卫
样式TailwindCSS 3自定义 “Iron & Chalk” 设计系统
HTTPAxios + 拦截器Cookie 自动携带 + 并发刷新拦截
图表Chart.js体重趋势 / 训练统计
WebSocket原生 WebSocket + 自封装 Composable实时聊天 + 在线状态

2.2 基础设施

组件技术说明
容器化Docker Compose6 个服务一键编排
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_session vs fitmall_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 节点直接调公网 APIhttp://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 适合学习的技术点

  1. HTTP-Only Cookie + Redis Session 的认证方案,比传统 JWT 更安全,值得在生产环境采用
  2. 自研 Snowflake 是一个很好的分布式系统入门实践,理解 64 位 ID 的每一位含义
  3. ES 双分析器策略 是中文搜索的标配方案,IK 分词器的选择直接影响搜索体验
  4. AI 优雅降级 是 AI 集成的最佳实践——AI 是锦上添花,不是核心链路

5.2 可以改进的地方

  1. 订单超时time.sleep 方案在大流量下会成为瓶颈,后期应改用 RabbitMQ 死信队列
  2. 运行时迁移适合当前阶段,但随着团队扩大应切换回 Alembic 版本管理
  3. ES 双通道同步存在极小概率的不一致,关键场景(如库存扣减)应优先查 MySQL
  4. 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 交流。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐