基于Django的景点印象服务管理系统设计与实现
一、 技术栈
本系统采用前后端分离的架构模式,主要技术栈如下:
1.1 后端技术栈
- 核心框架:Django 4.x
- 数据库:MySQL 8.0 / PostgreSQL 14
- ORM:Django ORM
- API接口:Django REST Framework (DRF)
- 用户认证:Django内置认证系统 + JWT (djangorestframework-simplejwt)
- 缓存:Redis
- 任务队列:Celery + Redis
- 文件存储:本地存储 / 阿里云OSS / 七牛云
1.2 前端技术栈
- 前端框架:Vue.js 3.x / React 18
- UI组件库:Element Plus (Vue) / Ant Design (React)
- 状态管理:Vuex / Pinia (Vue) 或 Redux Toolkit (React)
- HTTP客户端:Axios
- 路由管理:Vue Router / React Router
1.3 开发与部署
- 版本控制:Git
- 容器化:Docker, Docker Compose
- CI/CD:GitHub Actions / GitLab CI
- 部署:Nginx + Gunicorn (Django) / Node.js (前端静态资源)
二、 背景与意义
2.1 项目背景
随着旅游业的蓬勃发展和互联网技术的深度渗透,游客获取景点信息、分享旅行体验的方式发生了根本性变革。传统的旅游信息平台多以官方介绍和商业推广为主,缺乏真实、多元、个性化的用户声音。游客在规划行程时,往往难以获得关于景点实时状况、真实体验、小众玩法的有效信息。
“景点印象”旨在构建一个以用户生成内容为核心的服务管理系统,让游客成为信息的创造者和分享者,通过真实的图文、视频、评分和评论,形成一个动态、鲜活、可信的景点知识库。
2.2 现实意义
- 对游客:提供决策依据,提升旅行体验,发现隐藏玩法,建立旅行社交圈。
- 对景点管理者:获取真实用户反馈,用于服务改进和营销决策;建立与游客的直接沟通渠道。
- 对旅游生态:沉淀非结构化的旅行数据,为行业分析、智能推荐、精准营销提供数据基础。
- 技术实践意义:本项目涵盖了Web全栈开发的典型技术栈,是学习Django、前后端分离、RESTful API设计、数据库优化、缓存、异步任务等技术的优秀实践案例。
三、 核心代码实现
3.1 数据模型设计 (models.py)
from django.db import models from django.contrib.auth.models import User class ScenicSpot(models.Model): """景点模型""" name = models.CharField(max_length=200, verbose_name='景点名称') description = models.TextField(verbose_name='景点描述') location = models.CharField(max_length=500, verbose_name='地理位置') latitude = models.FloatField(null=True, blank=True, verbose_name='纬度') longitude = models.FloatField(null=True, blank=True, verbose_name='经度') cover_image = models.ImageField(upload_to='spots/covers/', verbose_name='封面图') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: verbose_name = '景点' verbose_name_plural = '景点' def __str__(self): return self.name class Impression(models.Model): """印象(用户评价)模型""" RATING_CHOICES = [ (1, '⭐'), (2, '⭐⭐'), (3, '⭐⭐⭐'), (4, '⭐⭐⭐⭐'), (5, '⭐⭐⭐⭐⭐'), ] spot = models.ForeignKey(ScenicSpot, on_delete=models.CASCADE, related_name='impressions') author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='impressions') title = models.CharField(max_length=200, verbose_name='印象标题') content = models.TextField(verbose_name='印象内容') rating = models.IntegerField(choices=RATING_CHOICES, verbose_name='评分') visit_date = models.DateField(verbose_name='到访日期') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: verbose_name = '景点印象' verbose_name_plural = '景点印象' ordering = ['-created_at'] def __str__(self): return f"{self.spot.name} - {self.title}" class ImpressionImage(models.Model): """印象图片模型""" impression = models.ForeignKey(Impression, on_delete=models.CASCADE, related_name='images') image = models.ImageField(upload_to='impressions/images/') uploaded_at = models.DateTimeField(auto_now_add=True) class Comment(models.Model): """评论模型""" impression = models.ForeignKey(Impression, on_delete=models.CASCADE, related_name='comments') author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='comments') content = models.TextField(verbose_name='评论内容') created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['created_at']3.2 序列化器 (serializers.py)
from rest_framework import serializers from .models import ScenicSpot, Impression, ImpressionImage, Comment from django.contrib.auth.models import User class UserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ['id', 'username', 'email'] class ImpressionImageSerializer(serializers.ModelSerializer): class Meta: model = ImpressionImage fields = ['id', 'image', 'uploaded_at'] class CommentSerializer(serializers.ModelSerializer): author = UserSerializer(read_only=True) class Meta: model = Comment fields = ['id', 'author', 'content', 'created_at'] read_only_fields = ['author', 'created_at'] class ImpressionSerializer(serializers.ModelSerializer): author = UserSerializer(read_only=True) images = ImpressionImageSerializer(many=True, read_only=True) comments = CommentSerializer(many=True, read_only=True) spot_name = serializers.CharField(source='spot.name', read_only=True) class Meta: model = Impression fields = ['id', 'spot', 'spot_name', 'author', 'title', 'content', 'rating', 'visit_date', 'images', 'comments', 'created_at', 'updated_at'] read_only_fields = ['author', 'created_at', 'updated_at'] class ScenicSpotSerializer(serializers.ModelSerializer): # 计算平均评分 average_rating = serializers.SerializerMethodField() # 关联的印象列表(可控制数量) recent_impressions = serializers.SerializerMethodField() class Meta: model = ScenicSpot fields = ['id', 'name', 'description', 'location', 'latitude', 'longitude', 'cover_image', 'average_rating', 'recent_impressions', 'created_at'] def get_average_rating(self, obj): # 计算该景点的平均评分 impressions = obj.impressions.all() if impressions: total = sum(imp.rating for imp in impressions) return round(total / len(impressions), 1) return 0 def get_recent_impressions(self, obj): # 获取最近3条印象 impressions = obj.impressions.all().order_by('-created_at')[:3] return ImpressionSerializer(impressions, many=True).data3.3 视图集 (views.py)
from rest_framework import viewsets, permissions, filters, status from rest_framework.decorators import action from rest_framework.response import Response from django_filters.rest_framework import DjangoFilterBackend from .models import ScenicSpot, Impression, Comment from .serializers import ScenicSpotSerializer, ImpressionSerializer, CommentSerializer from .permissions import IsOwnerOrReadOnly class ScenicSpotViewSet(viewsets.ModelViewSet): """ 景点视图集:支持列表、详情、创建、更新、删除。 支持按名称、位置搜索,按评分排序。 """ queryset = ScenicSpot.objects.all() serializer_class = ScenicSpotSerializer filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filterset_fields = [] search_fields = ['name', 'description', 'location'] ordering_fields = ['average_rating', 'created_at'] ordering = ['-created_at'] # 默认按创建时间倒序 @action(detail=True, methods=['get']) def impressions(self, request, pk=None): """获取某个景点的所有印象""" spot = self.get_object() impressions = spot.impressions.all() page = self.paginate_queryset(impressions) if page is not None: serializer = ImpressionSerializer(page, many=True) return self.get_paginated_response(serializer.data) serializer = ImpressionSerializer(impressions, many=True) return Response(serializer.data) class ImpressionViewSet(viewsets.ModelViewSet): """ 印象视图集:用户可创建、查看、更新、删除自己的印象。 支持按景点、评分、日期过滤。 """ queryset = Impression.objects.all() serializer_class = ImpressionSerializer permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly] filter_backends = [DjangoFilterBackend, filters.OrderingFilter] filterset_fields = ['spot', 'rating', 'visit_date'] ordering_fields = ['created_at', 'rating', 'visit_date'] ordering = ['-created_at'] def perform_create(self, serializer): # 自动将当前用户设置为作者 serializer.save(author=self.request.user) @action(detail=True, methods=['post']) def add_comment(self, request, pk=None): """为某条印象添加评论""" impression = self.get_object() serializer = CommentSerializer(data=request.data) if serializer.is_valid(): serializer.save(impression=impression, author=request.user) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class CommentViewSet(viewsets.ModelViewSet): """ 评论视图集:用户可对印象发表评论,或删除自己的评论。 """ queryset = Comment.objects.all() serializer_class = CommentSerializer permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly] def perform_create(self, serializer): serializer.save(author=self.request.user)3.4 权限控制 (permissions.py)
from rest_framework import permissions class IsOwnerOrReadOnly(permissions.BasePermission): """ 自定义权限:只允许对象的所有者对其进行编辑,其他用户只有读取权限。 """ def has_object_permission(self, request, view, obj): # 读取权限允许任何请求 (GET, HEAD, OPTIONS) if request.method in permissions.SAFE_METHODS: return True # 写入权限仅允许对象的所有者 # 假设对象有 `author` 字段 return obj.author == request.user四、 系统测试
4.1 测试策略与工具
- 单元测试:使用 Django 内置的
TestCase和APITestCase。 - API 接口测试:使用 Django REST Framework 的
APIClient。 - 前端组件测试:使用 Jest + Vue Test Utils (Vue) 或 Jest + React Testing Library (React)。
- 端到端测试:使用 Cypress 或 Playwright。
- 性能测试:使用 Locust 或 Apache JMeter。
4.2 后端单元测试示例 (tests.py)
from django.test import TestCase from django.contrib.auth.models import User from rest_framework.test import APITestCase, APIClient from rest_framework import status from .models import ScenicSpot, Impression class ScenicSpotModelTest(TestCase): """景点模型测试""" def setUp(self): self.spot = ScenicSpot.objects.create( name='测试景点', description='这是一个测试景点描述', location='测试地点', cover_image='spots/covers/test.jpg' ) def test_spot_creation(self): self.assertEqual(self.spot.name, '测试景点') self.assertTrue(isinstance(self.spot, ScenicSpot)) self.assertEqual(str(self.spot), '测试景点') class ImpressionAPITest(APITestCase): """印象API接口测试""" def setUp(self): self.user = User.objects.create_user(username='testuser', password='testpass123') self.client = APIClient() self.client.force_authenticate(user=self.user) self.spot = ScenicSpot.objects.create( name='API测试景点', description='描述', location='地点', cover_image='spots/covers/test.jpg' ) def test_create_impression(self): """测试创建印象""" url = '/api/impressions/' data = { 'spot': self.spot.id, 'title': '一次美好的体验', 'content': '这里风景很美,服务也很好。', 'rating': 5, 'visit_date': '2024-10-01' } response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(Impression.objects.count(), 1) self.assertEqual(Impression.objects.get().title, '一次美好的体验') self.assertEqual(Impression.objects.get().author, self.user) def test_get_impression_list(self): """测试获取印象列表""" # 先创建一条印象 Impression.objects.create( spot=self.spot, author=self.user, title='测试印象', content='内容', rating=4, visit_date='2024-10-01' ) url = '/api/impressions/' response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data['results']), 1) # 假设使用了分页 def test_unauthenticated_user_cannot_create(self): """测试未认证用户无法创建印象""" self.client.logout() # 退出登录 url = '/api/impressions/' data = { 'spot': self.spot.id, 'title': '匿名尝试', 'content': '应该失败', 'rating': 3, 'visit_date': '2024-10-01' } response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)4.3 前端组件测试示例 (Vue + Jest)
// ImpressionList.spec.js import { mount } from '@vue/test-utils' import ImpressionList from '@/components/ImpressionList.vue' import { createRouter, createWebHistory } from 'vue-router' describe('ImpressionList.vue', () => { it('渲染印象列表', async () => { const mockImpressions = [ { id: 1, title: '测试印象1', content: '内容1', rating: 5 }, { id: 2, title: '测试印象2', content: '内容2', rating: 4 } ] const wrapper = mount(ImpressionList, { props: { impressions: mockImpressions }, global: { plugins: [createRouter({ history: createWebHistory(), routes: [] })] } }) // 检查是否渲染了正确数量的印象项 const items = wrapper.findAll('.impression-item') expect(items).toHaveLength(2) // 检查标题是否正确显示 expect(wrapper.text()).toContain('测试印象1') expect(wrapper.text()).toContain('测试印象2') }) it('点击“查看更多”按钮触发事件', async () => { const mockImpressions = [{ id: 1, title: '测试', content: '内容', rating: 5 }] const wrapper = mount(ImpressionList, { props: { impressions: mockImpressions }, global: { plugins: [createRouter({ history: createWebHistory(), routes: [] })] } }) // 模拟按钮点击 await wrapper.find('.view-more-btn').trigger('click') // 检查是否触发了对应的事件 expect(wrapper.emitted('view-more')).toBeTruthy() })