Преглед на файлове

Feat: Implement Post CRUD APIs with author permissions

0225015 преди 1 месец
родител
ревизия
4c3e28ec4f

+ 27 - 34
tongqu_backend_v2/accounts/admin.py

@@ -1,67 +1,60 @@
 from django.contrib import admin
-from django.contrib.auth.admin import UserAdmin as BaseUserAdmin # 重命名以避免与可能的自定义UserAdmin冲突
-from .models import CustomUser # 导入你的CustomUser模型
+from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
+from .models import CustomUser, InterestTag # 确保 CustomUser 和 InterestTag 都已导入
 
-# 如果你想为Admin中的用户创建和修改表单提供更精细的控制,可以创建自定义表单
-# from django.contrib.auth.forms import UserCreationForm, UserChangeForm
 # class CustomUserCreationForm(UserCreationForm):
 #     class Meta(UserCreationForm.Meta):
 #         model = CustomUser
-#         fields = ('email', 'phone_number', 'nickname') # 添加你想在创建时显示的字段
+#         fields = ('email', 'phone_number', 'nickname')
 
 # class CustomUserChangeForm(UserChangeForm):
 #     class Meta(UserChangeForm.Meta):
 #         model = CustomUser
-#         fields = '__all__' # 或者列出所有你想在编辑时显示的字段
+#         fields = '__all__'
 
 class CustomUserAdmin(BaseUserAdmin):
-    # 使用你 CustomUser 模型的字段来配置 Admin 界面
-
-    # 定义在列表页显示的字段 (list_display)
-    # 确保这里的字段名与你的 CustomUser 模型中的字段名一致
     list_display = ('email', 'phone_number', 'nickname', 'is_staff', 'is_active', 'date_joined')
-
-    # 定义右侧的过滤器 (list_filter)
     list_filter = ('is_staff', 'is_active', 'date_joined')
 
     # 定义在编辑页的字段集 (fieldsets)
-    # 这是编辑现有用户时显示的表单布局
-    # 注意:BaseUserAdmin 的 fieldsets 是为包含 username 的 User 模型设计的
-    # 我们需要根据我们的 CustomUser 模型调整
     fieldsets = (
-        (None, {'fields': ('email', 'password')}), # Django Admin 会处理密码的显示和哈希
-        ('个人信息', {'fields': ('phone_number', 'nickname', 'avatar', 'bio', 'school')}),
+        (None, {'fields': ('email', 'password')}),
+        # 将 'interests' 添加到 '个人信息' 分组
+        ('个人信息', {'fields': ('phone_number', 'nickname', 'avatar', 'bio', 'school', 'interests')}),
         ('权限', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
         ('重要日期', {'fields': ('last_login', 'date_joined')}),
     )
-
+    
     # 定义在添加用户页的字段集 (add_fieldsets)
-    # 这是点击 "Add user" 时显示的表单布局
+    # 注意:通常在创建用户时,多对多字段(如interests)不会直接在主创建表单中处理,
+    # 而是用户创建成功后,再进入编辑页面进行关联。
+    # 如果确实需要在创建时就关联,需要更复杂的表单处理。
+    # 为简单起见,我们不在 add_fieldsets 中添加 'interests'。
     add_fieldsets = (
         (None, {
             'classes': ('wide',),
-            # USERNAME_FIELD 是 'email'
-            # REQUIRED_FIELDS 是 ['phone_number', 'nickname']
-            # UserCreationForm 通常处理 password 和 password2 (密码确认)
             'fields': ('email', 'phone_number', 'nickname', 'password', 'password2'),
         }),
-        # 你也可以在这里添加其他可选字段的分组,例如
+        # 如果你想在添加用户时,也能看到其他可选个人信息字段(非多对多):
         # ('可选个人信息', {'fields': ('avatar', 'bio', 'school')}),
-        # ('可选权限', {'fields': ('is_active', 'is_staff', 'is_superuser')}),
+        # ('可选权限', {'fields': ('is_active', 'is_staff', 'is_superuser')}), # 通常 is_active 默认 True, is_staff/superuser 默认 False
     )
-
-    # 定义可搜索的字段 (search_fields)
     search_fields = ('email', 'phone_number', 'nickname')
-
-    # 定义默认排序 (ordering)
     ordering = ('email',)
+    
+    # 将 'interests' 添加到 filter_horizontal 以获得更好的多对多选择界面
+    filter_horizontal = ('groups', 'user_permissions', 'interests')
 
-    # 对于多对多字段 (如 groups, user_permissions),使用 filter_horizontal 可以在Admin中提供更好的选择界面
-    filter_horizontal = ('groups', 'user_permissions',)
-
-    # 如果你创建了自定义的表单 (CustomUserCreationForm, CustomUserChangeForm),可以在这里指定
     # add_form = CustomUserCreationForm
     # form = CustomUserChangeForm
 
-# 注册你的 CustomUser 模型和自定义的 Admin 类
-admin.site.register(CustomUser, CustomUserAdmin)
+# 为 InterestTag 模型创建一个简单的Admin界面 (如果还没有)
+class InterestTagAdmin(admin.ModelAdmin):
+    list_display = ('id', 'name', 'created_at') # 在列表页显示的字段
+    search_fields = ('name',)                 # 允许按名称搜索
+    list_filter = ('created_at',)             # 允许按创建时间筛选
+    ordering = ('name',)                      # 默认按名称排序
+
+# 注册模型到Admin站点
+admin.site.register(CustomUser, CustomUserAdmin)
+admin.site.register(InterestTag, InterestTagAdmin) # 使用我们刚定义的 InterestTagAdmin

+ 55 - 0
tongqu_backend_v2/accounts/migrations/0002_interesttag_customuser_interests.py

@@ -0,0 +1,55 @@
+# Generated by Django 5.2.1 on 2025-06-05 20:18
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("accounts", "0001_initial"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="InterestTag",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "name",
+                    models.CharField(
+                        help_text="兴趣标签的名称,例如:摄影、篮球、Python编程",
+                        max_length=50,
+                        unique=True,
+                        verbose_name="标签名称",
+                    ),
+                ),
+                (
+                    "created_at",
+                    models.DateTimeField(auto_now_add=True, verbose_name="创建时间"),
+                ),
+            ],
+            options={
+                "verbose_name": "兴趣标签",
+                "verbose_name_plural": "兴趣标签们",
+                "ordering": ["name"],
+            },
+        ),
+        migrations.AddField(
+            model_name="customuser",
+            name="interests",
+            field=models.ManyToManyField(
+                blank=True,
+                related_name="users_with_interest",
+                to="accounts.interesttag",
+                verbose_name="兴趣标签",
+            ),
+        ),
+    ]

