From a5fdcb53d8996bdd6b81d29a52adaddb15a4c4f6 Mon Sep 17 00:00:00 2001 From: guke Date: Mon, 13 Jul 2026 15:14:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20POST=20/wechat/bind-phone/sms(?= =?UTF-8?q?=E7=9F=AD=E4=BF=A1=E7=BB=91=E5=8F=B7:=E5=BB=BA=E5=8F=B7/?= =?UTF-8?q?=E5=8D=A0=E7=94=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- app/api/v1/auth.py | 78 ++++++++++++++++++++++++++++++++++++++ app/repositories/user.py | 31 +++++++++++++++ app/schemas/auth.py | 22 +++++++++++ tests/test_wechat_login.py | 74 ++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+) diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index f60d5bb..4525d46 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -20,6 +20,7 @@ from app.core.ratelimit import enforce_rate_limit from app.core.security import ( TokenError, create_bind_ticket, + decode_bind_ticket, decode_token, issue_token_pair, ) @@ -31,6 +32,7 @@ from app.repositories import user as user_repo from app.schemas.auth import ( JverifyLoginRequest, LogoutResponse, + OccupiedAccountInfo, RefreshRequest, SmsLoginRequest, SmsSendRequest, @@ -38,6 +40,8 @@ from app.schemas.auth import ( TokenPair, TokenWithUser, UserOut, + WechatBindPhoneSmsRequest, + WechatBindResultResponse, WechatLoginRequest, WechatLoginResponse, ) @@ -222,6 +226,80 @@ def wechat_login(req: WechatLoginRequest, db: DbSession) -> WechatLoginResponse: ) +def _finish_wechat_bind( + db, + *, + openid: str, + wechat_nickname: str | None, + wechat_avatar_url: str | None, + phone: str, + device_id: str, +) -> WechatBindResultResponse: + """绑手机建号的公共尾段:手机号被占用 → 返回 phone_occupied(M2 处理 3 选 1); + 未占用 → 新建微信账号(channel=wechat,昵称头像取微信)→ 签 token 登入。""" + existing = user_repo.get_user_by_phone(db, phone) + if existing is not None: + logger.info("wechat bind phone occupied phone=%s by user_id=%d", mask_phone(phone), existing.id) + return WechatBindResultResponse( + status="phone_occupied", + occupied_account=OccupiedAccountInfo( + nickname=existing.nickname, + avatar_url=existing.avatar_url, + created_at=existing.created_at, + ), + ) + user = user_repo.create_wechat_user( + db, + phone=phone, + openid=openid, + wechat_nickname=wechat_nickname, + wechat_avatar_url=wechat_avatar_url, + ) + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=device_id) + logger.info("wechat bind ok user_id=%d phone=%s openid=%s*** onboarded=%s", + user.id, mask_phone(phone), openid[:6], completed) + return WechatBindResultResponse( + status="logged_in", + token=_login_response(user, onboarding_completed=completed), + ) + + +@router.post( + "/wechat/bind-phone/sms", + response_model=WechatBindResultResponse, + summary="微信登录·其他手机号(短信)绑定", +) +def wechat_bind_phone_sms( + req: WechatBindPhoneSmsRequest, request: Request, db: DbSession +) -> WechatBindResultResponse: + try: + claims = decode_bind_ticket(req.bind_ticket) + except TokenError as e: + raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e + + # 防刷:同 sms/login,按 设备+IP 每小时限流(放在验证码校验之前,失败也计数) + enforce_rate_limit( + request, + scope="wechat-bind-sms-device", + subject=req.device_id, + limit=SMS_LOGIN_MAX_PER_HOUR, + window_sec=3600, + detail="登录尝试过于频繁,请稍后再试", + ) + + if not verify_code(req.phone, req.code): + raise HTTPException(status_code=400, detail="invalid sms code") + + return _finish_wechat_bind( + db, + openid=claims["openid"], + wechat_nickname=claims["wnk"], + wechat_avatar_url=claims["wav"], + phone=req.phone, + device_id=req.device_id, + ) + + # ===================== Refresh ===================== @router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对") diff --git a/app/repositories/user.py b/app/repositories/user.py index 20ce84c..e10f529 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -99,6 +99,37 @@ def touch_last_login(db: Session, user: User) -> User: return user +def create_wechat_user( + db: Session, + *, + phone: str, + openid: str, + wechat_nickname: str | None, + wechat_avatar_url: str | None, +) -> User: + """微信登录新建账号:register_channel='wechat';展示昵称/头像取微信(§10 新注册), + 昵称缺失(微信隐私脱敏返回 None)时退默认昵称。同时保存 wechat_* 源字段。 + + openid 唯一约束是并发/重复绑定的最终防线(极罕见,openid 在 wechat-login 刚查过为空)。 + """ + now = datetime.now(timezone.utc) + user = User( + phone=phone, + username=_gen_unique_username(db), + nickname=wechat_nickname or _gen_nickname(), + avatar_url=wechat_avatar_url, + register_channel="wechat", + wechat_openid=openid, + wechat_nickname=wechat_nickname, + wechat_avatar_url=wechat_avatar_url, + last_login_at=now, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + def upsert_user_for_login( db: Session, *, diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 9606860..4162444 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -121,3 +121,25 @@ class WechatLoginResponse(BaseModel): bind_ticket: str | None = None wechat_nickname: str | None = None wechat_avatar_url: str | None = None + + +class OccupiedAccountInfo(BaseModel): + """手机号被占用时返回的原账号脱敏展示信息(供 M2 冲突页;M1 客户端仅用于提示)。""" + nickname: str | None = None + avatar_url: str | None = None + created_at: datetime + + +class WechatBindResultResponse(BaseModel): + # status="logged_in" → 未占用,已建号登入,token 有值; + # "phone_occupied" → 手机号被占用,occupied_account 有值,token 为 None + status: str + token: TokenWithUser | None = None + occupied_account: OccupiedAccountInfo | None = None + + +class WechatBindPhoneSmsRequest(BaseModel): + bind_ticket: str = Field(..., min_length=1) + phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$") + code: str = Field(..., min_length=4, max_length=8) + device_id: str = Field("", max_length=64) diff --git a/tests/test_wechat_login.py b/tests/test_wechat_login.py index 44b4868..0a87d01 100644 --- a/tests/test_wechat_login.py +++ b/tests/test_wechat_login.py @@ -63,3 +63,77 @@ def test_wechat_login_invalid_code_returns_400(client, monkeypatch) -> None: monkeypatch.setattr(wxpay, "code_to_userinfo", _raise) r = client.post("/api/v1/auth/wechat-login", json={"code": "bad", "device_id": "devA"}) assert r.status_code == 400, r.text + + +# ===== Task 3: bind-phone/sms ===== + +def test_wechat_bind_sms_creates_account_then_openid_logs_in(client, monkeypatch) -> None: + """未占用 → 建微信账号(channel=wechat,昵称头像取微信);再次同 openid 登录 → 直接登入同一账号。""" + monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_flow_2", "阿花", "http://x/h.png")) + phone = "13900139002" + + # 1) 微信登录 → 未命中 → 拿 ticket + r = client.post("/api/v1/auth/wechat-login", json={"code": "c1", "device_id": "devB"}) + ticket = r.json()["bind_ticket"] + assert ticket + + # 2) 短信绑号(SMS_MOCK:任意 6 位通过)→ 建号 + 登入 + r = client.post( + "/api/v1/auth/wechat/bind-phone/sms", + json={"bind_ticket": ticket, "phone": phone, "code": "123456", "device_id": "devB"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "logged_in" + user = body["token"]["user"] + assert user["phone"] == phone + assert user["register_channel"] == "wechat" + assert user["nickname"] == "阿花" + assert user["avatar_url"] == "http://x/h.png" + uid = user["id"] + + # 3) 再次微信登录(同 openid)→ 命中 → 直接登入同一账号 + r = client.post("/api/v1/auth/wechat-login", json={"code": "c2", "device_id": "devB"}) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "logged_in" + assert body["token"]["user"]["id"] == uid + + +def test_wechat_bind_sms_phone_occupied(client, monkeypatch) -> None: + """手机号已被其他账号占用 → 返回 phone_occupied + 原账号信息(不建号)。""" + phone = "13900139003" + # 先用普通短信登录占用该手机号(register_channel=sms) + assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 200 + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + occupied_nickname = r.json()["user"]["nickname"] + + # 微信登录(新 openid)→ 未命中 → ticket + monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_occ_3")) + ticket = client.post( + "/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devC"} + ).json()["bind_ticket"] + + # 绑同一手机号 → 占用 + r = client.post( + "/api/v1/auth/wechat/bind-phone/sms", + json={"bind_ticket": ticket, "phone": phone, "code": "123456", "device_id": "devC"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "phone_occupied" + assert body["token"] is None + assert body["occupied_account"]["nickname"] == occupied_nickname + assert body["occupied_account"]["created_at"] + + +def test_wechat_bind_sms_expired_ticket_returns_401(client, monkeypatch) -> None: + """过期 bind_ticket → 401。""" + monkeypatch.setattr(security.settings, "WECHAT_BIND_TICKET_EXPIRE_MINUTES", -1) + expired = security.create_bind_ticket(openid="openid_exp", wechat_nickname="x", wechat_avatar_url=None) + r = client.post( + "/api/v1/auth/wechat/bind-phone/sms", + json={"bind_ticket": expired, "phone": "13900139009", "code": "123456", "device_id": "devD"}, + ) + assert r.status_code == 401, r.text