news 2026/9/16 2:44:31

Django实战:构建二手交易平台的模型、事务与部署要点

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Django实战:构建二手交易平台的模型、事务与部署要点

简介:基于Python Django开发的二手商品交易平台源码,面向需要完成课程设计、毕业设计或入门Django Web开发的学习者。项目采用Python 3.8、Django 3.2与MySQL 5.7构建,整体实现二手商品发布、浏览与供需对接的完整流程,属于可直接运行演示的课程作业级项目。资源包共包含63个文件,压缩后大小约453KB,其中22个Python文件承担模型、视图与配置等核心逻辑,11个HTML模板负责页面骨架,8个JS与3个CSS处理前端交互和样式,另附SQL脚本、依赖清单及说明文档,便于本地还原环境。目前已有173人学习/下载,尤其适合课程设计与毕业设计参考。通过该项目,读者可获得完整的Django项目骨架与运行方案:包括数据库迁移、服务启动等步骤说明,同时可学习MTV分层设计、ORM数据操作和模板渲染机制,也能借鉴其目录划分方式与代码组织思路,为后续二次开发或功能扩展打下扎实基础。

1. 二手商品交易平台为什么用 Django 而不是原生 Python 一把梭

做二手闲置交易时,最常见的初稿是用 Flask 写几条路由,把商品塞进 SQLite,页面能跑就万事大吉。我自己也这么干过,痛感最深的不是 CRUD 不全会,而是“供需”两个字带来的约束:一个商品只能被一个人下单,下架后的商品不能在搜索里出现,卖家不能修改别人的商品。这些约束靠零散的 if 判断去维护,迟早会在某个版本迭代中漏网。Django 把 ORM、迁移、后台管理和认证直接集成好,开发这类平台的思路也随之明确:模型层定义业务约束,视图层处理交易闭环,admin 后台兜底运营操作。下面的命令和配置在 Python 3.10+ 和 Django 4.x/5.x 上可直接跑,能搭建一个真正可发布、搜索、下单的供需平台,而不是只做登录演示的 demo。

2. 供需平台从空目录到可迁移:Django 数据模型与后台管理先定义业务边界

2.1 先理清实体关系:用户、商品、订单不是三张孤立表

二手商品交易平台里最基本的三个实体是用户、商品、订单。新手喜欢一张表装一切,把卖家、买家、商品信息全部堆在一个 model 里,表面上看着方便,实际做交易状态流转时很难加约束。我一般先把关系画清楚:一个用户可发布多个商品,一个商品只归属于一个卖家;一个订单只包含一个商品,所以商品和订单是一对一关系;用户与订单是一对多,用户既可以是卖家又可以是买家。这里没有复杂的多对多,因为二手交易的“拼单”场景不常见,老老实实单品成单反而好维护。

2.1.1 用户表:继承 AbstractUser 还是建独立 Profile

选择用户扩展方式取决于项目有没有跑过第一次 migrate。Django 官方文档里有一条铁律:如果自定义了用户模型,必须在第一次 migrate 之前设置好 AUTH_USER_MODEL,否则中途切换用户模型会导致全站数据迁移爆炸。针对二手交易平台这种项目,我默认不用 AbstractUser 覆盖,而是建一个 UserProfile 与内置 User 做一对一关联。用户名密码这类认证字段由 Django 管,手机号信用分这类业务字段单独放一张表,双方解耦,未来如果换第三方登录也不会动核心用户表。

# accounts/models.py from django.conf import settings from django.db import models class UserProfile(models.Model): user = models.OneToOneField( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='profile', verbose_name='用户' ) phone = models.CharField('手机号', max_length=20, blank=True) nickname = models.CharField('昵称', max_length=32, blank=True) credit_score = models.PositiveIntegerField('信用分', default=100) is_verified = models.BooleanField('实名核验', default=False)

代码里最值得注意的两个点:字段是用 settings.AUTH_USER_MODEL 引用,不让模型直接依赖 auth.User;信用分用 PositiveIntegerField,天然拒绝负数,以后从 100 分扣起也不用担心写成负分。on_delete=models.CASCADE 表示用户注销时连同资料一起清理,符合供需平台“账号不走保存记录”的开发期约定。

2.1.2 商品模型:价格精度和商品状态不能妥协