+ 29 - 5
tongqu_backend_v2/accounts/models.py

@@ -40,10 +40,7 @@ class CustomUserManager(BaseUserManager):
         if extra_fields.get('is_superuser') is not True:
             raise ValueError(_('超级用户必须将 is_superuser 设置为 True.'))
         
-        # 对于 create_superuser,我们直接调用模型的构造函数并设置密码,
-        # 因为我们已经明确设置了 is_staff 和 is_superuser。
-        # 调用 self.create_user 可能会覆盖这些值,除非 create_user 逻辑特别处理。
-        if not email: # 再次检查,虽然 create_user 也会检查
+        if not email:
             raise ValueError(_('超级用户必须有一个邮箱地址'))
         if not phone_number:
             raise ValueError(_('超级用户必须有一个手机号'))
@@ -54,6 +51,23 @@ class CustomUserManager(BaseUserManager):
         user.save(using=self._db)
         return user
 
+class InterestTag(models.Model): # 将 InterestTag 定义在 CustomUser 之前,或者在 ManyToManyField 中使用字符串引用
+    name = models.CharField(
+        _('标签名称'), 
+        max_length=50, 
+        unique=True,
+        help_text=_('兴趣标签的名称,例如:摄影、篮球、Python编程')
+    )
+    created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('创建时间'))
+
+    class Meta:
+        verbose_name = _('兴趣标签')
+        verbose_name_plural = _('兴趣标签们')
+        ordering = ['name']
+
+    def __str__(self):
+        return self.name
+
 class CustomUser(AbstractBaseUser, PermissionsMixin):
     email = models.EmailField(_('邮箱地址'), unique=True)
     phone_number = models.CharField(_('手机号'), max_length=20, unique=True)
@@ -77,6 +91,14 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
     )
     date_joined = models.DateTimeField(_('注册日期'), default=timezone.now)
 
+    # 用户的兴趣标签 (多对多关系)
+    interests = models.ManyToManyField(
+        'InterestTag',       # 使用字符串形式引用 InterestTag 模型 (或者将InterestTag定义在CustomUser之前)
+        verbose_name=_('兴趣标签'),
+        blank=True,        # 允许用户没有任何兴趣标签
+        related_name='users_with_interest' # 明确的 related_name
+    )
+
     USERNAME_FIELD = 'email'
     REQUIRED_FIELDS = ['phone_number', 'nickname'] # 创建超级用户时需要的额外字段
 
@@ -84,7 +106,9 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
 
     def __str__(self):
         return self.email
-
+    @property # 作为一个属性来访问
+    def display_name(self): # 或者你喜欢的名字,比如 nickname_or_email
+        return self.nickname if self.nickname else self.email
     class Meta:
         verbose_name = _('用户')
         verbose_name_plural = _('用户们')

+ 56 - 32
tongqu_backend_v2/accounts/serializers.py

@@ -1,79 +1,103 @@
 from rest_framework import serializers
-from .models import CustomUser # 导入你的 CustomUser 模型
-from django.contrib.auth.password_validation import validate_password # Django 自带密码验证
-# from django.core.exceptions import ValidationError # 如果需要在 validate 方法中手动抛出
+from .models import CustomUser, InterestTag # 确保 InterestTag 也被导入
+from django.contrib.auth.password_validation import validate_password
+
+class InterestTagSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = InterestTag
+        fields = ('id', 'name', 'created_at')
+        read_only_fields = ('id', 'created_at')
 
 class UserRegistrationSerializer(serializers.ModelSerializer):
     password = serializers.CharField(
         write_only=True,
         required=True,
         validators=[validate_password],
-        style={'input_type': 'password'} # 在DRF的可浏览API中显示为密码输入框
+        style={'input_type': 'password'}
     )
     password2 = serializers.CharField(
         write_only=True,
         required=True,
-        label="确认密码", # 在DRF的可浏览API中的标签
+        label="确认密码",
         style={'input_type': 'password'}
     )
-    # 如果avatar是注册时可选上传的,确保在Meta中处理
     avatar = serializers.ImageField(required=False, allow_null=True, max_length=None, use_url=True)
 
-
     class Meta:
         model = CustomUser
-        # 列出注册时需要用户提供的字段
-        # 确保这些字段与 CustomUser 模型中定义的一致,特别是 USERNAME_FIELD 和 REQUIRED_FIELDS
         fields = ('email', 'phone_number', 'nickname', 'password', 'password2', 'school', 'bio', 'avatar')
         extra_kwargs = {
-            # password 和 password2 已经在上面定义了 write_only=True
-            'nickname': {'required': True}, # 根据你的 CustomUser.REQUIRED_FIELDS,nickname是必填
+            'nickname': {'required': True},
             'school': {'required': False, 'allow_blank': True},
             'bio': {'required': False, 'allow_blank': True},
-            # 'avatar': {'required': False} # 已经在上面字段定义中处理
         }
 
     def validate(self, attrs):
         if attrs['password'] != attrs['password2']:
-            raise serializers.ValidationError({"password2": "两次输入的密码不匹配。"}) # 将错误关联到password2字段
+            raise serializers.ValidationError({"password2": "两次输入的密码不匹配。"})
         return attrs
 
     def create(self, validated_data):
-        validated_data.pop('password2') # 移除 password2,它不需要保存到模型
-        
-        # 使用 CustomUser 模型的 create_user 方法创建用户
-        # 它会处理密码哈希
-        # validated_data 现在包含了 'email', 'phone_number', 'password', 'nickname' 以及可选的 'school', 'bio', 'avatar'
+        validated_data.pop('password2')
         user = CustomUser.objects.create_user(**validated_data)
         return user
 
 class UserProfileSerializer(serializers.ModelSerializer):
-    """
-    用于获取和更新用户个人资料的序列化器。
-    """
-    avatar_url = serializers.SerializerMethodField() # 用于获取完整的头像URL
+    avatar_url = serializers.SerializerMethodField()
+    interests = serializers.PrimaryKeyRelatedField(
+        queryset=InterestTag.objects.all(),
+        many=True,
+        required=False,
+        allow_empty=True
+    )
 
     class Meta:
         model = CustomUser
-        # 列出用户可以查看和修改的字段
-        # 不应包含敏感信息如 password (除非是 write_only 用于修改密码的特殊场景)
         fields = (
             'id', 'email', 'phone_number', 'nickname', 
             'avatar', 'avatar_url', 'bio', 'school', 
-            'date_joined', 'last_login', 'is_active' # is_active 可以考虑是否允许用户自己修改
+            'interests',
+            'date_joined', 'last_login', 'is_active'
         )
