ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Django充电桩管理系统:核心设计、数据库建模与计费实战解析

Django充电桩管理系统:核心设计、数据库建模与计费实战解析

我一直觉得,Django 这类业务管理系统,最不缺的就是“能跑的项目”,最缺的是“能讲清楚为什么这么设计”的源码。最近在整理一个新能源汽车充电管理系统,前后花了大概两周时间把核心模块撸完,顺手把源码也打包了一份。这篇文章就围绕这个项目,聊聊系统设计、数据库建模、核心业务逻辑和部署踩坑,尽量把每个关键决策背后的原因也讲清楚,让拿到源码的朋友不只是会启动,更能真正看懂、能改、能扩展。

1. 系统整体设计与思路拆解

1.1 充电管理系统的真实业务需求

在动手写代码之前,先想明白一件事:一个充电管理系统,到底要解决谁的什么问题?

从运营方角度看,痛点很直接:充电桩分布在多个站点,每根桩是空闲、占用还是故障,靠人工巡检根本不现实;用户充了电,电费怎么结算、服务费怎么收、余额够不够扣,全靠手工账本迟早要乱;设备接入、异常报警、月度营收统计,这些都是运营决策的刚需。

从用户角度讲,最关心的是三件事:附近哪里有空闲桩、充电状态能不能实时看到、充完电到底扣了多少钱。这三件事对应到系统里,就是站点信息展示、充电订单实时状态追踪、计费账单明细。

从开发者角度呢?这类系统其实是一个非常典型的“带状态流转的业务系统”:充电桩有状态、订单有状态、支付有状态,而且各个状态之间是有约束关系的。状态管理做清楚了,整个系统的骨架就稳了。

这套系统的核心模块我拆成了四块:用户与权限、站点与充电桩管理、充电订单与计费、数据统计与报表。用户端提供扫码充电、订单查看、余额充值与消费记录;管理端则是桩的维护、价格策略配置、订单管理、异常处理。前后台共用一套 Django 工程,只是通过不同的视图和权限层做隔离,避免维护两套后端。

1.2 技术选型:为什么是 Django 而不是 Flask 或 Spring Boot

这个项目选择 Django,不是因为它最流行,而是因为它和“管理类业务系统”的匹配度最高。

Django 自带 Admin 后台,在不写一行前端代码的情况下就能完成基础数据管理;自带 ORM,充电桩、订单这类实体的增删改查和关联查询非常顺手;自带用户认证体系,把 AbstractUser 扩展一下就能满足用户表字段定制;自带 Migrations 迁移机制,模型改动后同步数据库结构比手写 SQL 高效得多。

和 Flask 对比,Flask 的优势是轻量灵活,但业务系统需要的那套东西——Admin、Auth、ORM、表单校验——在 Flask 里都得自己拼装,开发效率低不少,对于一个需要快速交付、且业务规则有一定复杂度的项目来说,Django 的全家桶方案更合适。

和 Spring Boot 对比,Java 生态在企业级场景确实更重更稳,但对于中小型充电运营场景,Python + Django 的开发成本和迭代速度优势明显,而且只要数据库设计和事务控制做到位,业务正确性同样有保证。这个项目如果一开始用 Spring Boot,光环境搭建和依赖管理就会多花不少时间。实际开发中,我做了一个小对比供参考:

维度DjangoFlaskSpring Boot
开发效率高,内置组件全中,需自行组装中低,配置较多
内置管理后台自带,开箱即用需扩展需集成
ORM 能力强,适合复杂关联弱,依赖扩展强,但学习成本高
适合场景业务管理系统轻量API服务大型企业级平台

这套系统选择 Django 的另外一个原因是它的 ORM 在做“按站点统计充电量”“按桩查询订单列表”这类聚合查询时非常直观,不需要写一堆原生 SQL。后文会具体展示这些查询怎么落地。

1.3 项目整体目录结构与模块划分