商品模型是整站的核心,字段设计比页面设计还重要。价格不能用 FloatField,浮点数 0.1+0.2 的精度误差在交易场景里是致命的。Django 的 DecimalField 存的是 Python Decimal 类型,底层在 MySQL 里对应 decimal,在 PostgreSQL 里对应 numeric,计算不会失真。商品状态必须是一个有 choices 的 CharField,因为它会经历在售、已预订、已下架、已售出等多个阶段,布尔字段 is_on_sale 装不下这个生命周期的语义。

# products/models.py from django.conf import settings from django.db import models class Category(models.Model): name = models.CharField('分类名', max_length=32, unique=True) sort_order = models.SmallIntegerField('排序', default=0) class Meta: ordering = ['sort_order'] verbose_name_plural = '商品分类' def __str__(self): return self.name class Item(models.Model): STATUS_CHOICES = [ ('on_sale', '在售'), ('reserved', '已预订'), ('sold', '已售出'), ('off_shelf', '已下架'), ] CONDITION_CHOICES = [ ('new', '全新'), ('like_new', '几乎全新'), ('used', '轻度使用'), ('worn', '明显磨损'), ] seller = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='items', verbose_name='卖家', ) category = models.ForeignKey( Category, on_delete=models.PROTECT, related_name='items', verbose_name='分类', ) title = models.CharField('标题', max_length=120) description = models.TextField('描述', blank=True) price = models.DecimalField('价格', max_digits=10, decimal_places=2) condition = models.CharField('成色', max_length=16, choices=CONDITION_CHOICES, default='used') status = models.CharField('状态', max_length=16, choices=STATUS_CHOICES, default='on_sale') image = models.ImageField('主图', upload_to='items/%Y%m/', blank=True) view_count = models.PositiveIntegerField('浏览量', default=0) created_at = models.DateTimeField('发布时间', auto_now_add=True) updated_at = models.DateTimeField('更新时间', auto_now=True) class Meta: ordering = ['-created_at'] indexes = [ models.Index(fields=['status', 'price']), models.Index(fields=['title']), ] verbose_name_plural = '二手商品' def __str__(self): return self.title

seller 和 category 两个外键的 on_delete 设置成不同策略是有意的:商品没了用户可以一起没,所以 CASCADE;分类一旦有商品引用就不允许删除,所以 PROTECT,避免后台误删分类导致历史商品变成孤儿数据。索引加在 status 和 price 上,是商品搜索的常用路径,“在售且价格区间”是每个列表页都会走的查询组合。

2.1.3 订单模型:一单一货,给商品和订单加唯一约束

二手商品的订单天然是一单一货,不存在购物车合并结算的问题。在模型层面用 OneToOneField,比在视图层反复 if 判断更牢固。order_no 必须唯一且可读,我一般用“用户id+日期+商品id”拼,方便线下对账时一眼看出订单归属。

class Order(models.Model): ORDER_STATUS = [ ('pending', '待支付'), ('paid', '已支付'), ('completed', '已完成'), ('cancelled', '已取消'), ] order_no = models.CharField('订单号', max_length=64, unique=True) buyer = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='orders', verbose_name='买家', ) item = models.OneToOneField( Item, on_delete=models.PROTECT, related_name='order', verbose_name='商品', ) total_price = models.DecimalField('成交价', max_digits=10, decimal_places=2) status = models.CharField('订单状态', max_length=16, choices=ORDER_STATUS, default='pending') created_at = models.DateTimeField('创建时间', auto_now_add=True) class Meta: ordering = ['-created_at']

这里用 PROTECT 而不是 CASCADE,是为了保留交易记录。若删除订单导致商品被删,交易流水就不完整;反过来,商品要删除前必须先处理掉关联订单,这个约束的取舍到做“软删除”时会更有感觉。数据库层面的一对一关系,比在代码里保证“不下重复单”更硬核,配合视图层的事务锁才完整。

2.2 把 admin 后台注册成运营后台,第一版就能验收模型

模型做完先不要写模板,把注册信息丢给 django admin,数据录入和管理员操作立刻可用。admin 作用不是演示好看的 UI,而是让你还没写页面之前,就能验证数据模型到底顺不顺手。注册方法是注册模型进去,再定制列表展示字段、过滤器、搜索项和批量动作。

