Files
video/server/build/lib/app/services/interaction_service.py
T
2026-10-16 13:51:00 +08:00

322 lines
13 KiB
Python

"""互动服务:点赞 / 收藏 / 评论 / 我的收藏 / 观看历史(对应《03-接口文档》第 6 章)"""
from datetime import datetime
import redis.asyncio as aioredis
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
from app.core.response import BizException
from app.models.billing import WatchRecord
from app.models.incentive import VideoEarning
from app.models.interaction import Comment, Favorite, Like
from app.models.user import User
from app.models.video import Video
from app.services import sensitive_service, video_service, wallet_service
def _fmt(dt: datetime | None) -> str | None:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
def _reward_cfg() -> dict:
return (biz_config.get("points") or {}).get("comment_reward") or {}
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 != video_service.STATUS_PUBLISHED:
raise BizException(errors.VIDEO_NOT_AVAILABLE)
return video
# ---------- 点赞 / 收藏 ----------
async def toggle_like(db: AsyncSession, redis: aioredis.Redis, user_id: int, video_id: int) -> dict:
"""切换点赞:明细 + 计数 + 创作者收益刻度(likes_accrued)同事务,取消会回退"""
video = await _get_published_video(db, video_id)
existing = (await db.execute(
select(Like).where(Like.user_id == user_id, Like.video_id == video_id)
)).scalar_one_or_none()
if existing:
await db.delete(existing)
video.like_count = max(video.like_count - 1, 0)
is_liked = False
stmt = mysql_insert(VideoEarning).values(video_id=video_id, author_id=video.user_id, likes_accrued=0)
stmt = stmt.on_duplicate_key_update(likes_accrued=func.greatest(VideoEarning.likes_accrued - 1, 0))
await db.execute(stmt)
else:
db.add(Like(user_id=user_id, video_id=video_id))
video.like_count += 1
is_liked = True
stmt = mysql_insert(VideoEarning).values(video_id=video_id, author_id=video.user_id, likes_accrued=1)
stmt = stmt.on_duplicate_key_update(likes_accrued=VideoEarning.likes_accrued + 1)
await db.execute(stmt)
await db.commit()
await video_service.invalidate_detail_cache(redis, video_id)
return {"is_liked": is_liked, "like_count": video.like_count}
async def toggle_favorite(db: AsyncSession, redis: aioredis.Redis, user_id: int, video_id: int) -> dict:
"""切换收藏:明细 + 计数同事务"""
video = await _get_published_video(db, video_id)
existing = (await db.execute(
select(Favorite).where(Favorite.user_id == user_id, Favorite.video_id == video_id)
)).scalar_one_or_none()
if existing:
await db.delete(existing)
video.favorite_count = max(video.favorite_count - 1, 0)
is_favorited = False
else:
db.add(Favorite(user_id=user_id, video_id=video_id))
video.favorite_count += 1
is_favorited = True
await db.commit()
await video_service.invalidate_detail_cache(redis, video_id)
return {"is_favorited": is_favorited, "favorite_count": video.favorite_count}
# ---------- 评论 ----------
async def list_comments(
db: AsyncSession, video_id: int, page: int, page_size: int, parent_id: int
) -> dict:
"""评论列表:parent_id=0 查一级评论(带 reply_count),否则查二级回复"""
stmt = select(Comment).where(
Comment.video_id == video_id, Comment.parent_id == parent_id, Comment.status == 1
)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (
await db.execute(
stmt.order_by(Comment.id.desc()).offset((page - 1) * page_size).limit(page_size)
)
).scalars().all()
users = {
u.id: u
for u in (await db.execute(
select(User).where(User.id.in_({c.user_id for c in rows} or {0}))
)).scalars()
}
# 一级评论批量统计回复数
reply_counts: dict[int, int] = {}
if parent_id == 0 and rows:
rc = await db.execute(
select(Comment.parent_id, func.count())
.where(Comment.parent_id.in_([c.id for c in rows]), Comment.status == 1)
.group_by(Comment.parent_id)
)
reply_counts = {pid: cnt for pid, cnt in rc.all()}
return {
"total": total,
"list": [
{
"comment_id": c.id,
"user": {
"user_id": c.user_id,
"nickname": users[c.user_id].nickname if c.user_id in users else "",
"avatar_url": users[c.user_id].avatar_url if c.user_id in users else "",
},
"content": c.content,
"like_count": c.like_count,
"reply_count": reply_counts.get(c.id, 0),
"created_at": _fmt(c.created_at),
}
for c in rows
],
}
async def _read_prog(redis: aioredis.Redis, user_id: int, today: str) -> tuple[int, int]:
"""读取当日评论奖励进度(count 当前进度 / daily 当日已发奖次数),跨日自动清零"""
prog = await redis.hgetall(f"comment:prog:{user_id}") # type: ignore[misc]
if prog.get("date") != today:
return 0, 0
return int(prog.get("count", 0)), int(prog.get("daily", 0))
async def _write_prog(redis: aioredis.Redis, user_id: int, today: str, count: int, daily: int) -> None:
key = f"comment:prog:{user_id}"
await redis.hset(key, mapping={"count": count, "daily": daily, "date": today}) # type: ignore[misc]
await redis.expire(key, 48 * 3600)
async def create_comment(
db: AsyncSession, redis: aioredis.Redis, user_id: int, video_id: int, req
) -> dict:
"""发表评论:敏感词 → 落库 + 计数 → ≥min_length 字计入积分进度,满 per_count 自动发奖"""
video = await _get_published_video(db, video_id)
if await sensitive_service.find_sensitive(db, redis, req.content):
raise BizException(errors.SENSITIVE_WORD)
parent_id = int(req.parent_id or 0)
if parent_id:
parent = await db.get(Comment, parent_id)
if parent is None or parent.video_id != video_id or parent.status != 1:
raise BizException(errors.NOT_FOUND, "回复的评论不存在")
cfg = _reward_cfg()
per_count = int(cfg.get("per_count", 10))
reward = int(cfg.get("reward", 1))
min_length = int(cfg.get("min_length", 5))
daily_limit = int(cfg.get("daily_limit", 5))
today = f"{datetime.now():%Y%m%d}"
comment = Comment(
video_id=video_id,
user_id=user_id,
parent_id=parent_id,
reply_to_user_id=req.reply_to_user_id,
content=req.content,
)
video.comment_count += 1
rewarded_this_time = False
counted = len(req.content) >= min_length # 字数达标才计入进度
count, daily = await _read_prog(redis, user_id, today)
if counted:
count += 1
comment.reward_counted = 1
if count >= per_count and daily < daily_limit:
# 满刻度自动发奖:清零进度,标记该评论触发过发奖(删评时扣回)
await wallet_service.change_points(
db, user_id, reward, wallet_service.BIZ_COMMENT,
biz_id=str(video_id), remark="评论奖励",
)
count, daily = 0, daily + 1
comment.reward_counted = 2
rewarded_this_time = True
await _write_prog(redis, user_id, today, count, daily)
db.add(comment)
await db.commit()
await video_service.invalidate_detail_cache(redis, video_id)
progress: dict = {"current": count, "target": per_count, "rewarded_this_time": rewarded_this_time}
if rewarded_this_time:
progress["reward_points"] = reward
return {"comment_id": comment.id, "reward_progress": progress}
async def remove_comment(db: AsyncSession, redis: aioredis.Redis, comment: Comment) -> dict:
"""删除评论核心逻辑(作者自删 / 视频作者删除 / 后台删除共用):已计入进度的回退,已发奖的扣回"""
video = await db.get(Video, comment.video_id)
cfg = _reward_cfg()
per_count = int(cfg.get("per_count", 10))
reward = int(cfg.get("reward", 1))
today = f"{datetime.now():%Y%m%d}"
# 进度归属评论作者(删除者可能是视频作者或管理员)
count, daily = await _read_prog(redis, comment.user_id, today)
if comment.reward_counted == 1:
# 仅计入进度:回退一格
count = max(count - 1, 0)
await _write_prog(redis, comment.user_id, today, count, daily)
elif comment.reward_counted == 2:
# 该评论触发过发奖:扣回积分(余额不足则扣到 0),当日已发次数回退
wallet = await wallet_service.get_or_create_wallet(db, comment.user_id, for_update=True)
clawback = min(reward, wallet.points)
if clawback > 0:
await wallet_service.change_points(
db, comment.user_id, -clawback, wallet_service.BIZ_CLAWBACK,
biz_id=str(comment.id), remark="删除评论扣回奖励",
)
daily = max(daily - 1, 0)
await _write_prog(redis, comment.user_id, today, count, daily)
comment.status = 2
comment.reward_counted = 0
if video is not None:
video.comment_count = max(video.comment_count - 1, 0)
await db.commit()
if video is not None:
await video_service.invalidate_detail_cache(redis, video.id)
return {"reward_progress": {"current": count, "target": per_count}}
async def delete_comment(
db: AsyncSession, redis: aioredis.Redis, user_id: int, comment_id: int
) -> dict:
"""删除评论(App 端):本人评论或本人视频下的评论可删"""
comment = await db.get(Comment, comment_id)
if comment is None or comment.status != 1:
raise BizException(errors.NOT_FOUND, "评论不存在")
video = await db.get(Video, comment.video_id)
if comment.user_id != user_id and (video is None or video.user_id != user_id):
raise BizException(errors.FORBIDDEN, "无权删除该评论")
return await remove_comment(db, redis, comment)
# ---------- 我的收藏 / 观看历史 ----------
async def list_my_favorites(
db: AsyncSession, user_id: int, page: int, page_size: int
) -> dict:
"""我的收藏:仅已发布视频,按收藏时间倒序,结构同 Feed 单条"""
stmt = (
select(Video, Favorite.created_at)
.join(Favorite, Favorite.video_id == Video.id)
.where(Favorite.user_id == user_id, Video.status == video_service.STATUS_PUBLISHED)
)
total = (await db.execute(
select(func.count()).select_from(
select(Favorite.id).join(Video, Video.id == Favorite.video_id).where(
Favorite.user_id == user_id, Video.status == video_service.STATUS_PUBLISHED
).subquery()
)
)).scalar() or 0
rows = (
await db.execute(
stmt.order_by(Favorite.id.desc()).offset((page - 1) * page_size).limit(page_size)
)
).all()
return {
"total": total,
"list": await video_service.serialize_videos(db, [v for v, _ in rows], user_id),
}
async def list_my_history(
db: AsyncSession, user_id: int, page: int, page_size: int
) -> dict:
"""观看历史:每项 video(同 Feed 单条)+ watched_at + cost_type,按观看时间倒序"""
base = (
select(Video, WatchRecord.created_at, WatchRecord.cost_type)
.join(WatchRecord, WatchRecord.video_id == Video.id)
.where(
WatchRecord.subject_type == 1,
WatchRecord.subject_id == str(user_id),
Video.status == video_service.STATUS_PUBLISHED,
)
)
total = (await db.execute(
select(func.count()).select_from(
select(WatchRecord.id).join(Video, Video.id == WatchRecord.video_id).where(
WatchRecord.subject_type == 1,
WatchRecord.subject_id == str(user_id),
Video.status == video_service.STATUS_PUBLISHED,
).subquery()
)
)).scalar() or 0
rows = (
await db.execute(
base.order_by(WatchRecord.id.desc()).offset((page - 1) * page_size).limit(page_size)
)
).all()
videos = await video_service.serialize_videos(db, [v for v, _, _ in rows], user_id)
items = []
for (v, watched_at, cost_type), serialized in zip(rows, videos):
items.append({"video": serialized, "watched_at": _fmt(watched_at), "cost_type": cost_type})
return {"total": total, "list": items}