ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flask+Django+Vue构建公务员备考平台:前后端分离项目复盘

Flask+Django+Vue构建公务员备考平台:前后端分离项目复盘

做这类“复习备考平台”项目,最怕的不是功能写不出来,而是技术栈没想清楚就动手。我复盘的这个项目,标题里同时挂着Flask、Django、Vue和PyCharm,第一眼看上去像是技术杂烩,实际上是一个很典型的“双后端+前端分离”落地案例。项目面向前端开发者和Python全栈方向的同学,场景是给应届生提供公务员考试题库练习、模拟考试、错题回顾、学习计划跟踪和学情分析。这篇文章会把选型原因、数据库设计、Django与Flask各自负责什么、Vue页面怎么对接、上线踩了哪些坑,完整过一遍,给正在做考试类系统或前后端分离项目的朋友做个参考。

1. 公务员应届生备考平台的技术选型复盘:为什么是Flask+Django+Vue

1.1 平台要解决的备考场景痛点

应届生备考公务员,最典型的痛点不是“没有题目”,而是“题目太多、太散,不知道从哪刷起”。很多人买了一堆资料,今天看行测,明天翻申论,最后什么都没吃透。所以这个平台在设计之初就确定了三个核心目标:第一,把题目按考试类别、章节、知识点分层组织,让刷题有路径;第二,通过答题记录自动生成错题本,让复习有反馈;第三,通过统计数据识别薄弱章节,让备考有优先级。

围绕这三个目标,功能上拆成了几个大模块:题库管理、在线刷题、模拟考试、错题本、学习计划、个人中心,外加一个学情分析面板。

用户角色也很清楚:学生端是日常刷题和看数据,管理端是维护题库和查看整体学习情况。考虑到时间成本,我建议项目第一期重点做学生端,管理端只要能维护题目即可,别一上来就把后台做成“功能大而全”的重型系统。

这个定位直接影响后面的技术选型。因为核心是“题目+做题记录+统计分析”,所以后端需要成熟的ORM、权限体系和快速开发能力;而“学情分析”这种服务比较独立,适合拆成一个小服务单独跑。于是就有了那套争议很大的组合:Django管主系统,Flask跑分析服务,Vue做前端,PyCharm统一开发调试。

1.2 Flask与Django同台:按边界分工而不重复造轮子

先聊一个很多人会问的问题:一个项目里同时出现Flask和Django,是不是为了炫技?

真不是。

Django是一个“全家桶”式框架,自带了ORM、Admin后台、认证体系、表单处理、迁移工具。做题库管理、用户体系、答题记录这种强业务逻辑的模块,Django能省掉大量重复劳动。尤其是Admin后台,稍微配几行代码就能让管理员维护题目,对这类内容后台需求多的项目非常友好。

Flask则是一个“轻量级微框架”,路由简单、启动快、依赖少。它在这个项目里承担的是一个相对独立的任务:根据考生最近的答题记录,生成薄弱点分析和复习建议。这个功能不需要Django的ORM,也不需要Admin后台,它只需要接收一段JSON数据,经过计算后返回一段建议文本。把这种低频的、计算逻辑独立的接口塞进Django里也能做,但会污染主工程的目录结构,也会拖累主服务启动。

所以我的划分原则是:强业务逻辑、需要数据建模的部分全部交给Django;轻量、可独立部署的服务用Flask单独起一个进程。两边通过HTTP接口通信,互不干扰。

回头看,这个决策在后期联调时帮了大忙。Django那边频繁改模型、跑迁移,Flask服务完全不受影响;反过来Flask接口调整算法逻辑时,也不会导致整个平台重启。如果你把一个分析功能硬塞进Django,每次改动都要走一遍完整测试流程,成本高得多。

1.3 PyCharm下搭建项目骨架与虚拟环境

开发工具选择PyCharm,主要看重的是它的调试体验和代码提示。项目结构上我用了典型的“一主一辅”布局:主目录是Django项目,里面有一个独立目录放Flask服务。

结构大致是这样的:

exam_platform/ ├── config/ # Django主配置 │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── apps/ # Django业务应用 │ ├── users/ │ ├── question_bank/ │ └── study/ ├── analyzer/ # Flask独立服务 │ ├── app.py │ └── requirements.txt ├── frontend/ # Vue前端工程 │ └── src/ ├── manage.py └── requirements.txt