源码的目录结构遵循 Django 工程的标准约定,但做了一点针对充电业务的特别设计。项目名我定为 charging_system,核心应用有两个:accounts 负责用户认证与授权,charging 负责站点、充电桩、订单、计费等核心业务。这样拆分的原因很简单——把用户体系独立成 app,后续哪怕新增小程序端、管理端,用户模型都不用动。

charging app 内部按业务对象又分了几个模块文件,每个文件只聚焦一件事。models.py 只放数据模型,views.py 只放视图函数,services.py 专门放计费引擎和订单状态流转这类核心业务逻辑。为什么要单独拆一个 services.py?因为视图里的代码应该只负责“接请求、调服务、返回结果”,而计费规则、状态判断这种可能被多个入口复用的逻辑,放在服务层里才是正确的位置。如果将来有新的入口接入(比如 API 接口),依然可以直接复用 services 里的方法。

静态资源和模板也做了区分:用户端用 Bootstrap 做响应式页面,管理端用 Admin 搭配 SimpleUI 皮肤,报表页面用 ECharts 渲染柱状图和折线图。静态文件统一放在 static 目录下,模板放在 templates 下并区分 user 和 admin 两个子目录。

2. 核心功能设计与数据库模型拆解

2.1 四大核心模块的功能边界

系统不追求大而全,而是先把充电业务最核心的闭环做完整。第一是用户端功能:注册登录、个人资料、余额充值、充电消费记录、账单导出。第二是站点管理功能:站点名称、地址、经纬度、充电桩数量统计、站点状态管理。第三是充电桩管理功能:桩编号、类型(交流/直流)、功率、状态(空闲/充电中/离线/故障)、实时电价关联。第四是订单计费功能:用户扫码启动充电、记录充电开始时间与起始电量、充电期间状态更新、结束充电时计算费用并扣费。

这四个模块的依赖关系是:站点包含充电桩,充电桩产生订单,订单关联用户并按规则计费。数据流向非常清晰,所以数据库模型的设计也遵循这条主线。

2.2 用户扩展模型与权限设计

用户这块,Django 自带的 User 模型字段够用但不够用——够用的是账号密码邮箱这些基础字段,不够用的是手机号、余额、积分这类业务字段。所以直接采用继承 AbstractUser 的方式扩展,新增 phone、balance、avatar、gender 等字段。

余额字段必须用 DecimalField 而不是 FloatField,这点我在代码里加了非常明确的注释。浮点数在二进制下没法精确表示十进制小数,0.1 加 0.2 这种看似简单的问题在计算机里会变成 0.30000000000000004,金额计算一旦出现这种误差,对账的时候就会莫名多出几分钱。DecimalField(max_digits=10, decimal_places=2) 配合 Django 的 Decimal 对象,可以保证金额精度。

权限方面,用户角色分为普通用户、站点管理员、超级管理员。超级管理员用 Django 自带的 is_superuser 字段即可,站点管理员则通过一个 Profile 的 role 字段区分。普通用户只允许操作自己的订单和自己的余额,这个限制我不仅在前端隐藏了按钮,后端视图里也做了二次校验,避免有人绕过前端直接构造请求。

2.3 充电桩与站点的模型字段全解析

站点表 Station 的主要字段有:名称 name、地址 address、经度 longitude、纬度 latitude、联系电话 contact_phone、状态 status。经纬度字段我用的是 DecimalField 加 max_digits=9 和 decimal_places=6,这个精度大约能定位到 0.1 米级别,足够用于地图展示和附近站点查询。

充电桩表 ChargingPile 的核心字段如下:

