news 2026/9/10 1:20:47

基于Django+Vue3的校园租房系统全栈开发实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Django+Vue3的校园租房系统全栈开发实战

做这个Python + Vue3的校园租房系统,前前后后花了差不多三周时间。中途踩了不少坑,也推翻过几次方案,最后落地的一套前后端分离架构,我觉得挺有代表性的——既有校园业务场景的典型功能,又把Django后端和Vue3前端的关键技术点都串起来了。这篇文章就把我整个实现过程、技术选型思路和避坑记录完整写出来,希望能帮到正在做类似毕设或项目练手的朋友。

先说下这套系统最终长什么样:学生用户(租客)可以浏览房源、按校区或价格筛选、收藏房源、在线发起租房订单;房东可以是个人也可以是校内宿管或周边房东,能发布房源、管理房源上下架、处理租客订单;管理员在后端可以审核房源、管理用户和统计平台数据。技术栈上,后端用的是Python 3.10 + Django 4 + Django REST Framework + MySQL,认证方案用的JWT(simplejwt);前端用Vue 3.2 + Vite + Pinia + Vue Router 4 + Ant Design Vue 3.x。整套系统完全前后端分离,开发时通过Vite代理联调,部署时用Nginx托管前端并反向代理后端接口。

如果你是刚开始接触前后端分离项目,或者正在准备自己的课程设计、毕业设计,这篇文章可以当一份完整参考。下面按我的实际开发顺序来梳理,从整体思路、后端实现、前端实现、接口联调到最后的高频报错排查,一步一步说清楚。

1. 项目定位与整体设计思路

1.1 校园租房场景有什么特殊需求

校园租房和市面上通用的租房平台最大的区别在于“人群固定、周期短、信任成本高”。学生租房的核心场景无非这几种:考研复习需要在校区附近短租几个月、毕业实习需要租半年、寒暑假留校备考或者外地同学来学校交流需要临时住宿。这些场景决定了系统不能像贝壳、自如那样做长租逻辑,而是需要能表达“短租周期”、“按床位出租”、“距离校区距离”这类校园特有要素。

我在设计的时候把用户分为租客和房东两类,还允许同一账号在两类身份之间切换——因为在校园场景里,很多房东本身也是教职工甚至高年级学生,他们既可能出租房源,也可能出去租房。这个身份切换的需求一开始没做,是在画原型的时候和一个做宿管的老师聊过之后才决定必须支持,否则后期数据模型改起来非常痛苦。

另外,校园租房的信任机制很重要。小程序、微信群里的租房信息最大问题是真假难辨,所以系统里必须要有“用户认证”概念,比如学生认证、教职工认证。虽然最终我实现的是简版(后台手动审核),但在表设计上给后续接入学生证上传、学号验证预留了字段。这一点建议大家在设计阶段就想清楚,不然后面加字段做数据迁移虽然不复杂,但如果有线上数据就麻烦了。

1.2 为什么选Python + Vue3而不是其他组合

后端选Python而不是Java或者Node,原因有三点。第一,Django带Admin后台,做一个校园级别的管理系统,管理员页面几乎不用额外开发,Django Admin改改配置就能管理用户、审核房源,这个效率是Spring Boot没法比的。第二,Django的ORM写起来比MyBatis直观太多,尤其适合一个人开发全栈项目,可以在很短时间内把数据模型建好。第三,Python的生态里做爬虫、数据分析都很方便,如果后期想抓取周边房源数据做价格分析,或者对订单数据做统计报表,直接写Python脚本就行,不需要跨语言。

前端选Vue3而不用React,最主要的原因是Vue的上手曲线更平缓,模板语法对从没接触过前端框架的人更友好。而选Vue3而不是Vue2,则是考虑到Pinia比Vuex更简洁、Composition API对组件逻辑复用更友好,而且Vite的构建速度比webpack快一个量级,开发体验好很多。组件库方面,我用了Ant Design Vue而不是Element Plus,纯粹是个人偏好,Ant Design的表单校验和表格组件在管理端场景更好用,两个库其实都能胜任,不必纠结。

这套组合的另一个优势是社区资料极其丰富。Django的DRF教程、Vue3的教学视频、前后端分离的实战案例,随便一搜一大把,遇到问题很容易找到解决方案。对于新手来说,基本不太需要啃源码,靠搜索就能解决百分之八十的问题。

1.3 系统功能模块如何划分

我把整个系统拆成租客端、房东端和管理后台三个视角来设计功能,而不是直接按传统的前台、后台划分。这样做的原因是:租客端和房东端虽然共用前端工程,但功能入口、业务逻辑完全不同,如果混在一起写,代码会非常杂乱。

租客端核心模块包括:房源浏览与搜索(按校区、价格区间、面积、户型筛选)、房源详情(多图轮播、房东信息、收藏按钮)、在线下单(选择租期、提交订单)、订单管理(待支付、待入住、已入住、已退租)、个人中心(基本信息、我的收藏)。房东端核心模块包括:房源管理(发布房源、编辑、上下架)、订单处理(接单、拒绝、确认入住、确认退租)、收益统计(简单订单金额汇总)。管理后台则通过Django Admin实现,主要做用户管理、房源审核和平台数据概览。

