Files
shaguabijia-app-server/tests/test_admin_write.py
T
linkeyu 7e17df4130 完善提现审核与用户风险管理 (#175)
## 改动内容
- 拆分邀请提现与其他提现的筛选、统计和详情数据口径
- 增加人工高风险标识、备注、权限和数据库迁移
- 完善微信失败原因查单、退款兜底与审核操作
- 移除人工处理超时订单接口和提现账本校验功能
- 补充提现审核、邀请详情、风险操作与微信失败原因回归测试

## 验证
- 相关后端测试:82 passed
- Ruff F/I:通过
- 新数据库迁移 upgrade/downgrade/upgrade:通过

## 说明
- 本地 seed mock 脚本未提交

---------

Co-authored-by: guke <guke@wonderable.ai>
Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #175
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-27 16:03:31 +08:00

681 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Admin M3 写接口测试:调金币/封号/反馈/提现/admin账号。
验证:写操作落审计 + 金币写流水 + 扣负拒绝 + 角色守卫 + 提现复用 wallet(mock wxpay)。
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo
from app.core.security import hash_password
from app.db.session import SessionLocal
from app.models.admin import AdminAuditLog
from app.models.feedback import Feedback
from app.models.price_report import PriceReport
from app.models.user import User
from app.models.wallet import CoinAccount, CoinTransaction, WithdrawOrder
from app.repositories import user as user_repo
@pytest.fixture()
def admin_client() -> TestClient:
return TestClient(admin_app)
def _token(username: str, role: str) -> str:
db = SessionLocal()
try:
a = admin_repo.get_by_username(db, username)
if a is None:
admin_repo.create_admin(db, username=username, password="pass1234", role=role)
else:
a.password_hash = hash_password("pass1234")
a.role = role
a.status = "active"
db.commit()
finally:
db.close()
c = TestClient(admin_app)
return c.post(
"/admin/api/auth/login", json={"username": username, "password": "pass1234"}
).json()["access_token"]
@pytest.fixture()
def super_token() -> str:
return _token("w_super", "super_admin")
@pytest.fixture()
def finance_token() -> str:
return _token("w_finance", "finance")
@pytest.fixture()
def operator_token() -> str:
return _token("w_operator", "operator")
def _auth(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def _seed_user(phone: str) -> int:
db = SessionLocal()
try:
return user_repo.upsert_user_for_login(db, phone=phone, register_channel="sms").id
finally:
db.close()
def _seed_feedback(phone: str) -> int:
uid = _seed_user(phone)
db = SessionLocal()
try:
fb = Feedback(user_id=uid, content="测试", contact="wx", status="pending")
db.add(fb)
db.commit()
return fb.id
finally:
db.close()
def _seed_price_report(phone: str) -> int:
uid = _seed_user(phone)
db = SessionLocal()
try:
report = PriceReport(
user_id=uid,
store_name="测试门店",
reported_platform_id="eleme",
reported_platform_name="饿了么",
reported_price_cents=2990,
images=[],
status="pending",
)
db.add(report)
db.commit()
return report.id
finally:
db.close()
def test_set_user_risk_requires_note_and_writes_audit(
admin_client: TestClient, operator_token: str
) -> None:
uid = _seed_user("13900000991")
missing_note = admin_client.post(
f"/admin/api/users/{uid}/risk",
json={"is_high_risk": True, "note": " "},
headers=_auth(operator_token),
)
assert missing_note.status_code == 422
marked = admin_client.post(
f"/admin/api/users/{uid}/risk",
json={"is_high_risk": True, "note": "邀请记录存在批量异常"},
headers=_auth(operator_token),
)
assert marked.status_code == 200, marked.text
listed = admin_client.get(
"/admin/api/users",
params={"phone": "13900000991"},
headers=_auth(operator_token),
).json()["items"][0]
assert listed["is_high_risk"] is True
assert listed["high_risk_note"] == "邀请记录存在批量异常"
db = SessionLocal()
try:
audit = db.execute(
select(AdminAuditLog)
.where(
AdminAuditLog.action == "user.risk.set",
AdminAuditLog.target_id == str(uid),
)
.order_by(AdminAuditLog.id.desc())
).scalars().first()
assert audit is not None
assert audit.detail["after"]["is_high_risk"] is True
finally:
db.close()
cleared = admin_client.post(
f"/admin/api/users/{uid}/risk",
json={"is_high_risk": False, "note": None},
headers=_auth(operator_token),
)
assert cleared.status_code == 200
overview = admin_client.get(
f"/admin/api/users/{uid}", headers=_auth(operator_token)
).json()
assert overview["user"]["is_high_risk"] is False
assert overview["user"]["high_risk_note"] is None
# ===== 调金币 =====
def test_grant_coins_writes_txn_and_audit(admin_client: TestClient, finance_token: str) -> None:
uid = _seed_user("13900000001")
r = admin_client.post(
f"/admin/api/users/{uid}/coins", json={"amount": 1000, "reason": "补偿"},
headers=_auth(finance_token),
)
assert r.status_code == 200, r.text
db = SessionLocal()
try:
assert db.get(CoinAccount, uid).coin_balance == 1000
txns = db.execute(
select(CoinTransaction).where(
CoinTransaction.user_id == uid, CoinTransaction.biz_type == "admin_grant"
)
).scalars().all()
assert len(txns) == 1 and txns[0].amount == 1000
logs = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "user.coins.grant", AdminAuditLog.target_id == str(uid)
)
).scalars().all()
assert len(logs) == 1 and logs[0].detail["amount"] == 1000
finally:
db.close()
def test_deduct_below_zero_rejected(admin_client: TestClient, finance_token: str) -> None:
uid = _seed_user("13900000002")
r = admin_client.post(
f"/admin/api/users/{uid}/coins", json={"amount": -999999, "reason": ""},
headers=_auth(finance_token),
)
assert r.status_code == 400
db = SessionLocal()
try:
txns = db.execute(
select(CoinTransaction).where(CoinTransaction.user_id == uid)
).scalars().all()
assert len(txns) == 0 # 拒绝后无流水(原子:都不发生)
finally:
db.close()
def test_grant_zero_rejected(admin_client: TestClient, finance_token: str) -> None:
uid = _seed_user("13900000008")
r = admin_client.post(
f"/admin/api/users/{uid}/coins", json={"amount": 0, "reason": "x"},
headers=_auth(finance_token),
)
assert r.status_code == 400
def test_set_coins_writes_delta_txn(admin_client: TestClient, finance_token: str) -> None:
"""set 模式:先有余额,再设为目标值,只写一笔差值流水,审计带 mode/target/before。"""
uid = _seed_user("13900000009")
# 先增到 300
admin_client.post(
f"/admin/api/users/{uid}/coins", json={"amount": 300, "reason": ""},
headers=_auth(finance_token),
)
# 设为 100 → 差值 -200(扣减)
r = admin_client.post(
f"/admin/api/users/{uid}/coins", json={"mode": "set", "amount": 100, "reason": "设值"},
headers=_auth(finance_token),
)
assert r.status_code == 200, r.text
db = SessionLocal()
try:
assert db.get(CoinAccount, uid).coin_balance == 100
txns = db.execute(
select(CoinTransaction).where(CoinTransaction.user_id == uid)
.order_by(CoinTransaction.id.desc())
).scalars().all()
# 两笔:+300(admin_grant)、-200(admin_deduct)
assert txns[0].amount == -200 and txns[0].biz_type == "admin_deduct"
logs = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "user.coins.grant", AdminAuditLog.target_id == str(uid)
).order_by(AdminAuditLog.id.desc())
).scalars().all()
assert logs[0].detail["mode"] == "set"
assert logs[0].detail["target"] == 100
assert logs[0].detail["before"] == 300
assert logs[0].detail["amount"] == -200
finally:
db.close()
def test_set_coins_equal_balance_rejected(admin_client: TestClient, finance_token: str) -> None:
"""set 为当前余额(差值 0)→ 拒绝,不写流水。"""
uid = _seed_user("13900000010")
admin_client.post(
f"/admin/api/users/{uid}/coins", json={"amount": 50, "reason": ""},
headers=_auth(finance_token),
)
r = admin_client.post(
f"/admin/api/users/{uid}/coins", json={"mode": "set", "amount": 50, "reason": "x"},
headers=_auth(finance_token),
)
assert r.status_code == 400
db = SessionLocal()
try:
txns = db.execute(
select(CoinTransaction).where(CoinTransaction.user_id == uid)
).scalars().all()
assert len(txns) == 1 # 仅初始那笔,设值被拒后无新流水
finally:
db.close()
def test_set_coins_negative_target_rejected(admin_client: TestClient, finance_token: str) -> None:
uid = _seed_user("13900000011")
r = admin_client.post(
f"/admin/api/users/{uid}/coins", json={"mode": "set", "amount": -1, "reason": "x"},
headers=_auth(finance_token),
)
assert r.status_code == 400
def test_set_cash_writes_delta_txn(admin_client: TestClient, finance_token: str) -> None:
"""现金 set 模式:设为目标分值,写一笔差值流水。"""
uid = _seed_user("13900000012")
admin_client.post(
f"/admin/api/users/{uid}/cash", json={"amount_cents": 500, "reason": ""},
headers=_auth(finance_token),
)
r = admin_client.post(
f"/admin/api/users/{uid}/cash",
json={"mode": "set", "amount_cents": 200, "reason": "设值"},
headers=_auth(finance_token),
)
assert r.status_code == 200, r.text
db = SessionLocal()
try:
assert db.get(CoinAccount, uid).cash_balance_cents == 200
logs = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "user.cash.grant", AdminAuditLog.target_id == str(uid)
).order_by(AdminAuditLog.id.desc())
).scalars().all()
assert logs[0].detail["mode"] == "set"
assert logs[0].detail["target_cents"] == 200
assert logs[0].detail["before_cents"] == 500
assert logs[0].detail["amount_cents"] == -300
finally:
db.close()
# ===== 封号 =====
def test_set_user_status_and_audit(admin_client: TestClient, operator_token: str) -> None:
uid = _seed_user("13900000003")
r = admin_client.post(
f"/admin/api/users/{uid}/status", json={"status": "disabled"}, headers=_auth(operator_token)
)
assert r.status_code == 200, r.text
db = SessionLocal()
try:
assert db.get(User, uid).status == "disabled"
logs = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "user.status.set", AdminAuditLog.target_id == str(uid)
)
).scalars().all()
assert logs[0].detail == {"before": "active", "after": "disabled"}
finally:
db.close()
assert admin_client.post(
f"/admin/api/users/{uid}/status", json={"status": "active"}, headers=_auth(operator_token)
).status_code == 200
# ===== 角色守卫 =====
def test_operator_cannot_grant_coins(admin_client: TestClient, operator_token: str) -> None:
uid = _seed_user("13900000004")
r = admin_client.post(
f"/admin/api/users/{uid}/coins", json={"amount": 100, "reason": "x"},
headers=_auth(operator_token),
)
assert r.status_code == 403
def test_finance_cannot_manage_admins(admin_client: TestClient, finance_token: str) -> None:
assert admin_client.get("/admin/api/admins", headers=_auth(finance_token)).status_code == 403
def test_super_admin_can_grant_coins(admin_client: TestClient, super_token: str) -> None:
uid = _seed_user("13900000005")
r = admin_client.post(
f"/admin/api/users/{uid}/coins", json={"amount": 50, "reason": "x"},
headers=_auth(super_token),
)
assert r.status_code == 200
# ===== 反馈审核 =====
def test_approve_feedback_grants_coins_and_audit(
admin_client: TestClient, operator_token: str
) -> None:
fid = _seed_feedback("13900000006")
r = admin_client.post(
f"/admin/api/feedbacks/{fid}/approve",
json={"reward_coins": 800, "note": "真实详细,已采纳"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "adopted"
assert r.json()["reward_coins"] == 800
db = SessionLocal()
try:
fb = db.get(Feedback, fid)
assert fb is not None
assert fb.status == "adopted"
assert fb.reward_coins == 800
assert fb.review_note == "真实详细,已采纳"
assert fb.reviewed_by_admin_id is not None
assert fb.reviewed_at is not None
assert db.get(CoinAccount, fb.user_id).coin_balance == 800
txns = db.execute(
select(CoinTransaction).where(
CoinTransaction.user_id == fb.user_id,
CoinTransaction.biz_type == "feedback_reward",
CoinTransaction.ref_id == str(fid),
)
).scalars().all()
assert len(txns) == 1 and txns[0].amount == 800
logs = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "feedback.approve",
AdminAuditLog.target_id == str(fid),
)
).scalars().all()
assert len(logs) == 1 and logs[0].detail["reward_coins"] == 800
finally:
db.close()
r = admin_client.post(
f"/admin/api/feedbacks/{fid}/approve",
json={"reward_coins": 100},
headers=_auth(operator_token),
)
assert r.status_code == 400
def test_reject_feedback_records_reason_and_no_coin(
admin_client: TestClient, operator_token: str
) -> None:
fid = _seed_feedback("13900000013")
r = admin_client.post(
f"/admin/api/feedbacks/{fid}/reject",
json={"reason": "暂未提供可复现信息", "note": "信息不足"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "rejected"
assert r.json()["reject_reason"] == "暂未提供可复现信息"
db = SessionLocal()
try:
fb = db.get(Feedback, fid)
assert fb is not None
assert fb.status == "rejected"
assert fb.reject_reason == "暂未提供可复现信息"
assert fb.review_note == "信息不足"
txns = db.execute(
select(CoinTransaction).where(
CoinTransaction.user_id == fb.user_id,
CoinTransaction.biz_type == "feedback_reward",
CoinTransaction.ref_id == str(fid),
)
).scalars().all()
assert txns == []
logs = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "feedback.reject",
AdminAuditLog.target_id == str(fid),
)
).scalars().all()
assert len(logs) == 1 and logs[0].detail["reason"] == "暂未提供可复现信息"
finally:
db.close()
def test_feedback_review_stores_admin_reply(
admin_client: TestClient, operator_token: str
) -> None:
"""审核可给用户留言(admin_reply,用户端可见),采纳/拒绝都能带,并回显在响应里。"""
fid = _seed_feedback("13900000021")
r = admin_client.post(
f"/admin/api/feedbacks/{fid}/approve",
json={"reward_coins": 100, "note": "内部备注", "reply": "感谢反馈,已采纳~"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
assert r.json()["admin_reply"] == "感谢反馈,已采纳~"
db = SessionLocal()
try:
fb = db.get(Feedback, fid)
assert fb.admin_reply == "感谢反馈,已采纳~" and fb.review_note == "内部备注"
finally:
db.close()
fid2 = _seed_feedback("13900000022")
r = admin_client.post(
f"/admin/api/feedbacks/{fid2}/reject",
json={"reason": "无法复现", "reply": "已收到,后续跟进"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
assert r.json()["admin_reply"] == "已收到,后续跟进"
def test_bulk_approve_feedbacks_returns_per_item_results(
admin_client: TestClient, operator_token: str
) -> None:
first_id = _seed_feedback("13900000031")
second_id = _seed_feedback("13900000032")
r = admin_client.post(
"/admin/api/feedbacks/bulk/approve",
json={"ids": [first_id, second_id, 999999], "reward_coins": 600, "note": "批量采纳"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
payload = r.json()
assert payload["total"] == 3 and payload["success"] == 2 and payload["failed"] == 1
assert payload["items"][-1] == {"id": 999999, "ok": False, "status": None, "error": "反馈不存在"}
db = SessionLocal()
try:
for feedback_id in (first_id, second_id):
feedback = db.get(Feedback, feedback_id)
assert feedback is not None and feedback.status == "adopted"
assert db.get(CoinAccount, feedback.user_id).coin_balance == 600
log = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "feedback.approve",
AdminAuditLog.target_id == str(feedback_id),
)
).scalar_one()
assert log.detail["bulk"] is True
finally:
db.close()
def test_bulk_approve_price_reports_returns_per_item_results(
admin_client: TestClient, operator_token: str
) -> None:
first_id = _seed_price_report("13900000041")
second_id = _seed_price_report("13900000042")
r = admin_client.post(
"/admin/api/price-reports/bulk/approve",
json={"ids": [first_id, second_id, 999999]},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
payload = r.json()
assert payload["total"] == 3 and payload["success"] == 2 and payload["failed"] == 1
assert payload["items"][-1] == {"id": 999999, "ok": False, "status": None, "error": "上报记录不存在"}
db = SessionLocal()
try:
for report_id in (first_id, second_id):
report = db.get(PriceReport, report_id)
assert report is not None and report.status == "approved"
assert report.reward_coins == 1000
assert db.get(CoinAccount, report.user_id).coin_balance == 1000
log = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "price_report.approve",
AdminAuditLog.target_id == str(report_id),
)
).scalar_one()
assert log.detail["bulk"] is True
finally:
db.close()
def test_bulk_reject_review_requests_apply_shared_reason(
admin_client: TestClient, operator_token: str
) -> None:
feedback_id = _seed_feedback("13900000051")
report_id = _seed_price_report("13900000052")
feedback_response = admin_client.post(
"/admin/api/feedbacks/bulk/reject",
json={"ids": [feedback_id], "reason": "信息不足", "reply": "请补充完整截图"},
headers=_auth(operator_token),
)
report_response = admin_client.post(
"/admin/api/price-reports/bulk/reject",
json={"ids": [report_id], "reason": "截图无法核实"},
headers=_auth(operator_token),
)
assert feedback_response.status_code == 200, feedback_response.text
assert report_response.status_code == 200, report_response.text
assert feedback_response.json()["success"] == 1
assert report_response.json()["success"] == 1
db = SessionLocal()
try:
feedback = db.get(Feedback, feedback_id)
report = db.get(PriceReport, report_id)
assert feedback is not None and feedback.status == "rejected"
assert feedback.reject_reason == "信息不足" and feedback.admin_reply == "请补充完整截图"
assert report is not None and report.status == "rejected"
assert report.reject_reason == "截图无法核实"
finally:
db.close()
# ===== admin 账号管理(super_admin) =====
def test_create_and_update_admin(admin_client: TestClient, super_token: str) -> None:
r = admin_client.post(
"/admin/api/admins",
json={"username": "new_op", "password": "pass1234", "role": "operator"},
headers=_auth(super_token),
)
assert r.status_code == 200, r.text
new_id = r.json()["id"]
r = admin_client.patch(
f"/admin/api/admins/{new_id}", json={"role": "finance"}, headers=_auth(super_token)
)
assert r.status_code == 200 and r.json()["role"] == "finance"
r = admin_client.post(
"/admin/api/admins", json={"username": "new_op", "password": "pass1234"},
headers=_auth(super_token),
)
assert r.status_code == 409
def test_cannot_disable_self(admin_client: TestClient, super_token: str) -> None:
me = admin_client.get("/admin/api/auth/me", headers=_auth(super_token)).json()
r = admin_client.patch(
f"/admin/api/admins/{me['id']}", json={"status": "disabled"}, headers=_auth(super_token)
)
assert r.status_code == 400
# ===== 提现重试 / 对账(mock wxpay,不真调微信) =====
def test_withdraw_refresh(admin_client: TestClient, finance_token: str, monkeypatch) -> None:
uid = _seed_user("13900000007")
db = SessionLocal()
try:
db.add(WithdrawOrder(
user_id=uid, out_bill_no="adminrefresh0001", amount_cents=100, status="pending"
))
db.commit()
finally:
db.close()
from app.repositories import wallet as wr
monkeypatch.setattr(
wr.wxpay, "query_transfer",
lambda obn: {"status_code": 200, "data": {"state": "SUCCESS"}},
)
r = admin_client.post(
"/admin/api/withdraws/adminrefresh0001/refresh", headers=_auth(finance_token)
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "success"
db = SessionLocal()
try:
logs = db.execute(
select(AdminAuditLog).where(AdminAuditLog.action == "withdraw.refresh")
).scalars().all()
assert any(x.target_id == "adminrefresh0001" for x in logs)
finally:
db.close()
def test_withdraw_refresh_404(admin_client: TestClient, finance_token: str) -> None:
assert admin_client.post(
"/admin/api/withdraws/nope999notexist/refresh", headers=_auth(finance_token)
).status_code == 404
def test_withdraw_refresh_enriches_existing_failed_reason(
admin_client: TestClient, finance_token: str, monkeypatch
) -> None:
uid = _seed_user("13900000017")
with SessionLocal() as db:
db.add(
WithdrawOrder(
user_id=uid,
out_bill_no="adminfailedreason01",
amount_cents=100,
status="failed",
wechat_state="FAIL",
fail_reason="微信转账状态 FAIL",
)
)
db.commit()
from app.repositories import wallet as wr
monkeypatch.setattr(
wr.wxpay,
"query_transfer",
lambda out_bill_no: {
"status_code": 200,
"data": {
"state": "FAIL",
"fail_reason": "TRANSFER_RISK",
"transfer_bill_no": "wx_failed_reason_01",
},
},
)
response = admin_client.post(
"/admin/api/withdraws/adminfailedreason01/refresh",
headers=_auth(finance_token),
)
assert response.status_code == 200, response.text
assert response.json()["status"] == "failed"
assert response.json()["wechat_state"] == "FAIL"
assert response.json()["transfer_bill_no"] == "wx_failed_reason_01"
assert response.json()["fail_reason"] == (
"微信转账失败:该笔转账存在风险,已被微信拦截(TRANSFER_RISK"
)