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.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
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.
@@ -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)
|
||||
|
||||
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