class ChargingPile(models.Model): PILE_TYPE_CHOICES = ( ('AC', '交流桩'), ('DC', '直流桩'), ) STATUS_CHOICES = ( ('IDLE', '空闲'), ('CHARGING', '充电中'), ('OFFLINE', '离线'), ('FAULT', '故障'), ) pile_no = models.CharField('充电桩编号', max_length=32, unique=True) station = models.ForeignKey(Station, on_delete=models.CASCADE, verbose_name='所属站点') pile_type = models.CharField('桩类型', max_length=10, choices=PILE_TYPE_CHOICES, default='AC') power = models.DecimalField('功率(kW)', max_digits=6, decimal_places=2) status = models.CharField('当前状态', max_length=10, choices=STATUS_CHOICES, default='IDLE') price_per_kwh = models.DecimalField('电价(元/度)', max_digits=5, decimal_places=2) service_fee_per_kwh = models.DecimalField('服务费(元/度)', max_digits=5, decimal_places=2) create_time = models.DateTimeField('创建时间', auto_now_add=True)

桩编号使用唯一约束,就是既要避免重复录入,也方便后续对接硬件设备时用它作为通信标识。状态字段只定义了四种状态,没有搞得太复杂。实际业务中桩的状态可能更多,比如“预约中”“维护中”,但对于第一版系统,四种状态足够覆盖核心场景了。桩类型和功率是关联的,交流桩一般是 7kW 或者 22kW,直流桩则是 30kW 到 120kW 不等,设计时把这个作为展示信息,同时也可用于后台的功率负荷统计。

状态设计这块,我走了不少弯路纠结过“到底应该用 CharField 还是 IntegerField”。最后选择 CharField 配合语义化常量,纯粹是因为代码可读性好。订单状态用数字编号虽然省空间,但看到order.status == 2的时候,没人知道 2 是什么意思。用'CHARGING'表达状态,代码即文档。

2.4 充电订单与计费相关表设计

订单表是所有业务逻辑中最重要的表。字段设计直接决定了后续计费、对账、统计的复杂度。

class ChargeOrder(models.Model): STATUS_CHOICES = ( ('PENDING', '待支付'), ('CHARGING', '充电中'), ('FINISHED', '已完成'), ('CANCELLED', '已取消'), ('REFUNDED', '已退款'), ) order_no = models.CharField('订单编号', max_length=64, unique=True) user = models.ForeignKey(User, on_delete=models.PROTECT, related_name='orders') pile = models.ForeignKey(ChargingPile, on_delete=models.PROTECT, related_name='orders') start_time = models.DateTimeField('开始充电时间') end_time = models.DateTimeField('结束充电时间', null=True, blank=True) start_kwh = models.DecimalField('起始电量(kWh)', max_digits=10, decimal_places=2, null=True, blank=True) end_kwh = models.DecimalField('结束电量(kWh)', max_digits=10, decimal_places=2, null=True, blank=True) total_kwh = models.DecimalField('充电量(kWh)', max_digits=10, decimal_places=2, null=True, blank=True) amount = models.DecimalField('订单金额(元)', max_digits=10, decimal_places=2, null=True, blank=True) status = models.CharField('订单状态', max_length=10, choices=STATUS_CHOICES, default='CHARGING') create_time = models.DateTimeField('创建时间', auto_now_add=True)

订单编号采用“日期 + 随机串”的生成方式,比如 20250612103015 加 6 位随机字母,而不是自增 ID。为什么?因为订单号要暴露给用户查看,如果直接用自增 ID,用户可以猜到订单量,也容易被枚举遍历。随机订单号这件事成本很低,但安全性提升明显。

还有几个字段的设计值得注意。起始电量允许为空,因为一部分老旧桩不返回实时电量数据,只能按充电时长估算。结束时间和结束电量都允许为空,因为充电可能异常中断,这时候需要人工介入。订单金额也允许为空,只有充电结束后才回填,避免“先填一个预估金额然后后续还要改”的数据一致性麻烦。

使用on_delete=models.PROTECT而不是 CASCADE,这个细节我特别说明一下:用户或者充电桩一旦有了订单记录,就不应该被直接删除。业务数据要保留,删除用户会把历史账单一起删掉,这在真实运营中是不可接受的。PROTECT 会在尝试删除时报错,强制操作者改用停用或软删除的方式。

