This commit is contained in:
root
2026-10-16 13:51:00 +08:00
parent 6bf949949c
commit 8fc899d0a9
3830 changed files with 11228 additions and 188377 deletions
+155 -1
View File
@@ -1,7 +1,11 @@
"""后台管理服务:仪表盘 / 审核 / 用户 / 积分 / 订单 / 套餐 / 赠送 / 提现 / 推荐 / 配置 / 举报 / 敏感词
(对应《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
@@ -10,7 +14,8 @@ 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, verify_password
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
@@ -44,9 +49,11 @@ CONFIG_WHITELIST = {
"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",
}
@@ -229,9 +236,36 @@ async def review_video(
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()
@@ -1164,3 +1198,123 @@ async def delete_sensitive_word(
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}