Django实战:随机塔罗抽牌、3D翻牌与CMS后台

发布时间:2026/10/24 21:25:57
Django实战:随机塔罗抽牌、3D翻牌与CMS后台

简介:这是一套面向 Python 与 Django 初学者及小型内容站点开发者的塔罗主题实战项目,以“随机塔罗牌生成器 + 基础 CMS”为核心:点击“随机”按钮即可从22张塔罗牌中抽取一张,详情页展示牌面图片、牌名、占星与炼金术属性、希伯来字母及象征释义,并配有登陆/门户、关于页面与论文内容模块,可作为练习模板继承、路由、静态资源组织与数据展示的参考案例。压缩包共281个文件,约10.62MB,包含53个py源码、25个html模板、10个css样式表,以及95张jpg、27个gif、18个png等牌面与界面素材,另有sqlite数据库及备份、说明文档,目录按应用拆分,便于对照阅读。项目已有1965人学习下载,适合拿来拆解Django应用划分、CSS页面美化与图片资源管理,也能在此基础上改造成个人占卜或内容展示站点。

1. 朋友要一个抽牌页面,我以为半小时能收工

需求很朴素:点一下出三张牌,正逆位随机,牌面和释义能在后台改。tarot_juicer 就是那次需求的成品形态——一个 Python + Django 写的随机塔罗牌生成器,外面套一套基础 Django CMS。前台负责洗牌、抽牌、渲染牌阵,后台负责维护 78 张牌的编号、牌名、正位与逆位释义、牌面图。它适合两类人:想拿一个真实小项目把 Django 的模型、视图、模板、静态文件、admin 全链路走一遍的练手者;以及想在自家站点里塞个占卜小挂件、又不愿意为此引入一整套前端脚手架的开发者。抽牌逻辑和展示层是分开的,改牌阵不用碰算法。

2. 目录骨架与数据模型:一张塔罗牌在库里长什么样

拿到一个包,我第一件事不是读 README,是tree一遍根目录。目录规整的项目,八成能直接跑起来;目录里散着一堆test2.py、final_final.py的,后面踩坑的概率大得多。tarot_juicer 属于前一种,业务只有一个 app,配置和业务切得干净。

2.1 目录结构:一个 app 装下全部业务

tarot_juicer/ ├── manage.py ├── requirements.txt ├── tarot_juicer/ # 项目配置包 │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── cards/ # 唯一业务 app │ ├── models.py │ ├── views.py │ ├── urls.py │ ├── admin.py │ ├── migrations/ │ ├── management/commands/seed_cards.py │ ├── templates/cards/ │ │ ├── base.html │ │ ├── draw.html │ │ └── card_detail.html │ └── static/cards/ │ ├── css/main.css │ └── img/back.webp ├── media/cards/ # 后台上传的牌面,运行时产物 └── staticfiles/ # collectstatic 产物,部署用

模板和静态文件都放在cards/内部,而不是甩到项目根的templates/、static/下。这么放的好处是 app 自包含:以后想把抽牌功能摘出来塞进另一个项目,整个cards/拷走就能用。代价是模板查找顺序靠INSTALLED_APPS决定,同名模板会互相覆盖——这个坑在第 5 章会细说。

media/和staticfiles/的区别值得单独记一下:media是用户(这里是后台管理员)上传的内容,会随数据增长;staticfiles是collectstatic把各个 app 下的static/收集到一处的产物,部署时交给 Web 服务器直接吐。很多人部署后图片 404,就是把这两个目录的职责搞混了。

2.2 模型字段:78 张牌需要哪些列

# cards/models.py from django.db import models class TarotCard(models.Model): class Arcana(models.TextChoices): MAJOR = "major", "大阿卡纳" MINOR = "minor", "小阿卡纳" class Suit(models.TextChoices): WANDS = "wands", "权杖" CUPS = "cups", "圣杯" SWORDS = "swords", "宝剑" PENTACLES = "pentacles", "星币" NONE = "none", "无" number = models.PositiveSmallIntegerField("编号", unique=True) # 0-77,愚者到星币十 name = models.CharField("牌名", max_length=64) arcana = models.CharField("大类", max_length=8, choices=Arcana.choices, default=Arcana.MAJOR) suit = models.CharField("花色", max_length=16, choices=Suit.choices, default=Suit.NONE) upright = models.TextField("正位释义") reversed = models.TextField("逆位释义", blank=True) image = models.ImageField("牌面", upload_to="cards/", blank=True) is_active = models.BooleanField("启用", default=True) class Meta: ordering = ["number"] verbose_name = "塔罗牌" verbose_name_plural = "塔罗牌" def __str__(self): return f"{self.number:02d} {self.name}"

