diff --git a/backend/app/api/mini_auth.py b/backend/app/api/mini_auth.py index ce20c421..0e31e9e7 100644 --- a/backend/app/api/mini_auth.py +++ b/backend/app/api/mini_auth.py @@ -11,7 +11,10 @@ from app.core.database import get_db from app.core.response import error_response, success_response from app.crud.user import user_crud from app.services.issue_user_tokens import issue_tokens_for_user -from app.services.wechat_login_service import resolve_wechat_login_user +from app.services.wechat_login_service import ( + resolve_wechat_login_by_openid_only, + resolve_wechat_login_user, +) from app.services.wechat_mini_auth import ( WechatMiniAuthError, code2session, @@ -36,7 +39,7 @@ async def _resolve_user_id(request: Request, db: Session) -> str: @router.post("/wechat-login", response_model=dict) async def wechat_login(body: dict, db: Session = Depends(get_db)): - """微信授权登录:须授权手机号;openid 已绑定也须与账号 phone_no 一致。""" + """微信登录:openid 已绑定时可静默换票;否则须授权手机号且与账号 phone_no 一致。""" login_code = (body.get("loginCode") or "").strip() phone_code = (body.get("phoneCode") or "").strip() or None @@ -46,18 +49,30 @@ async def wechat_login(body: dict, db: Session = Depends(get_db)): return error_response(400, str(exc)) if not phone_code: - return error_response(400, "请授权手机号登录,或使用账号密码登录") + user, err_msg, err_code = resolve_wechat_login_by_openid_only( + db, openid=sess["openid"] + ) + if err_msg: + return error_response(err_code or 400, err_msg) + else: + try: + phone = get_phone_number(phone_code) + except WechatMiniAuthError as exc: + return error_response(400, str(exc)) - try: - phone = get_phone_number(phone_code) - except WechatMiniAuthError as exc: - return error_response(400, str(exc)) + user, err_msg, err_code = resolve_wechat_login_user( + db, openid=sess["openid"], phone=phone + ) + if err_msg: + return error_response(err_code or 400, err_msg) - user, err_msg, err_code = resolve_wechat_login_user( - db, openid=sess["openid"], phone=phone - ) - if err_msg: - return error_response(err_code or 400, err_msg) + unionid = (sess.get("unionid") or "").strip() + if unionid and not (getattr(user, "wx_unionid", None) or "").strip(): + user.wx_unionid = unionid + user.update_time = datetime.now() + db.add(user) + db.commit() + db.refresh(user) try: data = await issue_tokens_for_user(db, user) diff --git a/backend/app/services/wechat_login_service.py b/backend/app/services/wechat_login_service.py index 5c7498a0..5cc6a468 100644 --- a/backend/app/services/wechat_login_service.py +++ b/backend/app/services/wechat_login_service.py @@ -11,6 +11,21 @@ from app.models.models import SysUser from app.services.wechat_mini_auth import normalize_cn_mobile, phones_match +def resolve_wechat_login_by_openid_only( + db: Session, *, openid: str +) -> Tuple[Optional[SysUser], Optional[str], Optional[int]]: + """已绑定 openid 时静默换票(无需手机号授权)。""" + oid = (openid or "").strip() + if not oid: + return None, "微信未返回 openid", 400 + bound = user_crud.get_by_wx_openid(db, oid) + if not bound: + return None, "请授权手机号登录,或使用账号密码登录", 400 + if getattr(bound, "status", "1") != "1": + return None, "账号已停用", 403 + return bound, None, None + + def resolve_wechat_login_user( db: Session, *, openid: str, phone: str ) -> Tuple[Optional[SysUser], Optional[str], Optional[int]]: diff --git a/backend/tests/test_wechat_login_silent.py b/backend/tests/test_wechat_login_silent.py new file mode 100644 index 00000000..9996019f --- /dev/null +++ b/backend/tests/test_wechat_login_silent.py @@ -0,0 +1,83 @@ +"""微信登录:openid 已绑定可无 phoneCode 静默换票。""" +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.services.wechat_login_service import resolve_wechat_login_by_openid_only + + +def test_resolve_openid_bound_ok_without_phone(): + bound = SimpleNamespace( + id="u1", + wx_openid="oid-1", + status="1", + ) + db = MagicMock() + with patch( + "app.services.wechat_login_service.user_crud.get_by_wx_openid", + return_value=bound, + ): + user, err, code = resolve_wechat_login_by_openid_only(db, openid="oid-1") + assert err is None and code is None + assert user is bound + + +def test_resolve_openid_not_bound_asks_phone(): + db = MagicMock() + with patch( + "app.services.wechat_login_service.user_crud.get_by_wx_openid", + return_value=None, + ): + user, err, code = resolve_wechat_login_by_openid_only(db, openid="oid-x") + assert user is None + assert code == 400 + assert "请授权手机号" in err + + +def test_resolve_openid_bound_but_disabled(): + bound = SimpleNamespace( + id="u2", + wx_openid="oid-2", + status="0", + ) + db = MagicMock() + with patch( + "app.services.wechat_login_service.user_crud.get_by_wx_openid", + return_value=bound, + ): + user, err, code = resolve_wechat_login_by_openid_only(db, openid="oid-2") + assert user is None + assert code == 403 + assert "停用" in err + + +@pytest.mark.asyncio +async def test_wechat_login_api_silent_when_openid_bound(): + bound = SimpleNamespace( + id="u1", + wx_openid="oid-1", + status="1", + wx_unionid=None, + ) + with ( + patch( + "app.api.mini_auth.code2session", + return_value={"openid": "oid-1", "unionid": None}, + ), + patch( + "app.api.mini_auth.resolve_wechat_login_by_openid_only", + return_value=(bound, None, None), + ) as resolve_silent, + patch( + "app.api.mini_auth.issue_tokens_for_user", + new_callable=AsyncMock, + return_value={"accessToken": "t"}, + ), + ): + from app.api.mini_auth import wechat_login + + resp = await wechat_login({"loginCode": "lc"}, db=MagicMock()) + resolve_silent.assert_called_once() + assert resp["code"] == 200 + assert resp["data"]["accessToken"] == "t"