计费相关的计算不需要单独建表,因为每度电的价格已经冗余存储在了充电桩表里,订单金额只需要在结束充电时根据充电量和单价相乘得出即可。但要注意,如果未来开放“分时电价”或者“会员折扣”,那价格策略就需要独立成表了,到时候订单里的金额字段就成了冗余快照,这是后话。

3. 关键业务逻辑与计费规则的代码实现

3.1 充电启动流程:状态校验与订单创建

用户点击“开始充电”后,后端要做的第一件事是校验,不是生成订单。这个顺序特别重要。

def start_charging(request, pile_id): pile = get_object_or_404(ChargingPile, pk=pile_id) if pile.status != 'IDLE': return JsonResponse({'code': 400, 'msg': '该充电桩当前不可用'}) # 并发情况下防止重复启动 with transaction.atomic(): updated = ChargingPile.objects.select_for_update().filter( pk=pile.pk, status='IDLE' ).update(status='CHARGING') if not updated: return JsonResponse({'code': 400, 'msg': '该充电桩已被占用'}) order = ChargeOrder.objects.create( order_no=generate_order_no(), user=request.user, pile=pile, start_time=timezone.now(), status='CHARGING' ) return JsonResponse({'code': 200, 'data': {'order_id': order.id}})

这里的核心是transaction.atomic()配合select_for_update()。想象一下:两个用户同时扫码同一根空闲桩,不加锁的话,两个人都通过了status != 'IDLE'的判断,然后各自创建了一条充电订单,这根桩就被充电两次了。普通系统可能觉得无所谓最多浪费点数据,但充电桩是物理设备,同一时间只能给一辆车充电,这种并发错误会直接导致真实世界的事故。

行级锁的原理是:在 SELECT 查询时对命中的行加写锁,事务提交前其他事务无法修改这一行。第二个请求执行到 update 时发现状态已经变成 CHARGING,updated 为 0,于是直接返回“已被占用”。这就是并发环境下正确的业务判断方式。

3.2 计费引擎设计:阶梯价与动态费用

计费是充电系统里最容易出 bug 的地方。这个项目的计费支持两种模式:普通固定单价和按功率区间阶梯单价。

实现时我写了一个独立的计费函数,放在 services.py 中:

def calculate_fee(pile, start_kwh, end_kwh, charge_duration): kwh = end_kwh - start_kwh # 阶梯电价逻辑:不同功率区间不同单价 if pile.pile_type == 'DC' and pile.power >= 60: unit_price = pile.price_per_kwh * Decimal('1.2') else: unit_price = pile.price_per_kwh energy_fee = kwh * unit_price service_fee = kwh * pile.service_fee_per_kwh total = energy_fee + service_fee return total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)

特别注意最后的quantize操作。这是 Decimal 的精确舍入方法,ROUND_HALF_UP 表示四舍五入。为什么不用 Python 内置的 round?因为 round 遵循“银行家舍入”规则,遇到 0.005 这类边界值会舍成偶数而不是我们预期的一律进位。计费金额的舍入规则必须在代码层面明确,否则同样的充电量和单价,不同环境跑出来可能差一分钱,财务对账会非常头疼。

计费函数设计成独立函数还有一个好处:单元测试非常好写。我写了一组测试用例覆盖了普通桩、大功率桩、边界值舍入、零电量等场景,每次改动模型字段或者规则时,跑一遍测试就知道有没有影响核心计费逻辑。

实际测试中发现一个隐藏问题:如果桩上报的电量不是递增的,比如车辆中途断电重启导致电量倒退,那 end_kwh - start_kwh 就会变成负数。因此计费前要先做异常处理,当 kwh 为负值时记录异常日志,并把订单标记为异常待人工处理,而不是直接扣费。

3.3 结束充电与账户余额扣减

结束充电的流程比启动更复杂,因为涉及金额计算和余额扣减两件事,而这两件事之间必须保证要么都成功、要么都失败。

