一、入门基础

1. 环境安装与项目创建

1.1 安装

# 创建虚拟环境(推荐)
python -m venv venv
# Windows 激活
venv\Scripts\activate
# Linux/Mac 激活
source venv/bin/activate

# 安装 Django + DRF 全家桶
pip install django==5.2
pip install djangorestframework
pip install djangorestframework-simplejwt    # JWT 认证
pip install django-filter                     # 过滤
pip install psycopg2-binary                   # PostgreSQL 驱动
pip install django-cors-headers              # 跨域(前后端分离时用)

依赖清单建议写入 requirements.txtpip install -r requirements.txt 一键安装。

1.2 创建项目和应用

# 创建项目(会在当前目录生成 djangoproject/ 文件夹)
django-admin startproject djangoproject

# 进入项目目录
cd djangoproject

# 创建应用(app)—— Django 的功能模块单元
python manage.py startapp info_manage

项目 vs 应用:项目是整个网站的容器,应用是可复用的功能模块。一个项目可以包含多个应用,一个应用也可以被多个项目复用。

1.3 数据库准备

# PostgreSQL 创建数据库
psql -U admin -d postgres
CREATE DATABASE test;

# 生成迁移文件(检测模型变化,生成 0001_initial.py)
python manage.py makemigrations

# 执行迁移(把迁移文件应用到数据库,建表/改表)
python manage.py migrate

# 创建超级用户(访问 admin 后台用)
python manage.py createsuperuser

# 启动开发服务器
python manage.py runserver

makemigrations vs migrate:前者是"拍快照"(生成迁移文件),后者是"执行快照"(改数据库)。改了模型必须先 makemigrations 再 migrate。


2. Django 架构 (MVT)

Django 采用 MVT 设计模式(Model-View-Template),对应 MVC 中的 M-V-C:

┌──────────────────────────────────────────────────────┐
│                     Django MVT                        │
│                                                       │
│   用户请求 ──→ URLconf(路由) ──→ View(视图)            │
│                                    │                   │
│                         ┌──────────┼──────────┐        │
│                         ▼          ▼          ▼        │
│                      Model      Template   Response    │
│                    (数据层)    (模板层)    (返回给浏览器) │
│                         │                               │
│                         ▼                               │
│                    Database                            │
└──────────────────────────────────────────────────────┘

Model     = 数据模型,ORM 映射到数据库表
View      = 视图函数/类,处理业务逻辑(相当于 MVC 的 Controller)
Template  = HTML 模板,渲染页面(相当于 MVC 的 View)

与 MVC 的对应关系:Django 的 View = MVC 的 Controller,Django 的 Template = MVC 的 View。只是换了个名字,本质一样。


3. 项目目录结构

djangoproject/                    # 项目根目录
├── manage.py                     # 命令行入口(启动服务器、迁移等)
├── djangoproject/                # 项目配置包
│   ├── __init__.py
│   ├── settings.py               # 全局配置(数据库、应用列表、DRF 配置等)
│   ├── urls.py                   # 项目级 URL 路由(总路由)
│   ├── wsgi.py                   # 部署入口(ASGI/WSGI)
│   └── asgi.py
└── info_manage/                  # 应用目录
    ├── __init__.py
    ├── apps.py                   # 应用配置类
    ├── admin.py                  # Admin 后台注册
    ├── models/                   # 数据模型(拆分到多文件)
    │   ├── __init__.py           #   统一导出所有模型
    │   ├── BaseModel.py          #   抽象基类
    │   ├── User.py               #   自定义用户
    │   ├── InfoItem.py           #   信息记录(核心表)
    │   ├── Category.py           #   分类
    │   ├── Tag.py                #   标签
    │   ├── Attachment.py         #   附件
    │   └── Comment.py            #   评论(自引用)
    ├── views.py                  # 视图(函数视图 + 视图集)
    ├── serializers.py             # DRF 序列化器
    ├── permissions.py             # 自定义权限
    ├── exceptions.py              # 自定义异常处理
    ├── forms.py                  # ModelForm 表单
    ├── urls.py                   # MVT 路由(函数视图)
    ├── api_urls.py               # DRF 路由(Router 自动生成)
    └── templates/                # HTML 模板

模型可以拆分到 models/ 目录(包),每个模型一个文件。关键是 __init__.py 要 import + __all__ 导出,Django 才能发现。


4. settings.py 配置详解

settings.py 是整个项目的配置中心。以下按功能分组解释:

4.1 基础配置

import os
from pathlib import Path

# 项目根目录(settings.py 的上上一级)
BASE_DIR = Path(__file__).resolve().parent.parent

# 密钥(用于签名 session、CSRF token 等,生产环境必须保密)
SECRET_KEY = 'django-insecure-xxx'

# 调试模式:True 显示详细错误页,生产环境必须设为 False
DEBUG = True

# 允许访问的域名(DEBUG=True 时自动允许 localhost)
# 生产环境:ALLOWED_HOSTS = ['www.example.com']
ALLOWED_HOSTS = []

4.2 应用列表 (INSTALLED_APPS)

INSTALLED_APPS = [
    # ---- Django 内置应用 ----
    'django.contrib.admin',           # 后台管理
    'django.contrib.auth',            # 认证系统
    'django.contrib.contenttypes',    # 内容类型框架
    'django.contrib.sessions',        # session 支持
    'django.contrib.messages',        # 消息框架
    'django.contrib.staticfiles',     # 静态文件管理

    # ---- 第三方应用 ----
    'rest_framework',                 # DRF
    'rest_framework_simplejwt',       # JWT 认证
    'django_filters',                 # 过滤器

    # ---- 自己的应用 ----
    'info_manage',
]

新建的 app 必须加到 INSTALLED_APPS 才能被 Django 识别。

4.3 中间件 (MIDDLEWARE)

中间件是请求/响应的"钩子",按顺序执行:

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',      # 安全头
    'django.contrib.sessions.middleware.SessionMiddleware', # session 处理
    'django.middleware.common.CommonMiddleware',            # 通用处理
    'django.middleware.csrf.CsrfViewMiddleware',           # CSRF 防护
    'django.contrib.auth.middleware.AuthenticationMiddleware', # 用户认证
    'django.contrib.messages.middleware.MessageMiddleware',   # 消息框架
    'django.middleware.clickjacking.XFrameOptionsMiddleware', # 防点击劫持
]
请求 →  Middleware1 → Middleware2 → ... → View → Middleware2 → Middleware1 → 响应
        (正向处理)                            (反向处理)

4.4 数据库配置

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',  # 数据库引擎
        'NAME': 'test',           # 数据库名
        'USER': 'admin',          # 用户名
        'PASSWORD': '123',        # 密码
        'HOST': 'localhost',      # 主机
        'PORT': '5432',           # 端口
    }
}

其他引擎django.db.backends.sqlite3(默认)、django.db.backends.mysqldjango.db.backends.postgresql

4.5 模板配置

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / 'templates'],   # 全局模板目录
        'APP_DIRS': True,                    # 自动在 app 的 templates/ 下找模板
        'OPTIONS': {
            'context_processors': [
                # 以下四个是 Django 默认的上下文处理器,提供模板变量
                'django.template.context_processors.request',    # request 变量
                'django.contrib.auth.context_processors.auth',  # user 变量
                'django.contrib.messages.context_processors.messages',  # messages
            ],
        },
    },
]

4.6 认证配置

# 自定义用户模型(必须在第一次 migrate 之前设置!)
AUTH_USER_MODEL = "info_manage.User"

# 密码校验器(注册时检查密码强度)
AUTH_PASSWORD_VALIDATORS = [
    {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},  # 不能和用户名太像
    {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},             # 最短长度
    {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},           # 常见密码
    {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},          # 不能纯数字
]

⚠️ AUTH_USER_MODEL 必须在第一次 migrate 之前配置,否则需要删库重建。

4.7 国际化与时区

LANGUAGE_CODE = 'en-us'      # 'zh-hans' 表示简体中文
TIME_ZONE = 'UTC'            # 'Asia/Shanghai' 表示东八区
USE_I18N = True              # 启用国际化
USE_TZ = True                # 启用时区(数据库存 UTC,展示时转换)

4.8 静态文件与媒体文件

# 静态文件(CSS/JS/图片,开发时 Django 自动服务)
STATIC_URL = 'static/'
# STATIC_ROOT = BASE_DIR / 'staticfiles'   # 生产环境 collectstatic 收集目录

# 媒体文件(用户上传的文件)
MEDIA_ROOT = os.path.join(BASE_DIR, "media")   # 文件存储根目录
MEDIA_URL = "/media/"                          # URL 前缀

静态文件 vs 媒体文件:静态文件是开发者写的(CSS/JS),媒体文件是用户上传的(头像/附件)。

4.9 DRF 全局配置

REST_FRAMEWORK = {
    # 自定义异常处理:统一错误返回格式
    'EXCEPTION_HANDLER': 'info_manage.exceptions.custom_exception_handler',

    # 分页:按页码翻页
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 10,                    # 每页 10 条

    # 认证(识别"你是谁"):按顺序尝试,取第一个成功的
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',   # JWT(前后端分离)
        'rest_framework.authentication.SessionAuthentication',          # Session(浏览器)
    ],

    # 权限(决定"你能不能进"):所有 API 默认要求登录
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],

    # 过滤 / 搜索 / 排序后端
    'DEFAULT_FILTER_BACKENDS': [
        'django_filters.rest_framework.DjangoFilterBackend',   # 字段精确过滤
        'rest_framework.filters.SearchFilter',                  # 模糊搜索
        'rest_framework.filters.OrderingFilter',               # 排序
    ],
}

# JWT 令牌有效期
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(hours=2),    # 访问令牌 2 小时
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),     # 刷新令牌 7 天
}

5. manage.py 常用命令

命令 作用
python manage.py runserver 启动开发服务器(默认 8000 端口)
python manage.py runserver 8080 指定端口
python manage.py makemigrations 生成迁移文件
python manage.py migrate 执行迁移
python manage.py createsuperuser 创建超级管理员
python manage.py shell 进入 Django 交互式 shell
python manage.py collectstatic 收集静态文件到 STATIC_ROOT
python manage.py startapp myapp 创建新应用
python manage.py dbshell 进入数据库命令行

二、模型层 (Model)

1. ORM 概念

ORM(Object-Relational Mapping)把数据库表映射为 Python 类:

Python 类        →    数据库表
类的属性          →    表的列
类的实例          →    表的一行数据
obj.save()      →    INSERT/UPDATE
obj.delete()    →    DELETE
Model.objects.all() →  SELECT * FROM table

好处:不用写 SQL,用 Python 语法操作数据库,自动防 SQL 注入。

2. 常用字段类型