-        # 定义只读字段 (在更新时不能被修改,或者需要特殊流程)
         read_only_fields = ('id', 'email', 'phone_number', 'date_joined', 'last_login', 'avatar_url')
-        # avatar 字段本身可以用于上传,所以不是read_only,但 avatar_url 是只读的。
-        # email 和 phone_number 通常不应通过这个接口随意更改,如果允许更改,需要额外的验证流程(比如发送验证码)。
 
     def get_avatar_url(self, obj):
         request = self.context.get('request')
         if obj.avatar and hasattr(obj.avatar, 'url'):
             if request is not None:
                 return request.build_absolute_uri(obj.avatar.url)
-            return obj.avatar.url # Fallback if request is not available
-        return None # 如果没有头像或无法获取URL
+            return obj.avatar.url
+        return None
+
+class RecommendedUserSerializer(serializers.ModelSerializer): # 新增的序列化器
+    """
+    用于推荐用户列表中显示用户信息的序列化器。
+    """
+    avatar_url = serializers.SerializerMethodField()
+    common_interests_tags = InterestTagSerializer(many=True, read_only=True, source='common_interests_list') # 假设在视图中注入
 
-    # 如果允许通过这个Serializer更新头像,不需要特别做什么,DRF的ModelSerializer会自动处理ImageField
-    # 确保在视图中传递 request 到 serializer context,以便生成完整的URL
+    class Meta:
+        model = CustomUser
+        fields = (
+            'id', 
+            'nickname', 
+            'avatar_url', 
+            'school',
+            'bio',
+            'common_interests_tags',
+        )
+
+    def get_avatar_url(self, obj):
+        request = self.context.get('request')
+        if obj.avatar and hasattr(obj.avatar, 'url'):
+            if request is not None:
+                return request.build_absolute_uri(obj.avatar.url)
+            return obj.avatar.url
+        return None
+    
+    def to_representation(self, instance):
+        representation = super().to_representation(instance)
+        bio = representation.get('bio')
+        if bio and len(bio) > 50: # 截断bio到50字符
+            representation['bio'] = bio[:50] + '...'
+        return representation

+ 28 - 15
tongqu_backend_v2/accounts/urls.py

@@ -1,25 +1,38 @@
-from django.urls import path
-from .views import UserRegistrationView, UserProfileView # 导入我们刚创建的视图
+from django.urls import path, include # 确保 include 已导入
+from rest_framework.routers import DefaultRouter # 导入 DefaultRouter
+from .views import (
+    UserRegistrationView, 
+    UserProfileView,
+    InterestTagViewSet,
+    RecommendedUserListView # <<<< 新增导入 RecommendedUserListView
+)
 from rest_framework_simplejwt.views import (
-    TokenObtainPairView,    # SimpleJWT 自带的视图,用于登录并获取token对
-    TokenRefreshView,       # SimpleJWT 自带的视图,用于刷新access token
+    TokenObtainPairView,
+    TokenRefreshView,
 )
 
