创赢未来评分系统 - 初始化提交(移除大文件)

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

0
backend/shop/__init__.py Normal file
View File

548
backend/shop/admin.py Normal file
View 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]

View 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
View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class ShopConfig(AppConfig):
name = 'shop'
verbose_name = "课程培训"
def ready(self):
import shop.signals

View 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': '订单列表',
},
),
]

View File

@@ -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='发货地址'),
),
]

View File

@@ -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='所属销售员'),
),
]

View File

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

View File

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

View File

@@ -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='图标 (上传)'),
),
]

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

View File

@@ -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': '服务订单列表',
},
),
]

View File

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

View File

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

View File

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

View File

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

View 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='商户订单号'),
),
]

View File

@@ -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='快递单号'),
),
]

View File

@@ -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': '佣金结算',
},
),
]

View File

@@ -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='下单微信用户'),
),
]

View 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': '提现管理',
},
),
]

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

View File

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

View 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='课程类型'),
),
]

View File

@@ -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='获佣销售员'),
),
]

View File

@@ -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': '课程报名管理',
},
),
]

View File

@@ -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='所选配置'),
),
]

View File

@@ -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='讲师头衔'),
),
]

View File

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

View 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='手机号'),
),
]

View File

@@ -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='专家头衔'),
),
]

View 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 = [
# 空操作 - 此迁移仅用于旧数据库修复,新数据库无需执行
]

View 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 = [
# 空操作 - 此迁移仅用于旧数据库修复,新数据库无需执行
]

View File

@@ -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='所选配置'),
),
]

View 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': '管理员通知手机号',
},
),
]

View 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='所选活动'),
),
]

View File

@@ -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='课程具体时间'),
),
]

View File

@@ -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='开始时间'),
),
]

View 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='专家技能'),
),
]

View File

@@ -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='排序权重'),
),
]

View 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徽章'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-02-27 05:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0037_wechatuser_has_web_badge'),
]
operations = [
migrations.AddField(
model_name='vccourse',
name='is_video_course',
field=models.BooleanField(default=False, verbose_name='是否视频课程'),
),
migrations.AddField(
model_name='vccourse',
name='video_url',
field=models.URLField(blank=True, help_text='仅当用户付费或报名后可见', null=True, verbose_name='视频课程URL'),
),
]

View File

@@ -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='视频嵌入代码'),
),
]

View File

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

View File

495
backend/shop/models.py Normal file
View 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
View 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
View 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
View 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
View 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)

View 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
View File

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

31
backend/shop/urls.py Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff