diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 6c77a22..9aadae1 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -20,7 +20,9 @@ from app.core.ratelimit import enforce_rate_limit from app.core.security import ( TokenError, create_bind_ticket, + create_conflict_ticket, decode_bind_ticket, + decode_conflict_ticket, decode_token, issue_token_pair, ) @@ -28,6 +30,7 @@ from app.integrations import wxpay from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone from app.integrations.sms import SmsError, send_code, verify_code from app.repositories import onboarding as onboarding_repo +from app.repositories import phone_rebind as rebind_repo from app.repositories import user as user_repo from app.schemas.auth import ( JverifyLoginRequest, @@ -240,13 +243,32 @@ def _finish_wechat_bind( 未占用 → 新建微信账号(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) + from app.core.config import settings # 局部 import,避免循环 + + ticket = create_conflict_ticket( + openid=openid, + wechat_nickname=wechat_nickname, + wechat_avatar_url=wechat_avatar_url, + phone=phone, + ) + blocked = rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS) + logger.info( + "wechat bind phone occupied phone=%s by user_id=%d has_wechat=%s", + mask_phone(phone), existing.id, bool(existing.wechat_openid), + ) return WechatBindResultResponse( status="phone_occupied", occupied_account=OccupiedAccountInfo( nickname=existing.nickname, avatar_url=existing.avatar_url, created_at=existing.created_at, + has_wechat=bool(existing.wechat_openid), + ), + conflict_ticket=ticket, + rebind_available=not blocked, + rebind_blocked_days=( + rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS) + if blocked else 0 ), ) user = user_repo.create_wechat_user( diff --git a/app/repositories/phone_rebind.py b/app/repositories/phone_rebind.py new file mode 100644 index 0000000..34275d3 --- /dev/null +++ b/app/repositories/phone_rebind.py @@ -0,0 +1,39 @@ +"""手机号换绑台账(phone_rebind_log)的查询与写入。见 M2 spec §4.1。""" +from __future__ import annotations + +import math +from datetime import datetime, timedelta, timezone + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.phone_rebind_log import PhoneRebindLog + + +def rebound_within_days(db: Session, phone: str, days: int) -> bool: + """该手机号在最近 days 天内是否换绑过(命中 → 禁止再次换绑)。""" + since = datetime.now(timezone.utc) - timedelta(days=days) + stmt = ( + select(PhoneRebindLog.id) + .where(PhoneRebindLog.phone == phone, PhoneRebindLog.rebound_at >= since) + .limit(1) + ) + return db.execute(stmt).first() is not None + + +def remaining_block_days(db: Session, phone: str, days: int) -> int: + """距离该手机号可再次换绑还剩几天(向上取整;无记录返回 0)。""" + last = db.execute( + select(func.max(PhoneRebindLog.rebound_at)).where(PhoneRebindLog.phone == phone) + ).scalar_one_or_none() + if last is None: + return 0 + if last.tzinfo is None: # SQLite 取回 naive datetime,按 UTC 归一 + last = last.replace(tzinfo=timezone.utc) + remaining = (last + timedelta(days=days) - datetime.now(timezone.utc)).total_seconds() + return max(0, math.ceil(remaining / 86400)) + + +def add_rebind_log(db: Session, *, phone: str, old_user_id: int | None, new_user_id: int, source: str) -> None: + """写一条换绑台账(**不 commit**,交给调用方 rebind_account 的单事务)。""" + db.add(PhoneRebindLog(phone=phone, old_user_id=old_user_id, new_user_id=new_user_id, source=source)) diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 1353479..03a03b1 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -124,18 +124,22 @@ class WechatLoginResponse(BaseModel): class OccupiedAccountInfo(BaseModel): - """手机号被占用时返回的原账号脱敏展示信息(供 M2 冲突页;M1 客户端仅用于提示)。""" + """手机号被占用时返回的原账号脱敏展示信息(供冲突页)。""" nickname: str | None = None avatar_url: str | None = None created_at: datetime + has_wechat: bool = False class WechatBindResultResponse(BaseModel): # status="logged_in" → 未占用,已建号登入,token 有值; - # "phone_occupied" → 手机号被占用,occupied_account 有值,token 为 None + # "phone_occupied" → 手机号被占用,occupied_account + conflict_ticket 有值,token 为 None status: str token: TokenWithUser | None = None occupied_account: OccupiedAccountInfo | None = None + conflict_ticket: str | None = None # 占用时签发,换绑/继续绑定只认它 + rebind_available: bool | None = None # 该手机号 30 天内是否还能换绑(给换绑按钮预置禁用态) + rebind_blocked_days: int | None = None # 被限时剩余天数(rebind_available=False 时>0) class WechatBindPhoneSmsRequest(BaseModel): @@ -149,3 +153,13 @@ class WechatBindPhoneJverifyRequest(BaseModel): bind_ticket: str = Field(..., min_length=1) login_token: str = Field(..., min_length=1, description="客户端 loginAuth 拿到的 loginToken") device_id: str = Field("", max_length=64) + + +class WechatConflictContinueRequest(BaseModel): + conflict_ticket: str = Field(..., min_length=1) + device_id: str = Field("", max_length=64) + + +class WechatConflictRebindRequest(BaseModel): + conflict_ticket: str = Field(..., min_length=1) + device_id: str = Field("", max_length=64) diff --git a/tests/test_wechat_conflict.py b/tests/test_wechat_conflict.py index a1fa77d..81c41fe 100644 --- a/tests/test_wechat_conflict.py +++ b/tests/test_wechat_conflict.py @@ -76,3 +76,15 @@ def test_conflict_ticket_expired_rejected(monkeypatch) -> None: ) with pytest.raises(security.TokenError): security.decode_conflict_ticket(token) + + +# ===== Task 3: 占用响应扩展 ===== + +def test_phone_occupied_returns_conflict_ticket_and_flags(client, monkeypatch) -> None: + phone = "13900139101" + _sms_occupy(client, phone) # 老账号 X(sms,无微信) + body = _occupy_via_conflict(client, monkeypatch, "openid_occ_101", phone, "devO1") + assert body["conflict_ticket"] + assert body["rebind_available"] is True # 首次,未换绑过 + assert body["rebind_blocked_days"] == 0 + assert body["occupied_account"]["has_wechat"] is False # X 是 sms 账号