从零构建家庭照片管理系统:Django 模块化单体实战,以及我踩到的 4 个真实坑

一、需求与架构选型

1.1 原始需求

需求很明确:做一个家庭照片管理系统,要求「单体式 Web 应用 + 模块化页面」结构,

每个功能可以是独立页面或独立 App,但所有功能共用同一个数据库,以后加功能只需继续加模块。

这段描述其实已经框定了架构:模块化单体(Modular Monolith)

C:\Users\86182\Desktop\familyphotomanage

1.2 为什么不上微服务

家庭相册这个场景,微服务是纯粹的负担:

  • 用户量是「一家人」,个位数到十几个,单进程绰绰有余
  • 照片、相册、标签之间关联极强,拆开就要处理跨服务 JOIN,得不偿失
  • 部署在家里的 NAS 或一台小机器上,多进程编排纯属自找麻烦

但「单体」不等于「一坨」。模块化单体的关键在于:物理上一个进程一个数据库,
逻辑上模块边界清晰
。Django 的 App 机制天生适合这个模式。

1.3 开工前先问清两件事

在写第一行代码之前,我确认了两个会影响全局、且后期极难修改的决策点。

第一个:模块范围。 基础 6 个模块(core / accounts / photos / albums / tags / search)

是必须的,另外三个可选:回收站、分享链接、统计报表。最终确定全做。

第二个,也是更关键的:用户模型怎么建。

这里有两个选项:

方案 做法 代价
自定义 User 继承 AbstractUser,直接加字段 必须在项目初始就定,后期改动极痛苦
内置 User + Profile 沿用 auth.User,一对一扩展 侵入小,但每次查昵称都要多一跳

我推荐并最终采用了自定义 User 。理由很直接:AUTH_USER_MODEL 一旦有数据后再改,

需要手写数据迁移搬迁外键,是 Django 项目里最难回头的决策之一。而家庭相册场景下

「成员」天然带昵称、角色、头像、存储配额这些字段,Profile 方案会让几乎每个查询都多一次

关联。趁项目还是空目录,一次定对。

经验AUTH_USER_MODEL 属于「开工第一天就要定」的决策。哪怕你现在只想加一个字段,

也建议直接上自定义 User------成本只是多写 5 行,收益是永远不用做那个痛苦的迁移。

1.4 最终模块划分

复制代码
familyphoto/          # 项目配置
├── settings.py
├── urls.py           # 各模块在此挂载,加模块只需加一行
├── converters.py     # 中文 slug 路由转换器(第四节会讲为什么需要它)
└── test_runner.py    # 隔离 MEDIA_ROOT 的测试运行器(第五节)

apps/
├── core/       仪表盘、公共抽象模型、模板标签
├── accounts/   自定义 User、登录权限、成员管理
├── photos/     上传、EXIF、缩略图、时间线      ← 核心
├── albums/     相册、排序、封面
├── tags/       标签 + 人物
├── search/     多条件组合搜索
├── trash/      软删除回收站
├── sharing/    免登录 token 分享
└── stats/      统计报表

注意 searchtrashstats 三个模块没有自己的数据表 。它们只是不同的

「视图 + 查询逻辑」组合:搜索查 photos,回收站读其他模型的 deleted_at

统计做聚合查询。这正是模块化单体的优势------共用数据库让这类跨模块功能几乎零成本。


二、公共层:先把重复的东西抽出来

多模块项目最容易失控的地方是重复代码。所以第一步不是写业务,而是先建公共层。

2.1 时间戳与软删除抽象基类

几乎每张表都需要 created_at / updated_at,照片和相册都需要软删除。抽成

apps/core/models.py

python 复制代码
class TimeStampedModel(models.Model):
    """带创建/更新时间的抽象基类。"""
    created_at = models.DateTimeField("创建时间", auto_now_add=True, db_index=True)
    updated_at = models.DateTimeField("更新时间", auto_now=True)

    class Meta:
        abstract = True

软删除稍微复杂,需要配套的查询集和管理器:

python 复制代码
class SoftDeleteQuerySet(models.QuerySet):
    """区分「在用」与「已进回收站」两种状态的查询集。"""
    def alive(self):
        return self.filter(deleted_at__isnull=True)

    def trashed(self):
        return self.filter(deleted_at__isnull=False)


class SoftDeleteManager(models.Manager.from_queryset(SoftDeleteQuerySet)):
    """默认只返回未删除的记录。需要访问回收站内容时用 Model.all_objects。"""
    def get_queryset(self):
        return super().get_queryset().alive()


class SoftDeleteModel(models.Model):
    deleted_at = models.DateTimeField("删除时间", null=True, blank=True, db_index=True)
    deleted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
        null=True, blank=True, related_name="+",
    )

    objects = SoftDeleteManager()      # 默认排除回收站
    all_objects = SoftDeleteQuerySet.as_manager()  # 能看到全部

    class Meta:
        abstract = True

    def delete(self, using=None, keep_parents=False, user=None):
        """移入回收站,而不是真正删除。"""
        self.deleted_at = timezone.now()
        self.deleted_by = user
        self.save(update_fields=["deleted_at", "deleted_by", "updated_at"])

    def restore(self):
        self.deleted_at = None
        self.deleted_by = None
        self.save(update_fields=["deleted_at", "deleted_by", "updated_at"])

    def hard_delete(self, using=None, keep_parents=False):
        """真正从数据库中删除。"""
        return super().delete(using=using, keep_parents=keep_parents)

