Files
video/server/app/services/admin_service.py
T
2026-10-16 16:05:01 +08:00

1324 lines
50 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.
"""后台管理服务:仪表盘 / 审核 / 用户 / 积分 / 订单 / 套餐 / 赠送 / 提现 / 推荐 / 配置 / 举报 / 敏感词
(对应《03-接口文档》第 11 章;所有写操作落 admin_logs)"""
import logging
logger = logging.getLogger(__name__)
from datetime import date, datetime, time
from decimal import Decimal
from pathlib import Path
import redis.asyncio as aioredis
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import biz_config, settings
from app.core import errors
from app.core.response import BizException
from app.core.security import create_admin_token, hash_password, verify_password
from app.deps import AdminIdentity
from app.models.admin import AdminLog, AdminUser
from app.models.billing import PointTransaction, RechargeOrder, RechargePackage, WatchRecord
from app.models.common import Report, SensitiveWord, SystemConfig
from app.models.incentive import AssetTransaction, Referral, Withdrawal
from app.models.interaction import Comment
from app.models.user import User, UserMembership, UserWallet
from app.models.video import Video
from app.services import (
common_service,
config_service,
incentive_service,
interaction_service,
membership_service,
payment_service,
sensitive_service,
video_service,
wallet_service,
)
USER_STATUS_TEXT = {1: "正常", 2: "封禁", 3: "已注销"}
REPORT_STATUS_TEXT = {0: "待处理", 1: "已处理", 2: "已驳回"}
REFERRAL_STATUS_TEXT = incentive_service.REFERRAL_STATUS_TEXT
WITHDRAW_STATUS_TEXT = incentive_service.WITHDRAW_STATUS_TEXT
# 允许后台修改的配置键白名单
CONFIG_WHITELIST = {
"register_gift_trial.enabled",
"register_gift_trial.days",
"recharge.custom.enabled",
"recharge.custom.min_yuan",
"recharge.custom.max_yuan",
"app.invite_base_url",
"app.download_url",
"app.version.android",
"app.version.ios",
"ios_payment.enabled",
"sensitive.filter.enabled",
}
def _fmt(dt: datetime | None) -> str | None:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
def _points_per_yuan() -> int:
return int((biz_config.get("points") or {}).get("points_per_yuan", 10))
async def _log(
db: AsyncSession, admin_id: int, action: str, target: str = "", detail: str = "", ip: str = ""
) -> None:
"""操作日志:随当前事务一并提交"""
db.add(AdminLog(admin_id=admin_id, action=action, target=target, detail=detail[:1000], ip=ip))
# ---------- 11.1 登录 ----------
async def login(db: AsyncSession, req, ip: str) -> dict:
admin = (await db.execute(
select(AdminUser).where(AdminUser.username == req.username)
)).scalar_one_or_none()
if admin is None or not verify_password(req.password, admin.password_hash):
raise BizException(errors.PARAM_ERROR, "用户名或密码错误")
if admin.status != 1:
raise BizException(errors.ADMIN_FORBIDDEN, "账号已禁用", http_status=403)
token, _, _ = create_admin_token(admin.id, admin.role)
admin.last_login_at = datetime.now()
await _log(db, admin.id, "login", ip=ip)
await db.commit()
return {
"admin_token": token,
"expires_in": settings.admin_token_ttl,
"admin": {"id": admin.id, "name": admin.name, "role": admin.role},
}
# ---------- 11.2 仪表盘 ----------
async def dashboard(db: AsyncSession) -> dict:
day_start = datetime.combine(date.today(), time.min)
dau = (await db.execute(
select(func.count(func.distinct(WatchRecord.subject_id)))
.where(WatchRecord.created_at >= day_start)
)).scalar() or 0
new_users = (await db.execute(
select(func.count(User.id)).where(User.created_at >= day_start)
)).scalar() or 0
recharge_fen = (await db.execute(
select(func.coalesce(func.sum(RechargeOrder.amount_fen), 0)).where(
RechargeOrder.status == 1, RechargeOrder.paid_at >= day_start
)
)).scalar() or 0
points_issued = (await db.execute(
select(func.coalesce(func.sum(PointTransaction.change_amount), 0)).where(
PointTransaction.created_at >= day_start, PointTransaction.change_amount > 0
)
)).scalar() or 0
points_consumed = (await db.execute(
select(func.coalesce(func.sum(PointTransaction.change_amount), 0)).where(
PointTransaction.created_at >= day_start, PointTransaction.change_amount < 0
)
)).scalar() or 0
views = (await db.execute(
select(func.count(WatchRecord.id)).where(WatchRecord.created_at >= day_start)
)).scalar() or 0
pending = (await db.execute(
select(func.count(Video.id)).where(Video.status == 1)
)).scalar() or 0
total_users = (await db.execute(select(func.count(User.id)))).scalar() or 0
total_videos = (await db.execute(select(func.count(Video.id)))).scalar() or 0
total_recharge_fen = (await db.execute(
select(func.coalesce(func.sum(RechargeOrder.amount_fen), 0)).where(RechargeOrder.status == 1)
)).scalar() or 0
return {
"today": {
"dau": dau,
"new_users": new_users,
"recharge_yuan": float(Decimal(recharge_fen) / 100),
"points_issued": int(points_issued),
"points_consumed": int(-points_consumed),
"views": views,
},
"pending_review_count": pending,
"total": {
"users": total_users,
"videos": total_videos,
"recharge_yuan": float(Decimal(total_recharge_fen) / 100),
},
}
# ---------- 视频审核 / 管理 ----------
def _video_item(video: Video, author: User | None) -> dict:
return {
"video_id": video.id,
"title": video.title,
"description": video.description,
"cover_url": video.cover_url,
"video_url": video.video_url,
"duration": video.duration,
"category_id": video.category_id or 0,
"series_id": video.series_id,
"episode_no": video.episode_no,
"status": video.status,
"status_text": video_service.STATUS_TEXT.get(video.status, ""),
"reject_reason": video.reject_reason,
"view_count": video.view_count,
"like_count": video.like_count,
"comment_count": video.comment_count,
"favorite_count": video.favorite_count,
"author": {
"user_id": author.id,
"nickname": author.nickname,
"avatar_url": author.avatar_url,
} if author else None,
"published_at": _fmt(video.published_at),
"created_at": _fmt(video.created_at),
}
async def _authors_map(db: AsyncSession, videos: list[Video]) -> dict[int, User]:
if not videos:
return {}
return {
u.id: u
for u in (await db.execute(
select(User).where(User.id.in_({v.user_id for v in videos}))
)).scalars()
}
async def list_pending_videos(db: AsyncSession, page: int, page_size: int) -> dict:
"""待审核视频(11.3):先传先审"""
stmt = select(Video).where(Video.status == 1)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (await db.execute(
stmt.order_by(Video.id).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
authors = await _authors_map(db, rows)
return {"total": total, "list": [_video_item(v, authors.get(v.user_id)) for v in rows]}
async def list_videos(
db: AsyncSession, keyword: str | None, status: int | None, author_id: int | None,
page: int, page_size: int,
) -> dict:
"""视频列表(11.25)"""
stmt = select(Video)
if keyword:
cond = Video.title.like(f"%{keyword}%")
stmt = stmt.where(or_(cond, Video.id == int(keyword)) if keyword.isdigit() else cond)
if status is not None:
stmt = stmt.where(Video.status == status)
if author_id is not None:
stmt = stmt.where(Video.user_id == author_id)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (await db.execute(
stmt.order_by(Video.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
authors = await _authors_map(db, rows)
return {"total": total, "list": [_video_item(v, authors.get(v.user_id)) for v in rows]}
async def review_video(
db: AsyncSession, redis: aioredis.Redis, admin: AdminIdentity, video_id: int, req, ip: str
) -> dict:
"""审核视频(11.4):通过 → 发布 + 通知作者;拒绝 → 置拒绝态 + 原因回显"""
video = await db.get(Video, video_id)
if video is None:
raise BizException(errors.NOT_FOUND, "视频不存在")
if video.status != 1:
raise BizException(errors.PARAM_ERROR, "视频不在待审核状态")
if req.action == "reject" and not (req.reason or "").strip():
raise BizException(errors.REASON_REQUIRED)
if req.action == "approve":
# HLS 转码 + 封面抽帧(同步阻塞 → 线程池)
trans_ok = True
trans_err = ""
m3u8_url = video.video_url # 兜底:保留原 URL
cover_url = video.cover_url
duration = video.duration
try:
from app.services import transcode_service
src_full = transcode_service.resolve_source_path(video.video_url)
if src_full:
tc = await transcode_service.transcode_to_hls(src_full, video.id)
m3u8_url = tc["m3u8_url"]
duration = tc["duration"] or video.duration
if tc.get("cover_url"):
cover_url = tc["cover_url"]
else:
logger.warning("[transcode] video_id=%d 源文件无法还原: video_url=%s(保留原 URL)",
video.id, video.video_url)
except Exception as e:
trans_ok, trans_err = False, str(e)
logger.error("[transcode] video_id=%d 转码失败: %s(仍发布,保留原 URL)", video.id, e, exc_info=True)
video.video_url = m3u8_url
video.cover_url = cover_url
video.duration = duration
video.status = 2
video.published_at = datetime.now()
notify_title, notify_content = "视频审核通过", f"你的视频《{video.title}》已通过审核并发布"
if not trans_ok:
notify_content += "(转码暂未完成,稍候刷新可见)"
else:
video.status = 3
video.reject_reason = req.reason.strip()
notify_title = "视频审核未通过"
notify_content = f"你的视频《{video.title}》未通过审核:{video.reject_reason}"
video.reviewer_id = admin.admin_id
video.reviewed_at = datetime.now()
await common_service.notify(
db, redis, video.user_id, 1, notify_title, notify_content, biz_id=str(video.id)
)
await _log(db, admin.admin_id, "video_review", target=str(video.id),
detail=f"{req.action} {req.reason or ''}".strip(), ip=ip)
await db.commit()
await video_service.invalidate_detail_cache(redis, video.id)
return {
"video_id": video.id,
"status": video.status,
"status_text": video_service.STATUS_TEXT[video.status],
}
async def offline_video(
db: AsyncSession, redis: aioredis.Redis, admin: AdminIdentity,
video_id: int, reason: str, ip: str,
) -> None:
"""下架视频(11.5【敏感】):置下架态 + 通知作者 + 详情缓存失效"""
video = await db.get(Video, video_id)
if video is None:
raise BizException(errors.NOT_FOUND, "视频不存在")
if video.status != 2:
raise BizException(errors.PARAM_ERROR, "仅已发布视频可下架")
video.status = 4
video.reject_reason = reason
video.reviewer_id = admin.admin_id
video.reviewed_at = datetime.now()
await common_service.notify(
db, redis, video.user_id, 1, "视频已被下架",
f"你的视频《{video.title}》已被下架:{reason}", biz_id=str(video.id),
)
await _log(db, admin.admin_id, "video_offline", target=str(video.id), detail=reason, ip=ip)
await db.commit()
await video_service.invalidate_detail_cache(redis, video.id)
# ---------- 用户管理 ----------
async def list_users(
db: AsyncSession, keyword: str | None, status: int | None, page: int, page_size: int
) -> dict:
"""用户列表(11.6):基础信息 + 积分 + 会员 + 作品数"""
stmt = select(User)
if keyword:
cond = or_(User.phone.like(f"%{keyword}%"), User.email.like(f"%{keyword}%"))
if keyword.isdigit():
cond = or_(cond, User.id == int(keyword))
stmt = stmt.where(cond)
if status is not None:
stmt = stmt.where(User.status == status)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (await db.execute(
stmt.order_by(User.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
if not rows:
return {"total": total, "list": []}
user_ids = [u.id for u in rows]
wallets = {
w.user_id: w
for w in (await db.execute(select(UserWallet).where(UserWallet.user_id.in_(user_ids)))).scalars()
}
memberships = {
m.user_id: m
for m in (await db.execute(
select(UserMembership).where(UserMembership.user_id.in_(user_ids))
)).scalars()
}
video_counts = dict((await db.execute(
select(Video.user_id, func.count()).where(Video.user_id.in_(user_ids)).group_by(Video.user_id)
)).all())
return {
"total": total,
"list": [
{
"user_id": u.id,
"phone": u.phone,
"email": u.email,
"nickname": u.nickname,
"avatar_url": u.avatar_url,
"status": u.status,
"status_text": USER_STATUS_TEXT.get(u.status, ""),
"register_type": u.register_type,
"invite_code": u.invite_code,
"points": wallets[u.id].points if u.id in wallets else 0,
"is_member": bool(
u.id in memberships and membership_service.membership_active(memberships[u.id])
),
"video_count": video_counts.get(u.id, 0),
"last_login_at": _fmt(u.last_login_at),
"created_at": _fmt(u.created_at),
}
for u in rows
],
}
async def get_user_detail(db: AsyncSession, user_id: int) -> dict:
"""用户详情(11.7):信息 + 钱包 + 会员 + 近 10 条积分流水 + 作品数/获赞数"""
user = await db.get(User, user_id)
if user is None:
raise BizException(errors.NOT_FOUND, "用户不存在")
wallet = await wallet_service.get_or_create_wallet(db, user_id)
ms = await db.get(UserMembership, user_id)
active = ms is not None and membership_service.membership_active(ms)
records = (await db.execute(
select(PointTransaction).where(PointTransaction.user_id == user_id)
.order_by(PointTransaction.id.desc()).limit(10)
)).scalars().all()
video_count = (await db.execute(
select(func.count(Video.id)).where(Video.user_id == user_id)
)).scalar() or 0
like_received = (await db.execute(
select(func.coalesce(func.sum(Video.like_count), 0)).where(Video.user_id == user_id)
)).scalar() or 0
return {
"user_id": user.id,
"phone": user.phone,
"email": user.email,
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"gender": user.gender,
"bio": user.bio,
"status": user.status,
"status_text": USER_STATUS_TEXT.get(user.status, ""),
"register_type": user.register_type,
"invite_code": user.invite_code,
"register_ip": user.register_ip,
"last_login_at": _fmt(user.last_login_at),
"created_at": _fmt(user.created_at),
"wallet": {
"points": wallet.points,
"total_recharge": wallet.total_recharge,
"total_earned": wallet.total_earned,
"total_consumed": wallet.total_consumed,
},
"membership": {
"is_member": active,
"is_lifetime": bool(ms.is_lifetime) if ms else False,
"current_plan": ms.current_plan if active else None,
"expire_at": _fmt(ms.expire_at) if active and ms else None,
},
"video_count": video_count,
"like_received": int(like_received),
"recent_point_records": [
{
"id": r.id,
"change_amount": r.change_amount,
"balance_after": r.balance_after,
"biz_type": r.biz_type,
"remark": r.remark,
"created_at": _fmt(r.created_at),
}
for r in records
],
}
async def set_user_status(
db: AsyncSession, redis: aioredis.Redis, admin: AdminIdentity,
user_id: int, req, ip: str,
) -> dict:
"""封禁/解封(11.8【敏感】):封禁即递增令牌纪元,全端 Token 立即失效"""
user = await db.get(User, user_id)
if user is None:
raise BizException(errors.NOT_FOUND, "用户不存在")
if user.status == 3:
raise BizException(errors.PARAM_ERROR, "账号已注销,不可操作")
if user.status == req.status:
raise BizException(errors.PARAM_ERROR, "用户已处于目标状态")
user.status = req.status
if req.status == 2:
await redis.incr(f"auth:epoch:{user_id}")
await _log(db, admin.admin_id, "user_status", target=str(user_id),
detail=f"status={req.status} {req.reason}", ip=ip)
await db.commit()
return {"user_id": user_id, "status": req.status, "status_text": USER_STATUS_TEXT[req.status]}
# ---------- 积分 ----------
async def adjust_points(
db: AsyncSession, admin: AdminIdentity, req, ip: str
) -> dict:
"""积分调整(11.9【敏感】):走钱包统一入口写流水"""
if req.change_amount == 0:
raise BizException(errors.PARAM_ERROR, "调整金额不能为 0")
user = await db.get(User, req.user_id)
if user is None:
raise BizException(errors.NOT_FOUND, "用户不存在")
balance = await wallet_service.change_points(
db, req.user_id, req.change_amount, wallet_service.BIZ_ADJUST,
biz_id=f"admin:{admin.admin_id}", remark=f"后台调整:{req.reason}",
)
await _log(db, admin.admin_id, "points_adjust", target=str(req.user_id),
detail=f"{req.change_amount} {req.reason}", ip=ip)
await db.commit()
return {"user_id": req.user_id, "change_amount": req.change_amount, "balance_after": balance}
async def list_point_records(
db: AsyncSession, user_id: int | None, biz_type: int | None, page: int, page_size: int
) -> dict:
"""积分流水查询(11.10)"""
stmt = select(PointTransaction)
if user_id is not None:
stmt = stmt.where(PointTransaction.user_id == user_id)
if biz_type is not None:
stmt = stmt.where(PointTransaction.biz_type == biz_type)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (await db.execute(
stmt.order_by(PointTransaction.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
return {
"total": total,
"list": [
{
"id": r.id,
"user_id": r.user_id,
"change_amount": r.change_amount,
"balance_after": r.balance_after,
"biz_type": r.biz_type,
"biz_id": r.biz_id,
"remark": r.remark,
"created_at": _fmt(r.created_at),
}
for r in rows
],
}
# ---------- 订单 / 套餐 ----------
def _order_item(o: RechargeOrder) -> dict:
return {
"order_no": o.order_no,
"user_id": o.user_id,
"amount_yuan": float(Decimal(o.amount_fen) / 100),
"points": o.points,
"pay_method": o.pay_method,
"status": o.status,
"status_text": payment_service.ORDER_STATUS_TEXT.get(o.status, ""),
"third_trade_no": o.third_trade_no,
"usdt_amount": str(o.usdt_amount) if o.usdt_amount is not None else None,
"usdt_rate": float(o.usdt_rate) if o.usdt_rate is not None else None,
"paid_at": _fmt(o.paid_at),
"expire_at": _fmt(o.expire_at),
"created_at": _fmt(o.created_at),
}
async def list_orders(
db: AsyncSession, order_no: str | None, user_id: int | None, pay_method: int | None,
status: int | None, start_date: str | None, end_date: str | None, page: int, page_size: int,
) -> dict:
"""充值订单查询(11.11)"""
stmt = select(RechargeOrder)
if order_no:
stmt = stmt.where(RechargeOrder.order_no == order_no)
if user_id is not None:
stmt = stmt.where(RechargeOrder.user_id == user_id)
if pay_method is not None:
stmt = stmt.where(RechargeOrder.pay_method == pay_method)
if status is not None:
stmt = stmt.where(RechargeOrder.status == status)
if start_date:
stmt = stmt.where(RechargeOrder.created_at >= f"{start_date} 00:00:00")
if end_date:
stmt = stmt.where(RechargeOrder.created_at <= f"{end_date} 23:59:59")
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (await db.execute(
stmt.order_by(RechargeOrder.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
return {"total": total, "list": [_order_item(o) for o in rows]}
async def resettle_order(
db: AsyncSession, admin: AdminIdentity, order_no: str, req, ip: str
) -> dict:
"""人工补单(11.12【敏感】):核实收款后走与回调相同的幂等入账流程"""
order = (await db.execute(
select(RechargeOrder).where(RechargeOrder.order_no == order_no)
)).scalar_one_or_none()
if order is None:
raise BizException(errors.ORDER_NOT_FOUND)
if order.status != 0:
raise BizException(errors.ORDER_STATUS_ERROR, "仅待支付订单可补单")
third_trade_no = req.third_trade_no or f"MANUAL-{order_no}"
order = await payment_service.settle_order(db, order_no, third_trade_no)
await _log(db, admin.admin_id, "order_resettle", target=order_no,
detail=f"{third_trade_no} {req.reason}", ip=ip)
await db.commit()
return {"order_no": order_no, "status": order.status, "points": order.points}
async def refund_order(db: AsyncSession, admin: AdminIdentity, order_no: str, req, ip: str) -> dict:
"""退款登记(11.12-2【敏感】):已支付订单 → 已退款;事务内扣回已发积分 + 该单推荐返利资产。
积分/资产扣回均允许余额为负(用户可能已消耗),由对账兜底。
"""
order = (await db.execute(
select(RechargeOrder).where(RechargeOrder.order_no == order_no).with_for_update()
)).scalar_one_or_none()
if order is None:
raise BizException(errors.ORDER_NOT_FOUND)
if order.status != 1:
raise BizException(errors.ORDER_STATUS_ERROR, "仅已支付订单可退款")
order.status = 3
# 扣回该单已发积分
new_points = await wallet_service.change_points(
db, order.user_id, -order.points, wallet_service.BIZ_CLAWBACK,
biz_id=order_no, remark="充值退款扣回", allow_negative=True,
)
# 扣回该单产生的推荐返利资产(反向流水 biz_type=6)
rebates = (await db.execute(
select(AssetTransaction).where(
AssetTransaction.biz_type == incentive_service.ASSET_BIZ_REBATE,
AssetTransaction.biz_id == order_no,
)
)).scalars().all()
clawed_back = Decimal(0)
for r in rebates:
await incentive_service.change_assets(
db, r.user_id, -r.change_amount, incentive_service.ASSET_BIZ_CLAWBACK,
biz_id=order_no, remark=f"退款扣回推荐返利(原流水#{r.id})", allow_negative=True,
)
clawed_back += -r.change_amount
await _log(db, admin.admin_id, "order_refund", target=order_no,
detail=f"points=-{order.points} assets={clawed_back} reason={req.reason}", ip=ip)
await db.commit()
return {
"order_no": order_no,
"status": order.status,
"points_after": new_points,
"rebate_clawed_back": float(clawed_back),
}
def _package_item(p: RechargePackage) -> dict:
return {
"id": p.id,
"amount_yuan": p.amount_yuan,
"points": p.points,
"bonus_points": p.bonus_points,
"sort": p.sort,
"status": p.status,
}
async def list_packages(db: AsyncSession) -> dict:
"""充值套餐列表(11.13):含下架"""
rows = (await db.execute(
select(RechargePackage).order_by(RechargePackage.sort, RechargePackage.id)
)).scalars().all()
return {"list": [_package_item(p) for p in rows]}
async def create_package(db: AsyncSession, admin: AdminIdentity, req, ip: str) -> dict:
"""新增套餐(11.14):points 默认按汇率计算,可显式覆盖"""
points = req.points if req.points is not None else req.amount_yuan * _points_per_yuan()
pkg = RechargePackage(
amount_yuan=req.amount_yuan, points=points, bonus_points=req.bonus_points, sort=req.sort
)
db.add(pkg)
await _log(db, admin.admin_id, "package_create",
detail=f"{req.amount_yuan}元/{points}分+{req.bonus_points}", ip=ip)
await db.commit()
return _package_item(pkg)
async def update_package(db: AsyncSession, admin: AdminIdentity, package_id: int, req, ip: str) -> dict:
"""修改套餐(11.15):amount_yuan 变更时积分按汇率重算"""
pkg = await db.get(RechargePackage, package_id)
if pkg is None:
raise BizException(errors.NOT_FOUND, "套餐不存在")
changes = []
if req.amount_yuan is not None and req.amount_yuan != pkg.amount_yuan:
pkg.amount_yuan = req.amount_yuan
pkg.points = req.amount_yuan * _points_per_yuan()
changes.append(f"amount={req.amount_yuan}")
if req.bonus_points is not None:
pkg.bonus_points = req.bonus_points
changes.append(f"bonus={req.bonus_points}")
if req.sort is not None:
pkg.sort = req.sort
changes.append(f"sort={req.sort}")
if req.status is not None:
pkg.status = req.status
changes.append(f"status={req.status}")
await _log(db, admin.admin_id, "package_update", target=str(package_id),
detail=" ".join(changes), ip=ip)
await db.commit()
return _package_item(pkg)
async def delete_package(db: AsyncSession, admin: AdminIdentity, package_id: int, ip: str) -> dict:
"""删除套餐(11.16):存在同额历史订单的仅下架,否则物理删除"""
pkg = await db.get(RechargePackage, package_id)
if pkg is None:
raise BizException(errors.NOT_FOUND, "套餐不存在")
referenced = (await db.execute(
select(RechargeOrder.id).where(RechargeOrder.amount_fen == pkg.amount_yuan * 100).limit(1)
)).first()
if referenced:
pkg.status = 0
deleted = False
else:
await db.delete(pkg)
deleted = True
await _log(db, admin.admin_id, "package_delete", target=str(package_id),
detail="物理删除" if deleted else "有订单引用,下架", ip=ip)
await db.commit()
return {"id": package_id, "deleted": deleted}
# ---------- 11.17 赠送 ----------
async def gift(db: AsyncSession, admin: AdminIdentity, req, ip: str) -> dict:
"""赠送会员/积分(11.17【敏感】):会员写 membership_orders(source=2),积分写流水(biz_type=5)"""
if req.gift_type == "membership":
if not req.plan_code:
raise BizException(errors.PARAM_ERROR, "赠送会员需传 plan_code")
if req.plan_code not in membership_service.PLAN_NAMES:
raise BizException(errors.PARAM_ERROR, "套餐不存在")
if req.gift_type == "points" and not req.points:
raise BizException(errors.PARAM_ERROR, "赠送积分需传 points")
success = failed = 0
for uid in dict.fromkeys(req.user_ids):
user = await db.get(User, uid)
if user is None or user.status != 1:
failed += 1
continue
try:
if req.gift_type == "membership":
await membership_service.grant_membership(
db, uid, req.plan_code, 0, membership_service.SOURCE_GIFT
)
else:
await wallet_service.change_points(
db, uid, req.points, wallet_service.BIZ_GIFT,
biz_id=f"admin:{admin.admin_id}", remark=f"后台赠送:{req.reason}",
)
success += 1
except BizException:
failed += 1
detail = f"{req.gift_type} {req.plan_code or req.points} 成功{success}失败{failed} {req.reason}"
await _log(db, admin.admin_id, "gift", detail=detail, ip=ip)
await db.commit()
return {"success": success, "failed": failed}
# ---------- 11.18 操作日志 ----------
async def list_logs(
db: AsyncSession, admin_id: int | None, action: str | None, page: int, page_size: int
) -> dict:
stmt = select(AdminLog)
if admin_id is not None:
stmt = stmt.where(AdminLog.admin_id == admin_id)
if action:
stmt = stmt.where(AdminLog.action == action)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (await db.execute(
stmt.order_by(AdminLog.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
admins = {
a.id: a
for a in (await db.execute(
select(AdminUser).where(AdminUser.id.in_({r.admin_id for r in rows} or {0}))
)).scalars()
}
return {
"total": total,
"list": [
{
"id": r.id,
"admin_id": r.admin_id,
"admin_name": admins[r.admin_id].name if r.admin_id in admins else "",
"action": r.action,
"target": r.target,
"detail": r.detail,
"ip": r.ip,
"created_at": _fmt(r.created_at),
}
for r in rows
],
}
# ---------- 提现审核 ----------
async def list_withdrawals(
db: AsyncSession, status: int | None, user_id: int | None, page: int, page_size: int
) -> dict:
"""提现列表(11.19)"""
stmt = select(Withdrawal)
if status is not None:
stmt = stmt.where(Withdrawal.status == status)
if user_id is not None:
stmt = stmt.where(Withdrawal.user_id == user_id)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (await db.execute(
stmt.order_by(Withdrawal.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
users = {
u.id: u
for u in (await db.execute(
select(User).where(User.id.in_({w.user_id for w in rows} or {0}))
)).scalars()
}
return {
"total": total,
"list": [
{
"withdraw_no": w.withdraw_no,
"user_id": w.user_id,
"nickname": users[w.user_id].nickname if w.user_id in users else "",
"assets": float(w.assets),
"amount_yuan": float(Decimal(w.amount_fen) / 100),
"channel": w.channel,
"account": w.account,
"status": w.status,
"status_text": WITHDRAW_STATUS_TEXT.get(w.status, ""),
"reason": w.reason,
"created_at": _fmt(w.created_at),
"processed_at": _fmt(w.processed_at),
}
for w in rows
],
}
async def review_withdrawal(
db: AsyncSession, redis: aioredis.Redis, admin: AdminIdentity,
withdraw_no: str, req, ip: str,
) -> dict:
"""提现审核(11.20【敏感】):通过→置已打款释放锁;拒绝→退回资产 + 释放锁;均通知用户"""
wd = (await db.execute(
select(Withdrawal).where(Withdrawal.withdraw_no == withdraw_no).with_for_update()
)).scalar_one_or_none()
if wd is None:
raise BizException(errors.NOT_FOUND, "提现单不存在")
if wd.status != 0:
raise BizException(errors.ORDER_STATUS_ERROR, "提现单已处理")
if req.action == "reject" and not (req.reason or "").strip():
raise BizException(errors.REASON_REQUIRED)
refunded_assets: float | None = None
if req.action == "approve":
wd.status = 1
await common_service.notify(
db, redis, wd.user_id, 2, "提现已打款",
f"你的提现申请({withdraw_no})已完成打款,请查收", biz_id=withdraw_no,
)
else:
wd.status = 2
wd.reason = req.reason.strip()
await incentive_service.change_assets(
db, wd.user_id, wd.assets, incentive_service.ASSET_BIZ_WITHDRAW_BACK,
biz_id=withdraw_no, remark=f"提现退回:{wd.reason}",
)
refunded_assets = float(wd.assets)
await common_service.notify(
db, redis, wd.user_id, 2, "提现被拒绝",
f"你的提现申请({withdraw_no})被拒绝:{wd.reason},资产已退回", biz_id=withdraw_no,
)
wd.admin_id = admin.admin_id
wd.processed_at = datetime.now()
await redis.delete(f"wd:pending:{wd.user_id}")
await _log(db, admin.admin_id, "withdraw_review", target=withdraw_no,
detail=f"{req.action} {req.reason or ''}".strip(), ip=ip)
await db.commit()
resp = {"withdraw_no": withdraw_no, "status": wd.status}
if refunded_assets is not None:
resp["refunded_assets"] = refunded_assets
return resp
# ---------- 推荐风控 ----------
async def list_referrals(
db: AsyncSession, inviter_id: int | None, status: int | None, risk_flag: str | None,
page: int, page_size: int,
) -> dict:
"""推荐关系列表(11.21)"""
stmt = select(Referral)
if inviter_id is not None:
stmt = stmt.where(Referral.inviter_id == inviter_id)
if status is not None:
stmt = stmt.where(Referral.status == status)
if risk_flag:
stmt = stmt.where(Referral.risk_flag == risk_flag)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (await db.execute(
stmt.order_by(Referral.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
user_ids = {r.inviter_id for r in rows} | {r.invitee_id for r in rows} or {0}
users = {
u.id: u
for u in (await db.execute(select(User).where(User.id.in_(user_ids)))).scalars()
}
def _brief(uid: int) -> dict:
u = users.get(uid)
return {"user_id": uid, "nickname": u.nickname if u else ""}
return {
"total": total,
"list": [
{
"id": r.id,
"inviter": _brief(r.inviter_id),
"invitee": _brief(r.invitee_id),
"invite_code": r.invite_code,
"status": r.status,
"status_text": REFERRAL_STATUS_TEXT.get(r.status, ""),
"risk_flag": r.risk_flag,
"register_ip": r.register_ip,
"activated_at": _fmt(r.activated_at),
"created_at": _fmt(r.created_at),
}
for r in rows
],
}
async def review_referral(
db: AsyncSession, admin: AdminIdentity, referral_id: int, req, ip: str
) -> dict:
"""推荐风控处理(11.22【敏感】):解冻回待激活 / 判无效(奖励不发放)"""
ref = await db.get(Referral, referral_id)
if ref is None:
raise BizException(errors.NOT_FOUND, "推荐关系不存在")
if ref.status != 3:
raise BizException(errors.PARAM_ERROR, "仅风控冻结状态可处理")
ref.status = 0 if req.action == "release" else 2
await _log(db, admin.admin_id, "referral_review", target=str(referral_id),
detail=f"{req.action} {req.reason}", ip=ip)
await db.commit()
return {
"referral_id": referral_id,
"status": ref.status,
"status_text": REFERRAL_STATUS_TEXT[ref.status],
}
# ---------- 平台配置 ----------
async def list_configs(db: AsyncSession) -> dict:
"""平台配置列表(11.23)"""
rows = (await db.execute(select(SystemConfig).order_by(SystemConfig.config_key))).scalars().all()
return {
"list": [
{
"config_key": r.config_key,
"config_value": r.config_value,
"description": r.description,
"updated_at": _fmt(r.updated_at),
}
for r in rows
],
}
async def update_config(
db: AsyncSession, redis: aioredis.Redis, admin: AdminIdentity,
config_key: str, value: str, ip: str,
) -> dict:
"""修改平台配置(11.24):白名单键校验,缓存即时失效"""
if config_key not in CONFIG_WHITELIST:
raise BizException(errors.PARAM_ERROR, "不支持的配置项")
await config_service.set_config(db, redis, config_key, value, admin.admin_id)
await _log(db, admin.admin_id, "config_update", target=config_key, detail=value, ip=ip)
await db.commit()
return {"config_key": config_key, "config_value": value}
# ---------- 评论管理 ----------
async def list_comments(
db: AsyncSession, video_id: int | None, user_id: int | None, keyword: str | None,
page: int, page_size: int,
) -> dict:
"""评论列表(11.26):含已删除,附用户昵称 + 视频标题"""
stmt = select(Comment)
if video_id is not None:
stmt = stmt.where(Comment.video_id == video_id)
if user_id is not None:
stmt = stmt.where(Comment.user_id == user_id)
if keyword:
stmt = stmt.where(Comment.content.like(f"%{keyword}%"))
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (await db.execute(
stmt.order_by(Comment.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
users = {
u.id: u
for u in (await db.execute(
select(User).where(User.id.in_({c.user_id for c in rows} or {0}))
)).scalars()
}
videos = {
v.id: v
for v in (await db.execute(
select(Video).where(Video.id.in_({c.video_id for c in rows} or {0}))
)).scalars()
}
return {
"total": total,
"list": [
{
"comment_id": c.id,
"video_id": c.video_id,
"video_title": videos[c.video_id].title if c.video_id in videos else "",
"user_id": c.user_id,
"nickname": users[c.user_id].nickname if c.user_id in users else "",
"parent_id": c.parent_id,
"content": c.content,
"like_count": c.like_count,
"status": c.status,
"created_at": _fmt(c.created_at),
}
for c in rows
],
}
async def delete_comment(
db: AsyncSession, redis: aioredis.Redis, admin: AdminIdentity,
comment_id: int, reason: str, ip: str,
) -> None:
"""删除评论(11.27【敏感】):复用互动模块删除逻辑(含评论奖励回退)"""
comment = await db.get(Comment, comment_id)
if comment is None or comment.status != 1:
raise BizException(errors.NOT_FOUND, "评论不存在")
await interaction_service.remove_comment(db, redis, comment) # 内部已提交
await _log(db, admin.admin_id, "comment_delete", target=str(comment_id), detail=reason, ip=ip)
await db.commit()
# ---------- 举报处理 ----------
async def list_reports(
db: AsyncSession, status: int | None, target_type: int | None, page: int, page_size: int
) -> dict:
"""举报列表(11.28):举报人昵称 + 被举报对象摘要"""
stmt = select(Report)
if status is not None:
stmt = stmt.where(Report.status == status)
if target_type is not None:
stmt = stmt.where(Report.target_type == target_type)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (await db.execute(
stmt.order_by(Report.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
users = {
u.id: u
for u in (await db.execute(
select(User).where(User.id.in_({r.user_id for r in rows} or {0}))
)).scalars()
}
video_ids = {r.target_id for r in rows if r.target_type == 1} or {0}
comment_ids = {r.target_id for r in rows if r.target_type == 2} or {0}
videos = {
v.id: v
for v in (await db.execute(select(Video).where(Video.id.in_(video_ids)))).scalars()
}
comments = {
c.id: c
for c in (await db.execute(select(Comment).where(Comment.id.in_(comment_ids)))).scalars()
}
def _summary(r: Report) -> str:
if r.target_type == 1:
v = videos.get(r.target_id)
return v.title if v else "视频已删除"
c = comments.get(r.target_id)
return (c.content[:50] if c else "评论已删除") if c else "评论已删除"
return {
"total": total,
"list": [
{
"report_id": r.id,
"user_id": r.user_id,
"reporter_nickname": users[r.user_id].nickname if r.user_id in users else "",
"target_type": r.target_type,
"target_id": r.target_id,
"target_summary": _summary(r),
"reason_type": r.reason_type,
"remark": r.remark,
"status": r.status,
"status_text": REPORT_STATUS_TEXT.get(r.status, ""),
"handle_result": r.handle_result,
"created_at": _fmt(r.created_at),
"handled_at": _fmt(r.handled_at),
}
for r in rows
],
}
async def review_report(
db: AsyncSession, redis: aioredis.Redis, admin: AdminIdentity,
report_id: int, req, ip: str,
) -> dict:
"""举报处理(11.29【敏感】):下架视频/删除评论复用对应业务流程,结果站内通知举报人"""
report = await db.get(Report, report_id)
if report is None:
raise BizException(errors.NOT_FOUND, "举报不存在")
if report.status != 0:
raise BizException(errors.PARAM_ERROR, "举报已处理")
if req.action == "offline_video":
if report.target_type != 1:
raise BizException(errors.PARAM_ERROR, "举报对象不是视频")
video = await db.get(Video, report.target_id)
if video is not None and video.status == 2:
video.status = 4
video.reject_reason = req.reason
video.reviewer_id = admin.admin_id
video.reviewed_at = datetime.now()
await common_service.notify(
db, redis, video.user_id, 1, "视频已被下架",
f"你的视频《{video.title}》已被下架:{req.reason}", biz_id=str(video.id),
)
await video_service.invalidate_detail_cache(redis, video.id)
report.status = 1
notify_content = f"你举报的视频已被下架。处理说明:{req.reason}"
elif req.action == "delete_comment":
if report.target_type != 2:
raise BizException(errors.PARAM_ERROR, "举报对象不是评论")
comment = await db.get(Comment, report.target_id)
if comment is not None and comment.status == 1:
await interaction_service.remove_comment(db, redis, comment) # 含奖励回退
report.status = 1
notify_content = f"你举报的评论已被删除。处理说明:{req.reason}"
else:
report.status = 2
notify_content = f"你举报的内容经核实未违规。处理说明:{req.reason}"
report.handle_result = f"{req.action}: {req.reason}"
report.admin_id = admin.admin_id
report.handled_at = datetime.now()
await common_service.notify(
db, redis, report.user_id, 1, "举报处理结果", notify_content, biz_id=str(report.id)
)
await _log(db, admin.admin_id, "report_review", target=str(report_id),
detail=f"{req.action} {req.reason}", ip=ip)
await db.commit()
return {"report_id": report_id, "status": report.status,
"status_text": REPORT_STATUS_TEXT[report.status]}
# ---------- 敏感词 ----------
async def list_sensitive_words(
db: AsyncSession, keyword: str | None, page: int, page_size: int
) -> dict:
"""敏感词列表(11.30)"""
stmt = select(SensitiveWord)
if keyword:
stmt = stmt.where(SensitiveWord.word.like(f"%{keyword}%"))
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (await db.execute(
stmt.order_by(SensitiveWord.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
return {
"total": total,
"list": [{"id": w.id, "word": w.word, "created_at": _fmt(w.created_at)} for w in rows],
}
async def add_sensitive_word(
db: AsyncSession, redis: aioredis.Redis, admin: AdminIdentity, word: str, ip: str
) -> dict:
"""添加敏感词(11.31):重复返回 1001;词库缓存即时失效"""
word = word.strip()
exists = (await db.execute(
select(SensitiveWord.id).where(SensitiveWord.word == word)
)).first()
if exists:
raise BizException(errors.PARAM_ERROR, "敏感词已存在")
row = SensitiveWord(word=word)
db.add(row)
await sensitive_service.invalidate_cache(redis)
await _log(db, admin.admin_id, "sensitive_add", target=word, ip=ip)
await db.commit()
return {"id": row.id, "word": word}
async def delete_sensitive_word(
db: AsyncSession, redis: aioredis.Redis, admin: AdminIdentity, word_id: int, ip: str
) -> None:
"""删除敏感词(11.32)"""
row = await db.get(SensitiveWord, word_id)
if row is None:
raise BizException(errors.NOT_FOUND, "敏感词不存在")
word = row.word
await db.delete(row)
await sensitive_service.invalidate_cache(redis)
await _log(db, admin.admin_id, "sensitive_delete", target=word, ip=ip)
await db.commit()
# ---------- 管理员密码 ----------
async def change_admin_password(
db: AsyncSession, admin_id: int, old_password: str, new_password: str
) -> None:
"""超管修改自身密码。super 用户有 admin_id 直接 update 即可"""
from sqlalchemy import text
row = (await db.execute(text(
"SELECT id, password_hash FROM admin_users WHERE id = :id"
), {"id": admin_id})).mappings().first()
if not row:
raise BizException(errors.NOT_FOUND, "管理员不存在")
if not verify_password(old_password, row["password_hash"]):
raise BizException(errors.PARAM_ERROR, "原密码错误")
await db.execute(text(
"UPDATE admin_users SET password_hash = :h WHERE id = :id"
), {"h": hash_password(new_password), "id": admin_id})
await db.commit()
# ---------- 管理员新增视频 ----------
async def create_video(db: AsyncSession, redis: aioredis.Redis,
admin, req, ip: str) -> dict:
"""管理员新增视频(两种模式)"""
import asyncio
from pathlib import Path
from datetime import datetime
if req.upload_token and req.video_url:
raise BizException(errors.PARAM_ERROR, "上传视频和填链接二选一")
video_url = ""
cover_url = req.cover_url or ""
duration = req.duration or 0
file_size = 0
width = 0
height = 0
source_local = "" # 本地源文件路径(上传模式)
if req.upload_token:
# 模式 A:取 redis 里上传落盘的路径
path_str = await redis.get(f"admin_uploaded:{req.upload_token}")
if not path_str:
raise BizException(errors.PARAM_ERROR, "上传凭证无效或已过期")
await redis.delete(f"admin_uploaded:{req.upload_token}")
source_local = path_str.decode() if isinstance(path_str, bytes) else str(path_str)
p = Path(source_local)
if not p.exists():
raise BizException(errors.PARAM_ERROR, "上传文件丢失")
file_size = p.stat().st_size
# 先入库(status=1 待审核 或 2 发布),转码后补 video_url + cover_url + duration
video_url = "" # 转码后回填
# 抽封面也在转码里做
elif req.video_url:
# 模式 B:直接 m3u8 地址
video_url = req.video_url
if not video_url.startswith(("http://", "https://")):
raise BizException(errors.PARAM_ERROR, "video_url 必须是 http(s) 开头")
duration = req.duration or 0
else:
raise BizException(errors.PARAM_ERROR, "请上传视频或填 m3u8 地址")
# 封面:URL 模式必须填,upload 模式转码时自动抽
if req.video_url and not cover_url:
raise BizException(errors.PARAM_ERROR, "填 m3u8 地址时需要同时提供封面图 URL")
now = datetime.now()
video = Video(
user_id=admin.admin_id, # admin 作为作者(后台发布视频)
category_id=req.category_id,
title=req.title,
description=req.description or "",
cover_url=cover_url,
video_url=video_url,
duration=duration,
file_size=file_size,
width=width,
height=height,
status=1, # 默认待审核
reject_reason="",
reviewer_id=admin.admin_id,
reviewed_at=now,
is_priority=0,
view_count=0, like_count=0, comment_count=0, favorite_count=0,
published_at=now if req.status == 2 else None,
created_at=now, updated_at=now,
)
if req.status == 2:
video.status = 2
db.add(video)
await db.flush()
video_id = video.id
# 转码(上传模式才需要)
if source_local:
try:
from app.services import transcode_service
result = await asyncio.to_thread(
transcode_service.transcode_to_hls,
Path(source_local), video_id,
)
video.video_url = result["video_url"]
video.cover_url = result["cover_url"]
video.duration = result["duration"]
if result.get("width"):
video.width = result["width"]
if result.get("height"):
video.height = result["height"]
except Exception as exc:
logger.exception("admin create video transcode failed: id=%s", video_id)
video.status = 5
video.reject_reason = f"转码失败:{exc}"
await db.commit()
await _log(db, admin.admin_id, "video_create",
target=str(video_id), ip=ip)
return {"video_id": video_id, "status": video.status}