一、需求与架构选型
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/ 统计报表
注意 search、trash、stats 三个模块没有自己的数据表 。它们只是不同的
「视图 + 查询逻辑」组合:搜索查 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_to 和 can_view,
就能覆盖所有页面的权限行为。
3.4 EXIF 解析:比想象中麻烦
EXIF 是我在这个项目里花时间最多的地方。它的数据格式相当混乱,几个坑点:
坑点一:数值类型不统一。 光圈可能是 Fraction、IFDRational,也可能是
(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
坑点二:拍摄参数不在顶层。 Make、Model 在顶层 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)
两个细节都是实践中必踩的:
exif_transpose:手机竖拍的照片,像素数据其实是横的,靠 EXIF 的
Orientation 标记旋转。不处理的话缩略图全是躺着的。- 透明通道铺白底: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 步:
- 建目录
apps/yourapp/ apps.py里设name = "apps.yourapp"和label- 加入
settings.LOCAL_APPS - 根
urls.py加一行path("yourapp/", include("apps.yourapp.urls")) - 模板放
apps/yourapp/templates/yourapp/,继承base.html
需要时间戳或软删除,继承 TimeStampedModel / SoftDeleteModel;
表单套 BootstrapMixin 就有 Bootstrap 样式。
search、trash、stats 这三个无表模块是最好的范例------它们证明了共用数据库下,
跨模块功能的成本极低。想加「按地图浏览照片」?查一下 latitude__isnull=False 就行,
不需要动 photos 模块一行代码。
十、复盘:我认为最有价值的几条经验
1. 开工前把不可逆的决策问清楚。 AUTH_USER_MODEL 这类决策,五分钟的确认能省下
后期几天的迁移痛苦。
2. 公共层先行。 先抽 TimeStampedModel、SoftDeleteModel、BootstrapMixin,
后面 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/ # 上传的照片(不入版本库)
必须说明的两个限制,如果你打算实际使用:
-
ShareLink.password是明文存储的 。面向家庭内部低敏场景够用,但如果打算把链接发到家庭以外,应改为哈希存储。我在代码注释和 README 里都标注了这一点。
-
media/必须单独备份 。照片文件不在数据库里,只备份db.sqlite3会丢失全部照片。这是文件存储方案的固有特性,容易被忽略。
另外 purge_trash 需要挂到每日计划任务,回收站才会真正按 30 天保留期清理:
bash
python manage.py purge_trash # 按配置的保留期清理
python manage.py purge_trash --dry-run # 只报告,不删除
如果这篇文章对你有帮助,最值得直接抄走的两段代码是
MediaIsolatedRunner (任何有文件上传的 Django 项目都该配)和
UnicodeSlugConverter(任何用中文做 slug 的项目都会踩)。