业务流转是整个系统的核心。我设计了这样一条主流程:租客浏览房源→提交租房订单(状态为待房东接单)→房东接单(状态变为待付款)→租客付款(状态变为待入住,校园场景一般是线下付款,所以这里付款是确认付款)→租客入住(状态变为已入住)→租客申请退租/房东确认退租(状态变为已结束)。这个状态机看似简单,但实际实现时最大的坑是状态字段的类型和流转校验,我在2.2节会详细说。

2. 后端核心实现:Django + DRF + JWT

2.1 环境准备与项目初始化

Python环境的坑,我在Windows上踩过不少。先说结论:建议用Python 3.10或3.11,不要用最新的3.12/3.13,因为部分依赖包(尤其是django-cors-headers和一些图像处理库的编译版)在新版本上可能还没适配。安装Python时一定要勾选“Add Python to PATH”,不然装完以后在终端敲python提示找不到命令,只能手动配置环境变量,徒增麻烦。

安装完Python后,我用venv创建了独立的虚拟环境,避免和系统全局Python包冲突。项目目录结构如下:

campus-rent/ ├── backend/ # Django 后端 │ ├── manage.py │ ├── config/ # 项目配置目录 │ ├── apps/ │ │ ├── users/ # 用户模块 │ │ ├── houses/ # 房源模块 │ │ └── orders/ # 订单模块 │ └── requirements.txt ├── frontend/ # Vue3 前端 │ ├── src/ │ ├── package.json │ └── vite.config.js └── README.md

创建虚拟环境并安装依赖的操作很简单:

cd backend python -m venv venv # Windows激活虚拟环境 venv\Scripts\activate # macOS/Linux 激活虚拟环境 # source venv/bin/activate pip install django djangorestframework djangorestframework-simplejwt django-cors-headers mysqlclient pillow

依赖安装完成后,创建Django项目和应用:

django-admin startproject config . python manage.py startapp users python manage.py startapp houses python manage.py startapp orders

这里有个小习惯:把apps统一放在backend/apps/目录下,而不是放在根目录。这样做的原因是后续如果项目变大,可以方便地用Django的app目录指定,也便于把公共组件抽出来。如果你的项目规模不大,直接放根目录也没问题。

2.2 数据模型设计:从需求到表的完整拆解

数据模型是一套系统的地基,设计错了后面改起来特别费劲。我设计了5张核心表:用户表、房源表、房源图片表、订单表、收藏表。下面直接给出我最终落地的模型定义,并解释每张表设计时考虑的关键点。

用户表没有直接用Django的默认User表,而是扩展了一个UserProfile表来存用户类型、手机号、学校、学生证号等业务字段。原因是Django自带的User表字段固定,直接改源码权限模型不划算,用OneToOne扩展最稳妥。用户类型用一个整数字段表示,1代表租客,2代表房东,3代表既是租客又是房东,这样设计比存字符串更节省存储且查询效率更高。

房源表设计时,几个关键决策:租金使用IntegerField而不是DecimalField,因为校园房源的租金基本都是整数,用IntegerField可以避免前后端浮点运算的精度问题(前端传99.99,后端得到的可能是99.9899999);面积用DecimalField(max_digits=5, decimal_places=1)保留一位小数;状态字段用SmallIntegerField,0待审核、1已上架、2已下架、3已驳回。户型字段我用了CharField,存“一室一厅”、“四室两卫”这类字符串,而不是拆分出室、厅、卫三个字段,因为校园房源户型种类少,字符串查询也能满足筛选需求,不要过度设计。

房源图片单独建一张HouseImage表,而不是在House表里存一个JSON字段。这样设计的好处是:第一,多图上传时前端可以逐张上传并返回图片ID;第二,以后如果要做图片排序、图片懒加载,有独立表操作更灵活;第三,Django Admin里管理图片也方便。

订单表的设计是整个系统最核心的部分。关键字段包括:订单号(用时间戳+随机数生成,不用自增ID做展示用编号,避免被人遍历)、关联房源、租客、房东、租期起止日期、租金快照、押金、总金额和状态字段。这里特别要注意“租金快照”这个字段——用户在提交订单那一刻的房价,必须存到这个字段里,而不能在订单详情时再去读House表当前租金。原因是房东可能随时改价,如果订单详情实时读当前价,会出现用户看到的价格和支付的价格不一致,这在业务上是不可接受的。

下面的代码是订单模型的核心片段,重点是状态字段和几个约束:

class Order(models.Model): """租房订单""" STATUS_CHOICES = ( (0, '待房东接单'), (1, '待付款'), (2, '待入住'), (3, '已入住'), (4, '已退租'), (5, '已取消'), (6, '已拒绝'), ) order_no = models.CharField(max_length=32, unique=True, verbose_name='订单编号') house = models.ForeignKey(House, on_delete=models.CASCADE, related_name='orders', verbose_name='房源') tenant = models.ForeignKey(UserProfile, on_delete=models.CASCADE, related_name='tenant_orders', verbose_name='租客') landlord = models.ForeignKey(UserProfile, on_delete=models.CASCADE, related_name='landlord_orders', verbose_name='房东') start_date = models.DateField(verbose_name='入住日期') end_date = models.DateField(verbose_name='退租日期') rent_price = models.DecimalField(max_digits=8, decimal_places=2, verbose_name='租金快照') deposit = models.DecimalField(max_digits=8, decimal_places=2, default=0, verbose_name='押金') total_amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='订单总金额') status = models.SmallIntegerField(choices=STATUS_CHOICES, default=0, verbose_name='订单状态') created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') class Meta: db_table = 'rent_order' ordering = ['-created_at']

