去年年底接到一个内部需求:做一套干部测评系统。说得直白一点,就是把过去每年线下纸质打分、人工汇总的民主测评流程搬到线上,测评人不用再拿着一叠表格跑来跑去,管理员也不用熬夜粘 Excel。技术栈需求写得很宽松,就四个字:“Python Web”。我第一反应是 Django,毕竟它是 Python 里出了名的“全家桶”,自带 ORM、Admin、认证,开箱即用。但项目真正落地的时候,我用的却是 Flask 写后端 API、Vue 写管理端和打分端、PyCharm 做日常开发和接口调试。这篇文章把我当时的完整设计过程、关键代码、统计口径实现以及踩过的坑全部整理出来,给正在做测评类、问卷类、投票类系统的朋友一个可复现的参考。
1. 项目到底要做什么:测评系统的核心需求拆解
1.1 测评业务从纸质到线上的完整流程
很多人第一次接触“测评系统”会以为这只是一个简单的打分表单。实际上,测评类业务的流程链路比普通问卷长不少,而且每一环都有状态管理。
一个完整的测评流程可以拆成四步。第一步是组织者创建测评项目,设定测评对象范围、打分维度、每个维度的权重、测评人分组、各类测评人的权重比例、起止时间。第二步是测评人登录系统,看到分配给自己的待办任务,打开后是若干个被测评对象,每个对象下面按照维度逐项打分,最后提交。第三步是项目结束后系统自动统计,得出每个对象的分类得分、总分、排名,必要时导出报告。第四步是管理者查看结果,用于后续的人事参考。
这里有一个隐藏需求容易忽略:测评人和被测评对象通常是“多对多”的,一个测评人往往要给多名干部打分,一名干部又会被多名不同类别的人评价。而且,不同类别测评人的打分权重完全不同,比如领导打分占 40%、同级占 30%、下级占 20%、自我占 10%,不能简单把所有分数加起来取平均。这种“分组加权”的统计模型,是整个系统最核心的业务逻辑。
1.2 角色划分与核心功能清单
我把系统拆成了三个角色。系统管理员负责创建项目、维护用户、配置权重、启动和结束测评;普通测评人负责接收任务、打分、查看自己的提交记录;领导/查看人只读,只关注统计结果和排名。
为了把所有需求理清楚,我列了一张功能清单出来,后面就照着它开发:
- 用户管理:登录、角色区分、测评人类别维护
- 项目配置:创建测评项目、设定时间窗口、配置打分维度
- 维度管理:默认有“德、能、勤、绩、廉”五类,每类可设权重
- 测评对象管理:维护干部名单及所属部门
- 任务生成:项目发布时,按规则自动生成每个测评人的待办任务
- 打分提交:限制测评人只能打一次,截止后禁止提交
- 自动统计:按类别分组、组内求平均、再按类别权重加权求和
- 结果查看:对象总分、维度明细、排名
这里我想特别提醒一点:任务生成这个环节非常关键。系统在项目发布的时候,要自动给每个测评人生成一份“任务明细”,记录他需要对哪些对象打分。如果项目发布后再临时增减测评对象,任务也要同步更新,这种关联逻辑是目前最容易出错的地方,后面开发时要重点设计。
2. 技术选型的实战考量:Flask、Django、Vue 和 PyCharm 各自扮演什么角色
2.1 为什么最终选 Flask 而不是 Django
标题里同时出现了 Flask 和 Django,确实容易让人困惑。我先说结论:这套系统最终是 Flask 做后端 API,Django 在选型阶段被我拿来做了详细对比,最后被否掉不是因为不好,而是因为场景不匹配。
我当时的判断标准有三条。第一,这套系统是典型的前后端分离结构,前端展示交给 Vue,后端核心只提供 JSON API,用不上 Django 自带的模板引擎和 Admin。第二,测评业务的自定义程度非常高,任务生成、加权统计、权限隔离都是强业务逻辑,比起在 Django 的固定套路上做定制,不如用轻量的 Flask 自己拼装。第三,团队对 Flask 更熟悉,开发效率更高。
但 Django 也不是没有优点。它的 ORM 查询能力更成熟,Admin 后台在业务初期几乎能当半个管理端用。如果项目要求在单一框架里前后端不分离、快速搭出管理后台,我会毫不犹豫选 Django。所以更准确地说,这不是谁比谁强的问题,而是架构形态决定了框架选型。我还参考了 Django 的 MTV 分层思想来组织 Flask 代码:路由层只做参数接收和响应返回,业务逻辑层处理计算,模型层管数据表映射,从分层思路上两者其实是一致的。
2.2 前端为什么用 Vue,版本怎么定
前端我选了 Vue,直接原因有三个:Vue 的响应式数据绑定非常适合表单类交互场景;单文件组件让页面结构清爽,打分页、结果页、统计页各自独立维护不打架;生态成熟,配 Element Plus 组件库,表格、表单、弹窗基本不用重复造轮子。
我对比过 React,但在这个项目里 Vue 的性价比更高。React 的设计哲学是“一切皆 JavaScript”,函数式写法更灵活,但对团队而言成本也更高;Vue 的模板语法更接近传统 HTML,做这类中后台系统上手极快。版本方面我用的 Vue 3 + Vite + Element Plus,不建议再开新项目用 Vue 2 了,Vue 3 的 Composition API 写这种多状态交互页面会舒服很多。
2.3 PyCharm 在整个开发流里的位置
开发工具我用的是 PyCharm Professional。有人觉得 IDE 无所谓,但我坚持在类似项目里用 PyCharm,因为它的 Flask 调试支持确实省心:可以直接创建 Flask Run Configuration,打断点调试请求处理过程,配合内置的数据库工具还能直接看表结构和查询结果。
社区版免费,但少了数据库导航、HTTP Client、专业前端支持这些能力。如果预算有限,社区版也能开发,只是需要额外装插件弥补,体验稍打折扣。我的建议是:长期做 Python Web 开发,专业版值得投入;只做这一个项目,社区版加 VSCode 也完全够用。
3. 从零搭建项目的完整实操记录
3.1 开发环境配置:PyCharm + 虚拟环境 + 项目依赖
第一步是在 PyCharm 里新建 Flask 项目。我一般习惯先把虚拟环境建好,再装依赖,避免把全局 Python 环境搞乱。用虚拟环境的原因很简单:项目 A 要用 Flask 2.x,项目 B 要用 Django 4.x,依赖版本互相干扰会很痛苦。
建好虚拟环境后,在终端依次安装后端依赖:
pip install flask flask-sqlalchemy flask-cors PyJWT pymysql这几个库分别负责 Web 服务、ORM、跨域、JWT 认证和 MySQL 驱动。开发阶段我用 SQLite 当数据库,连安装和配置都省了,一键启动。生产环境再把连接串换成 MySQL,SQLAlchemy 对底层数据库切换是透明的。
后端项目结构我整理成下面这样,每个模块职责单一,方便后面加功能:
backend/ ├── app.py # Flask 入口,注册路由 ├── config.py # 配置类,包含数据库连接、密钥 ├── models.py # SQLAlchemy 模型定义 ├── auth.py # 登录、JWT 校验、权限装饰器 ├── api/ │ ├── project_api.py # 项目管理接口 │ ├── task_api.py # 任务查询与打分提交接口 │ └── stats_api.py # 统计结果接口 └── requirements.txt3.2 数据库表设计:任务与分数分离是关键
做好测评系统,表结构设计是命脉。我第一次设计时把所有字段塞进一张记录表,结果状态管理绕来绕去把自己绕晕了。后来重新拆成五张表,才算把整个业务理顺。
用户表包含登录凭证、角色和测评人类别;项目表记录测评活动的基本信息和时间窗口;维度表存“德能勤绩廉”这类打分项,每项一个权重;对象表存被测评干部清单;任务表记录“谁给谁打分”的待办关系;分数表存每个任务下每个维度的最终得分。
核心逻辑在任务表和分数表。任务表体现的是“一件事情”,分数表体现的是“这件事的明细结果”。为什么不合并?因为测评人打开页面后要先展示对象列表,如果只有一个分数表,查询时还得聚合再判断哪些打了、哪些没打。有了任务表,一个测评人有多少条待办,一条 SQL 就能查出来,任务状态独立管理,提交时再往分数表写明细,逻辑非常清晰。
核心模型代码大致长这样:
from flask_sqlalchemy import SQLAlchemy from datetime import datetime db = SQLAlchemy() class User(db.Model): __tablename__ = 'user' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(64), unique=True, nullable=False) password_hash = db.Column(db.String(128), nullable=False) real_name = db.Column(db.String(64), nullable=False) role = db.Column(db.String(16), default='user') # admin / user evaluator_type = db.Column(db.String(16), default='peer') # leader / peer / subordinate / self department = db.Column(db.String(128), default='') class Project(db.Model): __tablename__ = 'project' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128), nullable=False) status = db.Column(db.String(16), default='draft') # draft / published / finished start_time = db.Column(db.DateTime, nullable=True) end_time = db.Column(db.DateTime, nullable=True) type_weights = db.Column(db.Text, default='{"leader": 0.4, "peer": 0.3, "subordinate": 0.2, "self": 0.1}') created_at = db.Column(db.DateTime, default=datetime.now) class Dimension(db.Model): __tablename__ = 'dimension' id = db.Column(db.Integer, primary_key=True) project_id = db.Column(db.Integer, db.ForeignKey('project.id')) name = db.Column(db.String(32), nullable=False) # 德、能、勤、绩、廉 weight = db.Column(db.Float, default=1.0) # 维度权重 sort_order = db.Column(db.Integer, default=0) class Target(db.Model): __tablename__ = 'target' id = db.Column(db.Integer, primary_key=True) project_id = db.Column(db.Integer, db.ForeignKey('project.id')) name = db.Column(db.String(64), nullable=False) department = db.Column(db.String(128), default='') position = db.Column(db.String(64), default='') class Task(db.Model): __tablename__ = 'task' id = db.Column(db.Integer, primary_key=True) project_id = db.Column(db.Integer, db.ForeignKey('project.id')) evaluator_id = db.Column(db.Integer, db.ForeignKey('user.id')) target_id = db.Column(db.Integer, db.ForeignKey('target.id')) status = db.Column(db.String(16), default='todo') # todo / done submit_time = db.Column(db.DateTime, nullable=True) class Score(db.Model): __tablename__ = 'score' id = db.Column(db.Integer, primary_key=True) task_id = db.Column(db.Integer, db.ForeignKey('task.id')) dimension_id = db.Column(db.Integer, db.ForeignKey('dimension.id')) score = db.Column(db.Float, nullable=False)需要注意,type_weights 我直接以 JSON 字符串存在项目表里。虽然这不是最“范式化”的做法,但从业务角度来说,每个测评项目都可能配置一套独立的测评人权重,把它挂在项目上是最自然的,改权重也不影响历史项目。
3.3 登录、JWT 认证与权限控制实现
登录是系统的入口,我采用 JWT 方案。用户在登录接口提交用户名密码,后端验证通过后签发一个有效期为 7 天的 token,前端存到 localStorage,每次请求在 header 里带上。JWT 的好处是后端无状态,不用存 session,多机部署也方便。
登录接口实现:
from flask import Flask, request, jsonify, g from werkzeug.security import check_password_hash import jwt from datetime import datetime, timedelta from functools import wraps app = Flask(__name__) app.config['SECRET_KEY'] = 'your-secret-key-change-in-production' @app.route('/api/auth/login', methods=['POST']) def login(): data = request.get_json() user = User.query.filter_by(username=data.get('username')).first() if user and check_password_hash(user.password_hash, data.get('password')): token = jwt.encode( { 'user_id': user.id, 'exp': datetime.utcnow() + timedelta(days=7) }, app.config['SECRET_KEY'], algorithm='HS256' ) return jsonify({ 'code': 0, 'token': token, 'user': { 'id': user.id, 'real_name': user.real_name, 'role': user.role, 'evaluator_type': user.evaluator_type } }) return jsonify({'code': 1, 'msg': '用户名或密码错误'}), 401权限装饰器是这套系统的安全底座,所有需要登录的接口都要挂上。我在装饰器里解析 token,把当前用户塞进 Flask 的 g 对象,后续处理函数直接读 g.user 就能拿到当前用户信息,不用每个接口重复解析 token。
def login_required(f): @wraps(f) def wrapper(*args, **kwargs): token = request.headers.get('Authorization', '').replace('Bearer ', '') try: payload = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256']) user = User.query.get(payload['user_id']) if not user: raise Exception('user not exists') g.user = user except Exception: return jsonify({'code': 401, 'msg': '未登录或登录已过期'}), 401 return f(*args, **kwargs) return wrapper这里踩过一个坑:JWT 的 exp 时间是 UTC 时间,如果客户端和服务器时区不一致,用户会突然被提示“登录已过期”。后来我把过期时间统一换算,同时在前端 axios 拦截器里判断 401 状态码做跳转登录处理,问题才彻底解决。
3.4 打分提交与防重复设计
打分接口是最需要谨慎的。我做过一次技术评审,发现如果只在前端判断“任务是否已提交”,用户打开两个页面同时提交,后端会收到两次请求,最终导致数据库里出现重复分数。所以后端的防重复校验必须做,不能只靠前端限制。
提交打分接口的关键逻辑:根据 task_id 查出任务,判断任务的 evaluator_id 是否等于当前登录用户;判断任务状态是否已经是 done;判断当前时间是否超过项目截止时间;全部通过之后,先删除该任务下的旧分数(幂等设计),再批量插入新分数,最后把 task 状态改为 done。
用“先删旧数据再插入新数据”的方式,是为了保证接口的幂等性。哪怕前端重试请求,执行结果也是一样的,不会产生脏数据。相关代码大约是:
@app.route('/api/tasks/<int:task_id>/score', methods=['POST']) @login_required def submit_score(task_id): task = Task.query.get(task_id) if not task or task.evaluator_id != g.user.id: return jsonify({'code': 403, 'msg': '无权操作该任务'}), 403 if task.status == 'done': return jsonify({'code': 400, 'msg': '该任务已提交,不能重复打分'}), 400 project = Project.query.get(task.project_id) if project.status == 'finished' or ( project.end_time and datetime.now() > project.end_time ): return jsonify({'code': 400, 'msg': '测评已结束,无法提交'}), 400 data = request.get_json() scores = data.get('scores', {}) # 幂等处理:先删除旧分数,再插入新分数 Score.query.filter_by(task_id=task.id).delete() for dim_id, score_value in scores.items(): db.session.add(Score(task_id=task.id, dimension_id=int(dim_id), score=float(score_value))) task.status = 'done' task.submit_time = datetime.now() db.session.commit() return jsonify({'code': 0, 'msg': '提交成功'})3.5 统计模块:分组平均加权重计算的完整实现
统计模块是整个系统的价值核心,也是最容易算错的地方。我先把计算口径确认清楚:每个测评对象在每个维度下的最终得分,不是所有打分的简单平均,而是“先按测评人类别分组求平均分,再把各类别平均分按类别权重加权求和”。然后用所有维度的最终得分乘以维度权重,汇总出总分。
举个例子,某干部“德”这个维度,领导打分 95 分,同级打分 85 分,下级打分 80 分。如果三类权重是 0.4、0.3、0.3,那“德”的最终得分就是 95×0.4 + 85×0.3 + 80×0.3 = 87.5。如果直接四个数字取平均,结果会偏离实际权重意愿。这个口径一定在开发前和业务确认好,不然等系统上线再改统计逻辑,所有历史数据都会受影响。
统计接口的实现我写成了独立函数,方便以后扩展历史数据重算:
@app.route('/api/projects/<int:project_id>/stats', methods=['GET']) @login_required def project_stats(project_id): project = Project.query.get_or_404(project_id) targets = Target.query.filter_by(project_id=project_id).all() dimensions = Dimension.query.filter_by(project_id=project_id).order_by(Dimension.sort_order).all() type_weights = json.loads(project.type_weights or '{}') result = [] for target in targets: total_score = 0 dim_scores = {} for dim in dimensions: # 查出该对象在该维度下的所有分数,并关联到测评人类别 rows = ( db.session.query(Score.score, User.evaluator_type) .join(Task, Score.task_id == Task.id) .join(User, Task.evaluator_id == User.id) .filter(Task.target_id == target.id) .filter(Task.status == 'done') .filter(Score.dimension_id == dim.id) .all() ) # 按测评人类别分组求平均 group_scores = {} for score_value, etype in rows: group_scores.setdefault(etype, []).append(score_value) final_dim_score = 0.0 for etype, score_list in group_scores.items(): avg_score = sum(score_list) / len(score_list) final_dim_score += avg_score * type_weights.get(etype, 0) final_dim_score = round(final_dim_score, 2) dim_scores[dim.name] = final_dim_score total_score += final_dim_score * dim.weight result.append({ 'target_id': target.id, 'target_name': target.name, 'department': target.department, 'dim_scores': dim_scores, 'total_score': round(total_score, 2) }) result.sort(key=lambda x: x['total_score'], reverse=True) return jsonify({'code': 0, 'data': result})统计逻辑里有个容易被忽略的细节:某个测评人如果漏打、或者某类测评人数量为 0,那该类别直接跳过,不参与加权。按实际经验,这种情况很常见,毕竟民主测评总有人忘记打分。统计时对“缺失类别”的处理会直接影响最终排名,最好在需求阶段就明确“缺类别的对象是否还能参与排名”,避免后期扯皮。
3.6 前端 Vue 项目的搭建与核心页面
前端我用 Vite 创建 Vue 3 项目:
npm create vite@latest frontend -- --template vue cd frontend npm install axios element-plus vue-router@4开发阶段最麻烦的是跨域。之前不懂事,直接在 Flask 里配 flask-cors 放行所有源,开发时倒是通了,但生产环境有安全隐患。后来我改成:开发环境前端跑 5173 端口,Vite 配置代理把 /api 请求转发到后端 5000 端口,浏览器视角里始终是同一个源,后端也不再需要开放跨域。
Vite 代理配置如下:
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { proxy: { '/api': { target: 'http://localhost:5000', changeOrigin: true } } } })axios 统一封装我也贴一下。把 token 注入和 401 拦截统一处理,后面写业务页面就不用重复关心这些琐事。
// src/api/index.js import axios from 'axios' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('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('token') window.location.href = '/login' } return Promise.reject(error) } ) export default service前端最核心的页面是打分页。逻辑是:进入待办列表时,循环调接口把每个任务的维度和历史分数一并加载;用户在表单里修改分数;提交时把整个对象的分数一次性提交。页面状态要区分“未开始、编辑中、已提交”,并对已提交的任务锁定表单,防止误改。
具体到 Vue 3 的 Composition API,核心代码思路是这样:
<script setup> import { ref, onMounted } from 'vue' import api from '@/api' const tasks = ref([]) onMounted(async () => { const res = await api.get('/tasks') tasks.value = res.data.filter(t => t.project_status === 'published') }) async function submitScores(task) { if (task.status === 'done') return await api.post(`/tasks/${task.task_id}/score`, { scores: task.scores }) task.status = 'done' } </script>这里的 scores 是一个对象,键是 dimension_id,值是分数。页面上的每个输入框都直接绑定 task.scores[dim.id],实现双向同步,不需要额外写赋值逻辑,Vue 的响应式在这里确实帮我省了不少事。
4. 开发过程中最常见的问题与排查技巧
4.1 跨域、代理和部署相关的坑
我在开发阶段遇到过“接口直接能通、打包部署后就 404”的问题。原因是开发时 Vite 代理只作用于 dev server,前端打包成静态文件后,所有接口地址都变成了走 nginx 的 80 端口。如果 nginx 只配置了静态文件目录而没有配置反向代理,/api 请求全部 404。
解决办法是在 nginx 配置里增加如下一段:
location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }注意 proxy_pass 后面如果有路径,行为会不一样,容易把人绕晕。我这里直接用不带路径的写法,保证 /api 原样转发给后端。
后端生产环境我用 gunicorn 起服务:
pip install gunicorn gunicorn -w 4 -b 127.0.0.1:5000 app:app线上部署的最大心得是:前端打包、nginx 静态目录、后端反向代理这三层配置一定要提前用部署文档固定下来,别靠记忆。我后来把 nginx 配置提交到了仓库的 deploy 目录,换机器部署直接复制,省了很多查资料的功夫。
4.2 中文乱码问题
模拟数据导入后,页面显示中文全部变成问号,典型的 MySQL 字符集问题。数据库连接串里指定 utf8mb4 可以解决大部分情况:
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:password@localhost/eval_db?charset=utf8mb4'同时数据库表本身的字符集也要对。SQLAlchemy 建表时默认继承数据库配置,但如果数据库实例创建的时候没指定 utf8mb4,后面建出来的表也可能是 latin1。最省事的办法是建库时就执行:
CREATE DATABASE eval_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;4.3 重复提交与并发问题
有段时间测试反馈“偶尔提交两次”。排查后发现是测评人网络慢,双击提交按钮产生了并发请求。后端虽然做了 task.status 的判断,但两个请求同时进来,都读到 status 为 todo,就会同时进入写流程,导致重复数据。
解决办法是在任务更新时加乐观锁条件,更新任务状态时多带一个状态条件:
updated = Task.query.filter_by(id=task.id, status='todo').update({'status': 'done'})如果 updated 返回 0,说明任务已经被其他请求更新过,直接返回“重复提交”。这种基于条件更新的做法简单有效,不需要引入分布式锁。
4.4 统计结果不准的排查思路
统计接口上线后,业务方反馈某些人的总分和手动 Excel 算不一致。我排查后发现三个原因:第一,有测评人提交了空白分数,前端把所有维度默认初始化为 0,后端把这些 0 也当成有效分数统计了;第二,项目截止后管理员手动调整了权重,导致历史数据重算口径变了;第三,有个测试账号在任务生成前就已经登录,没有刷新任务列表,以为自己提交了,实际后端根本没有他的任务记录。
针对第一个问题,我在后端校验分数不能为 0,且必须在规定档位范围内。针对第二个问题,权重一旦项目发布就不允许修改,必须新增版本。第三个问题则是前端的任务列表缓存策略问题,我改成每次进入页面强制刷新任务列表,不读本地缓存。把这三个问题修完后,统计结果才和 Excel 对上了。
4.5 PyCharm 环境配置的常见问题
用 PyCharm 跑 Flask 项目时最容易踩的坑是解释器选错。打开新项目时如果选了默认的全局解释器,而不是项目里的 venv,会导致安装了依赖也没法 import。解决方法是在 Settings 的 Python Interpreter 里,把解释器路径指向项目的 venv。
还有一个坑:直接点运行按钮启动 Flask,有时会走 PyCharm 默认的 Flask 模板工程配置,导致路由加载不全。我的做法是坚持用命令行方式启动,先启动 venv 里的终端再跑python app.py,由我完全控制启动行为。PyCharm 只作为代码编辑和调试工具使用。
5. 系统上线之后的扩展方向与个人总结
5.1 从最小可用到完整业务的演进空间
当前版本已经能跑通完整测评流程,但如果要长期用,建议继续扩展几个模块。Excel 导入导出是刚需,项目发布前需要批量导入干部名单、测评人名单,项目结束后需要导出报表归档。刚开始做的时候图省事用了手动逐条添加,被业务方批评过。后来接入了 openpyxl 做批量导入,几百人的名单几分钟搞定,才算真正可用。
数据可视化也是一个重要的加分项。统计结果如果只用表格展示,人眼很难快速识别排名波动。可以接入 ECharts,在结果页画雷达图,展示每个干部在“德能勤绩廉”维度的能力覆盖情况,绩效对比也直观很多。
隐私保护这块也值得说明。测评系统天然要求匿名,但技术上的匿名和业务上的匿名是两回事。系统后台可以记录测评人身份用于任务状态管理,但在结果展示层必须严格屏蔽单个测评人的分数。任何查询接口都不能返回“谁给谁打了多少分”的数据粒度。
5.2 对这套系统整体架构的复盘体会
做完整套系统,我自己最大的收获是:干部测评这类业务系统的技术难点不在框架,而在业务模型的边界。框架选型只决定了开发效率的下限,而权限隔离、统计口径、数据一致性这些设计才真正决定系统能不能稳定上线。
如果让我重新做一次,我会在项目启动前花更多时间确认统计口径,把“某类测评人缺失怎么办”“权重能否中途改”“项目结束后还能不能补交”这三个问题问清楚。这些业务决策一旦定错了,返工成本远超写代码本身。
5.3 一个值得提前做的小工具:造数脚本
最后分享一个我后悔没早点做的东西:测试数据生成脚本。开发统计接口的时候,我是靠手工在数据库里插了几条数据测试,完全没法验证大数据量下的统计是否准确。后来写了一个 Python 脚本,自动生成 50 个测评人、20 个对象、5 个维度、每人 20 个对象,模拟出几千条打分记录,然后和用 Excel 手算的期望值对比。这一步帮我发现了统计代码里权重解析的一个隐蔽 bug,如果没有造数脚本,这个 bug 大概率会带着错误统计上线。
# scripts/mock_data.py 简化版 from app import app from models import db, User, Project, Target, Task, Dimension, Score import random with app.app_context(): db.drop_all() db.create_all() # 建项目 p = Project(name='2024年度民主测评', status='published') db.session.add(p) db.session.flush() # 建维度 for name in ['德', '能', '勤', '绩', '廉']: db.session.add(Dimension(project_id=p.id, name=name, weight=1.0)) # 建用户和对象、任务、分数 # 具体的循环插入逻辑这里省略,核心是批量造数并随机给分 db.session.commit()写造数脚本不需要花太多时间,但它的价值非常高。每次修改统计逻辑、调整表结构之后,跑一遍造数脚本把所有数据清空重新生成,配合期望值校验,就能快速回归测试整个核心链路。做这类数据敏感业务系统,我非常建议你也这样做。