创赢未来评分系统 - 初始化提交(移除大文件)
All checks were successful
Deploy to Server / deploy (push) Successful in 18s

This commit is contained in:
爽哒哒
2026-03-18 22:28:45 +08:00
commit f26d35da66
315 changed files with 36043 additions and 0 deletions

View File

404
backend/community/admin.py Normal file
View File

@@ -0,0 +1,404 @@
from django.contrib import admin
from django.utils.html import format_html
from django.urls import path, reverse
from django.shortcuts import redirect
from unfold.admin import ModelAdmin, TabularInline
from unfold.decorators import display
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
from .admin_actions import export_signups_csv, export_signups_excel
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', 'file_url', 'media_type', 'created_at')
readonly_fields = ('created_at',)
can_delete = True
class OrderableAdminMixin:
"""
为 Admin 添加排序功能的 Mixin
提供上移、下移按钮,直接交换 order 值
"""
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('<path:object_id>/move-up/', self.admin_site.admin_view(self.move_up_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_up'),
path('<path:object_id>/move-down/', self.admin_site.admin_view(self.move_down_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_down'),
]
return custom_urls + urls
def move_up_view(self, request, object_id):
obj = self.get_object(request, object_id)
if obj:
qs = self.model.objects.all()
# 如果模型有 is_pinned 字段,则只在相同置顶状态的记录中交换
if hasattr(obj, 'is_pinned'):
qs = qs.filter(is_pinned=obj.is_pinned)
# 找到排在它前面的一个 (order 小于它的最大值)
prev_obj = qs.filter(order__lt=obj.order).order_by('-order').first()
if prev_obj:
# 交换
obj.order, prev_obj.order = prev_obj.order, obj.order
obj.save()
prev_obj.save()
self.message_user(request, f"成功将 {obj} 上移")
else:
# 已经是第一个,或者前面没有更小的 order
pass
return redirect(request.META.get('HTTP_REFERER', '..'))
def move_down_view(self, request, object_id):
obj = self.get_object(request, object_id)
if obj:
qs = self.model.objects.all()
# 如果模型有 is_pinned 字段,则只在相同置顶状态的记录中交换
if hasattr(obj, 'is_pinned'):
qs = qs.filter(is_pinned=obj.is_pinned)
# 找到排在它后面的一个 (order 大于它的最小值)
next_obj = qs.filter(order__gt=obj.order).order_by('order').first()
if next_obj:
# 交换
obj.order, next_obj.order = next_obj.order, obj.order
obj.save()
next_obj.save()
self.message_user(request, f"成功将 {obj} 下移")
return redirect(request.META.get('HTTP_REFERER', '..'))
def order_actions(self, obj):
# 只有专家用户才显示排序按钮
if not getattr(obj, 'is_star', True): # 默认为True是为了兼容其他模型WeChatUser有is_star字段
return "默认排序"
# 使用 inline style 实现基本样式
btn_style = (
"display: inline-flex; align-items: center; justify-content: center; "
"width: 26px; height: 26px; border-radius: 6px; "
"background-color: #f3f4f6; color: #4b5563; text-decoration: none; "
"border: 1px solid #e5e7eb; transition: all 0.2s;"
)
# onmouseover js
hover_js = "this.style.backgroundColor='#dbeafe'; this.style.color='#2563eb'; this.style.borderColor='#bfdbfe';"
out_js = "this.style.backgroundColor='#f3f4f6'; this.style.color='#4b5563'; this.style.borderColor='#e5e7eb';"
return format_html(
'<div style="display: flex; align-items: center; gap: 6px;">'
'<a href="{}" title="上移" style="{}" onmouseover="{}" onmouseout="{}">'
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 15l-6-6-6 6"/></svg>'
'</a>'
'<span style="font-weight: 700; font-family: system-ui, -apple-system, sans-serif; min-width: 20px; text-align: center; color: #374151; font-size: 13px;">{}</span>'
'<a href="{}" title="下移" style="{}" onmouseover="{}" onmouseout="{}">'
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>'
'</a>'
'</div>',
reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_up', args=[obj.pk]),
btn_style, hover_js, out_js,
obj.order,
reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_down', args=[obj.pk]),
btn_style, hover_js, out_js,
)
order_actions.short_description = "排序调节"
order_actions.allow_tags = True
@admin.register(Activity)
class ActivityAdmin(ModelAdmin):
list_display = ('title', 'author_info_display', 'banner_display', 'start_time', 'location', 'signup_count', 'is_visible', 'is_active', 'auto_confirm', 'created_at')
list_filter = ('is_visible', 'is_active', 'auto_confirm', 'start_time')
search_fields = ('title', 'location', 'author__phone_number')
# autocomplete_fields = ['author'] # 暂时注释,避免环境不一致导致报错
raw_id_fields = ('author',)
inlines = [ActivitySignupInline]
fieldsets = (
('基本信息', {
'fields': ('title', 'author', 'description', 'banner', 'banner_url', 'is_visible', 'is_active', 'auto_confirm')
}),
('费用与时间', {
'fields': ('is_paid', 'price', 'start_time', 'end_time', 'location'),
'classes': ('tab',)
}),
('报名设置', {
'fields': ('max_participants', 'ask_name', 'ask_phone', 'ask_wechat', 'ask_company', 'signup_form_config'),
'description': '勾选需要收集的信息或者在下方“自定义报名配置”中填写高级JSON配置'
}),
)
@display(description="发布者 (手机号/昵称)")
def author_info_display(self, obj):
if not obj.author:
return "-"
phone = obj.author.phone_number or "无手机号"
nickname = obj.author.nickname or "无昵称"
return f"{phone} ({nickname})"
@display(description="Banner")
def banner_display(self, obj):
if obj.banner:
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', obj.banner.url)
elif obj.banner_url:
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', 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_info_display', 'signup_time', 'status_label', 'order_link')
list_filter = ('status', 'signup_time', 'activity')
search_fields = ('user__nickname', 'user__phone_number', 'activity__title')
autocomplete_fields = ['activity', 'user']
actions = [export_signups_csv, export_signups_excel]
fieldsets = (
('报名详情', {
'fields': ('activity', 'user', 'status', 'order', 'signup_info_display')
}),
('时间信息', {
'fields': ('signup_time',),
'classes': ('collapse',)
}),
)
readonly_fields = ('signup_time', 'signup_info_display')
@display(description="报名用户 (手机号/昵称)")
def user_info_display(self, obj):
phone = obj.user.phone_number or "无手机号"
nickname = obj.user.nickname or "无昵称"
return f"{phone} ({nickname})"
@display(description="报名信息")
def signup_info_display(self, obj):
import json
if not obj.signup_info:
return ""
try:
# Format JSON nicely
formatted_json = json.dumps(obj.signup_info, indent=2, ensure_ascii=False)
return format_html('<pre style="white-space: pre-wrap; word-break: break-all;">{}</pre>', formatted_json)
except:
return str(obj.signup_info)
@display(
description="状态",
label={
"pending": "warning",
"confirmed": "success",
"cancelled": "danger",
"unpaid": "secondary",
}
)
def status_label(self, obj):
# Auto sync with order status on display
if obj.check_payment_status():
# If status changed, return new status
return obj.status
return obj.status
@display(description="关联订单")
def order_link(self, obj):
if obj.order:
return format_html('<a href="/admin/shop/order/{}/change/">Order #{}</a>', obj.order.id, obj.order.id)
return "-"
@admin.register(Topic)
class TopicAdmin(OrderableAdminMixin, ModelAdmin):
list_display = ('title', 'status', 'category', 'author_info_display', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at', 'order_actions')
list_filter = ('status', 'category', 'is_pinned', 'created_at', 'related_product', 'related_service', 'related_course')
search_fields = ('title', 'content', 'author__nickname', 'author__phone_number')
autocomplete_fields = ['author', 'related_product', 'related_service', 'related_course']
filter_horizontal = ('likes',)
inlines = [TopicMediaInline, ReplyInline]
actions = ['reset_ordering', 'approve_topics', 'reject_topics']
list_editable = ('status', 'is_pinned', 'view_count')
@display(description="作者 (手机号/昵称)")
def author_info_display(self, obj):
if not obj.author:
return "-"
phone = obj.author.phone_number or "无手机号"
nickname = obj.author.nickname or "无昵称"
return f"{phone} ({nickname})"
@admin.action(description="批量通过审核")
def approve_topics(self, request, queryset):
rows_updated = queryset.update(status='published')
self.message_user(request, f"{rows_updated} 个帖子已通过审核")
@admin.action(description="批量拒绝")
def reject_topics(self, request, queryset):
rows_updated = queryset.update(status='rejected')
self.message_user(request, f"{rows_updated} 个帖子已拒绝")
def save_model(self, request, obj, form, change):
# 当帖子被置顶时(新建或修改状态)默认将排序值设为0
if obj.is_pinned and (not change or 'is_pinned' in form.changed_data):
obj.order = 0
super().save_model(request, obj, form, change)
@admin.action(description="重置排序 (0,1,2... 新帖子在前)")
def reset_ordering(self, request, queryset):
"""
将所有帖子按时间倒序重新分配order值 (0, 1, 2, ...)
"""
all_objects = Topic.objects.all().order_by('-created_at')
for index, obj in enumerate(all_objects):
if obj.order != index:
obj.order = index
obj.save(update_fields=['order'])
self.message_user(request, f"成功重置了 {all_objects.count()} 个帖子的排序权重从0开始")
fieldsets = (
('帖子内容', {
'fields': ('title', 'status', 'category', 'content', 'is_pinned', 'likes')
}),
('关联信息', {
'fields': ('author', 'related_product', 'related_service', 'related_course'),
'description': '可关联 硬件、服务 或 课程,用于技术求助或讨论'
}),
('统计数据', {
'fields': ('view_count', 'order', '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_info_display', 'is_pinned', 'like_count', 'created_at')
list_filter = ('is_pinned', 'created_at')
search_fields = ('content', 'author__nickname', 'author__phone_number', 'topic__title')
autocomplete_fields = ['author', 'topic', 'reply_to']
filter_horizontal = ('likes',)
list_editable = ('is_pinned',)
inlines = [TopicMediaInline]
fieldsets = (
('回复内容', {
'fields': ('topic', 'reply_to', 'content', 'likes')
}),
('发布信息', {
'fields': ('author', 'is_pinned', 'created_at')
}),
)
readonly_fields = ('created_at',)
@display(description="回复者 (手机号/昵称)")
def author_info_display(self, obj):
if not obj.author:
return "-"
phone = obj.author.phone_number or "无手机号"
nickname = obj.author.nickname or "无昵称"
return f"{phone} ({nickname})"
@display(description="点赞数")
def like_count(self, obj):
return obj.likes.count()
@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):
url = ""
if obj.file:
url = obj.file.url
elif obj.file_url:
url = obj.file_url
if obj.media_type == 'image' and url:
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', url)
return obj.file.name or "外部文件"
@admin.register(Announcement)
class AnnouncementAdmin(ModelAdmin):
list_display = ('title', 'image_preview', 'active_label', 'pinned_label', 'priority', 'start_time', 'end_time', 'created_at')
list_filter = ('is_active', 'is_pinned', 'created_at')
search_fields = ('title', 'content')
fieldsets = (
('公告信息', {
'fields': ('title', 'content', 'link_url')
}),
('图片设置', {
'fields': ('image', 'image_url'),
'description': '上传图片或填写图片链接,优先显示上传的图片'
}),
('显示设置', {
'fields': ('is_active', 'is_pinned', 'priority'),
'classes': ('tab',)
}),
('排期设置', {
'fields': ('start_time', 'end_time'),
'classes': ('tab',)
}),
)
@display(description="图片预览")
def image_preview(self, obj):
url = obj.display_image_url
if url:
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', url)
return "无图片"
@display(
description="状态",
label={
True: "success",
False: "danger",
}
)
def active_label(self, obj):
return obj.is_active
@display(
description="置顶",
label={
True: "warning",
False: "default",
}
)
def pinned_label(self, obj):
return obj.is_pinned

View File

@@ -0,0 +1,149 @@
import csv
import json
import datetime
from django.http import HttpResponse
from django.utils.encoding import escape_uri_path
def flatten_json(y):
"""
Flatten a nested json object
"""
out = {}
def flatten(x, name=''):
if type(x) is dict:
for a in x:
flatten(x[a], name + a + '_')
elif type(x) is list:
i = 0
for a in x:
flatten(a, name + str(i) + '_')
i += 1
else:
out[name[:-1]] = x
flatten(y)
return out
def get_signup_info_keys(queryset):
"""
Collect all unique keys from the signup_info JSON across the queryset
"""
keys = set()
for obj in queryset:
if obj.signup_info and isinstance(obj.signup_info, dict):
# Flatten the dictionary first to get all nested keys
flat_info = flatten_json(obj.signup_info)
keys.update(flat_info.keys())
return sorted(list(keys))
def export_signups_csv(modeladmin, request, queryset):
"""
Export selected signups to CSV, including flattened JSON fields
"""
opts = modeladmin.model._meta
filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
response = HttpResponse(content_type='text/csv; charset=utf-8-sig')
response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}'
writer = csv.writer(response)
# Base fields to export
base_headers = ['ID', '活动标题', '用户昵称', '用户ID', '报名时间', '状态', '关联订单ID']
# Get dynamic JSON keys
json_keys = get_signup_info_keys(queryset)
# Write header
writer.writerow(base_headers + json_keys)
# Write data
for obj in queryset:
row = [
str(obj.id),
obj.activity.title,
obj.user.nickname if obj.user else 'Unknown',
str(obj.user.id) if obj.user else '',
obj.signup_time.strftime('%Y-%m-%d %H:%M:%S'),
obj.get_status_display(),
str(obj.order.id) if obj.order else ''
]
# Add JSON data
flat_info = {}
if obj.signup_info and isinstance(obj.signup_info, dict):
flat_info = flatten_json(obj.signup_info)
for key in json_keys:
val = flat_info.get(key, '')
if val is None:
val = ''
row.append(str(val))
writer.writerow(row)
return response
export_signups_csv.short_description = "导出选中报名记录为 CSV (含详细信息)"
def export_signups_excel(modeladmin, request, queryset):
"""
Export selected signups to Excel, including flattened JSON fields
"""
try:
from openpyxl import Workbook
except ImportError:
modeladmin.message_user(request, "请先安装 openpyxl 库以使用 Excel 导出功能", level='error')
return
opts = modeladmin.model._meta
filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
response = HttpResponse(
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
)
response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}'
wb = Workbook()
ws = wb.active
ws.title = str(opts.verbose_name)[:31] # Sheet name limit is 31 chars
# Base fields to export
base_headers = ['ID', '活动标题', '用户昵称', '用户ID', '报名时间', '状态', '关联订单ID']
# Get dynamic JSON keys
json_keys = get_signup_info_keys(queryset)
# Write header
ws.append(base_headers + json_keys)
# Write data
for obj in queryset:
row = [
obj.id,
obj.activity.title,
obj.user.nickname if obj.user else 'Unknown',
obj.user.id if obj.user else '',
obj.signup_time.replace(tzinfo=None) if obj.signup_time else '', # Remove tz for Excel
obj.get_status_display(),
obj.order.id if obj.order else ''
]
# Add JSON data
flat_info = {}
if obj.signup_info and isinstance(obj.signup_info, dict):
flat_info = flatten_json(obj.signup_info)
for key in json_keys:
val = flat_info.get(key, '')
if val is None:
val = ''
row.append(str(val)) # Ensure string for simplicity, or handle types
ws.append(row)
wb.save(response)
return response
export_signups_excel.short_description = "导出选中报名记录为 Excel (含详细信息)"

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class CommunityConfig(AppConfig):
name = 'community'

View File

@@ -0,0 +1,144 @@
# Generated by Django 6.0.1 on 2026-03-04 04:57
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('shop', '__first__'),
]
operations = [
migrations.CreateModel(
name='Announcement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100, verbose_name='公告标题')),
('content', models.TextField(verbose_name='公告内容')),
('image', models.ImageField(blank=True, null=True, upload_to='announcements/', verbose_name='公告图片')),
('image_url', models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='图片链接')),
('link_url', models.URLField(blank=True, null=True, verbose_name='跳转链接')),
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
('is_pinned', models.BooleanField(default=False, verbose_name='是否置顶')),
('priority', models.IntegerField(default=0, help_text='数字越大越靠前', verbose_name='排序权重')),
('start_time', models.DateTimeField(blank=True, null=True, verbose_name='开始展示时间')),
('end_time', models.DateTimeField(blank=True, null=True, verbose_name='结束展示时间')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '社区公告',
'verbose_name_plural': '社区公告管理',
'ordering': ['-is_pinned', '-priority', '-created_at'],
},
),
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(blank=True, null=True, upload_to='activities/banners/', verbose_name='活动Banner图')),
('banner_url', models.URLField(blank=True, help_text='可直接填写图片链接,若同时上传图片,将优先显示上传的图片', null=True, 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_paid', models.BooleanField(default=False, verbose_name='是否收费')),
('price', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='报名费用')),
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
('is_visible', models.BooleanField(default=True, help_text='关闭后将不在前端列表页显示', verbose_name='是否显示')),
('auto_confirm', models.BooleanField(default=False, help_text='开启后,付费活动支付成功或免费活动报名后直接显示报名成功,不需要审核', verbose_name='无需审核')),
('ask_name', models.BooleanField(default=False, verbose_name='收集姓名')),
('ask_phone', models.BooleanField(default=False, verbose_name='收集手机号')),
('ask_wechat', models.BooleanField(default=False, verbose_name='收集微信号')),
('ask_company', models.BooleanField(default=False, verbose_name='收集公司/机构')),
('signup_form_config', models.JSONField(blank=True, default=list, help_text='JSON格式的高级配置若填写则优先于上方开关。例如[{"name": "job", "label": "职位", "type": "text", "required": 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='标题')),
('category', models.CharField(choices=[('discussion', '技术讨论'), ('help', '求助问答'), ('share', '经验分享'), ('notice', '官方公告')], default='discussion', max_length=20, verbose_name='分类')),
('status', models.CharField(choices=[('pending', '待审核'), ('published', '已发布'), ('rejected', '已拒绝')], default='published', max_length=20, verbose_name='状态')),
('content', models.TextField(help_text='支持Markdown格式支持插入图片', 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='更新时间')),
('order', models.IntegerField(default=0, verbose_name='排序')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='topics', to='shop.wechatuser', verbose_name='作者')),
('likes', models.ManyToManyField(blank=True, related_name='liked_topics', to='shop.wechatuser', verbose_name='点赞用户')),
('related_course', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.vccourse', verbose_name='关联课程')),
('related_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.esp32config', verbose_name='关联硬件')),
('related_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.service', verbose_name='关联服务')),
],
options={
'verbose_name': '论坛帖子',
'verbose_name_plural': '论坛帖子管理',
'ordering': ['order', '-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(help_text='支持Markdown格式', verbose_name='回复内容')),
('is_pinned', models.BooleanField(default=False, 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='回复者')),
('likes', models.ManyToManyField(blank=True, related_name='liked_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': ['-is_pinned', '-created_at'],
},
),
migrations.CreateModel(
name='TopicMedia',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(blank=True, null=True, upload_to='community/media/', verbose_name='文件')),
('file_url', models.URLField(blank=True, max_length=500, null=True, 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': '论坛媒体资源管理',
},
),
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='报名时间')),
('signup_info', models.JSONField(blank=True, default=dict, verbose_name='报名信息')),
('status', models.CharField(choices=[('unpaid', '待支付'), ('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='活动')),
('order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activity_signups', to='shop.order', 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')},
},
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 6.0.1 on 2026-03-04 04:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('community', '0001_initial'),
('shop', '__first__'),
]
operations = [
migrations.AddField(
model_name='activity',
name='author',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activities', to='shop.wechatuser', verbose_name='发布者'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 6.0.1 on 2026-03-17 11:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('community', '0002_activity_author'),
('shop', '0039_vccourse_video_embed_code'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='author',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activities', to='shop.wechatuser', to_field='phone_number', verbose_name='发布者'),
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 4.2.29 on 2026-03-18 12:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('community', '0003_alter_activity_author'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='id',
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='activitysignup',
name='id',
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='announcement',
name='id',
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='reply',
name='id',
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='topic',
name='id',
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='topicmedia',
name='id',
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

285
backend/community/models.py Normal file
View File

@@ -0,0 +1,285 @@
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图", null=True, blank=True)
banner_url = models.URLField(verbose_name="活动Banner链接", null=True, blank=True, help_text="可直接填写图片链接,若同时上传图片,将优先显示上传的图片")
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_paid = models.BooleanField(default=False, verbose_name="是否收费")
price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="报名费用")
author = models.ForeignKey(WeChatUser, to_field='phone_number', on_delete=models.SET_NULL, related_name='activities', verbose_name="发布者", null=True, blank=True)
is_active = models.BooleanField(default=True, verbose_name="是否启用")
is_visible = models.BooleanField(default=True, verbose_name="是否显示", help_text="关闭后将不在前端列表页显示")
auto_confirm = models.BooleanField(default=False, verbose_name="无需审核", help_text="开启后,付费活动支付成功或免费活动报名后直接显示报名成功,不需要审核")
# 常用报名信息开关
ask_name = models.BooleanField(default=False, verbose_name="收集姓名")
ask_phone = models.BooleanField(default=False, verbose_name="收集手机号")
ask_wechat = models.BooleanField(default=False, verbose_name="收集微信号")
ask_company = models.BooleanField(default=False, verbose_name="收集公司/机构")
signup_form_config = models.JSONField(
default=list,
verbose_name="自定义报名配置",
blank=True,
help_text='JSON格式的高级配置若填写则优先于上方开关。例如[{"name": "job", "label": "职位", "type": "text", "required": true}]'
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
def clean(self):
from django.core.exceptions import ValidationError
if not self.banner and not self.banner_url:
raise ValidationError("Banner图片和Banner链接必须至少填写一项")
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
@property
def display_banner_url(self):
"""
获取Banner显示的URL优先使用上传的图片
"""
if self.banner:
return self.banner.url
return self.banner_url
@property
def current_signups(self):
"""
当前有效报名人数(仅统计已确认/已支付的报名)
"""
return self.signups.filter(status='confirmed').count()
def __str__(self):
return self.title
class Meta:
verbose_name = "社区活动"
verbose_name_plural = "社区活动管理"
class ActivitySignup(models.Model):
"""
活动报名记录
"""
STATUS_CHOICES = (
('unpaid', '待支付'),
('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="报名时间")
signup_info = models.JSONField(
default=dict,
verbose_name="报名信息",
blank=True
)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='confirmed', verbose_name="状态")
# 关联订单(针对付费活动)
order = models.ForeignKey('shop.Order', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联订单", related_name='activity_signups')
def __str__(self):
return f"{self.user.nickname} - {self.activity.title}"
def check_payment_status(self):
"""
检查并同步关联订单的支付状态
"""
if self.status == 'unpaid' and self.order:
if self.order.status == 'paid':
self.status = 'confirmed' if self.activity.auto_confirm else 'pending'
self.save()
return True
return False
class Meta:
verbose_name = "活动报名"
verbose_name_plural = "活动报名管理"
unique_together = ('activity', 'user')
class Topic(models.Model):
"""
论坛帖子/主题
"""
title = models.CharField(max_length=200, verbose_name="标题")
CATEGORY_CHOICES = (
('discussion', '技术讨论'),
('help', '求助问答'),
('share', '经验分享'),
('notice', '官方公告'),
)
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default='discussion', verbose_name="分类")
STATUS_CHOICES = (
('pending', '待审核'),
('published', '已发布'),
('rejected', '已拒绝'),
)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='published', 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="浏览量")
likes = models.ManyToManyField(WeChatUser, related_name='liked_topics', blank=True, 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="更新时间")
order = models.IntegerField(default=0, verbose_name="排序")
def save(self, *args, **kwargs):
# 记录是否为新对象因为super().save后pk就有了
is_new = self.pk is None
# 第一次保存,先入库
super().save(*args, **kwargs)
# 如果是新创建,且 order 默认为 0未指定
if is_new and getattr(self, 'order', 0) == 0:
# 将所有其他帖子的 order + 1腾出 0 的位置
Topic.objects.exclude(pk=self.pk).filter(order__gte=0).update(order=models.F('order') + 1)
# 确保自己是 0
Topic.objects.filter(pk=self.pk).update(order=0)
self.order = 0
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:
pass
return False
class Meta:
verbose_name = "论坛帖子"
verbose_name_plural = "论坛帖子管理"
ordering = ['order', '-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="回复楼层")
likes = models.ManyToManyField(WeChatUser, related_name='liked_replies', blank=True, verbose_name="点赞用户")
is_pinned = models.BooleanField(default=False, 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 = ['-is_pinned', '-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="文件", null=True, blank=True)
file_url = models.URLField(max_length=500, verbose_name="文件链接", null=True, blank=True)
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 = "论坛媒体资源管理"
class Announcement(models.Model):
"""
社区公告模型
"""
title = models.CharField(max_length=100, verbose_name="公告标题")
content = models.TextField(verbose_name="公告内容")
image = models.ImageField(upload_to='announcements/', verbose_name="公告图片", null=True, blank=True)
image_url = models.URLField(verbose_name="图片链接", null=True, blank=True, help_text="优先使用上传的图片")
link_url = models.URLField(verbose_name="跳转链接", null=True, blank=True)
is_active = models.BooleanField(default=True, verbose_name="是否启用")
is_pinned = models.BooleanField(default=False, verbose_name="是否置顶")
priority = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越大越靠前")
start_time = models.DateTimeField(verbose_name="开始展示时间", null=True, blank=True)
end_time = models.DateTimeField(verbose_name="结束展示时间", null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
@property
def display_image_url(self):
if self.image:
return self.image.url
return self.image_url
def __str__(self):
return self.title
class Meta:
verbose_name = "社区公告"
verbose_name_plural = "社区公告管理"
ordering = ['-is_pinned', '-priority', '-created_at']

View File

@@ -0,0 +1,23 @@
from rest_framework import permissions
from .utils import get_current_wechat_user
class IsAuthorOrReadOnly(permissions.BasePermission):
"""
Object-level permission to only allow authors of an object to edit it.
Assumes the model instance has an `author` attribute.
"""
def has_object_permission(self, request, view, obj):
# Read permissions are allowed to any request,
# so we'll always allow GET, HEAD or OPTIONS requests.
if request.method in permissions.SAFE_METHODS:
return True
# Write permissions are only allowed to the author of the object.
# We need to manually get the user because we are using custom auth logic (get_current_wechat_user)
# instead of request.user for some reason (or in addition to).
# However, DRF's request.user might not be set if we don't use a standard authentication class.
# Based on views.py, it uses `get_current_wechat_user(request)`.
current_user = get_current_wechat_user(request)
return current_user and obj.author == current_user

View File

@@ -0,0 +1,190 @@
from rest_framework import serializers
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
from shop.serializers import WeChatUserSerializer, ESP32ConfigSerializer, ServiceSerializer, VCCourseSerializer
from .utils import get_current_wechat_user
class ActivitySerializer(serializers.ModelSerializer):
display_banner_url = serializers.ReadOnlyField()
signup_form_config = serializers.SerializerMethodField()
current_signups = serializers.IntegerField(read_only=True)
has_signed_up = serializers.SerializerMethodField()
is_signed_up = serializers.SerializerMethodField()
my_signup_status = serializers.SerializerMethodField()
class Meta:
model = Activity
fields = '__all__'
def get_has_signed_up(self, obj):
return self.get_is_signed_up(obj)
def get_my_signup_status(self, obj):
request = self.context.get('request')
if not request:
return None
user = get_current_wechat_user(request)
if user:
# Return the status of the non-cancelled signup
signup = obj.signups.filter(user=user).exclude(status='cancelled').first()
return signup.status if signup else None
return None
def get_is_signed_up(self, obj):
request = self.context.get('request')
if not request:
return False
user = get_current_wechat_user(request)
if user:
# Check if there is a valid signup (only confirmed counts)
return obj.signups.filter(user=user, status='confirmed').exists()
return False
def get_signup_form_config(self, obj):
# 1. 优先使用 JSON 配置
if obj.signup_form_config:
return obj.signup_form_config
# 2. 否则根据开关生成默认配置
config = []
if obj.ask_name:
config.append({"name": "name", "label": "姓名", "type": "text", "required": True})
if obj.ask_phone:
config.append({"name": "phone", "label": "手机号", "type": "number", "required": True})
if obj.ask_wechat:
config.append({"name": "wechat", "label": "微信号", "type": "text", "required": True})
if obj.ask_company:
config.append({"name": "company", "label": "公司/机构", "type": "text", "required": False})
return config
class ActivitySignupSerializer(serializers.ModelSerializer):
activity_info = serializers.SerializerMethodField()
class Meta:
model = ActivitySignup
fields = ['id', 'activity', 'activity_info', 'user', 'signup_time', 'status', 'signup_info']
read_only_fields = ['signup_time', 'status', 'user']
def get_activity_info(self, obj):
return ActivitySerializer(obj.activity).data
class TopicMediaSerializer(serializers.ModelSerializer):
url = serializers.SerializerMethodField()
class Meta:
model = TopicMedia
fields = ['id', 'file', 'file_url', 'url', 'media_type', 'created_at']
def get_url(self, obj):
if obj.file:
return obj.file.url
return obj.file_url
class ReplySerializer(serializers.ModelSerializer):
author_info = WeChatUserSerializer(source='author', read_only=True)
media = TopicMediaSerializer(many=True, read_only=True)
media_ids = serializers.ListField(
child=serializers.IntegerField(),
write_only=True,
required=False
)
like_count = serializers.IntegerField(source='likes.count', read_only=True)
is_liked = serializers.SerializerMethodField()
class Meta:
model = Reply
fields = ['id', 'topic', 'content', 'author', 'author_info', 'reply_to', 'media', 'created_at', 'media_ids', 'is_pinned', 'like_count', 'is_liked']
read_only_fields = ['author', 'created_at']
def get_is_liked(self, obj):
request = self.context.get('request')
if not request:
return False
user = get_current_wechat_user(request)
if user:
return obj.likes.filter(id=user.id).exists()
return False
def create(self, validated_data):
media_ids = validated_data.pop('media_ids', [])
reply = super().create(validated_data)
if media_ids:
TopicMedia.objects.filter(id__in=media_ids).update(reply=reply)
return reply
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)
like_count = serializers.IntegerField(source='likes.count', read_only=True)
is_liked = serializers.SerializerMethodField()
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)
media_ids = serializers.ListField(
child=serializers.IntegerField(),
write_only=True,
required=False
)
class Meta:
model = Topic
fields = [
'id', 'title', 'category', 'status', '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', 'media_ids',
'like_count', 'is_liked'
]
read_only_fields = ['author', 'view_count', 'created_at', 'updated_at', 'is_verified_owner', 'status']
def get_is_liked(self, obj):
request = self.context.get('request')
if not request:
return False
user = get_current_wechat_user(request)
if user:
return obj.likes.filter(id=user.id).exists()
return False
def create(self, validated_data):
media_ids = validated_data.pop('media_ids', [])
topic = super().create(validated_data)
if media_ids:
TopicMedia.objects.filter(id__in=media_ids).update(topic=topic)
return topic
class AnnouncementSerializer(serializers.ModelSerializer):
display_image_url = serializers.ReadOnlyField()
class Meta:
model = Announcement
fields = '__all__'
class AdminActivitySerializer(serializers.ModelSerializer):
signup_form_config = serializers.JSONField(required=False)
description = serializers.CharField(
style={'base_template': 'textarea.html'},
help_text="活动详情内容,支持 Markdown 格式。如果需要插入图片,请先调用媒体上传接口获取 URL。"
)
class Meta:
model = Activity
fields = '__all__'
read_only_fields = ['author', 'created_at', 'current_signups']
class AdminTopicSerializer(serializers.ModelSerializer):
content = serializers.CharField(
style={'base_template': 'textarea.html'},
help_text="帖子内容,支持 Markdown 格式。如果需要插入图片,请先调用媒体上传接口获取 URL。"
)
class Meta:
model = Topic
fields = '__all__'
read_only_fields = ['author', 'created_at', 'updated_at', 'view_count', 'is_verified_owner']

View File

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

15
backend/community/urls.py Normal file
View File

@@ -0,0 +1,15 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ActivityViewSet, TopicViewSet, ReplyViewSet, TopicMediaViewSet, AnnouncementViewSet, AdminPublishViewSet
router = DefaultRouter()
router.register(r'activities', ActivityViewSet)
router.register(r'topics', TopicViewSet)
router.register(r'replies', ReplyViewSet)
router.register(r'media', TopicMediaViewSet, basename='media')
router.register(r'announcements', AnnouncementViewSet)
router.register(r'admin-publish', AdminPublishViewSet, basename='admin-publish')
urlpatterns = [
path('', include(router.urls)),
]

View File

@@ -0,0 +1,55 @@
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from shop.models import WeChatUser
import logging
logger = logging.getLogger(__name__)
def get_current_wechat_user(request):
"""
根据 Authorization 头获取当前微信用户
增强逻辑:如果 Token 解析出的 OpenID 对应的用户不存在(可能已被合并删除),
但该 OpenID 是 Web 虚拟 ID (web_phone),尝试通过手机号查找现有的主账号。
"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
logger.warning(f"Authentication failed: Missing or invalid Authorization header. Header: {auth_header}")
return None
token = auth_header.split(' ')[1]
signer = TimestampSigner()
try:
# 签名包含 openid
openid = signer.unsign(token, max_age=86400 * 30) # 30天有效
user = WeChatUser.objects.filter(openid=openid).first()
if user:
return user
# 如果没找到用户,检查是否是 Web 虚拟 OpenID
# 场景Web 用户已被合并到小程序账号,旧 Web Token 依然有效,指向合并后的账号
logger.info(f"User not found for openid: {openid}, checking for merged account...")
if openid.startswith('web_'):
try:
# 格式: web_13800138000
parts = openid.split('_', 1)
if len(parts) == 2:
phone = parts[1]
# 尝试通过手机号查找(查找合并后的主账号)
user = WeChatUser.objects.filter(phone_number=phone).first()
if user:
logger.info(f"Found merged user {user.id} for phone {phone}")
return user
except Exception as e:
logger.error(f"Error checking merged account: {e}")
pass
logger.warning(f"Authentication failed: User not found for openid {openid}")
return None
except SignatureExpired:
logger.warning("Authentication failed: Signature expired")
return None
except BadSignature:
logger.warning("Authentication failed: Bad signature")
return None
except Exception as e:
logger.error(f"Authentication unexpected error: {e}")
return None

516
backend/community/views.py Normal file
View File

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