# products/admin.py from django.contrib import admin from .models import Category, Item, Order @admin.register(Item) class ItemAdmin(admin.ModelAdmin): list_display = ('id', 'title', 'seller', 'price', 'status', 'condition', 'created_at') list_display_links = ('id', 'title') list_filter = ('status', 'category', 'condition') search_fields = ('title', 'description', 'seller__username', 'seller__profile__nickname') list_editable = ('price', 'status') @admin.action(description='批量下架选中商品') def batch_off_shelf(self, request, queryset): queryset.update(status='off_shelf') actions = ['batch_off_shelf']

这里 search_fields 支持跨表搜索,seller__username 表示去用户表中找用户名,seller__profile__nickname 表示去一对一资料表里找昵称;如果 UserProfile 还没创建,这个字段暂时不写也不影响页面加载,但写了就会发现 admin 里能找到 seller 的昵称。list_editable 与 list_display_links 不能同时作用于同一字段,所以把 id 和 title 设为链接,把 price 与 status 做成直接可编辑状态。批量下架动作是二手平台管理者最需要的手工操作。

此外,admin 的美化是站队问题。不想用第三方包时,可以重写 AdminSite 的文案:

# myadmin.py from django.contrib.admin import AdminSite class TradeAdminSite(AdminSite): site_header = '二手交易平台运营后台' site_title = '供需数据控制台' index_title = '商品与订单概览' trade_admin_site = TradeAdminSite(name='trade_admin')

这行配置虽然简单,但把一个默认英文标题的后台换成品牌后台,比较适合在校内项目或团队内部演示。自己重写 AdminSite 与安装 django-simpleui 的区别在于不对模板动刀子,稳定但视觉提升有限;要真上商用级后台皮肤,再考虑 simpleui,但不要一开始就引入。

2.3 迁移与删除:改模型有节奏,清数据用 ORM

模型改完就要生成迁移:

python manage.py makemigrations accounts products python manage.py migrate python manage.py createsuperuser

这里 makemigrations 只负责生成迁移文件,migrate 执行时才真正创建表。开发期改模型是常事,如果只是加字段,makemigrations 一会自动生成 AddField,顺着 migrate 走下去就行;如果哪天发现自己把外键从 CASCADE 改成 PROTECT,应该额外做一次迁移单独处理,并观察有没有非空约束冲突。测试期要清空商品表但保留表结构,用 shell 执行 ORM 删除最快:

python manage.py shell -c "from products.models import Item; Item.objects.filter(title__icontains='测试').delete()"

注意 delete() 会返回 (总删除数, {'app.Model': 删除数}),当外键是一对多时,Django 的 Collector 会级联删除子表,所以想真清库时不能光盯着主表返回值。上面的写法已经足够筛选到测试数据,不会把正式商品误删。

为了给模型约束一个快速索引,我把上面的核心决策收敛成表格:

约束点落地方案说明
价格精度DecimalField(max_digits=10, decimal_places=2)钱永远不用浮点
商品生命周期CharField + choices,数值化保存数据库里存 on_sale,不存“在售”
一单一货Order.item = OneToOneField(Item)数据库层面防重复下单
分类删除保护Category.on_delete=PROTECT防止历史商品悬空
用户扩展UserProfile + OneToOne不覆盖 AUTH_USER_MODEL

3. 商品搜索、上下架与订单状态机的实现路径

3.1 列表页查询要能组合搜索条件,而不是写死一堆 if

供需平台的商品列表页,最常见的筛选是关键词、分类、价格区间、成色,可能还要按发布时间或价格排序。如果把这些全部写成 if,代码短平快,但后面加“同城”或“交易方式”条件时会越改越乱。我更喜欢先用一个空查询集,再逐步加上非空条件,把“用户没有选择”的场景在条件判断里直接处理掉。

3.1.1 用 Q 对象拼接关键词搜索

Q 对象的亮点在于可以组合或者、并且条件,搜索框输入的关键词往往需要同时覆盖标题和描述。当用户填了 q,就用标题包含和描述包含做或运算:

from django.db.models import Q keyword = self.request.GET.get('q', '').strip() if keyword: qs = qs.filter(Q(title__icontains=keyword) | Q(description__icontains=keyword))

