diff --git a/backend/db.sqlite3 b/backend/db.sqlite3
index 44244ba..7c09cf0 100644
Binary files a/backend/db.sqlite3 and b/backend/db.sqlite3 differ
diff --git a/backend/shop/__pycache__/models.cpython-312.pyc b/backend/shop/__pycache__/models.cpython-312.pyc
index 01bc3a1..50cf5ab 100644
Binary files a/backend/shop/__pycache__/models.cpython-312.pyc and b/backend/shop/__pycache__/models.cpython-312.pyc differ
diff --git a/backend/shop/__pycache__/models.cpython-313.pyc b/backend/shop/__pycache__/models.cpython-313.pyc
index 140b8db..fa1ef35 100644
Binary files a/backend/shop/__pycache__/models.cpython-313.pyc and b/backend/shop/__pycache__/models.cpython-313.pyc differ
diff --git a/backend/shop/__pycache__/serializers.cpython-312.pyc b/backend/shop/__pycache__/serializers.cpython-312.pyc
index 6140d80..f41f618 100644
Binary files a/backend/shop/__pycache__/serializers.cpython-312.pyc and b/backend/shop/__pycache__/serializers.cpython-312.pyc differ
diff --git a/backend/shop/__pycache__/urls.cpython-312.pyc b/backend/shop/__pycache__/urls.cpython-312.pyc
index 202d13c..395bf69 100644
Binary files a/backend/shop/__pycache__/urls.cpython-312.pyc and b/backend/shop/__pycache__/urls.cpython-312.pyc differ
diff --git a/backend/shop/__pycache__/views.cpython-312.pyc b/backend/shop/__pycache__/views.cpython-312.pyc
index bfb9b97..603a911 100644
Binary files a/backend/shop/__pycache__/views.cpython-312.pyc and b/backend/shop/__pycache__/views.cpython-312.pyc differ
diff --git a/backend/shop/migrations/0026_wechatuser_phone_number.py b/backend/shop/migrations/0026_wechatuser_phone_number.py
new file mode 100644
index 0000000..61c557c
--- /dev/null
+++ b/backend/shop/migrations/0026_wechatuser_phone_number.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.1 on 2026-02-11 07:18
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('shop', '0025_vccourse_alter_courseenrollment_course_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='wechatuser',
+ name='phone_number',
+ field=models.CharField(blank=True, max_length=20, null=True, unique=True, verbose_name='手机号'),
+ ),
+ ]
diff --git a/backend/shop/models.py b/backend/shop/models.py
index fa31b82..f2e5d88 100644
--- a/backend/shop/models.py
+++ b/backend/shop/models.py
@@ -14,6 +14,7 @@ class WeChatUser(models.Model):
unionid = models.CharField(max_length=64, blank=True, null=True, verbose_name="UnionID", db_index=True)
session_key = models.CharField(max_length=64, verbose_name="SessionKey", blank=True)
nickname = models.CharField(max_length=64, verbose_name="昵称", blank=True)
+ phone_number = models.CharField(max_length=20, unique=True, null=True, blank=True, verbose_name="手机号")
avatar_url = models.URLField(verbose_name="头像URL", blank=True)
gender = models.IntegerField(default=0, verbose_name="性别", help_text="0:未知, 1:男, 2:女")
country = models.CharField(max_length=64, verbose_name="国家", blank=True)
diff --git a/backend/shop/serializers.py b/backend/shop/serializers.py
index 3e42a00..61ea115 100644
--- a/backend/shop/serializers.py
+++ b/backend/shop/serializers.py
@@ -22,8 +22,8 @@ class CommissionLogSerializer(serializers.ModelSerializer):
class WeChatUserSerializer(serializers.ModelSerializer):
class Meta:
model = WeChatUser
- fields = ['id', 'nickname', 'avatar_url', 'gender', 'country', 'province', 'city']
- read_only_fields = ['id']
+ fields = ['id', 'nickname', 'avatar_url', 'gender', 'country', 'province', 'city', 'phone_number']
+ read_only_fields = ['id', 'phone_number']
class DistributorSerializer(serializers.ModelSerializer):
user_info = WeChatUserSerializer(source='user', read_only=True)
diff --git a/backend/shop/urls.py b/backend/shop/urls.py
index bc81dae..43b9cf1 100644
--- a/backend/shop/urls.py
+++ b/backend/shop/urls.py
@@ -4,7 +4,7 @@ from .views import (
ESP32ConfigViewSet, OrderViewSet, order_check_view,
ServiceViewSet, VCCourseViewSet, ServiceOrderViewSet,
payment_finish, pay, send_sms_code, wechat_login, update_user_info, DistributorViewSet,
- CourseEnrollmentViewSet
+ CourseEnrollmentViewSet, phone_login, bind_phone
)
router = DefaultRouter()
@@ -21,6 +21,8 @@ urlpatterns = [
re_path(r'^pay/?$', pay, name='wechat-pay-v3'),
path('auth/send-sms/', send_sms_code, name='send-sms'),
path('wechat/login/', wechat_login, name='wechat-login'),
+ path('auth/phone-login/', phone_login, name='phone-login'),
+ path('auth/bind-phone/', bind_phone, name='bind-phone'),
path('wechat/update/', update_user_info, name='wechat-update'),
path('page/check-order/', order_check_view, name='check-order-page'),
path('', include(router.urls)),
diff --git a/backend/shop/views.py b/backend/shop/views.py
index b9d6dd8..10cf409 100644
--- a/backend/shop/views.py
+++ b/backend/shop/views.py
@@ -153,14 +153,16 @@ def send_sms_code(request):
"phone_number": phone,
"code": code,
"template_code": "SMS_493295002",
- "sign_name": "叠加态科技云南"
+ "sign_name": "叠加态科技云南",
+ "additionalProp1": {}
}
headers = {
"Content-Type": "application/json",
"accept": "application/json"
}
- requests.post(api_url, json=payload, headers=headers, timeout=15)
+ 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)}")
@@ -760,6 +762,17 @@ class OrderViewSet(viewsets.ModelViewSet):
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)
@@ -985,6 +998,168 @@ def update_user_info(request):
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,
+ '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):
"""
分销员接口
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index e8bb771..0095f11 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,5 +1,6 @@
import React from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom';
+import { AuthProvider } from './context/AuthContext';
import Layout from './components/Layout';
import Home from './pages/Home';
import ProductDetail from './pages/ProductDetail';
@@ -14,20 +15,22 @@ import './App.css';
function App() {
return (
-