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}
+82 -2
View File
@@ -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)
+8 -1
View File
@@ -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)
+157
View File
@@ -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}