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)