From 100e0548961bbedc5c7f92b0a1343f140d5b810c Mon Sep 17 00:00:00 2001 From: CI Bot Date: Tue, 21 Jul 2026 08:38:12 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(#558):=20=E5=BE=AE=E4=BF=A1=E7=99=BB?= =?UTF-8?q?=E5=BD=95=20+=20=E7=BB=91=E5=AE=9A=E6=89=8B=E6=9C=BA=E5=8F=B7?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=E5=AE=8C=E6=95=B4=E5=90=8E=E7=AB=AF=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库:users表加phone/phone_verified/binding_completed_at字段 + 新建verification_codes表(migration 049) - 微信OAuth:GET /auth/wechat/url + POST /auth/wechat/callback,支持配置缺失时的mock模式 - 验证码服务:统一管理手机+邮箱验证码,60s冷却+每日10次+5次尝试错误频控 - 绑定接口:POST /auth/send-verification-code + POST /auth/bind-contact - /me 接口扩展:返回 phone / phone_verified / binding_complete - 短信服务:Noop + 阿里云适配器骨架 - 单元测试20个全绿 --- alembic/versions/049_wechat_login_phone.py | 66 ++++ apps/api/app/api/routes/auth.py | 210 +++++++++++++ .../adapters/in_memory/user_repository.py | 34 +++ packages/adapters/sms/sms_service.py | 86 ++++++ packages/adapters/sqlalchemy_impl/models.py | 21 +- .../sqlalchemy_impl/user_repository.py | 12 + .../verification_code_repository.py | 79 +++++ .../application/auth/bind_contact_use_case.py | 232 +++++++++++++++ .../auth/verification_code_service.py | 206 +++++++++++++ .../application/auth/wechat_oauth_service.py | 156 ++++++++++ packages/domain/auth/sms_service.py | 19 ++ packages/domain/entities.py | 5 + packages/domain/verification_code.py | 66 ++++ packages/ports/user_repository.py | 5 + .../ports/verification_code_repository.py | 32 ++ .../test_wechat_login_and_verification.py | 281 ++++++++++++++++++ 16 files changed, 1509 insertions(+), 1 deletion(-) create mode 100755 alembic/versions/049_wechat_login_phone.py mode change 100644 => 100755 packages/adapters/in_memory/user_repository.py create mode 100755 packages/adapters/sms/sms_service.py mode change 100644 => 100755 packages/adapters/sqlalchemy_impl/user_repository.py create mode 100755 packages/adapters/sqlalchemy_impl/verification_code_repository.py create mode 100755 packages/application/auth/bind_contact_use_case.py create mode 100755 packages/application/auth/verification_code_service.py create mode 100755 packages/application/auth/wechat_oauth_service.py create mode 100755 packages/domain/auth/sms_service.py create mode 100755 packages/domain/verification_code.py mode change 100644 => 100755 packages/ports/user_repository.py create mode 100755 packages/ports/verification_code_repository.py create mode 100755 tests/unit/test_wechat_login_and_verification.py diff --git a/alembic/versions/049_wechat_login_phone.py b/alembic/versions/049_wechat_login_phone.py new file mode 100755 index 000000000..628dd6f9f --- /dev/null +++ b/alembic/versions/049_wechat_login_phone.py @@ -0,0 +1,66 @@ +"""#558 - 微信登录:用户手机号字段 + 验证码表 + +Revision ID: 049 +Revises: 048 +Create Date: 2026-07-21 + +Changes: +1. users 表新增 phone / phone_verified / binding_completed_at 字段 +2. 新建 verification_codes 表(统一管理邮箱+手机验证码) +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "049_wechat_login_phone" +down_revision = "048_cleanup_result_count" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. users 表新增手机号相关字段 + op.add_column("users", sa.Column("phone", sa.String(32), nullable=True)) + op.add_column( + "users", + sa.Column( + "phone_verified", + sa.Boolean, + nullable=False, + server_default=sa.text("false"), + ), + ) + op.add_column( + "users", + sa.Column("binding_completed_at", sa.DateTime, nullable=True), + ) + # phone 唯一索引(已验证的手机号唯一,未验证的不做唯一约束) + op.create_index("ix_users_phone", "users", ["phone"], unique=True) + + # 2. verification_codes 表 + op.create_table( + "verification_codes", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("recipient", sa.String(255), nullable=False, index=True), + sa.Column("code", sa.String(10), nullable=False), + sa.Column("code_type", sa.String(32), nullable=False, index=True), + sa.Column("expires_at", sa.DateTime, nullable=False), + sa.Column("used_at", sa.DateTime, nullable=True), + sa.Column("attempts", sa.Integer, nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime, nullable=False), + sa.Index( + "ix_verification_recipient_type", + "recipient", + "code_type", + "created_at", + ), + ) + + +def downgrade() -> None: + op.drop_table("verification_codes") + op.drop_index("ix_users_phone", table_name="users") + op.drop_column("users", "binding_completed_at") + op.drop_column("users", "phone_verified") + op.drop_column("users", "phone") diff --git a/apps/api/app/api/routes/auth.py b/apps/api/app/api/routes/auth.py index 7132129b2..00fa8b549 100755 --- a/apps/api/app/api/routes/auth.py +++ b/apps/api/app/api/routes/auth.py @@ -81,6 +81,9 @@ class CurrentUserResponse(BaseModel): username: str display_name: str email_verified: bool + phone: str = "" + phone_verified: bool = False + binding_complete: bool = False class PasswordResetRequestModel(BaseModel): @@ -259,12 +262,21 @@ async def get_current_user_info( authenticated_user: AuthenticatedUser = Depends(get_current_user), ) -> CurrentUserResponse: user = authenticated_user.user + binding_complete = ( + user.phone_verified + and user.email_verified + and user.email + and "@wechat.local" not in user.email + ) return CurrentUserResponse( user_id=user.id, email=user.email, username=user.username, display_name=user.display_name, email_verified=user.email_verified, + phone=user.phone or "", + phone_verified=user.phone_verified, + binding_complete=binding_complete, ) @@ -380,3 +392,201 @@ async def wechat_sync( raise HTTPException(status_code=400, detail=error) return WechatSyncResponse(**response.to_dict()) + + +# ==================== 微信网页登录(OAuth) ==================== + +class WechatAuthUrlResponse(BaseModel): + auth_url: str + state: str + + +class WechatCallbackRequest(BaseModel): + code: str + state: str = "" + + +class WechatLoginResponse(BaseModel): + access_token: str + refresh_token: str + user_id: str + display_name: str + avatar_url: str = "" + is_new_user: bool + binding_complete: bool + expires_in: int + + +@router.get("/wechat/url", response_model=WechatAuthUrlResponse) +async def get_wechat_auth_url() -> WechatAuthUrlResponse: + """获取微信扫码登录授权链接""" + from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service + + oauth_service = get_wechat_oauth_service() + auth_url, state = oauth_service.generate_auth_url() + return WechatAuthUrlResponse(auth_url=auth_url, state=state) + + +@router.post("/wechat/callback", response_model=WechatLoginResponse) +async def wechat_callback( + request: WechatCallbackRequest, + user_repository: UserRepository = Depends(get_user_repository), +) -> WechatLoginResponse: + """微信登录回调处理""" + from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service + from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as SyncRequest + from packages.application.auth.wechat_sync_use_case import WechatSyncUseCase + + # 1. 用 code 换微信用户信息 + oauth_service = get_wechat_oauth_service() + wechat_user, err = oauth_service.handle_callback(request.code, request.state) + if err: + raise HTTPException(status_code=400, detail=err) + + # 2. 同步登录/注册(复用 wechat-sync 逻辑) + use_case = WechatSyncUseCase(user_repository=user_repository) + sync_request = SyncRequest( + openid=wechat_user.openid, + unionid=wechat_user.unionid, + nickname=wechat_user.nickname, + avatar_url=wechat_user.avatar_url, + source="web", + ) + response, err = use_case.execute(sync_request) + if err: + raise HTTPException(status_code=400, detail=err) + + # 3. 判断绑定状态 + user = user_repository.find_by_id(response.user_id) + binding_complete = False + if user: + binding_complete = ( + user.phone_verified + and user.email_verified + and user.email + and "@wechat.local" not in user.email + ) + + return WechatLoginResponse( + access_token=response.access_token, + refresh_token=response.refresh_token, + user_id=response.user_id, + display_name=response.nickname, + avatar_url=response.avatar_url or wechat_user.avatar_url, + is_new_user=response.is_new_user, + binding_complete=binding_complete, + expires_in=response.expires_in, + ) + + +# ==================== 验证码 & 绑定 ==================== + +class SendVerificationCodeRequest(BaseModel): + target: str # phone / email + value: str + purpose: str # bind / login / reset_password + + +class SendVerificationCodeResponse(BaseModel): + expires_in: int + resend_after: int + + +class BindContactRequest(BaseModel): + phone: str = "" + phone_code: str = "" + email: str = "" + email_code: str = "" + + +class BindContactResponse(BaseModel): + success: bool + user: dict + + +@router.post("/send-verification-code", response_model=SendVerificationCodeResponse) +async def send_verification_code( + request: SendVerificationCodeRequest, +) -> SendVerificationCodeResponse: + """发送验证码(手机或邮箱)""" + from packages.adapters.sms.sms_service import get_sms_service + from packages.adapters.smtp import get_email_service + from packages.adapters.sqlalchemy_impl.verification_code_repository import ( + SQLAlchemyVerificationCodeRepository, + ) + from packages.application.auth.bind_contact_use_case import ( + SendVerificationCodeRequest as UseCaseRequest, + ) + from packages.application.auth.bind_contact_use_case import SendVerificationCodeUseCase + from packages.application.auth.verification_code_service import VerificationCodeService + from app.dependencies import get_db + + db = next(get_db()) + repo = SQLAlchemyVerificationCodeRepository(db) + vc_service = VerificationCodeService(repo=repo) + sms_service = get_sms_service() + email_service = get_email_service() + + use_case = SendVerificationCodeUseCase( + verification_code_service=vc_service, + sms_service=sms_service, + email_service=email_service, + ) + uc_request = UseCaseRequest( + target=request.target, + value=request.value, + purpose=request.purpose, + ) + response, err = use_case.execute(uc_request) + if err: + raise HTTPException(status_code=400, detail=err) + + return SendVerificationCodeResponse( + expires_in=response.expires_in, + resend_after=response.resend_after, + ) + + +@router.post("/bind-contact", response_model=BindContactResponse) +async def bind_contact( + request: BindContactRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + user_repository: UserRepository = Depends(get_user_repository), +) -> BindContactResponse: + """绑定手机号和/或邮箱(需登录态)""" + from packages.adapters.sqlalchemy_impl.verification_code_repository import ( + SQLAlchemyVerificationCodeRepository, + ) + from packages.application.auth.bind_contact_use_case import ( + BindContactRequest as UseCaseRequest, + ) + from packages.application.auth.bind_contact_use_case import BindContactUseCase + from packages.application.auth.verification_code_service import VerificationCodeService + from app.dependencies import get_db + + db = next(get_db()) + vc_repo = SQLAlchemyVerificationCodeRepository(db) + vc_service = VerificationCodeService(repo=vc_repo) + + use_case = BindContactUseCase( + user_repository=user_repository, + verification_code_service=vc_service, + ) + uc_request = UseCaseRequest( + user_id=current_user.user_id, + phone=request.phone, + phone_code=request.phone_code, + email=request.email, + email_code=request.email_code, + ) + response, err = use_case.execute(uc_request) + if err: + raise HTTPException(status_code=400, detail=err) + + return BindContactResponse(success=True, user=response.to_dict()["user"]) + + +# ==================== 当前用户信息扩展 ==================== + +# 扩展 CurrentUserResponse 增加绑定状态字段(在原响应基础上补充) +# 通过给 get_current_user_info 返回值补充字段实现 diff --git a/packages/adapters/in_memory/user_repository.py b/packages/adapters/in_memory/user_repository.py old mode 100644 new mode 100755 index 7eebc680f..e892f0889 --- a/packages/adapters/in_memory/user_repository.py +++ b/packages/adapters/in_memory/user_repository.py @@ -17,6 +17,9 @@ class InMemoryUserRepository(UserRepository): self._username_index: Dict[str, str] = {} # username -> user_id self._verification_token_index: Dict[str, str] = {} # token -> user_id self._reset_token_index: Dict[str, str] = {} # token -> user_id + self._wechat_openid_index: Dict[str, str] = {} # openid -> user_id + self._wechat_unionid_index: Dict[str, str] = {} # unionid -> user_id + self._phone_index: Dict[str, str] = {} # phone -> user_id def save(self, user: User) -> None: """保存用户""" @@ -28,6 +31,12 @@ class InMemoryUserRepository(UserRepository): self._verification_token_index[user.email_verification_token] = user.id if user.password_reset_token: self._reset_token_index[user.password_reset_token] = user.id + if user.wechat_openid: + self._wechat_openid_index[user.wechat_openid] = user.id + if user.wechat_unionid: + self._wechat_unionid_index[user.wechat_unionid] = user.id + if user.phone: + self._phone_index[user.phone] = user.id def find_by_id(self, user_id: str) -> Optional[User]: """根据 ID 查找用户""" @@ -61,6 +70,31 @@ class InMemoryUserRepository(UserRepository): return self._users.get(user_id) return None + def find_by_wechat_openid(self, openid: str) -> Optional[User]: + """根据微信 openid 查找用户""" + user_id = self._wechat_openid_index.get(openid) + if user_id: + return self._users.get(user_id) + return None + + def find_by_wechat_unionid(self, unionid: str) -> Optional[User]: + """根据微信 unionid 查找用户""" + if not unionid: + return None + user_id = self._wechat_unionid_index.get(unionid) + if user_id: + return self._users.get(user_id) + return None + + def find_by_phone(self, phone: str) -> Optional[User]: + """根据手机号查找用户""" + if not phone: + return None + user_id = self._phone_index.get(phone) + if user_id: + return self._users.get(user_id) + return None + def delete(self, user_id: str) -> bool: """删除用户""" user = self._users.get(user_id) diff --git a/packages/adapters/sms/sms_service.py b/packages/adapters/sms/sms_service.py new file mode 100755 index 000000000..de164c458 --- /dev/null +++ b/packages/adapters/sms/sms_service.py @@ -0,0 +1,86 @@ +""" +短信服务实现(Noop + 阿里云) +""" + +from __future__ import annotations + +import logging +import os + +logger = logging.getLogger(__name__) + + +class NoopSmsService: + """空实现短信服务 - 开发/测试环境用,只打日志不真发""" + + def send_verification_code(self, phone: str, code: str) -> bool: + logger.info("[NoopSMS] 发送验证码到 %s: %s", phone, code) + return True + + def send_template_sms(self, phone: str, template_id: str, params: dict) -> bool: + logger.info("[NoopSMS] 发送模板短信到 %s, template=%s, params=%s", phone, template_id, params) + return True + + +class AliyunSmsService: + """阿里云短信服务""" + + def __init__( + self, + access_key_id: str | None = None, + access_key_secret: str | None = None, + sign_name: str | None = None, + verify_template_id: str | None = None, + ): + self.access_key_id = access_key_id or os.environ.get("ALIYUN_SMS_ACCESS_KEY_ID", "") + self.access_key_secret = access_key_secret or os.environ.get("ALIYUN_SMS_ACCESS_KEY_SECRET", "") + self.sign_name = sign_name or os.environ.get("ALIYUN_SMS_SIGN_NAME", "小应剪辑") + self.verify_template_id = verify_template_id or os.environ.get( + "ALIYUN_SMS_VERIFY_TEMPLATE_ID", "SMS_123456789" + ) + + def send_verification_code(self, phone: str, code: str) -> bool: + return self.send_template_sms(phone, self.verify_template_id, {"code": code}) + + def send_template_sms(self, phone: str, template_id: str, params: dict) -> bool: + try: + from alibabacloud_dysmsapi20170525.client import Client as DysmsapiClient + from alibabacloud_tea_openapi import models as open_api_models + from alibabacloud_dysmsapi20170525 import models as dysmsapi_models + import json + + config = open_api_models.Config( + access_key_id=self.access_key_id, + access_key_secret=self.access_key_secret, + ) + config.endpoint = "dysmsapi.aliyuncs.com" + client = DysmsapiClient(config) + + request = dysmsapi_models.SendSmsRequest( + phone_numbers=phone, + sign_name=self.sign_name, + template_code=template_id, + template_param=json.dumps(params), + ) + response = client.send_sms(request) + body = response.body + if body.code == "OK": + logger.info("阿里云短信发送成功: phone=%s, template=%s", phone, template_id) + return True + else: + logger.error("阿里云短信发送失败: code=%s, message=%s", body.code, body.message) + return False + except ImportError: + logger.error("阿里云短信 SDK 未安装,请 pip install alibabacloud-dysmsapi20170525") + return False + except Exception as e: + logger.error("阿里云短信发送异常: %s", e, exc_info=True) + return False + + +def get_sms_service() -> "NoopSmsService | AliyunSmsService": + """获取短信服务实例""" + provider = os.environ.get("SMS_PROVIDER", "noop").lower() + if provider == "aliyun": + return AliyunSmsService() + return NoopSmsService() diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index ff64a71d0..e6ee6a874 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -31,9 +31,13 @@ class UserModel(Base): used_storage_gb = Column(Integer, nullable=False, default=0) # 管理员标识 is_admin = Column(Boolean, nullable=False, default=False) - # 微信登录(小程序端) + # 微信登录 wechat_openid = Column(String(128), nullable=True, unique=True, index=True) wechat_unionid = Column(String(128), nullable=True, unique=True, index=True) + # 手机号绑定 + phone = Column(String(32), nullable=True, unique=True, index=True) + phone_verified = Column(Boolean, nullable=False, default=False) + binding_completed_at = Column(DateTime, nullable=True) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) @@ -555,3 +559,18 @@ class BillingRecordModel(Base): invoice_url = Column(String(500), nullable=True) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) paid_at = Column(DateTime, nullable=True) + + +class VerificationCodeModel(Base): + """验证码(邮箱/手机统一管理)""" + + __tablename__ = "verification_codes" + + id = Column(String(36), primary_key=True) + recipient = Column(String(255), nullable=False, index=True) # 邮箱或手机号 + code = Column(String(10), nullable=False) + code_type = Column(String(32), nullable=False, index=True) # email_bind / phone_bind / ... + expires_at = Column(DateTime, nullable=False) + used_at = Column(DateTime, nullable=True) + attempts = Column(Integer, nullable=False, default=0) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) \ No newline at end of file diff --git a/packages/adapters/sqlalchemy_impl/user_repository.py b/packages/adapters/sqlalchemy_impl/user_repository.py old mode 100644 new mode 100755 index ab81133ce..ac9631aa9 --- a/packages/adapters/sqlalchemy_impl/user_repository.py +++ b/packages/adapters/sqlalchemy_impl/user_repository.py @@ -35,6 +35,9 @@ class SQLAlchemyUserRepository(UserRepository): model.is_admin = user.is_admin model.wechat_openid = user.wechat_openid model.wechat_unionid = user.wechat_unionid + model.phone = user.phone + model.phone_verified = user.phone_verified + model.binding_completed_at = user.binding_completed_at model.created_at = user.created_at self.session.commit() @@ -61,6 +64,12 @@ class SQLAlchemyUserRepository(UserRepository): model = self.session.query(UserModel).filter(UserModel.wechat_unionid == unionid.strip()).first() return self._to_entity(model) + def find_by_phone(self, phone: str) -> User | None: + if not phone or not phone.strip(): + return None + model = self.session.query(UserModel).filter(UserModel.phone == phone.strip()).first() + return self._to_entity(model) + def find_by_verification_token(self, token: str) -> User | None: model = self.session.query(UserModel).filter(UserModel.email_verification_token == token).first() return self._to_entity(model) @@ -101,5 +110,8 @@ class SQLAlchemyUserRepository(UserRepository): is_admin=model.is_admin or False, wechat_openid=model.wechat_openid, wechat_unionid=model.wechat_unionid, + phone=model.phone, + phone_verified=model.phone_verified or False, + binding_completed_at=model.binding_completed_at, created_at=model.created_at, ) diff --git a/packages/adapters/sqlalchemy_impl/verification_code_repository.py b/packages/adapters/sqlalchemy_impl/verification_code_repository.py new file mode 100755 index 000000000..6c92fd821 --- /dev/null +++ b/packages/adapters/sqlalchemy_impl/verification_code_repository.py @@ -0,0 +1,79 @@ +""" +验证码仓储 SQLAlchemy 实现 +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.models import VerificationCodeModel +from packages.domain.verification_code import VerificationCode +from packages.ports.verification_code_repository import VerificationCodeRepository + + +class SQLAlchemyVerificationCodeRepository(VerificationCodeRepository): + def __init__(self, session: Session): + self.session = session + + def save(self, code: VerificationCode) -> None: + model = self.session.get(VerificationCodeModel, code.id) + if model is None: + model = VerificationCodeModel(id=code.id) + self.session.add(model) + + model.recipient = code.recipient + model.code = code.code + model.code_type = code.code_type + model.expires_at = code.expires_at + model.used_at = code.used_at + model.attempts = code.attempts + model.created_at = code.created_at + + self.session.commit() + self.session.refresh(model) + + def find_latest(self, recipient: str, code_type: str) -> Optional[VerificationCode]: + model = ( + self.session.query(VerificationCodeModel) + .filter( + VerificationCodeModel.recipient == recipient.strip(), + VerificationCodeModel.code_type == code_type, + ) + .order_by(VerificationCodeModel.created_at.desc()) + .first() + ) + return self._to_entity(model) + + def find_by_id(self, code_id: str) -> Optional[VerificationCode]: + return self._to_entity(self.session.get(VerificationCodeModel, code_id)) + + def count_today(self, recipient: str, code_type: str) -> int: + now = datetime.now(timezone.utc) + start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) + return ( + self.session.query(VerificationCodeModel) + .filter( + VerificationCodeModel.recipient == recipient.strip(), + VerificationCodeModel.code_type == code_type, + VerificationCodeModel.created_at >= start_of_day, + ) + .count() + ) + + @staticmethod + def _to_entity(model: VerificationCodeModel | None) -> VerificationCode | None: + if model is None: + return None + return VerificationCode( + id=model.id, + recipient=model.recipient, + code=model.code, + code_type=model.code_type, + expires_at=model.expires_at, + used_at=model.used_at, + attempts=model.attempts, + created_at=model.created_at, + ) diff --git a/packages/application/auth/bind_contact_use_case.py b/packages/application/auth/bind_contact_use_case.py new file mode 100755 index 000000000..cd5730d86 --- /dev/null +++ b/packages/application/auth/bind_contact_use_case.py @@ -0,0 +1,232 @@ +""" +微信登录 + 绑定手机号邮箱 Use Case +""" + +from __future__ import annotations + +import logging +import secrets +from datetime import datetime, timedelta, timezone +from typing import Optional +from uuid import uuid4 + +import jwt as pyjwt + +from packages.adapters.redis import get_session_store +from packages.application.auth.jwt_service import jwt_service +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)}" diff --git a/packages/application/auth/verification_code_service.py b/packages/application/auth/verification_code_service.py new file mode 100755 index 000000000..ee6b8e3f9 --- /dev/null +++ b/packages/application/auth/verification_code_service.py @@ -0,0 +1,206 @@ +""" +验证码服务 +- 生成验证码 +- 校验验证码 +- 频控(60s 冷却 + 每日上限) +""" + +from __future__ import annotations + +import logging +import re +from datetime import datetime, timedelta, timezone +from typing import Optional + +from packages.domain.verification_code import VerificationCode +from packages.ports.verification_code_repository import VerificationCodeRepository + +logger = logging.getLogger(__name__) + +# 频控参数 +RESEND_COOLDOWN_SECONDS = 60 # 重发冷却时间 +DAILY_LIMIT = 10 # 每日发送上限 +MAX_ATTEMPTS = 5 # 单验证码最大尝试次数 +DEFAULT_TTL_SECONDS = 300 # 默认有效期 5 分钟 + +# 验证码类型 +CODE_TYPE_EMAIL_BIND = "email_bind" +CODE_TYPE_PHONE_BIND = "phone_bind" +CODE_TYPE_EMAIL_LOGIN = "email_login" +CODE_TYPE_PHONE_LOGIN = "phone_login" +CODE_TYPE_RESET_PASSWORD = "reset_password" + +VALID_CODE_TYPES = { + CODE_TYPE_EMAIL_BIND, + CODE_TYPE_PHONE_BIND, + CODE_TYPE_EMAIL_LOGIN, + CODE_TYPE_PHONE_LOGIN, + CODE_TYPE_RESET_PASSWORD, +} + + +class VerificationCodeService: + """验证码服务""" + + def __init__( + self, + repo: VerificationCodeRepository, + resend_cooldown: int = RESEND_COOLDOWN_SECONDS, + daily_limit: int = DAILY_LIMIT, + max_attempts: int = MAX_ATTEMPTS, + default_ttl: int = DEFAULT_TTL_SECONDS, + ): + self.repo = repo + self.resend_cooldown = resend_cooldown + self.daily_limit = daily_limit + self.max_attempts = max_attempts + self.default_ttl = default_ttl + + def generate( + self, + recipient: str, + code_type: str, + ttl_seconds: int | None = None, + custom_code: str | None = None, + ) -> tuple[Optional[VerificationCode], Optional[str]]: + """ + 生成验证码 + + Returns: + (验证码实体, 错误信息) + """ + recipient = recipient.strip() + + # 参数校验 + if not recipient: + return None, "接收方不能为空" + if code_type not in VALID_CODE_TYPES: + return None, f"无效的验证码类型: {code_type}" + + # 频控检查 + can_send, wait_seconds = self._check_rate_limit(recipient, code_type) + if not can_send: + if wait_seconds > 0: + return None, f"发送太频繁,请 {wait_seconds} 秒后再试" + return None, "今日发送次数已达上限" + + # 生成并保存 + code = VerificationCode.create( + recipient=recipient, + code_type=code_type, + ttl_seconds=ttl_seconds or self.default_ttl, + custom_code=custom_code, + ) + self.repo.save(code) + + return code, None + + def verify( + self, + recipient: str, + code_type: str, + code_value: str, + consume: bool = True, + ) -> tuple[bool, Optional[str]]: + """ + 校验验证码 + + Args: + recipient: 接收方(邮箱/手机号) + code_type: 验证码类型 + code_value: 用户输入的验证码 + consume: 校验成功后是否标记为已使用 + + Returns: + (是否通过, 错误信息) + """ + recipient = recipient.strip() + code_value = code_value.strip() + + if not recipient or not code_value: + return False, "参数不完整" + + # 查找最新的验证码 + latest = self.repo.find_latest(recipient, code_type) + if not latest: + return False, "验证码不存在或已过期" + + # 增加尝试次数 + latest.increment_attempts() + self.repo.save(latest) + + # 检查是否已使用 + if latest.is_used: + return False, "验证码已使用,请重新获取" + + # 检查是否过期 + if latest.is_expired: + return False, "验证码已过期,请重新获取" + + # 检查尝试次数 + if latest.attempts > self.max_attempts: + return False, "验证次数过多,请重新获取验证码" + + # 校验验证码 + if latest.code != code_value: + return False, "验证码错误" + + # 校验通过,标记为已使用 + if consume: + latest.mark_used() + self.repo.save(latest) + + return True, None + + def _check_rate_limit(self, recipient: str, code_type: str) -> tuple[bool, int]: + """ + 频控检查 + + Returns: + (是否允许发送, 需等待秒数) + """ + # 检查冷却时间 + latest = self.repo.find_latest(recipient, code_type) + if latest: + elapsed = (datetime.now(timezone.utc) - latest.created_at).total_seconds() + if elapsed < self.resend_cooldown: + wait = int(self.resend_cooldown - elapsed) + return False, wait + + # 检查每日上限 + today_count = self.repo.count_today(recipient, code_type) + if today_count >= self.daily_limit: + return False, 0 + + return True, 0 + + +def validate_phone(phone: str) -> tuple[bool, str]: + """校验手机号格式(中国大陆手机号)""" + phone = phone.strip() + if not phone: + return False, "手机号不能为空" + # 支持 +86 前缀或纯 11 位 + pattern = r"^(\+86)?1[3-9]\d{9}$" + if not re.match(pattern, phone): + return False, "手机号格式不正确" + return True, "" + + +def normalize_phone(phone: str) -> str: + """标准化手机号(去掉 +86 前缀,统一存储格式)""" + phone = phone.strip() + if phone.startswith("+86"): + phone = phone[3:] + return phone + + +def validate_email(email: str) -> tuple[bool, str]: + """校验邮箱格式""" + email = email.strip() + if not email: + return False, "邮箱不能为空" + pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + if not re.match(pattern, email): + return False, "邮箱格式不正确" + return True, "" diff --git a/packages/application/auth/wechat_oauth_service.py b/packages/application/auth/wechat_oauth_service.py new file mode 100755 index 000000000..bf44c0d35 --- /dev/null +++ b/packages/application/auth/wechat_oauth_service.py @@ -0,0 +1,156 @@ +""" +微信 OAuth 服务 +- 生成授权链接(网页扫码登录) +- 处理回调,用 code 换 access_token + 用户信息 +""" + +from __future__ import annotations + +import logging +import os +import urllib.parse +from dataclasses import dataclass +from typing import Optional +from uuid import uuid4 + +import requests + +logger = logging.getLogger(__name__) + + +@dataclass +class WechatUserInfo: + """微信用户信息""" + openid: str + unionid: str = "" + nickname: str = "" + avatar_url: str = "" + + +class WechatOAuthService: + """微信开放平台 OAuth 服务(网页扫码登录)""" + + def __init__( + self, + app_id: str | None = None, + app_secret: str | None = None, + redirect_uri: str | None = None, + state_store=None, + ): + self.app_id = app_id or os.environ.get("WECHAT_OPEN_APP_ID", "") + self.app_secret = app_secret or os.environ.get("WECHAT_OPEN_APP_SECRET", "") + self.redirect_uri = redirect_uri or os.environ.get( + "WECHAT_OPEN_REDIRECT_URI", "" + ) + self._state_store = state_store # 可选:state 存储(Redis/内存),用于 CSRF 防护 + + def is_configured(self) -> bool: + """检查微信配置是否完整""" + return bool(self.app_id and self.app_secret and self.redirect_uri) + + def generate_auth_url(self, scope: str = "snsapi_login") -> tuple[str, str]: + """ + 生成微信授权链接 + + Returns: + (授权URL, state) + """ + state = uuid4().hex + + if not self.is_configured(): + # 未配置时返回 mock URL,方便前端联调 + mock_params = urllib.parse.urlencode({ + "app_id": "mock", + "redirect_uri": self.redirect_uri, + "scope": scope, + "state": state, + }) + return f"/mock/wechat/auth?{mock_params}", state + + params = { + "appid": self.app_id, + "redirect_uri": self.redirect_uri, + "response_type": "code", + "scope": scope, + "state": state, + } + url = "https://open.weixin.qq.com/connect/qrconnect?" + urllib.parse.urlencode(params) + "#wechat_redirect" + return url, state + + def handle_callback(self, code: str, state: str) -> tuple[Optional[WechatUserInfo], Optional[str]]: + """ + 处理微信回调 + + Args: + code: 微信授权码 + state: 防 CSRF 状态 + + Returns: + (微信用户信息, 错误信息) + """ + if not code: + return None, "缺少授权码" + + if not self.is_configured(): + # 开发模式:返回 mock 用户信息 + logger.info("微信未配置,使用 mock 用户信息") + return WechatUserInfo( + openid=f"mock_{code[:20]}", + unionid=f"mock_union_{code[:16]}", + nickname="微信测试用户", + avatar_url="", + ), None + + try: + # 1. 用 code 换 access_token + token_url = "https://api.weixin.qq.com/sns/oauth2/access_token" + token_params = { + "appid": self.app_id, + "secret": self.app_secret, + "code": code, + "grant_type": "authorization_code", + } + token_resp = requests.get(token_url, params=token_params, timeout=10) + token_data = token_resp.json() + + if "errcode" in token_data and token_data["errcode"] != 0: + logger.error("微信获取 access_token 失败: %s", token_data) + return None, f"微信授权失败: {token_data.get('errmsg', '未知错误')}" + + access_token = token_data["access_token"] + openid = token_data["openid"] + unionid = token_data.get("unionid", "") + + # 2. 获取用户信息 + user_url = "https://api.weixin.qq.com/sns/userinfo" + user_params = { + "access_token": access_token, + "openid": openid, + "lang": "zh_CN", + } + user_resp = requests.get(user_url, params=user_params, timeout=10) + user_data = user_resp.json() + + if "errcode" in user_data and user_data["errcode"] != 0: + logger.error("微信获取用户信息失败: %s", user_data) + return None, f"获取用户信息失败: {user_data.get('errmsg', '未知错误')}" + + return WechatUserInfo( + openid=openid, + unionid=unionid, + nickname=user_data.get("nickname", ""), + avatar_url=user_data.get("headimgurl", ""), + ), None + + except requests.RequestException as e: + logger.error("微信 OAuth 请求异常: %s", e, exc_info=True) + return None, "微信服务暂不可用,请稍后再试" + except Exception as e: + logger.error("微信回调处理异常: %s", e, exc_info=True) + return None, "微信登录处理失败" + + +def get_wechat_oauth_service() -> WechatOAuthService: + """获取微信 OAuth 服务单例""" + # TODO: 可替换为 Redis state store + return WechatOAuthService() diff --git a/packages/domain/auth/sms_service.py b/packages/domain/auth/sms_service.py new file mode 100755 index 000000000..69f5d4f49 --- /dev/null +++ b/packages/domain/auth/sms_service.py @@ -0,0 +1,19 @@ +""" +短信服务接口 +""" + +from abc import ABC, abstractmethod + + +class SmsService(ABC): + """短信服务接口""" + + @abstractmethod + def send_verification_code(self, phone: str, code: str) -> bool: + """发送验证码短信""" + pass + + @abstractmethod + def send_template_sms(self, phone: str, template_id: str, params: dict) -> bool: + """发送模板短信""" + pass diff --git a/packages/domain/entities.py b/packages/domain/entities.py index ce6ed56c3..079fc3af1 100755 --- a/packages/domain/entities.py +++ b/packages/domain/entities.py @@ -59,6 +59,11 @@ class User: wechat_openid: str | None = None wechat_unionid: str | None = None + # 手机号绑定 + phone: str | None = None + phone_verified: bool = False + binding_completed_at: datetime | None = None + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/packages/domain/verification_code.py b/packages/domain/verification_code.py new file mode 100755 index 000000000..e2cbb7b00 --- /dev/null +++ b/packages/domain/verification_code.py @@ -0,0 +1,66 @@ +""" +验证码领域实体 +""" + +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + + +@dataclass(slots=True) +class VerificationCode: + """验证码(邮箱/手机统一)""" + + id: str + recipient: str # 邮箱或手机号 + code: str + code_type: str # email_bind / phone_bind / email_login / phone_login / reset_password + expires_at: datetime + used_at: datetime | None = None + attempts: int = 0 + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @classmethod + def create( + cls, + recipient: str, + code_type: str, + ttl_seconds: int = 300, + custom_code: str | None = None, + ) -> "VerificationCode": + """创建验证码""" + import random + + code = custom_code or "".join(random.choices("0123456789", k=6)) + now = datetime.now(timezone.utc) + return cls( + id=uuid4().hex, + recipient=recipient.strip(), + code=code, + code_type=code_type, + expires_at=now + timedelta(seconds=ttl_seconds), + created_at=now, + ) + + @property + def is_expired(self) -> bool: + """是否已过期""" + return datetime.now(timezone.utc) > self.expires_at + + @property + def is_used(self) -> bool: + """是否已使用""" + return self.used_at is not None + + @property + def is_valid(self) -> bool: + """是否有效(未过期且未使用)""" + return not self.is_expired and not self.is_used + + def mark_used(self) -> None: + """标记为已使用""" + self.used_at = datetime.now(timezone.utc) + + def increment_attempts(self) -> None: + """增加尝试次数""" + self.attempts += 1 diff --git a/packages/ports/user_repository.py b/packages/ports/user_repository.py old mode 100644 new mode 100755 index 70c6d1124..0783578a9 --- a/packages/ports/user_repository.py +++ b/packages/ports/user_repository.py @@ -51,6 +51,11 @@ class UserRepository(ABC): """根据微信 unionid 查找用户""" pass + @abstractmethod + def find_by_phone(self, phone: str) -> Optional[User]: + """根据手机号查找用户""" + pass + @abstractmethod def delete(self, user_id: str) -> bool: """删除用户""" diff --git a/packages/ports/verification_code_repository.py b/packages/ports/verification_code_repository.py new file mode 100755 index 000000000..337a44f48 --- /dev/null +++ b/packages/ports/verification_code_repository.py @@ -0,0 +1,32 @@ +""" +验证码仓储接口 +""" + +from abc import ABC, abstractmethod +from typing import Optional + +from packages.domain.verification_code import VerificationCode + + +class VerificationCodeRepository(ABC): + """验证码仓储接口""" + + @abstractmethod + def save(self, code: VerificationCode) -> None: + """保存验证码""" + pass + + @abstractmethod + def find_latest(self, recipient: str, code_type: str) -> Optional[VerificationCode]: + """查找最新的有效验证码""" + pass + + @abstractmethod + def find_by_id(self, code_id: str) -> Optional[VerificationCode]: + """根据 ID 查找验证码""" + pass + + @abstractmethod + def count_today(self, recipient: str, code_type: str) -> int: + """统计当日发送次数(频控用)""" + pass diff --git a/tests/unit/test_wechat_login_and_verification.py b/tests/unit/test_wechat_login_and_verification.py new file mode 100755 index 000000000..e0d009532 --- /dev/null +++ b/tests/unit/test_wechat_login_and_verification.py @@ -0,0 +1,281 @@ +""" +验证码服务 + 绑定流程单元测试 +""" + +import pytest +from datetime import datetime, timedelta, timezone + +from packages.adapters.in_memory.user_repository import InMemoryUserRepository +from packages.application.auth.bind_contact_use_case import ( + BindContactRequest, + BindContactUseCase, + SendVerificationCodeRequest, + SendVerificationCodeUseCase, +) +from packages.application.auth.verification_code_service import ( + CODE_TYPE_EMAIL_BIND, + CODE_TYPE_PHONE_BIND, + VerificationCodeService, + validate_email, + validate_phone, + normalize_phone, +) +from packages.domain.entities import User +from packages.domain.verification_code import VerificationCode + + +class InMemoryVerificationCodeRepository: + """内存版验证码仓储""" + + def __init__(self): + self._codes = {} + + def save(self, code): + self._codes[code.id] = code + + def find_latest(self, recipient, code_type): + candidates = [ + c for c in self._codes.values() + if c.recipient == recipient and c.code_type == code_type + ] + if not candidates: + return None + return max(candidates, key=lambda c: c.created_at) + + def find_by_id(self, code_id): + return self._codes.get(code_id) + + def count_today(self, recipient, code_type): + now = datetime.now(timezone.utc) + start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) + return sum( + 1 for c in self._codes.values() + if c.recipient == recipient + and c.code_type == code_type + and c.created_at >= start_of_day + ) + + +@pytest.fixture +def vc_repo(): + return InMemoryVerificationCodeRepository() + + +@pytest.fixture +def vc_service(vc_repo): + return VerificationCodeService(repo=vc_repo) + + +@pytest.fixture +def user_repo(): + repo = InMemoryUserRepository() + # 创建一个微信用户(未绑定手机+邮箱) + user = User( + id="user_001", + email="wx_test@wechat.local", + username="wx_test", + display_name="微信测试用户", + password_hash="hash", + email_verified=True, + wechat_openid="openid_test", + ) + repo.save(user) + return repo + + +class TestPhoneValidation: + def test_valid_phone(self): + ok, _ = validate_phone("13800138000") + assert ok + + def test_valid_phone_with_86(self): + ok, _ = validate_phone("+8613800138000") + assert ok + + def test_invalid_phone_too_short(self): + ok, _ = validate_phone("12345") + assert not ok + + def test_invalid_phone_wrong_prefix(self): + ok, _ = validate_phone("12000000000") + assert not ok + + def test_normalize_phone_strip_86(self): + assert normalize_phone("+8613800138000") == "13800138000" + assert normalize_phone("13800138000") == "13800138000" + + +class TestEmailValidation: + def test_valid_email(self): + ok, _ = validate_email("test@example.com") + assert ok + + def test_invalid_email_no_at(self): + ok, _ = validate_email("testexample.com") + assert not ok + + def test_invalid_email_empty(self): + ok, _ = validate_email("") + assert not ok + + +class TestVerificationCodeService: + def test_generate_code(self, vc_service): + code, err = vc_service.generate("13800138000", CODE_TYPE_PHONE_BIND) + assert err is None + assert code is not None + assert len(code.code) == 6 + assert code.code.isdigit() + assert code.recipient == "13800138000" + assert code.code_type == CODE_TYPE_PHONE_BIND + assert code.is_valid + + def test_generate_code_invalid_type(self, vc_service): + code, err = vc_service.generate("13800138000", "invalid_type") + assert err is not None + assert code is None + + def test_verify_success(self, vc_service): + code, _ = vc_service.generate("13800138000", CODE_TYPE_PHONE_BIND) + ok, err = vc_service.verify("13800138000", CODE_TYPE_PHONE_BIND, code.code) + assert ok + assert err is None + assert code.is_used + + def test_verify_wrong_code(self, vc_service): + vc_service.generate("13800138000", CODE_TYPE_PHONE_BIND) + ok, err = vc_service.verify("13800138000", CODE_TYPE_PHONE_BIND, "000000") + assert not ok + assert "错误" in err + + def test_verify_expired_code(self, vc_service, vc_repo): + # 手动创建一个已过期的验证码 + expired = VerificationCode.create("13800138000", CODE_TYPE_PHONE_BIND, ttl_seconds=1) + expired.created_at = datetime.now(timezone.utc) - timedelta(seconds=10) + expired.expires_at = datetime.now(timezone.utc) - timedelta(seconds=5) + vc_repo.save(expired) + + ok, err = vc_service.verify("13800138000", CODE_TYPE_PHONE_BIND, expired.code) + assert not ok + assert "过期" in err + + def test_resend_cooldown(self, vc_service): + vc_service.generate("13800138000", CODE_TYPE_PHONE_BIND) + _, err = vc_service.generate("13800138000", CODE_TYPE_PHONE_BIND) + assert err is not None + assert "频繁" in err + + +class TestBindContactUseCase: + def test_bind_phone_and_email(self, vc_service, vc_repo, user_repo): + # 直接生成已知验证码(绕开发送流程,测试绑定逻辑) + phone_code, _ = vc_service.generate("13800138000", CODE_TYPE_PHONE_BIND, custom_code="123456") + email_code, _ = vc_service.generate("test@example.com", CODE_TYPE_EMAIL_BIND, custom_code="654321") + assert phone_code and email_code + + # 4. 绑定 + bind_uc = BindContactUseCase( + user_repository=user_repo, + verification_code_service=vc_service, + ) + bind_req = BindContactRequest( + user_id="user_001", + phone="13800138000", + phone_code="123456", + email="test@example.com", + email_code="654321", + ) + resp, err = bind_uc.execute(bind_req) + assert err is None + assert resp is not None + assert resp.user.phone == "13800138000" + assert resp.user.phone_verified + assert resp.user.email == "test@example.com" + assert resp.user.email_verified + assert resp.user.binding_completed_at is not None + + def test_bind_phone_only(self, vc_service, vc_repo, user_repo): + vc_service.generate("13800138000", CODE_TYPE_PHONE_BIND, custom_code="123456") + + bind_uc = BindContactUseCase( + user_repository=user_repo, + verification_code_service=vc_service, + ) + bind_req = BindContactRequest( + user_id="user_001", + phone="13800138000", + phone_code="123456", + ) + resp, err = bind_uc.execute(bind_req) + assert err is None + assert resp.user.phone == "13800138000" + assert resp.user.phone_verified + # 只绑了手机,邮箱还是 wechat.local,所以 binding_complete 为 false + assert resp.user.binding_completed_at is None + + def test_bind_phone_wrong_code(self, vc_service, user_repo): + vc_service.generate("13800138000", CODE_TYPE_PHONE_BIND) + + bind_uc = BindContactUseCase( + user_repository=user_repo, + verification_code_service=vc_service, + ) + bind_req = BindContactRequest( + user_id="user_001", + phone="13800138000", + phone_code="000000", + ) + resp, err = bind_uc.execute(bind_req) + assert err is not None + assert resp is None + + def test_bind_phone_already_used(self, vc_service, user_repo): + # 另一个用户已经绑定了这个手机号 + other = User( + id="user_002", + email="other@example.com", + username="other", + display_name="其他用户", + password_hash="hash", + phone="13800138000", + phone_verified=True, + ) + user_repo.save(other) + + vc_service.generate("13800138000", CODE_TYPE_PHONE_BIND, custom_code="123456") + + bind_uc = BindContactUseCase( + user_repository=user_repo, + verification_code_service=vc_service, + ) + bind_req = BindContactRequest( + user_id="user_001", + phone="13800138000", + phone_code="123456", + ) + resp, err = bind_uc.execute(bind_req) + assert err is not None + assert "已被其他账号绑定" in err + + +class TestWechatOAuthService: + def test_mock_mode_generate_url(self): + # 没配置真实微信参数时走 mock 模式 + from packages.application.auth.wechat_oauth_service import WechatOAuthService + + service = WechatOAuthService(app_id="", app_secret="", redirect_uri="") + assert not service.is_configured() + + url, state = service.generate_auth_url() + assert state + assert "mock" in url + + def test_mock_mode_callback(self): + from packages.application.auth.wechat_oauth_service import WechatOAuthService + + service = WechatOAuthService(app_id="", app_secret="", redirect_uri="") + user_info, err = service.handle_callback("test_code", "test_state") + assert err is None + assert user_info is not None + assert "mock" in user_info.openid + assert user_info.nickname == "微信测试用户" -- 2.54.0 From 78bb3c11a885d57e24d4c31707dd18a0729fa71b Mon Sep 17 00:00:00 2001 From: CI Bot Date: Tue, 21 Jul 2026 08:41:56 +0800 Subject: [PATCH 2/6] style: auto-format with black + isort + prettier [ci skip] --- apps/api/app/api/routes/auth.py | 14 +++--- packages/adapters/sms/sms_service.py | 9 ++-- packages/adapters/sqlalchemy_impl/models.py | 2 +- .../application/auth/bind_contact_use_case.py | 15 ++++-- .../auth/verification_code_service.py | 8 +-- .../application/auth/wechat_oauth_service.py | 49 +++++++++++-------- .../test_wechat_login_and_verification.py | 17 +++---- 7 files changed, 62 insertions(+), 52 deletions(-) diff --git a/apps/api/app/api/routes/auth.py b/apps/api/app/api/routes/auth.py index 00fa8b549..a77dd4b66 100755 --- a/apps/api/app/api/routes/auth.py +++ b/apps/api/app/api/routes/auth.py @@ -509,17 +509,18 @@ async def send_verification_code( request: SendVerificationCodeRequest, ) -> SendVerificationCodeResponse: """发送验证码(手机或邮箱)""" + from app.dependencies import get_db + from packages.adapters.sms.sms_service import get_sms_service from packages.adapters.smtp import get_email_service from packages.adapters.sqlalchemy_impl.verification_code_repository import ( SQLAlchemyVerificationCodeRepository, ) + from packages.application.auth.bind_contact_use_case import SendVerificationCodeRequest as UseCaseRequest from packages.application.auth.bind_contact_use_case import ( - SendVerificationCodeRequest as UseCaseRequest, + SendVerificationCodeUseCase, ) - from packages.application.auth.bind_contact_use_case import SendVerificationCodeUseCase from packages.application.auth.verification_code_service import VerificationCodeService - from app.dependencies import get_db db = next(get_db()) repo = SQLAlchemyVerificationCodeRepository(db) @@ -554,15 +555,16 @@ async def bind_contact( user_repository: UserRepository = Depends(get_user_repository), ) -> BindContactResponse: """绑定手机号和/或邮箱(需登录态)""" + from app.dependencies import get_db + from packages.adapters.sqlalchemy_impl.verification_code_repository import ( SQLAlchemyVerificationCodeRepository, ) + from packages.application.auth.bind_contact_use_case import BindContactRequest as UseCaseRequest from packages.application.auth.bind_contact_use_case import ( - BindContactRequest as UseCaseRequest, + BindContactUseCase, ) - from packages.application.auth.bind_contact_use_case import BindContactUseCase from packages.application.auth.verification_code_service import VerificationCodeService - from app.dependencies import get_db db = next(get_db()) vc_repo = SQLAlchemyVerificationCodeRepository(db) diff --git a/packages/adapters/sms/sms_service.py b/packages/adapters/sms/sms_service.py index de164c458..51f155831 100755 --- a/packages/adapters/sms/sms_service.py +++ b/packages/adapters/sms/sms_service.py @@ -35,19 +35,18 @@ class AliyunSmsService: self.access_key_id = access_key_id or os.environ.get("ALIYUN_SMS_ACCESS_KEY_ID", "") self.access_key_secret = access_key_secret or os.environ.get("ALIYUN_SMS_ACCESS_KEY_SECRET", "") self.sign_name = sign_name or os.environ.get("ALIYUN_SMS_SIGN_NAME", "小应剪辑") - self.verify_template_id = verify_template_id or os.environ.get( - "ALIYUN_SMS_VERIFY_TEMPLATE_ID", "SMS_123456789" - ) + self.verify_template_id = verify_template_id or os.environ.get("ALIYUN_SMS_VERIFY_TEMPLATE_ID", "SMS_123456789") def send_verification_code(self, phone: str, code: str) -> bool: return self.send_template_sms(phone, self.verify_template_id, {"code": code}) def send_template_sms(self, phone: str, template_id: str, params: dict) -> bool: try: + import json + + from alibabacloud_dysmsapi20170525 import models as dysmsapi_models from alibabacloud_dysmsapi20170525.client import Client as DysmsapiClient from alibabacloud_tea_openapi import models as open_api_models - from alibabacloud_dysmsapi20170525 import models as dysmsapi_models - import json config = open_api_models.Config( access_key_id=self.access_key_id, diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index e6ee6a874..a3ee20e74 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -573,4 +573,4 @@ class VerificationCodeModel(Base): expires_at = Column(DateTime, nullable=False) used_at = Column(DateTime, nullable=True) attempts = Column(Integer, nullable=False, default=0) - created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) \ No newline at end of file + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) diff --git a/packages/application/auth/bind_contact_use_case.py b/packages/application/auth/bind_contact_use_case.py index cd5730d86..7144ed666 100755 --- a/packages/application/auth/bind_contact_use_case.py +++ b/packages/application/auth/bind_contact_use_case.py @@ -189,7 +189,9 @@ class SendVerificationCodeUseCase: self.sms_service = sms_service self.email_service = email_service - def execute(self, request: SendVerificationCodeRequest) -> tuple[Optional[SendVerificationCodeResponse], Optional[str]]: + def execute( + self, request: SendVerificationCodeRequest + ) -> tuple[Optional[SendVerificationCodeResponse], Optional[str]]: try: # 1. 确定 code_type if request.target == "phone": @@ -222,10 +224,13 @@ class SendVerificationCodeUseCase: # 4. 返回 ttl = (code_obj.expires_at - code_obj.created_at).total_seconds() - return SendVerificationCodeResponse( - expires_in=int(ttl), - resend_after=60, - ), None + return ( + SendVerificationCodeResponse( + expires_in=int(ttl), + resend_after=60, + ), + None, + ) except Exception as e: logger.error("发送验证码失败: %s", e, exc_info=True) diff --git a/packages/application/auth/verification_code_service.py b/packages/application/auth/verification_code_service.py index ee6b8e3f9..413f2fa51 100755 --- a/packages/application/auth/verification_code_service.py +++ b/packages/application/auth/verification_code_service.py @@ -18,10 +18,10 @@ from packages.ports.verification_code_repository import VerificationCodeReposito logger = logging.getLogger(__name__) # 频控参数 -RESEND_COOLDOWN_SECONDS = 60 # 重发冷却时间 -DAILY_LIMIT = 10 # 每日发送上限 -MAX_ATTEMPTS = 5 # 单验证码最大尝试次数 -DEFAULT_TTL_SECONDS = 300 # 默认有效期 5 分钟 +RESEND_COOLDOWN_SECONDS = 60 # 重发冷却时间 +DAILY_LIMIT = 10 # 每日发送上限 +MAX_ATTEMPTS = 5 # 单验证码最大尝试次数 +DEFAULT_TTL_SECONDS = 300 # 默认有效期 5 分钟 # 验证码类型 CODE_TYPE_EMAIL_BIND = "email_bind" diff --git a/packages/application/auth/wechat_oauth_service.py b/packages/application/auth/wechat_oauth_service.py index bf44c0d35..23e7d8b28 100755 --- a/packages/application/auth/wechat_oauth_service.py +++ b/packages/application/auth/wechat_oauth_service.py @@ -21,6 +21,7 @@ logger = logging.getLogger(__name__) @dataclass class WechatUserInfo: """微信用户信息""" + openid: str unionid: str = "" nickname: str = "" @@ -39,9 +40,7 @@ class WechatOAuthService: ): self.app_id = app_id or os.environ.get("WECHAT_OPEN_APP_ID", "") self.app_secret = app_secret or os.environ.get("WECHAT_OPEN_APP_SECRET", "") - self.redirect_uri = redirect_uri or os.environ.get( - "WECHAT_OPEN_REDIRECT_URI", "" - ) + self.redirect_uri = redirect_uri or os.environ.get("WECHAT_OPEN_REDIRECT_URI", "") self._state_store = state_store # 可选:state 存储(Redis/内存),用于 CSRF 防护 def is_configured(self) -> bool: @@ -59,12 +58,14 @@ class WechatOAuthService: if not self.is_configured(): # 未配置时返回 mock URL,方便前端联调 - mock_params = urllib.parse.urlencode({ - "app_id": "mock", - "redirect_uri": self.redirect_uri, - "scope": scope, - "state": state, - }) + mock_params = urllib.parse.urlencode( + { + "app_id": "mock", + "redirect_uri": self.redirect_uri, + "scope": scope, + "state": state, + } + ) return f"/mock/wechat/auth?{mock_params}", state params = { @@ -94,12 +95,15 @@ class WechatOAuthService: if not self.is_configured(): # 开发模式:返回 mock 用户信息 logger.info("微信未配置,使用 mock 用户信息") - return WechatUserInfo( - openid=f"mock_{code[:20]}", - unionid=f"mock_union_{code[:16]}", - nickname="微信测试用户", - avatar_url="", - ), None + return ( + WechatUserInfo( + openid=f"mock_{code[:20]}", + unionid=f"mock_union_{code[:16]}", + nickname="微信测试用户", + avatar_url="", + ), + None, + ) try: # 1. 用 code 换 access_token @@ -135,12 +139,15 @@ class WechatOAuthService: logger.error("微信获取用户信息失败: %s", user_data) return None, f"获取用户信息失败: {user_data.get('errmsg', '未知错误')}" - return WechatUserInfo( - openid=openid, - unionid=unionid, - nickname=user_data.get("nickname", ""), - avatar_url=user_data.get("headimgurl", ""), - ), None + return ( + WechatUserInfo( + openid=openid, + unionid=unionid, + nickname=user_data.get("nickname", ""), + avatar_url=user_data.get("headimgurl", ""), + ), + None, + ) except requests.RequestException as e: logger.error("微信 OAuth 请求异常: %s", e, exc_info=True) diff --git a/tests/unit/test_wechat_login_and_verification.py b/tests/unit/test_wechat_login_and_verification.py index e0d009532..7a85ad681 100755 --- a/tests/unit/test_wechat_login_and_verification.py +++ b/tests/unit/test_wechat_login_and_verification.py @@ -2,9 +2,10 @@ 验证码服务 + 绑定流程单元测试 """ -import pytest from datetime import datetime, timedelta, timezone +import pytest + from packages.adapters.in_memory.user_repository import InMemoryUserRepository from packages.application.auth.bind_contact_use_case import ( BindContactRequest, @@ -16,9 +17,9 @@ from packages.application.auth.verification_code_service import ( CODE_TYPE_EMAIL_BIND, CODE_TYPE_PHONE_BIND, VerificationCodeService, + normalize_phone, validate_email, validate_phone, - normalize_phone, ) from packages.domain.entities import User from packages.domain.verification_code import VerificationCode @@ -34,10 +35,7 @@ class InMemoryVerificationCodeRepository: self._codes[code.id] = code def find_latest(self, recipient, code_type): - candidates = [ - c for c in self._codes.values() - if c.recipient == recipient and c.code_type == code_type - ] + candidates = [c for c in self._codes.values() if c.recipient == recipient and c.code_type == code_type] if not candidates: return None return max(candidates, key=lambda c: c.created_at) @@ -49,10 +47,9 @@ class InMemoryVerificationCodeRepository: now = datetime.now(timezone.utc) start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) return sum( - 1 for c in self._codes.values() - if c.recipient == recipient - and c.code_type == code_type - and c.created_at >= start_of_day + 1 + for c in self._codes.values() + if c.recipient == recipient and c.code_type == code_type and c.created_at >= start_of_day ) -- 2.54.0 From 54e04302cf1eb0d2a950cb827ef8633805eb95ad Mon Sep 17 00:00:00 2001 From: CI Bot Date: Tue, 21 Jul 2026 08:55:55 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(#558):=20=E6=B8=85=E7=90=86=E6=9C=AA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8import=EF=BC=8C=E4=BF=AE=E5=A4=8Druff?= =?UTF-8?q?=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/application/auth/bind_contact_use_case.py | 8 +------- packages/application/auth/verification_code_service.py | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/application/auth/bind_contact_use_case.py b/packages/application/auth/bind_contact_use_case.py index 7144ed666..ad4924116 100755 --- a/packages/application/auth/bind_contact_use_case.py +++ b/packages/application/auth/bind_contact_use_case.py @@ -5,15 +5,9 @@ from __future__ import annotations import logging -import secrets -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from typing import Optional -from uuid import uuid4 -import jwt as pyjwt - -from packages.adapters.redis import get_session_store -from packages.application.auth.jwt_service import jwt_service from packages.application.auth.verification_code_service import ( CODE_TYPE_EMAIL_BIND, CODE_TYPE_PHONE_BIND, diff --git a/packages/application/auth/verification_code_service.py b/packages/application/auth/verification_code_service.py index 413f2fa51..23bb00af9 100755 --- a/packages/application/auth/verification_code_service.py +++ b/packages/application/auth/verification_code_service.py @@ -9,7 +9,7 @@ from __future__ import annotations import logging import re -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from typing import Optional from packages.domain.verification_code import VerificationCode -- 2.54.0 From d594a7a273b524a70b53a4ebf81a70fa8fc0c8bc Mon Sep 17 00:00:00 2001 From: CI Bot Date: Tue, 21 Jul 2026 09:06:14 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(#558):=20migration=E5=B9=82=E7=AD=89?= =?UTF-8?q?=E5=A4=84=E7=90=86=EF=BC=8Cphone=E5=AD=97=E6=AE=B5=E5=B7=B2?= =?UTF-8?q?=E5=9C=A8029=E4=B8=AD=E5=AD=98=E5=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alembic/versions/049_wechat_login_phone.py | 86 +++++++++++++++------- 1 file changed, 61 insertions(+), 25 deletions(-) diff --git a/alembic/versions/049_wechat_login_phone.py b/alembic/versions/049_wechat_login_phone.py index 628dd6f9f..302caa573 100755 --- a/alembic/versions/049_wechat_login_phone.py +++ b/alembic/versions/049_wechat_login_phone.py @@ -1,17 +1,18 @@ -"""#558 - 微信登录:用户手机号字段 + 验证码表 +"""#558 - 微信登录:手机号绑定字段 + 验证码表 Revision ID: 049 Revises: 048 Create Date: 2026-07-21 Changes: -1. users 表新增 phone / phone_verified / binding_completed_at 字段 -2. 新建 verification_codes 表(统一管理邮箱+手机验证码) +1. users 表新增 phone_verified / binding_completed_at 字段(phone 字段已在 029 中添加) +2. users 表 phone 字段添加唯一索引(幂等) +3. 新建 verification_codes 表(统一管理邮箱+手机验证码) """ import sqlalchemy as sa -from alembic import op +from alembic import context, op revision = "049_wechat_login_phone" down_revision = "048_cleanup_result_count" @@ -19,26 +20,59 @@ branch_labels = None depends_on = None -def upgrade() -> None: - # 1. users 表新增手机号相关字段 - op.add_column("users", sa.Column("phone", sa.String(32), nullable=True)) - op.add_column( - "users", - sa.Column( - "phone_verified", - sa.Boolean, - nullable=False, - server_default=sa.text("false"), +def _column_exists(table: str, column: str) -> bool: + """检查列是否已存在。离线模式下返回 False。""" + if context.is_offline_mode(): + return False + conn = op.get_bind() + result = conn.execute( + sa.text( + "SELECT 1 FROM information_schema.columns " + "WHERE table_name = :table AND column_name = :column" ), + {"table": table, "column": column}, ) - op.add_column( - "users", - sa.Column("binding_completed_at", sa.DateTime, nullable=True), - ) - # phone 唯一索引(已验证的手机号唯一,未验证的不做唯一约束) - op.create_index("ix_users_phone", "users", ["phone"], unique=True) + return result.first() is not None - # 2. verification_codes 表 + +def _index_exists(index_name: str) -> bool: + """检查索引是否已存在。离线模式下返回 False。""" + if context.is_offline_mode(): + return False + conn = op.get_bind() + result = conn.execute( + sa.text( + "SELECT 1 FROM pg_indexes WHERE indexname = :index_name" + ), + {"index_name": index_name}, + ) + return result.first() is not None + + +def upgrade() -> None: + # 1. users 表新增手机号验证状态字段(幂等) + if not _column_exists("users", "phone_verified"): + op.add_column( + "users", + sa.Column( + "phone_verified", + sa.Boolean, + nullable=False, + server_default=sa.text("false"), + ), + ) + + if not _column_exists("users", "binding_completed_at"): + op.add_column( + "users", + sa.Column("binding_completed_at", sa.DateTime, nullable=True), + ) + + # 2. phone 字段唯一索引(幂等 - 029 加了字段但没加索引) + if not _index_exists("ix_users_phone"): + op.create_index("ix_users_phone", "users", ["phone"], unique=True) + + # 3. verification_codes 表 op.create_table( "verification_codes", sa.Column("id", sa.String(36), primary_key=True), @@ -60,7 +94,9 @@ def upgrade() -> None: def downgrade() -> None: op.drop_table("verification_codes") - op.drop_index("ix_users_phone", table_name="users") - op.drop_column("users", "binding_completed_at") - op.drop_column("users", "phone_verified") - op.drop_column("users", "phone") + if _index_exists("ix_users_phone"): + op.drop_index("ix_users_phone", table_name="users") + if _column_exists("users", "binding_completed_at"): + op.drop_column("users", "binding_completed_at") + if _column_exists("users", "phone_verified"): + op.drop_column("users", "phone_verified") -- 2.54.0 From 327bd875a2b13e5b916ae7696661993bbe23fce6 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Tue, 21 Jul 2026 09:09:41 +0800 Subject: [PATCH 5/6] style: auto-format with black + isort + prettier [ci skip] --- alembic/versions/049_wechat_login_phone.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/alembic/versions/049_wechat_login_phone.py b/alembic/versions/049_wechat_login_phone.py index 302caa573..1965b6c77 100755 --- a/alembic/versions/049_wechat_login_phone.py +++ b/alembic/versions/049_wechat_login_phone.py @@ -26,10 +26,7 @@ def _column_exists(table: str, column: str) -> bool: return False conn = op.get_bind() result = conn.execute( - sa.text( - "SELECT 1 FROM information_schema.columns " - "WHERE table_name = :table AND column_name = :column" - ), + sa.text("SELECT 1 FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"), {"table": table, "column": column}, ) return result.first() is not None @@ -41,9 +38,7 @@ def _index_exists(index_name: str) -> bool: return False conn = op.get_bind() result = conn.execute( - sa.text( - "SELECT 1 FROM pg_indexes WHERE indexname = :index_name" - ), + sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"), {"index_name": index_name}, ) return result.first() is not None -- 2.54.0 From f5574c2407ded7298de22ebf8354585d924a8e0a Mon Sep 17 00:00:00 2001 From: CI Bot Date: Tue, 21 Jul 2026 09:14:16 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(#558):=20black=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/api/routes/auth.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/api/app/api/routes/auth.py b/apps/api/app/api/routes/auth.py index a77dd4b66..c6a1a9c6e 100755 --- a/apps/api/app/api/routes/auth.py +++ b/apps/api/app/api/routes/auth.py @@ -262,12 +262,7 @@ async def get_current_user_info( authenticated_user: AuthenticatedUser = Depends(get_current_user), ) -> CurrentUserResponse: user = authenticated_user.user - binding_complete = ( - user.phone_verified - and user.email_verified - and user.email - and "@wechat.local" not in user.email - ) + binding_complete = user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email return CurrentUserResponse( user_id=user.id, email=user.email, @@ -396,6 +391,7 @@ async def wechat_sync( # ==================== 微信网页登录(OAuth) ==================== + class WechatAuthUrlResponse(BaseModel): auth_url: str state: str @@ -461,10 +457,7 @@ async def wechat_callback( binding_complete = False if user: binding_complete = ( - user.phone_verified - and user.email_verified - and user.email - and "@wechat.local" not in user.email + user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email ) return WechatLoginResponse( @@ -481,6 +474,7 @@ async def wechat_callback( # ==================== 验证码 & 绑定 ==================== + class SendVerificationCodeRequest(BaseModel): target: str # phone / email value: str -- 2.54.0