这里有个刻意的设计 :我重写了 delete(),让它默认变成「移入回收站」。这样任何地方

误调 photo.delete() 都不会真的丢文件------安全的行为是默认行为,危险操作必须显式写

hard_delete()

双管理器(objects / all_objects)是关键:业务代码用 objects 自动看不到已删除内容,

回收站模块用 all_objects.trashed() 精确访问。不需要在每个查询里手写

.filter(deleted_at__isnull=True)

2.2 Bootstrap 表单 Mixin

Django 默认渲染的表单没有 Bootstrap 类,逐个字段加 widget 太啰嗦:

python 复制代码
class BootstrapMixin:
    """给所有字段套上 Bootstrap 的 form-control / form-select 类。"""
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        for field in self.fields.values():
            widget = field.widget
            if isinstance(widget, forms.CheckboxInput):
                widget.attrs.setdefault("class", "form-check-input")
            elif isinstance(widget, (forms.Select, forms.SelectMultiple)):
                widget.attrs.setdefault("class", "form-select")
            else:
                widget.attrs.setdefault("class", "form-control")

setdefault 而非直接赋值,这样单个字段仍可覆盖默认样式。之后所有表单

class XxxForm(BootstrapMixin, forms.ModelForm) 即可。


三、核心模块:照片

photos 是整个系统的核心,其他模块都围绕它扩展。

3.1 数据模型的分层设计

Photo 的字段分成四类,注释里也明确标注了来源:

python 复制代码
class Photo(SoftDeleteModel, TimeStampedModel):
    # ---- 用户填写 ----
    title = models.CharField("标题", max_length=200, blank=True)
    description = models.TextField("描述", blank=True)
    visibility = models.CharField(..., choices=Visibility.choices, default=Visibility.FAMILY)
    is_favorite = models.BooleanField("收藏", default=False, db_index=True)

    # ---- 拍摄信息(多数来自 EXIF,可手动修正)----
    taken_at = models.DateTimeField("拍摄时间", null=True, blank=True, db_index=True)
    location_name = models.CharField("拍摄地点", max_length=200, blank=True)
    latitude / longitude = models.FloatField(...)

    # ---- 文件与相机元数据(由系统写入,后台只读)----
    file_size / width / height / image_format / checksum
    camera_make / camera_model / lens_model / aperture / shutter_speed / iso / focal_length

为什么 taken_at 允许为空? 因为不是所有照片都有 EXIF------微信转发过的图、

截图、扫描的老照片都没有。允许为空,然后在排序时特殊处理(见下),比强行塞一个假时间要好。

3.2 一个容易忽略的排序细节

时间线要按拍摄时间倒序,但没有拍摄时间的照片怎么排?如果直接 ordering = ["-taken_at"]

SQLite 里 NULL 会排在最前面------一堆「无日期」的照片霸占时间线顶部,体验很差。

python 复制代码
class Meta:
    # 时间线默认按拍摄时间倒序,没有 EXIF 时间的排在后面
    ordering = [models.F("taken_at").desc(nulls_last=True), "-created_at"]
    indexes = [
        models.Index(fields=["owner", "-taken_at"]),
        models.Index(fields=["deleted_at", "-taken_at"]),
    ]

nulls_last=True 解决排序,第二排序键 -created_at 保证无日期的照片之间也有稳定顺序

(否则分页会出现同一张照片重复出现或漏出的诡异现象)。

两个复合索引对应最高频的两种查询:「某成员的照片按时间倒序」和「排除回收站后按时间倒序」。

3.3 权限判定收敛到一处

可见性有三档:仅自己 / 全家可见 / 公开。这个判断会在列表、详情、搜索、分享等

七八个地方用到,绝不能各写一遍:

python 复制代码
class PhotoQuerySet(models.QuerySet):
    def visible_to(self, user):
        """该用户有权查看的照片。"""
        if user.is_anonymous:
            return self.filter(visibility=Visibility.PUBLIC)
        if user.is_family_admin:
            return self
        # 普通成员:自己的全部 + 别人的家庭/公开照片
        return self.filter(
            models.Q(owner=user)
            | models.Q(visibility__in=[Visibility.FAMILY, Visibility.PUBLIC])
        )

之后所有列表页统一 Photo.objects.visible_to(request.user),单对象判断用

photo.can_view(user) / can_edit(user)权限逻辑只有一份实现,就只有一处可能出错。

这个设计在后面写测试时价值就体现出来了------我只需要重点测 visible_tocan_view

就能覆盖所有页面的权限行为。

3.4 EXIF 解析:比想象中麻烦

EXIF 是我在这个项目里花时间最多的地方。它的数据格式相当混乱,几个坑点:

坑点一:数值类型不统一。 光圈可能是 FractionIFDRational,也可能是

(28, 10) 这样的二元组。得统一转换:

python 复制代码
def _to_float(value):
    """EXIF 里的数值可能是 Fraction/IFDRational/tuple,统一转 float。"""
    try:
        if isinstance(value, tuple) and len(value) == 2:
            return float(value[0]) / float(value[1]) if value[1] else None
        if isinstance(value, Fraction):
            return float(value)
        return float(value)
    except (TypeError, ValueError, ZeroDivisionError):
        return None

坑点二:拍摄参数不在顶层。 MakeModel 在顶层 IFD,但光圈快门 ISO 都在

