Files
2026-10-17 19:04:05 +08:00

768 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""视频服务:Feed / 详情 / 观看计费 / 完整观看(对应《03-接口文档》第 5 章、《02-技术文档》6.4)"""
import base64
import hashlib
import json
import random
import uuid
from datetime import date, datetime, timedelta
import redis.asyncio as aioredis
from fastapi import UploadFile
from sqlalchemy import func, select
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import biz_config
from app.core import errors, ratelimit
from app.core.response import BizException
from app.deps import Identity
from app.models.billing import WatchRecord
from app.models.incentive import VideoEarning
from app.models.interaction import Favorite, Like
from app.models.user import User
from app.models.video import Category, Tag, Video, VideoTag
from app.schemas.video import PublishVideoReq, UploadTokenReq
from app.services import membership_service, sensitive_service, storage_service, wallet_service
STATUS_PUBLISHED = 2
STATUS_TEXT = {0: "转码中", 1: "待审核", 2: "已发布", 3: "拒绝", 4: "下架", 5: "转码失败"}
# 观看上报限流:60 次/分钟/计费主体
WATCH_RATE_LIMIT = 60
# 免费额度 Set / 已看 Set 过期时间 48h
SET_TTL = 48 * 3600
# 推荐候选池上限
FEED_POOL_SIZE = 500
# 视频详情缓存 5min
DETAIL_CACHE_TTL = 300
# 免费额度 + 去重 Lua 原子脚本(《02-技术文档》3.4-2)
FREE_QUOTA_LUA = """
if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then return 1 end
local n = redis.call('SCARD', KEYS[1])
if n < tonumber(ARGV[2]) then redis.call('SADD', KEYS[1], ARGV[1]) redis.call('EXPIRE', KEYS[1], ARGV[3]) return 2 end
return 0
"""
def _fmt(dt: datetime | None) -> str | None:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
def _points_cfg() -> dict:
return biz_config.get("points") or {}
# ---------- Feed 单条序列化 ----------
def _author_brief(user: User | None) -> dict:
return {
"user_id": user.id if user else 0,
"nickname": user.nickname if user else "",
"avatar_url": user.avatar_url if user else "",
}
async def serialize_videos(
db: AsyncSession, videos: list[Video], viewer_user_id: int | None = None
) -> list[dict]:
"""Feed 单条结构(对齐《03-接口文档》5.1):批量取作者/标签/登录用户互动状态"""
if not videos:
return []
video_ids = [v.id for v in videos]
authors = {
u.id: u
for u in (await db.execute(select(User).where(User.id.in_({v.user_id for v in videos})))).scalars()
}
tags_map = await get_tags_map(db, video_ids)
liked: set[int] = set()
favorited: set[int] = set()
if viewer_user_id:
liked = set(
(await db.execute(
select(Like.video_id).where(Like.user_id == viewer_user_id, Like.video_id.in_(video_ids))
)).scalars()
)
favorited = set(
(await db.execute(
select(Favorite.video_id).where(Favorite.user_id == viewer_user_id, Favorite.video_id.in_(video_ids))
)).scalars()
)
return [
{
"video_id": v.id,
"title": v.title,
"cover_url": v.cover_url,
"video_url": v.video_url,
"duration": v.duration,
"category_id": v.category_id,
"author": _author_brief(authors.get(v.user_id)),
"view_count": v.view_count,
"like_count": v.like_count,
"comment_count": v.comment_count,
"favorite_count": v.favorite_count,
"tags": tags_map.get(v.id, []),
"is_liked": v.id in liked,
"is_favorited": v.id in favorited,
}
for v in videos
]
async def get_tags_map(db: AsyncSession, video_ids: list[int]) -> dict[int, list[str]]:
if not video_ids:
return {}
rows = await db.execute(
select(VideoTag.video_id, Tag.name)
.join(Tag, Tag.id == VideoTag.tag_id)
.where(VideoTag.video_id.in_(video_ids))
)
tags_map: dict[int, list[str]] = {}
for vid, name in rows.all():
tags_map.setdefault(vid, []).append(name)
return tags_map
async def get_tags_of_video(db: AsyncSession, video_id: int) -> list[str]:
return (await get_tags_map(db, [video_id])).get(video_id, [])
# ---------- 游标 ----------
def _encode_cursor(payload: dict) -> str:
return base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()).decode()
def _decode_cursor(cursor: str | None) -> dict:
if not cursor:
return {}
try:
return json.loads(base64.urlsafe_b64decode(cursor.encode()))
except Exception:
raise BizException(errors.PARAM_ERROR, "cursor 无效")
# ---------- Feed ----------
async def get_feed(
db: AsyncSession,
redis: aioredis.Redis,
identity: Identity,
feed_type: str,
cursor: str | None,
limit: int,
category_id: int | None,
) -> dict:
if feed_type == "latest":
return await _feed_latest(db, identity, cursor, limit, category_id)
if feed_type in ("liked", "viewed"):
return await _feed_ranked(db, identity, feed_type, cursor, limit, category_id)
return await _feed_scored(db, redis, identity, feed_type, cursor, limit, category_id)
async def _feed_ranked(
db: AsyncSession, identity: Identity, feed_type: str, cursor: str | None,
limit: int, category_id: int | None,
) -> dict:
"""liked / viewed:按点赞量 / 播放量严格倒序(值 + id 打破并列游标)"""
col = Video.like_count if feed_type == "liked" else Video.view_count
stmt = await _base_pool_stmt(category_id)
c = _decode_cursor(cursor)
if c:
val, vid = int(c["n"]), int(c["i"])
stmt = stmt.where((col < val) | ((col == val) & (Video.id < vid)))
rows = list(
(await db.execute(stmt.order_by(col.desc(), Video.id.desc()).limit(limit + 1))).scalars()
)
page, rows = (len(rows) > limit), rows[:limit]
next_cursor = None
if page and rows:
last = rows[-1]
last_val = last.like_count if feed_type == "liked" else last.view_count
next_cursor = _encode_cursor({"n": last_val, "i": last.id})
return {
"next_cursor": next_cursor,
"list": await serialize_videos(db, rows, identity.user_id),
}
async def _base_pool_stmt(category_id: int | None):
stmt = select(Video).where(Video.status == STATUS_PUBLISHED, Video.published_at.isnot(None))
if category_id:
stmt = stmt.where(Video.category_id == category_id)
return stmt
async def _feed_latest(
db: AsyncSession, identity: Identity, cursor: str | None, limit: int, category_id: int | None
) -> dict:
"""latest:published_at 游标倒序(ts + id 打破并列)"""
stmt = await _base_pool_stmt(category_id)
c = _decode_cursor(cursor)
if c:
ts, vid = datetime.fromtimestamp(c["p"]), int(c["i"])
stmt = stmt.where((Video.published_at < ts) | ((Video.published_at == ts) & (Video.id < vid)))
rows = list(
(await db.execute(stmt.order_by(Video.published_at.desc(), Video.id.desc()).limit(limit + 1))).scalars()
)
page, rows = (len(rows) > limit), rows[:limit]
next_cursor = None
if page and rows:
last = rows[-1]
next_cursor = _encode_cursor({"p": int(last.published_at.timestamp()), "i": last.id})
return {
"next_cursor": next_cursor,
"list": await serialize_videos(db, rows, identity.user_id),
}
def _hot_score(v: Video, now: datetime) -> float:
"""热度分:view×1 + like×5 + comment×10,随发布天数衰减"""
age_days = max((now - (v.published_at or now)).days, 0)
base = v.view_count + v.like_count * 5 + v.comment_count * 10
return base / (1 + age_days)
async def _user_tag_prefs(db: AsyncSession, user_id: int) -> dict[str, float]:
"""近 7 天观看/点赞/收藏视频的标签偏好(观看+1 点赞+3 收藏+5);无偏好返回空"""
since = datetime.now() - timedelta(days=7)
video_ids: list[tuple[int, float]] = []
watched = (await db.execute(
select(WatchRecord.video_id).where(
WatchRecord.subject_type == 1, WatchRecord.subject_id == str(user_id), WatchRecord.created_at > since
)
)).scalars().all()
video_ids += [(vid, 1.0) for vid in watched]
liked = (await db.execute(select(Like.video_id).where(Like.user_id == user_id))).scalars().all()
video_ids += [(vid, 3.0) for vid in liked]
fav = (await db.execute(select(Favorite.video_id).where(Favorite.user_id == user_id))).scalars().all()
video_ids += [(vid, 5.0) for vid in fav]
if not video_ids:
return {}
tags_map = await get_tags_map(db, list({vid for vid, _ in video_ids}))
prefs: dict[str, float] = {}
for vid, w in video_ids:
for tag in tags_map.get(vid, []):
prefs[tag] = prefs.get(tag, 0.0) + w
return prefs
async def _feed_scored(
db: AsyncSession,
redis: aioredis.Redis,
identity: Identity,
feed_type: str,
cursor: str | None,
limit: int,
category_id: int | None,
) -> dict:
"""recommend / hot:候选池打分 → 当日种子确定性加权抽样 → 当日去重(不足兜底)→ 偏移游标"""
now = datetime.now()
today = f"{now:%Y%m%d}"
subject = identity.subject or "anon"
# 1. 候选池:近 30 天已发布,封顶 FEED_POOL_SIZE
stmt = (await _base_pool_stmt(category_id)).where(
Video.published_at > now - timedelta(days=30)
).order_by(Video.published_at.desc()).limit(FEED_POOL_SIZE)
pool = list((await db.execute(stmt)).scalars())
if not pool:
return {"next_cursor": None, "list": []}
# 2. 打分
scores: dict[int, float] = {}
if feed_type == "recommend" and identity.user_id:
prefs = await _user_tag_prefs(db, identity.user_id)
else:
prefs = {}
tags_map = await get_tags_map(db, [v.id for v in pool])
for v in pool:
hot = _hot_score(v, now)
if feed_type == "recommend" and prefs:
# 命中偏好标签加权,未命中取热度分
pref = sum(prefs.get(t, 0.0) for t in tags_map.get(v.id, []))
scores[v.id] = pref * 1000 + hot if pref > 0 else hot
else:
# hot 策略 / 游客 / 新用户:热度分
scores[v.id] = hot
# 3. 排序:hot = 热度分严格倒序;recommend = 当日种子确定性加权抽样(每日重排,同日稳定)
if feed_type == "hot":
ordered = sorted(pool, key=lambda v: (scores[v.id], v.id), reverse=True)
else:
seed = int(hashlib.md5(f"{today}{subject}".encode()).hexdigest()[:8], 16)
rng = random.Random(seed)
ordered = sorted(pool, key=lambda v: rng.random() ** (1.0 / max(scores[v.id], 1e-6)), reverse=True)
# 4. 当日去重(观看上报写入 watched Set);候选不足时放开过滤兜底
watched = await redis.smembers(f"watched:{subject}:{today}") # type: ignore[misc]
filtered = [v for v in ordered if str(v.id) not in watched]
offset = int(_decode_cursor(cursor).get("o", 0))
items = filtered if len(filtered) > offset else ordered
page_items = items[offset: offset + limit]
next_cursor = _encode_cursor({"o": offset + limit}) if len(items) > offset + limit else None
return {
"next_cursor": next_cursor,
"list": await serialize_videos(db, page_items, identity.user_id),
}
# ---------- 视频详情 ----------
async def get_detail(db: AsyncSession, redis: aioredis.Redis, identity: Identity, video_id: int) -> dict:
"""详情:基础数据缓存 5min(中性,不含观看者态);登录用户叠加 my_position/互动状态"""
cache_key = f"video:detail:{video_id}"
cached = await redis.get(cache_key)
if cached:
data = json.loads(cached)
else:
video = await db.get(Video, video_id)
if video is None or video.status != STATUS_PUBLISHED:
raise BizException(errors.VIDEO_NOT_AVAILABLE)
base = (await serialize_videos(db, [video]))[0]
category = await db.get(Category, video.category_id) if video.category_id else None
base.update({
"description": video.description,
"category": {"category_id": category.id, "name": category.name} if category else None,
"published_at": _fmt(video.published_at),
})
data = base
await redis.set(cache_key, json.dumps(data, ensure_ascii=False), ex=DETAIL_CACHE_TTL)
data = dict(data)
data["my_position"] = 0
if identity.user_id:
# 登录用户:互动状态 + 续播进度(最近一次观看记录)
video_id_int = int(data["video_id"])
data["is_liked"] = bool((await db.execute(
select(Like.id).where(Like.user_id == identity.user_id, Like.video_id == video_id_int)
)).first())
data["is_favorited"] = bool((await db.execute(
select(Favorite.id).where(Favorite.user_id == identity.user_id, Favorite.video_id == video_id_int)
)).first())
last = (await db.execute(
select(WatchRecord.position).where(
WatchRecord.subject_type == 1, WatchRecord.subject_id == str(identity.user_id),
WatchRecord.video_id == video_id_int,
).order_by(WatchRecord.id.desc()).limit(1)
)).scalar()
data["my_position"] = int(last or 0)
return data
async def invalidate_detail_cache(redis: aioredis.Redis, video_id: int) -> None:
"""状态变更(编辑/上下架/审核)时失效详情缓存"""
await redis.delete(f"video:detail:{video_id}")
# ---------- 观看上报(计费) ----------
async def _get_published_video(db: AsyncSession, video_id: int) -> Video:
video = await db.get(Video, video_id)
if video is None or video.status != STATUS_PUBLISHED:
raise BizException(errors.VIDEO_NOT_AVAILABLE)
return video
async def report_watch(
db: AsyncSession, redis: aioredis.Redis, identity: Identity, video_id: int, position: int
) -> dict:
"""计费判定链:同日去重 → 会员 → 本人 → 免费额度 Lua → 游客拦截 → 扣积分"""
video = await _get_published_video(db, video_id)
if not identity.subject:
raise BizException(errors.PARAM_ERROR, "缺少 X-Device-ID")
today = date.today()
ymd = f"{today:%Y%m%d}"
subject = identity.subject
watched_key = f"watched:{subject}:{ymd}"
await ratelimit.hit(redis, f"rl:watch:{subject}:{datetime.now():%Y%m%d%H%M}", WATCH_RATE_LIMIT, 60)
# 1. 同日去重:重复观看不计费,仅更新续播进度
if await redis.sismember(watched_key, str(video_id)): # type: ignore[misc]
await _upsert_watch_record(db, identity, video_id, today, 3, 0, position)
await db.commit()
return await _watch_resp(db, redis, identity, 3, 0, ymd)
is_author = bool(identity.user_id and video.user_id == identity.user_id)
cost_type, cost_points = 3, 0
if is_author:
pass # 本人作品不计费
elif identity.user_id and await membership_service.is_member(db, identity.user_id):
cost_type = 2 # 会员免费
else:
# 2. 免费额度(Lua 原子:去重 + 计数 + 占位)
free_limit = int(_points_cfg().get("daily_free_count", 10))
free_key = f"free:watch:{subject}:{ymd}"
res = await redis.eval(FREE_QUOTA_LUA, 1, free_key, str(video_id), str(free_limit), str(SET_TTL)) # type: ignore[misc]
if res == 2:
cost_type = 0 # 占用免费额度
elif res == 1:
cost_type = 3 # 并发下已在免费集合 → 按重复处理
else:
# 3. 额度用尽:游客拦截 / 用户扣积分
if identity.is_guest:
raise BizException(errors.FREE_LIMIT_EXCEEDED, "今日免费观看次数已用完,登录后继续观看",
data={"need_login": True})
cost_type = 1
cost_points = int(_points_cfg().get("watch_cost", 1))
try:
await wallet_service.change_points(
db, identity.user_id, -cost_points, wallet_service.BIZ_WATCH,
biz_id=str(video_id), remark="观看视频",
)
except BizException as e:
if e.code == errors.POINTS_NOT_ENOUGH:
# 文档 5.3:积分不足响应 data 带当前积分
e.data = {"points": await wallet_service.get_balance(db, identity.user_id)}
raise
first_today = await _upsert_watch_record(db, identity, video_id, today, cost_type, cost_points, position)
if first_today:
video.view_count += 1
await redis.sadd(watched_key, str(video_id)) # type: ignore[misc]
await redis.expire(watched_key, SET_TTL)
await db.commit()
return await _watch_resp(db, redis, identity, cost_type, cost_points, ymd)
async def _upsert_watch_record(
db: AsyncSession, identity: Identity, video_id: int, watch_date: date,
cost_type: int, cost_points: int, position: int, watch_duration: int = 0,
) -> bool:
"""按 uk_dedup 天然键幂等写入;返回是否当日首次(用于播放量与收益计数)"""
insert_stmt = mysql_insert(WatchRecord).values(
subject_type=1 if identity.user_id else 2,
subject_id=identity.subject,
video_id=video_id,
watch_date=watch_date,
cost_type=cost_type,
cost_points=cost_points,
position=position,
watch_duration=watch_duration,
)
stmt = insert_stmt.on_duplicate_key_update(
position=insert_stmt.inserted.position,
watch_duration=insert_stmt.inserted.watch_duration,
)
result = await db.execute(stmt)
await db.flush()
return result.rowcount == 1
async def _watch_resp(db: AsyncSession, redis: aioredis.Redis, identity: Identity,
cost_type: int, cost_points: int, ymd: str) -> dict:
free_limit = int(_points_cfg().get("daily_free_count", 10))
used = await redis.scard(f"free:watch:{identity.subject}:{ymd}") # type: ignore[misc]
remaining_points = await wallet_service.get_balance(db, identity.user_id) if identity.user_id else 0
return {
"cost_type": cost_type,
"cost_points": cost_points,
"remaining_points": remaining_points,
"free_remaining_today": max(free_limit - int(used or 0), 0),
}
# ---------- 完整观看上报 ----------
async def report_complete(
db: AsyncSession, redis: aioredis.Redis, identity: Identity, video_id: int, watch_duration: int
) -> dict:
"""完整观看:本人/当日重复/时长明显不足 → credited=false;有效则累计 video_earnings"""
video = await _get_published_video(db, video_id)
if not identity.subject:
raise BizException(errors.PARAM_ERROR, "缺少 X-Device-ID")
credited = False
is_author = bool(identity.user_id and video.user_id == identity.user_id)
# 时长合理性:不得明显低于视频时长(低于 50% 视为异常)
duration_ok = video.duration <= 0 or watch_duration >= video.duration * 0.5
if not is_author and duration_ok and watch_duration > 0:
ymd = f"{datetime.now():%Y%m%d}"
dedup_key = f"cview:{identity.subject}:{video_id}:{ymd}"
if await redis.set(dedup_key, 1, nx=True, ex=SET_TTL):
stmt = mysql_insert(VideoEarning).values(
video_id=video_id, author_id=video.user_id, complete_views=1
).on_duplicate_key_update(complete_views=VideoEarning.complete_views + 1)
await db.execute(stmt)
credited = True
# 记录实际观看时长(续播记录复用)
await _upsert_watch_record(db, identity, video_id, date.today(), 3, 0, 0, watch_duration)
await db.commit()
return {"credited": credited}
# ---------- 分类 ----------
async def list_categories(db: AsyncSession) -> list[dict]:
rows = (await db.execute(
select(Category).where(Category.status == 1).order_by(Category.sort, Category.id)
)).scalars().all()
return [{"category_id": c.id, "name": c.name} for c in rows]
# ---------- 上传发布(5.5 / 5.6 / 5.7) ----------
# 上传凭证(object_key 占位)有效期 24h
UPLOAD_TOKEN_TTL = 24 * 3600
def _upload_cfg() -> dict:
return biz_config.get("upload") or {}
async def get_upload_token(redis: aioredis.Redis, identity: Identity, req: UploadTokenReq) -> dict:
"""5.5 获取上传凭证:开发环境无 OSS,返回 server 中转模式,客户端改走 upload/file"""
ext = storage_service.video_ext_of(req.file_name)
if ext not in storage_service.VIDEO_EXTS:
raise BizException(errors.PARAM_ERROR, "仅支持 mp4/mov/m4v/avi/mkv/webm 格式")
max_mb = int(_upload_cfg().get("max_size_mb", 500))
if req.file_size > max_mb * 1024 * 1024:
raise BizException(errors.PARAM_ERROR, f"文件不能超过 {max_mb}MB")
object_key = f"upload/tmp/{datetime.now():%Y%m%d}/{uuid.uuid4().hex}.{ext}"
# 占位登记:upload/file 与发布时校验归属与有效期
await redis.set(
f"upload:obj:{object_key}", json.dumps({"uid": identity.user_id, "url": ""}), ex=UPLOAD_TOKEN_TTL
)
return {"upload_type": "server", "object_key": object_key, "upload_url": "/videos/upload/file"}
async def _load_pending_upload(redis: aioredis.Redis, object_key: str, user_id: int) -> dict:
raw = await redis.get(f"upload:obj:{object_key}")
pending = json.loads(raw) if raw else None
if not pending or pending.get("uid") != user_id:
raise BizException(errors.PARAM_ERROR, "object_key 无效或已过期")
return pending
async def register_upload(
redis: aioredis.Redis, identity: Identity, object_key: str, file: UploadFile
) -> dict:
"""upload/token 配套:multipart 中转上传,保存到本地并登记可访问 url"""
storage_service.validate_object_key(object_key)
await _load_pending_upload(redis, object_key, identity.user_id)
url, size = await storage_service.save_video(file, object_key)
await redis.set(
f"upload:obj:{object_key}",
json.dumps({"uid": identity.user_id, "url": url, "file_size": size}),
ex=UPLOAD_TOKEN_TTL,
)
return {"object_key": object_key, "url": url, "file_size": size}
async def publish_video(
db: AsyncSession, redis: aioredis.Redis, identity: Identity, req: PublishVideoReq
) -> dict:
"""5.6 发布:敏感词过滤 → 分类/上传校验 → 创建 Video(开发环境无转码器,直接待审核)"""
for text in [req.title, req.description, *req.tags]:
if text and await sensitive_service.find_sensitive(db, redis, text):
raise BizException(errors.SENSITIVE_WORD)
_check_tags(req.tags)
category = await db.get(Category, req.category_id)
if category is None or category.status != 1:
raise BizException(errors.PARAM_ERROR, "分类不存在")
pending = await _load_pending_upload(redis, req.object_key, identity.user_id)
if not pending.get("url"):
raise BizException(errors.PARAM_ERROR, "视频文件尚未上传")
is_priority = 1 if await membership_service.is_member(db, identity.user_id) else 0
video = Video(
user_id=identity.user_id,
category_id=req.category_id,
title=req.title,
description=req.description,
video_url=pending["url"],
file_size=int(pending.get("file_size") or 0),
status=1, # 待审核:开发环境无转码器,由后台审核通过后直接上架
is_priority=is_priority,
)
db.add(video)
await db.flush()
await _upsert_tags(db, video.id, req.tags)
await redis.delete(f"upload:obj:{req.object_key}")
await db.commit()
return {
"video_id": video.id,
"status": video.status,
"status_text": STATUS_TEXT[video.status],
"is_priority": bool(is_priority),
}
def _check_tags(names: list[str]) -> None:
"""标签数量与长度校验(0~5 个,每个 1~20 字)"""
if len(names) > 5:
raise BizException(errors.PARAM_ERROR, "标签最多 5 个")
if any(not (1 <= len(t) <= 20) for t in names):
raise BizException(errors.PARAM_ERROR, "每个标签 1~20 字")
async def _upsert_tags(db: AsyncSession, video_id: int, names: list[str]) -> None:
"""标签 upsert + 关联(去重保序,维护冗余 video_count)"""
for name in dict.fromkeys(names):
tag = (await db.execute(select(Tag).where(Tag.name == name))).scalar_one_or_none()
if tag is None:
tag = Tag(name=name)
db.add(tag)
await db.flush()
db.add(VideoTag(video_id=video_id, tag_id=tag.id))
tag.video_count += 1
async def _replace_tags(db: AsyncSession, video_id: int, names: list[str]) -> None:
"""编辑视频时重建标签关联:旧关联删除并回退计数 → 写新关联"""
old = (await db.execute(
select(VideoTag, Tag)
.join(Tag, Tag.id == VideoTag.tag_id)
.where(VideoTag.video_id == video_id)
)).all()
for link, tag in old:
tag.video_count = max(tag.video_count - 1, 0)
await db.delete(link)
await _upsert_tags(db, video_id, names)
# 软删除标记(模型 status:5 转码失败/已删除)
STATUS_DELETED = 5
async def _get_own_video(db: AsyncSession, identity: Identity, video_id: int) -> Video:
"""取本人作品:不存在/已删除 → 2003;非作者 → 2107"""
video = await db.get(Video, video_id)
if video is None or video.status == STATUS_DELETED:
raise BizException(errors.VIDEO_NOT_AVAILABLE)
if video.user_id != identity.user_id:
raise BizException(errors.NOT_AUTHOR)
return video
async def update_video(
db: AsyncSession, redis: aioredis.Redis, identity: Identity, video_id: int, req
) -> dict:
"""5.12 编辑视频(仅作者):敏感词过滤 → 分类/标签校验 → 逐字段更新 → 失效详情缓存"""
video = await _get_own_video(db, identity, video_id)
texts = [t for t in (req.title, req.description) if t] + list(req.tags or [])
for text in texts:
if await sensitive_service.find_sensitive(db, redis, text):
raise BizException(errors.SENSITIVE_WORD)
if req.title is not None:
video.title = req.title
if req.description is not None:
video.description = req.description
if req.category_id is not None:
category = await db.get(Category, req.category_id)
if category is None or category.status != 1:
raise BizException(errors.PARAM_ERROR, "分类不存在")
video.category_id = req.category_id
if req.tags is not None:
_check_tags(req.tags)
await _replace_tags(db, video.id, req.tags)
await db.commit()
await invalidate_detail_cache(redis, video.id)
return {"video_id": video.id}
async def delete_video(
db: AsyncSession, redis: aioredis.Redis, identity: Identity, video_id: int
) -> None:
"""5.13 删除视频(仅作者,软删除):置已删除状态 → 立即下架不可见,互动计数保留"""
video = await _get_own_video(db, identity, video_id)
video.status = STATUS_DELETED
await db.commit()
await invalidate_detail_cache(redis, video.id)
async def list_my_videos(
db: AsyncSession, user_id: int, page: int, page_size: int, status: int | None
) -> dict:
"""5.7 我的作品:按创建倒序(排除已删除),列表项附加 status/status_text/reject_reason"""
stmt = select(Video).where(Video.user_id == user_id, Video.status != STATUS_DELETED)
if status is not None:
stmt = stmt.where(Video.status == status)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = list(
(await db.execute(stmt.order_by(Video.id.desc()).offset((page - 1) * page_size).limit(page_size))).scalars()
)
items = await serialize_videos(db, rows, user_id)
for item, v in zip(items, rows):
item.update({
"status": v.status,
"status_text": STATUS_TEXT.get(v.status, ""),
"reject_reason": v.reject_reason,
"created_at": _fmt(v.created_at),
})
return {"total": total, "list": items}
# ---------- 搜索 / 热搜 / 标签(5.9 / 5.10 / 5.11) ----------
# 热搜 ZSet(搜索计数,对齐《03-接口文档》5.10/5.11)
HOT_SEARCH_KEY = "search:hot"
# 热搜榜返回条数
HOT_SEARCH_TOP = 10
def _hot_order(stmt):
"""hot 排序:view×1 + like×5 + comment×10(与 Feed 热度分同权,SQL 内计算)"""
hot = Video.view_count + Video.like_count * 5 + Video.comment_count * 10
return stmt.order_by(hot.desc(), Video.id.desc())
def _latest_order(stmt):
return stmt.order_by(Video.published_at.desc(), Video.id.desc())
async def search_videos(
db: AsyncSession, redis: aioredis.Redis, identity: Identity,
keyword: str, sort: str, page: int, page_size: int,
) -> dict:
"""5.10 视频搜索:标题/标签 LIKE 匹配;命中敏感词返回空结果;搜索行为计入热搜 ZSet"""
if await sensitive_service.find_sensitive(db, redis, keyword):
return {"total": 0, "list": []}
await redis.zincrby(HOT_SEARCH_KEY, 1, keyword)
kw = f"%{keyword}%"
tag_video_ids = select(VideoTag.video_id).join(Tag, Tag.id == VideoTag.tag_id).where(Tag.name.like(kw))
stmt = select(Video).where(
Video.status == STATUS_PUBLISHED,
Video.title.like(kw) | Video.id.in_(tag_video_ids),
)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
stmt = _latest_order(stmt) if sort == "latest" else _hot_order(stmt)
rows = list((await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))).scalars())
return {"total": total, "list": await serialize_videos(db, rows, identity.user_id)}
async def get_hot_search(redis: aioredis.Redis) -> dict:
"""5.11 热搜榜:search:hot ZSet Top10"""
words = await redis.zrevrange(HOT_SEARCH_KEY, 0, HOT_SEARCH_TOP - 1)
return {"words": list(words)}
async def get_tag_videos(
db: AsyncSession, identity: Identity, tag_name: str, sort: str, page: int, page_size: int
) -> dict:
"""5.9 标签视频列表:标签不存在时返回空(tag 字段回显原名)"""
tag = (await db.execute(select(Tag).where(Tag.name == tag_name))).scalar_one_or_none()
if tag is None:
return {"tag": tag_name, "total": 0, "list": []}
stmt = (
select(Video)
.join(VideoTag, VideoTag.video_id == Video.id)
.where(VideoTag.tag_id == tag.id, Video.status == STATUS_PUBLISHED)
)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
stmt = _latest_order(stmt) if sort == "latest" else _hot_order(stmt)
rows = list((await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))).scalars())
return {"tag": tag.name, "total": total, "list": await serialize_videos(db, rows, identity.user_id)}