1656 lines
67 KiB
Python
1656 lines
67 KiB
Python
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 .services import handle_post_payment
|
||
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 = VCCourse.objects.get(id=good_id)
|
||
except VCCourse.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}")
|
||
product_name = getattr(product, 'name', getattr(product, 'title', 'Unknown Product'))
|
||
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} 状态已更新")
|
||
|
||
# 6. 处理支付后业务逻辑 (活动报名、佣金、短信通知)
|
||
handle_post_payment(order)
|
||
|
||
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 create(self, request, *args, **kwargs):
|
||
print(f"Creating order with data: {request.data}")
|
||
serializer = self.get_serializer(data=request.data)
|
||
if not serializer.is_valid():
|
||
print(f"Order validation failed: {serializer.errors}")
|
||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||
self.perform_create(serializer)
|
||
headers = self.get_success_headers(serializer.data)
|
||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||
|
||
def perform_create(self, serializer):
|
||
"""
|
||
创建订单时自动关联当前微信用户
|
||
"""
|
||
user = get_current_wechat_user(self.request)
|
||
instance = serializer.save(wechat_user=user)
|
||
|
||
# Check if free course and set to paid
|
||
if instance.course and instance.course.price == 0 and instance.status == 'pending':
|
||
instance.status = 'paid'
|
||
instance.save()
|
||
# Trigger post payment logic
|
||
from .services import handle_post_payment
|
||
handle_post_payment(instance)
|
||
|
||
@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()
|
||
|
||
# 处理支付后逻辑
|
||
handle_post_payment(order)
|
||
|
||
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()
|
||
|
||
handle_post_payment(order)
|
||
|
||
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})
|
||
|