diff --git a/app/repositories/user.py b/app/repositories/user.py index 2ff768d..2157cfc 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -59,6 +59,17 @@ def is_default_nickname(nickname: str | None) -> bool: ) +def apply_wechat_display_identity( + user: User, *, wechat_nickname: str | None, wechat_avatar_url: str | None +) -> None: + """§10:用已有账号绑微信时,仅当展示字段仍为默认才用微信昵称/头像替换(两规则独立); + 自定义(改过昵称/传过头像)则保留。只改内存对象,由调用方 commit。""" + if is_default_nickname(user.nickname) and wechat_nickname: + user.nickname = wechat_nickname + if user.avatar_url is None and wechat_avatar_url: + user.avatar_url = wechat_avatar_url + + def get_user_by_username(db: Session, username: str) -> User | None: return db.execute( select(User).where(User.username == username) @@ -105,14 +116,16 @@ def attach_wechat_to_user( ) -> User: """继续绑定:把微信 openid + 微信源字段并入已存在账号(调用方保证 user.wechat_openid 为空)。 - **只写 wechat_openid / wechat_nickname / wechat_avatar_url,不动展示 nickname/avatar_url** - (完整 §10"默认则用微信、改过则保留"规则留 M3)。撞 openid 唯一约束(O 期间被别处绑走, - 极罕见)时由调用方捕获 IntegrityError 兜底降级为"只登入不绑"。 + 写 wechat_openid / wechat_nickname / wechat_avatar_url,并按 §10 规则回填展示字段: + 仅当昵称仍为默认值(is_default_nickname)时用微信昵称替换,仅当头像为 null 时用微信头像替换; + 用户已自定义的展示昵称/头像始终保留,两规则相互独立。 + 撞 openid 唯一约束(O 期间被别处绑走,极罕见)时由调用方捕获 IntegrityError 兜底降级为"只登入不绑"。 """ user.wechat_openid = openid user.wechat_nickname = wechat_nickname user.wechat_avatar_url = wechat_avatar_url user.last_login_at = datetime.now(timezone.utc) + apply_wechat_display_identity(user, wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url) db.commit() db.refresh(user) return user diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index fe1e23e..d4b652c 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -20,6 +20,7 @@ from app.core.config import settings from app.core.rewards import COIN_PER_CENT, coins_to_cents from app.integrations import wxpay from app.models.user import User +from app.repositories.user import apply_wechat_display_identity from app.models.wallet import ( CashTransaction, CoinAccount, @@ -374,6 +375,7 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict: user.wechat_openid = info["openid"] user.wechat_nickname = info["nickname"] user.wechat_avatar_url = info["avatar_url"] + apply_wechat_display_identity(user, wechat_nickname=info["nickname"], wechat_avatar_url=info["avatar_url"]) db.commit() return info diff --git a/tests/test_wechat_conflict.py b/tests/test_wechat_conflict.py index 7ca6d01..5ba4b84 100644 --- a/tests/test_wechat_conflict.py +++ b/tests/test_wechat_conflict.py @@ -178,6 +178,27 @@ def test_rebind_creates_new_account_and_binds_openid(client, monkeypatch) -> Non assert r.json()["token"]["user"]["id"] == y["id"] +# ===== §10: continue 路径也应用展示身份回填规则 ===== + +def test_continue_applies_section10(client, monkeypatch) -> None: + """§10 via M2 attach 路径: X 是默认昵称+null头像的 sms 账号; + continue 绑定微信后,展示昵称/头像应被微信值替换,并体现在响应的 token.user 中。""" + phone = "13900139211" + _sms_occupy(client, phone) # 建 X:默认昵称, null avatar + body = _occupy_via_conflict( + client, monkeypatch, "openid_s10", phone, "devS10" + ) # fake_userinfo 默认 nickname="微信昵称", avatar="http://x/a.png" + + r = client.post( + "/api/v1/auth/wechat/conflict/continue", + json={"conflict_ticket": body["conflict_ticket"], "device_id": "devS10"}, + ) + assert r.status_code == 200, r.text + user_out = r.json()["token"]["user"] + assert user_out["nickname"] == "微信昵称" + assert user_out["avatar_url"] == "http://x/a.png" + + def test_rebind_blocked_within_30_days(client, monkeypatch) -> None: """同一手机号 30 天内二次换绑 → 409;占用响应 rebind_available=False。""" phone = "13900139302" diff --git a/tests/test_withdraw.py b/tests/test_withdraw.py index 55e6a2f..55a978a 100644 --- a/tests/test_withdraw.py +++ b/tests/test_withdraw.py @@ -312,6 +312,94 @@ def test_bind_rejects_openid_already_bound(client, monkeypatch) -> None: assert r.status_code == 409, r.text +# ===== §10 绑微信时展示身份回填规则 ===== + +def test_bind_replaces_default_nickname_and_null_avatar(client, monkeypatch) -> None: + """§10-A: 全默认(昵称=系统默认,头像=null) → 绑微信后两个展示字段都替换为微信值。""" + monkeypatch.setattr( + "app.integrations.wxpay.code_to_userinfo", + lambda code: {"openid": "openid_s10_a", "nickname": "微信昵称A", "avatar_url": "https://x/a.png", "raw": {}}, + ) + token = _login(client, "13800003001") + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + assert r.status_code == 200, r.text + + r = client.get("/api/v1/auth/me", headers=_auth(token)) + assert r.status_code == 200, r.text + body = r.json() + assert body["nickname"] == "微信昵称A" + assert body["avatar_url"] == "https://x/a.png" + + +def test_bind_keeps_customized_nickname(client, monkeypatch) -> None: + """§10-B: 用户已改过昵称 → 绑微信后昵称保留,但 null 头像仍替换为微信头像。""" + monkeypatch.setattr( + "app.integrations.wxpay.code_to_userinfo", + lambda code: {"openid": "openid_s10_b", "nickname": "微信昵称B", "avatar_url": "https://x/b.png", "raw": {}}, + ) + token = _login(client, "13800003002") + # 先把昵称改成非默认值 + r = client.patch( + "/api/v1/user/profile", json={"nickname": "我的名字"}, headers=_auth(token) + ) + assert r.status_code == 200, r.text + + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + assert r.status_code == 200, r.text + + r = client.get("/api/v1/auth/me", headers=_auth(token)) + body = r.json() + assert body["nickname"] == "我的名字" # 保留自定义昵称 + assert body["avatar_url"] == "https://x/b.png" # null 头像被微信头像替换 + + +def test_bind_keeps_customized_avatar(client, monkeypatch) -> None: + """§10-C: 已有自定义头像 → 绑微信后头像保留,但默认昵称替换为微信昵称。""" + monkeypatch.setattr( + "app.integrations.wxpay.code_to_userinfo", + lambda code: {"openid": "openid_s10_c", "nickname": "微信昵称C", "avatar_url": "https://x/c.png", "raw": {}}, + ) + token = _login(client, "13800003003") + phone = "13800003003" + # 直接用 DB 给用户写入自定义头像(保持默认昵称) + db = SessionLocal() + try: + user = db.execute(select(User).where(User.phone == phone)).scalar_one() + user.avatar_url = "https://custom/av.png" + db.commit() + finally: + db.close() + + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + assert r.status_code == 200, r.text + + r = client.get("/api/v1/auth/me", headers=_auth(token)) + body = r.json() + assert body["nickname"] == "微信昵称C" # 默认昵称被替换 + assert body["avatar_url"] == "https://custom/av.png" # 自定义头像保留 + + +def test_bind_wechat_empty_keeps_default(client, monkeypatch) -> None: + """§10-D: 微信侧 nickname/avatar 均为 None → 不覆盖,展示字段维持原默认值。""" + monkeypatch.setattr( + "app.integrations.wxpay.code_to_userinfo", + lambda code: {"openid": "openid_s10_d", "nickname": None, "avatar_url": None, "raw": {}}, + ) + token = _login(client, "13800003004") + + r = client.get("/api/v1/auth/me", headers=_auth(token)) + original_nickname = r.json()["nickname"] # 系统分配的默认昵称 + assert original_nickname.startswith("用户") + + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + assert r.status_code == 200, r.text + + r = client.get("/api/v1/auth/me", headers=_auth(token)) + body = r.json() + assert body["nickname"] == original_nickname # 默认昵称不变 + assert body["avatar_url"] is None # 头像仍为 null + + def test_withdraw_reject_refunds(client, monkeypatch) -> None: """管理员审核拒绝 → 退回现金 + 单 rejected + 理由写入 fail_reason(用户可见)。""" monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_reject", "nickname": None, "avatar_url": None, "raw": {}})