Q 意义的拆解:title__icontains 会生成 SQL 里的 LIKE '%keyword%',前面再加上 LOWER(),也就是不区分大小写。两个条件用 | 或起来,在 ORM 层面生成 OR,而不是 Python 层去遍历过滤,数据库会把条件推下去,性能要可靠得多。

3.1.2 列表视图的完整查询串与 select_related 参数说明

完整的类视图这样写:

# products/views.py from decimal import Decimal from django.db.models import Q from django.views.generic import ListView from .models import Item class ItemListView(ListView): model = Item template_name = 'products/item_list.html' context_object_name = 'items' paginate_by = 12 def get_queryset(self): qs = Item.objects.filter(status='on_sale') qs = qs.select_related('seller', 'category') keyword = self.request.GET.get('q', '').strip() category = self.request.GET.get('category', '') min_price = self.request.GET.get('min_price', '') max_price = self.request.GET.get('max_price', '') if keyword: qs = qs.filter(Q(title__icontains=keyword) | Q(description__icontains=keyword)) if category.isdigit(): qs = qs.filter(category_id=int(category)) if min_price.replace('.', '', 1).isdigit(): qs = qs.filter(price__gte=Decimal(min_price)) if max_price.replace('.', '', 1).isdigit(): qs = qs.filter(price__lte=Decimal(max_price)) return qs

select_related 的语义是 JOIN:Item 的 seller 和 category 都是外键,模板里要显示卖家昵称和分类名,如果不用 select_related,每渲染一条数据就要多查两次数据库,这就是经典的 N+1 查询问题。select_related 一次把关联表的数据合并进主查询结果,列表页 50 条商品不再多出 100 次额外查询。对于外键是 User 的情况,select_related 会把 User 的密码哈希也带出来,但那只是查询缓存中的数据,模板里不要输出,不会造成实际泄露。Min_price 与 max_price 在转 Decimal 前先用 replace('.', '', 1).isdigit() 校验格式,非法输入直接跳过过滤,不给后端留下拼 SQL 越权的机会。

3.1.3 参数速查表:查询参数与 ORM 条件映射
参数名示例ORM 条件说明
q手机Q(title__icontains='手机') | Q(...)关键词专栏
category3category_id=3分类精确匹配
min_price1000price__gte=1000最低价闭区间
max_price5000price__lte=5000最高价闭区间
sortprice_ascorder_by('price')白名单映射后使用

排序字段不能直接把用户输入塞进 order_by,常见做法是做一个映射表:

sort_map = { 'latest': '-created_at', 'price_asc': 'price', 'price_desc': '-price', } ordering = sort_map.get(self.request.GET.get('sort'), '-created_at') qs = qs.order_by(ordering)

3.2 下单服务的状态机:用事务和行锁挡住并发

二手商品库存是 1,并发下单时最容易出现超卖。这里必须用数据库的行锁,不能靠 Python 层变量。Django 的 select_for_update 会把选中的行在事务结束前锁住,第二个请求走到同一个查询时,会被阻塞到第一个事务提交,然后重新读取数据时 status 已经变掉,直接抛 DoesNotExist。

# products/services.py from django.db import transaction from django.utils import timezone from .models import Item, Order def create_order(item_id, buyer): with transaction.atomic(): item = Item.objects.select_for_update().get(pk=item_id, status='on_sale') order = Order.objects.create( order_no=f"{buyer.id}-{item.id}-{timezone.now():%Y%m%d%H%M%S}", buyer=buyer, item=item, total_price=item.price, ) item.status = 'reserved' item.save(update_fields=['status', 'updated_at']) return order

这里的顺序不能反过来。先建订单再改商品状态,两个操作在一个事务里,任何一个失败都会回滚。select_for_update 的注意事项是:必须放在事务里,而且不能和 select_related 的某些预加载用法混得太激进。update_fields 则非常关键:save() 不加参数会重写所有字段,并发场景下可能把其它请求刚更新过的价格覆盖回旧值;指定更新字段后,只有 status 和 updated_at 进 SQL,多一点安全。

3.2.1 下单并发场景的失败表现与预判

并发测试最简单的方式是用两个 shell 同时调用 create_order,或者用 ab 工具刷同一个下单 URL。模型有 OneToOneField 约束时,第二次插入订单会立刻抛 IntegrityError,因为同一商品已经有一个订单;但如果没有那行锁,两次读到的都是 on_sale,都会成功创建订单,OneToOneField 的约束也会拦截一条,此时已经出现“有订单但商品状态没改”的脏数据。所以行锁放在前面,唯一约束放在后面,两道防线缺一不可。