from django.db import models

# 基础类型
title      = models.CharField(max_length=200)          # VARCHAR(200),必须指定 max_length
content    = models.TextField()                          # TEXT,不限长度
count      = models.IntegerField()                       # INTEGER
price      = models.FloatField()                         # DOUBLE
is_active  = models.BooleanField(default=True)           # BOOLEAN
pub_date   = models.DateField()                          # DATE (2025-01-01)
pub_time   = models.DateTimeField()                      # DATETIME (2025-01-01 10:00:00)

# 特殊类型
code       = models.SlugField(max_length=50)             # 只允许字母、数字、下划线、连字符
email      = models.EmailField()                         # 自动校验邮箱格式
url_field  = models.URLField()                            # 自动校验 URL
file       = models.FileField(upload_to="attachments/")  # 文件上传
image      = models.ImageField(upload_to="avatars/")      # 图片上传(会校验是否为图片)
字段类型 Python 类型 数据库类型 说明
CharField str VARCHAR 短文本,必须 max_length
TextField str TEXT 长文本,不限长度
IntegerField int INTEGER 整数
FloatField float DOUBLE 浮点数
BooleanField bool BOOLEAN 布尔值
DateField date DATE 日期
DateTimeField datetime DATETIME 日期+时间
FileField str VARCHAR 文件路径
SlugField str VARCHAR URL 友好字符串

3. 字段常用参数

title = models.CharField(
    max_length=200,          # 最大长度(CharField 必填)
    verbose_name="标题",      # 人类可读名称(Admin 后台显示)
    default="",              # 默认值
    blank=True,              # 表单允许为空(Django 层校验)
    null=True,               # 数据库允许 NULL(数据库层)
    unique=True,             # 唯一约束
    choices=[                # 枚举选项
        ('draft', '草稿'),
        ('published', '已发布'),
    ],
    help_text="请输入标题",   # 帮助提示
    editable=False,          # 不可编辑(不在表单/Admin 中出现)
)

blank vs null

  • null=True:数据库允许存 NULL
  • blank=True:表单验证允许为空
  • 常见组合:字符串字段用 blank=True, default=""(不要用 null=True,会导致 NULL 和空字符串共存的问题)

choices 枚举(推荐写法)

class InfoItem(BaseModel):

    class Status(models.TextChoices):
        DRAFT = "draft", "草稿"
        PUBLISHED = "published", "已发布"
        ARCHIVED = "archived", "已归档"

    status = models.CharField(
        max_length=20,
        choices=Status.choices,      # 用 TextChoices 自动生成
        default=Status.DRAFT,
    )

TextChoices 是 Django 3+ 的枚举类。每个元组 (值, 标签) —— 值存数据库,标签显示给用户。代码里用 InfoItem.Status.DRAFT 引用,比硬编码字符串安全。

4. 时间字段特殊参数

created_at = models.DateTimeField(auto_now_add=True)   # 记录创建时自动设为当前时间,之后不再变
updated_at = models.DateTimeField(auto_now=True)        # 每次 .save() 时自动更新为当前时间
参数 作用 典型用途
auto_now_add=True 创建时自动设当前时间,之后不可修改 created_at
auto_now=True 每次 save 时自动更新为当前时间 updated_at

⚠️ 用了 auto_now / auto_now_add 的字段,在表单和 Admin 中默认不可编辑(editable=False)。

5. 关系字段

5.1 三种关系类型

┌──────────────┐         ┌──────────────┐
│   InfoItem   │  FK     │   Category   │
│  (信息记录)    │◄────────│   (分类)     │
│              │ 多对一   │              │
└──────────────┘         └──────────────┘

┌──────────────┐  M2M    ┌──────────────┐
│   InfoItem   │◄───────►│     Tag      │
│  (信息记录)    │ 多对多   │   (标签)     │
└──────────────┘         └──────────────┘
       │
       │ 中间表(自动生成):info_manage_infoitem_tags
       │ infoitem_id | tag_id
       ▼

┌──────────────┐  O2O    ┌──────────────┐
│     User     │◄────────│  UserProfile │
│   (用户)      │ 一对一   │  (扩展信息)   │
└──────────────┘         └──────────────┘

5.2 ForeignKey(外键 / 多对一)

category = models.ForeignKey(
    "Category",              # 关联的目标模型(字符串引用,避免循环 import)
    on_delete=models.PROTECT, # 被引用方删除时的行为(必填)
    related_name="items",     # 反向查询名:category.items.all() 查所有关联记录
    verbose_name="分类",
    null=True,               # 允许不选分类
    blank=True,
)
参数 作用
第一个参数 目标模型名,用字符串 "Category" 避免循环导入
on_delete 必填,被引用方删除时的行为(见下文)
related_name 反向查询名。不设则默认为 模型名小写_set
to_field 关联目标的哪个字段(默认主键 id)

related_name 是什么:设了 related_name="items" 后,category.items.all() 就能查这个分类下的所有记录。不设则用 category.infoitem_set.all()

5.3 ManyToManyField(多对多)

tags = models.ManyToManyField(
    "Tag",
    blank=True,              # 允许不选标签
    verbose_name="标签",
    # through="ItemTag",      # 自定义中间表(需要额外字段时才用)
)

M2M 会自动生成中间表(表名:app名_模型A_模型B小写)。中间表只有两列外键,不需要手动建。

5.4 OneToOneField(一对一)

user = models.OneToOneField(
    settings.AUTH_USER_MODEL,
    on_delete=models.CASCADE,
    related_name="profile",
)

一对一本质是 ForeignKey + unique=True。常用于扩展已有模型(如给 User 加 phone/department 字段)。

6. on_delete 级联选项

选项 效果 适用场景
CASCADE 删除被引用方 → 自动删除引用方 子记录跟随父记录(附件、评论)
PROTECT 有引用时禁止删除被引用方 主数据不能丢(分类、用户)
SET_NULL 删除被引用方 → 引用方外键设为 NULL 可选关联(需 null=True)
SET_DEFAULT 删除被引用方 → 引用方外键设为默认值 有默认值的字段(需 default=xxx)
DO_NOTHING 什么都不做 不推荐,会导致数据库错误
RESTRICT 类似 PROTECT,但允许级联链中的间接删除 比 PROTECT 更灵活
# 分类不能删(有记录在用)
category = models.ForeignKey("Category", on_delete=models.PROTECT)

# 记录删了,附件也删
info_item = models.ForeignKey("InfoItem", on_delete=models.CASCADE)

# 评论人不能删(有评论在引用)
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)

7. Meta 内部类

class Meta:
    verbose_name = "信息记录"          # 单数名称(Admin 后台显示)
    verbose_name_plural = verbose_name # 复数名称
    ordering = ["-created_at"]         # 默认排序(- 表示倒序)
    abstract = True                    # 抽象类,不建表(供继承)
    # db_table = 'my_table'            # 自定义表名
    # unique_together = [['field1', 'field2']]  # 联合唯一约束
    # indexes = [models.Index(fields=['title'])]  # 数据库索引
参数 作用
ordering 默认排序,- 前缀表示倒序
abstract=True 抽象基类,不建表
verbose_name 人类可读名
db_table 自定义表名
unique_together 联合唯一约束
indexes 添加数据库索引

8. 模型继承(抽象基类)

# BaseModel.py — 抽象基类,所有模型共用这两个时间字段
class BaseModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
    updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")

    class Meta:
        abstract = True    # 关键:不建表,只供继承

# InfoItem.py — 继承 BaseModel,自动获得 created_at 和 updated_at
class InfoItem(BaseModel):
    title = models.CharField(max_length=200)
    # ...自带 created_at / updated_at

三种继承方式

  • 抽象基类abstract=True):父类不建表,子类继承字段。最常用。
  • 多表继承(不设 abstract):父类和子类各建一张表,用隐式 OneToOne 关联。
  • 代理模型proxy=True):不建表,只改行为不改字段。

9. str 方法

def __str__(self):
    return self.title       # 在 Admin 后台 / print 时显示标题而不是 "InfoItem object(1)"

每个 Model 建议都写 __str__,否则在 Admin 和 shell 中显示为 ModelName object(1),难以区分。

10. 数据库迁移

# 1. 改了 models.py
# 2. 生成迁移文件
python manage.py makemigrations

# 3. 执行迁移
python manage.py migrate

# 查看迁移状态
python manage.py showmigrations

# 查看迁移会执行什么 SQL(不执行)
python manage.py sqlmigrate info_manage 0001

迁移文件(migrations/0001_initial.py)应该提交到 Git。不要手动改迁移文件,改了模型重新 makemigrations 即可。


三、URL 路由

1. path() 与 re_path()

from django.urls import path, re_path, include

# path():简单路由,支持转换器
path('items/', views.item_list),
path('items/<int:pk>/', views.item_detail),      # <int:pk> 匹配数字
path('items/<str:slug>/', views.item_by_slug),     # <str:slug> 匹配字符串

# re_path():正则路由(复杂匹配时用)
re_path(r'^items/(?P<pk>\d+)/edit/$', views.item_edit),

path() 转换器

转换器 匹配 示例 URL 视图接收
<int:pk> 数字 /items/42/ pk=42(int 类型)
<str:name> 字符串 /items/hello/ name='hello'
<slug:slug> 字母数字下划线连字符 /items/hello-world/ slug='hello-world'
<uuid:uid> UUID /items/xxx-xxx/ uid=UUID对象
<path:filepath> 含斜杠的路径 /files/a/b.txt filepath='a/b.txt'

2. include() 分发

# 项目级 urls.py(总路由)
urlpatterns = [
    path('admin/', admin.site.urls),
    path('items/', include('info_manage.urls')),          # MVT 路由
    path('api/', include('info_manage.api_urls')),        # DRF 路由
    path('accounts/', include('django.contrib.auth.urls')), # 内置认证
]

include() 把 URL 前缀 + 子路由拼接。path('items/', include(...)) 表示所有以 items/ 开头的 URL 交给 info_manage/urls.py 处理。

3. name 与 reverse 反向解析

# urls.py
path('items/<int:pk>/', views.item_detail, name='item_detail')

# 视图/模板中用 name 反向解析出 URL
from django.urls import reverse
url = reverse('item_detail', kwargs={'pk': 42})  # → '/items/42/'

# 模板中
{% url 'item_detail' item.id %}

为什么要 name:URL 硬编码后改路径要改很多地方。用 name 反向解析,改 URL 不用改代码。


四、视图层 - MVT 函数视图

1. request 对象

def my_view(request):
    request.method          # 'GET' / 'POST'
    request.GET.get('search', '')    # GET 参数
    request.POST.get('title', '')    # POST 参数
    request.FILES.get('file')        # 上传的文件
    request.user            # 当前登录用户
    request.path            # 当前 URL 路径
    request.is_ajax()       # 是否 AJAX 请求