ExifOffset(0x8769)指向的子 IFD 里,GPS 在 0x8825:

python 复制代码
raw = image.getexif()
exif_ifd = raw.get_ifd(0x8769)   # 拍摄参数在这里
gps_ifd = raw.get_ifd(0x8825)    # GPS 在这里

坑点三:GPS 是度分秒格式,需要转十进制,还要处理南纬西经的符号:

python 复制代码
def _dms_to_degrees(dms, ref):
    """把 EXIF 的度分秒 GPS 坐标转成十进制度数。"""
    if not dms or len(dms) != 3:
        return None
    degrees, minutes, seconds = (_to_float(v) for v in dms)
    if None in (degrees, minutes, seconds):
        return None
    result = degrees + minutes / 60 + seconds / 3600
    if ref in ("S", "W"):   # 南纬、西经为负
        result = -result
    return round(result, 6)

坑点四:字符串带 null 字节。 相机厂商写入的字符串经常以 \x00 结尾,

不清理会导致显示异常,所以统一 .strip("\x00 ").strip()

坑点五:快门速度要转成人类习惯的写法。 EXIF 里存的是 0.004 秒,

但摄影习惯写 1/250

python 复制代码
exposure = _to_float(value)
if exposure:
    data["shutter_speed"] = (
        f"1/{round(1 / exposure)}" if exposure < 1 else f"{exposure:g}"
    )

3.5 缩略图:两个必须处理的细节

python 复制代码
def make_thumbnail(image, max_edge):
    thumb = image.copy()
    # 按 EXIF Orientation 摆正,否则手机竖拍的照片缩略图会躺着
    thumb = ImageOps.exif_transpose(thumb)

    if thumb.mode not in ("RGB", "L"):
        # RGBA/P 带透明通道,转 JPEG 前要先铺白底
        if thumb.mode in ("RGBA", "LA", "PA"):
            background = Image.new("RGB", thumb.size, (255, 255, 255))
            background.paste(thumb, mask=thumb.split()[-1])
            thumb = background
        else:
            thumb = thumb.convert("RGB")

    thumb.thumbnail((max_edge, max_edge), Image.Resampling.LANCZOS)

两个细节都是实践中必踩的:

  1. exif_transpose :手机竖拍的照片,像素数据其实是横的,靠 EXIF 的
    Orientation 标记旋转。不处理的话缩略图全是躺着的。
  2. 透明通道铺白底:PNG 转 JPEG 时,透明区域会变成黑色。必须先贴到白色背景上。

thumbnail() 而不是 resize(),因为前者保持宽高比且不会放大小图。

3.6 批量上传:单张失败不能拖垮整批

这是上传流程的关键设计。用户一次选 50 张,其中 1 张损坏,不能让整批失败:

python 复制代码
for index, image_file in enumerate(images, start=1):
    checksum = compute_checksum(image_file)
    # 同一用户重复上传同一文件时跳过
    if Photo.all_objects.filter(owner=request.user, checksum=checksum).exists():
        skipped.append(image_file.name)
        continue

    photo = Photo(owner=request.user, image=image_file, ...)
    try:
        with transaction.atomic():
            # 先落库拿到文件路径,再解析 EXIF 与生成缩略图
            photo.save()
            if not process_upload(photo):
                raise ValueError("图片无法解析")
            photo.save()
            ...
        created.append(photo)
    except (ValueError, OSError) as exc:
        failed.append(f"{image_file.name}({exc})")
        # 事务回滚后清掉可能已落盘的文件
        if photo.image:
            photo.image.delete(save=False)

三个要点:

  • 每张照片独立事务,失败只回滚这一张
  • 事务回滚不会删文件 ,所以 except 里必须手动 photo.image.delete()
    否则磁盘上会堆积孤立文件
  • SHA-256 去重,避免同一张照片反复上传

去重用的 checksum 函数有个容易忽略的点------读完必须复位文件指针

python 复制代码
def compute_checksum(file_obj, chunk_size=65536):
    digest = hashlib.sha256()
    file_obj.seek(0)
    for chunk in iter(lambda: file_obj.read(chunk_size), b""):
        digest.update(chunk)
    file_obj.seek(0)   # ← 必须复位,否则后续保存会得到空文件
    return digest.hexdigest()

忘了这一行,后果是「照片上传成功但文件是 0 字节」。我专门为此写了个测试锁死行为:

python 复制代码
def test_resets_file_pointer(self):
    """算完校验和后指针要复位,否则后续保存会得到空文件。"""
    stream = io.BytesIO(make_jpeg())
    compute_checksum(stream)
    self.assertEqual(stream.tell(), 0)

四、分享模块:需要认真对待安全

分享链接是唯一对外开放、无需登录的入口,安全边界必须清楚。

4.1 token 设计

python 复制代码
def generate_token():
    return secrets.token_urlsafe(24)   # urlsafe 编码后约 32 字符

secrets 而不是 random------后者是伪随机,可预测。24 字节约 192 位熵,无法枚举。

4.2 有效性的四重判定

链接失效有多种原因,判定必须集中:

python 复制代码
@property
def is_valid(self):
    """链接当前是否还能访问。"""
    if not self.is_active or self.is_expired or self.is_exhausted:
        return False
    # 目标被移入回收站后链接自动失效
    target = self.target
    return target is not None and not target.is_trashed