3.3 卖家只能操作自己的商品:权限校验的两种写法

编辑商品、下架商品和删除商品这类操作,权限边界是“对象所有者等于当前登录用户”。最直接的做法是视图里取到对象后 if 判断,但一旦有好几个视图,复制粘贴很容易漏掉其中一个。常见的安全习惯是:在 get_queryset 的时候就按 seller 过滤,Django 的 UpdateView/DeleteView 会基于 queryset.get_object() 去取对象,不属于当前用户的对象根本拿不到,直接 404。

# products/views.py from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.views.generic import UpdateView from django.urls import reverse_lazy class ItemUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView): model = Item fields = ['title', 'description', 'price', 'condition', 'category'] template_name = 'products/item_form.html' def get_queryset(self): return Item.objects.filter(seller=self.request.user) def test_func(self): item = self.get_object() return item.seller == self.request.user def get_success_url(self): return reverse_lazy('item-detail', kwargs={'pk': self.object.pk})

这个类视图嵌套了三种控制:LoginRequiredMixin 处理未登录跳转,UserPassesTestMixin 的 test_func 处理“商品不属于当前用户”时返回 403,get_queryset 的过滤让 get_object 在取不到对象时抛 404。这里有两个请求周期的问题:test_func 里的 get_object() 和 form_valid 里的 get_object() 会对数据库重复查询。对当前场景没什么成本。若想精确控制错误码,在 test_func 中返回 False 会得到 403,如果接受 404 也可以不写 UserPassesTestMixin,直接让 get_queryset 返回空集合。

3.4 admin 后台的批量下架和订单回滚操作

运营侧最常用的动作是批量下架违规商品,以及在用户取消订单后恢复商品状态。这两个动作如果用代码在 shell 里操作,谁都能做但容易错。放进 admin action 就等于给后台加上“一键处理”的按钮:

@admin.register(Order) class OrderAdmin(admin.ModelAdmin): list_display = ('order_no', 'item', 'buyer', 'total_price', 'status', 'created_at') actions = ['cancel_orders_and_restore'] @admin.action(description='取消订单并恢复商品为在售') def cancel_orders_and_restore(self, request, queryset): for order in queryset.select_related('item'): if order.status != 'pending': continue order.status = 'cancelled' order.save(update_fields=['status']) if order.item.status == 'reserved': order.item.status = 'on_sale' order.item.save(update_fields=['status']) self.message_user(request, f'已处理 {queryset.filter(status="pending").count()} 笔订单')

注意这里的计 re-count 在循环里已经更新状态,展示给管理员用的还是处理前的数字,出现偏差也不影响业务,但严谨一点可以先记录已处理数量。select_related('item') 让 order.item 的访问不产生额外 SQL,循环里几十个订单时差异不大,几百个订单时差距明显。

4. 二手交易的真实风险点:图片上传、金额处理与越权防御

4.1 商品图片上传:既要有缩略图,也要防格式炸弹与目录爆炸

ImageField 本身只管把上传文件存到 MEDIA_ROOT 下那个路径,不会压缩也不会校验文件真伪。Django 的 forms.ImageField 可以校验扩展名,但一个文件把稍有扩展名的文本文件改成 .jpg,也能通过 ImageField 的初验。因此要在业务层用 Pillow 进一步处理。

4.1.1 上传时自动压缩为 jpg,并把尺寸限制在 1200px

在 helpers 里写一个独立的压缩函数,在模型 save 之前调用。这是一个常见做法。

# products/images.py from io import BytesIO from django.core.files.base import ContentFile from PIL import Image def process_uploaded_image(uploaded_file, max_width=1200): img = Image.open(uploaded_file) img = img.convert('RGB') if img.width > max_width: ratio = max_width / img.width img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS) buffer = BytesIO() img.save(buffer, format='JPEG', quality=82, optimize=True) filename = uploaded_file.name.rsplit('.', 1)[0] + '.jpg' return ContentFile(buffer.getvalue(), name=filename)