收藏表比较简单,就是用户和房源的多对多关系,但注意要加UniqueConstraint保证同一用户不能重复收藏同一房源:

class Favorite(models.Model): user = models.ForeignKey(UserProfile, on_delete=models.CASCADE, verbose_name='用户') house = models.ForeignKey(House, on_delete=models.CASCADE, verbose_name='房源') created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'rent_favorite' constraints = [ models.UniqueConstraint(fields=['user', 'house'], name='unique_user_house') ]

表设计完成后,执行makemigrations和migrate。如果在迁移时报依赖错误,多半是app的注册顺序或者外键引用路径写错了,检查一下INSTALLED_APPS里的app顺序以及ForeignKey中是否写了完整的“app_label.ModelName”。

2.3 DRF与JWT认证:登录鉴权的完整实现

接口鉴权我用的djangorestframework-simplejwt,选它而不是django-rest-knox或者自己写Token,理由很简单:JWT无状态、天然适合前后端分离,前端拿到token后存到localStorage,请求时放到Authorization头里,不需要后端维护会话记录。对于并发量不大的校园系统来说,JWT的性能和实现复杂度都更合适。

需要注意JWT的一个经典问题:token一旦签发,在过期之前无法从服务端撤销。所以我在用户修改密码或者被管理员封禁时,会强制要求重新登录(前端拦截401后跳转登录页)。简单粗暴但有效,如果追求更精细的控制,可以引入token黑名单机制,但校园场景没有必要。

配置JWT只需要在settings.py里加几行:

# config/settings.py from datetime import timedelta SIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(days=1), 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), 'AUTH_HEADER_TYPES': ('Bearer',), }

然后配置DRF的认证类和权限类:

REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_simplejwt.authentication.JWTAuthentication', ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', ), }

这里注意一个坑:如果全局配置了IsAuthenticated,那么登录接口和注册接口必须单独配置AllowAny权限,否则会出现“未认证用户无法调登录接口”的尴尬。解决办法是在对应的类视图上写permission_classes = [AllowAny]。

登录和注册接口的序列化器我直接用了DRF的Serializer,自定义了validate逻辑。注册时校验两次密码一致、手机号唯一,密码用Django的make_password加密后保存。登录接口直接使用simplejwt提供的TokenObtainPairView,但需要自定义序列化器把用户ID和昵称也返回给前端,因为前端一进来就要显示用户信息:

# apps/users/views.py from rest_framework_simplejwt.views import TokenObtainPairView from .serializers import CustomTokenObtainPairSerializer class CustomTokenObtainPairView(TokenObtainPairView): serializer_class = CustomTokenObtainPairSerializer
# apps/users/serializers.py class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): def validate(self, attrs): data = super().validate(attrs) data['user_id'] = self.user.id data['nickname'] = self.user.profile.nickname data['user_type'] = self.user.profile.user_type return data

自定义身份验证后端也要写一下,因为simplejwt默认通过USERNAME_FIELD(默认是username)认证,但业务流程里我希望能用手机号或学号登录。这个改造不算难,但必须在settings.py里配置AUTH_USER_MODEL或者自定义认证后端。我直接创建了apps/users/backends.py:

from django.contrib.auth.backends import ModelBackend from django.contrib.auth import get_user_model from django.db.models import Q User = get_user_model() class EmailOrPhoneBackend(ModelBackend): def authenticate(self, request, username=None, password=None, **kwargs): try: user = User.objects.get(Q(username=username) | Q(phone=username)) except User.DoesNotExist: return None if user.check_password(password) and self.user_can_authenticate(user): return user return None

然后在settings.py里配置AUTHENTICATION_BACKENDS指向这个类。如果不配置,你会发现用手机号登录永远提示“用户名或密码错误”,这个坑我印象很深。

2.4 核心接口实现:房源、订单、权限控制

房源模块的接口我按照DRF的ModelViewSet来写,借助DRF自带的Router注册路由,再用一个自定义的过滤器处理搜索和筛选条件。典型代码如下:

# apps/houses/views.py from rest_framework import viewsets from rest_framework.permissions import IsAuthenticatedOrReadOnly from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import SearchFilter, OrderingFilter from .models import House from .serializers import HouseListSerializer, HouseDetailSerializer class HouseViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticatedOrReadOnly] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_fields = ['community', 'bedroom', 'status'] search_fields = ['title', 'address', 'description'] ordering_fields = ['price', 'created_at'] def get_queryset(self): queryset = House.objects.filter(status=1) # 只返回已上架房源 price_min = self.request.query_params.get('price_min') price_max = self.request.query_params.get('price_max') if price_min: queryset = queryset.filter(price__gte=price_min) if price_max: queryset = queryset.filter(price__lte=price_max) return queryset def get_serializer_class(self): if self.action == 'retrieve': return HouseDetailSerializer return HouseListSerializer def perform_create(self, serializer): serializer.save(landlord=self.request.user.profile, status=0)