number上加了unique=True,同时用Meta.ordering固定排序。这是个不起眼但很实用的设计:抽牌算法里一旦需要「可复现」的随机(同一个 seed 抽同一组牌),牌的遍历顺序必须是稳定的,否则数据库返回顺序一变,结果就对不上。unique顺带把灌数据脚本的重复执行挡在了数据库层,这条会在第 5 章变成一条具体的报错。

arcana和suit用的是TextChoices而不是两张关联表。花色只有 5 个取值、大类只有 2 个,建表除了多两次 join 没有任何收益。释义用TextField存长文,比塞进JSONField好在后台编辑体验:admin 直接给你一个多行文本框,非技术同事也能改。

提示:ImageField依赖 Pillow,requirements.txt里必须写上,否则makemigrations阶段就会报Cannot use ImageField because Pillow is not installed。

2.3 后台就是那套 CMS

# cards/admin.py from django.contrib import admin from django.utils.html import format_html from .models import TarotCard @admin.register(TarotCard) class TarotCardAdmin(admin.ModelAdmin): list_display = ("number", "name", "arcana", "suit", "is_active", "thumb") list_filter = ("arcana", "suit", "is_active") search_fields = ("name", "upright", "reversed") list_editable = ("is_active",) ordering = ("number",) list_per_page = 30 @admin.display(description="缩略图") def thumb(self, obj): if not obj.image: return "-" return format_html('<img src="{}" style="height:48px;border-radius:4px">', obj.image.url)

list_editable让「启用」这一列能在列表页直接勾选,78 张牌里想临时下架某几张,不用一张张点进去。thumb用format_html拼 HTML 而不是字符串拼接,是因为 Django 模板会自动转义,直接返回裸字符串的话<img>会被当成文本显示出来。search_fields把释义也算进去,找「哪张牌提到过旅行」这种模糊需求时很省事。

2.4 一次性灌 78 张牌:management command

手点 78 次后台不现实,项目里带了个自定义命令。核心是update_or_create而不是create:

# cards/management/commands/seed_cards.py from django.core.management.base import BaseCommand from cards.models import TarotCard # 实际文件里是完整的 78 条,这里只留三条示意 CARDS = [ {"number": 0, "name": "愚者", "arcana": "major", "suit": "none", "upright": "新的开始,放手一搏。", "reversed": "鲁莽,准备不足。"}, {"number": 1, "name": "魔术师", "arcana": "major", "suit": "none", "upright": "资源齐备,主动出击。", "reversed": "空有想法,执行走样。"}, {"number": 22, "name": "权杖首牌", "arcana": "minor", "suit": "wands", "upright": "灵感落地,行动力强。", "reversed": "动力不足,方向摇摆。"}, ] class Command(BaseCommand): help = "写入或更新 78 张基础塔罗牌数据" def add_arguments(self, parser): parser.add_argument("--force", action="store_true", help="覆盖已有释义") def handle(self, *args, **options): created = updated = 0 for item in CARDS: obj, is_created = TarotCard.objects.update_or_create( number=item["number"], defaults={**item, "is_active": True} if options["force"] else {k: v for k, v in item.items() if k != "upright" and k != "reversed"}, ) created += is_created updated += (not is_created) self.stdout.write(self.style.SUCCESS(f"新增 {created} 张,更新 {updated} 张"))

update_or_create以number为查找键,重复执行不会炸unique约束,这对本地反复初始化特别友好。--force控制是否覆盖已经人工润色过的释义:默认只补元数据(牌名、花色、图片路径之外的字段),手动改过的文案保住;加--force才是全量覆盖。这个「默认保守、显式覆盖」的思路,在数据初始化脚本里我基本都会带上。

update_or_create不接受 kwargs 里混着pk或带__的查找表达式,defaults里也别塞主键,这是新手最容易翻的地方。

3. 抽牌链路:从 URL 到三张牌落到模板

整条链路只有两个 URL、一个视图函数。Django 的 MTV 在这个项目里体现得特别干净:Model 管数据,Template 管展示,View 只做一件事——把随机结果组装成模板能直接吃的结构。

3.1 路由:两个 URL 就够

