ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Django构建古文物趣味学习平台:模型、互动与部署实践

Django构建古文物趣味学习平台:模型、互动与部署实践 做这个基于 Django 的中国古文物趣味学习系统最初的起因其实特别简单有个朋友在博物馆当志愿讲解员经常跟我吐槽馆里文物信息不少但展板文字太干观众走马观花十分钟就逛完真正记住的没几个。我当时就在想能不能把文物知识做成一个能逛、能玩、能答题闯关的在线系统让学习过程像“寻宝”一样有成就感。于是就有了这个项目用 Python 的 Django 框架搭建一套集文物展示、趣味答题、任务闯关、收藏笔记于一体的学习平台。这套系统的主要使用对象是中小学生、历史爱好者以及博物馆社教活动的线上延伸场景。它解决的痛点是传统文物知识呈现方式单向、枯燥缺乏互动反馈用户记不住也留不下。通过游戏化机制把“被动看”变成“主动学”同时给管理员留出文物数据维护后台方便持续更新内容。无论你是 Django 初学者还是想做一个文化科普类 Web 项目的开发者这篇记录里都有可以直接参考的模型设计思路、互动功能实现细节以及我实测过程中踩过的坑。这篇文章不写泛泛的“项目背景技术栈”罗列只讲我从零搭到上线过程中真正花过时间琢磨的东西。1. 项目定位与功能拆解动手写代码之前我先给自己提了一个问题这个系统和普通的文物信息展示网站到底有什么本质区别如果只是把文物照片加一段文字说明那做个静态页面就够了根本不需要 Django。既然选择了 Web 应用就得让“趣味”两个字真正落在功能上。1.1 需求从哪来给谁用“中国古文物趣味学习”这个标题里关键词拆开看是“古文物”和“趣味学习”。用户不是考古专家而是对传统文化有一点好奇心的普通人。他们的需求通常分三种快速浏览想知道这件文物叫什么、什么朝代、有什么用深度了解想了解纹饰寓意、出土背景、制作工艺主动参与想测试自己记住多少和朋友比一比。对应到系统上就是三个核心模块文物展示模块负责“看”知识详情页负责“学”答题闯关和积分体系负责“玩”。加上后台的文物管理、用户管理、内容审核就是一个完整的闭环。我当时还参考了市面上比较成功的文博类 App 和线上展厅的做法发现它们有个共同点不会一次性把长篇大论扔给用户而是先给一个“钩子”比如“这件青铜器的名字 99% 的人都会读错”然后用户带着好奇心点进去。这个思路被我沿用到系统的“趣味提示”功能里。1.2 核心功能模块一览系统的功能规划用了最朴素的方式画了一张思维导图把用户能接触到的东西都列出来然后逐个判断“是否值得做、是否做得完”。最终保留的功能模块如下模块主要功能对应角色文物浏览按朝代、类别、材质筛选列表与网格切换游客、注册用户文物详情高清图片、档案信息、趣味故事、语音讲解位游客、注册用户答题闯关按专题出题5题一组答对有积分、连击加成注册用户任务系统每日签到、观看文物任务、分享任务注册用户收藏与笔记收藏文物、添加个人心得备注注册用户用户中心积分排行榜、答题记录、成就徽章注册用户管理后台文物入库、题目维护、用户封禁、内容审核管理员砍掉了一些花哨但短期做不好的功能比如实时对战答题和 AR 文物复原。因为项目本身要控制在一个人能完成的范围内把基础体验做扎实比堆功能更重要。1.3 技术选型为什么选 Django技术框架选型上我几乎没有犹豫就选了 Django。原因不是它最流行而是它最适合这个项目自带 Admin 后台文物和题目数据可以直接可视化录入省去单独写管理端的成本ORM 模型灵活文物、朝代、题目、收藏之间的关联关系用模型类就能表达内置用户认证模块注册、登录、会话管理开箱即用模板系统配合前端改造足够方便适合一个人同时写前后端。用 Flask 也能做但需要自己搭的组件更多尤其是 Admin 和权限管理。用前后端分离比如 Vue DRF也可以但项目规模没那么大交互也没复杂到必须完全分离。Django 的 MTV 模式让我一个人管理代码逻辑和页面渲染时更省心。2. 数据库设计与模型实现数据库设计是整个系统的地基。文物知识是非结构化内容很多字段长度不确定比如描述文字可能几百字纹饰解析可能上千字所以我几乎把所有文本字段都设置得比较宽松避免上线后频繁改表。2.1 文物信息表怎么建核心表是文物表Artifact它承载了最基本的展示数据。我当时设计了这些字段from django.db import models class Dynasty(models.Model): name models.CharField(max_length50, uniqueTrue, verbose_name朝代名称) period models.CharField(max_length100, blankTrue, verbose_name起止时间) sort models.IntegerField(default0, verbose_name排序权重) class Meta: ordering [sort, id] def __str__(self): return self.name class Artifact(models.Model): name models.CharField(max_length200, verbose_name文物名称) dynasty models.ForeignKey(Dynasty, on_deletemodels.CASCADE, verbose_name所属朝代) category models.CharField(max_length50, choices[ (bronze, 青铜器), (pottery, 陶器), (jade, 玉器), (painting, 书画), (ceramic, 瓷器), (other, 其他), ], verbose_name文物类别) image models.ImageField(upload_toartifacts/%Y/%m/, verbose_name主图) summary models.CharField(max_length300, verbose_name一句话简介) description models.TextField(verbose_name详细介绍) fun_fact models.TextField(blankTrue, verbose_name趣味冷知识) is_featured models.BooleanField(defaultFalse, verbose_name是否首页推荐) view_count models.PositiveIntegerField(default0, verbose_name浏览数) created_at models.DateTimeField(auto_now_addTrue) class Meta: ordering [-created_at] verbose_name 文物信息朝代用外键单独成表而不是直接存字符串是因为很多查询场景需要按朝代筛选而且朝代有明确的先后顺序。如果把朝代直接写成 CharField后面想按时间排序就要额外解析字符串纯粹给自己找麻烦。image字段用ImageField需要安装 Pillow 库。上传路径按年月分目录避免单目录文件过多。fun_fact字段是这个系统的“趣味灵魂”用来存放“这件文物在出土时差点被当成锅盖”这类冷知识也是答题出题的素材来源之一。2.2 趣味学习与用户行为的表结构只有文物表是远远不够的因为系统要记录用户行为。我把用户相关的表分成三块答题记录、收藏笔记、任务进度。答题表需要记录的不只是“对错”还要记录用户答案、作答耗时、获得的积分方便后续做数据分析。class QuizQuestion(models.Model): artifact models.ForeignKey(Artifact, on_deletemodels.CASCADE, verbose_name关联文物) question_text models.CharField(max_length500, verbose_name题干) option_a models.CharField(max_length300) option_b models.CharField(max_length300) option_c models.CharField(max_length300) option_d models.CharField(max_length300) correct_answer models.CharField(max_length1, choices[(A,A), (B,B), (C,C), (D,D)]) explain models.TextField(verbose_name答案解析) class AnswerRecord(models.Model): user models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.CASCADE) question models.ForeignKey(QuizQuestion, on_deletemodels.CASCADE) selected_answer models.CharField(max_length1) is_correct models.BooleanField() score models.IntegerField(default0) answered_at models.DateTimeField(auto_now_addTrue)答题记录表没有做“唯一约束”允许同一道题被多次作答。因为系统的策略是允许用户反复挑战争取满分而不是一次定终身。这样用户在重做时会主动去记忆正确答案学习效果更好。收藏和笔记表我合并成了一张UserCollectionclass UserCollection(models.Model): user models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.CASCADE) artifact models.ForeignKey(Artifact, on_deletemodels.CASCADE) note models.TextField(blankTrue, verbose_name个人笔记) created_at models.DateTimeField(auto_now_addTrue) class Meta: unique_together (user, artifact)unique_together保证同一个用户对同一件文物只能收藏一次第二次收藏就直接更新笔记内容避免重复记录。这个设计在代码里省了很多判断逻辑。2.3 一对多、多对多的实际应用从上面可以看到文物和朝代是一对多关系用户和文物是收藏多对多关系但中间表带了额外笔记字段所以直接用显式中间表。答题和文物是一对多关系。任务系统则是用户与任务的多对多我用了UserTaskProgress来存每个用户的任务完成状态。多对多关系在这个系统里最典型的是“用户获得成就徽章”class Badge(models.Model): name models.CharField(max_length50) icon models.CharField(max_length20, verbose_name图标类名) description models.CharField(max_length200) class UserBadge(models.Model): user models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.CASCADE) badge models.ForeignKey(Badge, on_deletemodels.CASCADE) obtained_at models.DateTimeField(auto_now_addTrue)后来我意识到成就徽章这种奖励机制对用户留存非常有效。设计上不必用复杂的权限系统只要能记录“谁在什么时候获得了什么徽章”就够了。数据库设计阶段最值得注意的事情是千万不能在刚开始就把表设计得太死板。我给文物表预留了custom_field的 JSON 字段万一后期需要增加“非遗级别”“出土时间”这类灵活信息不用改表结构直接在 JSON 里加 key 就行。3. 趣味互动功能的实现细节如果说数据模型是地基那么趣味互动就是这栋楼的门面。这个部分我花的时间最多因为要平衡“好玩”和“合理”避免做成一个披着趣味外衣的题库系统。3.1 文物展示与“寻宝”式浏览文物列表页如果只是做成普通的分页列表那和用搜索引擎搜文物没区别。我参考了线上展览的“讲故事”思路把首页分成几个主题化的“寻宝场景”“青铜时代的礼乐”系列精选商周青铜器“瓷中雅韵”系列按窑口展示瓷器“古墓里的神秘纹饰”系列吸引对未解谜题感兴趣的用户。每个系列是一组文物卡片卡片上不放完整说明只放“关键词悬念引导”比如“它身上的纹饰居然藏着古人眼中的宇宙。”用户点击后才会跳转到详情页看到完整解读。实现上就是给文物表增加了一个series字段或者单独建一个Series表做多对多。我用的是后一种方案因为一个文物可以同时出现在“青铜”和“纹饰”两个系列中。为了营造寻宝感列表页做了“未解锁”效果连续登录 3 天才能解锁“隐藏文物”板块。解锁逻辑不复杂就是判断用户连续登录天数但带来的期待感很强。注意连续登录判断要用last_login_date和today做对比不能简单用last_login时间戳否则跨天判断会出错。3.2 答题闯关与积分机制答题是这个系统互动性最强的部分。我把题目组织成“专题挑战”每个专题 5 道题题目围绕一个主题比如“青铜器铸造工艺”“瓷器的釉色魔法”答对一题 10 分连续答对会递增加成。积分计算的逻辑不算复杂但要注意一个细节用户中断答题后连续答题状态应该被重置。我在UserAnswerSession表里记录了用户当前专题的连续答对数一旦答错就清零。# 答题提交视图简化版 def submit_answer(request, question_id): question get_object_or_404(QuizQuestion, pkquestion_id) selected request.POST.get(answer, ) is_correct (selected.upper() question.correct_answer) session_key fquiz_{question.quiz_id}_{request.user.id} score 0 streak request.session.get(session_key, 0) if is_correct: streak 1 score 10 (streak - 1) * 2 # 每连续答对一次额外加2分 else: streak 0 request.session[session_key] streak AnswerRecord.objects.create( userrequest.user, questionquestion, selected_answerselected, is_correctis_correct, scorescore if is_correct else 0 ) return JsonResponse({correct: is_correct, score: score, explain: question.explain})答题之后立刻给出解析这一点很重要。用户刚做完题对答案的求知欲最强这时候展示一段文物的冷知识记忆效果远好于事后翻看资料。积分除了累计之外还做了排行榜。排行榜我一开始直接查所有的UserProfile表按积分排序后来数据量上来发现每次刷榜都很慢就改成每天凌晨用定时任务生成一份排行快照存在缓存里前台只读缓存。3.3 收藏、笔记与社区分享文物详情页右下角放了一个“收藏记笔记”的按钮。点击后弹出一个轻量文本框用户写下“我为什么喜欢这件文物”或者“相关知识联想”。这些笔记默认私密但用户可以勾选公开公开的笔记会聚合到“大家在看”的社区流里。社区流其实是搭了一个简单的动态列表模型叫SharedNoteclass SharedNote(models.Model): user models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.CASCADE) artifact models.ForeignKey(Artifact, on_deletemodels.CASCADE) content models.TextField() created_at models.DateTimeField(auto_now_addTrue)这个功能上线后效果出乎意料很多人并不是为了答题才来而是把它当成了“文物手账本”看到喜欢的文物就写两句心情。后来我干脆把首页改成了三个标签页推荐文物、最新笔记、热门答题让不同目的的用户都能快速找到自己感兴趣的内容。社区流最需要控制的是内容质量。我在发布笔记时做了敏感词过滤和长度限制并在管理后台增加了人工审核开关防止出现不合适的内容。虽然绝大多数用户是在认真分享知识但平台型功能必须有最基本的把关机制。4. 系统架构与关键代码实践Django 项目结构看起来千篇一律但真正落地时有很多组织代码的技巧。尤其是当模型、视图、模板逐渐增多时如果一开始不规划好分层后面每次加功能都要提心吊胆。4.1 Django 项目分层与配置我用的是官方推荐的目录结构但做了两个个性化处理一是把业务模块拆成了 4 个独立 App而不是把所有功能塞进一个 App二是用commonApp 存放共享的上下文处理器和工具函数。project/ ├── manage.py ├── config/ │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── apps/ │ ├── artifacts/ # 文物展示、详情 │ ├── quiz/ # 答题、题库 │ ├── users/ # 用户认证、积分、徽章 │ ├── community/ # 收藏笔记、动态 │ └── common/ # 共享工具函数拆 App 的边界是文物只负责展示和检索不直接写积分逻辑积分逻辑放在users里通过信号或者服务函数解耦。例如用户完成答题后要加积分我会在quiz的视图里调用users.services.add_points(user, points)而不是直接操作积分表。这样以后如果积分规则变了只需要改一处。在settings.py里我用环境变量读取敏感配置比如数据库密码和 Secret Key用django-environ管理。部署到服务器时不会把真实密钥提交到代码仓库最大程度避免安全事故。4.2 视图、模板、路由的配合视图层我大部分使用基于函数的视图FBV小部分复杂交互用了基于类的视图CBV。原因很简单项目逻辑大多是一次性请求处理FBV 写起来直观但收藏、点赞这类有前置条件判断的操作用 CBV 的dispatch统一做登录校验会更优雅。路由设计上我尽量把 URL 做成人类可读的格式urlpatterns [ path(, views.home, namehome), path(artifact/int:pk/, views.artifact_detail, nameartifact_detail), path(quiz/theme/int:theme_id/, views.quiz_theme, namequiz_theme), path(collection/toggle/, views.toggle_collection, nametoggle_collection), ]模板方面我没有使用很复杂的模板继承链。基础模板base.html里放导航栏和页脚子模板只用覆盖content区块。页面上的交互组件比如答题弹窗、收藏提示都通过include模板片段实现方便复用。4.3 用户认证与权限控制Django 自带的auth模块已经能覆盖绝大多数需求我在此基础上扩展了一个UserProfile模型用来存积分、连续登录天数、头像等信息。通过OneToOneField关联到内置 User 模型。权限控制上有几个容易忽略的点游客可以浏览文物但不能答题、收藏、记笔记所以答题视图要用login_required装饰器管理员对文物和题目有增删改权限我用 Django Admin 自带的权限组来区分而不是自己写角色表用户只能编辑自己的笔记不能改别人的所以在编辑视图里要校验request.user obj.user。有一次我忘记给收藏接口加登录校验结果游客可以无限调用接口往数据库里写入垃圾数据数据库甚至出现了几万条 user 为空的收藏记录。从那以后所有写操作接口我都统一加了一层“登录检测装饰器”并配合require_POST限制请求方法把安全隐患杜绝在入口。5. 前端体验与接口设计这个项目没有专业前端配合所以前端技术选型偏保守Bootstrap 5 负责布局少量原生 JavaScript 做交互页面采用服务端渲染加局部刷新。这种方式写起来快SEO 也友好很适合一个人维护。5.1 文物图片展示的取舍文物图片是系统的“门面”。一开始我打算接第三方全景图片库后来发现加载速度太慢而且很多文物图片的版权并不明确。最终方案是在本地服务器用ImageField保存原图然后通过一个简单的图片缩放函数生成缩略图和大图。缩略图用 Pillow 库处理from PIL import Image from io import BytesIO from django.core.files.base import ContentFile def generate_thumb(file_field, size(400, 400)): img Image.open(file_field) img img.convert(RGB) img.thumbnail(size, Image.Resampling.LANCZOS) buffer BytesIO() img.save(buffer, formatJPEG, quality85) return ContentFile(buffer.getvalue())文物主图不建议直接用原图单张几兆的图片会在列表页产生巨大的带宽压力。列表页用缩略图详情页用最大宽度不超过 1200px 的优化图大图查看才加载原图。图片加载还有一个容易被忽视的问题浏览器并发连接数有限如果一页有几十张缩略图同时加载会造成拥堵。我用了loadinglazy属性让图片进入视口时再加载同时在 Nginx 里开启图片缓存实测加载速度提升非常明显。5.2 响应式布局与移动端适配用户大部分时间是通过手机访问的所以页面必须优先保证手机端体验。我用了 Bootstrap 的栅格系统文物卡片在不同屏幕宽度下自动从 4 列变成 2 列甚至 1 列。详情页的排版上小屏幕把文物图片放在文档流顶部信息放下面大屏幕则采用左右两栏布局。这个用 CSS 的flex-direction和order就能实现不需要写两套模板。按钮的设置也做了移动端优化。答题选项在大屏幕上是一行四列手机端则改成上下堆叠的列表每个选项的高度至少 44px方便手指点击。否则“B 选项”在手机上很容易误触成“C 选项”严重影响答题体验。5.3 轻量接口与 Ajax 局部刷新收藏和答题提交我都是用 Ajax 实现的这样用户点击按钮后页面不会刷新反馈也更即时。Django 端只需要返回JsonResponse前端用 JavaScript 处理响应。一个小技巧是使用 Django 内置的csrf_exempt还是CsrfViewMiddleware。我保留了 CSRF 校验前端 Ajax 请求时从 cookie 里取csrftoken然后设置请求头。这个逻辑封装成一个公共函数避免每个按钮写重复代码。function getCookie(name) { let cookieValue null; if (document.cookie document.cookie ! ) { const cookies document.cookie.split(;); for (let i 0; i cookies.length; i) { const cookie cookies[i].trim(); if (cookie.substring(0, name.length 1) (name )) { cookieValue decodeURIComponent(cookie.substring(name.length 1)); break; } } } return cookieValue; } function csrfSafeMethod(method) { return /^(GET|HEAD|OPTIONS|TRACE)$/.test(method); } $.ajaxSetup({ beforeSend: function(xhr, settings) { if (!csrfSafeMethod(settings.type) !this.crossDomain) { xhr.setRequestHeader(X-CSRFToken, getCookie(csrftoken)); } } });接口返回的 JSON 里除了成功标识还会带上最新的积分、收藏状态、提示文案。这样前端不需要额外请求页面数据交互体验和前后端分离方案几乎没有差别。6. 常见问题与性能优化记录开发过程中难免会遇到一些“平时不觉得流量一起来就出事”的问题。我把这些经验整理成一张速查表给后来者提个醒。6.1 图片资源加载慢怎么办系统上线测试时最直观的问题是文物列表页非常卡。我用浏览器开发者工具看了 Network 面板发现单个页面加载了接近 30MB 的图片原因是后台录入人员直接上传了高清原图。解决方案分两步第一步是在上传时自动生成缩略图列表页一律用缩略图第二步是在 Nginx 层做静态资源缓存给图片设置 7 天的Cache-Control。处理后页面体积降到 3MB 以内加载时间从 8 秒降到 2 秒左右。文物图片数量会持续增长建议尽早接入对象存储或者 CDN。如果项目规模不大至少要把图片目录放到独立磁盘分区避免和系统日志抢 IO。6.2 查询 N1 问题这是一个经典问题。我在首页写“热门文物”列表时原本是这样的artifact_list Artifact.objects.filter(is_featuredTrue)[:12]模板里又循环访问每个文物对应的朝代和收藏数{% for artifact in artifact_list %} p{{ artifact.dynasty.name }}/p p{{ artifact.userannotation_count }}/p {% endfor %}这样会导致 N 次额外查询。解决方法是使用select_related(dynasty)和annotate()from django.db.models import Count artifact_list Artifact.objects.filter(is_featuredTrue)\ .select_related(dynasty)\ .annotate(collection_countCount(usercollection))\ [:12]select_related适合外键字段prefetch_related适合多对多字段。排查时可以在settings.py里临时开启 Django Debug Toolbar或者在查询链路上打印connection.queries看查了多少次数据库。6.3 并发答题与数据一致性答题加积分操作涉及读写数据库。初期实现是user_profile.points score user_profile.save()这在单用户操作时没问题但用户连续快速答题时多个请求可能同时读取相同的points值然后写回导致积分丢失。类似场景让我意识到需要使用数据库原子更新from django.db.models import F UserProfile.objects.filter(userrequest.user).update(pointsF(points) score)使用F表达式可以把“取当前值-加分数-写回”这步操作交给数据库完成避免并发覆盖。同理文物view_count字段也用了F(view_count) 1来增加。需要特别小心的是F表达式更新不会触发模型的save()方法所以如果还有关联的内存变量需要刷新对象。7. 部署与后续扩展项目开发完成只是上半场真正上线部署又是新一轮踩坑。这里记录一套可复制的部署方案和未来可能的演进方向。7.1 Linux 服务器部署要点我用一台轻量云服务器系统选了 Linux 发行版部署方案是 Nginx Gunicorn MySQL。大体步骤分为创建虚拟环境、安装项目依赖、迁移数据库、收集静态文件、配置 Nginx 反向代理、使用 Supervisor 守护 Gunicorn 进程。重点关注几个配置DEBUGFalse时必须处理ALLOWED_HOSTS否则会报错无法访问STATIC_ROOT要配置正确然后执行python manage.py collectstatic把静态文件集中到指定目录让 Nginx 直接托管MEDIA_ROOT对应上传的文物图片目录同样由 Nginx 托管并且要配置好目录权限。Gunicorn 的启动参数我用了--workers 3 --threads 4并结合服务器内存情况调整。如果内存只有 2GB建议减少 worker 数量否则多个进程同时启动很容易触发 OOM。7.2 内容安全与合规审核文物知识本身是正向内容但只要是用户产生内容就一定要有审核机制。我在公开笔记发布前做了两道过滤第一道是内置敏感词表命中后直接禁止发布第二道是后台人工审核列表管理员可以一键隐藏不合适的内容。管理员后台还有一个“用户举报”入口用户可以举报涉嫌剽窃或者不友善的笔记。处理举报的视图很简单就是更新status字段从published改为hidden。做这个功能是为了让社区能良性循环避免出现监管死角。关于文物数据的来源我建议团队整理资料时标注参考来源比如公开出版的图录、博物馆官网公开资料。尽量不要直接抓取其他平台的图片和文字既有版权风险内容质量也很难保证。7.3 后续还能加入什么这个系统目前实现了“能看、能玩、能分享”但离“真正的趣味学习”还有不小距离。后续我计划新增几个方向知识图谱把文物、朝代、工艺、考古遗址关联成图谱让用户通过关系链发现新文物语音讲解在详情页接入 TTS 语音合成用户可以用听的方式了解文物专题课程化把答题和阅读材料组织成“青铜器入门”“瓷器审美”等短课程学习过程更有体系线下扫码互动在博物馆场景中观众扫码进入对应文物页面线上学习与线下参观结合。技术上这些都是可行的关键是内容需要持续运营。系统做得再漂亮没有持续更新的文物故事和题库用户很快就会离开。最后再分享一个我个人的经验做这类文化科普系统技术难度其实都不算高真正拉开差距的是内容策划和交互细节。比如“趣味冷知识”每一条都要反复打磨确保准确、有记忆点答题错误时的解析要像朋友聊天一样自然而不是冷冰冰地甩一句“回答错误”。把注意力放在让用户愿意多停留一分钟、多想一个问题这个系统的价值就出来了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进