最后一条最容易漏:照片删了,但链接还在 。如果不检查 is_trashed

用户删掉照片后分享链接依然能访问------这是实打实的隐私泄露。

对应的 HTTP 语义我用了 410 Gone 而不是 404:链接确实存在过,只是已失效。

这样访客能看到「已过期,请联系分享者」的明确提示,而不是一个含糊的 404。

4.3 数据库层的约束

一条链接要么指向照片,要么指向相册,不能既是又是,也不能都不是。这个不变量不该只靠

应用层保证:

python 复制代码
constraints = [
    models.CheckConstraint(
        condition=(
            models.Q(photo__isnull=False, album__isnull=True)
            | models.Q(photo__isnull=True, album__isnull=False)
        ),
        name="share_target_exactly_one",
    )
]

4.4 访问计数的并发安全

python 复制代码
def register_view(self):
    """记录一次访问。用 F() 表达式避免并发下计数丢失。"""
    ShareLink.objects.filter(pk=self.pk).update(
        view_count=models.F("view_count") + 1,
        last_viewed_at=timezone.now(),
    )
    self.refresh_from_db(fields=["view_count", "last_viewed_at"])

如果写成 self.view_count += 1; self.save(),两个访客同时打开就会丢一次计数

(经典的读-改-写竞态)。F() 把加法下推到数据库执行,天然原子。

4.5 范围限制:最关键的安全检查

访客拿到一个相册链接,能不能用它下载相册 的照片?如果只检查「链接有效」就放行

photo_id,答案是能------这就是越权漏洞:

python 复制代码
def share_download(request, token, photo_id):
    link = _get_valid_link(token)
    if not link.is_valid or not link.allow_download or not _is_unlocked(request, link):
        raise Http404("无法下载")

    # 照片必须确实属于这个分享的范围内
    if link.target_kind == "photo":
        if link.photo_id != photo_id:
            raise Http404("照片不在此分享范围内")
        photo = link.photo
    else:
        photo = link.album.photos.filter(pk=photo_id, deleted_at__isnull=True).first()
        if photo is None:
            raise Http404("照片不在此分享范围内")

我后来专门写了测试验证这条边界,并在真实 HTTP 请求下确认过(见第六节)。


五、四个真实的 Bug:测试是怎么把它们抓出来的

前面都是设计。下面是这个项目最有价值的部分------代码写完、manage.py check 通过、
页面看起来也正常,但测试揪出了 4 个真实缺陷

Bug 1:中文 slug 让相册页面 500

现象 :跑第一批冒烟测试,报了 NoReverseMatch

复制代码
django.urls.exceptions.NoReverseMatch: Reverse for 'detail' with keyword
arguments '{'slug': '三亚之行'}' not found. 1 pattern(s) tried:
['albums/(?P<slug>[-a-zA-Z0-9_]+)/\\Z']

根因 :相册名是中文,我用了 SlugField(allow_unicode=True),所以

slugify("三亚之行", allow_unicode=True) 生成的 slug 就是 三亚之行

但路由里写的是 <slug:slug>,而 Django 内置 slug 转换器的正则是

[-a-zA-Z0-9_]+------只匹配 ASCII。中文 slug 永远匹配不上。

这个坑对中文项目非常典型:模型层允许 Unicode,路由层却不允许,两边不一致。

修复:注册一个 Unicode 感知的转换器:

python 复制代码
# familyphoto/converters.py
class UnicodeSlugConverter:
    """匹配含中文的 slug。

    Django 内置的 <slug:...> 只认 ASCII,而相册和标签的 slug 由中文名生成
    (SlugField(allow_unicode=True)),因此需要一个放宽字符集的转换器。
    正则与 django.core.validators.validate_unicode_slug 保持一致。
    """
    regex = r"[-\w]+"

    def to_python(self, value):
        return value

    def to_url(self, value):
        return value

\w 在 Python 3 的 re 里默认匹配 Unicode 字母,正好符合需求。注册后改用 <uslug:slug>

python 复制代码
register_converter(UnicodeSlugConverter, "uslug")

# apps/albums/urls.py
path("<uslug:slug>/", views.album_detail, name="detail"),

同时给 Tag.slug 也补上 allow_unicode=True,保持两处一致。

顺带一个测试教训 :我一开始验证这个修复时,用 shell 脚本读 Python 输出的 URL

列表去 curl,结果全返回 000(连接失败)。排查后发现是 Python 在 Windows 输出的

\r\n 被带进了 URL。工具链的问题会伪装成代码的问题 ------所以我又直接

curl 了单个 URL 确认页面标题正常渲染,才确定是脚本的锅。

Bug 2:相册封面取错了照片

现象:这个 bug 是测试先写、然后失败暴露出来的。我写了个符合直觉的断言:

python 复制代码
def test_falls_back_to_first_photo(self):
    # first 的 position=1,second 的 position=2
    self.assertEqual(self.album.cover_photo, self.first)

结果失败:

复制代码
AssertionError: <Photo: 第二张> != <Photo: 第一张>

根因:原实现是这样的:

python 复制代码
@property
def cover_photo(self):
    if self.cover and not self.cover.is_trashed:
        return self.cover
    return self.photos.filter(deleted_at__isnull=True).first()   # ← 问题在这

self.photos 是跨 M2M 的关联查询,.first() 会套用 Photo.Meta.ordering

------也就是「拍摄时间倒序」,而不是相册自己的 AlbumPhoto.position 顺序。