2. render / redirect / get_object_or_404

from django.shortcuts import render, redirect, get_object_or_404

def item_list(request):
    items = InfoItem.objects.all()
    # render(请求对象, 模板路径, 上下文字典)
    return render(request, 'info_manage/item_list.html', {'items': items})

def item_delete(request, pk):
    # 找不到记录返回 404,不用手写 try-except
    item = get_object_or_404(InfoItem, pk=pk)
    item.delete()
    return redirect('item_list')    # 重定向到 name='item_list' 的 URL

3. login_required 装饰器

from django.contrib.auth.decorators import login_required

@login_required          # 未登录用户自动跳转到 settings.LOGIN_URL
def item_list(request):
    ...

未登录用户访问被 @login_required 保护的视图时,自动重定向到 /accounts/login/?next=/items/

4. 函数视图 CRUD 实例

# 列表(搜索 + 关联查询优化)
@login_required
def item_list(request):
    title_search = request.GET.get('search', '')
    category_id = request.GET.get('category', '')

    # select_related: JOIN 查询 FK(减少查询次数)
    # prefetch_related: 单独查 M2M(避免 N+1 问题)
    items = InfoItem.objects.select_related('category', 'created_by').prefetch_related('tags')
    if title_search:
        items = items.filter(title__icontains=title_search)  # 模糊搜索
    if category_id:
        items = items.filter(category_id=category_id)

    return render(request, 'info_manage/item_list.html', {'items': items})

# 新增
@login_required
def item_create(request):
    if request.method == "POST":
        form = InfoItemForm(request.POST)
        if form.is_valid():
            item = form.save(commit=False)     # 不立即保存,先补充字段
            item.created_by = request.user
            item.updated_by = request.user
            item.save()                         # 保存到数据库
            form.save_m2m()                     # 保存多对多关系
            return redirect('item_list')
    else:
        form = InfoItemForm()
    return render(request, 'info_manage/item_form.html', {'form': form})

# 编辑
@login_required
def item_edit(request, pk):
    item = get_object_or_404(InfoItem, pk=pk)
    if request.method == "POST":
        form = InfoItemForm(request.POST, instance=item)  # instance 指定要修改的记录
        if form.is_valid():
            item = form.save(commit=False)
            item.updated_by = request.user
            item.save()
            form.save_m2m()
            return redirect('item_list')
    else:
        form = InfoItemForm(instance=item)
    return render(request, 'info_manage/item_form.html', {'form': form})

# 删除
@login_required
def item_delete(request, pk):
    item = get_object_or_404(InfoItem, pk=pk)
    if request.method == "POST":
        item.delete()
        return redirect('item_list')
    return render(request, 'info_manage/item_confirm_delete.html', {'item': item})

ORM 常用查询方法

# 基础查询
InfoItem.objects.all()                              # 全部
InfoItem.objects.get(pk=1)                           # 查单条(不存在报错)
InfoItem.objects.filter(status='published')         # 过滤(返回 QuerySet)
InfoItem.objects.exclude(status='draft')             # 排除
InfoItem.objects.order_by('-created_at')             # 排序
InfoItem.objects.first()                              # 第一条
InfoItem.objects.count()                              # 计数
InfoItem.objects.exists()                            # 是否存在

# 字段查找(双下划线语法)

# ── 大小比较(最容易写错:strict 严格 / inclusive 含等号)──
InfoItem.objects.filter(priority__gt=5)     # >   严格大于
InfoItem.objects.filter(priority__gte=5)    # >=  大于等于(含 5)
InfoItem.objects.filter(priority__lt=5)     # <   严格小于(不含 5!)
InfoItem.objects.filter(priority__lte=5)    # <=  小于等于(含 5)
# ⚠️ 易错:想要"小于 5"用的是 __lt(不含等号),不是 __lte;
#    __lte 是"小于等于"。把 <= 直觉直接套会多包含边界值。

InfoItem.objects.filter(title__icontains='hello')    # 模糊搜索(不区分大小写)
InfoItem.objects.filter(title__contains='hello')     # 包含(区分大小写)
InfoItem.objects.filter(created_at__gte='2025-01-01')# 大于等于
InfoItem.objects.filter(created_at__year=2025)        # 按年
InfoItem.objects.filter(id__in=[1, 2, 3])              # 在列表中(空列表→查不到任何记录)
InfoItem.objects.filter(title__isnull=True)            # 为 NULL(NULL 用 __isnull,别用 =None)

# 关联查询(跨表,用 related_name)
category.items.all()                                  # 反查:该分类下所有记录
item.tags.all()                                       # 正查:该记录的所有标签
item.attachments.all()                                # 反查:该记录的所有附件
Comment.objects.filter(parent__isnull=True)            # parent 为 NULL 的一级评论

# 关联查询优化
InfoItem.objects.select_related('category')           # FK 用 JOIN(1 次查询)
InfoItem.objects.prefetch_related('tags')             # M2M 单独查(2 次查询,避免 N+1)

# 聚合与分组
from django.db.models import Count
InfoItem.objects.aggregate(total=Count('id'))        # {'total': 10}
InfoItem.objects.values('status').annotate(count=Count('id'))  # GROUP BY
# 结果:[{'status': 'draft', 'count': 5}, {'status': 'published', 'count': 3}]

# F 对象(引用其他字段的值)
from django.db.models import F
InfoItem.objects.update(priority=F('priority') + 1)   # 所有记录 priority +1

# Q 对象(复杂 OR/AND 条件)
from django.db.models import Q
InfoItem.objects.filter(Q(title__icontains='hello') | Q(description__icontains='world'))

ORM 增删改操作

上一节全是「查」,这里补「增 / 改 / 删」。三者最终都对应 SQL 的 INSERT / UPDATE / DELETE

1. 创建(Create / INSERT)

方式 写法 说明 注意
一行创建并落库 InfoItem.objects.create(title=..., created_by=user) 返回模型实例,最常用 外键直接传对象
先实例化再 save() item = InfoItem(...); item.save() 适合 save 前做额外处理(算字段、调接口) 默认 UPDATE 所有字段
强制插入 item.save(force_insert=True) 即使对象已有 pk 也 INSERT 不 UPDATE
有则返、无则建 get_or_create(查询条件, defaults={...}) 返回 (对象, 是否新建) 避免重复插入
有则更新、无则建(upsert) update_or_create(查询条件, defaults={...}) 匹配到就更新 defaults 字段 同上
批量插入 bulk_create([obj1, obj2, ...]) 一次 INSERT 多行,比循环 create() ⚠️ 不触发 save / signal / auto_now

注意create() / save() 不会自动跑模型里的 clean() 校验(除非显式调用 instance.full_clean())。Django 的"校验"主要在 Form / Serializer 层,不在 ORM 写入层。

2. 更新(Update / UPDATE)

方式 写法 说明 是否触发 save / signal / auto_now
改属性后 save() item.status=...; item.save() 更新单条 ✅ 是
只更新指定字段 item.save(update_fields=["status"]) 减少 SQL 写入量,推荐 ✅ 是(仅该字段)
强制更新 item.save(force_update=True) 对象必须有 pk ✅ 是
批量更新 QuerySet.update(status='published') 直接生成 SQL ❌ 否(updated_at 不自动变)
F 对象原子自增 update(priority=F('priority') + 1) 数据库内算,避免"先读后写"竞态 ❌ 否
批量更新一组对象 bulk_update(objs, ['status']) Django 2.2+,比逐条 save ❌ 否
obj.save() QuerySet.update()
作用范围 单条对象 整个 QuerySet(批量)
触发 save() / signal ✅ 是 ❌ 否
更新 auto_now 字段 ✅ 是(updated_at 自动变) ❌ 否
适用场景 改单条 + 有业务逻辑 批量改状态 / 批量刷数据

经验法则:改单条且要跑校验/钩子用 save();批量改状态用 update()(高效,但 updated_at 等时间字段要自己维护)。

3. 删除(Delete / DELETE)

方式 写法 说明 注意
单条删除 item = InfoItem.objects.get(pk=1); item.delete() 先加载对象再删 走到实例的 delete() 钩子
批量删除 InfoItem.objects.filter(status='archived').delete() 直接生成 SQL ⚠️ 不调自定义 delete() 钩子
on_delete 级联 CASCADE / PROTECT / SET_NULL 定义外键删除行为 SET_NULL 需字段 null=True

get().delete()** 与 filter().delete() 的区别**:

  • Model.objects.filter(pk=1).delete()批量删除,直接生成 SQL,信号仍会发,但不会调用你在模型上重写的 delete() 方法
  • Model.objects.get(pk=1).delete() 会先加载对象再删,会走到实例的 delete() 逻辑。
  • 所以:想用自定义的 delete() 钩子(比如删文件),要 get() 出来再 .delete(),别用 filter().delete()

软删除(实战常用):很多项目不真删,而是加 is_deleted = BooleanField(default=False),删除改成 update(is_deleted=True),查询时 .filter(is_deleted=False)。好处:可恢复、留审计痕迹。

ORM 易错点与避坑

下面这些坑都和"把 Python / SQL 直觉直接套到 ORM"有关,写业务时最容易踩。

① 大小比较运算符(strict 严格 / inclusive 含等号)

Python 运算符 ORM 写法 含义 易错提醒
> field__gt 严格大于
>= field__gte 大于等于(含边界)
< field__lt 严格小于(不含边界) ⚠️ 想要"小于"用这个,不是 __lte
<= field__lte 小于等于(含边界)

⚠️ 想要"小于 5"用 __lt(不含等号),不是 __lte__lte 是"小于等于"。把 <= 直觉直接套会多包含边界值。

② 条件组合逻辑(AND / OR / exclude)

写法 实际逻辑 易错点
filter(a=1, b=2) a=1 AND b=2 多参数不是 OR
`filter(Q(a=1) Q(b=2))` a=1 OR b=2
exclude(a=1, b=2) 排除"同时满足 a=1 且 b=2"的行 不是"排除 a=1 或 b=2"

③ 取值 / 判空 / 计数 / 范围

场景 推荐写法
取单条(可能没有) filter(pk=1).first()None get() 查不到抛 DoesNotExist
取单条(必须有) get_object_or_404() 同上
判 NULL field__isnull=True / False 别用 =None
动态 __in 先判空再 filter(id__in=ids) id__in=[] 返回空结果(不是"不过滤")
只计数 .count() len() 加载全表进内存
只判断有无 .exists() 同上
日期区间 field__range=(a, b) 含 a 和 b(BETWEEN),不像 Python 切片右开

④ 关联查询优化

