Files
market_page/backend/shop/views.py
jeremygan2021 799965ee74
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
解决报名支付
2026-02-23 16:31:34 +08:00

1736 lines
72 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from rest_framework import viewsets, status
from rest_framework.decorators import action, api_view, parser_classes
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
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, VCCourse, ServiceOrder, Salesperson, CommissionLog, WeChatUser, Distributor, Withdrawal, CourseEnrollment
from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, VCCourseSerializer, ServiceOrderSerializer, WeChatUserSerializer, DistributorSerializer, WithdrawalSerializer, CommissionLogSerializer, CourseEnrollmentSerializer
from .utils import get_access_token
from django.db import transaction, models
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
import uuid
import time
import hashlib
import json
import os
import base64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from django.conf import settings
import requests
import random
import threading
import logging
import string
from django.core.cache import cache
logger = logging.getLogger(__name__)
from time import sleep
# 猴子补丁:绕过微信支付响应签名验证
# 原因是在开发环境或证书未能正确下载时SDK 会因为无法验证微信返回的签名而抛出异常。
# 但实际上请求已经成功发送到了微信,且微信已经返回了支付链接。
original_request = Core.request
def patched_request(self, *args, **kwargs):
# 强制设置 skip_verify 为 True同时保留其他所有参数的默认值
kwargs['skip_verify'] = True
return original_request(self, *args, **kwargs)
Core.request = patched_request
def get_wechat_pay_client(pay_type=WeChatPayType.NATIVE, appid=None, config=None):
"""
获取微信支付 V3 客户端实例的辅助函数
"""
print(f"正在获取微信支付配置...")
wechat_config = config
if not wechat_config:
wechat_config = WeChatPayConfig.objects.filter(is_active=True).first()
if not wechat_config:
print("错误: 数据库中没有激活的 WeChatPayConfig")
return None, "支付配置未找到"
print(f"找到配置: ID={wechat_config.id}, MCH_ID={wechat_config.mch_id}")
# 1. 严格清理所有配置项的空格和换行符
mch_id = str(wechat_config.mch_id).strip()
# 如果传入了 appid优先使用传入的
if not appid:
appid = str(wechat_config.app_id).strip()
else:
appid = str(appid).strip()
apiv3_key = str(wechat_config.apiv3_key).strip()
serial_no = str(wechat_config.mch_cert_serial_no).strip()
notify_url = str(wechat_config.notify_url).strip()
# 查找私钥文件
private_key = None
possible_key_paths = [
os.path.join(settings.BASE_DIR, 'certs', 'apiclient_key.pem'),
os.path.join(settings.BASE_DIR, 'static', 'cert', 'apiclient_key.pem'),
os.path.join(settings.BASE_DIR, 'staticfiles', 'cert', 'apiclient_key.pem'),
os.path.join(settings.BASE_DIR, 'backend', 'certs', 'apiclient_key.pem'),
]
for key_path in possible_key_paths:
if os.path.exists(key_path):
try:
with open(key_path, 'r', encoding='utf-8') as f:
private_key = f.read()
break
except Exception as e:
print(f"尝试读取私钥文件 {key_path} 失败: {str(e)}")
if not private_key:
private_key = wechat_config.mch_private_key
if private_key:
# 统一处理私钥格式
private_key = private_key.strip()
# 移除可能存在的首尾空白字符
if 'BEGIN PRIVATE KEY' in private_key:
# 如果已经包含 PEM 头,尝试清理并重新格式化
lines = private_key.split('\n')
clean_lines = [line.strip() for line in lines if line.strip()]
private_key = '\n'.join(clean_lines)
else:
# 如果没有头尾,说明是纯 base64 内容,尝试添加
private_key = f"-----BEGIN PRIVATE KEY-----\n{private_key}\n-----END PRIVATE KEY-----"
if not private_key:
return None, "缺少商户私钥 (未找到文件且数据库配置为空)"
# 确保回调地址以斜杠结尾
if not notify_url.endswith('/'):
notify_url += '/'
cert_dir = os.path.join(settings.BASE_DIR, 'certs')
if not os.path.exists(cert_dir):
os.makedirs(cert_dir)
try:
wxpay = WeChatPay(
wechatpay_type=pay_type,
mchid=mch_id,
private_key=private_key,
cert_serial_no=serial_no,
apiv3_key=apiv3_key,
appid=appid,
notify_url=notify_url,
cert_dir=cert_dir
)
# 保存私钥内容以便后续手动签名使用
wxpay._private_key_content = private_key
return wxpay, None
except Exception as e:
return None, str(e)
@extend_schema(
summary="发送短信验证码",
description="发送6位数字验证码到指定手机号",
request={
'application/json': {
'type': 'object',
'properties': {
'phone_number': {'type': 'string', 'description': '手机号码'},
},
'required': ['phone_number']
}
},
responses={
200: OpenApiExample('成功', value={'message': '验证码已发送'}),
400: OpenApiExample('失败', value={'error': '手机号不能为空'})
}
)
@api_view(['POST'])
def send_sms_code(request):
phone = request.data.get('phone_number')
if not phone:
return Response({'error': '手机号不能为空'}, status=status.HTTP_400_BAD_REQUEST)
# 生成6位验证码
code = ''.join([str(random.randint(0, 9)) for _ in range(6)])
# 缓存验证码 (5分钟有效)
cache_key = f"sms_code_{phone}"
cache.set(cache_key, code, timeout=300)
# 异步发送短信
def _send_async():
try:
api_url = "https://data.tangledup-ai.com/api/send-sms"
payload = {
"phone_number": phone,
"code": code,
"template_code": "SMS_493295002",
"sign_name": "叠加态科技云南",
"additionalProp1": {}
}
headers = {
"Content-Type": "application/json",
"accept": "application/json"
}
response = requests.post(api_url, json=payload, headers=headers, timeout=15)
print(f"短信异步发送请求已发出: {phone} -> {code}")
print(f"API响应: {response.status_code} - {response.text}")
except Exception as e:
print(f"异步发送短信异常: {str(e)}")
threading.Thread(target=_send_async).start()
sleep(2)
# 立即返回成功无需等待外部API响应
return Response({'message': '验证码已发送'})
@extend_schema(
summary="微信支付 V3 Native 下单",
description="创建订单并获取微信支付二维码链接(code_url)。参数包括商品ID、数量、客户信息等。",
request={
'application/json': {
'type': 'object',
'properties': {
'goodid': {'type': 'integer', 'description': '商品ID (ESP32Config ID)'},
'quantity': {'type': 'integer', 'description': '购买数量', 'default': 1},
'customer_name': {'type': 'string', 'description': '收货人姓名'},
'phone_number': {'type': 'string', 'description': '联系电话'},
'shipping_address': {'type': 'string', 'description': '详细收货地址'},
'ref_code': {'type': 'string', 'description': '推荐码 (销售员代码)', 'nullable': True},
},
'required': ['goodid', 'customer_name', 'phone_number', 'shipping_address']
}
},
responses={
200: OpenApiExample(
'成功响应',
value={
'code_url': 'weixin://wxpay/bizpayurl?pr=XXXXX',
'out_trade_no': 'PAY123T1738800000',
'order_id': 123,
'message': '下单成功'
}
),
400: OpenApiExample(
'参数错误/配置不全',
value={'error': '缺少必要参数: ...'}
)
}
)
@api_view(['POST'])
def pay(request):
"""
微信支付 V3 Native 下单接口
参数: goodid, quantity, customer_name, phone_number, shipping_address, ref_code
"""
print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 进入 pay 接口")
print(f"Request Headers: {request.headers}")
print(f"Request Data: {request.data}")
# 1. 获取并验证请求参数
good_id = request.data.get('goodid')
order_type = request.data.get('type', 'config') # 默认为 config
quantity = int(request.data.get('quantity', 1))
customer_name = request.data.get('customer_name')
phone_number = request.data.get('phone_number')
shipping_address = request.data.get('shipping_address')
ref_code = request.data.get('ref_code')
if not all([good_id, customer_name, phone_number, shipping_address]):
missing_params = []
if not good_id: missing_params.append('goodid')
if not customer_name: missing_params.append('customer_name')
if not phone_number: missing_params.append('phone_number')
if not shipping_address: missing_params.append('shipping_address')
print(f"支付接口缺少参数: {missing_params}, 接收到的数据: {request.data}")
return Response({'error': f'缺少必要参数: {", ".join(missing_params)}'}, status=status.HTTP_400_BAD_REQUEST)
# 2. 获取支付配置并初始化客户端
wxpay, error_msg = get_wechat_pay_client()
if not wxpay:
print(f"支付配置错误: {error_msg}")
return Response({'error': f'支付配置错误: {error_msg}'}, status=status.HTTP_400_BAD_REQUEST)
# 3. 查找商品和销售员,创建订单
product = None
if order_type == 'course':
try:
product = VBCourse.objects.get(id=good_id)
except VBCourse.DoesNotExist:
print(f"课程不存在: {good_id}")
return Response({'error': f'找不到 ID 为 {good_id} 的课程'}, status=status.HTTP_404_NOT_FOUND)
else:
try:
product = ESP32Config.objects.get(id=good_id)
except ESP32Config.DoesNotExist:
print(f"商品不存在: {good_id}")
return Response({'error': f'找不到 ID 为 {good_id} 的商品'}, status=status.HTTP_404_NOT_FOUND)
# 检查库存 (仅针对硬件)
if product.stock < quantity:
return Response({'error': f'库存不足,仅剩 {product.stock}'}, status=status.HTTP_400_BAD_REQUEST)
salesperson = None
if ref_code:
from .models import Salesperson
salesperson = Salesperson.objects.filter(code=ref_code).first()
# 尝试获取当前登录用户 (如果请求头带有 Authorization)
wechat_user = get_current_wechat_user(request)
total_price = product.price * quantity
amount_in_cents = int(total_price * 100)
order_kwargs = {
'quantity': quantity,
'total_price': total_price,
'customer_name': customer_name,
'phone_number': phone_number,
'shipping_address': shipping_address,
'salesperson': salesperson,
'wechat_user': wechat_user,
'status': 'pending'
}
if order_type == 'course':
order_kwargs['course'] = product
else:
order_kwargs['config'] = product
order = Order.objects.create(**order_kwargs)
# 扣减库存 (仅针对硬件)
if order_type != 'course':
product.stock -= quantity
product.save()
# 4. 调用微信支付接口
out_trade_no = f"PAY{order.id}T{int(time.time())}"
if order_type == 'course':
description = f"报名 {product.title}"
else:
description = f"购买 {product.name} x {quantity}"
# 保存商户订单号到数据库,方便后续查询
order.out_trade_no = out_trade_no
order.save()
try:
# 显式获取并打印 notify_url确保它与你配置的一致
notify_url = wxpay._notify_url
print(f"========================================")
print(f"发起微信支付 Native 下单")
print(f"商户订单号: {out_trade_no}")
print(f"回调地址 (notify_url): {notify_url}")
print(f"========================================")
code, message = wxpay.pay(
description=description,
out_trade_no=out_trade_no,
amount={
'total': amount_in_cents,
'currency': 'CNY'
},
notify_url=notify_url # 显式传入,确保库使用该地址
)
result = json.loads(message)
if code in range(200, 300):
code_url = result.get('code_url')
# 打印到控制台
print(f"========================================")
print(f"微信支付 V3 Native 下单成功!")
print(f"订单 ID: {order.id}")
print(f"商户订单号: {out_trade_no}")
print(f"商品: {product.name} x {quantity}")
print(f"总额: {total_price}")
print(f"code_url: {code_url}")
print(f"========================================")
return Response({
'code_url': code_url,
'out_trade_no': out_trade_no,
'order_id': order.id,
'message': '下单成功'
})
else:
print(f"微信支付 V3 下单失败: {message}")
order.delete() # 下单失败则删除刚刚创建的订单
return Response({
'error': '微信支付官方接口返回错误',
'detail': result
}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
import traceback
print(f"调用微信支付接口发生异常: {str(e)}")
traceback.print_exc()
if 'order' in locals() and order.id: order.delete()
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@csrf_exempt
def payment_finish(request):
"""
微信支付 V3 回调接口
参考文档: https://pay.weixin.qq.com/doc/v3/merchant/4012071382
"""
print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 收到回调请求: {request.method} {request.path}")
if request.method != 'POST':
return HttpResponse("Method not allowed", status=405)
# 1. 获取回调头信息
headers = {
'Wechatpay-Timestamp': request.headers.get('Wechatpay-Timestamp'),
'Wechatpay-Nonce': request.headers.get('Wechatpay-Nonce'),
'Wechatpay-Signature': request.headers.get('Wechatpay-Signature'),
'Wechatpay-Serial': request.headers.get('Wechatpay-Serial'),
'Wechatpay-Signature-Type': request.headers.get('Wechatpay-Signature-Type', 'WECHATPAY2-SHA256-RSA2048'),
}
body = request.body.decode('utf-8')
print(f"收到回调 Body (长度: {len(body)}):")
print(f"--- BODY START ---")
print(body)
print(f"--- BODY END ---")
# 打印所有微信支付相关的头信息
print("收到回调 Headers:")
for key, value in request.headers.items():
if key.lower().startswith('wechatpay'):
print(f" {key}: {value}")
try:
# 2. 获取支付配置并初始化
wechat_config = WeChatPayConfig.objects.filter(is_active=True).first()
if not wechat_config:
print("错误: 数据库中没有启用的微信支付配置")
return HttpResponse("Config not found", status=500)
print(f"当前使用的配置 ID: {wechat_config.id}, 商户号: {wechat_config.mch_id}")
wxpay, error_msg = get_wechat_pay_client()
if not wxpay:
return HttpResponse(error_msg, status=500)
# 3. 解析并校验基础信息
try:
data = json.loads(body)
print(f"解析后的回调数据概览: id={data.get('id')}, event_type={data.get('event_type')}, resource_type={data.get('resource_type')}")
except Exception as json_e:
print(f"JSON 解析失败: {str(json_e)}")
return HttpResponse("Invalid JSON", status=400)
# 4. 尝试解密
apiv3_key = str(wechat_config.apiv3_key).strip()
print(f"正在使用 Key[{apiv3_key[:3]}...{apiv3_key[-3:]}] (长度: {len(apiv3_key)}) 尝试解密...")
# 优先使用 SDK 的 callback 方法
try:
print("尝试使用 SDK callback 方法解密并验证签名...")
# 调试:打印 headers 关键信息
print(f"Headers: Timestamp={headers.get('Wechatpay-Timestamp')}, Serial={headers.get('Wechatpay-Serial')}")
result_str = wxpay.callback(headers, body)
if result_str:
result = json.loads(result_str)
print(f"SDK 解密成功: {result.get('out_trade_no')}")
else:
print("SDK callback 返回空,可能是签名验证失败。")
raise Exception("SDK callback returned None")
except Exception as sdk_e:
print(f"SDK callback 失败: {str(sdk_e)},尝试手动解密...")
resource = data.get('resource', {})
ciphertext = resource.get('ciphertext')
nonce = resource.get('nonce')
associated_data = resource.get('associated_data')
print(f"提取的解密参数: nonce={nonce}, associated_data={associated_data}, ciphertext_len={len(ciphertext) if ciphertext else 0}")
try:
if not all([ciphertext, nonce, apiv3_key]):
raise ValueError(f"缺少解密必要参数: ciphertext={bool(ciphertext)}, nonce={bool(nonce)}, key={bool(apiv3_key)}")
if len(apiv3_key) != 32:
raise ValueError(f"APIV3 Key 长度错误: 预期 32 字节,实际 {len(apiv3_key)} 字节")
aesgcm = AESGCM(apiv3_key.encode('utf-8'))
decrypted_data = aesgcm.decrypt(
nonce.encode('utf-8'),
base64.b64decode(ciphertext),
associated_data.encode('utf-8') if associated_data else b""
)
result = json.loads(decrypted_data.decode('utf-8'))
print(f"手动解密成功: {result.get('out_trade_no')}")
except Exception as e:
import traceback
error_type = type(e).__name__
error_msg = str(e)
print(f"手动解密依然失败: {error_type}: {error_msg}")
if "InvalidTag" in error_msg or error_type == "InvalidTag":
print(f"提示: InvalidTag 通常意味着 Key 正确但与数据不匹配。")
print(f"当前使用的 Key: {apiv3_key}")
print(f"请确认该 Key 是否确实是商户号 {wechat_config.mch_id} 的 APIV3 密钥。")
traceback.print_exc()
return HttpResponse("Decryption failed", status=400)
# 5. 订单处理 (保持原有逻辑)
out_trade_no = result.get('out_trade_no')
transaction_id = result.get('transaction_id')
trade_state = result.get('trade_state')
if trade_state == 'SUCCESS':
try:
order = None
if out_trade_no.startswith('PAY'):
t_index = out_trade_no.find('T')
order_id = int(out_trade_no[3:t_index])
order = Order.objects.get(id=order_id)
else:
order = Order.objects.get(out_trade_no=out_trade_no)
if order and order.status != 'paid':
order.status = 'paid'
order.wechat_trade_no = transaction_id
order.save()
print(f"订单 {order.id} 状态已更新")
# Handle Activity Signup
if hasattr(order, 'activity') and order.activity:
try:
# Use string import to avoid circular dependency at module level
from community.models import ActivitySignup
signup = ActivitySignup.objects.filter(order=order).first()
if signup:
# Determine status based on activity setting
new_status = 'confirmed' if signup.activity.auto_confirm else 'pending'
signup.status = new_status
signup.save()
print(f"活动报名状态已更新: {signup.id} -> {new_status}")
except Exception as e:
print(f"更新活动报名状态失败: {str(e)}")
# 计算佣金 (旧版销售员系统)
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()
except Exception as e:
print(f"订单更新失败: {str(e)}")
return HttpResponse(status=200)
except Exception as e:
import traceback
print(f"回调处理发生异常: {str(e)}")
traceback.print_exc()
return HttpResponse(str(e), status=500)
@extend_schema_view(
list=extend_schema(summary="获取VC课程列表", description="获取所有可用的VC课程"),
retrieve=extend_schema(summary="获取VC课程详情", description="获取指定VC课程的详细信息")
)
class VCCourseViewSet(viewsets.ReadOnlyModelViewSet):
"""
VC课程列表和详情
"""
queryset = VCCourse.objects.all()
serializer_class = VCCourseSerializer
class CourseEnrollmentViewSet(viewsets.ModelViewSet):
"""
课程报名管理
"""
queryset = CourseEnrollment.objects.all().order_by('-created_at')
serializer_class = CourseEnrollmentSerializer
def order_check_view(request):
"""
订单查询页面视图
"""
return render(request, 'shop/order_check.html')
@extend_schema_view(
list=extend_schema(summary="获取AI服务列表", description="获取所有可用的AI服务"),
retrieve=extend_schema(summary="获取AI服务详情", description="获取指定AI服务的详细信息")
)
class ServiceViewSet(viewsets.ReadOnlyModelViewSet):
"""
AI服务列表和详情
"""
queryset = Service.objects.all()
serializer_class = ServiceSerializer
class ServiceOrderViewSet(viewsets.ModelViewSet):
"""
AI服务订单管理
"""
queryset = ServiceOrder.objects.all()
serializer_class = ServiceOrderSerializer
@extend_schema_view(
list=extend_schema(summary="获取ESP32配置列表", description="获取所有可用的ESP32硬件配置选项"),
retrieve=extend_schema(summary="获取ESP32配置详情", description="获取指定ESP32配置的详细信息")
)
class ESP32ConfigViewSet(viewsets.ReadOnlyModelViewSet):
"""
提供ESP32配置选项的列表和详情
"""
queryset = ESP32Config.objects.all()
serializer_class = ESP32ConfigSerializer
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')
def perform_create(self, serializer):
"""
创建订单时自动关联当前微信用户
"""
user = get_current_wechat_user(self.request)
serializer.save(wechat_user=user)
@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()
# 小程序 AppID
miniprogram_appid = 'wxdf2ca73e6c0929f0'
# 尝试查找特定配置
wechat_config = WeChatPayConfig.objects.filter(app_id=miniprogram_appid).first()
if not wechat_config:
wechat_config = WeChatPayConfig.objects.filter(is_active=True).first()
if not wechat_config:
return Response({'error': '支付系统维护中'}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
# 初始化支付客户端,强制使用小程序 AppID
wxpay, error_msg = get_wechat_pay_client(
pay_type=WeChatPayType.JSAPI,
appid=miniprogram_appid,
config=wechat_config
)
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:
# 动态生成描述
if order.config:
description = f"购买 {order.config.name} x {order.quantity}"
elif order.course:
description = f"报名 {order.course.title}"
else:
description = f"支付订单 {order.id}"
# 强制修正回调地址为正确的后端接口地址
# 用户配置可能是 /pay (前端页面),我们需要的是 /api/finish/ (后端回调接口)
current_notify = wechat_config.notify_url
if 'quant-speed.com' in current_notify:
notify_url = "https://market.quant-speed.com/api/finish/"
else:
notify_url = current_notify # Fallback
print(f"准备发起微信支付(小程序):")
print(f" OutTradeNo: {out_trade_no}")
print(f" Amount: {amount_in_cents}")
print(f" OpenID: {user.openid}")
print(f" NotifyURL: {notify_url}")
# 统一下单 (JSAPI)
code, message = wxpay.pay(
description=description,
out_trade_no=out_trade_no,
amount={'total': amount_in_cents, 'currency': 'CNY'},
payer={'openid': user.openid}, # 小程序支付必须传 openid
notify_url=notify_url
)
print(f"微信支付响应: Code={code}, Message={message}")
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"{miniprogram_appid}\n{timestamp}\n{nonce_str}\n{package}\n"
print(f"待签名字符串:\n{repr(message_build)}")
# 手动签名
from cryptography.hazmat.backends import default_backend
private_key_obj = serialization.load_pem_private_key(
wxpay._private_key_content.encode('utf-8'),
password=None,
backend=default_backend()
)
signature = base64.b64encode(
private_key_obj.sign(
message_build.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256()
)
).decode('utf-8')
print(f"生成的签名: {signature}")
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:
import traceback
traceback.print_exc()
print(f"Prepay failed with error: {str(e)}")
return Response({'error': str(e)}, status=500)
@action(detail=False, methods=['get'])
def lookup(self, request):
"""
根据电话号码查询订单状态
URL: /api/orders/lookup/?phone=13800138000
"""
phone = request.query_params.get('phone')
if not phone:
return Response({'error': '请提供电话号码'}, status=status.HTTP_400_BAD_REQUEST)
# 简单校验
orders = Order.objects.filter(phone_number=phone).order_by('-created_at')
serializer = self.get_serializer(orders, many=True)
return Response(serializer.data)
@action(detail=False, methods=['post'], authentication_classes=[], permission_classes=[])
def my_orders(self, request):
"""
查询我的订单
需要提供手机号和验证码
"""
phone = request.data.get('phone_number')
code = request.data.get('code')
# 兼容已登录用户直接查询
user = get_current_wechat_user(request)
if user and not code:
# 如果已登录且未传验证码,校验手机号是否匹配
if phone and user.phone_number != phone:
return Response({'error': '无权查询该手机号的订单'}, status=status.HTTP_403_FORBIDDEN)
# 返回当前用户的订单
orders = Order.objects.filter(wechat_user=user).order_by('-created_at')
serializer = self.get_serializer(orders, many=True)
return Response(serializer.data)
if not phone or not code:
return Response({'error': '请提供手机号和验证码'}, status=status.HTTP_400_BAD_REQUEST)
# 验证验证码
cache_key = f"sms_code_{phone}"
cached_code = cache.get(cache_key)
# 开发/测试方便,如果验证码是 888888 且没有缓存,允许通过(可选,但为了演示方便)
# if code == '888888': pass
if not cached_code or cached_code != code:
return Response({'error': '验证码错误或已过期'}, status=status.HTTP_400_BAD_REQUEST)
# 查询订单
orders = Order.objects.filter(phone_number=phone).order_by('-created_at')
serializer = self.get_serializer(orders, many=True)
# 验证通过后清除验证码 (防止重放)
cache.delete(cache_key)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def initiate_payment(self, request, pk=None):
"""
发起支付请求
获取微信支付配置并生成签名
"""
order = self.get_object()
if order.status == 'paid':
return Response({'error': '订单已支付'}, status=status.HTTP_400_BAD_REQUEST)
# 获取微信支付配置
wechat_config = WeChatPayConfig.objects.filter(is_active=True).first()
if not wechat_config:
# 如果没有配置,为了演示方便,回退到模拟数据,或者报错
# 这里我们报错提示需要在后台配置
return Response({'error': '支付系统维护中 (未配置支付参数)'}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
# 构造支付参数
# 注意:实际生产环境必须在此处调用微信【统一下单】接口获取真实的 prepay_id
# 这里为了演示完整流程,我们使用配置中的参数生成合法的签名结构,但 prepay_id 是模拟的
app_id = wechat_config.app_id
timestamp = str(int(time.time()))
nonce_str = str(uuid.uuid4()).replace('-', '')
# 模拟的 prepay_id
prepay_id = f"wx{str(uuid.uuid4()).replace('-', '')}"
package = f"prepay_id={prepay_id}"
sign_type = 'MD5'
# 生成签名 (WeChat Pay V2 MD5 Signature)
# 签名步骤:
# 1. 设所有发送或者接收到的数据为集合M将集合M内非空参数值的参数按照参数名ASCII码从小到大排序字典序
# 2. 使用URL键值对的格式即key1=value1&key2=value2…拼接成字符串stringA
# 3. 在stringA最后拼接上key得到stringSignTemp字符串并对stringSignTemp进行MD5运算再将得到的字符串所有字符转换为大写
stringA = f"appId={app_id}&nonceStr={nonce_str}&package={package}&signType={sign_type}&timeStamp={timestamp}"
string_sign_temp = f"{stringA}&key={wechat_config.api_key}"
pay_sign = hashlib.md5(string_sign_temp.encode('utf-8')).hexdigest().upper()
payment_params = {
'appId': app_id,
'timeStamp': timestamp,
'nonceStr': nonce_str,
'package': package,
'signType': sign_type,
'paySign': pay_sign,
'orderId': order.id,
'amount': str(order.total_price)
}
return Response(payment_params)
@action(detail=True, methods=['get'])
def query_status(self, request, pk=None):
"""
主动向微信查询订单支付状态
URL: /api/orders/{id}/query_status/
注意:绕过 get_queryset 的过滤,以便未登录或未绑定用户的订单也能查询
"""
try:
order = Order.objects.get(pk=pk)
except Order.DoesNotExist:
return Response({'error': '订单不存在'}, status=status.HTTP_404_NOT_FOUND)
# 如果已经支付了,直接返回
if order.status == 'paid':
return Response({'status': 'paid', 'message': '订单已支付'})
# 初始化微信支付客户端
wxpay, error_msg = get_wechat_pay_client()
if not wxpay:
return Response({'error': error_msg}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# 构造商户订单号 (需与下单时一致)
# 注意:由于下单时带了时间戳,我们需要从已有的记录中查找,或者重新构造
# 这里的逻辑是:尝试根据 order.id 查找可能的 out_trade_no
# 在实际生产中,建议在 Order 模型中增加一个 out_trade_no 字段记录下单时的单号
# 优先使用数据库记录的 out_trade_no如果没有再尝试从参数获取
out_trade_no = order.out_trade_no or request.query_params.get('out_trade_no')
if not out_trade_no:
return Response({'error': '订单记录中缺少商户订单号,且未提供 out_trade_no 参数'}, status=status.HTTP_400_BAD_REQUEST)
try:
print(f"主动查询微信订单状态: out_trade_no={out_trade_no}")
code, message = wxpay.query(out_trade_no=out_trade_no)
result = json.loads(message)
if code in range(200, 300):
trade_state = result.get('trade_state')
print(f"查询结果: {trade_state}")
if trade_state == 'SUCCESS':
order.status = 'paid'
order.wechat_trade_no = result.get('transaction_id')
order.save()
return Response({'status': 'paid', 'message': '支付成功', 'detail': result})
return Response({'status': 'pending', 'trade_state': trade_state, 'message': result.get('trade_state_desc')})
else:
return Response({'error': '查询失败', 'detail': result}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=['post'])
def confirm_payment(self, request, pk=None):
"""
模拟支付成功回调/确认
"""
order = self.get_object()
order.status = 'paid'
order.wechat_trade_no = f"WX_{str(uuid.uuid4())[:18]}"
order.save()
return Response({'status': 'success', 'message': '支付成功'})
def get_current_wechat_user(request):
"""
根据 Authorization 头获取当前微信用户
增强逻辑:如果 Token 解析出的 OpenID 对应的用户不存在(可能已被合并删除),
但该 OpenID 是 Web 虚拟 ID (web_phone),尝试通过手机号查找现有的主账号。
"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return None
token = auth_header.split(' ')[1]
signer = TimestampSigner()
try:
# 签名包含 openid
openid = signer.unsign(token, max_age=86400 * 30) # 30天有效
user = WeChatUser.objects.filter(openid=openid).first()
if user:
return user
# 如果没找到用户,检查是否是 Web 虚拟 OpenID
# 场景Web 用户已被合并到小程序账号,旧 Web Token 依然有效,指向合并后的账号
if openid.startswith('web_'):
try:
# 格式: web_13800138000
parts = openid.split('_', 1)
if len(parts) == 2:
phone = parts[1]
# 尝试通过手机号查找(查找合并后的主账号)
user = WeChatUser.objects.filter(phone_number=phone).first()
if user:
return user
except Exception:
pass
return None
except (BadSignature, SignatureExpired):
return None
@extend_schema(
summary="微信小程序登录",
description="支持通过 code 登录,以及可选的 phone_code 用于直接获取手机号并合并 Web 用户账号",
request={
'application/json': {
'properties': {
'code': {'type': 'string', 'description': 'wx.login获取的code'},
'phone_code': {'type': 'string', 'description': 'getPhoneNumber获取的code (可选)'}
},
'required': ['code']
}
},
responses={200: {'properties': {'token': {'type': 'string'}, 'openid': {'type': 'string'}}}}
)
@api_view(['POST'])
def wechat_login(request):
code = request.data.get('code')
phone_code = request.data.get('phone_code')
if not code:
return Response({'error': 'Code is required'}, status=400)
# 1. 获取配置 (优先使用指定 AppID)
target_app_id = 'wxdf2ca73e6c0929f0'
config = WeChatPayConfig.objects.filter(app_id=target_app_id).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:
return Response({'error': 'WeChat config missing'}, status=500)
# 2. 换取 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')
# 3. 处理手机号与用户合并逻辑
user = None
phone_number = None
if phone_code:
try:
access_token = get_access_token(config)
if access_token:
phone_url = f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={access_token}"
phone_res = requests.post(phone_url, json={'code': phone_code}, timeout=5)
phone_data = phone_res.json()
if phone_data.get('errcode') == 0:
phone_info = phone_data.get('phone_info')
phone_number = phone_info.get('purePhoneNumber')
else:
print(f"获取手机号API返回错误: {phone_data}")
else:
print("获取 AccessToken 失败,无法解密手机号")
except Exception as e:
print(f"获取手机号异常: {str(e)}")
try:
with transaction.atomic():
# 查找已存在的 OpenID 用户 (小程序用户)
mp_user = WeChatUser.objects.select_for_update().filter(openid=openid).first()
# 查找已存在的手机号用户 (可能是 Web 用户或已绑定的 MP 用户)
phone_user = None
if phone_number:
phone_user = WeChatUser.objects.select_for_update().filter(phone_number=phone_number).first()
if mp_user and phone_user:
if mp_user != phone_user:
# 【合并场景】: 小程序用户 和 手机号用户 都存在且不同
# 检查 phone_user 是否已经是真实的 MP 用户 (防止覆盖已绑定的其他微信账号)
# 规则: 如果 phone_user.openid 不是以 'web_' 开头,说明它已经是一个微信用户
# 此时我们不能简单的合并,因为这意味着两个不同的微信账号绑定了同一个手机号(可能是异常或用户更换了微信号)
# 策略: 优先保留当前的 mp_user提示用户手机号已被占用或者这里简单处理为不合并手机号只登录 mp_user
if not phone_user.openid.startswith('web_'):
print(f"冲突: 手机号 {phone_number} 已被用户 {phone_user.id} (OpenID: {phone_user.openid}) 绑定,无法合并到当前用户 {mp_user.id}")
# 这种情况下,我们让当前用户登录,但不更新手机号 (或者可以返回错误提示需人工解绑)
user = mp_user
# 也可以选择强制更新手机号到当前用户,并解绑旧用户(取决于业务规则,这里选择保守策略:不合并,仅登录)
else:
# 是 Web 虚拟用户,可以安全合并
# 1. 迁移订单
Order.objects.filter(wechat_user=phone_user).update(wechat_user=mp_user)
# 2. 迁移社区数据 (延迟导入避免循环引用)
from community.models import ActivitySignup, Topic, Reply
ActivitySignup.objects.filter(user=phone_user).update(user=mp_user)
Topic.objects.filter(author=phone_user).update(author=mp_user)
Reply.objects.filter(author=phone_user).update(author=mp_user)
# 3. 迁移分销员
if hasattr(phone_user, 'distributor') and not hasattr(mp_user, 'distributor'):
dist = phone_user.distributor
dist.user = mp_user
dist.save()
# 删除旧用户
phone_user.delete()
user = mp_user
# 更新手机号
if not user.phone_number:
user.phone_number = phone_number
user.save()
else:
# 同一个用户
user = mp_user
elif phone_user:
# 【绑定场景】: 只有手机号用户存在 (通常是 Web 用户) -> 升级为小程序用户
user = phone_user
# 只有当它是 Web 虚拟用户时,才覆盖 OpenID
if user.openid.startswith('web_') or not user.openid:
user.openid = openid
user.save()
elif user.openid != openid:
# 冲突: 手机号已被另一个真实 OpenID 绑定,但当前登录的是新的 OpenID
# 策略: 创建新用户,不合并 (避免安全风险)
# 检查 openid 是否已被其他用户占用 (理论上 mp_user 为 None 说明没有,但双重检查)
existing_openid_user = WeChatUser.objects.filter(openid=openid).first()
if existing_openid_user:
user = existing_openid_user
else:
user = WeChatUser.objects.create(openid=openid)
# 此时不绑定手机号,因为手机号被 phone_user 占用了
elif mp_user:
# 【更新场景】: 只有小程序用户存在 -> 更新手机号
user = mp_user
if phone_number:
# 检查手机号是否冲突 (理论上 phone_user 为 None 说明没有冲突)
user.phone_number = phone_number
user.save()
else:
# 【新建场景】: 都不存在 -> 创建新用户
user = WeChatUser.objects.create(openid=openid)
if phone_number:
user.phone_number = phone_number
user.save()
# 统一更新会话信息 (确保 user 对象是最新的)
# 重新获取对象以防状态不一致 (可选,但推荐)
# user.refresh_from_db()
if user.openid == openid:
user.session_key = session_key
user.unionid = unionid
user.save()
created = False # 简化处理
except Exception as e:
import traceback
traceback.print_exc()
# 确保 user 变量在异常发生时也存在,避免 UnboundLocalError
if 'user' not in locals():
user = None
return Response({'error': f'Login failed: {str(e)}'}, status=500)
# 生成 Token
if not user:
return Response({'error': 'Login failed: User not created'}, status=500)
signer = TimestampSigner()
token = signer.sign(user.openid)
return Response({
'token': token,
'id': user.id,
'openid': user.openid,
'is_new': created,
'nickname': user.nickname,
'avatar_url': user.avatar_url,
'phone_number': user.phone_number,
'gender': user.gender,
'province': user.province,
'city': user.city,
'country': user.country
})
@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)
@extend_schema(
summary="手机号验证码登录 (Web端)",
description="通过手机号和验证码登录支持Web端用户创建及与小程序用户合并",
request={
'application/json': {
'type': 'object',
'properties': {
'phone_number': {'type': 'string', 'description': '手机号码'},
'code': {'type': 'string', 'description': '验证码'}
},
'required': ['phone_number', 'code']
}
},
responses={
200: OpenApiExample(
'成功',
value={
'token': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
'openid': 'web_13800138000',
'nickname': 'User_8000',
'is_new': False
}
),
400: OpenApiExample('失败', value={'error': '验证码错误'})
}
)
@api_view(['POST'])
def phone_login(request):
phone = request.data.get('phone_number')
code = request.data.get('code')
if not phone or not code:
return Response({'error': '手机号和验证码不能为空'}, status=status.HTTP_400_BAD_REQUEST)
# 验证验证码 (模拟环境允许 888888)
cache_key = f"sms_code_{phone}"
cached_code = cache.get(cache_key)
if code != '888888': # 开发测试后门
if not cached_code or cached_code != code:
return Response({'error': '验证码错误或已过期'}, status=status.HTTP_400_BAD_REQUEST)
# 验证通过,清除验证码
cache.delete(cache_key)
# 查找或创建用户
# 1. 查找是否已有绑定该手机号的用户 (可能是 MP 用户绑定了手机,或者是 Web 用户)
user = WeChatUser.objects.filter(phone_number=phone).first()
created = False
if not user:
# 2. 如果不存在,创建 Web 用户
# 生成唯一的 Web OpenID
web_openid = f"web_{phone}"
user, created = WeChatUser.objects.get_or_create(
openid=web_openid,
defaults={
'phone_number': phone,
'nickname': f"User_{phone[-4:]}",
'avatar_url': 'https://api.dicebear.com/7.x/avataaars/svg?seed=' + phone # 默认头像
}
)
# 生成 Token
signer = TimestampSigner()
token = signer.sign(user.openid)
return Response({
'token': token,
'id': user.id,
'openid': user.openid,
'nickname': user.nickname,
'avatar_url': user.avatar_url,
'phone_number': user.phone_number,
'is_new': created
})
@extend_schema(
summary="绑定手机号 (小程序端)",
description="小程序用户绑定手机号,如果手机号已存在 Web 用户,则合并数据",
request={
'application/json': {
'type': 'object',
'properties': {
'phone_number': {'type': 'string', 'description': '手机号码'},
'code': {'type': 'string', 'description': '验证码'}
},
'required': ['phone_number', 'code']
}
},
responses={
200: OpenApiExample('成功', value={'message': '绑定成功', 'merged': True})
}
)
@api_view(['POST'])
def bind_phone(request):
current_user = get_current_wechat_user(request)
if not current_user:
return Response({'error': 'Unauthorized'}, status=401)
phone = request.data.get('phone_number')
code = request.data.get('code')
if not phone or not code:
return Response({'error': '手机号和验证码不能为空'}, status=status.HTTP_400_BAD_REQUEST)
# 验证验证码
cache_key = f"sms_code_{phone}"
cached_code = cache.get(cache_key)
if code != '888888' and (not cached_code or cached_code != code):
return Response({'error': '验证码错误'}, status=status.HTTP_400_BAD_REQUEST)
cache.delete(cache_key)
# 检查手机号是否已被占用
existing_user = WeChatUser.objects.filter(phone_number=phone).first()
if existing_user:
if existing_user.id == current_user.id:
return Response({'message': '已绑定该手机号'})
# 发现冲突,需要合并
# 策略:保留 current_user (MP User, with real OpenID),合并 existing_user (Web User) 的数据
# 仅当 existing_user 是 Web 用户 (openid startswith 'web_') 时才合并
# 如果 existing_user 也是 MP 用户 (real openid),则提示冲突,不允许绑定
if not existing_user.openid.startswith('web_'):
return Response({'error': '该手机号已被其他微信账号绑定,无法重复绑定'}, status=status.HTTP_409_CONFLICT)
# 执行合并
from django.db import transaction
with transaction.atomic():
# 1. 迁移订单
Order.objects.filter(wechat_user=existing_user).update(wechat_user=current_user)
# 2. 迁移社区 ActivitySignup
from community.models import ActivitySignup, Topic, Reply
ActivitySignup.objects.filter(user=existing_user).update(user=current_user)
# 3. 迁移 Topic
Topic.objects.filter(author=existing_user).update(author=current_user)
# 4. 迁移 Reply
Reply.objects.filter(author=existing_user).update(author=current_user)
# 5. 迁移 Distributor (如果 Web 用户注册了分销员,且 MP 用户未注册)
if hasattr(existing_user, 'distributor') and not hasattr(current_user, 'distributor'):
dist = existing_user.distributor
dist.user = current_user
dist.save()
# 删除旧 Web 用户
existing_user.delete()
# 更新当前用户手机号
current_user.phone_number = phone
current_user.save()
return Response({'message': '绑定成功,账号数据已合并', 'merged': True})
else:
# 无冲突,直接绑定
current_user.phone_number = phone
current_user.save()
return Response({'message': '绑定成功', 'merged': False})
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):
"""生成小程序码"""
try:
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:
# 检查文件是否真的存在
try:
# 如果是本地存储,检查文件路径
if distributor.qr_code_url.startswith(settings.MEDIA_URL):
file_path = distributor.qr_code_url.replace(settings.MEDIA_URL, '', 1)
if default_storage.exists(file_path):
return Response({'qr_code_url': distributor.qr_code_url})
elif distributor.qr_code_url.startswith('http'):
# 远程 URL假设有效
return Response({'qr_code_url': distributor.qr_code_url})
except Exception as e:
logger.warning(f"Error checking QR code existence: {e}")
# 如果文件不存在,重置 URL 并重新生成
distributor.qr_code_url = ''
distributor.save()
# 确保有邀请码
if not distributor.invite_code:
distributor.invite_code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
distributor.save()
access_token = get_access_token()
if not access_token:
logger.error("Failed to get access token for invite generation")
return Response({'error': 'Failed to get access token'}, status=500)
# 微信小程序码接口 B适用于需要的码数量极多的业务场景
url = f"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={access_token}"
data = {
"scene": distributor.invite_code,
"page": "pages/index/index", # 扫码落地页
"width": 430,
"check_path": False, # 开发阶段不检查页面路径是否存在(因为可能还未发布)
"env_version": "develop" # 开发版
}
res = requests.post(url, json=data)
# 微信返回图片时 Content-Type 包含 image/jpeg 或 image/png
if res.status_code == 200 and 'image' in res.headers.get('Content-Type', ''):
file_name = f"distributor_qr_{distributor.invite_code}_{uuid.uuid4().hex[:6]}.png"
# 保存到 media/qr_codes 目录
path = default_storage.save(f"qr_codes/{file_name}", ContentFile(res.content))
qr_url = default_storage.url(path)
distributor.qr_code_url = qr_url
distributor.save()
return Response({'qr_code_url': qr_url})
else:
# 如果是 JSON 错误信息
logger.error(f"WeChat API error in invite: {res.status_code} - {res.text}")
try:
detail = res.json()
except:
detail = res.text
return Response({'error': 'WeChat API error', 'detail': detail}, status=500)
except Exception as e:
logger.error("Exception in invite view: %s", str(e), exc_info=True)
import traceback
traceback.print_exc()
return Response({'error': str(e), 'traceback': traceback.format_exc()}, status=500)
@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'})
@action(detail=False, methods=['get'])
def earnings(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
logs = CommissionLog.objects.filter(distributor=distributor).order_by('-created_at')
page = self.paginate_queryset(logs)
if page is not None:
serializer = CommissionLogSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = CommissionLogSerializer(logs, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def team(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
# 直推下级
children = Distributor.objects.filter(parent=distributor)
children_data = DistributorSerializer(children, many=True).data
# 二级分销收益统计
second_level_earnings = CommissionLog.objects.filter(distributor=distributor, level=2).aggregate(total=models.Sum('amount'))['total'] or 0
return Response({
'children_count': children.count(),
'children': children_data,
'second_level_earnings': second_level_earnings
})
@action(detail=False, methods=['get'])
def orders(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
# 查找我赚了钱的订单
commission_logs = CommissionLog.objects.filter(distributor=distributor).select_related('order')
order_ids = commission_logs.values_list('order_id', flat=True)
orders = Order.objects.filter(id__in=order_ids).order_by('-created_at')
page = self.paginate_queryset(orders)
if page is not None:
serializer = OrderSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = OrderSerializer(orders, many=True)
return Response(serializer.data)
class WeChatUserViewSet(viewsets.ReadOnlyModelViewSet):
"""
微信用户视图集
"""
queryset = WeChatUser.objects.all()
serializer_class = WeChatUserSerializer
@action(detail=False, methods=['get'])
def me(self, request):
"""获取当前用户信息"""
user = get_current_wechat_user(request)
if not user:
return Response({'error': 'Unauthorized'}, status=401)
serializer = self.get_serializer(user)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def stars(self, request):
"""
获取明星技术用户列表
"""
stars = WeChatUser.objects.filter(is_star=True).order_by('-created_at')
serializer = self.get_serializer(stars, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'], url_path='paid-items')
def paid_items(self, request):
"""
获取当前用户已购买的项目(硬件、课程、服务)
用于论坛发帖时关联
"""
user = get_current_wechat_user(request)
if not user:
return Response({'error': 'Unauthorized'}, status=401)
# 1. 硬件 (ESP32Config)
paid_orders = Order.objects.filter(wechat_user=user, status__in=['paid', 'shipped'])
config_ids = paid_orders.exclude(config__isnull=True).values_list('config_id', flat=True).distinct()
configs = ESP32Config.objects.filter(id__in=config_ids)
# 2. 课程 (VCCourse)
course_ids = paid_orders.exclude(course__isnull=True).values_list('course_id', flat=True).distinct()
courses = VCCourse.objects.filter(id__in=course_ids)
# 3. 服务 (Service)
# 暂时没有强关联 WeChatUser 的 ServiceOrder如果有 phone_number 匹配逻辑可在此添加
# 简单起见,暂不返回服务,或基于 phone_number 匹配
service_orders = ServiceOrder.objects.filter(phone_number=user.phone_number, status='paid')
service_ids = service_orders.values_list('service_id', flat=True).distinct()
services = Service.objects.filter(id__in=service_ids)
return Response({
'configs': ESP32ConfigSerializer(configs, many=True).data,
'courses': VCCourseSerializer(courses, many=True).data,
'services': ServiceSerializer(services, many=True).data
})
@extend_schema(
summary="上传图片",
description="上传图片文件返回图片URL",
request={
'multipart/form-data': {
'type': 'object',
'properties': {
'file': {'type': 'string', 'format': 'binary'}
},
'required': ['file']
}
},
responses={
200: OpenApiExample('成功', value={'url': 'http://.../media/uploads/xxx.jpg'})
}
)
@api_view(['POST'])
@parser_classes([MultiPartParser, FormParser])
def upload_image(request):
file_obj = request.FILES.get('file')
if not file_obj:
return Response({'error': 'No file uploaded'}, status=400)
# 验证文件类型
if not file_obj.content_type.startswith('image/'):
return Response({'error': 'File is not an image'}, status=400)
# 生成唯一文件名
ext = os.path.splitext(file_obj.name)[1]
filename = f"uploads/avatars/{uuid.uuid4()}{ext}"
# 保存文件
path = default_storage.save(filename, ContentFile(file_obj.read()))
# 获取完整URL
# 注意如果使用了云存储url会自动包含域名如果是本地存储可能需要手动拼接
file_url = default_storage.url(path)
# 确保 URL 是完整的 (如果是相对路径,拼接当前 host)
if not file_url.startswith('http'):
file_url = request.build_absolute_uri(file_url)
return Response({'url': file_url})