这个实现有几个关键点值得细说。get_queryset里面强制过滤了status=1,确保未上架的房源不会出现在列表接口中,这是数据层面上的权限隔离,比全部返回后让前端过滤更安全。get_serializer_class区分列表和详情,列表接口只返回摘要字段,详情接口才返回完整描述和房东信息,这样可以显著减少列表页的数据传输量,移动端弱网环境加载会快很多。perform_create里自动把当前登录用户设为房东,避免前端传一个假的身份。

房源图片上传接口单独放在/houses/upload/,使用Django的FileUploadParser或自定义基于DRF的APIView。前端用Ant Design Vue的Upload组件,拿到图片后逐张上传,后端返回图片URL,前端再把URL拼到房源数据里一起提交。

订单接口的实现是另一个重头戏。订单创建时后端要先做三项校验:房源必须存在且状态为上架;租客不能是房源房东本人(自己租自己的房子,业务上不允许);租期日期必须合法(开始日期不能早于今天,退租日期必须晚于入住日期)。校验通过后计算总金额:总金额 = 租金快照 × 租期天数 + 押金。这里租期的天数计算方式也有讲究,我用的方式是(end_date - start_date).days,即按自然日计算。但要注意,退租当天通常不算居住,所以实际收费天数应该是(end_date - start_date).days,不需要额外减一,因为学生租房一般当天退房当天走,不存在多算一天的问题。

订单状态的流转我用了一个简单的状态机校验函数,确保接口不允许跳过状态:

# apps/orders/utils.py ORDER_TRANSITIONS = { 0: [1, 5, 6], # 待房东接单 -> 待付款 / 已取消 / 已拒绝 1: [2, 5], # 待付款 -> 待入住 / 已取消 2: [3, 5], # 待入住 -> 已入住 / 已取消 3: [4], # 已入住 -> 已退租 } def can_transition(current_status, new_status): return new_status in ORDER_TRANSITIONS.get(current_status, [])

每次状态变更的接口里都调用这个函数,不允许用户把已取消的订单改成已入住。这也是整个系统里最值得写的业务逻辑之一。

3. 前端核心实现:Vue3 + 全家桶

3.1 Vue3工程初始化与目录规划

前端工程我用了Vite 4.x创建,命令很简单:

npm create vite@latest frontend -- --template vue cd frontend npm install npm install vue-router@4 pinia axios ant-design-vue@4

创建完成后,我把src目录重新规划了一下:

src/ ├── api/ # 所有接口请求封装,按模块拆分 ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── router/ # 路由配置 ├── store/ # Pinia状态管理 ├── views/ │ ├── home/ # 首页、房源列表、房源详情 │ ├── order/ # 订单相关页面 │ ├── landlord/ # 房东端页面 │ └── user/ # 个人中心、登录注册 ├── utils/ # 工具函数,axios实例等 └── App.vue

目录规划得好不好,直接影响后期维护效率。我在第一次写这个项目时把所有页面都放在views下平铺,后期找文件找得想哭。第二次重构后按业务域划分子目录,文件定位快了很多。这个习惯建议从一开始就养成。

3.2 axios二次封装:统一处理token和错误

axios封装是前后端分离项目里必不可少的一层。没有这层封装,每个页面都要重复写请求拦截、错误提示、token失效处理,代码会膨胀得很厉害。我的封装思路是:创建axios实例时配置基础URL和超时时间,请求拦截器里从Pinia读取token并加到Authorization头,响应拦截器里统一处理HTTP状态码和业务码。

这里我想特别强调一个坑:token的获取方式。如果直接在其他模块里import store文件,可能会遇到初始化顺序问题,因为Pinia store可能在axios模块加载时尚未初始化。我用的方案是在请求拦截器里从localStorage直接读取token,因为Pinia里的token本质上也是localStorage的镜像,读localStorage不会有初始化顺序问题:

// src/utils/request.js import axios from 'axios' import { message } from 'ant-design-vue' import router from '../router' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('access_token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => response.data, error => { if (error.response) { if (error.response.status === 401) { localStorage.removeItem('access_token') localStorage.removeItem('user_info') router.push('/login') message.warning('登录已过期,请重新登录') } else if (error.response.status === 403) { message.error('没有权限执行该操作') } else if (error.response.status === 404) { message.error('请求的资源不存在') } else { const msg = error.response.data.detail || error.response.data.message || '请求失败' message.error(msg) } } else { message.error('网络异常,请检查网络连接') } return Promise.reject(error) } ) export default request

接口封装我按模块拆分到api目录下,比如house.js里放房源相关的所有接口:

// src/api/house.js import request from '../utils/request' export function getHouseList(params) { return request.get('/houses/', { params }) } export function getHouseDetail(id) { return request.get(`/houses/${id}/`) } export function createHouse(data) { return request.post('/houses/', data) } export function uploadHouseImage(file) { const formData = new FormData() formData.append('file', file) return request.post('/houses/upload/', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) }

3.3 路由与权限守卫:页面访问控制

路由配置上,我分成三个层级:公开路由、需要登录的路由、房东专属路由。公开路由包括首页、房源列表、房源详情和登录注册页;需要登录的路由包括订单管理、个人中心和收藏;房东专属路由包括房源管理、订单处理。

路由守卫是前后端分离项目里控制页面的核心。它的作用有两层:第一层,没有token的用户不能访问需要登录的页面,直接跳转到登录页;第二层,登录用户如果访问了和自己身份不匹配的页面(比如租客访问房东管理页),要跳转到首页并给出提示。第二层控制很容易被忽略,但如果不加,用户手动输入/landlord就可以看到房东端接口了。