-app_name = 'accounts' # 为这个应用的URL模式设置一个命名空间 (可选,但推荐)
+app_name = 'accounts'
+
+# 1. 创建一个路由器实例
+router = DefaultRouter()
+
+# 2. 注册 InterestTagViewSet 到路由器
+#    'tags' 是这个ViewSet在URL中的前缀。
+#    例如,相关的URL会是 /api/v1/accounts/tags/ 和 /api/v1/accounts/tags/<pk>/
+#    basename='interesttag' 用于自动生成URL的名称,如 'interesttag-list', 'interesttag-detail'
+router.register(r'tags', InterestTagViewSet, basename='interesttag')
+
 
 urlpatterns = [
-    # 用户注册
+    # 用户认证和资料相关的URL
     path('register/', UserRegistrationView.as_view(), name='user_register'),
-    
-    # 用户登录 (使用SimpleJWT提供的视图)
-    # 访问此URL并POST email和password,会返回access和refresh tokens
     path('login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
-    
-    # 刷新Access Token (使用SimpleJWT提供的视图)
-    # 访问此URL并POST refresh token,会返回新的access token
     path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
-    
-    # 用户个人资料 (获取和更新)
-    # 需要认证 (在视图中已设置 IsAuthenticated)
     path('profile/', UserProfileView.as_view(), name='user_profile'),
+
+    # 新增:推荐用户列表API
+    path('users/recommendations/', RecommendedUserListView.as_view(), name='user_recommendations'), # <<<< 新增这行
+
+    # 3. 将路由器生成的URL模式 (为InterestTagViewSet生成的 /tags/ 和 /tags/<pk>/) 包含到 urlpatterns 中
+    path('', include(router.urls)),
 ]

+ 59 - 43
tongqu_backend_v2/accounts/views.py

@@ -1,33 +1,28 @@
-from rest_framework import generics, status, permissions
+from rest_framework import generics, status, permissions, viewsets # 确保 viewsets 导入
+from rest_framework.decorators import action # 如果有自定义action,虽然这里目前没有用到
 from rest_framework.response import Response
-from rest_framework_simplejwt.tokens import RefreshToken # 用于登录时或注册后手动生成token
+from rest_framework_simplejwt.tokens import RefreshToken
+from django.db.models import Count, Q # 确保 Q 和 Count 导入
 
-# 从当前应用的 models.py 和 serializers.py 导入
-from .models import CustomUser
-from .serializers import UserRegistrationSerializer, UserProfileSerializer
+from .models import CustomUser, InterestTag # <<<< 导入 InterestTag 和 CustomUser
+from .serializers import (
+    UserRegistrationSerializer, 
+    UserProfileSerializer, 
+    InterestTagSerializer, # <<<< 导入 InterestTagSerializer
+    RecommendedUserSerializer # <<<< 导入 RecommendedUserSerializer
+)
 
 class UserRegistrationView(generics.CreateAPIView):
-    """
-    用户注册视图。
-    允许任何人 (未认证用户) 访问此端点进行注册。
-    """
-    queryset = CustomUser.objects.all() # CreateAPIView 需要 queryset,即使我们不直接用它来过滤
+    queryset = CustomUser.objects.all()
     serializer_class = UserRegistrationSerializer
-    permission_classes = [permissions.AllowAny] # 明确允许任何人访问此视图
+    permission_classes = [permissions.AllowAny]
 
     def create(self, request, *args, **kwargs):
         serializer = self.get_serializer(data=request.data)
-        serializer.is_valid(raise_exception=True) # 如果数据无效,DRF会自动返回400错误和详细信息
-        user = serializer.save() # 调用 serializer 的 create 方法,创建用户
-
-        # 注册成功后,为用户生成JWT令牌 (实现注册即登录)
+        serializer.is_valid(raise_exception=True)
+        user = serializer.save()
         refresh = RefreshToken.for_user(user)
-
-        # 构建响应数据
-        # 我们使用 UserProfileSerializer 来序列化新创建的 user 对象,以便返回更完整的用户信息
-        # 需要传递 request 到 context 以便 UserProfileSerializer 中的 get_avatar_url 能构建完整URL
         user_data = UserProfileSerializer(user, context={'request': request}).data
-        
         response_data = {
             'refresh': str(refresh),
             'access': str(refresh.access_token),
@@ -35,33 +30,54 @@ class UserRegistrationView(generics.CreateAPIView):
         }
         return Response(response_data, status=status.HTTP_201_CREATED)
 
-
 class UserProfileView(generics.RetrieveUpdateAPIView):
-    """
-    获取和更新当前已认证用户的个人资料视图。
-    只允许已认证的用户访问他们自己的个人资料。
-    """
-    queryset = CustomUser.objects.all() # RetrieveUpdateAPIView 也需要 queryset
+    queryset = CustomUser.objects.all()
     serializer_class = UserProfileSerializer
-    permission_classes = [permissions.IsAuthenticated] # 明确只有认证用户才能访问
+    permission_classes = [permissions.IsAuthenticated]
 
     def get_object(self):
-        # 此方法返回视图将要操作的对象实例
-        # 在这里,我们返回当前发出请求的认证用户
         return self.request.user
 
-    # 当收到 PUT 或 PATCH 请求时,DRF的 RetrieveUpdateAPIView 会自动调用
-    # serializer_class 的 update() 或 partial_update() 方法。
-    # 我们在 UserProfileSerializer 中没有定义 update 方法,所以它会使用
-    # ModelSerializer 的默认 update 行为,这对于 ImageField (如 avatar) 通常是有效的。
-    # 如果需要更复杂的更新逻辑(例如,修改密码、验证邮箱/手机更改),则需要重写 update 方法。
+# --- 从 groups/views.py 移动过来的 ---
+class InterestTagViewSet(viewsets.ReadOnlyModelViewSet): # 应该在 accounts/views.py
+    """
+    一个只读的ViewSet,用于查看兴趣标签列表和详情。
+    """
+    queryset = InterestTag.objects.all().order_by('name') # InterestTag 现在已导入
+    serializer_class = InterestTagSerializer           # InterestTagSerializer 现在已导入
+    permission_classes = [permissions.AllowAny]
+
+class RecommendedUserListView(generics.ListAPIView): # 应该在 accounts/views.py
+    """
+    获取与当前登录用户有共同兴趣的推荐用户列表。
+    """
+    serializer_class = RecommendedUserSerializer # RecommendedUserSerializer 现在已导入
+    permission_classes = [permissions.IsAuthenticated]
+
+    def get_queryset(self):
+        current_user = self.request.user # CustomUser 现在已导入
+        current_user_interest_ids = list(current_user.interests.values_list('id', flat=True))
+
+        if not current_user_interest_ids:
+            return CustomUser.objects.none()
+
+        recommended_users = CustomUser.objects.filter(
+            interests__id__in=current_user_interest_ids
+        ).exclude(
+            id=current_user.id
+        ).distinct()
+
+        recommended_users = recommended_users.annotate(
+            common_interest_count=Count(
+                'interests', 
+                filter=Q(interests__id__in=current_user_interest_ids)
+            )
+        ).order_by('-common_interest_count', '?')
 
-    # 为了让 UserProfileSerializer 中的 get_avatar_url 能正确工作,
-    # 我们需要确保 serializer 在实例化时能获取到 request 对象。
-    # RetrieveUpdateAPIView 会自动将 request 传递给 serializer context。
-    # def get_serializer_context(self):
-    #     context = super().get_serializer_context()
-    #     context.update({"request": self.request})
-    #     return context
-    # 上面的 get_serializer_context 是DRF 3.9版本之后默认会做的,所以通常不需要显式重写。
-    # 但如果头像URL不正确,可以尝试取消注释上面这段代码。
+        current_user_interests_set = set(current_user.interests.all())
+        for user_obj in recommended_users:
+            user_interests_set = set(user_obj.interests.all())
+            common_tags = list(current_user_interests_set.intersection(user_interests_set))
+            user_obj.common_interests_list = common_tags
+            
+        return recommended_users

+ 2 - 1
tongqu_backend_v2/config/settings.py

@@ -41,7 +41,8 @@ INSTALLED_APPS = [
     'django.contrib.staticfiles',
     'rest_framework',
     'rest_framework_simplejwt',
-    'accounts',  # <--- 确保是这个,并且没有拼写错误
+    'accounts', 
+     'groups.apps.GroupsConfig', # <--- 确保是这个,并且没有拼写错误
 ]
 
 MIDDLEWARE = [

+ 8 - 7
tongqu_backend_v2/config/urls.py

@@ -1,16 +1,17 @@
 from django.contrib import admin
 from django.urls import path, include # 确保 include 已被导入
-from django.conf import settings      # 导入settings
-from django.conf.urls.static import static # 导入static
+from django.conf import settings
+from django.conf.urls.static import static
 
 urlpatterns = [
     path('admin/', admin.site.urls),
-    # 新增下面这行,将所有以 'api/v1/accounts/' 开头的URL路由到 accounts 应用的 urls.py 文件
-    path('api/v1/accounts/', include('accounts.urls')),
-    # 你未来可以添加其他应用的API路由,例如:
-    # path('api/v1/posts/', include('posts.urls')),
+    path('api/v1/accounts/', include('accounts.urls')), # 已有的accounts API
+    path('api/v1/', include('groups.urls')),       # 新增:包含groups应用的API
+                                                  # 注意:我将groups的URL直接放在 api/v1/ 下,
+                                                  # 所以小组相关的URL会是 /api/v1/groups/
+                                                  # 如果你想让它在 /api/v1/groups_app/ 下,可以改为 include('groups.urls') 前面加个路径
+                                                  # 但通常直接用应用名或资源名作为下一级路径更常见
 ]
 
-# 在开发环境下,添加处理媒体文件URL的路由
 if settings.DEBUG:
     urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

+ 0 - 0
tongqu_backend_v2/groups/__init__.py


+ 68 - 0
tongqu_backend_v2/groups/admin.py

@@ -0,0 +1,68 @@
+from django.contrib import admin
+from .models import Group, Membership, Post # 导入所有需要的模型
+
+# 为 Membership 模型创建一个内联 Admin 类
+class MembershipInline(admin.TabularInline):
+    model = Membership
+    extra = 1 # 默认显示一个空的额外行,方便添加新成员
+    autocomplete_fields = ['user'] # 假设 CustomUserAdmin 中设置了 search_fields
+
+# 为 Group 模型创建一个 Admin 类
+class GroupAdmin(admin.ModelAdmin):
+    list_display = ('name', 'creator', 'created_at', 'get_tags_display')
+    list_filter = ('created_at', 'tags')
+    search_fields = ('name', 'description', 'creator__email', 'creator__nickname')
+    
+    inlines = [MembershipInline] # 将 MembershipInline 嵌入
+    
+    filter_horizontal = ('tags',) # 为 tags 字段使用 filter_horizontal
+
+    def get_tags_display(self, obj):
+        return ", ".join([tag.name for tag in obj.tags.all()])
+    get_tags_display.short_description = '相关标签'
+
+# 为 Membership 模型创建一个 Admin 类 (可选,但方便单独管理)
+class MembershipAdmin(admin.ModelAdmin):
+    list_display = ('user_display', 'group', 'date_joined') # 假设 CustomUser 有 display_name
+    list_filter = ('group', 'date_joined')
+    search_fields = ('user__email', 'user__nickname', 'group__name')
+    autocomplete_fields = ['user', 'group']
+
+    def user_display(self, obj):
+        # 确保你的 CustomUser 模型有一个 display_name 属性或方法
+        # 如果没有,可以直接用 obj.user.email 或 obj.user.nickname
+        if hasattr(obj.user, 'display_name'):
+            return obj.user.display_name
+        return obj.user.email # 或者其他你想显示的字段
+    user_display.short_description = '用户'
+    user_display.admin_order_field = 'user__email'
+
+
+# 为 Post 模型创建一个 Admin 类
+class PostAdmin(admin.ModelAdmin):
+    list_display = ('title_or_author', 'group', 'author_display', 'created_at', 'short_content')
+    list_filter = ('group', 'created_at', 'author')
+    search_fields = ('title', 'content', 'author__email', 'author__nickname', 'group__name')
+    autocomplete_fields = ['group', 'author'] # 为外键启用自动完成
+
+    def title_or_author(self, obj):
+        return obj.title if obj.title else f"来自 {self.author_display(obj)} 的动态"
+    title_or_author.short_description = '标题/作者动态'
+
+    def author_display(self, obj):
+        if hasattr(obj.author, 'display_name'):
+            return obj.author.display_name
+        return obj.author.email # 或者其他你想显示的字段
+    author_display.short_description = '作者'
+    author_display.admin_order_field = 'author__email' # 允许按作者邮箱排序
+
+    def short_content(self, obj):
+        return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
+    short_content.short_description = '内容预览'
+# PostAdmin 类定义到此结束
+
+
+# 注册所有模型到Admin站点
+admin.site.register(Group, GroupAdmin)
+admin.site.register(Membership, MembershipAdmin)
+admin.site.register(Post, PostAdmin) # PostAdmin 的注册现在在类定义之后

+ 6 - 0
tongqu_backend_v2/groups/apps.py

@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class GroupsConfig(AppConfig):
+    default_auto_field = "django.db.models.BigAutoField"
+    name = "groups"

+ 132 - 0
tongqu_backend_v2/groups/migrations/0001_initial.py

@@ -0,0 +1,132 @@
+# Generated by Django 5.2.1 on 2025-06-05 21:16
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ("accounts", "0002_interesttag_customuser_interests"),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="Group",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "name",
+                    models.CharField(
+                        max_length=100, unique=True, verbose_name="小组名称"
+                    ),
+                ),
+                ("description", models.TextField(blank=True, verbose_name="小组描述")),
+                (
+                    "cover_image",
+                    models.ImageField(
+                        blank=True,
+                        null=True,
+                        upload_to="group_covers/",
+                        verbose_name="小组封面",
+                    ),
+                ),
+                (
+                    "created_at",
+                    models.DateTimeField(auto_now_add=True, verbose_name="创建时间"),
+                ),
+                (
+                    "updated_at",
+                    models.DateTimeField(auto_now=True, verbose_name="更新时间"),
+                ),
+                (
+                    "creator",
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="created_groups",
+                        to=settings.AUTH_USER_MODEL,
+                        verbose_name="创建者",
+                    ),
+                ),
+                (
+                    "tags",
+                    models.ManyToManyField(
+                        blank=True,
+                        related_name="groups_with_tag",
+                        to="accounts.interesttag",
+                        verbose_name="相关兴趣标签",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "小组",
+                "verbose_name_plural": "小组们",
+                "ordering": ["-created_at"],
+            },
+        ),
+        migrations.CreateModel(
+            name="Membership",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "date_joined",
+                    models.DateTimeField(auto_now_add=True, verbose_name="加入日期"),
+                ),
+                (
+                    "group",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="groups.group",
+                        verbose_name="小组",
+                    ),
+                ),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.AUTH_USER_MODEL,
+                        verbose_name="用户",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "小组成员关系",
+                "verbose_name_plural": "小组成员关系们",
+                "ordering": ["-date_joined"],
+                "unique_together": {("user", "group")},
+            },
+        ),
+        migrations.AddField(
+            model_name="group",
+            name="members",
+            field=models.ManyToManyField(
+                blank=True,
+                related_name="joined_groups",
+                through="groups.Membership",
+                through_fields=("group", "user"),
+                to=settings.AUTH_USER_MODEL,
+                verbose_name="小组成员",
+            ),
+        ),
+    ]

