一、后端架构规范:SpringBoot风格分层设计
1.1 为什么要分层?
项目初期,如果所有代码都堆在一个文件里,功能也许能跑通。但随着接口增多、业务逻辑变复杂,这种“面条式代码”会让开发和维护成本指数级上升。为了从一开始就避免这个问题,曹翔参考企业级 SpringBoot 架构的分层思想,对 FastAPI 后端项目进行了严格的标准化设计。
1.2 目录结构设计
app/ ├── controllers/ # 控制器层 → 接收请求、返回响应 │ ├── __init__.py │ ├── auth_controller.py # 登录、注册、Token刷新接口 │ └── user_controller.py # 用户信息管理接口 ├── services/ # 服务层 → 核心业务逻辑实现 │ ├── __init__.py │ ├── auth_service.py # 认证、加密、Token签发验证 │ └── user_service.py # 用户数据CRUD业务 ├── models/ # 模型层 → 数据结构定义 │ ├── __init__.py │ ├── user_models.py # Pydantic请求/响应模型 │ └── database_models.py # SQLAlchemy数据库表模型 ├── core/ # 核心层 → 全局配置与工具 │ ├── __init__.py │ ├── database.py # 数据库连接管理 │ ├── redis_client.py # Redis客户端封装 │ └── dependencies.py # 依赖注入(认证中间件等) ├── __init__.py └── main.py # FastAPI应用入口(路由注册、中间件挂载) run.py # 启动脚本1.3 每层的职责边界
| 层 | 职责 | 绝对不能做的事 |
|---|---|---|
| Controller | 接收HTTP请求,参数校验,调用Service,返回响应 | 不能写业务逻辑,不能直接操作数据库 |
| Service | 实现所有业务逻辑,协调多个数据源,处理事务 | 不能直接读取HTTP请求对象 |
| Model | 定义数据结构,Pydantic做请求/响应校验,SQLAlchemy做ORM映射 | 不能包含业务逻辑 |
| Core | 提供数据库连接、Redis客户端、认证中间件等全局能力 | 不能与具体业务耦合 |
这种分层带来的直接好处是:
解耦彻底:接口路由、业务逻辑、数据模型完全分离,改表结构不影响接口,改业务逻辑不影响路由
易于扩展:新增功能只需在对应层添加代码,其他层零改动
团队协作:前端联调Controller层,后端开发Service层,AI组调用Core层,三线并行
可测试性:每层可以独立单元测试,不依赖HTTP请求或数据库连接
二、用户认证体系:登录注册全流程实现
用户系统是所有功能的基础——没有登录,就没有个性化推荐、学习记录追踪。曹翔独立完成了注册和登录两大核心接口。
2.1 注册接口
注册是用户进入平台的第一道门。接口设计如下:
请求:
POST/auth/register{"username":"cx","email":"cx@example.com","password":"123456","full_name":"cx_full_name","age_group":"2"}后端处理流程:
校验各字段合法性(用户名长度4-20位、邮箱格式、密码长度≥6、两次密码一致)
查询数据库:用户名是否已存在?
如果都不冲突,对密码进行哈希加密(详见第三节)
将用户数据写入MySQL user 表
生成JWT Token(详见第四节)
返回Token
响应(成功):
{"access_token":"eyJhbGciOiJI...","token_type":"bearer"}响应(用户名已存在):
{"detail":"用户名已存在"}2.2 登录接口
登录接口需要考虑的安全问题比注册更多:密码验证、Token签发、设备标识、登录冲突……
请求:
POST/auth/login{"username":"zhangsan","password":"123456"}后端处理流程:
根据用户名从数据库查询用户记录
用户不存在?返回401“用户名或密码错误”(不明确说“用户名不存在”,防止账号枚举攻击)
取出数据库中存储的密码哈希,与用户输入的密码进行SHA256+盐值验证
验证通过后,后端随机生成一个16位唯一device_id
将 username → device_id 的映射写入Redis(覆盖旧值,实现“后登踢先登”)
签发JWT Token,Token中嵌入 sub(用户名)、device_id(设备标识)、role(角色)
返回Token和用户信息
响应(成功):
{"access_token":"eyJhbGciOiJI...","token_type":"bearer"}三、密码安全:SHA256 + 随机盐值加密
3.1 安全原则
密码安全有三个铁律,我们从一开始就严格遵守:
绝不存储明文密码——哪怕是测试环境
即使开发者也无法反推出用户密码——使用不可逆哈希
相同密码对应不同哈希值——加随机盐
3.2 实现方案
考虑到项目实训阶段无极高安全需求,我们选择了SHA256 + 随机盐值的方案。虽然没有用bcrypt那样自带成本因子的算法,但已经满足实训项目的安全要求,并且预留了平滑升级空间。
@staticmethoddefget_password_hash(password:str)->str:"""生成密码哈希"""salt=secrets.token_hex(16)password_salt=password+saltreturnhashlib.sha256(password_salt.encode()).hexdigest()+":"+salt@staticmethoddefverify_password(plain_password:str,hashed_password:str)->bool:"""验证密码"""try:hash_part,salt=hashed_password.split(":")password_salt=plain_password+salt computed_hash=hashlib.sha256(password_salt.encode()).hexdigest()returncomputed_hash==hash_partexcept:returnFalse3.3 设计要点
盐值随机性:使用Python标准库 secrets 模块生成,比 random 更安全,适合安全场景
存储格式:哈希值和盐值用冒号拼接存储,登录验证时拆分即可
不可逆:SHA256是单向哈希,即使数据库泄露,攻击者也难以反向推导出原始密码
升级预留:如果后续需要升级到bcrypt,只需修改这两个函数内部实现,数据库存储字段不变,业务代码零改动
为什么没有直接用bcrypt?
bcrypt需要安装 passlib 等第三方库。在项目实训场景中,SHA256+盐值已经能够满足核心安全诉求——防止明文密码泄露和人眼记忆密码。方案本身设计清晰,注释完善,随时可以切换。
四、JWT认证机制:带设备ID的令牌设计
4.1 为什么选JWT?
用户登录后,每次请求都需要证明“我是我”。常见方案有两种:
Session:服务器存一份会话信息,客户端传Session ID
JWT:服务器签发一个加密令牌,客户端携带令牌,服务器无状态验证
我们选JWT的理由:
无状态:服务端不需要存储会话,天然支持分布式部署
可扩展:Token中可以嵌入自定义字段(我们嵌入了device_id)
前后端分离友好:前端存localStorage,每次请求带在Header里即可
4.2 令牌设计
标准的JWT包含三部分:Header(算法声明)、Payload(载荷数据)、Signature(签名)。我们在Payload中扩展了自定义字段:
# 创建访问令牌access_token=AuthService.create_access_token(data={"sub":user.username,"device_id":device_id,"role":user.role},expires_delta=access_token_expires)@staticmethoddefcreate_access_token(data:dict,expires_delta:Optional[timedelta]=None)->str:"""创建JWT访问令牌"""to_encode=data.copy()ifexpires_delta:expire=datetime.utcnow()+expires_deltaelse:expire=datetime.utcnow()+timedelta(minutes=15)to_encode.update({"exp":expire})encoded_jwt=jwt.encode(to_encode,SECRET_KEY,algorithm=ALGORITHM)returnencoded_jwt4.3 各字段的作用
| 字段 | 作用 | 为什么需要 |
|---|---|---|
| sub | 标识用户身份 | JWT标准字段,标识“这是谁的Token” |
| device_id | 标识登录设备 | 实现“同一账号只允许一台设备在线”的核心字段 |
| role | 用户角色 | 后续权限控制(管理员/普通用户)预留 |
| exp | 过期时间 | JWT标准字段,Token到期自动失效 |
4.4 Token的完整生命周期
用户登录成功 ↓ 后端生成16位随机device_id ↓ 将 username→device_id 写入Redis ↓ 签发JWT(嵌入device_id) ↓ 前端存储Token到localStorage ↓ 每次请求携带Token(Header: Authorization: Bearer xxx) ↓ 中间件验证Token合法性 ↓ ↓ 合法 不合法/过期 ↓ ↓ 放行 返回401五、Redis + 中间件:登录冲突控制
5.1 业务需求
想象一个场景:张三在电脑上登录了LingualSpark,然后又在平板上登录了同一个账号。我们希望后登录的设备“踢掉”先登录的设备,防止账号被多人同时使用。
5.2 实现原理
核心逻辑很简单:Redis中只存最新的device_id,旧Token里的device_id对不上就拒绝访问。
5.3 登录时的Redis写入
# 用户登录成功后device_id=secrets.token_hex(8)# 生成16位随机设备ID# 写入Redis:key=用户名,value=设备IDredis_client.set(f"user_device:{username}",device_id)# 签发JWT(嵌入device_id)access_token=AuthService.create_access_token(data={"sub":user.username,"device_id":device_id,"role":user.role},expires_delta=access_token_expires)5.4 全局认证中间件
除登录、注册等开放接口外,所有请求必须经过中间件验证。中间件的核心逻辑:
classAuthMiddleware(BaseHTTPMiddleware):"""认证中间件:自动验证除白名单外的所有请求的Token"""asyncdefdispatch(self,request:Request,call_next):# 检查是否需要跳过认证path=request.url.pathifpathinEXACT_EXCLUDE_PATHSorany(path.startswith(exclude)forexcludeinEXCLUDE_PATHS):returnawaitcall_next(request)# 获取 Authorization headerauth_header=request.headers.get("Authorization")ifnotauth_headerornotauth_header.startswith("Bearer "):returnJSONResponse(status_code=401,content={"detail":"未提供认证凭据"})# 验证 tokentoken=auth_header.replace("Bearer ","")token_data=AuthService.verify_token(token)iftoken_dataisNone:returnJSONResponse(status_code=401,content={"detail":"无效的认证凭据或已过期"})# token 有效,继续处理请求returnawaitcall_next(request)5.5 完整踢人流程模拟
时间线: T1: 张三在电脑上登录 → device_id = "a1b2c3d4e5f6g7h8" → Redis存了这个ID T2: 张三在平板上登录 → device_id = "x9y8z7w6v5u4t3s2" → Redis覆盖为这个新ID T3: 电脑端发送请求 → 中间件解析Token得到device_id = "a1b2..." → Redis中现在的device_id = "x9y8..." → 不匹配 → 将token拉入黑名单并且返回401 "账号已在其他设备登录" T4: 电脑端前端收到401拒绝访问 → 跳转到登录页 → 提示用户这样,同一账号同一时刻只能在一台设备上使用,保证了账号安全性。
六、素材数据库:从方案设计到重构优化
除了用户认证体系,后端负责人还独立负责平台核心素材库的设计与实现,包括格林童话阅读素材和分级单词背诵素材两大模块。
6.1 格林童话素材:简洁高效的数据导入
童话阅读功能的素材处理相对简单,只需存储故事基础信息。
表结构设计:
CREATETABLE`grimms_tales`(idINTAUTO_INCREMENTPRIMARYKEY,titleVARCHAR(500)NOTNULLCOMMENT'故事标题',storyLONGTEXTNOTNULLCOMMENT'故事内容',ratingDECIMAL(3,1)DEFAULT0.0COMMENT'评分',votersINTDEFAULT0COMMENT'投票人数',created_atTIMESTAMPDEFAULTCURRENT_TIMESTAMP,updated_atTIMESTAMPDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,INDEXidx_title(title(100)),INDEXidx_rating(rating))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4COLLATE=utf8mb4_unicode_ciCOMMENT='格林童话故事表';导入过程顺畅,编写Python脚本读取原始故事数据,完成格式校验和编码统一后批量写入数据库。总计导入童话故事素材 500+篇,为前端故事展示功能提供了完整的数据支撑。
6.2 分级单词素材:三次迭代的数据库设计
单词背诵功能的素材库是数据库设计的核心难点。项目要求单词划分为7个难度等级:初中、高中、四级、六级、考研、托福、SAT,且数据源包含复杂的JSON结构。
6.2.1 初始方案设计——错误认知
在开发初期,后端负责人未完整解析JSON结构,凭初步观察形成了错误认知:认为每个单词的翻译、词性与词组例句是一一对应的。
基于这个错误认知,设计了方案1:
word 表:存储单词本体和翻译
difficulty_xxx 分难度表:每个难度一个表,存储用法和例句
word_user 关联表:记录用户背诵进度
核心思路:用户选择难度 → 筛选单词 → 展示单词+翻译 → 点击查看详情展示用法例句。
6.2.2 发现问题——JSON真实结构解析
在正式编写数据导入脚本时,后端负责人逐字段解析JSON数据,发现实际结构与预期完全不符:
{"word":"able","translations":[{"translation":"能;有能力的;能干的","type":"adj"},{"translation":"人名;阿布勒;埃布尔","type":"n"}],"phrases":[{"phrase":"will be able to","translation":"将能够"},{"phrase":"be able to do","translation":"能够做"}]}关键发现:
translations 是数组结构,一个单词可能有多个词性+多个翻译
phrases 是独立数组结构,与translations无一一对应关系
原有“一个单词对应一条翻译+一个例句”的方案完全无法适配
6.2.3 最终数据库设计——推倒重来
结合真实数据结构与业务需求,后端负责人推翻原有方案,重新设计了四表结构:
(1)word 单词主表
CREATETABLE`word`(idINTAUTO_INCREMENTPRIMARYKEY,wordVARCHAR(255)NOTNULLCOMMENT'单词本体',difficultyVARCHAR(50)NOTNULLCOMMENT'难度等级(初中/高中/四级/六级/考研/托福/SAT)',created_atTIMESTAMPDEFAULTCURRENT_TIMESTAMP,INDEXidx_difficulty(difficulty),INDEXidx_word(word))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;设计要点:
允许单词重复存储:同一单词在不同难度下视为独立学习对象(如"able"可能在初中和四级都出现,用户需分别背诵)
使用 difficulty 字段区分难度,而非分成7张表——单表更易维护,查询时加 WHERE difficulty='xxx' 即可
(2)translations 翻译表
CREATETABLE`translations`(idINTAUTO_INCREMENTPRIMARYKEY,word_idINTNOTNULLCOMMENT'关联word表',translationVARCHAR(500)NOTNULLCOMMENT'翻译内容',typeVARCHAR(20)COMMENT'词性(如adj, n, v等)',FOREIGNKEY(word_id)REFERENCESword(id)ONDELETECASCADE)ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;存储一个单词的多词性、多翻译,通过 word_id 外键关联。
(3)phrases 词组/例句表
CREATETABLE`phrases`(idINTAUTO_INCREMENTPRIMARYKEY,word_idINTNOTNULLCOMMENT'关联word表',phraseVARCHAR(500)NOTNULLCOMMENT'词组/例句',translationVARCHAR(500)COMMENT'词组/例句翻译',FOREIGNKEY(word_id)REFERENCESword(id)ONDELETECASCADE)ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;存储词组、固定搭配及对应的翻译。
(4)word_user 用户学习记录表
CREATETABLE`word_user`(idINTAUTO_INCREMENTPRIMARYKEY,user_idINTNOTNULLCOMMENT'用户ID',word_idINTNOTNULLCOMMENT'单词ID',ease_factorINTDEFAULT250COMMENT'简易度系数(SM-2),250表示2.50',interval_daysINTDEFAULT0COMMENT'复习间隔天数',repetitionsINTDEFAULT0COMMENT'成功复习次数',next_review_dateDATEDEFAULTNULLCOMMENT'下次复习日期',last_review_dateDATEDEFAULTNULLCOMMENT'上次复习日期',last_statusINTDEFAULTNULLCOMMENT'上次评估状态(1记得住,2模糊,3没记住)',created_atTIMESTAMPDEFAULTCURRENT_TIMESTAMP,updated_atTIMESTAMPDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,FOREIGNKEY(user_id)REFERENCES`users`(id)ONDELETECASCADE,FOREIGNKEY(word_id)REFERENCES`words`(id)ONDELETECASCADE,UNIQUEKEYuk_user_word(user_id,word_id),INDEXidx_user_id(user_id),INDEXidx_word_id(word_id),INDEXidx_next_review(next_review_date),INDEXidx_user_next_review(user_id,next_review_date))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4COLLATE=utf8mb4_unicode_ciCOMMENT='用户背诵记录表(SM-2算法)';记录用户与已背诵单词的关联关系,支撑遗忘曲线复习算法的数据基础。
6.2.4 业务逻辑实现
单词筛选: 前端传入难度等级 → 后端联合查询 word 表和 word_user 表 → 返回用户未背诵的单词列表
基础展示: 仅展示单词本体(前端卡片正面)
详情展示: 用户标记“不认识”时 → 后端查 translations + phrases 表 → 返回该难度下单词的全部词性翻译 + 全部词组例句 → 渲染至卡片背面
进度记录: 用户完成背诵 → 写入 word_user 表 → 更新遗忘曲线复习时间
6.3 数据导入脚本开发
基于最终表结构,曹翔编写了自动化JSON数据解析与导入脚本:
# 核心导入逻辑(简化)importjsonimportmysql.connectordefimport_words(json_path,difficulty):withopen(json_path,'r',encoding='utf-8')asf:data=json.load(f)conn=mysql.connector.connect(**DB_CONFIG)cursor=conn.cursor()foritemindata:# 1. 插入word主表cursor.execute("INSERT INTO word (word, difficulty) VALUES (%s, %s)",(item['word'],difficulty))word_id=cursor.lastrowid# 2. 批量插入translationsfortransinitem.get('translations',[]):cursor.execute("INSERT INTO translations (word_id, translation, type) VALUES (%s, %s, %s)",(word_id,trans['translation'],trans.get('type')))# 3. 批量插入phrasesforphraseinitem.get('phrases',[]):cursor.execute("INSERT INTO phrases (word_id, phrase, translation) VALUES (%s, %s, %s)",(word_id,phrase['phrase'],phrase.get('translation')))conn.commit()cursor.close()conn.close()遍历7个难度等级的JSON文件,开启事务批量插入,保证数据一致性。最终完成全量单词素材入库,总计 3000+单词,满足任务书要求。
七、接口测试:Postman全场景验证
所有接口开发完成后,后端负责人使用Postman进行了系统化测试。这不是随便点点“发送”按钮就完事,而是覆盖了正常场景和异常场景的完整测试矩阵。
测试覆盖清单:
| 测试场景 | 测试方法 | 预期结果 | 实际结果 |
|---|---|---|---|
| 正常注册 | POST /auth/register 正确参数 | 200,返回Token | ✅ |
| 用户名重复注册 | POST /auth/register 相同用户名 | 409,提示“用户名已被使用” | ✅ |
| 正常登录 | POST /auth/login 正确账号密码 | 200,返回Token | ✅ |
| 密码错误登录 | POST /auth/login 错误密码 | 401,提示“用户名或密码错误” | ✅ |
| 不存在用户登录 | POST /auth/login 不存在用户名 | 401 | ✅ |
| Token正常访问 | GET /auth/me 带正确Token | 200,返回用户信息 | ✅ |
| 无Token访问 | GET /auth/me 不带Token | 401 | ✅ |
| 过期Token访问 | GET /auth/me 带过期Token | 401 | ✅ |
| 异地登录踢人 | 设备A登录→设备B登录→设备A发请求 | 401,提示“已在其他设备登录” | ✅ |
所有接口返回格式统一、状态码规范、异常处理完善,满足了与前端联调的标准。
八、本阶段成果总结
第二阶段后端工作全部完成,交付清单如下:
| 任务 | 状态 | 产出物 |
|---|---|---|
| 后端架构分层设计 | ✅ | SpringBoot风格目录结构,Controller/Service/Model/Core四层分离 |
| 用户注册接口 | ✅ | 完整实现,多重校验,Postman测试通过 |
| 用户登录接口 | ✅ | 完整实现,SHA256+盐值加密,JWT签发 |
| 密码安全方案 | ✅ | SHA256+随机盐值,不存明文 |
| JWT认证机制 | ✅ | 自定义字段(sub/device_id/role),24小时过期 |
| Redis会话管理 | ✅ | 登录冲突控制,后登踢先登,全局中间件验证 |
| 格林童话素材库 | ✅ | 数据库设计 + 500+篇数据导入 |
| 分级单词素材库 | ✅ | 两次方案迭代,三表关联结构,3000+词入库 |
| 用户学习记录表 | ✅ | word_user表设计,支撑遗忘曲线复习 |
| 数据集最终整理 | ✅ | 12万+条数据,质量达标,全部上传Gitee |
| 接口测试 | ✅ | Postman全覆盖,11种场景验证 |
十、遇到的关键问题与解决记录
问题1:密码哈希方案的技术选型纠结
初期在“直接用bcrypt”和“用SHA256+盐值”之间犹豫。bcrypt安全性更高,但需要额外依赖;SHA256方案更轻量。
最终决策:先用SHA256+盐值,封装好接口,预留升级空间。理由是——实训项目的安全威胁模型里,攻击者拿到数据库的概率极低;即使拿到,SHA256+16字节随机盐值的彩虹表攻击成本也极高。如果后续真有安全审计需求,只需修改 get_password_hash 和 verify_password 两个函数,其他代码零改动。
问题2:数据库方案推倒重来的成本
当发现JSON真实结构与预期不符时,第一版数据库方案已经写了一部分代码。要不要将就?
决策:毫不犹豫推倒重来。原因很简单——数据库是地基,地基歪了,上面的楼怎么盖都会有问题。前期多花半天重构,后面省下的是无数个“为什么显示不对”“为什么关联不上”的排查时间。
教训:数据结构解析一定是方案设计的前提。不要凭“初步观察”下定论,必须深入解析每一条字段,确认数据真实结构后再动手设计数据库。
问题3:Redis连接断开导致中间件报500
现象:服务器长时间未请求后,第一个请求返回500错误,后续请求正常。
排查:Redis默认有连接超时机制,空闲连接会被断开。中间件在Redis断开时抛出未捕获异常。
解决:封装Redis客户端时增加重连机制——捕获连接异常后自动重新建立连接。