def finish_charging(order_id): with transaction.atomic(): order = ChargeOrder.objects.select_for_update().get(pk=order_id) if order.status != 'CHARGING': raise BusinessException('订单状态异常,无法重复结算') pile = order.pile end_kwh = get_end_kwh_from_device(pile) end_time = timezone.now() kwh = end_kwh - order.start_kwh if kwh < 0: kwh = Decimal('0') # 记录异常日志,标记人工处理 amount = calculate_fee(pile, order.start_kwh, end_kwh, end_time - order.start_time) order.end_kwh = end_kwh order.end_time = end_time order.total_kwh = kwh order.amount = amount order.status = 'FINISHED' order.save() user = User.objects.select_for_update().get(pk=order.user_id) if user.balance < amount: # 余额不足,订单置为待支付状态 order.status = 'PENDING' order.save() return {'code': 400, 'msg': '余额不足,请充值后再支付'} user.balance -= amount user.save() BalanceLog.objects.create( user=user, order=order, change=-amount, balance_after=user.balance, remark='充电消费' )

事务保证了金额计算和扣费的原子性。如果扣费成功但订单状态没有更新,用户会发现钱扣了订单还是充电中,这是很不好的体验。反过来,如果订单更新了但扣费失败,钱对不上账更麻烦。这两步操作必须放进同一个事务。

另外在扣费前我又查了一遍用户余额,为什么?因为充电开始到结束可能过去了半小时甚至更久,用户可能在此期间有另一笔消费把余额花掉了一部分。如果不用select_for_update在读余额时锁住这行,并发场景下两个事务同时读到余额 100,各自扣掉 80,最后数据库里剩下 20,但实际应该扣两次变 -60,这就是经典的“丢失更新”问题。用行锁串行化这一行的读写,才能避免超扣。

余额流水表 BalanceLog 几乎是必须的。用户对账、客服排查、财务审计都靠它。字段包含当前余额快照 balance_after,这是为了在余额变更后,能追溯当时的账户状态,否则只能看到一笔 -80 的流水,却不知道这 -80 是在余额 100 还是 1000 的基础上扣的。

3.4 电量统计与多维度数据报表

运营系统没有统计报表就等于白做。充电量、营收、客单价、站点利用率,这些指标直接决定运营策略。Django ORM 对这类聚合查询支持得很好,下面这个查询是统计某站点下每天的充电量与营收:

from django.db.models import Sum, Count, F report_data = ( ChargeOrder.objects .filter(pile__station=station, status='FINISHED', end_time__isnull=False) .annotate(day=F('end_time__date')) .values('day') .annotate( total_kwh=Sum('total_kwh'), total_amount=Sum('amount'), order_count=Count('id') ) .order_by('day') )

F 表达式可以引用模型字段进行运算,F('end_time__date')表示取 end_time 的日期部分。这个查询一次数据库往返就把站点每天的数据全部统计出来了,性能比 Python 层循环累加高得多。

管理端展示的图表我使用的是 ECharts,后端提供 JSON 接口返回聚合结果,前端用 Ajax 请求后渲染折线图和饼图。折线图展示近 30 天充电量趋势,饼图展示站点营收占比,柱状图对比交流桩和直流桩的利用率。数据可视化在这类系统里的价值在于:运营人员不需要看数据库报表,打开首页就能知道哪个站点忙、哪个站点闲,以及哪个站点的服务费定价可能有问题。

4. 系统环境搭建与全流程启动

4.1 开发与部署环境准备

这个项目是基于 Python 3.10 + Django 4.2 开发的。为什么选 Django 4.2?因为这是 4.x 系列的 LTS 长期支持版本,至少能持续收到安全更新,比追最新的 5.x 更稳。数据库使用 MySQL 8.0,本地开发时也可以切到 SQLite,只需要改一下 DATABASES 配置。

环境准备这里我踩过很多次坑,整理一下最稳妥的顺序:

# 1. 创建虚拟环境,避免污染全局 Python python3 -m venv venv source venv/bin/activate # 2. 安装依赖 pip install django==4.2.* mysqlclient pillow django-simpleui

