Files
shaguabijia-app-server/tests/test_admin_write.py
T
marco b50495bebe feat: 短信接入极光真实发送 + 新增运营 admin 后台子应用
短信(SMS_MOCK 切 mock/real):
- integrations/sms.py 重写: real 模式走极光短信 REST /v1/messages 自定义验证码(本服务 secrets
  生成 6 位码 + 进程内存 + 本地校验一次性/防爆破), 鉴权复用极光一键登录 JG_APP_KEY/MASTER_SECRET
  (同一极光应用, 上线只需 SMS_MOCK=false); mock 仍"任意6位通过"不动其余测试
- 防刷四层: 单号冷却 + 单号每日上限 + 单IP rate_limit(/sms/send 10/min、/sms/login 20/min)
  + 单码失败次数作废; SmsError 带 status_code 映射 429/503/400
- config 增 SMS_SEND_ENDPOINT/SIGN_ID/TEMPLATE_ID/CODE_LENGTH/DAILY_LIMIT/MAX_VERIFY_ATTEMPTS;
  test_auth 加 real 模式单测; sms.md/后端技术实现/待办账本同步

admin 后台(app/admin/ 独立子应用, uvicorn app.admin.main:admin_app :8771):
- 复用主仓 models/repositories/integrations + 同库, 鉴权完全隔离(ADMIN_JWT_SECRET≠JWT_SECRET_KEY
  + payload typ=admin + bcrypt 密码 + 可选 IP 白名单); 主 app 不 import 本包, admin 崩不影响主进程
- 路由: 登录 / 账号管理(RBAC: super_admin·finance·operator) / 用户列表+360详情+封禁+手动调币 /
  钱包流水 / 提现重试对账 / 反馈工单 / 数据大盘; 全写操作落 admin_audit_log(涉钱与业务写同事务)
- 涉钱逻辑(调微信/退款/对账)复用 app.repositories.wallet 不重写
- 新增 models/admin.py(AdminUser/AdminAuditLog) + admin_tables 迁移 + create_admin.py +
  deploy/shaguabijia-admin.service; 依赖加 bcrypt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 03:02:41 +08:00

279 lines
8.9 KiB
Python

"""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.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="new")
db.add(fb)
db.commit()
return fb.id
finally:
db.close()
# ===== 调金币 =====
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_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_handle_feedback(admin_client: TestClient, operator_token: str) -> None:
fid = _seed_feedback("13900000006")
r = admin_client.post(f"/admin/api/feedbacks/{fid}/handle", headers=_auth(operator_token))
assert r.status_code == 200
db = SessionLocal()
try:
assert db.get(Feedback, fid).status == "handled"
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_reconcile(admin_client: TestClient, finance_token: str, monkeypatch) -> None:
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/reconcile", params={"older_than_minutes": 0},
headers=_auth(finance_token),
)
assert r.status_code == 200, r.text
assert "checked" in r.json() and "resolved" in r.json()