ARTICLE DETAIL

资讯详情

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

Django+Vue停车管理系统实战:并发控制与部署详解

Django+Vue停车管理系统实战:并发控制与部署详解 最近在帮一家中型公司做内部停车管理系统技术栈定的 Python Vue开发工具用的 Pycharm后端一开始在 Django 和 Flask 之间犹豫了好几天。把这套系统从需求梳理到上线跑到现在最深的感触是项目看着不大但把公司园区里固定车位、访客临时停车、月租缴费、余位统计这几件事串起来后水挺深。这篇就把我的完整设计思路、后端核心实现、前端 Vue 联调以及部署踩坑整理成文给准备用 Python前端框架做管理系统的人当个实操参考。先说下使用场景内部员工固定车位访客临时车位月租按月扣费临时车按时计费需要实时显示剩余车位还要能查历史记录。听起来就是 CRUD但并发停车时余位不能超卖、计费不能算错这两点一旦出问题物业那边很快就会来找你。1. 项目需求拆解与技术选型心路1.1 公司停车管理系统究竟要解决什么一个公司停车场最核心的动作就是“进”和“出”。但这个“进”和“出”背后牵扯好几类用户普通员工月租车、访客临时车、偶尔还有领导预留车位。系统要管三件事一是车位资源管理总共多少车位、固定多少个、空闲多少个二是车辆身份管理车牌号、是否月租、有效期三是停车记录和计费谁几点进、几点出、该收多少钱。这东西如果用 Excel 登记第一天就会发现问题。两个岗亭同时登记Excel 文件版本冲突余位可能对不上人工计费经常漏算超时。所以系统的第一目标是数据一致性和实时性一旦有车入场车位状态要立刻变为占用余位减 1出场则反向操作。这个操作必须在一个事务里完成读和写不能有中间态。第二目标是可追溯。停车记录要永久保存一旦有人逃费或者纠纷能把进出时间、费用明细调出来。第三目标是权限。普通员工只能看自己的月租续费安保人员能操作车辆出入登记管理员能看全部报表和修改费率。这些需求决定了后端框架必须自带用户认证和角色管理前端需要一个清晰的后台管理界面。1.2 Django 和 Flask 到底怎么选别只看“轻量”很多人在选型时容易陷入“Flask 更轻量、更灵活”的误区。确实Flask 的起步代码非常少一个文件就能拖起一个服务。但停车管理系统是典型的“富业务系统”需要用户表、角色权限、数据建模、后台分页、CSRF 防护、迁移脚本这些如果全用 Flask 自己搭工作量会翻好几倍。比如 Flask 需要自己配 Flask-SQLAlchemy、Flask-Login、Flask-Migrate、Flask-Admin还要自己写序列化和校验。而 Django 把这些能力全部内置开发效率完全不同。我最终选了 Django原因很实际自带的 Admin 后台可以直接用来管理车位和费率数据省去单独做管理页面的时间自带的 ORM 配合迁移命令改模型后一条python manage.py makemigrations就能同步数据库自带的用户认证和权限系统直接满足员工、安保、管理员三种角色需求。Flask 不是不能用如果项目只有两三个接口、不考虑长期迭代Flask 确实更轻快但停车系统后续大概率要接车牌识别、对接企业微信消息提醒Django 的成熟生态和扩展能力更稳妥。如果你坚持用 Flask也有成熟组合Flask-RESTful Flask-SQLAlchemy Flask-JWT-Extended Flask-Admin。但你要接受一个事实这些库之间的版本兼容和配置复杂度比你想象中高。我本地尝试过 Flask 版本做原型光集成 JWT 和 Admin 就折腾了一下午最后果断回到 Django。1.3 前端为什么选 Vue 而不是直接 Django 模板Django 自带模板渲染可以直接写 HTML 加一点点 Vue 做交互但这种方式在功能复杂后会非常痛苦。停车管理首页要实时显示车位状态、动态刷新余位数据一变页面局部就要更新。用 Django 模板的话每次刷新都需要重新 GET 整个页面偶尔还要写一堆 onchange 事件越写越乱。Vue 的组件化和响应式在这里是天然优势。我可以把“车位格子”做成一个组件把“停车记录表格”做成另一个组件业务数据从接口来了之后Vue 自动更新视图不用手动操作 DOM。加上 Vue Router 做页面跳转、Element Plus 做表单和弹窗这套组合非常贴合管理系统开发场景。更重要的是团队里其他人对 Vue 的模板语法更熟悉上手成本比 React 低不少。前端工程化使用 Vite Vue 3。Pycharm 专业版对 Vue 插件支持不错能高亮模板语法、格式化代码配合终端里的npm run serve就能跑起来。如果你用的是社区版建议配一个 VSCode 专门写前端两个编辑器切着用也不冲突。2. 后端核心设计与实现2.1 数据库模型从车位到计费记录的设计要点数据库模型是整个系统的地基。我设计了四张核心表车位表、车辆表、停车记录表、费率配置表。下面直接看模型代码# Django models.py from django.db import models from django.contrib.auth.models import User class ParkingSpot(models.Model): STATUS_CHOICES ( (free, 空闲), (occupied, 占用), (reserved, 预留), ) spot_no models.CharField(车位编号, max_length20, uniqueTrue) status models.CharField(状态, max_length10, choicesSTATUS_CHOICES, defaultfree) spot_type models.CharField(车位类型, max_length10, choices((fixed, 固定), (temp, 临时)), defaulttemp) def __str__(self): return self.spot_no class Vehicle(models.Model): plate_no models.CharField(车牌号, max_length20, uniqueTrue) owner models.ForeignKey(User, on_deletemodels.CASCADE, related_namevehicles) is_monthly models.BooleanField(月租用户, defaultFalse) expires_at models.DateTimeField(月租到期时间, nullTrue, blankTrue) def __str__(self): return self.plate_no class ParkingRecord(models.Model): vehicle models.ForeignKey(Vehicle, on_deletemodels.CASCADE, related_namerecords) spot models.ForeignKey(ParkingSpot, on_deletemodels.PROTECT) enter_time models.DateTimeField(入场时间, auto_now_addTrue) exit_time models.DateTimeField(出场时间, nullTrue, blankTrue) fee models.DecimalField(应收费用, max_digits8, decimal_places2, default0) class Meta: indexes [ models.Index(fields[enter_time]), models.Index(fields[vehicle]), ] class FeeRule(models.Model): name models.CharField(规则名称, max_length50) hourly_rate models.DecimalField(每小时费用, max_digits5, decimal_places2, default5.00) free_minutes models.IntegerField(免费分钟数, default15) daily_cap models.DecimalField(单日封顶, max_digits6, decimal_places2, nullTrue, blankTrue)几个设计细节值得注意spot_no必须 unique因为物理车位的编号不会重复。ParkingRecord.spot用on_deletemodels.PROTECT防止车位被误删后历史记录悬空。Vehicle.plate_no也 unique但实际场景中同一辆外来车可能多次来访如果车牌作为唯一键就需要在ParkingRecord中通过 vehicle 外键关联到同一车。这里我默认固定和访客都先登记车辆然后在停车记录里引用。如果你不想保留多辆车可以直接在ParkingRecord里存车牌号字符串但这样统计车辆总数时不太好聚合。我还是倾向车辆表这样还能记录车主信息和月租状态。FeeRule是一张单例配置表很多开发习惯把费率写死在后端代码里但需求方经常会改“免费时长”“每小时价格”你总不能每次都改代码重新部署。放数据库后管理员在 Admin 后台改一下前端就能拿到新规则。时间字段我统一用DateTimeField遵守 Django 的时区设置。注意auto_now_addTrue只会在创建记录时写一次时间适合入场时间出场时间要手动赋值为timezone.now()不能复用auto_now_add。2.2 用 DRF 快速搭建 Rsetful APIDjango REST FrameworkDRF是我本次接口层的核心库。它能把模型转化成 JSON 接口自带认证、权限、分页、过滤器省去大量序列化手工活。如果你选了 Flask就要自己去处理这一套。先说序列化器# serializers.py from rest_framework import serializers from .models import ParkingSpot, ParkingRecord, Vehicle, FeeRule class ParkingSpotSerializer(serializers.ModelSerializer): class Meta: model ParkingSpot fields [id, spot_no, status, spot_type] class VehicleSerializer(serializers.ModelSerializer): owner_name serializers.CharField(sourceowner.username, read_onlyTrue) class Meta: model Vehicle fields [id, plate_no, owner, owner_name, is_monthly, expires_at] class ParkingRecordSerializer(serializers.ModelSerializer): plate_no serializers.CharField(sourcevehicle.plate_no, read_onlyTrue) spot_no serializers.CharField(sourcespot.spot_no, read_onlyTrue) class Meta: model ParkingRecord fields [id, plate_no, spot_no, enter_time, exit_time, fee]序列化器的source参数非常常用可以把外键关联的字段直接铺平前端只需要plate_no、spot_no这种扁平字段不用再通过对象嵌套去取。这里有一个经验前端展示层要尽量和后端模型解耦不该暴露vehicle_id表面上的关联关系。视图层推荐用ViewSet而不是写很多单独的APIView。因为停车系统接口基本是以“模型资源”为单位比如车位资源、记录资源用ModelViewSet直接提供 list、create、retrieve、update、partial_update、destroy再用permission_classes控制谁能访问。# views.py from rest_framework import viewsets, permissions from rest_framework.decorators import action from rest_framework.response import Response from .models import ParkingSpot, ParkingRecord, Vehicle, FeeRule from .serializers import ( ParkingSpotSerializer, ParkingRecordSerializer, VehicleSerializer, FeeRuleSerializer ) class ParkingSpotViewSet(viewsets.ModelViewSet): queryset ParkingSpot.objects.all() serializer_class ParkingSpotSerializer permission_classes [permissions.IsAuthenticated] class ParkingRecordViewSet(viewsets.ModelViewSet): queryset ParkingRecord.objects.select_related(vehicle, spot).all() serializer_class ParkingRecordSerializer permission_classes [permissions.IsAuthenticated] filterset_fields [vehicle__plate_no, spot__spot_no] action(detailFalse, methods[get]) def summary(self, request): total ParkingSpot.objects.count() free ParkingSpot.objects.filter(statusfree).count() occupied total - free return Response({total: total, free: free, occupied: occupied})上面summary是一个自定义动作专门给前端首页的余位概览用。DRF 的路由也不需要手动注册在urls.py里加一个DefaultRouter就够了。2.3 停车计费与余位统计的并发安全没有并发问题的小项目不叫管理系统。停车最核心的两个动作都涉及“检查-更新”的竞态条件。先看看余位统计如果同时来了两辆车拿到了余位都是 1然后都放行余位会变成负数。解决方式是在更新车位状态时加事务和行锁。Django 中使用select_for_update()可以锁住一行记录确保同一时刻只有一个请求能读到并更新这个车位。配合transaction.atomic()保证整个流程原子化。下面是入场登记的视图代码# 入场登记 from django.db import transaction from django.utils import timezone from rest_framework.decorators import action action(detailFalse, methods[post]) def enter(self, request): plate_no request.data.get(plate_no) spot_id request.data.get(spot_id) with transaction.atomic(): spot ParkingSpot.objects.select_for_update().get(pkspot_id) if spot.status ! free: return Response({error: 该车位已被占用}, status400) vehicle, _ Vehicle.objects.get_or_create( plate_noplate_no, defaults{owner: request.user} ) record ParkingRecord.objects.create(vehiclevehicle, spotspot) spot.status occupied spot.save() return Response({record_id: record.id, enter_time: record.enter_time})这段代码最关键的是select_for_update()。如果两辆同时请求同一个车位的入场第一个拿到锁第二个会阻塞等待执行第一笔事务提交后第二个再读到车位状态时已经是 occupied返回“已被占用”。没有这行就存在两个请求都读到 free 然后同时写入的隐患。出场时计费逻辑action(detailFalse, methods[post]) def exit(self, request): record_id request.data.get(record_id) with transaction.atomic(): record ParkingRecord.objects.select_for_update().get(pkrecord_id) if record.exit_time is not None: return Response({error: 已出场}, status400) record.exit_time timezone.now() duration record.exit_time - record.enter_time minutes duration.total_seconds() / 60 if record.vehicle.is_monthly: fee 0 else: rule FeeRule.objects.first() minutes - rule.free_minutes hours (minutes 59) // 60 # 向上取整 fee hours * rule.hourly_rate if rule.daily_cap and fee rule.daily_cap: fee rule.daily_cap record.fee fee record.save() spot record.spot spot.status free spot.save() return Response({fee: fee, exit_time: record.exit_time})计费公式里有一个我踩过的坑hours向上取整。Python 的除法和取整需要特别注意直接用hours minutes / 60会得到小数。我在这里用了(minutes 59) // 60等价于math.ceil(minutes / 60)但纯整数运算更安全。免费分钟数也要先扣除比如停了 10 分钟免费 15 分钟费用就是 0。Django 的数据库事务默认在with transaction.atomic()内所以并发时锁会一直持有到事务结束这是数据库层面的正确行为。千万不要在进入锁前做网络请求或耗时的外呼操作否则锁持有时间过长会拖垮吞吐。3. Vue 前端搭建与联调3.1 环境准备Vue 脚手架与 Pycharm 协同前端我使用 Vue 3 Vite。创建项目的命令很简单npm create vuelatest在创建过程中选择需要的特性TypeScript 可选我这里为了团队新人上手快选了 JavaScriptVue Router 必选Pinia 状态管理可加可不加但我加了因为全局要保存登录用户和某些过滤条件。创建完项目后npm install安装依赖。遇到网络不稳定时我习惯先把自己的 npm 镜像源切到国内镜像再执行安装速度会明显提升。Pycharm 专业版对 Vue 的支持不错可以直接打开前端目录作为项目设置里的 Node.js 解释器指向你本机的 node然后运行 npm scripts 面板中的dev即可启动开发服务器。如果你用的社区版我推荐用 VSCode 编辑前端代码Pycharm 只负责 Python 后端两者互不干扰。开发环境前后端是分开启动的。Django 跑在http://127.0.0.1:8000Vite 默认跑在http://127.0.0.1:5173。它们之间要跨域通信下面会说代理配置。3.2 页面划分与核心组件设计前端页面按后端功能模块拆成四个视图停车位总览、车辆管理、出入登记、停车记录。停车位总览是最复杂的因为它要用可视化的方式把每个车位格子画出来。我定义了一个ParkingSpotGrid.vue组件template div classspot-grid div v-forspot in spots :keyspot.spot_no classspot-cell :classstatus-${spot.status} clickhandleClick(spot) span{{ spot.spot_no }}/span span v-ifspot.status occupied classplate占用中/span /div /div /template script setup import { ref, onMounted } from vue import api from /api const spots ref([]) const fetchSpots async () { const { data } await api.get(/parking-spots/) spots.value data } const handleClick (spot) { // 弹出对话框做入场或出场操作 } onMounted(fetchSpots) /script响应式体现在spots.value一旦变化视图会自动重绘。页面加载后调一次接口之后通过轮询定时器不断刷新就能实现“实时余位”的效果。Vue 的核心思想就是数据驱动不需要手动管 DOM这是它比 jQuery 时代效率高的主要原因。出入登记页面我用了 Element Plus 的对话框点击车位格子弹出表单录入车牌号后调用入场接口。出场时同样点击占用中的车位调出场接口然后提示费用。这个交互模式非常简单安保人员两分钟就能学会。3.3 前后端联调axios 封装与跨域处理前端和后端通信我统一用 axios单独封装一个api.js方便统一设置 baseURL、请求头、错误提示。基础封装如下import axios from axios const api axios.create({ baseURL: /api, timeout: 10000, }) api.interceptors.request.use((config) { const token localStorage.getItem(access_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) api.interceptors.response.use( (response) response, (error) { if (error.response?.status 401) { // 跳转登录页 } return Promise.reject(error) } ) export default api开发阶段跨域我是通过 Vite 的代理配置解决的不需要在后端开 CORS。在vite.config.js中加export default defineConfig({ plugins: [vue()], server: { proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true, }, }, }, })通过代理前端请求/api/parking-spots/会自动转发到 Django 的 8000 端口这样浏览器看到的是“同源”请求不会触发 CORS 拦截。只有当你用 IP 直接访问后端或者后端单独部署时才需要考虑 Django-cors-headers 插件。如果要用 JWT 认证前端把所有请求工具封装好后开发效率会提升很多。这里我补充一个建议不要在前端组件里直接写axios.get每处都写一遍Authorization头和错误处理会非常难看。统一封装后后续替换为 WebSocket 或 GraphQL 也只需要改这一个文件。3.4 实时余位轮询还是 WebSocket前端首页需要实时显示停车场各车位状态。最直接的做法是每隔几秒钟请求一次summary和车位列表接口。我用的是setInterval轮询let timer null const startPolling () { if (timer) return timer setInterval(async () { await fetchSpots() await fetchSummary() }, 5000) } const stopPolling () { clearInterval(timer) timer null }轮询有一个小问题本地开发没问题但生产环境如果同一时间所有浏览器都 5 秒请求一次后端压力会比较大。不过内部管理系统用户一般就几十个Django 加 Nginx 扛得住不需要上 WebSocket。如果你非要追求秒级推送可以选 Django Channels 加 WebSocket前端用WebSocket对象监听消息。但这样做需要额外处理 ASGI 服务器、路由、心跳重连复杂度大得多。我的经验是在管理系统这类低频、小并发场景下轮询比 WebSocket 更稳定、更好维护。热搜词里有“python django websocket 实现后台有数据前端推送”其实就是 WebSocket 推送但这里我不推荐用。4. 部署上线与常见问题排查4.1 本地环境配置的细节与坑本地环境首先需要正确安装 Python 和 Django。Pycharm 创建项目时自动建虚拟环境venv然后安装依赖包pip install django djangorestframework django-cors-headers如果你的电脑上同时有 Python 2 和 Python 3或者系统自带的 Python 版本过低一定要确认 Pycharm 选择的解释器是venv里的那个。我见过不少同事因为解释器选错项目明明在终端能跑Pycharm 里一运行就报 ModuleNotFoundError。这个坑可以通过在 Pycharm 底部 Terminal 执行pip list和python --version来排查。前端安装依赖时如果npm install特别慢大概率是源的问题。可以临时用npm install --registryhttps://registry.npmmirror.com也有一种情况是node_modules损坏删除后重新装rm -rf node_modules package-lock.json npm install最后Pycharm 调试 Vue 代码时配合 Vue Devtools 浏览器插件能可视化组件树、路由和状态。如果你发现插件安装了但页面上没反应检查插件是否是对应 Vue 3 的 Beta 版或者页面是不是跑在localhost而不是127.0.0.1有些插件对非标准域名会禁用。4.2 生产部署Nginx Gunicorn 的最简方案生产环境我采用前后端分离部署Django 用 Gunicorn 跑Nginx 同时负责前端静态文件和后端反向代理。前端先构建npm run build构建完会产生dist目录这里面的文件是纯静态资源。把dist下的内容复制到服务器/srv/parking_web。后端先收集静态文件python manage.py collectstatic然后修改 Django 的settings.py生产配置DEBUG False ALLOWED_HOSTS [your-server-ip-or-domain] STATIC_ROOT /srv/parking_static STATIC_URL /static/启动 Gunicorngunicorn parking_project.wsgi:application -w 4 -b 127.0.0.1:8000 --timeout 60Nginx 配置里把dist目录作为站点根目录/api路径代理到 Django 进程server { listen 80; server_name your-domain; root /srv/parking_web; index index.html; location / { try_files $uri $uri/ /index.html; } 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; } }这里有个最容易忽略的点try_files $uri $uri/ /index.html;是 SPA 路由的核心没有它前端一刷新/parking-history这种路由就会 404。因为浏览器直接请求这个路径服务器上没有对应的物理文件Nginx 要把所有非 API 请求都引到index.html让 Vue Router 接管。API 的请求路径要设计成统一前缀/api/这样 Nginx 才能简单区分静态资源和接口代理。4.3 我踩过的坑并发、时区与脏数据开发到测试阶段我印象最深的问题有两个。第一个是并发超卖。第一次实现入场时我没有用select_for_update()用 JMeter 并发跑了 20 个请求创建了 20 条停车记录但车位状态全是 occupied 没有释放余量变成负数。加了事务和行锁后才解决。这个经验也验证了前面说的管理系统哪怕并发量小也不能忽略并发正确性因为一个错误数据会引发连锁反应。第二个是时区问题。Django 的USE_TZ True且TIME_ZONE Asia/Shanghai时数据库中存储的是 UTC 时间前端拿到的是经 Django 序列化后的带时区字符串。但有一段导出的统计功能里我用datetime.now()去查当天的记录却查不到因为datetime.now()返回本地时间而数据库存的是 UTC两者时间差 8 小时。后来我统一改用django.utils.timezone.now()和timezone.localtime()来处理问题才消失。这里提醒Django 项目中任何时间处理都要基于 Django 的 timezone 模块不要用 Python 标准库的datetime.now()。此外还有个容易踩的坑是删除对象时不符合预期。热搜词里提到“django 执行查询-删除对象”在使用 ORM 删除时例如vehicle.delete()如果该车辆有关联的停车记录且外键设置了PROTECT删除会抛异常。这其实是保护机制不是 Bug。需要删除时应该先确认关联记录或者设置CASCADE我倾向于不物理删除业务数据用软删除或状态标记更安全。4.4 常见问题速查表问题现象可能原因解决方案前端接口 404Nginxtry_files未配置增加try_files $uri $uri/ /index.html;后端跨域报 CORS前后端分离但未加代理或未装 cors-headers开发用 Vite proxy生产用同一 Nginx 或django-cors-headers并发超卖、余位为负数更新车位状态未加行锁使用select_for_update()transaction.atomic()计费时间不对混用datetime.now()与 Django timezone统一django.utils.timezone.now()删除车辆报 ProtectedError外键PROTECT策略检查关联记录或改用软删除Vue 刷新路由 404静态服务器未做 SPA fallbackNginx 配置try_filesnpm 安装依赖缓慢源是国外源切国内镜像源重装我觉得可以把这张表当成项目的交接文档给后来维护的人放在 README 里。很多时候线上的小问题都是配置和环境造成的一张速查表比啃代码更快定位。超卖问题的扩展思考其实除了数据库行锁还可以用 Redis 的 Lua 脚本做余位预扣减查询和写入延迟更低适合更大规模的停车场系统。但内部员工系统几辆车还不至于上 Redis我最终就用了数据库事务简单可靠。如果后面车位数超过 500 或者想接车牌识别摄像头我可以再考虑引入消息队列和 Redis但那是另一个量级的话题。最后私心说一句用 Django 的 Admin 后台真的很爽。我直接把车位表和费率表注册进 Admin管理人员改费率只需要登录后台改数字不用碰代码。如果你正在做类似的“公司内部管理系统”建议不要总想着从零手写每个页面Django Admin 能帮你省下非常多的时间。这套停车管理系统后续如果扩展月租续费提醒、月度报表导出我也会优先复用现有数据模型和 API 结构改造成本是可控的。
返回列表