去年朋友托我做一个校园美食分享系统,需求特别朴素:把食堂和街边小店的好吃的拍下来传上去,大家能浏览、搜索、点赞、收藏。我最终选了 Python 后端 + Vue 前端这套组合,后端在 Django 和 Flask 之间犹豫了两天,最后用 Django 落地,开发全程用 PyCharm 当主力工具。这篇就把整个开发过程完整复盘一遍,从选型、数据库设计、接口开发、前端联调,到 waitress + Nginx 部署上线,每一步都给出能直接复用的代码和配置,顺便把过程中踩过的坑一次性说清楚。
这个项目我做了大概一周,代码量不算大,但涉及的环节很全:数据建模、REST API、图片上传、用户认证、前后端联调、本地部署。不管你是刚学完 Python 语法想找个完整练手项目,还是被课程设计逼着要交一个能跑的系统,这篇文章都应该能帮你少走不少弯路。我不打算把官方文档复述一遍,只讲实际项目里真正用得上的东西,以及每个选择背后的理由。
1. 技术栈选型与整体架构拆解
1.1 需求梳理:一个美食分享系统到底要做哪些事
动手写代码之前,先把需求拆清楚,不然做着做着容易失控。美食分享系统说大不大,说小也不小,核心功能拆开来看就这几块:
- 用户相关:注册、登录、退出,登录之后才能发布美食和评论,游客只能浏览。
- 美食内容:美食的标题、分类、封面图、详细介绍、评分、发布人、发布时间。
- 互动相关:评论、点赞、收藏,这三个是内容型平台的标配。
- 检索相关:关键词搜索、按分类筛选、按评分排序。
- 后台管理:管理员能对内容做增删改查,审核不符合要求的信息。
我把需求整理成一个简单的表格,开发时对着表格逐项核对,避免漏功能,也方便后期排优先级。
| 模块 | 功能点 | 优先级 |
|---|---|---|
| 用户 | 注册、登录、退出、个人信息 | 高 |
| 美食 | 发布、编辑、删除、详情 | 高 |
| 互动 | 评论、点赞、收藏 | 中 |
| 检索 | 搜索、分类筛选、排序 | 中 |
| 管理 | 后台 CRUD、内容审核 | 低 |
需求定下来之后,技术栈选型就顺理成章了。前端需要一个组件化框架来管理这些页面状态,后端需要一个能快速处理数据模型和接口的方案。
1.2 Django 还是 Flask:我怎么选的
这是整个项目里最纠结的一个问题。Django 和 Flask 都是 Python 社区非常成熟的 Web 框架,但设计哲学完全不同。
Django 是“全家桶”思路,自带 ORM、Admin、认证系统、表单处理、模板引擎,甚至自带一套后台管理界面。你新建一个项目,它就把项目骨架给你搭好了,只需要在已有的框架里填业务代码。Flask 恰恰相反,它本身只提供一个最小的核心,路由、请求、响应这些最基础的东西,其余的 ORM、表单校验、登录验证都要靠你自己选择第三方库组合。
拿美食分享系统来说,需要做用户登录注册、需要管理美食和评论这些数据表、需要一个后台管理入口。如果用 Flask,用户认证要自己接 Flask-Login,ORM 要自己配 SQLAlchemy,后台管理还得找 Flask-Admin,虽然都能做,但组合搭配的成本不低。用 Django,这些问题它自带的功能基本全覆盖,尤其 Django Admin 在内容管理场景下几乎零成本。
另外,评论区里经常有人问“Django 的 MTV 模式到底是什么意思”,这个在第二章结合代码说更直观。这里先给一个结论:MTV 的 M 是 Model(数据模型),T 是 Template(模板),V 是 View(视图函数),它和 MVC 的对应关系是 M 对应 Model,T 对应 View,V 对应 Controller。本质上都是“把数据、展示、逻辑分开”,让代码不至于全堆在一起。
所以我的建议是:如果项目里数据模型多、业务完整、需要快速上线,优先 Django;如果只是几个 API、对接一下模型、或者想锻炼手动组装能力,用 Flask 更灵活。美食分享系统属于前者,我用 Django 其实是在给自己省时间。
1.3 为什么前端选 Vue,和后端怎么划分职责
前端选 Vue 基本上是当时的第一反应。Vue 上手曲线平缓,中文资料多,写起来也直观,特别适合这种以“表单 + 列表 + 详情页”为主的内容型系统。React 当然也能做,但对一个一周要出活的个人项目来说,Vue 的学习成本和开发效率更有优势。
前后端职责划分必须从一开始就明确,否则联调阶段会非常痛苦。我的做法是:后端只负责提供 JSON 数据接口和文件上传接口,不渲染任何网页;前端负责页面展示、交互逻辑、路由跳转,通过 axios 向后端请求数据。前端跑在 8080 端口,后端跑在 8000 端口,两个进程独立启动,开发时互不干扰。
这也就是很多人容易搞混的一个点:Flask 或 Django 能不能“绑定到网页元素”?严格来说,后端框架的操作对象是 URL、请求、数据库,它不直接操作网页上的按钮或输入框。网页元素的操作是前端框架的事,Vue 里用 v-model 绑定输入框、用 v-if 控制显示隐藏、用 {{ }} 插入数据。后端做的事只有一件:接收前端传来的请求,处理完把数据返回去。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计与 Django 后端核心实现
2.1 数据表设计:用户、美食、评论、收藏怎么建
Django 里建表不需要写 SQL,而是通过写 Model 类,然后执行迁移命令自动生成表结构。这个机制很省心,但前提是你得先想清楚每张表有哪些字段、表之间什么关系。
我的项目里一共设计了四张核心表:用户表直接用 Django 自带的 User,自己加扩展字段反而麻烦;美食表 Dish、评论表 Review、收藏表 Favorite 需要自己写。三张表的关联关系非常典型:
Dish通过ForeignKey关联到User,表示“这条美食是谁发布的”。Review通过ForeignKey关联到Dish和User,表示“谁在哪个美食下评论了”。Favorite是一个多对多关系的中间表,记录哪个用户收藏了哪个美食。
实际写出来的 models.py 大概是这样的:
python复制from django.db import models
from django.contrib.auth.models import User
class Dish(models.Model):
name = models.CharField(max_length=100, verbose_name="美食名称")
category = models.CharField(max_length=50, verbose_name="分类")
cover = models.ImageField(upload_to="dishes/", blank=True, verbose_name="封面图")
description = models.TextField(verbose_name="介绍")
location = models.CharField(max_length=200, blank=True, verbose_name="位置")
rating = models.FloatField(default=5.0, verbose_name="评分")
created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name="dishes")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.name
class Review(models.Model):
dish = models.ForeignKey(Dish, on_delete=models.CASCADE, related_name="reviews")
user = models.ForeignKey(User, on_delete=models.CASCADE)
content = models.TextField(verbose_name="评论内容")
created_at = models.DateTimeField(auto_now_add=True)
class Favorite(models.Model):
dish = models.ForeignKey(Dish, on_delete=models.CASCADE, related_name="favorites")
user = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ("dish", "user")
这里重点解释几个关键点。第一,ImageField 需要配合 Pillow 库使用,不安装的话迁移或上传时会直接报错,所以 pip install pillow 是必须的一步。第二,on_delete=models.CASCADE 表示级联删除,比如一个用户注销了,他发布的美食、评论、收藏记录也会一起删掉,避免数据库里留下孤立数据。第三,Favorite 里加了 unique_together,保证同一个用户不能重复收藏同一个美食。
建好 Model 之后,执行 python manage.py makemigrations 生成迁移文件,再执行 python manage.py migrate 把表建到数据库里。Django 默认用的是 SQLite,对个人项目来说完全够用,不用额外装数据库软件。
2.2 用 DRF 快速把 CRUD 接口写出来
Django 自带的是函数视图或类视图,返回 HTML 模板;如果要做前后端分离,直接返回 JSON 数据,那强烈建议用 Django REST Framework(DRF)。DRF 是在 Django 之上封装的一套接口开发工具,帮你处理序列化、反序列化、请求解析、响应格式化、分页、认证权限这些繁琐事。
安装就两行:
bash复制pip install djangorestframework
pip install django-cors-headers
然后在 settings.py 里把 rest_framework 和 corsheaders 加到 INSTALLED_APPS,中间件加上 corsheaders.middleware.CorsMiddleware,再允许所有来源跨域访问:
python复制CORS_ALLOW_ALL_ORIGINS = True
开发阶段先放开所有跨域限制,部署时再收紧。不然前端 8080 请求后端 8000,浏览器会因为跨域策略直接拦截请求,报 blocked by CORS policy。
接下来写序列化器,把 Django 数据模型转换成 JSON,同时承担校验功能。我的 serializers.py 里放了三个序列化器:
python复制from rest_framework import serializers
from .models import Dish, Review, Favorite
from django.contrib.auth.models import User
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "username"]
class DishSerializer(serializers.ModelSerializer):
created_by = UserSerializer(read_only=True)
cover_url = serializers.SerializerMethodField()
class Meta:
model = Dish
fields = ["id", "name", "category", "cover_url", "description",
"location", "rating", "created_by", "created_at"]
def get_cover_url(self, obj):
if obj.cover:
request = self.context.get("request")
return request.build_absolute_uri(obj.cover.url) if request else obj.cover.url
return None
cover_url 是我额外加的一个字段,因为 DRF 序列化 ImageField 时默认只返回相对路径,而前端要完整拼接出能访问的图片地址。用 SerializerMethodField 动态生成完整 URL,前端拿到就能直接用。
视图部分用 DRF 的 ViewSet 配合 ModelViewSet,能少写大量重复代码。美食接口我用了 ModelViewSet,只加了搜索、分类筛选和排序的支持:
python复制from rest_framework import viewsets, filters
from django_filters.rest_framework import DjangoFilterBackend
from .models import Dish
from .serializers import DishSerializer
class DishViewSet(viewsets.ModelViewSet):
queryset = Dish.objects.all()
serializer_class = DishSerializer
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ["category"]
search_fields = ["name", "description"]
ordering_fields = ["rating", "created_at"]
再用 DRF 的 DefaultRouter 注册路由,一个 ViewSet 自动生成 list、create、retrieve、update、delete 五个接口:
python复制from rest_framework.routers import DefaultRouter
from .views import DishViewSet
router = DefaultRouter()
router.register(r"dishes", DishViewSet)
urlpatterns = router.urls
这样 /api/dishes/ 用 GET 请求就是美食列表,用 POST 请求就是发布新美食,/api/dishes/3/ 用 GET、PUT、DELETE 分别对应详情、修改、删除。接口规范在前后端联调时几乎不用额外写文档,DRF 还自带一个可交互的调试页面,浏览器打开就能测试接口,非常方便。
2.3 图片上传与媒体文件处理
美食分享系统最核心的内容就是图片,图片上传这块坑不少。我简单梳理一下处理流程。
前端要上传图片时,不能直接给后端发一个 JSON 字符串,而是要用 FormData 把文件塞进去,以 multipart/form-data 格式提交。用 axios 写大概是:
javascript复制const formData = new FormData();
formData.append('name', this.form.name);
formData.append('category', this.form.category);
formData.append('description', this.form.description);
formData.append('cover', this.file);
await axios.post('/api/dishes/', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
后端这边,Django 已经处理好了文件接收和存储,你只需要在 settings.py 里配置好媒体文件的存放目录和访问 URL:
python复制MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
然后 urls.py 里加一行,让开发服务器能直接访问媒体文件:
python复制from django.conf import settings
from django.conf.urls.static import static
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
这里有个特别需要注意的地方:一旦 DEBUG = False,上面这行把媒体文件交给 Django 管理的配置就失效了。开发环境下你看到图片传上去能显示,部署到服务器后图片全部 404,原因就在这。生产环境里,媒体文件必须由 Nginx 这类 Web 服务器来托管,这个后面部署章节会细说。
2.4 如果换 Flask 该怎么写(对照版)
虽然项目最终用了 Django,但既然标题里提到了 Flask,我在开发过程中也顺手用 Flask 写了一个简化版接口做对照,这里分享一下差异。
Flask 版本的核心依赖是 Flask-SQLAlchemy 和 Flask-CORS,建表方式比 Django 自由很多,但也意味着所有东西要自己组装。一个美食列表接口大概长这样:
python复制from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy
from flask_cors import CORS
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///food.db"
db = SQLAlchemy(app)
CORS(app)
class Dish(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100))
category = db.Column(db.String(50))
rating = db.Column(db.Float, default=5.0)
@app.route("/api/dishes", methods=["GET"])
def dish_list():
dishes = Dish.query.all()
return jsonify([{"id": d.id, "name": d.name, "category": d.category, "rating": d.rating} for d in dishes])
@app.route("/api/dishes", methods=["POST"])
def create_dish():
data = request.json
dish = Dish(name=data["name"], category=data["category"])
db.session.add(dish)
db.session.commit()
return jsonify({"id": dish.id}), 201
if __name__ == "__main__":
db.create_all()
app.run(port=5000, debug=True)
对照之后我的感受是:Flask 轻量是真的轻量,写一个接口的路径很短,没有那么多文件要创建;但稍微复杂一点的需求,比如用户登录认证、分页、字段校验、后台管理,全部都要自己找库、自己写逻辑。Django 的项目结构虽然看起来文件多,但每个文件分工明确,随着业务复杂度上升,它的优势会越来越大。
3. Vue 前端实现与前后端联调
3.1 环境搭建:从空目录到能跑起来
前端部分我用 Vue CLI 搭建项目,虽然现在新项目推荐 Vite,但 Vue CLI 生态成熟,配置都在配置文件里,对新手更直观。创建项目的命令是:
bash复制npm install -g @vue/cli
vue create food-web
创建过程中会问一些预设问题,选择默认的 Vue 3 预设即可,后面需要什么依赖再手动装。我安装了这几个:
bash复制npm install axios
npm install element-plus
npm install vue-router@4
npm install pinia
Element Plus 是 Vue 3 对应的组件库,提供现成的按钮、表单、卡片、分页组件,省去自己写样式的麻烦。Pinia 是 Vue 3 推荐的状态管理库,用来存用户登录信息。
安装依赖如果网络慢,可以把 npm 源切换一下,下载速度会明显提升,这一步建议提前做好,不然后面每次装包都煎熬。
3.2 页面、组件与状态管理
整个前端项目我划分了六个页面:
- 首页
Home.vue:美食卡片列表,支持搜索和分类筛选。 - 详情页
Detail.vue:展示美食大图、介绍、评分、发布人,下方是评论区。 - 发布页
Publish.vue:表单 + 图片上传,只有登录用户能进入。 - 登录页
Login.vue和注册页Register.vue:账号相关。 - 我的收藏
Favorites.vue:展示当前用户收藏的美食列表。
首页的卡片列表是核心,实现并不复杂,一个 v-for 渲染加一个分页组件就够了。关键是要把 axios 请求封装好,我单独建了一个 api.js,把接口地址集中管理:
javascript复制import axios from 'axios'
const request = axios.create({
baseURL: 'http://127.0.0.1:8000/api',
timeout: 10000
})
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Token ${token}`
}
return config
})
export default request
这里用 axios 拦截器统一在每次请求的请求头里带上 token,后端通过 token 识别当前登录用户。发布美食、评论、收藏这些操作都需要登录状态,没有 token 的话后端会直接返回 401。
3.3 联调细节:axios、上传、错误提示
前后端联调最容易出问题的是跨域和请求格式。跨域问题后端加 django-cors-headers 能解决,但更推荐开发时直接配置 Vue 的 devServer,把 /api 开头的请求转发到后端地址,这样浏览器的请求看起来是同源的:
javascript复制// vue.config.js
module.exports = {
devServer: {
port: 8080,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true
}
}
}
}
配置好以后,前端代码里请求地址直接写 /api/dishes/ 就行,不用写完整的 http://127.0.0.1:8000,联调体验会舒服很多。
评论功能是典型的“先查登录状态,再提交表单”的流程。我踩过的一个坑是:用户没登录点了“发布评论”,后端返回 401,但前端没有统一处理错误提示,用户感觉点了个寂寞。后来我改成在 axios 响应拦截器里统一拦截 401:
javascript复制request.interceptors.response.use(
response => response,
error => {
if (error.response && error.response.status === 401) {
router.push('/login')
}
return Promise.reject(error)
}
)
这样任何接口返回未登录状态,系统都会自动把用户引导到登录页,体验合理很多。
3.4 顺带解决 Vue 播放 m3u8 视频的需求
美食分享系统除了图文,也有人想做探店视频。视频文件通常切片成 m3u8 格式,浏览器原生不支持直接播放,需要借助 hls.js 这个库。实现方式很直接:
bash复制npm install hls.js
然后在一个视频组件里:
vue复制<template>
<video ref="video" controls width="100%"></video>
</template>
<script>
import Hls from 'hls.js'
export default {
props: {
src: { type: String, required: true }
},
mounted() {
const video = this.$refs.video
if (Hls.isSupported()) {
const hls = new Hls()
hls.loadSource(this.src)
hls.attachMedia(video)
}
}
}
</script>
这样只要后端提供一个 m3u8 视频地址,前端就能正常播放。如果视频源是 mp4 格式,直接给 <video> 的 src 属性就行,不需要额外处理。
4. PyCharm 开发环境与上线部署
4.1 PyCharm 里配置 Python 虚拟环境
项目代码写好后,开发环境的正确配置能让你省很多事。我的建议是每个项目单独建一个 Python 虚拟环境,避免不同项目的依赖互相打架。
用 PyCharm 创建项目时,它一般会提示你选择虚拟环境类型,选 Virtualenv 然后指定 Python 解释器版本即可。如果项目已经存在,也可以通过 File -> Settings -> Project -> Python Interpreter 手动添加:
bash复制python -m venv venv
Windows 下激活虚拟环境的命令是 venv\Scripts\activate,macOS/Linux 下是 source venv/bin/activate。
激活之后,把项目需要的依赖一次性装好:
bash复制pip install django djangorestframework django-cors-headers pillow
pip freeze > requirements.txt
这里有个小技巧:pip freeze > requirements.txt 会把当前环境所有包名和版本号导出来。换一台电脑或者部署到服务器时,pip install -r requirements.txt 就能一键还原环境。PyCharm 里如果导入项目后提示找不到 django 模块,十有八九是解释器没选对,打开右下角的解释器设置切换一下就好。
4.2 本地启动整套系统
后端和前端需要分别启动,我实际开发时的操作流程是:
- 在 PyCharm 底部 Terminal 里执行
python manage.py runserver,启动 Django 服务。 - 打开一个普通终端,进入前端目录,执行
npm run serve,启动 Vue 开发服务器。 - 浏览器访问
http://localhost:8080,进入网站首页。
这种模式下,前端的改动会实时热更新,后端的代码改动则需要重启 Django 服务才能生效。不过现在 Django 也有 --reload 功能,runserver 默认是开启自动重载的。
用 PyCharm 有个好处:在 Run Configuration 里可以配置好启动参数,以后直接点绿色三角形按钮启动,不用每次敲命令。数据库迁移和管理后台创建管理员这类操作,也可以在 PyCharm 的 Terminal 里直接执行:
bash复制python manage.py createsuperuser
python manage.py runserver
启动后访问 http://127.0.0.1:8000/admin,输入刚创建的管理员账号,就能进入 Django 自带的后台,对美食和评论数据进行增删改查。
4.3 用 waitress 跑 Django 后端
开发环境下用的 runserver 性能很差,只适合本地调试,真正对外提供服务时必须换成一个正式的 WSGI 服务器。传统方案是用 Gunicorn,但它对 Windows 支持不好;在 Windows 服务器上部署时,我推荐用 waitress。
waitress 是一个纯 Python 编写的 WSGI 服务器,安装简单,跨平台,Windows 上用起来非常顺畅:
bash复制pip install waitress
启动命令有两种方式,一种是命令行直接启动:
bash复制waitress-serve --listen=127.0.0.1:8000 foodshare.wsgi:application
其中 foodshare 是 Django 项目的名称,wsgi:application 是 Django 自动生成的 WSGI 入口。另一种是写一个 Python 文件启动:
python复制from waitress import serve
from foodshare.wsgi import application
if __name__ == "__main__":
serve(application, host="127.0.0.1", port=8000)
不要开太多并发参数,waitress 默认的线程数对个人项目足够了。监听地址设置为 127.0.0.1,意味着只有本机可以访问这个服务,外部的请求统一由 Nginx 转进来,这样安全性更高。
4.4 Nginx 反向代理与前端静态文件
前后端分离项目部署时,Nginx 承担两个任务:托管前端构建好的静态文件,以及把 API 请求转发给后端服务。
首先要构建前端代码:
bash复制npm run build
构建完成后,项目目录下会生成一个 dist 文件夹,里面是压缩后的 html、js、css 文件。把这个文件夹里的内容放到服务器上某个目录,比如 /var/www/foodweb,然后写 Nginx 配置:
nginx复制server {
listen 80;
server_name yourdomain.com;
location / {
root /var/www/foodweb;
index index.html;
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 /path/to/foodshare/media/;
}
}
try_files $uri $uri/ /index.html; 是 Vue 路由使用 history 模式时必需的,否则刷新页面会出现 404。location /api/ 把接口请求转发给 waitress 监听的 8000 端口。location /media/ 把用户上传的图片目录交给 Nginx 直接读取,这一步就是前面说的“生产环境图片由 Nginx 托管”的具体实现。
改完配置执行 nginx -s reload,系统就能通过域名正式访问了。整个链路是:浏览器请求 Nginx,静态资源由 Nginx 返回,API 请求转发给 waitress,waitress 调用 Django 处理,图片资源由 Nginx 直接从 media 目录读取。
5. 高频报错与排查心得
5.1 常见问题速查表
开发过程中我记录了不少报错,这里整理成一张速查表,按频率排序。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
启动 Django 提示 No module named 'django' |
解释器没有切换到项目的虚拟环境 | 在 PyCharm 设置里重新选择虚拟环境解释器 |
| 上传图片报错 | 没有安装 Pillow | pip install pillow |
| 页面样式加载不出来 | Django 静态文件配置错误 | 检查 STATICFILES_DIRS 和模板里的 {% static %} 标签 |
| 前端请求接口被浏览器拦截 | 跨域问题 | 后端配置 django-cors-headers 或前端配置 devServer 转发 |
| 提交表单提示 403 CSRF | Django 的 CSRF 校验拦截 | 在模板中加 {% csrf_token %},或接口使用 DRF 的 Token 认证 |
| 图片上传后开发环境能显示、生产环境 404 | DEBUG=False 后 Django 不再托管媒体文件 |
用 Nginx 单独设置 /media/ 路径映射 |
| Vue 构建部署后刷新页面 404 | 路由模式与服务器配置不匹配 | Nginx 加 try_files $uri $uri/ /index.html; |
| 数据库查询排序不对 | Model Meta 里 ordering 未配置或配置错误 | 在 Model 的 Meta 类中设置 ordering 字段 |
5.2 我自己踩过的几个坑
第一个坑是本地联调时图片路径拼接错误。开发时 ImageField 返回的路径是 /media/dishes/xxx.jpg,我在前端直接拼成了 http://127.0.0.1:8000{{ cover }},结果开发环境下图片能显示,因为开发服务器托管了媒体文件;但换到服务器后,图片全部 404。排查了半天才发现是请求地址的问题——前端走的是 Nginx 的域名,但图片路径拼的还是开发环境的后端地址。后来我统一在序列化器里用 request.build_absolute_uri 动态生成完整图片地址,彻底解决了这个问题。这也是为什么我在 DishSerializer 里写了一个 get_cover_url 方法的原因。
第二个坑是数据库中文乱码。项目用的 SQLite 默认编码是 UTF-8,按理说不应该乱码,但我在 Windows 上开发时,终端输出中文经常乱码。后来发现是 PyCharm 的终端编码没设置成 UTF-8,在设置里把文件编码统一改成 UTF-8 就正常了。如果是 MySQL,建库时要显式指定 utf8mb4 字符集,否则 emoji 表情存不进去。
第三个坑是登录状态的跨域共享。前端在 8080 端口,后端在 8000 端口,两个端口属于不同的源,即使后端允许了跨域,浏览器存储的 cookie 也不会自动带上。我因此折腾了一晚上,最后决定不用 cookie 会话,改成 token 认证,前端把 token 保存在 localStorage 里,每次请求通过 axios 拦截器手动放到请求头。这个方法简单直接,也方便后期做移动端复用。
第四个坑是操作数据库删除对象时,级联删除带来的连锁反应。Django 里删除一个美食时会同时删除它的评论和收藏记录,设计时觉得合理,但实际使用时有一个尴尬场景:管理员本想删掉一条虚假评论,结果一不小心删掉了整条美食,连带所有真实用户的评论都没了。后来我在后台操作时明确区分了“删除美食”和“删除评论”两个入口,并在确认弹窗里写清楚删除范围,避免误操作。
最后一个建议是日志。开发时不要只靠 print 调试,最好给 Django 配置基本的日志输出,至少能看到 SQL 执行记录和请求日志。排查“查询很慢”“数据不对”这类问题时,日志里的 SQL 语句能帮上大忙。我在项目里只加了最基础的配置,就已经解决了好几个隐藏问题,强烈推荐在项目初期就把日志框架搭好。
做完这个项目我最大的体会是:前后端分离开发最关键的不是代码本身,而是把接口约定、数据格式、错误处理这些边界问题想清楚。前端只关心自己拿到的 JSON 能不能渲染,后端只关心接口的输入输出是否规范,两者之间的契约定好了,联调就顺了。美食分享系统本身技术难度不大,但麻雀虽小五脏俱全,从数据建模到部署上线的完整链路走一遍,对理解 Web 开发的整体脉络帮助很大。如果你也想练手,建议不要照抄,而是自己重新设计一套主题,哪怕是宠物分享、旅行攻略都可以,把同样的技术栈换个业务场景再写一遍,收获会更大。
