尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python Django+Vue实战:HPV疫苗预约系统并发防超卖与状态机设计

Python Django+Vue实战:HPV疫苗预约系统并发防超卖与状态机设计 1. 为什么这个项目最适合用 PythonVue 来落地1.1 预约系统的本质是状态管理过去两年我帮几家社区卫生服务中心做过预约类系统其中 HPV 疫苗预约是最典型的一种。它表面上是用户选疫苗-填信息-提交但拆开来看本质上是一个状态管理问题一条预约记录从创建开始要在已预约、待接种、已完成、已取消、已过期之间流转每一步都可能被用户手动触发或系统自动触发。很多新手接到这种需求第一反应是这不就是个表单吗但真正做起来会发现完全不是这么回事。HPV 疫苗预约有几个特性决定了它和普通挂号系统不同一是疫苗批次有真实库存比如某个批次只有 80 支预约满了就是满了二是用户实名限制一个人对同一批次不能重复预约三是预约开放时间点很关键常见的是每天早上 8 点放号几十上百人同时点提交这时候并发问题就会冒出来。1.2 前后端分离的协作优势选择 PythonVue 组合除了技术本身成熟还因为这类社区级项目经常需要快速交付、后续迭代。Python 后端在业务逻辑层写起来非常直接Vue 前端做预约流程的交互又很顺手——列表页、表单校验、倒计时开放、个人中心这几个页面用 Vue 的单文件组件组织代码非常清晰。项目里我把前后端彻底分离后端只提供 JSON API前端用 Vue Router 管理页面、axios 发请求。这样做的好处是开发和联调不互相阻塞而且后续如果要加管理后台、加小程序端复用同一套 API 即可。整套系统的架构说不上高大上但足够实用。2. 后端选型与工程搭建Django 还是 Flask一句话说清楚2.1 Django 与 Flask 的实际差异很多人在技术群问HPV 疫苗预约网站用 Django 还是 Flask其实这个问题不能泛泛回答要看你项目里到底需要什么。我做项目这些年对这两个框架的体感是这样的对比维度DjangoFlask自带组件ORM、Admin后台、认证、表单、迁移只有路由和基础WSGI数据库迁移内置 makemigrations/migrate要配 Alembic 或手动建表认证系统内置 User 模型和权限框架需自行实现或引入扩展项目结构规范统一适合多人协作灵活自由适合细微定制排错难度有明确规范和类名报错定位快自由度大规范靠自己维护上手门槛对新手偏重但资料极丰富简单起步快深入后要拼装预约系统需要事务、用户认证、数据库迁移这三大基础设施Django 全给你打包好了。Flask 很轻但意味着我要自己组装 user session、ORM、迁移工具项目做到一半容易在边角料上花时间。我个人在这类带库存、带账号体系的业务系统里都会选 Django开发效率高一个量级。2.2 我选 Django 的几个具体理由第一个理由是ORM 在处理事务锁的时候非常顺手。预约高峰期并发抢名额后端得保证两个请求同时扣减库存不会把库存扣成负数Django 的transaction.atomic搭配数据库行锁写起来很简洁这一点 Flask 也能做但要多接 SQLAlchemy 的会话管理心智负担更大。第二个理由是Admin 后台免费送。卫生服务中心的工作人员要核对预约名单、手动标记接种状态他们不太可能在命令行里操作。Django Admin 直接注册模型就能生成可用的后台页面权限管理、筛选、搜索都有省了我单独写管理端的时间。第三个理由是项目结构统一。Django 的 app 划分会让代码有清晰的边界用户认证一个 app、疫苗批次一个 app、预约记录一个 app后续加功能就在新 app 里做不会把项目写成一坨。2.3 用 Pycharm 初始化项目与目录结构环境准备其实不复杂。Python 版本我用的 3.10 以上Pycharm 里新建项目时选 Django 模板它会自动生成 manage.py 和项目同名配置目录。之前看到有人问Pycharm 怎么配置 Python 环境最稳的办法是装完 Python 后在 Pycharm 的 Settings Project Python Interpreter 里选择已有的解释器别用默认的虚拟环境当全局环境容易路径混乱。我习惯的目录长这样hpv_booking/ ├── manage.py ├── config/ # Django 项目配置目录 │ ├── settings.py │ └── urls.py ├── apps/ │ ├── users/ # 用户认证扩展 │ ├── vaccine/ # 疫苗批次管理 │ └── appointment/ # 预约记录 ├── static/ # 前端打包产物最终也放这里 └── templates/ # 仅登录页/首页兜底用Pycharm 里新建 app 用python manage.py startapp vaccine就行注意把 app 注册进INSTALLED_APPS否则数据迁移时会提示找不到表。这里有个新手高频报错Django 默认只识别项目根目录下的 app如果 app 放在apps/子目录还需要在配置里加上sys.path.insert(0, os.path.join(BASE_DIR, apps))或者在启动命令里换路径否则会一直报ModuleNotFoundError。3. 数据建模疫苗库存与预约状态机设计3.1 核心表结构与字段说明这套系统的核心表我用三张用户表、疫苗批次表、预约记录表。用户表直接继承 Django 内置的User因为它的认证、权限、session 机制都是现成的如果要存身份证号、手机号这些实名信息我在扩展表里加字段。疫苗批次表负责管理每一批可预约的疫苗# vaccine/models.py from django.db import models class VaccineBatch(models.Model): STATUS_CHOICES ( (open, 预约中), (closed, 已截止), ) name models.CharField(max_length50, verbose_name疫苗名称) total models.PositiveIntegerField(verbose_name本批总量) stock models.PositiveIntegerField(verbose_name剩余量) start_time models.DateTimeField(verbose_name预约开放时间) end_time models.DateTimeField(verbose_name预约截止时间) status models.CharField(max_length10, choicesSTATUS_CHOICES, defaultopen)预约记录表的核心在于用状态字段驱动流程# appointment/models.py class Appointment(models.Model): STATUS_CHOICES ( (booked, 已预约), (cancelled, 已取消), (completed, 已接种), (expired, 已过期), ) user models.ForeignKey(auth.User, on_deletemodels.CASCADE, related_nameappointments) batch models.ForeignKey(vaccine.VaccineBatch, on_deletemodels.CASCADE, related_nameappointments) status models.CharField(max_length10, choicesSTATUS_CHOICES, defaultbooked) remark models.CharField(max_length200, blankTrue, verbose_name备注) created_at models.DateTimeField(auto_now_addTrue, verbose_name预约时间)字段设计上我特意用了PositiveIntegerField而不是普通IntegerField这样数据库层面就把负数排除了auto_now_addTrue能保证创建时间不需要手动传。3.2 状态机设计与推进规则状态流转的规则我是这样定的用户提交预约后生成一条booked记录工作人员在后台确认接种完成后改成completed用户主动取消改cancelled预约截止后没来接种的由后台脚本统一改成expired。这里有个细节不要用删除记录表示取消。因为我需要在后台看到完整的预约历史谁曾经预约过又取消了统计数据里有用。状态机的好处是任何一条记录都能追溯而且后端的权限校验可以按状态来判断。3.3 唯一约束与索引重复预约是预约系统最头疼的问题之一。用户可能狂点提交按钮、可能用两个设备同时提交数据库层面必须兜住。我在模型里加了唯一约束class Meta: unique_together (user, batch)同时在batch和status字段上建立联合索引因为最常见的查询就是某个批次下有效预约有多少条。索引要加对位置不然数据量大了以后后台列表页会明显变慢。4. 预约接口防超卖并发控制是这套系统的灵魂4.1 什么情况下会超卖先解释一下超卖是怎么发生的。假设某批次库存stock1两个用户同时提交预约后端代码可能是这样的先查库存有没有剩再插入预约记录最后把库存减一。问题在于查库存-减库存这两步之间是有时间窗口的请求 A 查到 skock1 还没减请求 B 也查到 stock1于是两个人都插入成功库存却变负数。这就是典型的并发竞态。社区服务中心上线首日几十个人同时抢同一个批次遇到这种情况一点都不意外。要解决它光在 Python 代码里加判断没用必须让数据库层面保证查和改是原子的。4.2 Django 事务锁的正确用法Django ORM 里最直接的办法是select_for_update()。它会在事务内对查询到的行加锁其他事务想改同一行时必须等当前事务提交或回滚。预约接口的核心代码是这样# appointment/views.py from django.db import transaction from django.utils import timezone from rest_framework.response import Response transaction.atomic def book_vaccine(request, batch_id): try: # 加行锁锁定该批次记录 batch VaccineBatch.objects.select_for_update().get( idbatch_id, statusopen ) except VaccineBatch.DoesNotExist: return Response({detail: 批次不存在或预约已截止}, status400) now timezone.now() if now batch.start_time or now batch.end_time: return Response({detail: 当前时间不在预约范围内}, status400) if batch.stock 0: return Response({detail: 该批次已约满}, status400) # 事务内检查重复预约 if Appointment.objects.filter(userrequest.user, batchbatch).exists(): return Response({detail: 您已预约过该批次}, status400) Appointment.objects.create(userrequest.user, batchbatch, statusbooked) batch.stock - 1 batch.save() return Response({detail: 预约成功}, status201)关键词是transaction.atomic必须和select_for_update配合使用。光有锁但没有事务包裹锁无法生效。另外注意锁一定要锁到行如果用filter().select_for_update()查询出多行锁的范围会变大这里按主键id查询是最精准的。4.3 幂等与重复预约拦截锁解决了并发问题但高并发下还要考虑接口幂等。用户点击提交后如果网络差前端可能会自动重试重试就会产生两条记录。我在提交接口里先检查当前用户对当前批次是否存在有效预约重复的请求直接返回提示而不是再次扣减库存。更进一步建议在前端做一层按钮防抖提交后把按钮置为 loading 并禁用直到拿到响应。前端的防抖能挡住大部分重复点击后端的唯一约束和事务锁是最后一道防线。线上网站被真实用户疯狂点击的情况远超你想象千万不要把前端校验当保险。实测下来用这个方案在测试环境模拟 200 个并发请求抢 50 个名额最终预约记录数精确为 50没有超卖响应时间平均在 300ms 左右。这个结论让我后续部署时心里踏实很多。5. Vue 前端要做的三件事登录、列表、预约表单5.1 Vue 环境与项目初始化前端部分我用了 Vue 3 Vite没有用 Vue CLI。Vite 的开发服务器启动速度快很多热更新也流畅在 Pycharm 里打开前端项目没有任何问题。初始化命令npm create vitelatest web_front -- --template vue cd web_front npm install npm install axios vue-router pinia element-plus如果本地没有 Node.js先去官网装 LTS 版本然后在npm install过程中大概率会遇到网络慢的问题可以用npm config set registry https://registry.npmmirror.com切换镜像源实测比默认源快很多。Pycharm 里运行前端项目就是配置一个 npm 脚本Settings Tools Terminal 里切换到web_front目录然后npm run dev终端里会输出地址和端口。5.2 axios 拦截器与登录态维持前后端分离后登录态我用 JWT 方式维持。用户登录成功后端返回一个 token前端存到 localStorage之后的每个请求在请求头带上Authorization: Bearer token。问题是怎么让每个请求都带上答案就是 axios 拦截器// web_front/src/utils/request.js import axios from axios import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) request.interceptors.response.use( response response.data, error { const status error.response error.response.status if (status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } ) export default request这里有几个基础但关键的细节。一是 401 跳转登录页时要清掉旧 token否则死循环跳转二是开发阶段后端端口和前端不同需要配 vite 代理下面联调章节讲三是response.data直接把 HTTP 包裹层去掉各个页面里拿到的就是后端返回的业务 JSON。5.3 核心页面组件实现思路页面结构我做了四个视图首页展示可预约批次列表、批次详情页带预约按钮、我的预约列表、登录注册页。其中批次列表页是这个系统的门面用户进来第一眼就要看到还能不能约、什么时候放号。批次列表的数据结构由后端返回。前端拿到数据后按状态分组展示可预约的用绿色卡片标识约满的置灰并显示已约满未到开放时间的显示距离开放还有 xx:xx:xx。这个倒计时我用了一个 1 秒的定时器每秒刷新剩余时间到点后调接口重新拉取批次状态。预约表单页的关键是提交前二次确认。用户选好日期、确认个人信息后弹一个确认框显示您确认预约 XX 批次的 HPV 疫苗吗防误操作也降低无效请求。表单校验用 Element Plus 的表单组件身份证号、手机号按正则校验前端能过滤掉一大部分脏数据减少后端无谓的库存锁竞争。6. 联调部署阶段最容易踩的四个坑6.1 开发模式跨域与代理配置前后端分离联调时前端地址是localhost:5173后端是localhost:8000浏览器会拦截跨域请求。解决跨域有两种主流方案但网络上有大量过时文章。第一种是后端加 CORS 中间件允许特定来源访问。用django-cors-headers改成# settings.py INSTALLED_APPS [ ... corsheaders, ] MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, ... ] CORS_ALLOWED_ORIGINS [ http://localhost:5173, ]第二种推荐的做法是前端代理。在 Vite 配置里把/api开头的请求转发到后端// vite.config.js export default defineConfig({ plugins: [vue()], server: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true } } } })使用代理的好处是生产环境和开发环境前端代码不用改因为请求路径始终是相同的/api/...。我个人强烈推荐第二种省心很多。6.2 生产部署Nginx Gunicorn部署阶段我没有用 Django 自带的 runserver生产环境跑 runserver 是新手最容易犯的错。正确的做法是用 Gunicorn 启动 Djangopip install gunicorn gunicorn config.wsgi:application --workers 3 --bind 0.0.0.0:8000workers数量一般按 CPU 核心数的两倍到四倍配置。为什么不能单 worker因为单 worker 虽然能保证预约的select_for_update排队执行但响应吞吐不够高峰期会卡住。多 worker 下 Django 和数据库的连接池要留意Django 每来一个请求就取一个数据库连接把CONN_MAX_AGE设成 60 秒能减少连接频繁创建的开销。前端构建产物给 Nginx 托管cd web_front npm run build把dist/目录下的文件拷贝到服务器的某个目录然后在 Nginx 配置里做静态文件映射和 API 反向代理server { listen 80; server_name your_domain; # 前端静态文件 root /var/www/hpv_web; index index.html; # 前后端路由兼容前端是 history 模式 location / { try_files $uri $uri/ /index.html; } # API 转发到 Django location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }6.3 静态文件与 Django Admin 样式如果你开了 Django Admin部署时容易发现一个问题Admin 的 CSS 样式全丢了。原因是 Django 的静态文件没收集到指定目录。Django 在开发环境自动处理静态文件生产环境必须手动收集python manage.py collectstatic --noinput然后在settings.py配置静态文件路径STATIC_URL /static/ STATIC_ROOT BASE_DIR / staticfilesNginx 里再补一段位置指向staticfilesAdmin 界面就正常了。这个坑说大不大但没有文档提示很多第一次部署的人会卡在小半天。6.4 时区与预约开放时间的坑最后一个容易被忽视的坑是时间。Django 默认的USE_TZ True会在数据库里存 UTC 时间我们预约开放时间是2025年6月1日 08:00用户显然期望的是北京时间。如果后端配置的TIME_ZONE Asia/Shanghai且USE_TZ True写入start_time时要用带时区的时间前端展示时也要注意序列化后的 ISO 字符串里带不带08:00。我自己踩过一次测试时填的预约开放时间是明天 08:00前端倒计时显示的是凌晨某个时间排查半天才发现是 Vue 里new Date()把 ISO 字符串按本地时区解析了而后端返回的是 UTC。最后统一在后端返回数据时转成北京时间前端不做第二次转换问题才解决。最后说几句实战体会这套预约系统做完上线后我最深的体会是预约类的项目后端并发控制和状态机设计永远比页面样式重要。界面丑一点用户能忍但明明显示有库存提交却失败一个人约了两次都扣了库存这种事会直接让工作人员电话被打爆。如果你也打算做类似的疫苗预约、挂号预约、活动报名系统建议先从数据库模型和后端扣库存机制开始设计再用前端把流程串起来。过程中多想想用户最快能在一秒钟内点几下你会发现很多你以为是边角料的问题其实是核心问题。有条件的同学可以在本地多模拟几轮高并发抢购用工具打几百个并发请求跑一遍看到预约记录和库存对得上那才叫真的没问题。
返回列表