第一步是创建虚拟环境。为了避免全局pip污染,我推荐直接用PyCharm内置的Virtualenv功能。选中Python 3.9解释器,然后逐个安装依赖:

pip install django djangorestframework django-cors-headers flask requests gunicorn

把Django项目初始化出来之后,前端我用的是Vue 3加Vite直接初始化,和PyCharm里的后端目录并列。Vite比Webpack快很多,尤其是在项目中期频繁刷新页面时,热更新体验差距非常大。

还有一个细节建议:从一开始就要把数据库连接信息、调试开关、跨域白名单写进配置文件的本地版本里,不要提交到版本库。这个项目后面联调时,我用一个本地config副本节省了大量环境切换时间。

2. 从需求到表结构:核心业务模块与数据库建模

2.1 核心业务模块:题库、刷题、模拟考、错题本、学习计划

数据库设计是这类平台最关键的部分。题目表设计得不好,后面做随机组卷、错题统计、成绩分析都会很痛苦。我先把核心实体理了一遍:用户、知识点、题目、试卷、答题记录、错题本、学习计划。

用户表直接继承Django的AbstractUser,这样可以省去重写登录认证的功夫,只需要增加几个业务字段:考试日期、目标分数、性别、所在地区等。知识点表用自关联结构,支持二级甚至三级分类,这样题目既能挂在“行测-言语理解”这种大类下,也能精确定位到“逻辑填空-语境分析”这种细节点。

题目表是所有模块的地基。字段包括:题干、选项、答案、解析、题型、难度、知识点外键、来源。其中选项和答案我用的是JSONField,因为选择题的选项数量不固定,某些题还有多选或不定项选择,JSON存储让后端代码更灵活。

答题记录表解决的是“考生做过什么、做得怎么样”的问题。每次刷题提交后都会插入一条记录,记录题目ID、考生ID、是否答对、所选答案、用时等。错题本不是一张独立的静态表,而是基于答题记录动态生成的。什么时候把某道题放进错题本、什么时候算“已掌握”,都可以由这套元数据计算出来。

学习计划表相对简单,就是给用户按天生成任务,关联知识点、题目数量、完成状态。数据看板的统计结果,也是基于这些表的聚合查询得出的。

2.2 Django ORM模型的关键实现

我把几个核心模型写出来,大家可以直接对照落地:

from django.db import models from django.contrib.auth.models import AbstractUser class User(AbstractUser): GENDER_CHOICES = ( ('male', '男'), ('female', '女'), ) gender = models.CharField(max_length=10, choices=GENDER_CHOICES, blank=True) exam_date = models.DateField(null=True, blank=True) target_score = models.IntegerField(default=0) class KnowledgePoint(models.Model): name = models.CharField(max_length=100) parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE) class Question(models.Model): TYPE_CHOICES = ( ('single', '单选题'), ('multiple', '多选题'), ('judge', '判断题'), ('case', '案例分析'), ) DIFFICULTY_CHOICES = ( ('easy', '简单'), ('mid', '中等'), ('hard', '困难'), ) content = models.TextField() options = models.JSONField(default=dict) answer = models.JSONField() analysis = models.TextField() qtype = models.CharField(max_length=10, choices=TYPE_CHOICES) point = models.ForeignKey(KnowledgePoint, on_delete=models.PROTECT) difficulty = models.CharField(max_length=10, choices=DIFFICULTY_CHOICES, default='mid') source = models.CharField(max_length=100, blank=True) class AnswerRecord(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) question = models.ForeignKey(Question, on_delete=models.CASCADE) selected = models.JSONField() is_correct = models.BooleanField() cost_time = models.IntegerField(help_text='用时,单位秒') created_at = models.DateTimeField(auto_now_add=True) class StudyPlan(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) date = models.DateField() point = models.ForeignKey(KnowledgePoint, null=True, on_delete=models.SET_NULL) question_count = models.IntegerField(default=20) is_finished = models.BooleanField(default=False)

几个字段上的取舍值得说一下。答案字段也用JSON,是为了支持多选题和判断、案例混合题型;题干如果放HTML,可以存富文本,但安全上要注意转义,避免XSS风险。options用default=dict,保证Django迁移时不会有空值报错。

2.3 建模时容易踩的坑:题目重复、答案存储、统计性能

第一个坑是题目重复。题库录入时很容易因为复制粘贴造成同一道题存在多份。我在模型上给content加了唯一约束,同时在管理端加了一个“查重”入口。从数据源导入题目时,也做了内容哈希比对。