结果就是:用户精心把某张照片排在相册第一位,封面却显示了拍摄时间最新的那张。

页面上看不出「错」,只是「不符合预期」------这种 bug 靠肉眼验收几乎发现不了

修复:显式指定按中间表的 position 排序:

python 复制代码
@property
def cover_photo(self):
    """封面照片:手动指定优先,否则取相册内排在最前的一张。

    必须按 AlbumPhoto.position 排序 ------ 跨 M2M 取 first() 会套用
    Photo.Meta.ordering(拍摄时间倒序),那不是相册的展示顺序。
    """
    if self.cover and not self.cover.is_trashed:
        return self.cover
    return (
        self.photos.filter(deleted_at__isnull=True)
        .order_by("album_links__position")
        .first()
    )

经验 :只要模型定义了 Meta.ordering,任何跨关联的 .first() / .last()

都要警惕------它用的是目标模型的默认排序,不是你以为的那个顺序。

Bug 3:EXIF 时间没有时区

现象:我构造了一张带真实 EXIF 的 JPEG 走完整上传流程,输出里夹着一条警告:

复制代码
RuntimeWarning: DateTimeField Photo.taken_at received a naive datetime
(2023-08-15 14:22:01) while time zone support is active.

根因 :EXIF 里的时间是 2023:08:15 14:22:01 这样的纯字符串,不含任何时区信息

datetime.strptime() 解析出来是 naive datetime,而项目开了 USE_TZ = True

Django 此时会按 UTC 强行解释------照片实际是北京时间下午 2 点拍的,存进去变成 UTC 14:22,

显示时又转回本地,时间凭空偏移 8 小时

这个 bug 危害不小:时间线排序错乱,「按年份筛选」在跨年边界上出错。

修复:按项目配置的时区解释 EXIF 时间:

python 复制代码
def _parse_exif_datetime(value):
    """EXIF 时间字符串形如 '2024:03:15 14:22:01'。

    EXIF 不带时区信息,按项目配置的本地时区(TIME_ZONE)解释,
    再转成 aware datetime,避免 USE_TZ=True 下的 naive datetime 警告。
    """
    text = str(value).strip("\x00 ").strip()
    for fmt in ("%Y:%m:%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y:%m:%d"):
        try:
            naive = datetime.strptime(text, fmt)
        except ValueError:
            continue
        if timezone.is_naive(naive):
            return timezone.make_aware(naive, timezone.get_default_timezone())
        return naive
    return None

验证时我特意用 -W error::RuntimeWarning 把警告升级为错误,确保真的修掉了:

复制代码
ok = True (无 RuntimeWarning 即通过)
taken_at = 2024-05-01 09:15:30+08:00 tzinfo = Asia/Shanghai

经验RuntimeWarning 很容易在满屏日志里被忽略。把警告当错误跑一遍

是低成本高收益的做法。

Bug 4:测试把图片写进了真实的 media/

现象:跑完 photos 模块测试后,我顺手看了下 media 目录:

bash 复制代码
$ ls media/photos/2026/08/
bad.jpg          ← 这是测试用的损坏文件
d1.jpg           ← 这是测试用的重复上传文件
demo_001.jpg
demo_002.jpg
...

根因 :Django 测试会自动创建独立的测试数据库,但不会隔离 MEDIA_ROOT

测试里所有 SimpleUploadedFile 上传的文件,全都实实在在写进了生产的 media/ 目录。

这个问题的严重性容易被低估:

  • 每跑一次测试,media/ 就多一堆垃圾
  • 测试文件名如果和真实照片撞了(比如都叫 IMG_1234.jpg),会直接覆盖真实照片
  • 数据库是临时的、测完销毁,但文件留下来了,形成孤立文件

修复 :写一个把 MEDIA_ROOT 指向临时目录的测试运行器:

python 复制代码
# familyphoto/test_runner.py
class MediaIsolatedRunner(DiscoverRunner):
    """把 MEDIA_ROOT 指向临时目录,避免测试产生的图片污染真实 media/。

    在 settings.TEST_RUNNER 中启用。测试结束后临时目录整体删除。
    """

    def setup_test_environment(self, **kwargs):
        super().setup_test_environment(**kwargs)
        from django.conf import settings
        self._original_media_root = settings.MEDIA_ROOT
        self._temp_media_root = tempfile.mkdtemp(prefix="familyphoto-test-media-")
        settings.MEDIA_ROOT = self._temp_media_root

    def teardown_test_environment(self, **kwargs):
        from django.conf import settings
        settings.MEDIA_ROOT = self._original_media_root
        shutil.rmtree(self._temp_media_root, ignore_errors=True)
        super().teardown_test_environment(**kwargs)
python 复制代码
# settings.py
TEST_RUNNER = "familyphoto.test_runner.MediaIsolatedRunner"

验证方式是数文件------测试前后必须一致:

bash 复制代码
清理后 media 文件数: 92
Ran 34 tests --- OK
测试后 media 文件数: 92
✓ media 未被污染

经验 :任何涉及文件上传的 Django 项目都该配这个 runner。这是我认为最值得直接

抄走的一段代码。

附带修掉的一个警告

跑测试时还看到:

复制代码
UnorderedObjectListWarning: Pagination may yield inconsistent results with
an unordered object_list: <class 'apps.albums.models.Album'>