mysqlclient 这个包在 macOS 和 Linux 上偶尔会安装失败,因为它是编译型包,依赖系统的 mysql_config 工具。如果遇到报错,先通过系统包管理器安装 MySQL 开发库再重试。Windows 上则是另一个故事,建议直接换用 pymysql 并在项目入口加入pymysql.install_as_MySQLdb()这行兼容代码。

4.2 Settings 核心配置注意事项

配置方面有几个点特别容易出问题。第一是时区,如果USE_TZ = True,Django 默认使用 UTC 时间,但国内的业务系统无论展示还是计费都应该用本地时间。折中方案是数据库存储 UTC 时间,然后设置TIME_ZONE = 'Asia/Shanghai'同时保持USE_TZ = True,Django 会自动在存储和读取时做时区转换。这个方案的好处是数据库里所有时间都是 UTC,以后做跨地域扩展时不会混乱,页面显示和订单时间则自动换算成北京时间。

这里有个很隐蔽的坑:如果USE_TZ = True,直接用 Python 的datetime.now()生成的 naive datetime 写入模型字段,Django 会直接报错。必须使用django.utils.timezone.now(),或者datetime.now(timezone.utc)。我最初就是在这个问题上卡了半小时,报错信息提示 RuntimeWarning 但程序照样跑,数据里的时间却差整整 8 个小时。

第二是静态文件配置,STATIC_URL和MEDIA_URL需要正确指定,媒体文件目录用来存放用户上传的头像和站点图片。部署到生产环境时还要注意,Django 默认不托管静态文件,需要交给 Nginx 来处理。

4.3 数据库迁移与初始数据

数据库迁移是整个启动流程里最容易出错的一步。顺序是先改好模型,然后生成迁移文件:

python manage.py makemigrations accounts charging python manage.py migrate

makemigrations 会对比模型定义和数据库当前状态,生成增量迁移脚本。migrate 则按顺序执行这些脚本。如果模型改了但没执行迁移,启动系统时会看到 “table xxx has no column named yyy” 之类的报错。

初始数据方面,我准备了一个init_data.py脚本放在项目根目录,运行后会自动创建几个测试站点和十几根充电桩,同时生成一个测试账号,方便拿到源码的朋友快速进入体验状态,不用自己去后台手动录入。桩编号和站点编码都是模拟数据,但字段格式和真实场景一致,可以当作后续导入真实数据的模板。

4.4 项目启动与测试运行

启动前先做一次系统性检查,这一步能避免 90% 的启动问题:

# 检查项目配置是否有错误 python manage.py check # 创建超级管理员账号 python manage.py createsuperuser # 启动开发服务器 python manage.py runserver 0.0.0.0:8000

浏览器访问http://127.0.0.1:8000就是用户端页面,访问http://127.0.0.1:8000/admin进入管理后台。登录超级管理员后可以看到站点管理、充电桩管理、订单管理全部功能模块。

如果页面样式丢失,检查静态文件配置和python manage.py collectstatic是否执行过。这是一个新手高频问题,十个人里至少有四个人会在这里卡住,但排查方向非常固定。

4.5 生产部署方案简述

开发服务器runserver是单线程且性能很弱,只适合本地调试,绝对不能用于生产。生产环境我采用的标准组合是 Nginx + Gunicorn + MySQL。

Gunicorn 负责运行 Django 应用,Nginx 反向代理处理静态资源和流量转发。部署时还需要设置环境变量,把 SECRET_KEY、DEBUG、数据库密码等敏感信息从代码里拆出来,这点非常重要。很多项目源码在 Git 仓库里直接明文写着数据库密码,一旦仓库泄露,整个生产环境就裸奔了。使用os.environ.get('DJANGO_SECRET_KEY')方式读取环境变量,就算代码被看到了,没有环境变量也跑不起来。

