123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218 |
- from django.utils import timezone
- from django.http import JsonResponse
- from rest_framework.views import APIView
- from rest_framework.response import Response
- from rest_framework import status
- from rest_framework.permissions import IsAuthenticated
- from rest_framework.authentication import TokenAuthentication
- from .models import City, Attraction, RedTourismPlan, RedTourismDayPlan, RedTourismSpot, UserCheckIn
- from .serializers import SaveRedTourismPlanSerializer, RedTourismRegenerateSerializer
- from django.core.paginator import Paginator, EmptyPage
- from django.db import transaction
- import logging
- import re
- from bs4 import BeautifulSoup
- import logging
- logger = logging.getLogger(__name__)
- from .serializers import (
- CitySerializer,
- AttractionSerializer,
- RedTourismPlanSerializer
- )
- import json
- import logging
- from django.core.exceptions import ValidationError
- from datetime import time
- import traceback
- from .services import MoonshotAIService
- from django.contrib.auth import get_user_model
- from django.views.decorators.csrf import csrf_exempt
- import requests
- import os
- from django.conf import settings
- User = get_user_model()
- # views.py
- logger = logging.getLogger(__name__)
- class CityListView(APIView):
- permission_classes = [] # 无需登录
- def get(self, request):
- try:
- # Return all cities instead of just hot ones
- cities = City.objects.all() # Changed from filter(is_hot=True)
- serializer = CitySerializer(cities, many=True)
- return Response({
- "status": "success",
- "data": serializer.data
- }, status=status.HTTP_200_OK)
- except Exception as e:
- return Response({
- "status": "error",
- "message": str(e)
- }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
- # views.py
- class RedTourismPlanView(APIView):
- """
- 红色旅游路线规划API - 生产环境稳定版
- 功能特性:
- 1. 完整的参数验证链
- 2. 智能坐标补全机制
- 3. 标准化的响应数据结构
- 4. 详细的错误追踪
- """
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- def post(self, request):
- print("=== 请求头 ===")
- print(request.headers)
- print("=== 用户信息 ===")
- print(request.user)
- print("=== 认证信息 ===")
- print(request.auth)
- if not request.user.is_authenticated:
- return Response({
- 'status': 'error',
- 'message': '用户未认证'
- }, status=status.HTTP_401_UNAUTHORIZED)
- try:
- # === 1. 参数验证 ===
- validated_data = self._validate_request(request)
- # === 2. 获取城市基准坐标 ===
- city_coords = self._get_city_coordinates(validated_data['city_ids'])
- # === 3. 调用AI服务生成计划 ===
- raw_plan = self._generate_plan(validated_data, city_coords)
- # === 4. 数据标准化处理 ===
- standardized_plan = self._standardize_plan(
- raw_plan,
- city_coords,
- validated_data['days']
- )
- return Response({
- 'status': 'success',
- 'data': standardized_plan,
- 'meta': {
- 'generated_at': timezone.now().isoformat(),
- 'coordinate_quality': self._get_coordinate_quality(raw_plan)
- }
- })
- except ValidationError as e:
- logger.warning(f"参数验证失败: {str(e)}")
- return Response({
- 'status': 'error',
- 'code': 'INVALID_INPUT',
- 'message': str(e)
- }, status=status.HTTP_400_BAD_REQUEST)
- except Exception as e:
- logger.error(f"服务器错误: {str(e)}", exc_info=True)
- return Response({
- 'status': 'error',
- 'code': 'SERVER_ERROR',
- 'message': '行程生成服务暂时不可用'
- }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
- # === 核心私有方法 ===
- def _validate_request(self, request):
- """多层参数验证"""
- data = request.data if hasattr(request, 'data') else {}
- # 基础类型检查
- if not isinstance(data.get('city_ids'), (list, str, int)):
- raise ValidationError("city_ids必须是数组、整数或逗号分隔字符串")
- if not isinstance(data.get('days'), int) or not 1 <= data['days'] <= 7:
- raise ValidationError("days必须是1-7的整数")
- # 转换city_ids为统一格式
- city_ids = data['city_ids']
- if isinstance(city_ids, int):
- city_ids = [city_ids]
- elif isinstance(city_ids, str):
- try:
- city_ids = [int(cid.strip()) for cid in city_ids.split(',')]
- except ValueError:
- raise ValidationError("city_ids包含非数字字符")
- # 检查城市是否存在
- valid_cities = City.objects.filter(id__in=city_ids).values_list('id', flat=True)
- if len(valid_cities) != len(city_ids):
- invalid_ids = set(city_ids) - set(valid_cities)
- raise ValidationError(f"无效的城市ID: {invalid_ids}")
- return {
- 'city_ids': city_ids,
- 'days': data['days'],
- 'interests': self._normalize_interests(data.get('interests', [])),
- 'transport': data.get('transport', 'public'),
- 'custom_requirements': data.get('custom_requirements', '')
- }
- def _normalize_interests(self, interests):
- """标准化兴趣标签"""
- if isinstance(interests, str):
- interests = [s.strip() for s in interests.split(',')]
- interests = list(set(interests)) # 去重
- if 'red-tourism' not in interests:
- interests.append('red-tourism')
- return interests
- def _get_city_coordinates(self, city_ids):
- """获取城市坐标映射表"""
- return {
- city.id: (float(city.latitude), float(city.longitude))
- for city in City.objects.filter(id__in=city_ids)
- if city.latitude and city.longitude
- }
- def _generate_plan(self, params, city_coords):
- """调用AI服务生成行程"""
- # 注入城市坐标信息
- params['city_coordinates'] = city_coords
- logger.info(f"生成请求参数: {params}")
- plan = MoonshotAIService.generate_travel_plan(params)
- if not plan or 'error' in plan:
- error_msg = plan.get('error', 'AI服务返回空结果')
- logger.error(f"AI生成失败: {error_msg}")
- raise Exception(error_msg)
- return plan
- def _standardize_plan(self, raw_plan, city_coords, days):
- """标准化响应数据结构"""
- # 基础信息
- result = {
- **raw_plan, # 保留所有原始数据
- 'statistics': {
- 'total_days': days,
- 'total_attractions': sum(len(day['attractions']) for day in raw_plan['days']),
- 'red_attractions': sum(
- 1 for day in raw_plan['days']
- for attr in day['attractions']
- if attr.get('is_red_tourism', True)
- )
- }
- }
- # 确保每个景点有坐标
- for day in result['days']:
- for attr in day['attractions']:
- if 'latitude' not in attr or 'longitude' not in attr:
- coord = self._resolve_coordinates(attr, city_coords)
- attr.update({
- 'latitude': coord['latitude'],
- 'longitude': coord['longitude'],
- '_coord_source': coord['source']
- })
- return result
- def _process_attraction(self, attraction, city_coords):
- """处理单个景点数据(核心坐标逻辑)"""
- # 基础信息
- processed = {
- 'id': attraction.get('id', 0),
- 'name': attraction.get('name', '红色教育基地'),
- 'image': attraction.get('image', '/images/default-red.jpg'),
- 'is_red_tourism': attraction.get('is_red_tourism', True),
- 'educational_value': attraction.get('educational_value', '高'),
- 'visit_time': attraction.get('visit_time', '09:00-17:00'),
- 'ticket_info': attraction.get('ticket_info', '免费'),
- 'address': attraction.get('address', ''),
- '_coord_source': 'exact' # 跟踪坐标来源
- }
- # 坐标处理(四层回退机制)
- coord = self._resolve_coordinates(attraction, city_coords)
- processed.update({
- 'latitude': coord['latitude'],
- 'longitude': coord['longitude'],
- '_coord_source': coord['source']
- })
- return processed
- def _resolve_coordinates(self, attraction, city_coords):
- """解析景点坐标(优先级从高到低)"""
- # 1. 直接坐标
- if all(k in attraction for k in ['latitude', 'longitude']):
- try:
- lat = float(attraction['latitude'])
- lng = float(attraction['longitude'])
- if -90 <= lat <= 90 and -180 <= lng <= 180:
- return {
- 'latitude': lat,
- 'longitude': lng,
- 'source': 'exact'
- }
- except (TypeError, ValueError):
- pass
- # 2. 通过city_id关联
- city_id = attraction.get('city_id')
- if city_id and city_id in city_coords:
- lat, lng = city_coords[city_id]
- return {
- 'latitude': lat,
- 'longitude': lng,
- 'source': 'city'
- }
- # 3. 从地址解析城市
- if 'address' in attraction:
- match = re.search(r'(.+?[市县区])', attraction['address'])
- if match:
- city_name = match.group(1)
- for cid, (lat, lng) in city_coords.items():
- if city_name in str(cid):
- return {
- 'latitude': lat,
- 'longitude': lng,
- 'source': 'parsed_city'
- }
- # 4. 回退到第一个城市
- if city_coords:
- lat, lng = next(iter(city_coords.values()))
- return {
- 'latitude': lat,
- 'longitude': lng,
- 'source': 'fallback_city'
- }
- # 5. 终极回退(北京坐标)
- return {
- 'latitude': 36.6667,
- 'longitude': 117.0000 ,
- 'source': 'default'
- }
- def _get_coordinate_quality(self, plan):
- """评估坐标数据质量"""
- sources = set()
- for day in plan.get('days', []):
- for attr in day.get('attractions', []):
- if '_coord_source' in attr:
- sources.add(attr['_coord_source'])
- if not sources:
- return 'unknown'
- elif 'exact' in sources:
- return 'high'
- elif 'city' in sources:
- return 'medium'
- else:
- return 'low'
- class RedTourismRegenerateView(APIView):
- """
- 重新规划红色旅游行程API
- """
- def post(self, request):
- try:
- # 使用新的序列化器
- serializer = RedTourismRegenerateSerializer(data=request.data)
- if not serializer.is_valid():
- return Response({
- 'status': 'error',
- 'message': '输入数据验证失败',
- 'errors': serializer.errors
- }, status=status.HTTP_400_BAD_REQUEST)
- data = serializer.validated_data
- # 调用重新规划服务
- result = MoonshotAIService.regenerate_red_tourism_plan({
- 'plan_id': data.get('plan_id'),
- 'city_ids': data.get('city_ids', []),
- 'days': data.get('days', 3),
- 'interests': data.get('interests', []),
- 'transport': data.get('transport', 'public'),
- 'custom_requirements': data.get('custom_requirements', ''),
- 'previous_plan': data.get('previous_plan', {})
- })
- if 'error' in result:
- return Response({
- 'status': 'error',
- 'message': result['error']
- }, status=status.HTTP_400_BAD_REQUEST)
- return Response({
- 'status': 'success',
- 'data': result
- }, status=status.HTTP_200_OK)
- except Exception as e:
- logger.error(f"重新规划失败: {str(e)}", exc_info=True)
- return Response({
- 'status': 'error',
- 'message': f'重新规划失败: {str(e)}'
- }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
- class SaveRedTourismPlanView(APIView):
- """
- 保存红色旅游行程API
- 功能:
- 1. 保存完整的行程数据
- 2. 关联城市信息
- 3. 创建每日行程和景点
- 4. 事务处理确保数据一致性
- """
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- def post(self, request):
- # 1. 数据验证
- serializer = SaveRedTourismPlanSerializer(data=request.data)
- if not serializer.is_valid():
- logger.warning(f"保存行程数据验证失败: {serializer.errors}")
- return Response(
- {
- 'status': 'error',
- 'message': '数据验证失败',
- 'errors': serializer.errors
- },
- status=status.HTTP_400_BAD_REQUEST
- )
- plan_data = serializer.validated_data['plan_data']
- city_ids = serializer.validated_data['city_ids']
- try:
- with transaction.atomic():
- # 2. 验证城市是否存在
- cities = City.objects.filter(id__in=city_ids)
- if len(cities) != len(city_ids):
- invalid_ids = set(city_ids) - {city.id for city in cities}
- logger.warning(f"无效的城市ID: {invalid_ids}")
- return Response(
- {
- 'status': 'error',
- 'message': f'无效的城市ID: {invalid_ids}'
- },
- status=status.HTTP_400_BAD_REQUEST
- )
- # 3. 创建主行程
- plan = RedTourismPlan.objects.create(
- user=request.user,
- title=plan_data.get('title', '红色旅游行程'),
- description=plan_data.get('description', ''),
- days=len(plan_data.get('days', [])),
- statistics=plan_data.get('statistics', {}),
- education_goals=plan_data.get('education_goals', []),
- original_data=plan_data
- )
- # 4. 关联城市
- plan.cities.set(cities)
- # 5. 创建每日行程和景点
- for day_idx, day_data in enumerate(plan_data.get('days', []), start=1):
- day_plan = RedTourismDayPlan.objects.create(
- plan=plan,
- day_number=day_data.get('day', day_idx),
- theme=day_data.get('theme', '红色教育'),
- summary=day_data.get('summary', ''),
- transport=day_data.get('transport', 'public'),
- travel_tips=day_data.get('travel_tips', [])
- )
- # 处理景点
- for spot_idx, spot_data in enumerate(day_data.get('attractions', []), start=1):
- # 确保有location数据
- location = spot_data.get('location', {})
- if not location:
- location = {
- 'latitude': spot_data.get('latitude', 0),
- 'longitude': spot_data.get('longitude', 0)
- }
- RedTourismSpot.objects.create(
- day_plan=day_plan,
- spot_id=spot_data.get('id', f'spot_{day_plan.id}_{spot_idx}'),
- name=spot_data.get('name', '红色教育基地'),
- description=spot_data.get('description', ''),
- address=spot_data.get('address', ''),
- open_hours=spot_data.get('open_hours', '09:00-17:00'),
- ticket_info=spot_data.get('ticket_info', '免费'),
- history_significance=spot_data.get('history_significance', ''),
- image=spot_data.get('image', '/static/images/default-red.jpg'),
- latitude=location.get('latitude', 0),
- longitude=location.get('longitude', 0),
- visit_time=spot_data.get('visit_time', '09:00-11:00'),
- duration=spot_data.get('duration', 120),
- order=spot_idx,
- visiting_etiquette=spot_data.get('visiting_etiquette', []),
- is_red_tourism=spot_data.get('is_red_tourism', True)
- )
- logger.info(f"成功保存行程: {plan.id}, 关联城市: {city_ids}")
- return Response({
- 'status': 'success',
- 'message': '行程保存成功',
- 'data': {
- 'plan_id': plan.id,
- 'title': plan.title,
- 'days': plan.days,
- 'cities': [{'id': c.id, 'name': c.name} for c in cities]
- }
- }, status=status.HTTP_201_CREATED)
- except Exception as e:
- logger.error(f"保存行程失败: {str(e)}", exc_info=True)
- return Response(
- {
- 'status': 'error',
- 'message': '保存行程失败,请稍后重试',
- 'error_detail': str(e)
- },
- status=status.HTTP_500_INTERNAL_SERVER_ERROR
- )
- AMAP_KEY = "ad2afc314b741d5520274e419fda8655"
- @csrf_exempt
- def get_attraction_image(request):
- if request.method == 'GET':
- attraction_name = request.GET.get('name', '')
- city = request.GET.get('city', '')
- if not attraction_name:
- return JsonResponse({'status': 'error', 'message': '景点名称不能为空'})
- # 处理城市参数
- if city in ['undefined', '']:
- if '市' in attraction_name:
- city = attraction_name.split('市')[0] + '市'
- else:
- city = '济南'
- # 1. 首先尝试高德API
- amap_result = get_image_from_amap(attraction_name, city)
- if amap_result['status'] == 'success':
- return JsonResponse(amap_result)
- # 2. 高德失败后尝试Bing搜索
- bing_result = get_image_from_bing(attraction_name)
- if bing_result['status'] == 'success':
- return JsonResponse(bing_result)
- # 3. 全部失败返回错误
- return JsonResponse({
- 'status': 'error',
- 'message': f'无法获取该景点图片 (尝试城市: {city})',
- 'image_url': '/static/images/default-attraction.jpg' # 提供默认图片
- })
- return JsonResponse({'status': 'error', 'message': '无效的请求方法'})
- def get_image_from_amap(name, city):
- """从高德地图获取图片"""
- base_url = "https://restapi.amap.com/v3/place/text"
- params = {
- "key": AMAP_KEY,
- "keywords": name,
- "city": city,
- "citylimit": "true",
- "extensions": "all",
- "output": "JSON"
- }
- try:
- response = requests.get(base_url, params=params, timeout=10)
- data = response.json()
- if data.get("status") == "1" and int(data.get("count", 0)) > 0:
- for poi in data["pois"]:
- if name in poi["name"]:
- photos = poi.get("photos", [])
- if photos:
- return {
- 'status': 'success',
- 'image_url': photos[0].get("url", ""),
- 'source': 'amap',
- 'searched_city': city
- }
- return {'status': 'error'}
- except Exception:
- return {'status': 'error'}
- def get_image_from_bing(keyword):
- """从Bing获取图片"""
- url = f"https://www.bing.com/images/search?q={keyword}"
- headers = {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
- }
- try:
- response = requests.get(url, headers=headers, timeout=10)
- soup = BeautifulSoup(response.text, 'html.parser')
- # 尝试获取高质量图片
- for img in soup.find_all('img', {'class': 'mimg'}):
- if 'src' in img.attrs and img['src'].startswith('http'):
- return {
- 'status': 'success',
- 'image_url': img['src'],
- 'source': 'bing'
- }
- # 如果找不到,尝试其他选择方式
- pattern = re.compile(r'murl":"(http[^"]+)"')
- matches = pattern.findall(response.text)
- if matches:
- # 确保URL是可直接访问的
- image_url = matches[0].replace('\\', '')
- if not image_url.startswith('http'):
- image_url = 'https:' + image_url
- return {
- 'status': 'success',
- 'image_url': image_url,
- 'source': 'bing'
- }
- return {'status': 'error'}
- except Exception as e:
- print(f"Bing搜索出错: {e}")
- return {'status': 'error'}
- class UserPlansView(APIView):
- """
- 获取用户行程列表API
- """
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- def get(self, request):
- try:
- # 获取查询参数
- page = request.query_params.get('page', 1)
- page_size = request.query_params.get('page_size', 5)
- status_filter = request.query_params.get('status', 'all') # all, in_progress, completed
- limit = request.query_params.get('limit') # 限制返回数量
- # 获取用户所有行程,按创建时间降序排列
- queryset = RedTourismPlan.objects.filter(user=request.user).prefetch_related('cities').order_by(
- '-created_at')
- # 根据状态筛选
- if status_filter != 'all':
- queryset = queryset.filter(status=status_filter)
- # 如果有limit参数,直接返回限制数量的结果
- if limit:
- try:
- limit = int(limit)
- plans = queryset[:limit]
- serializer = RedTourismPlanSerializer(plans, many=True)
- return Response({
- 'status': 'success',
- 'data': {
- 'plans': serializer.data,
- 'has_more': queryset.count() > limit
- }
- })
- except ValueError:
- pass
- # 分页处理
- paginator = Paginator(queryset, page_size)
- try:
- plans = paginator.page(page)
- except EmptyPage:
- plans = paginator.page(paginator.num_pages)
- serializer = RedTourismPlanSerializer(plans, many=True)
- return Response({
- 'status': 'success',
- 'data': {
- 'plans': serializer.data,
- 'pagination': {
- 'current_page': plans.number,
- 'total_pages': paginator.num_pages,
- 'total_items': paginator.count,
- 'has_next': plans.has_next(),
- 'has_previous': plans.has_previous()
- }
- }
- })
- except Exception as e:
- logger.error(f"获取用户行程失败: {str(e)}", exc_info=True)
- return Response({
- 'status': 'error',
- 'message': '获取行程列表失败'
- }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
- # 添加完成行程的API
- class CompletePlanView(APIView):
- """
- 标记行程为已完成
- """
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- def post(self, request, plan_id):
- try:
- plan = RedTourismPlan.objects.get(id=plan_id, user=request.user)
- plan.status = 'completed'
- plan.save()
- return Response({
- 'status': 'success',
- 'message': '行程已标记为已完成'
- })
- except RedTourismPlan.DoesNotExist:
- return Response({
- 'status': 'error',
- 'message': '行程不存在或无权访问'
- }, status=status.HTTP_404_NOT_FOUND)
- except Exception as e:
- logger.error(f"标记行程完成失败: {str(e)}", exc_info=True)
- return Response({
- 'status': 'error',
- 'message': '标记行程完成失败'
- }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
- class UserCheckInView(APIView):
- """
- 用户打卡接口(包含创建和更新)
- 功能:
- - 创建新打卡记录
- - 更新已有打卡记录
- - 自动更新景点打卡状态
- - 更新行程统计信息
- """
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- def _update_plan_stats(self, plan):
- """更新行程统计信息(完成率和已打卡景点数)"""
- try:
- # 获取行程的所有景点
- spots = RedTourismSpot.objects.filter(day_plan__plan=plan)
- total_spots = spots.count()
- checked_spots = spots.filter(has_checked=True).count()
- # 计算完成率
- completion_rate = round((checked_spots / total_spots * 100), 2) if total_spots > 0 else 0
- # 更新行程统计信息
- if not hasattr(plan, 'statistics'):
- plan.statistics = {}
- plan.statistics.update({
- 'completion_rate': completion_rate,
- 'checked_spots': checked_spots,
- 'last_checkin_time': datetime.now().isoformat()
- })
- plan.save(update_fields=['statistics', 'last_updated'])
- logger.info(f"更新行程统计成功 - 计划ID:{plan.id} 完成率:{completion_rate}%")
- except Exception as e:
- logger.error(f"更新行程统计失败: {str(e)}", exc_info=True)
- raise
- def post(self, request):
- """
- 创建或更新打卡记录
- 请求参数:
- - spot_id (必填): 景点ID
- - plan_id (必填): 行程ID
- - image_url: 打卡图片URL
- - note: 打卡文案
- """
- # 打印原始请求数据用于调试
- logger.debug(f"打卡请求数据: {request.data}")
- try:
- # 获取请求数据(兼容form-data和json)
- spot_id = request.data.get('spot_id') or request.POST.get('spot_id')
- plan_id = request.data.get('plan_id') or request.POST.get('plan_id')
- image_url = request.data.get('image_url', '') or request.POST.get('image_url', '')
- note = request.data.get('note', '') or request.POST.get('note', '')
- # 验证必要参数
- if not spot_id or not plan_id:
- return Response({
- 'status': 'error',
- 'message': '缺少spot_id或plan_id参数',
- 'required_fields': ['spot_id', 'plan_id']
- }, status=status.HTTP_400_BAD_REQUEST)
- # 获取景点和行程
- try:
- spot = RedTourismSpot.objects.get(id=spot_id)
- plan = RedTourismPlan.objects.get(id=plan_id, user=request.user)
- except RedTourismSpot.DoesNotExist:
- return Response({
- 'status': 'error',
- 'message': '景点不存在',
- 'spot_id': spot_id
- }, status=status.HTTP_404_NOT_FOUND)
- except RedTourismPlan.DoesNotExist:
- return Response({
- 'status': 'error',
- 'message': '行程不存在或无权访问',
- 'plan_id': plan_id
- }, status=status.HTTP_403_FORBIDDEN)
- # 验证景点是否属于该行程
- if spot.day_plan.plan_id != plan.id:
- return Response({
- 'status': 'error',
- 'message': '景点不属于当前行程',
- 'detail': f'景点计划ID:{spot.day_plan.plan_id} ≠ 当前计划ID:{plan.id}'
- }, status=status.HTTP_400_BAD_REQUEST)
- # 检查是否已有打卡记录
- existing_checkin = UserCheckIn.objects.filter(
- user=request.user,
- spot=spot,
- plan=plan
- ).order_by('-check_in_time').first()
- with transaction.atomic():
- if existing_checkin:
- # 更新已有打卡记录
- existing_checkin.image_url = image_url
- existing_checkin.note = note
- existing_checkin.save()
- checkin = existing_checkin
- action = 'updated'
- else:
- # 创建新打卡记录
- checkin = UserCheckIn.objects.create(
- user=request.user,
- spot=spot,
- plan=plan,
- image_url=image_url,
- note=note,
- location={
- 'latitude': spot.latitude,
- 'longitude': spot.longitude,
- 'address': spot.address
- }
- )
- action = 'created'
- # 更新景点状态(无论新建还是更新都刷新)
- spot.has_checked = True
- spot.checkin_images = [image_url] # 只保留最新一张图片
- spot.checkin_note = note
- spot.save()
- # 更新计划统计
- self._update_plan_stats(plan)
- logger.info(f"打卡记录{action}成功 - 用户:{request.user.id} 景点:{spot.id}")
- return Response({
- 'status': 'success',
- 'message': f'打卡{"更新" if action == "updated" else ""}成功',
- 'data': {
- 'checkin_id': checkin.id,
- 'spot_id': spot.id,
- 'spot_name': spot.name,
- 'image_url': image_url,
- 'note': note,
- 'check_in_time': checkin.check_in_time,
- 'is_new': action == 'created',
- 'completion_rate': plan.statistics.get('completion_rate', 0)
- }
- }, status=status.HTTP_200_OK)
- except ValidationError as e:
- logger.warning(f"参数验证失败: {str(e)}")
- return Response({
- 'status': 'error',
- 'message': str(e),
- 'code': 'VALIDATION_ERROR'
- }, status=status.HTTP_400_BAD_REQUEST)
- except Exception as e:
- logger.error(f"打卡处理失败: {str(e)}", exc_info=True)
- return Response({
- 'status': 'error',
- 'message': '服务器处理打卡请求失败',
- 'code': 'SERVER_ERROR',
- 'error_detail': str(e)
- }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
- class UserCheckInListView(APIView):
- """
- 用户打卡记录查询
- 功能:
- - 分页查询
- - 按行程筛选
- - 返回打卡详情和图片
- """
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- def get(self, request):
- # 获取查询参数
- page = int(request.query_params.get('page', 1))
- page_size = int(request.query_params.get('page_size', 5))
- plan_id = request.query_params.get('plan_id')
- # 构建查询集
- queryset = UserCheckIn.objects.filter(
- user=request.user
- ).select_related('spot', 'plan').order_by('-check_in_time')
- # 按行程筛选
- if plan_id:
- queryset = queryset.filter(plan_id=plan_id)
- # 分页处理
- paginator = Paginator(queryset, page_size)
- try:
- checkins = paginator.page(page)
- except EmptyPage:
- checkins = paginator.page(paginator.num_pages)
- # 序列化数据
- data = []
- for checkin in checkins:
- data.append({
- 'id': checkin.id,
- 'plan_id': checkin.plan.id,
- 'plan_title': checkin.plan.title,
- 'spot_id': checkin.spot.id,
- 'spot_name': checkin.spot.name,
- 'day_number': checkin.spot.day_plan.day_number,
- 'checkin_time': checkin.check_in_time,
- 'image_url': checkin.image_url,
- 'note': checkin.note, # 新增返回文案
- 'address': checkin.spot.address
- })
- return Response({
- 'status': 'success',
- 'data': {
- 'checkins': data,
- 'pagination': {
- 'current_page': page,
- 'total_pages': paginator.num_pages,
- 'total_items': paginator.count
- }
- }
- })
- # views.py
- from django.core.files.storage import default_storage
- from django.core.files.base import ContentFile
- import os
- import uuid
- from datetime import datetime
- class CheckInImageUploadView(APIView):
- """
- 打卡图片上传接口
- 功能:
- - 支持JPG/PNG格式
- - 限制5MB大小
- - 按日期分类存储
- - 返回可访问的图片URL
- """
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- def post(self, request):
- # 验证图片文件是否存在
- if 'image' not in request.FILES:
- return Response({
- 'status': 'error',
- 'message': '请选择要上传的图片文件'
- }, status=status.HTTP_400_BAD_REQUEST)
- image_file = request.FILES['image']
- # 验证文件类型
- allowed_types = ['image/jpeg', 'image/png']
- if image_file.content_type not in allowed_types:
- return Response({
- 'status': 'error',
- 'message': '仅支持JPEG和PNG格式图片'
- }, status=status.HTTP_400_BAD_REQUEST)
- # 验证文件大小(5MB限制)
- max_size = 5 * 1024 * 1024 # 5MB
- if image_file.size > max_size:
- return Response({
- 'status': 'error',
- 'message': '图片大小不能超过5MB'
- }, status=status.HTTP_400_BAD_REQUEST)
- try:
- # 生成唯一文件名:用户ID_时间戳_随机码.扩展名
- file_ext = os.path.splitext(image_file.name)[1]
- filename = f"{request.user.id}_{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:6]}{file_ext}"
- # 按日期存储路径:checkins/年/月/日/
- save_path = os.path.join(
- 'checkins',
- datetime.now().strftime('%Y'),
- datetime.now().strftime('%m'),
- datetime.now().strftime('%d'),
- filename
- )
- # 保存文件
- file_path = default_storage.save(save_path, ContentFile(image_file.read()))
- # 返回完整的访问URL
- image_url = request.build_absolute_uri(settings.MEDIA_URL + file_path)
- return Response({
- 'status': 'success',
- 'data': {
- 'image_url': image_url,
- 'path': file_path # 存储路径
- }
- }, status=status.HTTP_201_CREATED)
- except Exception as e:
- logger.error(f"图片上传失败: {str(e)}")
- return Response({
- 'status': 'error',
- 'message': '图片上传失败,请稍后重试'
- }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
- class UserPlanDetailView(APIView):
- """
- 获取用户单个行程详情API
- """
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- def get(self, request, plan_id):
- try:
- plan = RedTourismPlan.objects.get(
- id=plan_id,
- user=request.user
- )
- serializer = RedTourismPlanSerializer(plan)
- return Response({
- 'status': 'success',
- 'data': {
- 'plan': serializer.data
- }
- })
- except RedTourismPlan.DoesNotExist:
- return Response({
- 'status': 'error',
- 'message': '行程不存在或无权访问'
- }, status=status.HTTP_404_NOT_FOUND)
- except Exception as e:
- logger.error(f"获取行程详情失败: {str(e)}", exc_info=True)
- return Response({
- 'status': 'error',
- 'message': '获取行程详情失败'
- }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
- class UserCheckInUpdateView(APIView):
- """
- 更新用户打卡记录
- 功能:
- - 更新已有打卡记录
- - 同步更新景点状态
- - 更新行程统计信息
- """
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- def _update_plan_stats(self, plan):
- """更新行程统计信息(完成率和已打卡景点数)"""
- try:
- # 获取行程的所有景点
- spots = RedTourismSpot.objects.filter(day_plan__plan=plan)
- total_spots = spots.count()
- checked_spots = spots.filter(has_checked=True).count()
- # 计算完成率
- completion_rate = round((checked_spots / total_spots * 100), 2) if total_spots > 0 else 0
- # 更新行程统计信息
- if not hasattr(plan, 'statistics'):
- plan.statistics = {}
- plan.statistics.update({
- 'completion_rate': completion_rate,
- 'checked_spots': checked_spots,
- 'last_checkin_time': datetime.now().isoformat()
- })
- plan.save(update_fields=['statistics', 'last_updated'])
- logger.info(f"更新行程统计成功 - 计划ID:{plan.id} 完成率:{completion_rate}%")
- except Exception as e:
- logger.error(f"更新行程统计失败: {str(e)}", exc_info=True)
- raise
- def put(self, request):
- """
- 更新打卡记录
- 请求参数:
- - spot_id (必填): 景点ID
- - plan_id (必填): 行程ID
- - image_url: 打卡图片URL
- - note: 打卡文案
- """
- try:
- # 获取请求数据
- spot_id = request.data.get('spot_id')
- plan_id = request.data.get('plan_id')
- image_url = request.data.get('image_url', '')
- note = request.data.get('note', '')
- # 验证必要参数
- if not spot_id or not plan_id:
- return Response({
- 'status': 'error',
- 'message': '缺少spot_id或plan_id参数'
- }, status=status.HTTP_400_BAD_REQUEST)
- # 获取打卡记录(获取最新的一条)
- checkin = UserCheckIn.objects.filter(
- user=request.user,
- spot_id=spot_id,
- plan_id=plan_id
- ).order_by('-check_in_time').first()
- if not checkin:
- return Response({
- 'status': 'error',
- 'message': '未找到打卡记录'
- }, status=status.HTTP_404_NOT_FOUND)
- # 获取关联的景点和行程
- spot = checkin.spot
- plan = checkin.plan
- with transaction.atomic():
- # 更新打卡记录
- checkin.image_url = image_url
- checkin.note = note
- checkin.save()
- # 更新景点状态
- spot.has_checked = True
- spot.checkin_images = [image_url] # 只保留最新一张图片
- spot.checkin_note = note
- spot.save()
- # 更新计划统计
- self._update_plan_stats(plan)
- logger.info(f"打卡记录更新成功 - 用户:{request.user.id} 景点:{spot.id}")
- return Response({
- 'status': 'success',
- 'message': '打卡更新成功',
- 'data': {
- 'checkin_id': checkin.id,
- 'spot_id': spot.id,
- 'spot_name': spot.name,
- 'image_url': image_url,
- 'note': note,
- 'check_in_time': checkin.check_in_time,
- 'completion_rate': plan.statistics.get('completion_rate', 0)
- }
- })
- except Exception as e:
- logger.error(f"更新打卡失败: {str(e)}", exc_info=True)
- return Response({
- 'status': 'error',
- 'message': '更新打卡失败',
- 'error_detail': str(e)
- }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
- class UserSpotCheckinsView(APIView):
- """
- 获取用户某个景点的所有打卡记录
- """
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- def get(self, request):
- spot_id = request.query_params.get('spot_id')
- plan_id = request.query_params.get('plan_id')
- if not spot_id or not plan_id:
- return Response({
- 'status': 'error',
- 'message': '缺少spot_id或plan_id参数'
- }, status=status.HTTP_400_BAD_REQUEST)
- try:
- # 确保只返回当前景点的打卡记录
- checkins = UserCheckIn.objects.filter(
- user=request.user,
- spot_id=spot_id,
- plan_id=plan_id
- ).order_by('-check_in_time')
- data = [{
- 'id': c.id,
- 'spot_id': c.spot_id,
- 'plan_id': c.plan_id,
- 'image_url': c.image_url,
- 'note': c.note,
- 'check_in_time': c.check_in_time.isoformat()
- } for c in checkins]
- return Response({
- 'status': 'success',
- 'data': {
- 'checkins': data
- }
- })
- except Exception as e:
- logger.error(f"获取打卡记录失败: {str(e)}")
- return Response({
- 'status': 'error',
- 'message': '获取打卡记录失败'
- }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|