原因是 annotate() 会清掉 Meta.ordering(Django 为避免生成错误的 GROUP BY

而特意如此)。查询集变成无序后,分页结果不稳定------同一张照片可能在第 1 页和第 2 页
都出现,或者干脆消失

修复是在 annotate 后显式重新排序:

python 复制代码
def get_queryset(self):
    # annotate() 会清掉 Meta.ordering,这里显式重新指定,否则分页结果不稳定
    return (
        Album.objects.visible_to(self.request.user)
        .select_related("owner", "cover")
        .annotate(n_photos=Count("photos", filter=Q(photos__deleted_at__isnull=True),
                                 distinct=True))
        .order_by("-is_pinned", F("event_date").desc(nulls_last=True), "-created_at")
    )

六、验证策略:不只跑单元测试

我没有停在「131 个测试通过」。对于安全相关的行为,我用真实 HTTP 请求,以未登录访客的

身份逐一验证。

6.1 构造带真实 EXIF 的 JPEG

用 Pillow 合成图片、piexif 写入 EXIF,走完整上传流程:

python 复制代码
exif_dict = {
    "0th": {piexif.ImageIFD.Make: b"Canon", piexif.ImageIFD.Model: b"Canon EOS R6"},
    "Exif": {
        piexif.ExifIFD.DateTimeOriginal: b"2023:08:15 14:22:01",
        piexif.ExifIFD.FNumber: (28, 10),        # f/2.8
        piexif.ExifIFD.ExposureTime: (1, 250),   # 1/250
        piexif.ExifIFD.ISOSpeedRatings: 400,
        piexif.ExifIFD.FocalLength: (85, 1),
    },
    "GPS": {
        piexif.GPSIFD.GPSLatitudeRef: b"N",
        piexif.GPSIFD.GPSLatitude: ((39,1),(54,1),(2600,100)),
        piexif.GPSIFD.GPSLongitudeRef: b"E",
        piexif.GPSIFD.GPSLongitude: ((116,1),(23,1),(2900,100)),
    },
}

实际输出:

复制代码
--- process_upload ok = True ---
  尺寸: 1200 x 800 | 格式: JPEG
  拍摄时间: 2023-08-15 14:22:01
  相机: Canon EOS R6 | 镜头: RF85mm F2 MACRO IS STM
  光圈: f/2.8 | 快门: 1/250 | ISO: 400 | 焦距: 85.0mm
  GPS: 39.907222 116.391389 | has_gps = True
  小缩略图: thumbs/small/... -> (320, 213)
  中缩略图: thumbs/medium/... -> (800, 533)

GPS 转换结果 39.907, 116.391 正好落在北京------说明度分秒转十进制是对的。

缩略图 (320, 213) 保持了 3:2 宽高比。相机显示是 Canon EOS R6 而非

Canon Canon EOS R6,说明品牌去重逻辑生效:

python 复制代码
@property
def camera_display(self):
    # 有些相机的 Model 已经含品牌名,避免出现 "Canon Canon EOS"
    if self.camera_make and self.camera_model.startswith(self.camera_make):
        return self.camera_model
    return " ".join(p for p in [self.camera_make, self.camera_model] if p)

6.2 以真实访客身份测分享链接

我构造了 5 种状态的链接,然后用完全独立的 cookie jar(模拟未登录访客)逐一访问:

复制代码
状态          期望    实际
无密码照片    200  →  200 ✓
已过期        410  →  410 ✓
次数用尽      410  →  410 ✓
已停用        410  →  410 ✓
不存在 token  404  →  404 ✓
带密码        302  →  302 → 跳转 /unlock/ ✓
未解锁下载    404  →  404 ✓

然后测密码流程和最关键的越权检查:

复制代码
错误密码            → 页面显示「密码不正确」✓
正确密码            → 302 跳回分享页,再访问 200 ✓
下载相册内照片 #1   → 200 ✓
下载相册外照片 #14  → 404 ✓  ← 越权被阻止

最后确认解锁分享不等于获得系统访问权

复制代码
访客访问 /photos/1/         → 302(跳登录)✓
访客访问 /dashboard/        → 302 ✓
访客访问 /photos/1/download/ → 302 ✓

这些行为随后都固化成了测试,防止将来回归:

python 复制代码
def test_album_link_cannot_download_photo_outside_album(self):
    """关键越权检查:相册链接不能用来取相册外的照片。"""
    link = ShareLink.objects.create(album=self.album, created_by=self.user)
    response = self.client.get(
        reverse("sharing:download", args=[link.token, self.outside.pk])
    )
    self.assertEqual(response.status_code, 404)

def test_share_does_not_grant_app_access(self):
    """访客解锁分享后,仍然不能访问系统内部页面。"""
    link = ShareLink.objects.create(photo=self.inside, created_by=self.user)
    self.client.get(reverse("sharing:view", args=[link.token]))
    for url in [reverse("photos:list"), reverse("core:dashboard")]:
        with self.subTest(url=url):
            self.assertEqual(self.client.get(url).status_code, 302)

6.3 最终测试分布

复制代码
模块       测试数
core         14    全页面冒烟、软删除语义、可见性
photos       20    EXIF、缩略图、上传流程、模型属性
albums       18    中文 slug、封面回退、排序、权限
sharing      24    有效性判定、密码门、越权防护
search       21    关键词跨字段、条件组合、去重、可见性
tags         17    slug、计数、人物关联、唯一约束
trash        17    还原、彻底删除、文件清理、purge 命令
─────────────────
合计        131    全部通过