+ 76 - 0
tongqu_backend_v2/groups/migrations/0002_alter_membership_options_and_more.py

@@ -0,0 +1,76 @@
+# Generated by Django 5.2.1 on 2025-06-05 21:58
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("groups", "0001_initial"),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="membership",
+            options={},
+        ),
+        migrations.AlterUniqueTogether(
+            name="membership",
+            unique_together=set(),
+        ),
+        migrations.CreateModel(
+            name="Post",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "title",
+                    models.CharField(
+                        blank=True, max_length=200, null=True, verbose_name="帖子标题"
+                    ),
+                ),
+                ("content", models.TextField(verbose_name="帖子内容")),
+                (
+                    "created_at",
+                    models.DateTimeField(auto_now_add=True, verbose_name="发布时间"),
+                ),
+                (
+                    "updated_at",
+                    models.DateTimeField(auto_now=True, verbose_name="更新时间"),
+                ),
+                (
+                    "author",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="posts",
+                        to=settings.AUTH_USER_MODEL,
+                        verbose_name="作者",
+                    ),
+                ),
+                (
+                    "group",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="posts",
+                        to="groups.group",
+                        verbose_name="所属小组",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "帖子/动态",
+                "verbose_name_plural": "帖子/动态们",
+                "ordering": ["-created_at"],
+            },
+        ),
+    ]

