269723ced2
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 53s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m57s
CI/CD Pipeline / Unit Tests (push) Successful in 4m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m9s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m35s
CI/CD Pipeline / Integration Tests (push) Successful in 1m53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 16m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m20s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
232 lines
7.4 KiB
Python
Executable File
232 lines
7.4 KiB
Python
Executable File
"""
|
||
微信登录 + 绑定手机号邮箱 Use Case
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import datetime, timezone
|
||
from typing import Optional
|
||
|
||
from packages.application.auth.verification_code_service import (
|
||
CODE_TYPE_EMAIL_BIND,
|
||
CODE_TYPE_PHONE_BIND,
|
||
normalize_phone,
|
||
validate_email,
|
||
validate_phone,
|
||
)
|
||
from packages.domain.entities import User
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class BindContactRequest:
|
||
"""绑定联系方式请求"""
|
||
|
||
def __init__(
|
||
self,
|
||
user_id: str,
|
||
phone: str = "",
|
||
phone_code: str = "",
|
||
email: str = "",
|
||
email_code: str = "",
|
||
):
|
||
self.user_id = user_id
|
||
self.phone = normalize_phone(phone) if phone else ""
|
||
self.phone_code = phone_code.strip() if phone_code else ""
|
||
self.email = email.strip().lower() if email else ""
|
||
self.email_code = email_code.strip() if email_code else ""
|
||
|
||
|
||
class BindContactResponse:
|
||
"""绑定响应"""
|
||
|
||
def __init__(self, user: User):
|
||
self.user = user
|
||
|
||
def to_dict(self) -> dict:
|
||
return {
|
||
"user": {
|
||
"id": self.user.id,
|
||
"email": self.user.email,
|
||
"phone": self.user.phone,
|
||
"phone_verified": self.user.phone_verified,
|
||
"display_name": self.user.display_name,
|
||
"binding_complete": self.user.binding_completed_at is not None,
|
||
}
|
||
}
|
||
|
||
|
||
class BindContactUseCase:
|
||
"""绑定手机+邮箱用例"""
|
||
|
||
def __init__(
|
||
self,
|
||
user_repository,
|
||
verification_code_service,
|
||
email_service=None,
|
||
):
|
||
self.user_repo = user_repository
|
||
self.verification_service = verification_code_service
|
||
self.email_service = email_service
|
||
|
||
def execute(self, request: BindContactRequest) -> tuple[Optional[BindContactResponse], Optional[str]]:
|
||
try:
|
||
# 1. 校验参数
|
||
if not request.phone and not request.email:
|
||
return None, "至少填写手机号或邮箱"
|
||
|
||
# 2. 查找用户
|
||
user = self.user_repo.find_by_id(request.user_id)
|
||
if not user:
|
||
return None, "用户不存在"
|
||
|
||
# 3. 手机号绑定
|
||
if request.phone:
|
||
ok, err = validate_phone(request.phone)
|
||
if not ok:
|
||
return None, err
|
||
|
||
if not request.phone_code:
|
||
return None, "请输入手机验证码"
|
||
|
||
# 校验手机号未被其他账号绑定
|
||
existing = self.user_repo.find_by_phone(request.phone)
|
||
if existing and existing.id != user.id:
|
||
return None, "该手机号已被其他账号绑定"
|
||
|
||
# 校验验证码
|
||
ok, err = self.verification_service.verify(
|
||
recipient=request.phone,
|
||
code_type=CODE_TYPE_PHONE_BIND,
|
||
code_value=request.phone_code,
|
||
)
|
||
if not ok:
|
||
return None, f"手机验证码错误:{err}"
|
||
|
||
user.phone = request.phone
|
||
user.phone_verified = True
|
||
|
||
# 4. 邮箱绑定
|
||
if request.email:
|
||
ok, err = validate_email(request.email)
|
||
if not ok:
|
||
return None, err
|
||
|
||
if not request.email_code:
|
||
return None, "请输入邮箱验证码"
|
||
|
||
# 校验邮箱未被其他账号绑定
|
||
existing = self.user_repo.find_by_email(request.email)
|
||
if existing and existing.id != user.id:
|
||
return None, "该邮箱已被其他账号绑定"
|
||
|
||
# 校验验证码
|
||
ok, err = self.verification_service.verify(
|
||
recipient=request.email,
|
||
code_type=CODE_TYPE_EMAIL_BIND,
|
||
code_value=request.email_code,
|
||
)
|
||
if not ok:
|
||
return None, f"邮箱验证码错误:{err}"
|
||
|
||
user.email = request.email
|
||
user.email_verified = True
|
||
|
||
# 5. 判断是否完成绑定
|
||
if user.phone_verified and user.email_verified and "@wechat.local" not in user.email:
|
||
user.binding_completed_at = datetime.now(timezone.utc)
|
||
|
||
# 6. 保存
|
||
self.user_repo.save(user)
|
||
|
||
return BindContactResponse(user=user), None
|
||
|
||
except Exception as e:
|
||
logger.error("绑定联系方式失败: %s", e, exc_info=True)
|
||
return None, f"绑定失败: {str(e)}"
|
||
|
||
|
||
class SendVerificationCodeRequest:
|
||
"""发送验证码请求"""
|
||
|
||
def __init__(self, target: str, value: str, purpose: str):
|
||
self.target = target # phone / email
|
||
self.value = value.strip()
|
||
self.purpose = purpose # bind / login / reset_password
|
||
|
||
|
||
class SendVerificationCodeResponse:
|
||
"""发送验证码响应"""
|
||
|
||
def __init__(self, expires_in: int, resend_after: int):
|
||
self.expires_in = expires_in
|
||
self.resend_after = resend_after
|
||
|
||
def to_dict(self) -> dict:
|
||
return {
|
||
"expires_in": self.expires_in,
|
||
"resend_after": self.resend_after,
|
||
}
|
||
|
||
|
||
class SendVerificationCodeUseCase:
|
||
"""发送验证码用例"""
|
||
|
||
def __init__(
|
||
self,
|
||
verification_code_service,
|
||
sms_service=None,
|
||
email_service=None,
|
||
):
|
||
self.verification_service = verification_code_service
|
||
self.sms_service = sms_service
|
||
self.email_service = email_service
|
||
|
||
def execute(
|
||
self, request: SendVerificationCodeRequest
|
||
) -> tuple[Optional[SendVerificationCodeResponse], Optional[str]]:
|
||
try:
|
||
# 1. 确定 code_type
|
||
if request.target == "phone":
|
||
ok, err = validate_phone(request.value)
|
||
if not ok:
|
||
return None, err
|
||
code_type = f"{request.target}_{request.purpose}"
|
||
recipient = normalize_phone(request.value)
|
||
elif request.target == "email":
|
||
ok, err = validate_email(request.value)
|
||
if not ok:
|
||
return None, err
|
||
code_type = f"{request.target}_{request.purpose}"
|
||
recipient = request.value.lower()
|
||
else:
|
||
return None, f"不支持的目标类型: {request.target}"
|
||
|
||
# 2. 生成验证码
|
||
code_obj, err = self.verification_service.generate(recipient, code_type)
|
||
if err:
|
||
return None, err
|
||
|
||
# 3. 发送
|
||
if request.target == "phone" and self.sms_service:
|
||
self.sms_service.send_verification_code(recipient, code_obj.code)
|
||
elif request.target == "email" and self.email_service:
|
||
subject = "验证码 - 小应剪辑"
|
||
body = f"您的验证码是:{code_obj.code},5分钟内有效。"
|
||
self.email_service.send_email(recipient, subject, body)
|
||
|
||
# 4. 返回
|
||
ttl = (code_obj.expires_at - code_obj.created_at).total_seconds()
|
||
return (
|
||
SendVerificationCodeResponse(
|
||
expires_in=int(ttl),
|
||
resend_after=60,
|
||
),
|
||
None,
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error("发送验证码失败: %s", e, exc_info=True)
|
||
return None, f"发送失败: {str(e)}"
|