我刻意在几个地方写了「反向测试」------即断言不该发生的事没有发生

python 复制代码
def test_private_photo_of_others_never_returned(self):
    """搜索必须遵守可见性,不能泄露别人的私密照片。"""
    response = self._search(q="私密")
    self.assertNotContains(response, "私密照片")

def test_soft_delete_keeps_files(self):
    """移入回收站时文件必须保留,否则无法还原。"""
    photo = real_photo(self.user, "soft.jpg")
    path = os.path.join(settings.MEDIA_ROOT, photo.image.name)
    photo.delete(user=self.user)
    self.assertTrue(os.path.exists(path))

def test_album_purge_keeps_photos(self):
    """彻底删除相册后,其中的照片必须保留。"""
    ...

这类测试保护的是「用户数据不该丢」这种底线行为。


七、生产配置:让错误配置无法启动

开发到最后一步,我跑了 manage.py check --deploy,得到 6 条安全警告------DEBUG=True

密钥不安全、Cookie 未加 Secure 等。

常见做法是在 README 里写一段「上线前记得改这些」。但文档不会阻止任何人犯错

我改成用环境变量切换,并让危险配置直接拒绝启动:

python 复制代码
DEBUG = env_bool("DJANGO_DEBUG", True)

# 开发环境用固定值方便调试;生产必须由环境变量注入,否则会话与密码重置均不安全。
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "django-insecure-change-me-...")
if not DEBUG and SECRET_KEY.startswith("django-insecure-"):
    raise RuntimeError("生产环境必须通过 DJANGO_SECRET_KEY 环境变量提供真实密钥。")
python 复制代码
# DEBUG=False 时自动启用;本地开发(HTTP)下若开启会导致无法登录,故仅在生产生效。
if not DEBUG:
    SECURE_SSL_REDIRECT = env_bool("DJANGO_SECURE_SSL_REDIRECT", True)
    SESSION_COOKIE_SECURE = True
    CSRF_COOKIE_SECURE = True
    SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
    SECURE_HSTS_SECONDS = int(os.environ.get("DJANGO_HSTS_SECONDS", 3600))
    SECURE_HSTS_INCLUDE_SUBDOMAINS = True
    X_FRAME_OPTIONS = "DENY"

验证三种情形:

bash 复制代码
# 开发模式(默认)
$ python manage.py check
System check identified no issues (0 silenced).

# 生产模式但忘了给密钥 → 直接拒绝启动
$ DJANGO_DEBUG=0 python manage.py check
RuntimeError: 生产环境必须通过 DJANGO_SECRET_KEY 环境变量提供真实密钥。

# 生产模式配置完整 → 6 条警告降到 1 条(HSTS preload,刻意留给人工决定)
$ DJANGO_DEBUG=0 DJANGO_SECRET_KEY=... python manage.py check --deploy
System check identified 1 issue (0 silenced).

注意 if not DEBUG 这个条件是必要的:如果无条件开 SESSION_COOKIE_SECURE

本地用 HTTP 开发时 Cookie 根本发不出去,会出现「登录了但立刻掉线」的诡异问题。

设计原则 :把「正确」做成默认,把「危险」做成必须显式声明。这比在文档里写十条

注意事项有效得多。


八、几个提升体验的小设计

8.1 时间线的分组

时间线要按「年-月」分组,但又要分页。我的做法是在当前页内 做分组,

保持分页与分组共存:

python 复制代码
def get_context_data(self, **kwargs):
    ctx = super().get_context_data(**kwargs)
    # 在当前页内按月份聚合,保持分页与分组共存
    groups = {}
    for photo in ctx["photos"]:
        date = photo.effective_date
        key = (date.year, date.month) if date else (0, 0)
        groups.setdefault(key, []).append(photo)
    ctx["photo_groups"] = [
        {"year": y, "month": m,
         "label": f"{y} 年 {m} 月" if y else "无拍摄日期",
         "photos": items}
        for (y, m), items in sorted(groups.items(), reverse=True)
    ]

(0, 0) 作为无日期的排序键,配合 reverse=True 自动排到最后。

配上 CSS 的 sticky 分组标题:

css 复制代码
.timeline-heading {
  position: sticky;
  top: 56px;              /* 让开固定导航栏 */
  border-bottom: 2px solid var(--bs-primary);
}

8.2 分页保留筛选条件

筛选后翻页,条件不能丢。写个模板标签:

python 复制代码
@register.simple_tag(takes_context=True)
def query_replace(context, **kwargs):
    """在保留当前查询参数的基础上替换部分参数。

    用法:<a href="?{% query_replace page=3 %}">
    """
    query = context["request"].GET.copy()
    for key, value in kwargs.items():
        if value is None or value == "":
            query.pop(key, None)
        else:
            query[key] = value
    return query.urlencode()

8.3 照片多选用 CSS 伪元素画勾选态

给相册添加照片时需要多选。不引入前端框架,纯 CSS + 少量 JS:

css 复制代码
.selectable.selected {
  outline: 3px solid var(--bs-primary);
  outline-offset: -3px;
}
.selectable.selected::after {
  content: "\F26E";              /* bootstrap-icons 的 check 字形 */
  font-family: "bootstrap-icons";
  position: absolute;
  top: .25rem; left: .25rem;
  background: var(--bs-primary);
  color: #fff;
  border-radius: 50%;
  /* ... */
}