我的路由配置和守卫代码如下:

// src/router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', name: 'Home', component: () => import('../views/home/Index.vue') }, { path: '/houses/:id', name: 'HouseDetail', component: () => import('../views/home/HouseDetail.vue') }, { path: '/login', name: 'Login', component: () => import('../views/user/Login.vue') }, { path: '/order', name: 'OrderList', component: () => import('../views/order/OrderList.vue'), meta: { requiresAuth: true } }, { path: '/landlord/houses', name: 'LandlordHouses', component: () => import('../views/landlord/LandlordHouses.vue'), meta: { requiresAuth: true, requiresLandlord: true } }, ] const router = createRouter({ history: createWebHistory(), routes }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('access_token') const userInfo = JSON.parse(localStorage.getItem('user_info') || '{}') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) return } if (to.meta.requiresLandlord && userInfo.user_type === 1) { next('/') return } next() })

这里用了路由懒加载,每个页面按需加载,首屏性能会好很多。注意meta配置,requiresAuth表示需要登录,requiresLandlord表示必须是房东身份。组件内可以通过$route.meta判断当前页面权限来渲染不同的按钮或区块。

3.4 登录注册与Pinia用户状态管理

登录流程是整个前端的基础。用户在登录页输入手机号和密码,调用后端登录接口拿到access_token和refresh_token,然后把用户信息保存到localStorage和Pinia中。之后所有请求自动带上token,路由守卫放行。

Pinia的store设计很简单,核心是一个useUserStore,管理token、用户信息和登录/登出动作:

// src/store/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('access_token') || '', userInfo: JSON.parse(localStorage.getItem('user_info') || '{}') }), getters: { isLogin: (state) => !!state.token, isLandlord: (state) => state.userInfo.user_type !== 1 }, actions: { setLoginData({ token, userInfo }) { this.token = token this.userInfo = userInfo localStorage.setItem('access_token', token) localStorage.setItem('user_info', JSON.stringify(userInfo)) }, logout() { this.token = '' this.userInfo = {} localStorage.removeItem('access_token') localStorage.removeItem('user_info') } } })

登录页的表单校验用了Ant Design Vue的Form组件,手机号和密码都有各自的校验规则。提交成功后调用userStore.setLoginData,然后根据登录来源跳转:如果有redirect参数就跳回来源页,否则根据user_type跳转,租客去首页,房东去房源管理页。

有一个细节容易踩坑:登录成功后用户刷新页面,Pinia会重置,但localStorage里的token和userInfo还在。所以我在store初始化的state里就直接从localStorage读数据,保证刷新后用户状态不丢失。这比在main.js里做额外初始化简单得多。

3.5 核心页面实现要点

房源列表页是用户进入系统的第一个主要界面,我设计成左侧筛选栏+右侧卡片列表。筛选栏包括校区下拉框(数据不来自后端,是前端写死的枚举,因为校园校区的数量非常有限)、价格区间两个数字输入框、户型单选按钮。筛选条件变化时触发getHouseList重新请求,请求参数通过URL参数传递,这样刷新页面后筛选条件还会保留在URL中,可以分享链接给别人。

房源详情页有更多交互细节。顶部是图片轮播,用了Ant Design Vue的Carousel组件,点击缩略图切换大图。右侧是核心信息:标题、租金(大字加粗显示)、面积、户型、地址、房东信息(头像、昵称、认证标识)、入住退租日期选择器、下单按钮。下单按钮点击后弹出一个确认弹窗,展示租期天数和总价,用户确认后调用创建订单接口。这里有个交互细节:日期选择器需要设置禁止选择过去的日期,否则用户可以选到昨天,后端虽然会校验,但前端的及时校验体验更好。

收藏功能我做了防重复点击处理:用户点击收藏时先判断isLogin,未登录跳转登录页,已登录则调用收藏接口,如果已收藏则取消收藏,按钮状态实时切换。为了避免用户在接口还没返回时连续点击导致重复请求,我用了loading状态做按钮禁用,这是一个很常见的用户体验细节。

房东端页面相对简单,主要是房源管理列表和订单处理。房源管理列表用Ant Design Vue的Table组件,每一行显示房源标题、图片缩略图、价格、状态标签和操作按钮。操作按钮根据状态变化:待审核的房源只能下架;已上架的可以下架和编辑;已下架的可以重新上架。订单处理页面侧重点在于展示订单状态流转,房东可以通过点击按钮把订单状态变更为下一个状态,按钮的状态根据当前订单状态动态渲染。

4. 前后端联调与部署落地

4.1 跨域问题的两种解法

跨域(CORS)是前后端分离项目一定会遇到的问题,但它的解法很固定,搞懂原理后就不会再被困扰。所谓跨域,简单说就是浏览器出于安全策略,默认不允许前端网页去请求不同源的接口。这里的“源”由协议、域名、端口三部分组成,只要有任何一个不同,就构成跨域。

开发环境最推荐的方式是Vite代理,在vite.config.js里配置proxy,让前端请求转发到后端,浏览器看到的请求都在同一个源下,从根本上避免跨域。配置如下:

// vite.config.js export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true } } } })

