up
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user