from rest_framework import viewsets, status, mixins, parsers, filters from django_filters.rest_framework import DjangoFilterBackend from rest_framework.decorators import action from rest_framework.response import Response from rest_framework import serializers, permissions from django.core.signing import TimestampSigner, BadSignature, SignatureExpired from django.utils import timezone from django.db import models from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes from shop.models import WeChatUser, Order from shop.views import get_wechat_pay_client from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer, AnnouncementSerializer, AdminActivitySerializer, AdminTopicSerializer from .utils import get_current_wechat_user from .permissions import IsAuthorOrReadOnly class ActivityViewSet(viewsets.ReadOnlyModelViewSet): """ 社区活动接口 """ queryset = Activity.objects.filter(is_active=True).order_by('-created_at') serializer_class = ActivitySerializer def get_queryset(self): qs = super().get_queryset() # list 接口过滤 is_visible=True if self.action == 'list': qs = qs.filter(is_visible=True) return qs def retrieve(self, request, *args, **kwargs): instance = self.get_object() # Sync status for current user user = get_current_wechat_user(request) if user: # Use filter to avoid exception if multiple exist (though unique_together constraint exists) signup = instance.signups.filter(user=user).exclude(status='cancelled').first() if signup: has_changed = signup.check_payment_status() if has_changed: print(f"DEBUG: Synced signup status for user {user.id} activity {instance.id}") serializer = self.get_serializer(instance) # Debug print to verify data print(f"DEBUG: Activity {instance.title} current_signups: {instance.current_signups}") return Response(serializer.data) @extend_schema(summary="报名活动") @action(detail=True, methods=['post']) def signup(self, request, pk=None): user = get_current_wechat_user(request) if not user: return Response({'error': '请先登录'}, status=401) activity = self.get_object() # 1. Check confirmed signup if ActivitySignup.objects.filter(activity=activity, user=user, status='confirmed').exists(): return Response({'error': '您已报名该活动'}, status=400) # 2. Get pending signup (for retry) pending_signup = ActivitySignup.objects.filter(activity=activity, user=user, status='pending').first() # 3. Check limit (exclude cancelled, exclude current pending) query = activity.signups.exclude(status='cancelled') if pending_signup: query = query.exclude(id=pending_signup.id) if query.count() >= activity.max_participants: return Response({'error': '活动名额已满'}, status=400) # Get signup info signup_info = request.data.get('signup_info', {}) # Validate signup info effective_config = activity.signup_form_config if not effective_config: effective_config = [] if activity.ask_name: effective_config.append({"name": "name", "label": "姓名", "type": "text", "required": True}) if activity.ask_phone: effective_config.append({"name": "phone", "label": "手机号", "type": "number", "required": True}) if activity.ask_wechat: effective_config.append({"name": "wechat", "label": "微信号", "type": "text", "required": True}) if activity.ask_company: effective_config.append({"name": "company", "label": "公司/机构", "type": "text", "required": False}) if effective_config: required_fields = [f['name'] for f in effective_config if f.get('required')] for field in required_fields: val = signup_info.get(field) if val is None or (isinstance(val, str) and not val.strip()): label = next((f['label'] for f in effective_config if f['name'] == field), field) return Response({'error': f'请填写: {label}'}, status=400) # Handle Payment Logic if activity.is_paid and activity.price > 0: import time from wechatpayv3 import WeChatPayType # Create or Get Order order = None if pending_signup and pending_signup.order: # Reuse existing order if it's pending if pending_signup.order.status == 'pending': order = pending_signup.order # Update contact info if needed contact_name = signup_info.get('name') or signup_info.get('participant_name') or user.nickname or 'Activity User' contact_phone = signup_info.get('phone') or user.phone_number or '' if contact_name: order.customer_name = contact_name if contact_phone: order.phone_number = contact_phone # Ensure activity is linked if not order.activity: order.activity = activity order.save() if not order: # Check independent pending order pending_order = Order.objects.filter( wechat_user=user, activity=activity, status='pending' ).first() if pending_order: order = pending_order # Ensure shipping address is up-to-date order.shipping_address = activity.location or '线下活动' order.save() else: contact_name = signup_info.get('name') or signup_info.get('participant_name') or user.nickname or 'Activity User' contact_phone = signup_info.get('phone') or user.phone_number or '' order = Order.objects.create( wechat_user=user, activity=activity, total_price=activity.price, status='pending', quantity=1, customer_name=contact_name, phone_number=contact_phone, shipping_address=activity.location or '线下活动', ) # Generate Pay Code out_trade_no = f"ACT{activity.id}U{user.id}T{int(time.time())}" order.out_trade_no = out_trade_no order.save() wxpay, error_msg = get_wechat_pay_client(pay_type=WeChatPayType.NATIVE) if not wxpay: return Response({'error': f'支付配置错误: {error_msg}'}, status=500) code, message = wxpay.pay( description=f"报名活动: {activity.title}", out_trade_no=out_trade_no, amount={ 'total': int(activity.price * 100), 'currency': 'CNY' }, notify_url=wxpay._notify_url, attach=f'{{"type":"activity","activity_id":{activity.id}}}' ) import json result = json.loads(message) if code in range(200, 300): code_url = result.get('code_url') if pending_signup: pending_signup.signup_info = signup_info pending_signup.order = order pending_signup.status = 'unpaid' # Explicitly set to unpaid pending_signup.save() else: ActivitySignup.objects.create( activity=activity, user=user, signup_info=signup_info, status='unpaid', order=order ) return Response({ 'payment_required': True, 'code_url': code_url, 'order_id': order.id, 'price': activity.price, 'message': '请完成支付' }, status=200) else: return Response({'error': '支付接口调用失败', 'detail': result}, status=500) # Free Activity Signup # Check auto_confirm status_val = 'confirmed' if activity.auto_confirm else 'pending' signup = ActivitySignup.objects.create( activity=activity, user=user, signup_info=signup_info, status=status_val ) # Send SMS for free activity signup (if confirmed) if status_val == 'confirmed': try: from shop.sms_utils import notify_user_activity_signup_success # Mock an order object for the SMS template # The template expects: customer_name, wechat_user, phone_number class MockOrder: def __init__(self, user, signup_info): # Ensure we get the name and phone from signup_info first # signup_info keys might vary, let's try common ones self.customer_name = signup_info.get('name') or signup_info.get('username') or user.nickname or "用户" self.wechat_user = user self.phone_number = signup_info.get('phone') or signup_info.get('mobile') or user.phone_number or "" mock_order = MockOrder(user, signup_info) # Check if we have a valid phone number before sending if mock_order.phone_number: notify_user_activity_signup_success(mock_order, signup) else: print(f"Skipping SMS for signup {signup.id}: No phone number found") except Exception as e: print(f"发送免费活动报名短信失败: {str(e)}") serializer = ActivitySignupSerializer(signup) return Response(serializer.data, status=201) @extend_schema(summary="我的报名记录") @action(detail=False, methods=['get']) def my_signups(self, request): user = get_current_wechat_user(request) if not user: return Response({'error': '请先登录'}, status=401) signups = ActivitySignup.objects.filter(user=user).order_by('-signup_time') # Sync payment status for signup in signups: signup.check_payment_status() serializer = ActivitySignupSerializer(signups, many=True) return Response(serializer.data) class TopicViewSet(viewsets.ModelViewSet): """ 技术论坛帖子接口 """ queryset = Topic.objects.all() serializer_class = TopicSerializer permission_classes = [IsAuthorOrReadOnly] filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend] search_fields = ['title', 'content'] filterset_fields = ['category', 'is_pinned'] ordering_fields = ['created_at', 'view_count', 'order'] ordering = ['-is_pinned', 'order', '-created_at'] def get_queryset(self): qs = super().get_queryset() # 列表接口仅显示已发布的帖子 if self.action == 'list': qs = qs.filter(status='published') return qs def perform_create(self, serializer): user = get_current_wechat_user(self.request) # Auth check is done in create or permission, but here we need user for save if user: # 如果关联了系统用户(user字段不为空),则是管理员/内部人员,直接发布 # 否则进入审核流程 status = 'published' if user.user else 'pending' serializer.save(author=user, status=status) def create(self, request, *args, **kwargs): user = get_current_wechat_user(request) if not user: return Response({'error': '请先登录'}, status=401) return super().create(request, *args, **kwargs) @action(detail=True, methods=['post'], permission_classes=[permissions.AllowAny]) def like(self, request, pk=None): obj = self.get_object() user = get_current_wechat_user(request) if not user: return Response({'error': '请先登录'}, status=401) if obj.likes.filter(id=user.id).exists(): obj.likes.remove(user) liked = False else: obj.likes.add(user) liked = True return Response({'liked': liked, 'count': obj.likes.count()}) def retrieve(self, request, *args, **kwargs): instance = self.get_object() instance.view_count += 1 instance.save(update_fields=['view_count']) serializer = self.get_serializer(instance) return Response(serializer.data) class ReplyViewSet(viewsets.ModelViewSet): """ 帖子回复接口 """ queryset = Reply.objects.all() serializer_class = ReplySerializer permission_classes = [IsAuthorOrReadOnly] def perform_create(self, serializer): user = get_current_wechat_user(self.request) if user: serializer.save(author=user) def create(self, request, *args, **kwargs): user = get_current_wechat_user(request) if not user: return Response({'error': '请先登录'}, status=401) return super().create(request, *args, **kwargs) @action(detail=True, methods=['post'], permission_classes=[permissions.AllowAny]) def like(self, request, pk=None): obj = self.get_object() user = get_current_wechat_user(request) if not user: return Response({'error': '请先登录'}, status=401) if obj.likes.filter(id=user.id).exists(): obj.likes.remove(user) liked = False else: obj.likes.add(user) liked = True return Response({'liked': liked, 'count': obj.likes.count()}) import requests class TopicMediaViewSet(viewsets.ViewSet): """ 论坛多媒体资源上传接口 (代理到外部OSS服务) """ permission_classes = [] # 内部鉴权 parser_classes = [parsers.MultiPartParser, parsers.FormParser] @extend_schema(summary="上传媒体文件 (返回URL用于Markdown)") def create(self, request, *args, **kwargs): user = get_current_wechat_user(request) if not user: return Response({'error': '请先登录'}, status=401) file_obj = request.FILES.get('file') if not file_obj: return Response({'error': '未提供文件'}, status=400) # 转发到外部 OSS 上传服务 upload_url = "https://data.tangledup-ai.com/upload?folder=uploads%2Fmarket%2Fforum_image" files = {'file': (file_obj.name, file_obj, file_obj.content_type)} try: # 这里的 headers 不需要 Content-Type,requests 会自动设置 multipart/form-data response = requests.post(upload_url, files=files, timeout=30) if response.status_code == 200: data = response.json() if data.get('success'): # Create TopicMedia record media_type = 'image' if 'image' in file_obj.content_type else 'video' media_obj = TopicMedia.objects.create( file_url=data.get('file_url'), media_type=media_type, # topic will be associated later ) # 返回符合前端预期的格式 return Response({ 'id': media_obj.id, # Return real DB ID 'file': media_obj.file_url, 'media_type': media_obj.media_type, 'created_at': media_obj.created_at }) else: return Response({'error': '外部服务上传失败', 'detail': data}, status=400) else: return Response({'error': f'上传服务响应错误: {response.status_code}', 'detail': response.text}, status=502) except Exception as e: return Response({'error': str(e)}, status=500) class AnnouncementViewSet(viewsets.ReadOnlyModelViewSet): """ 社区公告接口 """ queryset = Announcement.objects.all() serializer_class = AnnouncementSerializer permission_classes = [permissions.AllowAny] def get_queryset(self): now = timezone.now() qs = Announcement.objects.filter(is_active=True) # Filter by start_time (if set, must be <= now) qs = qs.filter(models.Q(start_time__isnull=True) | models.Q(start_time__lte=now)) # Filter by end_time (if set, must be >= now) qs = qs.filter(models.Q(end_time__isnull=True) | models.Q(end_time__gte=now)) return qs.order_by('-is_pinned', '-priority', '-created_at') class AdminPublishViewSet(viewsets.ViewSet): """ 管理员/API发布接口 """ permission_classes = [] authentication_classes = [] def check_api_key(self, request): key = request.headers.get('X-API-KEY') or request.query_params.get('apikey') if key != '123quant-speed': return False return True def get_admin_user_by_phone(self, phone): if not phone: return None # Find WeChatUser by phone user = WeChatUser.objects.filter(phone_number=phone).first() if not user: return None # Check if linked to a system user and has admin privileges (is_staff) if user.user and user.user.is_staff: return user return None @extend_schema( summary="API发布活动", request=AdminActivitySerializer, parameters=[ OpenApiParameter( name='apikey', description='API访问密钥', required=True, type=OpenApiTypes.STR, location=OpenApiParameter.QUERY ), OpenApiParameter( name='phone_number', description='管理员手机号 (用于关联发布者)', required=True, type=OpenApiTypes.STR, location=OpenApiParameter.QUERY ) ] ) @action(detail=False, methods=['post']) def publish_activity(self, request): if not self.check_api_key(request): return Response({'error': 'Invalid API Key'}, status=403) phone = request.data.get('phone_number') or request.query_params.get('phone_number') user = self.get_admin_user_by_phone(phone) if not user: return Response({'error': 'Admin user not found with this phone number'}, status=404) data = request.data.copy() serializer = AdminActivitySerializer(data=data) if serializer.is_valid(): activity = serializer.save(author=user) return Response(serializer.data, status=201) return Response(serializer.errors, status=400) @extend_schema( summary="API发布帖子", request=AdminTopicSerializer, parameters=[ OpenApiParameter( name='apikey', description='API访问密钥', required=True, type=OpenApiTypes.STR, location=OpenApiParameter.QUERY ), OpenApiParameter( name='phone_number', description='管理员手机号 (用于关联发布者)', required=True, type=OpenApiTypes.STR, location=OpenApiParameter.QUERY ) ] ) @action(detail=False, methods=['post']) def publish_topic(self, request): if not self.check_api_key(request): return Response({'error': 'Invalid API Key'}, status=403) phone = request.data.get('phone_number') or request.query_params.get('phone_number') user = self.get_admin_user_by_phone(phone) if not user: return Response({'error': 'Admin user not found with this phone number'}, status=404) data = request.data.copy() serializer = AdminTopicSerializer(data=data) if serializer.is_valid(): # Only set status to published if not provided, otherwise respect the input status = data.get('status', 'published') topic = serializer.save(author=user, status=status) return Response(serializer.data, status=201) return Response(serializer.errors, status=400)