"""注销账号(DELETE /api/v1/user):软删 + 释放唯一约束 + 资金前置校验。 覆盖: - 干净注销:status=deleted + phone 匿名化 + wechat_openid/invite_code 等唯一槽全部释放 - 回归原始 bug:注销释放 openid 后,别的账号能绑同一个微信 - 资金前置闸:有可提现现金 / 在审提现单 → 409 拒绝注销,账号不被删 - 注销后旧 token 即时失效(status==active 闸) """ from __future__ import annotations from sqlalchemy import select from app.db.session import SessionLocal from app.models.user import User from app.models.wallet import CoinAccount def _login(client, phone: str) -> str: client.post("/api/v1/auth/sms/send", json={"phone": phone}) r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) assert r.status_code == 200, r.text return r.json()["access_token"] def _auth(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} def _patch_userinfo(monkeypatch, openid: str) -> None: monkeypatch.setattr( "app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": openid, "nickname": "微信昵称", "avatar_url": "https://x/a.png", "raw": {}}, ) def _seed_cash(client, token: str, phone: str, cents: int) -> None: """先访问 /account 触发建账户,再用 DB 直接灌现金余额。""" client.get("/api/v1/wallet/account", headers=_auth(token)) db = SessionLocal() try: user = db.execute(select(User).where(User.phone == phone)).scalar_one() acc = db.get(CoinAccount, user.id) acc.cash_balance_cents = cents db.commit() finally: db.close() def _get_user(phone: str) -> User | None: db = SessionLocal() try: return db.execute(select(User).where(User.phone == phone)).scalar_one_or_none() finally: db.close() def test_delete_clean_account_releases_all_unique_slots(client) -> None: phone = "13900000001" token = _login(client, phone) # 预置:给该用户造昵称 + 邀请码(占 invite_code 唯一槽),验证注销时一并释放 db = SessionLocal() try: u = db.execute(select(User).where(User.phone == phone)).scalar_one() u.nickname = "张三" u.invite_code = "INVCODE0001" db.commit() uid = u.id finally: db.close() r = client.delete("/api/v1/user", headers=_auth(token)) assert r.status_code == 200, r.text u = _get_user(f"deleted_{uid}") assert u is not None assert u.status == "deleted" assert u.phone == f"deleted_{uid}" assert u.nickname is None assert u.avatar_url is None assert u.wechat_openid is None assert u.wechat_nickname is None assert u.wechat_avatar_url is None assert u.invite_code is None def test_delete_releases_wechat_so_another_account_can_bind(client, monkeypatch) -> None: """原始 bug 回归:A 绑微信 → 注销,B 必须能绑同一个 openid(修复前会 409)。""" openid = "openid_delreuse_a1" # 独特值,避免跨测试共享库污染(如 test_withdraw 也用 openid_shared_xyz) # A 绑微信 _patch_userinfo(monkeypatch, openid) token_a = _login(client, "13900000002") r = client.post("/api/v1/wallet/bind-wechat", json={"code": "ca"}, headers=_auth(token_a)) assert r.status_code == 200, r.text # A 注销(无现金、无提现单 → 允许) r = client.delete("/api/v1/user", headers=_auth(token_a)) assert r.status_code == 200, r.text # B 绑同一个 openid → 必须成功(修复前会 409 "该微信已绑定其他账号") token_b = _login(client, "13900000003") r = client.post("/api/v1/wallet/bind-wechat", json={"code": "cb"}, headers=_auth(token_b)) assert r.status_code == 200, r.text assert r.json()["bound"] is True def test_delete_blocked_when_cash_balance_positive(client) -> None: phone = "13900000004" token = _login(client, phone) _seed_cash(client, token, phone, 100) # 1 元未提现 r = client.delete("/api/v1/user", headers=_auth(token)) assert r.status_code == 409, r.text assert "现金" in r.json()["detail"] u = _get_user(phone) # 账号未被删 assert u is not None and u.status == "active" def test_delete_blocked_when_reviewing_withdraw_exists(client, monkeypatch) -> None: """cash 提光(=0)但有在审提现单 → 仍拒绝(命中 reviewing 闸,非 cash 闸)。""" phone = "13900000005" _patch_userinfo(monkeypatch, "openid_delrev_b2") token = _login(client, phone) _seed_cash(client, token, phone, 50) client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) # 提光 50 → cash 归 0,单进 reviewing r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token)) assert r.status_code == 200 and r.json()["status"] == "reviewing", r.text assert r.json()["cash_balance_cents"] == 0 r = client.delete("/api/v1/user", headers=_auth(token)) assert r.status_code == 409, r.text assert "审核" in r.json()["detail"] u = _get_user(phone) assert u is not None and u.status == "active" def test_deleted_user_old_token_is_rejected(client) -> None: phone = "13900000006" token = _login(client, phone) r = client.delete("/api/v1/user", headers=_auth(token)) assert r.status_code == 200, r.text # 注销后旧 token 立即失效(get_current_user 校验 status==active) r = client.get("/api/v1/user/onboarding/status", params={"device_id": "d"}, headers=_auth(token)) assert r.status_code == 401, r.text