这意味着前端请求/api/houses/,实际上被Vite转发到了后端http://127.0.0.1:8000/api/houses/。这个方案的好处是开发时不需要后端开启CORS,也不需要在请求里写死完整域名。

生产环境则用Nginx做反向代理,把前端静态文件和/api开头的请求都放在同一个域名下。Nginx的配置片段如下:

server { listen 80; server_name your-domain.com; root /var/www/campus-rent/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /media/ { alias /var/www/campus-rent/media/; } }

Nginx配置里最容易踩的坑是location /的try_files配置。如果缺少这行,用户直接访问子路由(比如刷新/order页面)会返回404,因为前端路由是history模式,Nginx默认会去找/order这个真实文件,找不到就404。try_files的作用就是当找不到对应文件时回退到index.html,让Vue Router接管路由。

除了以上两种方式,还有一个方案是后端用django-cors-headers开启CORS。如果你一定要在开发环境直接请求后端地址,可以这样配置:

pip install django-cors-headers

然后在INSTALLED_APPS中添加corsheaders,在MIDDLEWARE中尽量靠前添加CorsMiddleware,最后设置:

CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", ]

我个人的建议是:开发环境用Vite代理,彻底绕开跨域;生产环境用Nginx代理,也保持同源。django-cors-headers只在特殊场景下才需要(比如小程序、移动App直接请求后端),日常前端项目的跨域问题用代理方案就已经解决了。

4.2 图片上传与文件存储

图片上传是这类系统里绕不开的功能。我在后端实现了两个上传入口:一个是房源图片上传,一个是用户头像上传。上传逻辑其实很简单,接收入参文件,校验类型和大小,然后保存到MEDIA_ROOT目录,返回文件的URL。

settings.py中的配置:

MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media'

上传接口的实现关键点是要限制文件类型和大小。我只允许jpg、jpeg、png三种格式,最大5MB。这个限制在前端和后端都要做,不能只做前端,因为接口可以直接被curl调用绕过前端限制。后端校验代码:

import os from rest_framework.views import APIView from rest_framework.parsers import MultiPartParser from rest_framework.response import Response class HouseImageUploadView(APIView): parser_classes = [MultiPartParser] def post(self, request): file = request.FILES.get('file') if not file: return Response({'detail': '未接收到文件'}, status=400) ext = os.path.splitext(file.name)[1].lower() if ext not in ['.jpg', '.jpeg', '.png']: return Response({'detail': '仅支持jpg/jpeg/png格式'}, status=400) if file.size > 5 * 1024 * 1024: return Response({'detail': '图片大小不能超过5MB'}, status=400) house_image = HouseImage.objects.create( image=file, uploader=request.user.profile ) return Response({'url': house_image.image.url})

前端上传组件我直接用Ant Design Vue的Upload,配置了自定义上传逻辑,因为默认上传方式是提交表单,我需要用封装好的axios实例带token上传:

<template> <a-upload :file-list="fileList" :custom-request="handleUpload" list-type="picture-card" accept=".jpg,.jpeg,.png" > <div v-if="fileList.length < 5"> <PlusOutlined /> <div>上传图片</div> </div> </a-upload> </template> <script setup> const handleUpload = async (options) => { const { file, onSuccess, onError } = options try { const res = await uploadHouseImage(file) imageUrls.value.push(res.url) onSuccess(res) } catch (err) { onError(err) } } </script>

关于文件存储,如果是在云服务器上做演示或小型部署,本地存储完全够用。但如果要上线到正式环境,建议用OSS/S3对象存储,把图片放到云存储上,Django只存图片URL。原因有两个:一是本地存储会占用服务器磁盘空间,图片多了以后备份和迁移都很头疼;二是对象存储自带的CDN加速能让图片加载更快。不过考虑到很多校园项目其实是课程设计、毕设级别,我用本地存储+定期备份就够了。

4.3 从开发到上线的完整流程

前后端联调完成后,部署流程我走了一遍,记录下来供参考。前端项目先构建,生成静态文件到dist目录:

cd frontend npm run build

然后把dist目录里的所有文件复制到服务器的/webroot/campus-rent/dist目录下,同时把前端的环境变量里VITE_API_BASE_URL改成正式环境的值。构建时如果没有配置环境变量,axios的baseURL会使用vite代理配置,但代理只在开发服务器下生效,生产环境必须依赖Nginx把/api转发到后端,所以构建结果中的请求路径必须是/api开头的相对路径,而不是localhost:8000这种绝对地址。

后端代码部署到服务器上,安装依赖,然后执行迁移和收集静态文件:

pip install -r requirements.txt python manage.py migrate python manage.py collectstatic --noinput python manage.py runserver 0.0.0.0:8000

生产环境我用了Gunicorn替代runserver:

pip install gunicorn gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3

最后配置Nginx反向代理和媒体文件路径,重启Nginx,整个系统就可以通过域名访问了。

部署过程中的一个坑:Django的ALLOWED_HOSTS必须加上服务器域名或IP,否则访问会报400 Bad Request。另一个坑:DEBUG=False以后,Django不再自动托管静态文件和媒体文件,必须用collectstatic收集静态文件交给Nginx托管,媒体文件需要在Nginx配置location /media/映射。

5. 高频报错与排查技巧实录

5.1 后端常见问题

