简介基于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_deletemodels.CASCADE, related_nameprofile, verbose_name用户 ) phone models.CharField(手机号, max_length20, blankTrue) nickname models.CharField(昵称, max_length32, blankTrue) credit_score models.PositiveIntegerField(信用分, default100) is_verified models.BooleanField(实名核验, defaultFalse)代码里最值得注意的两个点字段是用 settings.AUTH_USER_MODEL 引用不让模型直接依赖 auth.User信用分用 PositiveIntegerField天然拒绝负数以后从 100 分扣起也不用担心写成负分。on_deletemodels.CASCADE 表示用户注销时连同资料一起清理符合供需平台“账号不走保存记录”的开发期约定。2.1.2 商品模型价格精度和商品状态不能妥协商品模型是整站的核心字段设计比页面设计还重要。价格不能用 FloatField浮点数 0.10.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_length32, uniqueTrue) sort_order models.SmallIntegerField(排序, default0) 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_deletemodels.CASCADE, related_nameitems, verbose_name卖家, ) category models.ForeignKey( Category, on_deletemodels.PROTECT, related_nameitems, verbose_name分类, ) title models.CharField(标题, max_length120) description models.TextField(描述, blankTrue) price models.DecimalField(价格, max_digits10, decimal_places2) condition models.CharField(成色, max_length16, choicesCONDITION_CHOICES, defaultused) status models.CharField(状态, max_length16, choicesSTATUS_CHOICES, defaulton_sale) image models.ImageField(主图, upload_toitems/%Y%m/, blankTrue) view_count models.PositiveIntegerField(浏览量, default0) created_at models.DateTimeField(发布时间, auto_now_addTrue) updated_at models.DateTimeField(更新时间, auto_nowTrue) class Meta: ordering [-created_at] indexes [ models.Index(fields[status, price]), models.Index(fields[title]), ] verbose_name_plural 二手商品 def __str__(self): return self.titleseller 和 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_length64, uniqueTrue) buyer models.ForeignKey( settings.AUTH_USER_MODEL, on_deletemodels.PROTECT, related_nameorders, verbose_name买家, ) item models.OneToOneField( Item, on_deletemodels.PROTECT, related_nameorder, verbose_name商品, ) total_price models.DecimalField(成交价, max_digits10, decimal_places2) status models.CharField(订单状态, max_length16, choicesORDER_STATUS, defaultpending) created_at models.DateTimeField(创建时间, auto_now_addTrue) 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(statusoff_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(nametrade_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_digits10, decimal_places2)钱永远不用浮点商品生命周期CharField choices数值化保存数据库里存 on_sale不存“在售”一单一货Order.item OneToOneField(Item)数据库层面防重复下单分类删除保护Category.on_deletePROTECT防止历史商品悬空用户扩展UserProfile OneToOne不覆盖 AUTH_USER_MODEL3. 商品搜索、上下架与订单状态机的实现路径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__icontainskeyword) | Q(description__icontainskeyword))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(statuson_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__icontainskeyword) | Q(description__icontainskeyword)) if category.isdigit(): qs qs.filter(category_idint(category)) if min_price.replace(., , 1).isdigit(): qs qs.filter(price__gteDecimal(min_price)) if max_price.replace(., , 1).isdigit(): qs qs.filter(price__lteDecimal(max_price)) return qsselect_related 的语义是 JOINItem 的 seller 和 category 都是外键模板里要显示卖家昵称和分类名如果不用 select_related每渲染一条数据就要多查两次数据库这就是经典的 N1 查询问题。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_id3分类精确匹配min_price1000price__gte1000最低价闭区间max_price5000price__lte5000最高价闭区间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(pkitem_id, statuson_sale) order Order.objects.create( order_nof{buyer.id}-{item.id}-{timezone.now():%Y%m%d%H%M%S}, buyerbuyer, itemitem, total_priceitem.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(sellerself.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 处理“商品不属于当前用户”时返回 403get_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(statuspending).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_width1200): 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, formatJPEG, quality82, optimizeTrue) filename uploaded_file.name.rsplit(., 1)[0] .jpg return ContentFile(buffer.getvalue(), namefilename)使用Image.LANCZOS来做高质量重采样quality82把一张 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, saveFalse) super().save(*args, **kwargs)这里的坑是self.image.save()会再次触发文件系统写入而且 saveFalse 防止重复入库。但覆盖 save 不是唯一方式如果项目用了 Django REST Framework还可以在 serializer 层签收每个项目的持久层位置不同。4.1.2 文件校验参数与安全清单Django 自带的 FileExtensionValidator 可以控制白名单。常用配置放在模型中from django.core.validators import FileExtensionValidator image models.ImageField( 主图, upload_toitems/%Y%m/, blankTrue, validators[FileExtensionValidator(allowed_extensions[jpg, jpeg, png, webp])], )upload_to 里带 %Y%mDjango 会自动替换成年度和月份让文件均匀散到多个目录。在线服务还会限制请求体大小Nginx 的 client_max_body_size 要写小一些例如 5m否则一个 100MB 的超大文件会直接打到 Django造成内存压力。Django 前还可以套一层DATA_UPLOAD_MAX_MEMORY_SIZE的配置这是高并发服务的一个必调参数。这个安全清单我几乎每做一个平台都贴一遍环节配置/代码作用扩展名白名单FileExtensionValidator拒绝 exe/php内容校验Pillow Image.open拒绝伪造图片体积压缩quality82 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 forder_no{order_no}amount{callback_amount} expected hmac.new( keyPAYMENT_SECRET.encode(), msgsign_str.encode(), digestmodhashlib.sha256, ).hexdigest() if not hmac.compare_digest(sign, expected): return JsonResponse({code: 400, msg: 验签失败}) order Order.objects.select_for_update().get(order_noorder_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, pkitem_id) if item.seller ! request.user: return HttpResponseForbidden()这样逻辑没错但同类判断在整个项目里散落得越多越容易漏掉。更好的姿势是让查询集带上权限上下文item get_object_or_404(Item.objects.filter(sellerrequest.user), pkitem_id)get_object_or_404 的第一个参数可以是一个 QuerySet而不是 model 类。这样查询在数据库层就已经过滤了 seller取不到任何别人的商品直接抛 404不暴露资源是否存在。这条原则同样适用于删除Item.objects.filter(sellerrequest.user, pkitem_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(namemarket_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 里 DEBUGTrueDjango 会把所有错误详情输出到浏览器。正式上线之前必须改成 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 --settingsconfig.settings_prod宝塔的 Python 管理器里通常有一个“启动命令”的输入框把 gunicorn 的启动命令填进去即可不需要额外写 systemd 文件。如果直接用宝塔中的 Nginx 反向代理还要在站点配置中加入 location 转发规则宝塔生成的配置默认反向代理到 127.0.0.1:8000你只需要确认端口一致。5.2 用 Gunicorn Nginx 把平台跑在 8000 端口上先在项目虚拟环境中安装 gunicornpip install gunicorn21.2.0 gunicorn config.wsgi:application -w 2 -k gthread --threads 8 -b 0.0.0.0:8000gunicorn 的主要参数参数值给二手交易平台的建议-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 --noinputcollectstatic 会从各个 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(statuson_sale).exists() assert not Order.objects.filter(statuspaid, item__statuson_sale).exists() print(商品在售订单状态闭环) 这段命令验证一个横切规则订单已支付时商品不可能还在售。有了这条反向校验就能抓住下单状态机里的逻辑漏洞。执行完成后登录 admin 看后台商品列表能不能正常展示再点开一个商品详情、提交一个询价表单整条链路才算跑完。若想更严谨补充一条安全基线扫描python manage.py check --deploy --settingsconfig.settings_prodcheck --deploy 会输出一堆提示包括 DEBUG、ALLOWED_HOSTS、SECRET_KEY 和环境隔离把 ERROR 级别的项全部处理。如果 check --deploy 报了 staticfiles 的 warning回到 5.1 确认 STATIC_ROOT 和 collectstatic 已经跑过一次再刷新页面看 Network 面板里静态资源是否都变成 200。本文还有配套的精品资源点击获取