diff --git a/app/admin/main.py b/app/admin/main.py index ca3a374..46693eb 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -4,6 +4,7 @@ 复用 App 的 DB/models/repositories/integrations;鉴权独立(admin JWT,见 app/admin/security.py)。 现有 app.main:app 不 import 本模块,两进程互不影响。 """ + from __future__ import annotations import logging @@ -12,6 +13,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import PlainTextResponse, RedirectResponse from app.admin.routers.ad_audit import router as ad_audit_router from app.admin.routers.ad_config import router as ad_config_router @@ -25,12 +27,12 @@ from app.admin.routers.coupon_data import router as coupon_data_router from app.admin.routers.cps import router as cps_router from app.admin.routers.dashboard import router as dashboard_router from app.admin.routers.device_liveness import router as device_liveness_router -from app.admin.routers.ops_stat_config import router as ops_stat_config_router from app.admin.routers.event_logs import router as event_logs_router from app.admin.routers.feedback import router as feedback_router from app.admin.routers.feedback_qr import router as feedback_qr_router from app.admin.routers.onboarding import router as onboarding_router from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router +from app.admin.routers.ops_stat_config import router as ops_stat_config_router from app.admin.routers.price_report import router as price_report_router from app.admin.routers.users import router as users_router from app.admin.routers.wallet import router as wallet_router @@ -84,6 +86,15 @@ def health() -> dict[str, str]: return {"status": "ok", "service": "admin"} +@admin_app.get("/", include_in_schema=False) +def root(): + """根路径落脚点:直接访问 :8771 时,非 prod 跳 API 文档,prod 下给一句纯文本说明 + (prod 关了 docs_url,不能跳一个不存在的页面,也不暴露文档)。避免浏览器打开根路径吃 404。""" + if not settings.is_prod: + return RedirectResponse(url="/admin/docs") + return PlainTextResponse("shaguabijia admin API. See /admin/api/*.") + + admin_app.include_router(auth_router) admin_app.include_router(dashboard_router) admin_app.include_router(device_liveness_router) diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index 7e4a81b..2968ea6 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -13,6 +13,7 @@ from app.admin.schemas.common import CursorPage, OkResponse from app.admin.schemas.user import ( AdminUserListItem, AdminUserOverview, + DeleteUserRequest, GrantCashRequest, GrantCoinsRequest, SetDebugTraceRequest, @@ -132,6 +133,40 @@ def set_user_status( return OkResponse() +@router.delete("/{user_id}", response_model=OkResponse, summary="注销/删除账号(清理个人数据 + 匿名化,带审计)") +def delete_user( + user_id: int, + body: DeleteUserRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("super_admin"))], + db: AdminDb, +) -> OkResponse: + """后台删除账号:复用 C 端自助注销同一套清理逻辑(soft_delete_account)——物理删除个人内容/ + 行为/PII 表 + 清零余额 + 匿名化 user 行释放唯一约束,保留资金流水/邀请关系/广告幂等+对账表。 + 仅 super_admin 可操作(最高危、不可逆)。资金安全同 C 端:有未提现现金/邀请金 / 在审提现单 → 409 拒绝。 + """ + user = user_repo.get_user_by_id(db, user_id) + if user is None: + raise HTTPException(status_code=404, detail="用户不存在") + if user.status == "deleted": + raise HTTPException(status_code=400, detail="账号已注销,请勿重复操作") + # 资金前置校验(与 C 端 delete_account 端点一致):余额一旦清零拿不回,有活钱先拦下。 + if wallet_repo.get_cash_balance_cents(db, user_id) > 0: + raise HTTPException(status_code=409, detail="该账户还有未提现的现金余额,请先处理后再删除") + if wallet_repo.get_invite_cash_balance_cents(db, user_id) > 0: + raise HTTPException(status_code=409, detail="该账户还有未提现的邀请奖励金,请先处理后再删除") + if wallet_repo.has_reviewing_withdraw(db, user_id): + raise HTTPException(status_code=409, detail="该账户有提现正在审核中,请等审核完成后再删除") + # 先写审计(commit=False,挂进 session),再由 soft_delete_account 做清理 + 最终 commit —— 一次 commit + # 同时落审计行与清理结果,保持"改了就有痕、有痕就改了"原子(soft_delete_account 内部 commit 会一并 flush)。 + write_audit( + db, admin, action="user.delete", target_type="user", target_id=user_id, + detail={"reason": body.reason, "phone": user.phone}, ip=get_client_ip(request), commit=False, + ) + user_repo.soft_delete_account(db, user) + return OkResponse() + + @router.post("/{user_id}/debug-trace", response_model=OkResponse, summary="开关调试链接权限") def set_user_debug_trace( user_id: int, diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index 2e554a8..81bedb8 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -115,3 +115,9 @@ class SetUserStatusRequest(BaseModel): class SetDebugTraceRequest(BaseModel): enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限") + + +class DeleteUserRequest(BaseModel): + reason: str = Field(..., min_length=1, max_length=128, description="删除原因(必填,入审计)") + + _v_reason = field_validator("reason")(_strip_reason) diff --git a/app/api/v1/user.py b/app/api/v1/user.py index f310b2b..c058958 100644 --- a/app/api/v1/user.py +++ b/app/api/v1/user.py @@ -98,15 +98,19 @@ def onboarding_status( return OnboardingStatusResponse(completed=completed) -@router.delete("", response_model=OkResponse, summary="注销账号(软删除)") +@router.delete("", response_model=OkResponse, summary="注销账号(清理个人数据 + 匿名化)") def delete_account(user: CurrentUser, db: DbSession) -> OkResponse: - # 资金前置校验:注销是软删 + 匿名化,余额一旦留在 deleted 行就锁死(既退不出、新号也 - # 拿不回)。有可提现现金 / 在审提现单 → 拒绝注销,引导用户先把钱处理掉。 + # 资金前置校验:注销会物理清理个人数据 + 清零余额 + 匿名化,余额一旦清零就再也拿不回。 + # 有可提现现金 / 邀请奖励金 / 在审提现单 → 拒绝注销,引导用户先把钱处理掉。 # (pending 提现转账已在途、对账 worker 不依赖 user.status 照常到账,故不拦 pending。) if wallet_repo.get_cash_balance_cents(db, user.id) > 0: raise HTTPException( status_code=409, detail="账户还有未提现的现金余额,请先提现后再注销" ) + if wallet_repo.get_invite_cash_balance_cents(db, user.id) > 0: + raise HTTPException( + status_code=409, detail="账户还有未提现的邀请奖励金,请先提现后再注销" + ) if wallet_repo.has_reviewing_withdraw(db, user.id): raise HTTPException( status_code=409, detail="有提现正在审核中,请等审核完成后再注销" diff --git a/app/repositories/user.py b/app/repositories/user.py index 7397d44..74e7ac7 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -8,10 +8,29 @@ import secrets import string from datetime import datetime, timezone -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.orm import Session +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_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.user import User +from app.models.wallet import CoinAccount # ===== 创建时分配的标识:用户名(对外展示账号 ID)+ 默认昵称 ===== @@ -114,19 +133,63 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User: def soft_delete_account(db: Session, user: User) -> None: - """注销账号:软删除 + 匿名化 + 释放唯一约束/外部绑定。 + """注销账号:物理删除个人数据 + 清零余额 + 匿名化保留财务/关系 + 释放唯一约束。 - 把 phone 改成 `deleted_` 释放手机号唯一约束,允许同号码重新注册成全新账号; - 清空 PII(昵称/头像),保留行用于审计。status=deleted 后将无法再登录该行 - (登录接口校验 status==active)。 + 删除策略(按"删了是否破坏对账 / 影响别人 / 是否 PII"分类): + 物理删除(个人内容/成绩/行为/PII,只涉本人、删了不碰账目也不影响别人):比价/ + 省钱/签到/领券/任务/里程碑领取/埋点/引导标记/设备活跃/价格上报/反馈/看广告时长日志 + (ad_watch_log:前端上报时长、不可信、非发奖依据,纯行为埋点),以及 + 邀请指纹(含 IP/机型/UA 的 PII,按 inviter_user_id 存 = 本人作为邀请人留的)。 + 清零保留:coin_account 的金币/现金/邀请金/累计收益归零(现金/邀请金已由端点 + 前置校验=0),行保留 —— 避免碰资金流水外键,也留作审计。 + 保留 + 随本行匿名化与身份解绑(动了会破坏对账/在途到账/发奖幂等/误伤邀请人): + 三本资金流水(coin/cash/invite_cash_transaction)、提现单(pending 在途照常到账)、 + 微信转账授权(worker 对账 pending 要用)、广告发奖幂等记录(ad_reward 有 trans_id、 + ad_feed_reward_record 有 client_event_id 唯一键,删了幂等键会致回调/重放重复发奖)、 + 广告 eCPM 收益记录(ad_ecpm_record 是内部收益统计/对账口径,同资金流水按对账保留)、 + 邀请关系(inviter/invitee 双向、不含 PII,删了会破坏邀请人侧归属)。这些表的 user_id + 指向已匿名化的本行,身份已解绑。 - ⚠️ 注销必须连同释放 user 上其余唯一约束/外部身份,否则 deleted 行永久占着槽位, - 对应资源再也无法被复用: + ⚠️ 匿名化必须连同释放 user 其余唯一约束/外部身份,否则 deleted 行永久占着槽位: - wechat_openid: 不清 → 这个微信再也无法被任何账号绑定(提现通道彻底锁死); - invite_code: 不清 → 邀请码槽位被占 + 裸查仍命中死号(发奖已被 bind 的 status==active 校验兜住,清掉更干净)。 - 资金安全(未提现现金 / 在审提现单)由调用方 delete_account 端点前置校验,这里只匿名化。 + 资金安全(未提现现金/邀请金 / 在审提现单)由调用方 delete_account 端点前置校验。 """ + uid = user.id + + # 1) 物理删除个人内容/行为/成绩/PII 数据(不碰资金流水/账户/邀请关系/广告幂等) + personal_deletions = [ + delete(ComparisonRecord).where(ComparisonRecord.user_id == uid), + delete(SavingsRecord).where(SavingsRecord.user_id == uid), + delete(SigninRecord).where(SigninRecord.user_id == uid), + delete(SigninBoostRecord).where(SigninBoostRecord.user_id == uid), + delete(CouponClaimRecord).where(CouponClaimRecord.user_id == uid), + delete(CouponDailyCompletion).where(CouponDailyCompletion.user_id == uid), + delete(CouponPromptEngagement).where(CouponPromptEngagement.user_id == uid), + delete(CouponSession).where(CouponSession.user_id == uid), + delete(UserTask).where(UserTask.user_id == uid), + delete(ComparisonMilestoneClaim).where(ComparisonMilestoneClaim.user_id == uid), + delete(DeviceLiveness).where(DeviceLiveness.user_id == uid), + delete(PriceReport).where(PriceReport.user_id == uid), + delete(Feedback).where(Feedback.user_id == uid), + delete(OnboardingCompletion).where(OnboardingCompletion.user_id == uid), + delete(AnalyticsEvent).where(AnalyticsEvent.user_id == uid), + delete(AdWatchLog).where(AdWatchLog.user_id == uid), + delete(InviteFingerprint).where(InviteFingerprint.inviter_user_id == uid), + ] + for stmt in personal_deletions: + db.execute(stmt) + + # 2) 清零余额账户(现金/邀请金已前置=0;金币/累计收益一并归零,行保留不碰流水外键) + acc = db.get(CoinAccount, uid) + if acc is not None: + acc.coin_balance = 0 + acc.cash_balance_cents = 0 + acc.invite_cash_balance_cents = 0 + acc.total_coin_earned = 0 + + # 3) 匿名化本行 + 释放唯一约束/外部身份(保留行:审计锚 + 资金流水/邀请关系外键不断) user.status = "deleted" user.phone = f"deleted_{user.id}" user.nickname = None diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 345cda7..12beb7e 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -383,6 +383,16 @@ def get_cash_balance_cents(db: Session, user_id: int) -> int: return val or 0 +def get_invite_cash_balance_cents(db: Session, user_id: int) -> int: + """只读查邀请奖励金余额(分)。账户不存在返回 0,**不创建账户**(同 get_cash_balance_cents)。 + 注销前置校验用:邀请奖励金与金币兑换现金物理隔离,注销会把账户余额一并清零,若不单独 + 校验,用户没提现的返佣金会被直接清零吞掉。""" + val = db.execute( + select(CoinAccount.invite_cash_balance_cents).where(CoinAccount.user_id == user_id) + ).scalar_one_or_none() + return val or 0 + + def has_reviewing_withdraw(db: Session, user_id: int) -> bool: """用户是否有「待审核(reviewing)」的提现单。 diff --git a/tests/test_admin_write.py b/tests/test_admin_write.py index ec6d39d..e239b15 100644 --- a/tests/test_admin_write.py +++ b/tests/test_admin_write.py @@ -476,3 +476,100 @@ def test_withdraw_reconcile(admin_client: TestClient, finance_token: str, monkey ) assert r.status_code == 200, r.text assert "checked" in r.json() and "resolved" in r.json() + + +# ===== 删除账号(super_admin,复用 soft_delete_account + 审计) ===== + +def test_admin_delete_user_purges_and_audits( + admin_client: TestClient, super_token: str +) -> None: + """super_admin 删除:user 行匿名化 + 个人数据被清 + 落审计(action=user.delete,含 reason)。""" + uid = _seed_user("13900000031") + db = SessionLocal() + try: + db.add(Feedback(user_id=uid, content="待清理", contact="wx", status="pending")) + db.commit() + finally: + db.close() + + r = admin_client.request( + "DELETE", f"/admin/api/users/{uid}", + json={"reason": "违规账号"}, headers=_auth(super_token), + ) + assert r.status_code == 200, r.text + db = SessionLocal() + try: + u = db.get(User, uid) + assert u is not None and u.status == "deleted" + assert u.phone == f"deleted_{uid}" + # 个人表被清(复用 C 端 soft_delete_account 同一套级联删除) + fbs = db.execute( + select(Feedback).where(Feedback.user_id == uid) + ).scalars().all() + assert fbs == [] + # 审计:业务 + 审计同一 commit 一起落(改了就有痕) + logs = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "user.delete", AdminAuditLog.target_id == str(uid) + ) + ).scalars().all() + assert len(logs) == 1 and logs[0].detail["reason"] == "违规账号" + finally: + db.close() + + +def test_admin_delete_user_forbidden_for_operator( + admin_client: TestClient, operator_token: str +) -> None: + """删除是最高危操作:operator/finance 都不可,仅 super_admin。且拒绝后账号不被删。""" + uid = _seed_user("13900000032") + r = admin_client.request( + "DELETE", f"/admin/api/users/{uid}", + json={"reason": "x"}, headers=_auth(operator_token), + ) + assert r.status_code == 403 + db = SessionLocal() + try: + assert db.get(User, uid).status == "active" # 未被删 + finally: + db.close() + + +def test_admin_delete_user_blocked_when_cash_positive( + admin_client: TestClient, super_token: str, finance_token: str +) -> None: + """有未提现现金 → 409 拒绝(同 C 端资金前置闸),账号不被删。""" + uid = _seed_user("13900000033") + # 用 finance 给该用户灌现金 + admin_client.post( + f"/admin/api/users/{uid}/cash", json={"amount_cents": 100, "reason": "底"}, + headers=_auth(finance_token), + ) + r = admin_client.request( + "DELETE", f"/admin/api/users/{uid}", + json={"reason": "x"}, headers=_auth(super_token), + ) + assert r.status_code == 409, r.text + assert "现金" in r.json()["detail"] + db = SessionLocal() + try: + assert db.get(User, uid).status == "active" + finally: + db.close() + + +def test_admin_delete_user_rejects_already_deleted( + admin_client: TestClient, super_token: str +) -> None: + uid = _seed_user("13900000034") + r = admin_client.request( + "DELETE", f"/admin/api/users/{uid}", + json={"reason": "首删"}, headers=_auth(super_token), + ) + assert r.status_code == 200, r.text + # 再删已注销账号 → 400 + r = admin_client.request( + "DELETE", f"/admin/api/users/{uid}", + json={"reason": "重复"}, headers=_auth(super_token), + ) + assert r.status_code == 400 diff --git a/tests/test_delete_account.py b/tests/test_delete_account.py index 464b02c..a476f1e 100644 --- a/tests/test_delete_account.py +++ b/tests/test_delete_account.py @@ -1,14 +1,18 @@ -"""注销账号(DELETE /api/v1/user):软删 + 释放唯一约束 + 资金前置校验。 +"""注销账号(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 sqlalchemy import select +from datetime import date, datetime, timezone + +from sqlalchemy import func, select from app.db.session import SessionLocal from app.models.user import User @@ -139,3 +143,130 @@ def test_deleted_user_old_token_is_rejected(client) -> None: # 注销后旧 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