做HSK汉语水平考试学习工具的想法是在帮我一位留学生朋友备考时冒出来的。市面上的App要么收费不低要么词汇列表和真题脱节想按自己的节奏做一套词库、练习、错题本都难。既然天天写Python那干脆自己用Python和Flask搭一个能随手改、能本地跑的学习平台也算把Web开发从头到尾走一遍。这个项目做下来之后我把完整的搭建过程、数据库设计、核心功能实现的坑都整理在这篇里尤其适合正在学Flask、想找课程设计项目或者单纯想给自己做一个学习工具的读者。整件事没什么玄乎的高科技就是Flask SQLite Jinja2模板 Bootstrap纯网页端数据落在本地一个文件里。做完之后我最大的感受是这类“个人学习工具”型项目真正花时间的不是写代码而是把学习逻辑想清楚、把词汇数据整理好。代码倒是其次。下面我把整个项目的拆解思路、关键实现代码和踩坑记录都写出来按我实际开发的顺序来。1. 技术选型为什么是Flask而不是Django或FastAPI1.1 先理清HSK学习平台该解决什么问题HSK全称是汉语水平考试从一级到六级一共六个级别词汇量大概从150个逐步涨到5000多个。学习平台最基础的需求很明确能按级别浏览词汇、能查词、能做模拟测试、能记录学过哪些词、错了哪些词。如果只是单机背单词拿Excel也能凑合但一旦涉及“随机出题、自动判分、错题回看、进度追踪”这几件事就必须有稳定的数据存储和一套可交互的页面逻辑这时候就该Web应用上场了。这套东西的痛点不计较流量、不计较高并发逻辑也不复杂核心诉求就三个词汇数据要分类清晰能按HSK级别过滤。测试要能随机抽题并自动判分错误答案要能被记住。用户学习进度要能持续记录关掉页面再打开数据不能丢。抱着这三个诉求去选型心里就有底了。1.2 Flask、Django、FastAPI三个框架的一次实在对比坦白说这三个框架我都用过各有所长但放在HSK学习平台这个场景里差距非常明显。框架上手成本适合场景对HSK项目的吻合度Flask低一个文件就能起步中小型应用、定制化强、模板渲染很合适想怎么写就怎么写Django高自带ORM/Admin/迁移体系大而全的后台管理系统、内容站偏重光Admin和迁移机制对这个项目就是累赘FastAPI中强调异步和API文档前后端分离的API服务、高并发读写不太合适本项目渲染页面为主异步优势用不上我不选Django的原因很简单项目只有十来个页面业务逻辑就“查词、出题、判分、记进度”四件事Django自带的Admin后台和复杂的分层反而增加了心智负担。不选FastAPI的原因也很现实FastAPI擅长的是写HTTP API接口配一个Vue或React前端才会有优势而我们的学习平台直接用Jinja2模板渲染就够了没必要多包一层。Flask 2.x之后支持async且生态成熟找教程、排错都非常方便。1.3 整体技术栈与项目边界这个项目的整体技术栈如下后端Python 3.10 Flask 2.3 / 3.x数据库SQLite通过Flask-SQLAlchemy操作方便以后换MySQL模板引擎Jinja2Flask自带前端样式Bootstrap 5CDN引入不写复杂JavaScript密码处理werkzeug.securityFlask自带依赖CSV导入脚本Python标准库csv用于批量导入HSK词库为了控制项目复杂度我明确划了几条“不做”的边界不做找回密码、不做邮箱验证、不做用户头像、不做在线支付。这些对一个学习工具来说都不是必需的。先把核心链路跑通后面要增强随时可以加。这个思想也建议初学Flask的朋友重点体会一下——很多人次写项目就想把用户系统做得很全结果流失在邮件验证和权限隔离上核心功能反而没做好。2. 词汇库与数据库设计整个平台的地基2.1 HSK词库的数据来源与清洗做学习平台第一件事不是写代码而是先搞定词库。HSK词汇表本身是公开的按1-6级划分总共约5000词。我整理时先把每个级别的词汇做成CSV文件字段尽量全级别、汉字、拼音、词性、释义、例句、例句翻译。这样做的好处是后面所有功能都是在这张表上转。我建议不要把全部5000词一次性塞进来先按你想用的级别整理。像我只整理了一级和二级的词大约600个把导入、查询、出题全流程跑顺之后再补充其他级别。CSV格式大概是这样的level,word,pinyin,pos,meaning,example,example_translate 1,你好,nǐ hǎo,int,你好,你好我叫李华。,Hello, my name is Li Hua. 1,谢谢,xiè xie,int,谢谢,谢谢你的帮助。,Thanks for your help. 2,机场,jī chǎng,n,机场,我要去机场。,I am going to the airport. 2,认识,rèn shi,v,认识,我认识他。,I know him.这里有个很实际的坑用Excel打开CSV后如果你再保存很可能变成GBK编码导致Python打开乱码。我建议统一用UTF-8保存在Python里读取时使用encodingutf-8-sig这个编码会自动跳过Excel写入的BOM头就是开头那几个肉眼看不见的字符。第一次导入后立刻用Word.query.count()验证数据量防止导入脚本跑一半报错或者重复导入。2.2 四张核心数据表的设计数据库我设计了四张表users用户、words词汇、user_words学习进度与错题、test_records测试记录。核心实现代码如下from flask_sqlalchemy import SQLAlchemy from datetime import datetime db SQLAlchemy() class User(db.Model): __tablename__ users id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(64), uniqueTrue, nullableFalse) password_hash db.Column(db.String(128), nullableFalse) created_at db.Column(db.DateTime, defaultdatetime.now) class Word(db.Model): __tablename__ words id db.Column(db.Integer, primary_keyTrue) level db.Column(db.Integer, indexTrue) word db.Column(db.String(32), nullableFalse, indexTrue) pinyin db.Column(db.String(64), indexTrue) pos db.Column(db.String(16)) meaning db.Column(db.String(256)) example db.Column(db.String(256), nullableTrue) example_translate db.Column(db.String(256), nullableTrue) class UserWord(db.Model): __tablename__ user_words user_id db.Column(db.Integer, db.ForeignKey(users.id), primary_keyTrue) word_id db.Column(db.Integer, db.ForeignKey(words.id), primary_keyTrue) status db.Column(db.Integer, default0) # 0未学 1学习中 2已掌握 review_count db.Column(db.Integer, default0) error_count db.Column(db.Integer, default0) updated_at db.Column(db.DateTime, defaultdatetime.now) class TestRecord(db.Model): __tablename__ test_records id db.Column(db.Integer, primary_keyTrue) user_id db.Column(db.Integer, db.ForeignKey(users.id)) score db.Column(db.Integer, default0) total db.Column(db.Integer, default0) created_at db.Column(db.DateTime, defaultdatetime.now)几个设计上值得注意的点user_words使用user_id word_id复合主键表达“某个用户对某个词的学习状态”天然防止重复记录。如果一开始没设计这个表你的进度和错题就会散落在不同地方后面非常难查。我单独给word和pinyin字段加了索引。别小看这一步5000词虽然不多但搜索时带索引和全表扫描的体感差异还是很明显的尤其是例句字段很长的情况下。error_count是为了错题本准备的。不是所有做错的词都要永远挂在错题里如果一个词后来连续答对几次就应该从错题本里降权甚至移除这一点后面会说。2.3 为什么SQLite在这个项目里反而最省心很多人一听到“数据库”就想上MySQL但在这个项目里SQLite才是最合适的。SQLite整个数据库就一个文件数据量在几千词量级时读写毫无压力备份直接把文件拷走就行。用Flask-SQLAlchemy连接它也不需要单独启动数据库服务对个人项目和课程设计非常友好。SQLite最常见的顾虑是并发写。同一时刻多个用户同时写数据SQLite可能会报“database is locked”。但在学习平台这种个人或少数人使用的场景里根本达不到这个并发量。真到了要上线多人使用的时候切换MySQL的方法也异常简单只需要改一行配置app.config[SQLALCHEMY_DATABASE_URI] mysqlpymysql://user:passwordlocalhost/hsk_db因为代码里用的全是Flask-SQLAlchemy的模型操作换数据库不会动业务逻辑。这也是我坚持引入ORM而不是直接写裸SQL的原因。3. 用户系统与学习进度让平台从“能用”到“好用”3.1 注册登录与密码安全用户系统的核心目的是让每个人的学习进度互相隔离。我用了Flask自带session机制保存登录状态密码用werkzeug.security的哈希函数处理。注册和登录的核心代码from flask import session, flash, redirect, url_for, render_template, request from werkzeug.security import generate_password_hash, check_password_hash app.route(/register, methods[GET, POST]) def register(): if request.method POST: username request.form.get(username, ).strip() password request.form.get(password, ) if len(username) 2 or len(password) 6: flash(用户名至少2个字符密码至少6位) return render_template(register.html) if User.query.filter_by(usernameusername).first(): flash(用户名已存在) return render_template(register.html) user User(usernameusername, password_hashgenerate_password_hash(password)) db.session.add(user) db.session.commit() session[user_id] user.id return redirect(url_for(index)) return render_template(register.html) app.route(/login, methods[GET, POST]) def login(): if request.method POST: username request.form.get(username, ).strip() password request.form.get(password, ) user User.query.filter_by(usernameusername).first() if user and check_password_hash(user.password_hash, password): session[user_id] user.id session.permanent True return redirect(url_for(index)) flash(用户名或密码错误) return render_template(login.html)这里必须强调一个安全细节不要拿明文密码做比对。用generate_password_hash生成的哈希即便被拖库也无法反推密码。网上很多Flask教程为省事直接明文存密码自己玩可以但凡能被人访问到都是隐患。app.secret_key我放在单独的配置里并且用随机字符串生成不是写死某个固定值。它的作用是对session内容做签名防止用户伪造登录状态。如果secret_key弱攻击者是可以伪造cookie的。如果忘记配置secret_keyFlask会直接报错运行不起来这点对小白来说反而是好事。3.2 学习进度与错题统计的数据流转学习进度说白了就是对user_words表做增删查改。用户在词汇详情页点击“标记为已学”或“标记为掌握”时更新status做测试答错时给error_count加1。核心更新函数如下app.route(/word/int:word_id/state, methods[POST]) def update_word_state(word_id): if user_id not in session: return redirect(url_for(login)) user_id session[user_id] uw UserWord.query.filter_by(user_iduser_id, word_idword_id).first() if not uw: uw UserWord(user_iduser_id, word_idword_id) db.session.add(uw) new_status int(request.form.get(status, 1)) uw.status new_status uw.review_count 1 uw.updated_at datetime.now() db.session.commit() # 如果一个词连续答对若干次从错题本里降权 if uw.error_count 0 and new_status 2: uw.error_count max(0, uw.error_count - 2) db.session.commit() return redirect(url_for(word_detail, word_idword_id))注意这里“连续答对就减少error_count”的设计。我是用“掌握”操作来触发降错次虽然逻辑简单但对学习体验的提升很明显——否则错题本里永远堆着最初学习时做错的词越往后越失去参考价值。3.3 会话管理的三个小坑会话部分实战中容易踩三个坑我逐一说明第一Flask的session默认是“浏览器关闭就失效”。如果想长期保持登录需要在登录时设置session.permanent True同时配置app.config[PERMANENT_SESSION_LIFETIME]为合适的过期时间比如30天。第二session内容不要塞太多东西。我在测试功能里曾经一度把整套试题塞进session结果cookie一下子超过4KB浏览器直接忽略新cookie导致登录一直跳回首页排查老半天。后来改成session只保存测试的题目ID序列和正确答案ID数据量小很多。第三用户改密码或管理员封号时一定要清掉旧session。最简单的做法是在改密码接口里调用session.clear()否则旧cookie仍然有效。我在这个项目里做了“修改密码”后强制重新登录的逻辑就是一个session.clear()的事。4. 核心功能搜索、模拟测试、错题收录4.1 多条件搜索与防注入HSK单词搜索我做了三个维度按汉字模糊匹配、按拼音模糊匹配、按释义模糊匹配。比如用户输入“jichang”要能搜出“机场”输入“机”也能搜出“机场、手机、机器”等词汇。Flask-SQLAlchemy写法如下from flask_sqlalchemy import SQLAlchemy from sqlalchemy import or_ app.route(/words) def words(): page request.args.get(page, 1, typeint) keyword request.args.get(q, ).strip() level request.args.get(level, 0, typeint) query Word.query if keyword: query query.filter(or_( Word.word.like(f%{keyword}%), Word.pinyin.like(f%{keyword.lower()}%), Word.meaning.like(f%{keyword}%) )) if level: query query.filter(Word.level level) pagination query.order_by(Word.level, Word.word).paginate( pagepage, per_page20, error_outFalse ) return render_template(words.html, paginationpagination)这里必须提醒所有新手不要用字符串拼接SQL。网上很多老教程喜欢写WHERE word LIKE %{keyword}%这种写法一旦用户在输入框里输入引号或百分号轻则报错重则被SQL注入。用SQLAlchemy的like方法和参数绑定就不存在注入问题。像Flask-SQLAlchemy的paginate()方法我强烈推荐分页逻辑一页页写Offset和Limit看着代码量少实际上很容易漏边界而且还要自己处理页码越界paginate(error_outFalse)天然解决了这个问题。拼音搜索有个小细节用户可能输入带声调的拼音nǐ hǎo也可能输入不带声调的ni hao。我建议存库时同时存一份“去声调拼音”或者干脆只存不带声调的版本。我的做法是在CSV清洗阶段把nǐ hǎo转成ni hao这样用户无论输入哪种都能匹配上。4.2 随机抽题与干扰项生成逻辑模拟测试是整个平台最有技术含量的部分。核心逻辑是从指定级别随机抽N个词每个词生成“看词选释义”或“看释义选词”的题目同时从同级别的其他词里抽3个作为干扰项。但不是每道题都是人工拼出来的我用一个函数统一处理import random def build_quiz(level, count10): # 从指定级别随机抽题 words Word.query.filter_by(levellevel)\ .order_by(db.func.random()).limit(count).all() quiz_data [] for w in words: # 从同一级别里找干扰词直到有4个不同选项 distractors Word.query.filter( Word.level level, Word.id ! w.id ).order_by(db.func.random()).limit(20).all() choices [{word: w.word, meaning: w.meaning, is_answer: True}] used {w.word} for d in distractors: if d.word not in used: choices.append({word: d.word, meaning: d.meaning, is_answer: False}) used.add(d.word) if len(choices) 4: break random.shuffle(choices) quiz_data.append({ word_id: w.id, question: w.word, choices: choices }) return quiz_data这里有两个容易翻车的小点。第一随机抽干扰词时如果运气不好抽到的干扰词和正确答案意思极其接近比如“机场”和“飞机场”用户看了会一脸懵。所以在干扰词选择前可以加上一层过滤把释义相似度过高的词排除掉。第二一定要去重。随机抽20个词再截取前3个就是为了避免遇到同义词反复占用选项位。我实际跑的时候遇到过“苹果、香蕉、梨、花生”这种还算正常也遇到过四个选项里有两个完全相同释义那就是去重没做到位。生成题目后我把正确答案的word_id和选项顺序存进session交卷时才能判分。但这部分我做了裁剪只存结构化数据不存整道题避免cookie膨胀。4.3 交卷、判分与错题本的联动交卷用一个独立的POST /quiz/submit接口处理。用户在答题页勾选选项后提交后端循环判断对错同时更新错题统计和测试记录app.route(/quiz/submit, methods[POST]) def quiz_submit(): if user_id not in session: return redirect(url_for(login)) quiz_session session.get(quiz_session, []) correct 0 for item in quiz_session: word_id item[word_id] answer request.form.get(fq_{word_id}, typeint) if answer item[correct_choice_id]: correct 1 else: uw UserWord.query.filter_by( user_idsession[user_id], word_idword_id).first() if not uw: uw UserWord(user_idsession[user_id], word_idword_id) db.session.add(uw) uw.error_count 1 uw.review_count 1 total len(quiz_session) record TestRecord( user_idsession[user_id], scorecorrect, totaltotal ) db.session.add(record) db.session.commit() return render_template(quiz_result.html, correctcorrect, totaltotal)错题本页面就简单了查user_words表里error_count 0的记录关联words表拿到汉字、拼音和释义按错误次数倒序排列app.route(/mistakes) def mistakes(): if user_id not in session: return redirect(url_for(login)) records db.session.query(UserWord, Word)\ .join(Word, UserWord.word_id Word.id)\ .filter(UserWord.user_id session[user_id], UserWord.error_count 0)\ .order_by(UserWord.error_count.desc(), UserWord.updated_at.desc())\ .all() return render_template(mistakes.html, recordsrecords)错题本这个地方我一开始做得很粗糙直接把所有error_count 0的词全列出来。后来发现一个问题用户第一次做测试时因为手滑选错这个词就永远挂在错题本里了学习体验很差。于是加了“当用户把某词标为掌握时error_count减2”的逻辑才让错题本有了“流动性”——错过的词有再次答对的机会就能自行代谢掉。5. 前端模板与交互不写复杂JavaScript也能撑起学习流程5.1 模板继承与页面划分前端我全程使用Jinja2模板 Bootstrap 5的CDN。页面结构用模板继承统一管理基础模板base.html放导航栏和公共样式子页面通过{% block content %}填充各自内容。这个做法比复制粘贴HTML强太多——后期改导航栏只需要改一处。整个平台的页面规划如下首页index.html显示最近测试成绩、待复习词汇数词汇列表页words.html支持搜索、按级别筛选、分页单词详情页word_detail.html展示汉字、拼音、词性、释义、例句以及“标为已学”“标为掌握”按钮测试页quiz.html选择题交互测试结果页quiz_result.html得分与正确率错题本mistakes.html按错误次数排序的列表登录/注册页login.html/register.html基础表单模板继承最大的收益是格式统一。Bootstrap的卡片组件天然适合词汇展示我把每个词都做成一张卡片词性、拼音、释义分块铺开手机端看起来也不挤。测试页则用了单选按钮组一行一个选项前端的活就到此为止了判分全部交给后端。5.2 分页、查询参数与词汇列表词汇列表页我做了两个查询参数q关键词和levelHSK级别。分页用Flask-SQLAlchemy内置的paginate()。模板里的分页导航直接复用Bootstrap的分页样式。这里有一个实际体验细节翻页时漏掉查询参数会导致结果页错乱。比如用户搜索“机”之后点第2页表单参数是/?q机page2但如果分页链接只传?page2关键词就丢了用户看到的就变成全量词库第2页。所以模板里生成分页链接时必须带上当前的q和level!-- templates/words.html 中的分页链接示例 -- nav ul classpagination {% for p in pagination.iter_pages() %} {% if p %} li classpage-item {% if p pagination.page %}active{% endif %} a classpage-link href{{ url_for(words, pagep, qrequest.args.get(q, ), levelrequest.args.get(level, 0)) }} {{ p }} /a /li {% else %} li classpage-item disabledspan classpage-link…/span/li {% endif %} {% endfor %} /ul /nav很多入门项目栽在这个小事上所以单独拎出来说。页面刷新、跳转之后永远记得把筛选条件带回。5.3 测试页的数据传递与状态维护测试页的数据传递我踩过一个比较深的坑值得详细说。第一次实现时我在/quiz/start里把整套试题的对象都塞进session结果cookie大小直接超限表现就是登录状态一会儿有、一会儿没有非常诡异。后来我改成只把“题目ID 正确答案的选项唯一标识”存session页面渲染时通过模板拿到该题的4个选项用户勾选的选项value对应数据库里的词汇ID。这样session里只有几K的数据完全没问题。同时测试页里每个问题如果是“看词选释义”选项就是4个释义文本如果是“看释义选词”选项就是4个汉字。为了前端代码简单我把这两类题统一成“选项id 选项文本”的结构渲染时不管题型一律生成单选按钮。字段名统一用q_{word_id}后端逻辑不需要区分题型。这种“将不同业务形态统一成一个数据接口”的思路虽然简单但对项目维护帮了大忙。6. 本地部署与上线踩坑记录6.1 从零跑通项目的完整命令项目在本地跑通是非常轻松的。假设代码已经拉到本地完整步骤如下# 1. 创建虚拟环境Windows / macOS / Linux 均可 python -m venv venv # 2. 激活虚拟环境 # Windows: venv\Scripts\activate # macOS / Linux: source venv/bin/activate # 3. 安装依赖 pip install flask flask-sqlalchemy # 4. 初始化数据库并导入CSV词库 python init_db.py # 5. 启动开发服务器 python app.py启动后浏览器打开http://127.0.0.1:5000即可。requirements.txt建议顺手生成一份方便换机器部署pip freeze requirements.txtinit_db.py的导入逻辑里有一个必须注意的点Flask-SQLAlchemy的模型绑定必须发生在Flask应用上下文里。如果你直接from app import app再执行db.create_all()大概率会遇到“Working outside of application context”错误。正确写法是在with app.app_context():里面操作# init_db.py import csv from app import app, db from models import Word def import_words(): with app.app_context(): db.create_all() with open(data/hsk_words.csv, encodingutf-8-sig) as f: reader csv.DictReader(f) count 0 for row in reader: if not row[word].strip(): continue word Word( levelint(row[level]), wordrow[word].strip(), pinyinrow[pinyin].strip(), posrow.get(pos, ).strip(), meaningrow.get(meaning, ).strip(), examplerow.get(example, ).strip(), example_translaterow.get(example_translate, ).strip() ) db.session.add(word) count 1 db.session.commit() print(f成功导入 {count} 条词汇) if __name__ __main__: import_words()6.2 生产环境部署的改动点开发模式下python app.py用的是Flask自带的开发服务器性能一般且官方明确不建议用于生产。如果只是个人学习工具跑在内网其实开发服务器也够用但真正放到公网上就不是那么回事了。我的建议是用waitress做生产服务器它是一个纯Python的WSGI服务器跨平台尤其Windows友好安装一条命令启动也就一条命令pip install waitress waitress-serve --host0.0.0.0 --port8080 app:appLinux服务器上更常见的选择是gunicorn配Nginx做反向代理和静态文件服务。但如果去掉Nginx也能跑只是静态文件Bootstrap CDN已经替我们承担了大部分会稍微慢一点。上线时的配置改动点app.config[DEBUG] False开发模式下默认开着的设置app.config[SECRET_KEY]为环境变量读取不要写死在代码里init_db.py只执行一次后续不要反复运行导致重复导入用SQLite时注意备份hsk.db文件数据库就靠它了6.3 实际运行中我遇到过的五个问题1. 中文乱码第一次导词库时所有汉字全部变成乱码。排查后发现问题出在CSV编码上Excel打开再保存会把UTF-8转成GBK。解决方案就是前面说的读取时用encodingutf-8-sig并且确认CSV本身是UTF-8编码。如果已经导入了乱码数据别慌直接删掉数据库文件重新导入即可SQLite单文件的好处在这时候展露无遗。2. cookie大小超限导致登录失效这个坑前面提过很隐蔽。表现形式是页面偶发跳回登录页刷新几次又好了。根因是session里塞了整套试题cookie体积超过4KB。解决方法是session只存精简数据涉及列表类的内容尽量只存ID列表。3. 拼音搜索不匹配用户输入“xiexie”搜不到“谢谢”因为库里存的是带声调的xiè xie。后来我用unicodedata.normalize(NFKD, ...)把拼音转成ASCII统一存成不带声调的版本这个问题就彻底解决了。这个转换在CSV清洗阶段做掉是最省事的。4. 测试生成速度慢当我导入全部5000词后出测试题时每次都在distractors Word.query.filter(...).limit(20).all()这里卡一下。原因是用order_by(db.func.random())做随机排序在SQLite里是全表扫描再排序虽然数据量不大但经不起每个词都来一遍。我的优化是先随机找一个偏移量再用offset().limit()取干扰词虽然随机性没那么均匀但速度提升很明显。5. SQLite并发写锁曾有一次我用浏览器开两个页面同时提交测试结果其中一个页面报“database is locked”。这个错误在本地单机开发时很少见但如果以后做成多人使用建议换MySQL或者把并发写操作收编到同一个进程里用消息队列串行化。还有一个非常容易出现的问题时间显示错乱。TestRecord.created_at默认用的是服务器本地时间如果你部署到海外服务器而不是本地运行记录的测试时间会偏几个小时。这个问题解决起来很简单统一用datetime.utcnow存储展示时再转本地时区。结尾这个HSK学习平台我前后用了大概一周的业余时间核心功能就四件事用户系统、词汇查询、模拟测试、错题本。做完最大的体会是Flask这类轻量框架非常适合做“一个人的学习工具”——你不用花精力应付复杂框架的规矩可以把全部注意力放在学习逻辑本身。如果后面有时间我会考虑加入按照艾宾浩斯记忆曲线的间隔重复复习功能或者接入语音朗读API把例句念出来这些在这个项目结构上扩展都不难。如果你也想练手建议别急着实现我文中提到的所有功能先跑通一个“能查词的最小版本”再逐步往里面加东西你会发现项目的复杂度和信心增长是成正比的最佳状态。