From 357bd75f249d837cb9690fa58401dbebc5762d21 Mon Sep 17 00:00:00 2001 From: jeremygan2021 Date: Fri, 27 Feb 2026 13:47:32 +0800 Subject: [PATCH] video curcse --- backend/community/admin.py | 22 ++++++- backend/community/views.py | 2 +- ...urse_is_video_course_vccourse_video_url.py | 23 ++++++++ backend/shop/models.py | 4 ++ backend/shop/serializers.py | 34 +++++++++++ backend/shop/utils.py | 41 ++++++++++++- backend/shop/views.py | 40 +------------ .../src/components/MarkdownReader/index.scss | 26 +++++++++ .../src/components/MarkdownReader/index.tsx | 58 ++++++++++++++++++- 9 files changed, 206 insertions(+), 44 deletions(-) create mode 100644 backend/shop/migrations/0038_vccourse_is_video_course_vccourse_video_url.py diff --git a/backend/community/admin.py b/backend/community/admin.py index ccf2896..9811c82 100644 --- a/backend/community/admin.py +++ b/backend/community/admin.py @@ -46,8 +46,13 @@ class OrderableAdminMixin: 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 = self.model.objects.filter(order__lt=obj.order).order_by('-order').first() + 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 @@ -62,8 +67,13 @@ class OrderableAdminMixin: 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 = self.model.objects.filter(order__gt=obj.order).order_by('order').first() + 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 @@ -202,6 +212,12 @@ class TopicAdmin(OrderableAdminMixin, ModelAdmin): inlines = [TopicMediaInline, ReplyInline] actions = ['reset_ordering'] + 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): """ @@ -223,7 +239,7 @@ class TopicAdmin(OrderableAdminMixin, ModelAdmin): 'description': '可关联 硬件、服务 或 课程,用于技术求助或讨论' }), ('统计数据', { - 'fields': ('view_count', 'created_at', 'updated_at'), + 'fields': ('view_count', 'order', 'created_at', 'updated_at'), 'classes': ('collapse',) }), ) diff --git a/backend/community/views.py b/backend/community/views.py index 24fbdc1..b510573 100644 --- a/backend/community/views.py +++ b/backend/community/views.py @@ -260,7 +260,7 @@ class TopicViewSet(viewsets.ModelViewSet): search_fields = ['title', 'content'] filterset_fields = ['category', 'is_pinned'] ordering_fields = ['created_at', 'view_count', 'order'] - ordering = ['order', '-is_pinned', '-created_at'] + ordering = ['-is_pinned', 'order', '-created_at'] def perform_create(self, serializer): user = get_current_wechat_user(self.request) diff --git a/backend/shop/migrations/0038_vccourse_is_video_course_vccourse_video_url.py b/backend/shop/migrations/0038_vccourse_is_video_course_vccourse_video_url.py new file mode 100644 index 0000000..f08bbee --- /dev/null +++ b/backend/shop/migrations/0038_vccourse_is_video_course_vccourse_video_url.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-02-27 05:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0037_wechatuser_has_web_badge'), + ] + + operations = [ + migrations.AddField( + model_name='vccourse', + name='is_video_course', + field=models.BooleanField(default=False, verbose_name='是否视频课程'), + ), + migrations.AddField( + model_name='vccourse', + name='video_url', + field=models.URLField(blank=True, help_text='仅当用户付费或报名后可见', null=True, verbose_name='视频课程URL'), + ), + ] diff --git a/backend/shop/models.py b/backend/shop/models.py index 1f30c0b..9295c2a 100644 --- a/backend/shop/models.py +++ b/backend/shop/models.py @@ -362,6 +362,10 @@ class VCCourse(models.Model): tag = models.CharField(max_length=20, blank=True, verbose_name="标签", help_text="例如: 热门, 推荐, 进阶") + # 视频课程相关 + is_video_course = models.BooleanField(default=False, verbose_name="是否视频课程") + video_url = models.URLField(blank=True, null=True, verbose_name="视频课程URL", help_text="仅当用户付费或报名后可见") + # 课程时间安排 is_fixed_schedule = models.BooleanField(default=False, verbose_name="是否固定时间课程", help_text="勾选后,前端将显示具体的开课时间") start_time = models.DateTimeField(blank=True, null=True, verbose_name="开始时间") diff --git a/backend/shop/serializers.py b/backend/shop/serializers.py index 382f80a..98de22e 100644 --- a/backend/shop/serializers.py +++ b/backend/shop/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers from .models import ESP32Config, Order, Salesperson, Service, VCCourse, ProductFeature, ServiceOrder, WeChatUser, Distributor, Withdrawal, CommissionLog, CourseEnrollment +from .utils import get_current_wechat_user class CommissionLogSerializer(serializers.ModelSerializer): """ @@ -174,6 +175,7 @@ class VCCourseSerializer(serializers.ModelSerializer): display_cover_image = serializers.SerializerMethodField() display_detail_image = serializers.SerializerMethodField() course_type_display = serializers.CharField(source='get_course_type_display', read_only=True) + video_url = serializers.SerializerMethodField() class Meta: model = VCCourse @@ -193,6 +195,38 @@ class VCCourseSerializer(serializers.ModelSerializer): return obj.detail_image.url return None + def get_video_url(self, obj): + """ + 仅当用户已付费/报名时返回视频URL + """ + if not obj.is_video_course: + return None + + request = self.context.get('request') + if not request: + return None + + # 尝试获取当前用户 + user = get_current_wechat_user(request) + if not user: + return None + + # 如果是管理员,直接返回 + if user.user and user.user.is_staff: + return obj.video_url + + # 检查是否已购买/报名 (通过已支付的订单) + has_paid = Order.objects.filter( + wechat_user=user, + course=obj, + status__in=['paid', 'shipped', 'completed'] # 包含所有已完成状态 + ).exists() + + if has_paid: + return obj.video_url + + return None + class ESP32ConfigSerializer(serializers.ModelSerializer): """ ESP32配置序列化器 diff --git a/backend/shop/utils.py b/backend/shop/utils.py index 175e816..c0e9b69 100644 --- a/backend/shop/utils.py +++ b/backend/shop/utils.py @@ -1,11 +1,50 @@ import requests from django.core.cache import cache -from .models import WeChatPayConfig +from django.core.signing import TimestampSigner, BadSignature, SignatureExpired +from .models import WeChatPayConfig, 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 '): + 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 依然有效,指向合并后的账号 + 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: + return user + except Exception: + pass + + return None + except (BadSignature, SignatureExpired): + return None + def get_access_token(config=None): """ 获取微信接口调用凭证 (client_credential) diff --git a/backend/shop/views.py b/backend/shop/views.py index a360039..7bd1d99 100644 --- a/backend/shop/views.py +++ b/backend/shop/views.py @@ -10,7 +10,7 @@ from django.http import HttpResponse from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample from .models import ESP32Config, Order, WeChatPayConfig, Service, VCCourse, ServiceOrder, Salesperson, CommissionLog, WeChatUser, Distributor, Withdrawal, CourseEnrollment from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, VCCourseSerializer, ServiceOrderSerializer, WeChatUserSerializer, DistributorSerializer, WithdrawalSerializer, CommissionLogSerializer, CourseEnrollmentSerializer -from .utils import get_access_token +from .utils import get_access_token, get_current_wechat_user from .services import handle_post_payment from django.db import transaction, models from django.core.signing import TimestampSigner, BadSignature, SignatureExpired @@ -934,43 +934,7 @@ class OrderViewSet(viewsets.ModelViewSet): return Response({'status': 'success', 'message': '支付成功'}) -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 '): - 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 依然有效,指向合并后的账号 - 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: - return user - except Exception: - pass - - return None - except (BadSignature, SignatureExpired): - return None + @extend_schema( summary="微信小程序登录", diff --git a/miniprogram/src/components/MarkdownReader/index.scss b/miniprogram/src/components/MarkdownReader/index.scss index 5c27351..6ee2967 100644 --- a/miniprogram/src/components/MarkdownReader/index.scss +++ b/miniprogram/src/components/MarkdownReader/index.scss @@ -10,6 +10,32 @@ max-width: 100%; } } + + .markdown-video-container { + width: 100%; + margin: 16px 0; + border-radius: 12px; + overflow: hidden; + position: relative; + background: #0a0a1a; + border: 1px solid rgba(0, 243, 255, 0.3); + box-shadow: 0 0 20px rgba(0, 243, 255, 0.1); + + .markdown-video { + width: 100%; + height: 225px; + display: block; + } + + .markdown-video-caption { + padding: 12px; + background: rgba(10, 10, 26, 0.9); + color: rgba(0, 243, 255, 0.9); + font-size: 14px; + text-align: center; + border-top: 1px solid rgba(255, 255, 255, 0.1); + } + } } .markdown-code-block { diff --git a/miniprogram/src/components/MarkdownReader/index.tsx b/miniprogram/src/components/MarkdownReader/index.tsx index f90c6e6..8db2af2 100644 --- a/miniprogram/src/components/MarkdownReader/index.tsx +++ b/miniprogram/src/components/MarkdownReader/index.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react' -import { View, RichText } from '@tarojs/components' +import { View, RichText, Video } from '@tarojs/components' import { marked, Renderer } from 'marked' import CodeBlock from './CodeBlock' import './index.scss' @@ -51,6 +51,11 @@ const MarkdownReader: React.FC = ({ content, themeColor = '#00b96b' }) => // Process tokens tokens.forEach((token, index) => { if (token.type === 'code') { + // Skip css blocks that look like the video component styles + if ((token.lang === 'css' || !token.lang) && token.text.includes('.simple-tech-video')) { + return + } + // Flush accumulated tokens if (currentTokens.length > 0) { // preserve links if any @@ -69,6 +74,57 @@ const MarkdownReader: React.FC = ({ content, themeColor = '#00b96b' }) => /> ) + } else if (token.type === 'html') { + // Check for video tag + const videoRegex = /]*>[\s\S]*?]*src=["'](.*?)["'][^>]*>[\s\S]*?<\/video>/i + const simpleVideoRegex = /]*src=["'](.*?)["'][^>]*>/i + + const match = token.text.match(videoRegex) || token.text.match(simpleVideoRegex) + + if (match) { + // Flush accumulated tokens + if (currentTokens.length > 0) { + (currentTokens as any).links = (tokens as any).links + const html = marked.parser(currentTokens as any, { renderer, breaks: true }) + result.push() + currentTokens = [] + } + + const src = match[1] + // Try to extract caption + const captionRegex = /class=["']video-caption["'][^>]*>(.*?)<\/div>/i + const captionMatch = token.text.match(captionRegex) + const caption = captionMatch ? captionMatch[1] : null + + result.push( + + + ) + } else { + // Filter out style tags for video component if they are parsed as HTML + if (token.text.includes('