Python+Vue打造租赁管理系统:Django后端与前端实战解析
直接说结论如果你打算从零搭一套带管理后台的租赁业务系统Python Vue 这套组合是目前成本最低、效率最高的选择之一。这次我完整走了一遍摩托租赁管理系统的开发流程从后端框架选型、数据库建模到 Vue 管理页面的开发再到最后的联调上线把整个过程中的关键决策和踩过的坑都整理在下面。这篇内容既适合想拿 Python 做实战项目的学生也适合确实有租车业务管理需求、想自己动手做一套内部系统的朋友。1. 为什么我选了 Django 这套组合而不是 Flask 自建轮子标题里同时出现了 Django 和 Flask说明很多人在起步阶段确实会在这两个框架之间纠结。我先说结论租赁管理系统这类偏业务管理的项目优先用 DjangoFlask 更适合做轻量 API 服务或者前后端高度分离的小工具。1.1 管理系统类需求决定后端框架选型摩托租赁管理系统本质上是一个管理员管理车辆 用户创建订单 系统计算费用的业务系统。这类系统的共同特征是实体多、状态多、权限分明、需要后台管理界面。用一张表来对比两个框架在这个场景下的表现对比维度DjangoFlaskORM 模型内置迁移命令一键同步数据库需自行集成 SQLAlchemy后台管理自带 Admin 后台改改配置就有需用 Flask-Admin 或自己写用户认证内置 User 模型 权限系统需手动实现或扩展插件表单处理内置 Form/ModelForm 校验需用 WTForms 自行组装项目结构固定 app 划分规范统一灵活但需要自己规划学习成本概念多上手偏重轻量三五天能跑起来适合场景ERP、管理系统、内容平台API 服务、微服务、原型验证这个系统里摩托车、用户、订单、押金、租金这些实体之间有明确的关系Django 的 ORM 能让我们直接通过 Python 代码建模然后makemigrations和migrate自动生成数据库表。而 Flask 虽然也能做到但你需要自己拼装 SQLAlchemy 的 scoped_session、配置 alembic 做迁移还要手动设计蓝图目录结构——这些工作不是说不能做而是没必要在一个业务系统里重复造轮子。1.2 前后端分离的取舍有人会问Django 自带模板引擎为什么还要额外用 Vue原因在于租赁系统里的管理后台交互比普通展示页复杂车辆上架下架、订单状态流转待取车/租赁中/已完成、费用结算弹窗这些操作如果用 Django 模板配合 jQuery 来做页面会越来越难维护。而 Vue 的组件化开发方式天然适合这种页面局部更新 复杂表单交互的场景——车辆信息是一个独立组件订单列表是一个独立组件任何一个地方改了不会影响其他地方。我的最终技术栈是后端Python 3.10 Django 4.2 Django REST Framework django-cors-headers前端Vue 3 Vite Element Plus Axios Vue Router Pinia认证方案JWT通过 djangorestframework-simplejwt开发数据库SQLite部署时可切换 MySQL这套组合的好处是后端只负责提供 API前端只负责渲染界面两边通过 JSON 数据通信开发和调试互不干扰。下面我从数据模型开始逐步拆解。2. 数据模型设计与计费规则先把算钱逻辑想清楚做租赁管理系统最忌讳一上来就写页面。数据库建模阶段如果没把业务规则理清后期每个功能都会返工。摩托租赁的核心业务可以说是两条线车辆的状态流转和订单的计费流转。2.1 三张核心业务表怎么建模这个系统我拆成了四张核心表用户表、摩托车表、订单表、费用明细表。用户表直接扩展 Django 内置的AbstractUser不自己从零写因为内置模型已经带了密码哈希、权限分组、登录状态管理这些能力。摩托车表的字段设计如下字段类型说明brandCharField品牌如 雅马哈、铃木model_nameCharField车型名称plate_numberCharField车牌号唯一索引displacementIntegerField排量ccdaily_rentDecimalField日租金元depositDecimalField押金元statusCharField可租/已租/维修/下架cover_imageImageField车辆图片create_timeDateTimeField创建时间订单表是整个系统的核心字段比车辆表复杂得多关联用户ForeignKey(User)关联摩托车ForeignKey(Motorcycle)start_time预计取车时间expect_return_time预计归还时间actual_return_time实际归还时间可空total_fee订单总费用deposit_status押金状态未收/已收/已退status订单状态待取车/租赁中/已完成/已取消这里有三个关键设计决策我想特别说明。第一每张表的关键状态都用 CharField 而不是 BooleanField。比如车辆状态用status字段存字符串而不是设一个is_rented布尔字段。原因是车辆可能存在维修中已下架等中间状态布尔值只能表达两种状态硬用布尔值会导致后续加状态时改动数据库。订单状态同理pending、renting、completed、cancelled用字符串常量维护状态列表。第二押金单独拎出来管理。押金和租金是两条不同的资金流。顾客取车时交押金还车时如果车没有损坏押金全额退回如果有损坏从押金中扣除维修费。所以订单表里要同时有deposit字段押金金额和deposit_status字段押金状态它们在业务上并不总是一起变化。第三金额一律用 DecimalField 而不是 FloatField。这是很多初学者最容易踩的坑。Float 在计算机里是二进制浮点数0.1 0.2会得到0.30000000000000004。涉及到钱的计算必须用 Decimal并且在 Django 设置里配置好max_digits10, decimal_places2。2.2 计费规则与超时逻辑计费规则是整个系统里最容易产生分歧的地方需要在开发前就定死。我的规则是这样设计的按自然日计费租一天算一天取车的当天就算一天超时不足 4 小时按半天0.5 天计费超时超过 4 小时按一整天计费如果超过预计归还时间且订单仍未还车订单状态自动变为已超期但与租赁中共享同一个状态只是前端标记为红色。计算费用的核心代码逻辑如下from datetime import datetime, timedelta from decimal import Decimal def calculate_rental_fee(motorcycle, start_time, actual_return_time): 计算租赁费用 计费逻辑按自然日计费超时不足4小时按半天超过4小时按全天 daily_rent motorcycle.daily_rent half_day_rent daily_rent / Decimal(2) # 计算是否有跨天超时 rental_days (actual_return_time.date() - start_time.date()).days if rental_days 0: return daily_rent # 当天租当天还按一天算 # 计算最后一天的超时时间 last_day_return actual_return_time day_end datetime.combine(actual_return_time.date(), datetime.min.time()) timedelta(days1) # 检查是否超过次日归还时间 base_days rental_days # 计算超时小时数 # 设定正常每日归还时间为18:00 normal_return_time datetime.combine(actual_return_time.date(), datetime.strptime(18:00, %H:%M).time()) if actual_return_time normal_return_time: # 超时 over_hours (actual_return_time - normal_return_time).total_seconds() / 3600 if over_hours 4: # 不足4小时按半天 total base_days * daily_rent half_day_rent else: # 超过4小时按全天 total (base_days 1) * daily_rent else: total base_days * daily_rent return total这段代码的重点在于先算完整天数再单独处理最后一天的超时逻辑。如果没有把基础天数和超时部分拆开直接拿两个时间点相减再取整很容易出现租了 2.2 天但只收 2 天的钱或多收一天的钱这类账目问题。2.3 状态机的联动关系车辆状态和订单状态不是独立变化的它们之间有一条明确的状态流转链用户创建订单订单变为待取车车辆状态不变或标记为已预订用户实际取车订单变为租赁中车辆状态变为已租用户归还车辆系统计算费用订单变为已完成车辆状态变回可租取消订单订单变为已取消车辆状态恢复可租。这个联动逻辑必须写在后端接口里不能靠前端页面点击来控制否则多终端操作时数据会不一致。Django REST Framework 的action装饰器为这类操作提供了很自然的实现方式下面会详细讲。3. Django 后端 API 实现DRF 让 CRUD 变成配置数据模型设计好之后后端开发的速度会明显提上来。这一节讲我在实现 API 时的实际代码路径和关键配置包括环境版本、视图集、权限控制和 JWT 登录。3.1 项目初始化和环境版本先说版本Python 3.10 和 Django 4.2 是目前兼容性最稳的组合。Django 5.x 已经发布但是第三方生态尤其是一些老的图片处理库和部署工具还没完全跟上做项目优先选 LTS 版本。创建项目的步骤# 创建虚拟环境 python -m venv venv # 激活虚拟环境Windows venv\Scripts\activate # 安装依赖 pip install django4.2 pip install djangorestframework pip install djangorestframework-simplejwt pip install django-cors-headers pip install pillow # 处理车辆图片上传 # 创建项目和应用 django-admin startproject moto_rental cd moto_rental python manage.py startapp motos python manage.py startapp orders项目的 app 划分按照业务边界来车辆相关的功能车型管理、车辆状态放在motos订单相关创建订单、计费、还车放在orders。用户管理直接用 Django 自带的认证 app。3.2 用 DRF ModelViewSet 快速搭建车辆管理与订单接口车辆管理的 CRUD 接口我几乎没写任何重复代码完全靠 DRF 的ModelViewSet加上ModelSerializer完成。车辆序列化器from rest_framework import serializers from .models import Motorcycle class MotorcycleSerializer(serializers.ModelSerializer): class Meta: model Motorcycle fields __all__ extra_kwargs { cover_image: {required: False} }车辆视图集from rest_framework.viewsets import ModelViewSet from .models import Motorcycle from .serializers import MotorcycleSerializer from rest_framework.permissions import IsAuthenticatedOrReadOnly class MotorcycleViewSet(ModelViewSet): queryset Motorcycle.objects.all().order_by(-create_time) serializer_class MotorcycleSerializer permission_classes [IsAuthenticatedOrReadOnly]然后注册路由from rest_framework.routers import DefaultRouter router DefaultRouter() router.register(rmotos, MotorcycleViewSet)这样/api/motos/就有了一整套标准接口GET 列表、GET 详情、POST 创建、PUT 更新、DELETE 删除。IsAuthenticatedOrReadOnly权限意味着游客只能看登录用户才能操作。但这里有个问题需要特别注意ModelViewSet的写操作默认是任何人有权限的都能执行而车辆管理应该是管理员专属。所以我在项目里额外做了自定义权限类from rest_framework.permissions import BasePermission class IsAdminUser(BasePermission): def has_permission(self, request, view): return bool(request.user and request.user.is_staff) class MotorcycleViewSet(ModelViewSet): permission_classes [IsAuthenticatedOrReadOnly] def get_permissions(self): if self.action in [create, update, partial_update, destroy]: return [IsAdminUser()] return [IsAuthenticatedOrReadOnly()]这个写法的核心是self.action判断——DRF 的 action 取值是list、retrieve、create、update、partial_update、destroy分别对应不同 HTTP 方法。通过 action 判断可以精细控制每个接口的权限比在全局配置里一刀切灵活得多。3.3 自定义 action还车时如何计算费用并联动状态还车操作不是简单的更新订单字段它要同时做三件事计算总费用更新订单状态为已完成更新车辆状态为可租。这三个操作必须在一个事务里完成不能分开提交。我用 DRF 的action装饰器实现了一个return_moto接口from django.db import transaction from django.utils import timezone from rest_framework.decorators import action from rest_framework.response import Response from rest_framework import status class OrderViewSet(ModelViewSet): # 其他代码... action(detailTrue, methods[post]) def return_moto(self, request, pkNone): 还车操作计算费用并联动更新车辆状态 order self.get_object() # 只有租赁中的订单才能还车 if order.status ! renting: return Response( {detail: 只有租赁中的订单才能执行还车操作}, statusstatus.HTTP_400_BAD_REQUEST ) # 计算实际归还时间 order.actual_return_time timezone.now() with transaction.atomic(): # 计算租金 order.total_fee calculate_rental_fee( order.motorcycle, order.start_time, order.actual_return_time ) # 更新订单状态 order.status completed order.save() # 联动更新车辆状态 moto order.motorcycle moto.status available moto.save() return Response(OrderSerializer(order).data)用transaction.atomic()包裹的核心原因是如果费用计算成功了但车辆状态更新失败了数据会处于不一致状态——订单显示已完成但车辆还是已租这辆车就永远租不出去了。事务能保证要么两个操作都成功要么都回滚。前端调用还车操作时POST /api/orders/{id}/return_moto/即可。3.4 JWT 登录认证的配置细节前后端分离项目里Session 认证处理跨域问题会比较麻烦JWT 是更顺手的方案。用 simplejwt 的配置如下# settings.py from datetime import timedelta REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: ( rest_framework_simplejwt.authentication.JWTAuthentication, ), DEFAULT_PERMISSION_CLASSES: ( rest_framework.permissions.IsAuthenticated, ), } SIMPLE_JWT { ACCESS_TOKEN_LIFETIME: timedelta(hours2), REFRESH_TOKEN_LIFETIME: timedelta(days7), AUTH_HEADER_TYPES: (Bearer,), }路由方面用 simplejwt 自带的两个 TokenObtainPairView 即可from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns [ path(api/token/, TokenObtainPairView.as_view(), nametoken_obtain_pair), path(api/token/refresh/, TokenRefreshView.as_view(), nametoken_refresh), ]这样登录后前端拿着 access token 放在请求头Authorization: Bearer token里访问所有接口。access token 有效期我设置了 2 小时refresh token 7 天——刷 token 的频率不能太短否则用户一会儿就要重新登录也不能太长否则有安全风险。4. Vue 3 前端开发管理后台三个核心页面的落地后端 API 就绪后我就在前端把所有接口数据落地成页面。前端项目我用了 Vue 3 Vite Element Plus 的组合老读者可能更熟悉 Vue 2 Vue CLI但 Vue 3 的 Composition API 在这里明显更好用尤其是管理后台的表格筛选、弹窗表单这类交互。4.1 工程创建与依赖安装# 创建 Vue 3 项目 npm create vitelatest moto-admin -- --template vue cd moto-admin npm install npm install axios vue-router4 pinia element-plus需要注意一个细节Vue 3 需要 Element Plus 的对应版本不能装 Element UI那是 Vue 2 的。装 Element Plus 时建议顺便引入图标库element-plus/icons-vue页面里的编辑、删除按钮图标要用到。4.2 请求封装和登录态处理由于每个接口都需要带 token我把 axios 实例单独封装了一个模块。这里有一个很实用的技巧响应拦截器统一处理 401 错误——当 token 过期时自动跳转到登录页而不是让每个页面去单独判断。// src/utils/request.js import axios from axios import router from ../router import { useUserStore } from ../stores/user const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器注入 token request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) // 响应拦截器统一处理错误 request.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { const userStore useUserStore() userStore.logout() router.push(/login) } return Promise.reject(error) } ) export default request开发环境里baseURL: /api不是直接请求后端而是通过 Vite 的代理转发到 Django 服务。这样前端代码不需要写死后端地址以后部署环境变了只需要改代理配置不用动业务代码。4.3 车辆列表页与数据交互摩托车列表页我用了一个 Element Plus 的表格组件加上上架/下架状态的 tag 展示和编辑/删除/设为可租操作按钮。核心交互是页面加载时用 axios 请求GET /api/motos/拿车辆数据然后在onMounted里调用。script setup import { ref, onMounted } from vue import request from ../utils/request const motos ref([]) const loading ref(false) const fetchMotos async () { loading.value true try { const data await request.get(/motos/) motos.value data.results || data } finally { loading.value false } } onMounted(fetchMotos) /script这里有个需要留意的差异Django 的ModelViewSet在分页开启时GET 列表接口返回的是一个{ count, next, previous, results }结构而不是直接的数组。如果你的 settings 里没有配置DEFAULT_PAGINATION_CLASS返回的就是普通数组。我在前端统一用data.results || data做了兼容这样两种格式都能拿到真正的列表数据。车辆新增和编辑共用一个弹窗表单这也是管理后台最常见的模式。Element Plus 的el-dialogel-form组合配上表单校验规则el-dialog v-modeldialogVisible :titleform.id ? 编辑车辆 : 新增车辆 width500px el-form refformRef :modelform :rulesrules label-width80px el-form-item label品牌 propbrand el-input v-modelform.brand / /el-form-item el-form-item label车型 propmodel_name el-input v-modelform.model_name / /el-form-item el-form-item label排量 propdisplacement el-input-number v-modelform.displacement :min50 :step50 / /el-form-item el-form-item label日租金 propdaily_rent el-input-number v-modelform.daily_rent :min0 :precision2 / /el-form-item el-form-item label押金 propdeposit el-input-number v-modelform.deposit :min0 :precision2 / /el-form-item /el-form /el-dialog4.4 订单管理页状态过滤和还车操作订单管理页是所有页面里逻辑最重的因为订单数据多、状态多还车操作还涉及费用计算。我用一个el-tabs组件根据订单状态切换视图全部、待取车、租赁中、已完成、已取消。每个 tab 切换时重新请求后端接口带上?statusxxx查询参数。订单列表的每一行有一个还车按钮点击后弹确认框确认后调用POST /api/orders/{id}/return_moto/。还车成功后后端返回计算好的total_fee前端立即更新当前行数据并提示用户。还车确认弹窗里我会展示一个信息提示车辆将变更为可租订单状态将变更为已完成系统将根据实际归还时间自动计算费用。这个提示很重要因为很多新手会误以为还车操作只是填个日期实际上后端已经自动做了费用结算。5. 前后端联调与部署上线的坑开发完毕并不代表工作结束联调和部署阶段通常才是真正耗时间的环节。我把自己遇到的最典型的几个问题放在这一节都是拿时间换来的经验。5.1 跨域问题的开发期解法前后端分离开发的第一个阻碍就是跨域。后端跑在http://localhost:8000前端跑在http://localhost:5173端口不同就构成跨域。开发阶段最优雅的解决方案是在 Vite 里配置代理让浏览器以为前端的/api请求是同源的// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8000, changeOrigin: true } } } })这样前端代码里所有/api/xxx请求都会由 Vite 开发服务器转发到后端的 8000 端口不需要在 Django 里配置django-cors-headers也能正常工作。但生产环境部署时前端构建后的静态文件由 nginx 托管nginx 再把/api/路径反向代理到后端服务。这个配置同样解决了跨域问题前后端共用一个域名和端口没有任何 CORS 头需要用。5.2 时区与日期序列化最容易算错账的地方Django 默认USE_TZ True数据库里存的是 UTC 时间前端展示的时候如果不做转换所有时间都会差 8 个小时。因为摩托租赁的计费是按天计算的如果时区没处理好一个晚上 23 点还车可能被算成第二天的 7 点直接导致多算一天租金。我的处理方式是在 Django 的 settings 里明确配置TIME_ZONE Asia/Shanghai USE_TZ TrueUSE_TZ True意味着 Django 内部使用 UTC 时间但当你调用timezone.now()时它会自动使用TIME_ZONE指定的时区。API 返回的时间是带时区信息的 ISO 格式字符串前端拿到后直接用new Date(value)解析浏览器会自动转换为本地时间这样展示上就不会有 8 小时的偏差。有个细节需要注意下单和还车时间必须用timezone.now()而不是 Python 原生的datetime.now()。原生的datetime.now()返回的是操作系统本地时间在USE_TZ True时存入数据库会被 Django 认为是 UTC 时间导致读出来时 8 小时的误差。5.3 nginx waitress 部署后端部署方面我选用了 waitressWindows 环境或 gunicornLinux 环境作为 WSGI 服务器。Django 自带的runserver只能用于开发直接暴露到公网会有性能和安全性问题。Linux 服务器上的部署流程# 安装 gunicorn pip install gunicorn # 收集静态文件 python manage.py collectstatic --noinput # 启动 gunicorn4 个 worker 进程 gunicorn moto_rental.wsgi:application --bind 127.0.0.1:8000 --workers 4nginx 配置server { listen 80; server_name your_domain.com; # 前端打包后的静态文件 root /var/www/moto-admin/dist; index index.html; # 前端路由 location / { try_files $uri $uri/ /index.html; } # 后端 API 反向代理 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; } # 后端静态文件admin 后台和上传的图片 location /static/ { alias /path/to/moto_rental/static/; } }nginx 配置里有两处容易踩坑一是location /里必须有try_files $uri $uri/ /index.html;这样才能配合 Vue Router 的 history 模式否则刷新页面会 404二是proxy_set_header Host $host;这行必须有Django 依赖 Host 头来生成绝对链接不设置可能引发CSRF或ALLOWED_HOSTS相关的问题。5.4 测试阶段暴露的几个典型问题给系统做完整测试时我整理了一组高频问题这些问题不是我没处理好才出现的而是这一类系统普遍会遇到的问题原因解决方案删除车辆时订单数据被连带删除ForeignKey 默认on_deleteCASCADE订单表的外键改为on_deletePROTECT同一辆车被重复下单创建订单时没检查车辆状态下单接口里用select_for_update()锁行数据库里时间显示 8 小时误差原生datetime.now()与USE_TZ不兼容统一使用django.utils.timezone.now()上传图片后页面不显示Django 开发环境没配置媒体文件路由urlpatterns static(settings.MEDIA_URL, document_rootsettings.MEDIA_ROOT)前端收不到后端错误信息DRF 默认返回英文结构前端在响应拦截器里读取error.response.data.detail其中车辆重复下单的问题我要特别展开讲一下。如果两个用户同时发起对同一辆车的租车请求后端代码如果只是先查询车辆状态再创建订单理论上高并发下可能出现查的时候是可租、创建订单时已被别人租走的情况。解决方法是使用数据库行锁from django.db import transaction class OrderViewSet(ModelViewSet): action(detailFalse, methods[post]) def create_order(self, request): moto_id request.data.get(moto_id) with transaction.atomic(): # 锁定这辆车直到事务结束 moto Motorcycle.objects.select_for_update().get(pkmoto_id) if moto.status ! available: return Response({detail: 车辆已被租出或不可用}, status400) # 创建订单、更新车辆状态... moto.status rented moto.save() return Response(order_data, status201)select_for_update()是 PostgreSQL 和 MySQL 都支持的语法。SQLite 环境下虽然没有真正意义上的行锁但 Django 会模拟一个全局写锁也能避免并发问题。6. 从这套系统还能怎么扩展虽然摩托租赁管理系统本身已经可以完整运作了但我开发完后又复盘了整个架构觉得有四个扩展方向是真实业务场景中一定会用到的多门店支持给摩托车型号表加一个门店外键订单关联门店系统就能支撑连锁租赁业务线上支付对接微信支付/支付宝当面付接口在订单创建和还车结算时插入支付回调逻辑车辆轨迹记录给每辆摩托加一个 GPS 定位设备前端展示车辆位置和历史轨迹这个方向对车辆防盗和资产安全很有价值催还提醒通过 Celery 定时任务扫描超过预计归还时间未还车的订单自动推送提醒短信。考虑到车辆 GPS 轨迹如果真正接入实时定位数据会大幅增加数据库层面可以引入时序数据库或者退一步讲用 Redis 存最近位置、MySQL 存历史轨迹根据查询维度选择存储方案。技术选型方面当前这套 Django Vue 的结构在功能迭代上仍然有很大空间新增门店模型和支付模型不需要改变现有架构整体演进性是够用的。最后再分享一个我在实际开发中经常用到的习惯系统上线之前一定要写一个手动模拟用户全流程的测试脚本——注册账号、浏览车辆、下单、还车、查看订单全部走一遍确认没有遗漏的状态流转。做这类管理系统最大的难点往往不在技术而在业务规则本身想没想清楚。数据模型的设计层如果足够扎实后面无非是接口和页面的堆叠不会出现推到重来的局面。希望这篇拆解能帮你把这条路径走得更顺。