Compare commits

..

3 Commits

Author SHA1 Message Date
marco bbaca793c9 Merge origin/main into feat/compare-record-backend-harvest
解决 compare.py 冲突 + 对齐 #113 邀请发奖口径:
- compare.py 冲突(#112 新增 /trace/epilogue vs 本分支 harvest 重写):保留 harvest
  全套,把 #112 的 /trace/epilogue 端点从已删除的 _passthrough 改写到 _forward
  (harvest_first_frame=False 纯透传,不建行/不落库——该 trace 记录已由 done/finalize 落终态)
- 对齐 #113(发奖从「比价」改「实际下单」):从 harvest_done 移除 try_reward_on_compare
  调用 + 其 import,发奖只保留在 order.py(#113 加)。否则比价先于下单 + try_reward
  幂等闸会把奖落在「比价」这步,架空 #113 的「下单才发奖」防刷。同步清理相关
  docstring/summary(price_step「+发奖」措辞、模块头「幂等发一次邀请奖」)

验证:本分支 harvest/proxy/milestone 测试全绿(44 passed);py_compile 通过;无残留冲突标记。
注:tests/test_invite.py::test_bind_flow_no_coins_either_side 在 pristine origin/main 上
即已红(#113 改 invite 行为但未随附测试更新),非本次合并引入,不在本分支职责内。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 02:28:46 +08:00
marco 635d127f95 比价记录改后端 harvest: 透传壳落库替代客户端主动 POST
compare.py 透传壳新增后端 harvest(不再依赖客户端 POST /compare/record):
- 帧0(客户端首帧不带 trace_id)由 app-server 用 uuid 签发 trace_id、注入转发 body、
  回填响应顶层,并按 trace_id 建 running 行(harvest_running)
- done 帧(command=done 且 continue=false)更新为 success/failed 终态,并对本次新落成
  success 幂等发一次邀请奖(harvest_done + try_reward_on_compare)
- /trace/finalize(用户终止/Phase1 未识别,无 done)更新为 cancelled/failed,
  不降级已 success(harvest_abort)
- 软鉴权 OptionalUser(deps.py 新增 get_current_user_optional):新客户端带 JWT 绑
  user_id,老客户端匿名建行、user_id 暂空,由其后续 POST /compare/record 按 trace_id
  reconcile;新版覆盖够高后可收紧成硬鉴权
- 结构化日志(logging.py 加 trace_id_ctx ContextVar):请求级 trace_id 贯穿 harvest 各阶段

comparison_record 表结构调整(迁移 comparison_record_trace_unique):
- 唯一键 (user_id,trace_id) → trace_id 单列(harvest 帧0 建行时 user_id 可能暂缺)
- user_id 改可空

repositories/comparison.py 加 harvest_running/harvest_done/harvest_abort;
compare_record.py 的 POST /record 降级为灰度期老客户端兼容(按 trace_id 幂等、不降级 success);
补 tests/test_compare_harvest.py,test_compare_proxy/milestone 适配。

文档同步: docs/database/comparison_record.md 更新表结构(唯一键/user_id 可空/status 取值)
与写入模型; docs/api/compare/compare-record-report.md 标注 POST /record 灰度定位。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:56:46 +08:00
marco ea60dc391b feat(onboarding): 新增重置新手引导端点 /api/v1/user/onboarding/reset
- user.py:新增 reset_onboarding 端点(需登录),删当前账号+该设备的引导完成标记,
  与 /onboarding/complete 互逆;幂等(无记录也返回 ok),只删自己不影响他人
- repositories/onboarding.py:新增 delete_completion(user_id, device_id),
  device_id 为空忽略,返回删除行数
- tests:补 test_onboarding_reset_makes_relogin_reonboard,覆盖
  完成 → 重置 → 再登录变未完成 + 重复重置幂等

配合客户端「开发设置 → 重置新手引导」:先删后端行再退登录重开,复现全新用户。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:30:09 +08:00
8 changed files with 14 additions and 371 deletions
+1 -12
View File
@@ -4,7 +4,6 @@
复用 App 的 DB/models/repositories/integrations;鉴权独立(admin JWT,见 app/admin/security.py)。
现有 app.main:app 不 import 本模块,两进程互不影响。
"""
from __future__ import annotations
import logging
@@ -13,7 +12,6 @@ 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
@@ -27,12 +25,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
@@ -86,15 +84,6 @@ 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)
-35
View File
@@ -13,7 +13,6 @@ from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.user import (
AdminUserListItem,
AdminUserOverview,
DeleteUserRequest,
GrantCashRequest,
GrantCoinsRequest,
SetDebugTraceRequest,
@@ -133,40 +132,6 @@ 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,
-6
View File
@@ -115,9 +115,3 @@ 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)
+3 -7
View File
@@ -98,19 +98,15 @@ 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 -71
View File
@@ -8,29 +8,10 @@ import secrets
import string
from datetime import datetime, timezone
from sqlalchemy import delete, select
from sqlalchemy import 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)+ 默认昵称 =====
@@ -133,63 +114,19 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User:
def soft_delete_account(db: Session, user: User) -> None:
"""注销账号:物理删除个人数据 + 清零余额 + 匿名化保留财务/关系 + 释放唯一约束。
"""注销账号:软删除 + 匿名化 + 释放唯一约束/外部绑定
删除策略(按"删了是否破坏对账 / 影响别人 / 是否 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
指向已匿名化的本行,身份已解绑。
把 phone 改成 `deleted_<id>` 释放手机号唯一约束,允许同号码重新注册成全新账号;
清空 PII(昵称/头像),保留行用于审计。status=deleted 后将无法再登录该行
(登录接口校验 status==active)。
⚠️ 匿名化必须连同释放 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
-10
View File
@@ -383,16 +383,6 @@ 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)」的提现单。
-97
View File
@@ -476,100 +476,3 @@ 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
+2 -133
View File
@@ -1,18 +1,14 @@
"""注销账号(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 datetime import date, datetime, timezone
from sqlalchemy import func, select
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.user import User
@@ -143,130 +139,3 @@ 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