JS 只负责切换 class 和同步隐藏的 checkbox,勾选标记完全由 CSS 生成。

8.4 演示数据用固定随机种子

为了让人一键看到完整效果,我写了 seed_demo 命令,用 Pillow 合成渐变色示例图。

关键细节是固定随机种子

python 复制代码
# 固定随机种子,重复执行得到一致的演示数据
rng = random.Random(20240815)

这样每次生成的演示数据完全一致,便于复现问题和截图。

bash 复制代码
python manage.py seed_demo
# 5 个成员、8 个标签、5 个人物、3 个相册、24 张示例照片
# 账号 admin / family2024

九、如何继续加模块

这是需求里明确要求的可扩展性。加一个新模块只需 5 步:

  1. 建目录 apps/yourapp/
  2. apps.py 里设 name = "apps.yourapp"label
  3. 加入 settings.LOCAL_APPS
  4. urls.py 加一行 path("yourapp/", include("apps.yourapp.urls"))
  5. 模板放 apps/yourapp/templates/yourapp/,继承 base.html

需要时间戳或软删除,继承 TimeStampedModel / SoftDeleteModel

表单套 BootstrapMixin 就有 Bootstrap 样式。

searchtrashstats 这三个无表模块是最好的范例------它们证明了共用数据库下,

跨模块功能的成本极低。想加「按地图浏览照片」?查一下 latitude__isnull=False 就行,

不需要动 photos 模块一行代码。


十、复盘:我认为最有价值的几条经验

1. 开工前把不可逆的决策问清楚。 AUTH_USER_MODEL 这类决策,五分钟的确认能省下

后期几天的迁移痛苦。

2. 公共层先行。 先抽 TimeStampedModelSoftDeleteModelBootstrapMixin

后面 9 个模块都在吃这个红利。

3. 权限逻辑只写一份。 visible_to() / can_view() / can_edit() 集中实现,

是「只有一处可能出错」和「八处都可能出错」的区别。

4. 危险操作不能是默认行为。 重写 delete() 变成软删除,hard_delete() 必须显式调用。

5. 「页面能打开」离「功能正确」还很远。 本文 4 个 bug 里,

封面顺序错、EXIF 时区偏移、media 污染这三个,肉眼验收完全发现不了

6. 写反向测试。 断言「私密照片不出现在搜索结果里」、「软删除后文件还在」,

这类测试守的是数据安全底线。

7. 把警告当错误跑一遍。 -W error::RuntimeWarning 让我发现了时区问题。

8. 用配置代替文档。 让错误配置无法启动,比在 README 里写「上线前记得改」可靠得多。


附:项目结构与已知限制

复制代码
familyphotomanage/
├── manage.py
├── requirements.txt          # Django>=5.0, Pillow>=10.0
├── db.sqlite3                # 所有模块共用
├── familyphoto/
│   ├── settings.py           # 环境变量驱动,生产自动加固
│   ├── urls.py               # 各模块在此挂载
│   ├── converters.py         # 中文 slug 转换器
│   └── test_runner.py        # 隔离 MEDIA_ROOT
├── apps/                     # 9 个业务模块
├── templates/
│   ├── base.html
│   └── partials/             # photo_grid / pagination / form_fields
├── static/css/app.css
├── static/js/app.js
└── media/                    # 上传的照片(不入版本库)

必须说明的两个限制,如果你打算实际使用:

  1. ShareLink.password 是明文存储的 。面向家庭内部低敏场景够用,但如果打算把链接

    发到家庭以外,应改为哈希存储。我在代码注释和 README 里都标注了这一点。

  2. media/ 必须单独备份 。照片文件不在数据库里,只备份 db.sqlite3 会丢失全部照片。

    这是文件存储方案的固有特性,容易被忽略。

另外 purge_trash 需要挂到每日计划任务,回收站才会真正按 30 天保留期清理:

bash 复制代码
python manage.py purge_trash            # 按配置的保留期清理
python manage.py purge_trash --dry-run  # 只报告,不删除

如果这篇文章对你有帮助,最值得直接抄走的两段代码是

MediaIsolatedRunner (任何有文件上传的 Django 项目都该配)和

UnicodeSlugConverter(任何用中文做 slug 的项目都会踩)。

相关推荐
Wang's Blog1 小时前
PostgreSQL笔记36:执行计划基础解读与优化器成本模型
数据库·笔记·postgresql
jyOverQ1 小时前
LangGraph 流式执行详解:stream、astream 与 stream_mode 怎么选?
python·langchain
Wang's Blog1 小时前
PostgreSQL笔记35:索引常见问题诊断与解决方案全景解析
数据库·笔记·postgresql
闲猫2 小时前
LangGraph / Capabilities / Stores
python·agent·langgraph
pjj198542 小时前
机器学习-NumPy2
人工智能·python·机器学习
OPEN-F2 小时前
Python进阶教程:单元测试与代码质量
开发语言·python·单元测试
Logintern092 小时前
装饰器和洋葱模式的区分
开发语言·python·架构
呆呆敲代码的小Y3 小时前
10 分钟搞懂 cua:开源 AI 操作电脑基础设施,附 Python 沙箱与 Agent 上手代码
人工智能·python·开源·ai agent·awesome·llm应用·cua
工具分享3 小时前
带店托管必用爆单AI选品,一人公司轻松掌握
人工智能·python