# tarot_juicer/urls.py from django.contrib import admin from django.urls import include, path from django.conf import settings from django.conf.urls.static import static urlpatterns = [ path("admin/", admin.site.urls), path("", include("cards.urls")), ] if settings.DEBUG: # 只在开发期由 Django 自己吐 media,上线交给 Nginx urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# cards/urls.py from django.urls import path from . import views app_name = "cards" urlpatterns = [ path("", views.draw, name="draw"), path("card/<int:number>/", views.card_detail, name="detail"), ]

app_name配name构成命名空间,模板里写{% url 'cards:draw' %}就能反向解析。好处是以后要给抽牌页换个路径(比如从/挪到/tarot/),模板一行都不用改。card/<int:number>/用编号而不是自增id做路径参数,是为了让链接可读:/card/22/比/card/47/直观,也方便直接分享某张牌。

注意static()那一行被if settings.DEBUG包着。这行只在开发时有用,DEBUG=False之后 Django 不再代理media请求,图片必须由前置的 Web 服务器接管——部署后图片全裂,九成是这里没配。

3.2 视图:random.sample 与正逆位

# cards/views.py import random from django.shortcuts import render, get_object_or_404 from .models import TarotCard POSITIONS = ("过去", "现在", "未来") def draw(request): pool = list(TarotCard.objects.filter(is_active=True)) # 先落成 list count = min(len(POSITIONS), len(pool)) picked = random.sample(pool, count) if count else [] cards = [] for position, card in zip(POSITIONS, picked): is_reversed = random.random() < 0.5 cards.append({ "position": position, "obj": card, "reversed": is_reversed, "meaning": card.reversed if is_reversed else card.upright, }) return render(request, "cards/draw.html", {"cards": cards}) def card_detail(request, number): card = get_object_or_404(TarotCard, number=number, is_active=True) return render(request, "cards/card_detail.html", {"card": card})

几个关键点值得逐条过一遍:

random.sample而不是random.choices。前者保证一次抽出的三张牌互不重复,后者是「有放回」抽样,同一张牌可能连出三次。塔罗牌阵里抽到重复牌在语义上讲不通,所以必须是sample。

list(...)这一层转换不是多余。random.sample要求传入的pool支持len()和下标访问,QuerySet恰好都支持,表面上能跑通,但每次下标访问都可能触发一次新的 SQL,三张牌抽查下来数据库被打了十几次。先落成 list,只查一次,后面全在内存里操作。

count = min(len(POSITIONS), len(pool))是防御性写法。牌库刚建、还没灌数据的时候,random.sample(pool, 3)会直接抛ValueError: Sample larger than population,页面变 500。这里把张数压到实际可用数量,配合模板里的{% empty %}分支,空库时至少能给出「先去后台加牌」的提示,而不是一屏调试信息。

正逆位用random.random() < 0.5独立判定,和牌本身解耦。也就是说「抽到哪几张」和「哪几张逆位」是两次独立随机,这符合常规玩法。想让逆位概率低一点(比如 30%),只改这一个阈值就行,不用动抽牌逻辑。

zip(POSITIONS, picked)把位置和牌绑定。zip的语义是取短的那一个,前面已经用min压过长度,所以不会出现「有牌没位置」的错位。

提示:如果要做「同一个链接每次刷新结果一样」的分享功能,把random换成rnd = random.Random(seed),seed从 query string 或 session 里取,后面所有rnd.sample、rnd.random都用这个实例。这是第 6 章主要改造方向之一。

3.3 模板:把牌交给 CSS