+ 0 - 0
tongqu_backend_v2/groups/migrations/__init__.py


+ 119 - 0
tongqu_backend_v2/groups/models.py

@@ -0,0 +1,119 @@
+from django.db import models
+from django.conf import settings # 用于引用 AUTH_USER_MODEL
+from django.utils.translation import gettext_lazy as _
+# 我们可以让小组也关联到兴趣标签
+# from accounts.models import InterestTag # 如果你想直接引用
+
+class Group(models.Model):
+    name = models.CharField(_('小组名称'), max_length=100, unique=True)
+    description = models.TextField(_('小组描述'), blank=True)
+    cover_image = models.ImageField(_('小组封面'), upload_to='group_covers/', null=True, blank=True)
+    creator = models.ForeignKey(
+        settings.AUTH_USER_MODEL, # 关联到你的CustomUser模型
+        verbose_name=_('创建者'),
+        on_delete=models.SET_NULL, # 如果创建者被删除,小组可以保留,或者设为CASCADE则一起删除
+        null=True, # 允许创建者为空 (虽然通常不希望这样,但SET_NULL要求)
+        related_name='created_groups'
+    )
+    members = models.ManyToManyField(
+        settings.AUTH_USER_MODEL,
+        through='Membership', # 通过我们下面定义的Membership模型来管理成员关系
+        through_fields=('group', 'user'), # 指定Membership模型中关联Group和User的字段名
+        verbose_name=_('小组成员'),
+        blank=True, # 小组刚创建时可以没有成员 (创建者会自动成为第一个成员)
+        related_name='joined_groups'
+    )
+    # 关联到兴趣标签 (可选,但对推荐小组有帮助)
+    tags = models.ManyToManyField(
+        'accounts.InterestTag', # 引用 accounts 应用的 InterestTag 模型
+        verbose_name=_('相关兴趣标签'),
+        blank=True,
+        related_name='groups_with_tag'
+    )
+    created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('创建时间'))
+    updated_at = models.DateTimeField(auto_now=True, verbose_name=_('更新时间'))
+
+    # (可选) 小组类型:公开、私密等
+    # PUBLIC = 'public'
+    # PRIVATE = 'private'
+    # GROUP_TYPE_CHOICES = [
+    #     (PUBLIC, _('公开小组')),
+    #     (PRIVATE, _('私密小组')),
+    # ]
+    # group_type = models.CharField(
+    #     max_length=10,
+    #     choices=GROUP_TYPE_CHOICES,
+    #     default=PUBLIC,
+    #     verbose_name=_('小组类型')
+    # )
+
+    class Meta:
+        verbose_name = _('小组')
+        verbose_name_plural = _('小组们')
+        ordering = ['-created_at'] # 按创建时间降序排列
+
+    def __str__(self):
+        return self.name
+
+class Membership(models.Model):
+    """
+    用户和小组之间的中间模型,用于存储额外的成员关系信息。
+    """
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name=_('用户'))
+    group = models.ForeignKey(Group, on_delete=models.CASCADE, verbose_name=_('小组'))
+    date_joined = models.DateTimeField(auto_now_add=True, verbose_name=_('加入日期'))
+    
+    # (可选) 成员角色
+    # MEMBER = 'member'
+    # ADMIN = 'admin'
+    # MODERATOR = 'moderator'
+    # ROLE_CHOICES = [
+    #     (MEMBER, _('成员')),
+    #     (ADMIN, _('管理员')),
+    #     (MODERATOR, _('版主')),
+    # ]
+    # role = models.CharField(
+    #     max_length=10,
+    #     choices=ROLE_CHOICES,
+    #     default=MEMBER,
+    #     verbose_name=_('角色')
+    # )
+class Post(models.Model):
+    group = models.ForeignKey(
+        Group, 
+        on_delete=models.CASCADE, # 如果小组被删除,该小组下的所有帖子也一并删除
+        related_name='posts',     # 允许通过 group_instance.posts.all() 获取小组所有帖子
+        verbose_name=_('所属小组')
+    )
+    author = models.ForeignKey(
+        settings.AUTH_USER_MODEL, # 关联到 CustomUser 模型
+        on_delete=models.CASCADE, # 如果发帖用户被删除,其帖子也一并删除 (你也可以考虑SET_NULL)
+        related_name='posts',     # 允许通过 user_instance.posts.all() 获取用户所有帖子
+        verbose_name=_('作者')
+    )
+    title = models.CharField(_('帖子标题'), max_length=200, blank=True, null=True) # 帖子可以有标题,也可以没有(类似动态)
+    content = models.TextField(_('帖子内容'))
+    # (可选) 帖子图片或附件,可以用 ImageField 或 FileField,或者更复杂的方案如多图片关联
+    # image = models.ImageField(_('帖子图片'), upload_to='post_images/', null=True, blank=True)
+    
+    created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('发布时间'))
+    updated_at = models.DateTimeField(auto_now=True, verbose_name=_('更新时间'))
+
+    # (可选) 点赞数、评论数等统计字段,可以通过 annotate 动态获取,或在这里冗余存储以提高查询性能
+    # likes_count = models.PositiveIntegerField(default=0, verbose_name=_('点赞数'))
+    # comments_count = models.PositiveIntegerField(default=0, verbose_name=_('评论数'))
+
+    class Meta:
+        verbose_name = _('帖子/动态')
+        verbose_name_plural = _('帖子/动态们')
+        ordering = ['-created_at'] # 默认按发布时间降序排列 (最新的在前面)
+
+    def __str__(self):
+        return self.title if self.title else f"帖子来自 {self.author.display_name} 在 {self.group.name}"
+# 为了上面的 __str__ 方法,我们可能需要在 CustomUser 模型中添加一个辅助方法
+# 打开 accounts/models.py,在 CustomUser 类中添加:
+# def nickname_or_email(self):
+#     return self.nickname if self.nickname else self.email
+# CustomUser.add_to_class("nickname_or_email", nickname_or_email) # 如果想动态添加
+# 或者直接在 __str__ 中写:
+# return f"{self.user.nickname if self.user.nickname else self.user.email} 加入了 {self.group.name}"