第二个坑是答案校验的位置。用户在刷题时选了A、B,后端接收到的数据是['A','B']。校验逻辑尽量在序列化层做,不要让前端先算好对错再传回来。等于是后端根据正确答案重新比较一次,防止有人直接改请求体刷成绩。

第三个坑是统计性能。答题记录表一旦数据量上来,按用户聚合会变慢。我的做法不是实时去全表统计,而是用户每次做完一组题后,把知识点正确率增量更新到用户知识点统计表里。这样打开数据看板时,只需要查少量汇总行,而不是扫描上万条答题记录。这个设计在最开始就要想好,不然后期加汇总表会面临历史数据回填的麻烦。

3. 接口层实现:Django支撑核心API,Flask跑独立学习分析服务

3.1 Django REST framework:认证、权限与公共接口

接口层我用Django REST framework实现。JWT认证用djangorestframework-simplejwt,登录后前端拿token请求受保护资源。

settings里需要配置:

REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework_simplejwt.authentication.JWTAuthentication', ], 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.IsAuthenticated', ], }

然后把跨域配置好。后端装django-cors-headers,在settings里加入APP并配置中间件:

INSTALLED_APPS = [ 'corsheaders', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', ] CORS_ALLOWED_ORIGINS = [ 'http://localhost:5173', ]

我写接口时会尽量用ViewSet,而不是一个方法写一个函数视图。ViewSet配合Router,可以自动生成标准REST风格的路由,代码量少很多。

以刷题接口为例,核心是“按知识点取随机题”。这个接口不接收前端传入的“题号列表”,而是接收知识点ID和数量,由后端做随机抽取:

class QuestionViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = QuestionSerializer def get_queryset(self): point_id = self.request.query_params.get('point_id') if point_id: return Question.objects.filter(point_id=point_id) return Question.objects.all() @action(detail=False, methods=['get']) def random_paper(self, request): point_id = request.query_params.get('point_id') count = int(request.query_params.get('count', 10)) qs = Question.objects.filter(point_id=point_id) selected = random.sample(list(qs), min(count, qs.count())) return Response(self.get_serializer(selected, many=True).data)

这样前端只需要拿到一组题目渲染即可,不需要关心组卷逻辑。

3.2 刷题与成绩分析接口的关键细节

提交答题记录不能一条条插库,性能太差。我设计成批量提交:一次传一个题目列表,里面每一项包含题目ID、所选答案、用时,后端统一循环校验、计算正确率,并更新错题本和知识点统计。

实现大致如下:

class AnswerRecordViewSet(viewsets.ModelViewSet): serializer_class = AnswerRecordSerializer def create(self, request): data = request.data.get('answers', []) results = [] for item in data: q = Question.objects.get(id=item['question_id']) correct = self._check_answer(q, item['selected']) record = AnswerRecord.objects.create( user=request.user, question=q, selected=item['selected'], is_correct=correct, cost_time=item.get('cost_time', 0), ) results.append({'question_id': q.id, 'is_correct': correct, 'correct_answer': q.answer}) self._update_user_point_stats(request.user, results) return Response({'results': results}, status=201)

这里有个容易被忽略的点:错题本不是每次答错就机械加入。如果同一道题错第三次,我会把它的优先级调高而不是再塞一条重复记录。错题本表的字段可以设置成“最近错误时间”和“错误次数”,页面展示时按这两个字段排序,真正把错题本变成“复习队列”。

3.3 Flask分析服务怎么和Django主系统联合协作

Flask在整个项目里扮演“学情分析师”。Django提供聚合好的答题统计数据,Flask基于这个数据计算薄弱知识点,并返回复习建议文本。

例如Flask接口:

from flask import Flask, request, jsonify app = Flask(__name__) @app.route('/analyze', methods=['POST']) def analyze(): data = request.get_json() stats = data.get('stats', []) if not stats: return jsonify({'suggestion': '暂无足够做题数据,请先完成基础练习'}) weakest = min(stats, key=lambda x: x['accuracy']) strongest = max(stats, key=lambda x: x['accuracy']) return jsonify({ 'weak_point': weakest['point_name'], 'accuracy': weakest['accuracy'], 'strong_point': strongest['point_name'], 'suggestion': ( f"你的薄弱点是{weakest['point_name']},正确率仅{weakest['accuracy']:.0%}。" f"建议本周优先安排该知识点每日20题专项训练。" ) }) if __name__ == '__main__': app.run(port=5000)

Django这边通过requests库调用它:

def get_study_suggestion(request): uid = request.user.id stats = get_point_accuracy(uid) # 内部聚合逻辑 try: resp = requests.post('http://127.0.0.1:5000/analyze', json={'stats': stats}, timeout=3) return JsonResponse(resp.json()) except requests.exceptions.RequestException: return JsonResponse({'suggestion': '分析服务暂时不可用'})

它独立进程跑,Django挂了它还能单独测试;Flask挂了也不影响主体业务,前端收到提示后会降级显示“暂无分析结果”。

看起来多了一层服务,但实际部署时就是多一个进程而已,收益远大于成本。

4. Vue前端联调实录:登录页、刷题页、数据看板

4.1 Vue工程初始化与请求层封装

前端用Vue 3 + Vite + Pinia + Element Plus。初始化命令是:

npm create vite@latest frontend -- --template vue cd frontend npm install npm install axios pinia element-plus echarts

开发过程中最大的痛点是跨域。本地前端端口是5173,后端Django端口是8000。直接在axios里写http://localhost:8000/api,浏览器会报CORS错误。我用的是Vite代理配置:

// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true } } } })

这样前端请求/api/xxx时,Vite服务器会转发到8000端口,浏览器完全感知不到跨域。

axios请求封装统一放在request.js里:

import axios from 'axios' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('access_token') if (token) config.headers.Authorization = `Bearer ${token}` return config }) service.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('access_token') window.location.href = '/login' } return Promise.reject(error) } )

