Python+微信小程序背单词系统开发:FastAPI与SQLite全栈实战

发布时间:2026/10/4 4:40:31
Python+微信小程序背单词系统开发:FastAPI与SQLite全栈实战 简介这是一套基于Python与微信小程序的背单词系统完整开发实例适合具备一定Python基础的后端开发、小程序开发及教育技术爱好者用于解决传统背单词枯燥、遗忘快、缺少个性化规划等痛点。内容从项目背景、目标、架构设计到核心算法逐一展开重点讲解艾宾浩斯记忆曲线在单词推荐与复习计划中的应用详细展示数据库设计、用户行为跟踪、智能单词推送、复习计划、错词管理、学习成效可视化及前后端交互逻辑并给出接口规范、图形界面实现、安全隐私保护与部署方案可直接迁移至在线教育、语言备考等场景。包体为一个Word文档压缩包约77KB虽体量不大但章节结构完整含项目模型描述、代码示例与目录导航便于按章节实战演练。该资源已有74人学习下载适合希望从零搭建智能背单词系统全栈实现并据此进行功能扩展的开发者为后续接入智能发音评测或多语言支持留下清晰接口。1. 背单词项目为什么选“Python 微信小程序”以及这套实例能让你少走哪些弯路做教育技术方向的课程设计或毕设背单词系统是出现频率最高的一类选题。需求明确、用户场景清楚、数据模型不复杂却能完整覆盖前后端、数据库和界面设计。但动手的人常常卡在同一个地方用 Django 写后端觉得重纯网页版又没法在手机上随时背等把微信小程序前端调通又被 Python 版本、数据库编码、请求域名校验这些细节绊住。这篇文章要拆解的是一个基于 Python 与微信小程序的背单词系统完整实例FastAPI 提供接口SQLite 存词库与学习记录PyQt6 做成管理端 GUI微信小程序负责学生端打卡、刷词与错题复习。跟着这条路线走完你会清楚每个文件为什么存在、每个接口怎么设计以及那些最容易翻车的地方。2. 系统拆解后端、小程序端与管理端 GUI 各自该管什么先定边界再写代码2.1 三个端的数据流背单词的增删改查如何流转任何背单词系统核心都是围绕“词”的增删改查。学生端看到的是单词卡片点击“认识/不认识”结果要写回数据库管理端看到的是词库表格新增一套四级词汇或修改某个音标也要写回数据库。如果一上来就画界面很容易把业务逻辑散落在各处后面加一个“每日学习计划”就要改动很多地方。所以第一步不是写代码而是界定三个端各自负责什么。我一般会把数据流分成三条线。第一条是词库线管理端 GUI 把单词批量导入到 word 表小程序端通过 GET /words 读取今天要学的词第二条是学习记录线小程序端每完成一次判断向后端 POST /records后端写进 study_record 表同时更新 word 的 familiarity 字段第三条是错题线用户点“不认识”时后端自动把记录写入 wrong_words 表小程序端从 /wrong_words 拉取错题列表。三条线在逻辑上独立但在数据库层通过 user_id 和 word_id 关联。这样拆分的好处很直接任何一个端出问题你能快速判断是接口问题还是数据问题。比如小程序端单词列表加载不出来先看 GET /words 返回有没有数据如果有数据但页面不显示那是前端渲染问题如果接口直接报 500那再去查数据库。下面所有的代码和表结构都按这个边界来组织避免“后端返回的字段前端不知道”“前端传的参数后端接不到”这类联调黑洞。还有一种常见的错误做法是让小程序直连 SQLite。千万不要这样微信开发者工具里的小程序运行在沙箱环境根本无法直接打开电脑上的数据库文件即使通过 Web 能力勉强做也会把数据库地址和账号暴露给所有用户。后端 API 这一层必须存在它既是安全边界也是未来把 SQLite 换成 MySQL、把 FastAPI 换成其他框架的替换点。2.2 数据库表设计单词表、错题表、学习计划表的字段与关系数据库是背单词系统的地基。表设计不合理后面每个接口都别扭。这里以 SQLite 为例给出核心表的建表语句可以直接复制到 database.py 的初始化脚本里-- 用户表小程序端用存储微信登录后的 openid CREATE TABLE user ( id INTEGER PRIMARY KEY AUTOINCREMENT, openid TEXT NOT NULL UNIQUE, nickname TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 单词表核心词库 CREATE TABLE word ( id INTEGER PRIMARY KEY AUTOINCREMENT, spelling TEXT NOT NULL, phonetic TEXT, meaning TEXT NOT NULL, example TEXT, familiarity INTEGER DEFAULT 0, book_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 学习记录表每次答题的结果 CREATE TABLE study_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, word_id INTEGER NOT NULL, is_known INTEGER NOT NULL, answered_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES user(id), FOREIGN KEY (word_id) REFERENCES word(id) ); -- 错题表不认识/答错的单词重复出现 CREATE TABLE wrong_words ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, word_id INTEGER NOT NULL, wrong_count INTEGER DEFAULT 1, last_wrong_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, word_id), FOREIGN KEY (user_id) REFERENCES user(id), FOREIGN KEY (word_id) REFERENCES word(id) ); -- 学习计划表记录用户每天应该学的新词和复习词数量 CREATE TABLE study_plan ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, plan_date DATE NOT NULL, new_words INTEGER DEFAULT 20, review_words INTEGER DEFAULT 40, completed INTEGER DEFAULT 0, UNIQUE(user_id, plan_date), FOREIGN KEY (user_id) REFERENCES user(id) );注意几个细节。familiarity 是一个 0 到 5 的整数表示熟悉度每次答对加 1答错清零用于后面做简单记忆曲线。wrong_words 表用了 UNIQUE(user_id, word_id)同一个错题只保留一条记录通过 wrong_count 累计错误次数避免每错一次就插一行导致学习记录表无限膨胀。study_plan 的 UNIQUE 约束保证一个用户每天只有一条计划前端打卡时直接 UPDATE completed 字段而不是反复 INSERT 产生脏数据。这段建表语句里字段类型我刻意保持简单。spelling 和 meaning 都用了 TEXT因为单词和释义长度不定不需要像 MySQL 那样指定 VARCHAR(255)。DATETIME DEFAULT CURRENT_TIMESTAMP 是 SQLite 的特色插入时不用手动传时间业务代码里少写一行。如果你后续要迁移到 MySQL把 AUTOINCREMENT 改成 AUTO_INCREMENTDATETIME 默认值改成 CURRENT_TIMESTAMP 即可其他部分基本可以不动。2.3 为什么用 FastAPI SQLite而不是 Django MySQL 起步选型往往比写代码更影响心态。这个项目用 FastAPI 而不是 Django主要看中三点依赖轻、异步友好、接口文档自动生成。背单词系统接口数量不超过十几个Django 的 admin、ORM、模板那一套在这里用不全反而增加理解成本。FastAPI 用 Pydantic 做参数校验写接口时顺手把数据格式定死小程序端照着自动生成的 /docs 页面调基本不会出“字段名不一致”的错。数据库方面SQLite 对桌面管理端和单机测试非常友好。它不需要单独装数据库服务Python 标准库自带 sqlite3把项目复制到另一台电脑也能直接跑。MySQL 在 Windows 上安装、改字符集、配权限每一步都能劝退一批初学者。当然SQLite 不适合高并发写入但课程设计、教育产品原型、几百个用户同时在线的情况下完全够用。把 SQLite 换成 MySQL真正要改的只有数据库连接部分和少数 SQL 方言业务逻辑不用动。管理端 GUI 我用 PyQt6原因是 Python 生态里做桌面端最稳的就是 Qt。背单词的管理员需要浏览词库、批量导入、修改词条一个带表格和按钮的窗口足够。PyQt6 的 QTableView 配合 QSqlTableModel 可以直接绑定数据库表做到界面上的增删改查自动同步这比用 Tkinter 手写事件循环省力得多。下表是这套选型和常见的 Django MySQL 方案对比你可以根据自己答辩时的侧重点选对比维度FastAPI SQLite PyQt6Django MySQL Admin环境安装成本3 个 pip 包即可需要装 mysqlclient 或 PyMySQL接口文档自动生成 /docs需要额外配 DRF前后端分离程度天然适合小程序自带模板做前后端分离要改结构并发能力低并发够用高并发更好管理端实现自己写 PyQt6 窗口用 Django Admin 改配置如果题目里明确要求“管理端 GUI”PyQt6 是更贴合题目的选择因为你确实交出了一个桌面程序而不是浏览器里的后台。如果你更熟悉 Django也可以把 FastAPI 换成 Django REST Framework表结构不变接口逻辑几乎一样。选型没有绝对答案但“先定边界、再定表、最后写接口”的顺序是通用的。3. 用 Python 把背单词后端跑通API 设计、代码与关键参数说明3.1 最小可运行的 FastAPI 项目结构与依赖安装当你把后端、小程序、GUI 的边界理清后就可以从后端开始动手。推荐的项目结构如下文件不多但每个文件职责单一word_app/ ├── main.py # FastAPI 入口路由和业务逻辑 ├── database.py # 数据库连接、建表脚本 ├── models.py # Pydantic 请求模型 ├── requirements.txt ├── gui/ │ └── admin_gui.py # PyQt6 管理端 └── tests/ └── smoke.py # 接口自测脚本依赖安装很简单在项目目录下运行pip install fastapi uvicorn pyqt6这里不装任何数据库驱动因为 SQLite 属于 Python 标准库。装完以后用下面的命令启动后端uvicorn main:app --reload --port 8000说明--reload 是开发模式改代码后自动重启省去手动 kill 进程的麻烦--port 指定端口默认 8000小程序开发者工具里请求本地服务就靠这个端口。等界面写好以后真机预览时还需要把 --host 改成 0.0.0.0让手机能通过局域网 IP 访问这一点会在第 5 章展开。database.py 里最容易被忽略的是 check_same_threadFalse。FastAPI 默认的多线程模型里不同请求可能在不同线程里拿到同一个连接SQLite 会报 “SQLite objects created in a thread can only be used in that same thread”。我一般不用全局连接而是每次请求都通过 get_db() 获得新连接函数返回时 close()。下面是一个最小实现# database.py import sqlite3 from contextlib import closing DB_PATH words.db def get_db(): conn sqlite3.connect(DB_PATH, check_same_threadFalse) conn.row_factory sqlite3.Row # 让查询结果支持字段名访问 return conn def init_db(): with closing(get_db()) as conn: conn.executescript( CREATE TABLE IF NOT EXISTS word (...); CREATE TABLE IF NOT EXISTS study_record (...); )row_factory 设置为 sqlite3.Row 后cursor.fetchall() 返回的每一行都可以用 row[spelling] 访问而不是只能按下标取。这能让接口返回值更友好也避免在小程序端看到的字段变成奇怪的数组嵌套。init_db() 里的 executescript 是 SQLite 特有的批量执行方法它会把分号分隔的建表语句一次性跑完。3.2 单词随机抽取与记忆曲线接口的实现细节背单词后端最核心的是两个接口获取今日单词、提交学习记录。下面的代码是可直接运行的精简版没有使用 SQLAlchemy因为这种量级的项目用原生 sqlite3 足够清楚# main.py from fastapi import FastAPI, Query from database import get_db, init_db app FastAPI() app.on_event(startup) def startup(): init_db() app.get(/words/today) def get_today_words(user_id: int, limit: int Query(20, ge1, le100)): 获取今天的单词优先返回错题再从低熟悉度单词随机补足 conn get_db() # 错题优先最多占 30% wrong conn.execute( SELECT w.* FROM wrong_words wr JOIN word w ON wr.word_idw.id WHERE wr.user_id? AND wr.wrong_count0 ORDER BY wr.last_wrong_at ASC LIMIT ?, (user_id, max(1, limit * 3 // 10)) ).fetchall() # 从熟悉度低于 2 的单词里随机抽剩余部分 already [row[id] for row in wrong] placeholders ,.join(? * len(already)) if already else NULL rest conn.execute( fSELECT * FROM word WHERE familiarity2 AND id NOT IN ({placeholders}) ORDER BY RANDOM() LIMIT ?, already [limit - len(wrong)] ).fetchall() conn.close() return {words: [dict(row) for row in wrong rest]} app.post(/records) def submit_record(user_id: int, word_id: int, is_known: int): 提交答题结果更新熟悉度和错题表 conn get_db() conn.execute( INSERT INTO study_record(user_id, word_id, is_known) VALUES(?,?,?), (user_id, word_id, is_known) ) if is_known: conn.execute( UPDATE word SET familiarity MIN(familiarity1, 5) WHERE id?, (word_id,) ) conn.execute( DELETE FROM wrong_words WHERE user_id? AND word_id?, (user_id, word_id) ) else: conn.execute( UPDATE word SET familiarity0 WHERE id?, (word_id,) ) conn.execute( INSERT INTO wrong_words(user_id, word_id, wrong_count) VALUES(?,?,1) ON CONFLICT(user_id, word_id) DO UPDATE SET wrong_countwrong_count1, last_wrong_atCURRENT_TIMESTAMP, (user_id, word_id) ) conn.commit() conn.close() return {status: ok}这里有两个关键参数。第一个是 limit 的范围约束Query(20, ge1, le100) 用 Pydantic 的校验把一次请求限制在 1 到 100 之间防止小程序端因为滚动频繁把整个词库一次拉走。第二个是熟悉度上限MIN(familiarity1, 5) 保证熟悉度最高是 5否则同一个词背一年熟悉度变成 500后面的“低熟悉度优先”就完全失效。错题优先的逻辑里还有一个细节ORDER BY last_wrong_at ASC 会把“错得最久”的单词排在最前面这是最朴素的间隔重复。如果你想做得更专业可以把 last_wrong_at 与当前时间做差按逾期天数排序。课程设计做到这里已经能解释清楚“为什么背单词系统不是简单随机出题”。注意POST /records 里我故意没有加事务装饰器。SQLite 的每个 INSERT 或 UPDATE 默认是独立事务但如果这两个操作中间崩了会出现学习记录写了、错题表没更新的情况。稳妥做法是在 conn.execute(BEGIN) 和 conn.commit() 之间包住所有写操作出现异常时 conn.rollback()。上面代码为了简洁省略了异常处理实际项目中一定要补上 try/except否则用户点一次“不认识”后系统崩溃数据会变得不一致。3.3 管理端 GUI 与数据库的联动用 PyQt6 做增删改查后端接口跑通后再写管理端 GUI。PyQt6 里最省力的思路是用 QSqlTableModel 直接操作 SQLite 表而不是自己维护一套数据模型。下面是一个词库管理窗口的骨架# gui/admin_gui.py import sys from PyQt6.QtWidgets import (QApplication, QMainWindow, QTableView, QPushButton, QVBoxLayout, QWidget, QLineEdit) from PyQt6.QtSql import QSqlDatabase, QSqlTableModel class AdminWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(背单词词库管理) db QSqlDatabase.addDatabase(QSQLITE) db.setDatabaseName(../words.db) if not db.open(): print(数据库打开失败) sys.exit(1) self.model QSqlTableModel(self) self.model.setTable(word) self.model.setEditStrategy(QSqlTableModel.EditStrategy.OnManualSubmit) self.model.select() self.table QTableView() self.table.setModel(self.model) self.table.horizontalHeader().setStretchLastSection(True) self.search QLineEdit() self.search.setPlaceholderText(输入拼写搜索) self.search.textChanged.connect(self.on_search) btn_add QPushButton(新增单词) btn_submit QPushButton(提交修改) btn_add.clicked.connect(self.on_add) btn_submit.clicked.connect(self.on_submit) layout QVBoxLayout() layout.addWidget(self.search) layout.addWidget(self.table) layout.addWidget(btn_add) layout.addWidget(btn_submit) container QWidget() container.setLayout(layout) self.setCentralWidget(container) def on_add(self): self.model.insertRow(self.model.rowCount()) def on_submit(self): if self.model.submitAll(): print(保存成功) else: print(保存失败请检查字段) def on_search(self, text): self.model.setFilter(fspelling LIKE %{text}%) self.model.select() app QApplication(sys.argv) window AdminWindow() window.show() sys.exit(app.exec())这段代码里最关键的参数是 setEditStrategy(QSqlTableModel.EditStrategy.OnManualSubmit)。它表示表格里的修改不会立刻写库而是等用户点“提交修改”后统一 submitAll。这么做能防止编辑到一半误触也让批量修改有后悔药。如果你想每次单元格编辑完立即保存可以改成 OnFieldChange但误操作很难回滚课程设计不太推荐。搜索框里的 setFilter 是 SQL 过滤这里必须拼接字符串因此存在 SQL 注入风险。管理端用于本机或内网时问题不大但如果未来做成 Web 管理后台一定要换成参数化查询。PyQt6 的 QSqlTableModel 不直接支持参数化 filter所以更推荐在数据量变大后改用 QSqlQuery 参数绑定。GUI 和管理端的本质是同一套数据库所以这里不需要任何网络接口。它和小程序端的区别在于小程序端只通过 HTTP 走 FastAPI管理端 GUI 直接连 SQLite 文件。如果你不想让两处代码都维护数据库结构可以让管理端也调用 FastAPI 的接口。但桌面 GUI 直接连库更简单答辩演示时不容易出现“后端没启动管理端一片空白”的尴尬。4. 微信小程序端从单词列表到每日打卡的页面实现4.1 小程序页面结构与顶部导航栏高度适配微信小程序的页面由 wxml、wxss、js、json 四个文件组成。背单词小程序建议把页面分成四个index 展示今日单词列表detail 展示单词卡片wrong 展示错题本plan 展示学习计划和打卡状态。目录结构如下pages/ ├── index/ │ ├── index.wxml │ ├── index.wxss │ ├── index.js │ └── index.json ├── detail/ ├── wrong/ └── plan/第一次做小程序的人最容易在顶部导航栏高度上栽跟头。默认导航栏高度在不同机型不一样如果你要自定义导航栏不能写死 64px 或 88px必须用胶囊按钮的位置计算。常见做法是在 app.js 的 onLaunch 里算一次// app.js App({ onLaunch() { const info wx.getSystemInfoSync() const menu wx.getMenuButtonBoundingClientRect() this.globalData.statusBarHeight info.statusBarHeight this.globalData.navBarHeight menu.bottom menu.top - info.statusBarHeight }, globalData: { statusBarHeight: 0, navBarHeight: 0 } })navBarHeight 的值等于胶囊按钮底部到状态栏底部的距离也就是导航栏内容区的高度。页面里拿到这个值后给自定义导航栏容器设置 padding-top 或 height内容就不会被状态栏遮挡。如果你直接用默认导航栏则不需要关心这些但教育类小程序往往希望顶部放搜索框和打卡状态自定义导航栏能做出更接近真实产品的效果。4.2 单词列表“加载更多”与分页请求的实现小程序端最常见的翻车点是“页面列表加载更多”功能。很多第一版是在 onReachBottom 里直接请求下一页但快速滑动时会连续触发多次导致数据重复或加载错乱。我一般会在 data 里维护 isLoading 和 hasMore 两个标志位并在请求前做拦截// pages/index/index.js Page({ data: { words: [], page: 1, pageSize: 20, hasMore: true, isLoading: false }, onLoad() { this.loadWords() }, loadWords() { if (this.data.isLoading || !this.data.hasMore) return this.setData({ isLoading: true }) wx.request({ url: http://127.0.0.1:8000/words/today, data: { user_id: 1, page: this.data.page, limit: this.data.pageSize }, success: (res) { const list res.data.words || [] this.setData({ words: this.data.words.concat(list), page: this.data.page 1, hasMore: list.length this.data.pageSize }) }, complete: () { this.setData({ isLoading: false }) } }) }, onReachBottom() { this.loadWords() } })关键参数是 hasMore。如果服务器返回的条数小于 pageSize说明到底了不再触发下一次请求。isLoading 是防止快速滚动时重复请求的锁在 success 或 fail 之后通过 complete 解锁。注意 success 里 setData 的对象是在请求发出时捕获的如果同时发多个请求需要用闭包或额外参数保存页码不能依赖 this.data.page 的实时值。这个项目并发低串行请求下没问题。这里的 url 是 127.0.0.1。微信开发者工具里可以通因为工具运行在电脑上能访问本机。真机预览时这个地址指向手机自己必须换成电脑的局域网 IP并在开发者工具里关闭域名校验。后面避坑章节会详细讲。补充一点关于“每日打卡”的逻辑。学习计划页可以这样调进入页面时请求 GET /plan?user_id1拿到今天的 new_words、review_words 和 completed 字段用户学完一组单词后按钮触发 POST /plan/checkin后端把 completed 置 1。前端不需要自己判断“是否完成”因为后端可能还要校验今天的记录数是否达标。把业务规则放后端小程序端只管展示和交互这是前后端分离项目里比较健康的协作方式。4.3 调用 Python API 的封装请求封装、登录态与错误处理如果每个页面都直接写 wx.request代码会非常散。我习惯先封装一个 request 模块把 BASE_URL、错误提示、Promise 化都放在里面// utils/request.js const BASE_URL http://127.0.0.1:8000 function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL path, method: method, data: data, header: { Content-Type: application/json }, success(res) { if (res.statusCode 200) { resolve(res.data) } else { wx.showToast({ title: 请求失败 res.statusCode, icon: none }) reject(res) } }, fail(err) { // fail 不等于业务失败更多是网络层问题 wx.showToast({ title: 网络异常, icon: none }) reject(err) } }) }) } module.exports { request, BASE_URL }这里的 Promise 化非常有用。页面里可以这样调用const { request } require(../../utils/request) Page({ async onLoad() { const data await request(/words/today, GET, { user_id: 1, limit: 20 }) this.setData({ words: data.words }) } })出现异常时可以用 try/catch 捕获不会让页面白屏。关于登录态很多背单词课程设计不真正接入微信登录而是在后端写一个 mock 接口 GET /mock_user返回 user_id1。这样小程序端不用处理 wx.login 换 token 的流程能节省大量时间。但你必须知道这不是真正的鉴权上线前要加 token 机制。真正的流程是 wx.login 拿 code后端用 code 换 openid然后返回一个自定义登录态 token小程序端在后续请求里通过 header 携带。这属于微信生态的内容不在本文展开。还有一点fail 回调里不只是断网域名没配置、TLS 版本不匹配、请求被拦截都会走到 fail。如果你在开发者工具里遇到 request:fail优先去“详情—本地设置”打开“不校验合法域名”。这一步解决不了再看后端有没有收到请求别在代码里瞎试。网络问题大多数时候不是 Python 后端的问题而是小程序安全策略在起作用调这类问题最怕把时间浪费在猜上。5. 落地避坑Python 版本、数据库同步和小程序联调的 5 个典型问题5.1 微信开发者工具里请求本地后端直接报 “fail url not in domain list”现象开发者工具里点击“学习”按钮控制台报错 request:fail url not in domain list后端没有任何请求日志。原因微信小程序默认只允许请求配置到微信公众平台的合法域名本地 IP、localhost 都不在白名单。开发者工具基于安全策略拦截了请求。解决在开发者工具右上角“详情—本地设置”里勾选“不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”。这个选项是开发阶段的后悔药但只对当前项目有效重新导入项目后需要再开一次。如果你提交体验版真机上同样要去微信公众平台“开发管理—服务器域名”里配置 request 合法域名并且小程序正式版要求域名必须备案且支持 HTTPS。课程设计答辩时建议直接演示开发者工具避免卡在域名备案上。5.2 Python 端写入中文乱码数据库里变成 “????”现象PyQt6 管理端输入“苹果”保存后数据库里显示 “????”FastAPI 通过 requests 测试接口时返回的中文正常但管理端写入就乱码。原因Windows 下 QSqlDatabase 连接 SQLite 时没有显式指定 UTF-8 编码或者 CSV 导入时用系统默认编码读取导致非 ASCII 字符被错误转码。解决在数据库连接后立即执行 PRAGMA encoding UTF-8;。同时CSV 导入时必须用 open(words.csv, encodingutf-8)不能省略 encoding 参数。如果 Windows 控制台打印中文报 UnicodeEncodeError可以在 gui 入口处加 sys.stdout.reconfigure(encodingutf-8)这只影响 GUI 的日志输出不影响数据入库。检查乱码时不要只盯数据库先用 Python 脚本直接 select 一条记录打印确认数据本身是否正确再把范围缩小到 PyQt6 显示层。5.3 小程序真机预览时查不到数据开发者工具却正常现象开发者工具里能正常出单词列表手机扫码预览后列表空白请求超时或连接失败。原因手机上访问 127.0.0.1:8000 指向手机自身而 FastAPI 服务监听在电脑的 127.0.0.1 上手机请求到不了电脑。解决把 FastAPI 启动命令改成 uvicorn main:app --host 0.0.0.0 --port 8000然后在小程序 utils/request.js 里把 BASE_URL 改成电脑的局域网 IP例如 http://192.168.1.23:8000。手机和电脑必须在同一个 Wi-Fi 下Windows 防火墙弹窗时要允许 Python 通过专用网络。如果这样还不通用手机浏览器直接访问 http://192.168.1.23:8000/docs 验证后端是否可达。这一步能排除 80% 的网络玄学问题。注意不要在小程序端把 BASE_URL 写成 http://localhost真机上一定失败。5.4 页面下拉加载更多出现重复数据现象在单词列表底部反复上拉同一批单词反复出现且每页数量不稳定。原因服务端 /words/today 里用了 ORDER BY RANDOM()每次请求的顺序不同。前端用 page 和 limit 做偏移分页时上一页末尾的单词可能被随机到下一页开头造成重复和遗漏。解决分页必须基于稳定排序。业务上今日单词推荐使用游标分页前端把当前页最后一个单词的 id 作为 next_cursor 传给后端后端使用 WHERE id ? ORDER BY id ASC LIMIT ? 返回下一页。如果还想保留“随机”的新鲜感可以每天第一次请求时把词库按某种规则洗牌后缓存后续请求按固定顺序返回。记住随机出题和稳定分页是两件事不要在同一个接口里混在一起。5.5 数据库同步与多端迁移SQLite 文件备份和 MySQL 改写的注意事项现象项目在开发电脑上一切正常换了一台电脑或把数据库拷给队友后小程序端数据对不上或者从 SQLite 迁到 MySQL 后某些接口报 “no such column”。原因SQLite 数据库是单文件但 FastAPI 项目里如果存在两个 words.db一个在项目根目录一个在 gui 目录管理端和小程序端实际操作的不是同一个库导致数据不一致。迁移到 MySQL 时SQLite 的 INTEGER PRIMARY KEY AUTOINCREMENT 和 CURRENT_TIMESTAMP 在 MySQL 里有不同写法直接复制会丢字段。解决在 database.py 里用绝对路径定义 DB_PATH例如 BASE_DIR / words.db不要用相对路径。PyQt6 管理端的 db.setDatabaseName 必须指向同一个绝对路径。迁移 MySQL 时把建表语句里的 AUTOINCREMENT 换成 AUTO_INCREMENTDATETIME DEFAULT CURRENT_TIMESTAMP 在 MySQL 8 里可以直接用旧版本要改成 TIMESTAMP DEFAULT CURRENT_TIMESTAMP。外键约束在 MySQL 里默认不开启需要额外加 FOREIGN_KEY_CHECKS。这些差异是数据库同步迁移中最常见的坑动手前先把两张表的建表语句并排对比而不是一把梭复制。6. 把这套背单词系统从“能跑”做到“抗打”验证方法、优化点与部署习惯6.1 用一条命令跑通接口冒烟测试后端写完不要急着切到小程序页面。我先写一个 tests/smoke.py每次改完接口就执行一次python tests/smoke.py# tests/smoke.py import requests BASE http://127.0.0.1:8000 r requests.get(f{BASE}/words/today, params{user_id: 1, limit: 20}) assert r.status_code 200, r.text words r.json().get(words, []) assert len(words) 0, 单词列表为空 print(f接口正常返回 {len(words)} 个单词)这个脚本只验证“通不通”不验证“对不对”。更完整的测试应该断言返回的单词结构包含 spelling、meaning、familiarity。如果你想让答辩更稳还可以用 pytest 写几个用例覆盖“错题优先”“熟悉度上限”“limit 越界返回 422”这三条核心逻辑。6.2 给 SQLite 加索引别等卡了再补背单词系统数据量小但 study_record 表会一直膨胀。在项目早期就加上这两个索引到几千条记录时查询速度不会明显退化CREATE INDEX idx_record_user ON study_record(user_id); CREATE INDEX idx_wrong_user ON wrong_words(user_id);加完索引后用 EXPLAIN QUERY PLAN SELECT * FROM study_record WHERE user_id1 验证查询是否走了索引。SQLite 的 EXPLAIN 输出会显示 “SEARCH study_record USING INDEX”看到这个就可以放心了。6.3 部署习惯先用 HTTPS再谈优化课程设计通常用开发者工具演示但如果你想把项目放到服务器上展示就要面对微信小程序的 HTTPS 强制要求。FastAPI uvicorn 本身不支持 HTTPS常见做法是前面挂一层 Nginx用 certbot 申请证书并反向代理到 127.0.0.1:8000。这个方案属于常规部署路径不会影响代码结构。部署前记得把数据库里的 mock 用户换掉否则别人能看到所有学习记录。我做过第一个背单词项目时最大的教训就是顺序反了先写小程序页面再写后端结果花了两个晚上排查一个 Python 缩进错误导致的接口 500。后来我养成的习惯是每写一个接口先用 requests 冒烟脚本测一遍再画页面。这个习惯让我后面加记忆曲线、错题本时几乎没为联调发过愁。教育技术这类项目亮点不在技术堆得多高而在每一步都能自圆其说数据库为什么这样设计、接口为什么这样分页、GUI 为什么直接连库。把这些讲清楚比炫技更能打动人。希望这个实例和这些踩坑记录能帮你把背单词系统做得更快、更稳也真正把它当成一个可以继续迭代的小产品而不只是一份交差的作业。希望帮到你。本文还有配套的精品资源点击获取