This commit is contained in:
jeremygan2021
2026-02-11 01:31:21 +08:00
parent 61afc52ac2
commit 2d090cd0f4
97 changed files with 3661 additions and 4 deletions

Binary file not shown.

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

@@ -3,6 +3,86 @@ 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)
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)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
def __str__(self):
return self.nickname or self.openid
class Meta:
verbose_name = "微信用户"
verbose_name_plural = "微信用户管理"
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):
"""
@@ -146,6 +226,9 @@ class Order(models.Model):
# 销售归属
salesperson = models.ForeignKey(Salesperson, 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="")

View File

@@ -1,5 +1,25 @@
from rest_framework import serializers
from .models import ESP32Config, Order, Salesperson, Service, ARService, ProductFeature, ServiceOrder
from .models import ESP32Config, Order, Salesperson, Service, ARService, ProductFeature, ServiceOrder, WeChatUser, Distributor, Withdrawal
class WeChatUserSerializer(serializers.ModelSerializer):
class Meta:
model = WeChatUser
fields = ['id', 'nickname', 'avatar_url', 'gender', 'country', 'province', 'city']
read_only_fields = ['id']
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):
"""

View File

@@ -3,7 +3,7 @@ from rest_framework.routers import DefaultRouter
from .views import (
ESP32ConfigViewSet, OrderViewSet, order_check_view,
ServiceViewSet, ARServiceViewSet, ServiceOrderViewSet,
payment_finish, pay, send_sms_code
payment_finish, pay, send_sms_code, wechat_login, update_user_info, DistributorViewSet
)
router = DefaultRouter()
@@ -12,11 +12,14 @@ router.register(r'orders', OrderViewSet)
router.register(r'services', ServiceViewSet)
router.register(r'ar', ARServiceViewSet)
router.register(r'service-orders', ServiceOrderViewSet)
router.register(r'distributor', DistributorViewSet, basename='distributor')
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('wechat/update/', update_user_info, name='wechat-update'),
path('page/check-order/', order_check_view, name='check-order-page'),
path('', include(router.urls)),
]

View File