数据库驱动安装失败是Windows环境下的重灾区。我使用MySQL,需要在Windows上安装mysqlclient,但mysqlclient在Windows下需要预编译的wheel包或者Visual C++编译环境,否则pip install会直接报错。笨办法是安装Visual C++ Build Tools,聪明一点的办法是直接到网站下载对应Python版本的whl文件安装,或者改用pymysql并在manage.py里加一行pymysql.install_as_MySQLdb()。pymysql的性能略低于mysqlclient,但对校园系统的小并发完全够用。

迁移文件冲突也是常见问题。多人协作开发时经常出现两个人同时改了同一个模型然后都生成了迁移文件,导致makemigrations报依赖冲突。解决方案是协商好谁先合并谁后合并,后合并的人删除自己生成的迁移文件后重新生成一次。一个人开发时基本不会遇到这个问题,但如果用了Git分支开发不同模块,也要注意。

5.2 前端常见问题

跨域错误是出现频率最高的前端问题。表现是浏览器控制台出现“Access to XMLHttpRequest at ... from origin ... has been blocked by CORS policy”。看到这行报错时先别急着配置后端CORS,要分场景。开发环境下首先确认Vite代理配置是否正确,请求路径是否真的走了代理;生产环境则确认Nginx是否已经把/api转发到后端。

刷新页面404的问题我已经在4.1节提到过了,这是Vue Router history模式的经典问题。解决方法是Nginx配置try_files,或者改用hash模式路由。hash模式的URL长这样/campus-rent/#/order,不太美观,但对部署最简单的场景够用。我推荐用history模式+try_files,因为URL更符合现代网站的观感。

接口返回401但用户确实已经登录了,这个问题的排查思路要清晰。第一步打开浏览器开发者工具的Network面板,看请求头里有没有Authorization字段;第二步看Authorization前缀是不是“Bearer ”,比如“Bearer eyJhbGciOi...”。如果前缀写错了(比如写成了“JWT eyJhbGci...”),simplejwt默认不认识,就会401。我封装axios时专门排查过这个问题,最终确认了写法是Bearer ${token}

5.3 独家避坑建议

版本锁定是我吃了大亏后养成的习惯。Django 4.x和3.x的部分API有细微差异,DRF的版本更新也可能导致序列化器行为变化,Vue3的Vite版本也经常升级。建议在requirements.txt里锁定所有依赖版本号,在package.json里固定版本而不是用^符号,否则半年后重新部署会发现项目跑不起来或者行为诡异。比如我项目的requirements.txt是这样的:

Django==4.2.7 djangorestframework==3.14.0 djangorestframework-simplejwt==5.3.0 django-cors-headers==4.3.0 pymysql==1.1.0 Pillow==10.1.0 gunicorn==21.2.0

前后端字段命名的一致性也很关键。后端序列化器返回的字段名和前端Vue组件里使用的字段名必须完全一致。因为Python习惯用下划线命名(比如user_type),JavaScript习惯用驼峰命名(比如userType),如果前后端各自选择了不同的命名风格,联调时就会出现“接口返回了user_type,前端却取userType”的问题。最简单的做法是统一用下划线命名,前端拿到数据后直接使用,不做转换。Django模型默认的字段名就是下划线风格,所以前端配合使用下划线风格的变量名最省事。

日期时间的序列化格式也要提前约定。DRF默认会把DateTimeField序列化成ISO 8601格式,比如“2024-06-15T14:30:00+08:00”,前端拿到这个字符串直接显示会很丑。我全局配置了日期格式:

REST_FRAMEWORK = { 'DATETIME_FORMAT': '%Y-%m-%d %H:%M:%S', 'DATE_FORMAT': '%Y-%m-%d', }

这样后端直接返回“2024-06-15 14:30:00”,前端不用做任何格式化就能展示。注意,如果你需要在前端做时间运算(比如计算租期天数),建议还是返回时间戳或ISO格式,因为字符串格式做运算很麻烦。我的做法是租期天数由后端计算并返回,前端不参与任何时间计算。

5.4 搜索功能中的一个小优化

搜索是用户高频使用的功能,但很多教程里的搜索实现都没有做“防抖”。用户在搜索框里连续输入“阳光小区”,如果每次输入都触发一次请求,会发送“阳”、“阳光”、“阳光小”、“阳光小区”四次请求,既浪费带宽又可能造成后端压力。我在搜索框组件里加了300毫秒的防抖:

import { ref, watch } from 'vue' const keyword = ref('') let timer = null watch(keyword, () => { clearTimeout(timer) timer = setTimeout(() => { fetchHouseList() }, 300) })

这只是个小优化,但从用户体验和代码规范角度看,是很加分的细节。面试官如果问到搜索功能怎么优化,能说出防抖、节流、请求取消(用AbortController)这些点,印象分会高不少。

6. 测试与性能优化要点

6.1 接口测试:postman还是drf自带文档

后端接口写完以后,我习惯先用Postman做一轮冒烟测试,确认每个接口都能正确返回。但等到接口数量多了以后,维护Postman里的请求集合也变得麻烦。DRF自带的接口文档页面(开启DEFAULT_SCHEMA_CLASS后访问/api/docs/)能直接展示所有接口,支持在线调试,对前后端联调和给外部人员演示都非常方便。

需要注意的一点:在settings.py里如果全局配置了权限为IsAuthenticated,接口文档页也需要登录才能访问。如果希望文档可以公开访问,可以在URL配置里单独设置AllowAny。

