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 7392c16..43a7600 100644
Binary files a/backend/config/__pycache__/settings.cpython-312.pyc and b/backend/config/__pycache__/settings.cpython-312.pyc differ
diff --git a/backend/config/__pycache__/urls.cpython-312.pyc b/backend/config/__pycache__/urls.cpython-312.pyc
index 6343ef6..5952efe 100644
Binary files a/backend/config/__pycache__/urls.cpython-312.pyc and b/backend/config/__pycache__/urls.cpython-312.pyc differ
diff --git a/backend/config/settings.py b/backend/config/settings.py
index 634dffd..a86129e 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -44,6 +44,7 @@ INSTALLED_APPS = [
'drf_spectacular', # Swagger文档生成
'drf_spectacular_sidecar',
'shop',
+ 'community',
]
MIDDLEWARE = [
@@ -264,6 +265,32 @@ UNFOLD = {
},
],
},
+ {
+ "title": "社区与论坛",
+ "separator": True,
+ "items": [
+ {
+ "title": "活动管理",
+ "icon": "event",
+ "link": reverse_lazy("admin:community_activity_changelist"),
+ },
+ {
+ "title": "活动报名",
+ "icon": "how_to_reg",
+ "link": reverse_lazy("admin:community_activitysignup_changelist"),
+ },
+ {
+ "title": "技术论坛帖子",
+ "icon": "forum",
+ "link": reverse_lazy("admin:community_topic_changelist"),
+ },
+ {
+ "title": "帖子回复",
+ "icon": "chat_bubble",
+ "link": reverse_lazy("admin:community_reply_changelist"),
+ },
+ ],
+ },
{
"title": "系统配置",
"separator": True,
diff --git a/backend/config/urls.py b/backend/config/urls.py
index 4f02810..9fc6851 100644
--- a/backend/config/urls.py
+++ b/backend/config/urls.py
@@ -7,6 +7,7 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, Sp
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('shop.urls')),
+ path('api/community/', include('community.urls')),
# Swagger文档路由
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
diff --git a/backend/db.sqlite3 b/backend/db.sqlite3
index 8be5396..5342bc5 100644
Binary files a/backend/db.sqlite3 and b/backend/db.sqlite3 differ