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
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.
+250
View File
@@ -35,6 +35,14 @@ async def login(req: schema.AdminLoginReq, request: Request, db: AsyncSession =
return ok(await admin_service.login(db, req, _ip(request)))
@router.put("/auth/password")
async def change_password(req: schema.AdminPasswordChangeReq,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
await admin_service.change_admin_password(db, admin.admin_id, req.old_password, req.new_password)
return ok()
@router.get("/dashboard")
async def dashboard(db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
@@ -76,6 +84,96 @@ async def offline_video(video_id: int, req: schema.ReasonReq, request: Request,
return ok()
@router.post("/videos")
async def create_video(req: schema.AdminVideoCreateReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
"""管理后台新增视频:
- upload_token 模式:先通过 /admin/videos/upload 获取上传凭证,前端上传后传 token(自动转码)
- video_url 模式:直接填 m3u8 地址
"""
return ok(await admin_service.create_video(db, redis, admin, req, _ip(request)))
@router.post("/videos/upload")
async def get_upload_token(redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
"""获取 admin 专用上传凭证(服务端直传,无 OSS)"""
from uuid import uuid4
token = str(uuid4()).replace("-", "")
await redis.set(f"admin_upload:{token}", "1", ex=3600)
return ok({
"upload_url": "/api/v1/admin/videos/upload/file",
"method": "POST",
"token": token,
"expire_seconds": 3600,
})
@router.post("/videos/upload/file")
async def upload_video_file(request: Request, token: str = "",
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
"""接收 admin 上传的 mp4/mov 文件并存到 server/uploads(供转码)"""
from fastapi import UploadFile, File, HTTPException
from datetime import datetime
from pathlib import Path
import hashlib
import time
# 校验 token(必须通过 /admin/videos/upload 先拿)
key = f"admin_upload:{token}"
if not await redis.exists(key):
raise HTTPException(400, "上传凭证无效或已过期")
await redis.delete(key)
form = await request.form()
upload: UploadFile | None = form.get("file") if hasattr(form, "get") else None
if upload is None or not hasattr(form, "get"):
files = [v for v in form.values() if hasattr(v, "filename") and v.filename]
if not files:
raise HTTPException(400, "缺少 file 字段")
upload = files[0]
allowed = {".mp4", ".mov", ".mkv", ".avi"}
suffix = Path(upload.filename or "").suffix.lower()
if suffix not in allowed:
raise HTTPException(400, f"仅支持 {','.join(sorted(allowed))}")
# 落盘到 server/uploads/upload/YYYYMMDD/
day = datetime.now().strftime("%Y%m%d")
out_dir = Path("/root/project/video/server/uploads/upload") / day
out_dir.mkdir(parents=True, exist_ok=True)
hasher = hashlib.md5()
file_path = out_dir / f"{hasher.update(f'{time.time()}'.encode()) and ''}"
# 用 UUID 保证唯一(time.time() 碰撞)
from uuid import uuid4 as _uuid
file_path = out_dir / f"{_uuid().hex}{suffix}"
size = 0
with open(file_path, "wb") as f:
while True:
chunk = await upload.read(1024 * 1024)
if not chunk:
break
size += len(chunk)
f.write(chunk)
await upload.close()
# 生成 admin 上传凭证(前端下一步调 POST /admin/videos 时带这个 token 认领)
admin_token = str(_uuid()).replace("-", "")
await redis.set(f"admin_uploaded:{admin_token}",
str(file_path), ex=86400)
return ok({
"upload_token": admin_token,
"object_key": f"upload/{day}/{file_path.name}",
"file_size": size,
})
# ---------- 用户 / 积分 ----------
@router.get("/users")
@@ -297,3 +395,155 @@ async def delete_sensitive_word(word_id: int, request: Request, db: AsyncSession
admin: AdminIdentity = Depends(require_super)):
await admin_service.delete_sensitive_word(db, redis, admin, word_id, _ip(request))
return ok()
# ---------- 系列管理 ----------
@router.get("/series")
async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
from app.models.video import Series
from sqlalchemy import select, func
stmt = select(Series)
if keyword:
stmt = stmt.where(Series.title.contains(keyword))
total = (await db.execute(select(func.count()).select_from(Series).where(
Series.title.contains(keyword) if keyword else True
))).scalar_one()
rows = (await db.execute(
stmt.order_by(Series.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
return ok({
"total": total,
"list": [{
"id": s.id, "title": s.title, "cover_url": s.cover_url,
"total_episodes": s.total_episodes, "status": s.status,
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
} for s in rows],
})
# ---------- 系列管理 ----------
from pydantic import BaseModel, Field as _Field
class _SeriesCreate(BaseModel):
title: str = _Field(min_length=1, max_length=100)
cover_url: str = ""
description: str = ""
category_id: int = 0
class _SeriesUpdate(BaseModel):
title: str | None = _Field(None, min_length=1, max_length=100)
cover_url: str | None = None
description: str | None = None
status: int | None = _Field(None, ge=0, le=1)
class _VideoSeriesBind(BaseModel):
series_id: int
episode_no: int = _Field(ge=1)
@router.get("/series")
async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
from app.models.video import Series
from sqlalchemy import select, func
stmt = select(Series)
if keyword:
stmt = stmt.where(Series.title.contains(keyword))
count_stmt = select(func.count()).select_from(Series)
if keyword:
count_stmt = count_stmt.where(Series.title.contains(keyword))
total = (await db.execute(count_stmt)).scalar_one()
rows = (await db.execute(
stmt.order_by(Series.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
return ok({
"total": total,
"list": [{
"id": s.id, "title": s.title, "cover_url": s.cover_url,
"total_episodes": s.total_episodes, "status": s.status,
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
} for s in rows],
})
@router.post("/series")
async def create_series(req: _SeriesCreate, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
from app.models.video import Series
s = Series(
title=req.title, cover_url=req.cover_url, description=req.description,
category_id=req.category_id, status=1,
)
db.add(s)
await db.commit()
await db.refresh(s)
return ok({"series_id": s.id})
@router.put("/series/{series_id}")
async def update_series(series_id: int, req: _SeriesUpdate, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
from app.models.video import Series
s = await db.get(Series, series_id)
if not s:
raise BizException(errors.NOT_FOUND, "系列不存在")
d = req.model_dump(exclude_unset=True)
for k, v in d.items():
setattr(s, k, v)
await db.commit()
return ok()
@router.delete("/series/{series_id}")
async def delete_series(series_id: int, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
from app.models.video import Series
s = await db.get(Series, series_id)
if not s:
raise BizException(errors.NOT_FOUND, "系列不存在")
await db.delete(s)
await db.commit()
return ok()
@router.post("/videos/{video_id}/series")
async def bind_video_series(video_id: int, req: _VideoSeriesBind,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
"""把视频归到某系列某集(episode_no 会自动维护 series.total_episodes)"""
from app.models.video import Series, Video
from sqlalchemy import select, func
v = await db.get(Video, video_id)
if not v:
raise BizException(errors.NOT_FOUND, "视频不存在")
s = await db.get(Series, req.series_id)
if not s:
raise BizException(errors.NOT_FOUND, "系列不存在")
v.series_id = req.series_id
v.episode_no = req.episode_no
# 先 flush 让 count 查询能查到刚改的值
await db.flush()
# total_episodes 只统计已发布(status=2)的集数,和 public 接口过滤条件一致
cnt = (await db.execute(
select(func.count(Video.id)).where(
Video.series_id == req.series_id, Video.status == 2
)
)).scalar_one()
s.total_episodes = cnt
await db.commit()
return ok()
+88
View File
@@ -115,3 +115,91 @@ async def report_complete(video_id: int, req: WatchCompleteReq, db: AsyncSession
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await video_service.report_complete(db, redis, identity, video_id, req.watch_duration))
# ---------- 系列(Series)----------
@router.get("/series/{series_id}")
async def get_series(series_id: int, db: AsyncSession = Depends(get_db)):
"""系列详情 + 全部集数列表(按 episode_no 排序)"""
from app.models.video import Series, Video
from sqlalchemy import select
series = await db.get(Series, series_id)
if not series or series.status != 1:
raise BizException(errors.NOT_FOUND, "系列不存在或已隐藏")
# 关联视频(已发布)
stmt = select(Video).where(
Video.series_id == series_id, Video.status == 2
).order_by(Video.episode_no.asc(), Video.id.asc())
eps = (await db.execute(stmt)).scalars().all()
return ok({
"series": {
"id": series.id,
"title": series.title,
"cover_url": series.cover_url,
"description": series.description,
"total_episodes": len(eps),
},
"episodes": [
{
"video_id": v.id,
"episode_no": v.episode_no,
"title": v.title,
"cover_url": v.cover_url,
"duration": v.duration,
"video_url": v.video_url,
}
for v in eps
],
})
@router.get("/{video_id}/series")
async def get_video_series(video_id: int, db: AsyncSession = Depends(get_db)):
"""当前视频所属系列 + 上下集(用于 PlayerScreen 抽屉入口)"""
from app.models.video import Series, Video
from sqlalchemy import select
video = await db.get(Video, video_id)
if not video or not video.series_id or video.status != 2:
return ok({"has_series": False})
series = await db.get(Series, video.series_id)
if not series or series.status != 1:
return ok({"has_series": False})
stmt = select(Video).where(
Video.series_id == series.id, Video.status == 2
).order_by(Video.episode_no.asc(), Video.id.asc())
all_eps = (await db.execute(stmt)).scalars().all()
# 找当前集索引
current_idx = next((i for i, v in enumerate(all_eps) if v.id == video_id), -1)
prev_ep = all_eps[current_idx - 1] if current_idx > 0 else None
next_ep = all_eps[current_idx + 1] if 0 <= current_idx < len(all_eps) - 1 else None
return ok({
"has_series": True,
"current_episode_no": video.episode_no,
"series": {
"id": series.id,
"title": series.title,
"cover_url": series.cover_url,
"total_episodes": len(all_eps),
},
"episodes": [
{
"video_id": v.id,
"episode_no": v.episode_no,
"title": v.title,
"cover_url": v.cover_url,
"duration": v.duration,
}
for v in all_eps
],
"prev_video_id": prev_ep.id if prev_ep else None,
"next_video_id": next_ep.id if next_ep else None,
})
+4 -4
View File
@@ -56,10 +56,10 @@ class Settings(BaseSettings):
wechat_api_base: str = "https://api.mch.weixin.qq.com"
tron_api_key: str = ""
# 短信 / 邮件
sms_access_key_id: str = ""
sms_access_key_secret: str = ""
sms_sign_name: str = ""
# 短信(短信宝)/ 邮件
smsbao_username: str = ""
smsbao_api_key: str = ""
sms_sign_name: str = "【followwin】"
smtp_host: str = ""
smtp_port: int = 465
smtp_user: str = ""
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.
+25
View File
@@ -14,6 +14,8 @@ class Video(Base):
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True), comment="作者")
category_id: Mapped[int] = mapped_column(default=0)
series_id: Mapped[int | None] = mapped_column(BIGINT(unsigned=True))
episode_no: Mapped[int | None] = mapped_column()
title: Mapped[str] = mapped_column(String(100), default="")
description: Mapped[str] = mapped_column(String(500), default="")
cover_url: Mapped[str] = mapped_column(String(500), default="")
@@ -89,3 +91,26 @@ class TranscodeTask(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (Index("idx_pick", "task_status", "priority", "created_at"),)
class Series(Base):
"""系列(合集):把多条视频组织成一个集数列表"""
__tablename__ = "series"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(100), default="")
cover_url: Mapped[str] = mapped_column(String(500), default="")
description: Mapped[str] = mapped_column(String(500), default="")
author_id: Mapped[int | None] = mapped_column(BIGINT(unsigned=True))
category_id: Mapped[int] = mapped_column(default=0)
total_episodes: Mapped[int] = mapped_column(default=0)
view_count: Mapped[int] = mapped_column(BigInteger, default=0)
status: Mapped[int] = mapped_column(SmallInteger, default=1, comment="1正常 0隐藏")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (
Index("idx_series_cat_status", "category_id", "status"),
Index("idx_series_status_created", "status", "created_at"),
)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+34
View File
@@ -109,3 +109,37 @@ class SensitiveWordAddReq(BaseModel):
"""添加敏感词(11.31)"""
word: str = Field(min_length=1, max_length=50)
class AdminPasswordChangeReq(BaseModel):
"""管理员修改密码(11.35)"""
old_password: str = Field(min_length=1, max_length=100)
new_password: str = Field(min_length=6, max_length=100)
class AdminVideoCreateReq(BaseModel):
"""管理后台新增视频(11.36)
两种模式二选一:
- upload_token + cover_upload_token:先通过 /admin/videos/upload 获取上传凭证,
前端直传到 server/uploads 后把 token 传回(视频自动转码)
- video_url + cover_url:直接填 m3u8 地址(适合 CDN 外站 / 已经转码好的资源)
"""
title: str = Field(min_length=1, max_length=100)
description: str = Field(max_length=500, default="")
category_id: int = Field(ge=1)
tag_names: list[str] | None = Field(None)
# 模式 A:先上传再发布(token 方式)
upload_token: str | None = None
cover_upload_token: str | None = None # 可选
# 模式 B:直接填 URL(m3u8 外站)
video_url: str | None = Field(None, max_length=500)
cover_url: str | None = Field(None, max_length=500)
duration: int | None = Field(None, ge=1, le=36000) # 秒,可选
# 发布状态:默认待审核(1),也可直接发布(2)
status: int = Field(1, ge=1, le=2)
+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}