35f24084ae
C 端与后台复用同一套 soft_delete_account:物理删除个人内容/行为/PII 表(比价/省钱/
签到/领券/任务/设备/价格上报/反馈/引导/埋点/看广告时长/邀请指纹)+ 清零余额 + 匿名化
user 行释放唯一约束;保留资金流水/邀请关系/广告幂等+对账表(对账/幂等/邀请人侧归属)。
- api/v1/user.py: DELETE /api/v1/user 加资金前置闸(未提现现金/邀请金 / 在审提现单 → 409)
- repositories/user.py: soft_delete_account 落地分类删除(补 ad_watch_log 埋点删除)
- repositories/wallet.py: 新增 get_invite_cash_balance_cents(邀请金 409 校验用)
- admin/routers/users.py: DELETE /admin/api/users/{id} 复用清理 + 审计,仅 super_admin
- admin/schemas/user.py: DeleteUserRequest(必填 reason 入审计)
- tests: 级联删除/余额归零/保留表存活 6 测 + admin 删除/审计/403/400/409 4 测
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
273 lines
12 KiB
Python
273 lines
12 KiB
Python
"""注销账号(DELETE /api/v1/user):数据级联清理 + 软删 + 释放唯一约束 + 资金前置校验。
|
|
|
|
覆盖:
|
|
- 干净注销:status=deleted + phone 匿名化 + wechat_openid/invite_code 等唯一槽全部释放
|
|
- 回归原始 bug:注销释放 openid 后,别的账号能绑同一个微信
|
|
- 资金前置闸:有可提现现金 / 在审提现单 → 409 拒绝注销,账号不被删
|
|
- 注销后旧 token 即时失效(status==active 闸)
|
|
- 级联清理:个人内容/成绩/行为/PII 表被物理删空、余额清零;财务流水/邀请关系/广告幂等
|
|
+对账表按策略保留(soft_delete_account 的分类落地断言)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timezone
|
|
|
|
from sqlalchemy import func, 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
|
|
|
|
|
|
# ===== 级联清理:个人表删空 / 余额清零 / 保留表存活 =====
|
|
|
|
def _count(model, col, uid: int) -> int:
|
|
db = SessionLocal()
|
|
try:
|
|
return db.execute(
|
|
select(func.count()).select_from(model).where(col == uid)
|
|
).scalar_one()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_delete_purges_personal_data_zeroes_balance_keeps_ledgers(client) -> None:
|
|
"""注销后:① 个人内容/行为/PII 表按 user_id 被物理删空;② 金币等余额清零;
|
|
③ 资金流水 / 邀请关系 / 广告幂等+对账表保留(soft_delete_account 分类策略的端到端断言)。"""
|
|
from app.models.ad_ecpm import AdEcpmRecord
|
|
from app.models.ad_feed_reward import AdFeedRewardRecord
|
|
from app.models.ad_watch_log import AdWatchLog
|
|
from app.models.analytics_event import AnalyticsEvent
|
|
from app.models.comparison import ComparisonRecord
|
|
from app.models.comparison_milestone import ComparisonMilestoneClaim
|
|
from app.models.coupon_state import (
|
|
CouponClaimRecord,
|
|
CouponDailyCompletion,
|
|
CouponPromptEngagement,
|
|
CouponSession,
|
|
)
|
|
from app.models.device import DeviceLiveness
|
|
from app.models.feedback import Feedback
|
|
from app.models.invite import InviteRelation
|
|
from app.models.invite_fingerprint import InviteFingerprint
|
|
from app.models.onboarding import OnboardingCompletion
|
|
from app.models.price_report import PriceReport
|
|
from app.models.savings import SavingsRecord
|
|
from app.models.signin import SigninBoostRecord, SigninRecord
|
|
from app.models.task import UserTask
|
|
from app.models.wallet import CoinTransaction
|
|
|
|
phone = "13900000007"
|
|
token = _login(client, phone)
|
|
client.get("/api/v1/wallet/account", headers=_auth(token)) # 建 coin_account
|
|
uid = _get_user(phone).id
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
# 余额:给金币 + 累计收益(现金/邀请金保持 0,否则命中 409 前置闸)
|
|
acc = db.get(CoinAccount, uid)
|
|
acc.coin_balance = 500
|
|
acc.total_coin_earned = 500
|
|
|
|
# 每张"应删"表各灌 1 行(user_id 维度;指纹按 inviter_user_id)
|
|
db.add_all([
|
|
ComparisonRecord(user_id=uid, trace_id="del_test_comparison"),
|
|
SavingsRecord(user_id=uid, order_amount_cents=1, saved_amount_cents=1),
|
|
SigninRecord(user_id=uid, signin_date=date(2026, 7, 4), cycle_day=1, streak=1, coin_awarded=1),
|
|
SigninBoostRecord(user_id=uid, signin_date=date(2026, 7, 4), coin_awarded=1),
|
|
CouponClaimRecord(user_id=uid, device_id="del_cc", coupon_id="c1", claim_date=date(2026, 7, 4), status="success"),
|
|
CouponDailyCompletion(user_id=uid, device_id="del_cd", complete_date=date(2026, 7, 4)),
|
|
CouponPromptEngagement(user_id=uid, device_id="del_ce", engage_date=date(2026, 7, 4), engage_type="shown"),
|
|
CouponSession(user_id=uid, trace_id="del_test_couponsession", device_id="d1", status="started",
|
|
started_at=datetime(2026, 7, 4, tzinfo=timezone.utc), started_date=date(2026, 7, 4)),
|
|
UserTask(user_id=uid, task_key="del_test_usertask"),
|
|
ComparisonMilestoneClaim(user_id=uid, milestone=1),
|
|
DeviceLiveness(user_id=uid, device_id="del_test_dl"),
|
|
PriceReport(user_id=uid, reported_platform_id="p1", reported_platform_name="x", reported_price_cents=1),
|
|
Feedback(user_id=uid, content="x", contact="x"),
|
|
OnboardingCompletion(user_id=uid, device_id="del_test_ob"),
|
|
AnalyticsEvent(user_id=uid, event="x", device_id="del_test_an", client_ts=1),
|
|
AdWatchLog(user_id=uid, watch_date="2026-07-04"),
|
|
InviteFingerprint(inviter_user_id=uid, ip="1.2.3.4"),
|
|
])
|
|
# 每张"应保留"表各灌 1 行
|
|
db.add_all([
|
|
CoinTransaction(user_id=uid, amount=1, balance_after=1, biz_type="signin"),
|
|
InviteRelation(inviter_user_id=uid, invitee_user_id=999999),
|
|
AdEcpmRecord(user_id=uid, ad_type="reward_video", ecpm_raw="100", report_date="2026-07-04"),
|
|
AdFeedRewardRecord(user_id=uid, client_event_id="del_test_afr", ecpm_raw="100", reward_date="2026-07-04"),
|
|
])
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
# 灌完先确认确实各有 1 行(否则"删空"断言可能因根本没灌进去而假过)
|
|
deleted_specs = [
|
|
(ComparisonRecord, ComparisonRecord.user_id),
|
|
(SavingsRecord, SavingsRecord.user_id),
|
|
(SigninRecord, SigninRecord.user_id),
|
|
(SigninBoostRecord, SigninBoostRecord.user_id),
|
|
(CouponClaimRecord, CouponClaimRecord.user_id),
|
|
(CouponDailyCompletion, CouponDailyCompletion.user_id),
|
|
(CouponPromptEngagement, CouponPromptEngagement.user_id),
|
|
(CouponSession, CouponSession.user_id),
|
|
(UserTask, UserTask.user_id),
|
|
(ComparisonMilestoneClaim, ComparisonMilestoneClaim.user_id),
|
|
(DeviceLiveness, DeviceLiveness.user_id),
|
|
(PriceReport, PriceReport.user_id),
|
|
(Feedback, Feedback.user_id),
|
|
(OnboardingCompletion, OnboardingCompletion.user_id),
|
|
(AnalyticsEvent, AnalyticsEvent.user_id),
|
|
(AdWatchLog, AdWatchLog.user_id),
|
|
(InviteFingerprint, InviteFingerprint.inviter_user_id),
|
|
]
|
|
for model, col in deleted_specs:
|
|
assert _count(model, col, uid) == 1, f"seed missing for {model.__name__}"
|
|
|
|
r = client.delete("/api/v1/user", headers=_auth(token))
|
|
assert r.status_code == 200, r.text
|
|
|
|
# ① 应删表全部删空
|
|
for model, col in deleted_specs:
|
|
assert _count(model, col, uid) == 0, f"{model.__name__} 未被删空(残留 PII/行为)"
|
|
|
|
# ② 余额清零(行保留,不碰流水外键)
|
|
acc = SessionLocal().get(CoinAccount, uid)
|
|
assert acc is not None
|
|
assert acc.coin_balance == 0
|
|
assert acc.total_coin_earned == 0
|
|
assert acc.cash_balance_cents == 0
|
|
assert acc.invite_cash_balance_cents == 0
|
|
|
|
# ③ 保留表存活(资金流水 / 邀请关系 / 广告幂等+对账)
|
|
assert _count(CoinTransaction, CoinTransaction.user_id, uid) == 1
|
|
assert _count(InviteRelation, InviteRelation.inviter_user_id, uid) == 1
|
|
assert _count(AdEcpmRecord, AdEcpmRecord.user_id, uid) == 1
|
|
assert _count(AdFeedRewardRecord, AdFeedRewardRecord.user_id, uid) == 1
|