权限控制放在路由守卫里,没有token就跳到登录页。登录成功后缓存用户信息和token,刷新页面也能保持会话。

4.2 刷题页面核心交互与答题回传

刷题页是整个平台的核心页面,交互上要求“快速作答、即时反馈、并不打断刷题节奏”。

组件结构我分成三个部分:题目渲染区、答题卡、结果弹窗。题目渲染区根据题型动态渲染,单选题用单选按钮,多选题用复选框,判断题用两个大按钮,案例分析用textarea。

刷题流程是这样的:

  1. 用户从知识点列表进入刷题页。
  2. 页面加载时请求随机题组接口,拿到题目数组。
  3. 每次做完一题点击“下一题”,前端把本题答案暂存在一个数组中。
  4. 做完一组后点击“提交本轮”,前端把所有暂存答案一次性POST到后端。

用暂存而不是逐题提交,好处是用户中途可以回退修改答案。数据上报时附带每道题的耗时,这个字段对后面学情分析很有用。

代码核心片段:

function nextQuestion(selectedAnswer, timeUsed) { answerList.value.push({ question_id: currentQuestion.value.id, selected: selectedAnswer, cost_time: timeUsed }) if (currentIndex.value < questionList.value.length - 1) { currentIndex.value++ timer.start() } else { submitRound() } } async function submitRound() { const res = await answerApi.submit({ answers: answerList.value }) // 根据返回结果显示正确率弹窗 showResult.value = true resultData.value = res.results // 请求最新错题本 await loadMistakeList() }

正确率弹窗里不仅仅是“答对多少题”,还会列出每道错题的正确答案和解析,并提供“加入错题重练”的按钮。

4.3 ECharts数据看板与跨域联调

数据看板是给用户“看到努力成果”的地方,也是学情服务价值最直观的出口。我用ECharts做三个可视化模块:每题正确率趋势折线图、各知识点正确率雷达图、每日刷题数量柱状图。

雷达图的数据来源是知识点正确率聚合。前端请求/api/study/radar拿到的数据格式大致是:

[ {"name": "言语理解", "accuracy": 0.72}, {"name": "判断推理", "accuracy": 0.64}, {"name": "数量关系", "accuracy": 0.38} ]

前端渲染雷达图:

const radarOption = computed(() => ({ radar: { indicator: radarData.value.map(item => ({ name: item.name, max: 100 })) }, series: [{ type: 'radar', data: [{ value: radarData.value.map(item => Math.round(item.accuracy * 100)) }] }] }))

看板页面还有一个“AI复习建议”卡片,它调用的就是前文写的Django接口,Django再去请求Flask分析服务。调通这个联动链路后,整个项目的核心价值就闭环了:刷题产生数据,数据驱动分析,分析反哺复习计划。

联调时最容易出的问题就是数据格式不一致。Django返回的是蛇形命名(snake_case),前端如果习惯用驼峰命名的接口,常会出现字段对不上的问题。我的建议是统一全部用蛇形命名,前端代码里也保持蛇形,省去一层转换逻辑。

5. 上线部署与常见问题排查实录

5.1 本地到生产:nginx+gunicorn+Django+Flask部署流程

部署部分我踩的坑比开发部分还多。最开始只会在本地跑runserver,上线时发现runserver是单进程开发服务器,稍微多点并发就卡。

Django侧的部署命令建议用gunicorn:

pip install gunicorn python manage.py collectstatic --noinput gunicorn config.wsgi:application -w 4 -b 127.0.0.1:8000

Flask作为独立服务,也用gunicorn单跑一个进程:

gunicorn -w 2 -b 127.0.0.1:5000 analyzer.app:app

Nginx负责前端静态文件和反向代理:

server { listen 80; server_name your_domain; root /opt/exam_platform/frontend/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

这里有一个容易忽略的问题:Flask服务只允许本机访问就够了,不要暴露公网端口。Nginx不需要对外开5000端口,Django内部通过127.0.0.1:5000访问即可。安全边界上一定要收敛。

部署完成后记得测试三条链路:登录是否正常、刷题提交是否正常、看板能否拿到Flask分析结果。只要有一条链路不通,优先看对应进程日志。

5.2 开发期高频报错速查表

我整理了项目开发过程中出现频率最高的几个问题和解决办法,可以直接当作速查表用:

现象原因处理方式
前端请求/api返回404Vite代理没生效,或者后端路由前缀不匹配检查vite.config.js代理配置,确认Django的urls里包含api路径
登录成功但刷题接口返回401JWT token没放在Authorization请求头里在axios请求拦截器里统一设置token
提交答题记录后错题本没变化错题本更新逻辑放在事务外的异步任务里,被吞掉检查更新逻辑是否成功执行,建议先同步更新再异步统计
随机组卷接口返回重复题目没有做去重,或者sample方法传的count大于总量抽题前先去重,数量超限时做截断
数据看板接口响应慢查询没有走索引,或者N+1查询严重使用select_related和prefetch_related提前加载外键
Flask分析接口超时分析逻辑处理慢或网络隔离问题增加requests超时时间,并把分析服务改成异步任务
上传图片或资料在Nginx下404没配置media目录的alias在Nginx配置中增加media路由映射
Vue的热更新经常失效项目文件权限或Node版本过高用稳定的Node LTS版本,清理依赖缓存

这些问题都不是什么高深技术,但每一个卡住都够折腾两三个小时。如果你是自己一个人开发整套系统,建议把日志从第一天就打印规范:Django用模块级logger,Flask用统一的请求日志,前端在axios拦截器里记录请求路径和响应状态。这样排查问题时不用到处翻。

5.3 性能优化、安全加固与后续扩展思路

性能层面,我最有体感的优化是给项目加缓存。题库是读多写少的数据,每次进刷题页都查数据库很浪费。我用Redis缓存了热点知识点的题目列表,缓存键设计成question:list:{point_id}:{difficulty},组卷时先查缓存,没有再查库回填。

安全层面,Django的settings里要做几件事:把SECRET_KEY从代码里抽出来放到环境变量;Debug关掉;ALLOWED_HOSTS限定实际域名;管理后台路径不要用默认的/admin,换成自定义路径;上传文件强制校验类型。还有一点,用户提交的内容如果是富文本,一定要做转义处理,否则会埋XSS隐患。

这个项目后续的可扩展空间其实挺大。现在题库刷题、错题本、看板已经跑通,接下来可以加模拟考试计时模式、申论在线批改、社区经验分享、按用户画像推送题目。核心表设计已经留好了扩展位,知识点继续往深做细分不会影响现有结构。

整个平台做完后,我最大的体会是:不要害怕在一个项目里用多个框架,关键是边界要清楚。Django管业务、Flask管独立分析、Vue管交互,每个组件都只解决一类问题,而不是所有逻辑都堆在一个工程里。技术上所有复杂的东西拆开后都不复杂,复杂的是那些想清楚却不敢动手的决策。希望这篇复盘能让你在做类似考试平台或复习系统时,少走几天弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进