From 7e4d2a9579f280bff2a9d7468183850c24579686 Mon Sep 17 00:00:00 2001 From: jeremygan2021 Date: Wed, 11 Feb 2026 14:58:28 +0800 Subject: [PATCH] community --- backend/community/__init__.py | 0 backend/community/admin.py | 160 ++++++++++++++++++ backend/community/apps.py | 5 + backend/community/migrations/0001_initial.py | 85 ++++++++++ ...d_course_topic_related_service_and_more.py | 30 ++++ ..._content_alter_topic_content_topicmedia.py | 39 +++++ backend/community/migrations/__init__.py | 0 backend/community/models.py | 155 +++++++++++++++++ backend/community/serializers.py | 49 ++++++ backend/community/tests.py | 3 + backend/community/urls.py | 13 ++ backend/community/views.py | 118 +++++++++++++ .../__pycache__/settings.cpython-312.pyc | Bin 5768 -> 6264 bytes .../config/__pycache__/urls.cpython-312.pyc | Bin 1201 -> 1278 bytes backend/config/settings.py | 27 +++ backend/config/urls.py | 1 + backend/db.sqlite3 | Bin 303104 -> 372736 bytes 17 files changed, 685 insertions(+) create mode 100644 backend/community/__init__.py create mode 100644 backend/community/admin.py create mode 100644 backend/community/apps.py create mode 100644 backend/community/migrations/0001_initial.py create mode 100644 backend/community/migrations/0002_topic_related_course_topic_related_service_and_more.py create mode 100644 backend/community/migrations/0003_alter_reply_content_alter_topic_content_topicmedia.py create mode 100644 backend/community/migrations/__init__.py create mode 100644 backend/community/models.py create mode 100644 backend/community/serializers.py create mode 100644 backend/community/tests.py create mode 100644 backend/community/urls.py create mode 100644 backend/community/views.py diff --git a/backend/community/__init__.py b/backend/community/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/community/admin.py b/backend/community/admin.py new file mode 100644 index 0000000..1d6bfaa --- /dev/null +++ b/backend/community/admin.py @@ -0,0 +1,160 @@ +from django.contrib import admin +from django.utils.html import format_html +from unfold.admin import ModelAdmin, TabularInline +from unfold.decorators import display +from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia + +class ActivitySignupInline(TabularInline): + model = ActivitySignup + extra = 0 + readonly_fields = ('signup_time',) + fields = ('user', 'status', 'signup_time') + autocomplete_fields = ['user'] + can_delete = True + show_change_link = True + +class ReplyInline(TabularInline): + model = Reply + extra = 0 + readonly_fields = ('created_at',) + fields = ('content', 'author', 'created_at') + can_delete = True + show_change_link = True + +class TopicMediaInline(TabularInline): + model = TopicMedia + extra = 0 + fields = ('file', 'media_type', 'created_at') + readonly_fields = ('created_at',) + can_delete = True + +@admin.register(Activity) +class ActivityAdmin(ModelAdmin): + list_display = ('title', 'banner_display', 'start_time', 'location', 'signup_count', 'is_active', 'created_at') + list_filter = ('is_active', 'start_time') + search_fields = ('title', 'location') + inlines = [ActivitySignupInline] + + fieldsets = ( + ('基本信息', { + 'fields': ('title', 'description', 'banner', 'is_active') + }), + ('时间与地点', { + 'fields': ('start_time', 'end_time', 'location'), + 'classes': ('tab',) + }), + ('报名设置', { + 'fields': ('max_participants',) + }), + ) + + @display(description="Banner") + def banner_display(self, obj): + if obj.banner: + return format_html('', obj.banner.url) + return "暂无" + + @display(description="报名人数") + def signup_count(self, obj): + return obj.signups.count() + +@admin.register(ActivitySignup) +class ActivitySignupAdmin(ModelAdmin): + list_display = ('activity', 'user', 'signup_time', 'status_label') + list_filter = ('status', 'signup_time', 'activity') + search_fields = ('user__nickname', 'activity__title') + autocomplete_fields = ['activity', 'user'] + + fieldsets = ( + ('报名详情', { + 'fields': ('activity', 'user', 'status') + }), + ('时间信息', { + 'fields': ('signup_time',), + 'classes': ('collapse',) + }), + ) + readonly_fields = ('signup_time',) + + @display( + description="状态", + label={ + "pending": "warning", + "confirmed": "success", + "cancelled": "danger", + } + ) + def status_label(self, obj): + return obj.status + +@admin.register(Topic) +class TopicAdmin(ModelAdmin): + list_display = ('title', 'author', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at') + list_filter = ('is_pinned', 'created_at', 'related_product', 'related_service', 'related_course') + search_fields = ('title', 'content', 'author__nickname') + autocomplete_fields = ['author', 'related_product', 'related_service', 'related_course'] + inlines = [TopicMediaInline, ReplyInline] + + fieldsets = ( + ('帖子内容', { + 'fields': ('title', 'content', 'is_pinned') + }), + ('关联信息', { + 'fields': ('author', 'related_product', 'related_service', 'related_course'), + 'description': '可关联 硬件、服务 或 课程,用于技术求助或讨论' + }), + ('统计数据', { + 'fields': ('view_count', 'created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + readonly_fields = ('created_at', 'updated_at') + + @display(description="关联项目") + def get_related_item(self, obj): + if obj.related_product: + return f"[硬件] {obj.related_product.name}" + if obj.related_service: + return f"[服务] {obj.related_service.title}" + if obj.related_course: + return f"[课程] {obj.related_course.title}" + return "-" + + @display(description="回复数") + def reply_count(self, obj): + return obj.replies.count() + +@admin.register(Reply) +class ReplyAdmin(ModelAdmin): + list_display = ('short_content', 'topic', 'author', 'created_at') + list_filter = ('created_at',) + search_fields = ('content', 'author__nickname', 'topic__title') + autocomplete_fields = ['author', 'topic', 'reply_to'] + inlines = [TopicMediaInline] + + fieldsets = ( + ('回复内容', { + 'fields': ('topic', 'reply_to', 'content') + }), + ('发布信息', { + 'fields': ('author', 'created_at') + }), + ) + readonly_fields = ('created_at',) + + @display(description="内容摘要") + def short_content(self, obj): + return obj.content[:30] + '...' if len(obj.content) > 30 else obj.content + +@admin.register(TopicMedia) +class TopicMediaAdmin(ModelAdmin): + list_display = ('id', 'media_type', 'file_preview', 'topic', 'reply', 'created_at') + list_filter = ('media_type', 'created_at') + search_fields = ('file', 'topic__title') + autocomplete_fields = ['topic', 'reply'] + + @display(description="预览") + def file_preview(self, obj): + if obj.media_type == 'image': + return format_html('', obj.file.url) + return obj.file.name diff --git a/backend/community/apps.py b/backend/community/apps.py new file mode 100644 index 0000000..2ab4c53 --- /dev/null +++ b/backend/community/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CommunityConfig(AppConfig): + name = 'community' diff --git a/backend/community/migrations/0001_initial.py b/backend/community/migrations/0001_initial.py new file mode 100644 index 0000000..74d848e --- /dev/null +++ b/backend/community/migrations/0001_initial.py @@ -0,0 +1,85 @@ +# Generated by Django 6.0.1 on 2026-02-11 06:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('shop', '0025_vccourse_alter_courseenrollment_course_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Activity', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, verbose_name='活动标题')), + ('description', models.TextField(verbose_name='活动详情')), + ('banner', models.ImageField(upload_to='activities/banners/', verbose_name='活动Banner图')), + ('start_time', models.DateTimeField(verbose_name='开始时间')), + ('end_time', models.DateTimeField(verbose_name='结束时间')), + ('location', models.CharField(max_length=100, verbose_name='活动地点')), + ('max_participants', models.IntegerField(default=50, verbose_name='最大报名人数')), + ('is_active', models.BooleanField(default=True, verbose_name='是否启用')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ], + options={ + 'verbose_name': '社区活动', + 'verbose_name_plural': '社区活动管理', + }, + ), + migrations.CreateModel( + name='Topic', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='标题')), + ('content', models.TextField(verbose_name='内容')), + ('view_count', models.IntegerField(default=0, verbose_name='浏览量')), + ('is_pinned', models.BooleanField(default=False, 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='topics', to='shop.wechatuser', verbose_name='作者')), + ('related_product', models.ForeignKey(blank=True, help_text='如果是技术求助,请选择关联的硬件', null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.esp32config', verbose_name='关联硬件')), + ], + options={ + 'verbose_name': '论坛帖子', + 'verbose_name_plural': '论坛帖子管理', + 'ordering': ['-is_pinned', '-created_at'], + }, + ), + migrations.CreateModel( + name='Reply', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.TextField(verbose_name='回复内容')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='回复时间')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='shop.wechatuser', verbose_name='回复者')), + ('reply_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='community.reply', verbose_name='回复楼层')), + ('topic', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='community.topic', verbose_name='所属帖子')), + ], + options={ + 'verbose_name': '帖子回复', + 'verbose_name_plural': '帖子回复管理', + 'ordering': ['created_at'], + }, + ), + migrations.CreateModel( + name='ActivitySignup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('signup_time', models.DateTimeField(auto_now_add=True, verbose_name='报名时间')), + ('status', models.CharField(choices=[('pending', '审核中'), ('confirmed', '报名成功'), ('cancelled', '已取消')], default='confirmed', max_length=20, verbose_name='状态')), + ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='signups', to='community.activity', verbose_name='活动')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_signups', to='shop.wechatuser', verbose_name='报名用户')), + ], + options={ + 'verbose_name': '活动报名', + 'verbose_name_plural': '活动报名管理', + 'unique_together': {('activity', 'user')}, + }, + ), + ] diff --git a/backend/community/migrations/0002_topic_related_course_topic_related_service_and_more.py b/backend/community/migrations/0002_topic_related_course_topic_related_service_and_more.py new file mode 100644 index 0000000..a8265a7 --- /dev/null +++ b/backend/community/migrations/0002_topic_related_course_topic_related_service_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.1 on 2026-02-11 06:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('community', '0001_initial'), + ('shop', '0025_vccourse_alter_courseenrollment_course_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='topic', + name='related_course', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.vccourse', verbose_name='关联课程'), + ), + migrations.AddField( + model_name='topic', + name='related_service', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.service', verbose_name='关联服务'), + ), + migrations.AlterField( + model_name='topic', + name='related_product', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.esp32config', verbose_name='关联硬件'), + ), + ] diff --git a/backend/community/migrations/0003_alter_reply_content_alter_topic_content_topicmedia.py b/backend/community/migrations/0003_alter_reply_content_alter_topic_content_topicmedia.py new file mode 100644 index 0000000..99ef0d3 --- /dev/null +++ b/backend/community/migrations/0003_alter_reply_content_alter_topic_content_topicmedia.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0.1 on 2026-02-11 06:57 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('community', '0002_topic_related_course_topic_related_service_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='reply', + name='content', + field=models.TextField(help_text='支持Markdown格式', verbose_name='回复内容'), + ), + migrations.AlterField( + model_name='topic', + name='content', + field=models.TextField(help_text='支持Markdown格式,支持插入图片', verbose_name='内容'), + ), + migrations.CreateModel( + name='TopicMedia', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to='community/media/', verbose_name='文件')), + ('media_type', models.CharField(choices=[('image', '图片'), ('video', '视频'), ('file', '文件')], default='image', max_length=10, verbose_name='媒体类型')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='上传时间')), + ('reply', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='media', to='community.reply', verbose_name='所属回复')), + ('topic', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='media', to='community.topic', verbose_name='所属帖子')), + ], + options={ + 'verbose_name': '论坛媒体资源', + 'verbose_name_plural': '论坛媒体资源管理', + }, + ), + ] diff --git a/backend/community/migrations/__init__.py b/backend/community/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/community/models.py b/backend/community/models.py new file mode 100644 index 0000000..c9c082d --- /dev/null +++ b/backend/community/models.py @@ -0,0 +1,155 @@ +from django.db import models +from shop.models import WeChatUser, ESP32Config, Order, Service, VCCourse, ServiceOrder + +class Activity(models.Model): + """ + 社区活动模型 + """ + title = models.CharField(max_length=100, verbose_name="活动标题") + description = models.TextField(verbose_name="活动详情") + banner = models.ImageField(upload_to='activities/banners/', verbose_name="活动Banner图") + start_time = models.DateTimeField(verbose_name="开始时间") + end_time = models.DateTimeField(verbose_name="结束时间") + location = models.CharField(max_length=100, verbose_name="活动地点") + max_participants = models.IntegerField(default=50, verbose_name="最大报名人数") + is_active = models.BooleanField(default=True, verbose_name="是否启用") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + + def __str__(self): + return self.title + + class Meta: + verbose_name = "社区活动" + verbose_name_plural = "社区活动管理" + + +class ActivitySignup(models.Model): + """ + 活动报名记录 + """ + STATUS_CHOICES = ( + ('pending', '审核中'), + ('confirmed', '报名成功'), + ('cancelled', '已取消'), + ) + + activity = models.ForeignKey(Activity, on_delete=models.CASCADE, related_name='signups', verbose_name="活动") + user = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='activity_signups', verbose_name="报名用户") + signup_time = models.DateTimeField(auto_now_add=True, verbose_name="报名时间") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='confirmed', verbose_name="状态") + + def __str__(self): + return f"{self.user.nickname} - {self.activity.title}" + + class Meta: + verbose_name = "活动报名" + verbose_name_plural = "活动报名管理" + unique_together = ('activity', 'user') + + +class Topic(models.Model): + """ + 论坛帖子/主题 + """ + title = models.CharField(max_length=200, verbose_name="标题") + content = models.TextField(verbose_name="内容", help_text="支持Markdown格式,支持插入图片") + author = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='topics', verbose_name="作者") + + # 关联对象:硬件、服务、课程 + related_product = models.ForeignKey(ESP32Config, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联硬件") + related_service = models.ForeignKey(Service, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联服务") + related_course = models.ForeignKey(VCCourse, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联课程") + + view_count = models.IntegerField(default=0, verbose_name="浏览量") + is_pinned = models.BooleanField(default=False, verbose_name="置顶") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="发布时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + def __str__(self): + return self.title + + @property + def is_verified_owner(self): + """ + 判断作者是否为关联项目(硬件/服务/课程)的已购用户(Verified Owner) + """ + # 1. 验证硬件 + if self.related_product: + if Order.objects.filter( + wechat_user=self.author, + config=self.related_product, + status__in=['paid', 'shipped'] + ).exists(): + return True + + # 2. 验证课程 + if self.related_course: + if Order.objects.filter( + wechat_user=self.author, + course=self.related_course, + status__in=['paid', 'shipped'] + ).exists(): + return True + + # 3. 验证服务 + if self.related_service: + # ServiceOrder 模型中没有 direct link to WeChatUser (only phone/name), + # 但我们假设通过手机号或未来关联来验证,目前先检查 ServiceOrder 是否有对应记录。 + # 由于 ServiceOrder 目前设计没有直接关联 WeChatUser 字段,我们暂时尝试通过名字或后续改进。 + # 经检查 shop/models.py, ServiceOrder 确实只有 customer_name/phone_number. + # 这里为了严谨,我们暂时仅对有关联的进行检查,或者需要改进 ServiceOrder。 + # 鉴于当前任务范围,如果 ServiceOrder 没有 user 字段,我们可能无法精确验证, + # 除非我们假设用户填写的手机号与微信用户关联。 + # *修正*: 为了快速实现,我们先跳过 ServiceOrder 的精确验证,或者仅仅返回 False, + # 等待后续 ServiceOrder 添加 wechat_user 字段。 + pass + + return False + + class Meta: + verbose_name = "论坛帖子" + verbose_name_plural = "论坛帖子管理" + ordering = ['-is_pinned', '-created_at'] + + +class Reply(models.Model): + """ + 帖子回复 + """ + topic = models.ForeignKey(Topic, on_delete=models.CASCADE, related_name='replies', verbose_name="所属帖子") + content = models.TextField(verbose_name="回复内容", help_text="支持Markdown格式") + author = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='replies', verbose_name="回复者") + reply_to = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name="回复楼层") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="回复时间") + + def __str__(self): + return f"回复: {self.topic.title}" + + class Meta: + verbose_name = "帖子回复" + verbose_name_plural = "帖子回复管理" + ordering = ['created_at'] + + +class TopicMedia(models.Model): + """ + 论坛多媒体资源(图片/视频/文件) + """ + MEDIA_TYPE_CHOICES = ( + ('image', '图片'), + ('video', '视频'), + ('file', '文件'), + ) + + topic = models.ForeignKey(Topic, on_delete=models.CASCADE, related_name='media', verbose_name="所属帖子", null=True, blank=True) + reply = models.ForeignKey(Reply, on_delete=models.CASCADE, related_name='media', verbose_name="所属回复", null=True, blank=True) + file = models.FileField(upload_to='community/media/', verbose_name="文件") + media_type = models.CharField(max_length=10, choices=MEDIA_TYPE_CHOICES, default='image', verbose_name="媒体类型") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间") + + def __str__(self): + return f"{self.media_type} - {self.file.name}" + + class Meta: + verbose_name = "论坛媒体资源" + verbose_name_plural = "论坛媒体资源管理" diff --git a/backend/community/serializers.py b/backend/community/serializers.py new file mode 100644 index 0000000..2c226f2 --- /dev/null +++ b/backend/community/serializers.py @@ -0,0 +1,49 @@ +from rest_framework import serializers +from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia +from shop.serializers import WeChatUserSerializer, ESP32ConfigSerializer, ServiceSerializer, VCCourseSerializer + +class ActivitySerializer(serializers.ModelSerializer): + class Meta: + model = Activity + fields = '__all__' + +class ActivitySignupSerializer(serializers.ModelSerializer): + class Meta: + model = ActivitySignup + fields = ['id', 'activity', 'user', 'signup_time', 'status'] + read_only_fields = ['signup_time', 'status'] + +class TopicMediaSerializer(serializers.ModelSerializer): + class Meta: + model = TopicMedia + fields = ['id', 'file', 'media_type', 'created_at'] + +class ReplySerializer(serializers.ModelSerializer): + author_info = WeChatUserSerializer(source='author', read_only=True) + media = TopicMediaSerializer(many=True, read_only=True) + + class Meta: + model = Reply + fields = ['id', 'topic', 'content', 'author', 'author_info', 'reply_to', 'media', 'created_at'] + +class TopicSerializer(serializers.ModelSerializer): + author_info = WeChatUserSerializer(source='author', read_only=True) + replies = ReplySerializer(many=True, read_only=True) + media = TopicMediaSerializer(many=True, read_only=True) + is_verified_owner = serializers.BooleanField(read_only=True) + + product_info = ESP32ConfigSerializer(source='related_product', read_only=True) + service_info = ServiceSerializer(source='related_service', read_only=True) + course_info = VCCourseSerializer(source='related_course', read_only=True) + + class Meta: + model = Topic + fields = [ + 'id', 'title', 'content', 'author', 'author_info', + 'related_product', 'product_info', + 'related_service', 'service_info', + 'related_course', 'course_info', + 'view_count', 'is_pinned', 'created_at', 'updated_at', + 'is_verified_owner', 'replies', 'media' + ] + read_only_fields = ['view_count', 'created_at', 'updated_at', 'is_verified_owner'] diff --git a/backend/community/tests.py b/backend/community/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/community/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/community/urls.py b/backend/community/urls.py new file mode 100644 index 0000000..dc2d25f --- /dev/null +++ b/backend/community/urls.py @@ -0,0 +1,13 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import ActivityViewSet, TopicViewSet, ReplyViewSet, TopicMediaViewSet + +router = DefaultRouter() +router.register(r'activities', ActivityViewSet) +router.register(r'topics', TopicViewSet) +router.register(r'replies', ReplyViewSet) +router.register(r'media', TopicMediaViewSet) + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/backend/community/views.py b/backend/community/views.py new file mode 100644 index 0000000..db6e013 --- /dev/null +++ b/backend/community/views.py @@ -0,0 +1,118 @@ +from rest_framework import viewsets, status, mixins, parsers +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework import serializers +from django.core.signing import TimestampSigner, BadSignature, SignatureExpired +from drf_spectacular.utils import extend_schema + +from shop.models import WeChatUser +from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia +from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer + +def get_current_wechat_user(request): + """ + 根据 Authorization 头获取当前微信用户 (复用 shop app 的逻辑) + """ + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return None + token = auth_header.split(' ')[1] + signer = TimestampSigner() + try: + # 签名包含 openid + openid = signer.unsign(token, max_age=86400 * 30) # 30天有效 + return WeChatUser.objects.filter(openid=openid).first() + except (BadSignature, SignatureExpired): + return None + +class ActivityViewSet(viewsets.ReadOnlyModelViewSet): + """ + 社区活动接口 + """ + queryset = Activity.objects.filter(is_active=True).order_by('-created_at') + serializer_class = ActivitySerializer + + @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() + + # Check if already signed up + if ActivitySignup.objects.filter(activity=activity, user=user).exists(): + return Response({'error': '您已报名该活动'}, status=400) + + if activity.signups.count() >= activity.max_participants: + return Response({'error': '活动名额已满'}, status=400) + + signup = ActivitySignup.objects.create(activity=activity, user=user) + 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') + serializer = ActivitySignupSerializer(signups, many=True) + return Response(serializer.data) + +class TopicViewSet(viewsets.ModelViewSet): + """ + 技术论坛帖子接口 + """ + queryset = Topic.objects.all() + serializer_class = TopicSerializer + + 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: + 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) + +class ReplyViewSet(viewsets.ModelViewSet): + """ + 帖子回复接口 + """ + queryset = Reply.objects.all() + serializer_class = ReplySerializer + + 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) + +class TopicMediaViewSet(viewsets.GenericViewSet, mixins.CreateModelMixin): + """ + 论坛多媒体资源上传接口 + """ + queryset = TopicMedia.objects.all() + serializer_class = TopicMediaSerializer + 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) + + # 允许上传时不关联 Topic (发帖前上传),或后续关联 + # 主要是返回 url + return super().create(request, *args, **kwargs) diff --git a/backend/config/__pycache__/settings.cpython-312.pyc b/backend/config/__pycache__/settings.cpython-312.pyc index 7392c167fa6c84f5264e9a14628ac8c8364aeb3a..43a7600410e5fa71b4eb7f88669e4d4ee302f5a7 100644 GIT binary patch delta 699 zcmeCs{b8VSnwOW00SHP}dNRAz85kaeI4~d#Wqj6S-l#EysXiq+C54G0RW2oUH6uhS z#dZ#JN?J-fSTqA!G@T{I1t^!9!kUtmk`0#6L6%R+P00g`<|B)yGl6uav!)aP^%l-y zjgn7cjZ#P{O0h^OPLVE2Da~ZeWK1beXG|$eDWAiXQZa`mO0kkjvvTtfrgla~-pLIt zueon=Cg8Xav+{1?}*t0FWpLVTyzHZ_332nDnQ_E8GN~)9-Q*tx&tl$R6CnlFhyi-j7ws(|KzV!C+GWIbVDc0M7d2EI=U zo126ynZmVi$SQtdV^A`VjpyFh|3C*a8WauTK~%m z0dP^E3A$jlAkoP$ME%6;nerLMxjwQNGYYVMRCj0MVrpRf5XLguSInHH0_Yw9V=eK# delta 219 zcmexi(4nhwnwOW00SLHzx-;XH85kaeI4~dtWqdxvv{7RQlSMjHipw14be5FlIm{_3 zb6BI~Q&^)EQc_baQqoe=Q!+9cGZ|CT(iu}SQ?lkTrDV@xiBha&(#+W$#oW%w$TRs7 z%j?ZIShblMg*LNrin1~qZkFO>WZG;Zn8?WJyLp<>Bt}N1$(kaKT%|yZ8G*PsXz~^j zUv^$0rUt%Ga+^6sE14$07Pl7nW(s8#=laMV$tb|~Q9YNDi>ZO_LlX03TM2WPVxT?% DGb}cV diff --git a/backend/config/__pycache__/urls.cpython-312.pyc b/backend/config/__pycache__/urls.cpython-312.pyc index 6343ef698fbfcb9c58a72fc9c6a55c2364b28fd5..5952efe811fb13fda6153028ce5ccaa6e7ff5687 100644 GIT binary patch delta 280 zcmdnU`HxfmG%qg~0}$L*>B%%;=lkml<|4jMDqqatoucsNia#LqMR4 wmw_Qlpi-z(NK<(7GselAXEOCMGVYzM!D2hPj3tPJpM#&Nf%}8lm{D@?^jxm0qwIs_xP2)N)N#pP~*gPz+v};?$NXC{O zOwukJ3e7;t*wIWk9fnL{k~EVU8iJcTArs1U(*9wlNjjatj|NCn)3jwE>9n1u{Almq z)k>?C40y(OwC8^3-0$3T&V3!aRC?$}#cWl{RSd&+!GHblA9KyVs;gM*^l>W`S(!z( zGQ8DtCP~_N6yPF`Th7e$_H`C1)M~|C z&vZrN`}R%6qKO%=*Xs-MV+nC0G$D?U&4eQHSVD{?QnEyRJQ_*M(APdui1KaTw)Pfp zTMNgzz3poP!8P8FR?gS%5BP^#-~ivt8lWwtnVAsBw($v3fcB;)CdG^t2`9yg>1ad@ z@i8H^FFqk=58+?E#>=&Kw6AXC{QhP*M15w65ZuR`hvO85^ zGeiu#(mk+gf=^KIVOD9J&%YC(E_ zGA>t^5iZu$;mnqy8FW+=uEq?Gk0hegaH1!pBeAJ*wt{fgC`*7$&#+MPG0aPG6 z`lJmZ+7&5Nx-_IoQ^s`ZAR3}VwWo{rAZiY1vj1r}y!BV`c9YnLWUSwo_oh?+w+ zg}w)z1n>bii<%bC{^Xhd-)^lUKoGDdmKtEkn5gxd*lNAo9R%P2YvinT^v%CF@SW)b zHQdzMB>B7Y?xNHyWp{RLqjdNZLM3gea5 z$pk-5&S(CkMN0TJce-2ef)vYID|$c(Q!VOnDn@#a1R@SX6E*S z2Iapacn#6`eVJ08{R!bz=#@m}!ZQkS#J0fH5^r0;vUzahf3DXy5%%-vZ1x4)IPtZs z9<0C^>>y@6h+k*+((lrB)DMgQSxgqKUzWrU7XHchE%GJ2BgxyhN%Bg$l4SWNAooRM zf_Na~dV1G0ZhBE6B#3^W&(8%-$Q`K}dVIs9J^eW;09PU{aH-8xx1$>JxHoRdQSi7| zq=TK-@ifBS!w!!E#TH4flOH14fdG(u@)1WnL28eNgNZ`G-yUgeH<3oDUH4?Zgdr&} zYXnxQYm*lhJdKB0xrAgpJHe5zC9+K`{R}fbr8IeO{>-Ci())ypOAZwU3p53kX3EtO z++))%DXvkxwlsGH1qh%~c{z?d(^bax9^N3^QRN@qY9fr*uZ$J+`9ywtGuqapaK|m} z#oC$7<4%YN5~#-WmbY3N$Rv!KTGusZmA9m$ODX_URPlVn4kK;CR( ztQU<|O`$2M6@q*N;o&yEog2>ZH2@`eINr7ta{~^-atxYjXTw$gDGHGEH)RLOjzH3U zHfbqqF@S^UMpWkB;dXCO5Dhb}_i8R~eL&*6meFR4yrf^q(gSOzRrUro=s{FwZLADB z!E>%`7fhPzPjxgs20?+3tRChfA$a-QZwv%wyrpbO?x)HnW4E#Q{)X=#5Ti?Q)yl+& zkt;}cBP;_>GI^)fJE|DfBfhO|*dG+Q3`+#qyi_QbP&ZRWFtq87P;&}*0aa_km%5yP4Xtr%!pTVc9y zcJnc}CUXDfm5Yl%x^VLs?=2pC{oy?jT_4E0HLFl~BhA9q?}oVApn8=~-K4uE2va}Z zi|_n+@$Bs4$(I*jInlUFwYN6wg00%q?#9{Bof6UrO|5L0ED^%nG3m!dqm}E zM78!qlrNa?ny%szSIz62$6j5WJu!c?D-I+ZXjoO&wgajL*XF;%8~7X5+qP>uris>; zZG$65o#sBM!vzc%{%ZYYNYzk1GQ#J-?6wzIQ}95xjPAgg-!rc>hnaq+g(;vvp?^y6 zr90r2x|)`}qtI+qxA}P|?e!Nwzp!}f=QB$A@M!QWi9RuQT-cA4Idbcb{C^#a7#1`SV2W=~MYHAWz;eyvlG4DVOD z{55P?!y1|Aaz@ZP9ztD#%)^w4C<3h*f}n<=_FC1e?`CP5#u94|K`1Op)ANvls1dDQ MM$caYc62NKKRR8_#sB~S delta 2204 zcmaKte@s(X6vyAa@3yZkw2xAFrIgY_``ThbM5fFvx-o(dH#ef7q5=Z#Kqo&1lpg|J z7L}n!)3}q&%&33tN0!XEs&%?8nk;6?7UmKYm;F$dX-Bp>b$=|*{X8uzv}~B0)ApYC zIp2HEdFQ>3Oh_XaQxBT;;~dAWL|cJI(QX{dFE)>J^Lxh>xKoH>{Nz@Ozl*<$_r#yX z@TvY6#N-C_mH8QdmJ9^$i3Z-o9|;I4>w-nLrF`&+;f|S91;z@WBI$vvg&w^}qw{lV zx@u1RS-c^BB7P9aTwtTR0OEFCuzK0sM9}i9xk?fS;R(fY6$wwRU}wr=5(<|p+R5-y z;wm)sZt?A??`>>q@9Em^D{E-8MI5%ZPfniv@cTE%FrA&mQ*7}1w)wh!3EY$`o_sb! zHnlXgH7Al&u6WWxXveqI&DC>a$Kd;PAxpO zRvXY>Uyy-O8HUI?sfLJdvLT|QG*kquub=#uPt#R_aFNU-RTz=03ssGi(}$=y5Y#`> zDH{Ehq;Yz^#iF)oxl%1>(W*GET85*;tTB1=%uC_WsmXWVy>)Ky*$&F?+O&#nlJr_q zop!gjT+5PslD<}7Qx^*s<*?#2g_V?s9GiJN1ziiD3|C&Hw$N2K%b{@EOA4T{kw|dV zOVZ((n-zsDKK|x;tMUj(iOBIE5ix#otHmyP-3^V~eA`>vJEreh!o~$Td#OSAcaq@Hep(Ite8d8OHS(j-<>i;Z;^QqaF`qHW z-a}V_-OFb|jh8n;csI?2YfZc!E;aFuu&0T4D)O+U2jF8TTL_mOEDP$JNg+NQN-Z3A zup(F`vrL%4U+7cp2T5in#0{MuW&oXwDIs5GMi`Qr1Fy3`;SIbF?>$d4;l9isi}+gM zQ78HyI8kTR8TIksz)Bahg13SHF0rL0Bu(j8De*RQoPVBQ5RA1Y#J>uj+svCo6&_Yj zh$cuT{>f=r+GTYC?vaAGw$n|Z?56ofjXyQd zz^R4w^3qbHT|Z5P*04=*tefUanm%^~Fms7O=~|Wnn|r7N?sQXU1eGAahbBv!U9K74 zG%Xn8J+QBbI+1cdj8eix50zoGnjtzM&!Fk?g4wX47pak6>Ojg7qtflhc&ZHUBY>51 z_84KxvBnc-_;M|CL&px9hZxI^VOIMrtacq@w|CG2#FBDim`N^5R9n82x}kk1eMHg> z*zNC4x5lXSoLN*wA5xh$EK*_ioK#y4CiX*Lqz{9ZY05rJ>&FO{ zwt>l!W@Jrc@~kR9?WZa@-%oAuRX@#>G;QV>YC^^62;mHUzJZk$+9vZB3gAvrcChJ<>9S+5J|F+2s#WlO(hnX3&{JqUe?(oQsh