简介:这是一套基于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_INCREMENT,DATETIME 默认值改成 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 + PyQt6 | Django + 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_thread=False。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_thread=False) 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, ge=1, le=100)): """获取今天的单词:优先返回错题,再从低熟悉度单词随机补足""" conn = get_db() # 错题优先,最多占 30% wrong = conn.execute( "SELECT w.* FROM wrong_words wr JOIN word w ON wr.word_id=w.id " "WHERE wr.user_id=? AND wr.wrong_count>0 " "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( f"SELECT * FROM word WHERE familiarity<2 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(familiarity+1, 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 familiarity=0 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_count=wrong_count+1, last_wrong_at=CURRENT_TIMESTAMP", (user_id, word_id) ) conn.commit() conn.close() return {"status": "ok"}这里有两个关键参数。第一个是 limit 的范围约束:Query(20, ge=1, le=100) 用 Pydantic 的校验把一次请求限制在 1 到 100 之间,防止小程序端因为滚动频繁把整个词库一次拉走。第二个是熟悉度上限:MIN(familiarity+1, 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(f"spelling 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_id=1,拿到今天的 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_id=1。这样小程序端不用处理 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", encoding="utf-8"),不能省略 encoding 参数。如果 Windows 控制台打印中文报 UnicodeEncodeError,可以在 gui 入口处加 sys.stdout.reconfigure(encoding="utf-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_INCREMENT,DATETIME 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_id=1 验证查询是否走了索引。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 为什么直接连库。把这些讲清楚,比炫技更能打动人。希望这个实例和这些踩坑记录,能帮你把背单词系统做得更快、更稳,也真正把它当成一个可以继续迭代的小产品,而不只是一份交差的作业。希望帮到你。
本文还有配套的精品资源,点击获取