“灵语星火”第二阶段团队记录(二)
2026/5/8 6:35:34 网站建设 项目流程

一、后端架构规范: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:returnFalse

3.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_jwt

4.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 带正确Token200,返回用户信息
无Token访问GET /auth/me 不带Token401
过期Token访问GET /auth/me 带过期Token401
异地登录踢人设备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客户端时增加重连机制——捕获连接异常后自动重新建立连接。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询