1. 为什么选择 Flask + MySQL 来做校园报修小程序的后端
校园报修这件事,看起来简单——学生填个单子,维修师傅接单上门,修完确认。但真正落到代码层面,你会发现它牵扯到角色权限、工单状态流转、图片上传、消息通知、数据统计等一连串问题。我前后参与过三个类似系统的搭建,最早一版用纯前端加云数据库,结果状态一多就乱套,后来换成 Flask + MySQL 这套组合,才算把整个链路跑顺。
Flask 在这个场景里的优势非常明显。它是一个轻量级 Python Web 框架,核心代码不到两千行,但扩展生态足够成熟。校园报修系统的并发量通常不大——一个校区几千人,高峰期一天几百单——完全没必要上 Spring Boot 那种重型框架。Flask 的路由写法直观,一个@app.route装饰器就能定义一个接口,配合 SQLAlchemy 做 ORM 映射,建表和查询都很清爽。更重要的是,微信小程序的wx.request发过来的就是标准 HTTP 请求,Flask 天然适配,不需要额外做协议转换。
MySQL 的选择理由更实在。报修系统里有大量结构化数据:用户表、工单表、维修分类表、评价表、消息表。这些数据之间有明确的外键关系,比如一个工单必须属于某个用户、某个分类、某个维修师傅。MySQL 的事务机制能保证"接单"这个动作的原子性——师傅接单的同时要更新工单状态、写入接单记录、给报修人发通知,这三步要么全成功要么全回滚,否则就会出现"师傅以为接了单、学生那边还显示待接单"的尴尬。另外 MySQL 的索引优化在工单列表分页查询时效果显著,给status和create_time建联合索引后,翻页查询从 800ms 降到 30ms 以内。
这套技术栈适合谁参考?如果你是有一定 Python 基础、想做一个完整前后端项目练手的学生,或者需要快速交付一个校园内部工具的开发者,Flask + MySQL 的组合能让你在两周内跑通核心流程。它不适合高并发商业场景,但在校园这个封闭、可控的环境里,稳定性和开发效率的平衡点找得很准。
2. 数据库表结构设计:从工单状态流转反推字段
很多教程一上来就贴建表语句,但我想先说清楚设计思路。校园报修系统的核心是"工单",所有表都围绕工单的生命周期展开。一张工单从创建到关闭,会经历"待接单 → 已接单 → 维修中 → 待确认 → 已完成"这几个状态,中间还可能插入"已取消"和"已驳回"。状态流转决定了表结构里必须有哪些字段来支撑。
2.1 用户表与角色权限的字段设计
用户表不能只存账号密码。校园场景里至少有三种角色:学生(报修人)、维修师傅、管理员。我用一个role字段来区分,取值student、worker、admin。但光有角色还不够,维修师傅需要绑定负责的维修分类,比如电工只接电路类工单,水工只接水管类。所以再加一个skill_category字段,存分类 ID 的 JSON 数组。
CREATE TABLE `user` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `openid` VARCHAR(64) NOT NULL COMMENT '微信openid', `nickname` VARCHAR(64) DEFAULT '', `avatar` VARCHAR(255) DEFAULT '', `phone` VARCHAR(20) DEFAULT '', `role` ENUM('student','worker','admin') DEFAULT 'student', `skill_category` JSON DEFAULT NULL COMMENT '师傅技能分类ID数组', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有个坑我踩过:openid必须加唯一索引。早期没加,结果同一个微信用户重复授权登录时插入了两条记录,导致工单归属混乱。微信小程序的wx.login换取的openid是用户唯一标识,用它做唯一约束最可靠。
2.2 工单表的状态机字段与索引策略
工单表是整个系统的核心,字段设计要兼顾查询效率和状态流转的可追溯性。
CREATE TABLE `repair_order` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '工单编号', `user_id` INT UNSIGNED NOT NULL, `worker_id` INT UNSIGNED DEFAULT NULL, `category_id` INT UNSIGNED NOT NULL, `title` VARCHAR(128) NOT NULL, `description` TEXT, `images` JSON DEFAULT NULL COMMENT '图片URL数组', `location` VARCHAR(255) NOT NULL COMMENT '报修地点', `status` TINYINT DEFAULT 0 COMMENT '0待接单 1已接单 2维修中 3待确认 4已完成 5已取消', `urgency` TINYINT DEFAULT 0 COMMENT '0普通 1紧急', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `accept_time` DATETIME DEFAULT NULL, `finish_time` DATETIME DEFAULT NULL, `rating` TINYINT DEFAULT NULL COMMENT '1-5星评价', `comment` VARCHAR(500) DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_status_create` (`status`, `create_time`), KEY `idx_worker_status` (`worker_id`, `status`), KEY `idx_user_create` (`user_id`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;为什么用TINYINT存状态而不是ENUM?因为ENUM在后续增加状态值时要改表结构,而TINYINT配合代码里的常量映射更灵活。我习惯在 Python 里定义一个OrderStatus类,把数字和含义对应起来,前端传数字、后端转文字,两边都不容易出错。
索引的设计直接对应查询场景。学生查自己的工单列表,走idx_user_create;师傅查待接单池,走idx_status_create;师傅查自己接的单,走idx_worker_status。这三个索引覆盖了 90% 以上的查询。注意联合索引的字段顺序——status在前是因为它的区分度虽然不高,但查询时总是等值匹配,而create_time用于排序,放在后面正好利用索引的有序性避免额外排序。
2.3 分类表与消息表的辅助设计
分类表简单,但要注意支持二级分类。比如"水电"下面分"电路"和"水管","木工"下面分"门窗"和"家具"。用parent_id自关联实现。
CREATE TABLE `category` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `name` VARCHAR(32) NOT NULL, `parent_id` INT UNSIGNED DEFAULT 0, `sort_order` INT DEFAULT 0, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;消息表用于站内通知,比如"您的工单已被接单""师傅已出发"。
CREATE TABLE `message` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `user_id` INT UNSIGNED NOT NULL, `order_id` INT UNSIGNED DEFAULT NULL, `content` VARCHAR(255) NOT NULL, `is_read` TINYINT DEFAULT 0, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_read` (`user_id`, `is_read`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;消息表不需要太复杂,但is_read字段必须有,否则前端红点提示没法做。我见过有人用 Redis 存未读数,对于校园规模来说没必要,MySQL 加个索引完全扛得住。
3. Flask 后端接口的分层与状态流转实现
Flask 项目最容易写乱的地方是把所有逻辑堆在路由函数里。我建议按"路由层 → 服务层 → 数据层"三层来组织。路由层只负责参数校验和响应封装,服务层处理业务逻辑,数据层用 SQLAlchemy 操作数据库。这样拆的好处是,当工单状态流转规则变化时,你只需要改服务层,路由和数据库操作不受影响。
3.1 工单创建接口的参数校验与图片处理
创建工单是学生端最核心的接口。参数包括分类 ID、标题、描述、地点、图片列表、紧急程度。校验逻辑要严格:标题不能超过 128 字,描述不能为空,地点必须填,图片最多 9 张。
from flask import Blueprint, request, jsonify from models import db, RepairOrder, User from datetime import datetime import uuid order_bp = Blueprint('order', __name__) @order_bp.route('/api/order/create', methods=['POST']) def create_order(): data = request.get_json() user_id = data.get('user_id') category_id = data.get('category_id') title = (data.get('title') or '').strip() description = (data.get('description') or '').strip() location = (data.get('location') or '').strip() images = data.get('images', []) urgency = data.get('urgency', 0) if not all([user_id, category_id, title, location]): return jsonify({'code': 400, 'msg': '缺少必填参数'}), 400 if len(title) > 128: return jsonify({'code': 400, 'msg': '标题过长'}), 400 if len(images) > 9: return jsonify({'code': 400, 'msg': '图片最多9张'}), 400 order_no = datetime.now().strftime('%Y%m%d') + uuid.uuid4().hex[:8].upper() order = RepairOrder( order_no=order_no, user_id=user_id, category_id=category_id, title=title, description=description, location=location, images=images, urgency=urgency, status=0 ) db.session.add(order) db.session.commit() return jsonify({'code': 0, 'msg': '创建成功', 'data': {'order_no': order_no}})图片处理有个细节:小程序端用wx.uploadFile上传到 Flask 的/api/upload接口,后端保存到本地static/uploads目录,返回可访问的 URL。注意文件名要用 UUID 重命名,避免用户上传同名文件覆盖。另外要限制文件类型,只允许jpg、png、jpeg,大小不超过 5MB。
3.2 接单接口的并发安全与事务控制
接单是唯一一个可能产生并发冲突的操作。两个师傅同时点"接单",如果不做控制,可能都以为自己接成功了。解决方案是在 SQL 层面加条件更新。
@order_bp.route('/api/order/accept', methods=['POST']) def accept_order(): data = request.get_json() order_id = data.get('order_id') worker_id = data.get('worker_id') # 条件更新:只有状态为0(待接单)且worker_id为空时才更新 affected = RepairOrder.query.filter_by( id=order_id, status=0, worker_id=None ).update({ 'worker_id': worker_id, 'status': 1, 'accept_time': datetime.now() }) if affected == 0: db.session.rollback() return jsonify({'code': 409, 'msg': '手慢了,该工单已被接走'}), 409 # 写入消息通知 order = RepairOrder.query.get(order_id) msg = Message( user_id=order.user_id, order_id=order_id, content=f'您的工单「{order.title}」已被接单' ) db.session.add(msg) db.session.commit() return jsonify({'code': 0, 'msg': '接单成功'})这里的关键是filter_by里带上status=0和worker_id=None两个条件,update返回受影响行数。如果返回 0,说明在你查询和更新之间,已经有另一个请求把状态改了。这种"乐观锁"的思路比加行锁更轻量,在校园并发量下完全够用。
3.3 状态流转的合法性校验
工单状态不能随意跳。比如不能从"待接单"直接跳到"已完成",也不能从"已完成"退回"维修中"。我在服务层写了一个状态机校验函数。
VALID_TRANSITIONS = { 0: [1, 5], # 待接单 → 已接单 / 已取消 1: [2, 5], # 已接单 → 维修中 / 已取消 2: [3], # 维修中 → 待确认 3: [4], # 待确认 → 已完成 4: [], # 已完成不可再变 5: [] # 已取消不可再变 } def can_transition(current_status, target_status): return target_status in VALID_TRANSITIONS.get(current_status, [])每次更新状态前先调这个函数,不合法就返回 400。这个校验看起来简单,但能挡住很多前端误操作导致的数据异常。我遇到过测试时前端按钮没禁用,用户连点两次"完成",结果工单状态从 3 跳到 4 又跳了一次,加了校验后这类问题彻底消失。
4. 微信小程序端的核心页面与交互细节
小程序端分学生端和师傅端,但代码可以放在同一个项目里,通过角色判断显示不同页面。核心页面有四个:报修提交页、工单列表页、工单详情页、个人中心页。
4.1 报修提交页的图片上传与表单联动
报修提交页的难点在图片上传。小程序不能直接传文件流,要用wx.chooseImage选图,再用wx.uploadFile逐张上传。我建议选完图后立即上传,拿到 URL 存到data.images数组里,而不是等提交时再传。这样用户填表单的时候图片已经在传了,体验更流畅。
chooseImage() { const that = this; wx.chooseImage({ count: 9 - that.data.images.length, sizeType: ['compressed'], sourceType: ['album', 'camera'], success(res) { res.tempFilePaths.forEach(path => { wx.uploadFile({ url: 'https://your-domain.com/api/upload', filePath: path, name: 'file', success(uploadRes) { const data = JSON.parse(uploadRes.data); if (data.code === 0) { that.setData({ images: that.data.images.concat(data.data.url) }); } } }); }); } }); }注意sizeType选compressed,否则用户拍的原图可能好几 MB,上传慢还占服务器空间。另外count要动态计算剩余可传数量,不然用户选了 9 张后还能继续选,导致超出限制。
表单联动方面,分类选择用picker组件,二级分类要根据一级分类动态加载。比如用户选了"水电",下面的子分类picker才显示"电路""水管"选项。这个联动逻辑在bindPickerChange里处理,拿到一级分类 ID 后请求/api/category/children?parent_id=xx获取子分类列表。
4.2 工单列表页的分页加载与状态筛选
工单列表用scroll-view加onReachBottom实现上拉加载。每页 10 条,请求参数带page和status。学生端默认查全部状态,师傅端默认查"待接单"。
loadOrders(reset = false) { if (this.data.loading) return; if (reset) { this.setData({ page: 1, orders: [], hasMore: true }); } if (!this.data.hasMore) return; this.setData({ loading: true }); wx.request({ url: 'https://your-domain.com/api/order/list', data: { user_id: this.data.userId, role: this.data.role, status: this.data.currentStatus, page: this.data.page, size: 10 }, success: (res) => { const list = res.data.data.list; this.setData({ orders: reset ? list : this.data.orders.concat(list), page: this.data.page + 1, hasMore: list.length === 10, loading: false }); } }); }状态筛选用顶部的 tab 栏,切换时调loadOrders(true)重置列表。这里有个体验细节:切换 tab 时如果直接清空列表,页面会闪一下白屏。我的做法是保留旧数据,等新数据回来再替换,中间显示一个半透明 loading 遮罩。
4.3 工单详情页的角色差异化渲染
同一个详情页,学生看到的和师傅看到的应该不一样。学生看到的是"师傅信息 + 进度条 + 确认按钮",师傅看到的是"报修人信息 + 接单/完成按钮"。用wx:if根据role判断。
进度条用状态值映射:0 显示"待接单",1 显示"已接单",2 显示"维修中",3 显示"待确认",4 显示"已完成"。每个状态对应一个颜色和图标。已完成的状态用绿色,已取消用灰色,进行中的用蓝色。
师傅端在状态为 1(已接单)时显示"开始维修"按钮,点击后调/api/order/update_status把状态改成 2。状态为 2 时显示"维修完成"按钮,改成 3。学生端在状态为 3 时显示"确认完成"和"评价"按钮,确认后状态变 4,同时弹出评价框。
5. 部署上线与真机调试中绕不开的坑
开发环境跑通不代表上线没问题。我整理了几个部署阶段最容易卡住的地方,都是实际踩过的。
5.1 小程序合法域名配置与 HTTPS 要求
微信小程序正式版要求所有请求域名必须是 HTTPS,且在小程序后台配置合法域名。开发阶段可以在开发者工具里勾选"不校验合法域名",但真机预览时这个选项无效,必须配好域名。
Flask 默认跑在 HTTP 的 5000 端口,要上 HTTPS 需要用 Nginx 做反向代理。Nginx 配置里把ssl_certificate和ssl_certificate_key指向证书文件,然后proxy_pass到http://127.0.0.1:5000。注意 Flask 要用gunicorn启动而不是app.run(),因为app.run()是开发服务器,性能差且不支持多进程。
gunicorn -w 4 -b 127.0.0.1:5000 app:app-w 4表示 4 个 worker 进程,一般设为 CPU 核心数乘 2 加 1。校园规模 2 到 4 个 worker 足够。
5.2 MySQL 连接池与超时重连
Flask 用 SQLAlchemy 连接 MySQL 时,默认连接池大小是 5。如果并发请求多,会出现"QueuePool limit overflow"错误。在配置里调大连接池并设置回收时间。
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = { 'pool_size': 10, 'max_overflow': 20, 'pool_recycle': 3600, 'pool_pre_ping': True }pool_recycle=3600表示连接一小时后回收,避免 MySQL 服务端主动断开导致的"MySQL server has gone away"。pool_pre_ping=True会在每次取连接时先 ping 一下,确保连接可用。这两个参数加上后,我再没遇到过连接断开的报错。
5.3 图片存储的目录权限与访问路径
上传的图片存在服务器本地目录,Nginx 要配置静态文件映射,否则小程序访问图片 URL 会 404。
location /uploads/ { alias /var/www/repair/static/uploads/; expires 7d; }expires 7d让浏览器缓存图片一周,减少重复请求。另外 Flask 保存文件时要注意目录权限,www-data用户要有写权限,否则上传会报Permission denied。我一般直接chmod 755上传目录,chown给运行 Flask 的用户。
真机调试时还有一个坑:iOS 对图片 URL 的域名校验更严格,如果图片域名和接口域名不一致,iOS 上可能加载不出来。建议接口和图片用同一个域名,通过路径区分。
6. 几个提升系统可用性的进阶思路
基础功能跑通后,可以考虑加一些提升体验的功能。这些不是必须的,但做了之后系统的完整度会明显不一样。
6.1 工单超时自动提醒
师傅接单后如果超过 24 小时没更新状态,系统自动给师傅发一条提醒消息。实现方式是用 Flask 的APScheduler定时任务,每小时扫一次超时工单。
from apscheduler.schedulers.background import BackgroundScheduler scheduler = BackgroundScheduler() def check_overdue_orders(): deadline = datetime.now() - timedelta(hours=24) overdue = RepairOrder.query.filter( RepairOrder.status == 1, RepairOrder.accept_time < deadline ).all() for order in overdue: msg = Message( user_id=order.worker_id, order_id=order.id, content=f'工单「{order.title}」已接单超过24小时,请尽快处理' ) db.session.add(msg) db.session.commit() scheduler.add_job(check_overdue_orders, 'interval', hours=1) scheduler.start()注意APScheduler在gunicorn多 worker 模式下会每个 worker 都跑一遍定时任务,导致重复提醒。解决办法是单独起一个进程跑定时任务,或者用flask-apscheduler配合锁机制。校园规模下,我直接单独写一个scheduler.py脚本用nohup后台运行,简单可靠。
6.2 数据统计看板
管理员需要看到报修量趋势、分类占比、师傅工作量排名。这些数据用 SQL 聚合查询就能出。
# 近7天每日报修量 daily_stats = db.session.query( func.date(RepairOrder.create_time).label('date'), func.count(RepairOrder.id).label('count') ).filter( RepairOrder.create_time >= datetime.now() - timedelta(days=7) ).group_by(func.date(RepairOrder.create_time)).all()前端用echarts-for-weixin渲染折线图和饼图。注意小程序端 echarts 需要下载组件包放到components目录,然后在页面 JSON 里注册。数据量不大时直接返回 JSON 数组,前端渲染即可。
6.3 评价与信用分机制
学生确认完成后可以给师傅打分,1 到 5 星。师傅的信用分取所有评价的平均值,显示在师傅主页上。信用分高的师傅在待接单池里排序靠前,形成正向激励。
def get_worker_rating(worker_id): result = db.session.query( func.avg(RepairOrder.rating) ).filter( RepairOrder.worker_id == worker_id, RepairOrder.rating.isnot(None) ).scalar() return round(float(result), 1) if result else 5.0这个机制上线后,师傅的平均响应时间从 4 小时降到了 1.5 小时,因为大家都想拿高分接更多单。不过要注意,新师傅没有评价记录,默认给 5 分,否则永远排不到单。
7. 我在这个项目里踩过的三个真实教训
第一个教训关于数据库字符集。建表时用了utf8而不是utf8mb4,结果学生报修描述里打 emoji 表情时直接报错。后来把所有表的字符集改成utf8mb4,连接字符串也加上charset=utf8mb4,问题才解决。现在建表我默认就写utf8mb4,不再用utf8。
第二个教训关于时间字段。早期用INT存时间戳,后来做统计查询时发现FROM_UNIXTIME转换很麻烦,而且时区处理容易出错。改成DATETIME类型后,SQL 里直接DATE(create_time)就能按天分组,方便太多。MySQL 的DATETIME不存时区,Flask 里统一用datetime.now()取本地时间,前后端约定好都用北京时间,省去转换烦恼。
第三个教训关于接口返回格式。一开始每个接口返回的 JSON 结构都不一样,有的用{code, data},有的用{status, result},前端处理起来很痛苦。后来统一成{code: 0, msg: '', data: {}}的格式,code=0表示成功,非 0 表示各种错误码。前端写一个统一的request封装,自动处理错误提示,代码量少了一半。
这套系统从零到上线大概用了三周,其中一周在调小程序端的交互细节,一周在后端接口和数据库,一周在部署和真机测试。如果你也在做类似的项目,我的建议是先把工单状态流转的规则想清楚再动手写代码,表结构设计好了,后面写接口就是水到渠成的事。另外别急着上微服务、消息队列这些重型方案,校园场景用最简单的技术栈把流程跑通,比什么都重要。