做了几年Web开发,前后端分离的项目没少碰,但像“基于python都市供求信息网vue”这种练手项目,反而是我每次带新人时最喜欢推荐的。原因很简单:分类信息网站的业务逻辑不复杂,但链路极其完整——用户注册登录、发布信息、分类浏览、关键词搜索、详情展示、联系方式获取,每一步都踩在真实项目的痛点上。用Python(我习惯用Django)做后端接口,Vue做前端页面,正好能把前端路由、状态管理、接口联调、文件上传这些核心技术点全部过一遍。
这个项目做完之后,你手里等于有了一套可以反复复用的“分类信息平台骨架”。想做二手交易、同城服务、招聘求职,甚至企业内部的需求公告栏,都是在这个骨架上改改字段、换个皮肤的事。这篇文章不聊空理论,我直接把从数据库设计到前端页面、再到部署上线的完整思路和核心代码捋一遍,重点讲清楚每一步为什么这么设计、踩过哪些坑。
1. 项目定位:一套能跑通的分类信息平台骨架
1.1 这类网站的商业模式与用户链路
都市供求信息网,说白了就是轻量版的分类信息平台。传统上这类网站靠信息发布费和置顶推广赚钱,但作为技术项目,我们更关注的是它的核心用户链路:
- 访客进入首页,看到分类导航和信息列表。
- 通过搜索或分类筛选,缩小范围找到感兴趣的信息。
- 点击进入详情页,获取发布者留下的联系方式。
- 注册登录的用户,可以发布自己的供求信息、管理自己发布过的内容。
这条链路非常典型,覆盖了绝大多数Web应用的CRUD、权限控制、搜索、文件上传等基本场景。而且分类信息网站天然适合做前后端分离——浏览者看列表不需要刷新页面,发布表单需要实时校验,这些交互用Vue做体验会好很多。
1.2 需求整理:先做核心闭环,再谈扩展
在动手写代码之前,一定要先把需求理清楚。我给V1.0版本划分了明确的边界:
| 模块 | 功能点 | 优先级 |
|---|---|---|
| 用户端 | 注册、登录、退出登录 | 必须 |
| 分类管理 | 后台维护分类,前端按层级展示 | 必须 |
| 信息发布 | 选择分类、填标题和内容、上传图片、留联系方式 | 必须 |
| 信息浏览 | 列表分页、分类筛选、关键词搜索 | 必须 |
| 信息详情 | 展示完整内容、查看联系方式 | 必须 |
| 个人中心 | 我发布的信息列表、编辑、下架 | 建议 |
| 后台审核 | 管理员审核信息是否公开可见 | 可选 |
这里有个重要的设计判断:V1.0不要做在线聊天、不要做支付、不要做地理定位。这些功能会大量分散精力,而核心闭环——从发布到被看到——反而不容易打磨好。把基础链路跑通,后续扩展都在骨架上做加法,这是做这类项目最务实的策略。
1.3 技术选型:Django + Vue 这对组合好在哪
后端我选Django而不是Flask,原因有三点:
第一,Django自带Admin后台,等于免费送了一个信息管理后台。分类维护、用户管理、信息审核,这些运营操作在Admin里几分钟就能配好,不用额外写管理端前端页面。
第二,Django的ORM比裸写SQL省心太多。对于供求信息这种需要频繁联表查询(用户、分类、信息)的业务,ORM的联表查询和序列化配合DRF(Django REST Framework)用起来非常顺手。
第三,Django自带用户认证体系,注册登录可以直接基于AbstractUser扩展,省掉了自己设计密码加密和会话管理的时间。
前端Vue选择Vue 3 + Vite + Element Plus这套组合。Vue 3的Composition API在处理表单校验、搜索防抖这类逻辑时比Options API更直观;Element Plus则提供了现成的表单、表格、上传组件,能省去大量样式调试时间。
2. 后端搭建:数据库模型与REST接口
2.1 数据库设计:三张核心表搞定大部分业务
数据库是整个项目的根基,设计得好不好直接决定后续开发的顺畅程度。这个项目最核心的是三张表:用户表、分类表、信息表。
先看用户表。我没有直接用Django内置的User,而是继承AbstractUser做扩展,增加手机号和头像字段:
# users/models.py from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): phone = models.CharField(max_length=11, blank=True, null=True, verbose_name="手机号") avatar = models.ImageField(upload_to="avatars/", blank=True, null=True, verbose_name="头像") class Meta: verbose_name = "用户" verbose_name_plural = verbose_name接着是分类表。分类表设计成单表自关联(通过parent字段指向自己),这样既能支持一层简单分类,也能扩展成两级分类:
# category/models.py class Category(models.Model): name = models.CharField(max_length=32, verbose_name="分类名称") parent = models.ForeignKey("self", on_delete=models.CASCADE, blank=True, null=True, related_name="children", verbose_name="父分类") sort_order = models.IntegerField(default=0, verbose_name="排序权重") icon = models.CharField(max_length=64, blank=True, verbose_name="图标类名") class Meta: ordering = ["sort_order", "id"] verbose_name = "分类" verbose_name_plural = verbose_name def __str__(self): return self.name最后是信息表。这是整个项目最核心的表,字段需要覆盖业务和运营两个维度:
# posts/models.py class Post(models.Model): STATUS_CHOICES = [ ("pending", "待审核"), ("published", "已发布"), ("rejected", "已拒绝"), ("offline", "已下架"), ] user = models.ForeignKey("users.User", on_delete=models.CASCADE, related_name="posts", verbose_name="发布者") category = models.ForeignKey("category.Category", on_delete=models.PROTECT, related_name="posts", verbose_name="分类") title = models.CharField(max_length=64, verbose_name="标题") content = models.TextField(verbose_name="内容描述") price = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True, verbose_name="价格") image = models.ImageField(upload_to="post_images/", blank=True, null=True, verbose_name="封面图") contact = models.CharField(max_length=64, verbose_name="联系方式") view_count = models.PositiveIntegerField(default=0, verbose_name="浏览次数") status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="published", 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字段设计上有一个容易被忽略的细节:category使用on_delete=models.PROTECT而不是CASCADE。意思是如果某个分类下已经发布了信息,那么这个分类不允许被删掉。这么设计是为了保护数据完整性——运营手滑删掉一个分类,可能导致几十条信息变成无分类的孤儿数据。真正要调整分类,应该先转移信息或下线信息,再对分类做处理。
2.2 用DRF把接口串起来
数据模型定好之后,下一步就是写接口。这里我用Django REST Framework的ViewSet+ModelSerializer组合,能省掉大量重复代码。
Serializer主要解决两个问题:一是把ORM对象转成前端友好的JSON格式,二是在创建和更新时做字段校验。
# posts/serializers.py from rest_framework import serializers from .models import Post class PostListSerializer(serializers.ModelSerializer): # 返回分类名和发布者昵称,避免前端拿到ID后还要再查一次 category_name = serializers.CharField(source="category.name", read_only=True) username = serializers.CharField(source="user.username", read_only=True) class Meta: model = Post fields = ["id", "title", "category_name", "price", "image", "view_count", "created_at", "username"] class PostDetailSerializer(serializers.ModelSerializer): category_name = serializers.CharField(source="category.name", read_only=True) username = serializers.CharField(source="user.username", read_only=True) class Meta: model = Post fields = "__all__" extra_kwargs = {"contact": {"write_only": True}}注意PostDetailSerializer里我把contact设置成了write_only,意思是写入时必须提供,但读取详情时默认不返回。为什么这么做?因为联系方式是敏感信息,如果列表接口也返回联系方式,一个爬虫就能轻松抓走全站用户的手机号和微信。正确的做法是:列表接口只返回脱敏信息,详情接口配合用户登录状态再返回完整联系方式。这个设计对分类信息网站尤其重要,防爬取要从接口设计就开始。
然后用ViewSet把接口动作定义出来:
# posts/views.py from rest_framework import viewsets, permissions, filters from django_filters.rest_framework import DjangoFilterBackend from .models import Post from .serializers import PostListSerializer, PostDetailSerializer class PostViewSet(viewsets.ModelViewSet): queryset = Post.objects.filter(status="published") filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_fields = ["category_id"] search_fields = ["title", "content"] def get_serializer_class(self): if self.action == "list": return PostListSerializer return PostDetailSerializer def get_permissions(self): # 查看列表和详情无需登录,发布/修改/删除必须登录 if self.action in ["create", "update", "partial_update", "destroy"]: return [permissions.IsAuthenticated()] return [permissions.AllowAny()] def perform_create(self, serializer): serializer.save(user=self.request.user) def retrieve(self, request, *args, **kwargs): # 每次点击详情,浏览次数+1 instance = self.get_object() instance.view_count += 1 instance.save(update_fields=["view_count"]) return super().retrieve(request, *args, **kwargs)有几个点值得展开说说。
第一,queryset里直接过滤了status="published",这样所有未审核通过的信息在接口层面就被屏蔽了,前端不需要关心状态逻辑。但这里有个隐患:信息发布者本人应该能看到自己“待审核”的信息。所以更严谨的写法是:
def get_queryset(self): if self.request.user.is_authenticated: return Post.objects.filter(Q(status="published") | Q(user=self.request.user)) return Post.objects.filter(status="published")这种细节虽然代码量不大,但它是真实业务和Demo项目的分水岭。
第二,perform_create里直接用self.request.user作为发布者,前端不需要传用户ID,避免有人伪造别人的ID发布信息。
第三,浏览次数+1的逻辑放在retrieve里。实际项目中可以做更细的优化,比如同一个用户在短时间内多次刷新只加一次,或者把计数放到Redis里异步写入。但V1.0版本直接在请求里更新已经够用,重点是记录这个思路。
路由配置更简单:
# config/urls.py from rest_framework.routers import DefaultRouter from posts.views import PostViewSet router = DefaultRouter() router.register("posts", PostViewSet, basename="post") urlpatterns = [ path("api/", include(router.urls)), ]2.3 图片上传与静态资源访问
图片上传是信息发布类网站的刚需。后端处理的关键是配置好文件存储路径和访问URL。在Django的settings.py里做如下配置:
MEDIA_URL = "/media/" MEDIA_ROOT = os.path.join(BASE_DIR, "media")然后开发环境下需要在主路由里加一层:
from django.conf import settings from django.conf.urls.static import static urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)图片上传接口用DRF的FileUploadParser或者直接用APIView都行。我习惯写一个独立的上传接口,原因后面讲前端时细说:
# upload/views.py from rest_framework.views import APIView from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.response import Response from rest_framework import status from django.conf import settings class ImageUploadView(APIView): parser_classes = [MultiPartParser, FormParser] def post(self, request, format=None): file = request.FILES.get("file") if not file: return Response({"error": "未收到文件"}, status=status.HTTP_400_BAD_REQUEST) # 校验文件类型 allow_types = ["image/jpeg", "image/png", "image/webp", "image/gif"] if file.content_type not in allow_types: return Response({"error": "不支持的图片格式"}, status=status.HTTP_400_BAD_REQUEST) # 校验文件大小,限制最大5MB if file.size > 5 * 1024 * 1024: return Response({"error": "图片大小不能超过5MB"}, status=status.HTTP_400_BAD_REQUEST) # 调用Django默认的FileSystemStorage保存文件 from django.core.files.storage import default_storage path = default_storage.save(f"post_images/{file.name}", file) url = request.build_absolute_uri(settings.MEDIA_URL + path) return Response({"url": url}, status=status.HTTP_201_CREATED)文件类型和大小校验必须在后端做,前端校验只能提升用户体验,不能作为安全边界。如果上线到公网,我还会建议加一层图片内容安全检测,但V1.0阶段类型、大小、后缀名三件套校验已经可以挡住大部分基础问题。
2.4 查询优化与分页参数
分类信息网站最大的特点就是列表页信息量大,如果不做分页,一次查几千条数据返回给前端,响应时间会很难看。DRF默认提供了分页类,我在settings里做了统一配置:
REST_FRAMEWORK = { "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", "PAGE_SIZE": 10, "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], }前端请求接口时,通过URL参数控制页码和筛选条件:
?page=1获取第一页,每页10条?category_id=3按分类筛选?search=沙发按关键词搜索标题和内容
分类筛选和搜索可能同时生效,DRF的FilterBackend会把这些条件组合成一条SQL,不需要额外处理。
关于分页大小,我建议列表页用10到15条比较合适。太少了用户要频繁翻页,太多了首屏加载慢。移动端甚至可以考虑把PAGE_SIZE改成6,和屏幕一般展示的卡片数量匹配。
3. 前端实现:Vue 页面从零搭建
3.1 初始化项目与开发环境配置
前端我用Vite创建Vue 3项目,命令很简单:
npm create vite@latest supply-frontend -- --template vue cd supply-frontend npm install npm install axios element-plus vue-router @element-plus/icons-vue项目创建后,我会第一时间做几件事:
第一,配置Axios实例,统一处理接口地址和错误提示:
// src/utils/request.js import axios from "axios"; import { ElMessage } from "element-plus"; import router from "@/router"; const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || "/api", 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) => { if (error.response?.status === 401) { localStorage.removeItem("token"); router.push("/login"); } else { ElMessage.error(error.response?.data?.error || "请求失败"); } return Promise.reject(error); } ); export default request;注意baseURL这里读的是import.meta.env.VITE_API_BASE_URL,这是Vite的环境变量机制。在项目根目录的.env.development文件里写:
VITE_API_BASE_URL=/api开发环境下通过Vite的代理把/api转发到Django服务,这个后面联调部分细说。生产环境部署时,再把环境变量改成实际的后端域名。
第二,配置Vue Router和静态布局。整体布局采用经典的“顶部导航 + 左侧分类 + 右侧内容”结构:
// src/router/index.js import { createRouter, createWebHistory } from "vue-router"; const routes = [ { path: "/", component: () => import("@/views/Home.vue") }, { path: "/post/:id", component: () => import("@/views/PostDetail.vue") }, { path: "/publish", component: () => import("@/views/Publish.vue"), meta: { requiresAuth: true } }, { path: "/login", component: () => import("@/views/Login.vue") }, { path: "/register", component: () => import("@/views/Register.vue") }, { path: "/mine", component: () => import("@/views/MyPosts.vue"), meta: { requiresAuth: true } }, ]; const router = createRouter({ history: createWebHistory(), routes, }); // 路由守卫:需要登录的页面跳转前检查token router.beforeEach((to, from, next) => { const token = localStorage.getItem("token"); if (to.meta.requiresAuth && !token) { next("/login"); } else { next(); } }); export default router;第三步,在main.js里注册Element Plus和路由:
// src/main.js import { createApp } from "vue"; import ElementPlus from "element-plus"; import "element-plus/dist/index.css"; import App from "./App.vue"; import router from "./router"; import request from "./utils/request"; const app = createApp(App); app.use(ElementPlus); app.use(router); // 把请求对象挂到全局,页面里直接用 this.$request app.config.globalProperties.$request = request; app.mount("#app");3.2 首页信息流:分类导航 + 列表 + 搜索
首页是用户感知项目的第一眼,结构上我分三个区域:顶部分类导航、中间的搜索栏、下方的信息列表。
分类导航从后端接口拉取,渲染成标签按钮:
<!-- Home.vue 核心部分 --> <template> <div class="home"> <!-- 搜索栏 --> <el-input v-model="keyword" placeholder="搜索二手物品、房屋租售、招聘信息..." clearable @input="handleSearch" @keyup.enter="fetchPosts" > </el-input> <!-- 分类导航 --> <div class="category-nav"> <el-tag :type="activeCategory === '' ? 'primary' : 'info'" @click="switchCategory('')" style="cursor: pointer" > 全部 </el-tag> <el-tag v-for="cat in categories" :key="cat.id" :type="activeCategory === cat.id ? 'primary' : 'info'" @click="switchCategory(cat.id)" style="cursor: pointer; margin-left: 8px" > {{ cat.name }} </el-tag> </div> <!-- 信息列表 --> <div class="post-list" v-loading="loading"> <el-card v-for="item in posts" :key="item.id" class="post-card" @click="goDetail(item.id)"> <div class="post-content"> <div class="post-title">{{ item.title }}</div> <div class="post-meta"> <span>{{ item.category_name }}</span> <span>{{ formatTime(item.created_at) }}</span> <span>{{ item.username }}</span> <span>{{ item.view_count }}次浏览</span> </div> </div> <div class="post-price" v-if="item.price">{{ item.price }}元</div> </el-card> </div> <el-pagination background layout="prev, pager, next" :total="total" :page-size="10" v-model:current-page="currentPage" @current-change="fetchPosts" /> </div> </template>这里的搜索框我用了@input="handleSearch"做实时搜索,但要做防抖处理,否则用户每敲一个字母就会发一次请求,后端压力大,前端也会频繁渲染列表。防抖的简单实现:
let timer = null; const handleSearch = () => { clearTimeout(timer); timer = setTimeout(() => { currentPage.value = 1; fetchPosts(); }, 500); };防抖时间设500毫秒,打字停顿超过半秒才发起请求,实测体验和性能都在可接受范围。
fetchPosts的逻辑就是组装查询参数并调用接口:
const fetchPosts = async () => { loading.value = true; try { const params = { page: currentPage.value, page_size: 10, }; if (activeCategory.value) params.category_id = activeCategory.value; if (keyword.value.trim()) params.search = keyword.value.trim(); const res = await request.get("/posts/", { params }); posts.value = res.results; total.value = res.count; } finally { loading.value = false; } };这里有个小细节:DRF默认分页返回的数据结构是{count, next, previous, results},所以前端要取res.results而不是res.data。很多新人在这里踩坑,接口返回200但页面上始终是空的,排查半天发现是数据结构对不上。
3.3 发布信息页:表单校验与图片上传
发布信息页是整个项目交互最重的页面,包含分类选择、标题、内容、价格、联系方式、图片上传六个字段,再加上表单校验逻辑。
先看模板:
<!-- Publish.vue --> <template> <el-form ref="formRef" :model="form" :rules="rules" label-width="100px"> <el-form-item label="分类" prop="category_id"> <el-select v-model="form.category_id" placeholder="请选择分类"> <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" /> </el-select> </el-form-item> <el-form-item label="标题" prop="title"> <el-input v-model="form.title" maxlength="64" show-word-limit placeholder="一句话说清楚你要发布什么" /> </el-form-item> <el-form-item label="详细描述" prop="content"> <el-input v-model="form.content" type="textarea" :rows="6" placeholder="物品成色、交易方式、地址等详细信息" /> </el-form-item> <el-form-item label="价格" prop="price"> <el-input-number v-model="form.price" :min="0" :precision="2" placeholder="面议可不填" /> </el-form-item> <el-form-item label="联系方式" prop="contact"> <el-input v-model="form.contact" placeholder="手机号、微信或QQ" /> </el-form-item> <el-form-item label="封面图"> <el-upload :show-file-list="false" :http-request="handleUpload" accept="image/*" > <img v-if="form.image" :src="form.image" class="upload-preview" /> <el-button v-else>上传封面图</el-button> </el-upload> </el-form-item> <el-form-item> <el-button type="primary" @click="submitForm" :loading="submitting">发布</el-button> <el-button @click="resetForm">重置</el-button> </el-form-item> </el-form> </template>表单校验规则按字段类型设置:
const rules = { category_id: [{ required: true, message: "请选择分类", trigger: "change" }], title: [ { required: true, message: "请输入标题", trigger: "blur" }, { min: 4, max: 64, message: "标题长度4-64个字", trigger: "blur" }, ], content: [{ required: true, message: "请填写详细描述", trigger: "blur" }], contact: [{ required: true, message: "请留下联系方式", trigger: "blur" }], };图片上传这里要重点说。Element Plus的el-upload组件有两种用法:一种是直接传action属性,组件自己发请求;另一种是用http-request自定义上传函数。我强烈建议用第二种,原因有三个:
第一,组件虽然能直接传action,但默认不带Authorization请求头,如果后端接口需要登录认证,图片上传就会401。
第二,直接传action意味着图片在表单提交前就已经传到服务器了,如果用户最后取消发布,服务器上会残留一堆孤儿图片文件。
第三,用http-request自定义上传,我可以先手动校验图片大小和类型,再发请求,拿到URL后暂存在表单里,等用户点击“发布”时才把图片URL随其他字段一起提交。
具体实现:
const handleUpload = async (options) => { const file = options.file; // 前端再做一层校验 if (file.size > 5 * 1024 * 1024) { ElMessage.error("图片不能超过5MB"); return; } const formData = new FormData(); formData.append("file", file); const res = await request.post("/upload/", formData, { headers: { "Content-Type": "multipart/form-data" }, }); form.value.image = res.url; };提交表单时用$refs.formRef.validate()做整体校验,全部通过后POST到/posts/:
const submitForm = async () => { try { await formRef.value.validate(); } catch (e) { return; } submitting.value = true; try { await request.post("/posts/", form.value); ElMessage.success("发布成功"); router.push("/mine"); } finally { submitting.value = false; } };这里的form.value里包含了image字段(图片URL),因为Serializer里image字段被设计成ImageField,DRF在反序列化时直接接受URL字符串并解析成对应的文件路径,不需要前端再单独处理。
3.4 详情页与个人中心
详情页的核心是展示信息、调用浏览量接口、显示联系方式。设计上我将联系方式默认隐藏一截,用户点击“查看联系方式”按钮之后再展示完整内容。前端逻辑很简单:
<template> <div class="detail" v-if="post"> <h1>{{ post.title }}</h1> <div class="meta"> <span>{{ post.category_name }}</span> <span>{{ post.username }}</span> <span>发布于 {{ formatTime(post.created_at) }}</span> <span>{{ post.view_count }}次浏览</span> </div> <img v-if="post.image" :src="post.image" class="cover" /> <p class="content">{{ post.content }}</p> <div class="price" v-if="post.price">价格:{{ post.price }}元</div> <div class="contact-box"> <template v-if="showContact"> <el-tag type="success">{{ post.contact }}</el-tag> </template> <el-button v-else @click="revealContact">查看联系方式</el-button> </div> </div> </template>注意详情页的contact字段在Serializer里是write_only,所以默认接口返回里不会有。要拿到联系方式,需要写一个额外接口或者用不同的Serializer。我建议的做法是在PostViewSet里提供一个额外的action:
from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from .serializers import ContactSerializer @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) def contact(self, request, pk=None): post = self.get_object() if post.contact: return Response({"contact": post.contact}) return Response({"error": "该信息未留联系方式"}, status=404)这样前端点击“查看联系方式”时才向后端请求,而且要求用户必须登录,能在一定程度上防止爬虫批量抓取联系方式。个人中心页就更简单了,调/posts/?user_id=当前用户(DjangoFilterBackend直接支持)然后展示信息列表,提供下架操作。下架接口调用PATCH /posts/{id}/,但要把状态改成offline,这个逻辑可以在ViewSet的partial_update里覆盖或专门加一个action。
4. 前后端联调细节与排查清单
4.1 跨域问题与开发代理
前后端分离开发时,跨域是第一个绕不开的坎。Vite开发服务器在localhost:5173,Django在localhost:8000,默认情况下浏览器会拦截来自5173的跨域请求。
开发阶段最简单的做法不是在后端配CORS,而是在Vite的配置里加代理:
// vite.config.js export default defineConfig({ plugins: [vue()], server: { proxy: { "/api": { target: "http://127.0.0.1:8000", changeOrigin: true, }, "/media": { target: "http://127.0.0.1:8000", changeOrigin: true, }, }, }, });这样前端的/api和/media请求都会被转发到Django服务,浏览器看到的请求都来自同一个源,跨域问题直接消失。
但要注意,这只是开发环境的解决方案。生产环境如果前后端部署在不同域名,就必须在后端配置CORS白名单:
# settings.py INSTALLED_APPS = [ ... "corsheaders", ] MIDDLEWARE = [ "corsheaders.middleware.CorsMiddleware", ... ] CORS_ALLOWED_ORIGINS = [ "https://your-frontend-domain.com", ] # 如果还需要支持携带Authorization头,还要加: CORS_ALLOW_CREDENTIALS = True4.2 联调高频问题速查表
做这类前后端分离项目,有几个问题出现的频率特别高,我整理成速查表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 列表接口200但页面为空 | 数据结构取错了,DRF分页是res.results | Console打印res对象,核对字段 |
| POST发布一直401 | token没带上或过期 | 检查请求拦截器、Authorization头格式 |
| 图片上传403 | CSRF校验未处理或token缺失 | DRF的SessionAuthentication会检查CSRF |
| 发布成功但列表看不到 | 状态不是published,被get_queryset过滤 | 检查数据库status字段 |
| 详情页联系方式不显示 | Serializer设置了write_only | 确认是否调用了额外的contact接口 |
| 搜索中文没结果 | 数据库编码问题或search_fields没配 | 检查表字符集是否为utf8mb4 |
其实图片上传403是很多人的第一道坎。解决方案很简单,DRF里把默认认证类改成TokenAuthentication:
REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework.authentication.TokenAuthentication", ], }然后在INSTALLED_APPS里加rest_framework.authtoken,登录成功后下发token:
from rest_framework.authtoken.models import Token def login(request): # 登录逻辑... token, created = Token.objects.get_or_create(user=user) return Response({"token": token.key, "user_id": user.id})用了TokenAuthentication之后,前端请求通过Authorization: Token xxx携带凭证,不再走Session和CSRF那套,图片上传接口就不会被403拦住了。
4.3 防垃圾信息与数据安全
分类信息网站天然容易吸引垃圾信息——乱发广告的、留虚假联系方式的、挂钓鱼链接的。代码层面能做的防护有几点:
第一,发布频率限制。同一个用户短时间内不能连续发布多条信息。简单做法是在perform_create里加限流判断:
import datetime from rest_framework.exceptions import APIException def perform_create(self, serializer): user = self.request.user last_post = Post.objects.filter(user=user).first() if last_post and (datetime.datetime.now() - last_post.created_at).seconds < 60: raise APIException("发布太频繁,请稍后再试") serializer.save(user=user)更标准的做法是用DRF自带的throttle,按用户ID限流,但这个简单判断在初期完全够用。
第二,内容字段长度限制。标题64字、内容限制2000字,防止用户把大量垃圾文本塞进content字段。Serializer里可以加max_length限制:
content = serializers.CharField(max_length=2000)第三,联系方式脱敏。列表接口返回脱敏后的联系方式(比如只显示前三位和后四位),详情接口仅在登录后返回完整内容。前端展示时可以再做一层处理,但后端接口层面就要有意识控制敏感信息出口。
5. 部署上线与后续扩展
5.1 Nginx + uWSGI 分离部署
项目开发完成后要上线跑起来,前后端分离项目的部署方式比单体Django项目多一些步骤。我在服务器上的做法是:
前端打包:
npm run build会生成dist/目录,里面的内容是纯静态文件,部署到Nginx的静态文件目录即可。
后端用uWSGI启动Django服务,监听127.0.0.1:8001:
uwsgi --http 127.0.0.1:8001 --module config.wsgi --processes 4 --threads 2Nginx配置里做两层分发:
server { listen 80; server_name your-domain.com; # 前端静态文件 root /var/www/supply-frontend/dist; index index.html; # 前端路由history模式,所有非静态文件请求都返回index.html location / { try_files $uri $uri/ /index.html; } # API请求转发到后端 location /api/ { proxy_pass http://127.0.0.1:8001; 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 /var/www/supply-backend/media/; } }这里有个非常容易踩的坑:Nginx的try_files $uri $uri/ /index.html是history路由模式的关键,不写这一行的话,用户直接访问/publish会返回404,因为Nginx在静态目录里找不到publish这个文件。这也是SPA应用部署的标准配置,一定要记住。
生产环境别忘了执行Django的静态文件收集:
python manage.py collectstatic --noinput python manage.py migrate如果Django的DEBUG模式设为False,还需要在ALLOWED_HOSTS里加上服务器域名,否则会直接返回400。
5.2 搜索与性能优化路线
V1.0版本的搜索用的是数据库LIKE查询,数据量在几万条时没问题。但如果信息量涨到几十万条,LIKE查询的性能就会明显下降。届时可以考虑两条路线:
轻量方案:用Django的SearchVector(PostgreSQL全文检索)或者SQLite的FTS5虚拟表。优点是不引入新的服务,缺点是中文分词效果一般。
重量方案:引入Elasticsearch或者Meilisearch。数据量越大,搜索效果差异越明显,尤其是中文分词和搜索排序。但部署和维护成本会上升,V1.0阶段不必急着上。
分页这块,数据量大之后要从offset分页改成cursor分页(游标分页),因为offset越大,数据库扫描的行越多,游标分页则能保证每次查询都是固定开销。
性能优化还有一个建议:把首页列表做成Redis缓存。分类信息网站的热门分类(比如租房)的列表页访问量很高,但数据变化不算频繁,可以直接把第一页的JSON缓存到Redis,缓存键是home:page:1:category:2,缓存时间60秒。发布新信息时删除对应分类的缓存,能大幅降低数据库压力。
5.3 从骨架到商业化产品还要补什么
如果你是想把这个项目做成真正可运营的产品,除了上述技术优化,还要补几个模块:
- 信息审核后台:Django Admin已经能处理,但更专业的做法是做一个独立的管理前端,支持批量审核、违规信息下架。
- 举报功能:用户可以对信息进行举报,后端记录举报理由和处理状态。
- 置顶/加急:这是分类信息网站的主要收入来源,需要在
Post表加is_top和top_expire_at字段,列表排序优先置顶信息。 - 地图组件:同城网站会和地理信息绑定,前端用高德或腾讯地图的JS API,后端存经纬度字段,做“附近1公里”的筛选。
- 用户信用体系:完成实名认证的用户发布的信息,展示“已实名”标识,能显著提升信息可信度。
我在实际做这类项目的过程中,最大的体会是:分类信息网站看起来简单,但“防垃圾信息”和“信息真实度”这两个问题决定了产品的生死。技术上的坑反而不是最难解决的,业务规则设计的合理性才是真正考验人的地方。
发布频率限制、联系方式脱敏、内容规范校验、审核状态流转,这些模块在一开始就要设计好,不然后期数据脏了、垃圾信息满屏跑,再想治理就非常痛苦。如果有朋友想拿这个项目练手,我建议你在一开始就把这些“业务正确性”的细节放进实现清单里,而不是只盯着CURD代码能不能跑通。技术是骨架,业务规则才是血肉,两者都做扎实了,这个项目才真正有价值。