Django+Flask+Vue构建律师预约系统:排期冲突与并发控制实战

发布时间:2026/10/10 3:10:30
Django+Flask+Vue构建律师预约系统:排期冲突与并发控制实战
律师预约系统这类项目我在过去几年里陆续接触过不少但这次用Django Flask双后端、Vue做前端、PyCharm做主力开发工具来落地的过程还是让我有不少新体会。表面上看就是一个选律师、约时间、下单、确认的流程真正动手之后才发现排期冲突、状态流转、时间处理、并发控制这些问题一个比一个刁钻。这篇文章不聊虚的直接把我从需求拆解到数据库设计、接口实现、前后端联调、问题排查的完整过程写出来项目里的关键代码和思路都会贴出来给准备做类似预约系统的朋友当一份路线图参考。1. 项目定位与需求拆解律师服务预约系统到底在做什么1.1 律师服务预约的特殊性很多人觉得预约系统不就是选个时间段提交完事吗我一开始也这么以为等真把需求文档梳理完才发现律师服务预约和普通的美甲预约、健身房预约完全不是一个量级。最直观的差异在时间颗粒度。律师的咨询时段通常不是按整天排的而是按半小时甚至十五分钟一个格子切分上午九点到十一点中间可能有四个格子但律师开会、出庭、写文书会随时占掉其中一部分。这种碎片化排期要求系统必须支持按律师可用时段生成可预约格子而不是简单地把工作日开放出来。另一个差异是决策链条长。用户翻律师列表要看执业年限、擅长领域、收费标准、历史评价选完律师之后还要填案件类型、案情描述提交之后还要等律师确认才能算预约成功。这一条链路里有大量的中间状态任何一个节点处理不当用户的耐心就会被消耗光。第三个特殊性是改期和取消的高频发生。律师临时有庭审、客户临时有会议都会触发改期流程。系统必须记录完整的预约变更历史否则两边各说各话售后问题能让人崩溃。1.2 角色与核心流程梳理我把整个系统拆成了三个角色客户端、律师端、管理端。这个拆分不是拍脑袋决定的而是顺着业务流走的。客户端要做的事很明确注册登录、浏览律师列表、查看律师详情和可预约时段、提交预约申请、查看预约状态、发起取消或改期、接收提醒通知。律师端的核心是排期管理维护自己的固定可预约时间段比如每周一三五的上午、设置临时不可预约的时间比如某天要出庭、查看收到的预约请求并确认或拒绝、查看历史预约记录和收入明细。管理端则负责基础数据的维护律师资质的审核、案件类型的增删改、收费标准的上限校验、以及全平台的预约数据统计。整个核心流程走下来是这样用户在客户端选中律师和时段创建预约单状态变为待确认律师在后台看到请求后选择确认或拒绝。确认后系统给双方发送提醒到了预约时间状态自动流转为已完成。如果有一方要取消进入取消流程需要记录取消方和原因。1.3 技术栈选型为什么是Django Flask Vue技术选型是项目启动时争论最多的一件事。团队里有人主张全用Django有人觉得Flask轻量更适合快速上手。最后定下来的是Django Flask双后端原因不复杂让合适的框架待在合适的位置。Django承担主体业务系统所有和用户、律师、预约订单相关的核心逻辑都放在这里。Django的ORM、Admin后台、数据迁移机制在处理这种关系型数据密集的系统时太成熟了尤其是Admin管理端的一半需求几乎可以零成本实现。Flask不做主业务单独跑在一个独立进程里负责两类边缘任务一类是定时提醒任务比如预约开始前两小时给双方推送通知另一类是接收第三方短信、邮件服务的回调接口。这两类任务有一个共同点——挂掉不能影响主流程。如果短信服务超时导致预约请求被阻塞这体验就太糟糕了。拆出去之后Flask服务挂了最多是提醒不发预约流程完全不受影响。前端选Vue是团队熟悉度决定的同时也考虑到Vue在组件复用、状态管理上的生态成熟度。配合Vue Router和Vuex现在也可以用Pinia做这种多页面的业务系统非常顺手。开发工具用PyCharm前后端一套IDE搞定Python后端调试和前端调试都能在一个工具里完成。需求选型核心理由主体后端Django DRFORM、Admin、迁移机制成熟业务开发效率高辅助服务Flask轻量、独立部署、失败不影响主流程前端Vue Vue Router Vuex组件化开发生态系统完善团队熟悉度高IDEPyCharm对Django、Flask、Vue都有完善支持调试方便这套组合的本质思路是重业务用重框架轻任务用轻框架互相之间不做过度耦合。2. Django主服务搭建工程结构、数据模型与预约核心逻辑2.1 工程规划与App划分项目启动阶段我的第一件事不是写代码而是规划Django的工程结构和App划分。律师预约系统的业务边界其实很清楚按领域模型拆App是最省心的方式。lawyer_appointment/ ├── manage.py ├── config/ │ ├── settings/ │ │ ├── base.py │ │ ├── dev.py │ │ └── prod.py │ ├── urls.py │ └── wsgi.py ├── apps/ │ ├── users/ # 用户注册、登录、资料管理 │ ├── lawyers/ # 律师档案、资质、排期 │ ├── cases/ # 案件类型与费率管理 │ └── appointments/ # 预约订单、时段冲突检测 ├── flask_service/ # Flask辅助服务 │ ├── app.py │ ├── scheduler.py │ └── requirements.txt └── frontend/ # Vue3前端工程 ├── src/ ├── package.json └── vite.config.js四个App的边界划分标准很简单一个App只负责一条完整的业务链路。users管人和身份lawyers管律师的静态档案和排期规则cases管案例类型和案件相关的分类数据appointments管预约实例及其状态变化。宁可多拆一个App也不要揉在一起后期维护的省心程度完全取决于这一步。Django项目里管理端不用另起炉灶直接用django.contrib.admin注册所有模型再配合自定义的ModelAdmin类和权限控制管理端80%的需求就有了。2.2 核心数据模型设计数据模型是整个系统的地基模型关系理顺了后面写逻辑能省一半时间。我直接贴核心模型代码然后逐一说下设计时的思考。用户模型直接继承Django的AbstractUser加一个user_type字段区分角色。这样做的好处是Django自带认证体系全部可用密码加密、登录态管理都不用自己造轮子。# apps/users/models.py from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): USER_TYPE_CHOICES ( (client, 客户), (lawyer, 律师), (admin, 管理员), ) user_type models.CharField(max_length20, choicesUSER_TYPE_CHOICES, defaultclient) phone models.CharField(max_length20, uniqueTrue) avatar models.URLField(blankTrue, default)律师档案和用户是一对一关系这样User表保持纯粹的身份认证律师专业信息单独放一张表。律师表里除了基本资料还要存收费标准和评价信息。# apps/lawyers/models.py from django.db import models from apps.users.models import User class Lawyer(models.Model): user models.OneToOneField(User, on_deletemodels.CASCADE, related_namelawyer_profile) license_number models.CharField(max_length64, uniqueTrue) title models.CharField(max_length50) # 执业职称 years_of_practice models.IntegerField() # 执业年限 firm models.CharField(max_length100, blankTrue) price_per_hour models.DecimalField(max_digits10, decimal_places2) rating models.FloatField(default5.0) bio models.TextField(blankTrue) is_verified models.BooleanField(defaultFalse) # 后台审核是否通过 def __str__(self): return f{self.user.username} - {self.title}lawyer和case_type是多对多关系一个律师可以擅长多个领域一个领域下也有多个律师。这里我没有单独建关联表直接从ManyToManyField的through参数自定义中间表好处是可以给关联关系附加字段比如擅长评分。预约表是核心中的核心。设计它的第一原则是所有关键业务操作都要有迹可循。# apps/appointments/models.py from django.db import models from django.conf import settings from apps.lawyers.models import Lawyer from apps.cases.models import CaseType class Appointment(models.Model): STATUS_PENDING pending STATUS_CONFIRMED confirmed STATUS_COMPLETED completed STATUS_CANCELLED cancelled STATUS_CHOICES ( (STATUS_PENDING, 待确认), (STATUS_CONFIRMED, 已确认), (STATUS_COMPLETED, 已完成), (STATUS_CANCELLED, 已取消), ) lawyer models.ForeignKey(Lawyer, on_deletemodels.CASCADE, related_nameappointments) client models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.CASCADE, related_nameappointments) case_type models.ForeignKey(CaseType, on_deletemodels.SET_NULL, nullTrue, blankTrue) start_time models.DateTimeField() # 预约开始时间 end_time models.DateTimeField() # 预约结束时间 description models.TextField(blankTrue, help_text案情描述) status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultSTATUS_PENDING) cancel_reason models.TextField(blankTrue) cancelled_by models.CharField(max_length20, blankTrue) # client或lawyer created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue) class Meta: ordering [-created_at] indexes [ models.Index(fields[lawyer, start_time]), models.Index(fields[client, start_time]), ]索引设计是很多人容易漏掉的部分。预约系统最常见的查询是查某个律师在某段时间内的预约和查某个用户自己的预约列表这两个组合索引能显著提升查询效率。初期数据量小可能感觉不到等订单量到几万条之后差别就出来了。2.3 预约冲突检测与并发控制预约系统的技术核心不在CRUD而在怎么防止同一个时段被两个人同时约上。我在第一次实现时踩过一个很经典的坑只判断了新时段和现有预约是否重叠但忽略了并发请求。先看重叠判断的边界条件。两个时间段重叠的判断方式是新时段的开始时间早于已有时段的结束时间且新时段的结束时间晚于已有时段的开始时间。写成Django ORM就是conflicting Appointment.objects.filter( lawyer_idlawyer_id, status__in[pending, confirmed], start_time__ltend_time, end_time__gtstart_time ).exists()注意状态过滤必须包含pending。因为一个待确认的预约虽然还没被律师点头但它已经占住了这个时段。如果pending不拦截就会出现用户A提交预约还在等确认用户B又约了同一个时间律师想确认A却发现撞车的尴尬局面。但上面的代码在并发场景下是有问题的。假设两个用户同时点了提交预约两个请求都通过了exists()检查然后都执行了create就会产生两条重叠记录。解决办法是给整个检查加事务锁from django.db import transaction transaction.atomic def create_appointment(lawyer_id, client_id, start_time, end_time, case_type_id, description): # 锁定律师行让并发请求串行执行 lawyer Lawyer.objects.select_for_update().get(idlawyer_id) conflicting Appointment.objects.filter( lawyer_idlawyer_id, status__in[pending, confirmed], start_time__ltend_time, end_time__gtstart_time ).exists() if conflicting: raise ValueError(该时段已被预约请选择其他时间) return Appointment.objects.create( lawyer_idlawyer_id, client_idclient_id, case_type_idcase_type_id, start_timestart_time, end_timeend_time, descriptiondescription, statusSTATUS_PENDING )select_for_update会把律师这行数据锁住第二个请求必须等第一个请求的事务提交后才能读到数据此时exists()检查就能正确发现冲突。这里有个开发环境的注意点SQLite不支持行级锁select_for_update不会真的锁所以开发时看起来是好的部署到MySQL或者PostgreSQL后才是真实行为。有条件的话尽量在开发环境也用MySQL或者PostgreSQL避免开发环境没问题生产环境出并发事故。注意事务内select_for_update必须和事务外的查询分开理解。用transaction.atomic包起来后锁的生命周期由事务控制一定要确保事务尽快提交不要在里面做耗时的外部请求。3. 接口实现与业务逻辑落地3.1 DRF配置与路由规划Django后端和Vue前端之间的通信走RESTful API这里用的是Django REST Framework。DRF的序列化、视图集、认证和权限体系非常完整省去了大量重复劳动。路由规划我按照资源来组织清晰且便于前端对接# config/urls.py from django.contrib import admin from django.urls import path, include urlpatterns [ path(admin/, admin.site.urls), path(api/users/, include(apps.users.urls)), path(api/lawyers/, include(apps.lawyers.urls)), path(api/cases/, include(apps.cases.urls)), path(api/appointments/, include(apps.appointments.urls)), ]每个App内部用DRF的router做标准资源路由以appointments为例# apps/appointments/urls.py from rest_framework.routers import DefaultRouter from .views import AppointmentViewSet router DefaultRouter() router.register(, AppointmentViewSet, basenameappointment) urlpatterns router.urlsViewSet里我重写了list和create方法list根据登录用户身份返回不同数据——用户看到自己的预约律师看到自己被预约的请求。这个判断逻辑放ViewSet里比放序列化器里更合适因为它是接口层的行为。3.2 用户认证与权限控制认证方案用的是JWT具体是djangorestframework-simplejwt这个库。选它的原因很直接前后端分离架构下Session的方案要处理跨域CookieCSRF防护也要额外配置JWT直接把Token放在请求头里前端用起来简单后端也不用管Session存储。配置起来不复杂# config/settings/dev.py from datetime import timedelta REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: [ rest_framework_simplejwt.authentication.JWTAuthentication, ], DEFAULT_PERMISSION_CLASSES: [ rest_framework.permissions.IsAuthenticated, ], } SIMPLE_JWT { ACCESS_TOKEN_LIFETIME: timedelta(hours2), REFRESH_TOKEN_LIFETIME: timedelta(days7), }权限控制上系统有四种角色未登录用户、客户、律师、管理员。未登录用户只能看律师列表和公开信息不能创建预约律师只能处理发给自己的预约请求客户只能操作自己的预约单。DRF的权限类把这种逻辑写清楚# apps/appointments/permissions.py from rest_framework.permissions import BasePermission class IsClientOrLawyer(BasePermission): def has_object_permission(self, request, view, obj): user request.user if user.user_type admin: return True if user.user_type client: return obj.client user if user.user_type lawyer: return obj.lawyer.user user return False这样的权限类在ViewSet的get_permissions里按action分配写起来一劳永逸。3.3 可用时段生成算法前端要给用户展示律师可预约的时段这个数据不是直接存数据库的而是根据律师的排期规则动态生成的。我设计的排期规则模型是律师定义一周内的固定可预约时间段再定义某些日期的临时不可预约时段。生成逻辑如下取某一天把固定可预约时间段的起止时间和该天已有的预约、临时不可预约时段全部拉出来然后做时间段的差集得到真正空闲的区间再按30分钟粒度切成小格子。# apps/lawyers/services.py from datetime import datetime, timedelta from apps.appointments.models import Appointment SLOT_DURATION timedelta(minutes30) def generate_available_slots(lawyer, target_date): # 1. 律师固定排期里该天的可用区间 fixed_schedules lawyer.schedules.filter(weekdaytarget_date.weekday(), is_activeTrue) occupied [] # 2. 收集已占用区间待确认已确认的预约 appointments Appointment.objects.filter( lawyerlawyer, status__in[pending, confirmed], start_time__datetarget_date, ) for appt in appointments: occupied.append((appt.start_time, appt.end_time)) # 3. 收集临时不可预约区间 unavailables lawyer.unavailables.filter(datetarget_date) for ua in unavailables: occupied.append((datetime.combine(target_date, ua.start_time), datetime.combine(target_date, ua.end_time))) # 4. 对每个固定排期区间扣除occupied后按30分钟切片 slots [] for schedule in fixed_schedules: cursor datetime.combine(target_date, schedule.start_time) period_end datetime.combine(target_date, schedule.end_time) while cursor SLOT_DURATION period_end: slot_end cursor SLOT_DURATION if not any(s slot_end and e cursor for s, e in occupied): slots.append({start: cursor.isoformat(), end: slot_end.isoformat()}) cursor slot_end return slots这套算法的核心是占用区间合并 差集切割。实现上因为每个预约都是不重叠的所以occupied列表里天然没有重叠区间判断当前切片是否可用只需遍历occupied即可。如果未来要做多律师合并排期就需要先对occupied区间做排序合并那个复杂度会稍微上来一些。3.4 订单状态流转预约单的状态机是我画得最仔细的部分。四个状态之间的流转规则如下当前状态触发动作下一状态额外约束pending律师确认confirmed仅律师本人操作pending客户取消cancelled需填取消原因pending超时未处理24小时cancelled定时任务触发pending律师拒绝cancelled需填拒绝原因confirmed预约时间到达completed定时任务触发confirmed客户取消cancelled需填取消原因记录取消方confirmed律师取消cancelled需填原因记录取消方状态流转我用了一个简单的映射字典来约束合法性避免代码里到处散落if elseALLOWED_TRANSITIONS { STATUS_PENDING: {STATUS_CONFIRMED, STATUS_CANCELLED}, STATUS_CONFIRMED: {STATUS_COMPLETED, STATUS_CANCELLED}, } def transition_appointment(appointment, new_status, operator, reason): if new_status not in ALLOWED_TRANSITIONS.get(appointment.status, set()): raise ValueError(f不允许从 {appointment.status} 流转到 {new_status}) appointment.status new_status appointment.cancel_reason reason appointment.cancelled_by operator appointment.save()这套东西看起来简单实际是后期避免脏数据的一道关键防线。没有状态机约束业务代码越写越多之后很容易出现乱七八糟的非法状态组合。4. Flask辅助服务与Vue前端协作4.1 Flask在系统里的实际角色我在前面提到Flask是用来做辅助服务的这里展示实际代码。Flask服务跑在9000端口Django主服务跑在8000端口互不干扰。第一个任务预约提醒。用APScheduler在Flask进程里做定时任务每30秒扫描一次数据库找那些预约开始时间在两小时内且尚未发送提醒的预约单然后调第三方短信接口发通知。# flask_service/scheduler.py from apscheduler.scheduler import Scheduler from datetime import datetime, timedelta import requests scheduler Scheduler() def send_appointment_reminders(): # 这里复用Django的ORM不现实直接查数据库 # 简化写法假设通过一个内部API获取待提醒列表 response requests.get( http://localhost:8000/api/appointments/reminders/, headers{Authorization: Bearer INTERNAL_TOKEN}, timeout5 ) if response.status_code ! 200: return for item in response.json(): # 调用短信服务 requests.post( https://sms.example.com/send, json{phone: item[phone], message: f您预约的咨询将在{item[start_time]}开始}, timeout5 ) # 标记已提醒 requests.post( fhttp://localhost:8000/api/appointments/{item[id]}/mark_reminded/, headers{Authorization: Bearer INTERNAL_TOKEN} ) scheduler.add_interval_job(send_appointment_reminders, seconds30) scheduler.start()这里有个经验之谈两个服务之间共享数据最忌讳直接连同一个数据库然后各写各的ORM。Flask这边简单用requests调用Django暴露的内部API虽然多一层网络开销但业务规则全部收敛在Django一端不会出现两边逻辑不一致的问题。内部API用单独的token鉴权不占用用户的JWT体系。第二个任务第三方回调接口。短信服务商发送状态回传、支付回调等场景都统一打到Flask服务上Flask只做一件事——把回调数据转发给Django或者写入本地日志。# flask_service/app.py from flask import Flask, request, jsonify import requests app Flask(__name__) app.route(/webhook/sms-status, methods[POST]) def sms_status(): data request.get_json() # 转发到Django记录短信状态 try: requests.post( http://localhost:8000/api/internal/sms-status/, jsondata, headers{Authorization: Bearer INTERNAL_TOKEN}, timeout3 ) except Exception: # 失败先落本地日志保底不丢数据 app.logger.error(fsms status forward failed: {data}) return jsonify({code: 0})这种架构给后期维护带来一个好处主业务代码里不会出现任何调第三方服务的痕迹Flask成了一个网关层替换短信服务商或者加新的通知渠道都只改Flask不碰Django。4.2 Vue前端架构与核心页面Vue前端用的是Vue3 Vite Vue Router VuexUI组件库用的Element Plus。页面结构按角色拆分客户端页面、律师端页面、登录注册页。核心的预约流程页面是一个三步向导组件第一步展示律师信息第二步选择日期和时段第三步填写案件描述并提交。时间段选择的交互是这类系统的门面。当时段数据从后端返回后前端需要判断哪些格子可用、哪些被占用、当前选中的是哪个这些状态管理我放在组件内部不引入全局状态保持简单template div classtime-slot-picker div classdate-selector button v-fordate in nextSevenDays :keydate :class{ active: selectedDate date } clickhandleDateChange(date) {{ formatDate(date) }} /button /div div classslot-grid button v-forslot in slots :keyslot.start :disabled!slot.available :class{ slot-selected: selectedSlot slot.start } clickselectedSlot slot.start {{ formatTime(slot.start) }} /button /div /div /template script setup import { ref, computed, watch } from vue import { getAvailableSlots } from /api/appointment const props defineProps({ lawyerId: { type: Number, required: true } }) const selectedDate ref(new Date()) const slots ref([]) const selectedSlot ref() const nextSevenDays computed(() { const days [] for (let i 0; i 7; i) { const d new Date() d.setDate(d.getDate() i) days.push(d) } return days }) watch(selectedDate, async (newDate) { const params { lawyer_id: props.lawyerId, date: formatDateParam(newDate) } const res await getAvailableSlots(params) slots.value res.data.map(item ({ start: item.start, end: item.end, available: true })) selectedSlot.value }) /script时段加载放在watch里而不是mounted里是为了让切换日期时能自动拉取新数据同时避免重复请求——第一次进入页面时computed的nextSevenDays默认值就会触发一次watch。4.3 Axios请求封装与跨域处理Axios封装的核心是统一处理Token注入和错误提示。我的做法是请求拦截器从localStorage取accessToken塞进Authorization头响应拦截器统一处理401过期跳登录、500错误弹提示。// src/api/request.js import axios from axios import { ElMessage } from element-plus import router from /router const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(access_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( response response.data, error { const status error.response?.status if (status 401) { localStorage.removeItem(access_token) router.push(/login) } else if (status 500) { ElMessage.error(服务端异常请稍后重试) } return Promise.reject(error) } ) export default service跨域处理有两种方案开发环境下用Vite的代理转发生产环境用Nginx反代。开发环境我推荐代理方案好处是不用处理CORS预检前端写相对路径就行。// vite.config.js export default { server: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true } } } }生产环境就是Nginx统一入口/api开头的请求转发给Django的Gunicorn其他静态资源直接服务Vue打包后的dist目录。注意Flask服务的/api/internal开头的请求要单独转发到9000端口配置里最好用location匹配路径前缀而不是整个/api一刀切。5. 踩坑实录与PyCharm调试经验5.1 CORS跨域的真实坑开发阶段如果不用Vite代理而是直连后端地址CORS是第一个要处理的坎。我的settings里一开始只配了CORS_ALLOWED_ORIGINS然后发现带自定义Header的请求全部失败。原因在于JWT认证需要在请求头里带Authorization这属于非简单请求浏览器会先发一个OPTIONS预检请求。Django这边要确保corsheaders这个中间件在配置里正确放置。放错位置会导致预检请求没有进入CORS处理逻辑# config/settings/base.py MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, # 必须放在CommonMiddleware之前 django.middleware.common.CommonMiddleware, # ... 其他中间件 ] CORS_ALLOWED_ORIGINS [ http://localhost:5173, ] CORS_ALLOW_HEADERS [ accept, authorization, # 这行不加JWT Token根本带不过去 content-type, ]CORS_ALLOW_HEADERS默认列表里其实已经包含authorization但如果手动覆盖了列表一定要把authorization加回来。这是很容易踩的坑。5.2 并发预约导致的数据覆盖问题我在2.3小节提到过select_for_update实际测试中遇到过更隐蔽的问题如果创建预约前的校验逻辑写在了事务外面即使create在事务里加了锁冲突检查也白搭。因为两个事务可能都先通过了外部检查然后进入事务排队执行第二个事务执行时不知道第一个事务已经插入数据。我的解决办法是把检查冲突 创建记录整个业务操作封装成一个独立的service函数所有ViewSet、后台任务都调它不直接调用Appointment.objects.create。这样并发保护就收敛在一个地方。还有一种方案是用数据库的唯一约束兜底比如给lawyer_id、start_time、end_time加唯一约束。但预约表里有pending状态允许待确认记录存在单纯唯一约束会挡住合法的重复时段比如同一律师不同案件类型的预约。所以最后还是用锁方案唯一约束只作为异常情况的最后防线。5.3 时区与时间格式的坑预约系统对时间敏感度极高时区问题会直接造成用户端显示时间偏移。我在第一次联调时就发现前端显示的预约时间和律师确认的时间差了八个小时——典型的UTC和北京时间混用问题。Django的settings里USE_TZ True时数据库存的是UTC时间DateTimeField读取出来也是带时区的aware对象。前端拿到的是ISO字符串浏览器和Vue按本地时区解析如果后端正确返回了带时区偏移的ISO8601字符串前端显示不会错。问题往往出在中间某个环节用了naive时间。排查时最直接的办法是检查API返回的时间字符串末尾有没有时区偏移标志08:00或Z。如果没有多半是代码里用了datetime.now()而不是django.utils.timezone.now()。from django.utils import timezone # 正确带时区 now timezone.now() # 错误naive时间 import datetime bad_now datetime.datetime.now()建议在项目规范里明确规定业务代码一律用timezone.now()获取当前时间禁止直接import datetime。5.4 PyCharm调试与运行配置技巧PyCharm在这个项目里帮了大忙。首先是Run Configuration配置。Django和Flask是两套独立服务PyCharm的Run/Debug Configuration里可以分别新建Django Server类型和Flask类型并设置不同的端口和Python解释器。两个服务可以用同一个虚拟环境也可以各自建独立的venv推荐后者因为Flask服务的依赖极少独立环境可以避免Django那边升级依赖时把Flask服务搞挂。其次是数据库工具面板。PyCharm的Database面板直接连MySQL写ORM过滤条件之前可以先在数据库工具里跑一遍SQL验证结果集。调试预约冲突逻辑时我经常在面板里手动插入几条边界数据来验证exists查询的边界条件。第三是断点调试。Django的调试有个技巧在manage.py执行的进程上打断点PyCharm会自动进入Django调试模式。前端Vue的调试则用PyCharm的JavaScript调试配合Vite的sourcemap在Vue组件里的断点也能命中和单步调试。一个IDE搞定两端的调试这是这套开发环境最大的效率优势。提示PyCharm里调试DjangoFlask双服务时记得给Run Configuration设置不同的端口和环境变量。我在环境变量里用DJANGO_SETTINGS_MODULE区分dev和prod配置同时Flask服务单独设置PORT和INTERNAL_API_TOKEN避免两个服务互相抢端口和共用密钥。5.5 遇到的问题与排查速查表把这几个月遇到的典型问题整理成一个速查表方便以后快速定位现象可能原因排查方向前端提交预约一直返回400时段已过期或格式不对检查start_time是否在当前时间之后ISO格式是否正确跨域请求被拦截CORS配置缺失确认中间件顺序、CORS_ALLOWED_ORIGINS、allow_headers两个用户同时预约成功并发未加锁检查冲突检测和创建是否在同一个事务里是否用了select_for_update提醒通知不发送Flask服务定时任务未执行或请求失败查看Flask日志、Django内部API是否返回401前端显示时间差8小时使用了naive datetime全局搜索datetime.now替换成timezone.now律师看不到预约请求权限过滤条件写错检查ViewSet的get_queryset按角色过滤逻辑JWT过期后页面不跳登录响应拦截器未处理401检查Axios拦截器的status判断逻辑这套速查表是我在实际开发中逐步沉淀下来的每次遇到问题解决后都会把现象和原因补进去。整个项目后期维护时这个表格比任何文档都实用。做律师服务预约系统这个项目我个人最大的体会是技术难点从来不在某个框架用得多溜而在于把业务规则想清楚之后用合适的架构去承接。Django管主体业务Flask做隔离的辅助服务Vue做交互界面PyCharm统一开发调试这套组合在效率和可维护性之间找到了一个不错的平衡点。如果你也打算做一个类似的预约系统建议在动手写代码之前先把状态流转表和并发策略定下来——这两件事是我这次项目里最值钱的提前设计。预约系统后续还可以扩展的地方很多比如多律师团队的批量排期、预约付费流程集成、律师评价体系升级这些都是在现有骨架上可以逐步迭代的方向。