
HPV疫苗预约这个事做过的人才知道有多折腾。社区卫生服务中心的电话基本打不进去放号那几秒页面卡死等你刷新出来热门接种点的名额早就被秒空了。我去年帮朋友做了一套HPV疫苗预约网站技术栈就用标题里这四样Python做后端、Vue做前端开发工具选PyCharm后端框架在Django和Flask之间做了个组合方案——主服务用Django辅助服务用Flask。这篇文章把当时的完整方案复盘一遍项目结构怎么搭、数据模型怎么设计、预约并发怎么防超卖、部署踩了哪些坑尽量把能直接抄作业的部分都写出来。文章适合正在做类似预约类系统的人参考不论你用的是疫苗、挂号还是其他资源预约场景核心逻辑都是相通的。如果你刚看完Python基础想找一个完整项目练手也可以照着这条路线走一遍。1. 项目整体设计与技术选型思路1.1 为什么是PythonVue这套组合先聊为什么选这套技术栈。Python在Web开发里属于短平快选手Django和Flask的学习曲线都相对平缓ORM、模板引擎、Admin后台这些配套设施齐全一个人两周左右就能把核心业务做出来。Vue则解决了前端交互的痛点——疫苗预约涉及大量表单、动态库存展示、预约状态切换如果用传统的后端模板渲染页面上每个状态变化都要刷新页面体验很僵硬。Vue的响应式数据绑定让前端状态和用户操作直接联动组件化也让疫苗卡片、预约表单、记录列表这些模块可以复用。对比过Java Spring Boot React那套组合。Spring Boot在大型企业级系统里确实更强但HPV预约这个业务的复杂度撑不起那么重的架构。选型原则应该是业务复杂度决定架构复杂度而不是反过来。PythonVue的组合在业务逻辑不复杂、需要快速上线、一个人能维护的前提下开发和运维成本最低。1.2 Django还是Flask我的选型和理由标题里同时出现了django和flask这也是我当时纠结的地方。这里放一个对比对比维度DjangoFlask自带功能ORM、Admin后台、认证、表单、中间件等全家桶极简核心只处理路由和视图ORM质量成熟完善支持复杂查询、事务无内置ORM需配SQLAlchemyAdmin后台自带管理界面改配置就能用需要扩展包或自己写适合场景数据模型多、关系复杂、需要管理后台的业务系统轻量API、微服务、快速原型学习曲线稍陡概念多但都有文档平缓半天能跑通预约系统最麻烦的一点是数据关系比较多用户、疫苗品类、批次库存、预约记录、接种点信息这些表之间有关联查询而且需要后台管理疫苗库存、审核预约记录。Django的ORM和Admin后台在这里简直是天然匹配——Admin界面改一改就能管理疫苗品类和库存数量省掉一整块后台开发时间。Flask被我用在了另一个地方。系统里需要一个给接种点工作人员生成预约接种名单报表的小工具用Flask写了三个路由读数据库、渲染HTML模板、返回PDF跑在Django主服务旁边互不干扰。这样两个框架都发挥了各自的长处。如果你做类似项目不必纠结到底选哪个完全可以像我这样让主服务用Django边缘小功能用Flask按需分配。1.3 整体架构与数据流设计架构上采用前后端分离模式。Vue负责页面渲染和用户交互Django只提供REST API两者通过HTTP JSON通信。整个数据流长这样用户在Vue页面选择疫苗品类、填写个人信息、选择预约时段点击提交后Vue通过封装的axios请求把数据POST到Django的API接口Django的URL路由把请求转给对应的视图函数视图函数通过序列化器校验参数然后调用ORM操作数据库数据库返回结果后Django组织成JSON响应回传给前端前端拿到数据后更新页面状态提示预约成功或失败。这套数据流的优势在于前后端完全解耦。前端改版不影响后端接口后端换数据库也不影响前端逻辑。对个人开发者来说意味着可以先把后端API全部跑通用Postman测试完毕再开发前端页面调试效率高很多。2. 开发环境准备与项目初始化2.1 Python与PyCharm环境配置要点先说环境。Python版本建议直接用3.10我实际用的是3.10.11。别再用Python 2.x了很多新库都不支持。装完之后一定要建虚拟环境我见过太多人在全局环境里装了一堆包最后版本冲突到连Django都启动不了。在PyCharm里配置环境很简单新建项目时选择Virtualenv作为解释器类型Python版本选到你安装的版本。PyCharm社区版完全够用免费开源Django插件、Vue插件都支持不需要折腾专业版。装依赖的时候注意版本兼容。我当时踩过一个坑Django 5.0发布后有些第三方库还没跟上安装时自动拉取了最新版结果启动直接报错。稳妥做法是在requirements.txt里锁定主版本号Django4.2.17 djangorestframework3.14.0 django-cors-headers4.3.1 PyMySQL1.1.0 gunicorn21.2.0安装命令就是pip install -r requirements.txt。2.2 Django项目骨架与App设计用命令行创建项目django-admin startproject hpv_reservation cd hpv_reservation python manage.py startapp users python manage.py startapp vaccines python manage.py startapp appointments设计三个App对应三个业务域users用户注册、登录、个人信息vaccines疫苗品类、批次、库存管理appointments预约记录、时间安排、取消操作在settings.py里注册App配置数据库。开发阶段直接用SQLite零配置就跑得起来。部署前切换到MySQL兼容性很好。INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, rest_framework, corsheaders, users, vaccines, appointments, ] DATABASES { default: { ENGINE: django.db.backends.sqlite3, NAME: BASE_DIR / db.sqlite3, } } TIME_ZONE Asia/Shanghai USE_TZ True这里有个容易忽略的点预约系统对时间敏感时区必须设置成Asia/Shanghai。否则Django默认用UTC时间数据库里存的时间和用户实际看到的时间相差8小时预约时段会整个错位。2.3 Vue项目创建与Axios接入Vue项目我用Vite来创建比之前的Vue CLI快很多npm create vuelatest npm install npm install vue-router4 axios项目结构如下src/ ├── api/ │ ├── request.js # axios实例封装 │ └── appointment.js # 预约相关接口 ├── components/ │ ├── VaccineCard.vue │ └── AppointmentForm.vue ├── views/ │ ├── HomeView.vue │ ├── VaccineDetail.vue │ └── AppointmentRecord.vue ├── router/ │ └── index.js ├── App.vue └── main.jsaxios封装这里建议统一设置baseURL和拦截器。预约系统后续要加用户认证拦截器里预留token注入的位置避免每个接口单独写请求头// api/request.js import axios from axios 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 }) export default request2.4 前后端联调配置开发阶段最大的问题是跨域。Vue开发服务器跑在5173端口Django跑在8000端口浏览器会拦截跨域请求。需要两头配置。Vue这边在vite.config.js里配置devServer代理把/api开头的请求转发到Djangoexport default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true } } } })Django这边安装django-cors-headers允许本地开发域名访问CORS_ALLOWED_ORIGINS [ http://localhost:5173, http://127.0.0.1:5173, ]注意CORS中间件要放在最前面否则请求会被Django的安全机制拦截报错信息会误导你排查半天。3. 核心功能模块的落地实现3.1 疫苗品类与预约数据模型设计预约系统的核心是三个数据模型。疫苗品类表存疫苗的基础信息批次库存表存每个接种点的具体库存量预约记录表存用户的预约情况。# vaccines/models.py from django.db import models class VaccineType(models.Model): 疫苗品类比如二价、四价、九价 name models.CharField(max_length50, uniqueTrue, verbose_name疫苗名称) price models.DecimalField(max_digits8, decimal_places2, verbose_name单价) min_age models.IntegerField(verbose_name最小接种年龄) max_age models.IntegerField(verbose_name最大接种年龄) # 二价需要打三针每针间隔0-1-6个月这里简化成针数 dose_count models.IntegerField(default3, verbose_name接种针数) created_at models.DateTimeField(auto_now_addTrue) class Meta: verbose_name 疫苗品类 def __str__(self): return self.name class VaccineBatch(models.Model): 某个接种点的疫苗批次库存 vaccine models.ForeignKey(VaccineType, on_deletemodels.CASCADE, verbose_name疫苗) batch_no models.CharField(max_length50, verbose_name批次号) stock models.IntegerField(default0, verbose_name剩余库存) hospital_name models.CharField(max_length100, verbose_name接种点名称) expire_date models.DateField(verbose_name有效期) class Meta: verbose_name 疫苗批次 # appointments/models.py class Appointment(models.Model): STATUS_CHOICES ( (pending, 待接种), (completed, 已完成), (cancelled, 已取消), ) user models.ForeignKey(users.UserProfile, on_deletemodels.CASCADE, verbose_name预约用户) batch models.ForeignKey(vaccines.VaccineBatch, on_deletemodels.CASCADE, verbose_name疫苗批次) appoint_date models.DateField(verbose_name预约日期) appoint_time models.TimeField(verbose_name预约时段) status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultpending, verbose_name状态) created_at models.DateTimeField(auto_now_addTrue) class Meta: unique_together (batch, appoint_date, appoint_time) verbose_name 预约记录重点说说这个unique_together约束。HPV预约系统最怕的一件事就是同一时段同一接种点出现重复预约。这个数据库层面的唯一约束是防止超卖的最后一道防线——即使代码里逻辑有漏洞数据库也会在插入时直接报错拒绝。用户表我这里用Django自带的User模型扩展了UserProfile存身份证号、手机号这些预约必需的实名信息。Django自带的auth_user表已经处理了用户名、密码、邮箱等字段密码加密、登录认证、session管理这些功能直接继承没必要自己造轮子。3.2 REST API接口实现我用了Django REST Framework来构建API省去大量手写JSON解析和序列化的代码。先设计接口清单方法路径功能GET/api/vaccines/获取疫苗品类列表GET/api/vaccines/{id}/获取疫苗详情及可用库存POST/api/appointments/提交预约GET/api/appointments/查询我的预约记录DELETE/api/appointments/{id}/取消预约GET/api/users/profile/获取用户信息视图代码用DRF的APIView配合手动逻辑控制比ModelViewSet更清晰方便理解每一步做了什么# appointments/views.py from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from django.db import transaction from django.utils import timezone from .models import Appointment from .serializers import AppointmentSerializer, AppointmentCreateSerializer class AppointmentCreateView(APIView): def post(self, request): 创建预约核心逻辑是库存检查和冲突检查 serializer AppointmentCreateSerializer(datarequest.data) if not serializer.is_valid(): return Response(serializer.errors, statusstatus.HTTP_400_BAD_REQUEST) data serializer.validated_data batch data[batch] # 检查疫苗是否过期 if batch.expire_date timezone.now().date(): return Response({message: 该批次疫苗已过期}, statusstatus.HTTP_400_BAD_REQUEST) # 检查库存 if batch.stock 0: return Response({message: 疫苗库存不足}, statusstatus.HTTP_400_BAD_REQUEST) # 检查用户是否已有未完成的预约简化逻辑同一用户只能预约同一疫苗一次 existing Appointment.objects.filter( userdata[user], batch__vaccinebatch.vaccine, statuspending ).exists() if existing: return Response({message: 您已预约过该疫苗}, statusstatus.HTTP_400_BAD_REQUEST) # 扣减库存并创建预约 with transaction.atomic(): batch.stock - 1 batch.save() appointment Appointment.objects.create(**data) return Response({message: 预约成功, appointment_id: appointment.id}, statusstatus.HTTP_201_CREATED)取消预约的功能也很关键经常会用到。ORM里查询和删除操作要掌握class AppointmentCancelView(APIView): def delete(self, request, pk): 取消预约把库存加回去 try: appointment Appointment.objects.get( idpk, userrequest.user.userprofile ) except Appointment.DoesNotExist: return Response({message: 预约不存在}, statusstatus.HTTP_404_NOT_FOUND) if appointment.status cancelled: return Response({message: 预约已取消}, statusstatus.HTTP_400_BAD_REQUEST) with transaction.atomic(): # 释放库存 batch appointment.batch batch.stock 1 batch.save() # 修改预约状态 appointment.status cancelled appointment.save() return Response({message: 取消成功})这里用到了Django ORM的查询get、filter和删除/更新操作。Django的执行逻辑是懒查询真正访问数据库时才执行SQL。调试时可以打印.query属性看实际执行的SQL排查性能问题很有用。3.3 Vue前端疫苗选择与预约表单组件前端最重要的两个组件是疫苗卡片和预约表单。疫苗卡片展示疫苗名称、价格、适用年龄、剩余库存点击进入详情页。预约表单收集用户信息和时间选择。Vue组件用script setup语法比Options API简洁很多!-- components/VaccineCard.vue -- script setup defineProps({ vaccine: { type: Object, required: true } }) /script template div classvaccine-card click$router.push(/vaccine/${vaccine.id}) h3{{ vaccine.name }}/h3 p价格¥{{ vaccine.price }}/p p适用年龄{{ vaccine.min_age }}-{{ vaccine.max_age }}岁/p p接种针数{{ vaccine.dose_count }}针/p span classstock :classvaccine.stock_count 0 ? in-stock : out-stock {{ vaccine.stock_count 0 ? 剩余${vaccine.stock_count}支 : 已约满 }} /span /div /template这里注意路由参数的传递。从列表页进入详情页用$router.push带参数详情页通过route.params.id接收。Vue Router的params传参在页面刷新后会丢失所以详情页应该用query参数或者在进入详情页时根据ID重新请求API获取数据我在实现时选择了后者保证刷新后数据依然正确。预约表单是另一个核心。这里用到了Vue的双向绑定v-model来管理表单状态校验逻辑放在前端先跑一遍通过后再提交后端!-- components/AppointmentForm.vue -- script setup import { ref } from vue import request from ../api/request const props defineProps({ vaccineId: { type: Number, required: true } }) const form ref({ real_name: , id_card: , phone: , appoint_date: , appoint_time: }) const loading ref(false) const message ref() async function submitAppointment() { if (!form.value.real_name || !form.value.id_card || !form.value.phone) { message.value 请填写完整的个人信息 return } if (!form.value.appoint_date || !form.value.appoint_time) { message.value 请选择预约时间 return } loading.value true try { const res await request.post(/appointments/, { vaccine_id: props.vaccineId, ...form.value }) message.value res.data.message } catch (error) { message.value error.response?.data?.message || 预约失败请重试 } finally { loading.value false } } /script template div classappointment-form h3填写预约信息/h3 input v-modelform.real_name placeholder真实姓名 / input v-modelform.id_card placeholder身份证号 / input v-modelform.phone placeholder手机号 / input typedate v-modelform.appoint_date / select v-modelform.appoint_time option value09:00上午 09:00/option option value10:00上午 10:00/option option value14:00下午 14:00/option option value15:00下午 15:00/option /select button clicksubmitAppointment :disabledloading {{ loading ? 提交中... : 确认预约 }} /button p v-ifmessage{{ message }}/p /div /template预约成功后前端跳转到预约记录页展示当前用户的预约列表。这里也用了Vue Router的路由跳转配合组件内的onMounted钩子请求API拉取数据实现提交后自动刷新的效果。3.4 预约时段冲突与库存扣减的处理这是整个项目里最需要严谨对待的部分——并发场景。想象一个真实场景九价疫苗一个接种点放20个名额上午10点放号那一刻可能有几百人同时抢。如果不做处理库存大概率会被超卖。我用了两层保护。第一层是数据库的唯一约束Appointment模型里已经定义了unique_together (batch, appoint_date, appoint_time)这保证同一个接种点的同一个时段最多只存在一条预约记录。第二层是事务加行锁扣减库存时锁住批次记录防止两个请求同时读到库存为1然后都通过检查。from django.db import transaction with transaction.atomic(): # select_for_update 会锁定这一行直到事务结束 batch VaccineBatch.objects.select_for_update().get(idbatch_id) if batch.stock 0: return Response({message: 库存不足}, statusstatus.HTTP_400_BAD_REQUEST) # 扣库存 batch.stock - 1 batch.save() # 创建预约 appointment Appointment.objects.create( useruser, batchbatch, appoint_dateappoint_date, appoint_timeappoint_time, )select_for_update是MySQL InnoDB引擎的行级锁。当一个事务锁住了某一行的VaccineBatch数据其他事务在尝试锁定同一行时会阻塞等待。这样就保证了读库存扣库存是原子操作不会出现两个人同时读到库存1的情况。如果不是用MySQL而是SQLite开发测试行锁效果没那么明显好在生产环境切到MySQL后问题完全消失。4. 常见问题排查与项目部署实录4.1 前后端跨域与请求异常排查开发时最常遇到的报错就是CORS。典型场景浏览器控制台提示Access to XMLHttpRequest at http://localhost:8000/api/vaccines/ from origin http://localhost:5173 has been blocked by CORS policy。这个报错的原因很直接浏览器安全策略要求同源5173端口和8000端口不同源。解决办法就是前面配置的corsheaders中间件加CORS_ALLOWED_ORIGINS白名单。如果请求带上了自定义Header比如Authorization还需要额外配置CORS_ALLOW_HEADERS允许这些Header。另外有个坑如果Django的DEBUG True本地开发时跨域配置都是生效的但部署到服务器后ALLOWED_HOSTS必须显式写上域名或IP否则Django会返回400错误而且这个错误页面往往是英文的很容易让人误以为是CORS问题。4.2 并发场景下的预约数据一致性数据一致性我这里单独做一个排查实录。项目上线前做了一次压测用简单脚本模拟100个并发请求同时预约最后一个库存。第一次测试结果吓一跳——成功了12次库存变成了负数。原因复盘下来有两层第一层是我开始的代码根本没有加transaction.atomic()每个请求独立地读库存、判断、扣库存天然存在竞态条件。第二层是即使加了事务如果不用select_for_update事务隔离级别默认是REPEATABLE READ读操作是快照读不会锁行。完整解决办法就是上面代码展示的事务行锁方案。加完之后重新压测100个并发请求只有1个成功其余全部返回库存不足符合预期。这里要强调数据库层的唯一约束和代码层的事务锁是两道防线缺一不可。只靠代码逻辑锁遇到极端情况仍可能出问题只靠数据库约束报错信息不友好用户体验差。4.3 生产环境部署要点部署到生产环境大概花了半天时间。我的方案是Django主服务用Gunicorn跑Vue打包成静态文件交给Nginx托管Flask辅助服务单独用Gunicorn跑在另一个端口上Nginx将不同路径转发到不同后端服务。Nginx配置核心部分server { listen 80; server_name your_domain.com; # Vue打包后的静态文件 root /var/www/hpv_frontend/dist; index index.html; # 前端路由history模式刷新页面不404 location / { try_files $uri $uri/ /index.html; } # Django API 反向代理 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; } # Flask 辅助服务 location /report/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; } }try_files $uri $uri/ /index.html这行特别关键。Vue Router默认用history模式路由切换是前端行为但用户刷新/vaccine/3这个页面时浏览器会向服务器请求这个路径而服务器上并没有这个静态文件如果没有try_files回退到index.html页面会直接404。Django的启动命令gunicorn hpv_reservation.wsgi:application --bind 127.0.0.1:8000 --workers 3worker数量建议设为CPU核心数×21太多反而会因为进程切换降低性能。数据库要记得切到MySQL部署前先跑一遍python manage.py migrate建好所有表结构。静态文件收集用python manage.py collectstatic。4.4 踩坑记录与问题速查表做这个项目的过程中有很多零碎问题这里整理成一张速查表给后来的人少绕点弯问题现象根本原因解决办法接口返回401但登录状态正常未配置Token认证类Django默认要求Session认证跨域请求不带SessionDRF全局配置AUTHENTICATION_CLASSES使用TokenAuthentication预约时段显示偏差8小时Django时区未设置数据库存UTC时间settings里配TIME_ZONE和USE_TZ前端做时区转换前端提交表单后收不到响应axios拦截器未设置请求头Content-Type被默认成text/plain在axios实例中显式设置headers: {Content-Type: application/json}并发压测时库存变为负数未使用事务和行锁transaction.atomic()select_for_update()Nginx时报404错误前端路由history模式未配置try_files回退配置try_files $uri $uri/ /index.html部署后静态文件加载失败Django静态文件配置问题配置STATIC_ROOT和STATIC_URL用collectstatic收集交由Nginx处理推送疫苗到货通知给用户普通轮询体验差扩展方案用Django Channels做WebSocket后端库存变化时主动推送给前端关于WebSocket这个扩展点多说一句。预约到货通知这种场景用轮询也能实现前端每隔几秒请求一次库存接口看是否有变化。但用户量大了之后轮询压力不小而且延迟明显。如果要做更实时的体验Django Channels可以给Django加上WebSocket能力库存变化时后台主动推送给在线用户。这个属于二期优化项我第一版用的是轮询够用就行。写在最后的一些体会做这个项目最大的感触是预约类系统的难点不在功能实现而在数据一致性上。表面上看起来就是选疫苗、填信息、提交三个步骤但把并发、库存、冲突这几个边界情况都处理到位才算真正闭环。我建议你在仿写这个项目时一定要把并发扣库存的代码自己写一遍再想办法压测一下踩过这个坑之后对数据库事务的理解会很不一样。另外提醒一个容易忽略的点个人信息和疫苗相关数据属于敏感信息开发时可以随便用测试数据但如果有上线计划一定要做好HTTPS配置和用户隐私数据的加密存储不要在日志里打印身份证号和手机号。技术方案本身可以在开源社区找到很多参考但合规和数据安全这部分没有捷径该做的防护一个都不能省。如果你正在做类似项目先从最小可行版本跑通核心预约流程再加上用户管理、库存批次、取消预约这些功能一步步扩展。这套PythonVue的组合方案应付疫苗、挂号、培训课程这类资源预约场景完全够用。