up
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""认证服务:验证码 / 注册 / 登录 / 刷新 / 登出 / 找回密码"""
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import smtplib
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
import httpx
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -66,14 +70,90 @@ async def send_code(
|
||||
await redis.expire(ip_key, 3600)
|
||||
|
||||
data = {"expire_seconds": ttl, "retry_after": 60}
|
||||
# TODO: 接入阿里云短信 / SMTP 实际发送;未配置通道时按开发模式处理
|
||||
channel_configured = bool(settings.sms_access_key_id) if channel == "sms" else bool(settings.smtp_host)
|
||||
channel_configured = (
|
||||
bool(settings.smsbao_username and settings.smsbao_api_key)
|
||||
if channel == "sms"
|
||||
else bool(settings.smtp_host)
|
||||
)
|
||||
if not channel_configured:
|
||||
logger.info("[dev] %s code for %s (%s): %s", channel, target, scene, code)
|
||||
data["dev_code"] = code
|
||||
elif channel == "sms":
|
||||
await _send_sms_code(target, code)
|
||||
else:
|
||||
await _send_email_code(target, code, scene)
|
||||
return data
|
||||
|
||||
|
||||
_SCENES = {"register": "注册", "login": "登录", "reset": "找回密码"}
|
||||
|
||||
# ---------- 短信(短信宝)----------
|
||||
SMSBAO_SEND_URL = "https://api.smsbao.com/sms"
|
||||
# 验证码短信正文(签名前缀由 settings.sms_sign_name 提供)
|
||||
_SMS_CONTENT = "您的验证码是{code}。如非本人操作,请忽略本短信"
|
||||
# 接口文本返回码(非 0 均为失败,仅用于日志定位)
|
||||
_SMS_ERRORS = {
|
||||
"30": "密码/ApiKey 错误",
|
||||
"40": "账号不存在",
|
||||
"41": "余额不足",
|
||||
"42": "账号已过期",
|
||||
"43": "IP 地址限制",
|
||||
"50": "内容含有敏感词",
|
||||
"51": "手机号码不正确",
|
||||
"60": "尚未充值,接口不可调用",
|
||||
}
|
||||
|
||||
|
||||
async def _send_sms_code(target: str, code: str) -> None:
|
||||
"""短信宝发送验证码:返回 '0' 视为提交成功"""
|
||||
content = f"{settings.sms_sign_name}{_SMS_CONTENT.format(code=code)}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
SMSBAO_SEND_URL,
|
||||
params={
|
||||
"u": settings.smsbao_username,
|
||||
"p": settings.smsbao_api_key,
|
||||
"m": target,
|
||||
"c": content, # httpx 自动按 UTF-8 URL Encode
|
||||
},
|
||||
)
|
||||
result = resp.text.strip()
|
||||
except Exception:
|
||||
logger.exception("smsbao request failed: %s", target)
|
||||
raise BizException(errors.PARAM_ERROR, "验证码发送失败,请稍后重试")
|
||||
if result != "0":
|
||||
logger.error(
|
||||
"smsbao send failed: mobile=%s result=%s(%s)",
|
||||
target, result, _SMS_ERRORS.get(result, "未知错误"),
|
||||
)
|
||||
raise BizException(errors.PARAM_ERROR, "验证码发送失败,请稍后重试")
|
||||
|
||||
|
||||
def _smtp_send(target: str, code: str, scene: str) -> None:
|
||||
"""SMTP 发送验证码(同步 smtplib,由调用方放线程池)"""
|
||||
msg = MIMEText(
|
||||
f"您的{_SCENES.get(scene, scene)}验证码:{code},10 分钟内有效。"
|
||||
"如非本人操作请忽略本邮件。"
|
||||
)
|
||||
msg["Subject"] = f"短视频{_SCENES.get(scene, scene)}验证码"
|
||||
msg["From"] = settings.smtp_user
|
||||
msg["To"] = target
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15) as server:
|
||||
server.starttls()
|
||||
server.login(settings.smtp_user, settings.smtp_password)
|
||||
server.send_message(msg)
|
||||
|
||||
|
||||
async def _send_email_code(target: str, code: str, scene: str) -> None:
|
||||
"""SMTP 异步包装:发送失败视为验证码错误前置(用户需重发)"""
|
||||
try:
|
||||
await asyncio.to_thread(_smtp_send, target, code, scene)
|
||||
except Exception:
|
||||
logger.exception("smtp send failed: %s", target)
|
||||
raise BizException(errors.PARAM_ERROR, "验证码发送失败,请稍后重试")
|
||||
|
||||
|
||||
async def _verify_code(redis: aioredis.Redis, channel: str, target: str, scene: str, code: str) -> None:
|
||||
key = _code_key(scene, channel, target)
|
||||
saved = await redis.get(key)
|
||||
|
||||
@@ -7,10 +7,14 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.common import SensitiveWord
|
||||
from app.services import config_service
|
||||
|
||||
CACHE_KEY = "words:sensitive"
|
||||
CACHE_TTL = 60
|
||||
|
||||
# 敏感词过滤总开关(system_configs 可配,后台系统配置页可热改;"0" 关闭)
|
||||
FILTER_ENABLED_KEY = "sensitive.filter.enabled"
|
||||
|
||||
|
||||
class DFAMatcher:
|
||||
"""确定性有穷自动机敏感词匹配器"""
|
||||
@@ -69,9 +73,12 @@ async def get_matcher(db: AsyncSession, redis: aioredis.Redis) -> DFAMatcher:
|
||||
|
||||
|
||||
async def find_sensitive(db: AsyncSession, redis: aioredis.Redis, text: str) -> str | None:
|
||||
"""检查文本,返回命中的敏感词或 None"""
|
||||
"""检查文本,返回命中的敏感词或 None;后台关闭过滤开关时直接放行"""
|
||||
if not text:
|
||||
return None
|
||||
enabled = await config_service.get_config(db, redis, FILTER_ENABLED_KEY, "1")
|
||||
if str(enabled) == "0":
|
||||
return None
|
||||
matcher = await get_matcher(db, redis)
|
||||
return matcher.find(text)
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""FFmpeg HLS 转码 + 封面抽帧服务"""
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import BASE_DIR as _SERVER_BASE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 项目根(server 上级,即 /root/project/video)
|
||||
_PROJECT_ROOT = _SERVER_BASE.parent
|
||||
|
||||
# 源上传文件根目录(服务端 uploads)
|
||||
UPLOAD_ROOT = _SERVER_BASE / "uploads"
|
||||
|
||||
# 转码产物根目录(nginx h5.256kb.top 用 alias 指向这里分发)
|
||||
HLS_ROOT = _PROJECT_ROOT / "runtime" / "videos"
|
||||
|
||||
# h5 域名(转码产物对外访问地址)
|
||||
HLS_BASE = "https://h5.256kb.top/videos"
|
||||
|
||||
# ffmpeg 二进制(imageio-ffmpeg 随 venv 安装自动带上静态编译 libx264+aac+hls,
|
||||
# 开发/生产通用,无需系统包管理器)
|
||||
try:
|
||||
import imageio_ffmpeg # noqa: F401
|
||||
_FFMPEG = str(Path(imageio_ffmpeg.get_ffmpeg_exe()))
|
||||
except Exception: # pragma: no cover - 降级:PATH 里找系统 ffmpeg
|
||||
import shutil
|
||||
_FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
|
||||
|
||||
# HLS 参数
|
||||
_HLS_TIME = 6 # ts 切片 6s
|
||||
_HLS_LIST_SIZE = 0 # 保留所有切片,支持回放
|
||||
_HLS_SEG_FORMAT = "seg_%05d.ts"
|
||||
|
||||
# 封面:从源视频 1s 处抽帧 JPEG,宽度 1280px
|
||||
_COVER_AT_SEC = 1
|
||||
_COVER_WIDTH = 1280
|
||||
|
||||
|
||||
def _probe_duration(path: Path) -> int:
|
||||
"""ffprobe 读时长(秒,取整);失败返回 0"""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[_FFMPEG.replace("ffmpeg", "ffprobe"), "-v", "error",
|
||||
"-show_entries", "format=duration", "-of", "default=nw=1:nk=1", str(path)],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
return int(float(out.stdout.strip() or 0))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def extract_cover(source_path: str | Path, out_dir: Path) -> Path | None:
|
||||
"""从源视频 1s 处抽帧存 JPEG;返回输出文件 Path(失败返回 None)。"""
|
||||
src = Path(source_path)
|
||||
if not src.exists():
|
||||
return None
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out = out_dir / "cover.jpg"
|
||||
args = [
|
||||
_FFMPEG, "-y",
|
||||
"-ss", str(_COVER_AT_SEC),
|
||||
"-i", str(src),
|
||||
"-frames:v", "1",
|
||||
"-vf", f"scale={_COVER_WIDTH}:-2",
|
||||
"-q:v", "5",
|
||||
str(out),
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(args, capture_output=True, text=True, timeout=60)
|
||||
if proc.returncode == 0 and out.exists() and out.stat().st_size > 0:
|
||||
return out
|
||||
logger.warning("[cover] 抽帧失败 rc=%d src=%s: %s", proc.returncode, src, proc.stderr[-300:])
|
||||
except Exception as e:
|
||||
logger.warning("[cover] 抽帧异常 src=%s: %s", src, e)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_source_path(video_url_or_object_key: str) -> Path | None:
|
||||
"""从 DB video_url(/static/upload/...)或 upload object_key(upload/...)还原本地源文件路径"""
|
||||
raw = video_url_or_object_key
|
||||
if not raw:
|
||||
return None
|
||||
if raw.startswith("/static/"):
|
||||
rel = raw.replace("/static/", "", 1)
|
||||
elif raw.startswith("upload/"):
|
||||
rel = raw
|
||||
else:
|
||||
return None
|
||||
p = (UPLOAD_ROOT / rel).resolve()
|
||||
return p if p.exists() else None
|
||||
|
||||
|
||||
async def transcode_to_hls(source_path: str | Path, video_id: int) -> dict:
|
||||
"""MP4 → HLS + 封面。返回 {"m3u8_url", "cover_url", "duration", "ts_count"};异常直接 raise。
|
||||
|
||||
所有 subprocess 都跑在线程池,不阻塞事件循环。
|
||||
"""
|
||||
src = Path(source_path)
|
||||
if not src.exists():
|
||||
raise FileNotFoundError(f"源文件不存在: {src}")
|
||||
|
||||
out_dir = HLS_ROOT / str(video_id)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
m3u8_path = out_dir / "index.m3u8"
|
||||
cover_path = out_dir / "cover.jpg"
|
||||
|
||||
# 已转码直接返回(幂等)
|
||||
if m3u8_path.exists():
|
||||
logger.info("[transcode] video_id=%d 已存在 %s,跳过", video_id, m3u8_path)
|
||||
return {
|
||||
"m3u8_url": f"{HLS_BASE}/{video_id}/index.m3u8",
|
||||
"cover_url": f"{HLS_BASE}/{video_id}/cover.jpg" if cover_path.exists() else "",
|
||||
"duration": _probe_duration(m3u8_path),
|
||||
"ts_count": len(list(out_dir.glob("*.ts"))),
|
||||
}
|
||||
|
||||
args = [
|
||||
_FFMPEG, "-y",
|
||||
"-i", str(src),
|
||||
"-codec:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "23",
|
||||
"-codec:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-movflags", "+faststart",
|
||||
"-hls_time", str(_HLS_TIME),
|
||||
"-hls_list_size", str(_HLS_LIST_SIZE),
|
||||
"-hls_segment_filename", str(out_dir / _HLS_SEG_FORMAT),
|
||||
"-hls_playlist_type", "vod",
|
||||
"-f", "hls",
|
||||
str(m3u8_path),
|
||||
]
|
||||
logger.info("[transcode] video_id=%d 开始转码: %s → %s", video_id, src, m3u8_path)
|
||||
start = datetime.now()
|
||||
proc = await asyncio.to_thread(
|
||||
subprocess.run, args, capture_output=True, text=True, timeout=600
|
||||
)
|
||||
if proc.returncode != 0 or not m3u8_path.exists():
|
||||
logger.error("[transcode] video_id=%d 失败 rc=%s\nstderr=%s", video_id, proc.returncode, proc.stderr[-2000:])
|
||||
raise RuntimeError(f"ffmpeg 转码失败 rc={proc.returncode}: {proc.stderr[-500:]}")
|
||||
|
||||
# 转码成功 → 同步抽封面(独立线程)
|
||||
cover = await asyncio.to_thread(extract_cover, src, out_dir)
|
||||
cover_url = f"{HLS_BASE}/{video_id}/cover.jpg" if cover else ""
|
||||
dur = _probe_duration(m3u8_path)
|
||||
ts_count = len(list(out_dir.glob("*.ts")))
|
||||
cost = (datetime.now() - start).total_seconds()
|
||||
m3u8_url = f"{HLS_BASE}/{video_id}/index.m3u8"
|
||||
logger.info(
|
||||
"[transcode] video_id=%d 完成 cost=%.1fs dur=%ds ts=%d cover=%s → %s",
|
||||
video_id, cost, dur, ts_count, "ok" if cover else "-", m3u8_url,
|
||||
)
|
||||
return {"m3u8_url": m3u8_url, "cover_url": cover_url, "duration": dur, "ts_count": ts_count}
|
||||
Reference in New Issue
Block a user