ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python+Flask+微信小程序:从零搭建全栈背单词系统

Python+Flask+微信小程序:从零搭建全栈背单词系统 想做一个能真正用起来的背单词系统用 Python 做后端、微信小程序做前端是我试过最顺手的技术组合。这个项目麻雀虽小五脏俱全MySQL 数据库负责单词和用户数据Flask 提供 API 接口小程序端负责日常背诵和复习交互再加一个 Tkinter 写的桌面管理端 GUI专门用来批量维护词库和查看学习数据。整套系统从零搭建到跑通前前后后折腾了两周踩了不少坑。很多朋友找我推荐练手项目我一般都会把这个方案拿给他们看。它最大的价值不是“又多了一个背单词 App”而是把一套完整业务系统的通用能力都串起来了用户登录、数据建模、接口设计、前后端联调、后台管理界面每一块都不深但连起来就是一条完整的链路。无论你是刚学完 Python 基础想做第一个全栈项目还是做课程设计需要一个带界面、带数据库、能跑通的完整示例这套东西都可以直接参考、改造、扩展。这篇文章不打算讲大道理直接把我的整体方案、表结构、接口设计、页面交互、核心代码、以及联调时踩过的坑一条条摆出来你可以按图索骥。1. 项目定位与整体架构小程序端、Python后端、管理端GUI怎么分工1.1 背单词产品的核心痛点是什么先说一个可能和你直觉相反的事实背单词产品拼的从来不是词库大不大、例句多不多而是“用户能不能每天打开”。我见过很多朋友手机里装了三四个背单词 App真正坚持超过一个月的少之又少。原因无非是打开成本高、反馈不及时、不知道今天该学什么。所以我做这个项目时第一优先级不是功能堆叠而是把“每天打开、30秒完成反馈”这件事做到顺滑。微信小程序正好符合这个场景。它不需要安装打开即用碎片时间等公交、排队、睡前里一个卡片页就能完成一次单词复习。而选择 Python 作为后端主要是考虑到词库处理和统计脚本的便利性Flask 又是轻量框架里写接口最快的那一类没有太多额外记忆负担。再加上一个桌面管理端 GUI完全是实际需求倒逼的频繁往数据库里插单词、改释义命令行列命令还是不如界面直观。1.2 整体架构与请求链路这个系统分成三端小程序端用户日常接触的界面负责登录、背单词、查看复习任务和统计数据。Python 后端一组 Flask REST 接口负责用户鉴权、任务下发、学习记录上报、复习计划生成。管理端 GUI用 Tkinter 写的桌面程序直连 MySQL负责词库批量导入、单词编辑、学习数据预览。请求链路大概是这样的用户在手机上打开小程序先通过wx.login拿到临时 code后端再用 code 换取微信 openid 并签发 token。后续每次请求小程序都带着 token 访问/api/...后端校验通过后读写数据库返回 JSON。管理员需要维护词库时打开桌面的 Tkinter 程序选择 CSV 导入或者直接在表格里增删改改动落库后小程序端刷新即可看到。这三端各管一摊事代码组织起来非常清晰小程序只关心界面后端只关心逻辑管理端只关心数据维护。任何一个端出问题都能快速定位。1.3 项目目录结构总览word_miniapp/ ├── backend/ # Python 后端服务 │ ├── app.py # Flask 主程序路由注册 │ ├── config.py # 数据库连接的配置 │ ├── db.py # 连接池封装 │ ├── api/ │ │ ├── auth.py # 登录与 token 校验 │ │ ├── task.py # 今日任务、学习上报 │ │ └── stats.py # 学习统计接口 │ ├── scheduler.py # 每日复习计划生成脚本 │ └── gui/ │ └── manager_gui.py # Tkinter 管理端 ├── miniprogram/ # 微信小程序前端 │ ├── app.js # 全局登录逻辑 │ ├── app.json # 页面注册与 tabBar │ ├── utils/ │ │ └── request.js # Promise 封装请求 │ └── pages/ │ ├── index/ # 今日任务首页 │ ├── study/ # 背单词卡片页 │ ├── dict/ # 词库浏览页 │ ├── stats/ # 学习统计页 │ └── profile/ # 个人中心 ├── data/ │ ├── word_books.csv # 词库导入文件 │ └── init.sql # 建表脚本 └── README.md每个文件职责都很单一这也是我刻意保持的项目规模不大时别急着上微服务、ORM 全家桶平铺直叙反而好维护。2. 数据库设计两张核心业务表怎么支撑“学习-复习-统计”闭环2.1 六张数据表的字段拆解表名核心字段作用useropenid, nickname, avatar, daily_target, created_at用户信息openid 是微信用户唯一标识word_bookname, word_count, description词书如 CET-4、雅思核心词汇wordbook_id, word, phonetic, meaning, example, example_trans, difficulty, audio_url单词详情归属某本词书study_recorduser_id, word_id, familiarity, review_times, last_review_date, next_review_date, status用户对每个单词的学习状态整个系统的核心表review_planuser_id, word_id, review_date, status每天要复习的单词计划调度器按日期取数daily_statuser_id, stat_date, new_words, review_words, mastered_words每日学习统计给首页和图表用这里最费心思的是study_record。它本质上是一张“用户-单词”的关联表记录了这个人对每个单词的熟练度。familiarity我用 0-5 表示熟练等级0 表示完全陌生5 表示基本掌握review_times记录累计复习次数next_review_date指下一次该复习的日期。你会发现这个表里没有去记用户哪一天学过而是直接记录“下一次该什么时候复习”因为复习计划只需要按日期扫描查询压力小很多。review_plan表面上看和study_record有部分重叠但两者的职责不同study_record表示“我学到哪了”review_plan表示“今天该复习哪些词”。前者是状态后者是任务。每天凌晨调度器会根据状态生成第二天的任务写入review_plan小程序端今天只需查这一张表就能知道用户的待办逻辑非常直白。2.2 建表 SQL 与关键索引设计CREATE DATABASE IF NOT EXISTS word_app DEFAULT CHARACTER SET utf8mb4; USE word_app; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) NOT NULL UNIQUE, nickname VARCHAR(64) DEFAULT , avatar VARCHAR(255) DEFAULT , daily_target INT DEFAULT 15, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE word_book ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, word_count INT DEFAULT 0, description VARCHAR(255) DEFAULT ) ENGINEInnoDB; CREATE TABLE word ( id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, word VARCHAR(128) NOT NULL, phonetic VARCHAR(128) DEFAULT , meaning VARCHAR(255) NOT NULL, example TEXT, example_trans TEXT, difficulty TINYINT DEFAULT 3, audio_url VARCHAR(255) DEFAULT , UNIQUE KEY uk_word_book (book_id, word) ) ENGINEInnoDB; CREATE TABLE study_record ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, word_id INT NOT NULL, familiarity TINYINT DEFAULT 0, review_times INT DEFAULT 0, last_review_date DATE DEFAULT NULL, next_review_date DATE DEFAULT NULL, status TINYINT DEFAULT 0 COMMENT 0未掌握 1已掌握 2已收藏, UNIQUE KEY uk_user_word (user_id, word_id), KEY idx_next_review (user_id, next_review_date) ) ENGINEInnoDB; CREATE TABLE review_plan ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, word_id INT NOT NULL, review_date DATE NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待复习 1已完成, KEY idx_review_date (user_id, review_date) ) ENGINEInnoDB; CREATE TABLE daily_stat ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, stat_date DATE NOT NULL, new_words INT DEFAULT 0, review_words INT DEFAULT 0, mastered_words INT DEFAULT 0, UNIQUE KEY uk_date_user (stat_date, user_id) ) ENGINEInnoDB;建表时有几个细节值得留意。第一所有表都用utf8mb4字符集否则例句里的表情符号、特殊字符可能入库报错。第二study_record上加了(user_id, word_id)的唯一索引这不止是为了查询快更是为了防止同一个用户对同一个单词产生多条学习记录——这个问题在后面联调时坑过我一次。第三review_plan的索引方向是(user_id, review_date)因为查询条件是“某个用户的某一天”这种组合顺序最贴合实际请求。2.3 词库初始化从CSV到数据库的批量导入词库数据可以从开源词库项目或公开的词典数据整理成 CSV三列起步单词、释义、音标例句看情况补。我自己用的是这样的格式word,phonetic,meaning,example,example_trans,difficulty abandon,/əˈbændən/,v. 放弃抛弃,He abandoned his car in the snow.,他把车弃在雪地里。,2 ability,/əˈbɪləti/,n. 能力才能,She has the ability to solve the problem.,她有能力解决这个问题。,2导入脚本我用纯csv模块加批量提交一次五千条没问题import csv import pymysql conn pymysql.connect(host127.0.0.1, userroot, password123456, databaseword_app, charsetutf8mb4) cursor conn.cursor() with open(data/word_books.csv, encodingutf-8) as f: reader csv.DictReader(f) rows [] for line in reader: rows.append((line[word], line[phonetic], line[meaning], line[example], line[example_trans], int(line[difficulty]))) sql INSERT IGNORE INTO word (book_id, word, phonetic, meaning, example, example_trans, difficulty) VALUES (1, %s, %s, %s, %s, %s, %s) cursor.executemany(sql, rows) conn.commit()INSERT IGNORE配合uk_word_book(book_id, word)唯一索引重复导入同一批数据时自动跳过不会把词库搞出大量重复行。第一次导入后顺手更新一下word_book.word_countcursor.execute(UPDATE word_book SET word_count (SELECT COUNT(*) FROM word WHERE book_id 1) WHERE id 1) conn.commit()2.4 记忆曲线如何落进表结构遗忘曲线落到代码里很简单就是“隔几天再让你见一次”。我采用的复习间隔是熟练度对应操作下一次复习间隔0-1单词很陌生或连续答错第2天2-3有点印象但不牢固第4天4基本记住第7天5已掌握第15天后进入长期维护这套间隔用study_record.next_review_date字段保存不需要复杂的实时算法。每天凌晨的调度器做一件事扫描所有next_review_date 今天的记录把它们写入review_plan作为今日复习任务。这样设计的好处是用户永远不会因为“上次是七小时前学的”而在同一天重复刷同一个词一切以自然日为粒度简单、稳定、好解释。3. 后端接口实现Flask 如何把登录、任务、复习串起来3.1 Flask 项目骨架与统一返回格式后端我用的 Python 3.9 Flask 2.x PyMySQL没有上 ORM。原因很简单这个项目表关系不复杂SQL 写在 DAO 层里反而比迁就 ORM 的查询语法更直观。先看app.py的主结构from flask import Flask, jsonify, request from api.auth import auth_api from api.task import task_api from api.stats import stats_api app Flask(__name__) app.json.ensure_ascii False # 返回中文不转义Flask 2.2 app.register_blueprint(auth_api, url_prefix/api) app.register_blueprint(task_api, url_prefix/api) app.register_blueprint(stats_api, url_prefix/api) if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)为了所有接口吐给小程序的数据格式一致我封装了一个统一的返回函数def ok(dataNone, msgsuccess): return jsonify({code: 0, msg: msg, data: data}) def fail(msgerror, code1): return jsonify({code: code, msg: msg, data: None})小程序端拿着code 0判断成功读取data逻辑统一前端请求封装也少写很多分支。3.2 登录接口code换openid并签发token小程序的登录和传统账号密码不一样前端调用wx.login得到临时code后端拿 code 通过微信官方接口换openid。openid 是用户在这个小程序里的唯一 ID业务系统直接用这个 ID 当用户名。import requests from flask import Blueprint, request, jsonify auth_api Blueprint(auth, __name__) APPID your_appid SECRET your_secret auth_api.route(/login, methods[POST]) def login(): code request.json.get(code) if not code: return fail(缺少code参数) url https://api.weixin.qq.com/sns/jscode2session params { appid: APPID, secret: SECRET, js_code: code, grant_type: authorization_code } resp requests.get(url, paramsparams).json() openid resp.get(openid) if not openid: return fail(登录失败请重试) # 首次登录则插入用户 user find_user_by_openid(openid) if not user: create_user(openid) token generate_token(openid) return ok({token: token, user: get_user_info(openid)})注意generate_token我这里没有用微信返回的session_key直接当 token而是自己生成一个随机串存到数据库中并设置七天过期。这么做的原因是session_key是微信用于解密敏感信息的密钥不适合当作业务 token 长期使用自己生成 token 可以控制过期时间也能在后台主动踢人下线。3.3 今日任务接口新词与复习词合并下发按我的设计用户打开首页会触发两件事拉取今天要新学的单词拉取今天要复习的单词。新词来自“从未出现在用户 study_record 里的词”复习词来自今天的review_plan。合并下发后小程序端一次性渲染减少请求次数。task_api.route(/task/today, methods[GET]) def today_task(): user_id get_current_user_id(request.headers.get(Token)) page request.args.get(page, 1, typeint) limit request.args.get(limit, 10, typeint) # 新词尚未出现在用户学习记录里的词 new_words get_new_words(user_id, page, limit) # 复习词今日复习计划 review_words get_review_words(user_id, page, limit) return ok({ new_words: new_words, review_words: review_words, new_total: count_new_words(user_id), review_total: count_today_review(user_id) })get_new_words的 SQL 核心一句话就能说清用LEFT JOIN把有学习记录的词排除掉只留下从未见过的SELECT w.* FROM word w LEFT JOIN study_record sr ON sr.word_id w.id AND sr.user_id %s WHERE w.book_id %s AND sr.id IS NULL ORDER BY RAND() LIMIT %s关于复习队列我要多一点考虑如果用户断了两三天复习任务会积压很多一次全塞给用户很容易劝退。所以我设了每日复习上限新词默认limit10复习默认limit30超出的部分自动顺延到下一天。宁可每天少学几个也不要让用户一打开就看到一个几十个词的待办列表。3.4 学习上报接口一句话更新熟练度与复习计划用户点完“认识/不认识”之后小程序会把单词 ID 和反馈发给后端。后端要做的动作更新study_record的熟练度和下一次复习日期同时把今天的review_plan标记为已完成并更新daily_stat统计。task_api.route(/task/report, methods[POST]) def report(): user_id get_current_user_id(request.headers.get(Token)) word_id request.json.get(word_id) feedback request.json.get(feedback) # know / unknown / half record find_record(user_id, word_id) if record: # 已有的学习记录按反馈调节熟练度 if feedback know: new_familiarity min(5, record[familiarity] 1) elif feedback unknown: new_familiarity max(0, record[familiarity] - 2) else: new_familiarity max(0, record[familiarity] - 1) next_date compute_next_review(new_familiarity) update_record(user_id, word_id, new_familiarity, next_date) else: # 第一次学习新词 create_record(user_id, word_id, 1) incr_stat(user_id, new_words) mark_review_done(user_id, word_id) return ok({next_review: next_date if record else today()})这个接口是整个系统的心脏。每次上报都执行三个动作改状态、消任务、统计。注意统计和任务更新应该放在事务里避免出现复习计划已经完成但统计没加上去的脏数据。我在项目里用pymysql的conn.begin()包了一下这几个写操作单独封装成update_report_transaction。4. 小程序端页面与交互设计把背单词压缩到30秒内4.1 页面结构与tabBar设置小程序端用原生框架写不引第三方 UI 库。原生组件足够应付卡片页和列表引入框架反而增加包体积和首次加载耗时。页面规划是五个首页今日任务、词库、学习、统计、我的。{ pages: [ pages/index/index, pages/dict/dict, pages/study/study, pages/stats/stats, pages/profile/profile ], tabBar: { list: [ { pagePath: pages/index/index, text: 首页 }, { pagePath: pages/dict/dict, text: 词库 }, { pagePath: pages/study/study, text: 学习 }, { pagePath: pages/stats/stats, text: 统计 }, { pagePath: pages/profile/profile, text: 我的 } ] } }把“学习”页放在 tabBar 正中间是我有意为之。用户点开小程序最频繁的入口就是每天打卡这个动作把它放在黄金位置比任何引导都好使。4.2 请求封装与登录态管理小程序端所有接口请求我统一封装成 Promise。登录 token 存在wx.storage里每次请求自动带上遇到 401 就重新走登录流程。封装的核心逻辑const BASE_URL https://your-domain.com/api function request(path, method GET, data {}) { return new Promise((resolve, reject) { const token wx.getStorageSync(token) wx.request({ url: BASE_URL path, method, data, header: { Content-Type: application/json, Token: token }, success(res) { if (res.statusCode 401) { login().then(() resolve(request(path, method, data))) } else if (res.data res.data.code 0) { resolve(res.data.data) } else { wx.showToast({ title: res.data.msg || 请求失败, icon: none }) reject(res) } }, fail: reject }) }) } module.exports { request }关于这个封装有几个细节。第一401 时自动重新登录并重发原请求用户无感知这是提升体验很关键的一环第二res.data.code 0是后端统一定义的成功码小程序端只认这一种状态第三失败时统一wx.showToast业务代码里就不用到处写错误提示了。登录逻辑放在app.js的onLaunch里冷启动就先看一眼有没有 token没有就调wx.login换 code然后用 code 请求后端/api/login把 token 写回 storage。4.3 背单词卡片交互实现背单词页是这个项目的核心界面。我的设计是一张卡片显示一个单词顶部单词和音标中间释义和例句底部两个按钮“认识/模糊/不认识”。为了让操作足够快按钮只放大的不做下拉菜单。先看 WXML 的结构view classcard {{flipped ? flipped : }} bindtapflipCard view classcard-front text classword{{current.word}}/text text classphonetic{{current.phonetic}}/text text classmeaning{{current.meaning}}/text text classtip点击卡片查看例句/text /view view classcard-back text classexample{{current.example}}/text text classexample-trans{{current.example_trans}}/text /view /view view classactions button classbtn unknown bindtapmarkUnknown不认识/button button classbtn half bindtapmarkHalf模糊/button button classbtn know bindtapmarkKnow认识/button /viewCSS 翻转效果用transform: rotateY(180deg)配合backface-visibility: hidden实现卡片正面和背面各占一面。需要注意翻转动画在小程序里对老机型支持一般我在真机上测过这里只保留 transform 和 opacity 的过渡避免动画卡顿。JS 部分的交互逻辑Page({ data: { list: [], currentIndex: 0, current: null, flipped: false }, onLoad() { this.loadTask() }, async loadTask() { const data await request(/task/today?limit10) this.setData({ list: data.new_words, current: data.new_words[0] }) }, flipCard() { this.setData({ flipped: !this.data.flipped }) }, async mark(feedback) { const { current, currentIndex, list } this.data await request(/task/report, POST, { word_id: current.id, feedback: feedback }) if (currentIndex list.length - 1) { this.setData({ currentIndex: currentIndex 1, current: list[currentIndex 1], flipped: false }) } else { wx.showToast({ title: 今日任务完成, icon: success }) } }, markKnow() { this.mark(know) }, markHalf() { this.mark(half) }, markUnknown() { this.mark(unknown) } })这里有一个很重要的小细节用户点击三个按钮后需要立刻把按钮置灰或者加 loading防止网络慢的时候重复上报同一个单词。这个坑我后面专门排查过完整过程放在 6.2 节。4.4 首页统计、词库浏览与复习提醒首页不需要太复杂展示三个数字今日新词、今日复习、已完成进度。这些数据在/task/today接口里已经返回页面onShow时重新拉一次即可。复习提醒方面除了订阅消息这种受微信模板限制的方式之外更实际的方案是在首页顶部放一个待复习角标比如“今日还有 12 个词待复习”用户一打开就有紧迫感还不依赖订阅授权。词库浏览页相对简单顶部一个搜索框下面是当前词书的单词列表支持按难度筛选。搜索用的是后端接口里的LIKE %keyword%词库上万词以后我会再换成全文索引不过当前规模下这个接口响应速度完全够。词库页的定位是让用户提前看看今天要学的词长什么样真正的背诵动作还是集中在学习页。统计页的图表数据从/api/stats按月聚合返回前端用简单的 canvas 柱状图就行不必为了一个小项目引入整套图表库。5. 核心代码详解连接池、调度算法与管理端GUI5.1 数据库连接池封装后端所有 SQL 都通过一个db.py拿连接。项目初期我是每次请求现连 MySQL压测时发现并发一上来频繁报Too many connections加上 Flask 开发服务器默认线程池一多连接数就爆。后来换成连接池才稳定下来。from dbutils.pooled_db import PooledDB import pymysql pool PooledDB( creatorpymysql, maxconnections20, mincached2, maxcached10, blockingTrue, host127.0.0.1, userroot, password123456, databaseword_app, charsetutf8mb4, autocommitFalse ) def get_conn(): return pool.connection()解释一下参数maxconnections20限制连接池最多 20 条连接超过后blockingTrue会让请求排队等待而不是无限新建连接mincached2保持两个空闲连接预热避免高并发时冷启动。所有业务代码统一with get_conn() as conn拿连接用完自动归还逻辑一清二楚。5.2 记忆曲线调度算法复习日期计算不复杂关键是把规则集中写在一个函数里方便调整from datetime import date, timedelta def compute_next_review(familiarity): today date.today() if familiarity 1: return today timedelta(days1) elif familiarity 2: return today timedelta(days2) elif familiarity 3: return today timedelta(days4) elif familiarity 4: return today timedelta(days7) else: return today timedelta(days15) def generate_daily_review_plan(user_id, limit30): 把到期未复习的记录写入今日复习计划超过limit部分顺延 conn get_conn() cursor conn.cursor() cursor.execute( SELECT word_id FROM study_record WHERE user_id %s AND next_review_date CURDATE() AND status ! 2 ORDER BY familiarity ASC LIMIT %s , (user_id, limit)) plans [(user_id, row[0], date.today().isoformat(), 0) for row in cursor.fetchall()] cursor.executemany( INSERT INTO review_plan (user_id, word_id, review_date, status) VALUES (%s, %s, %s, %s) ON DUPLICATE KEY UPDATE status 0 , plans) conn.commit()注意这句排序ORDER BY familiarity ASC它的意思是把熟练度低的词排在前面优先复习。毕竟最需要复习的永远是“快忘掉”的那批而不是早就掌握了的词。每天凌晨我跑一个脚本全量用户扫一遍把到期的记录生成复习计划。用户下次打开小程序时/task/today只需要查review_plan不用实时计算遗忘曲线接口响应时间能稳定在几十毫秒。5.3 Tkinter管理端GUI词库维护与数据预览为什么这个项目需要一个管理端 GUI我实际开发中最大的感受是词库不是一次导入就完事的后面会频繁增删改——某个单词的释义写错了、一个词书要删除、需要临时调整难度。如果这一切都靠命令行 SQL 操作很容易出错。后来我花半天用 Tkinter 写了个图形界面管理词库的效率提升非常明显。import tkinter as tk from tkinter import ttk, filedialog, messagebox from db import get_conn class ManagerGUI: def __init__(self): self.root tk.Tk() self.root.title(背单词系统 - 词库管理端) self.root.geometry(800x500) # 顶部工具栏 toolbar tk.Frame(self.root) toolbar.pack(sidetk.TOP, filltk.X) tk.Button(toolbar, text导入CSV, commandself.import_csv).pack(sidetk.LEFT, padx5, pady5) tk.Button(toolbar, text刷新, commandself.refresh_words).pack(sidetk.LEFT, padx5, pady5) # 单词列表 self.tree ttk.Treeview(self.root, columns(id, word, meaning, difficulty), showheadings) self.tree.heading(id, textID) self.tree.heading(word, text单词) self.tree.heading(meaning, text释义) self.tree.heading(difficulty, text难度) self.tree.pack(filltk.BOTH, expandTrue) def refresh_words(self): # 清空表格后从数据库拉取最新数据 for row in self.tree.get_children(): self.tree.delete(row) conn get_conn() cursor conn.cursor() cursor.execute(SELECT id, word, meaning, difficulty FROM word LIMIT 500) for r in cursor.fetchall(): self.tree.insert(, tk.END, valuesr) conn.close() def import_csv(self): path filedialog.askopenfilename(filetypes[(CSV, *.csv)]) if not path: return # 复用 2.3 节的批量导入逻辑 import_csv_to_db(path) messagebox.showinfo(完成, CSV导入成功) self.refresh_words() if __name__ __main__: ManagerGUI().root.mainloop()界面很简单就是一个工具栏加一个表格但带来的收益是实打实的。选中一行可以直接改释义点导入按钮选 CSV 就能批量进库再也不用记 SQL。GUI 部分我用的是 Python 自带的 Tkinter不引入 PyQt原因是不想为了一个内部分享的管理工具增加打包体积而且 Tkinter 的跨平台支持也够用了。6. 联调、部署与踩坑记录从“能跑”到“稳跑”的最后一公里6.1 小程序request合法域名与HTTPS部署联调阶段遇到最经典的一个坑本地开发一切正常但真机预览时所有请求全部失败。原因很明确微信小程序对wx.request的域名有强制校验生产环境必须把域名配置到小程序后台的“request合法域名”里而且必须是 HTTPS不能是 IP 或局域网地址。开发阶段的解决办法是打开微信开发者工具在“详情-本地设置”里勾选“不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”。这是官方提供的调试方式只影响开发者工具不影响线上。真正上线时我给后端配了一个域名加 Nginx 反向代理加 SSL 证书配置大概是server { listen 443 ssl; server_name api.example.com; ssl_certificate /etc/nginx/cert/fullchain.pem; ssl_certificate_key /etc/nginx/cert/privkey.pem; location / { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }然后把api.example.com填进小程序后台的 request 合法域名列表重新上传体验版请求就通了。这里要提醒一句域名需要正常备案SSL 证书可以用正规渠道申请免费证书流程都是微信和国内云服务商的标准流程按规范走即可。6.2 重复上报导致的学习记录错乱这个坑在 4.3 节里已经埋了伏笔这里把完整排查链路写出来方便以后遇到类似问题能复现排查思路。现象用户连续快速点击“认识”和“不认识”学习页记录异常昨天显示已掌握的单词第二天又出现在新词推荐里同时管理员在管理端看到某个用户在study_record表里对同一个单词出现了两条记录。排查过程分三步。第一步看后端日志发现同一个report接口在 1 秒内被同一个用户调用了三四次参数一模一样返回也都成功。第二步查数据库study_record里确实出现了(user_id, word_id)相同的重复行说明接口的“先查再改”流程在并发请求下失效了两个请求同时 SELECT都没查到记录于是同时 INSERT。第三步定位前端按钮点击事件没有加防抖mark()被触发了多次。修复方案有三层。第一层前端在submit加锁请求完成前按钮不可点。第二层数据库给study_record表加上UNIQUE KEY (user_id, word_id)从根上杜绝重复行。第三层后端把这个接口的查询和写入包进事务使用INSERT ... ON DUPLICATE KEY UPDATE保证无论多少请求并发最终状态都是对的。修完后这个现象再也没出现过。6.3 稳定运行后的优化与我的实测体会系统稳定跑了两周之后我做了几处优化都很简单但收益明显。第一所有列表类接口都加了分页参数避免用户学习记录多了以后首页一次性查几千行第二把常用查询条件的联合索引补上比如(user_id, next_review_date)和(review_date, status)SQL 执行计划从全表扫描变成了索引范围扫描第三给小程序端加了本地缓存首页打开时先渲染缓存数据再异步拉最新任务体感上快了非常多。最后说一点我个人的实测体会背单词类系统完成度比复杂度重要得多。我本来想加组队打卡、排行榜、每日 PK 这些功能后来都砍了因为对我自己来说每天愿意打开去点的那几下靠的是入口清晰、任务量小、反馈即时。把 daily_target 从 50 降到 15 之后连续打卡天数反而上去了这比任何花哨的功能都有效。如果你也想做类似的小程序项目我的建议是先把“学一个词-上报反馈-生成复习任务”这条链路跑通再考虑怎么加功能。
返回列表