1344 lines
51 KiB
Python
1344 lines
51 KiB
Python
"""后台管理服务:仪表盘 / 审核 / 用户 / 积分 / 订单 / 套餐 / 赠送 / 提现 / 推荐 / 配置 / 举报 / 敏感词
|
||
(对应《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 reset_user_password(
|
||
db: AsyncSession, redis: aioredis.Redis, admin: AdminIdentity,
|
||
user_id: int, req, ip: str,
|
||
) -> None:
|
||
"""后台重置用户密码【敏感】:重置后全端 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, "账号已注销,不可操作")
|
||
|
||
user.password_hash = hash_password(req.new_password)
|
||
# 全端 Token 失效:递增令牌纪元 + 清 refresh 白名单
|
||
await redis.incr(f"auth:epoch:{user_id}")
|
||
await redis.delete(f"auth:refresh:{user_id}")
|
||
await _log(db, admin.admin_id, "user_password_reset", target=str(user_id),
|
||
detail=req.reason, ip=ip)
|
||
await db.commit()
|
||
|
||
|
||
# ---------- 积分 ----------
|
||
|
||
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}
|