+ 17 - 0
tongqu_backend_v2/groups/permissions.py

@@ -0,0 +1,17 @@
+from rest_framework import permissions
+
+class IsAuthorOrReadOnly(permissions.BasePermission):
+    """
+    自定义权限,只允许对象的所有者编辑它。
+    对于列表视图和详情视图的GET请求,总是允许(如果IsAuthenticatedOrReadOnly也应用了)。
+    """
+    def has_object_permission(self, request, view, obj):
+        # 读取权限允许任何请求 (GET, HEAD, OPTIONS)
+        # 这是针对单个对象的权限检查,列表视图的权限由视图的permission_classes控制
+        if request.method in permissions.SAFE_METHODS:
+            return True
+
+        # 写入权限只授予帖子的作者
+        # 确保 obj 有 author 属性,并且 request.user 已认证
+        # (IsAuthenticated 权限类应该在视图层面已经检查了 request.user 是否存在)
+        return obj.author == request.user

+ 126 - 0
tongqu_backend_v2/groups/serializers.py

@@ -0,0 +1,126 @@
+from rest_framework import serializers
+from .models import Group, Membership, Post
+from accounts.models import CustomUser, InterestTag
+from accounts.serializers import InterestTagSerializer as AccountsInterestTagSerializer
+
+# --- Minimal Serializers for nested representations ---
+class MinimalUserSerializer(serializers.ModelSerializer):
+    avatar_url = serializers.SerializerMethodField()
+
+    class Meta:
+        model = CustomUser
+        fields = ('id', 'nickname', 'avatar_url')
+
+    def get_avatar_url(self, obj):
+        request = self.context.get('request')
+        if obj.avatar and hasattr(obj.avatar, 'url'):
+            if request is not None:
+                return request.build_absolute_uri(obj.avatar.url)
+            return obj.avatar.url
+        return None
+
+class MinimalGroupSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Group
+        fields = ('id', 'name')
+
+
+# --- Main Serializers ---
+class GroupSerializer(serializers.ModelSerializer):
+    creator = MinimalUserSerializer(read_only=True)
+    members_count = serializers.IntegerField(source='members.count', read_only=True)
+    
+    tags = serializers.PrimaryKeyRelatedField(
+        queryset=InterestTag.objects.all(),
+        many=True,
+        required=False,
+        allow_empty=True
+    )
+    tags_details = AccountsInterestTagSerializer(source='tags', many=True, read_only=True)
+
+    cover_image = serializers.ImageField(max_length=None, use_url=True, required=False, allow_null=True)
+    cover_image_url = serializers.SerializerMethodField(read_only=True)
+
+    class Meta:
+        model = Group
+        fields = (
+            'id', 'name', 'description', 
+            'cover_image', 'cover_image_url', 
+            'creator', 'members_count', 
+            'tags', 'tags_details',
+            'created_at', 'updated_at'
+        )
+        read_only_fields = ('id', 'created_at', 'updated_at', 'creator', 'members_count', 'tags_details', 'cover_image_url')
+
+    def get_cover_image_url(self, obj):
+        request = self.context.get('request')
+        if obj.cover_image and hasattr(obj.cover_image, 'url'):
+            if request is not None:
+                return request.build_absolute_uri(obj.cover_image.url)
+            return obj.cover_image.url
+        return None
+
+    def create(self, validated_data):
+        tags_data = validated_data.pop('tags', None)
+        group = super().create(validated_data)
+        if tags_data is not None:
+            group.tags.set(tags_data)
+        return group
+
+    def update(self, instance, validated_data):
+        tags_data = validated_data.pop('tags', None)
+        instance = super().update(instance, validated_data)
+        if tags_data is not None:
+            instance.tags.set(tags_data)
+        return instance
+
+
+class MembershipSerializer(serializers.ModelSerializer):
+    user = MinimalUserSerializer(read_only=True)
+    group_id = serializers.IntegerField(source='group.id', read_only=True)
+    group_name = serializers.CharField(source='group.name', read_only=True)
+
+    class Meta:
+        model = Membership
+        fields = ('id', 'user', 'group_id', 'group_name', 'date_joined')
+        read_only_fields = ('id', 'user', 'group_id', 'group_name', 'date_joined')
+
+
+class PostSerializer(serializers.ModelSerializer): # 经过再次修正和简化的 PostSerializer
+    author = MinimalUserSerializer(read_only=True) # 作者信息只读,在GET时显示
+    group = MinimalGroupSerializer(read_only=True)   # 小组信息只读,在GET时显示
+
+    class Meta:
+        model = Post
+        fields = (
+            'id', 
+            'group',        # 在GET时显示小组信息 (只读)
+            'author',       # 在GET时显示作者信息 (只读)
+            'title', 
+            'content',
+            # 'image',      # 如果模型中有 image 字段,可以在这里添加用于上传
+            # 'image_url',  # 如果模型中有 image 字段,可以添加 get_image_url 方法
+            'created_at', 
+            'updated_at',
+        )
+        # author 和 group 将在视图的 serializer.save() 中设置,所以它们对客户端来说是只读的
+        # title 和 content 是客户端在创建帖子时可以发送的
+        read_only_fields = ('id', 'author', 'group', 'created_at', 'updated_at')
+    
+    # create 方法不需要特别修改,因为 author 和 group 会在视图的 save() 中传入
+    # def create(self, validated_data):
+    #     # validated_data 只包含客户端发送的 title, content
+    #     # author 和 group 是通过 serializer.save(author=request.user, group=group) 传入的
+    #     # ModelSerializer的默认create会将这些额外参数设置到模型实例上
+    #     return super().create(validated_data)
+    # 实际上,DRF的ModelSerializer在调用save时传递的额外关键字参数,
+    # 会在模型实例被创建(通过调用模型的objects.create或类似方法)之前,
+    # 被添加到传递给模型构造函数的关键字参数中。
+    # 所以,PostSerializer 的 create 方法可以保持默认,或者明确地像下面这样处理(如果需要更细致控制)
+    # def create(self, validated_data):
+    #     # author 和 group 是从 serializer.save(author=..., group=...) 传递过来的
+    #     # 它们现在应该在 validated_data 中,或者需要从 context 中获取
+    #     # 更准确地说,它们会作为额外参数传递给模型的create方法
+    #     # 保持默认的 super().create() 即可,只要视图的save()调用正确
+    #     return super().create(validated_data)
+    # 我们让 PostSerializer 的 create 方法保持默认,依赖视图的 serializer.save(author=..., group=...)

