Compare commits

...

1 Commits

Author SHA1 Message Date
no_gen_mu 35f24084ae feat(user): 注销账号数据级联清理 + 后台删除端点
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>
2026-07-04 15:00:40 +08:00
8 changed files with 371 additions and 14 deletions
+12 -1
View File
@@ -4,6 +4,7 @@
复用 App 的 DB/models/repositories/integrations;鉴权独立(admin JWT,见 app/admin/security.py)。 复用 App 的 DB/models/repositories/integrations;鉴权独立(admin JWT,见 app/admin/security.py)。
现有 app.main:app 不 import 本模块,两进程互不影响。 现有 app.main:app 不 import 本模块,两进程互不影响。
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -12,6 +13,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware 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_audit import router as ad_audit_router
from app.admin.routers.ad_config import router as ad_config_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.cps import router as cps_router
from app.admin.routers.dashboard import router as dashboard_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.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.event_logs import router as event_logs_router
from app.admin.routers.feedback import router as feedback_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.feedback_qr import router as feedback_qr_router
from app.admin.routers.onboarding import router as onboarding_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_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.price_report import router as price_report_router
from app.admin.routers.users import router as users_router from app.admin.routers.users import router as users_router
from app.admin.routers.wallet import router as wallet_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"} 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(auth_router)
admin_app.include_router(dashboard_router) admin_app.include_router(dashboard_router)
admin_app.include_router(device_liveness_router) admin_app.include_router(device_liveness_router)
+35
View File
@@ -13,6 +13,7 @@ from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.user import ( from app.admin.schemas.user import (
AdminUserListItem, AdminUserListItem,
AdminUserOverview, AdminUserOverview,
DeleteUserRequest,
GrantCashRequest, GrantCashRequest,
GrantCoinsRequest, GrantCoinsRequest,
SetDebugTraceRequest, SetDebugTraceRequest,
@@ -132,6 +133,40 @@ def set_user_status(
return OkResponse() 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="开关调试链接权限") @router.post("/{user_id}/debug-trace", response_model=OkResponse, summary="开关调试链接权限")
def set_user_debug_trace( def set_user_debug_trace(
user_id: int, user_id: int,
+6
View File
@@ -115,3 +115,9 @@ class SetUserStatusRequest(BaseModel):
class SetDebugTraceRequest(BaseModel): class SetDebugTraceRequest(BaseModel):
enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限") enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限")
class DeleteUserRequest(BaseModel):
reason: str = Field(..., min_length=1, max_length=128, description="删除原因(必填,入审计)")
_v_reason = field_validator("reason")(_strip_reason)
+7 -3
View File
@@ -98,15 +98,19 @@ def onboarding_status(
return OnboardingStatusResponse(completed=completed) return OnboardingStatusResponse(completed=completed)
@router.delete("", response_model=OkResponse, summary="注销账号(软删除)") @router.delete("", response_model=OkResponse, summary="注销账号(清理个人数据 + 匿名化)")
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse: def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
# 资金前置校验:注销是软删 + 匿名化,余额一旦留在 deleted 行就锁死(既退不出、新号也 # 资金前置校验:注销会物理清理个人数据 + 清零余额 + 匿名化,余额一旦清零就再也拿不回。
# 拿不回)。有可提现现金 / 在审提现单 → 拒绝注销,引导用户先把钱处理掉。 # 有可提现现金 / 邀请奖励金 / 在审提现单 → 拒绝注销,引导用户先把钱处理掉。
# (pending 提现转账已在途、对账 worker 不依赖 user.status 照常到账,故不拦 pending。) # (pending 提现转账已在途、对账 worker 不依赖 user.status 照常到账,故不拦 pending。)
if wallet_repo.get_cash_balance_cents(db, user.id) > 0: if wallet_repo.get_cash_balance_cents(db, user.id) > 0:
raise HTTPException( raise HTTPException(
status_code=409, detail="账户还有未提现的现金余额,请先提现后再注销" 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): if wallet_repo.has_reviewing_withdraw(db, user.id):
raise HTTPException( raise HTTPException(
status_code=409, detail="有提现正在审核中,请等审核完成后再注销" status_code=409, detail="有提现正在审核中,请等审核完成后再注销"
+71 -8
View File
@@ -8,10 +8,29 @@ import secrets
import string import string
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import delete, select
from sqlalchemy.orm import Session 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.user import User
from app.models.wallet import CoinAccount
# ===== 创建时分配的标识:用户名(对外展示账号 ID)+ 默认昵称 ===== # ===== 创建时分配的标识:用户名(对外展示账号 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: def soft_delete_account(db: Session, user: User) -> None:
"""注销账号:软删除 + 匿名化 + 释放唯一约束/外部绑定 """注销账号:物理删除个人数据 + 清零余额 + 匿名化保留财务/关系 + 释放唯一约束。
把 phone 改成 `deleted_<id>` 释放手机号唯一约束,允许同号码重新注册成全新账号; 删除策略(按"删了是否破坏对账 / 影响别人 / 是否 PII"分类):
清空 PII(昵称/头像),保留行用于审计。status=deleted 后将无法再登录该行 物理删除(个人内容/成绩/行为/PII,只涉本人、删了不碰账目也不影响别人):比价/
(登录接口校验 status==active)。 省钱/签到/领券/任务/里程碑领取/埋点/引导标记/设备活跃/价格上报/反馈/看广告时长日志
(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: 不清 → 这个微信再也无法被任何账号绑定(提现通道彻底锁死); - wechat_openid: 不清 → 这个微信再也无法被任何账号绑定(提现通道彻底锁死);
- invite_code: 不清 → 邀请码槽位被占 + 裸查仍命中死号(发奖已被 bind 的 - invite_code: 不清 → 邀请码槽位被占 + 裸查仍命中死号(发奖已被 bind 的
status==active 校验兜住,清掉更干净)。 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.status = "deleted"
user.phone = f"deleted_{user.id}" user.phone = f"deleted_{user.id}"
user.nickname = None user.nickname = None
+10
View File
@@ -383,6 +383,16 @@ def get_cash_balance_cents(db: Session, user_id: int) -> int:
return val or 0 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: def has_reviewing_withdraw(db: Session, user_id: int) -> bool:
"""用户是否有「待审核(reviewing)」的提现单。 """用户是否有「待审核(reviewing)」的提现单。
+97
View File
@@ -476,3 +476,100 @@ def test_withdraw_reconcile(admin_client: TestClient, finance_token: str, monkey
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert "checked" in r.json() and "resolved" in r.json() 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
+133 -2
View File
@@ -1,14 +1,18 @@
"""注销账号(DELETE /api/v1/user):软删 + 释放唯一约束 + 资金前置校验。 """注销账号(DELETE /api/v1/user):数据级联清理 + 软删 + 释放唯一约束 + 资金前置校验。
覆盖: 覆盖:
- 干净注销:status=deleted + phone 匿名化 + wechat_openid/invite_code 等唯一槽全部释放 - 干净注销:status=deleted + phone 匿名化 + wechat_openid/invite_code 等唯一槽全部释放
- 回归原始 bug:注销释放 openid 后,别的账号能绑同一个微信 - 回归原始 bug:注销释放 openid 后,别的账号能绑同一个微信
- 资金前置闸:有可提现现金 / 在审提现单 → 409 拒绝注销,账号不被删 - 资金前置闸:有可提现现金 / 在审提现单 → 409 拒绝注销,账号不被删
- 注销后旧 token 即时失效(status==active 闸) - 注销后旧 token 即时失效(status==active 闸)
- 级联清理:个人内容/成绩/行为/PII 表被物理删空、余额清零;财务流水/邀请关系/广告幂等
+对账表按策略保留(soft_delete_account 的分类落地断言)
""" """
from __future__ import annotations 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.db.session import SessionLocal
from app.models.user import User 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) # 注销后旧 token 立即失效(get_current_user 校验 status==active)
r = client.get("/api/v1/user/onboarding/status", params={"device_id": "d"}, headers=_auth(token)) r = client.get("/api/v1/user/onboarding/status", params={"device_id": "d"}, headers=_auth(token))
assert r.status_code == 401, r.text 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