每年考期一到,最忙的永远不是考生,而是负责安排考试的人。报名表在微信群里传来传去,Excel改了第八个版本还有人填错场次,管理员一边核对名额一边被考生反复追问“还有没有位置”。我做这套基于Python后端、微信小程序前端的考试预约报名系统,最初的目标就是结束这种混乱。整套系统的核心价值可以压缩成三件事:考生能随时看到真实名额、预约报名不冲突、管理员能一键导出名单。如果你正在做毕业设计,或者需要给培训机构、校内考试、资格认证活动搭建一套报名入口,这篇文章应该能帮你少走不少弯路。
需要先说明一点:这篇文章默认你有一定的Python基础,也会用微信开发者工具新建项目。我不会从“什么是print()”开始讲,而是把整个系统从需求拆解、技术选型、数据库设计、后端接口、小程序端页面,一直聊到上线部署和踩坑记录。你完全可以照着文章里的表和代码去搭,再根据自己的考试场景改字段。
1. 需求拆解:预约系统的本质不是填表,而是资源分配
动手写代码之前,最忌讳的是直接打开编辑器就建项目。考试预约报名系统和普通的“意见收集表单”有本质区别,核心差异就是两个字:名额。意见收集表单无所谓超不超,100个人提交和1000个人提交都能存进数据库;但考试预约不一样,一个考场就那么多座位,报满了就是报满了。所以这套系统的设计核心不是“把数据存下来”,而是“在有限名额下把报名行为管住”。
1.1 两类使用者的真实诉求不一样
我把需求拆成了两个角色来看,只有先搞清楚每类人到底要什么,才能设计出不被吐槽的页面。
对考生来说,他们要的东西很朴素:
- 能浏览有哪些考试、什么时间考、在哪个考场
- 能看到某个场次还剩多少名额,不用打电话问管理员
- 一键报名,报名后能查看自己的预约记录
- 有事去不了,能在截止时间前取消,把名额让给别人
对管理员来说,他们的诉求更偏向管理侧:
- 创建考试和场次,设置每个场次的总名额
- 查看某个场次的报名人数和名单
- 手动取消违规或重复的报名记录
- 导出Excel名单,方便线下安排座位和通知
我把这些整理成了一张功能清单,后续所有表和接口都围绕这张清单展开:
| 模块 | 考生端功能 | 管理员端功能 |
|---|---|---|
| 考试浏览 | 查看考试列表、场次、剩余名额 | 增删改考试信息、设置场次名额 |
| 预约报名 | 一键报名、取消预约 | 查看报名列表、手动取消记录 |
| 个人中心 | 查看我的预约、状态变更 | 导出Excel、统计数据 |
| 身份识别 | 微信授权登录,自动识别用户 | 绑定管理员账号,权限区分 |
1.2 为什么说“预约”和“报名”是两个状态机
刚开始我以为预约记录就是一行数据:用户选了考试,插入记录,完事。后来真正用起来才发现不对。预约记录在生命周期内要经历好几种状态:
- 已预约:用户提交成功,等待考试
- 已取消:用户主动取消或管理员取消
- 已完成:考试时间已过,状态自动或手动置为完成
- 已失效:超时未参加之类的情况
状态一旦多起来,就不能再拍脑袋写逻辑了。我在项目里把状态字段设计成字符串常量,前后端统一约定,后面做“我的预约”页面就非常轻松。这一点在第五章会详细展开。
2. 技术选型:为什么是Python后端配上原生微信小程序
这套系统的关键词里有Python、微信小程序,很多人一看就会问:Python后端框架那么多,该选哪个?小程序端是用原生还是用uni-app?我分别说一下我的选择过程和理由。
2.1 后端框架:Flask还是Django
近几年Python后端的两个主流选择就是Flask和Django。我从实际项目体量出发做了对比:
| 对比维度 | Flask | Django |
|---|---|---|
| 项目体量 | 轻量,适合中小型API服务 | 重量级,自带Admin后台、ORM、迁移工具 |
| 学习曲线 | 平缓,写路由很快 | 陡峭,概念多,目录结构严格 |
| 灵活度 | 高,想用什么库自己挑 | 低,很多模块绑定了框架约定 |
| 适合场景 | 预约报名、小型工具、毕业设计 | 内容管理系统、大型Web应用 |
| 部署成本 | 低,一个gunicorn命令就能跑 | 略高,静态文件、迁移等都要处理 |
我最后选了Flask。原因很实际:这套系统核心就是十几个HTTP接口,不需要Django那套繁重的后台管理;虽然Django自带的Admin可以直接管理数据,但预约系统需要一个更贴近业务的“管理界面”,不只是在表里增删改查。如果你实在喜欢Django的Admin体验,也可以拿Django重写一遍接口层,但是对预约报名这种体量来说,Flask是性价比最高的选择。
顺带提一下Python环境问题。很多同学卡在第一步,并不是代码的问题,而是Python没有装好。不管你是Windows还是macOS,我建议去Python官网下载安装包时,勾选“Add Python to PATH”这个选项,否则后面命令行里会找不到python命令;安装第三方库时用国内镜像源会快很多,比如 pip install -i https://pypi.tuna.tsinghua.edu.cn/simple flask flask-sqlalchemy pyjwt requests 这样一条命令就能把这些依赖装好。
2.2 小程序端:原生还是uni-app
这个选择其实要看你的长远目标。如果只想做微信小程序,我强烈建议用原生方式写,也就是直接在微信开发者工具里创建项目。原因是原生框架的包体积最小、API调用最直接、调试最方便,也不会遇到“uni-app打包后体积超过2MB”这种头疼事。网上经常有人问uniapp打包微信小程序为什么会报 source size 2612kb exceed max limit 2mb,其实就是因为是跨端框架生成的冗余代码太多。
如果你有同时上线支付宝小程序、抖音小程序的计划,那用uni-app没毛病;但如果只是围绕微信生态解决问题,原生就够了。原生提供了完整的WXML、WXSS、JavaScript体系,写预约页面这种结构不算复杂的前端绰绰有余。
数据库方面,我强烈建议开发阶段直接用SQLite,零配置、文件型数据库,非常适合先跑通逻辑;等要部署到服务器时再切换成MySQL,SQLAlchemy ORM的适配成本很低。如果你们的并发量预期很高,可以在数据库前面再加一层Redis做分布式锁,但这是后话,第六章我会说清楚什么情况才需要它。
3. 数据库设计:四张核心表与“防超卖”的底层逻辑
预约报名系统听起来功能不少,但真正核心的表其实就是四张:用户表、考试表、场次表、预约记录表。把这四张表的关系和字段设计清楚,系统的大梁就搭起来了。
3.1 用户表:用openid做身份标识
微信小程序不像传统网站有“用户名+密码”,用户身份是靠微信的openid来识别的。同一个微信用户在不同小程序里的openid不一样,但在同一个小程序里是稳定唯一的。所以用户表的第一个唯一字段必须是openid。
我的用户表大概是这样的:
CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, openid VARCHAR(64) NOT NULL UNIQUE, unionid VARCHAR(64), nickname VARCHAR(64), avatar_url VARCHAR(512), phone VARCHAR(20), role VARCHAR(16) DEFAULT 'student', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP );role字段用来区分考生和管理员,默认是student,管理员账号我在后台手动UPDATE成admin。这样小程序端可以通过这个字段决定要不要显示“管理入口”,不需要单独建一张角色表。
3.2 考试表与场次表:父子表结构
考试信息我拆成了两张表:考试(exam)和场次(session)。原因是同一场考试可能有多个时间段、多个考场。比如“全国计算机等级考试”可能分周六场和周日场,两场的名额是独立的。考试表存“这是什么考试”,场次表存“哪一场在什么时间什么地点有多少名额”。
CREATE TABLE exams ( id INTEGER PRIMARY KEY AUTOINCREMENT, title VARCHAR(128) NOT NULL, description TEXT, enrollment_start DATETIME, enrollment_end DATETIME, status VARCHAR(16) DEFAULT 'open', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE exam_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, exam_id INTEGER NOT NULL, name VARCHAR(128) NOT NULL, location VARCHAR(256), exam_time DATETIME NOT NULL, total_quota INTEGER NOT NULL, remaining_quota INTEGER NOT NULL, version INTEGER DEFAULT 0 );这里的 remaining_quota 字段非常关键,它就是防超卖的第一道闸门。为什么不用“每次查询预约记录数然后和总名额对比”?原因有两个:一是count查询在数据量上来后效率低,二是它无法应对并发——两个用户同时看到剩余1个名额,同时提交,count出来的结果都是没满,最后就超卖了。所以我在数据库里直接冗余一个剩余名额字段,每次报名时把它做条件更新,具体逻辑在第六章讲。
3.3 预约记录表:数据库层面的防重复
预约记录表是整个系统的“流水账”,它记录的是“谁在什么时候预约了哪个场次”。
CREATE TABLE reservations ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, exam_id INTEGER NOT NULL, session_id INTEGER NOT NULL, status VARCHAR(16) DEFAULT 'booked', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE (user_id, session_id) );最后这行 UNIQUE (user_id, session_id) 是很多新手会忽略的。如果没有它,代码里判断“是否已报名”只需要一瞬间的并发就可能失效——两个请求同时查到没报名,然后同时插入成功,用户就预约了两次。有了数据库层的唯一约束,不管多高的并发,只要同一个用户对同一个场次插入两次,数据库自身就会报错。这是一种“哪怕业务代码写漏了,数据库也会兜底”的设计思路。
3.4 外键与级联删除的慎用
微博上有些教程喜欢在每张表上写外键约束,我在开发时刻意减少了外键的使用。原因很现实:预约系统的业务数据很重要,我不希望一个考试被误删时,级联删除把几万条预约记录全清掉。所以我在代码里控制业务逻辑,不在数据库层面绑定级联关系。这种取舍不一定是数据库规范主义的答案,但对业务安全是更稳的选择。
4. 后端接口设计与微信登录闭环
数据库设计好了,接下来就是后端接口。Flask写接口很快,路径清晰,我把核心接口列一下:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/auth/login | 微信登录,用code换openid并签发token |
| GET | /api/exams | 获取考试列表 |
| GET | /api/exams/<exam_id>/sessions | 获取某考试的场次列表及剩余名额 |
| POST | /api/reservations | 提交预约报名 |
| DELETE | /api/reservations/<reservation_id> | 取消预约 |
| GET | /api/my/reservations | 查看我的预约列表 |
| GET | /api/admin/exams/<exam_id>/reservations | 管理员查看报名名单 |
| GET | /api/admin/export/<session_id> | 导出Excel名单 |
4.1 微信登录:code换openid再换Token
微信小程序端的登录流程看起来简单,实际有一套固定的执行序列:
- 小程序调用 wx.login() 拿到一个临时code
- 小程序把code通过请求发给后端
- 后端拿着code调用微信的 code2Session 接口,换取openid和session_key
- 后端用openid查用户表,查不到就自动创建用户
- 后端生成JWT返回给小程序,之后所有请求都带这个token
这里最需要注意的一点是:code只能使用一次,有效期只有几分钟。所以后端拿到code后必须立刻调用微信接口换取信息,不要想着把code存进数据库备用。我在项目里写过一段很典型的登录代码:
import requests import jwt from datetime import datetime, timedelta from flask import Flask, request, jsonify app = Flask(__name__) app.config['SECRET_KEY'] = 'your-secret-key' APPID = 'your-appid' SECRET = 'your-appsecret' @app.route('/api/auth/login', methods=['POST']) def login(): data = request.get_json() code = data.get('code') if not code: return jsonify({'code': 400, 'msg': '缺少code'}), 400 resp = requests.get( 'https://api.weixin.qq.com/sns/jscode2session', params={ 'appid': APPID, 'secret': SECRET, 'js_code': code, 'grant_type': 'authorization_code' } ).json() if 'openid' not in resp: return jsonify({'code': 400, 'msg': resp}), 400 openid = resp['openid'] user = find_or_create_user(openid) token = jwt.encode( {'user_id': user['id'], 'exp': datetime.utcnow() + timedelta(days=7)}, app.config['SECRET_KEY'], algorithm='HS256' ) return jsonify({'code': 0, 'data': {'token': token, 'user': user}})JWT的好处是后端不需要维护session表,每次请求带过来的token里自己包含用户id和过期时间,解码验证通过就算登录成功。
4.2 获取手机号:先想清楚你真的需要吗
很多教程一上来就教你怎么在小程序里“获取手机号”,但2023年之后微信对手机号快速验证组件陆续开始按调用量计费,动不动就谈获取手机号其实很贵,而且对预约系统来说也没必要。
预约系统的身份标识靠openid就够了。考生报名、取消、查看记录,全部围绕openid关联的user_id来做。手机号只有在管理员需要线下联系考生时才重要,而这时候完全可以让考生在报名表单里自己填写“联系电话”,或者注册时通过普通input组件输入,不需要硬接微信的付费接口。想清楚这一点,你会少走很多弯路,也少花冤枉钱。
4.3 管理员接口的权限校验
管理员接口不能只靠前端隐藏入口来保护,后端必须做校验。我的做法是写一个装饰器,在JWT解码成功后再检查user的role字段,不是admin就返回403。Flask里写装饰器非常简单:
from functools import wraps def admin_required(f): @wraps(f) def wrapper(*args, **kwargs): payload = decode_jwt(request) if not payload or payload.get('role') != 'admin': return jsonify({'code': 403, 'msg': '无权限'}), 403 return f(*args, **kwargs) return wrapper5. 小程序端核心页面与预约状态机
后端接口就绪后,前端这边我分了四个页面:首页考试列表、考试详情页、报名确认页、我的预约页。前端代码用原生写法,整体逻辑很直白。
5.1 首页:考试列表与剩余名额展示
首页通过 wx.request 请求 /api/exams 接口,拿到考试列表。每个考试卡片显示标题、报名截止时间、场次个数,点击进入详情页。这里有两个细节值得注意:
第一,wx.request 的 url 必须是HTTPS地址,而且要在小程序后台配置“合法域名”,开发阶段可以勾选开发者工具里的“不校验合法域名”,但真机调试和上线时必须配置正确,否则请求会被拦截。
第二,前端展示的剩余名额,只是一个“参考值”。真正的名额扣减必须由后端事务处理,前端就算把数字写死也没有意义。所以我在详情页直接展示后端返回的 remaining_quota,不做任何本地计算。
5.2 预约状态机:怎么设计状态流转
预约记录的状态流转,是整个前端逻辑里最容易乱的部分。我是这样规定的:
- 已报名(booked):用户报名成功,等待考试
- 已取消(cancelled):用户主动取消或者管理员取消
- 已完成(completed):考试时间已过,管理员手动标记或定时任务自动标记
- 已失效(expired):考试结束但用户未参加
这两个状态之间的切换规则是:
- booked -> cancelled(用户取消、管理员取消)
- booked -> completed(考试结束)
- booked -> expired(考试结束且未参加)
- cancelled 状态不可再变回 booked
我的“我的预约”页面里,根据不同的status显示不同样式的卡片和按钮。已报名显示“取消预约”按钮,已取消显示灰色“已取消”标签,已完成显示“查看成绩”或“已完成”标签。
5.3 报名链路上的防重复点击
报名按钮的点击,必须做防重复处理。用户手抖连点两下,或者网络慢时反复点击,后端会收到两个相同请求。除了数据库唯一约束兜底,前端也要加一层保护:
Page({ data: { submitting: false, session: null }, submitReservation() { if (this.data.submitting) return; this.setData({ submitting: true }); wx.request({ url: 'https://your-domain.com/api/reservations', method: 'POST', header: { Authorization: 'Bearer ' + wx.getStorageSync('token') }, data: { session_id: this.data.session.id }, success: (res) => { if (res.data.code === 0) { wx.showToast({ title: '报名成功', icon: 'success' }); } else { wx.showToast({ title: res.data.msg, icon: 'none' }); } }, complete: () => { this.setData({ submitting: false }); } }); } });这个 submitting 标志位本质上只是降低重复点击概率,真正的防重还是靠后端。用户看到“报名成功”的toast之后,一定要跳转到“我的预约”页,让用户直观看到那条 booking 状态的记录,这一步对信任感提升帮助很大。
6. 并发场景与反作弊处理:抢名额时的关键一战
预约报名最刺激的场景就是“放名额”。比如管理员设定上午10点开放某场次的50个名额,瞬间有300个人同时点击报名。这种并发场景下有两大问题:超卖和重复预约。
6.1 超卖问题的两种解法
超卖就是“卖出去的座位比实际座位多”,根源在于并发下的“先查后改”。两个请求同时读到 remaining_quota=1,都判断可以扣减,都执行了插入,最后报名成功的人就超过了实际名额。
我采用的解法是原子更新,也就是让“检查名额和扣减名额”变成一步操作:
from flask import current_app from werkzeug.exceptions import BadRequest def create_reservation(user_id, session_id, exam_id): db = current_app.db # 尝试扣减名额,remaining_quota > 0 是条件 result = db.execute( """ UPDATE exam_sessions SET remaining_quota = remaining_quota - 1, version = version + 1 WHERE id = ? AND remaining_quota > 0 """, (session_id,) ) if result.rowcount == 0: raise BadRequest('该场次名额已满,报名失败') # 扣减成功后再插入预约记录,靠唯一约束兜底 try: db.execute( """ INSERT INTO reservations (user_id, exam_id, session_id, status) VALUES (?, ?, ?, 'booked') """, (user_id, exam_id, session_id) ) db.commit() except Exception: db.rollback() raise BadRequest('您已报名过该场次,请勿重复提交')这段代码的精髓在于UPDATE语句里的 AND remaining_quota > 0。数据库在执行UPDATE时会锁住符合条件的行,两个并发请求到了这一层会排队,第一个扣减成功后 remaining_quota 变成0,第二个 UPDATE 匹配不到行,rowcount 返回0,直接判定为“名额已满”。这就是“条件更新”天然防超卖的原理。
如果并发量真的巨大,比如几十万人在同一秒抢,单靠数据库行锁也够呛。那时候可以引入Redis做分布式锁,或者用Redis的Lua脚本先扣减Redis里的库存,再异步落库。但对于考试报名来说,峰值也就是几千人并发,MySQL的原子UPDATE完全扛得住,我用压测脚本模拟500个并发请求时,数据库没有一次超卖。所以不要过早引入Redis,那会让系统复杂度翻好几倍。
6.2 防重复预约的三道防线
同样的用户对同一个场次重复报名,可能有三种手段触达:
- 前端防重复点击:按钮置灰,减少无效请求
- 后端逻辑判断:先查是否已报名,存在则直接拒绝
- 数据库唯一约束:最底层的兜底
三道防线缺一不可。但我要强调,第一道和第二道都是优化体验,只有第三道是真正的“法律”。哪怕前两道都因为并发穿透了,数据库唯一索引也会直接报错,事务回滚后重新释放名额,不会产生脏数据。
6.3 取消预约的名额释放
取消预约同样要小心。用户取消时,要把预约记录状态改成cancelled,同时把场次表的 remaining_quota 加1。这两个操作必须放在同一个事务里,否则会出现“记录取消了但名额没加回去”的经典bug。我早期就犯过这个错:有一次用户在截止时间前取消预约,前台显示取消成功,但管理员看到名额还是满的,最后是我手动补跑了一条SQL才解决。后来我把释放名额也写进事务,再没出过问题。
7. 上线部署与真机适配:从开发者工具到真实用户
开发环境跑通不等于系统能交付。真正上线时要处理三件事:注册认证、服务器部署、真机适配。
7.1 小程序注册与认证
需要先到微信公众平台注册一个小程序账号。个人主体和企业主体的区别在于:企业主体认证后能开通支付、获取更多接口权限;个人主体做预约报名这种类型通常够用。所以不必为了交一份不错的作业,就着急去花企业认证的钱。认证费也不是所有类目都必须交,具体以官方平台的要求为准。
账号注册后,要拿到AppID填进开发者工具,同时在小程序后台配置“服务器域名”。注意,不是填IP,必须是备案过的域名,而且HTTPS证书要有效。
7.2 后端部署:gunicorn + nginx
后端部署我用的是最常见的方案:Flask应用跑在gunicorn上,nginx做反向代理,同时由nginx或certbot管理HTTPS证书。核心命令大概长这样:
pip install gunicorn gunicorn -w 4 -b 127.0.0.1:5000 app:app然后nginx配置里把443端口的请求转发到5000端口。这样做的原因是gunicorn直接暴露公网不够安全,nginx可以帮忙处理静态文件、SSL终结、限流等事情。调试阶段我经常用 systemctl status xxx 查服务状态,用 journalctl -u xxx 看日志,这一步对定位线上问题特别管用。
7.3 顶部导航栏高度适配
真机适配里最容易被忽略的就是导航栏高度。很多开发者习惯用默认的navigationStyle,但如果你想做自定义导航,就得搞清楚小程序顶部那块区域在不同机型上的高度差异。尤其是有“胶囊按钮”(右上角那三个点)的机型,胶囊位置不一,写死高度就会在iPhone和安卓上对不齐。
我用的是微信提供的 wx.getMenuButtonBoundingClientRect 方法,拿到胶囊按钮的位置和尺寸,再配合系统状态栏高度计算出导航栏总高度:
const menuRect = wx.getMenuButtonBoundingClientRect(); const statusBarHeight = wx.getSystemInfoSync().statusBarHeight; const navHeight = (menuRect.top - statusBarHeight) * 2 + menuRect.height;这段代码几乎是每个自定义导航栏页面的标配。把这个值通过globalData传递下去,所有页面的顶部布局就能保持一致。
7.4 真机调试的接口问题
开发工具里接口能通,一到真机就黑屏或者白屏,九成问题出在“合法域名”和“HTTPS证书”。先在开发者工具里打开“不校验合法域名”确认接口逻辑本身没问题,然后在微信后台的“开发管理-开发设置-服务器域名”里把 request 合法域名配好,注意是只填域名,不要带 https:// 前缀,也不要写路径。配完之后在开发者工具里清缓存重新编译,基本能解决。
8. 踩坑记录与调试经验:我建这个系统时遇到的五个大坑
最后这部分是全文的精华,这些事情在官方文档里都不会写,却实实在在会耽误你一整天。
8.1 坑一:把code当成会话ID反复用
我第一版登录逻辑踩过这个坑:小程序每次页面加载都调用 wx.login 拿code,然后后端把code存在内存里,下次请求时校验。结果用户刷新几次页面就登录失效了。本质问题是我没有理解code是一次性的,正确做法是用code换完openid后,立刻生成自己的token,后续所有请求都靠token来识别用户,再也不碰code。
8.2 坑二:日期格式导致报名截止判断失灵
小程序端把日期字符串传给后端,Flask的JSON解析会把它变成UTC格式的字符串,导致我在判断“当前时间是否晚于报名截止时间”时出现八小时的偏差。后来我统一约定:前后端传输时间全部用时间戳,或者统一用“yyyy-MM-dd HH:mm:ss”格式字符串,并在后端显式带上时区解析。这个约定我写进了接口文档的第一条,后面再也没有因为时间出过问题。
8.3 坑三:用count()判断名额导致超卖
我在第六章已经说明了原理,这里要强调一下当时定位问题的过程。第一次压测时,我往报名接口发送200个并发,发现数据库里多出了3条超卖记录。当时的第一反应是加数据库唯一约束,跑完发现并没有完全解决,因为六个不同用户的并发也会超卖。后来我开始一行行过插入逻辑,看到“先 SELECT COUNT(*) 判断再 INSERT”的代码时,马上意识到问题所在——这个判断在并发下根本不是判断,然后才改成了原子UPDATE。如果你在网上找过免费的源码包,大概率也会遇到这种写法。所以我的建议是:免费源码可以参考页面设计,但核心的报名逻辑一定要自己重写,加上条件更新和唯一约束这两道保险。
8.4 坑四:管理权限只在前端藏入口
有一版我偷懒,管理员功能只是在页面里不显示入口,接口本身没做校验。后来拿抓包工具一测就露馅了——直接把普通用户的token放到管理员接口请求里,居然能拿到名单。从那之后我养成了习惯:任何管理接口,第一行就是权限校验,宁可多校验一次,也不能默认调用方是好人。
8.5 坑五:SQLite和MySQL的迁移差异
开发阶段用SQLite很爽,但部署时切到MySQL会碰到一些小差异。比如SQLite的布尔类型其实就是整型,MySQL是TINYINT;SQLite的 TEXT 类型非常宽松,MySQL的 VARCHAR 有长度限制;还有DATETIME默认值的语法也不同。建议在开发阶段就用SQLAlchemy之类的ORM来定义模型,让ORM帮你抹平这些差异,而不是手写大量原生SQL。如果非要手写,那至少把数据库切换这件事安排在项目早期,不要拖到最后两天再折腾。
这套系统从需求梳理到最终上线,我前后花了大概半个月。期间最大的体会是:预约报名类系统真正复杂的地方不在页面交互,而在数据一致性上,把“唯一约束”和“原子扣减”这两个机制吃透,整个项目就稳了一半。如果你后续想继续扩展,可以往短信通知、Excel批量导入考试、多管理员权限、成绩查询这些方向走,骨架已经打好了,加功能并不会太费劲。