简介:基于Python与Django的智能停车场收费系统实现方案,定位为计算机专业毕业设计、课程设计及停车场管理系统开发者的可直接参考的完整样板工程。资源包共235个文件,压缩包大小3.82MB,主体包括25个Python源码文件、15个HTML页面、15个JS脚本、10个CSS样式表、1个SQL数据库结构文件,另含大量运行截图和数据库备份,便于直观了解系统运行状态并及时恢复数据。整套方案覆盖车辆进出记录、停车费用计算、车牌自动识别、数据统计等核心业务流程;车牌识别模块基于图像处理算法实现,具备良好识别准确率;数据库采用规范化设计,确保数据存取高效性。项目代码注释详尽,目录结构清晰,前端基于Bootstrap等成熟组件构建,界面风格统一,可直接用于部署演示或作为二次开发基础。已有58人学习下载,资源经本地编译验证,技术评审超过95分,适合需要快速搭建停车收费管理原型的研究者参考。
1. 智能停车场收费系统:为什么说瓶颈在“计费状态机”而不是车牌识别
车牌识别准确率做到 99% 并不难,难的是把识别结果变成一笔不会出错的收费记录。停车场收费系统的真正复杂度集中在两处:一是车牌识别结果如何快速且稳定地进入数据库,二是临时车、月租车、免费车这些计费状态如何在并发下正确流转。基于 Python 与 Django 实现这套系统,意味着你选择了一条开发效率高、二次开发友好的路线,但同时也必须正面处理 Django 默认的同步阻塞模型与硬件设备回调之间的节奏冲突。
我接下来说的方案,适合具备 Python 基础、想在 Django 项目实战中把车牌识别和数据库管理串起来的人。整套系统用一个 HTTP 服务接收识别设备推送的车牌事件,用 Django ORM 完成车辆档案、入场记录、收费结算的闭环,最后通过管理后台和 API 把数据消费掉。你可以用模拟数据先跑通,再挂接真实设备,也可以直接把识别模块替换成你自己的推理服务。
2. 选型与结构:Django 做业务中枢,识别设备只负责“报车牌”
2.1 为什么用 Django 而不是 Flask 或 FastAPI
这是一个典型的“管理后台重、对外接口轻”的系统。停车收费需要大量的数据维护操作——车辆档案、月租套餐、费率表、优惠券、异常流水审核,Django 自带 Admin 和 ORM,可以直接把后台管理成本压到最低。如果选 Flask,这些都要从零拼;选 FastAPI,异步性能好看,但 Admin 生态远不如 Django 成熟。
Django 的 ORM 在这个场景下的价值容易被低估。停车场收费涉及多张表的关联查询,比如根据车牌找车辆档案、再关联套餐和剩余次数,用 ORM 的select_related和prefetch_related能把查询次数从 N+1 降下来。同时,迁移机制让数据库结构变更可以走版本控制。项目实战时,最怕的是改一张表导致线上数据不同步,Django 的 migration 至少给了后悔药。
2.2 识别设备接入的两种常见姿态
市面上的车牌识别一体机,绝大多数都支持 HTTP 推送或 SDK 回调。HTTP 推送是更通用的方案,设备识别到车牌后,POST 一条 JSON 到你的 Django 接口,包含车牌号、识别时间、设备编号和一张抓拍图 URL。SDK 回调往往需要额外的转发服务,不建议直接嵌进 Django 进程。
我一般会这样设计接入层:
# parking/views.py import json import logging from django.views.decorators.csrf import csrf_exempt from django.http import JsonResponse from django.utils import timezone from .models import PassRecord, Device logger = logging.getLogger(__name__) @csrf_exempt def device_callback(request, device_code): if request.method != 'POST': return JsonResponse({'code': 1, 'msg': 'method not allowed'}) try: payload = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'code': 1, 'msg': 'bad json'}) # 设备侧一般会签名字段,这里只做基础校验 plate_no = payload.get('plate_no', '').strip() event_type = payload.get('event_type', 'entry') # entry / exit recognize_time = payload.get('recognize_time') image_url = payload.get('image_url', '') if not plate_no: return JsonResponse({'code': 1, 'msg': 'plate_no required'}) try: device = Device.objects.get(code=device_code, is_active=True) except Device.DoesNotExist: logger.warning(f"unknown device: {device_code}") return JsonResponse({'code': 1, 'msg': 'unknown device'}) # 通过事务创建通行记录 from django.db import transaction with transaction.atomic(): record = PassRecord.objects.create( device=device, plate_no=plate_no, event_type=event_type, recognize_time=recognize_time or timezone.now(), image_url=image_url, ) return JsonResponse({'code': 0, 'msg': 'ok', 'data': {'record_id': record.id}})这段代码有两点值得注意。第一,csrf_exempt是必须的,设备回调不会带 Django 的 CSRF token,但你要在设备侧配一个签名参数做身份校验,不能裸奔。第二,transaction.atomic()不是摆设,因为等一下创建通行记录的同时,很可能还要联动更新车位余量或车辆在场状态,这两个操作必须同生共死。
2.3 数据库表结构与 Django 模型设计
停车场收费系统的核心表逃不出这几张:设备表、车辆档案表、通行记录表、收费规则表、订单表。设备表记录每个出入口的设备编码和方向;车辆档案表区分临时车、月租车、内部车;通行记录表是流水账,每次识别都写一条;收费规则表存费率;订单表在车辆出场结算时生成。
# parking/models.py from django.db import models class Device(models.Model): code = models.CharField(max_length=50, unique=True) name = models.CharField(max_length=100) direction = models.CharField(max_length=10, choices=[('entry', '入口'), ('exit', '出口')]) is_active = models.BooleanField(default=True) class VehicleProfile(models.Model): plate_no = models.CharField(max_length=15, unique=True, db_index=True) vehicle_type = models.CharField( max_length=10, choices=[('temp', '临时车'), ('monthly', '月租车'), ('free', '免费车')], default='temp' ) monthly_expire_at = models.DateTimeField(null=True, blank=True) class PassRecord(models.Model): device = models.ForeignKey(Device, on_delete=models.PROTECT) plate_no = models.CharField(max_length=15, db_index=True) event_type = models.CharField(max_length=10, choices=[('entry', '入场'), ('exit', '出场')]) recognize_time = models.DateTimeField(db_index=True) image_url = models.URLField(blank=True) class RateRule(models.Model): name = models.CharField(max_length=50) free_minutes = models.IntegerField(default=15) first_hour_fee = models.DecimalField(max_digits=6, decimal_places=2) per_hour_fee = models.DecimalField(max_digits=6, decimal_places=2) daily_cap = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True) class ParkingOrder(models.Model): record = models.ForeignKey(PassRecord, on_delete=models.PROTECT) plate_no = models.CharField(max_length=15, db_index=True) entry_time = models.DateTimeField() exit_time = models.DateTimeField() duration_minutes = models.IntegerField() total_fee = models.DecimalField(max_digits=8, decimal_places=2) status = models.CharField(max_length=10, choices=[('unpaid', '未支付'), ('paid', '已支付')], default='unpaid')这里关键的决定是把PassRecord做成只插入不更新的流水表,在场状态另外通过“最近一条入场记录没有对应出场记录”来推导,或者用独立的在场表维护。不要试图在流水表上做繁重的更新操作,后续并发纠正状态时会非常被动。VehicleProfile需要加db_index,因为所有查询都是按车牌号走的,没有任何场景需要扫全表。
3. 把计费逻辑在 Django 里落地:从入场到结算的完整闭环
3.1 入场流程:临时车、月租车如何分流
入场事件进来后,不是所有车都直接放行。月租车要判断有效期,临时车要判断黑名单,免费车要判断白名单。这一步如果放在设备回调里做同步判断,很容易因为数据库查询慢导致设备端等待超时,所以正确姿势是“先落库、再异步判断”。在 Django 里,最简单的方式是直接同步处理,因为回调接口通常只做判断不放行,真正的道闸控制由设备侧自己决定。
# parking/services.py from django.utils import timezone from .models import VehicleProfile, PassRecord def handle_entry(record: PassRecord): profile = VehicleProfile.objects.filter(plate_no=record.plate_no).first() if not profile: # 未建档车辆,直接按临时车处理 return {'action': 'open', 'vehicle_type': 'temp'} if profile.vehicle_type == 'monthly': if profile.monthly_expire_at and profile.monthly_expire_at > timezone.now(): return {'action': 'open', 'vehicle_type': 'monthly'} else: # 月租到期,降级为临时车 return {'action': 'open_with_warning', 'vehicle_type': 'temp', 'msg': 'monthly expired'} if profile.vehicle_type == 'free': return {'action': 'open', 'vehicle_type': 'free'} return {'action': 'open', 'vehicle_type': 'temp'}这里的业务规则是“月租到期不拦截,但提醒收费”。因为在实际停车场里,月租过期几天的车很常见,硬拦会导致出入口拥堵,不如放行后由出口按临时车计费。这个决策逻辑应该在 service 层,而不是堆在视图函数里。视图只负责解析请求和处理异常,业务规则集中在 service 层,后面改规则时不用动接口代码。
3.2 出场结算:时长计算与分段计费
出场计算的难点不在算钱,而在“如何定义入场时间”。入场时间应该取最近一条没有对应出场的入场记录,而不是设备推送的当前时间往前猜。如果在入场记录缺失的情况下出场,需要走人工纠正流程,而不是用默认时间瞎算。
# parking/billing.py from datetime import timedelta from decimal import Decimal from .models import RateRule, PassRecord, ParkingOrder def calc_fee(entry_time, exit_time, rule: RateRule): duration = exit_time - entry_time total_minutes = int(duration.total_seconds() // 60) if total_minutes <= rule.free_minutes: return Decimal('0.00'), total_minutes # 超过免费时长的部分,按“首小时 + 后续每小时”计费 billable_minutes = total_minutes - rule.free_minutes hours = (billable_minutes + 59) // 60 # 向上取整 if hours <= 1: fee = rule.first_hour_fee else: fee = rule.first_hour_fee + (hours - 1) * rule.per_hour_fee if rule.daily_cap and fee > rule.daily_cap: fee = rule.daily_cap return fee, total_minutes def settle_exit(record: PassRecord): entry_record = ( PassRecord.objects .filter(plate_no=record.plate_no, event_type='entry') .exclude(id=record.id) .order_by('-recognize_time') .first() ) if not entry_record: raise ValueError('找不到对应入场记录,需要人工处理') rule = RateRule.objects.filter(name='default').first() fee, minutes = calc_fee(entry_record.recognize_time, record.recognize_time, rule) order = ParkingOrder.objects.create( record=record, plate_no=record.plate_no, entry_time=entry_record.recognize_time, exit_time=record.recognize_time, duration_minutes=minutes, total_fee=fee, ) return order(billable_minutes + 59) // 60这个写法是向上取整的经典技巧。停车计费超过一分钟就要按一小时算,不能四舍五入。daily_cap是封顶费,比如白天 8 小时封顶 30 元,这里一天只按自然日算,如果你的需求要跨天累计封顶,还需要把分段逻辑改得更复杂。settle_exit在找不到入场记录时直接抛异常,在接口层要捕获并通知值班人员。
3.3 Django Admin 如何接管配置与查询
Admin 是整个系统交付时最容易出彩的部分。把 RateRule、VehicleProfile、PassRecord 都注册进去,运营人员不需要写 SQL 就能完成日常维护。关键是要把列表页的字段和过滤器配置好,让查询效率有保障。
# parking/admin.py from django.contrib import admin from .models import Device, VehicleProfile, PassRecord, RateRule, ParkingOrder @admin.register(PassRecord) class PassRecordAdmin(admin.ModelAdmin): list_display = ('plate_no', 'event_type', 'device', 'recognize_time') list_filter = ('event_type', 'device__direction', 'recognize_time') search_fields = ('plate_no',) date_hierarchy = 'recognize_time' raw_id_fields = ('device',) @admin.register(VehicleProfile) class VehicleProfileAdmin(admin.ModelAdmin): list_display = ('plate_no', 'vehicle_type', 'monthly_expire_at') list_filter = ('vehicle_type',) search_fields = ('plate_no',)date_hierarchy会在列表页顶部生成一个按日期钻取的导航,对查询出场记录非常实用。raw_id_fields会把外键下拉框换成输入框,因为设备表可能只有几十条,但用户量大了之后,外键下拉框会卡死浏览器。这些细节决定了后台在真实运营中好不好用。
实时推送不在 Django 默认能力范围内,可以后续引入 Django Channels 做 WebSocket。常见的做法是识别设备回调时,把一条消息塞进 Redis 队列,然后 Channels 层从 Redis 订阅并推送给前端大屏。Django 和 WebSocket 的整合坑很多,尤其是和 Django 的会话机制、认证机制混在一起时,建议只在独立端口跑 WebSocket,不要和 WSGI 进程混布。
4. 并发、状态一致性与数据修正:这个方案最大的翻车点
4.1 重复回调导致重复入场记录
真实设备经常因为网络闪断把同一张车牌推送两次,如果不去重,就会产生两条入场记录,出场结算时取到的是较早那条,费用就会多算。解决思路不能依赖设备端保证,服务端必须做幂等。
# parking/views.py from django.db import transaction, IntegrityError @csrf_exempt def device_callback(request, device_code): # ... 前面的解析代码不变 ... # 用设备号 + 设备本地事件序号做唯一键 event_id = payload.get('event_id', '') if event_id: with transaction.atomic(): record, created = PassRecord.objects.get_or_create( device=device, device_event_id=event_id, defaults={ 'plate_no': plate_no, 'event_type': event_type, 'recognize_time': recognize_time or timezone.now(), 'image_url': image_url, } ) if not created: return JsonResponse({'code': 0, 'msg': 'duplicated'})device_event_id是设备每次推送时自增的序号,这个字段在模型中要加unique_together约束。比用时间戳去重靠谱得多,因为设备时钟经常不准。处理重复回调时,第二次请求直接返回成功但不创建记录,让设备端不再重试。
4.2 车牌识别错误的人工纠正流程
识别错误是必然事件。相似字符“0”和“O”、“1”和“I”在低分辨率抓拍下经常混淆。一旦入场时识别错了,出场时又识别对了,系统会看成两辆不同的车,导致找不到入场记录。
我见过最有效的处理方式,是在管理后台提供一个“合并记录”的操作。值班人员搜到两条记录后,把错误的车牌号改成正确的,然后重算订单。在 Django Admin 中写一个 action 就行:
# parking/admin.py from django.contrib import admin, messages def merge_plate_records(modeladmin, request, queryset): if queryset.count() != 2: messages.error(request, '只能选择两条记录进行合并') return # 取第一条记录的入场时间,第二条的出场时间 # 这里需要更严谨的判断逻辑,此处只做示意 messages.success(request, '合并完成,请检查订单')人工操作一定要留下操作日志。谁改的、什么时候改的、原车牌是什么、新车牌是什么,这些都要有记录。停车场收费的客诉往往集中在“你多收我钱了”,没有日志就意味着查无实据,只能赔钱安抚。
4.3 收费计算与并发扣费
同一个车牌在极短时间内连续出场两次的概率极低,但不代表没有。比如出口闸机抬杆后又落杆,司机倒车再冲一次,设备又推送了一条出场记录。这会导致同一辆车生成两张订单。处理方式是在创建订单时锁住入场记录,或者在车辆档案上加一个“在场”标记。
# parking/billing.py def settle_exit(record: PassRecord): entry_record = ( PassRecord.objects .filter(plate_no=record.plate_no, event_type='entry') .exclude(id=record.id) .order_by('-recognize_time') .first() ) if not entry_record: raise ValueError('找不到对应入场记录,需要人工处理') # 使用 select_for_update 锁住入场记录 # 依赖数据库事务,防止并发创建订单select_for_update是 PostgreSQL 和 MySQL 都支持的行级锁。只有在一个事务里执行时才有意义,Django 的 ORM 调用它时,必须用transaction.atomic()包起来,否则锁会在查询结束后立刻释放。锁住入场记录后,第二个请求再进来时要么等锁,要么直接超时,配合唯一约束可以兜底。
行级锁是并发问题的兜底手段。如果入口和出口的并发量很高,依赖锁会让接口响应变慢,更优雅的方案是用 Redis 分布式锁,但引入 Redis 又增加了运维成本。对于日均千辆级别的停车场,数据库行锁完全够用,不要为了架构好看而过度设计。
5. 从开发到上线的 6 个坑:基于 Django 实现停车场收费的实测避坑记录
5.1 时区问题导致计费时长错乱
现象:车辆停了 1 小时 5 分钟,系统显示停了 2 小时 5 分钟。
原因:设备推送的recognize_time是带时区偏移的字符串,Django 的USE_TZ=True时,直接存DateTimeField会被当成 UTC 时间。入场记录被加上了 8 小时偏差,出场记录正常,时长就多出 8 小时。
解决:在解析设备推送时,把recognize_time字符串先转成 aware datetime。如果设备给的是2025-01-01 12:00:00这种无时区格式,默认按本地时区处理:
from django.utils.timezone import make_aware import pytz naive_time = datetime.strptime(payload['recognize_time'], '%Y-%m-%d %H:%M:%S') aware_time = make_aware(naive_time, timezone=pytz.timezone('Asia/Shanghai'))5.2 数据库字符集导致车牌乱码
现象:新能源车牌中的“D”“F”正常,但汉字“京”“沪”存入数据库变成问号。
原因:MySQL 表级字符集是 latin1,或者 Django 连接串里没有指定 utf8mb4。
解决:创建数据库时指定字符集,连接串用 MySQL 时注意,Django 4.0 及以上版本不再自动把utf8mb4的varchar转成对应字段类型:
CREATE DATABASE parking_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;5.3 入口摄像头识别到“无牌车”导致死循环
现象:无牌车在入口处触发识别,设备推送plate_no为空字符串,系统拒收后设备反复推送。
原因:设备会根据服务端返回失败进行重试,重试间隔很短。
解决:宁可创建一条plate_no='无牌_20250101120000'的记录,也不要返回失败。等到人工发卡或扫码入场后,再更新这条记录的车牌号。这比在设备端配置“无牌车不上传”要可靠,因为设备端经常没这个选项。
5.4 管理后台查询变慢
现象:运营人员筛选一个月内的入场记录,页面转圈超过 10 秒。
原因:PassRecord表有几百万条流水,PlateNo上的索引虽然能加速单点查询,但list_filter的时间范围筛选走了full scan。
解决:给时间和车牌建联合索引,同时限制 Admin 的默认查询范围:
class PassRecordAdmin(admin.ModelAdmin): def get_queryset(self, request): qs = super().get_queryset(request) # 默认只看最近 90 天 threshold = timezone.now() - timedelta(days=90) return qs.filter(recognize_time__gte=threshold)5.5 设备时间和服务端时间不一致
现象:明明车在 12:00 入库,入场记录显示 11:50,导致出场时时长被多算 10 分钟。
原因:设备端 NTP 同步失败,系统断电重启后时钟回退。
解决:信任服务端时间,不信任设备时间。设备推送的时间只作为参考字段保存,业务计算一律使用服务端接收到请求的时间。在复杂场景中,入口和出口的时钟都在服务端统一,比校对设备省心得多。
5.6 免密支付回调重复通知
现象:对接停车场缴费平台时,支付回调重复推送,订单被重复标记为已支付。
原因:支付平台的“最终一致性”设计,回调本身就是不保证一次性的。
解决:支付回调接口做幂等,用transaction_id做唯一约束,重复回调直接返回成功,不再更新订单:
class PaymentCallback(models.Model): transaction_id = models.CharField(max_length=64, unique=True) order = models.ForeignKey(ParkingOrder, on_delete=models.PROTECT) amount = models.DecimalField(max_digits=8, decimal_places=2) paid_at = models.DateTimeField()6. 进阶玩法:从离线车牌识别到半离线计费
如果前期的机场、园区类项目不需要实时上报识别记录,或者设备部署点位和服务器之间网络质量不稳定,可以考虑“设备本地识别 + 半离线计费”的方案。识别设备的 SDK 通常支持本地保存通行记录,网络恢复后再批量补传。Django 这边的对接逻辑要稍微改一下:设备回调接口收到的是批量数据,每一条带一个本地事件序号,服务端按这个序号做增量同步。
判断哪些是增量数据,最稳妥的做法是在设备端存last_uploaded_event_id,每次补传时带上这个游标。服务端接收后,先校验游标之前的事件是否已经存在,再插入新数据。这种方式比在服务端按created_at过滤可靠得多,因为批量补传中可能有设备本地手动调整过的数据。
我会在服务端增加一个“设备心跳”接口,让设备每隔 10 秒上报一次自身状态,包括缓存队列长度和最近识别时间。一旦发现某台设备的队列长度持续增长,基本可以判断是网络链路故障。硬件设备也是项目的一部分,纯软件层面不监控设备在线状态,等到出口排长队的时候,运维压力会非常大。
做这个项目给我最大的教训是:车牌识别只是一瞬间的事,数据管理才是贯穿始终的命题。不要把精力都花在调模型准确率上,先想清楚异常数据怎么进、怎么改、怎么出。数据链路稳了,哪怕识别率只有 95%,人工纠正也能扛住;数据链路乱了,识别率 99% 也会被无牌车和错误车牌冲垮。希望这套 Django 实现方案能帮你少走一段弯路。
以上。
本文还有配套的精品资源,点击获取