322 lines
13 KiB
Python
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}
|