Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35f24084ae |
+12
-1
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+7
-3
@@ -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="有提现正在审核中,请等审核完成后再注销"
|
||||
|
||||
@@ -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_<id>` 释放手机号唯一约束,允许同号码重新注册成全新账号;
|
||||
清空 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
|
||||
|
||||
@@ -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)」的提现单。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user