简介:一套基于Python与Django的智能停车场收费系统,是一份面向高校毕业设计、课程实训及停车场管理系统二次开发的完整资源包。内容围绕车辆进出管理、费用结算、数据统计和车牌识别四条业务主线展开,能够帮助学习者快速理解Web管理系统从设计到落地的全过程。资源包压缩后约3.82MB,共235个文件,包括25个Python源码文件、15个HTML页面、15个JS脚本、10个CSS样式表,以及SQL数据库脚本、Markdown说明文档和一百余张界面素材图,前端、后端与数据库三层结构一目了然。系统代码已完成本地环境编译验证,技术评审得分超过95分;车牌识别模块采用图像处理算法,数据库遵循规范化设计理念,注释与文档链完备,既适合毕业设计直接参考,也可作为在此基础上新增功能、优化业务流程的工程样板。目前已有58人浏览学习,适合具备一定Python与Web基础、正在规划同类系统的读者下载使用。
1. 智能停车场收费系统,值不值得自己写:先看清这四件事
一个停车场项目最尴尬的瞬间,是车牌识别率标称 99%,真正上线后入口漏识别、出口算错费,运营方半夜打电话找你。把「基于 Python 与 Django 的智能停车场收费系统」当成算法题来做,一定会翻车。它本质是个 Web 工程题:车牌识别只是数据源,收费系统的命门在数据库管理、订单一致性和异常兜底。这篇笔记面向想自己搭一套系统的人——你懂一点 Python 和 Django,知道 ORM 怎么用,但拿不准识别服务怎么接、计费模型怎么建、并发下怎么不重复扣费。我会按一个可落地方案把架构、模型、接口和踩坑逐层拆开,照着搭能跑,跑进生产也不心虚。
2. 系统拆解:Django 应用划分与车牌识别接入的三种选型
2.1 以 Django 为中枢的三应用架构
停车场收费系统不是一个单应用能撑起来的。我一般按业务边界拆成三个 App:vehicles管车辆档案和车牌归属,parking管车位与进出记录,billing管计费和订单。这样做不是图好看,而是因为月租车、临时车、免费车的业务流程差异很大,拆开后信号(signal)和事务边界才清晰。比如月租车到期提醒只订阅 vehicles 的变化,免费车名单变更不需要触碰停车记录表。
创建项目的标准姿势是先建工程再逐个创建 App:
django-admin startproject parking_system cd parking_system python manage.py startapp vehicles python manage.py startapp parking python manage.py startapp billing这一步对应的就是 Django 创建 App 的标准流程。startapp会生成models.py、views.py、migrations/等骨架文件,但这只是起点。真正关键的是想清楚三个 App 之间的依赖方向:parking依赖vehicles的车辆表,billing依赖parking的停车记录表,反过来不成立。依赖单向,后面做迁移、做权限控制、做缓存失效时才不会乱成一团。
2.2 车牌识别:相机 SDK、本地模型还是云端 API
绝大部分人问「车牌识别怎么集成」时,默认以为是训练一个深度学习模型。实际上停车场项目的第一选择是采购自带识别能力的相机。主流停车场相机厂商会在相机端完成抓拍、识别、输出车牌号和置信度,Django 只需要接收相机推送的 HTTP 回调。这个方案延时最低、最稳定,识别准确率和光环境强相关,实际能到 97% 以上。
如果你要低成本验证原型,本地模型选 HyperLPR 或 PaddleOCR 都行。安装时就是常见的 python 安装 cv2 流程:
pip install opencv-python hyperlpr再把识别服务独立成一个 Python 进程,Django 通过 HTTP 或消息队列调用,绝不要把模型加载进 Django 进程。模型初始化占几百 MB 内存,推理时还会卡住 GIL,直接拖垮并发请求。云端 API 适合快速验证但受网络影响,停车场进出口的网络抖动一次,车道就堵一次,所以生产环境我建议至少保留本地降级路径。
三种方案对比下来,选型边界很清楚:
| 方案 | 精度 | 单路成本 | 延时 | 适用阶段 |
|---|---|---|---|---|
| 相机 SDK 回调 | 高 | 硬件已含 | 极低 | 生产首选 |
| 本地模型独立服务 | 中高 | 仅算力 | 低 | 原型、降级 |
| 云端 API | 高 | 按次计费 | 高 | 临时验证 |
2.3 识别结果接入:HTTP 回调与主动拉帧
相机 SDK 方案的接入非常简单。入口相机抓拍后向 Django 的/api/enter/接口 POST JSON,Django 校验车牌和置信度后写停车记录。本地模型方案则需要一个识别服务,我通常用一个轻量客户端把图片交给识别服务:
import requests def recognize_plate(image_path: str) -> tuple: """调用本地识别服务,返回车牌号和置信度;置信度不足时返回 None。""" with open(image_path, "rb") as fp: resp = requests.post( "http://127.0.0.1:8001/ocr", files={"image": fp}, timeout=5, ) data = resp.json() confidence = data.get("confidence", 0) # 置信度阈值低于 0.85 的识别结果直接丢弃,宁可人工介入 if confidence < 0.85: return None, confidence return data.get("plate"), confidence这里timeout=5是血泪经验。识别服务偶尔会因并发过载变慢,如果没有超时保护,Django 请求会一直挂着,出口道闸迟迟不开。阈值 0.85 也不是玄学,是按白天逆光、夜间弱光两类样本各抽 200 张实测得到的平衡点——调太低会把错牌放进系统,调太高人工介入频率又受不了。识别失败时不要直接拒绝出场,给一个「无法识别进入人工通道」的响应,由岗亭操作员在后台手工补录。
3. 数据库模型与计费逻辑:让收费规则不再是一堆 if else
3.1 模型设计:从车辆档案到订单
数据库管理是整个收费系统的重心。我见过太多项目把计费规则写在视图函数里,几个月后费率一调就四处打补丁。正确的做法是先把模型立住。下面这份models.py是经过两个停车场项目迭代后的版本:
from django.db import models class Vehicle(models.Model): """车辆档案:一个车牌对应一辆车的基本信息。""" plate = models.CharField("车牌号", max_length=10, unique=True) plate_color = models.CharField("车牌颜色", max_length=8, default="蓝") vehicle_type = models.CharField( "车辆类型", max_length=16, choices=[("temp", "临时车"), ("monthly", "月租车"), ("free", "免费车")], default="temp", ) is_blacklist = models.BooleanField("黑名单", default=False) valid_until = models.DateField("月租有效期", null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) class ParkingRecord(models.Model): """一次完整的入场到出场记录。""" vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT) enter_time = models.DateTimeField("入场时间") exit_time = models.DateTimeField("出场时间", null=True, blank=True) enter_image = models.ImageField(upload_to="enter/%Y%m%d/") exit_image = models.ImageField(upload_to="exit/%Y%m%d/", null=True, blank=True) status = models.CharField( "状态", max_length=8, choices=[("in", "在场"), ("out", "已离场")], db_index=True, ) class BillingOrder(models.Model): """计费订单:与停车记录一对一,防止重复计费。""" record = models.OneToOneField(ParkingRecord, on_delete=models.CASCADE) amount = models.DecimalField("金额", max_digits=8, decimal_places=2) paid_at = models.DateTimeField("支付时间", null=True, blank=True) payment_method = models.CharField("支付方式", max_length=16, default="cash")几个关键决策点。Vehicle.plate加unique=True是必须的,因为 DBA 和运营需要拿车牌直接查车,重复车牌会让账单对不上。ParkingRecord.vehicle用on_delete=PROTECT而不是 CASCADE——这是 Django 执行查询-删除对象时最值得记住的差异:一个车牌如果有未结算记录,直接级联删除会把财务数据清掉,PROTECT 会在删除时抛ProtectedError,逼你先处理关联数据。BillingOrder和ParkingRecord用 OneToOneField 是从结构上杜绝多笔订单对应同一段停车的问题。
3.2 计费规则:阶梯费率与跨天处理
计费函数是收费系统最容易写错的地方,尤其是跨天。30 分钟内免费、首小时 6 元、之后每小时 3 元、单日封顶 25 元,这套规则看起来简单,但用(exit_time - enter_time).total_seconds() / 3600直接算会在跨天时出错。我建议按「逐日分段计算」实现:
from datetime import datetime, time as dtime, timedelta from django.utils import timezone def calc_fee(enter_time, exit_time) -> float: """临时车计费:30分钟内免费,首小时6元,之后每小时3元,单日封顶25元。""" if exit_time <= enter_time: return 0.0 total_fee = 0.0 day = enter_time.date() while day <= exit_time.date(): day_start = timezone.make_aware( datetime.combine(day, dtime.min), timezone.get_current_timezone() ) day_end = timezone.make_aware( datetime.combine(day, dtime.max), timezone.get_current_timezone() ) seg_start = max(enter_time, day_start) seg_end = min(exit_time, day_end) seg_minutes = int((seg_end - seg_start).total_seconds() / 60) if seg_minutes > 30: if seg_minutes <= 60: day_fee = 6.0 else: extra_hours = (seg_minutes - 60 + 59) // 60 day_fee = min(6 + extra_hours * 3, 25.0) else: day_fee = 0.0 total_fee += day_fee day += timedelta(days=1) return total_fee逐日分段的意义在于,跨天时每一段都独立享受免费时长和封顶。比如 23:00 停到次日 00:30,首日按 1 小时收 6 元,次日 30 分钟内免费,总额 6 元;如果整体按 1.5 小时算,会收成 9 元。差距不大,但月度结算时这类边界单会积少成多。extra_hours的+59是向上取整,避免停 1 小时 1 分钟按 1 小时收费产生的客诉。参数全部集中在函数头部,后续改免费时长或封顶值只动一处,别在视图里散落 magic number。
3.3 入场与出场:用 Django REST Framework 快速搭接口
入场和出场是最核心的两个操作,我用 Django REST Framework 写接口。入场接口接收识别服务传来的车牌号,查出或创建车辆档案,再生成一条上位停车记录:
from rest_framework.decorators import api_view from rest_framework.response import Response @api_view(["POST"]) def enter(request): """入口相机回调或人工录入车辆入场。""" plate = request.data.get("plate") if not plate: return Response({"error": "plate required"}, status=400) vehicle, created = Vehicle.objects.get_or_create(plate=plate) if vehicle.is_blacklist or ( vehicle.vehicle_type == "monthly" and vehicle.valid_until and vehicle.valid_until < timezone.localdate() ): return Response({"error": "拒绝入场,请走人工通道"}, status=403) record = ParkingRecord.objects.create( vehicle=vehicle, enter_time=timezone.now(), status="in", enter_image=request.FILES.get("image"), ) return Response({"record_id": record.id, "plate": vehicle.plate})出场接口与入场对称,但多了一步计费和生成订单。这里要注意:ParkingRecord.objects.filter(vehicle__plate=plate, status="in")查出来的是该车所有在场记录,如果车牌被相机误识别过两次,会产生多条在场记录,所以出场要取最新一条,并把其余记录标记为异常,交给后台核对。
4. 出入场接口与并发一致:select_for_update 与事务边界
4.1 用 select_for_update 防止重复出场扣费
出场场景的并发问题在停车场是真实存在的:出口相机回调一次、岗亭操作员手动点一次,两次请求几乎同时到达;或者前端超时后用户重新提交,订单就重了。Django 的 ORM 层面没有天然防重,必须在数据库事务里锁行。
from django.db import transaction @api_view(["POST"]) def exit(request): """出口结算:锁定在场记录后生成订单并置为已离场。""" record_id = request.data.get("record_id") plate = request.data.get("plate") with transaction.atomic(): if record_id: record = ParkingRecord.objects.select_for_update().filter( id=record_id, status="in" ).first() elif plate: record = ParkingRecord.objects.select_for_update().filter( vehicle__plate=plate, status="in" ).order_by("-enter_time").first() else: return Response({"error": "缺少 record_id 或 plate"}, status=400) if record is None: return Response({"error": "该车辆不在场或已结算"}, status=400) fee = calc_fee(record.enter_time, timezone.now()) order = BillingOrder.objects.create( record=record, amount=fee, payment_method="cash" ) record.status = "out" record.exit_time = timezone.now() record.save(update_fields=["status", "exit_time"]) return Response({"amount": str(fee), "order_id": order.id})select_for_update()是关键。它在事务内对命中行加排他锁,第二个并发请求必须等第一个事务提交后才能读到这行,此时status已经是out,会走record is None的分支返回「已结算」,从源头避免重复扣费。注意必须配合transaction.atomic(),锁才会保持到事务结束。save(update_fields=[...])是另一个细节:只更新状态和时间两个字段,避免无意中覆盖入场图片等数据。
4.2 事务边界:算费与锁记录是同一笔事务
初版出场接口我犯过把算费放在事务外的错误——先查记录算费用,再开启事务写订单。结果并发下第二笔请求读到的是未更新的在场记录,照样生成订单。后来才把calc_fee和订单创建全部挪进同一个with transaction.atomic()块里。事务边界的原则是:凡是基于同一份读数产生的写操作,必须在同一个事务里。
这里的坑还涉及 MySQL 隔离级别。默认 REPEATABLE READ 下,两个事务同时读同一行status="in"的记录,如果不加锁,两边都能读到并各自创建订单。加了select_for_update后,第二个事务会阻塞而不是读到旧快照。如果你在用 PostgreSQL,没有这个问题,但 MySQL 生产库一定要确认表引擎是 InnoDB,MyISAM 不支持行锁,select_for_update会退化成表锁甚至不生效。
还有一个容易忽略的点:黑名单校验和月租车有效期检查要不要也放进事务?我的做法是入场时校验放在事务外,因为黑名单变更频率远低于进出场并发频率,放事务外可以缩短锁持有时间。出场时则把黑名单检查放在计算费用前、锁内执行,防止车辆在结算瞬间被手工加入黑名单。
4.3 数据库管理:切换到 MySQL 与日常保护
Django 默认的 SQLite 只适合本地开发,收费系统一上线就要换 MySQL。settings.py里的配置项很容易出问题,字符集必须显式指定utf8mb4,否则车牌里的汉字和生僻字在写入时报Incorrect string value的错误:
DATABASES = { "default": { "ENGINE": "django.db.backends.mysql", "NAME": "parking_system", "USER": "parking_user", "PASSWORD": "强密码", "HOST": "127.0.0.1", "PORT": "3306", "OPTIONS": {"charset": "utf8mb4"}, } }数据库管理的日常保护分三层。第一层是索引:进出场查询总是以status和enter_time为条件,给ParkingRecord建联合索引(status, enter_time),加索引前后查询量级完全不同。第二层是迁移:每次模型变更后跑python manage.py makemigrations和python manage.py migrate,迁移文件要入库管理,不能只在本地执行。第三层是备份:用 mysqldump 每天凌晨做全量备份,至少保留 7 天,收费记录是运营方对账的凭据,丢一天的账单都可能引发纠纷。
5. 集成避坑:车牌识别与数据库管理的 5 个典型事故
5.1 识别结果张冠李戴:字符替换导致出场查不到记录
现象:入场时车牌被识别成「京A1234S」,出场识别正确为「京A12345」,系统查不到入场记录,道闸不开,车辆堵在出口。
原因:单字符识别错误是车牌识别最主要的误差来源,字形相近的「S」和「5」、「0」和「D」尤其容易混淆。入场识别错误会在记录里留下一个错误车牌,而出场识别正确时反而匹配不上。
解决:识别服务返回的置信度保留到数据库,入场记录增加recog_confidence字段;出场时若匹配不到在场记录,先按置信度从低到高列出该车最近的离场记录让操作员确认,而不是直接放行或拒行。再在后台加一个「车牌修正」功能,改错牌后自动关联原始入场图片,留有审计痕迹。我在生产上把置信度低于 0.8 的记录全部标记为待人工复核,宁缺毋滥。
5.2 时区没配好,跨天计费差了一天
现象:有车辆 23:50 入场、次日 00:10 出场,账单按 2 小时收,多收了钱。
原因:Django 默认USE_TZ=True时,数据库存的是 UTC 时间,如果TIME_ZONE没设置为本地时区,enter_time和exit_time的日期切片会各差 8 小时,跨天边界全部错位。
解决:settings.py里TIME_ZONE = "Asia/Shanghai",并且所有涉及计费的时间都用timezone.now()生成,不要用 Python 原生的datetime.now()。日切时间也建议从 00:00 改为凌晨 3 点——停车场日切通常在凌晨,避免把夜班车跨天算成两天。改完后要批量重算历史订单,用第 6 章的回归脚本把跨天用例全跑一遍。
5.3 删除月租车时抛 ProtectedError
现象:运营想删除一辆已过期的月租车,界面报错ProtectedError: Cannot delete some instances of model Vehicle,删不掉。
原因:ParkingRecord.vehicle用了on_delete=PROTECT,只要这辆车有停车记录就不允许物理删除,这是保护财务数据的设计,但运营不理解。
解决:不要教运营去改数据库,给一个「停用」接口,把Vehicle.valid_until设为昨天、状态标记为停用,并保留关联记录。真正要物理删除时,先确认该车没有未结算订单,再按顺序清理BillingOrder→ParkingRecord→Vehicle。这个坑提醒我们:Django 执行查询-删除对象时要看清外键关联,CASCADE 是省事但不是所有场景都该用。
5.4 并发下同一辆车出现两条在场记录
现象:早晚高峰时入口相机连续两次回调,同一车牌在ParkingRecord里插入了两条status="in"的记录,出场时系统虽然有最新一条能结算,但账目里多了「幽灵停车」记录。
原因:入场接口没有加幂等控制。识别服务在弱光条件下回重试,或者网络抖动导致同一张抓拍图被提交两次。
解决:入场接口增加幂等判断。入口用ParkingRecord.objects.filter(vehicle=vehicle, status="in", enter_time__gte=timezone.now() - timedelta(minutes=2))检查两分钟内是否有在场记录,有则直接返回已有记录 ID,不再新建。识别帧还可以带上相机的frame_id,数据库给(frame_id, camera_code)加唯一约束,彻底堵住重复提交。
5.5 模型加载进 Django 进程,请求全线卡死
现象:识别服务上线当天,入口频繁超时,集成商排查发现 Django CPU 跑到 100%,所有请求排队。
原因:为了省一台服务器,把 HyperLPR 模型直接在 Django 进程里初始化,每个请求的识别推理占满 GIL,Web 请求被堵死。
解决:识别进程独立部署,Django 与识别服务之间只走 HTTP 请求。如果买的是相机 SDK,识别在相机端完成,Django 连模型都不用见。这个事故带来的经验很简单:Web 进程只做 Web 的事,计算密集任务一律外置。本地模型降级路径可以单独起一个ocr_service.py进程,用 gevent 或 gunicorn 承载并发识别请求。
6. 进阶:无牌车兜底流程与计费回归验证脚本
6.1 无牌车在场管理
无牌车是所有停车场的硬需求,电车临牌、丢失号牌、新车都是真实场景。我的做法是把无牌车当成特殊车牌处理:入场识别返回空车牌时,生成一个临时车牌号「无牌-{通道号}-{YYYYMMDDHHMMSS}」,并把入场图片存好。出场时操作员根据图片核对车辆,确认后手动匹配入场记录。临时车牌号要保证唯一且可读,运营方月底对账时能按日期和通道快速定位。这一个兜底流程看着简单,但能省掉大量和业主的扯皮。
6.2 计费回归验证脚本
改过计费规则后,手工测试总是漏场景,我习惯写一个脚本把所有边界用例跑一遍。脚本放在scripts/regression_test.py,用 Django 的 shell 环境执行:
from datetime import datetime, timedelta from django.utils import timezone from billing.utils import calc_fee cases = [ ("30分钟内", timedelta(minutes=25), 0), ("停满2小时", timedelta(hours=2), 9), ("停4小时30分", timedelta(hours=4, minutes=30), 15), ] for name, delta, expected in cases: fee = calc_fee(timezone.now(), timezone.now() + delta) assert fee == expected, f"{name}: 期望 {expected}, 实际 {fee}" # 跨天用例:23:00 入场,次日 00:30 出场 enter_time = timezone.make_aware(datetime(2024, 1, 1, 23, 0)) exit_time = timezone.make_aware(datetime(2024, 1, 2, 0, 30)) assert calc_fee(enter_time, exit_time) == 6, "跨天30分钟应只收首日6元" print("全部计费用例通过")执行方式是python manage.py shell < scripts/regression_test.py。这样的脚本要当成和代码一样的资产维护,每次改完calc_fee先跑它,再上生产。我在一个项目里因为改了日封顶没跑回归,结果跨天订单全按新封顶重新算了三天,运营对账对到崩溃。后来这个脚本成了发版前的强制检查项。无牌车流程也一样,建议写一个模拟入场-人工出场-生成账单的集成测试,比上线后拿真车试要靠谱得多。希望这些方案和踩坑经历能帮到你,让你在搭这套系统时少走几步弯路。
本文还有配套的精品资源,点击获取