使用Image.LANCZOS来做高质量重采样;quality=82把一张 5MB 手机照片压到 300KB 左右,浏览体验和真实性之间比较平衡。convert('RGB')是为了统一去除 PNG 的 alpha 通道,直接转成 JPEG 背景就不会变黑。如果一定要保留透明背景,格式就改成 WebP,但这里为了兼容所有浏览器,先用 JPEG。

在 Item 模型里接上这个函数,最直接的办法是重写 save:

# products/models.py def save(self, *args, **kwargs): if self.image and hasattr(self.image, 'file'): processed = process_uploaded_image(self.image) filename = processed.name self.image.save(filename, processed, save=False) super().save(*args, **kwargs)

这里的坑是:self.image.save()会再次触发文件系统写入,而且 save=False 防止重复入库。但覆盖 save 不是唯一方式,如果项目用了 Django REST Framework,还可以在 serializer 层签收,每个项目的持久层位置不同。

4.1.2 文件校验参数与安全清单

Django 自带的 FileExtensionValidator 可以控制白名单。常用配置放在模型中:

from django.core.validators import FileExtensionValidator image = models.ImageField( '主图', upload_to='items/%Y%m/', blank=True, validators=[FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'webp'])], )

upload_to 里带 %Y%m,Django 会自动替换成年度和月份,让文件均匀散到多个目录。在线服务还会限制请求体大小,Nginx 的 client_max_body_size 要写小一些,例如 5m,否则一个 100MB 的超大文件会直接打到 Django,造成内存压力。Django 前还可以套一层DATA_UPLOAD_MAX_MEMORY_SIZE的配置,这是高并发服务的一个必调参数。

这个安全清单我几乎每做一个平台都贴一遍:

环节配置/代码作用
扩展名白名单FileExtensionValidator拒绝 exe/php
内容校验Pillow Image.open拒绝伪造图片
体积压缩quality=82 + resize 1200防 5MB+ 大图
分目录items/%Y%m/防单目录文件膨胀
请求体限制Nginx client_max_body_size 5m防超大上传击穿内存

4.2 交易安全:回调验签、幂等处理和金额比对

二手交易平台如果可以走第三方担保,支付成功回调是双方信任的关键。回调接口默认是开放的,不能用 Django 的登录态来验证来源,所以必须做验签。常见的对账算法是使用平台方下发的密钥对请求参数做 HMAC-SHA256 签名,回调过来后自己再算一次,diff 不一致直接拒绝。

4.2.1 验签与幂等处理示例

以一个简化回调为例:

import hashlib import hmac import json from django.http import JsonResponse def payment_callback(request): body = json.loads(request.body) order_no = body.get('order_no') callback_amount = body.get('amount') sign = body.get('sign') sign_str = f"order_no={order_no}&amount={callback_amount}" expected = hmac.new( key=PAYMENT_SECRET.encode(), msg=sign_str.encode(), digestmod=hashlib.sha256, ).hexdigest() if not hmac.compare_digest(sign, expected): return JsonResponse({'code': 400, 'msg': '验签失败'}) order = Order.objects.select_for_update().get(order_no=order_no) if order.total_price != callback_amount: return JsonResponse({'code': 400, 'msg': '金额不一致'}) if order.status == 'paid': return JsonResponse({'code': 0, 'msg': '已处理,忽略重复回调'}) order.status = 'paid' order.save(update_fields=['status']) return JsonResponse({'code': 0, 'msg': 'ok'})

这里 hmac.compare_digest 比普通 == 更抗时序攻击;金额一致性比对必须用 Decimal,不用浮点;status 状态判断是幂等处理的核心,重复回调在已支付时直接返回成功但不再触发业务副作用。很多人忽略的一点:回调里带的金额只拿来做比对,不要用回调金额去覆盖订单原始金额。订单金额必须来自数据库,回调金额只用于校验,避免中间被篡改。

4.3 水平越权防御:queryset 先行 + 统一异常兜底

水平越权指的是用户 A 用自己登录态去操作用户 B 的数据。二手平台上最常见的越权点就是“修改商品”。很多初学教程会写成:

# 不推荐的写法 item = get_object_or_404(Item, pk=item_id) if item.seller != request.user: return HttpResponseForbidden()

这样逻辑没错,但同类判断在整个项目里散落得越多,越容易漏掉。更好的姿势是让查询集带上权限上下文:

item = get_object_or_404(Item.objects.filter(seller=request.user), pk=item_id)

