This commit is contained in:
@@ -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',)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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="开始时间")
|
||||
|
||||
@@ -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配置序列化器
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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="微信小程序登录",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user