数据分析平台Day6:从零补全用户系统与生产部署实战

数据分析平台Day6:从零补全用户系统与生产部署实战 上周五晚上十一点小组群里弹出一条消息“我们的数据分析平台下周要去做结项演示导师要求加上用户系统而且服务器环境也得按生产标准来准备。”那时候我们团队的数据分析平台已经跑通了上传 CSV、数据清洗、分组统计、可视化报表这一整条链路但所有文件都散落在项目目录里任何访问到服务的人都能直接看到分析结果也没有“登录”这个概念。Day6 的任务就是在不搞乱现有功能的前提下把用户系统和生产部署准备补上。这篇就记录我们这一整天是怎么拆需求、选方案、动手写代码以及踩完坑之后觉得值得写下来的东西。整个项目用的是 FastAPI Vue 3 的经典组合数据库开发环境用 SQLite部署环境切到 PostgreSQL会话层用 Redis 做统一存储。如果你也在做类似的数据分析项目这篇可以当成一份可直接对照的清单。1. 为什么是第6天才做用户系统需求驱动远比功能堆砌重要1.1 前五天我们的平台到底能做什么前五天我们做的事情本质上是把一套 Python 数据分析流程做成了 Web 服务用户上传 Excel 或 CSV后端用 pandas 做清洗与聚合前端用 ECharts 渲染图表支持常见的分组统计、缺失值处理和简单的相关性分析。功能能跑但架构很“天真”——没有登录没有权限没有数据隔离任何人打开页面就能用全部功能。这种状态在课堂演示时没问题但导师一句话点醒了我们结项演示时有外部评委万一有人上来随便上传几个 G 的文件把服务打挂这项目就砸了。而且平台里存了真实脱敏数据没有用户体系意味着数据归属不清出了问题连是谁操作的都查不到。用户系统从“锦上添花”变成了“上线的硬门槛”。1.2 三个真实触发点比“加个功能”更具体我后来总结很多学生项目最大的问题不是功能太少而是功能太多。第 6 天加用户系统这件事是因为我们遇到了三个具体到不能再具体的痛点第一个是操作不可追溯。小组五个人共用同一个服务谁改了哪份数据、跑了哪次分析没有任何记录。导师问“上周谁导出了这份报表”我们面面相觑。第二个是功能没法个性化。用户想保存自己的分析历史、收藏常用图表配置没有账号就没法做数据绑定。两个人同时跑同一个分析结果写进同一个目录互相覆盖。第三个是资源安全。平台里的分析功能是 CPU 密集型任务匿名用户可以被反复触发别人在跑大分析时整个服务会卡死。没有用户体系就没有配额管理没有配额管理就不敢提生产部署。1.3 需求收敛我们不做什么痛点明确之后我们把需求收敛成了一个最小可用集合注册、登录、登出按用户隔离分析数据和历史记录普通用户和系统管理员两种角色管理员可以查看用户列表和禁用异常账号。就这么四条。我们明确砍掉了这些邮箱验证、手机号绑定、第三方登录、复杂的细粒度权限比如按菜单授权、密码找回。砍掉它们不是因为难而是因为它们对结项演示没有实际价值。邮箱验证需要额外配置 SMTP第三方登录要注册开放平台账号细粒度权限会让后端每个接口都要维护权限矩阵。学生项目的节奏是“先能跑再谈优雅”把有限的时间花在演示时真正会被点到的功能上才是对的取舍。2. 用户体系的三个硬决策会话方案、密码存储与角色模型2.1 会话方案JWT 与 Redis 服务端会话的取舍动手写代码之前我们首先纠结的是登录之后用什么凭证。网上教程很多推荐 JWT说它无状态、跨语言、适合前后端分离。但我们最终选了“服务端会话 Redis 存储”理由很实在。维度JWTRedis 服务端会话服务端存储不存状态在令牌里存 session 或 token 记录吊销能力难黑名单要另做直接删 Redis key 即可跨实例支持天然支持所有实例连同一个 Redis实现难度中低适合场景大型分布式、跨平台授权中小型项目、需要强管控JWT 最大的问题是“一旦签发在过期前永远有效”。我们结项演示时遇到过这样一个场景某个账号被管理员禁用但只要他的 JWT 没过期照样能调接口。要在 JWT 方案里解决吊销得额外维护一个黑名单或版本号复杂度和服务端会话没什么区别了。而 Redis 方案里禁用账号就是删一个 key 的事干净利落。2.2 密码存储别再用 SHA-256 偷懒第二个决策是密码怎么存。这里我必须直接说用 SHA-256、MD5 这类快哈希存密码是学生项目里最大的安全隐患。快哈希的设计目的是快速计算摘要攻击者可以用 GPU 每秒跑几亿次暴力猜测。正确做法是使用专门为密码设计的慢哈希算法。我们用的是 bcryptPython 里通过 passlib 库接入核心代码就一行from passlib.hash import bcrypt # 注册时 hashed bcrypt.hash(password) # 登录时 bcrypt.verify(password, hashed)bcrypt 会自动生成随机盐且计算速度可以调节。我们测试时发现默认 rounds 在普通服务器上单次校验大约 80ms这个速度对用户无感但对暴力破解已经是天文数字般的成本。如果你用的框架内置了专用算法比如 Django 的 PBKDF2、Laravel 的 bcrypt直接用它内置的就好不要自己造轮子。2.3 角色模型两套权限够用且好解释第三件事是角色。我们没有引入复杂的 RBAC 表结构只做“普通用户”和“管理员”两个角色在用户表里用一个role字段标识。评审演示时这个设计三句话就能讲清楚普通用户只能操作自己的数据管理员除了普通用户的所有能力还能查看用户列表、禁用账号、查看系统运行状态。数据隔离的落地逻辑是每个分析任务、每份上传的文件、每条历史记录都带一个owner_id外键指向用户表。所有查询语句强制加WHERE owner_id current_user.id。我们约定“取数据必须带 owner 条件”在 Code Review 时作为硬性检查项。这一点看着简单但实际项目里最容易翻车的就是漏掉某个列表接口的过滤条件导致用户 A 看到了用户 B 的分析记录。3. 从注册到鉴权的完整落地路由、中间件与数据模型3.1 用户表与数据模型设计我们用 SQLAlchemy 定义用户模型字段不多但每个都有讲究from sqlalchemy import Column, Integer, String, DateTime, Boolean from sqlalchemy.sql import func from app.database import Base class User(Base): __tablename__ users id Column(Integer, primary_keyTrue) username Column(String(50), uniqueTrue, nullableFalse, indexTrue) hashed_password Column(String(128), nullableFalse) role Column(String(20), nullableFalse, defaultuser) is_active Column(Boolean, nullableFalse, defaultTrue) created_at Column(DateTime, server_defaultfunc.now()) last_login_at Column(DateTime, nullableTrue)用户名加唯一索引是必须的注册时还要在应用层做一次查重双保险防并发注册产生重复账号。last_login_at这个字段看起来可有可无但管理员排查异常账号时非常有用——登录时间异常本身就是安全信号。数据分析任务表则通过owner_id关联用户所有查询都受这个外键约束。3.2 注册、登录与鉴权依赖的代码落地FastAPI 的依赖注入让鉴权变得很清爽。我们定义了get_current_user依赖放在需要保护的路由参数里即可from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from redis import Redis oauth2_scheme OAuth2PasswordBearer(tokenUrl/api/auth/login) def get_current_user(token: str Depends(oauth2_scheme), redis: Redis Depends(get_redis)): user_id redis.get(fsession:{token}) if not user_id: raise HTTPException(status_code401, detail登录已失效请重新登录) user db.query(User).filter(User.id int(user_id)).first() if not user or not user.is_active: raise HTTPException(status_code401, detail账号不存在或已被禁用) return user登录成功后我们生成一个随机字符串作为 token把它作为 key 存进 Redisvalue 是用户 ID过期时间设为 12 小时。这里有个细节ttl 是 12 小时但我们会在用户每次访问时用redis.expire续期这样用户只要持续活跃就不会掉线闲置半天后自动失效——比固定过期时间的体验好很多。注册接口则要处理参数校验、用户名查重、bcrypt 哈希三步。特别提醒注册接口的响应里千万不要返回hashed_password字段我们专门写了一个UserOut的 Pydantic schema只暴露 id、用户名、角色和创建时间。3.3 前端配合Token 存储与请求拦截后端完成后前端要做三件事登录页表单、token 存储、请求拦截器。Vue 3 项目里我们用 Pinia 管理用户状态token 存在 localStorage每次 axios 请求都带上Authorization: Bearer token。axios.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) axios.interceptors.response.use( response response, error { if (error.response error.response.status 401) { router.push(/login) } return Promise.reject(error) } )路由守卫配合检查本地 token 是否存在不存在就跳转登录页。这里有一个我们一开始没注意到的问题token 存 localStorage 有 XSS 风险理论上更安全的做法是存内存或 httpOnly Cookie。但学生项目里前后端分离部署跨域情况下 Cookie 要配SameSiteNone; Secure调试复杂度高不少。我们最后选择了 localStorage同时在前端做了基础的输入转义和 CSP 头。取舍的过程要清楚安全是分层的项目阶段先保证最基本的认证流程可用后续再逐步加固。4. Redis Docker Compose 生产化会话存储与缓存拆分的取舍4.1 为什么把 Redis 放进 Docker Compose开发阶段我们的 Redis 是本地直接 brew install 装的到了生产部署准备阶段统一改成 Docker Compose 管理。原因有两个一是团队五个人的开发环境不一致有人安装了 Redis 6有人是 Redis 7配置参数有差异Docker 可以锁定版本二是服务器上要用同一套 Compose 编排应用、数据库、Redis一键启动和一键停止比手动维护多个进程省心太多。我们最终的 docker-compose.yml 长这样version: 3.8 services: redis: image: redis:7-alpine container_name: analysis-redis restart: always command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes --appendfsync everysec --maxmemory 512mb --maxmemory-policy allkeys-lru ports: - 6379:6379 volumes: - redis-data:/data healthcheck: test: [CMD, redis-cli, -a, ${REDIS_PASSWORD}, ping] interval: 10s timeout: 3s retries: 5 postgres: image: postgres:16-alpine container_name: analysis-postgres restart: always environment: POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: ${DB_NAME} volumes: - pg-data:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U ${DB_USER}] interval: 10s timeout: 3s retries: 5 api: build: . container_name: analysis-api restart: always depends_on: redis: condition: service_healthy postgres: condition: service_healthy environment: DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}postgres:5432/${DB_NAME} REDIS_URL: redis://:${REDIS_PASSWORD}redis:6379/0 SECRET_KEY: ${SECRET_KEY} ports: - 8000:8000 volumes: redis-data: pg-data:特别注意depends_on的condition: service_healthy写法它保证 API 服务只会在 Redis 和 PostgreSQL 健康检查通过后才启动避免应用启动时数据库还没就绪导致连接失败重试的尴尬场面。这个细节在生产部署里非常关键。4.2 生产可用的 Redis 配置参数解读这套 Redis 配置里每个参数都是踩坑换来的我逐个说一下--requirepass设置访问密码。不设密码的 Redis 在公网服务器上等于裸奔扫描工具几分钟就能扫到并植入挖矿程序。--appendonly yes开启 AOF 持久化。这是给会话数据兜底的关键否则 Redis 一重启所有登录状态全部消失用户会突然全部掉线。--appendfsync everysec是持久化频率的平衡点每秒刷盘一次兼顾性能和数据安全。--maxmemory 512mb加上--maxmemory-policy allkeys-lru是内存上限控制。这里有一个重要提醒如果 Redis 里只存会话绝对不能无脑用 allkeys-lru因为内存不足时它可能把未过期的会话 key 淘汰掉造成用户莫名掉线。更严谨的做法是只对缓存类 key 设置 TTL并给会话 key 打前缀然后配合volatile-ttl策略让它优先淘汰剩余存活时间最短的 key。我们之所以敢用 allkeys-lru是因为数据量不大且所有 key 都设置了 TTL淘汰策略退化为按剩余时间淘汰影响可控。4.3 数据持久化与备份策略Redis 容器通过 volume 把/data目录挂载到宿主机AOF 文件就落在 volume 里容器重建不丢数据。但 volume 不等于备份服务器磁盘坏了也会丢。我们的备份策略是每天凌晨用 cron 执行redis-cli BGSAVE然后把 dump.rdb 文件和 PostgreSQL 的 pg_dump 一起打包上传到对象存储。备份是部署准备里最容易偷懒的一环但真出事的时候它是唯一救命的稻草。建议所有做生产部署准备的同学把“备份恢复演练”也写进 checklist光备份不演练等于没备份。5. 部署前的最后一公里环境配置、反向代理、日志与备份5.1 环境隔离一份配置两个环境开发环境和生产环境的差异主要靠环境变量隔离。我们项目根目录放了.env.example里面列清楚所有必要的变量名提交到 Git 仓库供团队成员参考.env则写入gitignore只在本地存在。生产服务器的环境变量由部署脚本从部署平台的 Secret 管理里注入不落到磁盘明文。FastAPI 这边用一个Settings类统一读取环境变量from pydantic_settings import BaseSettings class Settings(BaseSettings): database_url: str redis_url: str secret_key: str debug: bool False class Config: env_file .env这里有一个容易忽略的点secret_key千万不要在代码里写默认值。我们有位同学图省事在代码里写secret_key dev-secret结果 dev 环境泄露的密钥直接可以用于生产环境伪造会话这是非常低级但真实存在的隐患。5.2 Nginx 反向代理与静态资源托管前端 Vue 项目构建后是一堆静态文件后端是 FastAPI 服务。生产环境我们用 Nginx 做反向代理一个 server 块同时处理静态资源和 API 转发server { listen 443 ssl; server_name analysis.example.com; # 前端静态文件 root /var/www/analysis/dist; index index.html; location / { try_files $uri $uri/ /index.html; } # API 反向代理 location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files这行是 Vue Router history 模式的关键没有它刷新某个子路由页面会直接 404。proxy_set_header X-Forwarded-For也很关键因为鉴权日志里要记录真实来源 IP不做转发的话后端看到的全是 Nginx 的地址。我们实测发现多个代理层叠时后端需要取X-Forwarded-For里的第一个 IP而不是最后一个这个容易搞反。5.3 日志、健康检查与数据库备份日志这块我们没有引入复杂的 ELK只做到“结构化 轮转”。FastAPI 用 logging 模块输出 JSON 格式日志每条日志带上 request_id、用户 id、路径、耗时。Nginx 访问日志按天切割保留 14 天。排查线上问题时有结构化的日志比看一坨普通文本高效得多。健康检查端点我们做了两个/api/health/live只检查进程是否存活/api/health/ready会真正连一次 Redis 和 PostgreSQL。Docker Compose 和 Nginx 的监控探针都用ready那一个。这样编排工具就能自动判断服务是否真的可用而不是进程还活着但数据库连不上的假健康。数据库备份用 cron 定时执行0 2 * * * pg_dump -U $DB_USER -h localhost $DB_NAME | gzip /backup/db_$(date \%F).sql.gz备份文件保留 30 天同时推一份到对象存储。这些工作不复杂但都是“生产部署准备”里必须签到的项目。6. 联调阶段踩过的坑从502到令牌失效的排查实录6.1 容器重启后全员 401会话与持久化的关系第一次联调部署完成后我们执行了一次docker compose restart模拟服务器重启结果所有用户全部掉线前端疯狂跳登录页。排查链路是这样的先看 API 日志发现全部请求在get_current_user抛 401然后手动用 redis-cli 查GET session:xxx返回 nil接着查 Redis 是活着的但内存里没有任何 key。最终定位Compose 文件里 Redis 服务没配置 volume数据全在容器可写层容器一重建就清空。这个坑的教训很简单任何有状态的服务容器编排时第一件事就是挂载数据卷。我们在docker-compose.yml里补上volumes: - redis-data:/data后再测试重启会话存活正常。6.2 CORS 预检失败本地好好的部署就崩开发环境前端跑在localhost:5173后端跑在localhost:8000我们配了 CORS 放过http://localhost:5173一切正常。部署后前端域名变成https://analysis.example.com后端 CORS 配置没同步更新浏览器发 DELETE、PUT 这类非简单请求时先在预检阶段就被浏览器拦截控制台报“CORS policy: No Access-Control-Allow-Origin header”。排查时我们一度怀疑是 Nginx 配置问题后来用 curl 手动请求才发现 Nginx 转发正常是后端响应头里少了 CORS 头。这个坑提醒我们CORS 配置是跟着环境走的不是写一次就能一劳永逸。生产环境 CORS 的来源列表应该从环境变量读取部署时单独设置。另外要注意allow_origins不要简单写成[*]因为带凭证的请求不允许通配符来源。6.3 令牌过期时间的时区陷阱我们给 Redis 会话设置的 TTL 是timedelta(hours12)测试时发现刷新页面后过期时间计算总是差 8 小时。查了半天才发现是时区问题服务器系统时区是 UTC应用代码取的datetime.now()是本地时区 UTC8两者混用导致 TTL 计算偏差。解决办法是统一规范代码里所有时间一律用datetime.now(timezone.utc)存数据库也统一 UTC只有展示层才转换到用户时区。容器镜像里把时区设置为 Asia/Shanghai并确保 Python 的TZ环境变量一致。这个坑很小但排查成本很高因为现象是“时灵时不灵”没有明确报错。6.4 静态资源 404Nginx root 与 alias 的区别Vue 打包后的 assets 路径是/assets/index-xxxx.js我们一开始 Nginx 配置里写成location /assets/ { root /var/www/analysis/dist; }结果所有 JS、CSS 全部 404。原因是root会把完整路径拼在 root 目录后面实际去读了/var/www/analysis/dist/assets/assets/index-xxxx.js。正确做法是换成alias或者直接把root指到上一层让路径自然匹配。后来我们把location /的try_files方案统一处理静态文件删掉了单独的 assets location问题彻底解决。这个坑属于“看着文档写的但本质没理解”的典型写出来提醒一句root和alias的路径拼接逻辑完全不同配静态资源时先想清楚。最后再分享一个实际心得完成用户系统和部署准备后我们专门做了一次“从零部署演练”——删掉服务器上所有容器和数据卷然后照着 README 从克隆代码开始重新部署一遍记录每一步的操作和时间。这个演练逼我们把部署文档补全了包括环境变量清单、构建命令、备份恢复步骤。结项演示前一天我们模拟了一次服务器宕机用备份在半小时内恢复服务。那一刻才真正觉得Day6 这个“生产部署准备”不是把东西跑起来就完了而是跑起来之后挂了还能救回来才算真的有准备。