ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Django+Flask构建博物馆门户:票务、实时推送与部署全解析

Django+Flask构建博物馆门户:票务、实时推送与部署全解析 接到要开发一套恐龙博物馆门户景点系统的需求时我其实挺兴奋的。这不是那种无聊的内部管理系统而是游客真的会天天打开、在线预约门票、扫码看展品导览、实时查看客流的一个面向公众的产品——用 Python、Django 和 Flask 这套技术栈去落地恰好能把内容展示、业务表单、实时数据推送这几件核心事情全部理顺。整套系统从零设计到部署上线前后我花了大约三周。这里把完整的技术选型思路、数据库建模过程、核心功能实现以及最后部署时踩的那些坑都整理出来。如果你正准备做类似的文博场馆、景区门户系统或者正在学 Django 和 Flask 想找一个能落地的实战项目这篇文章可以直接当参考蓝图用。1. 为什么是Python门户系统技术选型复盘1.1 Django为主框架的三个硬理由我见过不少团队做这类内容型站点一上来就搞微服务、上前后端彻底分离结果权限、内容管理、后台一大堆东西全要自己重新造轮子上线周期拖得很长。博物馆门户系统的核心需求其实非常集中大量展品和展馆信息需要日常维护游客要能在线预约票务管理员需要一个趁手的后台去改内容。这类业务用 Django 是天作之合。第一个硬理由是 Django 自带 Admin 后台。你不用额外开发一套内容管理界面staff 账号登录之后直接就能增删改展馆、展品、门票类型和活动公告这对预算有限又需要高频更新内容的博物馆来说价值极高。第二个理由是 Django ORM 和迁移机制很成熟从 SQLite 开发到 MySQL 生产切换基本不用改业务代码执行 python manage.py migrate 就能平滑过渡。第三个理由是安全能力默认内置CSRF 防护、XSS 转义、SQL 注入防护都开箱即用能省掉大量安全审计的时间。当然纯粹从技术炫技的角度看Node.js、Java Spring Boot 也能做甚至性能上限更高。但对这种 PV 量级中等、以内容和表单为主的景点门户Python 生态的性价比是最高的——开发快、维护简单、招人容易这才是真实项目里最该算的账。1.2 Flask在架构里的真实分工标题里同时出现 Django 和 Flask很多人第一反应是二选一。我实际落地时做的是一主一辅Django 负责门户站的全部核心业务Flask 独立跑一个小服务专门处理实时数据推送和扫码导览这类轻量接口。为什么要单独拆一个 Flask 服务因为实时客流推送如果用 Django Channels 来做整套 ASGI 部署复杂度会明显上升还要引入 channel layer、redis 等额外组件。而 Flask 生态里有非常轻量的 SSEServer-Sent Events方案几行代码就能实现服务器主动推送。既然客流数字每隔几十秒更新一次这个场景并不需要全双工通信SSE 比 WebSocket 更合适配合 Flask 这个小而轻的服务独立部署、独立扩容完全不干扰主站稳定性。实际操作中Flask 服务只依赖三个接口客流推送、排队时长查询、展品扫码信息读取。它不连接业务数据库而是通过内部 HTTP 调用后台服务去取数据从架构上就避免了两套框架争抢同一个库的连接资源。1.3 为什么没有做彻底的前后端分离现在打开招聘网站满眼都是 Vue、React纯服务端渲染反而显得复古了。但在这类门户系统中我刻意没有做彻底的前后端分离原因是 SEO。游客搜索本地恐龙博物馆门票预约周末亲子游攻略靠的是搜索引擎和各类小程序入口。Django 模板服务端渲染可以直接把展品标题、场馆介绍、活动公告的完整 HTML 输出给搜索引擎比前端渲染后抓空页面强太多。我采取的是混合模式门户首页、展馆列表、展品详情、门票预约这些核心页面用 Django Template 渲染局部的动态交互——比如票种切换联动金额、表单异步校验、客流数字刷新——用少量原生 JavaScript 请求 JSON 接口。这样既保住了 SEO 和首屏速度又不会让用户体验停留在石器时代。提示不要因为追求技术潮流而忽略业务真实场景。对内容展示型门户站点服务端渲染仍然是性价比极高的方案等未来游客端小程序、App 出现明确需求时再用 DRF 补一套 API 也不迟。2. 数据模型设计把恐龙展馆搬进数据库2.1 核心业务实体怎么拆拿到需求后我做的第一件事不是写代码而是把实体关系画清楚。博物馆门户系统的核心可以拆成六张业务表展馆、展品、门票类型、订单、预约记录、游客反馈。展馆和展品之间是典型的一对多关系——一个展馆里陈列多件展品展品上再挂一个所属时期标签辅助筛选。门票这块要单独说。很多人做票务系统会把展馆和门票类型绑死比如霸王龙馆成人票恐龙蛋馆儿童票但实际业务中更常见的是一张通票逛全馆或者按日期分时段售票。所以我给门票类型单独建表用 name、price、stock、sale_start、sale_end 五个字段描述哪段时间、什么人群、多少钱、剩几张。订单表则关联门票类型和游客的手机号保存数量、总价、订单状态和核销码订单状态从待支付到已支付再到已核销已取消用状态机管起来。2.2 关键models代码落地用 Django 表达这套模型并不复杂核心代码如下。我写的时候刻意把价格字段用 DecimalField 而不是 FloatField避免浮点精度问题订单号用 UUID 而不是自增 ID这样对外展示时不容易被遍历出真实订单量。from django.db import models from django.contrib.auth.models import User import uuid class ExhibitionHall(models.Model): name models.CharField(展馆名称, max_length100) cover models.ImageField(封面图, upload_tohalls/, blankTrue) intro models.TextField(展馆介绍) open_time models.CharField(开放时间, max_length100) sort_order models.IntegerField(排序号, default0) class Meta: db_table exhibition_hall ordering [sort_order] def __str__(self): return self.name class Exhibit(models.Model): PERIOD_CHOICES [ (triassic, 三叠纪), (jurassic, 侏罗纪), (cretaceous, 白垩纪), ] hall models.ForeignKey( ExhibitionHall, verbose_name所属展馆, on_deletemodels.CASCADE, related_nameexhibits, ) name models.CharField(展品名称, max_length100) period models.CharField(时期, max_length20, choicesPERIOD_CHOICES) description models.TextField(展品描述) audio_url models.CharField(语音导览地址, max_length255, blankTrue) class Meta: db_table exhibit ordering [id] def __str__(self): return self.name class TicketType(models.Model): name models.CharField(票种名称, max_length50) price models.DecimalField(价格, max_digits8, decimal_places2) stock models.IntegerField(库存, default100) sale_start models.DateTimeField(开售时间) sale_end models.DateTimeField(停售时间) class Meta: db_table ticket_type订单表我拆成了两张TicketOrder 保存订单头OrderItem 保存订单明细方便未来一个订单购买多种票时平滑扩展。订单头里有 status、mobile、verify_code 三个关键字段verify_code 是一串 6 位随机码游客到现场后出示给工作人员核销。2.3 库存扣减与订单状态的设计细节票务系统最怕超卖。如果游客提交订单时不做任何并发控制两个人同时下单同一场次最后一张票库存就会变成负数。我在扣库存时用了 select_for_update 行级锁配合事务原子更新确保同一时刻只有一个请求能成功扣减from django.db import transaction transaction.atomic def create_order(ticket_type_id, quantity, mobile): ticket ( TicketType.objects.select_for_update() .filter(idticket_type_id) .first() ) if not ticket or ticket.stock quantity: raise ValueError(票已售罄) ticket.stock - quantity ticket.save(update_fields[stock]) # 创建订单、核销码等后续逻辑...另外我强烈建议给订单状态加一条约束订单创建后先占库存如果 15 分钟内未支付则自动释放。这里用的是一个简单的定时脚本每分钟扫一次超时未支付订单并回补库存。实测中这个方案比 Redis 过期回调更可控——脚本挂了能重跑库存不会凭空消失。删除数据时也要小心。Django 的数据删除有级联行为直接 Exhibit.objects.filter(hall_id1).delete() 会把整个展馆的展品都删掉。做后台删除按钮之前我用操作日志把即将删除的关联对象全部打印出来验证过一遍然后把是否还有未核销订单作为硬性拦截条件避免运营误删导致现场游客没票可用。这个先检查后删除的习惯后续在维护其他项目时也帮我躲了好几次坑。3. Django端核心模块实现3.1 项目初始化和App划分我拿到需求后先按官方推荐方式初始化了工程django-admin startproject dino_portal然后创建 exhibition、ticket、account 三个 App。App 的划分原则很简单——exhibition 管展馆和展品内容ticket 管门票和订单account 管游客的登录注册。不要一个 App 写到底也不要拆得过碎按业务域划分是最稳的。settings.py 里有几个必须改的配置INSTALLED_APPS 加上新 App 的名字DATABASES 配好 MySQL 连接TEMPLATES 里的 DIRS 指向项目根目录下的模板文件夹STATIC_URL 和 MEDIA_URL 分别处理 CSS/JS 与上传的展品图片。做这些基础配置时没有花哨技巧但漏掉任何一项后面跑起来都会在莫名其妙的地方报错。创建完 App 之后我用 manage.py startapp 自动生成的 models、views、urls 作为起点每个模块再按实际需求扩展。这个过程中的一个关键习惯是每建一个 App 就立刻在根 urls.py 里挂上 include 路由避免最后所有功能堆到一个文件里无从下手。3.2 展馆与展品页面的实现门户首页的核心是展馆卡片列表。我在 views.py 里写一个大厅视图查询所有展馆并按 sort_order 排序聚合出每个展馆下的展品数量然后渲染到模板。这个查询如果用 ORM 的 annotate 一行搞定不会产生 N1 问题from django.views.generic import ListView from .models import ExhibitionHall class HallListView(ListView): model ExhibitionHall template_name portal/hall_list.html context_object_name halls def get_queryset(self): return ( ExhibitionHall.objects .annotate(exhibit_countmodels.Count(exhibits)) .order_by(sort_order) )展品详情页则做了一个比较实用的设计页面上半部分是展品图文介绍下半部分自动列出同一展馆的其他展品引导游客继续逛下去。这个相关内容推荐不需要任何推荐算法一个简单的过滤查询就能实现但对游客停留时长的提升非常明显。模板方面Django Template 的 {% url %} 标签建议统一用来生成链接不要硬编码路径。我踩过的坑是项目初期写死在模板里的链接后期调整路由后整站链接全部失效。用 {% url exhibit-detail exhibit.id %} 这样带名字的路由重构时只需要改 urls.py 里的 name模板不用动。3.3 在线预约购票与Cookie设置Token保持登录态游客购票流程我设计成四步选择展馆日期、选择票种数量、填写手机号、提交订单。这个流程不需要强制注册账号——对游客来说买一张博物馆门票还要先注册账号转化率会掉一大截。所以我把游客手机号当作用户唯一标识首次下单时自动创建一个匿名账号并用 Cookie 写入一个 token 保持登录态。写 token 时要注意安全细节value 用随机字符串而不是直接存用户 ID必须加上 HttpOnly 属性防止前端脚本读取生产环境再叠加 Secure 和 SameSiteLax。Django 里设置 Cookie 的操作非常简单from django.http import HttpResponse def set_anonymous_token(response, user): token get_random_string(32) cache.set(fanon_token:{token}, user.id, timeout7 * 24 * 3600) response.set_cookie( anon_token, token, max_age7 * 24 * 3600, httponlyTrue, securesettings.SECURE_COOKIE, samesiteLax, ) return response这样游客第二次访问时视图里只要读取 Cookie 中的 anon_token 再从缓存反查用户 ID就能直接拉取他们的历史订单不需要重新注册。实测这个方案对游客下单转化率非常友好又避免了把敏感信息直接暴露在 Cookie 中。下单后的支付环节真实生产环境通常会对接微信支付或支付宝支付。我最初把支付流程简化为线下支付和按需对接支付 API两种模式线下支付就是提交订单生成核销码现场付款后核销对接支付 API 则预留了回调接口的字段。如果你做的是毕设或课程设计可以用 mock 支付模块代替重点把库存扣减和订单状态流转写清楚。3.4 管理后台的实用定制Django Admin 开箱即用的界面够用但默认展示方式比较粗糙。我给展品列表做了三层定制list_display 显示名称、所属展馆、时期三个字段list_filter 加一个展馆过滤器和时期过滤器search_fields 设置按展品名称模糊搜索。运营人员反馈加了 search_fields 之后查找效率提升非常明显不用再一页页翻。订单模块在 Admin 里更值得用心。除了默认字段我在 changelist 里加了一个自定义列根据订单状态渲染不同颜色的标签——待支付灰色、已支付蓝色、已核销绿色、已取消红色。运营只需要扫一眼颜色就知道哪些订单需要处理。这部分代码量不大但对真实使用体验的提升远超预期from django.contrib import admin from .models import TicketOrder admin.register(TicketOrder) class TicketOrderAdmin(admin.ModelAdmin): list_display [order_no, mobile, total_amount, status_tag, created_at] list_filter [status, created_at] admin.display(description状态) def status_tag(self, obj): colors { pending: gray, paid: blue, used: green, canceled: red, } return format_html( span stylecolor:{0}{1}/span, colors.get(obj.status, gray), obj.get_status_display(), )整个项目开发到这一步核心的门户展示和票务流程已经能跑通了。接下来要处理的是那些锦上添花但游客真的会在意的实时功能。4. Flask端辅助服务实时客流推送与扫码导览4.1 为什么把实时数据拆给Flask独立进程游客到了博物馆门口最想知道的是里面挤不挤现在排多长的队。这类数据是典型的动态数据需要服务器主动推送给页面。最朴素的方案是前端每隔 30 秒轮询一次接口但这样有两个问题请求频繁会造成不必要的数据库压力数据没有变化时也会白白浪费流量。更好的方案是 SSE——服务器建立一条长连接一旦客流数据有变化立刻推送给前端。实现 SSE 用 Flask 比 Django 轻得多不需要把主站切换到 ASGI 模式。我在生产环境里用两个独立进程分别跑 Django 和 FlaskFlask 进程只开了一个 worker避免多 worker 下 SSE 连接漂移导致的消息不一致问题。三个接口的分工是/live/crowd 返回当前各展馆客流人数/live/waiting 返回当前排队时长/exhibit/ 接收二维码里的展品 ID返回展品名、简介和语音导览地址。前两个接口用 SSE 长连接推送最后一个接口是普通 GET 请求。4.2 一个简单可靠的SSE推送实现Flask 端我用了一个非常轻量的实现没有引入消息队列直接用全局变量存储当前客流数据。后台调度任务每隔 30 秒从主库查一次各展馆的人流量写入全局变量然后推送事件给所有连接的客户端from flask import Flask, Response, jsonify import time app Flask(__name__) current_crowd { hall_1: 120, hall_2: 86, updated_at: 2025-06-01 10:00:00, } app.route(/live/crowd) def crowd_stream(): def generate(): last_snapshot None while True: snapshot json.dumps(current_crowd, ensure_asciiFalse) if snapshot ! last_snapshot: yield fdata: {snapshot}\n\n last_snapshot snapshot time.sleep(5) return Response( generate(), mimetypetext/event-stream, headers{ Cache-Control: no-cache, X-Accel-Buffering: no, Connection: keep-alive, }, ) app.route(/exhibit/int:exhibit_id) def exhibit_info(exhibit_id): # 实际项目中这里会调用内部 API 获取展品数据 data { exhibit_id: exhibit_id, name: 霸王龙骨架, description: 完整度约65%是目前馆内保存最完整的兽脚类恐龙骨架化石。, } return jsonify(data) if __name__ __main__: app.run(host0.0.0.0, port5001, threadedTrue)注意 SSE 必须设置 X-Accel-Buffering: no 这个响应头否则 Nginx 默认会对响应做缓冲导致前端收到的是积压后的数据而不是实时推送。这个坑我第一版部署时踩过前端页面上显示的客流数字始终不更新排查了半天才发现是反代缓冲在作祟。前端接入很简单用原生 EventSource 监听即可。页面关闭时浏览器会自动断开连接不用担心内存泄漏const source new EventSource(/live/crowd); source.onmessage (event) { const data JSON.parse(event.data); document.getElementById(hall-1-crowd).innerText data.hall_1 人; };顺带说一句如果你确定要上 WebSocketFlask 可以用 flask-sock 扩展Django 则对应 Channels。我在这个项目里没用 WebSocket是因为客流推送只需要服务端往客户端单向推数据SSE 的语义完全匹配部署和调试成本反而更低。能选最简单的工具解决问题就别给生产环境塞复杂度。4.3 展品扫码导览的小工具这个功能简单但特别受游客欢迎。我在每个展品说明牌的左下角印了一个二维码内容就是 https://play.dinomuseum.test/exhibit/12 这种链接。游客扫码后Flask 服务返回展品的 JSON 数据前端页面把展品名称、简介、语音导览地址渲染出来——如果游客手机安装了小程序还可以直接拉起小程序里的解说页面。二维码本身不需要放到系统里处理任何在线二维码生成工具都能完成。关键是服务端要处理好展品不存在展品已下架这两种异常状态返回一个友好的提示页而不是直接报 404 白屏。我在 Flask 的 errorhandler 里统一对 404 返回了一个静态提示卡片从用户体验上比默认的错误页好很多。5. 从开发到生产Linux部署与GunicornNginx实践5.1 服务器环境准备部署阶段我用的是 Ubuntu 22.04 云服务器Python 版本选择 3.10。注意在服务器上不要直接改系统自带的 Python而是用 venv 建独立的虚拟环境这一步能避免很多权限和依赖冲突问题。新建项目的部署目录结构如下/opt/dinomuseum/ ├── dino_portal/ # Django 主项目 ├── dino_live/ # Flask 辅助服务 ├── venv/ # 公共虚拟环境 ├── static/ # Django collected static └── media/ # 上传的展馆图片等进入项目目录后执行 python3 -m venv venv然后分别用 requirements.txt 安装 Django 和 Flask 的依赖。生产环境的依赖文件建议用 pip freeze 生成但只保留直接依赖的顶层包把传递依赖也一起锁死会导致后续升级时出现大量兼容性问题。我习惯把核心依赖写死版本号其余用 的方式放宽范围。5.2 用Gunicorn管理两个Python进程Django 和 Flask 都是 Python 进程直接裸跑开发服务器是不可靠的。生产环境我用 Gunicorn 管理这两个服务。Django 侧的启动命令是gunicorn dino_portal.wsgi:application \ --bind 127.0.0.1:8000 \ --workers 3 \ --name dino_portal \ --access-logfile /var/log/dinomuseum/portal-access.log \ --error-logfile /var/log/dinomuseum/portal-error.logworkers 数量我定为 3这是按服务器的 2 核 CPU 算出来的——经验值大约是 CPU 核心数加 1。这个配置在压测中能稳定承受几百并发对博物馆门户来说绰绰有余。Flask 侧有一个关键区别因为 SSE 依赖单进程维持连接所以 worker 必须强制写成 1不能再按 CPU 核数扩展gunicorn app:app \ --bind 127.0.0.1:5001 \ --workers 1 \ --threads 10 \ --timeout 0 \ --name dino_livetimeout 设成 0 很重要。SSE 连接是长连接如果 Gunicorn 默认的 30 秒超时生效每个连接都会被强制断开前端 EventSource 就会不断自动重连造成一种一直连不上的假象。这个坑我调了一晚上才弄明白。进程守护我用了 supervisor配置文件里写好启动命令和自动重启策略这样即使进程意外退出也会被立即拉起来。如果你不喜欢 supervisor也可以用 systemd 的 service 文件效果等价看个人习惯。5.3 Nginx反向代理与静态文件处理Nginx 在这套部署里的作用有三个转发动态请求到 Django 和 Flask、托管静态文件、处理 HTTPS 证书。我的配置核心如下server { listen 80; server_name www.dinomuseum.test; location /static/ { alias /opt/dinomuseum/static/; expires 30d; } location /media/ { alias /opt/dinomuseum/media/; } # Flask 实时服务 location /live/ { proxy_pass http://127.0.0.1:5001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; } # Django 主服务 location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }/live/ 这个 location 是专门给 Flask 的 SSE 服务用的。proxy_buffering off 和 proxy_read_timeout 3600s 这两行缺一不可前者关闭缓冲让数据边到边发后者保证长连接不被 Nginx 掐断。如果忘了配置前端 EventSource 会间歇性断线。静态文件部分我在 Django 的 settings.py 里设置了 STATIC_ROOT然后运行 python manage.py collectstatic 把所有 App 的静态资源收集到统一目录。这个步骤在第一次部署时特别容易遗漏——开发环境静态文件能显示是因为 Django 开发服务器自动去找各 App 的 static 文件夹生产环境压根不会这么做。5.4 部署后会遇到的几个真实问题第一件是 ALLOWED_HOSTS。Django 3.0 之后默认只允许 localhost我的服务器域名忘记加进去结果页面直接报 DisallowedHost 错误。部署检查清单里必须包含这一项。第二件是时区问题。开发机上我用的本地时区数据库里存的预约时间到了服务器上全部偏差 8 小时。Django 的 settings.py 里 USE_TZ True 且 TIME_ZONE Asia/Shanghai 时所有时间都按 UTC 存储、按上海时区显示但你自己用 datetime.now() 生成的时间戳会不匹配。统一换成 django.utils.timezone.now() 之后这个坑就消失了。第三件是 Media 文件权限。服务器上的媒体目录归属 www-data 用户但 Gunicorn 进程如果跑在 root 或普通用户下上传的展馆图片就会 403。把 Gunicorn 的 user 和 group 通过 supervisor 指定好同时给 media 目录设置 755 权限才能彻底解决。我再补一个更隐蔽的问题Django 的 SECRET_KEY 如果直接写死在 settings.py 里每次 git 提交都会被带上存在泄漏风险。生产环境我改成从环境变量读取配合 session 加密时使用的 SECRET_KEY 也一起统一管理。这是你去面试或者说服别人信任你部署能力时值得拿出来讲的一个细节。6. 做完这套系统后我留下来的几个建议整个项目从零到交付我最大的体会是选技术栈不要贪多求全。Python 生态里 Django 和 Flask 都足够成熟根据业务场景给它们分好工比争论谁更厉害有意义得多。如果你正在写自己的博客系统、毕设课题或者博物馆、景区这类门户项目有几个经验可以直接复用。第一个是库存扣减必须用 select_for_update 加事务防止并发超卖这个坑在真实运营中一旦出现就是事故。第二个是 SSE 代替 WebSocket 处理单向实时推送开发成本低一个量级。第三个是部署环节先把 Nginx 缓冲、Gunicorn 超时、时区、静态文件收集这四件事写好能省掉上线后大半的排查时间。我个人还有一个建议像门户网站这种项目开发阶段可以先把 Django Admin 定制好再铺业务功能因为内容维护的后台体验决定了运营同学愿不愿意持续更新内容——博物馆的展品介绍如果一年没人维护游客来一次就不会再来了。这个项目后续如果要扩展我会优先考虑加一套 REST API 给小程序端用再接入真正的在线支付和短信核销把预约-支付-到场-核销这个闭环彻底打通。游客侧的体验每提升一步都值得花钱花时间。
返回列表