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>
This commit is contained in:
2026-06-04 03:02:41 +08:00
parent 90c0d65a16
commit b50495bebe
46 changed files with 2621 additions and 68 deletions
+155
View File
@@ -0,0 +1,155 @@
"""Admin 后台 M1 测试:登录闭环 + 与 App 用户鉴权的彻底隔离。
admin app 是独立的 FastAPI(app.admin.main:admin_app),用独立 TestClient。
admin 表由 conftest 的 Base.metadata.create_all 一起建好(models/__init__ 已登记)。
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo
from app.core.security import create_token, hash_password
from app.db.session import SessionLocal
@pytest.fixture()
def admin_client() -> TestClient:
return TestClient(admin_app)
def _ensure_admin(
username: str = "test_admin",
password: str = "admin-pass-123",
role: str = "super_admin",
) -> tuple[str, str]:
"""create-or-reset 一个 admin(测试 DB 跨用例共享,需幂等)。"""
db = SessionLocal()
try:
a = admin_repo.get_by_username(db, username)
if a is None:
admin_repo.create_admin(db, username=username, password=password, role=role)
else:
a.password_hash = hash_password(password)
a.role = role
a.status = "active"
db.commit()
finally:
db.close()
return username, password
def test_admin_login_and_me(admin_client: TestClient) -> None:
username, password = _ensure_admin()
r = admin_client.post(
"/admin/api/auth/login", json={"username": username, "password": password}
)
assert r.status_code == 200, r.text
data = r.json()
assert "access_token" in data
assert data["admin"]["username"] == username
assert data["admin"]["role"] == "super_admin"
token = data["access_token"]
r = admin_client.get("/admin/api/auth/me", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200, r.text
assert r.json()["username"] == username
def test_admin_login_wrong_password(admin_client: TestClient) -> None:
username, _ = _ensure_admin()
r = admin_client.post(
"/admin/api/auth/login", json={"username": username, "password": "definitely-wrong"}
)
assert r.status_code == 401
def test_admin_me_requires_token(admin_client: TestClient) -> None:
assert admin_client.get("/admin/api/auth/me").status_code == 401
def test_disabled_admin_cannot_login(admin_client: TestClient) -> None:
username, password = _ensure_admin(username="disabled_admin")
db = SessionLocal()
try:
a = admin_repo.get_by_username(db, username)
a.status = "disabled"
db.commit()
finally:
db.close()
r = admin_client.post(
"/admin/api/auth/login", json={"username": username, "password": password}
)
assert r.status_code == 403
# ============================ 关键:鉴权隔离 ============================
def test_app_user_token_cannot_access_admin(admin_client: TestClient) -> None:
"""App 用户的 access_token(用 JWT_SECRET_KEY 签)不能访问 admin。
admin 用独立 ADMIN_JWT_SECRET 验签 → App token 直接验签失败 → 401。
这是后台防越权的第一道线。
"""
user_token, _ = create_token(user_id=1, token_type="access")
r = admin_client.get(
"/admin/api/auth/me", headers={"Authorization": f"Bearer {user_token}"}
)
assert r.status_code == 401
def test_admin_token_cannot_access_app_api(client: TestClient, admin_client: TestClient) -> None:
"""反向:admin token 也不能访问 App 用户接口(App 用 JWT_SECRET_KEY 验签,admin token 失败)。"""
username, password = _ensure_admin()
r = admin_client.post(
"/admin/api/auth/login", json={"username": username, "password": password}
)
admin_token = r.json()["access_token"]
r = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {admin_token}"})
assert r.status_code == 401
# ============================ 回归:review 修掉的两个 bug ============================
def test_long_password_does_not_crash(admin_client: TestClient) -> None:
""">72 UTF-8 字节的密码(如多个中文)不能让建账号/登录崩(bcrypt 72 字节限制)。"""
username = "longpw_admin"
long_pw = "超长密码测试" * 8 # 6 中文 ×8 = 48 字 ≈ 144 字节 UTF-8,远超 72
_ensure_admin(username=username, password=long_pw) # 建账号不应抛 ValueError
r = admin_client.post(
"/admin/api/auth/login", json={"username": username, "password": long_pw}
)
assert r.status_code == 200, r.text
def test_audit_log_pagination_no_gap() -> None:
"""审计游标分页跨页不丢/不重(回归 next_cursor off-by-one)。"""
from app.admin.repositories import admin_user as admin_repo
from app.admin.repositories import audit_log as audit_repo
_ensure_admin() # 确保有 test_admin 供 FK 引用
db = SessionLocal()
try:
admin = admin_repo.get_by_username(db, "test_admin")
action = "test.pagination.probe"
created_ids = []
for i in range(5):
log = audit_repo.add_audit_log(
db, admin_id=admin.id, admin_username=admin.username,
action=action, target_type="probe", target_id=str(i),
)
created_ids.append(log.id)
# limit=2 翻 5 条,收集所有 id,应正好覆盖创建的 5 条(无丢无重)
seen: list[int] = []
cursor = None
for _ in range(10): # 上限防死循环
rows, cursor = audit_repo.list_audit_logs(db, action=action, limit=2, cursor=cursor)
seen.extend(r.id for r in rows)
if cursor is None:
break
assert sorted(seen) == sorted(created_ids), f"分页丢/重: want={created_ids} got={seen}"
finally:
db.close()