做这类“信息发布类”全栈项目,我最大的体会是:它不像电商或者社交项目有那么复杂的业务规则,但该有的前后端难点一个都不少。Vue 负责页面交互和状态管理,Python 负责提供 API 和数据持久化,两边一拆,开发效率高,后面扩展也舒服。
这篇文章我就以“基于 Python + Vue 的都市供求信息网”为例,从项目拆解、环境搭建、数据库设计、后端 API 实现、前端页面联调,到部署上线和常见坑,完整走一遍。无论你是拿它做毕业设计,还是想把它改造成本地生活服务小程序的后台,都能直接参考。
1. 项目整体设计与思路拆解
1.1 这类“都市供求信息网”到底在做什么
供求信息网的核心业务很简单,就是让用户发布“我要卖什么 / 我要找什么”的信息,然后让其他用户浏览、搜索、联系发布者。常见的形态包括二手交易、租房买房、招聘求职、家政服务、拼车顺风车等等。
如果用一句话概括项目价值:它用最小成本实现了“信息聚合 + 信息检索 + 用户发布”的闭环。和电商网站不同,它不需要购物车、不需要支付、不需要订单履约,所以非常适合作为全栈项目练手,也适合用来快速搭建一个垂直领域的分类信息平台。
我见过不少同学一开始就把这类项目想复杂了,加了一堆优惠券、积分、会员等级之类的东西。其实没必要。做供求信息网,核心功能就是三件事:
- 用户注册登录,能区分普通用户和管理员;
- 信息分类管理,用户能按分类发布、修改、下架信息;
- 浏览检索,游客能看列表、看详情、按关键词搜、按分类筛。
把这三条主线做好,项目就已经完整了。其他都是锦上添花。
1.2 为什么是 Python + Vue 这套组合
后端我用 Python,具体框架选了 Django + DRF(Django REST Framework)。原因很简单:
- Python 上手快,ORM 写起来比原生 SQL 省太多事;
- Django 自带 Admin 后台,管理供求信息时直接改改配置就能用;
- DRF 的序列化器让前端交互变得非常规范,接口文档也可以自动生成。
有人会问 Flask 行不行?当然行。但如果项目里有多张表关联、需要做用户权限、需要后台管理,Django 的自带功能能帮你省掉至少三天的开发量。Flask 更自由,但你需要自己拼装很多组件。
前端我选了 Vue 3 + Vite + Element Plus。Vue 的响应式机制和组件化开发方式本身就适合这种“列表 + 详情 + 表单”的站点,Element Plus 能直接提供表格、表单、分页、弹窗这些成熟组件,不用自己从零写样式。
前后端分离的好处在于:前端只关心页面渲染和用户交互,后端只关心数据校验和业务逻辑,两边通过 JSON 接口通信。这样项目结构清晰,分工明确,后续部署也可以把前端静态文件丢到 Nginx,后端单独跑。
1.3 目录结构与项目分层
我前后端分两个目录放,开发时用 Vite 代理解决跨域,生产时用 Nginx 统一转发。一个推荐的项目结构是这样的:
supply-demand-platform/ ├── backend/ # Django 项目 │ ├── manage.py │ ├── requirements.txt │ ├── config/ # 项目配置(settings、urls) │ └── apps/ │ ├── users/ # 用户模块 │ ├── category/ # 分类模块 │ ├── info/ # 供求信息模块 │ └── common/ # 公共工具、分页、验证 └── frontend/ # Vue 3 项目 ├── package.json ├── vite.config.js ├── index.html └── src/ ├── api/ # axios 请求封装 ├── router/ # 路由配置 ├── stores/ # Pinia 状态管理 ├── views/ # 页面组件 ├── components/ # 通用组件 └── utils/ # 工具函数这样做的最大好处是职责清晰。比如后端想加一个“信息审核”功能,只需要在 info 应用里加字段和接口;前端想改首页布局,只需要动 views/Home.vue,完全不影响其他模块。
2. 环境搭建与核心依赖配置
2.1 后端 Python 环境准备
做 Python 项目,第一步永远是建虚拟环境。千万别图省事直接装到全局,否则不同项目的依赖版本互相冲突,排查起来非常痛苦。
# 创建虚拟环境 python -m venv venv # 激活环境(Windows) venv\Scripts\activate # 激活环境(Mac / Linux) source venv/bin/activateWindows 下如果提示执行策略限制,先执行这个:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser然后安装依赖。我用的 Django 版本是 4.x 稳定版,DRF 和 Django 配合也有固定版本要求,建议直接按我这份 requirements.txt 来:
Django==4.2.7 djangorestframework==3.14.0 django-cors-headers==4.3.1 PyMySQL==1.1.0 Pillow==10.1.0 python-dotenv==1.0.0安装命令:
pip install -r requirements.txt数据库我日常开发用 MySQL,部署也继续用 MySQL。Django 默认是 SQLite,本地测试没问题,但上线后并发一高,SQLite 的写锁问题就会让人头疼。所以项目一开始就直接切到 MySQL 更省事。
在 config/settings.py 里配置数据库:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'supply_db', 'USER': 'root', 'PASSWORD': 'your_password', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': { 'charset': 'utf8mb4', } } }记得先手动创建数据库:
CREATE DATABASE supply_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;用 utf8mb4 而不是 utf8,是因为用户发布的信息里很可能出现 emoji 表情,utf8 存不了四个字节的字符。
2.2 前端 Vue 环境准备
前端我用 Vite 创建 Vue 3 项目,Node.js 版本要求在 16 以上。装完 Node 后,通过 npm 或 pnpm 管理依赖。
# 创建 Vue 3 项目 npm create vite@latest frontend -- --template vue # 进入目录 cd frontend # 安装基础依赖 npm install # 安装路由、状态管理、UI 组件库 npm install vue-router@4 pinia element-plus axios装完依赖后,Vite 开发服务器的默认端口是 5173,Django 跑在 8000 端口。开发时跨域问题通过 Vite 代理解决,在 vite.config.js 里配置:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true } } } })这样前端代码里请求/api/info/list时,实际会被转发到http://127.0.0.1:8000/api/info/list,浏览器层面不会出现跨域报错。
生产环境则不需要这个代理,Nginx 会把/api路径统一转发给后端的 Gunicorn 服务。
注意:不要在生产环境依赖 Vite 代理,那只为本地开发服务。生产环境要用 Nginx 或 Caddy 这类反向代理来处理前后端路由。
2.3 安装过程里的常见坑
我帮别人排查过很多次环境问题,发现 80% 都出在这几个地方:
第一个坑:Python 装完但命令行里找不到。Windows 下安装后检查一下python --version是否正常。如果之前装过多个 Python 版本,建议直接用py -3.10这样的方式指定版本,或者在系统环境变量里把 Python 安装路径和 Scripts 路径都配置好。
第二个坑:pip 安装超时。国内网络环境下经常遇到,解决办法是改成国内镜像源:
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple第三个坑:Node 版本太高导致安装依赖失败。比如某些老项目不兼容 Node 18 以上版本,会报Unsupported engine警告。如果出现这种问题,用 nvm 切换 Node 版本。但我们的 Vue 3 项目一般没有这个问题。
3. 数据库表设计与建模思路
数据表设计是整个项目的地基,我通常在写代码前先画出所有表和关系。供求信息网的核心表包括:用户表、分类表、信息表、图片表、收藏表。
3.1 用户表
用户表需要保存用户名、密码哈希、手机号、头像、角色等信息。密码绝对不能明文存储,Django 自带make_password和check_password来做哈希校验。
3.2 分类表
分类表是树形结构,因为有些信息平台会分两级分类,比如“二手交易”下面还有“手机数码”“家具家电”。用parent自关联字段来实现层级关系。
3.3 信息表
信息表是核心,字段包括标题、内容、价格、分类、发布者、所在城市、联系方式、状态、发布时间、浏览次数等。一个用户能发多条信息,一个分类下有多条信息,都是外键关系。
3.4 图片表
一个信息可以有多张图片,所以图片单独建一张表,通过外键关联到信息表。这样查询信息列表时不需要一次性把大字段加载出来,加载详情时才去查图。
3.5 收藏表
收藏功能是“用户和信息”的多对多关系,可以用一张中间表实现。Django 里也可以用ManyToManyField,但用中间表的好处是以后可以扩展“收藏时间”“收藏备注”等字段。
下面是核心模型代码,我贴的是精简版:
# apps/category/models.py from django.db import models class Category(models.Model): name = models.CharField(max_length=50, verbose_name='分类名称') parent = models.ForeignKey( 'self', null=True, blank=True, on_delete=models.CASCADE, related_name='children', verbose_name='父级分类' ) sort = models.IntegerField(default=0, verbose_name='排序') created_at = models.DateTimeField(auto_now_add=True) class Meta: verbose_name = '信息分类' verbose_name_plural = verbose_name def __str__(self): return self.name# apps/info/models.py from django.db import models from django.contrib.auth.models import User from apps.category.models import Category class Info(models.Model): STATUS_CHOICES = ( ('published', '已发布'), ('draft', '草稿'), ('disabled', '已下架'), ('reviewing', '审核中'), ) title = models.CharField(max_length=100, verbose_name='标题') content = models.TextField(verbose_name='详细描述') price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name='价格') city = models.CharField(max_length=30, verbose_name='城市') district = models.CharField(max_length=30, blank=True, verbose_name='区域') contact_name = models.CharField(max_length=30, verbose_name='联系人') contact_phone = models.CharField(max_length=20, verbose_name='联系电话') publisher = models.ForeignKey(User, on_delete=models.CASCADE, related_name='infos', verbose_name='发布者') category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name='infos', verbose_name='分类') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='published', verbose_name='状态') views_count = models.IntegerField(default=0, verbose_name='浏览次数') created_at = models.DateTimeField(auto_now_add=True, verbose_name='发布时间') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') class Meta: ordering = ['-created_at'] verbose_name = '供求信息' verbose_name_plural = verbose_name def __str__(self): return self.title分类的on_delete=models.PROTECT是刻意的:如果分类下面还有信息,就不允许直接删分类,防止出现“孤儿数据”。这在真实项目里比CASCADE更安全。
写好模型后,执行迁移命令:
python manage.py makemigrations python manage.py migrate4. 后端接口开发与核心逻辑实现
4.1 接口设计概览
前后端分离项目的关键约定是接口规范。我习惯使用 RESTful 风格,资源用名词,动作用 HTTP 方法。供求信息网站点主要接口如下:
| 方法 | 路径 | 功能 | 是否需要登录 |
|---|---|---|---|
| POST | /api/users/register/ | 用户注册 | 否 |
| POST | /api/users/login/ | 用户登录 | 否 |
| GET | /api/category/list/ | 分类列表 | 否 |
| GET | /api/info/list/ | 信息列表(支持分页、搜索、筛选) | 否 |
| GET | /api/info/detail/{id}/ | 信息详情 | 否 |
| POST | /api/info/publish/ | 发布信息 | 是 |
| PUT | /api/info/{id}/update/ | 修改自己的信息 | 是 |
| DELETE | /api/info/{id}/delete/ | 下架删除 | 是 |
| POST | /api/favorite/add/ | 收藏 | 是 |
| DELETE | /api/favorite/remove/ | 取消收藏 | 是 |
4.2 实现列表接口:最关键的一个接口
列表接口是整个项目最常被调用的接口,也是性能关键点。它要支持分页、分类筛选、关键词搜索、城市筛选、排序。
用 DRF 实现时,我推荐用ListAPIView搭配filter_backends:
# apps/info/views.py from rest_framework import generics, filters from rest_framework.pagination import PageNumberPagination from .models import Info from .serializers import InfoListSerializer class InfoPagination(PageNumberPagination): page_size = 12 page_size_query_param = 'page_size' max_page_size = 50 class InfoListView(generics.ListAPIView): queryset = Info.objects.filter(status='published').select_related('category', 'publisher') serializer_class = InfoListSerializer pagination_class = InfoPagination filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ['title', 'content'] ordering_fields = ['created_at', 'views_count', 'price'] ordering = ['-created_at'] def get_queryset(self): queryset = super().get_queryset() category_id = self.request.query_params.get('category') city = self.request.query_params.get('city') keyword = self.request.query_params.get('keyword') if category_id: # 包含子分类 child_ids = Category.objects.filter(parent_id=category_id).values_list('id', flat=True) queryset = queryset.filter(category_id__in=[category_id, *child_ids]) if city: queryset = queryset.filter(city=city) if keyword: queryset = queryset.filter(Q(title__icontains=keyword) | Q(content__icontains=keyword)) return querysetselect_related的作用是连表查询时一次性把外键对象加载出来,避免循环查询。如果没有这行,列表页渲染 20 条信息就会产生 20 次额外的分类查询和用户查询,数据库压力会大很多。这是新手最容易忽略的性能问题。
4.3 发布与修改信息的权限控制
发布信息必须校验用户登录。Django 自带IsAuthenticated权限类,直接配置在视图上即可。
from rest_framework.permissions import IsAuthenticated class InfoPublishView(generics.CreateAPIView): queryset = Info.objects.all() serializer_class = InfoPublishSerializer permission_classes = [IsAuthenticated] def perform_create(self, serializer): serializer.save(publisher=self.request.user)修改和删除时,要额外校验“这条信息是不是当前用户发布的”。这里的写法值得注意:不能只靠get_object(),要同时过滤publisher字段。
from rest_framework.exceptions import PermissionDenied class InfoUpdateView(generics.UpdateAPIView): queryset = Info.objects.all() serializer_class = InfoUpdateSerializer permission_classes = [IsAuthenticated] def get_queryset(self): return super().get_queryset().filter(publisher=self.request.user) def perform_update(self, serializer): # 防止用户把状态随意改回 published if serializer.instance.status != 'published' and serializer.validated_data.get('status') == 'published': raise PermissionDenied('该信息已下架,不能重新上架,请联系管理员') serializer.save()4.4 用户注册登录的逻辑
用户注册用 Django 自带的 User 模型,但密码字段需要处理。DRF 的CreateAPIView默认不会自动调用create_user,所以要在序列化器里重写create方法:
from django.contrib.auth.models import User from django.contrib.auth.hashers import make_password from rest_framework import serializers class RegisterSerializer(serializers.ModelSerializer): username = serializers.CharField() password = serializers.CharField(write_only=True, min_length=6) confirm_password = serializers.CharField(write_only=True) class Meta: model = User fields = ['username', 'password', 'confirm_password'] def validate_username(self, value): if User.objects.filter(username=value).exists(): raise serializers.ValidationError('用户名已存在') return value def validate(self, attrs): if attrs['password'] != attrs['confirm_password']: raise serializers.ValidationError('两次密码不一致') return attrs def create(self, validated_data): validated_data.pop('confirm_password') user = User.objects.create_user( username=validated_data['username'], password=validated_data['password'] ) return user登录用 DRF 自带的TokenAuthentication最省事。配置好后,登录接口返回一个 token,前端把它存起来,后续请求带在 Header 里:
Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b在 settings.py 里配置认证方式:
REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.TokenAuthentication', 'rest_framework.authentication.SessionAuthentication', ], 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 12, }如果要做长期项目,建议后面换成 JWT 认证,因为 token 可以设置过期时间,更安全。但做课程设计或快速上线,DRF 自带的 token 足够。
5. 前端核心页面与交互实现
5.1 前端整体路由结构
前端路由用 vue-router 4,按页面拆分成这样:
// src/router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', name: 'home', component: () => import('../views/Home.vue') }, { path: '/category/:id', name: 'category-list', component: () => import('../views/CategoryList.vue') }, { path: '/info/:id', name: 'info-detail', component: () => import('../views/InfoDetail.vue') }, { path: '/publish', name: 'info-publish', component: () => import('../views/InfoPublish.vue'), meta: { requiresAuth: true } }, { path: '/login', name: 'login', component: () => import('../views/Login.vue') }, { path: '/register', name: 'register', component: () => import('../views/Register.vue') }, { path: '/user/center', name: 'user-center', component: () => import('../views/UserCenter.vue'), meta: { requiresAuth: true } }, { path: '/user/favorites', name: 'user-favorites', component: () => import('../views/UserFavorites.vue'), meta: { requiresAuth: true } }, { path: '/:pathMatch(.*)*', component: () => import('../views/NotFound.vue') } ] const router = createRouter({ history: createWebHistory(), routes }) // 全局前置守卫:未登录不能访问需要认证的页面 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ name: 'login', query: { redirect: to.fullPath } }) } else { next() } }) export default router5.2 列表页核心实现:信息卡片流
信息列表页是用户最常看到的页面,需要考虑加载状态、空状态、分页逻辑。Element Plus 的el-card配合 v-for 就能做出不错的列表。
<!-- src/views/Home.vue(模板部分) --> <template> <div class="info-list"> <el-input v-model="keyword" placeholder="搜索标题或内容" clearable @keyup.enter="handleSearch" /> <el-select v-model="categoryId" placeholder="选择分类" clearable @change="handleSearch"> <el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /> </el-select> <el-button type="primary" @click="handleSearch">搜索</el-button> <div v-loading="loading" class="card-grid"> <el-card v-for="info in infoList" :key="info.id" class="info-card" @click="goDetail(info.id)"> <template #header> <div class="card-header"> <span>{{ info.title }}</span> </div> </template> <p class="price">¥{{ info.price }}</p> <p class="meta">{{ info.city }} · {{ info.category_name }} · {{ info.created_at }}</p> </el-card> </div> <el-pagination v-model:current-page="currentPage" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="fetchList" /> </div> </template>对应的请求逻辑,我通常把所有接口统一封装在src/api/info.js里:
import request from '../utils/request' export function getInfoList(params) { return request.get('/api/info/list/', { params }) } export function getInfoDetail(id) { return request.get(`/api/info/detail/${id}/`) } export function publishInfo(data) { return request.post('/api/info/publish/', data) }src/utils/request.js是 axios 实例,负责拦截器和 token 注入:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' const request = axios.create({ baseURL: '/', timeout: 10000 }) // 请求拦截器:自动带上 token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Token ${token}` } return config }) // 响应拦截器:统一处理错误 request.interceptors.response.use( response => response.data, error => { const status = error.response?.status if (status === 401) { localStorage.removeItem('token') router.push({ name: 'login' }) } else if (status === 403) { ElMessage.error('没有权限执行此操作') } else if (status >= 500) { ElMessage.error('服务器开小差了,请稍后重试') } else { const msg = error.response?.data?.detail || '请求失败' ElMessage.error(msg) } return Promise.reject(error) } ) export default request5.3 发布页的关键校验
发布信息表单前后端都要校验。前端校验是为了用户体验,后端校验是为了数据安全,两者一个都不能少。
发布页的表单字段包括:标题、分类、城市、区域、价格、联系人、联系电话、详细描述、图片上传。
Element Plus 的表单校验规则:
const publishRules = { title: [ { required: true, message: '请输入标题', trigger: 'blur' }, { min: 5, max: 100, message: '标题长度在 5 到 100 个字符之间', trigger: 'blur' } ], category: [ { required: true, message: '请选择分类', trigger: 'change' } ], contact_phone: [ { required: true, message: '请输入联系电话', trigger: 'blur' }, { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' } ] }图片上传用 Element Plus 的el-upload,要注意后端接收文件的接口需要支持multipart/form-data。我通常会配置 upload 组件的action指向后端的图片上传接口,或者用http-request自定义上传逻辑。
5.4 详情页浏览量统计
详情页有个小逻辑:每次打开详情,浏览数加 1。但要防止刷新一次就重复计数,这里使用 sessionStorage 来标记,同一会话内只统计一次。
const infoId = route.params.id const viewedKey = `info_viewed_${infoId}` if (!sessionStorage.getItem(viewedKey)) { await incrementViews(infoId) sessionStorage.setItem(viewedKey, '1') }后端对应接口:
class InfoDetailView(generics.RetrieveAPIView): queryset = Info.objects.all() serializer_class = InfoDetailSerializer def retrieve(self, request, *args, **kwargs): instance = self.get_object() # 浏览量自增 Info.objects.filter(pk=instance.pk).update(views_count=F('views_count') + 1) serializer = self.get_serializer(instance) data = serializer.data data['views_count'] = instance.views_count + 1 return Response(data)这里的F('views_count')是数据库层面的原子操作,避免并发时计数不准确。
5.5 用户中心
用户中心主要展示用户发布的信息列表和收藏列表。我把它做成一个带标签页的布局,用el-tabs区分“我发布的”和“我的收藏”。
发布管理里面有“编辑”“下架”“删除”三个操作。下架使用 PUT 请求把状态改为disabled,不是直接删除,这样可以保留数据,后续管理员也有据可查。
6. 联调、部署与常见问题排查
6.1 跨域问题:最典型的联调痛点
开发环境下,即使 Vite 配了代理,如果路径没写对,还是会遇到跨域。生产环境前后端分离部署后,跨域更是家常便饭。我建议后端直接配置django-cors-headers,这样无论从哪个域名访问 API 都能兼容。
settings.py 里配置:
INSTALLED_APPS = [ ... 'corsheaders', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', ... ] CORS_ALLOW_ALL_ORIGINS = True # 开发环境先这样,生产改成具体域名注意:
CorsMiddleware要放在CommonMiddleware之前,否则某些场景下跨域响应头不会生效。
6.2 生产部署方案
我推荐部署方案是:Nginx + Gunicorn + Django + MySQL,前端构建后的静态文件交给 Nginx。
前端构建:
npm run build生成dist目录,把里面内容上传到服务器,比如放到/var/www/supply_frontend/。
后端用 Gunicorn 跑:
gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 3Nginx 配置示例:
server { listen 80; server_name your-domain.com; # 前端静态文件 root /var/www/supply_frontend; 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; } # 上传的图片等静态资源 location /media/ { alias /path/to/backend/media/; } }try_files $uri $uri/ /index.html是 SPA 路由的核心,没有这一行,刷新非首页路由时会 404。
6.3 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 前端请求接口报 404 | Nginx 配了location /的 try_files,但没把/api单独转发 | 检查 Nginx 配置文件,确保/api/有独立 location |
| 接口报 403 跨域 | 没安装 corsheaders 或配置顺序不对 | 参考 6.1 配置 |
| 用户登录后刷新 token 消失 | localStorage 存了 token,但刷新时没有做用户信息恢复 | 页面加载时根据 token 调一次/api/users/profile/ |
| 图片上传成功但访问 404 | Django 生产环境没有配置 MEDIA_URL 的静态服务 | Nginx 里加/media/的 alias |
| 时间显示是 UTC | Django 默认时区不是本地时区 | settings.py 里改TIME_ZONE = 'Asia/Shanghai'并设USE_TZ = True |
| 修改密码后旧 token 仍有效 | DRF Token 不会自动失效 | 需要重写 token 刷新逻辑,删除旧 token 再生成新 token |
6.4 一个容易被忽略的性能问题:N+1 查询
列表接口我做过性能优化,用select_related预查询外键关系。如果你发现接口响应越来越慢,首先要怀疑的就是 N+1 查询。
最简单的方法:Django Debug Toolbar 看一下 SQL 执行数量。如果列表页 20 条数据却执行了几十次 SQL,那基本就是没用select_related或prefetch_related。
再就是给常用的查询字段加索引:
class Info(models.Model): ... class Meta: indexes = [ models.Index(fields=['status', '-created_at']), models.Index(fields=['category', 'status']), ]加索引后按状态排序、按分类筛选这两类高频查询会有明显改善。
7. 功能扩展与后续优化建议
7.1 从信息展示升级为带互动的平台
基础供求信息网做完后,想让它更像一个完整产品,可以考虑加这些功能:
信息审核机制。用户发布的信息先进入“审核中”状态,管理员在 Django Admin 后台审核通过后才公开展示。实现上就是在信息表加status字段,管理员可改。这个功能对真实运营几乎是必须的,否则垃圾信息会淹没平台。
站内消息。用户对某条信息感兴趣时,可以点击“联系发布者”,给发布者发站内消息。用 Django Channels 可以做 WebSocket 实时通知,或者先用简单的表实现站内信。
信息到期自动下架。普通分类信息有有效期,比如 30 天。定时任务用 Celery beat 每天跑一次,把超过有效期仍未续期的信息状态改为disabled。
地图展示。如果用到了“城市”和“区域”字段,可以在详情页集成腾讯地图或高德地图,把位置用经纬度展示出来。前端用地图 JS SDK,后端给经纬度字段即可。
7.2 Vue 前端后续可以打磨的点
前端有太多细节能提升体验:
- 骨架屏:列表页数据加载前,用
el-skeleton显示占位,视觉上比 loading 转圈高级很多; - 虚拟滚动:如果列表页一页 50 条、每条卡片信息量大,页面会卡,可以用虚拟滚动组件优化;
- 富文本编辑器:信息描述支持富文本后,用户体验提升明显,可以集成 wangEditor 或 Quill;
- 图片懒加载:使用 v-lazy 指令,图片进入视口再加载,减少首屏流量。
7.3 数据层面要考虑的问题
随着用户量上来,搜索就是个大问题。早期用title__icontains做模糊搜索,数据量到十万条以后性能会明显下降。这时候有两条路:
一是用 MySQL 全文索引(FULLTEXT),简单有效,但分词效果一般;
二是引入 Elasticsearch 或 Meilisearch 做专业搜索。Meilisearch 部署简单,支持中文分词,对个人项目来说性价比最高。
考虑到排序和筛选,可以把价格、浏览量、发布时间等字段建联合索引。如果线上数据多,建议再引入 Redis 做列表页缓存,把热门的分类列表页缓存 60 秒,压力立刻缓解。
8. 写在最后的实操心得
做完全部功能后,我最大的感受是:这个项目真正考验人的不是某个单一技术,而是前后端联调时的整体思维。
比如列表页的分页,前端展示的页码要和后端page参数对齐;筛选条件一多,前端要正确拼接 query 参数,后端要用filter_backends正确解析;上传图片时,前端要处理 FormData,后端要处理 request.FILES。每个环节都不难,但串起来就是一个完整的项目经验。
我自己踩过的一个印象深刻的坑是:开发时一直用 Vite 代理,觉得跨域不是问题,结果部署到服务器后,用户浏览器请求接口直接报 CORS 错误。原因就是后端没配 corsheaders,开发环境被 Vite 代理掩盖了问题。现在我做前后端分离项目,第一步就会把后端 CORS 配好,这样开发、测试、生产环境的行为才一致。
还有一个经验是:数据库表设计一定要预留扩展字段。比如信息表一开始就加了status字段,后来加审核功能时基本没改表结构,只是加了几个枚举值。如果当初没设计这个字段,后面改起来就要迁移数据,非常麻烦。
如果有读者想在这套基础上继续扩展,我建议先做管理员后台的数据统计模块,把每日发布量、分类分布、热门城市这些数据可视化出来,既能作为面试时展示的项目亮点,也是让平台从“能用”走向“好用”的第一步。