6.2 性能优化:除了加缓存还能做什么

校园租房系统的并发量不会很高,所以在性能优化上我没有做太复杂的操作。但几个基本层面的优化还是做了:第一,房源列表接口加了django-filter的复杂查询时,确保所有筛选字段都有数据库索引,用explain查看执行计划确认没有全表扫描;第二,列表接口使用select_related和prefetch_related预加载外键数据,避免N+1查询问题;第三,前端对房源列表做了分页,每页12条,而不是一次性返回几百条数据。

index字段的添加也值得说。我在House表的外键字段和常用筛选字段上建立了索引:

class Meta: indexes = [ models.Index(fields=['price']), models.Index(fields=['status', 'created_at']), ]

订单表的status字段也建了索引,因为管理后台会按状态筛选订单。这些索引对大数据量场景来说很有必要,但对小数据量项目来说收益不明显。做决策时可以有一个理性预期:校园系统数据量到了一万条以上,索引优化才开始有明显效果。

6.3 代码规范与提交规范

一个人开发项目时最容易忽略代码规范,但代码规范其实是给自己看的。我用的Python代码格式化工具是Black,前端用Prettier,两个工具都能在保存时自动格式化代码。统一代码风格的好处不是“给别人看”,而是自己在一个月以后回来看代码时,不用额外花精力去理解当时是怎么写的。

Git提交信息我用的是这种格式:feat(模块): 描述、fix(模块): 描述。虽然不是严格遵循Conventional Commits规范,但能保证从提交历史里快速定位到某个模块的改动。好的提交习惯对回滚某个功能、协作者评审代码都很有帮助。

7. 最后的实操心得

做这个项目的过程中,零零散散踩了太多次坑。有些坑看着很小,但找问题就能花掉一下午。比如Vite代理配置里少了“/api”前缀后转发会404,前端在响应拦截器里把data直接给到调用方导致分页对象被拆开,后端因为忘了加权限装饰器导致匿名用户可以删除房源等等。这些经验写出来是想告诉大家,前后端分离项目调试一个接口时,排查链路的先后顺序很重要:先看浏览器Network面板请求是否发出、请求头是否正确、后端是否收到、返回什么状态码,再去看代码,不要一上来就怀疑代码逻辑。

还要特别提一点:做这类系统时一定要有“数据安全”的意识。虽然校园系统的用户量不大,但用户真实姓名、手机号、学号都属于个人敏感信息。我在实现里没有做过度设计,但至少做到密码使用Django自带的PBKDF2加密,数据库不开公网端口,Nginx层面限制了上传文件大小,后端接口对用户权限做了严格校验。

最后想说的是,技术方案永远是为业务场景服务的。校园租房这个场景如果放到商业公司里做,可能要接入支付、信用体系、电子合同,但在校园项目里,最核心的其实是把信息和信任的问题解决了。能通过技术手段把“找房、带看、签约、入住”这条链路在线上打通,让数据沉淀下来,就已经算成功了。

我这个项目的源码和部署文档已经整理好放在GitHub上,评论区留了链接。如果你也在做类似的系统,欢迎直接拿去参考,更欢迎在评论区交流你踩过的坑。如果我有时间,后面还可以写一篇关于这个项目如何接入校园统一认证登录的具体教程。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 1:20:46

从457页指引到落地:数据要素场景拆解与数据资产盘点实战

最近部门开项目复盘会&#xff0c;好几个项目经理都在吐槽同一件事&#xff1a;那份457页的“数据要素”典型场景指引&#xff0c;翻到第100页就放弃了&#xff0c;太厚&#xff0c;读不下去。但恰恰是这份被大家当成“床头催眠读物”的文件&#xff0c;把工业制造、现代农业、…

作者头像 李华
网站建设 2026/9/10 1:19:11

Spring Boot内部接口为何优先选用JSON-RPC?从零实现全解析

简介&#xff1a;这是一份基于Spring Boot的JSON-RPC服务端示例&#xff0c;面向有Java基础、希望快速实现RPC接口的开发者&#xff0c;也适用于需要了解JSON-RPC 2.0协议与Spring Boot整合方式的学习场景。资源包共26个文件&#xff0c;压缩后仅55KB&#xff0c;内容以Java源码…

作者头像 李华
网站建设 2026/9/10 1:12:21

51单片机驱动RC522读写M1卡:从SPI模拟到防碰撞全解析

简介&#xff1a;面向51单片机开发者的RC522 RFID读写方案资料包&#xff0c;聚焦13.56MHz非接触式通信&#xff0c;适用于门禁系统、智能卡读写器及物联网设备等场景&#xff0c;也可作为电子设计竞赛与课程设计参考。压缩包为zip格式&#xff0c;共0个文件&#xff08;上游未…

作者头像 李华
网站建设 2026/9/10 1:08:15

HBase 快照机制:在线快照、克隆与表级恢复的运维实战

1. HBase 快照机制概述 HBase快照机制是Hadoop生态系统中重要的数据保护工具&#xff0c;它允许在不阻塞生产服务的情况下创建表的只读备份。快照是一个表的元数据和数据块引用的集合&#xff0c;不会立即复制所有数据&#xff0c;因此创建速度快且对集群性能影响极小。 HBase快…

作者头像 李华