简介:这是一套基于Django与Vue.js全栈开发的旅游景区管理系统源码,面向Python Web开发初学者与中小型旅游类项目开发者,解决景区门票在线管理、用户预订及后台运营一体化需求。资源包含392个文件,涵盖32个Python后端逻辑文件、28个Vue前端组件、154个JPEG/JPG景区图片素材、35个TS类型脚本及1个SQL数据库初始化文件,整体压缩包25.02MB,结构清晰分为server(Django后端)与web(Vue前端)两大目录,便于分层学习与二次开发。已有278人学习下载,资源提供完整前后端代码、可直接运行的MySQL数据脚本(含shop数据库建表与初始数据)、详细项目说明文档,以及首页、景区详情、门票购买、订单管理、分类/标签/评论/日志等全功能模块实现,适合用于课程设计、毕设参考或旅游SaaS系统快速原型搭建。
1. 用 Django + Vue 搭建旅游景区管理系统,不是堆技术,而是解决「票务流程割裂、数据难同步、前端交互卡顿」这三类真实痛点
很多景区还在用 Excel 管理门票库存、靠人工核销入场码、后台改个票价要发邮件通知前端——这种模式在节假日瞬时并发超 500 请求时必然崩盘。本项目标题里的「Django + Vue」不是为了凑热门词,而是明确采用「Django 做强一致性事务后端 + Vue 做高响应式前端」的分工逻辑:Django 负责订单原子性扣减、库存锁、支付回调幂等校验;Vue 负责实时渲染余票数、动态加载景区导览图、游客端扫码核销动画反馈。它面向的是中小型文旅单位(如县级文旅局下属景区、5A级分园)的技术实施者——你不需要从零写支付网关,但必须能看懂models.py里TicketInventory的select_for_update()用法;你不必精通 Vue3 Composition API 全套语法,但得会改api/ticket.js里getAvailableTickets()的请求拦截器加 token。项目附带的.sql文件不是简单建表脚本,而是含初始景区分类、门票阶梯定价策略、管理员角色权限预置的生产级数据快照,可直接导入 MySQL 8.0+ 或 PostgreSQL 12+ 运行。
2. Django 后端:用原生 ORM 构建门票核心模型,避开常见事务陷阱与权限漏洞
2.1 设计符合景区业务逻辑的数据库模型,重点处理「多类型门票+时段库存+实名核销」三重约束
景区门票远不止「成人票/学生票」两种。实际需支持:
- 类型维度:日票、年卡、夜场票、联票(如「黄山+宏村」组合)
- 时间维度:固定日期票(如「2024-10-01」)、有效期内任选日(如「购票后30天内有效」)、时段票(如「14:00-17:00 场次」)
- 核销维度:二维码核销(需绑定身份证号)、人脸识别核销(需对接硬件SDK)、窗口人工核销(需操作员工号留痕)
对应 Djangomodels.py关键字段设计如下:
# models.py from django.db import models from django.contrib.auth.models import User class ScenicArea(models.Model): name = models.CharField(max_length=100, verbose_name="景区名称") code = models.CharField(max_length=20, unique=True, verbose_name="景区编码") # 用于API路由和二维码生成 address = models.TextField(verbose_name="地址") class TicketType(models.Model): SCENE_TYPE_CHOICES = [ ('day', '日票'), ('year', '年卡'), ('night', '夜场票'), ('combo', '联票'), ] name = models.CharField(max_length=50, verbose_name="票种名称") scene_type = models.CharField(max_length=10, choices=SCENE_TYPE_CHOICES) is_time_limited = models.BooleanField(default=False, verbose_name="是否限时票") # 控制是否显示时段选择 is_realname_required = models.BooleanField(default=True, verbose_name="是否实名制") class TicketInventory(models.Model): scenic_area = models.ForeignKey(ScenicArea, on_delete=models.CASCADE, related_name="inventories") ticket_type = models.ForeignKey(TicketType, on_delete=models.CASCADE, related_name="inventories") date = models.DateField(null=True, blank=True, verbose_name="适用日期") # 仅对固定日期票有效 time_slot = models.CharField(max_length=20, null=True, blank=True, verbose_name="适用时段") # 如 "14:00-17:00" total_stock = models.PositiveIntegerField(verbose_name="总库存") available_stock = models.PositiveIntegerField(verbose_name="可用库存") class Meta: unique_together = ['scenic_area', 'ticket_type', 'date', 'time_slot'] # 防止重复库存记录提示:
unique_together是关键约束。若漏掉time_slot,同一景区同一天的多个时段库存会冲突;若漏掉date,年卡类无日期限制的票种则无法复用该模型。实际部署前务必用python manage.py makemigrations && python manage.py migrate生成迁移文件,并检查0001_initial.py中unique_together是否生效。
2.2 实现高并发下的库存扣减,用select_for_update()防止超卖而非简单available_stock -= 1
单纯ticket.available_stock -= 1在并发场景下必然超卖。正确做法是在数据库层面加行锁:
# views.py from django.db import transaction from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt import json @csrf_exempt def create_order(request): if request.method != 'POST': return JsonResponse({'error': 'Method not allowed'}, status=405) data = json.loads(request.body) scenic_code = data.get('scenic_code') ticket_type_id = data.get('ticket_type_id') date_str = data.get('date') # 可为空,年卡类不传 time_slot = data.get('time_slot') # 可为空 try: with transaction.atomic(): # 锁定库存记录,防止并发修改 inventory = TicketInventory.objects.select_for_update().get( scenic_area__code=scenic_code, ticket_type_id=ticket_type_id, date=date_str or None, time_slot=time_slot or None ) if inventory.available_stock <= 0: return JsonResponse({'error': '库存不足'}, status=400) # 扣减库存(此时其他请求已被阻塞) inventory.available_stock -= 1 inventory.save() # 创建订单(省略具体字段) order = Order.objects.create( scenic_area=inventory.scenic_area, ticket_type=inventory.ticket_type, user=request.user, status='paid' ) return JsonResponse({'order_id': order.id, 'remaining': inventory.available_stock}) except TicketInventory.DoesNotExist: return JsonResponse({'error': '票种不可用'}, status=404) except Exception as e: return JsonResponse({'error': str(e)}, status=500)参数说明:
select_for_update()默认在当前事务结束前持有行锁。若使用 MySQL,需确保表引擎为 InnoDB;若用 PostgreSQL,需注意FOR UPDATE在READ COMMITTED隔离级别下行为一致。此处未加nowait=True是因业务允许短暂等待(如用户刷新页面),若需立即失败,可改为select_for_update(nowait=True)并捕获DatabaseError。
2.3 配置 Django Admin 界面,用list_filter和search_fields快速定位异常订单
景区管理员日常需快速查「某天某景区退票率过高」或「某身份证号重复购票」,原生 Admin 需定制:
# admin.py from django.contrib import admin from .models import Order, TicketInventory @admin.register(Order) class OrderAdmin(admin.ModelAdmin): list_display = ['id', 'user', 'scenic_area', 'ticket_type', 'status', 'created_at', 'realname'] list_filter = [ 'status', 'scenic_area__name', # 关联外键过滤 ('created_at', admin.DateFieldListFilter), # 时间范围筛选 ] search_fields = ['user__username', 'realname', 'id_number'] # 支持身份证号模糊搜索 date_hierarchy = 'created_at' # 顶部添加时间导航条 # 重写保存逻辑,确保实名信息入库 def save_model(self, request, obj, form, change): if not change and obj.realname and obj.id_number: # 新建订单时校验身份证号格式(简化版) if len(obj.id_number) != 18: self.message_user(request, '身份证号格式错误', level='error') return super().save_model(request, obj, form, change)注意:
search_fields中user__username利用了 Django 的双下划线语法穿透外键,比手动写get_user_display()更高效;date_hierarchy自动生成「按年/月/日」的折叠导航,比list_filter的日期选择器更符合管理员直觉操作。
3. Vue 前端:用 Axios 封装请求 + Vuex 持久化用户态,解决「页面刷新丢失登录态、余票数不同步」问题
3.1 构建可复用的 API 请求模块,自动注入 token 并统一处理 401 登录过期
Vue 项目中若每个组件都手动axios.get('/api/tickets/', {headers: {Authorization: ...}}),维护成本极高。应封装为api/index.js:
// src/api/index.js import axios from 'axios' // 从 localStorage 读取 token(登录后由 login 接口写入) const getToken = () => localStorage.getItem('auth_token') const apiClient = axios.create({ baseURL: '/api/', // Django 后端 API 前缀 timeout: 10000, }) // 请求拦截器:自动添加 Authorization 头 apiClient.interceptors.request.use( config => { const token = getToken() if (token) { config.headers.Authorization = `Token ${token}` } return config }, error => Promise.reject(error) ) // 响应拦截器:统一处理 401 apiClient.interceptors.response.use( response => response, error => { if (error.response?.status === 401) { localStorage.removeItem('auth_token') window.location.href = '/login/' // 跳转登录页 } return Promise.reject(error) } ) export default apiClient逻辑说明:
baseURL: '/api/'表明所有请求路径自动补前缀,调用apiClient.get('tickets/')实际请求/api/tickets/;interceptors.response中window.location.href是硬跳转,比 Vue Router 的router.push()更可靠,避免路由守卫未生效导致白屏。
3.2 使用 Vuex 持久化用户信息与景区列表,避免每次刷新都重新请求
游客端首页需展示「附近景区」,管理端需记住「当前操作景区」。Vuex 默认内存存储,刷新即丢,需配合vuex-persistedstate:
npm install vuex-persistedstate// src/store/index.js import Vue from 'vue' import Vuex from 'vuex' import createPersistedState from 'vuex-persistedstate' Vue.use(Vuex) export default new Vuex.Store({ state: { user: null, // 登录用户信息 scenicAreas: [], // 缓存景区列表,避免首页重复请求 currentScenic: null, // 当前选中的景区对象 }, mutations: { SET_USER(state, user) { state.user = user }, SET_SCENIC_AREAS(state, areas) { state.scenicAreas = areas }, SET_CURRENT_SCENIC(state, scenic) { state.currentScenic = scenic } }, plugins: [ createPersistedState({ key: 'tourism-vue-store', paths: ['user', 'scenicAreas', 'currentScenic'] // 仅持久化这三个字段 }) ] })参数说明:
paths显式声明需持久化的 state 字段,避免将临时 loading 状态也存入 localStorage;key设置为唯一字符串,防止与其他 Vue 项目冲突;createPersistedState默认使用localStorage,若需兼容低版本浏览器可传入storage: window.sessionStorage。
3.3 实现余票数实时更新,用 WebSocket 替代轮询降低服务器压力
Vue 页面显示「黄山风景区 · 日票剩余 128 张」,若每秒setInterval(() => api.get('/tickets/'), 1000),1000 用户同时访问将产生 1000 QPS。Django Channels 提供轻量级 WebSocket 支持:
# consumers.py import json from channels.generic.websocket import AsyncWebsocketConsumer from asgiref.sync import sync_to_async from .models import TicketInventory class TicketStockConsumer(AsyncWebsocketConsumer): async def connect(self): await self.accept() # 订阅特定景区票种的库存变更 scenic_code = self.scope['url_route']['kwargs'].get('scenic_code') if scenic_code: await self.channel_layer.group_add( f'stock_{scenic_code}', self.channel_name ) async def disconnect(self, close_code): scenic_code = self.scope['url_route']['kwargs'].get('scenic_code') if scenic_code: await self.channel_layer.group_discard( f'stock_{scenic_code}', self.channel_name ) async def send_stock_update(self, event): # 接收库存更新事件并推送给前端 await self.send(text_data=json.dumps({ 'type': 'stock_update', 'scenic_code': event['scenic_code'], 'available_stock': event['available_stock'] }))Vue 组件中建立连接:
<!-- components/TicketDisplay.vue --> <script> export default { data() { return { stock: 0, ws: null } }, mounted() { this.initWebSocket() }, beforeDestroy() { if (this.ws) this.ws.close() }, methods: { initWebSocket() { const scenicCode = this.$route.params.scenicCode this.ws = new WebSocket(`ws://${window.location.host}/ws/stock/${scenicCode}/`) this.ws.onmessage = (event) => { const data = JSON.parse(event.data) if (data.type === 'stock_update') { this.stock = data.available_stock } } } } } </script>注意:Django Channels 需额外安装
channels包并配置ASGI_APPLICATION;WebSocket URL 中ws://协议必须与前端页面协议一致(HTTP 页面不能连wss://);beforeDestroy生命周期钩子确保组件卸载时关闭连接,防止内存泄漏。
4. 数据库与部署:用提供的 SQL 文件初始化结构,规避 MySQL 8.0+ 密码认证兼容性问题
4.1 导入 SQL 文件前必做的三件事:确认字符集、禁用严格模式、验证外键约束
项目附带的.sql文件通常基于 MySQL 5.7 生成,在 MySQL 8.0+ 上直接source tourism.sql可能报错。需先执行:
-- 步骤1:切换到 utf8mb4 字符集(支持 emoji 和生僻字) ALTER DATABASE tourism CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; -- 步骤2:临时禁用严格模式(避免 datetime 默认值报错) SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'STRICT_TRANS_TABLES','')); -- 步骤3:检查外键是否启用(Django 迁移依赖此功能) SELECT @@FOREIGN_KEY_CHECKS; -- 应返回 1然后导入:
mysql -u root -p tourism < tourism_init.sql提示:
tourism_init.sql中若含CREATE TABLE ... ENGINE=InnoDB DEFAULT CHARSET=utf8;,需手动替换为utf8mb4,否则中文可能乱码;STRICT_TRANS_TABLES在 MySQL 8.0.19+ 默认开启,禁用后需重启 MySQL 生效,生产环境建议在my.cnf中永久配置sql_mode = "NO_ENGINE_SUBSTITUTION"。
4.2 Django 连接 MySQL 8.0+,必须安装mysqlclient并指定auth_plugin
MySQL 8.0 默认使用caching_sha2_password认证插件,旧版mysqlclient不兼容:
# 先卸载旧版本 pip uninstall mysqlclient # 安装支持新认证的版本(>=2.1.0) pip install "mysqlclient>=2.1.0" # 配置 settings.py DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'tourism', 'USER': 'tourism_user', 'PASSWORD': 'your_secure_password', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': { 'auth_plugin': 'mysql_native_password', # 关键!强制使用旧认证 } } }参数说明:
auth_plugin参数告诉mysqlclient用mysql_native_password插件连接,绕过caching_sha2_password的握手问题;若仍报错Authentication plugin 'caching_sha2_password' cannot be loaded,需在 MySQL 中为用户重置密码:ALTER USER 'tourism_user'@'%' IDENTIFIED WITH mysql_native_password BY 'new_password';。
4.3 Vue 打包后静态资源部署,用 Nginx 代理 API 请求避免跨域
Vue CLI 默认打包到dist/目录,Django 静态文件服务性能差,应交由 Nginx:
# /etc/nginx/sites-available/tourism server { listen 80; server_name tourism.example.com; # 静态资源直接由 Nginx 服务 location /static/ { alias /var/www/tourism/dist/static/; expires 1y; add_header Cache-Control "public, immutable"; } # Vue Router history 模式回退 location / { root /var/www/tourism/dist; try_files $uri $uri/ /index.html; } # API 请求反向代理到 Django location /api/ { 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; } }注意:
try_files $uri $uri/ /index.html;是 Vue Router history 模式的必需配置,否则刷新/ticket/123页面会 404;proxy_pass末尾的/表示路径重写,/api/tickets/会被转发为http://127.0.0.1:8000/tickets/,与 Djangourls.py中path('api/tickets/', ...)匹配。
5. 关键调试技巧:用 Django Debug Toolbar 查慢查询,用 Chrome Network 验证 Vue 请求头
5.1 定位「门票列表加载慢」问题:在 Django Admin 中启用 Debug Toolbar
当/admin/tourism/ticketinventory/页面打开缓慢,不是盲目优化前端,而是先确认是否数据库查询拖慢:
# settings.py(仅开发环境) INSTALLED_APPS += ['debug_toolbar'] MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] INTERNAL_IPS = ['127.0.0.1'] # urls.py if settings.DEBUG: import debug_toolbar urlpatterns += [path('__debug__/', include(debug_toolbar.urls))]访问 Admin 页面后,右上角出现黄色小图标,点击进入「SQL」标签页,可看到:
| Time | SQL Query |
|---|---|
| 124ms | SELECT * FROM tourism_ticketinventory WHERE scenic_area_id = 1 AND ticket_type_id = 2 |
| 89ms | SELECT * FROM tourism_scenicarea WHERE id = 1 |
若第一条查询耗时 >100ms,说明缺少索引:
-- 为高频查询字段添加复合索引 CREATE INDEX idx_inventory_scenic_type ON tourism_ticketinventory (scenic_area_id, ticket_type_id);验证方法:执行
EXPLAIN SELECT * FROM tourism_ticketinventory WHERE scenic_area_id = 1 AND ticket_type_id = 2;,若key列显示idx_inventory_scenic_type且rows显著减少,则索引生效。
5.2 解决「Vue 登录后无法获取用户信息」:用 Chrome DevTools 检查请求头与响应体
登录成功后调用/api/user/返回 401,但 token 已存入 localStorage。此时打开 Chrome Network 标签页:
- 找到
/api/user/请求 → 点击 → 查看Headers标签页 - 检查
Request Headers中是否有Authorization: Token abc123... - 若无,说明
apiClient.interceptors.request未生效,检查getToken()是否返回空字符串(可能 localStorage key 名写错) - 若有,再看Response标签页,若返回
{"detail":"Invalid token."},说明 Django 的TokenAuthentication未正确配置,检查settings.py中REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES']是否包含'rest_framework.authentication.TokenAuthentication'
技巧:在 Network 面板右键请求 → 「Copy as cURL」,粘贴到终端执行,可排除浏览器缓存干扰;若 cURL 成功而浏览器失败,问题必在前端 JS 逻辑而非后端。
5.3 验证库存扣减是否真正防超卖:用 Apache Bench 模拟并发请求
不要依赖人工点按钮测试,用ab工具压测:
# 模拟 100 个用户并发请求创建订单(库存初始为 50) ab -n 100 -c 100 -H "Authorization: Token your_token_here" \ -p order_data.json -T "application/json" \ "http://localhost:8000/api/orders/"order_data.json内容:
{"scenic_code": "huangshan", "ticket_type_id": 1, "date": "2024-10-01"}压测结束后检查数据库:
SELECT available_stock FROM tourism_ticketinventory WHERE scenic_area_id = 1 AND ticket_type_id = 1 AND date = '2024-10-01';结果应为0(初始 50 - 成功下单 50),若大于 0 说明有请求被拒绝(正常),若小于 0 说明超卖(严重 bug)。
本文还有配套的精品资源,点击获取