get_object_or_404 的第一个参数可以是一个 QuerySet,而不是 model 类。这样查询在数据库层就已经过滤了 seller,取不到任何别人的商品,直接抛 404,不暴露资源是否存在。这条原则同样适用于删除:

Item.objects.filter(seller=request.user, pk=item_id).delete()

在类视图里对应 get_queryset 重写,前面已经展示过。我始终强调“过滤前置”的原因是:Django 开发者习惯从模板反推到视图,对象越多,控制点越分散;一旦把公共查询放到 get_queryset,后续模板访问自动继承安全边界。

4.4 admin 后台的美化与角色权限:给运营人员“有限但顺手”的界面

后台美化不应该是最后一步才做的事。默认 admin 足够扛任务,但长时间看会很累。常见的第三方主题 django-simpleui 已经比较普及,通过 settings 里换 admin_site 的 skin 就能实现,不需要改模型。我自己在商业项目里通常不换模板,只把 AdminSite 的品牌文案改掉,成本最低,并且不会在升级 Django 时踩模板不兼容的坑:

# config/admin.py from django.contrib.admin import AdminSite class MarketAdminSite(AdminSite): site_header = '二手交易平台运营后台' site_title = '供需管理控制台' index_title = '商品与订单概览' admin_site = MarketAdminSite(name='market_admin')

接着在 urls.py 中注册自己的 admin_site,而不是 django.contrib.admin.site.urls:

# config/urls.py from django.urls import path from config.admin import admin_site urlpatterns = [ path('marketing-admin/', admin_site.urls), # 其他路由 ]

这样做还有一个实际价值:默认 admin 路径为 /admin/,自己起的 /marketing-admin/ 名字对扫描器相对隐蔽一点,“low-hanging fruit”少一些。虽然不能靠改路径来保安全,但可以显著减少后台登录页被爆破日志的数量。

5. 性能优化与宝塔部署:从开发机到正式环境的验证要点

5.1 项目从 DEBUG 模式切换到生产模式时必改的 settings

开发环境下 settings.py 里 DEBUG=True,Django 会把所有错误详情输出到浏览器。正式上线之前必须改成 False,并显式声明 ALLOWED_HOSTS。宝塔面板也适用这一套逻辑,只要 Python 项目运行目录对就可以。下面是一份比较常用的生产配置片段:

# config/settings_prod.py from .settings import * DEBUG = False SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY') ALLOWED_HOSTS = ['trade.example.com', '你的公网IP'] STATIC_ROOT = BASE_DIR / 'assets' / 'static' MEDIA_ROOT = BASE_DIR / 'assets' / 'media'

若配置文件使用分文件管理,本地运行时需要显式传入模块名:

python manage.py runserver --settings=config.settings_prod

宝塔的 Python 管理器里通常有一个“启动命令”的输入框,把 gunicorn 的启动命令填进去即可,不需要额外写 systemd 文件。如果直接用宝塔中的 Nginx 反向代理,还要在站点配置中加入 location 转发规则,宝塔生成的配置默认反向代理到 127.0.0.1:8000,你只需要确认端口一致。

5.2 用 Gunicorn + Nginx 把平台跑在 8000 端口上

先在项目虚拟环境中安装 gunicorn:

pip install gunicorn==21.2.0 gunicorn config.wsgi:application -w 2 -k gthread --threads 8 -b 0.0.0.0:8000

gunicorn 的主要参数:

参数给二手交易平台的建议
-w2单机 2 个 worker,按 CPU 核数调整
-k gthreadgthread用线程处理并发请求,兼容同步视图
--threads 88每个 worker 开 8 个线程,适合 IO 密集
-b0.0.0.0:8000Nginx 只在本机回环代理,不暴露公网
--timeout30按接口耗时设置,调太小会误杀上传接口

然后再挂一层 Nginx。最简配置长这样:

server { listen 80; server_name trade.example.com; client_max_body_size 5m; location /static/ { alias /opt/trade/assets/static/; } location /media/ { alias /opt/trade/assets/media/; } location / { 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; proxy_set_header X-Forwarded-Proto $scheme; } }

location /static/ 与 location /media/ 这两段的作用是让 Nginx 直接从磁盘返回静态文件,而不要让请求打回 Django。图片、CSS、JS 都属于 IO 密集,Django 处理它们纯粹浪费 CPU;代理转发只留给动态页面的数据流。

