forked from quant-speed-AI/Scoring-System
创赢未来评分系统 - 初始化提交(移除大文件)
This commit is contained in:
0
backend/shop/__init__.py
Normal file
0
backend/shop/__init__.py
Normal file
548
backend/shop/admin.py
Normal file
548
backend/shop/admin.py
Normal file
@@ -0,0 +1,548 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.db.models import Sum
|
||||
from django import forms
|
||||
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 ESP32Config, Order, Salesperson, WeChatPayConfig, Service, VCCourse, ProductFeature, CommissionLog, WeChatUser, Distributor, Withdrawal, ServiceOrder, CourseEnrollment, AdminPhoneNumber, IdentityTag, UserIdentity
|
||||
from .admin_actions import export_to_csv, export_to_excel
|
||||
import qrcode
|
||||
from io import BytesIO
|
||||
import base64
|
||||
|
||||
# 自定义后台标题
|
||||
admin.site.site_header = "创赢未来评分系统"
|
||||
admin.site.site_title = "创赢未来"
|
||||
admin.site.index_title = "欢迎使用创赢未来评分系统"
|
||||
|
||||
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:
|
||||
# 找到排在它前面的一个 (order 小于它的最大值)
|
||||
prev_obj = self.model.objects.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
|
||||
# 尝试查找 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:
|
||||
# 找到排在它后面的一个 (order 大于它的最小值)
|
||||
next_obj = self.model.objects.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 实现基本样式,hover 效果如果不能用 CSS 文件,就只能妥协或者用 onmouseover
|
||||
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
|
||||
|
||||
|
||||
class ExternalUploadWidget(forms.URLInput):
|
||||
def __init__(self, upload_url, accept='*', *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.upload_url = upload_url
|
||||
self.attrs.update({
|
||||
'class': 'upload-url-input vTextField',
|
||||
'data-upload-url': upload_url,
|
||||
'data-accept': accept,
|
||||
'placeholder': '上传文件后自动生成URL',
|
||||
'style': 'width: 100%;'
|
||||
})
|
||||
|
||||
class Media:
|
||||
js = ('shop/js/admin_upload.js',)
|
||||
css = {
|
||||
'all': ('shop/css/admin_upload.css',)
|
||||
}
|
||||
|
||||
class ESP32ConfigAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ESP32Config
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'static_image_url': ExternalUploadWidget(
|
||||
upload_url='https://data.tangledup-ai.com/upload?folder=market_page%2Fhardware_xiaozhi%2Fproduct_static_image',
|
||||
accept='image/*'
|
||||
),
|
||||
'model_3d_url': ExternalUploadWidget(
|
||||
upload_url='https://data.tangledup-ai.com/upload?folder=market_page%2Fhardware_xiaozhi%2Fproduct_3D_image',
|
||||
accept='.zip'
|
||||
),
|
||||
}
|
||||
|
||||
class ProductFeatureInline(TabularInline):
|
||||
model = ProductFeature
|
||||
extra = 1
|
||||
fields = ('title', 'description', 'icon_name', 'icon_image', 'icon_url', 'order')
|
||||
|
||||
@admin.register(WeChatPayConfig)
|
||||
class WeChatPayConfigAdmin(ModelAdmin):
|
||||
list_display = ('app_id', 'mch_id', 'is_active', 'notify_url', 'updated_at_display')
|
||||
list_filter = ('is_active',)
|
||||
search_fields = ('app_id', 'mch_id')
|
||||
|
||||
def updated_at_display(self, obj):
|
||||
# 假设模型没有 updated_at,如果有可以显示,这里仅作占位或移除
|
||||
return "N/A"
|
||||
updated_at_display.short_description = "更新时间"
|
||||
|
||||
fieldsets = (
|
||||
('核心配置 (登录与支付)', {
|
||||
'fields': ('app_id', 'app_secret', 'mch_id', 'is_active'),
|
||||
'description': 'AppID 和 AppSecret 是小程序登录和支付的基础凭证。请确保 AppID 与小程序后台一致 (项目中优先使用 wxdf2ca73e6c0929f0)。'
|
||||
}),
|
||||
('微信支付 V3 安全配置 (推荐)', {
|
||||
'fields': ('apiv3_key', 'mch_cert_serial_no', 'mch_private_key'),
|
||||
'description': '使用 Native 支付必须配置这些项。私钥可以粘贴在这里,或者放在 backend/certs/apiclient_key.pem 文件中。'
|
||||
}),
|
||||
('微信支付 V2 安全配置 (旧版)', {
|
||||
'fields': ('api_key',),
|
||||
'classes': ('collapse',),
|
||||
'description': '仅旧版支付接口需要 API Key (V2)。'
|
||||
}),
|
||||
('回调配置', {
|
||||
'fields': ('notify_url',)
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(ESP32Config)
|
||||
class ESP32ConfigAdmin(OrderableAdminMixin, ModelAdmin):
|
||||
form = ESP32ConfigAdminForm
|
||||
list_display = ('name', 'chip_type', 'price', 'stock', 'has_camera', 'has_microphone', 'order_actions')
|
||||
list_filter = ('chip_type', 'has_camera')
|
||||
search_fields = ('name', 'description')
|
||||
inlines = [ProductFeatureInline]
|
||||
fieldsets = (
|
||||
('基本信息', {
|
||||
'fields': ('name', 'price', 'stock', 'commission_rate', 'description')
|
||||
}),
|
||||
('硬件参数', {
|
||||
'fields': ('chip_type', 'flash_size', 'ram_size', 'has_camera', 'has_microphone')
|
||||
}),
|
||||
('详情页图片', {
|
||||
'fields': ('detail_image', 'detail_image_url'),
|
||||
'description': '图片上传和URL二选一,优先使用URL'
|
||||
}),
|
||||
('多媒体资源', {
|
||||
'fields': ('static_image_url', 'model_3d_url'),
|
||||
'description': '产品静态图和3D模型的外部链接'
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(Service)
|
||||
class ServiceAdmin(OrderableAdminMixin, ModelAdmin):
|
||||
list_display = ('title', 'created_at', 'order_actions')
|
||||
search_fields = ('title', 'description')
|
||||
fieldsets = (
|
||||
('基本信息', {
|
||||
'fields': ('title', 'description', 'color')
|
||||
}),
|
||||
('价格与交付', {
|
||||
'fields': ('price', 'unit', 'delivery_time', 'delivery_content')
|
||||
}),
|
||||
('图标', {
|
||||
'fields': ('icon', 'icon_url'),
|
||||
'description': '图标上传和URL二选一,优先使用URL'
|
||||
}),
|
||||
('详情页图片', {
|
||||
'fields': ('detail_image', 'detail_image_url'),
|
||||
'description': '图片上传和URL二选一,优先使用URL'
|
||||
}),
|
||||
('详细内容', {
|
||||
'fields': ('features',)
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(ServiceOrder)
|
||||
class ServiceOrderAdmin(ModelAdmin):
|
||||
list_display = ('id', 'customer_name', 'service', 'total_price', 'status', 'salesperson', 'created_at')
|
||||
list_filter = ('status', 'service', 'salesperson', 'created_at')
|
||||
search_fields = ('id', 'customer_name', 'phone_number', 'email')
|
||||
readonly_fields = ('total_price', 'created_at', 'updated_at')
|
||||
|
||||
fieldsets = (
|
||||
('订单信息', {
|
||||
'fields': ('service', 'status', 'total_price', 'created_at')
|
||||
}),
|
||||
('客户信息', {
|
||||
'fields': ('customer_name', 'company_name', 'phone_number', 'email', 'requirements')
|
||||
}),
|
||||
('销售归属', {
|
||||
'fields': ('salesperson',)
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(VCCourse)
|
||||
class VCCourseAdmin(OrderableAdminMixin, ModelAdmin):
|
||||
list_display = ('title', 'course_type', 'is_video_course', 'price', 'tag', 'instructor', 'lesson_count', 'duration', 'created_at', 'order_actions')
|
||||
search_fields = ('title', 'description', 'instructor', 'tag')
|
||||
list_filter = ('course_type', 'is_video_course', 'instructor', 'tag')
|
||||
actions = ['reset_ordering']
|
||||
|
||||
@admin.action(description="重置排序 (按ID顺序)")
|
||||
def reset_ordering(self, request, queryset):
|
||||
"""
|
||||
将选中的课程(或全部)按ID顺序重新分配order值
|
||||
"""
|
||||
# 如果没有选中任何项,默认处理所有(Django Admin默认行为是选中了才会触发Action,但为了稳健)
|
||||
# 这里既然是Action,用户必须选中。建议用户选中所有。
|
||||
# 为了方便,如果用户只选了一个,我们可以提示他选更多,或者我们其实可以忽略queryset,直接重置所有?
|
||||
# 通常Action是针对queryset的。
|
||||
# 更好的做法:对选中的queryset按ID排序,然后更新order。
|
||||
|
||||
# 这种实现方式:只重置选中的部分,可能会导致order冲突。
|
||||
# 稳妥方式:重置整个表的排序。
|
||||
|
||||
all_objects = VCCourse.objects.all().order_by('id')
|
||||
for index, obj in enumerate(all_objects, start=1):
|
||||
obj.order = index
|
||||
obj.save(update_fields=['order'])
|
||||
|
||||
self.message_user(request, f"成功重置了 {all_objects.count()} 个课程的排序权重。")
|
||||
|
||||
fieldsets = (
|
||||
('基本信息', {
|
||||
'fields': ('title', 'description', 'course_type', 'tag', 'price')
|
||||
}),
|
||||
('视频设置', {
|
||||
'fields': ('is_video_course', 'video_url', 'video_embed_code'),
|
||||
'description': '设置是否为视频课程及视频链接'
|
||||
}),
|
||||
('课程安排', {
|
||||
'fields': ('is_fixed_schedule', 'start_time', 'end_time'),
|
||||
'description': '勾选“是否固定时间课程”后,请设置开始和结束时间'
|
||||
}),
|
||||
('讲师信息', {
|
||||
'fields': ('instructor', 'instructor_title', 'instructor_desc', 'instructor_avatar', 'instructor_avatar_url'),
|
||||
'description': '讲师头像上传和URL二选一,优先使用URL'
|
||||
}),
|
||||
('课程详情', {
|
||||
'fields': ('duration', 'lesson_count', 'content')
|
||||
}),
|
||||
('封面', {
|
||||
'fields': ('cover_image', 'cover_image_url'),
|
||||
'description': '图片上传和URL二选一,优先使用URL'
|
||||
}),
|
||||
('详情页长图', {
|
||||
'fields': ('detail_image', 'detail_image_url'),
|
||||
'description': '图片上传和URL二选一,优先使用URL'
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(CourseEnrollment)
|
||||
class CourseEnrollmentAdmin(ModelAdmin):
|
||||
list_display = ('customer_name', 'course', 'phone_number', 'status', 'created_at')
|
||||
list_filter = ('status', 'course', 'created_at')
|
||||
search_fields = ('customer_name', 'phone_number', 'wechat_id')
|
||||
|
||||
fieldsets = (
|
||||
('报名信息', {
|
||||
'fields': ('course', 'status', 'created_at')
|
||||
}),
|
||||
('客户资料', {
|
||||
'fields': ('customer_name', 'phone_number', 'wechat_id', 'email', 'message')
|
||||
}),
|
||||
('销售归属', {
|
||||
'fields': ('salesperson', 'distributor')
|
||||
}),
|
||||
)
|
||||
|
||||
# 分销员管理已隐藏 - 取消注册
|
||||
# @admin.register(Salesperson)
|
||||
class SalespersonAdmin(ModelAdmin):
|
||||
pass
|
||||
|
||||
# 分销佣金记录已隐藏
|
||||
# @admin.register(CommissionLog)
|
||||
class CommissionLogAdmin(ModelAdmin):
|
||||
list_display = ('id', 'salesperson', 'distributor', 'amount', 'level', 'status', 'created_at')
|
||||
list_filter = ('status', 'level', 'salesperson', 'distributor', 'created_at')
|
||||
search_fields = ('salesperson__name', 'distributor__user__nickname', 'distributor__user__phone_number', 'order__id')
|
||||
readonly_fields = ('amount', 'level', 'created_at')
|
||||
|
||||
fieldsets = (
|
||||
('基本信息', {
|
||||
'fields': ('salesperson', 'distributor', 'order', 'amount', 'level')
|
||||
}),
|
||||
('状态管理', {
|
||||
'fields': ('status', 'created_at')
|
||||
}),
|
||||
)
|
||||
|
||||
class GenderFilter(admin.SimpleListFilter):
|
||||
title = '性别'
|
||||
parameter_name = 'gender'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
(1, '男'),
|
||||
(2, '女'),
|
||||
(0, '未知'),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value():
|
||||
return queryset.filter(gender=self.value())
|
||||
return queryset
|
||||
|
||||
class UserSourceFilter(admin.SimpleListFilter):
|
||||
title = '用户来源'
|
||||
parameter_name = 'user_source'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('miniprogram', '仅小程序用户'),
|
||||
('both', '网页小程序都已注册'),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value() == 'miniprogram':
|
||||
return queryset.filter(user__isnull=True)
|
||||
if self.value() == 'both':
|
||||
return queryset.filter(user__isnull=False)
|
||||
return queryset
|
||||
|
||||
class PriceRangeFilter(admin.SimpleListFilter):
|
||||
title = '价格区间'
|
||||
parameter_name = 'price_range'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('0-50', '¥0 - ¥50'),
|
||||
('50-100', '¥50 - ¥100'),
|
||||
('100-500', '¥100 - ¥500'),
|
||||
('500-1000', '¥500 - ¥1000'),
|
||||
('1000+', '¥1000以上'),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
value = self.value()
|
||||
if value == '0-50':
|
||||
return queryset.filter(total_price__gte=0, total_price__lte=50)
|
||||
elif value == '50-100':
|
||||
return queryset.filter(total_price__gt=50, total_price__lte=100)
|
||||
elif value == '100-500':
|
||||
return queryset.filter(total_price__gt=100, total_price__lte=500)
|
||||
elif value == '500-1000':
|
||||
return queryset.filter(total_price__gt=500, total_price__lte=1000)
|
||||
elif value == '1000+':
|
||||
return queryset.filter(total_price__gt=1000)
|
||||
return queryset
|
||||
|
||||
class ProductTypeFilter(admin.SimpleListFilter):
|
||||
title = '商品类型'
|
||||
parameter_name = 'product_type'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('hardware', '硬件产品'),
|
||||
('course', '课程'),
|
||||
('activity', '活动'),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
value = self.value()
|
||||
if value == 'hardware':
|
||||
return queryset.filter(config__isnull=False)
|
||||
elif value == 'course':
|
||||
return queryset.filter(course__isnull=False)
|
||||
elif value == 'activity':
|
||||
return queryset.filter(activity__isnull=False)
|
||||
return queryset
|
||||
|
||||
@admin.register(Order)
|
||||
class OrderAdmin(ModelAdmin):
|
||||
list_display = ('id', 'customer_name', 'get_item_name', 'total_price', 'status', 'salesperson', 'distributor', 'created_at')
|
||||
list_filter = ('status', ProductTypeFilter, 'config', 'course', 'activity', PriceRangeFilter, 'salesperson', 'distributor', 'created_at')
|
||||
search_fields = ('id', 'customer_name', 'phone_number', 'wechat_trade_no', 'wechat_user__phone_number')
|
||||
readonly_fields = ('total_price', 'created_at', 'wechat_trade_no')
|
||||
actions = [export_to_csv, export_to_excel]
|
||||
|
||||
def get_item_name(self, obj):
|
||||
if obj.config:
|
||||
return f"[硬件] {obj.config.name}"
|
||||
if obj.course:
|
||||
return f"[课程] {obj.course.title}"
|
||||
if obj.activity:
|
||||
return f"[活动] {obj.activity.title}"
|
||||
return "未知商品"
|
||||
get_item_name.short_description = "购买商品"
|
||||
|
||||
fieldsets = (
|
||||
('订单信息', {
|
||||
'fields': ('config', 'course', 'activity', 'quantity', 'total_price', 'status', 'created_at')
|
||||
}),
|
||||
('客户信息', {
|
||||
'fields': ('customer_name', 'phone_number', 'shipping_address', 'wechat_user')
|
||||
}),
|
||||
('物流信息', {
|
||||
'fields': ('courier_name', 'tracking_number')
|
||||
}),
|
||||
('销售归属', {
|
||||
'fields': ('salesperson', 'distributor')
|
||||
}),
|
||||
('支付信息', {
|
||||
'fields': ('wechat_trade_no',)
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(WeChatUser)
|
||||
class WeChatUserAdmin(OrderableAdminMixin, ModelAdmin):
|
||||
list_display = ('nickname', 'phone_number', 'is_star', 'title', 'avatar_display', 'gender_display', 'province', 'city', 'created_at', 'order_actions')
|
||||
search_fields = ('nickname', 'openid', 'phone_number')
|
||||
list_filter = ('is_star', GenderFilter, UserSourceFilter, 'province', 'city', 'created_at')
|
||||
readonly_fields = ('openid', 'unionid', 'session_key', 'created_at', 'updated_at')
|
||||
actions = [export_to_csv, export_to_excel]
|
||||
|
||||
def avatar_display(self, obj):
|
||||
if obj.avatar_url:
|
||||
return format_html('<img src="{}" width="50" height="50" style="border-radius: 50%;" />', obj.avatar_url)
|
||||
return "暂无"
|
||||
avatar_display.short_description = "头像"
|
||||
|
||||
def gender_display(self, obj):
|
||||
choices = {0: '未知', 1: '男', 2: '女'}
|
||||
return choices.get(obj.gender, '未知')
|
||||
gender_display.short_description = "性别"
|
||||
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
fieldsets = [
|
||||
('基本信息', {
|
||||
'fields': ('user', 'nickname', 'phone_number', 'avatar_url', 'gender')
|
||||
}),
|
||||
]
|
||||
|
||||
if obj and obj.is_star:
|
||||
fieldsets.append(('专家认证', {
|
||||
'fields': ('is_star', 'title', 'skills', 'order'),
|
||||
'description': '标记为明星技术用户/专家,将在社区中展示'
|
||||
}))
|
||||
else:
|
||||
fieldsets.append(('专家认证', {
|
||||
'fields': ('is_star',),
|
||||
'description': '标记为明星技术用户/专家,将在社区中展示。保存后若为专家用户,可进一步编辑专家信息。'
|
||||
}))
|
||||
|
||||
fieldsets.append(('位置信息', {
|
||||
'fields': ('country', 'province', 'city')
|
||||
}))
|
||||
|
||||
fieldsets.append(('认证信息', {
|
||||
'fields': ('openid', 'unionid', 'session_key'),
|
||||
'classes': ('collapse',)
|
||||
}))
|
||||
|
||||
fieldsets.append(('时间信息', {
|
||||
'fields': ('created_at', 'updated_at')
|
||||
}))
|
||||
|
||||
return fieldsets
|
||||
|
||||
# 小程序分销员已隐藏 - 取消注册
|
||||
# @admin.register(Distributor)
|
||||
class DistributorAdmin(ModelAdmin):
|
||||
pass
|
||||
|
||||
# 提现管理已隐藏
|
||||
# @admin.register(Withdrawal)
|
||||
class WithdrawalAdmin(ModelAdmin):
|
||||
pass
|
||||
|
||||
@admin.register(AdminPhoneNumber)
|
||||
class AdminPhoneNumberAdmin(ModelAdmin):
|
||||
list_display = ('name', 'phone_number', 'is_active', 'created_at')
|
||||
list_filter = ('is_active',)
|
||||
search_fields = ('name', 'phone_number')
|
||||
|
||||
|
||||
class UserIdentityInline(TabularInline):
|
||||
model = UserIdentity
|
||||
extra = 1
|
||||
autocomplete_fields = ['tag']
|
||||
|
||||
|
||||
@admin.register(IdentityTag)
|
||||
class IdentityTagAdmin(ModelAdmin):
|
||||
list_display = ('name', 'color_preview', 'icon', 'sort_order', 'is_active', 'created_at')
|
||||
list_editable = ['sort_order', 'is_active']
|
||||
list_filter = ('is_active', 'created_at')
|
||||
search_fields = ('name', 'description')
|
||||
|
||||
@display(description='颜色预览')
|
||||
def color_preview(self, obj):
|
||||
return format_html(
|
||||
'<span style="display: inline-block; width: 20px; height: 20px; background-color: {}; border-radius: 4px; border: 1px solid #ddd;"></span> {}',
|
||||
obj.color, obj.color
|
||||
)
|
||||
|
||||
|
||||
@admin.register(UserIdentity)
|
||||
class UserIdentityAdmin(ModelAdmin):
|
||||
list_display = ('user_info', 'tag', 'assigned_at', 'assigned_by')
|
||||
list_filter = ('tag', 'assigned_at')
|
||||
search_fields = ('user__nickname', 'user__phone_number', 'user__openid', 'tag__name')
|
||||
autocomplete_fields = ['user', 'tag']
|
||||
date_hierarchy = 'assigned_at'
|
||||
|
||||
@display(description='用户信息')
|
||||
def user_info(self, obj):
|
||||
return f"{obj.user.nickname or ''} {obj.user.phone_number or ''}".strip() or obj.user.openid[:20]
|
||||
110
backend/shop/admin_actions.py
Normal file
110
backend/shop/admin_actions.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import csv
|
||||
import datetime
|
||||
from django.http import HttpResponse
|
||||
from django.utils.encoding import escape_uri_path
|
||||
|
||||
def export_to_csv(modeladmin, request, queryset):
|
||||
"""
|
||||
通用导出 CSV 的 Admin Action
|
||||
支持中文编码(UTF-8 BOM),可直接用 Excel 打开
|
||||
"""
|
||||
opts = modeladmin.model._meta
|
||||
# 设置文件名,使用模型的 verbose_name
|
||||
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)
|
||||
|
||||
# 获取所有非多对多字段和非反向关联字段
|
||||
fields = [field for field in opts.get_fields() if not field.many_to_many and not field.one_to_many]
|
||||
|
||||
# 写入表头 (使用字段的 verbose_name)
|
||||
writer.writerow([field.verbose_name for field in fields])
|
||||
|
||||
# 写入数据
|
||||
for obj in queryset:
|
||||
data_row = []
|
||||
for field in fields:
|
||||
value = getattr(obj, field.name)
|
||||
|
||||
# 处理 Choice 字段,显示可读的标签
|
||||
if hasattr(obj, f'get_{field.name}_display'):
|
||||
value = getattr(obj, f'get_{field.name}_display')()
|
||||
|
||||
# 处理关联对象(ForeignKey)
|
||||
if field.is_relation and value:
|
||||
value = str(value)
|
||||
|
||||
# 处理日期时间
|
||||
if isinstance(value, datetime.datetime):
|
||||
value = value.strftime('%Y-%m-%d %H:%M:%S')
|
||||
elif isinstance(value, datetime.date):
|
||||
value = value.strftime('%Y-%m-%d')
|
||||
|
||||
# 处理 None
|
||||
if value is None:
|
||||
value = ""
|
||||
|
||||
data_row.append(str(value))
|
||||
writer.writerow(data_row)
|
||||
|
||||
return response
|
||||
|
||||
export_to_csv.short_description = "导出选中项为 CSV"
|
||||
|
||||
def export_to_excel(modeladmin, request, queryset):
|
||||
"""
|
||||
导出为 Excel (需要安装 openpyxl)
|
||||
"""
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
except ImportError:
|
||||
modeladmin.message_user(request, "请先安装 openpyxl 库以使用 Excel 导出功能: pip install openpyxl", 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
|
||||
# Sheet name limit is 31 chars
|
||||
ws.title = str(opts.verbose_name)[:31]
|
||||
|
||||
fields = [field for field in opts.get_fields() if not field.many_to_many and not field.one_to_many]
|
||||
|
||||
# 写入表头
|
||||
ws.append([str(field.verbose_name) for field in fields])
|
||||
|
||||
# 写入数据
|
||||
for obj in queryset:
|
||||
row = []
|
||||
for field in fields:
|
||||
value = getattr(obj, field.name)
|
||||
|
||||
if hasattr(obj, f'get_{field.name}_display'):
|
||||
value = getattr(obj, f'get_{field.name}_display')()
|
||||
|
||||
# 处理关联对象(ForeignKey)
|
||||
if field.is_relation and value:
|
||||
value = str(value)
|
||||
|
||||
if isinstance(value, (datetime.datetime, datetime.date)):
|
||||
# openpyxl 可以直接处理 datetime 格式,Excel 会自动识别
|
||||
# 但为了避免时区问题,通常转为无时区时间或字符串
|
||||
if isinstance(value, datetime.datetime):
|
||||
value = value.replace(tzinfo=None)
|
||||
|
||||
row.append(value)
|
||||
ws.append(row)
|
||||
|
||||
wb.save(response)
|
||||
return response
|
||||
|
||||
export_to_excel.short_description = "导出选中项为 Excel"
|
||||
9
backend/shop/apps.py
Normal file
9
backend/shop/apps.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ShopConfig(AppConfig):
|
||||
name = 'shop'
|
||||
verbose_name = "课程培训"
|
||||
|
||||
def ready(self):
|
||||
import shop.signals
|
||||
50
backend/shop/migrations/0001_initial.py
Normal file
50
backend/shop/migrations/0001_initial.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 04:06
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ESP32Config',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='配置名称')),
|
||||
('chip_type', models.CharField(help_text='例如: ESP32-S3, ESP32-C3', max_length=50, verbose_name='芯片型号')),
|
||||
('flash_size', models.IntegerField(default=4, verbose_name='Flash大小(MB)')),
|
||||
('ram_size', models.IntegerField(default=2, verbose_name='PSRAM大小(MB)')),
|
||||
('has_camera', models.BooleanField(default=False, verbose_name='是否包含摄像头')),
|
||||
('has_microphone', models.BooleanField(default=False, verbose_name='是否包含麦克风')),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='价格')),
|
||||
('description', models.TextField(blank=True, verbose_name='描述')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '硬件配置',
|
||||
'verbose_name_plural': '硬件配置列表',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Order',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.IntegerField(default=1, verbose_name='数量')),
|
||||
('total_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='总价')),
|
||||
('status', models.CharField(choices=[('pending', '待支付'), ('paid', '已支付'), ('shipped', '已发货'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='订单状态')),
|
||||
('wechat_trade_no', models.CharField(blank=True, max_length=100, null=True, verbose_name='微信支付单号')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('config', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.esp32config', verbose_name='所选配置')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '订单',
|
||||
'verbose_name_plural': '订单列表',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 04:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='customer_name',
|
||||
field=models.CharField(default='', max_length=100, verbose_name='收货人姓名'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='phone_number',
|
||||
field=models.CharField(default='', max_length=20, verbose_name='联系电话'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='shipping_address',
|
||||
field=models.TextField(default='', verbose_name='发货地址'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 04:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0002_order_customer_name_order_phone_number_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Salesperson',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, verbose_name='销售员姓名')),
|
||||
('code', models.CharField(help_text='唯一的推广标识码,如: zhangsan01', max_length=20, unique=True, verbose_name='推广码')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '销售员',
|
||||
'verbose_name_plural': '销售员管理',
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='esp32config',
|
||||
options={'verbose_name': '硬件配置 (小智参数)', 'verbose_name_plural': '硬件配置 (小智参数)'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='salesperson',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.salesperson', verbose_name='所属销售员'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.2.10 on 2026-02-02 04:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0003_salesperson_alter_esp32config_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WeChatPayConfig',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('app_id', models.CharField(max_length=50, verbose_name='AppID')),
|
||||
('mch_id', models.CharField(max_length=50, verbose_name='商户号(MchID)')),
|
||||
('api_key', models.CharField(max_length=100, verbose_name='API密钥(Key)')),
|
||||
('app_secret', models.CharField(blank=True, max_length=100, null=True, verbose_name='AppSecret')),
|
||||
('notify_url', models.URLField(verbose_name='回调通知地址')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '微信支付配置',
|
||||
'verbose_name_plural': '微信支付配置',
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='esp32config',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesperson',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 05:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0004_wechatpayconfig_alter_esp32config_id_alter_order_id_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Service',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=100, verbose_name='服务名称')),
|
||||
('icon', models.ImageField(upload_to='services/icons/', verbose_name='图标')),
|
||||
('description', models.TextField(verbose_name='简介')),
|
||||
('features', models.TextField(help_text='每行一个特性', verbose_name='特性列表')),
|
||||
('color', models.CharField(default='#00f0ff', max_length=20, verbose_name='主题色')),
|
||||
('detail_image', models.ImageField(blank=True, null=True, upload_to='services/details/', verbose_name='详情页长图')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'AI服务',
|
||||
'verbose_name_plural': 'AI服务管理',
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='esp32config',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesperson',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wechatpayconfig',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 05:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0005_service_alter_esp32config_id_alter_order_id_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ARService',
|
||||
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='简介')),
|
||||
('cover_image', models.ImageField(blank=True, null=True, upload_to='ar/covers/', verbose_name='封面/长图 (上传)')),
|
||||
('cover_image_url', models.URLField(blank=True, null=True, verbose_name='封面/长图 (URL)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'AR体验',
|
||||
'verbose_name_plural': 'AR体验管理',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='esp32config',
|
||||
name='detail_image',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='products/details/', verbose_name='详情页长图 (上传)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='esp32config',
|
||||
name='detail_image_url',
|
||||
field=models.URLField(blank=True, help_text='如果填写了URL,将优先使用URL', null=True, verbose_name='详情页长图 (URL)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='detail_image_url',
|
||||
field=models.URLField(blank=True, null=True, verbose_name='详情页长图 (URL)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='icon_url',
|
||||
field=models.URLField(blank=True, null=True, verbose_name='图标 (URL)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='service',
|
||||
name='detail_image',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='services/details/', verbose_name='详情页长图 (上传)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='service',
|
||||
name='icon',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='services/icons/', verbose_name='图标 (上传)'),
|
||||
),
|
||||
]
|
||||
32
backend/shop/migrations/0007_productfeature.py
Normal file
32
backend/shop/migrations/0007_productfeature.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 06:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0006_arservice_esp32config_detail_image_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProductFeature',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=50, verbose_name='特性标题')),
|
||||
('description', models.TextField(verbose_name='特性描述')),
|
||||
('icon_name', models.CharField(blank=True, help_text='例如: SafetyCertificate, Eye, Thunderbolt', max_length=50, null=True, verbose_name='Antd图标名称')),
|
||||
('icon_image', models.ImageField(blank=True, null=True, upload_to='products/features/', verbose_name='特性图标 (上传)')),
|
||||
('icon_url', models.URLField(blank=True, null=True, verbose_name='特性图标 (URL)')),
|
||||
('order', models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='features', to='shop.esp32config', verbose_name='所属产品')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '产品特性',
|
||||
'verbose_name_plural': '产品特性',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 06:16
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0007_productfeature'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='delivery_content',
|
||||
field=models.TextField(blank=True, help_text='描述将交付给客户的具体成果', verbose_name='交付内容'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='delivery_time',
|
||||
field=models.CharField(blank=True, help_text='例如:3-5个工作日', max_length=50, verbose_name='预计交付周期'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='price',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='起步价格'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='unit',
|
||||
field=models.CharField(default='次', help_text='例如:次、小时、月、个', max_length=20, verbose_name='计费单位'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ServiceOrder',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('customer_name', models.CharField(max_length=100, verbose_name='客户姓名')),
|
||||
('company_name', models.CharField(blank=True, max_length=100, verbose_name='公司名称')),
|
||||
('phone_number', models.CharField(max_length=20, verbose_name='联系电话')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='电子邮箱')),
|
||||
('requirements', models.TextField(blank=True, verbose_name='具体需求描述')),
|
||||
('total_price', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='预估总价')),
|
||||
('status', models.CharField(choices=[('pending', '待沟通/待支付'), ('processing', '服务进行中'), ('completed', '已完成'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='订单状态')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('salesperson', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.salesperson', verbose_name='所属销售员')),
|
||||
('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.service', verbose_name='所选服务')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '服务订单',
|
||||
'verbose_name_plural': '服务订单列表',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 11:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0008_service_delivery_content_service_delivery_time_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='esp32config',
|
||||
name='model_3d_url',
|
||||
field=models.URLField(blank=True, null=True, verbose_name='产品3D模型 (URL)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='esp32config',
|
||||
name='static_image_url',
|
||||
field=models.URLField(blank=True, null=True, verbose_name='产品静态图 (URL)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 12:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0009_esp32config_model_3d_url_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='esp32config',
|
||||
name='model_3d_url',
|
||||
field=models.URLField(blank=True, help_text='请上传包含 .obj 模型文件和 .mtl 材质文件的 .zip 压缩包', null=True, verbose_name='产品3D模型 (URL)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 14:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0010_alter_esp32config_model_3d_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='esp32config',
|
||||
name='model_3d_url',
|
||||
field=models.URLField(blank=True, null=True, verbose_name='产品3D模型 (URL)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-06 13:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0011_alter_esp32config_model_3d_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='wechatpayconfig',
|
||||
name='apiv3_key',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='API V3密钥'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='wechatpayconfig',
|
||||
name='mch_cert_serial_no',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='商户证书序列号'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='wechatpayconfig',
|
||||
name='mch_private_key',
|
||||
field=models.TextField(blank=True, help_text='apiclient_key.pem 的内容', null=True, verbose_name='商户私钥内容'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wechatpayconfig',
|
||||
name='api_key',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='API密钥(V2 Key)'),
|
||||
),
|
||||
]
|
||||
18
backend/shop/migrations/0013_order_out_trade_no.py
Normal file
18
backend/shop/migrations/0013_order_out_trade_no.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-07 09:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0012_wechatpayconfig_apiv3_key_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='out_trade_no',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='商户订单号'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 15:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0013_order_out_trade_no'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='esp32config',
|
||||
name='stock',
|
||||
field=models.IntegerField(default=0, verbose_name='库存数量'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='courier_name',
|
||||
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='快递公司'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='tracking_number',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='快递单号'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 15:48
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0014_esp32config_stock_order_courier_name_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='esp32config',
|
||||
name='commission_rate',
|
||||
field=models.DecimalField(decimal_places=4, default=0.0, help_text='例如 0.10 表示 10%,优先级高于销售员默认比例', max_digits=5, verbose_name='产品分润比例'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesperson',
|
||||
name='commission_rate',
|
||||
field=models.DecimalField(decimal_places=4, default=0.1, help_text='例如 0.10 表示 10%', max_digits=5, verbose_name='默认分润比例'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesperson',
|
||||
name='parent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='shop.salesperson', verbose_name='上级分销员'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesperson',
|
||||
name='second_level_rate',
|
||||
field=models.DecimalField(decimal_places=4, default=0.02, help_text='作为上级时可获得的分润比例,例如 0.02 表示 2%', max_digits=5, verbose_name='二级分销比例'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CommissionLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='佣金金额')),
|
||||
('level', models.IntegerField(default=1, help_text='1: 直接销售, 2: 二级分销', verbose_name='分销层级')),
|
||||
('status', models.CharField(choices=[('pending', '待结算'), ('settled', '已结算'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='状态')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='commissions', to='shop.order', verbose_name='关联订单')),
|
||||
('salesperson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='commissions', to='shop.salesperson', verbose_name='获佣销售员')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '佣金记录',
|
||||
'verbose_name_plural': '佣金结算',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 16:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0015_esp32config_commission_rate_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WeChatUser',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('openid', models.CharField(max_length=64, unique=True, verbose_name='OpenID')),
|
||||
('unionid', models.CharField(blank=True, db_index=True, max_length=64, null=True, verbose_name='UnionID')),
|
||||
('session_key', models.CharField(blank=True, max_length=64, verbose_name='SessionKey')),
|
||||
('nickname', models.CharField(blank=True, max_length=64, verbose_name='昵称')),
|
||||
('avatar_url', models.URLField(blank=True, verbose_name='头像URL')),
|
||||
('gender', models.IntegerField(default=0, help_text='0:未知, 1:男, 2:女', verbose_name='性别')),
|
||||
('country', models.CharField(blank=True, max_length=64, verbose_name='国家')),
|
||||
('province', models.CharField(blank=True, max_length=64, verbose_name='省份')),
|
||||
('city', models.CharField(blank=True, max_length=64, verbose_name='城市')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='wechat_profile', to=settings.AUTH_USER_MODEL, verbose_name='关联系统用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '微信用户',
|
||||
'verbose_name_plural': '微信用户管理',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Distributor',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('level', models.IntegerField(default=1, verbose_name='分销等级')),
|
||||
('commission_rate', models.DecimalField(decimal_places=4, default=0.1, help_text='例如 0.10 表示 10%', max_digits=5, verbose_name='分佣比例')),
|
||||
('total_earnings', models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='累计收益')),
|
||||
('withdrawable_balance', models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='可提现余额')),
|
||||
('status', models.CharField(choices=[('pending', '审核中'), ('active', '正常'), ('disabled', '已禁用')], default='pending', max_length=20, verbose_name='状态')),
|
||||
('invite_code', models.CharField(blank=True, max_length=20, unique=True, verbose_name='邀请码')),
|
||||
('qr_code_url', models.URLField(blank=True, verbose_name='推广二维码URL')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='申请时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='shop.distributor', verbose_name='上级分销员')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='distributor', to='shop.wechatuser', verbose_name='关联微信用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '分销员',
|
||||
'verbose_name_plural': '分销员管理',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='wechat_user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.wechatuser', verbose_name='下单微信用户'),
|
||||
),
|
||||
]
|
||||
30
backend/shop/migrations/0017_withdrawal.py
Normal file
30
backend/shop/migrations/0017_withdrawal.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 16:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0016_wechatuser_distributor_order_wechat_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Withdrawal',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='提现金额')),
|
||||
('status', models.CharField(choices=[('pending', '审核中'), ('approved', '已打款'), ('rejected', '已拒绝')], default='pending', max_length=20, verbose_name='状态')),
|
||||
('remark', models.TextField(blank=True, verbose_name='备注')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='申请时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('distributor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='withdrawals', to='shop.distributor', verbose_name='分销员')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '提现记录',
|
||||
'verbose_name_plural': '提现管理',
|
||||
},
|
||||
),
|
||||
]
|
||||
35
backend/shop/migrations/0018_vbcourse_delete_arservice.py
Normal file
35
backend/shop/migrations/0018_vbcourse_delete_arservice.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 18:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0017_withdrawal'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VBCourse',
|
||||
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='课程简介')),
|
||||
('course_type', models.CharField(choices=[('software', '软件课程'), ('hardware', '硬件课程')], default='software', max_length=20, verbose_name='课程类型')),
|
||||
('duration', models.CharField(default='30分钟', help_text='例如: 30分钟', max_length=50, verbose_name='课程时长')),
|
||||
('lesson_count', models.IntegerField(default=1, verbose_name='课时数量')),
|
||||
('instructor', models.CharField(default='VB讲师', max_length=50, verbose_name='讲师')),
|
||||
('cover_image', models.ImageField(blank=True, null=True, upload_to='courses/covers/', verbose_name='封面图 (上传)')),
|
||||
('cover_image_url', models.URLField(blank=True, null=True, verbose_name='封面图 (URL)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'VB课程',
|
||||
'verbose_name_plural': 'VB课程管理',
|
||||
},
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='ARService',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 18:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0018_vbcourse_delete_arservice'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vbcourse',
|
||||
name='detail_image',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='courses/details/', verbose_name='详情页长图 (上传)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vbcourse',
|
||||
name='detail_image_url',
|
||||
field=models.URLField(blank=True, help_text='如果填写了URL,将优先使用URL', null=True, verbose_name='详情页长图 (URL)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vbcourse',
|
||||
name='tag',
|
||||
field=models.CharField(blank=True, help_text='例如: 热门, 推荐, 进阶', max_length=20, verbose_name='标签'),
|
||||
),
|
||||
]
|
||||
18
backend/shop/migrations/0020_alter_vbcourse_course_type.py
Normal file
18
backend/shop/migrations/0020_alter_vbcourse_course_type.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 18:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0019_vbcourse_detail_image_vbcourse_detail_image_url_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='vbcourse',
|
||||
name='course_type',
|
||||
field=models.CharField(choices=[('software', '软件课程'), ('hardware', '硬件课程'), ('incubation', '产品商业孵化')], default='software', max_length=20, verbose_name='课程类型'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 19:08
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0020_alter_vbcourse_course_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='commissionlog',
|
||||
name='distributor',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='commissions', to='shop.distributor', verbose_name='获佣分销员'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='distributor',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.distributor', verbose_name='所属分销员'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='commissionlog',
|
||||
name='salesperson',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='commissions', to='shop.salesperson', verbose_name='获佣销售员'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 19:20
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0021_commissionlog_distributor_order_distributor_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vbcourse',
|
||||
name='content',
|
||||
field=models.TextField(blank=True, help_text='支持Markdown或HTML', verbose_name='详细内容'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vbcourse',
|
||||
name='price',
|
||||
field=models.DecimalField(decimal_places=2, default=0, help_text='0表示免费', max_digits=10, verbose_name='价格'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CourseEnrollment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('customer_name', models.CharField(max_length=100, verbose_name='姓名')),
|
||||
('phone_number', models.CharField(max_length=20, verbose_name='联系电话')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='电子邮箱')),
|
||||
('wechat_id', models.CharField(blank=True, max_length=50, verbose_name='微信号')),
|
||||
('message', models.TextField(blank=True, verbose_name='留言/备注')),
|
||||
('status', models.CharField(choices=[('pending', '待联系'), ('contacted', '已联系'), ('completed', '已完成'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='状态')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='提交时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to='shop.vbcourse', verbose_name='咨询课程')),
|
||||
('distributor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.distributor', verbose_name='所属分销员')),
|
||||
('salesperson', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.salesperson', verbose_name='所属销售员')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '课程报名',
|
||||
'verbose_name_plural': '课程报名管理',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 19:23
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0022_vbcourse_content_vbcourse_price_courseenrollment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='course',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.vbcourse', verbose_name='所选课程'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='config',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='shop.esp32config', verbose_name='所选配置'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 19:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0023_order_course_alter_order_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vbcourse',
|
||||
name='instructor_avatar',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='instructors/avatars/', verbose_name='讲师头像 (上传)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vbcourse',
|
||||
name='instructor_avatar_url',
|
||||
field=models.URLField(blank=True, null=True, verbose_name='讲师头像 (URL)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vbcourse',
|
||||
name='instructor_desc',
|
||||
field=models.TextField(blank=True, default='拥有多年开发经验,擅长...', verbose_name='讲师简介'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vbcourse',
|
||||
name='instructor_title',
|
||||
field=models.CharField(default='资深讲师', max_length=50, verbose_name='讲师头衔'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 19:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0024_vbcourse_instructor_avatar_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VCCourse',
|
||||
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='课程简介')),
|
||||
('course_type', models.CharField(choices=[('software', '软件课程'), ('hardware', '硬件课程'), ('incubation', '产品商业孵化')], default='software', max_length=20, verbose_name='课程类型')),
|
||||
('duration', models.CharField(default='30分钟', help_text='例如: 30分钟', max_length=50, verbose_name='课程时长')),
|
||||
('lesson_count', models.IntegerField(default=1, verbose_name='课时数量')),
|
||||
('instructor', models.CharField(default='VC讲师', max_length=50, verbose_name='讲师')),
|
||||
('instructor_title', models.CharField(default='资深讲师', max_length=50, verbose_name='讲师头衔')),
|
||||
('instructor_avatar', models.ImageField(blank=True, null=True, upload_to='instructors/avatars/', verbose_name='讲师头像 (上传)')),
|
||||
('instructor_avatar_url', models.URLField(blank=True, null=True, verbose_name='讲师头像 (URL)')),
|
||||
('instructor_desc', models.TextField(blank=True, default='拥有多年开发经验,擅长...', verbose_name='讲师简介')),
|
||||
('tag', models.CharField(blank=True, help_text='例如: 热门, 推荐, 进阶', max_length=20, verbose_name='标签')),
|
||||
('price', models.DecimalField(decimal_places=2, default=0, help_text='0表示免费', max_digits=10, verbose_name='价格')),
|
||||
('content', models.TextField(blank=True, help_text='支持Markdown或HTML', verbose_name='详细内容')),
|
||||
('cover_image', models.ImageField(blank=True, null=True, upload_to='courses/covers/', verbose_name='封面图 (上传)')),
|
||||
('cover_image_url', models.URLField(blank=True, null=True, verbose_name='封面图 (URL)')),
|
||||
('detail_image', models.ImageField(blank=True, null=True, upload_to='courses/details/', verbose_name='详情页长图 (上传)')),
|
||||
('detail_image_url', models.URLField(blank=True, help_text='如果填写了URL,将优先使用URL', null=True, verbose_name='详情页长图 (URL)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'VC课程',
|
||||
'verbose_name_plural': 'VC课程管理',
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='courseenrollment',
|
||||
name='course',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to='shop.vccourse', verbose_name='咨询课程'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='course',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.vccourse', verbose_name='所选课程'),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='VBCourse',
|
||||
),
|
||||
]
|
||||
18
backend/shop/migrations/0026_wechatuser_phone_number.py
Normal file
18
backend/shop/migrations/0026_wechatuser_phone_number.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-11 07:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0025_vccourse_alter_courseenrollment_course_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='wechatuser',
|
||||
name='phone_number',
|
||||
field=models.CharField(blank=True, max_length=20, null=True, unique=True, verbose_name='手机号'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-12 06:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0026_wechatuser_phone_number'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='wechatuser',
|
||||
name='is_star',
|
||||
field=models.BooleanField(default=False, verbose_name='是否明星技术用户'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='wechatuser',
|
||||
name='title',
|
||||
field=models.CharField(blank=True, default='技术专家', max_length=50, verbose_name='专家头衔'),
|
||||
),
|
||||
]
|
||||
14
backend/shop/migrations/0028_fix_goodsid_schema.py
Normal file
14
backend/shop/migrations/0028_fix_goodsid_schema.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-12 14:26
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0027_wechatuser_is_star_wechatuser_title'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# 空操作 - 此迁移仅用于旧数据库修复,新数据库无需执行
|
||||
]
|
||||
14
backend/shop/migrations/0029_fix_legacy_fields.py
Normal file
14
backend/shop/migrations/0029_fix_legacy_fields.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-12 14:33
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0028_fix_goodsid_schema'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# 空操作 - 此迁移仅用于旧数据库修复,新数据库无需执行
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-13 16:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0029_fix_legacy_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='esp32config',
|
||||
options={'ordering': ['order'], 'verbose_name': '硬件配置 (小智参数)', 'verbose_name_plural': '硬件配置 (小智参数)'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='service',
|
||||
options={'ordering': ['order'], 'verbose_name': 'AI服务', 'verbose_name_plural': 'AI服务管理'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='vccourse',
|
||||
options={'ordering': ['order'], 'verbose_name': 'VC课程', 'verbose_name_plural': 'VC课程管理'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='esp32config',
|
||||
name='order',
|
||||
field=models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='order',
|
||||
field=models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vccourse',
|
||||
name='order',
|
||||
field=models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='config',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='shop.esp32config', verbose_name='所选配置'),
|
||||
),
|
||||
]
|
||||
27
backend/shop/migrations/0031_adminphonenumber.py
Normal file
27
backend/shop/migrations/0031_adminphonenumber.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-16 11:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0030_alter_esp32config_options_alter_service_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AdminPhoneNumber',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, verbose_name='管理员姓名')),
|
||||
('phone_number', models.CharField(max_length=20, verbose_name='手机号')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='是否接收通知')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '管理员通知手机号',
|
||||
'verbose_name_plural': '管理员通知手机号',
|
||||
},
|
||||
),
|
||||
]
|
||||
20
backend/shop/migrations/0032_order_activity.py
Normal file
20
backend/shop/migrations/0032_order_activity.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-23 07:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('community', '0001_initial'),
|
||||
('shop', '0031_adminphonenumber'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='activity',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='community.activity', verbose_name='所选活动'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-23 15:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0032_order_activity'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vccourse',
|
||||
name='is_fixed_schedule',
|
||||
field=models.BooleanField(default=False, help_text='勾选后,前端将显示具体的开课时间', verbose_name='是否固定时间课程'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vccourse',
|
||||
name='schedule_time',
|
||||
field=models.CharField(blank=True, help_text='例如:每周六晚 20:00', max_length=100, null=True, verbose_name='课程具体时间'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-23 16:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0033_vccourse_is_fixed_schedule_vccourse_schedule_time'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='vccourse',
|
||||
name='schedule_time',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vccourse',
|
||||
name='end_time',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='结束时间'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vccourse',
|
||||
name='start_time',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='开始时间'),
|
||||
),
|
||||
]
|
||||
18
backend/shop/migrations/0035_wechatuser_skills.py
Normal file
18
backend/shop/migrations/0035_wechatuser_skills.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-24 09:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0034_remove_vccourse_schedule_time_vccourse_end_time_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='wechatuser',
|
||||
name='skills',
|
||||
field=models.JSONField(blank=True, default=list, help_text="格式: [{'icon': 'url', 'text': 'React'}, ...]", verbose_name='专家技能'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-24 09:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0035_wechatuser_skills'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='wechatuser',
|
||||
options={'ordering': ['order', '-created_at'], 'verbose_name': '微信用户', 'verbose_name_plural': '微信用户管理'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='wechatuser',
|
||||
name='order',
|
||||
field=models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重'),
|
||||
),
|
||||
]
|
||||
18
backend/shop/migrations/0037_wechatuser_has_web_badge.py
Normal file
18
backend/shop/migrations/0037_wechatuser_has_web_badge.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-26 10:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0036_alter_wechatuser_options_wechatuser_order'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='wechatuser',
|
||||
name='has_web_badge',
|
||||
field=models.BooleanField(default=False, verbose_name='是否拥有Web徽章'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
18
backend/shop/migrations/0039_vccourse_video_embed_code.py
Normal file
18
backend/shop/migrations/0039_vccourse_video_embed_code.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-01 09:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0038_vccourse_is_video_course_vccourse_video_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vccourse',
|
||||
name='video_embed_code',
|
||||
field=models.TextField(blank=True, help_text='支持iframe嵌入代码,优先级高于视频URL', null=True, verbose_name='视频嵌入代码'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,128 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-18 12:00
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0039_vccourse_video_embed_code'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='IdentityTag',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, unique=True, verbose_name='标签名称')),
|
||||
('description', models.TextField(blank=True, verbose_name='标签描述')),
|
||||
('color', models.CharField(default='#3B82F6', help_text='十六进制颜色代码,如 #3B82F6', max_length=7, verbose_name='标签颜色')),
|
||||
('icon', models.CharField(blank=True, help_text='Material图标名称', max_length=50, verbose_name='图标名称')),
|
||||
('sort_order', models.IntegerField(default=0, verbose_name='排序')),
|
||||
('is_active', models.BooleanField(default=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': ['sort_order', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='courseenrollment',
|
||||
options={'verbose_name': '课程报名', 'verbose_name_plural': '课程报名'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='vccourse',
|
||||
options={'ordering': ['order'], 'verbose_name': '课程', 'verbose_name_plural': '课程管理'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='adminphonenumber',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='commissionlog',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='courseenrollment',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='distributor',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='esp32config',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='productfeature',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesperson',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='service',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='serviceorder',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='vccourse',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wechatpayconfig',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wechatuser',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='withdrawal',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserIdentity',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('assigned_at', models.DateTimeField(auto_now_add=True, verbose_name='分配时间')),
|
||||
('assigned_by', models.CharField(blank=True, max_length=100, verbose_name='分配人')),
|
||||
('notes', models.TextField(blank=True, verbose_name='备注')),
|
||||
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='users', to='shop.identitytag', verbose_name='身份标签')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identities', to='shop.wechatuser', verbose_name='微信用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '用户身份',
|
||||
'verbose_name_plural': '用户身份管理',
|
||||
'ordering': ['-assigned_at'],
|
||||
'unique_together': {('user', 'tag')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/shop/migrations/__init__.py
Normal file
0
backend/shop/migrations/__init__.py
Normal file
495
backend/shop/models.py
Normal file
495
backend/shop/models.py
Normal file
@@ -0,0 +1,495 @@
|
||||
from django.db import models
|
||||
from django.utils.html import format_html
|
||||
import qrcode
|
||||
from io import BytesIO
|
||||
import base64
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
class WeChatUser(models.Model):
|
||||
"""
|
||||
微信小程序用户模型
|
||||
"""
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, null=True, blank=True, related_name='wechat_profile', verbose_name="关联系统用户")
|
||||
openid = models.CharField(max_length=64, unique=True, verbose_name="OpenID")
|
||||
unionid = models.CharField(max_length=64, blank=True, null=True, verbose_name="UnionID", db_index=True)
|
||||
session_key = models.CharField(max_length=64, verbose_name="SessionKey", blank=True)
|
||||
nickname = models.CharField(max_length=64, verbose_name="昵称", blank=True)
|
||||
phone_number = models.CharField(max_length=20, unique=True, null=True, blank=True, verbose_name="手机号")
|
||||
avatar_url = models.URLField(verbose_name="头像URL", blank=True)
|
||||
gender = models.IntegerField(default=0, verbose_name="性别", help_text="0:未知, 1:男, 2:女")
|
||||
country = models.CharField(max_length=64, verbose_name="国家", blank=True)
|
||||
province = models.CharField(max_length=64, verbose_name="省份", blank=True)
|
||||
city = models.CharField(max_length=64, verbose_name="城市", blank=True)
|
||||
|
||||
# 明星技术用户/专家标识
|
||||
is_star = models.BooleanField(default=False, verbose_name="是否明星技术用户")
|
||||
title = models.CharField(max_length=50, default="技术专家", verbose_name="专家头衔", blank=True)
|
||||
skills = models.JSONField(default=list, verbose_name="专家技能", blank=True, help_text="格式: [{'icon': 'url', 'text': 'React'}, ...]")
|
||||
order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前")
|
||||
|
||||
# 徽章标识
|
||||
has_web_badge = models.BooleanField(default=False, verbose_name="是否拥有Web徽章")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
is_new = self.pk is None
|
||||
super().save(*args, **kwargs)
|
||||
if is_new and self.order == 0:
|
||||
WeChatUser.objects.filter(pk=self.pk).update(order=self.pk)
|
||||
self.order = self.pk
|
||||
|
||||
def __str__(self):
|
||||
return self.phone_number or self.nickname or self.openid
|
||||
|
||||
class Meta:
|
||||
verbose_name = "微信用户"
|
||||
verbose_name_plural = "微信用户管理"
|
||||
ordering = ['order', '-created_at']
|
||||
|
||||
|
||||
class IdentityTag(models.Model):
|
||||
"""
|
||||
身份标签模型 - 用于给用户打身份标签
|
||||
"""
|
||||
name = models.CharField(max_length=50, verbose_name="标签名称", unique=True)
|
||||
description = models.TextField(blank=True, verbose_name="标签描述")
|
||||
color = models.CharField(max_length=7, default="#3B82F6", verbose_name="标签颜色", help_text="十六进制颜色代码,如 #3B82F6")
|
||||
icon = models.CharField(max_length=50, blank=True, verbose_name="图标名称", help_text="Material图标名称")
|
||||
sort_order = models.IntegerField(default=0, verbose_name="排序")
|
||||
is_active = models.BooleanField(default=True, verbose_name="是否启用")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "身份标签"
|
||||
verbose_name_plural = "身份标签管理"
|
||||
ordering = ['sort_order', '-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class UserIdentity(models.Model):
|
||||
"""
|
||||
用户身份关联模型 - 记录用户拥有的身份标签
|
||||
"""
|
||||
user = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='identities', verbose_name="微信用户")
|
||||
tag = models.ForeignKey(IdentityTag, on_delete=models.CASCADE, related_name='users', verbose_name="身份标签")
|
||||
assigned_at = models.DateTimeField(auto_now_add=True, verbose_name="分配时间")
|
||||
assigned_by = models.CharField(max_length=100, blank=True, verbose_name="分配人")
|
||||
notes = models.TextField(blank=True, verbose_name="备注")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "用户身份"
|
||||
verbose_name_plural = "用户身份管理"
|
||||
ordering = ['-assigned_at']
|
||||
unique_together = ['user', 'tag']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.nickname or self.user.phone_number} - {self.tag.name}"
|
||||
|
||||
|
||||
class Distributor(models.Model):
|
||||
"""
|
||||
分销员模型 (替代原 Salesperson 或与其并存,此处为新系统)
|
||||
"""
|
||||
STATUS_CHOICES = (
|
||||
('pending', '审核中'),
|
||||
('active', '正常'),
|
||||
('disabled', '已禁用'),
|
||||
)
|
||||
|
||||
user = models.OneToOneField(WeChatUser, on_delete=models.CASCADE, related_name='distributor', verbose_name="关联微信用户")
|
||||
parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name="上级分销员")
|
||||
level = models.IntegerField(default=1, verbose_name="分销等级")
|
||||
commission_rate = models.DecimalField(max_digits=5, decimal_places=4, default=0.10, verbose_name="分佣比例", help_text="例如 0.10 表示 10%")
|
||||
total_earnings = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name="累计收益")
|
||||
withdrawable_balance = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name="可提现余额")
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态")
|
||||
invite_code = models.CharField(max_length=20, unique=True, blank=True, verbose_name="邀请码")
|
||||
qr_code_url = models.URLField(blank=True, verbose_name="推广二维码URL")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="申请时间")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.nickname} - {self.get_status_display()}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "分销员"
|
||||
verbose_name_plural = "分销员管理"
|
||||
|
||||
|
||||
class Withdrawal(models.Model):
|
||||
"""
|
||||
提现记录
|
||||
"""
|
||||
STATUS_CHOICES = (
|
||||
('pending', '审核中'),
|
||||
('approved', '已打款'),
|
||||
('rejected', '已拒绝'),
|
||||
)
|
||||
|
||||
distributor = models.ForeignKey(Distributor, on_delete=models.CASCADE, related_name='withdrawals', verbose_name="分销员")
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="提现金额")
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态")
|
||||
remark = models.TextField(blank=True, verbose_name="备注")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="申请时间")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.distributor.user.nickname} - ¥{self.amount}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "提现记录"
|
||||
verbose_name_plural = "提现管理"
|
||||
|
||||
class ESP32Config(models.Model):
|
||||
"""
|
||||
ESP32 硬件配置选项模型
|
||||
用于定义可售卖的硬件参数
|
||||
"""
|
||||
name = models.CharField(max_length=100, verbose_name="配置名称")
|
||||
chip_type = models.CharField(max_length=50, verbose_name="芯片型号", help_text="例如: ESP32-S3, ESP32-C3")
|
||||
flash_size = models.IntegerField(verbose_name="Flash大小(MB)", default=4)
|
||||
ram_size = models.IntegerField(verbose_name="PSRAM大小(MB)", default=2)
|
||||
has_camera = models.BooleanField(default=False, verbose_name="是否包含摄像头")
|
||||
has_microphone = models.BooleanField(default=False, verbose_name="是否包含麦克风")
|
||||
stock = models.IntegerField(default=0, verbose_name="库存数量")
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="价格")
|
||||
commission_rate = models.DecimalField(max_digits=5, decimal_places=4, default=0.00, verbose_name="产品分润比例", help_text="例如 0.10 表示 10%,优先级高于销售员默认比例")
|
||||
description = models.TextField(verbose_name="描述", blank=True)
|
||||
detail_image = models.ImageField(upload_to='products/details/', blank=True, null=True, verbose_name="详情页长图 (上传)")
|
||||
detail_image_url = models.URLField(blank=True, null=True, verbose_name="详情页长图 (URL)", help_text="如果填写了URL,将优先使用URL")
|
||||
static_image_url = models.URLField(blank=True, null=True, verbose_name="产品静态图 (URL)")
|
||||
model_3d_url = models.URLField(blank=True, null=True, verbose_name="产品3D模型 (URL)")
|
||||
order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} - ¥{self.price}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "硬件配置 (小智参数)"
|
||||
verbose_name_plural = "硬件配置 (小智参数)"
|
||||
ordering = ['order']
|
||||
|
||||
|
||||
class ProductFeature(models.Model):
|
||||
"""
|
||||
产品特性模型 (关联到具体硬件配置)
|
||||
"""
|
||||
product = models.ForeignKey(ESP32Config, on_delete=models.CASCADE, related_name='features', verbose_name="所属产品")
|
||||
title = models.CharField(max_length=50, verbose_name="特性标题")
|
||||
description = models.TextField(verbose_name="特性描述")
|
||||
icon_name = models.CharField(max_length=50, blank=True, null=True, verbose_name="Antd图标名称", help_text="例如: SafetyCertificate, Eye, Thunderbolt")
|
||||
icon_image = models.ImageField(upload_to='products/features/', blank=True, null=True, verbose_name="特性图标 (上传)")
|
||||
icon_url = models.URLField(blank=True, null=True, verbose_name="特性图标 (URL)")
|
||||
order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name} - {self.title}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "产品特性"
|
||||
verbose_name_plural = "产品特性"
|
||||
ordering = ['order']
|
||||
|
||||
|
||||
class Salesperson(models.Model):
|
||||
"""
|
||||
销售人员模型
|
||||
"""
|
||||
name = models.CharField(max_length=50, verbose_name="销售员姓名")
|
||||
code = models.CharField(max_length=20, unique=True, verbose_name="推广码", help_text="唯一的推广标识码,如: zhangsan01")
|
||||
parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name="上级分销员")
|
||||
|
||||
commission_rate = models.DecimalField(max_digits=5, decimal_places=4, default=0.10, verbose_name="默认分润比例", help_text="例如 0.10 表示 10%")
|
||||
second_level_rate = models.DecimalField(max_digits=5, decimal_places=4, default=0.02, verbose_name="二级分销比例", help_text="作为上级时可获得的分润比例,例如 0.02 表示 2%")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.code})"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "销售员"
|
||||
verbose_name_plural = "销售员管理"
|
||||
|
||||
|
||||
class CommissionLog(models.Model):
|
||||
"""
|
||||
佣金结算记录
|
||||
"""
|
||||
STATUS_CHOICES = (
|
||||
('pending', '待结算'),
|
||||
('settled', '已结算'),
|
||||
('cancelled', '已取消'),
|
||||
)
|
||||
|
||||
order = models.ForeignKey('Order', on_delete=models.CASCADE, verbose_name="关联订单", related_name='commissions')
|
||||
salesperson = models.ForeignKey(Salesperson, on_delete=models.CASCADE, verbose_name="获佣销售员", related_name='commissions', null=True, blank=True)
|
||||
distributor = models.ForeignKey(Distributor, on_delete=models.CASCADE, verbose_name="获佣分销员", related_name='commissions', null=True, blank=True)
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="佣金金额")
|
||||
level = models.IntegerField(default=1, verbose_name="分销层级", help_text="1: 直接销售, 2: 二级分销")
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "佣金记录"
|
||||
verbose_name_plural = "佣金结算"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.salesperson.name} - ¥{self.amount} ({self.get_status_display()})"
|
||||
|
||||
|
||||
class WeChatPayConfig(models.Model):
|
||||
"""
|
||||
微信支付配置模型
|
||||
"""
|
||||
app_id = models.CharField(max_length=50, verbose_name="AppID")
|
||||
mch_id = models.CharField(max_length=50, verbose_name="商户号(MchID)")
|
||||
api_key = models.CharField(max_length=100, verbose_name="API密钥(V2 Key)", blank=True, null=True)
|
||||
apiv3_key = models.CharField(max_length=100, verbose_name="API V3密钥", blank=True, null=True)
|
||||
mch_cert_serial_no = models.CharField(max_length=100, verbose_name="商户证书序列号", blank=True, null=True)
|
||||
mch_private_key = models.TextField(verbose_name="商户私钥内容", blank=True, null=True, help_text="apiclient_key.pem 的内容")
|
||||
app_secret = models.CharField(max_length=100, verbose_name="AppSecret", blank=True, null=True)
|
||||
notify_url = models.URLField(verbose_name="回调通知地址")
|
||||
is_active = models.BooleanField(default=True, verbose_name="是否启用")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "微信支付配置"
|
||||
verbose_name_plural = "微信支付配置"
|
||||
|
||||
def __str__(self):
|
||||
return f"微信支付配置 ({'启用' if self.is_active else '禁用'})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# 确保只有一个启用的配置
|
||||
if self.is_active:
|
||||
WeChatPayConfig.objects.filter(is_active=True).exclude(id=self.id).update(is_active=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Order(models.Model):
|
||||
"""
|
||||
订单模型
|
||||
记录用户的购买请求和支付状态
|
||||
"""
|
||||
STATUS_CHOICES = (
|
||||
('pending', '待支付'),
|
||||
('paid', '已支付'),
|
||||
('shipped', '已发货'),
|
||||
('cancelled', '已取消'),
|
||||
)
|
||||
|
||||
config = models.ForeignKey(ESP32Config, on_delete=models.CASCADE, verbose_name="所选配置", null=True, blank=True, related_name='orders')
|
||||
course = models.ForeignKey('VCCourse', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所选课程", related_name='orders')
|
||||
activity = models.ForeignKey('community.Activity', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所选活动", related_name='orders')
|
||||
quantity = models.IntegerField(default=1, verbose_name="数量")
|
||||
total_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="总价")
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="订单状态")
|
||||
|
||||
# 销售归属
|
||||
salesperson = models.ForeignKey(Salesperson, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属销售员", related_name='orders')
|
||||
distributor = models.ForeignKey(Distributor, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属分销员", related_name='orders')
|
||||
|
||||
# 关联微信用户
|
||||
wechat_user = models.ForeignKey(WeChatUser, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="下单微信用户", related_name='orders')
|
||||
|
||||
# 用户信息
|
||||
customer_name = models.CharField(max_length=100, verbose_name="收货人姓名", default="")
|
||||
phone_number = models.CharField(max_length=20, verbose_name="联系电话", default="")
|
||||
shipping_address = models.TextField(verbose_name="发货地址", default="")
|
||||
|
||||
# 物流信息
|
||||
courier_name = models.CharField(max_length=50, blank=True, null=True, verbose_name="快递公司")
|
||||
tracking_number = models.CharField(max_length=100, blank=True, null=True, verbose_name="快递单号")
|
||||
|
||||
# 微信支付相关字段
|
||||
out_trade_no = models.CharField(max_length=100, blank=True, null=True, verbose_name="商户订单号")
|
||||
wechat_trade_no = models.CharField(max_length=100, 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="更新时间")
|
||||
|
||||
def __str__(self):
|
||||
return f"Order #{self.id} - {self.customer_name} - {self.status}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "订单"
|
||||
verbose_name_plural = "订单列表"
|
||||
|
||||
|
||||
class Service(models.Model):
|
||||
"""
|
||||
AI服务项目模型
|
||||
"""
|
||||
title = models.CharField(max_length=100, verbose_name="服务名称")
|
||||
icon = models.ImageField(upload_to='services/icons/', blank=True, null=True, verbose_name="图标 (上传)")
|
||||
icon_url = models.URLField(blank=True, null=True, verbose_name="图标 (URL)")
|
||||
description = models.TextField(verbose_name="简介")
|
||||
features = models.TextField(verbose_name="特性列表", help_text="每行一个特性")
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="起步价格")
|
||||
unit = models.CharField(max_length=20, default="次", verbose_name="计费单位", help_text="例如:次、小时、月、个")
|
||||
delivery_time = models.CharField(max_length=50, blank=True, verbose_name="预计交付周期", help_text="例如:3-5个工作日")
|
||||
delivery_content = models.TextField(blank=True, verbose_name="交付内容", help_text="描述将交付给客户的具体成果")
|
||||
color = models.CharField(max_length=20, default="#00f0ff", verbose_name="主题色")
|
||||
detail_image = models.ImageField(upload_to='services/details/', blank=True, null=True, verbose_name="详情页长图 (上传)")
|
||||
detail_image_url = models.URLField(blank=True, null=True, verbose_name="详情页长图 (URL)")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||
order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前")
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Meta:
|
||||
verbose_name = "AI服务"
|
||||
verbose_name_plural = "AI服务管理"
|
||||
ordering = ['order']
|
||||
|
||||
|
||||
class ServiceOrder(models.Model):
|
||||
"""
|
||||
AI服务订单模型
|
||||
"""
|
||||
STATUS_CHOICES = (
|
||||
('pending', '待沟通/待支付'),
|
||||
('processing', '服务进行中'),
|
||||
('completed', '已完成'),
|
||||
('cancelled', '已取消'),
|
||||
)
|
||||
|
||||
service = models.ForeignKey(Service, on_delete=models.CASCADE, verbose_name="所选服务")
|
||||
customer_name = models.CharField(max_length=100, verbose_name="客户姓名")
|
||||
company_name = models.CharField(max_length=100, blank=True, verbose_name="公司名称")
|
||||
phone_number = models.CharField(max_length=20, verbose_name="联系电话")
|
||||
email = models.EmailField(blank=True, verbose_name="电子邮箱")
|
||||
requirements = models.TextField(verbose_name="具体需求描述", blank=True)
|
||||
|
||||
total_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="预估总价", default=0)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="订单状态")
|
||||
|
||||
salesperson = models.ForeignKey(Salesperson, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属销售员")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.customer_name} - {self.service.title}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "服务订单"
|
||||
verbose_name_plural = "服务订单列表"
|
||||
|
||||
|
||||
class VCCourse(models.Model):
|
||||
"""
|
||||
VC (VB Coding) 课程模型
|
||||
"""
|
||||
COURSE_TYPE_CHOICES = (
|
||||
('software', '软件课程'),
|
||||
('hardware', '硬件课程'),
|
||||
('incubation', '产品商业孵化'),
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=100, verbose_name="课程名称")
|
||||
description = models.TextField(verbose_name="课程简介")
|
||||
course_type = models.CharField(max_length=20, choices=COURSE_TYPE_CHOICES, default='software', verbose_name="课程类型")
|
||||
duration = models.CharField(max_length=50, verbose_name="课程时长", help_text="例如: 30分钟", default="30分钟")
|
||||
lesson_count = models.IntegerField(default=1, verbose_name="课时数量")
|
||||
instructor = models.CharField(max_length=50, verbose_name="讲师", default="VC讲师")
|
||||
instructor_title = models.CharField(max_length=50, verbose_name="讲师头衔", default="资深讲师")
|
||||
instructor_avatar = models.ImageField(upload_to='instructors/avatars/', blank=True, null=True, verbose_name="讲师头像 (上传)")
|
||||
instructor_avatar_url = models.URLField(blank=True, null=True, verbose_name="讲师头像 (URL)")
|
||||
instructor_desc = models.TextField(blank=True, verbose_name="讲师简介", default="拥有多年开发经验,擅长...")
|
||||
|
||||
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="仅当用户付费或报名后可见")
|
||||
video_embed_code = models.TextField(blank=True, null=True, verbose_name="视频嵌入代码", help_text="支持iframe嵌入代码,优先级高于视频URL")
|
||||
|
||||
# 课程时间安排
|
||||
is_fixed_schedule = models.BooleanField(default=False, verbose_name="是否固定时间课程", help_text="勾选后,前端将显示具体的开课时间")
|
||||
start_time = models.DateTimeField(blank=True, null=True, verbose_name="开始时间")
|
||||
end_time = models.DateTimeField(blank=True, null=True, verbose_name="结束时间")
|
||||
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="价格", help_text="0表示免费")
|
||||
content = models.TextField(blank=True, verbose_name="详细内容", help_text="支持Markdown或HTML")
|
||||
|
||||
cover_image = models.ImageField(upload_to='courses/covers/', blank=True, null=True, verbose_name="封面图 (上传)")
|
||||
cover_image_url = models.URLField(blank=True, null=True, verbose_name="封面图 (URL)")
|
||||
|
||||
detail_image = models.ImageField(upload_to='courses/details/', blank=True, null=True, verbose_name="详情页长图 (上传)")
|
||||
detail_image_url = models.URLField(blank=True, null=True, verbose_name="详情页长图 (URL)", help_text="如果填写了URL,将优先使用URL")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||
order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
is_new = self.pk is None
|
||||
super().save(*args, **kwargs)
|
||||
if is_new and self.order == 0:
|
||||
VCCourse.objects.filter(pk=self.pk).update(order=self.pk)
|
||||
self.order = self.pk
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Meta:
|
||||
verbose_name = "课程"
|
||||
verbose_name_plural = "课程管理"
|
||||
ordering = ['order']
|
||||
|
||||
|
||||
class CourseEnrollment(models.Model):
|
||||
"""
|
||||
课程报名/咨询记录
|
||||
"""
|
||||
STATUS_CHOICES = (
|
||||
('pending', '待联系'),
|
||||
('contacted', '已联系'),
|
||||
('completed', '已完成'),
|
||||
('cancelled', '已取消'),
|
||||
)
|
||||
|
||||
course = models.ForeignKey(VCCourse, on_delete=models.CASCADE, verbose_name="咨询课程", related_name='enrollments')
|
||||
customer_name = models.CharField(max_length=100, verbose_name="姓名")
|
||||
phone_number = models.CharField(max_length=20, verbose_name="联系电话")
|
||||
email = models.EmailField(blank=True, verbose_name="电子邮箱")
|
||||
wechat_id = models.CharField(max_length=50, blank=True, verbose_name="微信号")
|
||||
message = models.TextField(blank=True, verbose_name="留言/备注")
|
||||
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态")
|
||||
|
||||
# 销售归属
|
||||
salesperson = models.ForeignKey(Salesperson, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属销售员")
|
||||
distributor = models.ForeignKey(Distributor, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属分销员")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="提交时间")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.customer_name} - {self.course.title}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "课程报名"
|
||||
verbose_name_plural = "课程报名"
|
||||
|
||||
|
||||
class AdminPhoneNumber(models.Model):
|
||||
"""
|
||||
管理员通知手机号配置
|
||||
用于接收订单支付成功等重要通知
|
||||
"""
|
||||
name = models.CharField(max_length=50, verbose_name="管理员姓名")
|
||||
phone_number = models.CharField(max_length=20, verbose_name="手机号")
|
||||
is_active = models.BooleanField(default=True, verbose_name="是否接收通知")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} - {self.phone_number}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "管理员通知手机号"
|
||||
verbose_name_plural = "管理员通知手机号"
|
||||
368
backend/shop/serializers.py
Normal file
368
backend/shop/serializers.py
Normal file
@@ -0,0 +1,368 @@
|
||||
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):
|
||||
"""
|
||||
佣金记录序列化器
|
||||
"""
|
||||
order_info = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = CommissionLog
|
||||
fields = ['id', 'amount', 'level', 'status', 'created_at', 'order_info']
|
||||
read_only_fields = ['id', 'amount', 'level', 'status', 'created_at', 'order_info']
|
||||
|
||||
def get_order_info(self, obj):
|
||||
return {
|
||||
'order_id': obj.order.id,
|
||||
'total_price': obj.order.total_price,
|
||||
'customer_name': obj.order.customer_name
|
||||
}
|
||||
|
||||
class WeChatUserSerializer(serializers.ModelSerializer):
|
||||
is_admin = serializers.SerializerMethodField()
|
||||
has_web_account = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = WeChatUser
|
||||
fields = ['id', 'openid', 'nickname', 'avatar_url', 'gender', 'country', 'province', 'city', 'phone_number', 'is_star', 'title', 'skills', 'is_admin', 'has_web_account', 'has_web_badge']
|
||||
read_only_fields = ['id', 'openid', 'phone_number', 'is_star', 'title', 'skills', 'is_admin', 'has_web_account', 'has_web_badge']
|
||||
|
||||
def get_is_admin(self, obj):
|
||||
# 检查是否关联了系统用户且具有管理员权限
|
||||
return bool(obj.user and obj.user.is_staff)
|
||||
|
||||
def get_has_web_account(self, obj):
|
||||
# 检查是否关联了系统用户(即网页账号)
|
||||
return obj.user is not None
|
||||
|
||||
class DistributorSerializer(serializers.ModelSerializer):
|
||||
user_info = WeChatUserSerializer(source='user', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Distributor
|
||||
fields = ['id', 'user_info', 'level', 'commission_rate', 'total_earnings', 'withdrawable_balance', 'status', 'invite_code', 'qr_code_url']
|
||||
read_only_fields = ['level', 'commission_rate', 'total_earnings', 'withdrawable_balance', 'status', 'invite_code', 'qr_code_url']
|
||||
|
||||
class WithdrawalSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Withdrawal
|
||||
fields = ['id', 'amount', 'status', 'remark', 'created_at']
|
||||
read_only_fields = ['status', 'created_at', 'remark']
|
||||
|
||||
class ProductFeatureSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
产品特性序列化器
|
||||
"""
|
||||
display_icon = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ProductFeature
|
||||
fields = ['title', 'description', 'icon_name', 'display_icon', 'order']
|
||||
|
||||
def get_display_icon(self, obj):
|
||||
if obj.icon_url:
|
||||
return obj.icon_url
|
||||
if obj.icon_image:
|
||||
return obj.icon_image.url
|
||||
return None
|
||||
|
||||
class ServiceSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
AI服务序列化器
|
||||
"""
|
||||
features_list = serializers.SerializerMethodField()
|
||||
display_icon = serializers.SerializerMethodField()
|
||||
display_detail_image = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = '__all__'
|
||||
|
||||
def get_features_list(self, obj):
|
||||
if obj.features:
|
||||
return [line.strip() for line in obj.features.split('\n') if line.strip()]
|
||||
return []
|
||||
|
||||
def get_display_icon(self, obj):
|
||||
if obj.icon_url:
|
||||
return obj.icon_url
|
||||
if obj.icon:
|
||||
return obj.icon.url
|
||||
return None
|
||||
|
||||
def get_display_detail_image(self, obj):
|
||||
if obj.detail_image_url:
|
||||
return obj.detail_image_url
|
||||
if obj.detail_image:
|
||||
return obj.detail_image.url
|
||||
return None
|
||||
|
||||
class CourseEnrollmentSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
课程报名序列化器
|
||||
"""
|
||||
course_title = serializers.CharField(source='course.title', read_only=True)
|
||||
ref_code = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
||||
|
||||
class Meta:
|
||||
model = CourseEnrollment
|
||||
fields = ['id', 'course', 'course_title', 'customer_name', 'phone_number', 'email', 'wechat_id', 'message', 'status', 'created_at', 'ref_code']
|
||||
read_only_fields = ['status', 'created_at']
|
||||
|
||||
def create(self, validated_data):
|
||||
ref_code = validated_data.pop('ref_code', None)
|
||||
|
||||
# 尝试关联销售员或分销员
|
||||
if ref_code:
|
||||
try:
|
||||
salesperson = Salesperson.objects.get(code=ref_code)
|
||||
validated_data['salesperson'] = salesperson
|
||||
except Salesperson.DoesNotExist:
|
||||
pass
|
||||
|
||||
try:
|
||||
distributor = Distributor.objects.get(invite_code=ref_code)
|
||||
validated_data['distributor'] = distributor
|
||||
except Distributor.DoesNotExist:
|
||||
pass
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
class ServiceOrderSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
AI服务订单序列化器
|
||||
"""
|
||||
service_name = serializers.CharField(source='service.title', read_only=True)
|
||||
# 接收前端传来的 ref_code
|
||||
ref_code = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
||||
|
||||
class Meta:
|
||||
model = ServiceOrder
|
||||
fields = ['id', 'service', 'service_name', 'customer_name', 'company_name',
|
||||
'phone_number', 'email', 'requirements', 'total_price', 'status', 'created_at', 'ref_code']
|
||||
read_only_fields = ['total_price', 'status', 'created_at']
|
||||
|
||||
def create(self, validated_data):
|
||||
ref_code = validated_data.pop('ref_code', None)
|
||||
service = validated_data.get('service')
|
||||
|
||||
# 默认设置预估总价为服务起步价
|
||||
if service:
|
||||
validated_data['total_price'] = service.price
|
||||
|
||||
# 尝试关联销售员
|
||||
if ref_code:
|
||||
try:
|
||||
salesperson = Salesperson.objects.get(code=ref_code)
|
||||
validated_data['salesperson'] = salesperson
|
||||
except Salesperson.DoesNotExist:
|
||||
pass
|
||||
|
||||
try:
|
||||
distributor = Distributor.objects.get(invite_code=ref_code)
|
||||
validated_data['distributor'] = distributor
|
||||
except Distributor.DoesNotExist:
|
||||
pass
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
class VCCourseSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
VC课程序列化器
|
||||
"""
|
||||
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()
|
||||
video_embed_code = serializers.SerializerMethodField()
|
||||
is_purchased = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = VCCourse
|
||||
fields = '__all__'
|
||||
|
||||
def get_display_cover_image(self, obj):
|
||||
if obj.cover_image_url:
|
||||
return obj.cover_image_url
|
||||
if obj.cover_image:
|
||||
return obj.cover_image.url
|
||||
return None
|
||||
|
||||
def get_display_detail_image(self, obj):
|
||||
if obj.detail_image_url:
|
||||
return obj.detail_image_url
|
||||
if obj.detail_image:
|
||||
return obj.detail_image.url
|
||||
return None
|
||||
|
||||
def _check_purchased(self, obj):
|
||||
request = self.context.get('request')
|
||||
if not request:
|
||||
return False
|
||||
|
||||
# 尝试获取当前用户
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
# 如果是管理员,视为已购买
|
||||
if user.user and user.user.is_staff:
|
||||
return True
|
||||
|
||||
# 检查是否已购买/报名 (通过已支付的订单)
|
||||
has_order = Order.objects.filter(
|
||||
wechat_user=user,
|
||||
course=obj,
|
||||
status__in=['paid', 'shipped', 'completed']
|
||||
).exists()
|
||||
|
||||
return has_order
|
||||
|
||||
def get_is_purchased(self, obj):
|
||||
return self._check_purchased(obj)
|
||||
|
||||
def get_video_url(self, obj):
|
||||
"""
|
||||
仅当用户已付费/报名时返回视频URL
|
||||
"""
|
||||
if not obj.is_video_course:
|
||||
return None
|
||||
|
||||
if self._check_purchased(obj):
|
||||
return obj.video_url
|
||||
|
||||
return None
|
||||
|
||||
def get_video_embed_code(self, obj):
|
||||
"""
|
||||
仅当用户已付费/报名时返回视频嵌入代码
|
||||
"""
|
||||
if not obj.is_video_course:
|
||||
return None
|
||||
|
||||
if self._check_purchased(obj):
|
||||
return obj.video_embed_code
|
||||
|
||||
return None
|
||||
|
||||
class ESP32ConfigSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
ESP32配置序列化器
|
||||
"""
|
||||
display_detail_image = serializers.SerializerMethodField()
|
||||
features = ProductFeatureSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ESP32Config
|
||||
fields = '__all__'
|
||||
|
||||
def get_display_detail_image(self, obj):
|
||||
if obj.detail_image_url:
|
||||
return obj.detail_image_url
|
||||
if obj.detail_image:
|
||||
return obj.detail_image.url
|
||||
return None
|
||||
|
||||
|
||||
class OrderSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
订单序列化器
|
||||
"""
|
||||
config_name = serializers.CharField(source='config.name', read_only=True)
|
||||
course_title = serializers.CharField(source='course.title', read_only=True)
|
||||
activity_title = serializers.CharField(source='activity.title', read_only=True)
|
||||
config_image = serializers.SerializerMethodField()
|
||||
salesperson_name = serializers.CharField(source='salesperson.name', read_only=True)
|
||||
salesperson_code = serializers.CharField(source='salesperson.code', read_only=True)
|
||||
# 接收前端传来的 ref_code,用于查找 Salesperson
|
||||
ref_code = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['id', 'config', 'config_name', 'config_image', 'course', 'course_title', 'activity', 'activity_title', 'quantity', 'total_price', 'status', 'created_at', 'updated_at', 'wechat_trade_no',
|
||||
'customer_name', 'phone_number', 'shipping_address', 'ref_code', 'salesperson_name', 'salesperson_code', 'courier_name', 'tracking_number']
|
||||
read_only_fields = ['total_price', 'status', 'wechat_trade_no', 'created_at', 'updated_at']
|
||||
extra_kwargs = {
|
||||
'customer_name': {'required': True},
|
||||
'phone_number': {'required': True},
|
||||
}
|
||||
|
||||
def validate(self, data):
|
||||
# 如果是部分更新 (PATCH),可能不需要校验所有字段,但这里主要用于创建
|
||||
if self.instance:
|
||||
return data
|
||||
|
||||
config = data.get('config')
|
||||
course = data.get('course')
|
||||
activity = data.get('activity')
|
||||
|
||||
if not config and not course and not activity:
|
||||
raise serializers.ValidationError("必须选择一种商品(硬件配置、课程或活动)")
|
||||
|
||||
# Count how many types are selected
|
||||
selected_types = sum([bool(config), bool(course), bool(activity)])
|
||||
if selected_types > 1:
|
||||
raise serializers.ValidationError("一次只能购买一种类型的商品")
|
||||
|
||||
if config and not data.get('shipping_address'):
|
||||
raise serializers.ValidationError({"shipping_address": "购买硬件产品需要填写收货地址"})
|
||||
|
||||
return data
|
||||
|
||||
def get_config_image(self, obj):
|
||||
if obj.config:
|
||||
if obj.config.static_image_url:
|
||||
return obj.config.static_image_url
|
||||
if obj.config.detail_image_url:
|
||||
return obj.config.detail_image_url
|
||||
if obj.config.detail_image:
|
||||
return obj.config.detail_image.url
|
||||
elif obj.course:
|
||||
if obj.course.cover_image_url:
|
||||
return obj.course.cover_image_url
|
||||
if obj.course.cover_image:
|
||||
return obj.course.cover_image.url
|
||||
elif obj.activity:
|
||||
# Use activity.display_banner_url logic
|
||||
if obj.activity.banner:
|
||||
return obj.activity.banner.url
|
||||
if obj.activity.banner_url:
|
||||
return obj.activity.banner_url
|
||||
return None
|
||||
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
重写创建方法,自动计算总价并关联销售员/分销员
|
||||
"""
|
||||
config = validated_data.get('config')
|
||||
course = validated_data.get('course')
|
||||
activity = validated_data.get('activity')
|
||||
quantity = validated_data.get('quantity', 1)
|
||||
ref_code = validated_data.pop('ref_code', None)
|
||||
|
||||
if config:
|
||||
validated_data['total_price'] = config.price * quantity
|
||||
elif course:
|
||||
validated_data['total_price'] = course.price * quantity
|
||||
elif activity:
|
||||
validated_data['total_price'] = activity.price * quantity
|
||||
|
||||
# 尝试关联销售员或分销员
|
||||
if ref_code:
|
||||
# 1. 尝试查找旧版销售员
|
||||
try:
|
||||
salesperson = Salesperson.objects.get(code=ref_code)
|
||||
validated_data['salesperson'] = salesperson
|
||||
except Salesperson.DoesNotExist:
|
||||
pass
|
||||
|
||||
# 2. 尝试查找新版分销员
|
||||
try:
|
||||
distributor = Distributor.objects.get(invite_code=ref_code)
|
||||
validated_data['distributor'] = distributor
|
||||
except Distributor.DoesNotExist:
|
||||
pass
|
||||
|
||||
return super().create(validated_data)
|
||||
177
backend/shop/services.py
Normal file
177
backend/shop/services.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import logging
|
||||
from django.db import models
|
||||
from .models import Order, CommissionLog, Distributor
|
||||
# To avoid circular imports, import other models inside function if needed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def handle_post_payment(order):
|
||||
"""
|
||||
处理订单支付成功后的业务逻辑
|
||||
包括:
|
||||
1. 更新活动报名状态
|
||||
2. 发送活动报名短信
|
||||
3. 计算分销佣金
|
||||
4. 发送普通订单短信
|
||||
"""
|
||||
print(f"开始处理订单 {order.id} 支付后逻辑...")
|
||||
|
||||
# 1. Handle Activity Signup
|
||||
if hasattr(order, 'activity') and order.activity:
|
||||
try:
|
||||
# Use apps.get_model to avoid circular dependency
|
||||
from django.apps import apps
|
||||
ActivitySignup = apps.get_model('community', 'ActivitySignup')
|
||||
|
||||
signup = ActivitySignup.objects.filter(order=order).first()
|
||||
|
||||
# Fallback: try to find by user and activity if not found by order
|
||||
if not signup and order.wechat_user:
|
||||
print(f"Warning: ActivitySignup not found by order {order.id}, trying by user/activity")
|
||||
signup = ActivitySignup.objects.filter(
|
||||
user=order.wechat_user,
|
||||
activity=order.activity,
|
||||
status='unpaid'
|
||||
).first()
|
||||
if signup:
|
||||
print(f"Found signup {signup.id} by user/activity, linking order...")
|
||||
signup.order = order
|
||||
signup.save()
|
||||
|
||||
if signup:
|
||||
# Determine status based on activity setting
|
||||
# Use the model method if available, otherwise manual logic
|
||||
if hasattr(signup, 'check_payment_status'):
|
||||
signup.check_payment_status()
|
||||
print(f"活动报名状态已更新(check_payment_status): {signup.id} -> {signup.status}")
|
||||
else:
|
||||
new_status = 'confirmed' if signup.activity.auto_confirm else 'pending'
|
||||
signup.status = new_status
|
||||
signup.save()
|
||||
print(f"活动报名状态已更新: {signup.id} -> {new_status}")
|
||||
|
||||
# Send Activity SMS
|
||||
try:
|
||||
from .sms_utils import notify_user_activity_signup_success
|
||||
notify_user_activity_signup_success(order, signup)
|
||||
except Exception as sms_e:
|
||||
print(f"发送活动报名短信失败: {str(sms_e)}")
|
||||
|
||||
else:
|
||||
print(f"Error: No ActivitySignup found for paid order {order.id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"更新活动报名状态失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# 2. 计算佣金 (旧版销售员系统 & 新版分销员系统)
|
||||
try:
|
||||
# 旧版销售员系统
|
||||
salesperson = order.salesperson
|
||||
if salesperson:
|
||||
# 1. 计算直接佣金 (一级)
|
||||
# 优先级: 产品独立分润比例 > 销售员个人分润比例
|
||||
rate_1 = 0
|
||||
if order.config:
|
||||
rate_1 = order.config.commission_rate if order.config.commission_rate > 0 else salesperson.commission_rate
|
||||
elif order.course:
|
||||
# 课程暂时使用销售员默认比例
|
||||
rate_1 = salesperson.commission_rate
|
||||
|
||||
amount_1 = order.total_price * rate_1
|
||||
|
||||
if amount_1 > 0:
|
||||
CommissionLog.objects.create(
|
||||
order=order,
|
||||
salesperson=salesperson,
|
||||
amount=amount_1,
|
||||
level=1,
|
||||
status='pending'
|
||||
)
|
||||
print(f"生成一级佣金(Salesperson): {salesperson.name} - {amount_1}")
|
||||
|
||||
# 2. 计算上级佣金 (二级)
|
||||
parent = salesperson.parent
|
||||
if parent:
|
||||
rate_2 = parent.second_level_rate
|
||||
amount_2 = order.total_price * rate_2
|
||||
|
||||
if amount_2 > 0:
|
||||
CommissionLog.objects.create(
|
||||
order=order,
|
||||
salesperson=parent,
|
||||
amount=amount_2,
|
||||
level=2,
|
||||
status='pending'
|
||||
)
|
||||
print(f"生成二级佣金(Salesperson): {parent.name} - {amount_2}")
|
||||
|
||||
# 新版分销员系统
|
||||
distributor = order.distributor
|
||||
if distributor:
|
||||
# 1. 计算直接佣金 (一级)
|
||||
# 优先级: 产品独立分润比例 > 分销员个人分润比例
|
||||
rate_1 = 0
|
||||
if order.config:
|
||||
rate_1 = order.config.commission_rate if order.config.commission_rate > 0 else distributor.commission_rate
|
||||
elif order.course:
|
||||
# 课程暂时使用分销员默认比例
|
||||
rate_1 = distributor.commission_rate
|
||||
|
||||
amount_1 = order.total_price * rate_1
|
||||
|
||||
if amount_1 > 0:
|
||||
CommissionLog.objects.create(
|
||||
order=order,
|
||||
distributor=distributor,
|
||||
amount=amount_1,
|
||||
level=1,
|
||||
status='settled' # 简化流程,直接结算到余额
|
||||
)
|
||||
# 更新余额
|
||||
distributor.total_earnings += amount_1
|
||||
distributor.withdrawable_balance += amount_1
|
||||
distributor.save()
|
||||
print(f"生成一级佣金(Distributor): {distributor.user.nickname} - {amount_1}")
|
||||
|
||||
# 2. 计算上级佣金 (二级)
|
||||
parent = distributor.parent
|
||||
if parent:
|
||||
# 二级固定比例 2% (0.02)
|
||||
rate_2 = 0.02
|
||||
amount_2 = order.total_price * models.DecimalField(max_digits=5, decimal_places=4).to_python(rate_2)
|
||||
|
||||
if amount_2 > 0:
|
||||
CommissionLog.objects.create(
|
||||
order=order,
|
||||
distributor=parent,
|
||||
amount=amount_2,
|
||||
level=2,
|
||||
status='settled'
|
||||
)
|
||||
# 更新余额
|
||||
parent.total_earnings += amount_2
|
||||
parent.withdrawable_balance += amount_2
|
||||
parent.save()
|
||||
print(f"生成二级佣金(Distributor): {parent.user.nickname} - {amount_2}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"佣金计算失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# 3. 发送普通商品/课程购买的短信通知(排除活动报名,避免重复发送)
|
||||
# 活动报名的短信已经在上面发送过了
|
||||
if not (hasattr(order, 'activity') and order.activity):
|
||||
try:
|
||||
from .sms_utils import notify_admins_order_paid, notify_user_order_paid
|
||||
notify_admins_order_paid(order)
|
||||
notify_user_order_paid(order)
|
||||
except Exception as e:
|
||||
print(f"发送短信通知失败: {str(e)}")
|
||||
else:
|
||||
# 额外保险:如果是活动订单,手动标记不触发 signals 中的支付/发货通知
|
||||
# 因为 signals 可能会在 save() 时触发
|
||||
order._was_paid = False
|
||||
order._was_shipped = False
|
||||
65
backend/shop/signals.py
Normal file
65
backend/shop/signals.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from django.db.models.signals import pre_save, post_save
|
||||
from django.dispatch import receiver
|
||||
from .models import Order
|
||||
from .sms_utils import notify_admins_order_paid, notify_user_order_paid, notify_user_order_shipped
|
||||
|
||||
@receiver(pre_save, sender=Order)
|
||||
def track_order_changes(sender, instance, **kwargs):
|
||||
"""
|
||||
在保存之前检查状态变化
|
||||
"""
|
||||
if instance.pk:
|
||||
try:
|
||||
old_instance = Order.objects.get(pk=instance.pk)
|
||||
|
||||
# 检查是否从非支付状态变为支付状态
|
||||
if old_instance.status != 'paid' and instance.status == 'paid':
|
||||
instance._was_paid = True
|
||||
|
||||
# 检查是否发货 (状态变为 shipped 且有单号)
|
||||
# 或者已经是 shipped 状态但刚填入单号
|
||||
if instance.status == 'shipped' and instance.tracking_number:
|
||||
if old_instance.status != 'shipped' or not old_instance.tracking_number:
|
||||
instance._was_shipped = True
|
||||
|
||||
except Order.DoesNotExist:
|
||||
pass
|
||||
|
||||
@receiver(post_save, sender=Order)
|
||||
def send_order_notifications(sender, instance, created, **kwargs):
|
||||
"""
|
||||
在保存之后发送通知
|
||||
"""
|
||||
if created:
|
||||
return
|
||||
|
||||
# 1. 处理支付成功通知
|
||||
if getattr(instance, '_was_paid', False):
|
||||
try:
|
||||
# 只有当订单不是活动订单时才发送普通支付成功短信
|
||||
# 活动订单会在 views.py 中单独处理(发送报名成功短信)
|
||||
if not (hasattr(instance, 'activity') and instance.activity):
|
||||
print(f"订单 {instance.id} 支付成功,触发短信通知流程...")
|
||||
notify_admins_order_paid(instance)
|
||||
notify_user_order_paid(instance)
|
||||
else:
|
||||
print(f"订单 {instance.id} 是活动订单,跳过普通支付短信通知(已在 views.py 处理)")
|
||||
|
||||
# 清除标记防止重复发送 (虽然实例通常是新的,但保险起见)
|
||||
instance._was_paid = False
|
||||
except Exception as e:
|
||||
print(f"发送支付成功短信失败: {str(e)}")
|
||||
|
||||
# 2. 处理发货通知
|
||||
if getattr(instance, '_was_shipped', False):
|
||||
try:
|
||||
# 同样,活动订单不需要发送发货短信(通常活动无需发货)
|
||||
if not (hasattr(instance, 'activity') and instance.activity):
|
||||
print(f"订单 {instance.id} 已发货,触发短信通知流程...")
|
||||
notify_user_order_shipped(instance)
|
||||
else:
|
||||
print(f"订单 {instance.id} 是活动订单,跳过发货短信通知")
|
||||
|
||||
instance._was_shipped = False
|
||||
except Exception as e:
|
||||
print(f"发送发货短信失败: {str(e)}")
|
||||
144
backend/shop/sms_utils.py
Normal file
144
backend/shop/sms_utils.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import requests
|
||||
import threading
|
||||
import json
|
||||
from .models import AdminPhoneNumber
|
||||
|
||||
# SMS API Configuration
|
||||
SMS_API_URL = "https://data.tangledup-ai.com/api/send-sms/diy"
|
||||
SIGN_NAME = "叠加态科技云南"
|
||||
|
||||
def send_sms(phone_number, template_code, template_params):
|
||||
"""
|
||||
通用发送短信函数 (异步)
|
||||
"""
|
||||
def _send():
|
||||
try:
|
||||
payload = {
|
||||
"phone_number": phone_number,
|
||||
"template_code": template_code,
|
||||
"sign_name": SIGN_NAME,
|
||||
"template_params": template_params
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"accept": "application/json"
|
||||
}
|
||||
# print(f"Sending SMS to {phone_number} with params: {template_params}")
|
||||
response = requests.post(SMS_API_URL, json=payload, headers=headers, timeout=15)
|
||||
print(f"SMS Response for {phone_number}: {response.status_code} - {response.text}")
|
||||
except Exception as e:
|
||||
print(f"发送短信异常: {str(e)}")
|
||||
|
||||
threading.Thread(target=_send).start()
|
||||
|
||||
def notify_admins_order_paid(order):
|
||||
"""
|
||||
通知管理员有新订单支付成功
|
||||
"""
|
||||
# 获取激活的管理员手机号,最多3个
|
||||
admins = AdminPhoneNumber.objects.filter(is_active=True)[:3]
|
||||
if not admins.exists():
|
||||
print("未配置管理员手机号,跳过管理员通知")
|
||||
return
|
||||
|
||||
# 构造参数
|
||||
# 模板变量: consignee, order_id, address
|
||||
# order_id 格式要求: "订单编号/电话号码"
|
||||
params = {
|
||||
"consignee": order.customer_name or "未填写",
|
||||
"order_id": f"{order.id}/{order.phone_number}",
|
||||
"address": order.shipping_address or "无地址"
|
||||
}
|
||||
|
||||
print(f"准备发送管理员通知,共 {admins.count()} 人")
|
||||
for admin in admins:
|
||||
send_sms(admin.phone_number, "SMS_501735480", params)
|
||||
|
||||
def notify_user_order_paid(order):
|
||||
"""
|
||||
通知用户下单成功 (支付成功)
|
||||
"""
|
||||
if not order.phone_number:
|
||||
return
|
||||
|
||||
# 模板变量: user_nick, address
|
||||
# 尝试获取用户昵称,如果没有则使用收货人姓名
|
||||
user_nick = order.customer_name
|
||||
if order.wechat_user and order.wechat_user.nickname:
|
||||
user_nick = order.wechat_user.nickname
|
||||
|
||||
params = {
|
||||
"user_nick": user_nick or "用户",
|
||||
"address": order.shipping_address or "无地址"
|
||||
}
|
||||
|
||||
print(f"准备发送用户支付成功通知: {order.phone_number}")
|
||||
send_sms(order.phone_number, "SMS_501850529", params)
|
||||
|
||||
def notify_user_order_shipped(order):
|
||||
"""
|
||||
通知用户已发货
|
||||
"""
|
||||
if not order.phone_number:
|
||||
return
|
||||
|
||||
# 模板变量: user_nick, address, delivery_company, order_id (这里指快递单号)
|
||||
user_nick = order.customer_name
|
||||
if order.wechat_user and order.wechat_user.nickname:
|
||||
user_nick = order.wechat_user.nickname
|
||||
|
||||
params = {
|
||||
"user_nick": user_nick or "用户",
|
||||
"address": order.shipping_address or "无地址",
|
||||
"delivery_company": order.courier_name or "快递",
|
||||
"order_id": order.tracking_number or "暂无单号"
|
||||
}
|
||||
|
||||
print(f"准备发送用户发货通知: {order.phone_number}")
|
||||
#send_sms(order.phone_number, "SMS_501650557", params)
|
||||
send_sms(order.phone_number, "SMS_501665569", params)
|
||||
|
||||
def notify_user_activity_signup_success(order, signup):
|
||||
"""
|
||||
通知用户活动报名成功 (支付成功后)
|
||||
模板CODE: SMS_501990528
|
||||
模板变量: user_nick, unit_name, time, address
|
||||
"""
|
||||
if not order.phone_number:
|
||||
return
|
||||
|
||||
# 1. user_nick
|
||||
user_nick = order.customer_name
|
||||
if order.wechat_user and order.wechat_user.nickname:
|
||||
user_nick = order.wechat_user.nickname
|
||||
|
||||
# 2. unit_name (Activity Title)
|
||||
unit_name = f"【{signup.activity.title}】"
|
||||
|
||||
# 3. time
|
||||
start_time = signup.activity.start_time
|
||||
# Format time as YYYY-MM-DD HH:MM
|
||||
time_str = start_time.strftime("%Y-%m-%d %H:%M") if start_time else "待定"
|
||||
|
||||
# 4. address
|
||||
address = signup.activity.location or "线上活动"
|
||||
|
||||
# 5. Handle phone number format (remove +86 or spaces if any)
|
||||
phone_number = str(order.phone_number) if order.phone_number else ""
|
||||
if phone_number:
|
||||
phone_number = phone_number.replace("+86", "").replace(" ", "").strip()
|
||||
|
||||
# Ensure phone number is valid (11 digits)
|
||||
if not phone_number or len(phone_number) != 11 or not phone_number.isdigit():
|
||||
print(f"无效的手机号: {phone_number}, 跳过短信发送")
|
||||
return
|
||||
|
||||
params = {
|
||||
"user_nick": user_nick or "用户",
|
||||
"unit_name": unit_name,
|
||||
"time": time_str,
|
||||
"address": address
|
||||
}
|
||||
|
||||
print(f"准备发送活动报名成功通知: {phone_number}")
|
||||
send_sms(phone_number, "SMS_501990528", params)
|
||||
151
backend/shop/templates/shop/order_check.html
Normal file
151
backend/shop/templates/shop/order_check.html
Normal file
@@ -0,0 +1,151 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>订单查询 - 量迹AI硬件</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
#result {
|
||||
margin-top: 30px;
|
||||
}
|
||||
.order-card {
|
||||
border: 1px solid #eee;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
background-color: #fff;
|
||||
}
|
||||
.order-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.status {
|
||||
font-weight: bold;
|
||||
}
|
||||
.status-paid { color: green; }
|
||||
.status-pending { color: orange; }
|
||||
.status-shipped { color: blue; }
|
||||
.error { color: red; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>订单状态查询</h1>
|
||||
<div class="form-group">
|
||||
<label for="phone">请输入手机号码查询:</label>
|
||||
<input type="tel" id="phone" placeholder="请输入下单时填写的手机号" required>
|
||||
</div>
|
||||
<button onclick="searchOrders()">查询订单</button>
|
||||
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function searchOrders() {
|
||||
const phone = document.getElementById('phone').value;
|
||||
const resultDiv = document.getElementById('result');
|
||||
|
||||
if (!phone) {
|
||||
alert('请输入手机号码');
|
||||
return;
|
||||
}
|
||||
|
||||
resultDiv.innerHTML = '<p style="text-align:center">查询中...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/orders/lookup/?phone=${phone}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
if (data.length === 0) {
|
||||
resultDiv.innerHTML = '<p class="error">未找到相关订单</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
data.forEach(order => {
|
||||
const statusMap = {
|
||||
'pending': '待支付',
|
||||
'paid': '已支付',
|
||||
'shipped': '已发货',
|
||||
'cancelled': '已取消'
|
||||
};
|
||||
const statusText = statusMap[order.status] || order.status;
|
||||
const statusClass = `status-${order.status}`;
|
||||
|
||||
html += `
|
||||
<div class="order-card">
|
||||
<div class="order-header">
|
||||
<span>订单号: ${order.id}</span>
|
||||
<span class="status ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p><strong>商品:</strong> ${order.config_name || '未命名配置'}</p>
|
||||
<p><strong>数量:</strong> ${order.quantity}</p>
|
||||
<p><strong>总价:</strong> ¥${order.total_price}</p>
|
||||
<p><strong>下单时间:</strong> ${new Date(order.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
resultDiv.innerHTML = html;
|
||||
} else {
|
||||
resultDiv.innerHTML = `<p class="error">${data.error || '查询失败'}</p>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
resultDiv.innerHTML = '<p class="error">网络错误,请稍后重试</p>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
3
backend/shop/tests.py
Normal file
3
backend/shop/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
31
backend/shop/urls.py
Normal file
31
backend/shop/urls.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django.urls import path, include, re_path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import (
|
||||
ESP32ConfigViewSet, OrderViewSet, order_check_view,
|
||||
ServiceViewSet, VCCourseViewSet, ServiceOrderViewSet,
|
||||
payment_finish, pay, send_sms_code, wechat_login, update_user_info, DistributorViewSet,
|
||||
CourseEnrollmentViewSet, phone_login, bind_phone, WeChatUserViewSet, upload_image
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'configs', ESP32ConfigViewSet)
|
||||
router.register(r'orders', OrderViewSet)
|
||||
router.register(r'services', ServiceViewSet)
|
||||
router.register(r'courses', VCCourseViewSet)
|
||||
router.register(r'course-enrollments', CourseEnrollmentViewSet)
|
||||
router.register(r'service-orders', ServiceOrderViewSet)
|
||||
router.register(r'distributor', DistributorViewSet, basename='distributor')
|
||||
router.register(r'users', WeChatUserViewSet, basename='wechatuser')
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^finish/?$', payment_finish, name='payment-finish'),
|
||||
re_path(r'^pay/?$', pay, name='wechat-pay-v3'),
|
||||
path('auth/send-sms/', send_sms_code, name='send-sms'),
|
||||
path('wechat/login/', wechat_login, name='wechat-login'),
|
||||
path('auth/phone-login/', phone_login, name='phone-login'),
|
||||
path('auth/bind-phone/', bind_phone, name='bind-phone'),
|
||||
path('wechat/update/', update_user_info, name='wechat-update'),
|
||||
path('upload/image/', upload_image, name='upload-image'),
|
||||
path('page/check-order/', order_check_view, name='check-order-page'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
88
backend/shop/utils.py
Normal file
88
backend/shop/utils.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import requests
|
||||
from django.core.cache import cache
|
||||
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, force_refresh=False):
|
||||
"""
|
||||
获取微信接口调用凭证 (client_credential)
|
||||
"""
|
||||
# 尝试从缓存获取
|
||||
cache_key = 'wechat_access_token'
|
||||
if config:
|
||||
cache_key = f'wechat_access_token_{config.app_id}'
|
||||
|
||||
if not force_refresh:
|
||||
token = cache.get(cache_key)
|
||||
if token:
|
||||
return token
|
||||
|
||||
if not config:
|
||||
# 优先查找指定 AppID
|
||||
config = WeChatPayConfig.objects.filter(app_id='wxdf2ca73e6c0929f0').first()
|
||||
if not config:
|
||||
config = WeChatPayConfig.objects.filter(is_active=True).first()
|
||||
|
||||
if not config or not config.app_id or not config.app_secret:
|
||||
logger.error("No active WeChatPayConfig found or missing app_id/app_secret")
|
||||
return None
|
||||
|
||||
url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={config.app_id}&secret={config.app_secret}"
|
||||
try:
|
||||
response = requests.get(url, timeout=10)
|
||||
data = response.json()
|
||||
|
||||
if 'access_token' in data:
|
||||
token = data['access_token']
|
||||
expires_in = data.get('expires_in', 7200)
|
||||
# 缓存 Token,留出 200 秒缓冲时间
|
||||
cache.set(cache_key, token, expires_in - 200)
|
||||
return token
|
||||
else:
|
||||
logger.error(f"获取 AccessToken 失败: {data}")
|
||||
except Exception as e:
|
||||
logger.error(f"获取 AccessToken 异常: {str(e)}", exc_info=True)
|
||||
|
||||
return None
|
||||
1693
backend/shop/views.py
Normal file
1693
backend/shop/views.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user