+ 3 - 0
tongqu_backend_v2/groups/tests.py

@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.

+ 15 - 0
tongqu_backend_v2/groups/urls.py

@@ -0,0 +1,15 @@
+from django.urls import path, include
+from rest_framework.routers import DefaultRouter
+from .views import GroupViewSet, PostViewSet # <<<< 确保 PostViewSet 已导入
+
+app_name = 'groups'
+
+router = DefaultRouter()
+router.register(r'groups', GroupViewSet, basename='group')
+router.register(r'posts', PostViewSet, basename='post')   # <<<< 新增注册 PostViewSet
+
+urlpatterns = [
+    # router.urls 会自动包含为 GroupViewSet 和 PostViewSet 生成的所有标准URL
+    # 以及 GroupViewSet 中通过 @action 定义的路由
+    path('', include(router.urls)),
+]

+ 117 - 0
tongqu_backend_v2/groups/views.py

@@ -0,0 +1,117 @@
+from rest_framework import viewsets, status, permissions
+from rest_framework.decorators import action
+from rest_framework.response import Response
+from rest_framework.exceptions import MethodNotAllowed # <<<< 新增导入
+# from django.shortcuts import get_object_or_404
+
+from .models import Group, Membership, Post
+from .serializers import (
+    GroupSerializer,
+    # MembershipSerializer,
+    PostSerializer
+)
+from .permissions import IsAuthorOrReadOnly # 从自定义权限文件导入
+
+class GroupViewSet(viewsets.ModelViewSet):
+    queryset = Group.objects.all().order_by('-created_at')
+    serializer_class = GroupSerializer
+
+    def get_permissions(self):
+        if self.action in ['list', 'retrieve']:
+            permission_classes = [permissions.AllowAny]
+        elif self.action in ['list_group_posts', 'create_group_post', 'join_group', 'leave_group', 'my_groups']:
+            permission_classes = [permissions.IsAuthenticated]
+        else: # create, update, partial_update, destroy (for groups)
+            permission_classes = [permissions.IsAuthenticated]
+        return [permission() for permission in permission_classes]
+
+    def perform_create(self, serializer):
+        group = serializer.save(creator=self.request.user)
+        Membership.objects.create(user=self.request.user, group=group)
+
+    @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
+    def join_group(self, request, pk=None):
+        group = self.get_object()
+        user = request.user
+        if Membership.objects.filter(user=user, group=group).exists():
+            return Response({'detail': '您已经是该小组成员。'}, status=status.HTTP_400_BAD_REQUEST)
+        Membership.objects.create(user=user, group=group)
+        return Response({'detail': '成功加入小组。'}, status=status.HTTP_200_OK)
+
+    @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
+    def leave_group(self, request, pk=None):
+        group = self.get_object()
+        user = request.user
+        try:
+            membership = Membership.objects.get(user=user, group=group)
+            membership.delete()
+            return Response({'detail': '成功退出小组。'}, status=status.HTTP_200_OK)
+        except Membership.DoesNotExist:
+            return Response({'detail': '您不是该小组成员。'}, status=status.HTTP_400_BAD_REQUEST)
+
+    @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated], url_path='my-groups')
+    def my_groups(self, request):
+        user = request.user
+        memberships = Membership.objects.filter(user=user).select_related('group')
+        groups = [membership.group for membership in memberships]
+        page = self.paginate_queryset(groups)
+        if page is not None:
+            serializer = self.get_serializer(page, many=True, context={'request': request})
+            return self.get_paginated_response(serializer.data)
+        serializer = self.get_serializer(groups, many=True, context={'request': request})
+        return Response(serializer.data)
+
+    @action(detail=True, methods=['get'], url_path='posts', url_name='group-posts-list')
+    def list_group_posts(self, request, pk=None):
+        group = self.get_object()
+        posts_queryset = Post.objects.filter(group=group).order_by('-created_at')
+        page = self.paginate_queryset(posts_queryset)
+        if page is not None:
+            serializer = PostSerializer(page, many=True, context={'request': request})
+            return self.get_paginated_response(serializer.data)
+        serializer = PostSerializer(posts_queryset, many=True, context={'request': request})
+        return Response(serializer.data)
+
+    @action(detail=True, methods=['post'], url_path='create-post', url_name='group-post-create')
+    def create_group_post(self, request, pk=None):
+        group = self.get_object()
+        serializer = PostSerializer(data=request.data, context={'request': request})
+        if serializer.is_valid():
+            serializer.save(author=request.user, group=group)
+            return Response(serializer.data, status=status.HTTP_201_CREATED)
+        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+
+class PostViewSet(viewsets.ModelViewSet):
+    queryset = Post.objects.all().select_related('author', 'group').order_by('-created_at')
+    serializer_class = PostSerializer
+
+    def get_permissions(self):
+        if self.action in ['list', 'retrieve']:
+            permission_classes = [permissions.IsAuthenticated] # 或者 IsAuthenticatedOrReadOnly
+        elif self.action in ['update', 'partial_update', 'destroy']:
+            permission_classes = [permissions.IsAuthenticated, IsAuthorOrReadOnly]
+        elif self.action == 'create':
+            # 即使是Admin,我们也希望通过小组接口创建帖子,以确保group关联正确
+            # 所以这里可以直接禁止,或者只允许特定调试场景
+            permission_classes = [permissions.IsAdminUser] # 示例:只允许Admin(但会被perform_create阻止)
+        else:
+            permission_classes = [permissions.IsAuthenticated]
+        return [permission() for permission in permission_classes]
+
+    def perform_create(self, serializer): # <<<< 修改这里
+        """
+        通过此独立ViewSet创建帖子是不推荐的。
+        应通过 /api/v1/groups/<group_pk>/create-post/ 创建。
+        """
+        raise MethodNotAllowed(
+            method=self.request.method,
+            detail="不推荐通过此接口创建帖子。请通过小组接口 /api/v1/groups/<group_pk>/create-post/ 创建。"
+        )
+
+    # perform_update 和 perform_destroy 由 IsAuthorOrReadOnly 权限类控制,无需显式覆盖默认行为
+    # def perform_update(self, serializer):
+    #     serializer.save() # DRF默认行为
+
+    # def perform_destroy(self, instance):
+    #     instance.delete() # DRF默认行为

BIN
tongqu_backend_v2/media/group_covers/QQ图片20221102081910.jpg


BIN
tongqu_backend_v2/media/group_covers/QQ图片20221102081910_SdGL8mD.jpg