又是一年毕业季。宿舍楼下陆续贴出“搬不走、带不走,低价出”的纸板告示,微信群里一张宿舍台灯照片刚发出来,几秒钟就被聊天记录淹没。做这个基于Python校园二手物品交易网站,最初就是看不下去这种“有效信息被天然浪费”的困境——需求一直都在,但缺一个适合校园半径的信息承载形式。
这篇分享不打算把网上那种“系统设计与实现”的论文模板搬过来凑字数,而是围绕一个真能跑起来、真能拿去演示的校园二手交易网站项目,说清楚为什么选Python + Django这套技术栈、数据表怎么设计、交易状态怎么闭环,以及我在编码过程中踩过的几个能让人半夜坐起来的坑。如果你正在做课设、毕业设计,或者想把这套二手交易思路搬到其他场景,这篇可以作为一份“少走弯路”的参照。
1. 校园场景的真实痛点:二手交易的“最后一公里”断在哪
1.1 群聊刷屏与跳蚤市场的不可检索性
校园二手交易的盘子其实不小。毕业生离校前要处理教材、小家电、收纳架、自行车;新生开学要采购一堆“只用一学期”的东西。问题不在于没有供需,而在于供需信息根本匹配不上。
传统的线下跳蚤市场受限于场地和举办频次,一年可能就一两场,交易窗口太短。微信群和QQ群是大家最常用的替代方案,但它的弊端非常明显:消息被聊天刷掉之后就等于不存在了,既不能按关键词搜索,也不能按分类浏览。想找一本二手教材,你得翻几百条聊天记录,还未必找得到;就算有人发过这台九成新的风扇,三小时之后它就沉到屏幕底下去了。
更麻烦的是信任问题。群里交易通常靠“我先转钱你再去买东西”的君子协议,买卖双方可能互相不认识,出问题之后找谁都不好使。做一个校园专属的交易网站,核心价值还不是“好看”,而是把分散的发布行为变成结构化数据,让信息可以被检索、被筛选、被留痕。
1.2 网站化改造的目标边界与需求清单
真正开始动手之前,我先把需求收拢到一个“最小可用系统”的范围里。校园二手交易平台不是闲鱼的完整复刻,不需要考虑跨校园物流、担保交易、芝麻信用这些重资产功能。对课程设计和初期落地来说,只需要把交易链路跑通即可。
我最后确认的核心需求是这样的:用户注册登录、商品发布与管理、分类浏览与关键词搜索、商品收藏、站内联系、订单状态流转、管理员后台审核。前台的商品列表要支持按分类、按价格区间、按关键词过滤;商品详情页要有图片、描述、价格、卖家信息和联系方式;订单状态简单一点,在售、已预订、已售出三个状态就够了。
后台管理是很多人容易忽略的需求。网站上线后一定会出现违规商品或者纠纷,管理员需要能下架商品、冻结用户、查看交易记录。Django自带的Admin站点如果设计得当,完全可以直接当作管理后台来用,省掉写独立后台界面的大量时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型逻辑:为什么是Python + Django而不是“全干”框架
2.1 Django自带全家桶带来的开发效率
项目主题一旦定成“基于Python”,技术选型的大型基本就锁定在Python生态里了。Python做Web后端的主流方案无非Django和Flask两个。这两个框架选谁,我个人的判断标准很简单:项目偏重业务闭环还是偏重接口灵活性。
Django最舒服的一点是“全家桶”。ORM、表单验证、用户认证、Admin后台、模板引擎、Csrf防护、分页组件全都内置,不用自己拿碎片拼。做一个包含用户、商品、收藏、订单四类核心模型的网站,几乎每个模块都能从Django内置能力里继承一部分,开发节奏会快很多。而且Django的ORM对新手特别友好,写完模型之后执行makemigrations和migrate就能建表,不需要手动写一堆SQL。
Flask的优势是轻巧灵活,但它默认什么都不带。你要自己决定用什么数据库访问层、用什么表单库、怎么处理登录状态、怎么做后台管理。这些选择本身也是一种学习成本。除非你刻意想折腾,否则在一篇要求“完成一个可用系统”的课设里,Django的综合性优势是压倒性的。
2.2 几种主流方案的成本对照
我评估过另外几种方案,包括Spring Boot、Node.js + Express,以及小程序后端方案。给个直观的比较,大家可以根据自己的技术基础做选择。
| 技术组合 | 开发效率 | 学习成本 | 管理后台 | 适合场景 |
|---|---|---|---|---|
| Python + Django + Bootstrap | 高 | 中低 | 自带Admin | 业务完整、自主可控 |
| Python + Flask + Vue | 中 | 中 | 自行开发 | 偏接口/前后端分离 |
| Java + Spring Boot | 中低 | 高 | 自行集成 | 偏企业级、简历味重 |
| Node.js + Express | 中 | 中 | 自行开发 | 偏好JS语法 |
一句话总结:如果你想快速把业务做完整、把更多精力放在功能设计而非底层拼装,Django是性价比最高的选项。而且Python的生态对其他技术栈有一个隐性优势——后期想做数据分析或推荐功能,可以直接在同一个语言体系里扩展。
2.3 版本、依赖与环境锁定的建议
具体到版本,我用的是Python 3.10和Django的高版本LTS线,搭配一个比较保守的依赖清单。Django版本尽量选LTS版本,因为社区支持时间长、资料多,排查问题的时候不容易陷入“网上答案全过时”的窘境。
我习惯在项目根目录维护一个requirements.txt:
code复制Django>=4.2,<5.0
Pillow>=10.0
Pillow是ImageField上传图片时必装的图像处理库,缺少它会导致图片字段迁移失败。开发环境还用了SQLite,零配置、单文件,适合初期演示。生产部署如果能换MySQL或PostgreSQL更好,但对课设来说SQLite完全够用,答辩演示反而更省事,直接拷数据库文件就行。
前端方面没有引入重型框架,用模板语法加Bootstrap做响应式布局。要记住一个原则:这个项目的主角是交易闭环,不是炫酷的前端动效。Bootstrap能把表单、卡片、导航栏这些常用组件快速组织起来,已经足够撑起页面颜值。
3. 数据库设计:一笔交易背后需要几张表来兜底
3.1 用户模型的自定义方案
用户模块我建议直接扩展Django内置的AbstractUser,而不是新建一张单独的Profile表。Django的auth_user表本身已经包含用户名、密码、邮箱、权限等字段,但校园场景还需要学号、联系电话这些信息,直接加字段会更直观。
自定义用户模型要在创建项目后的第一次迁移之前完成,否则后期再改会面临复杂的数据迁移。一个比较稳妥的做法是新建一个users应用,然后写:
python复制# users/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
phone = models.CharField('联系电话', max_length=20, blank=True)
student_id = models.CharField('学号', max_length=20, blank=True)
class Meta:
db_table = 'auth_user'
同时在settings里指定AUTH_USER_MODEL = 'users.User'。这样既能复用Django的认证逻辑,又能扩展校园需要的信息。
3.2 商品表与分类表:字段、状态与图片路径
商品表是整个系统的核心。它的字段设计要能支撑列表筛选和详情展示两个场景。大致的模型结构如下:
python复制# products/models.py
from django.db import models
from django.conf import settings
class Category(models.Model):
name = models.CharField('分类名称', max_length=50)
class Meta:
verbose_name = '商品分类'
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class Product(models.Model):
STATUS_CHOICES = [
('on_sale', '在售'),
('reserved', '已预订'),
('sold', '已售出'),
]
title = models.CharField('商品标题', max_length=100)
description = models.TextField('商品描述')
price = models.DecimalField('价格', max_digits=8, decimal_places=2)
image = models.ImageField('商品图片', upload_to='products/', blank=True, null=True)
category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name='products')
seller = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='products')
status = models.CharField('状态', max_length=10, choices=STATUS_CHOICES, default='on_sale')
created_at = models.DateTimeField('发布时间', auto_now_add=True)
class Meta:
ordering = ['-created_at']
这里有几个值得注意的设计细节。价格字段用DecimalField而不用FloatField,是因为浮点数存在精度误差,涉及金额的字段不能给0.1 + 0.2不等于0.3这种问题留下隐患。分类外键用on_delete=models.PROTECT,意思是分类下还有商品时不能随便删除分类,避免出现“商品挂在已删除分类下”的脏数据。商品状态用CharField传入choices而不是单独建状态表,是因为状态集合固定、有限、业务量小,用枚举就足够了。
3.3 收藏、预约与订单的表结构及状态机
交易环节光有商品表还不够,还需要收藏表和订单表。收藏表本质上是一张多对多关系表,记录哪个用户收藏了哪个商品:
python复制class Favorite(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='favorites')
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='favorited_by')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'product')
订单表管的是真正的交易流程。我给它设计了一个比较轻的状态机:买家提交购买意向,商品进入已预订,卖家确认成交后进入已售出。
python复制class Order(models.Model):
STATUS_CHOICES = [
('pending', '待确认'),
('completed', '已成交'),
('canceled', '已取消'),
]
buyer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='buy_orders')
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='orders')
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
created_at = models.DateTimeField(auto_now_add=True)
我在设计状态机时特别强调了一件事:商品表的status和订单表的status是两个层面的东西,不能混为一谈。商品状态表示商品当前能不能继续被其他人购买;订单状态表示这笔交易走到哪一步。下单动作发生时,商品状态从在售改为已预订;买家确认收货或者卖家确认收款后,订单状态改为已成交,商品状态同步改为已售出。两个状态相互配合,才能防止同一件商品被多个人下单。
3.4 为什么订单表不直接写status一个字段了事
有同学会问:订单表就一个status字段,干嘛还要和商品表联动?如果只改商品表状态,不建订单表,交易记录就丢了,管理员想知道“谁在什么时候买了什么”会非常费劲。
如果只建订单表不改商品状态,就会出现漏卖或重卖。所以我的建议是:订单表负责“交易记录”,商品表负责“当前可售性”。一次操作的原子性由后端的视图函数保证,先用事务锁住商品行,检查状态,再创建订单和改商品状态,具体怎么做我放到第四节讲。
4. 功能实现的主干路线:从注册到成交的完整链路
4.1 自定义用户注册与登录
Django内置的登录视图LoginView可以省不少事。注册视图需要自己写一点,但也不复杂。我用了Django内置的UserCreationForm扩展出自定义注册表单,额外加入学号和电话字段。关键代码大致是这样:
python复制# users/forms.py
from django import forms
from django.contrib.auth.forms import UserCreationForm
from .models import User
class RegisterForm(UserCreationForm):
phone = forms.CharField(max_length=20, label='联系电话')
student_id = forms.CharField(max_length=20, label='学号')
class Meta:
model = User
fields = ('username', 'phone', 'student_id')
登录页面直接用内置的LoginView加一个模板就能跑。因为我指定了AUTH_USER_MODEL,Django认证系统会正确识别自定义用户模型,不用额外改逻辑。这里要提醒一句:注册完成后默认不会自动登录,如果你希望用户注册完直接进入系统,可以在注册视图里调用login(request, user),这个小体验对校园用户非常友好。
4.2 商品发布与图片上传的细节配置
商品发布用ModelForm实现,视图里只需要处理POST请求和图片文件。看起来简单,但图片上传是新手最容易九死一生的地方,因为牵扯到三个配置项:MEDIA_URL、MEDIA_ROOT和urls.py里的静态托管。
在settings.py里要这样设置:
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 = [
# ...
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
如果不加最后这几行,开发环境里上传的图片会存储成功,但访问图片地址时直接404。这个问题我在第五节会再展开讲,因为它实在太典型了。
ModelForm处理文件上传时,表单类里要显式声明ImageField,视图函数里接收request.FILES:
python复制# products/forms.py
from django import forms
from .models import Product
class ProductForm(forms.ModelForm):
class Meta:
model = Product
fields = ['title', 'description', 'price', 'category', 'image']
发布商品的视图方法比较直接:POST提交后检查表单是否合法,合法就设置seller为当前登录用户并保存。这里千万别忘了指定卖家,否则新商品会因为外键没有值直接崩溃。给新手一个建议:给商品列表和详情页加上图片为空时的默认占位图,不然没有图片的商品会让页面显得很粗糙。
4.3 列表页搜索、分类筛选与分页
商品列表页我做了三个维度的筛选:分类、关键词、价格区间。用Django的ORM表达检索逻辑非常顺手:
python复制# products/views.py
from django.shortcuts import render
from django.core.paginator import Paginator
from django.db.models import Q
from .models import Product, Category
def product_list(request):
products = Product.objects.filter(status='on_sale')
kw = request.GET.get('kw', '').strip()
if kw:
products = products.filter(
Q(title__icontains=kw) | Q(description__icontains=kw)
)
category_id = request.GET.get('category', '')
if category_id.isdigit():
products = products.filter(category_id=int(category_id))
min_price = request.GET.get('min_price', '').strip()
max_price = request.GET.get('max_price', '').strip()
if min_price.replace('.', '', 1).isdigit():
products = products.filter(price__gte=float(min_price))
if max_price.replace('.', '', 1).isdigit():
products = products.filter(price__lte=float(max_price))
paginator = Paginator(products.distinct(), 12)
page = paginator.get_page(request.GET.get('page'))
categories = Category.objects.all()
return render(request, 'products/list.html', {
'page_obj': page,
'categories': categories,
})
用Q对象做关键词的跨字段搜索,是为了让用户搜标题和描述都能命中。分页选每页12个,因为商品卡片在Bootstrap栅格下每行四个、一行三个,界面排布刚好。筛选逻辑全部放在后端,URL参数保持可分享性,比如/product/list/?kw=教材&category=2这样,用户可以把筛选结果直接发给别人。
4.4 收藏与订单流程的代码骨架
收藏功能用一个toggle方式实现,可以避免“添加收藏”“取消收藏”各写一套路由:
python复制# products/views.py
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from .models import Product, Favorite
@login_required
@require_POST
def toggle_favorite(request, product_id):
product = Product.objects.filter(pk=product_id).first()
if not product:
return JsonResponse({'status': 'fail', 'message': '商品不存在'})
fav, created = Favorite.objects.get_or_create(
user=request.user,
product=product
)
if not created:
fav.delete()
return JsonResponse({
'status': 'ok',
'favorited': created,
})
get_or_create这个写法非常讨巧:第一次点击生成收藏,第二次点击自动取消。返回JSON后前端用fetch更新星标图标状态即可,不用整页刷新。
订单创建的并发问题必须正面处理。举一个场景:同一件商品两个买家同时点击“购买”,如果代码只是简单地读状态再写入,两个请求都可能看到“在售”状态,结果双双下单成功。解决办法是用数据库事务配合行级锁:
python复制from django.db import transaction
@login_required
@require_POST
def create_order(request, product_id):
with transaction.atomic():
product = Product.objects.select_for_update().get(pk=product_id)
if product.status != 'on_sale':
return JsonResponse({'status': 'fail', 'message': '商品已被预订或售出'})
if product.seller_id == request.user.id:
return JsonResponse({'status': 'fail', 'message': '不能购买自己发布的商品'})
Order.objects.create(
buyer=request.user,
product=product,
status='pending'
)
product.status = 'reserved'
product.save(update_fields=['status'])
return JsonResponse({'status': 'ok', 'message': '下单成功,请等待卖家确认'})
select_for_update会在事务期间锁住这一行,第二个请求必须等第一个事务提交后才能读,这时它读到的状态已经是“已预订”,自然就拦截住了。这个细节如果写进答辩,体现出的水平会比“我只会增删改查”高一个档次。
4.5 Django Admin如何帮忙兜住管理需求
后台管理不用自己造轮子,Django Admin注册模型即可:
python复制# products/admin.py
from django.contrib import admin
from .models import Product, Category, Favorite, Order
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ('title', 'seller', 'price', 'status', 'created_at')
list_filter = ('status', 'category')
search_fields = ('title', 'description')
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ('product', 'buyer', 'status', 'created_at')
经过配置,管理员登录后台后,可以按状态筛选商品、搜索关键词、下架违规商品、查看和取消订单。用一个admin.py文件就完成了管理端主体功能,这是Django不可替代的快乐。
5. 我在这套系统上踩过的三个具体坑
5.1 图片上传后404:MEDIA_ROOT与STATICFILES的混乱
第一个坑是图片显示404。一开始我确认了图片确实上传成功,因为控制台显示文件已经被写入media/products目录,但浏览器访问图片地址就是打不开。我当时的排查链路是这样的:先看文件是否真的存在(存在),再看MEDIA_URL配置(没错),再看模板里的img标签src(是/mdia/products/...,马上发现是单词拼错)。
拼错这只是表层问题,修完之后还是404,说明另有根因。继续追,发现项目urls.py里没有把MEDIA_ROOT映射到路由上。总结一下,图片404的完整排查顺序应该是:文件是否物理存在,MEDIA_URL和MEDIA_ROOT是否配对,开发环境是否正确挂载static函数,模板里src是否正确。
还有一个容易被忽略的点:MEDIA_ROOT和STATIC_ROOT的职责是完全不同的。STATIC_ROOT管的是CSS、JS、图片等静态资源;MEDIA_ROOT管的是用户上传文件。混用会带来权限和路径的灾难,我在刚接触Django时不止一次把用户上传的图片塞进static目录,结果部署后一执行collectstatic就被清了。所以一开始就要把目录分开,media和static各归各。Python django media static 404 这个问题几乎每个新手都会遇到,值得提前背下排查口诀。
5.2 CSRF验证失败:模板与AJAX请求的两种解决链路
第二个坑是提交商品表单时频繁出现“CSRF verification failed”。这个错误分两种场景。
第一种场景是普通表单POST。Django要求每个表单内部包含{% csrf_token %},一旦模板里漏写,POST请求就会被拒绝。这一类比较好解决,在form标签内加一行即可。另一种场景是AJAX提交JSON数据,没法在表单数据里带token,要额外在请求头加上X-CSRFToken。当时我把fetch请求从form表单改成JSON发送时没有加请求头,后端直接403。
常规的解法是在页面里读取CSRF token,然后放进请求头:
javascript复制fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
},
body: JSON.stringify(data)
});
如果页面里没有渲染csrfmiddlewaretoken,也可以通过Django的Cookie拿到token,但最简单可靠的方式还是保证模板有{% csrf_token %}。这个坑最难排查的地方在于,控制台报错信息很抽象,不会直接告诉你“你的AJAX缺了请求头”。以后遇到403,先别急着改后端,把模板和请求头都检查一遍。
5.3 卖出两件商品的尴尬:事务写进循环的深层问题
第三个坑来自订单处理逻辑。最初为了图省事,我在视图里做一个循环,遍历买家提交的兴趣意向列表然后逐一创建订单。问题是,循环里对每一件商品都做了状态判断,但如果不用事务包裹,第一件商品的库存更新成功,第二件商品状态异常,就会造成一个订单段里的历史遗留数据错乱。
有一次演示时我连续点击两次购买按钮,成功生成了两个订单,同一件商品被两个买家同时预定了。原因就是没有应用事务和行锁。修复方案就是我4.4节展示的select_for_update,而不是用Python循环加if判断。这个经历给我的教训是:凡是“先读状态再写数据”的操作,都必须把整个过程放进一个事务里,数据库锁才是最后的围墙。
6. 答辩/验收前值得补上的质量细节
6.1 查询性能:警惕核心列表页的N+1查询
商品列表页面一旦数据量超过一两百条,打开速度就会明显变慢。最典型的原因是模板里循环显示卖家信息时,每条商品都会去执行一次卖家查询,形成N+1次查询。
解决办法很简单,在视图的查询链上使用select_related:
python复制products = Product.objects.select_related('seller', 'category').filter(status='on_sale')
select_related会通过外键一次性join出关联表数据,把多次查询合并成一次。收藏数量和订单数量如果在列表页有展示,可以考虑用annotate配合Count来做聚合。这部分性能优化在演示时看不出来,但老师如果问你“你这个系统能不能扛住全校几千人用”,这就是实打实的回答素材。
6.2 权限边界:越权访问与表单伪造
只判断“登录”是不够的,还要判断“操作者是不是本人”。比如商品编辑和删除接口,不能只看request.user是不是登录状态,还要校验商品的seller_id是否等于当前用户ID。否则随便登录一个账号就能改别人的商品。
我的做法是在商品编辑视图里先取出商品,再做权限判断:
python复制product = Product.objects.filter(pk=pk, seller=request.user).first()
if not product:
return HttpResponseForbidden('没有权限操作该商品')
另外要注意表单提交的字段校验。ModelForm默认会对类型、必填项做校验,但这不代表数据是安全的。对于price这类字段,防御性地检查一下提交值是否在合理范围,比如不允许0元或超过五位数的离谱价格,能减少脏数据。
上传文件的类型校验也需要加。Django的ImageField会验证是否为合法图片,但不会限制文件大小。一张十几MB的图片也会被接受,然后直接把服务器磁盘塞满。解决思路是自定义验证函数,限制扩展名和大小:
python复制from django.core.exceptions import ValidationError
def validate_image_size(image):
if image.size > 2 * 1024 * 1024:
raise ValidationError('图片大小不能超过2MB')
同时在前端给input加accept="image/*"限制选择范围。前端限制提升体验,后端限制保证安全,两者缺一不可。
6.3 演示数据的准备与录屏素材
答辩/演示翻车的现场,有一半原因是数据库里没有像样的数据,页面空荡荡。我会提前写一个数据初始化脚本,生成几十条分类齐全的模拟商品,价格故意设计成有梯度,比如有的9成新、有的只用过一次,保证筛选时能看到明显变化。
演示时我会准备一个最小录制清单:注册一个新用户、发布一件带图商品、在列表页搜索自己发布的词、用另一个账号收藏并下单、退回卖家账号确认订单、去后台管理员页面下架一件违规商品。这套流程涵盖用户端和管理端,大约五分钟,能把系统的主要亮点全部展示出来。
我还习惯准备两个不同角色的浏览器窗口,一个普通用户、一个管理员。这样可以快速切换视角,不用反复登录退出。
7. 项目延伸的方向与我的收尾建议
做到这里,一个“能跑、能演示、能讲清设计逻辑”的校园二手物品交易网站就成型了。如果时间充裕,还可以往两个方向做增量开发:一是把站内联系升级成WebSocket实时会话,二是给商品标题做全文检索并引入推荐逻辑,通过用户的浏览和收藏数据算出相似商品。
我的个人体会是,这类项目的得分点往往不在功能数量,而在三个地方:数据库状态机是否清晰、并发边界是否考虑过、权限控制是否严格。把这三个问题想透彻,比多做一个花哨功能更能体现设计能力。
最后给一个实用建议:做项目时一定要为每个关键接口写一个简短的测试脚本。不用追求高覆盖率,但至少要覆盖下单、越权访问、搜索这三个核心场景的流程。我第一次验证并发下单问题时,就是靠一个脚本连续发送十个请求,迅速复现了“一单两卖”的bug。如果没有这些脚本,很多问题只会在演示现场变成突发事故。
