feat(#1719): 微信账号绑定/解绑三接口 + /me 返回 wechat_bound #1725

Merged
xiaoxia merged 1 commits from feature/wechat-bind-unbind-1719 into develop 2026-09-05 21:50:55 +08:00
4 changed files with 717 additions and 0 deletions
+118
View File
@@ -84,6 +84,7 @@ class CurrentUserResponse(BaseModel):
phone: str = ""
phone_verified: bool = False
binding_complete: bool = False
wechat_bound: bool = False
class PasswordResetRequestModel(BaseModel):
@@ -272,6 +273,7 @@ async def get_current_user_info(
phone=user.phone or "",
phone_verified=user.phone_verified,
binding_complete=binding_complete,
wechat_bound=bool(user.wechat_openid),
)
@@ -492,6 +494,122 @@ async def wechat_callback(
)
# ==================== 微信账号绑定/解绑(已登录用户) ====================
class WechatBindUrlResponse(BaseModel):
auth_url: str
state: str
class WechatBindCompleteRequest(BaseModel):
code: str
state: str = ""
class WechatBindUserProfile(BaseModel):
"""绑定/解绑后返回的用户信息(字段对齐 /auth/me,前端 normalizeUser 直接消费)"""
user_id: str
email: str
username: str
display_name: str
email_verified: bool
phone: str = ""
phone_verified: bool = False
binding_complete: bool = False
wechat_bound: bool = False
class WechatBindCompleteResponse(BaseModel):
success: bool
user: WechatBindUserProfile
class WechatUnbindResponse(BaseModel):
success: bool
def _wechat_user_profile(user) -> WechatBindUserProfile:
binding_complete = bool(
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
)
return WechatBindUserProfile(
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,
wechat_bound=bool(user.wechat_openid),
)
@router.get("/wechat/bind/url", response_model=WechatBindUrlResponse)
async def get_wechat_bind_url(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> WechatBindUrlResponse:
"""获取微信绑定授权链接(已登录用户场景)。state 经 Redis 存储做 CSRF 校验。"""
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()
logger.info("[微信绑定] 用户 %s 请求绑定授权链接", current_user.user.id)
return WechatBindUrlResponse(auth_url=auth_url, state=state)
@router.post("/wechat/bind", response_model=WechatBindCompleteResponse)
async def wechat_bind(
request: WechatBindCompleteRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> WechatBindCompleteResponse:
"""微信绑定完成:扫码回调后用 code 换 openid,绑定到当前登录账号(不创建新用户)。"""
from packages.application.auth.wechat_bind_use_case import WechatBindRequest, WechatBindUseCase
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
oauth_service = get_wechat_oauth_service()
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
if err:
logger.warning("[微信绑定] 用户 %s 换取微信信息失败: %s", current_user.user.id, err)
raise HTTPException(status_code=400, detail=err)
use_case = WechatBindUseCase(user_repository=user_repository)
result, error, http_status = use_case.bind(
WechatBindRequest(
user_id=current_user.user.id,
openid=wechat_user.openid,
unionid=wechat_user.unionid or "",
)
)
if error:
logger.warning("[微信绑定] 用户 %s 绑定失败: %s", current_user.user.id, error)
raise HTTPException(status_code=http_status, detail=error)
logger.info("[微信绑定] 用户 %s 绑定成功 openid=%s", current_user.user.id, wechat_user.openid[:8])
return WechatBindCompleteResponse(success=True, user=_wechat_user_profile(result.user))
@router.delete("/wechat/bind", response_model=WechatUnbindResponse)
async def wechat_unbind(
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> WechatUnbindResponse:
"""解绑微信:需账号仍有其他登录方式(密码/手机/真实邮箱),否则拒绝。"""
from packages.application.auth.wechat_bind_use_case import WechatUnbindUseCase
use_case = WechatUnbindUseCase(user_repository=user_repository)
result, error, http_status = use_case.unbind(current_user.user.id)
if error:
logger.warning("[微信解绑] 用户 %s 解绑失败: %s", current_user.user.id, error)
raise HTTPException(status_code=http_status, detail=error)
logger.info("[微信解绑] 用户 %s 解绑成功", current_user.user.id)
return WechatUnbindResponse(success=True)
# ==================== 验证码 & 绑定 ====================
@@ -0,0 +1,115 @@
"""
微信账号绑定/解绑 Use Case(已登录用户场景)
与 wechat_sync_use_case(登录/注册,系统级)不同:
- bind:把微信 openid/unionid 绑定到【当前登录账号】,不创建新用户;
微信身份若已绑定其他账号则冲突(409)。
- unbind:解除当前账号的微信绑定;若账号没有其他登录方式(手机/邮箱/密码),
解绑后将无法登录,因此拒绝解绑。
"""
from __future__ import annotations
from typing import Optional
from packages.domain.entities import User
class WechatBindRequest:
"""微信绑定请求"""
def __init__(self, user_id: str, openid: str, unionid: str = ""):
self.user_id = user_id
self.openid = (openid or "").strip()
self.unionid = (unionid or "").strip()
class WechatBindResult:
"""微信绑定/解绑结果"""
def __init__(self, user: User):
self.user = user
class WechatBindUseCase:
"""已登录用户绑定微信用例"""
def __init__(self, user_repository):
self.user_repository = user_repository
def bind(self, request: WechatBindRequest) -> tuple[Optional[WechatBindResult], Optional[str], int]:
"""
绑定微信到当前登录账号。
Returns:
(结果, 错误信息, http状态码) - 成功时错误信息为 None、状态码为 200;
冲突返回 409,客户端/服务端错误返回 400/404。
"""
if not request.openid:
return None, "缺少微信 openid", 400
user = self.user_repository.find_by_id(request.user_id)
if user is None:
return None, "当前用户不存在", 404
# 已绑定同一个微信:幂等成功
if user.wechat_openid == request.openid:
return WechatBindResult(user=user), None, 200
# 当前账号已绑定其他微信
if user.wechat_openid:
return None, "当前账号已绑定微信,请先解绑", 409
# openid 已被其他账号占用
existing = self.user_repository.find_by_wechat_openid(request.openid)
if existing is not None and existing.id != user.id:
return None, "该微信已绑定其他账号,请先在原账号解绑", 409
# unionid 冲突:同主体微信已绑其他账号
if request.unionid:
existing_union = self.user_repository.find_by_wechat_unionid(request.unionid)
if existing_union is not None and existing_union.id != user.id:
return None, "该微信主体已绑定其他账号,请先在原账号解绑", 409
user.wechat_openid = request.openid
if request.unionid and not user.wechat_unionid:
user.wechat_unionid = request.unionid
self.user_repository.save(user)
return WechatBindResult(user=user), None, 200
class WechatUnbindUseCase:
"""已登录用户解绑微信用例"""
def __init__(self, user_repository):
self.user_repository = user_repository
def unbind(self, user_id: str) -> tuple[Optional[WechatBindResult], Optional[str], int]:
"""
解除当前账号的微信绑定。
解绑前置条件:账号必须还有其他登录方式(密码 / 已验证手机 / 真实邮箱),
否则解绑后将永远无法登录。
"""
user = self.user_repository.find_by_id(user_id)
if user is None:
return None, "当前用户不存在", 404
if not user.wechat_openid:
return None, "当前账号未绑定微信", 400
# 守卫:解绑后账号必须仍有可实际使用的登录方式。
# 注意:微信注册用户带的是【随机密码】(用户不知道、无法用密码登录,
# 且 @wechat.local 占位邮箱收不到重置邮件),故 password_hash 不作为兜底依据,
# 口径与 /auth/me 的 binding_complete 一致。
has_phone = bool(user.phone and user.phone_verified)
has_real_email = bool(user.email and user.email_verified and "@wechat.local" not in user.email)
if not (has_phone or has_real_email):
return None, "账号需要至少一种其他登录方式(已验证手机或真实邮箱)后才能解绑微信", 400
user.wechat_openid = None
user.wechat_unionid = None
self.user_repository.save(user)
return WechatBindResult(user=user), None, 200
+233
View File
@@ -0,0 +1,233 @@
"""#1719:微信绑定/解绑路由层测试(直接驱动路由函数)。
覆盖:
- GET /wechat/bind/url:调 oauth 生成链接、记日志
- POST /wechat/bindoauth 失败→400;绑定成功→success+user.wechat_bound=True
use case 返回冲突→对应状态码透传
- DELETE /wechat/bind:成功→success=Trueuse case 报错→状态码透传
- /auth/me 返回 wechat_bound 字段
"""
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from app.api.routes import auth as auth_route # noqa: E402
from fastapi import HTTPException # noqa: E402
def _auth_user(user_id="u-1", openid=None):
user = SimpleNamespace(
id=user_id,
wechat_openid=openid,
email="user@example.com",
email_verified=True,
username="user",
display_name="用户",
phone="",
phone_verified=False,
)
return SimpleNamespace(user=user, session_id="s-1", token_type="user_auth")
def _patched_bind(result, error, status):
"""构造打了补丁的 wechat_bind_use_case 模块"""
mod = SimpleNamespace(
WechatBindRequest=lambda **kw: SimpleNamespace(**kw),
WechatBindUseCase=MagicMock(),
WechatUnbindUseCase=MagicMock(),
)
fake_bind_uc = MagicMock()
fake_bind_uc.bind.return_value = (result, error, status)
mod.WechatBindUseCase.return_value = fake_bind_uc
return mod
def test_get_bind_url_returns_url_and_state():
fake_oauth = MagicMock()
fake_oauth.generate_auth_url.return_value = ("https://open.weixin.qq.com/qrconnect?xxx", "state-bind-1")
import packages.application.auth.wechat_oauth_service as oauth_mod
orig = oauth_mod.get_wechat_oauth_service
oauth_mod.get_wechat_oauth_service = MagicMock(return_value=fake_oauth)
try:
resp = asyncio.run(auth_route.get_wechat_bind_url(current_user=_auth_user()))
finally:
oauth_mod.get_wechat_oauth_service = orig
assert resp.auth_url.startswith("https://open.weixin.qq.com")
assert resp.state == "state-bind-1"
def test_bind_oauth_error_returns_400():
fake_oauth = MagicMock()
fake_oauth.handle_callback.return_value = (None, "无效的 state 参数")
import packages.application.auth.wechat_oauth_service as oauth_mod
orig = oauth_mod.get_wechat_oauth_service
oauth_mod.get_wechat_oauth_service = MagicMock(return_value=fake_oauth)
try:
with pytest.raises(HTTPException) as exc:
asyncio.run(
auth_route.wechat_bind(
SimpleNamespace(code="c-1", state="s-1"),
current_user=_auth_user(),
user_repository=MagicMock(),
)
)
finally:
oauth_mod.get_wechat_oauth_service = orig
assert exc.value.status_code == 400
assert "state" in exc.value.detail
def test_bind_success_returns_user_with_wechat_bound():
fake_oauth = MagicMock()
fake_oauth.handle_callback.return_value = (
SimpleNamespace(openid="wx-openid-1", unionid="wx-union-1"),
None,
)
bound_user = SimpleNamespace(
id="u-1",
wechat_openid="wx-openid-1",
email="user@example.com",
email_verified=True,
username="user",
display_name="用户",
phone="",
phone_verified=False,
)
import packages.application.auth.wechat_oauth_service as oauth_mod
from packages.application.auth import wechat_bind_use_case as bind_mod
orig_oauth = oauth_mod.get_wechat_oauth_service
fake_bind_uc = MagicMock()
fake_bind_uc.bind.return_value = (SimpleNamespace(user=bound_user), None, 200)
orig_bind = bind_mod.WechatBindUseCase
bind_mod.WechatBindUseCase = MagicMock(return_value=fake_bind_uc)
oauth_mod.get_wechat_oauth_service = MagicMock(return_value=fake_oauth)
try:
resp = asyncio.run(
auth_route.wechat_bind(
SimpleNamespace(code="c-1", state="s-1"),
current_user=_auth_user(),
user_repository=MagicMock(),
)
)
finally:
oauth_mod.get_wechat_oauth_service = orig_oauth
bind_mod.WechatBindUseCase = orig_bind
assert resp.success is True
assert resp.user.wechat_bound is True
assert resp.user.user_id == "u-1"
# 绑定请求应带上当前用户 id 与微信 openid
call_kwargs = fake_bind_uc.bind.call_args[0][0]
assert call_kwargs.user_id == "u-1"
assert call_kwargs.openid == "wx-openid-1"
def test_bind_conflict_propagates_409():
fake_oauth = MagicMock()
fake_oauth.handle_callback.return_value = (
SimpleNamespace(openid="wx-openid-1", unionid=""),
None,
)
import packages.application.auth.wechat_oauth_service as oauth_mod
from packages.application.auth import wechat_bind_use_case as bind_mod
orig_oauth = oauth_mod.get_wechat_oauth_service
fake_bind_uc = MagicMock()
fake_bind_uc.bind.return_value = (None, "该微信已绑定其他账号,请先在原账号解绑", 409)
orig_bind = bind_mod.WechatBindUseCase
bind_mod.WechatBindUseCase = MagicMock(return_value=fake_bind_uc)
oauth_mod.get_wechat_oauth_service = MagicMock(return_value=fake_oauth)
try:
with pytest.raises(HTTPException) as exc:
asyncio.run(
auth_route.wechat_bind(
SimpleNamespace(code="c-1", state="s-1"),
current_user=_auth_user(),
user_repository=MagicMock(),
)
)
finally:
oauth_mod.get_wechat_oauth_service = orig_oauth
bind_mod.WechatBindUseCase = orig_bind
assert exc.value.status_code == 409
assert "已绑定其他账号" in exc.value.detail
def test_unbind_success_returns_success_true():
unbound_user = SimpleNamespace(
id="u-1",
wechat_openid=None,
email="user@example.com",
email_verified=True,
username="user",
display_name="用户",
phone="",
phone_verified=False,
)
from packages.application.auth import wechat_bind_use_case as bind_mod
fake_uc = MagicMock()
fake_uc.unbind.return_value = (SimpleNamespace(user=unbound_user), None, 200)
orig = bind_mod.WechatUnbindUseCase
bind_mod.WechatUnbindUseCase = MagicMock(return_value=fake_uc)
try:
resp = asyncio.run(
auth_route.wechat_unbind(current_user=_auth_user(openid="wx-old"), user_repository=MagicMock())
)
finally:
bind_mod.WechatUnbindUseCase = orig
assert resp.success is True
fake_uc.unbind.assert_called_once_with("u-1")
def test_unbind_rejected_no_other_login_propagates_400():
from packages.application.auth import wechat_bind_use_case as bind_mod
fake_uc = MagicMock()
fake_uc.unbind.return_value = (None, "账号需要至少一种其他登录方式(已验证手机或真实邮箱)后才能解绑微信", 400)
orig = bind_mod.WechatUnbindUseCase
bind_mod.WechatUnbindUseCase = MagicMock(return_value=fake_uc)
try:
with pytest.raises(HTTPException) as exc:
asyncio.run(auth_route.wechat_unbind(current_user=_auth_user(openid="wx-old"), user_repository=MagicMock()))
finally:
bind_mod.WechatUnbindUseCase = orig
assert exc.value.status_code == 400
assert "登录方式" in exc.value.detail
def test_me_includes_wechat_bound_flag():
# 已绑定用户
resp = asyncio.run(auth_route.get_current_user_info(authenticated_user=_auth_user(openid="wx-openid-1")))
assert resp.wechat_bound is True
# 未绑定用户
resp2 = asyncio.run(auth_route.get_current_user_info(authenticated_user=_auth_user(openid=None)))
assert resp2.wechat_bound is False
@@ -0,0 +1,251 @@
"""#1719:已登录用户微信绑定/解绑 Use Case 测试。
覆盖:
- bind:幂等重复绑定、未绑定成功、当前账号已绑其他微信、openid/unionid 冲突 409、用户不存在
- unbind:成功清 openid+unionid、未绑定拒绝、无其他登录方式拒绝、密码/手机/真实邮箱各兜底放行、用户不存在
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from packages.application.auth.wechat_bind_use_case import (
WechatBindRequest,
WechatBindUseCase,
WechatUnbindUseCase,
)
def _user(
user_id="u-1",
wechat_openid=None,
wechat_unionid=None,
password_hash="hashed-pw",
phone=None,
phone_verified=False,
email="user@example.com",
email_verified=True,
):
return SimpleNamespace(
id=user_id,
wechat_openid=wechat_openid,
wechat_unionid=wechat_unionid,
password_hash=password_hash,
phone=phone,
phone_verified=phone_verified,
email=email,
email_verified=email_verified,
)
class _FakeRepo:
"""内存仓储:按 id/openid/unionid 建索引,save 原地更新。"""
def __init__(self, users):
self.users = {u.id: u for u in users}
self.saved = []
def find_by_id(self, user_id):
return self.users.get(user_id)
def find_by_wechat_openid(self, openid):
for u in self.users.values():
if u.wechat_openid == openid:
return u
return None
def find_by_wechat_unionid(self, unionid):
if not unionid:
return None
for u in self.users.values():
if u.wechat_unionid == unionid:
return u
return None
def save(self, user):
self.saved.append(user)
# ==================== bind ====================
def test_bind_success_when_not_bound():
user = _user()
repo = _FakeRepo([user])
result, err, status = WechatBindUseCase(repo).bind(
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-1")
)
assert err is None
assert status == 200
assert result.user.wechat_openid == "wx-openid-1"
assert result.user.wechat_unionid == "wx-union-1"
assert repo.saved == [user]
def test_bind_idempotent_same_openid():
user = _user(wechat_openid="wx-openid-1", wechat_unionid="wx-union-1")
repo = _FakeRepo([user])
result, err, status = WechatBindUseCase(repo).bind(
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-1")
)
assert err is None
assert status == 200
assert result.user is user
assert repo.saved == [] # 幂等不写库
def test_bind_conflict_user_already_bound_other_wechat():
user = _user(wechat_openid="wx-old")
repo = _FakeRepo([user])
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="u-1", openid="wx-new"))
assert result is None
assert status == 409
assert "已绑定微信" in err
def test_bind_conflict_openid_used_by_other_user():
user = _user(user_id="u-1")
other = _user(user_id="u-2", wechat_openid="wx-openid-1")
repo = _FakeRepo([user, other])
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="u-1", openid="wx-openid-1"))
assert result is None
assert status == 409
assert "已绑定其他账号" in err
assert user.wechat_openid is None # 未写库
def test_bind_conflict_unionid_used_by_other_user():
user = _user(user_id="u-1")
# openid 不同,但 unionid 指向同一微信主体
other = _user(user_id="u-2", wechat_openid="wx-other", wechat_unionid="wx-union-x")
repo = _FakeRepo([user, other])
result, err, status = WechatBindUseCase(repo).bind(
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-x")
)
assert result is None
assert status == 409
assert "微信主体" in err
def test_bind_missing_openid_returns_400():
repo = _FakeRepo([_user()])
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="u-1", openid=""))
assert result is None
assert status == 400
assert "openid" in err
def test_bind_user_not_found_returns_404():
repo = _FakeRepo([])
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="ghost", openid="wx-openid-1"))
assert result is None
assert status == 404
def test_bind_fills_unionid_when_existing_user_has_none():
# 用户历史上只绑了 openid(unionid 为空),再次绑定时补齐 unionid 不冲突
user = _user(wechat_openid="wx-openid-1", wechat_unionid=None)
repo = _FakeRepo([user])
result, err, status = WechatBindUseCase(repo).bind(
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-new")
)
# openid 相同 → 幂等成功(不覆盖 unionid,保持数据稳定)
assert err is None
assert status == 200
# ==================== unbind ====================
def test_unbind_success_with_real_verified_email():
# 默认 _user 即 real@example.com 且 email_verified=True
user = _user(wechat_openid="wx-openid-1", wechat_unionid="wx-union-1")
repo = _FakeRepo([user])
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
assert err is None
assert status == 200
assert result.user.wechat_openid is None
assert result.user.wechat_unionid is None
assert repo.saved == [user]
def test_unbind_rejected_when_only_random_password_hash():
# 微信注册用户:随机密码 hash 存在、邮箱是 @wechat.local 占位、无手机 → 不允许解绑
user = _user(
wechat_openid="wx-openid-1",
password_hash="random-secret-hash",
email="abc@wechat.local",
email_verified=True,
)
repo = _FakeRepo([user])
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
assert result is None
assert status == 400
assert "登录方式" in err
assert user.wechat_openid == "wx-openid-1" # 未写库
def test_unbind_allowed_with_verified_phone_even_without_password():
user = _user(
wechat_openid="wx-openid-1",
password_hash="",
phone="13800000000",
phone_verified=True,
email="wx@wechat.local",
email_verified=True,
)
repo = _FakeRepo([user])
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
assert err is None
assert status == 200
assert result.user.wechat_openid is None
def test_unbind_rejected_when_no_other_login_method():
# 无手机、邮箱占位 → 唯一登录方式就是微信,禁止解绑
user = _user(
wechat_openid="wx-openid-1",
password_hash="",
email="abc@wechat.local",
email_verified=True,
)
repo = _FakeRepo([user])
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
assert result is None
assert status == 400
assert "登录方式" in err
assert user.wechat_openid == "wx-openid-1" # 未写库
def test_unbind_not_bound_returns_400():
user = _user() # 未绑定
repo = _FakeRepo([user])
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
assert result is None
assert status == 400
assert "未绑定" in err
def test_unbind_user_not_found_returns_404():
repo = _FakeRepo([])
result, err, status = WechatUnbindUseCase(repo).unbind("ghost")
assert result is None
assert status == 404
def test_unbind_unverified_phone_does_not_count():
# 手机未验证不算有效登录方式
user = _user(
wechat_openid="wx-openid-1",
password_hash="",
phone="13800000000",
phone_verified=False,
email="abc@wechat.local",
email_verified=True,
)
repo = _FakeRepo([user])
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
assert result is None
assert status == 400