@@ -5,8 +5,10 @@ from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample
from .models import ESP32Config, Order, WeChatPayConfig, Service, ARService, ServiceOrder, Salesperson, CommissionLog
from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, ARServiceSerializer, ServiceOrderSerializer
from .models import ESP32Config, Order, WeChatPayConfig, Service, ARService, ServiceOrder, Salesperson, CommissionLog, WeChatUser, Distributor, Withdrawal
from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, ARServiceSerializer, ServiceOrderSerializer, WeChatUserSerializer, DistributorSerializer, WithdrawalSerializer
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from django.contrib.auth.models import User
from wechatpayv3 import WeChatPay, WeChatPayType
from wechatpayv3.core import Core
import xml.etree.ElementTree as ET
@@ -560,6 +562,96 @@ class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.all()
serializer_class = OrderSerializer
def get_queryset(self):
"""
如果用户已通过微信登录,只返回自己的订单
否则(如管理员)返回所有订单
"""
queryset = super().get_queryset()
user = get_current_wechat_user(self.request)
if user:
return queryset.filter(wechat_user=user).order_by('-created_at')
return queryset.order_by('-created_at')
@action(detail=True, methods=['post'])
def prepay_miniprogram(self, request, pk=None):
"""
小程序支付下单 (返回 wx.requestPayment 所需参数)
"""
order = self.get_object()
if order.status == 'paid':
return Response({'error': '订单已支付'}, status=status.HTTP_400_BAD_REQUEST)
user = get_current_wechat_user(request)
if not user:
return Response({'error': 'Unauthorized'}, status=401)
# 绑定用户
if not order.wechat_user:
order.wechat_user = user
order.save()
wechat_config = WeChatPayConfig.objects.filter(is_active=True).first()
if not wechat_config:
return Response({'error': '支付系统维护中'}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
# 初始化支付客户端
wxpay, error_msg = get_wechat_pay_client()
if not wxpay:
return Response({'error': error_msg}, status=500)
amount_in_cents = int(order.total_price * 100)
out_trade_no = f"PAY{order.id}T{int(time.time())}"
order.out_trade_no = out_trade_no
order.save()
try:
# 统一下单 (JSAPI)
code, message = wxpay.pay(
description=f"购买 {order.config.name} x {order.quantity}",
out_trade_no=out_trade_no,
amount={'total': amount_in_cents, 'currency': 'CNY'},
payer={'openid': user.openid}, # 小程序支付必须传 openid
notify_url=wechat_config.notify_url
)
result = json.loads(message)
if code in range(200, 300):
prepay_id = result.get('prepay_id')
# 生成小程序调起支付所需的参数
timestamp = str(int(time.time()))
nonce_str = str(uuid.uuid4()).replace('-', '')
package = f"prepay_id={prepay_id}"
# 再次签名 (小程序端需要的签名)
# 注意WeChatPayV3 SDK 可能没有直接提供生成小程序签名的 helper需手动计算
# 签名串格式appId\ntimeStamp\nnonceStr\npackage\n
message_build = f"{wechat_config.app_id}\n{timestamp}\n{nonce_str}\n{package}\n"
# 使用商户私钥签名
# 这里的私钥加载逻辑需复用 get_wechat_pay_client 中的逻辑,或者直接从 wxpay 实例获取 (如果它暴露了)
# 简单起见,我们重新加载私钥
private_key_str = wxpay._private_key # 假设 SDK 内部存储了 private_key (通常是 obj)
# 由于 SDK 内部处理复杂,我们尝试用 cryptography 库签名
# 实际上 wechatpayv3 库提供了 sign 方法
signature = wxpay.sign(message_build)
return Response({
'timeStamp': timestamp,
'nonceStr': nonce_str,
'package': package,
'signType': 'RSA',
'paySign': signature,
'out_trade_no': out_trade_no
})
else:
return Response({'error': '微信下单失败', 'detail': result}, status=400)
except Exception as e:
return Response({'error': str(e)}, status=500)
@action(detail=False, methods=['get'])
def lookup(self, request):
"""
@@ -720,3 +812,180 @@ class OrderViewSet(viewsets.ModelViewSet):
order.wechat_trade_no = f"WX_{str(uuid.uuid4())[:18]}"
order.save()
return Response({'status': 'success', 'message': '支付成功'})
def get_current_wechat_user(request):
"""
根据 Authorization 头获取当前微信用户
"""
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天有效
return WeChatUser.objects.filter(openid=openid).first()
except (BadSignature, SignatureExpired):
return None
@extend_schema(
summary="微信小程序登录",
request={
'application/json': {
'properties': {'code': {'type': 'string', 'description': 'wx.login获取的code'}},
'required': ['code']
}
},
responses={200: {'properties': {'token': {'type': 'string'}, 'openid': {'type': 'string'}}}}
)
@api_view(['POST'])
def wechat_login(request):
code = request.data.get('code')
if not code:
return Response({'error': 'Code is required'}, status=400)
config = WeChatPayConfig.objects.filter(is_active=True).first()
if not config or not config.app_id or not config.app_secret:
return Response({'error': 'WeChat config missing'}, status=500)
# 换取 OpenID
url = f"https://api.weixin.qq.com/sns/jscode2session?appid={config.app_id}&secret={config.app_secret}&js_code={code}&grant_type=authorization_code"
try:
res = requests.get(url, timeout=10)
data = res.json()
except Exception as e:
return Response({'error': str(e)}, status=500)
if 'errcode' in data and data['errcode'] != 0:
return Response({'error': data.get('errmsg')}, status=400)
openid = data.get('openid')
session_key = data.get('session_key')
unionid = data.get('unionid')
# 创建或更新用户
user, created = WeChatUser.objects.update_or_create(
openid=openid,
defaults={
'session_key': session_key,
'unionid': unionid
}
)
# 生成 Token
signer = TimestampSigner()
token = signer.sign(openid)
return Response({
'token': token,
'openid': openid,
'is_new': created,
'nickname': user.nickname
})
@extend_schema(
summary="更新微信用户信息",
request=WeChatUserSerializer,
)
@api_view(['POST'])
def update_user_info(request):
user = get_current_wechat_user(request)
if not user:
return Response({'error': 'Unauthorized'}, status=401)
serializer = WeChatUserSerializer(user, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)
class DistributorViewSet(viewsets.GenericViewSet):
"""
分销员接口
"""
queryset = Distributor.objects.all()
serializer_class = DistributorSerializer
@action(detail=False, methods=['post'])
def register(self, request):
user = get_current_wechat_user(request)
if not user:
return Response({'error': 'Unauthorized'}, status=401)
if hasattr(user, 'distributor'):
return Response({'error': 'Already registered'}, status=400)
# 检查是否有关联上级 (通过 invite_code)
parent = None
invite_code = request.data.get('invite_code')
if invite_code:
parent = Distributor.objects.filter(invite_code=invite_code).first()
# 生成自己的邀请码
my_invite_code = str(uuid.uuid4())[:8]
distributor = Distributor.objects.create(
user=user,
parent=parent,
invite_code=my_invite_code,
status='pending' # 需要审核
)
return Response(DistributorSerializer(distributor).data)
@action(detail=False, methods=['get'])
def info(self, request):
user = get_current_wechat_user(request)
if not user:
return Response({'error': 'Unauthorized'}, status=401)
if not hasattr(user, 'distributor'):
return Response({'error': 'Not a distributor'}, status=404)
return Response(DistributorSerializer(user.distributor).data)
@action(detail=False, methods=['post'])
def invite(self, request):
"""生成小程序码"""
user = get_current_wechat_user(request)
if not user or not hasattr(user, 'distributor'):
return Response({'error': 'Unauthorized'}, status=401)
distributor = user.distributor
if distributor.qr_code_url:
return Response({'qr_code_url': distributor.qr_code_url})
# 调用微信接口生成小程序码 (wxacode.getUnlimited)
# 这里简化处理返回模拟URL或需要实现具体逻辑
# 实际逻辑需要获取 AccessToken 然后调用 API
return Response({'qr_code_url': 'https://placeholder.com/qrcode.png'})
@action(detail=False, methods=['post'])
def withdraw(self, request):
user = get_current_wechat_user(request)
if not user or not hasattr(user, 'distributor'):
return Response({'error': 'Unauthorized'}, status=401)
amount = float(request.data.get('amount', 0))
if amount <= 0:
return Response({'error': 'Invalid amount'}, status=400)
distributor = user.distributor
if distributor.withdrawable_balance < amount:
return Response({'error': 'Insufficient balance'}, status=400)
# 创建提现记录
Withdrawal.objects.create(
distributor=distributor,
amount=amount,
status='pending'
)
# 扣减余额
distributor.withdrawable_balance -= models.DecimalField(max_digits=12, decimal_places=2).to_python(amount)
distributor.save()
return Response({'message': 'Withdrawal request submitted'})