{# cards/templates/cards/draw.html #} {% extends "cards/base.html" %} {% load static %} {% block content %} <section class="spread"> {% for item in cards %} <article class="card-slot {% if item.reversed %}is-reversed{% endif %}" style="--i: {{ forloop.counter0 }}"> <div class="card-inner"> <div class="card-face card-back"> <img src="{% static 'cards/img/back.webp' %}" alt="牌背"> </div> <div class="card-face card-front"> {% if item.obj.image %} <img src="{{ item.obj.image.url }}" alt="{{ item.obj.name }}"> {% endif %} <h3>{{ item.position }} · {{ item.obj.name }}</h3> <p>{{ item.meaning|linebreaksbr }}</p> </div> </div> </article> {% empty %} <p class="empty">牌库还是空的,先去后台加几张牌。</p> {% endfor %} </section> <a class="again" href="{% url 'cards:draw' %}">重新洗牌</a> {% endblock %}

模板本身几乎没有逻辑,只有三个位置在「传参」:

style="--i: {{ forloop.counter0 }}"把循环下标写成内联 CSS 变量,第 4 章的动画就靠它做错峰延迟。用内联变量而不是给三张牌分别写.card-slot:nth-child(1),是因为牌阵张数以后可能从 3 改成 5,写死 nth-child 就得跟着改样式。

{% if item.reversed %}只往 DOM 上挂一个is-reversed类,具体怎么表现(图片转 180 度、还是加一层暗色蒙版)全交给 CSS。视图里判断的是「语义」,模板里输出的是「标记」,样式在 CSS 里决定,三层各管各的。

{{ item.meaning|linebreaksbr }}处理释义里的换行。后台 textarea 里写的换行在 HTML 里会被折叠成空格,加这个过滤器才能让「关键词:/ 解读:」这种分行排版的释义正常显示。

{% empty %}分支配合前面的min(),把空库场景兜住了。很多人写循环只想着有数据的情况,真跑起来发现牌库没灌数据,页面一片空白还查不出原因。

4. CSS 牌阵:3D 翻牌怎么翻才不穿帮

塔罗牌这种视觉型项目,CSS 的地位不亚于后端。翻牌动画看着玄乎,拆开就是三行属性的组合:perspective给父级建立透视,preserve-3d让子元素保留三维空间,backface-visibility: hidden让背面在转到一半之后消失。

4.1 static 与 media 的配置边界

# tarot_juicer/settings.py 片段 import os from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent STATIC_URL = "static/" STATICFILES_DIRS = [BASE_DIR / "static"] # 项目级公共静态文件,可为空 STATIC_ROOT = BASE_DIR / "staticfiles" # collectstatic 输出目录 MEDIA_URL = "media/" MEDIA_ROOT = BASE_DIR / "media" # 后台上传落地目录

STATIC_URL和MEDIA_URL都以斜杠结尾、都不带前导斜杠,这是 Django 的约定,写错了模板里{% static %}拼出来的路径会多一个或漏一个/,浏览器 404 但页面不报错,排查起来纯靠肉眼。STATICFILES_DIRS和STATIC_ROOT不能指向同一个目录,Django 启动时会直接抛ImproperlyConfigured,这是新手常见的第一次翻车。

app 内部的cards/static/cards/css/main.css不需要在STATICFILES_DIRS里登记,Django 的AppDirectoriesFinder会自动找到。所以模板里写{% static 'cards/css/main.css' %}——注意路径里那层cards/是 app 内的命名空间目录,不是 app 名,两者同名只是巧合,改 app 名时这层目录可以不改。

开发期runserver会自动服务 static,改了 CSS 刷新就生效;一旦DEBUG=False,Django 立刻罢工,必须collectstatic之后由 Web 服务器托管。这个切换点是最容易「本地好好的、上线样式全丢」的地方。

4.2 翻牌动画:三个属性加一段 keyframes

/* cards/static/cards/css/main.css */ :root { --card-ratio: 1.65; --flip: 900ms; --bg: #14121c; --gold: #c9a227; } body { margin: 0; background: radial-gradient(circle at 50% 0%, #241d38 0%, var(--bg) 60%); color: #e8e3f3; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; } .spread { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; max-width: 900px; margin: 56px auto; padding: 0 16px; } .card-slot { aspect-ratio: 1 / var(--card-ratio); perspective: 1200px; /* 透视距离,越小越夸张 */ } .card-inner { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; animation: flip-in var(--flip) cubic-bezier(.35, .1, .25, 1) both; animation-delay: calc(var(--i, 0) * 180ms); } @keyframes flip-in { from { transform: rotateY(0deg); } to { transform: rotateY(180deg); } } .card-face { position: absolute; inset: 0; border-radius: 12px; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; box-shadow: 0 12px 30px rgba(0, 0, 0, .45); } .card-front { transform: rotateY(180deg); /* 正面预先转过去,翻完正好朝向用户 */ background: #1f1b2e; border: 1px solid rgba(201, 162, 39, .35); padding: 12px; box-sizing: border-box; } .card-front img { width: 100%; display: block; border-radius: 8px; transition: transform 300ms ease; } /* 逆位只转牌面图,不转整张牌,文字仍然正着读 */ .is-reversed .card-front img { transform: rotate(180deg); }

both这个animation-fill-mode是关键。没有它,动画结束的瞬间元素会弹回rotateY(0),用户看到的是「翻开又盖回去」。both让动画结束后停在最后一帧,也就是rotateY(180deg)的状态。

animation-delay: calc(var(--i, 0) * 180ms)配合模板里的--i,三张牌依次翻,间隔 180 毫秒。变量有个0兜底,万一哪张牌忘了传--i,动画也不会整段失效。

.card-front上那个transform: rotateY(180deg)一开始很容易漏。漏了的表现是:牌翻过来了,但背面朝外,用户看到的是镜像的文字。因为正面在三维空间里和背面共面,翻 180 度之后朝向用户的是背面的背面,必须先给正面预转 180 度。

逆位处理放在.is-reversed .card-front img上,只转图片。如果把rotate(180deg)加在.card-inner或.card-slot上,整张牌连内容带布局一起转,标题和释义会变成倒着的——这个坑我在第 5 章单列了一条。

perspective: 1200px放在父级.card-slot而不是.card-inner上。放在元素自身上,透视原点会跟着元素走,转动时产生的水波纹畸变特别诡异;放在父级,透视才像是「观察者在看」。

4.3 牌阵布局与移动端降级

@media (max-width: 720px) { .spread { grid-template-columns: 1fr; gap: 16px; margin: 24px auto; max-width: 340px; } .card-slot { aspect-ratio: 1 / 1.4; /* 单列时略扁,避免一屏塞不下一张 */ } } @media (prefers-reduced-motion: reduce) { .card-inner { animation: none; transform: rotateY(180deg); /* 直接呈现翻好的状态 */ } }

宽屏三列、窄屏单列,靠minmax(0, 1fr)防止 grid 项被内容撑爆——1fr的默认最小值是auto,牌面图一宽,三列就会溢出容器。把最小宽度钉成0才是安全的。

prefers-reduced-motion这个媒体查询不是可有可无的装饰。系统里开了「减少动态效果」的用户(前庭功能敏感人群),翻了 900 毫秒的三张牌会让人不适。降级方案不是把动画调快,而是直接跳过动画、给出终态,同时保留transform保证牌还是翻好的那一面。

aspect-ratio的兼容性现在已经不用操心,但如果要照顾老一点的浏览器,退化方案是给.card-slot一个padding-top: 165%的占位盒子,再用绝对定位把内容塞进去。我一般会两种都写,先写aspect-ratio再写padding-top兜底。

5. 避坑与排查:五条踩过的记录

这一节按「现象、原因、解决」记,都是跑这个项目时真实撞过的,照着对号入座能省掉不少时间。

第一条:后台上传的牌面,前台全是裂图

现象:admin 里上传成功,列表页缩略图也能看到,但前台抽牌页的<img src="/media/cards/01.webp">返回 404。

原因:MEDIA_URL/MEDIA_ROOT配了,但urls.py里没挂static(settings.MEDIA_URL, ...);或者挂了但DEBUG=False,那行代码根本不生效,而前置的 Web 服务器又没配media的 location。

解决:开发期确认if settings.DEBUG:那段存在;上线时把media目录交给 Nginx 或对象存储,Django 进程不负责吐文件。顺便检查文件权限,MEDIA_ROOT目录对运行用户不可写时,上传会静默失败但表单不报错。

第二条:一次抽三张,抽出了两张一样的牌

现象:刷新十次里有两次出现重复牌,用户一眼就看出来是假随机。

原因:视图里用了random.choices(pool, k=3),或者对同一个QuerySet连续调两次random.choice,甚至有人写了random.sample(TarotCard.objects.all(), 3)后又用filter重新取了一遍。

解决:统一成「先list()落内存,再random.sample」。sample是「不放回」,choices是「放回」,塔罗牌阵必须用前者。另外别在sample之后再对结果做filter(is_active=True)——那等于先抽后过滤,抽三张过滤掉两张,页面就只剩一张牌了。

第三条:逆位牌的标题和释义跟着倒过来了

现象:牌翻过来之后,牌面图是倒的(正常),但下面的牌名和解读文字也一起倒了,手机上看着特别扭。

原因:is-reversed的旋转加在了.card-slot或.card-inner上,整张牌 180 度转,DOM 里所有内容跟着转。

解决:旋转只作用在.is-reversed .card-front img上。视图层的reversed字段表达的是「这张牌是逆位」,不是「这个块要转」,表现层自己决定转什么。另外backface-visibility在部分老版本 Safari 上需要-webkit-前缀,不加会出现翻转过程中背面「透出」正面的重影。

第四条:改了 CSS 刷新没反应,或者上线后样式全丢

现象:本地改main.css保存刷新,样式纹丝不动;或者DEBUG=False部署后,页面裸奔,只有 HTML 没有样式。

原因:本地是浏览器缓存(尤其是runserver返回的 304);上线是没执行collectstatic,或者STATIC_ROOT里是旧文件,而 Web 服务器指到了错误的目录。还有一种隐蔽情况:两个 app 下有同名static/cards/css/main.css,Django 按INSTALLED_APPS顺序取第一个,你以为改的是生效的那份。

解决:本地先强制刷新(或者开 DevTools 的 Disable cache);部署流程里把python manage.py collectstatic --noinput写成固定步骤;app 内静态目录统一加一层命名空间(static/cards/...),杜绝跨 app 撞名。

第五条:灌数据脚本跑第二遍就报 IntegrityError

现象:python manage.py seed_cards第一次成功,第二次直接抛UNIQUE constraint failed: cards_tarotcard.number。

原因:脚本里用的是TarotCard.objects.create(...),或者先get()再create(),中间没有任何「已存在则更新」的分支。

解决:改成update_or_create(number=..., defaults={...})。注意defaults里不要放主键、不要放带__的查找表达式,update_or_create会把它们当普通字段处理然后报错。还有一个变体坑:如果defaults里的字段包含auto_now,每次跑都会刷新时间戳,用--force之外的路径时留意一下。

提示:这五条里有四条的表现都发生在「页面层」,但根因分别在配置、算法、样式作用域和数据脚本。排查时先看浏览器 Network 面板的状态码,能直接把范围砍掉一半。

6. 二次开发:从「能抽」到「能分享、能扩展」

这套代码留的扩展口比看上去多,因为它把随机、渲染、数据三层切开了。想加点东西,基本都能找到对应的单点去改,不用动全局。

最值得做的一个改造是可复现抽牌。现在每次刷新结果都变,想做「把这个牌阵分享给朋友」就没法实现。做法是把种子从 URL 里读出来,塞进一个独立的Random实例:

# cards/views.py 改造片段 import random from django.shortcuts import render from .models import TarotCard POSITIONS = ("过去", "现在", "未来") def draw(request): seed = request.GET.get("seed") rnd = random.Random(seed) if seed else random.Random() pool = list(TarotCard.objects.filter(is_active=True)) count = min(len(POSITIONS), len(pool)) picked = rnd.sample(pool, count) if count else [] cards = [] for position, card in zip(POSITIONS, picked): is_reversed = rnd.random() < 0.5 cards.append({ "position": position, "obj": card, "reversed": is_reversed, "meaning": card.reversed if is_reversed else card.upright, }) return render(request, "cards/draw.html", { "cards": cards, "seed": seed or rnd.random(), # 没带种子时回填一个,供「复制链接」用 })

改完之后,/?seed=abc123无论谁打开、打开多少次,抽到的三张牌和正逆位都一样。验证方法很直接:本地起服务,同一个 seed 刷五次,肉眼比对;再写个TestCase断言两次渲染的 context 里牌名列表相等。这类「只有随机」的逻辑最适合补测试,因为它不依赖数据库状态之外的任何东西。

另外两条改造路线可以按需选:一是加牌阵,把POSITIONS抽成SPREADS = {"three": ("过去", "现在", "未来"), "cross": (...)}这种字典,URL 加个<slug:spread>参数,视图里按 key 取,模板不用改;二是把抽牌结果做成 JSON 接口给小程序或前端用,复用同一个draw里的组装逻辑,抽出_pick_cards(seed)函数,视图和接口各取所需。加接口时记得把 CSRF 和限流补上,抽牌是纯读操作,用GET加缓存头就够,别做成POST。

改造点涉及文件验证方式
可复现抽牌cards/views.py、draw.html同 seed 多次请求比对牌名
新增牌阵views.py、urls.py、模板切 URL 看张数与位置文案
JSON 接口views.py、urls.pycurl 看返回结构
牌面换 WebPadmin.py上传、模板看 Network 里的 content-type

最后说个习惯问题。我最早做这类项目,习惯在视图里直接render一坨 context 完事,后来接手的人想复用抽牌逻辑,只能整段复制。从那以后我每次写带随机或带业务规则的视图,都强制走一遍「把纯逻辑抽成不依赖 request 的函数」这一步,哪怕只有十行。这个项目里的_pick_cards(seed)就该这么抽,抽完测试好写,接口也好加。希望帮到你。

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