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:
@@ -0,0 +1,6 @@
|
||||
"""运营 Admin 后台子应用。
|
||||
|
||||
独立 FastAPI app(app.admin.main:admin_app),独立进程/端口运行,复用 App 的
|
||||
models/repositories/integrations + 同一个 DB,但鉴权完全隔离(独立 JWT secret)。
|
||||
现有 app.main:app 不 import 本包,admin 崩溃不影响 App 主进程。
|
||||
"""
|
||||
@@ -0,0 +1,38 @@
|
||||
"""审计写入门面。
|
||||
|
||||
每个 admin 写操作调一次 write_audit,把"谁(admin)在哪个 IP 对什么(target)做了什么
|
||||
(action)+ 前后值(detail)"落进 admin_audit_log。
|
||||
|
||||
⚠️ 涉钱/涉状态的写操作:传 commit=False,和业务写操作放同一事务一起 commit,
|
||||
保证"改了就有痕、有痕就改了"原子(见 plan 风险点 1)。轻量操作可 commit=True。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories import audit_log as audit_repo
|
||||
from app.models.admin import AdminUser
|
||||
|
||||
|
||||
def write_audit(
|
||||
db: Session,
|
||||
admin: AdminUser,
|
||||
*,
|
||||
action: str,
|
||||
target_type: str,
|
||||
target_id: str | int | None = None,
|
||||
detail: dict | None = None,
|
||||
ip: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
audit_repo.add_audit_log(
|
||||
db,
|
||||
admin_id=admin.id,
|
||||
admin_username=admin.username,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=str(target_id) if target_id is not None else None,
|
||||
detail=detail,
|
||||
ip=ip,
|
||||
commit=commit,
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Admin API 共享依赖:DB session、当前 admin、角色守卫、客户端 IP。
|
||||
|
||||
仿 app/api/deps.py 的 get_current_user,但验的是独立的 admin JWT、查的是 admin_user 表。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.admin.security import AdminTokenError, decode_admin_token
|
||||
from app.db.session import get_db
|
||||
from app.models.admin import AdminUser
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False, scheme_name="AdminBearer")
|
||||
|
||||
|
||||
def get_current_admin(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> AdminUser:
|
||||
"""从 Authorization: Bearer <admin_token> 解出当前管理员。失败统一 401。"""
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="missing bearer token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = decode_admin_token(credentials.credentials)
|
||||
except AdminTokenError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
) from e
|
||||
|
||||
admin = admin_repo.get_by_id(db, int(payload["sub"]))
|
||||
if admin is None or admin.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="admin not found or disabled",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return admin
|
||||
|
||||
|
||||
CurrentAdmin = Annotated[AdminUser, Depends(get_current_admin)]
|
||||
AdminDb = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
def require_role(*roles: str):
|
||||
"""角色守卫依赖工厂。super_admin 恒通过(全权)。
|
||||
|
||||
用法:在路由签名加 `_: Annotated[AdminUser, Depends(require_role("finance"))]`,
|
||||
或 `dependencies=[Depends(require_role("finance"))]`(不需要拿 admin 时)。
|
||||
"""
|
||||
allowed = set(roles)
|
||||
|
||||
def _checker(admin: CurrentAdmin) -> AdminUser:
|
||||
if admin.role != "super_admin" and admin.role not in allowed:
|
||||
need = sorted(allowed | {"super_admin"})
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"role '{admin.role}' not allowed (need one of {need})",
|
||||
)
|
||||
return admin
|
||||
|
||||
return _checker
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""取客户端 IP(审计日志用)。生产经 nginx 反代,优先 X-Forwarded-For 第一段;否则直连 IP。
|
||||
|
||||
⚠️ XFF 可被客户端伪造。nginx 必须用 `proxy_set_header X-Forwarded-For $remote_addr`
|
||||
覆盖客户端传入值(见 M4 部署),否则审计里的 IP 可被伪造。审计 IP 仅作记录、不参与鉴权。
|
||||
"""
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else ""
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Admin 后台 FastAPI app(独立进程)。
|
||||
|
||||
启动:uvicorn app.admin.main:admin_app --host 127.0.0.1 --port 8771
|
||||
复用 App 的 DB/models/repositories/integrations;鉴权独立(admin JWT,见 app/admin/security.py)。
|
||||
现有 app.main:app 不 import 本模块,两进程互不影响。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.admin.routers.admins import router as admins_router
|
||||
from app.admin.routers.audit import router as audit_router
|
||||
from app.admin.routers.auth import router as auth_router
|
||||
from app.admin.routers.dashboard import router as dashboard_router
|
||||
from app.admin.routers.feedback import router as feedback_router
|
||||
from app.admin.routers.users import router as users_router
|
||||
from app.admin.routers.wallet import router as wallet_router
|
||||
from app.admin.routers.withdraw import router as withdraw_router
|
||||
from app.core.config import settings
|
||||
from app.core.logging import setup_logging
|
||||
|
||||
setup_logging(debug=settings.APP_DEBUG)
|
||||
logger = logging.getLogger("shagua.admin")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
logger.info(
|
||||
"admin app started env=%s db=%s",
|
||||
settings.APP_ENV,
|
||||
settings.DATABASE_URL.split("://", 1)[0],
|
||||
)
|
||||
yield
|
||||
logger.info("admin app shutting down")
|
||||
|
||||
|
||||
admin_app = FastAPI(
|
||||
title=f"{settings.APP_NAME} · Admin",
|
||||
version="0.1.0",
|
||||
docs_url="/admin/docs" if not settings.is_prod else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# admin 前端独立部署。生产同域(nginx)无需 CORS;本地 next dev 跨域需放行开发源。
|
||||
_dev_origins = [
|
||||
"http://localhost:3001",
|
||||
"http://127.0.0.1:3001",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
]
|
||||
_origins = settings.cors_origins_list or ([] if settings.is_prod else _dev_origins)
|
||||
if _origins:
|
||||
admin_app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@admin_app.get("/admin/api/health", tags=["meta"])
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok", "service": "admin"}
|
||||
|
||||
|
||||
admin_app.include_router(auth_router)
|
||||
admin_app.include_router(dashboard_router)
|
||||
admin_app.include_router(users_router)
|
||||
admin_app.include_router(wallet_router)
|
||||
admin_app.include_router(withdraw_router)
|
||||
admin_app.include_router(feedback_router)
|
||||
admin_app.include_router(admins_router)
|
||||
admin_app.include_router(audit_router)
|
||||
@@ -0,0 +1 @@
|
||||
"""admin 专用数据访问层(跨用户查询 + admin 账号 + 审计 + 大盘聚合)。"""
|
||||
@@ -0,0 +1,64 @@
|
||||
"""admin_user 表 CRUD。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.models.admin import AdminUser
|
||||
|
||||
|
||||
def get_by_id(db: Session, admin_id: int) -> AdminUser | None:
|
||||
return db.get(AdminUser, admin_id)
|
||||
|
||||
|
||||
def get_by_username(db: Session, username: str) -> AdminUser | None:
|
||||
stmt = select(AdminUser).where(AdminUser.username == username)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def create_admin(
|
||||
db: Session, *, username: str, password: str, role: str = "operator"
|
||||
) -> AdminUser:
|
||||
admin = AdminUser(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
db.refresh(admin)
|
||||
return admin
|
||||
|
||||
|
||||
def update_last_login(db: Session, admin: AdminUser) -> None:
|
||||
admin.last_login_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
|
||||
def list_admins(db: Session) -> list[AdminUser]:
|
||||
stmt = select(AdminUser).order_by(AdminUser.id)
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
|
||||
|
||||
def set_role(db: Session, admin: AdminUser, *, role: str) -> AdminUser:
|
||||
admin.role = role
|
||||
db.commit()
|
||||
db.refresh(admin)
|
||||
return admin
|
||||
|
||||
|
||||
def set_status(db: Session, admin: AdminUser, *, status: str) -> AdminUser:
|
||||
admin.status = status
|
||||
db.commit()
|
||||
db.refresh(admin)
|
||||
return admin
|
||||
|
||||
|
||||
def set_password(db: Session, admin: AdminUser, *, password: str) -> AdminUser:
|
||||
admin.password_hash = hash_password(password)
|
||||
db.commit()
|
||||
db.refresh(admin)
|
||||
return admin
|
||||
@@ -0,0 +1,70 @@
|
||||
"""admin_audit_log 写入 + 查询。
|
||||
|
||||
审计日志只增不改不删——任何写操作经 app.admin.audit.write_audit 落一条。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.admin import AdminAuditLog
|
||||
|
||||
|
||||
def add_audit_log(
|
||||
db: Session,
|
||||
*,
|
||||
admin_id: int,
|
||||
admin_username: str,
|
||||
action: str,
|
||||
target_type: str,
|
||||
target_id: str | None = None,
|
||||
detail: dict | None = None,
|
||||
ip: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> AdminAuditLog:
|
||||
"""插一条审计。commit=False 时只 flush,让调用方把审计和业务写操作放同一事务。"""
|
||||
log = AdminAuditLog(
|
||||
admin_id=admin_id,
|
||||
admin_username=admin_username,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
detail=detail,
|
||||
ip=ip,
|
||||
)
|
||||
db.add(log)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(log)
|
||||
else:
|
||||
db.flush()
|
||||
return log
|
||||
|
||||
|
||||
def list_audit_logs(
|
||||
db: Session,
|
||||
*,
|
||||
action: str | None = None,
|
||||
target_type: str | None = None,
|
||||
admin_id: int | None = None,
|
||||
limit: int = 50,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[AdminAuditLog], int | None]:
|
||||
"""游标分页(id 倒序),与现有 list_* 约定一致。返回 (rows, next_cursor)。"""
|
||||
stmt = select(AdminAuditLog)
|
||||
if action:
|
||||
stmt = stmt.where(AdminAuditLog.action == action)
|
||||
if target_type:
|
||||
stmt = stmt.where(AdminAuditLog.target_type == target_type)
|
||||
if admin_id is not None:
|
||||
stmt = stmt.where(AdminAuditLog.admin_id == admin_id)
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(AdminAuditLog.id < cursor)
|
||||
stmt = stmt.order_by(AdminAuditLog.id.desc())
|
||||
rows = list(db.execute(stmt.limit(limit + 1)).scalars().all())
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
# next_cursor 必须是"本页返回的最后一条"的 id(下一页查 id < 它),不能用 rows[limit]——
|
||||
# rows[limit] 是探测下一页用的第 limit+1 条,它既不在本页也不在下页 → 每页边界丢一条。
|
||||
next_cursor = items[-1].id if has_more else None
|
||||
return items, next_cursor
|
||||
@@ -0,0 +1,36 @@
|
||||
"""admin 写操作 repo(状态改写)。
|
||||
|
||||
涉钱的金币/提现复用 app.repositories.wallet(grant_coins / refresh_withdraw_status /
|
||||
reconcile_pending_withdraws),不在这里重写——重写涉钱逻辑就是给自己埋雷。
|
||||
|
||||
set_user_status / update_feedback_status 支持 commit=False,让 router 把"业务写 + 审计写"
|
||||
放进同一事务一起 commit(原子:改了就有审计、有审计就真改了,见 plan 风险点 1)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def set_user_status(db: Session, user: User, *, status: str, commit: bool = True) -> User:
|
||||
user.status = status
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
else:
|
||||
db.flush()
|
||||
return user
|
||||
|
||||
|
||||
def update_feedback_status(
|
||||
db: Session, feedback: Feedback, *, status: str, commit: bool = True
|
||||
) -> Feedback:
|
||||
feedback.status = status
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(feedback)
|
||||
else:
|
||||
db.flush()
|
||||
return feedback
|
||||
@@ -0,0 +1,153 @@
|
||||
"""admin 跨用户查询(去掉现有 repo 的 user_id 强制过滤)+ 通用游标分页 helper + 用户概览。
|
||||
|
||||
现有 app/repositories/ 的 list_* 都强绑单个 user_id(C 端只看自己);admin 要看全量、按条件筛,
|
||||
所以在这里另起一套。游标约定与现有一致:id 倒序,cursor=上页最后一条 id,返回 (items, next_cursor)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
||||
|
||||
|
||||
def cursor_paginate(
|
||||
db: Session, stmt: Select, id_col, *, limit: int, cursor: int | None
|
||||
) -> tuple[list, int | None]:
|
||||
"""通用游标分页(id 倒序)。stmt 不要预先带 order_by/limit。
|
||||
|
||||
多取 1 条探测有没有下一页;next_cursor 取本页最后一条的 id(下一页查 id < 它),
|
||||
绝不用第 limit+1 条的 id——那条既不在本页也不在下页,会每页边界丢一条(见 audit_log 同款修复)。
|
||||
"""
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(id_col < cursor)
|
||||
stmt = stmt.order_by(id_col.desc()).limit(limit + 1)
|
||||
rows = list(db.execute(stmt).scalars().all())
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
next_cursor = items[-1].id if has_more else None
|
||||
return items, next_cursor
|
||||
|
||||
|
||||
def list_users(
|
||||
db: Session,
|
||||
*,
|
||||
phone: str | None = None,
|
||||
register_channel: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[User], int | None]:
|
||||
stmt = select(User)
|
||||
if phone:
|
||||
stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配
|
||||
if register_channel:
|
||||
stmt = stmt.where(User.register_channel == register_channel)
|
||||
if status:
|
||||
stmt = stmt.where(User.status == status)
|
||||
return cursor_paginate(db, stmt, User.id, limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def list_all_coin_transactions(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
biz_type: str | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[CoinTransaction], int | None]:
|
||||
stmt = select(CoinTransaction)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(CoinTransaction.user_id == user_id)
|
||||
if biz_type:
|
||||
stmt = stmt.where(CoinTransaction.biz_type == biz_type)
|
||||
return cursor_paginate(db, stmt, CoinTransaction.id, limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def list_all_cash_transactions(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
biz_type: str | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[CashTransaction], int | None]:
|
||||
stmt = select(CashTransaction)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(CashTransaction.user_id == user_id)
|
||||
if biz_type:
|
||||
stmt = stmt.where(CashTransaction.biz_type == biz_type)
|
||||
return cursor_paginate(db, stmt, CashTransaction.id, limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def list_all_withdraw_orders(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[WithdrawOrder], int | None]:
|
||||
stmt = select(WithdrawOrder)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(WithdrawOrder.user_id == user_id)
|
||||
if status:
|
||||
stmt = stmt.where(WithdrawOrder.status == status)
|
||||
return cursor_paginate(db, stmt, WithdrawOrder.id, limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def list_feedbacks(
|
||||
db: Session,
|
||||
*,
|
||||
status: str | None = None,
|
||||
user_id: int | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[Feedback], int | None]:
|
||||
stmt = select(Feedback)
|
||||
if status:
|
||||
stmt = stmt.where(Feedback.status == status)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(Feedback.user_id == user_id)
|
||||
return cursor_paginate(db, stmt, Feedback.id, limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None:
|
||||
"""按商户单号查提现单(admin 重试打款先拿 user_id 用,M3)。"""
|
||||
return db.execute(
|
||||
select(WithdrawOrder).where(WithdrawOrder.out_bill_no == out_bill_no)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_user_overview(db: Session, user_id: int) -> dict | None:
|
||||
"""用户 360 概览:基础资料 + 钱包余额 + 各项 count。历史明细走各自分页接口(带 user_id 过滤)。"""
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
return None
|
||||
acc = db.get(CoinAccount, user_id) # 可能为 None(从未发生过金币动作)
|
||||
|
||||
def _count(model, *conds) -> int:
|
||||
return db.execute(select(func.count(model.id)).where(*conds)).scalar_one()
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
"coin_balance": acc.coin_balance if acc else 0,
|
||||
"cash_balance_cents": acc.cash_balance_cents if acc else 0,
|
||||
"total_coin_earned": acc.total_coin_earned if acc else 0,
|
||||
"comparison_total": _count(ComparisonRecord, ComparisonRecord.user_id == user_id),
|
||||
"comparison_success": _count(
|
||||
ComparisonRecord,
|
||||
ComparisonRecord.user_id == user_id,
|
||||
ComparisonRecord.status == "success",
|
||||
),
|
||||
"withdraw_total": _count(WithdrawOrder, WithdrawOrder.user_id == user_id),
|
||||
"withdraw_success_cents": db.execute(
|
||||
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
|
||||
WithdrawOrder.user_id == user_id, WithdrawOrder.status == "success"
|
||||
)
|
||||
).scalar_one(),
|
||||
"feedback_total": _count(Feedback, Feedback.user_id == user_id),
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"""admin 大盘聚合查询(全局 count/sum/DAU/成功率)。全部只读、不改任何数据。
|
||||
|
||||
⚠️ 性能:这些是全表 count/sum,P0 数据量小够用;用户量上来后热点字段(user.created_at /
|
||||
user.last_login_at / comparison_record.status / withdraw_order.status)要加索引,或改增量统计表。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinTransaction, WithdrawOrder
|
||||
|
||||
_BEIJING = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _beijing_today_start_utc() -> datetime:
|
||||
"""北京时间今天 0 点对应的 UTC 时刻(DAU / 今日新增按北京时区切天)。"""
|
||||
now_bj = datetime.now(_BEIJING)
|
||||
start_bj = now_bj.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return start_bj.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def dashboard_overview(db: Session) -> dict:
|
||||
today_start = _beijing_today_start_utc()
|
||||
|
||||
def _count(model, *conds) -> int:
|
||||
stmt = select(func.count(model.id))
|
||||
if conds:
|
||||
stmt = stmt.where(*conds)
|
||||
return db.execute(stmt).scalar_one()
|
||||
|
||||
def _sum(col, *conds) -> int:
|
||||
stmt = select(func.coalesce(func.sum(col), 0))
|
||||
if conds:
|
||||
stmt = stmt.where(*conds)
|
||||
return db.execute(stmt).scalar_one()
|
||||
|
||||
# ===== 用户 =====
|
||||
by_status = dict(
|
||||
db.execute(select(User.status, func.count(User.id)).group_by(User.status)).all()
|
||||
)
|
||||
|
||||
# ===== 提现状态分布 =====
|
||||
wd_by_status = dict(
|
||||
db.execute(
|
||||
select(WithdrawOrder.status, func.count(WithdrawOrder.id)).group_by(
|
||||
WithdrawOrder.status
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
# ===== 比价 =====
|
||||
comparison_total = _count(ComparisonRecord)
|
||||
comparison_success = _count(ComparisonRecord, ComparisonRecord.status == "success")
|
||||
success_rate = round(comparison_success / comparison_total, 4) if comparison_total else 0.0
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": _count(User),
|
||||
"active": by_status.get("active", 0),
|
||||
"disabled": by_status.get("disabled", 0),
|
||||
"deleted": by_status.get("deleted", 0),
|
||||
"new_today": _count(User, User.created_at >= today_start),
|
||||
"dau": _count(User, User.last_login_at >= today_start),
|
||||
},
|
||||
"coins": {
|
||||
# 累计发放金币(coin_transaction 里所有 amount>0 之和;负数是兑换/扣减不计)
|
||||
"granted_total": _sum(CoinTransaction.amount, CoinTransaction.amount > 0),
|
||||
},
|
||||
"cash": {
|
||||
"withdraw_success_cents": _sum(
|
||||
WithdrawOrder.amount_cents, WithdrawOrder.status == "success"
|
||||
),
|
||||
"withdraw_pending_count": wd_by_status.get("pending", 0),
|
||||
"withdraw_success_count": wd_by_status.get("success", 0),
|
||||
"withdraw_failed_count": wd_by_status.get("failed", 0),
|
||||
},
|
||||
"comparison": {
|
||||
"total": comparison_total,
|
||||
"success": comparison_success,
|
||||
"success_rate": success_rate,
|
||||
},
|
||||
"feedback": {"new": _count(Feedback, Feedback.status == "new")},
|
||||
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
|
||||
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""admin 路由(前缀统一 /admin/api)。"""
|
||||
@@ -0,0 +1,69 @@
|
||||
"""admin 账号管理(仅 super_admin):列表 / 创建 / 改角色启停重置密码。均写审计。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.admin.schemas.admin import AdminCreateRequest, AdminUpdateRequest
|
||||
from app.admin.schemas.auth import AdminOut
|
||||
from app.core.security import hash_password
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/admins",
|
||||
tags=["admin-accounts"],
|
||||
dependencies=[Depends(require_role())], # require_role() 无参 = 仅 super_admin 通过
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AdminOut], summary="管理员列表")
|
||||
def list_admins(db: AdminDb) -> list[AdminOut]:
|
||||
return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)]
|
||||
|
||||
|
||||
@router.post("", response_model=AdminOut, summary="创建管理员")
|
||||
def create_admin(
|
||||
body: AdminCreateRequest, request: Request, admin: CurrentAdmin, db: AdminDb
|
||||
) -> AdminOut:
|
||||
if admin_repo.get_by_username(db, body.username) is not None:
|
||||
raise HTTPException(status_code=409, detail="用户名已存在")
|
||||
new = admin_repo.create_admin(
|
||||
db, username=body.username, password=body.password, role=body.role
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="admin.create", target_type="admin", target_id=new.id,
|
||||
detail={"username": new.username, "role": new.role}, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return AdminOut.model_validate(new)
|
||||
|
||||
|
||||
@router.patch("/{admin_id}", response_model=AdminOut, summary="改角色/启停/重置密码")
|
||||
def update_admin(
|
||||
admin_id: int, body: AdminUpdateRequest, request: Request, admin: CurrentAdmin, db: AdminDb
|
||||
) -> AdminOut:
|
||||
target = admin_repo.get_by_id(db, admin_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="管理员不存在")
|
||||
if admin_id == admin.id and body.status == "disabled":
|
||||
raise HTTPException(status_code=400, detail="不能禁用自己")
|
||||
|
||||
changes: dict = {}
|
||||
if body.role is not None:
|
||||
target.role = body.role
|
||||
changes["role"] = body.role
|
||||
if body.status is not None:
|
||||
target.status = body.status
|
||||
changes["status"] = body.status
|
||||
if body.password is not None:
|
||||
target.password_hash = hash_password(body.password)
|
||||
changes["password"] = "reset"
|
||||
if not changes:
|
||||
raise HTTPException(status_code=400, detail="无任何变更字段")
|
||||
db.commit()
|
||||
db.refresh(target)
|
||||
write_audit(
|
||||
db, admin, action="admin.update", target_type="admin", target_id=admin_id,
|
||||
detail=changes, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return AdminOut.model_validate(target)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""admin 操作审计日志查询(所有 admin 可看:谁在何时对什么做了什么)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import audit_log as audit_repo
|
||||
from app.admin.schemas.admin import AdminAuditLogOut
|
||||
from app.admin.schemas.common import CursorPage
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/audit-logs",
|
||||
tags=["admin-audit"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=CursorPage[AdminAuditLogOut], summary="审计日志(谁改了什么)")
|
||||
def list_audit_logs(
|
||||
db: AdminDb,
|
||||
action: Annotated[str | None, Query()] = None,
|
||||
target_type: Annotated[str | None, Query()] = None,
|
||||
admin_id: Annotated[int | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[AdminAuditLogOut]:
|
||||
items, next_cursor = audit_repo.list_audit_logs(
|
||||
db, action=action, target_type=target_type, admin_id=admin_id, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[AdminAuditLogOut.model_validate(x) for x in items], next_cursor=next_cursor,
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Admin 认证:账号密码登录 → admin JWT(独立 secret)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.admin.deps import AdminDb, CurrentAdmin
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.admin.schemas.auth import AdminLoginRequest, AdminLoginResponse, AdminOut
|
||||
from app.admin.security import create_admin_token
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.core.security import verify_password
|
||||
|
||||
logger = logging.getLogger("shagua.admin.auth")
|
||||
|
||||
router = APIRouter(prefix="/admin/api/auth", tags=["admin-auth"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=AdminLoginResponse,
|
||||
summary="管理员登录",
|
||||
dependencies=[Depends(rate_limit(10, 60, "admin-login"))], # 同 IP 每分钟≤10 次,防爆破
|
||||
)
|
||||
def login(req: AdminLoginRequest, db: AdminDb) -> AdminLoginResponse:
|
||||
admin = admin_repo.get_by_username(db, req.username)
|
||||
# 用户名不存在 / 密码错统一 401 同文案(防账号枚举)。
|
||||
# disabled 账号单独 403"账号已禁用":admin 是内部少数已知账号、无枚举价值,
|
||||
# 明确提示比防枚举更有运维价值(与 App 端 auth.py 对 disabled 用户的 403 一致)。
|
||||
if admin is None or not verify_password(req.password, admin.password_hash):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
if admin.status != "active":
|
||||
raise HTTPException(status_code=403, detail="账号已禁用")
|
||||
|
||||
token, expires_in = create_admin_token(admin_id=admin.id, role=admin.role)
|
||||
admin_repo.update_last_login(db, admin)
|
||||
logger.info("admin login ok id=%d username=%s role=%s", admin.id, admin.username, admin.role)
|
||||
return AdminLoginResponse(
|
||||
access_token=token,
|
||||
expires_in=expires_in,
|
||||
admin=AdminOut.model_validate(admin),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=AdminOut, summary="当前管理员")
|
||||
def me(admin: CurrentAdmin) -> AdminOut:
|
||||
return AdminOut.model_validate(admin)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""admin 数据大盘(只读聚合)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import stats
|
||||
from app.admin.schemas.dashboard import DashboardOverview
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/stats",
|
||||
tags=["admin-stats"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/overview", response_model=DashboardOverview, summary="大盘核心指标")
|
||||
def overview(db: AdminDb) -> DashboardOverview:
|
||||
return DashboardOverview.model_validate(stats.dashboard_overview(db))
|
||||
@@ -0,0 +1,56 @@
|
||||
"""admin 反馈工单:列表(读)+ 标记已处理(写,带审计)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.repositories import mutations, queries
|
||||
from app.admin.schemas.common import CursorPage, OkResponse
|
||||
from app.admin.schemas.feedback import FeedbackOut
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.feedback import Feedback
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/feedbacks",
|
||||
tags=["admin-feedback"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=CursorPage[FeedbackOut], summary="反馈工单列表")
|
||||
def list_feedbacks(
|
||||
db: AdminDb,
|
||||
status: Annotated[str | None, Query()] = None,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[FeedbackOut]:
|
||||
items, next_cursor = queries.list_feedbacks(
|
||||
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[FeedbackOut.model_validate(f) for f in items], next_cursor=next_cursor,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
|
||||
def handle_feedback(
|
||||
feedback_id: int,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
fb = db.get(Feedback, feedback_id)
|
||||
if fb is None:
|
||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||
before = fb.status
|
||||
mutations.update_feedback_status(db, fb, status="handled", commit=False)
|
||||
write_audit(
|
||||
db, admin, action="feedback.handle", target_type="feedback", target_id=feedback_id,
|
||||
detail={"before": before, "after": "handled"}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""admin 用户管理:列表 + 360 详情(读)+ 封禁/解封 + 手动调金币(写,带审计)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.repositories import mutations, queries
|
||||
from app.admin.schemas.common import CursorPage, OkResponse
|
||||
from app.admin.schemas.user import (
|
||||
AdminUserListItem,
|
||||
AdminUserOverview,
|
||||
GrantCoinsRequest,
|
||||
SetUserStatusRequest,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
from app.repositories import user as user_repo
|
||||
from app.repositories import wallet as wallet_repo
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/users",
|
||||
tags=["admin-users"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+分页)")
|
||||
def list_users(
|
||||
db: AdminDb,
|
||||
phone: Annotated[str | None, Query()] = None,
|
||||
register_channel: Annotated[str | None, Query()] = None,
|
||||
status: Annotated[str | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[AdminUserListItem]:
|
||||
items, next_cursor = queries.list_users(
|
||||
db, phone=phone, register_channel=register_channel, status=status,
|
||||
limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[AdminUserListItem.model_validate(u) for u in items],
|
||||
next_cursor=next_cursor,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=AdminUserOverview, summary="用户 360 详情")
|
||||
def get_user(user_id: int, db: AdminDb) -> AdminUserOverview:
|
||||
overview = queries.get_user_overview(db, user_id)
|
||||
if overview is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return AdminUserOverview.model_validate(overview)
|
||||
|
||||
|
||||
@router.post("/{user_id}/status", response_model=OkResponse, summary="封禁/解封用户")
|
||||
def set_user_status(
|
||||
user_id: int,
|
||||
body: SetUserStatusRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
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="已注销账号不可改状态")
|
||||
before = user.status
|
||||
# 业务写 + 审计写同一事务(commit=False),最后一起 commit:改了就有痕、有痕就真改了
|
||||
mutations.set_user_status(db, user, status=body.status, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="user.status.set", target_type="user", target_id=user_id,
|
||||
detail={"before": before, "after": body.status}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)")
|
||||
def grant_user_coins(
|
||||
user_id: int,
|
||||
body: GrantCoinsRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
if body.amount == 0:
|
||||
raise HTTPException(status_code=400, detail="amount 不能为 0")
|
||||
user = user_repo.get_user_by_id(db, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
# 负数扣减时不允许扣成负余额(运营误操作保护)
|
||||
if body.amount < 0:
|
||||
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False)
|
||||
if acc_now.coin_balance + body.amount < 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})"
|
||||
)
|
||||
biz_type = "admin_grant" if body.amount > 0 else "admin_deduct"
|
||||
# grant_coins 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
|
||||
acc, _ = wallet_repo.grant_coins(
|
||||
db, user_id, body.amount, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="user.coins.grant", target_type="user", target_id=user_id,
|
||||
detail={"amount": body.amount, "balance_after": acc.coin_balance, "reason": body.reason},
|
||||
ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""admin 钱包:金币流水 + 现金流水(跨用户,可按 user_id 过滤)。手动调金币见 M3。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import queries
|
||||
from app.admin.schemas.common import CursorPage
|
||||
from app.admin.schemas.wallet import CashTxnOut, CoinTxnOut
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/wallet",
|
||||
tags=["admin-wallet"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/coin-transactions", response_model=CursorPage[CoinTxnOut], summary="金币流水")
|
||||
def coin_transactions(
|
||||
db: AdminDb,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
biz_type: Annotated[str | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[CoinTxnOut]:
|
||||
items, next_cursor = queries.list_all_coin_transactions(
|
||||
db, user_id=user_id, biz_type=biz_type, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[CoinTxnOut.model_validate(t) for t in items], next_cursor=next_cursor,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cash-transactions", response_model=CursorPage[CashTxnOut], summary="现金流水")
|
||||
def cash_transactions(
|
||||
db: AdminDb,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
biz_type: Annotated[str | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[CashTxnOut]:
|
||||
items, next_cursor = queries.list_all_cash_transactions(
|
||||
db, user_id=user_id, biz_type=biz_type, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[CashTxnOut.model_validate(t) for t in items], next_cursor=next_cursor,
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""admin 提现:列表(读)+ 单笔重试查单 + 批量对账(写,带审计)。
|
||||
|
||||
提现的钱逻辑(查微信/退款/撤单/幂等)全部复用 app.repositories.wallet,admin 只触发 + 记审计。
|
||||
这些 wallet 函数内部各自 commit(涉及微信调用),审计在其后单独 commit:操作本身幂等,
|
||||
审计记录"谁触发的 + 结果",事务边界比"改金币"宽松是有意的(不能塞进 wallet 的自有事务)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.repositories import queries
|
||||
from app.admin.schemas.common import CursorPage
|
||||
from app.admin.schemas.wallet import ReconcileResult, WithdrawOrderOut
|
||||
from app.integrations import wxpay
|
||||
from app.models.admin import AdminUser
|
||||
from app.repositories import wallet as wallet_repo
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/withdraws",
|
||||
tags=["admin-withdraw"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=CursorPage[WithdrawOrderOut], summary="提现单列表")
|
||||
def list_withdraws(
|
||||
db: AdminDb,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
status: Annotated[str | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[WithdrawOrderOut]:
|
||||
items, next_cursor = queries.list_all_withdraw_orders(
|
||||
db, user_id=user_id, status=status, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[WithdrawOrderOut.model_validate(o) for o in items], next_cursor=next_cursor,
|
||||
)
|
||||
|
||||
|
||||
# 注意:/reconcile 必须在 /{out_bill_no}/refresh 之前声明(静态路径优先于路径参数)
|
||||
@router.post("/reconcile", response_model=ReconcileResult, summary="批量对账(扫超时 pending 单)")
|
||||
def reconcile(
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
older_than_minutes: Annotated[int, Query(ge=0)] = 15,
|
||||
) -> ReconcileResult:
|
||||
try:
|
||||
result = wallet_repo.reconcile_pending_withdraws(db, older_than_minutes=older_than_minutes)
|
||||
except wxpay.WxPayNotConfiguredError as e:
|
||||
raise HTTPException(status_code=503, detail="微信支付未配置") from e
|
||||
write_audit(
|
||||
db, admin, action="withdraw.reconcile", target_type="withdraw", target_id=None,
|
||||
detail=result, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return ReconcileResult(**result)
|
||||
|
||||
|
||||
@router.post("/{out_bill_no}/refresh", response_model=WithdrawOrderOut, summary="单笔重试查单")
|
||||
def refresh_withdraw(
|
||||
out_bill_no: str,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
) -> WithdrawOrderOut:
|
||||
order = queries.get_withdraw_by_out_bill_no(db, out_bill_no)
|
||||
if order is None:
|
||||
raise HTTPException(status_code=404, detail="提现单不存在")
|
||||
try:
|
||||
refreshed = wallet_repo.refresh_withdraw_status(
|
||||
db, order.user_id, out_bill_no, cancel_if_unconfirmed=True,
|
||||
)
|
||||
except wxpay.WxPayNotConfiguredError as e:
|
||||
raise HTTPException(status_code=503, detail="微信支付未配置") from e
|
||||
write_audit(
|
||||
db, admin, action="withdraw.refresh", target_type="withdraw", target_id=out_bill_no,
|
||||
detail={"status": refreshed.status, "wechat_state": refreshed.wechat_state},
|
||||
ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return WithdrawOrderOut.model_validate(refreshed)
|
||||
@@ -0,0 +1 @@
|
||||
"""admin 请求/响应 schemas(snake_case,与前端约定一致)。"""
|
||||
@@ -0,0 +1,37 @@
|
||||
"""admin 账号管理 + 审计日志 schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
_Role = Literal["super_admin", "finance", "operator"]
|
||||
|
||||
|
||||
class AdminCreateRequest(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=64)
|
||||
password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节
|
||||
role: _Role = "operator"
|
||||
|
||||
|
||||
class AdminUpdateRequest(BaseModel):
|
||||
"""改角色 / 启用禁用 / 重置密码,字段都可选(只改传了的)。"""
|
||||
|
||||
role: _Role | None = None
|
||||
status: Literal["active", "disabled"] | None = None
|
||||
password: str | None = Field(None, min_length=8, max_length=72)
|
||||
|
||||
|
||||
class AdminAuditLogOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
admin_id: int
|
||||
admin_username: str
|
||||
action: str
|
||||
target_type: str
|
||||
target_id: str | None = None
|
||||
detail: dict | None = None
|
||||
ip: str | None = None
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Admin 认证 schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class AdminLoginRequest(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=64)
|
||||
password: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class AdminOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
username: str
|
||||
role: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
last_login_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminLoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int = Field(..., description="access_token 剩余秒数(过期重新登录)")
|
||||
admin: AdminOut
|
||||
@@ -0,0 +1,19 @@
|
||||
"""通用 schema:游标分页响应 + 通用 ok 响应。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class CursorPage(BaseModel, Generic[T]):
|
||||
"""游标分页响应:items + 下一页游标(next_cursor=None 表示末页)。"""
|
||||
|
||||
items: list[T]
|
||||
next_cursor: int | None = None
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
@@ -0,0 +1,48 @@
|
||||
"""admin 大盘 schemas(对应 stats.dashboard_overview 的嵌套结构)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DashboardUsers(BaseModel):
|
||||
total: int
|
||||
active: int
|
||||
disabled: int
|
||||
deleted: int
|
||||
new_today: int
|
||||
dau: int
|
||||
|
||||
|
||||
class DashboardCoins(BaseModel):
|
||||
granted_total: int
|
||||
|
||||
|
||||
class DashboardCash(BaseModel):
|
||||
withdraw_success_cents: int
|
||||
withdraw_pending_count: int
|
||||
withdraw_success_count: int
|
||||
withdraw_failed_count: int
|
||||
|
||||
|
||||
class DashboardComparison(BaseModel):
|
||||
total: int
|
||||
success: int
|
||||
success_rate: float
|
||||
|
||||
|
||||
class DashboardFeedback(BaseModel):
|
||||
new: int
|
||||
|
||||
|
||||
class DashboardCps(BaseModel):
|
||||
available: bool
|
||||
note: str
|
||||
|
||||
|
||||
class DashboardOverview(BaseModel):
|
||||
users: DashboardUsers
|
||||
coins: DashboardCoins
|
||||
cash: DashboardCash
|
||||
comparison: DashboardComparison
|
||||
feedback: DashboardFeedback
|
||||
cps: DashboardCps
|
||||
@@ -0,0 +1,18 @@
|
||||
"""admin 反馈工单 schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class FeedbackOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
content: str
|
||||
contact: str
|
||||
images: list[str] | None = None
|
||||
status: str
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,47 @@
|
||||
"""admin 用户管理 schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class AdminUserListItem(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
phone: str
|
||||
nickname: str | None = None
|
||||
register_channel: str
|
||||
status: str
|
||||
wechat_openid: str | None = None
|
||||
created_at: datetime
|
||||
last_login_at: datetime
|
||||
|
||||
|
||||
class AdminUserOverview(BaseModel):
|
||||
"""用户 360 概览:基础资料 + 钱包余额 + 各项 count(历史明细走各自分页接口)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
user: AdminUserListItem
|
||||
coin_balance: int
|
||||
cash_balance_cents: int
|
||||
total_coin_earned: int
|
||||
comparison_total: int
|
||||
comparison_success: int
|
||||
withdraw_total: int
|
||||
withdraw_success_cents: int
|
||||
feedback_total: int
|
||||
|
||||
|
||||
class GrantCoinsRequest(BaseModel):
|
||||
amount: int = Field(..., description="金币变动:正=增加,负=扣减(不可为 0)")
|
||||
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
|
||||
|
||||
|
||||
class SetUserStatusRequest(BaseModel):
|
||||
status: Literal["active", "disabled"] = Field(
|
||||
..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)"
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""admin 钱包(金币/现金流水 + 提现单)schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class CoinTxnOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
amount: int
|
||||
balance_after: int
|
||||
biz_type: str
|
||||
ref_id: str | None = None
|
||||
remark: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CashTxnOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
amount_cents: int
|
||||
balance_after_cents: int
|
||||
biz_type: str
|
||||
ref_id: str | None = None
|
||||
remark: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class WithdrawOrderOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
out_bill_no: str
|
||||
amount_cents: int
|
||||
status: str
|
||||
wechat_state: str | None = None
|
||||
transfer_bill_no: str | None = None
|
||||
fail_reason: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ReconcileResult(BaseModel):
|
||||
checked: int
|
||||
resolved: int
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Admin JWT:与 App 用户 token 完全隔离。
|
||||
|
||||
用独立 secret(settings.ADMIN_JWT_SECRET ≠ JWT_SECRET_KEY)+ payload typ="admin",
|
||||
双重保证 App 用户的 access_token 无法当 admin token 用(secret 不同直接验签失败)。
|
||||
admin 无 refresh:过期(默认 12h)重新登录,简单。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_ALGO = "HS256"
|
||||
|
||||
|
||||
class AdminTokenError(Exception):
|
||||
"""admin token 解析/校验失败,api 层捕获后返回 401。"""
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def create_admin_token(*, admin_id: int, role: str) -> tuple[str, int]:
|
||||
"""签发 admin access token,返回 (token, expires_in_seconds)。"""
|
||||
now = _now()
|
||||
expire = now + timedelta(minutes=settings.ADMIN_JWT_EXPIRE_MINUTES)
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(admin_id),
|
||||
"role": role,
|
||||
"typ": "admin",
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expire.timestamp()),
|
||||
}
|
||||
token = jwt.encode(payload, settings.ADMIN_JWT_SECRET, algorithm=_ALGO)
|
||||
return token, int((expire - now).total_seconds())
|
||||
|
||||
|
||||
def decode_admin_token(token: str) -> dict[str, Any]:
|
||||
"""解析校验 admin token。签名错/过期/typ 非 admin → AdminTokenError。"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.ADMIN_JWT_SECRET, algorithms=[_ALGO])
|
||||
except jwt.ExpiredSignatureError as e:
|
||||
raise AdminTokenError("token expired") from e
|
||||
except jwt.InvalidTokenError as e:
|
||||
raise AdminTokenError(f"invalid token: {e}") from e
|
||||
|
||||
if payload.get("typ") != "admin":
|
||||
raise AdminTokenError("not an admin token")
|
||||
if "sub" not in payload:
|
||||
raise AdminTokenError("token missing sub")
|
||||
return payload
|
||||
Reference in New Issue
Block a user