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>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user