关系类型 用哪个 说明
FK / O2O select_related('x') JOIN 一次查完
M2M / 反向 FK prefetch_related('x') 单独查,避免 N+1
用反 无效 / 低效 对 M2M 用 select_related 不起作用

⑤ 返回值类型 / 执行时机 / 批量钩子

写法 返回 / 行为
values() / values_list() 字典 / 元组,非模型对象 不能调模型方法、不能 .save()
QuerySet(未求值) 惰性,不立即查库 遍历 / 切片 / count 才触发 SQL
queryset[-1] 报错 不支持负索引,取末尾用 .last()
QuerySet.update() / delete() 批量 SQL 不触发 save() / signal(要钩子需逐条 get()

五、表单层 (Form)

1. ModelForm

from django import forms
from .models import InfoItem

class InfoItemForm(forms.ModelForm):
    class Meta:
        model = InfoItem
        fields = ['title', 'category', 'tags', 'status', 'description', 'priority', 'published_at']
        # fields = '__all__'           # 所有字段
        # exclude = ['created_by']     # 排除某些字段

    # 自定义字段 widget(覆盖模型默认的表单控件)
    description = forms.CharField(
        widget=forms.Textarea(attrs={"rows": 4}),   # 多行文本框
        required=False,
    )
    published_at = forms.DateTimeField(
        widget=forms.DateTimeInput(attrs={"type": "datetime-local"}),  # HTML5 日期选择器
        required=False,
    )
Meta 属性 作用
model 关联的模型类
fields 包含哪些字段(列表或 '__all__'
exclude 排除哪些字段
widgets 自定义控件
labels 字段标签

2. save(commit=False)

form = InfoItemForm(request.POST)
if form.is_valid():
    item = form.save(commit=False)    # 创建对象但不保存到数据库
    item.created_by = request.user    # 补充额外字段
    item.save()                      # 现在才保存
    form.save_m2m()                  # 保存多对多关系(commit=False 后必须手动调)

为什么需要 commit=False:有些字段(如 created_by)不来自表单,而是来自 request.user。先创建对象不保存,补充字段后再一起保存。


六、模板层 (Template)

1. 模板继承

<!-- base.html — 基础模板 -->
<!DOCTYPE html>
<html>
<head>
    <title>{% block title %}默认标题{% endblock %}</title>

</head>

<body>
    <nav>导航栏</nav>

    {% block content %}{% endblock %}
    <footer>页脚</footer>

</body>

</html>

<!-- item_list.html — 子模板 -->
{% extends "base.html" %}

{% block title %}信息列表{% endblock %}

{% block content %}
    <h1>信息列表</h1>

    {% for item in items %}
        <p>{{ item.title }}</p>

    {% endfor %}
{% endblock %}

2. 常用模板标签

{% extends "base.html" %}           {# 继承基础模板 #}
{% include "header.html" %}         {# 包含子模板 #}
{% for item in items %}...{% endfor %}  {# 循环 #}
{% if user.is_authenticated %}...{% endif %}  {# 条件判断 #}
{% url 'item_detail' item.id %}     {# URL 反向解析 #}
{% csrf_token %}                     {# CSRF 令牌(POST 表单必加)#}
{% load static %}                    {# 加载静态文件标签库 #}
{% static 'css/style.css' %}        {# 引用静态文件 #}
{{ forloop.counter }}               {# 当前循环序号(从1开始)#}
{{ forloop.last }}                  {# 是否最后一次循环 #}
{% empty %}                          {# for 循环为空时显示 #}

3. 常用过滤器

{{ value|default:"默认值" }}          {# 为空时显示默认值 #}
{{ value|length }}                    {# 长度 #}
{{ value|date:"Y-m-d H:i" }}         {# 日期格式化 #}
{{ value|truncatewords:30 }}         {# 截断为30个词 #}
{{ value|truncatechars:50 }}         {# 截断为50个字符 #}
{{ value|safe }}                     {# 不转义 HTML(信任内容时才用)#}
{{ value|add:10 }}                    {# 加法 #}
{{ value|upper }}                     {# 转大写 #}
{{ value|lower }}                     {# 转小写 #}
{{ value|join:", " }}                 {# 列表拼接 #}

七、DRF 入门

1. MVT vs DRF 对比

维度 MVT DRF
返回格式 HTML 页面 JSON 数据
前后端 模板渲染,前后端不分离 前端独立(Vue/React),调 API
路由 手动写 path() Router 自动生成
视图 函数 / 类视图 ViewSet(一个类搞定 CRUD)
数据处理 Form + 模板 Serializer(JSON ↔ Python 对象)
认证 Session JWT / Session
表单 ModelForm Serializer
模板 必须 不需要
MVT 流程:
  请求 → URL → View(函数) → Model(数据库) → Template(HTML) → 响应 HTML

DRF 流程:
  请求 → URL → ViewSet → Serializer → Model(数据库) → JSON 响应
                                          ↑
                        前端(Vue/React) ←→ JSON

2. DRF 核心组件

┌──────────────────────────────────────────────┐
│                   DRF 架构                     │
│                                                │
│  请求 → Router(路由) → ViewSet(视图集)         │
│                         │                      │
│            ┌────────────┼────────────┐         │
│            ▼            ▼            ▼         │
│      Serializer     Permission   Filter       │
│      (序列化器)      (权限)       (过滤)       │
│            │                                   │
│            ▼                                   │
│         Model(数据库)                           │
│            │                                   │
│            ▼                                   │
│         JSON Response                          │
└──────────────────────────────────────────────┘

Router       → 自动把 ViewSet 映射成 RESTful URL
ViewSet      → 一个类封装 CRUD + 自定义动作
Serializer   → 模型对象 ↔ JSON 互转 + 数据校验
Permission   → 控制谁能访问
Filter       → 搜索/过滤/排序

八、序列化器 (Serializer)

1. ModelSerializer

from rest_framework import serializers
from .models import InfoItem

class InfoItemSerializer(serializers.ModelSerializer):
    class Meta:
        model = InfoItem
        fields = '__all__'                              # 序列化所有字段
        read_only_fields = ['created_by', 'updated_by']  # 这两个字段前端只读,后端自动填

Meta 常用参数

参数 作用
model 关联的模型类
fields 包含哪些字段。'__all__' 表示全部
exclude 排除哪些字段(和 fields 二选一)
read_only_fields 只读字段(前端传了也忽略,后端自动填)
extra_kwargs 给字段额外配置
depth 外键展开深度(不推荐,用 SerializerMethodField 代替)

fields vs exclude:二选一,不能同时用。推荐 fields 显式列出(即使改了模型也不会意外暴露字段)。

read_only_fields 的作用:防止前端伪造数据。比如 created_by 应该从 JWT 取当前用户,不能让前端传。

2. SerializerMethodField(自定义计算字段)

class AttachmentSerializer(serializers.ModelSerializer):
    file_url = serializers.SerializerMethodField()

    class Meta:
        model = Attachment
        fields = '__all__'

    def get_file_url(self, obj):
        """obj 是当前序列化的模型实例"""
        request = self.context.get('request')
        if obj.file and request:
            return request.build_absolute_uri(obj.file.url)  # 拼完整 URL
        return None

SerializerMethodField 不是数据库字段,而是通过方法计算出来的值。方法名必须是 get_字段名

3. source 跨表取值

class CommentSerializer(serializers.ModelSerializer):
    # 不返回 author 的 ID,而是直接返回用户名
    author_name = serializers.CharField(source='author.username', read_only=True)

    class Meta:
        model = Comment
        fields = ['id', 'author', 'author_name', 'content', ...]

source='author.username' 告诉 DRF:从 obj.author.username 取值。DRF 会自动跨表查询。

4. 嵌套序列化(限深一层)

class CommentSerializer(serializers.ModelSerializer):
    author_name = serializers.CharField(source='author.username', read_only=True)
    replies = serializers.SerializerMethodField()

    class Meta:
        model = Comment
        fields = ['id', 'info_item', 'author', 'author_name', 'content',
                  'parent', 'replies', 'created_at']
        read_only_fields = ['author']

    def get_replies(self, obj):
        """只对一级评论查回复,回复不再嵌套(限深一层,避免无限递归)"""
        if obj.parent is not None:
            return []                              # 当前是回复 → 不再查子回复
        replies = obj.replies.all()                # 当前是一级评论 → 查所有回复
        return CommentSerializer(replies, many=True, context=self.context).data

为什么要限深:如果不加 if obj.parent is not None: return [],序列化器会无限递归——查评论的回复的回复的回复……直到内存溢出。限深一层是性能和功能的平衡。

5. context 传递

# ViewSet 中调用时传 context
serializer = CommentSerializer(comments, many=True, context={'request': request})

# Serializer 中取 context
request = self.context.get('request')

context 用于在 ViewSet 和 Serializer 之间传递数据。最常见的是传 request,用于拼完整 URL、判断当前用户等。


九、视图集 (ViewSet)

1. ModelViewSet 源码解读

ModelViewSet 继承链:

ModelViewSet
    ├── CreateModelMixin    → create()   → POST   /api/items/
    ├── RetrieveModelMixin  → retrieve() → GET    /api/items/{id}/
    ├── UpdateModelMixin    → update()    → PUT    /api/items/{id}/
    ├── DestroyModelMixin  → destroy()  → DELETE /api/items/{id}/
    └── ListModelMixin      → list()      → GET    /api/items/
        └── GenericViewSet
            └── GenericAPIView
                ├── queryset             → 数据集
                ├── serializer_class     → 序列化器
                ├── filter_backends     → 过滤后端
                ├── pagination_class    → 分页
                └── ...

6 个 Mixin 各自带一个 CRUD 方法。ModelViewSet 把它们全组合在一起,所以一个类就实现了完整的 CRUD。

CRUD 方法 → HTTP 映射

Mixin 方法 HTTP URL 作用
ListModelMixin list() GET /api/items/ 列表
CreateModelMixin create() POST /api/items/ 新增
RetrieveModelMixin retrieve() GET /api/items/{id}/ 详情
UpdateModelMixin update() PUT /api/items/{id}/ 全量修改
UpdateModelMixin partial_update() PATCH /api/items/{id}/ 部分修改
DestroyModelMixin destroy() DELETE /api/items/{id}/ 删除

create() 源码简化版

# CreateModelMixin.create() 的核心逻辑(简化):
def create(self, request, *args, **kwargs):
    # 1. 用序列化器校验数据
    serializer = self.get_serializer(data=request.data)
    serializer.is_valid(raise_exception=True)

    # 2. 保存(调用 perform_create,可以重写)
    self.perform_create(serializer)

    # 3. 返回序列化后的数据 + 201 状态码
    headers = self.get_success_headers(serializer.data)
    return Response(serializer.data, status=201, headers=headers)

def perform_create(self, serializer):
    serializer.save()    # 默认只保存,不填额外字段

为什么重写 perform_create:默认 perform_create 只调 serializer.save(),不会自动填 created_by。重写后可以从 request.user 取当前用户自动填充。

2. ViewSet 实例

class InfoItemViewSet(viewsets.ModelViewSet):
    queryset = InfoItem.objects.all()              # 数据集
    serializer_class = InfoItemSerializer           # 序列化器
    permission_classes = [IsOwnerOrReadOnly]        # 权限(覆盖全局配置)

    # 字段精确过滤:?category=3&tags=1&status=published
    filterset_fields = ['category', 'tags', 'status']
    # 模糊搜索:?search=关键词 → 同时搜 title 和 description
    search_fields = ['title', 'description']
    # 排序:?ordering=-created_at(倒序)
    ordering_fields = ['created_at', 'priority', 'published_at']

    def perform_create(self, serializer):
        serializer.save(created_by=self.request.user, updated_by=self.request.user)

    def perform_update(self, serializer):
        serializer.save(updated_by=self.request.user)
属性 作用
queryset 该视图集操作的数据集
serializer_class 使用的序列化器类
permission_classes 权限类(覆盖全局 DEFAULT_PERMISSION_CLASSES)
filterset_fields 精确过滤字段
search_fields 模糊搜索字段
ordering_fields 排序字段
pagination_class 分页类(覆盖全局)

3. perform_create / perform_update 钩子

# 默认实现(DRF 源码):
def perform_create(self, serializer):
    serializer.save()

# 重写后:从 JWT 取当前用户,自动填 created_by / updated_by
def perform_create(self, serializer):
    serializer.save(
        created_by=self.request.user,
        updated_by=self.request.user,
    )

def perform_update(self, serializer):
    serializer.save(updated_by=self.request.user)

serializer.save(**kwargs) 中传的 kwargs 会覆盖序列化器中的同名字段。前端就算传了 created_by 也会被忽略。

4. @action 自定义端点

from rest_framework.decorators import action
from rest_framework.response import Response

class InfoItemViewSet(viewsets.ModelViewSet):
    ...

    # detail=False → 作用于集合(URL 不带 ID)
    @action(detail=False, methods=['get'])
    def stats(self, request):
        """统计:GET /api/info-items/stats/"""
        queryset = self.get_queryset()
        total = queryset.count()
        status_counts = queryset.values('status').annotate(count=Count('id'))
        return Response({
            'total': total,
            'by_status': {item['status']: item['count'] for item in status_counts}
        })

    @action(detail=False, methods=['post'])
    def batch_delete(self, request):
        """批量删除:POST /api/info-items/batch_delete/"""
        ids = request.data.get('ids', [])
        deleted, _ = InfoItem.objects.filter(id__in=ids).delete()
        return Response({'deleted': deleted})

    # detail=True → 作用于单条记录(URL 带 ID)
    @action(detail=True, methods=['get'])
    def attachments(self, request, pk=None):
        """获取附件:GET /api/info-items/{id}/attachments/"""
        item = self.get_object()              # 根据 URL 中的 pk 自动查出对象
        attachments = item.attachments.all()  # 通过 related_name 反查
        serializer = AttachmentSerializer(attachments, many=True, context={'request': request})
        return Response(serializer.data)

    @action(detail=True, methods=['get'])
    def comments(self, request, pk=None):
        """获取评论:GET /api/info-items/{id}/comments/"""
        item = self.get_object()
        comments = item.comments.filter(parent__isnull=True)  # 只查一级评论
        serializer = CommentSerializer(comments, many=True, context={'request': request})
        return Response(serializer.data)

@action 参数

参数 作用
detail=True URL 带 pk,如 /items/{id}/attachments/
detail=False URL 不带 pk,如 /items/stats/
methods=['get'] 允许的 HTTP 方法
url_path='xxx' 自定义 URL 路径(默认用方法名)
url_name='xxx' URL name(用于 reverse)

URL 生成规则

detail=False → /api/{prefix}/{method_name}/
    stats       → /api/info-items/stats/
    batch_delete → /api/info-items/batch_delete/

detail=True  → /api/{prefix}/{pk}/{method_name}/
    attachments → /api/info-items/1/attachments/
    comments   → /api/info-items/1/comments/

5. get_object / get_queryset

# get_object():根据 URL 中的 pk 自动查出单条记录
item = self.get_object()      # 等价于 get_object_or_404(InfoItem, pk=pk)

# get_queryset():获取当前视图集的 queryset
qs = self.get_queryset()       # 等价于 self.queryset

# get_serializer():获取序列化器实例
serializer = self.get_serializer(data=request.data)

十、DRF 路由 (Router)

1. DefaultRouter

from rest_framework.routers import DefaultRouter
from info_manage.views import InfoItemViewSet, CategoryViewSet, TagViewSet, \
    AttachmentViewSet, CommentViewSet

router = DefaultRouter()
router.register('info-items', InfoItemViewSet, basename='info-item')
router.register('categories', CategoryViewSet, basename='category')
router.register('tags', TagViewSet, basename='tag')
router.register('attachments', AttachmentViewSet, basename='attachment')
router.register('comments', CommentViewSet, basename='comment')

urlpatterns = router.urls

register 参数

参数 作用
prefix URL 前缀,如 'info-items'/api/info-items/
viewset 视图集类
basename URL name 前缀,用于 reverse:reverse('info-item-list')

2. URL 自动生成规则

注册 router.register('info-items', InfoItemViewSet, basename='info-item') 后:

HTTP 方法 URL 对应方法 URL name
GET /api/info-items/ list() info-item-list
POST /api/info-items/ create() info-item-list
GET /api/info-items/{id}/ retrieve() info-item-detail
PUT /api/info-items/{id}/ update() info-item-detail
PATCH /api/info-items/{id}/ partial_update() info-item-detail
DELETE /api/info-items/{id}/ destroy() info-item-detail
GET /api/info-items/stats/ stats() (@action) info-item-stats
GET /api/info-items/{id}/attachments/ attachments() (@action) info-item-attachments

3. DefaultRouter vs SimpleRouter

特性 DefaultRouter SimpleRouter
根路径 有 API 根视图(列出所有路由)
URL 尾斜杠 有(/items/ 可选
适用场景 开发环境(带浏览界面) 生产环境

项目级 urls.py 中通过 include() 挂载:

path('api/', include('info_manage.api_urls')),

十一、认证与权限

1. 认证 vs 权限

请求进来 → 认证(Authentication) → 权限(Permission) → 视图
            "你是谁?"           "你能干什么?"
            JWT / Session        IsAuthenticated / IsOwnerOrReadOnly
            失败 → 401            失败 → 403
概念 作用 失败状态码
认证 (Authentication) 识别用户身份 401 Unauthorized
权限 (Permission) 控制访问权限 403 Forbidden

2. JWT 认证 (SimpleJWT)

┌────────┐     POST /api/auth/token/      ┌──────────┐
│  前端  │ ──── username+password ──────→ │  后端    │
│        │                                 │          │
│        │ ←── access_token + refresh ──── │          │
│        │                                 └──────────┘
│        │
│        │     GET /api/info-items/        ┌──────────┐
│        │ ──── Authorization: Bearer xx → │  后端    │
│        │                                 │  验证JWT │
│        │ ←────── JSON 数据 ────────────── │          │
└────────┘                                 └──────────┘

access_token  → 2 小时过期,每次 API 请求带它
refresh_token → 7 天过期,过期后用它换新的 access_token
# settings.py 中配置
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(hours=2),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
}

# urls.py 中配置 JWT 端点
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns = [
    path('api/auth/token/', TokenObtainPairView.as_view()),       # 获取 token
    path('api/auth/token/refresh/', TokenRefreshView.as_view()),  # 刷新 token
]

请求时怎么带 JWT:在 HTTP 头加 Authorization: Bearer <access_token>(注意 Bearer 后面有空格)。

3. Session 认证

'DEFAULT_AUTHENTICATION_CLASSES': [
    'rest_framework_simplejwt.authentication.JWTAuthentication',  # 优先 JWT
    'rest_framework.authentication.SessionAuthentication',        # 备选 Session
],

Session 认证靠浏览器 Cookie 中的 sessionid。MVT 模板渲染时用这个。前后端分离时用 JWT。

4. 内置权限类

权限类 作用
IsAuthenticated 必须登录
IsAdminUser 必须是管理员(is_staff=True)
AllowAny 任何人都可以
IsAuthenticatedOrReadOnly 读操作任何人可访问,写操作需登录
# 全局配置(settings.py 中所有 API 生效)
'DEFAULT_PERMISSION_CLASSES': [
    'rest_framework.permissions.IsAuthenticated',
]

# 局部覆盖(单个 ViewSet 中设置)
class InfoItemViewSet(viewsets.ModelViewSet):
    permission_classes = [IsOwnerOrReadOnly]    # 覆盖全局配置

5. 自定义权限 (IsOwnerOrReadOnly)

from rest_framework import permissions

class IsOwnerOrReadOnly(permissions.BasePermission):
    """只有创建人能修改/删除,其他人只能读"""

    # 视图级权限:每个请求都先过这里
    def has_permission(self, request, view):
        return request.user.is_authenticated    # 未登录直接拒绝

    # 对象级权限:操作单条记录时(retrieve/update/destroy)才过这里
    def has_object_permission(self, request, view, obj):
        # 读操作(GET/HEAD/OPTIONS)所有人都能访问
        if request.method in permissions.SAFE_METHODS:
            return True

        # 写操作(POST/PUT/PATCH/DELETE)只有创建人可以
        return obj.created_by == request.user

两级权限执行流程

请求 → has_permission()     → True? → 继续
                              False? → 403

         ↓ (如果是操作单条记录)

      has_object_permission() → True? → 允许操作
                                False? → 403
方法 调用时机 典型用途
has_permission 每个请求都调用 检查是否登录
has_object_permission retrieve/update/destroy 时调用 检查是否是记录的拥有者

⚠️ has_object_permission 只在 get_object() 被调用时触发,也就是操作单条记录时。list()create() 不会触发。

SAFE_METHODS

permissions.SAFE_METHODS = ('GET', 'HEAD', 'OPTIONS')

这些是"安全方法"——不会修改数据,所以权限可以放宽。


十二、过滤 / 搜索 / 排序 / 分页

1. DjangoFilterBackend(字段精确过滤)

class InfoItemViewSet(viewsets.ModelViewSet):
    filterset_fields = ['category', 'tags', 'status']
GET /api/info-items/?category=3&status=published
    → WHERE category_id=3 AND status='published'

精确匹配,不支持模糊搜索。适合状态、分类等枚举字段。

2. SearchFilter(模糊搜索)

class InfoItemViewSet(viewsets.ModelViewSet):
    search_fields = ['title', 'description']
GET /api/info-items/?search=Python
    → WHERE title LIKE '%Python%' OR description LIKE '%Python%'

模糊搜索,不区分大小写。用 ?search=关键词 触发。

搜索模式

# 默认:包含搜索(icontains)
search_fields = ['title', 'description']

# 前缀搜索(istartswith)
search_fields = ['=title']           # 以关键词开头

# 完全匹配
search_fields = ['^title']           # 正则匹配开头

# 正则表达式
search_fields = ['$title']           # 正则匹配

3. OrderingFilter(排序)

class InfoItemViewSet(viewsets.ModelViewSet):
    ordering_fields = ['created_at', 'priority', 'published_at']
GET /api/info-items/?ordering=-created_at     → ORDER BY created_at DESC(倒序)
GET /api/info-items/?ordering=priority        → ORDER BY priority ASC(正序)
GET /api/info-items/?ordering=-priority,title → ORDER BY priority DESC, title ASC

- 前缀表示倒序,不加表示正序。可以多字段排序(逗号分隔)。

4. PageNumberPagination(分页)

# settings.py 全局配置
REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 10,
}
GET /api/info-items/?page=2           → 第2页
GET /api/info-items/?page=2&page_size=20  → 第2页,每页20条

分页返回格式

{
    "count": 42,                    // 总记录数
    "next": "http://...?page=3",   // 下一页 URL(null 表示没有)
    "previous": "http://...?page=1", // 上一页 URL
    "results": [...]               // 当前页数据
}

自定义分页类

from rest_framework.pagination import PageNumberPagination

class CustomPagination(PageNumberPagination):
    page_size = 10
    page_size_query_param = 'page_size'    # 允许前端指定每页条数
    max_page_size = 100                     # 最大每页条数

class InfoItemViewSet(viewsets.ModelViewSet):
    pagination_class = CustomPagination    # 局部覆盖
分页类 URL 参数 特点
PageNumberPagination ?page=2 按页码
LimitOffsetPagination ?limit=10&offset=20 按偏移量
CursorPagination ?cursor=xxx 加密游标(不可跳页,适合大数据)

十三、异常处理

1. DRF 默认异常

异常 HTTP 状态码 触发场景
NotAuthenticated 401 未登录
PermissionDenied 403 无权限
NotFound 404 记录不存在
ValidationError 400 数据校验失败
MethodNotAllowed 405 方法不允许
Throttled 429 限流

DRF 默认返回格式:{"detail": "错误信息"},前后端格式不统一。

2. 自定义 exception_handler

import logging
from rest_framework.views import exception_handler
from rest_framework.response import Response

logger = logging.getLogger(__name__)

def custom_exception_handler(exc, context):
    # 第一步:先交给 DRF 默认处理器
    response = exception_handler(exc, context)

    if response is not None:
        # DRF 认识的异常 → 统一格式化
        detail = response.data.get('detail', str(response.data))
        response.data = {
            'code': response.status_code,
            'message': str(detail),
            'data': None,
        }
        return response

    # DRF 不认识的异常(如 IntegrityError、KeyError)→ 记日志 + 返回 500
    logger.exception("未捕获异常")
    return Response(
        {'code': 500, 'message': '服务器内部错误', 'data': None},
        status=500
    )
# settings.py 中注册
REST_FRAMEWORK = {
    'EXCEPTION_HANDLER': 'info_manage.exceptions.custom_exception_handler',
}

统一返回格式

// 成功
{"code": 200, "message": "success", "data": {...}}

// 失败
{"code": 401, "message": "Given token not valid for any token type", "data": null}
{"code": 404, "message": "Not found.", "data": null}

为什么要统一格式:前端只需要判断 code 字段就知道是否成功,不需要看 HTTP 状态码。


十四、文件上传

1. MEDIA 配置

# settings.py
MEDIA_ROOT = os.path.join(BASE_DIR, "media")   # 文件存储根目录
MEDIA_URL = "/media/"                          # URL 前缀
# urls.py — 挂载媒体文件路由
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    ...
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

⚠️ static() 返回的是列表 [URLPattern(...)],必须用 + 拼接到 urlpatterns 后面,不能作为列表项直接放进去。

⚠️ static() 只在 DEBUG=True 时生效。生产环境用 Nginx 直接服务媒体文件。

2. FileField / ImageField

class Attachment(BaseModel):
    file = models.FileField(upload_to="attachments/", verbose_name="文件")
    # ImageField 会校验是否为图片
    # avatar = models.ImageField(upload_to="avatars/")
参数 作用
upload_to 文件保存的子目录(相对于 MEDIA_ROOT)
max_length 文件路径最大长度(默认 100)

上传的文件会保存到 MEDIA_ROOT/attachments/xxx.pdf,数据库中存的是相对路径 attachments/xxx.pdf

upload_to 高级用法

# 按日期分目录
file = models.FileField(upload_to="attachments/%Y/%m/%d/")

# 自定义函数
def user_directory_path(instance, filename):
    return f'attachments/{instance.uploaded_by.id}/{filename}'
file = models.FileField(upload_to=user_directory_path)

3. ViewSet 上传处理

class AttachmentViewSet(viewsets.ModelViewSet):
    queryset = Attachment.objects.all()
    serializer_class = AttachmentSerializer

    def perform_create(self, serializer):
        """上传时自动提取文件信息"""
        uploaded_file = serializer.validated_data['file']
        serializer.save(
            uploaded_by=self.request.user,
            original_name=uploaded_file.name,    # 原始文件名
            file_size=uploaded_file.size,        # 文件大小(字节)
        )

serializer.validated_data 是经过校验的数据字典。文件字段返回的是 Django 的 UploadedFile 对象,有 .name.size 属性。

4. 前端上传 (FormData)

// 前端必须用 multipart/form-data,不能用 JSON
const formData = new FormData()
formData.append('file', file)              // 文件本体
formData.append('info_item', itemId)       // 关联记录 ID

// 注意:不要手动设 Content-Type!
// 浏览器传 FormData 时会自动加 boundary
request.post('/attachments/', formData)

⚠️ 如果手动设 Content-Type: multipart/form-data,会丢掉 boundary 参数,后端无法解析文件。


十五、自引用与嵌套序列化

1. 自引用外键 (“self”)

class Comment(BaseModel):
    info_item = models.ForeignKey("InfoItem", on_delete=models.CASCADE, related_name="comments")
    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="comments")
    content = models.TextField()
    parent = models.ForeignKey(
        "self",                    # "self" 表示指向自己这张表
        on_delete=models.CASCADE,
        null=True,                 # 一级评论没有父评论
        blank=True,
        related_name="replies",    # 父评论.replies.all() 查所有回复
    )
Comment 表结构:
┌────┬───────────┬────────┬──────────────────┐
│ id │ content   │ author │ parent_id (FK→self) │
├────┼───────────┼────────┼──────────────────┤
│ 1  │ 一级评论A  │ 用户1  │ NULL             │ ← 一级评论
│ 2  │ 一级评论B  │ 用户2  │ NULL             │ ← 一级评论
│ 3  │ 回复A-1   │ 用户2  │ 1                │ ← 回复评论1
│ 4  │ 回复A-2   │ 用户3  │ 1                │ ← 回复评论1
│ 5  │ 回复B-1   │ 用户1  │ 2                │ ← 回复评论2
└────┴───────────┴────────┴──────────────────┘

查询树形结构:
  一级评论A (id=1)
    ├── 回复A-1 (id=3)   ← parent_id=1
    └── 回复A-2 (id=4)   ← parent_id=1
  一级评论B (id=2)
    └── 回复B-1 (id=5)   ← parent_id=2

"self" 是 Django 特殊字符串,表示外键指向同一张表。parent_id 存的是同表的另一行的 id。

2. 评论树形结构查询

# 只查一级评论(parent 为 NULL)
comments = item.comments.filter(parent__isnull=True)

# parent__isnull=True 是 Django ORM 双下划线语法
# 等价于 SQL: WHERE parent_id IS NULL

3. 限深嵌套实现

class CommentSerializer(serializers.ModelSerializer):
    author_name = serializers.CharField(source='author.username', read_only=True)
    replies = serializers.SerializerMethodField()

    class Meta:
        model = Comment
        fields = ['id', 'info_item', 'author', 'author_name', 'content',
                  'parent', 'replies', 'created_at']
        read_only_fields = ['author']

    def get_replies(self, obj):
        if obj.parent is not None:
            return []                          # 当前是回复 → 不再递归
        replies = obj.replies.all()
        return CommentSerializer(replies, many=True, context=self.context).data

限深关键if obj.parent is not None: return []。如果当前评论本身是一条回复(有 parent),就返回空列表,不再嵌套。这样递归只走一层就停。

为什么不用 depth=1:DRF 内置的 depth 会展开所有外键(info_item、author、parent 全展开),而且嵌套对象变只读。用 SerializerMethodField 可以精确控制只嵌套 replies


十六、级联删除专题

1. .delete() 返回值

deleted, details = InfoItem.objects.filter(id__in=[1, 2, 3]).delete()
# deleted = 10
# details = {'info_manage.InfoItem': 3, 'info_manage.InfoItem_tags': 7}

.delete() 返回元组 (总数, {表名: 删除数})。总数包含级联删除的所有表记录。

2. 级联删除范围

删除 InfoItem (id=1) 时会级联删除:

┌─────────────────────────────────┐
│         InfoItem (id=1)          │  ← 手动删除
│          on_delete=CASCADE       │
├─────────────────────────────────┤
│  ├── Attachment (info_item=1)   │  ← CASCADE 级联删除
│  ├── Comment (info_item=1)      │  ← CASCADE 级联删除
│  │   └── Comment (parent=上面)  │  ← CASCADE 级联删除回复
│  └── InfoItem_tags 中间表记录    │  ← M2M 中间表自动删除
├─────────────────────────────────┤
│  Category ← PROTECT 不删         │  ← PROTECT 保护
│  User ← PROTECT 不删             │  ← PROTECT 保护
└─────────────────────────────────┘
on_delete 选项 是否级联 说明
CASCADE ✅ 是 父记录删 → 子记录也删
PROTECT ❌ 否 有子记录时禁止删父记录
SET_NULL ❌ 否 父记录删 → 子记录外键设 NULL
SET_DEFAULT ❌ 否 父记录删 → 子记录外键设默认值

M2M 中间表:多对多关系生成的中间表记录,在删除任一方时都会被自动删除(不受 on_delete 控制)。

3. 批量删除实例

@action(detail=False, methods=['post'])
def batch_delete(self, request):
    """批量删除:POST /api/info-items/batch_delete/ {"ids": [1,2,3]}"""
    ids = request.data.get('ids', [])
    deleted, details = InfoItem.objects.filter(id__in=ids).delete()
    return Response({'deleted': deleted})

{"ids": [1, 2, 3]} 返回 {"deleted": 10} 而非 3,因为 .delete() 返回的是级联删除总数(3 条记录 + 7 条中间表记录)。

deleted, details = ... 解构可以看到明细:{'info_manage.InfoItem': 3, 'info_manage.InfoItem_tags': 7}


十七、前后端分离架构

1. JWT 认证完整流程

┌─────────┐                                    ┌─────────┐
│  前端    │                                    │  后端   │
│ (Vue)   │                                    │(Django) │
└────┬────┘                                    └────┬────┘
     │                                              │
     │  1. POST /api/auth/token/ {username,password} │
     │ ──────────────────────────────────────────→ │
     │                                              │
     │  2. 返回 {access: "xxx", refresh: "yyy"}     │
     │ ←────────────────────────────────────────── │
     │                                              │
     │  3. localStorage.setItem('token', access)    │
     │                                              │
     │  4. GET /api/info-items/                     │
     │     Authorization: Bearer xxx                │
     │ ──────────────────────────────────────────→ │
     │                                              │
     │  5. 返回 JSON 数据                            │
     │ ←────────────────────────────────────────── │
     │                                              │
     │  ··· 2 小时后 access 过期 ···                │
     │                                              │
     │  6. POST /api/auth/token/refresh/            │
     │     {refresh: "yyy"}                        │
     │ ──────────────────────────────────────────→ │
     │                                              │
     │  7. 返回新的 {access: "zzz"}                  │
     │ ←────────────────────────────────────────── │
     └──────────────────────────────────────────────┘

2. axios 拦截器(前端 JWT 自动携带)

// 请求拦截器:每个请求自动带 token
request.interceptors.request.use((config) => {
    const token = localStorage.getItem('token')
    if (token) {
        config.headers.Authorization = `Bearer ${token}`   // 自动加 JWT
    }
    return config
})

// 响应拦截器:token 过期自动跳登录
request.interceptors.response.use(
    (response) => response.data,    // 直接返回 data,不用 .data
    (error) => {
        if (error.response?.status === 401) {
            localStorage.removeItem('token')
            router.push('/login')    // 跳转登录页
        }
        return Promise.reject(error)
    }
)

3. CORS 配置(前后端不同端口时)

# settings.py
INSTALLED_APPS = [
    ...
    'corsheaders',    # 添加 CORS 应用
]

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',    # 必须放在最前面!
    ...
]

# 允许所有来源(开发环境)
CORS_ALLOW_ALL_ORIGINS = True

# 或指定来源(生产环境)
# CORS_ALLOWED_ORIGINS = [
#     'http://localhost:5173',
#     'http://127.0.0.1:5173',
# ]

⚠️ CorsMiddleware 必须放在 CommonMiddleware 前面,否则 CORS 头不会正确添加。

4. Vite 代理(开发环境免 CORS)

// vite.config.js
export default defineConfig({
    server: {
        proxy: {
            '/api': {
                target: 'http://localhost:8000',   // Django 后端
                changeOrigin: true,
            },
            '/media': {
                target: 'http://localhost:8000',   // 媒体文件
                changeOrigin: true,
            },
        }
    }
})

前端请求 /api/items/ 时,Vite 自动转发到 http://localhost:8000/api/items/。浏览器看到的还是同源请求,不会有 CORS 问题。生产环境用 Nginx 代理。


十八、自定义用户模型

1. 为什么自定义用户模型

Django 内置 User 模型有 username/password/email/first_name/last_name 等字段。如果需要额外字段(如手机号、部门),有两种方式:

方式 做法 优缺点
OneToOne 扩展 建 UserProfile 表关联 User 不破坏原有 User,但不能改 username
继承 AbstractUser 自定义 User 模型 灵活,推荐但必须在 migrate 前设置

2. 继承 AbstractUser

# models/User.py
from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
    """自定义用户——继承 AbstractUser,自带所有认证功能"""
    # 可以在这里加字段,如 phone = models.CharField(...)
    # 本项目用 UserProfile 一对一扩展,所以 User 不加字段

    class Meta:
        verbose_name = "用户"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.username
# settings.py(必须在第一次 migrate 前设置!)
AUTH_USER_MODEL = "info_manage.User"

⚠️ AUTH_USER_MODEL 必须在第一次 migrate 之前设置。如果已经 migrate 过,需要删库重建。

3. UserProfile 一对一扩展

# models/UserProfile.py
class UserProfile(BaseModel):
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="profile",     # user.profile.phone 反查
    )
    phone = models.CharField(max_length=20, blank=True, default="")
    department = models.CharField(max_length=100, blank=True, default="")

related_name="profile" 后,user.profile 就能取到扩展信息。如果用户没创建 UserProfile,访问 user.profile 会报 RelatedObjectDoesNotExist


十九、settings 多环境分离

1. 为什么要分离

开发 / 测试 / 生产环境的配置不同(数据库、密钥、DEBUG),把密码写死在 settings.py 里既不安全也不灵活。拆成"公共 + 多环境"结构,敏感信息放 .env(不提交 Git)。

2. 目录结构(包代替单文件)

djangoproject/
├── settings/
│   ├── __init__.py        # 根据环境变量决定加载哪个环境
│   ├── base.py            # 公共配置(INSTALLED_APPS / MIDDLEWARE / REST_FRAMEWORK 等)
│   ├── development.py      # 开发环境(DEBUG=True)
│   └── production.py       # 生产环境(DEBUG=False,正式库)
└── ...

3. base.py 用 dotenv 加载敏感信息

from dotenv import load_dotenv
import os

load_dotenv()  # 加载项目根目录的 .env 文件

SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'dev-insecure-key')
DEBUG = os.getenv('DJANGO_DEBUG', 'True') == 'True'   # 字符串转布尔
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.getenv('DB_NAME', 'test'),
        'USER': os.getenv('DB_USER', 'admin'),
        'PASSWORD': os.getenv('DB_PASSWORD', '123'),
        'HOST': os.getenv('DB_HOST', 'localhost'),
        'PORT': os.getenv('DB_PORT', '5432'),
    }
}

.env不要提交 Git):

DJANGO_SECRET_KEY=你的密钥
DJANGO_DEBUG=True
DB_NAME=test
DB_USER=admin
DB_PASSWORD=123

4. init.py 按环境变量切换

import os

env = os.getenv('DJANGO_ENV', 'development')
if env == 'production':
    from .production import *
else:
    from .development import *

5. 各环境文件继承 base

# development.py
from .base import *          # 先继承全部公共配置

DEBUG = True
# 开发环境可覆盖:用 SQLite、放开 CORS 等
# production.py
from .base import *

DEBUG = False
ALLOWED_HOSTS = ['www.example.com']
# 生产环境覆盖:正式数据库、关掉调试、配正式 CORS

启动 / 迁移时指定环境:DJANGO_SETTINGS_MODULE='djangoproject.settings.development'(PyCharm 的运行配置里改这个变量)。


二十、drf-yasg 自动 API 文档

1. 安装与配置

pip install drf-yasg

urls.py 里注册 schema 视图:

from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from rest_framework import permissions

schema_view = get_schema_view(
    openapi.Info(
        title="信息管理 API",
        default_version='v1',
        description="DRF 学习项目接口文档",
    ),
    public=True,
    permission_classes=[permissions.AllowAny],   # 文档页允许任何人访问
)

urlpatterns = [
    # ... 其他路由 ...
    path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
    path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
]

访问 /swagger/ 是交互式文档(可直接填参数试调),/redoc/ 是静态排版文档。DRF 会根据 ViewSet 自动生成所有端点。

2. 自定义参数(文件上传必看)

drf-yasg 默认把 POST 当 application/json不会显示文件上传框。上传附件必须显式声明:

from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser, FormParser

@swagger_auto_schema(
    method='post',
    operation_description='上传附件',
    manual_parameters=[
        openapi.Parameter(
            'file', openapi.IN_FORM,        # 关键:声明为表单字段
            description='附件文件',
            type=openapi.TYPE_FILE,
            required=True,
        ),
    ],
)
@action(detail=True, methods=['post'], parser_classes=[MultiPartParser, FormParser])
def attachments(self, request, pk=None):
    ...

三件套缺一不可:manual_parameters 让 swagger 显示 file 控件 + parser_classes 让后端能解析 multipart/form-data


二十一、GenericForeignKey 通用外键

1. 解决什么问题

普通 ForeignKey 只能指向固定一张表。但有时一条记录想关联"任意模型"——比如附件可以挂到信息记录、评论等任意对象上。通用外键(GenericForeignKey)用 ContentType 表做中转,实现"一条记录关联任意模型"。

2. 三件套

from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType

class Attachment(BaseModel):
    # 1. content_type:指向哪个模型(外键到 django_content_type 表)
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    # 2. object_id:那条记录的主键 id
    object_id = models.PositiveIntegerField()
    # 3. content_object:ORM 语法糖,直接拿关联对象(不存数据库)
    content_object = GenericForeignKey('content_type', 'object_id')

    file = models.FileField(upload_to='attachments/')
字段 是否存数据库 作用
content_type 指向 ContentType 表的哪一行(即哪个模型)
object_id 目标记录的主键
content_object 语法糖,运行时通过上面两字段定位对象

3. 反向查询(在被关联模型上加)

class InfoItem(BaseModel):
    # GenericRelation 让 item.attachments.all() 能反查出所有挂自己的附件
    attachments = GenericRelation('Attachment')

4. 视图中推断并注入

前端只传 file,content_type 从 URL 推断,后端注入:

item = self.get_object()
content_type = ContentType.objects.get_for_model(item)   # 拿到 InfoItem 的 ContentType
serializer.save(
    content_type=content_type,
    object_id=item.id,
    uploaded_by=request.user,
    original_name=uploaded_file.name,
    file_size=uploaded_file.size,
)

5. 序列化器注意事项(易踩坑)

class AttachmentSerializer(serializers.ModelSerializer):
    file_url = serializers.SerializerMethodField()

    class Meta:
        model = Attachment
        fields = ['id', 'content_type', 'object_id', 'file', 'file_url',
                  'original_name', 'file_size', 'uploaded_by', 'created_at']
        extra_kwargs = {
            # content_type / object_id 由 save() 注入,不来自前端
            # 不设 required=False,is_valid() 会在 save() 之前就因"必填项"而 400
            'content_type': {'required': False},
            'object_id': {'required': False},
            'original_name': {'required': False},
            'file_size': {'required': False},
        }

    def get_file_url(self, obj):
        request = self.context.get('request')
        if obj.file and request:
            return request.build_absolute_uri(obj.file.url)
        return None

调试技巧:用 f"{obj.content_type.app_label}.{obj.content_type.model}" 在序列化器里显示关联模型名(如 info_manage.infoitem)。

6. N+1 风险

GenericForeignKey **不能用 **select_related(它不是真正的 FK 列)。查询关联对象时有 N+1 风险,必要时用 prefetch_related 或接受。


二十二、RBAC 角色权限(企业级重点)

1. 概念:和 IsOwnerOrReadOnly 的区别

之前的 IsOwnerOrReadOnly 只判断"是不是创建人"——一个维度。RBAC(基于角色的访问控制)升级成"用户 → 角色 → 权限"三层,权限维度更灵活:同一用户可以有多个角色,每个角色继承父角色的权限。

IsOwnerOrReadOnly:  请求 → 是创建人? → 放行/拒绝
RBAC:                请求 → 用户有哪些角色? → 角色有哪些权限(含继承)? → 有该权限?

2. 数据模型

class Role(BaseModel):
    name = models.CharField(max_length=50)                       # 显示名,如"管理员"
    code = models.CharField(max_length=50, unique=True)          # 程序判断用,如"admin"
    parent = models.ForeignKey(                                  # 自引用继承(和评论 parent 同套路)
        'self', null=True, blank=True,
        on_delete=models.CASCADE, related_name='children')
    permissions = models.TextField(blank=True)                   # 权限码,逗号分隔字符串

class UserRole(BaseModel):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='user_roles')
    role = models.ForeignKey('Role', on_delete=models.CASCADE, related_name='role_users')

角色继承链:admin(删) → editor(增改) → viewer(查)。admin 继承 editor 的"增改" + viewer 的"查" = 全部四项权限。箭头方向是子 → 父。

3. 权限双层架构

RBACPermission(DRF 权限类)       RolePermissionBackend(Django 认证后端)
"决定查什么权限码"                  "决定怎么查权限"
        │                                      │
        └──── request.user.has_perm(perm) ──────┘
                     │
            遍历 AUTHENTICATION_BACKENDS 链

3.1 RBACPermission(DRF 层,决定查什么)

class RBACPermission(permissions.BasePermission):
    def has_permission(self, request, view):
        action_perms = getattr(view, 'action_permissions', None)
        if not action_perms:
            return True                       # 视图没配权限映射 → 放行
        required_perm = action_perms.get(view.action)   # 按当前动作查权限码
        if not required_perm:
            return True
        return request.user.has_perm(required_perm)

3.2 RolePermissionBackend(Django 认证后端,决定怎么查)

class RolePermissionBackend:
    def has_perm(self, user_obj, perm, obj=None):
        from info_manage.models import UserRole   # 延迟导入防循环
        user_roles = UserRole.objects.filter(user=user_obj).select_related('role')
        for ur in user_roles:
            if self._role_has_perm(ur.role, perm):
                return True
        return False

    def _role_has_perm(self, role, perm, _checked=None):
        if _checked is None:
            _checked = set()
        if role.id in _checked:          # 防循环继承死递归(A→B→A)
            return False
        _checked.add(role.id)
        if perm in [p.strip() for p in role.permissions.split(',')]:
            return True
        if role.parent:
            return self._role_has_perm(role.parent, perm, _checked)
        return False

    def get_all_permissions(self, user_obj):
        """收集用户全部权限码(含继承),返回 set,供前端 UserInfoView 用"""
        from info_manage.models import UserRole
        perms = set()
        for ur in UserRole.objects.filter(user=user_obj).select_related('role'):
            self._collect_role_perms(ur.role, perms)
        return perms

    def _collect_role_perms(self, role, perms, _checked=None):
        """递归收集一个角色 + 其所有父角色链上的权限码(set 去重 + 防循环继承)"""
        if _checked is None:
            _checked = set()
        if role.id in _checked:          # 已查过 → 直接返回,避免 A→B→A 死递归
            return
        _checked.add(role.id)
        for p in role.permissions.split(','):
            p = p.strip()
            if p:
                perms.add(p)             # set 自动去重
        if role.parent:                  # 递归向上收集父角色(继承)
            self._collect_role_perms(role.parent, perms, _checked)

select_related('role') 防 N+1;_checked(set 记录已查角色 id)防循环继承(角色环 A→B→A);split(',') 后必须 strip() 去空格。

4. ViewSet 声明(按 action 映射权限码)

class InfoItemViewSet(viewsets.ModelViewSet):
    permission_classes = [IsOwnerOrReadOnly, RBACPermission]   # 两个取交集
    action_permissions = {
        'list':           'info_manage.view_infoitem',
        'retrieve':       'info_manage.view_infoitem',
        'create':         'info_manage.add_infoitem',
        'update':         'info_manage.change_infoitem',
        'partial_update': 'info_manage.change_infoitem',
        'destroy':        'info_manage.delete_infoitem',
    }

权限码格式(Django 约定):app_label.动作_模型名小写

动作 权限码
查看 info_manage.view_infoitem
新增 info_manage.add_infoitem
修改 info_manage.change_infoitem
删除 info_manage.delete_infoitem

5. 注册双后端(settings)

AUTHENTICATION_BACKENDS = [
    'django.contrib.auth.backends.ModelBackend',      # Django 默认(查 user_permissions 表)
    'info_manage.backends.RolePermissionBackend',     # 自定义 RBAC(查角色权限)
]

两个后端并存has_perm() 遍历链,任一返回 True 即放行。老代码用 user_permissions 的照常工作,新代码用角色的也工作。

6. 前端联调(按权限控制按钮)

后端 + 前端配合,让按钮跟着用户角色显隐:

登录 → 拿 token → 调 GET /api/auth/user/ → 返回 {username, roles, permissions}
   → 前端 store 持久化 → 列表页 v-if="hasPerm('info_manage.add_infoitem')" 控制按钮
  1. 后端加 UserInfoView(GET /api/auth/user/),复用 get_all_permissions() 收集权限码
  2. 前端登录后调该接口,把权限码存到 user store(reactive + localStorage,项目未装 Pinia)
  3. 列表页 v-if="hasPerm('info_manage.add_infoitem')" 控制"新增"按钮显隐

7. 测试账号(密码统一 123456)

账号 角色 权限
test_admin 管理员 admin 全部(view + add + change + delete)
test_editor 编辑 editor view + add + change(无 delete
test_viewer 访客 viewer 仅 view

用不同账号登录前端,能看到"新增 / 编辑 / 删除"按钮按角色显隐,与后端 RBAC 一致。


附录:常用查询速查表

ORM 查询

# ──── 基础查询 ────
Model.objects.all()                         # 全部
Model.objects.get(pk=1)                     # 查单条(不存在报 DoesNotExist)
Model.objects.filter(field=value)           # 过滤
Model.objects.exclude(field=value)          # 排除
Model.objects.order_by('-field')            # 排序
Model.objects.first() / .last()             # 第一/最后一条
Model.objects.count()                        # 计数
Model.objects.exists()                       # 是否存在
Model.objects.none()                        # 空 QuerySet

# ──── 字段查找(双下划线)────
filter(field__exact=value)                  # 精确等于(默认)
filter(field__icontains='text')            # 模糊搜索(不区分大小写)
filter(field__contains='text')             # 包含(区分大小写)
filter(field__startswith='text')           # 以...开头
filter(field__endswith='text')             # 以...结尾
filter(field__in=[1, 2, 3])                 # 在列表中
filter(field__gt=value)                    # 大于
filter(field__gte=value)                   # 大于等于
filter(field__lt=value)                    # 小于
filter(field__lte=value)                   # 小于等于
filter(field__range=(1, 10))              # 范围(含两端)
filter(field__isnull=True)                 # 为 NULL
filter(field__year=2025)                   # 按年
filter(field__month=7)                     # 按月
filter(field__date='2025-07-22')           # 按日期

# ──── 关联查询 ────
item.category                               # 正向:取关联对象
item.tags.all()                             # 正向 M2M
category.items.all()                         # 反向 FK(用 related_name)
item.attachments.all()                       # 反向 FK
user.profile                                # 正向 O2O

# ──── 性能优化 ────
select_related('fk_field')                  # FK/OneToOne 用 JOIN(1 次查询)
prefetch_related('m2m_field')              # M2M/FK反向 单独查(2 次查询)

# ──── 聚合 ────
from django.db.models import Count, Sum, Avg, Max, Min
Model.objects.aggregate(total=Count('id'), avg_price=Avg('price'))
Model.objects.values('category').annotate(count=Count('id'))   # GROUP BY

# ──── F / Q 对象 ────
from django.db.models import F, Q
Model.objects.filter(views__gt=F('likes'))              # 字段间比较
Model.objects.filter(Q(title__icontains='a') | Q(status='published'))  # OR

# ──── 创建/更新/删除 ────
Model.objects.create(field=value)            # 创建并保存
obj = Model(field=value); obj.save()         # 创建后手动保存
obj.field = new_value; obj.save()            # 修改
Model.objects.filter(pk=1).update(field=val) # 批量更新
obj.delete()                                  # 删除单条
Model.objects.filter(id__in=ids).delete()    # 批量删除

DRF 视图集方法速查

# ──── ModelViewSet 自带方法 ────
self.get_queryset()           # 获取 queryset
self.get_object()             # 根据 pk 查单条记录
self.get_serializer()         # 获取序列化器实例
self.filter_queryset(qs)      # 应用过滤
self.paginate_queryset(qs)    # 应用分页
self.get_paginated_response(data)  # 生成分页响应

# ──── 可重写的钩子方法 ────
perform_create(serializer)    # POST 时调用
perform_update(serializer)    # PUT/PATCH 时调用
perform_destroy(instance)     # DELETE 时调用
get_queryset()               # 动态返回 queryset
get_serializer_class()        # 动态返回序列化器
get_permissions()            # 动态返回权限列表

# ──── request 对象 ────
request.data                  # POST/PUT 的 JSON 数据
request.query_params          # GET 参数(等价于 request.GET)
request.user                 # 当前用户
request.method              # HTTP 方法
request.FILES               # 上传的文件

HTTP 状态码速查

状态码 含义 DRF 场景
200 OK GET 成功
201 Created POST 创建成功
204 No Content DELETE 成功
400 Bad Request 数据校验失败
401 Unauthorized 未认证 / token 无效
403 Forbidden 无权限
404 Not Found 记录不存在
405 Method Not Allowed 方法不允许
429 Too Many Requests 限流
500 Internal Server Error 服务器错误

代码