还有几个生产环境的硬性要求:DEBUG 必须设为 False,ALLOWED_HOSTS 必须配置为实际域名,所有请求强制 HTTPS,关闭 Debug 页面防止异常信息泄露。我用一个检查清单,每次部署前逐项确认:

检查项目开发环境生产环境
DEBUGTrueFalse
ALLOWED_HOSTS空配置域名
SECRET_KEY固定伪随机环境变量注入
静态文件Django 托管Nginx 托管
数据库SQLite/MySQLMySQL

5. 常见问题与排查技巧实录

5.1 时区导致的时间错乱问题

这类问题最典型的症状是:订单时间显示“上午 8 点”,实际充电发生在“下午 4 点”。排查步骤很简单,先用 Django Shell 检查当前时间和timezone.now()是否一致:

from django.utils import timezone print(timezone.now())

如果输出的是 UTC 时间,需要检查TIME_ZONE和USE_TZ配置。我遇到过用户反馈订单结束时间比开始时间早 7 个多小时的情况——因为电池管理系统上报时间用的是本地时间,而 Django 以 UTC 解析,造成 8 小时偏移,而当时恰好在夏令时和非夏令时切换期又有额外偏差。最后统一约定:所有设备上报时间戳一律携带时区标识,后端统一转 UTC 存储,展示时再转换。约定越清晰,问题越少。

5.2 数据库迁移报错与字段变更的坑

迁移报错最典型的场景是:修改了模型字段后直接运行 migrate,系统提示 “column does not exist”。原因是 makemigrations 只对比了之前的迁移文件和当前模型,不会自动同步数据库里的实际结构。解决方法是先让模型与上一次迁移保持一致,然后重新生成迁移文件。

另外,如果在已有数据的表里新增非空字段且没有给默认值,migrate 会报错,提示需要先提供默认值或允许为空。比如给充电桩表新增一个 rating 字段时,如果直接加CharField(max_length=10)会因为存量数据无法满足非空约束而失败。正确做法是加default=''或null=True,或者分两步走:先新增可空字段,迁移后填充数据,再修改为不可空。这个操作顺序非常重要。

5.3 并发请求导致的超扣或重复订单

超扣问题前面讲过并发锁的解决方案,但实际开发中还有另一类更容易被忽略的情况:MySQL 默认隔离级别是 REPEATABLE READ,在这个隔离级别下,两个事务同时更新同一行时如果没有 SELECT FOR UPDATE,后提交的事务覆盖前一个事务的修改。我负责的一个早期项目里,用户在充电结束瞬间正好在另一台设备上充了值,结果余额显示充值成功但扣费却是用旧余额扣的,账务平不了。排查后就是缺少select_for_update。给所有“读-改-写”操作都加上行锁,这个坑才算彻底填平。

这类并发问题很难在功能测试阶段暴露,因为单个用户操作很难触发。我在项目里留了一个并发测试脚本,模拟 20 个线程同时抢同一根桩,用来验证锁是否生效。第一次跑这个脚本时真的复现了重复订单,修完锁之后再去跑就只剩一个成功了。

5.4 前端跨域与接口对接注意事项

如果这个项目的 API 后续要对接小程序或者 App,跨域问题就会冒出来。开发环境下可以采用 django-cors-headers 这个组件,配置CORS_ALLOWED_ORIGINS即可。生产环境如果接口由 Nginx 代理同域部署,则不存在跨域问题。需要注意的另一点是 CSRF:Django 对 POST 请求有 CSRF 校验,小程序端请求没有带 cookie 里的 csrftoken,会被 Django 拒绝。解决方案是使用 JWT 代替 Django Session 认证,或者为 API 专门设置@csrf_exempt,但显然后者不安全,不推荐。

5.5 充电桩状态不同步的异常处理

真实场景中,充电桩硬件和服务器之间的状态可能因为网络波动出现不同步。桩显示充电中,服务端订单已经结束;服务端显示空闲,桩其实还在充电。这类问题无法完全避免,但可以通过定时对账任务来弥补。系统里我写了一个 audit 命令,每五分钟拉取一次桩的上报状态并与数据库比对,如果发现不一致,记录告警日志,并自动修正桩状态。这个命令通过 Crontab 定时调度,稳定性比 Celery 定时任务简单得多,适合中小型项目。

