video curcse
All checks were successful
Deploy to Server / deploy (push) Successful in 26s

This commit is contained in:
jeremygan2021
2026-02-27 13:47:32 +08:00
parent f57edbd4ee
commit 357bd75f24
9 changed files with 206 additions and 44 deletions

View File

@@ -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',)
}),
)

View File

@@ -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)

View File

@@ -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'),
),
]

View File

@@ -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="开始时间")

View File

@@ -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配置序列化器

View File

@@ -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)

View File

@@ -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="微信小程序登录",

View File

@@ -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 {

View File

@@ -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<Props> = ({ 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<Props> = ({ content, themeColor = '#00b96b' }) =>
/>
</View>
)
} else if (token.type === 'html') {
// Check for video tag
const videoRegex = /<video[^>]*>[\s\S]*?<source[^>]*src=["'](.*?)["'][^>]*>[\s\S]*?<\/video>/i
const simpleVideoRegex = /<video[^>]*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(<RichText key={`rt-${index}`} nodes={html} className='markdown-text' />)
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(
<View key={`video-${index}`} className='markdown-video-container'>
<Video
src={src}
className='markdown-video'
controls
autoplay={false}
objectFit='contain'
showFullscreenBtn
showPlayBtn
showCenterPlayBtn
enablePlayGesture
/>
{caption && <View className='markdown-video-caption'>{caption}</View>}
</View>
)
} else {
// Filter out style tags for video component if they are parsed as HTML
if (token.text.includes('<style>') && token.text.includes('.simple-tech-video')) {
// If it's JUST the style tag, ignore it. If it's mixed with other content, we might need to be careful.
// But usually marked parses block HTML separately.
// Let's verify if we can just skip it.
// If the token is ONLY the style block, we skip it.
// If it contains other content, we might need to strip the style.
// For now, let's assume it's a block HTML token.
return
}
currentTokens.push(token)
}
} else {
currentTokens.push(token)
}