在执行 Nginx 配置之前,先在 Django 侧统一收集静态文件:

python manage.py collectstatic --noinput

collectstatic 会从各个 app 和 STATICFILES_DIRS 里把文件复制到 STATIC_ROOT。如果这步不执行,Nginx 的 alias 会指向一个空目录,页面会瞬间变成裸 HTML。

5.3 上线后接口验证:两三个命令判断 Django 是否在正常工作

部署后第一件事是先做定向测试,而不是打开页面瞪着眼睛看。在容器外用 curl 打一次动态页和一次静态文件:

curl -I http://127.0.0.1:8000/ curl -I http://127.0.0.1:8080/static/css/bootstrap.min.css

第一条命令能看 Gunicorn 是否响应,第二条命令要先去收集静态文件,然后看 Nginx 是否把文件返回为 200。如果代码模型和 URL 都没有问题,再用 shell 冒烟测试数据闭环:

python manage.py shell -c " from products.models import Item, Order assert Item.objects.filter(status='on_sale').exists() assert not Order.objects.filter(status='paid', item__status='on_sale').exists() print('商品在售,订单状态闭环') "

这段命令验证一个横切规则:订单已支付时,商品不可能还在售。有了这条反向校验,就能抓住下单状态机里的逻辑漏洞。执行完成后,登录 admin 看后台商品列表能不能正常展示,再点开一个商品详情、提交一个询价表单,整条链路才算跑完。若想更严谨,补充一条安全基线扫描:

python manage.py check --deploy --settings=config.settings_prod

check --deploy 会输出一堆提示,包括 DEBUG、ALLOWED_HOSTS、SECRET_KEY 和环境隔离,把 ERROR 级别的项全部处理。如果 check --deploy 报了 staticfiles 的 warning,回到 5.1 确认 STATIC_ROOT 和 collectstatic 已经跑过一次,再刷新页面看 Network 面板里静态资源是否都变成 200。

本文还有配套的精品资源,点击获取

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

老平台升级DDR4内存?笔记本内存转接卡实测与避坑指南

老平台内存升级这个话题,最近被问得特别多,尤其是手头攒了几根 DDR4 笔记本拆机内存条的朋友。说实话,笔记本 DDR4 内存在二手市场上量大又便宜,8GB 普条经常比同容量台式机内存低一半价钱,所以“笔记本内存转接卡”这…

作者头像 李华
网站建设 2026/9/16 2:43:38

Prometheus、Zabbix、Nightingale监控选型实战指南

1. 这不是选软件,是选运维的“神经中枢”——为什么必须吃透这三款监控工具Prometheus、Zabbix、Nightingale——这三个名字在今天国内中大型企业的运维值班室、SRE晨会、技术方案评审会上出现的频率,几乎和“CPU使用率超阈值”一样高频。它们不是简单的…

作者头像 李华
网站建设 2026/9/16 2:43:17

grasp_nms安装与并行NMS原理:6DoF抓取候选高效过滤实战

简介:grasp_nms 1.0.2是一个面向机器人抓取与目标检测场景的非极大值抑制加速库,基于Cython和C编写,专注于解决抓取候选框数量大、重叠度高时的后处理效率问题,适合有一定Python和C基础的视觉算法工程师使用。整个发布包共19个文件…

作者头像 李华
网站建设 2026/9/16 2:42:56

269元旧笔记本变身家庭NAS:飞牛云三盘位改造全攻略

1. 为什么一台269元的七代i5联想本,能扛起家庭NAS的活儿先说结论:这台机器不值得当电脑用,但太适合当NAS了。标题里那句“咸鱼流出269元联想笔记本电脑”并不是夸张,二手平台上确实能捡到这种成色一般、跑Windows已经卡顿、但硬件…

作者头像 李华
网站建设 2026/9/16 2:40:19

ESP32-S3上LVGL实现键盘输入实时生成二维码

简介:一套面向物联网嵌入式开发者的ESP32实战例程,基于LVGL开源图形库,实现通过键盘输入实时生成二维码的功能。例程采用Visual Studio Code ESP-IDF环境,使用C语言编写,在ESP32-S3上验证运行,适合正在学习…

作者头像 李华