这里要强调一个思路:当硬件状态和软件状态不一致时,永远以设备上报为准,不要以软件自动判断为准。因为软件可能因为 bug 误判,而设备的物理状态是事实。自动修正的逻辑是:设备上报“空闲”,数据库是“充电中”,那订单大概率已经完成了,需要人工确认;设备上报“充电中”,数据库是“空闲”,那可能是有人还没有下单就插枪了,这时候应该触发异常告警而不是自动改状态。

6. 系统后续的扩展方向与二次开发建议

6.1 计费策略的扩展空间

当前系统采用的是固定单价和简单阶梯价,对于大多数场景够用了。但如果运营方想推出分时电价(高峰时段贵、低谷时段便宜),就需要增加一个价格策略表,配置不同时间段的价格系数。订单金额不再直接由单价计算,而是根据充电过程中不同时段的电量分段累计。这种设计对计费函数的影响较大,建议一开始就把计费入口收敛在 services.py 里,扩展时只改这一个函数和新增一张表,不用动视图层。

计费引擎的未来方向还有会员折扣、套餐包、优惠券抵扣。哪怕现在不做,设计订单表时也要预留一个 discount_amount 字段,避免后续加需求时做数据库大迁移。

6.2 地图周边站点推荐功能

用户端目前是列表方式展示站点,体验上还有很大提升空间。接入地图 SDK 后,可以根据用户当前经纬度,按距离排序返回附近的充电站,并用地图打点展示空闲桩数量、功率类型、当前电价。后端需要增加一个按距离排序的接口,MySQL 8.0 支持空间数据类型,可以用 ST_Distance_Sphere 函数直接计算球面距离,性能也不错。

6.3 小程序端接口复用

Django 的视图函数天然支持返回 JSON 数据,只需要在视图函数里判断请求方式就能同时支持页面和小程序接口。建议下一步把核心接口抽出独立的 url 路径,加上/api/前缀,配合 Django REST Framework 做序列化和权限控制。前端页面的接口可以继续沿默认路径,小程序端走/api/,一套后端两端用,同时也为后续前后端分离打基础。

目前在充电桩上实现订单查询、余额充值等能力,用 Django REST Framework 的 ViewSet 和 Router 可以很快搞定,序列化器复用现有的模型定义,返回字段可以灵活裁剪,不必把多余的管理字段暴露给客户端。

6.4 设备接入协议与远程监控

当前系统和硬件设备的对接是模拟的,实际生产环境需要接入具体的充电桩通信协议。常见的对接方式是 OCPP(Open Charge Point Protocol),桩端通过 WebSocket 主动上报状态变化,服务端可以下发启动、停止指令。这样系统架构会新增一层消息中间层,但订单状态流转依然复用的是现有的底层逻辑,只需要把设备状态同步的逻辑替换成监听 OCPP 消息。

如果是中小型充电站,也可以采用更轻量的 MQTT 协议,每个充电桩作为一个主题发布者,后端订阅所有桩的状态消息,再用通道推送状态变化到用户端页面实时展示。

我个人测试下来,充电管理系统这类项目,核心复杂度从来不在技术本身,而在于业务状态的完整流转。把充电桩状态、订单状态、支付状态理清楚,这套系统就等于成功了一大半。最后再分享一个小技巧:开发这类业务系统,一定要从最开始就保留状态变更日志,哪怕只是简单地记录“谁在什么时间把哪个字段从 A 改成 B”。线上出问题的时候,这些日志就是最重要的排查线索。我自己在开发过程中就靠这个功能快速定位过一个异常充电订单——用户说没充过电,但订单显示完成,日志显示是运维人员用后台误操作的,几行日志就解释清楚了来龙去脉。这个习惯,比任何高级框架和花哨功能都值钱。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进