简介:这是一个基于Django框架实现的共享单车后台管理系统源码包,适合正在学习Python Web开发、或准备做课程设计与毕业设计的开发者参考;项目使用pymysql连接MySQL数据库,围绕用户管理、单车管理、订单记录和统计分析等业务展开,能直观展示Django的MTV架构和实际开发流程。资源共64个文件,约207KB,主要包含Python源码、HTML模板、CSS/JS前端文件、XML配置文件、SQL数据库转储及SQLite数据库,其中16个py文件对应模型、视图、路由等核心逻辑,14个pyc为编译缓存,10个html构成后台管理页面,目录按Django项目规范划分,便于按模块阅读和二次开发。目前已有525人学习下载;除了完整可运行的Django工程外,附带的bicycle_management.sql脚本可直接导入MySQL完成建表与初始数据填充,同时后台页面模板展示了表单提交、列表展示、登录鉴权等前后端交互方式,能帮助理解Django项目从模型设计到页面渲染的完整链路。
1. 共享单车后台管理系统为什么值得用django重写一版
线下运营手里攥着一大堆Excel,车辆分布、故障上报、骑行订单各对不上账,这是共享单车后台管理系统最典型的立项场景;另一类场景是车辆与车锁接口已经能上报数据,缺一个给运营、客服、财务共同使用的管理后台。django自带ORM和迁移,admin界面和权限框架都是现成的,覆盖共享单车后台的车辆档案、订单结算、计费规则、运维工单绰绰有余。用django做这个后台,不是因为它擅长写炫酷的前端,而是因为admin加自定义视图能在三天内把可用的版本跑起来,后续再按需做前后端分离。适合django熟、以管理端为主、上线时间紧的团队;C端小程序需要实时骑行轨迹的话,接口单独设计,别和后台塞同一个工程。
2. django模型设计:车辆、订单、计费规则怎么落库
先在环境里装好django与数据库驱动,常规操作是pip install django mysqlclient,然后创建bikes应用,因为车辆、订单、费率这些核心表都从车辆上下文出发:
python manage.py startapp bikesbikes会生成models.py、admin.py等骨架文件。这里注意startapp只是生成目录,真正的模型、迁移、注册admin都要自己补,下面逐张表拆。
2.1 车辆状态与车锁编号:用choices锁死状态集合
共享单车后台管理系统里,车辆是最核心的资源。车辆表不只要存车牌号和GPS坐标,更要存锁的状态。锁的状态决定了用户能不能借,也决定了后台能不能远程下发指令。先看bikes/models.py里的Bike模型:
from django.db import models class Bike(models.Model): STATUS_AVAILABLE = 'available' STATUS_IN_USE = 'in_use' STATUS_MAINTENANCE = 'maintenance' STATUS_LOST = 'lost' STATUS_CHOICES = [ (STATUS_AVAILABLE, '可借'), (STATUS_IN_USE, '骑行中'), (STATUS_MAINTENANCE, '维修中'), (STATUS_LOST, '丢失'), ] bike_no = models.CharField('车辆编号', max_length=32, unique=True) lock_no = models.CharField('车锁编号', max_length=32, unique=True) region = models.CharField('辖区', max_length=32, default='北京') status = models.CharField('状态', max_length=20, choices=STATUS_CHOICES, default=STATUS_AVAILABLE) longitude = models.DecimalField('经度', max_digits=9, decimal_places=6, null=True, blank=True) latitude = models.DecimalField('纬度', max_digits=9, decimal_places=6, null=True, blank=True) battery = models.IntegerField('电量%', default=100) updated_at = models.DateTimeField('最后上报时间', auto_now=True) def __str__(self): return f'{self.bike_no}({self.status})'bike_no和lock_no都加了unique=True。车辆编号对用户可见,车锁编号对硬件平台可见,两套编号在对接车锁厂商时经常对不上,如果数据库不约束唯一,排查问题时会多出很多无效沟通。status用choices约束后,admin下拉框只有四种状态可填,业务代码里也只引用Bike.STATUS_IN_USE这样的常量,少一层魔法字符串。region字段先当字符串用,后面有跨城运营再拆成城市表。
四种状态的含义直接决定业务流程,列一下:
| 状态值 | 中文 | 业务含义 |
|---|---|---|
| available | 可借 | 空闲,用户扫码可以开锁 |
| in_use | 骑行中 | 已被订单占用,不允许再借 |
| maintenance | 维修中 | 运维提交工单后置位 |
| lost | 丢失 | 超时未还或运维上报找回 |
把bikes应用加进INSTALLED_APPS并迁移:
python manage.py makemigrations bikes python manage.py migrate迁移文件会记录choices、unique这些约束,执行migrate前看一眼生成的迁移文件,确认新增字段和索引没有误伤线上数据。新手常犯的错是改了模型就一路yes,结果生产环境里加了非空字段却没有默认值,迁移直接中断。
2.2 订单表与计费规则:django里存单价还是存规则
订单表要回答三个问题:谁借的、哪辆车、花了多少钱。骑行的费用不是单价乘时长那么简单,共享单车行业常见起步价、免费时长、超出单价、每日封顶。把单价直接存在订单表里,计费规则一变,老订单就成了历史账单,财务对账时没法解释。常见做法是订单表存金额快照,另建一张费率表维护规则版本:
class RateRule(models.Model): name = models.CharField('规则名', max_length=64) start_time = models.DateTimeField('生效时间') end_time = models.DateTimeField('失效时间', null=True, blank=True) base_fee = models.DecimalField('起步价', max_digits=6, decimal_places=2) base_minutes = models.IntegerField('起步时长(分钟)') extra_fee_per_minute = models.DecimalField('超出后每分钟单价', max_digits=6, decimal_places=2) daily_cap = models.DecimalField('每日封顶', max_digits=6, decimal_places=2, null=True, blank=True) def is_active(self, dt): return self.start_time <= dt and ( self.end_time is None or dt <= self.end_time )订单表只存费率规则的外键和计费结果:
from django.conf import settings class RideOrder(models.Model): STATUS_UNPAID = 'unpaid' STATUS_PAID = 'paid' STATUS_EXCEPTION = 'exception' order_no = models.CharField('订单号', max_length=32, unique=True) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) bike = models.ForeignKey(Bike, on_delete=models.PROTECT) rate_rule = models.ForeignKey(RateRule, on_delete=models.PROTECT, null=True, blank=True) start_time = models.DateTimeField('借车时间') end_time = models.DateTimeField('还车时间', null=True, blank=True) fee = models.DecimalField('应收金额', max_digits=8, decimal_places=2, default=0) status = models.CharField('状态', max_length=20, choices=[ (STATUS_UNPAID, '待支付'), (STATUS_PAID, '已支付'), (STATUS_EXCEPTION, '异常'), ], default=STATUS_UNPAID)两个外键都用了on_delete=models.PROTECT。车辆或用户被删除时,只要还有关联订单,删除就会抛ProtectedError,强迫你先处理历史订单的归档,而不是让账单悬空。rate_rule在订单结算那一刻被引用,算出fee后写入订单,之后改费率规则不影响历史订单对账。is_active方法按生效时间判断规则可用,适合运营后台管理系统里「现在生效的是哪条规则」这种高频查询。
2.3 数据导入与初始化:用管理命令而不是loaddata
初始化几百辆车的台账,很多人第一反应是django fixtures(loaddata),但车辆数据隔三差五要批量更新,JSON文件维护起来不如CSV顺手。常见做法是写一个management command,直接读CSV,后续导入运维排班表、导出台账都能复用:
mkdir -p bikes/management/commands touch bikes/management/commands/__init__.py touch bikes/management/commands/import_bikes.py在import_bikes.py里:
import csv from django.core.management.base import BaseCommand from bikes.models import Bike class Command(BaseCommand): help = '从CSV导入车辆' def add_arguments(self, parser): parser.add_argument('csv_path') def handle(self, *args, **options): with open(options['csv_path'], encoding='utf-8-sig') as f: for row in csv.DictReader(f): Bike.objects.update_or_create( bike_no=row['bike_no'], defaults={ 'lock_no': row['lock_no'], 'region': row['region'], 'longitude': row['longitude'], 'latitude': row['latitude'], } ) self.stdout.write(self.style.SUCCESS('车辆导入完成'))执行:
python manage.py import_bikes bikes_2025.csv这里核心是encoding='utf-8-sig',兼容Excel导出CSV时常见的BOM头,不然第一行字段名会变成bike_no前面带不可见字符。update_or_create的查找键是bike_no,同一份CSV重复执行不会产生重复车辆,适合每天夜间同步一次。管理命令里可以继续加--dry-run参数,先打印要变更的行数,确认无误再真正写库。
3. 后台管理系统的权限与admin定制
3.1 自定义用户模型:运营、客服、财务的角色区分
项目从零开始就换掉django默认的auth.User,这是共享单车后台管理系统最值得做的决定。默认的is_staff和is_superuser只能区分「能不能进后台」和「是不是超管」,表达不了运营、客服、财务之间细粒度的权限差异。运营要能查订单但不能改金额,客服要能远程开关锁但不能导入车辆,财务要能看账单和退款,但不能动车辆档案。在第一次migrate前创建accounts应用并定义用户模型:
python manage.py startapp accountsaccounts/models.py:
from django.contrib.auth.models import AbstractUser from django.db import models class Role(models.TextChoices): OPERATOR = 'operator', '运营' SUPPORT = 'support', '客服' FINANCE = 'finance', '财务' ADMIN = 'admin', '管理员' class User(AbstractUser): role = models.CharField('角色', max_length=20, choices=Role.choices, default=Role.OPERATOR) phone = models.CharField('手机号', max_length=20, unique=True) region = models.CharField('负责辖区', max_length=32, blank=True)改settings.py:
AUTH_USER_MODEL = 'accounts.User'这个切换必须在第一次migrate之前完成。项目一旦执行过初始迁移,默认auth_user表已经存在,再改AUTH_USER_MODEL要清理相关迁移和表,代价很大。如果线上项目已经跑了大半年,不要硬改,用OneToOne扩展Profile表更稳妥。roles只给了四个,后面加「调度员」这类新角色,直接在TextChoices里加一项,再配admin的Group权限。
角色与权限的分配,落到django admin上要分清两个层级:模型级权限用admin自带的add/change/delete/view,行级数据范围靠重写get_queryset实现。参考下表:
| 角色 | 数据范围 | 可执行动作 | 禁止动作 |
|---|---|---|---|
| 运营 | 本辖区车辆、订单 | 导入车辆、编辑车辆状态 | 退款、改价 |
| 客服 | 全部用户、订单 | 查询订单、登记工单 | 修改金额 |
| 财务 | 全部订单、账单 | 导出报表、发起退款 | 车辆档案操作 |
| 管理员 | 全部 | 一切 | 无 |
3.2 重写admin视图:让运营只看自己辖区的车辆
自定义用户模型只是第一步。django admin变成运营真正愿意用的后台管理系统,关键在get_queryset和save_model。先给Bike注册admin:
from django.contrib import admin from bikes.models import Bike @admin.register(Bike) class BikeAdmin(admin.ModelAdmin): list_display = ['bike_no', 'lock_no', 'region', 'status', 'battery', 'updated_at'] list_filter = ['status', 'region'] search_fields = ['bike_no', 'lock_no'] def get_queryset(self, request): qs = super().get_queryset(request) if request.user.is_superuser: return qs return qs.filter(region=request.user.region) def save_model(self, request, obj, form, change): if not request.user.is_superuser: obj.region = request.user.region super().save_model(request, obj, form, change)get_queryset同时作用于列表、批量操作和删除,运营永远只能看到本辖区的车。save_model的补充是防止运营手动创建车辆时把region选到别处,导致自己创建的记录自己看不到。这里的边界是:如果运营需要跨区调配车辆,就不能用save_model强制覆盖region,而应该走一个专门的调拨action,并在调拨里记录操作人和时间。
admin还可以提供批量action,比如把选中车辆集体标记为维修:
@admin.action(description='批量标记为维修') def mark_maintenance(modeladmin, request, queryset): queryset.update(status=Bike.STATUS_MAINTENANCE) actions = [mark_maintenance]queryset.update不会调用save,也就不会触发save_model的region覆盖逻辑,适合做批量状态流转。批量操作对审计有要求,所以在3.3节还要补一层通用日志。
3.3 操作审计:记录谁在后台管理系统改了什么
共享单车后台被多个角色共用,出问题时要能回答「谁在什么时候改了什么」。django admin自带的LogEntry只记录管理后台的增删改,记录颗粒度是模型对象,不区分具体字段,运营和客服都改状态时看不出来差异。常见做法是单独建审计表:
class AdminLog(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) model_name = models.CharField('模型', max_length=64) object_id = models.CharField('对象ID', max_length=64) action = models.CharField('动作', max_length=16) detail = models.JSONField('变更详情', default=dict) created_at = models.DateTimeField('时间', auto_now_add=True)再把这个表挂进admin的变更流程。ModelAdmin自带log_change方法,覆盖它,把所有变更都落一份到AdminLog:
class AuditMixin: def log_change(self, request, obj, message): AdminLog.objects.create( user=request.user, model_name=obj._meta.label, object_id=str(obj.pk), action='change', detail={'message': message}, ) super().log_change(request, obj, message) class BikeAdmin(AuditMixin, admin.ModelAdmin): ...detail用JSONField可以记录前后值,比如status从maintenance改成available,审计里就能看到操作人和时间。前后值对比不能依赖obj的当前值,要在save_model里把form.initial和form.cleaned_data做diff,再传给log_change。注意AuditMixin会影响所有继承它的ModelAdmin,不要把敏感字段直接塞进message,日志表本身也要按天归档。
admin界面美化不是这一章的重点,但有一个性价比很高的动作:改站点头和site title。运营每天打开后台管理系统,看到的是django默认「Django administration」,观感上不像给自家业务做的系统:
admin.site.site_header = '共享单车运营后台' admin.site.site_title = '单车后台' admin.site.index_title = '车辆与订单管理'4. 借还车与计费:共享单车后台的核心业务实现
4.1 借车接口的状态校验与原子更新
用户扫码借车时,后台管理系统要做的事是:根据车锁上报的状态判断车能不能借,然后调车锁接口开锁,同时生成一条待支付订单,并把车辆置为骑行中。这个过程最容易出并发问题:同一辆车,两个请求同时查到status=available,都往下走,最后两笔订单对应同一辆车,车锁开了两次。django ORM的get或filter都不带锁,需要用select_for_update:
import uuid from django.db import transaction from django.db.models import Q from django.utils import timezone from bikes.models import Bike, RideOrder, RateRule class BikeNotAvailable(Exception): pass def gen_order_no(): return 'B' + timezone.now().strftime('%Y%m%d%H%M%S') + uuid.uuid4().hex[:6] @transaction.atomic def borrow_bike(bike_id, user): bike = Bike.objects.select_for_update().filter( id=bike_id, status=Bike.STATUS_AVAILABLE ).first() if bike is None: raise BikeNotAvailable('车辆不可用或已被借走') now = timezone.now() rule = RateRule.objects.filter( start_time__lte=now, ).filter( Q(end_time__isnull=True) | Q(end_time__gte=now) ).order_by('-start_time').first() ride = RideOrder.objects.create( order_no=gen_order_no(), user=user, bike=bike, rate_rule=rule, start_time=now, status=RideOrder.STATUS_UNPAID, ) updated = Bike.objects.filter( id=bike_id, status=Bike.STATUS_AVAILABLE ).update(status=Bike.STATUS_IN_USE) if updated != 1: raise BikeNotAvailable('车辆状态已变化') return rideselect_for_update在事务内锁住这一行,第二个请求会阻塞而不是读到旧状态。update语句把filter条件再带上status=available,这是双保险:一旦锁没有生效,update受影响行数也能暴露竞态。异常会让整个事务回滚,订单不会留下半截脏数据。注意select_for_update必须包在transaction.atomic里,django会直接抛TransactionManagementError。
这个接口要不要做成django REST framework的ViewSet,取决于前端是谁。后台管理系统里远程开锁通常是给客服用的,从admin后台点一个按钮更顺手;如果未来要做小程序扫码借车,再把borrow_bike包一层API。业务逻辑独立成函数,不要直接写在View里,方便Django的manage.py shell直接调用测试。
4.2 还车计费:用策略模式处理起步价、超出单价与封顶
还车时后台管理系统要做三件事:校验订单状态、确认车锁已落锁、计算费用。计费逻辑最忌讳用if-else堆,起步价、超出单价、每日封顶是个组合,规则一多,视图函数就变成一坨。把计算抽成独立函数,只依赖RateRule和骑行分钟数,不读数据库,不碰request:
from decimal import Decimal def calc_fee(rule, minutes): minutes = int(minutes) if minutes <= rule.base_minutes: fee = Decimal(rule.base_fee) else: extra = minutes - rule.base_minutes fee = Decimal(rule.base_fee) + Decimal(rule.extra_fee_per_minute) * extra if rule.daily_cap is not None and fee > Decimal(rule.daily_cap): fee = Decimal(rule.daily_cap) return fee.quantize(Decimal('0.01'))几个典型费率放在一起,便于后面写单测:
| 规则名 | 起步价 | 起步时长 | 超出单价 | 每日封顶 |
|---|---|---|---|---|
| 普通车 | 1.50元 | 15分钟 | 0.10元/分钟 | 20.00元 |
| 节假日活动 | 1.00元 | 10分钟 | 0.08元/分钟 | 15.00元 |
| 学生优惠 | 0.50元 | 30分钟 | 0.06元/分钟 | 10.00元 |
骑行15分钟内只要1.5元,第16分钟开始按分钟计费,超过20元按20元收。边界情况是:minutes等于base_minutes,走起步价分支;extra为0时不会额外计费;daily_cap为空时不做封顶。把这个函数放在bikes/services.py里,用pytest或unittest直接构造RateRule对象测,不需要创建数据库记录。
还车动作的数据库操作在事务里完成:
@transaction.atomic def finish_ride(ride_id): ride = RideOrder.objects.select_for_update().filter( id=ride_id, status=RideOrder.STATUS_UNPAID ).first() if ride is None: raise ValueError('订单不存在或已结算') ride.end_time = timezone.now() minutes = (ride.end_time - ride.start_time).total_seconds() / 60 ride.fee = calc_fee(ride.rate_rule, minutes) ride.status = RideOrder.STATUS_PAID ride.save(update_fields=['end_time', 'fee', 'status']) Bike.objects.filter(pk=ride.bike_id).update(status=Bike.STATUS_AVAILABLE)select_for_update锁的是订单行,防止同一笔订单被两次还车请求重复结算。update_fields限定只写end_time、fee、status三个字段,避免把start_time等字段在并发下覆盖。车辆状态用update而不是save,是因为这里不关心车辆的其他字段,直接条件更新更干净。如果运营要求「还车后车辆必须立刻出现在可借列表」,这个顺序是对的:先结算订单,再放车。
4.3 超时未还车的清理任务:django命令加定时调度
骑行超过24小时没还车,正常流程是系统自动标记异常,并把车辆置为丢失,转入人工排查。这个任务不适合在请求线程里跑,应该做成management command,由定时任务调度:
mkdir -p bikes/management/commands touch bikes/management/commands/__init__.py touch bikes/management/commands/handle_stale_rides.pyfrom django.core.management.base import BaseCommand from django.utils import timezone from bikes.models import Bike, RideOrder class Command(BaseCommand): help = '标记超时未还的订单和车辆' def handle(self, *args, **options): cutoff = timezone.now() - timezone.timedelta(hours=24) stale_rides = RideOrder.objects.filter( status=RideOrder.STATUS_UNPAID, start_time__lt=cutoff, ).select_related('bike') for ride in stale_rides: ride.status = RideOrder.STATUS_EXCEPTION ride.save(update_fields=['status']) Bike.objects.filter(pk=ride.bike_id).update( status=Bike.STATUS_LOST, updated_at=timezone.now(), ) self.stdout.write(self.style.SUCCESS(f'处理 {stale_rides.count()} 条超时订单'))这个命令幂等吗?由于filter限定status=RideOrder.STATUS_UNPAID,已经标记过exception的订单不会再次处理。注意count()会额外执行一次查询,如果要看实际处理数量,用列表收集ride对象再计数更准确。定时调度用系统crontab最省事:
*/30 * * * * cd /opt/bike_admin && venv/bin/python manage.py handle_stale_rides >> logs/stale.log 2>&1每半小时跑一次。如果公司内部有通用任务平台,把这个命令包装成shell调用同样能接。共享单车后台管理系统里,订单增长很快,历史数据不要物理删除,用状态标记再加归档任务,这也是后台管理系统处理「删除对象」的通用做法。
5. 后台管理系统的搜索、Excel导出与部署排错
5.1 让订单搜索不卡:覆盖admin搜索与查询优化
订单量到百万级后,直接在admin的search_fields里同时放order_no、user_name、bike_no多个字段,页面响应会明显变慢。django的search_fields对CharField默认生成带前后通配符的LIKE查询,索引用不上。订单号短且唯一,保留在search_fields里;用户名、手机号这类字段交给list_filter,再加时间范围筛选。给订单表建复合索引:
class Meta: indexes = [ models.Index(fields=['status', 'start_time'], name='idx_status_start'), models.Index(fields=['order_no'], name='idx_order_no'), ]5.2 用openpyxl生成订单报表
运营要的「后台管理系统在线预览excel表格」,本质是让列表能筛、能导出、能打开。django admin里加一个导出action,用openpyxl直接生成xlsx:
from openpyxl import Workbook from django.http import HttpResponse def export_orders(modeladmin, request, queryset): wb = Workbook() ws = wb.active ws.title = '订单' ws.append(['订单号', '车辆编号', '用户', '借车时间', '还车时间', '金额']) for order in queryset.select_related('bike', 'user').iterator(chunk_size=2000): ws.append([ order.order_no, order.bike.bike_no, order.user.username, order.start_time.strftime('%Y-%m-%d %H:%M:%S'), order.end_time.strftime('%Y-%m-%d %H:%M:%S') if order.end_time else '', str(order.fee), ]) resp = HttpResponse( content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) resp['Content-Disposition'] = 'attachment; filename="orders.xlsx"' wb.save(resp) return resp export_orders.short_description = '导出选中订单为Excel'注册的时候在ModelAdmin里加actions = [export_orders]。大导出用iterator分批取,避免一次查几万行把内存打满。如果运营还想在页面上直接预览,就把导出的xlsx放到nginx的静态目录,再加一个签名下载地址,但先保证导出本身的数据准确,预览只是展示层的事。
5.3 部署前要检查的三个配置
用宝塔或systemd跑django后台管理系统,上线前查三处:STATIC_ROOT没配,admin样式全丢;ALLOWED_HOSTS漏了域名,访问直接400;DEBUG开着,运营点到异常页能看到堆栈和路径信息。部署时顺序是:
pip install gunicorn python manage.py collectstatic --noinput gunicorn bike_admin.wsgi:application -w 4 -b 0.0.0.0:8080nginx里把/static/代理到STATIC_ROOT。gunicorn的worker数不要照抄推荐值,后台管理系统有Excel导出这类高内存操作,先压一遍再定。最后做一次数据自检,确认车辆状态和订单没有脏数据:
python manage.py shell -c "from bikes.models import Bike; print(Bike.objects.filter(status='in_use', rideorder__isnull=True).count())"输出为0再发布。这条查询找的是「骑行中但没有订单」的车辆,只要有,查一下是不是哪个借车或还车流程少了一笔事务提交。之后的每次发布,把迁移执行、静态文件收集、缓存清理三步串进发布脚本,这个自检命令也放进同一段脚本里,人工少一步,漏配置的概率就小一步。
本文还有配套的精品资源,点击获取