十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Django与Vue3实战:疫苗接种预约系统的并发控制与前后端联调

Django与Vue3实战:疫苗接种预约系统的并发控制与前后端联调 做这类“管理系统”的项目很多人第一步就卡在技术栈选型和前后端怎么配合上。问我“Python后端配Vue前端到底怎么落地”的人一直不少这次就以一个疫苗接种预约管理系统为例把从表结构设计到前后端联调部署的完整思路捋一遍。这套东西麻雀虽小五脏俱全有权限、有时段库存、有并发控制做完它能顺手解决掉一大类业务管理系统的通病。1. 从需求到架构为什么是Django Vue 3这条组合1.1 业务需求先拆清楚一个疫苗接种预约管理系统表面看就是“用户选疫苗→选时间→提交预约→现场接种”但真落库的时候你会发现至少有四类角色在操作普通用户注册登录、浏览疫苗列表与库存、选择接种点和时段、提交/取消预约、查看接种记录接种点管理员维护本接种点的可预约时段、统计当日预约人数、核销预约码疫苗管理员维护疫苗品种、厂家、批号、剂次信息和库存总量系统管理员管理用户、管理接种点、查看全局预约数据、处理超时未接种的异常订单我见过很多半途而废的项目问题都出在没把“疫苗”和“接种点”的关系理清楚。疫苗是全局的但库存和时段是跟接种点绑定的。A接种点能约到某款疫苗B接种点不一定有货。所以预约表的外键不能只指向疫苗必须同时落到“某个接种点某个时间段某个疫苗批次”这个组合上。1.2 技术选型Django的“电池”优势后端选了Python。Python后端里Django和Flask常年被拿来对比这个场景我直接站Django而且建议你也这么选理由很实际ORM自带迁移机制改模型后一条python manage.py makemigrations就能同步表结构不用手写SQL到处对字段自带Admin后台疫苗、接种点这些基础数据的管理界面几乎零成本生成省掉一大块开发量用户认证体系完整配合djangorestframework-simplejwt做JWT登录很顺畅事务处理、信号机制这些能力预约系统中扣减库存、记录日志全是刚需前端选了Vue 3 Vite Pinia Element Plus。Vue 3的Composition API写业务逻辑比Options API清晰很多Vite的启动速度和热更新效率也比Webpack时代的Vue CLI舒服。Element Plus的表格、表单、日期选择器正好覆盖管理后台的常见需求。1.3 表结构设计从需求到字段核心表我设计了这样几张UserProfile扩展Django自带的User加手机号、身份证号脱敏字段、接种记录关联Vaccine疫苗信息名称、生产厂家、批号、适用年龄段、剂次要求第1针/第2针/加强针、禁忌说明VaccinationSite接种点名称、地址、经度纬度、联系电话、每日最大接容量、工作时间VaccineBatch某接种点下的疫苗批次库存关联Vaccine和VaccinationSite记录剩余数量AppointmentSlot可预约时段关联VaccinationSite包含日期、开始时间、结束时间、剩余名额Appointment预约单关联用户、时段、疫苗批次记录预约状态、预约码、接种时间、实际接种的医生/护士这里有个关键点AppointmentSlot的剩余名额和VaccineBatch的剩余库存是两套数字。前者控制“这个时间段还能进几个人”后者控制“这款疫苗在这个点还有多少支”。用户提交预约时两端都要扣任一端不足都直接拒绝。# models.py 核心表设计 from django.db import models from django.contrib.auth.models import User class Vaccine(models.Model): name models.CharField(疫苗名称, max_length100) manufacturer models.CharField(生产厂家, max_length100) batch_no models.CharField(批号, max_length64, uniqueTrue) doses_required models.PositiveIntegerField(所需剂次, default1) age_min models.PositiveIntegerField(最小年龄, default0) age_max models.PositiveIntegerField(最大年龄, default200) description models.TextField(说明, blankTrue) created_at models.DateTimeField(auto_now_addTrue) class VaccinationSite(models.Model): name models.CharField(接种点名称, max_length100) address models.CharField(地址, max_length255) phone models.CharField(联系电话, max_length20) work_start models.TimeField(开始工作时间) work_end models.TimeField(结束工作时间) daily_capacity models.PositiveIntegerField(日最大接容量, default100) class VaccineBatch(models.Model): vaccine models.ForeignKey(Vaccine, on_deletemodels.PROTECT, related_namebatches) site models.ForeignKey(VaccinationSite, on_deletemodels.PROTECT, related_namebatches) batch_no models.CharField(批次号, max_length64) stock models.PositiveIntegerField(库存数量, default0) expire_date models.DateField(有效期至) class AppointmentSlot(models.Model): site models.ForeignKey(VaccinationSite, on_deletemodels.PROTECT, related_nameslots) date models.DateField(日期) start_time models.TimeField(开始时间) end_time models.TimeField(结束时间) total_quota models.PositiveIntegerField(总配额, default10) remaining models.PositiveIntegerField(剩余名额, default10) class Meta: unique_together (site, date, start_time, end_time) class Appointment(models.Model): STATUS_CHOICES [ (pending, 待接种), (completed, 已接种), (cancelled, 已取消), (expired, 已过期), ] user models.ForeignKey(User, on_deletemodels.PROTECT, related_nameappointments) slot models.ForeignKey(AppointmentSlot, on_deletemodels.PROTECT, related_nameappointments) batch models.ForeignKey(VaccineBatch, on_deletemodels.PROTECT, related_nameappointments) status models.CharField(状态, max_length20, choicesSTATUS_CHOICES, defaultpending) appointment_code models.CharField(预约码, max_length32, uniqueTrue, blankTrue) created_at models.DateTimeField(auto_now_addTrue) vaccinated_at models.DateTimeField(实际接种时间, nullTrue, blankTrue)外键上的related_name一定要写。不写的话Django默认叫appointment_set多张表关联起来代码惨不忍睹写了之后user.appointments.all()直接取某人的预约列表。1.4 状态机预约单的流转逻辑预约单的状态不能随便跳我设计了这样一套流转规则pending待接种用户提交预约或管理员后台确认后的状态completed已接种接种点管理员核销时从pending改为completedcancelled已取消用户主动取消或管理员审核不通过expired已过期预约日期已过且未接种由定时任务批量更新状态机的好处是后端代码里不用到处判断“能不能取消”统一写一个服务方法处理状态迁移和库存回补。后面如果加“改期”功能也是先取消再新建逻辑依然清晰。2. 预约系统的核心难点时段库存与并发控制2.1 可预约时段是怎么生成的很多人不知道这个环节多耗时。每个接种点每天的工作时间不一样光靠管理员手动为每个接种点每天建时段一个月下来能烦死。正常做法是写一个管理命令或后台按钮输入日期范围、每次预约时长比如15分钟、时间段粒度比如半小时一个槽系统自动生成所有时段的AppointmentSlot记录。# management/commands/generate_slots.py from django.core.management.base import BaseCommand from datetime import datetime, timedelta, time from appointments.models import VaccinationSite, AppointmentSlot class Command(BaseCommand): help 生成指定日期范围内所有接种点的预约时段 def add_arguments(self, parser): parser.add_argument(--days, typeint, default7) def handle(self, *args, **options): days options[days] sites VaccinationSite.objects.all() for site in sites: current datetime.now().date() for offset in range(days): work_date current timedelta(daysoffset) slot_start datetime.combine(work_date, site.work_start) slot_end datetime.combine(work_date, site.work_end) while slot_start timedelta(minutes30) slot_end: AppointmentSlot.objects.get_or_create( sitesite, datework_date, start_timeslot_start.time(), end_time(slot_start timedelta(minutes30)).time(), defaults{total_quota: 10, remaining: 10} ) slot_start timedelta(minutes30) self.stdout.write(时段生成完成)注意get_or_create命令重复执行不会产生重复数据。2.2 超卖问题为什么普通判断不靠谱业务上最容易出事故的就是超卖。用户A和用户B同时请求提交预约都先查了remaining 1都判断“还有名额”然后都插入了一条预约记录。结果就是同一个时段约进来了两个人现场打起来。教科书上的解决办法是数据库行级锁Django里对应的是select_for_update()。它必须在事务块里使用否则不会真正锁定。# services.py 预约提交核心逻辑 from django.db import transaction from django.utils import timezone from .models import AppointmentSlot, VaccineBatch, Appointment import uuid transaction.atomic def create_appointment(user, slot_id, batch_id): # 锁定时段行防止并发超卖 slot AppointmentSlot.objects.select_for_update().get(idslot_id) if slot.date timezone.now().date(): raise ValueError(该时段已过期) if slot.remaining 0: raise ValueError(该时段预约名额已满) # 锁定疫苗批次行同样要锁 batch VaccineBatch.objects.select_for_update().get(idbatch_id) if batch.stock 0: raise ValueError(疫苗库存不足) appointment Appointment.objects.create( useruser, slotslot, batchbatch, statuspending, appointment_codeuuid.uuid4().hex[:12].upper(), ) slot.remaining - 1 slot.save() batch.stock - 1 batch.save() return appointment为什么用了select_for_update还要在代码里判断remaining 0因为锁挡住的是同时提交的并发请求但锁不住先前的逻辑判断。拿到锁之后必须重新检查一次最新值这叫“乐观判断死悲观锁下手”。这段代码放在transaction.atomic里任何一个raise ValueError都会让整个事务回滚已经扣掉的库存自动恢复。2.3 取消预约的库存回补取消预约的逻辑相对简单同样要走事务transaction.atomic def cancel_appointment(user, appointment_id): appointment Appointment.objects.select_for_update().get(idappointment_id) if appointment.user ! user: raise PermissionError(不能取消别人的预约) if appointment.status ! pending: raise ValueError(当前状态不允许取消) appointment.status cancelled appointment.save() # 回补时段名额和疫苗库存 slot AppointmentSlot.objects.select_for_update().get(idappointment.slot.id) slot.remaining 1 slot.save() batch VaccineBatch.objects.select_for_update().get(idappointment.batch.id) batch.stock 1 batch.save() return appointment回补的逻辑必须和下单逻辑放在同一个事务里否则可能出现“预约取消了但库存没加回来”的数据不一致。2.4 JWT认证接入后端接口用DRF实现认证用JWT。配置不复杂装两个包pip install djangorestframework djangorestframework-simplejwt django-cors-headerssettings.py里配置REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: ( rest_framework_simplejwt.authentication.JWTAuthentication, ), DEFAULT_PERMISSION_CLASSES: ( rest_framework.permissions.IsAuthenticated, ), } from datetime import timedelta SIMPLE_JWT { ACCESS_TOKEN_LIFETIME: timedelta(hours2), REFRESH_TOKEN_LIFETIME: timedelta(days7), }需要用户名密码换token的标准接口SimpleJWT已经帮你写好了直接映射路由就行。# urls.py from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns [ path(api/token/, TokenObtainPairView.as_view(), nametoken_obtain_pair), path(api/token/refresh/, TokenRefreshView.as_view(), nametoken_refresh), ]3. Vue前端预约页面的组件化实现3.1 工程搭建和基础配置前端用Vite创建Vue 3项目然后安装Element Plus、Vue Router、Pinia、Axiosnpm create vitelatest vaccination-frontend -- --template vue cd vaccination-frontend npm install element-plus vue-router4 pinia axios目录结构推荐按模块划分而不是按文件类型划分src/ api/ # 接口请求 views/ # 页面 components/ # 公共组件 store/ # Pinia状态 router/ # 路由配置 utils/ # 工具函数axios实例等按文件类型划分的坏处是改一个预约功能要在api、components、views三个目录来回跳按模块聚合之后一个功能的所有相关文件都在附近维护成本明显低。3.2 Axios封装与token自动携带Axios实例一定要单独封装不要在每个页面里直接axios.get。统一封装后可以集中处理baseURL、超时时间、请求头、响应拦截器。// utils/request.js import axios from axios import { ElMessage } from element-plus import router from ../router const service axios.create({ baseURL: /api, timeout: 10000, }) // 请求拦截器每次请求自动携带token service.interceptors.request.use( config { const token localStorage.getItem(access_token) if (token) { config.headers.Authorization Bearer ${token} } return config }, error Promise.reject(error) ) // 响应拦截器统一处理错误码和token失效 service.interceptors.response.use( response response.data, error { const status error.response?.status if (status 401) { ElMessage.error(登录已过期请重新登录) localStorage.clear() router.push(/login) } else { ElMessage.error(error.response?.data?.message || 请求失败) } return Promise.reject(error) } ) export default serviceaxios拦截器是前端联调阶段必须搞懂的东西。后端返回401的时候不可能让每个页面都自己跳转登录统一在拦截器里处理是唯一合理的方案。3.3 预约核心页面从选疫苗到确认提交用户预约的流程拆成四个步骤我建议用一个独立页面配合Stepper组件实现选择疫苗选择接种点和日期选择具体时间段确认信息提交每个步骤的组件单独写用Pinia保存跨步骤的数据。以下是一个简化但完整的选择时段组件!-- components/SlotSelect.vue -- template div classslot-select el-date-picker v-modelselectedDate typedate :disabled-datedisabledDate placeholder选择日期 changefetchSlots / div v-ifloading classloading加载中.../div div v-else-ifslotList.length 0 classempty 该日期暂无可用时段 /div el-radio-group v-modelselectedSlot v-else el-radio-button v-forslot in slotList :keyslot.id :labelslot.id :disabledslot.remaining 0 {{ slot.start_time }} - {{ slot.end_time }} (剩余{{ slot.remaining }}) /el-radio-button /el-radio-group /div /template script setup import { ref, watch } from vue import { getAvailableSlots } from ../api/appointment const props defineProps({ siteId: { type: Number, required: true }, }) const emit defineEmits([update:modelValue]) const selectedDate ref() const selectedSlot ref(null) const slotList ref([]) const loading ref(false) function disabledDate(date) { // 只允许选择未来7天 const today new Date() const maxDate new Date() maxDate.setDate(today.getDate() 7) return date today || date maxDate } async function fetchSlots() { if (!selectedDate.value) return loading.value true try { const res await getAvailableSlots({ site_id: props.siteId, date: selectedDate.value, }) slotList.value res } finally { loading.value false } } watch(selectedSlot, val { emit(update:modelValue, val) }) /scriptdisabled-date是Element Plus日期选择器的一个小细节很多人忘记加这个属性结果用户能选过去的日期后端再返回“该时段已过期”体验就很差。前端拦截一部分无效请求后端再兜底校验两层防护最稳。3.4 路由守卫未登录状态拦截前端不能只靠隐藏按钮控制访问路由守卫是必须的// router/index.js router.beforeEach((to, from, next) { const token localStorage.getItem(access_token) const requiresAuth to.matched.some(record record.meta.requiresAuth) if (requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) return } if (to.path /login token) { next(/) return } next() })query: { redirect: to.fullPath }这个细节值得提一下用户被拦截到登录页后登录成功可以直接跳回原来想去的页面而不是永远回到首页。用户体验的差异就是从这个细节来的。3.5 管理端预约数据的表格化呈现管理端用Element Plus的el-table和el-tag就能做得很规整。我习惯给状态字段加一个映射表不直接在模板里写死字符串const statusMap { pending: { label: 待接种, type: warning }, completed: { label: 已接种, type: success }, cancelled: { label: 已取消, type: info }, expired: { label: 已过期, type: danger }, }模板里这样用el-table-column propstatus label状态 width100 template #default{ row } el-tag :typestatusMap[row.status].type {{ statusMap[row.status].label }} /el-tag /template /el-table-column管理端还有一个刚需按日期查看各接种点的预约统计。后端写一个聚合接口按site_id和date分组统计预约数前端用柱状图或日历热力图展示。这个功能我建议优先做因为管理者最关心的永远是“明天XX接种点约了多少人排班够不够”。4. 前后端联调与部署阶段踩过的坑4.1 跨域问题开发环境和生产环境的正确解法联调阶段第一个拦路虎就是跨域。很多新手一遇到跨域就想着开代理、关浏览器安全策略其实正经解法就一个后端开启CORS。Django项目里装好django-cors-headers之后配置INSTALLED_APPS [ ... corsheaders, ] MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, ... ] # 开发阶段先全放开上线前收紧 CORS_ALLOW_ALL_ORIGINS True # 或者指定可访问域名上线建议用这个 # CORS_ALLOWED_ORIGINS [ # http://localhost:5173, # https://yourdomain.com, # ]开发阶段我用5173端口跑Vite后端在8000端口本地不配CORS照样跨域。配好之后联调非常顺畅。生产环境更推荐用Nginx反向代理让Vue的请求直接打到同域名的/api路径上浏览器层面根本不会跨域server { listen 80; server_name yourdomain.com; # Vue打包后的静态文件 location / { root /var/www/frontend; try_files $uri $uri/ /index.html; } # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }4.2 Vue打包后的“白屏”和“路由404”前端跑npm run build之后把dist目录丢到服务器一访问要么白屏要么刷新路由页面就404。这两个问题几乎人人都能遇到。白屏大概率是静态资源路径问题。Vite默认base是/如果你的站点部署在子目录下资源路径全错。打开页面按F12看请求发现css/js的URL是/assets/xxx.js而不是/yourpath/assets/xxx.js那就改vite.config.jsexport default defineConfig({ base: /, // 如果部署在根路径保持默认 // base: /vaccine/, // 如果部署在子路径改成这个 })路由404是Vue Router的history模式导致的。history模式路径切换由前端路由接管但用户直接刷新时浏览器会向Nginx请求/appointment/123这个真实路径Nginx找不到就直接返回404了。解决办法就是Nginx配置里的try_files $uri $uri/ /index.html;把所有请求都指向index.html由前端路由自己判断。提示如果项目对SEO有要求history模式就是必须的try_files这条配置不可或缺。如果纯内部管理系统对SEO没要求也可以直接用hash模式createWebHashHistory部署时什么路由配置都不用加基本不会出现这种问题。4.3 时区问题“预约时间怎么差了8小时”我第一次上这个系统就遇到过——用户提交预约之后管理员后台看到的时间比实际早8小时。原因是Django默认开启USE_TZ True数据库存的是UTC时间MySQL本地时间却是东八区两边各说各话。有两个解法我更推荐后者方案一把USE_TZ设为False。缺点是后续如果要处理多时区的用户就很痛苦。方案二保持USE_TZ True但在MySQL连接参数里加上时区设置DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: vaccine_db, USER: root, PASSWORD: yourpassword, HOST: 127.0.0.1, PORT: 3306, OPTIONS: { init_command: SET time_zone 08:00, }, } } TIME_ZONE Asia/Shanghai更稳妥的做法是前端展示时间前统一做一次格式化后端只保证数据正确存储。前端可以直接用原生Date对象function formatTime(datetimeString) { const date new Date(datetimeString) return date.toLocaleString(zh-CN, { hour12: false }) }4.4 性能优化索引和分页预约系统的表数据量单看不大但预约记录会持续积累查询会越来越慢。两个最基础的优化必须提前做第一数据库索引。Appointment表的status和created_at联合索引AppointmentSlot表的site和date联合索引一定要建。用Django的index_together或Meta.indexes在model里直接声明class Appointment(models.Model): ... class Meta: indexes [ models.Index(fields[status, created_at]), models.Index(fields[user, status]), ]第二列表接口必须分页。DRF框架自带了分页类配置一下就好REST_FRAMEWORK { DEFAULT_PAGINATION_CLASS: rest_framework.pagination.PageNumberPagination, PAGE_SIZE: 20, }4.5 一个小细节预约时间段冲突的用户体验前后端联调到后期我发现一个用户高频反馈的问题选好疫苗、选好接种点、选好日期但点开时段列表时发现全被约满了。用户白白操作了三步。优化方案查询时段列表时把疫苗库存也一并判断。接口里加一个参数vaccine_batch_id过滤时段时同时筛掉对应批次库存不足的# views.py 获取可用时段 def get_available_slots(request): site_id request.GET.get(site_id) date request.GET.get(date) batch_id request.GET.get(batch_id) slots AppointmentSlot.objects.filter( site_idsite_id, datedate, remaining__gt0, ) # 过滤掉对应批次库存不足的时段 if batch_id: batch VaccineBatch.objects.select_for_update().get(idbatch_id) if batch.stock 0: return Response([]) serializer SlotSerializer(slots, manyTrue) return Response(serializer.data)这个优化倒不是纯性能问题更多是减少无效操作带来的用户流失。真正做业务系统的时候很多“小优化”的价值比什么缓存策略、分布式架构大得多。写在最后我给不少朋友看过这个系统的代码发现大家最容易忽略的永远是两件事事务边界和状态流转。一个预约系统订单、库存、时段三条数据链路只要有一条没有在事务里保持一致上线后补数据能补到怀疑人生。另一个是状态机很多项目把状态判断散落在各个视图函数里今天加一个“改期”明天加一个“爽约”代码迟早变成一团乱麻。如果这个项目要往下走我建议按这个顺序迭代先把超时未接种的自动过期任务做了再说接入微信通知先把管理端的统计报表做好再说优化Appointment表的分表方案。基础功能做到位这套架构再撑一两万日活用户不成问题。
返回列表