up
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,331 @@
|
||||
"""认证服务:验证码 / 注册 / 登录 / 刷新 / 登出 / 找回密码"""
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import func, or_, select
|
||||
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_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from app.models.incentive import AssetWallet, Referral
|
||||
from app.models.user import User
|
||||
from app.services import config_service, invite_service, membership_service, sensitive_service, wallet_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REFRESH_TTL = 30 * 24 * 3600
|
||||
DEACTIVATE_FREEZE_DAYS = 7
|
||||
|
||||
|
||||
# ---------- 验证码 ----------
|
||||
|
||||
def _code_key(scene: str, channel: str, target: str) -> str:
|
||||
return f"{channel}:code:{scene}:{target}"
|
||||
|
||||
|
||||
async def send_code(
|
||||
db: AsyncSession, redis: aioredis.Redis, channel: str, target: str, scene: str, ip: str
|
||||
) -> dict:
|
||||
"""发送验证码(短信/邮箱)。未配置通道时按开发模式返回 dev_code 并记日志。"""
|
||||
# 场景前置校验:register 要求未注册,login/reset 要求已注册
|
||||
user = await _find_user(db, channel, target)
|
||||
if scene == "register" and user is not None:
|
||||
raise BizException(errors.PARAM_ERROR, "该账号已注册")
|
||||
if scene in ("login", "reset") and user is None:
|
||||
raise BizException(errors.PARAM_ERROR, "该账号未注册")
|
||||
|
||||
# 限流:同号 60s 1 条 + 同号每天 10 条 + 同 IP 5 条/小时
|
||||
limit_key = f"{channel}:limit:{target}"
|
||||
if await redis.exists(limit_key):
|
||||
raise BizException(errors.RATE_LIMITED, "发送过于频繁,请 60 秒后再试")
|
||||
day_key = f"{channel}:day:{target}:{datetime.now():%Y%m%d}"
|
||||
if int(await redis.get(day_key) or 0) >= 10:
|
||||
raise BizException(errors.RATE_LIMITED, "今日验证码发送已达上限")
|
||||
ip_key = f"{channel}:iphour:{ip}:{datetime.now():%Y%m%d%H}"
|
||||
if int(await redis.get(ip_key) or 0) >= 5:
|
||||
raise BizException(errors.RATE_LIMITED, "发送过于频繁,请稍后再试")
|
||||
|
||||
code = "".join(random.choices(string.digits, k=6))
|
||||
ttl = 300 if channel == "sms" else 600
|
||||
await redis.set(_code_key(scene, channel, target), code, ex=ttl)
|
||||
await redis.set(limit_key, 1, ex=60)
|
||||
await redis.incr(day_key)
|
||||
await redis.expire(day_key, 48 * 3600)
|
||||
await redis.incr(ip_key)
|
||||
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)
|
||||
if not channel_configured:
|
||||
logger.info("[dev] %s code for %s (%s): %s", channel, target, scene, code)
|
||||
data["dev_code"] = code
|
||||
return data
|
||||
|
||||
|
||||
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)
|
||||
if not saved or saved != code:
|
||||
raise BizException(errors.CAPTCHA_ERROR)
|
||||
await redis.delete(key)
|
||||
|
||||
|
||||
async def _find_user(db: AsyncSession, channel: str, target: str) -> User | None:
|
||||
field = User.phone if channel == "sms" else User.email
|
||||
return (await db.execute(select(User).where(field == target))).scalar_one_or_none()
|
||||
|
||||
|
||||
# ---------- Token ----------
|
||||
|
||||
async def _issue_tokens(redis: aioredis.Redis, user_id: int) -> dict:
|
||||
"""签发双 Token:access 2h + refresh 30d(白名单 Set 支持多端)"""
|
||||
epoch = int(await redis.get(f"auth:epoch:{user_id}") or 0)
|
||||
access, _, _ = create_access_token(user_id, epoch)
|
||||
refresh, refresh_jti, _ = create_refresh_token(user_id, epoch)
|
||||
key = f"auth:refresh:{user_id}"
|
||||
await redis.sadd(key, refresh_jti) # type: ignore[misc]
|
||||
await redis.expire(key, REFRESH_TTL)
|
||||
return {"access_token": access, "refresh_token": refresh, "expires_in": settings.access_token_ttl}
|
||||
|
||||
|
||||
async def _user_brief(db: AsyncSession, user: User) -> dict:
|
||||
is_member = await membership_service.is_member(db, user.id)
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"nickname": user.nickname,
|
||||
"avatar_url": user.avatar_url,
|
||||
"is_member": is_member,
|
||||
}
|
||||
|
||||
|
||||
# ---------- 注册 ----------
|
||||
|
||||
async def register(
|
||||
db: AsyncSession, redis: aioredis.Redis, req, device_id: str | None, ip: str
|
||||
) -> dict:
|
||||
channel = "sms" if req.type == "phone" else "email"
|
||||
target = req.phone if req.type == "phone" else req.email
|
||||
await _verify_code(redis, channel, target, "register", req.code)
|
||||
|
||||
# 唯一性校验
|
||||
dup = await db.execute(
|
||||
select(User.id).where(or_(User.phone == req.phone if req.phone else False,
|
||||
User.email == req.email if req.email else False))
|
||||
)
|
||||
if dup.first():
|
||||
raise BizException(errors.PARAM_ERROR, "手机号/邮箱已注册")
|
||||
|
||||
# 昵称:默认"用户+随机尾号",过敏感词
|
||||
nickname = req.nickname or f"用户{random.randint(100000, 999999)}"
|
||||
if await sensitive_service.find_sensitive(db, redis, nickname):
|
||||
raise BizException(errors.SENSITIVE_WORD)
|
||||
|
||||
# 生成唯一邀请码
|
||||
invite_code = await _gen_invite_code(db)
|
||||
|
||||
user = User(
|
||||
phone=req.phone if req.type == "phone" else None,
|
||||
email=req.email if req.type == "email" else None,
|
||||
password_hash=hash_password(req.password),
|
||||
nickname=nickname,
|
||||
register_type=1 if req.type == "phone" else 2,
|
||||
invite_code=invite_code,
|
||||
register_ip=ip,
|
||||
last_login_at=datetime.now(),
|
||||
last_login_ip=ip,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await wallet_service.get_or_create_wallet(db, user.id)
|
||||
db.add(AssetWallet(user_id=user.id))
|
||||
|
||||
# 绑定推荐关系:显式邀请码(口令/手填)优先,否则取首启模糊归因缓存
|
||||
bind_code = req.invite_code
|
||||
if not bind_code and device_id:
|
||||
bind_code = await invite_service.pop_attribution(redis, device_id)
|
||||
if bind_code:
|
||||
await _bind_referral(db, redis, user, bind_code, device_id, ip)
|
||||
|
||||
# 设备注册标记(防小号绑定,须在绑定校验之后写入,否则同设备校验永远读到新账号自己)
|
||||
if device_id:
|
||||
await redis.set(f"ref:device:{device_id}", user.id)
|
||||
|
||||
# 新注册赠送体验会员(后台可配开关与天数,实时生效)
|
||||
trial_granted = None
|
||||
gift_enabled = await config_service.get_config(db, redis, "register_gift_trial.enabled", "1")
|
||||
if str(gift_enabled) == "1":
|
||||
days = await config_service.get_config_int(db, redis, "register_gift_trial.days", 1)
|
||||
order = await membership_service.grant_trial_gift(db, user.id, days)
|
||||
trial_granted = {
|
||||
"plan_code": "trial",
|
||||
"plan_name": "体验会员",
|
||||
"days": days,
|
||||
"end_at": order.end_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
await db.commit()
|
||||
|
||||
tokens = await _issue_tokens(redis, user.id)
|
||||
return {
|
||||
"user_id": user.id,
|
||||
**tokens,
|
||||
"user": await _user_brief(db, user),
|
||||
"trial_granted": trial_granted,
|
||||
}
|
||||
|
||||
|
||||
async def _gen_invite_code(db: AsyncSession) -> str:
|
||||
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" # 去除易混淆字符
|
||||
for _ in range(10):
|
||||
code = "".join(random.choices(alphabet, k=8))
|
||||
exists = await db.execute(select(User.id).where(User.invite_code == code))
|
||||
if not exists.first():
|
||||
return code
|
||||
raise BizException(errors.INTERNAL_ERROR, "邀请码生成失败")
|
||||
|
||||
|
||||
async def _bind_referral(
|
||||
db: AsyncSession, redis: aioredis.Redis, invitee: User, invite_code: str, device_id: str | None, ip: str
|
||||
) -> None:
|
||||
"""注册时绑定推荐关系,含风控规则(同设备无效/邀请人单日超限冻结/同IP批量注册标记)"""
|
||||
inviter = (await db.execute(select(User).where(User.invite_code == invite_code))).scalar_one_or_none()
|
||||
if inviter is None or inviter.id == invitee.id:
|
||||
return # 邀请码无效则忽略绑定(不阻断注册)
|
||||
|
||||
status, risk_flag = 0, ""
|
||||
# 同 device 已存在账号 → 绑定直接无效
|
||||
if device_id and await redis.exists(f"ref:device:{device_id}"):
|
||||
old = int(await redis.get(f"ref:device:{device_id}") or 0)
|
||||
if old and old != invitee.id:
|
||||
status, risk_flag = 2, "same_device"
|
||||
# 邀请人 24h 新增绑定 >10 → 冻结
|
||||
risk_key = f"ref:risk:{inviter.id}:{datetime.now():%Y%m%d}"
|
||||
bind_count = await redis.incr(risk_key)
|
||||
await redis.expire(risk_key, 48 * 3600)
|
||||
max_bind = (biz_config.get("referral") or {}).get("risk_max_bind_per_day", 10)
|
||||
if status == 0 and bind_count > max_bind:
|
||||
status, risk_flag = 3, "inviter_burst"
|
||||
# 同 IP 24h 注册 >5 → 标记风险
|
||||
if status == 0:
|
||||
max_reg = (biz_config.get("referral") or {}).get("risk_max_register_per_ip", 5)
|
||||
cnt = (await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.register_ip == ip, User.created_at > datetime.now() - timedelta(hours=24)
|
||||
)
|
||||
)).scalar() or 0
|
||||
if cnt > max_reg:
|
||||
status, risk_flag = 3, "batch_register"
|
||||
|
||||
db.add(Referral(
|
||||
inviter_id=inviter.id,
|
||||
invitee_id=invitee.id,
|
||||
invite_code=invite_code,
|
||||
status=status,
|
||||
risk_flag=risk_flag,
|
||||
register_ip=ip,
|
||||
))
|
||||
|
||||
|
||||
# ---------- 登录 ----------
|
||||
|
||||
async def login(db: AsyncSession, redis: aioredis.Redis, req, ip: str) -> dict:
|
||||
# 连续失败 5 次锁定 15 分钟(IP 维度)
|
||||
fail_key = f"login:fail:{ip}"
|
||||
if int(await redis.get(fail_key) or 0) >= 5:
|
||||
raise BizException(errors.RATE_LIMITED, "失败次数过多,账号已锁定 15 分钟", http_status=429)
|
||||
|
||||
channel = "sms" if req.type.startswith("phone") else "email"
|
||||
target = req.phone if channel == "sms" else req.email
|
||||
user = await _find_user(db, channel, target)
|
||||
|
||||
async def _fail():
|
||||
await redis.incr(fail_key)
|
||||
await redis.expire(fail_key, 15 * 60)
|
||||
raise BizException(errors.PARAM_ERROR, "账号或密码错误")
|
||||
|
||||
if user is None:
|
||||
await _fail()
|
||||
if req.type.endswith("code"):
|
||||
try:
|
||||
await _verify_code(redis, channel, target, "login", req.code)
|
||||
except BizException:
|
||||
await _fail()
|
||||
elif not verify_password(req.password, user.password_hash):
|
||||
await _fail()
|
||||
|
||||
await redis.delete(fail_key)
|
||||
|
||||
if user.status == 2:
|
||||
raise BizException(errors.FORBIDDEN, "账号已被封禁", http_status=403)
|
||||
# 注销冻结期内登录:返回 1009 + can_revoke(App 弹窗询问;确认后带 revoke=1 重试即撤销注销)
|
||||
if user.deactivate_at is not None:
|
||||
if getattr(req, "revoke", False):
|
||||
user.deactivate_at = None # 冻结期内撤销注销
|
||||
else:
|
||||
raise BizException(
|
||||
errors.ACCOUNT_DEACTIVATED, "账号已注销", http_status=403, data={"can_revoke": True}
|
||||
)
|
||||
|
||||
user.last_login_at = datetime.now()
|
||||
user.last_login_ip = ip
|
||||
await db.commit()
|
||||
|
||||
tokens = await _issue_tokens(redis, user.id)
|
||||
return {"user_id": user.id, **tokens, "user": await _user_brief(db, user)}
|
||||
|
||||
|
||||
# ---------- 刷新 / 登出 / 找回密码 ----------
|
||||
|
||||
async def refresh(db: AsyncSession, redis: aioredis.Redis, refresh_token: str) -> dict:
|
||||
"""刷新令牌:白名单校验 + 旋转(旧 refresh 立即作废)"""
|
||||
payload = decode_token(refresh_token)
|
||||
if payload.get("type") != "refresh":
|
||||
raise BizException(errors.UNAUTHORIZED, http_status=401)
|
||||
user_id = int(payload["sub"])
|
||||
jti = payload.get("jti", "")
|
||||
epoch = int(await redis.get(f"auth:epoch:{user_id}") or 0)
|
||||
if int(payload.get("ep", 0)) != epoch:
|
||||
raise BizException(errors.UNAUTHORIZED, http_status=401)
|
||||
key = f"auth:refresh:{user_id}"
|
||||
if not await redis.sismember(key, jti): # type: ignore[misc]
|
||||
raise BizException(errors.UNAUTHORIZED, http_status=401)
|
||||
await redis.srem(key, jti) # type: ignore[misc]
|
||||
# 用户仍须可用
|
||||
user = await db.get(User, user_id)
|
||||
if user is None or user.status != 1:
|
||||
raise BizException(errors.UNAUTHORIZED, http_status=401)
|
||||
return await _issue_tokens(redis, user_id)
|
||||
|
||||
|
||||
async def logout(redis: aioredis.Redis, user_id: int, access_jti: str, access_exp: int) -> None:
|
||||
"""登出:access jti 入黑名单 + 删 refresh 白名单"""
|
||||
ttl = max(access_exp - int(datetime.now().timestamp()), 1)
|
||||
await redis.set(f"token:blacklist:{access_jti}", 1, ex=ttl)
|
||||
await redis.delete(f"auth:refresh:{user_id}")
|
||||
|
||||
|
||||
async def reset_password(db: AsyncSession, redis: aioredis.Redis, req) -> None:
|
||||
channel = "sms" if req.type == "phone" else "email"
|
||||
target = req.phone if req.type == "phone" else req.email
|
||||
user = await _find_user(db, channel, target)
|
||||
if user is None:
|
||||
raise BizException(errors.PARAM_ERROR, "账号不存在")
|
||||
await _verify_code(redis, channel, target, "reset", req.code)
|
||||
|
||||
user.password_hash = hash_password(req.new_password)
|
||||
# 全端 Token 失效:递增令牌纪元 + 清 refresh 白名单
|
||||
await redis.incr(f"auth:epoch:{user.id}")
|
||||
await redis.delete(f"auth:refresh:{user.id}")
|
||||
await db.commit()
|
||||
@@ -0,0 +1,145 @@
|
||||
"""通用服务:版本检查 / 通知 / 举报(对应《03-接口文档》第 10 章)"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core import errors, ratelimit
|
||||
from app.core.response import BizException
|
||||
from app.models.common import Notification, Report
|
||||
from app.services import config_service
|
||||
|
||||
# 举报限流:10 次/天/用户
|
||||
REPORT_DAILY_LIMIT = 10
|
||||
|
||||
|
||||
def _fmt(dt: datetime | None) -> str | None:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
|
||||
|
||||
|
||||
# ---------- 版本检查 ----------
|
||||
|
||||
async def get_app_version(db: AsyncSession, redis: aioredis.Redis, platform: str, version_code: int) -> dict:
|
||||
"""版本检查(10.1):配置存 system_configs key=app.version.{platform},JSON 值,后台可改"""
|
||||
raw = await config_service.get_config(db, redis, f"app.version.{platform}", "")
|
||||
cfg: dict = {}
|
||||
if raw:
|
||||
try:
|
||||
cfg = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
cfg = {}
|
||||
min_code = int(cfg.get("min_version_code", 0))
|
||||
return {
|
||||
"latest_version": cfg.get("latest_version", "1.0.0"),
|
||||
"latest_version_code": int(cfg.get("latest_version_code", 1)),
|
||||
"force_update": version_code < min_code,
|
||||
"update_url": cfg.get("update_url", ""),
|
||||
"notice": cfg.get("notice", ""),
|
||||
}
|
||||
|
||||
|
||||
# ---------- 通知 ----------
|
||||
|
||||
async def notify(
|
||||
db: AsyncSession, redis: aioredis.Redis, user_id: int,
|
||||
type: int, title: str, content: str = "", biz_id: str = "",
|
||||
) -> None:
|
||||
"""写入通知并累加 Redis 未读数(审核/提现/公告/评论回复共用,供后续模块调用)"""
|
||||
db.add(Notification(user_id=user_id, type=type, title=title, content=content, biz_id=biz_id))
|
||||
await db.flush()
|
||||
await redis.incr(f"notif:unread:{user_id}")
|
||||
|
||||
|
||||
async def list_notifications(
|
||||
db: AsyncSession, user_id: int, page: int, page_size: int, type: int | None
|
||||
) -> dict:
|
||||
"""通知列表(10.2)"""
|
||||
stmt = select(Notification).where(Notification.user_id == user_id)
|
||||
if type is not None:
|
||||
stmt = stmt.where(Notification.type == type)
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
|
||||
rows = (
|
||||
await db.execute(
|
||||
stmt.order_by(Notification.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
)
|
||||
).scalars().all()
|
||||
return {
|
||||
"total": total,
|
||||
"list": [
|
||||
{
|
||||
"id": n.id,
|
||||
"type": n.type,
|
||||
"title": n.title,
|
||||
"content": n.content,
|
||||
"biz_id": n.biz_id,
|
||||
"is_read": bool(n.is_read),
|
||||
"created_at": _fmt(n.created_at),
|
||||
}
|
||||
for n in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def get_unread_count(db: AsyncSession, redis: aioredis.Redis, user_id: int) -> int:
|
||||
"""未读数(10.3):直接读 Redis;缺失时从 DB 回填"""
|
||||
key = f"notif:unread:{user_id}"
|
||||
cached = await redis.get(key)
|
||||
if cached is not None:
|
||||
return max(int(cached), 0)
|
||||
count = (await db.execute(
|
||||
select(func.count(Notification.id)).where(Notification.user_id == user_id, Notification.is_read == 0)
|
||||
)).scalar() or 0
|
||||
await redis.set(key, count)
|
||||
return count
|
||||
|
||||
|
||||
async def mark_read(
|
||||
db: AsyncSession, redis: aioredis.Redis, user_id: int, ids: list[int] | None, mark_all: bool
|
||||
) -> None:
|
||||
"""标记已读(10.4):ids 与 all 二选一;同步修正 Redis 未读数"""
|
||||
if mark_all:
|
||||
await db.execute(
|
||||
update(Notification).where(Notification.user_id == user_id, Notification.is_read == 0)
|
||||
.values(is_read=1)
|
||||
)
|
||||
await db.commit()
|
||||
await redis.set(f"notif:unread:{user_id}", 0)
|
||||
return
|
||||
if not ids:
|
||||
raise BizException(errors.PARAM_ERROR, "ids 与 all 需传其一")
|
||||
result = await db.execute(
|
||||
update(Notification)
|
||||
.where(Notification.user_id == user_id, Notification.is_read == 0, Notification.id.in_(ids))
|
||||
.values(is_read=1)
|
||||
)
|
||||
await db.commit()
|
||||
affected = result.rowcount or 0
|
||||
if affected > 0:
|
||||
# 未读数下限 0(DB 为准重算,避免并发脏读)
|
||||
remaining = (await db.execute(
|
||||
select(func.count(Notification.id)).where(Notification.user_id == user_id, Notification.is_read == 0)
|
||||
)).scalar() or 0
|
||||
await redis.set(f"notif:unread:{user_id}", remaining)
|
||||
|
||||
|
||||
# ---------- 举报 ----------
|
||||
|
||||
async def create_report(db: AsyncSession, redis: aioredis.Redis, user_id: int, req) -> None:
|
||||
"""提交举报(10.5):唯一索引 uk_user_target 防重复(2106);限流 10 次/天"""
|
||||
today = f"{datetime.now():%Y%m%d}"
|
||||
await ratelimit.hit(redis, f"rl:report:{user_id}:{today}", REPORT_DAILY_LIMIT, 24 * 3600)
|
||||
db.add(Report(
|
||||
user_id=user_id,
|
||||
target_type=req.target_type,
|
||||
target_id=req.target_id,
|
||||
reason_type=req.reason_type,
|
||||
remark=req.remark or "",
|
||||
))
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise BizException(errors.REPORT_DUPLICATED)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""平台配置服务:system_configs 表 + Redis 缓存 60s(后台修改即时生效)"""
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.common import SystemConfig
|
||||
|
||||
CACHE_KEY = "config:system"
|
||||
CACHE_TTL = 60
|
||||
|
||||
|
||||
async def get_all_configs(db: AsyncSession, redis: aioredis.Redis) -> dict[str, str]:
|
||||
"""全量读取配置(Redis Hash 缓存 60s)"""
|
||||
cached = await redis.hgetall(CACHE_KEY) # type: ignore[misc]
|
||||
if cached:
|
||||
return dict(cached)
|
||||
rows = (await db.execute(select(SystemConfig))).scalars().all()
|
||||
configs = {row.config_key: row.config_value for row in rows}
|
||||
if configs:
|
||||
await redis.hset(CACHE_KEY, mapping=configs) # type: ignore[misc]
|
||||
await redis.expire(CACHE_KEY, CACHE_TTL)
|
||||
return configs
|
||||
|
||||
|
||||
async def get_config(db: AsyncSession, redis: aioredis.Redis, key: str, default: Any = None) -> str | Any:
|
||||
"""读取单个配置项"""
|
||||
configs = await get_all_configs(db, redis)
|
||||
return configs.get(key, default)
|
||||
|
||||
|
||||
async def get_config_int(db: AsyncSession, redis: aioredis.Redis, key: str, default: int) -> int:
|
||||
value = await get_config(db, redis, key, None)
|
||||
try:
|
||||
return int(value) if value is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
async def set_config(db: AsyncSession, redis: aioredis.Redis, key: str, value: str, admin_id: int | None = None) -> None:
|
||||
"""写入配置并失效缓存(后台调用)"""
|
||||
row = await db.get(SystemConfig, key)
|
||||
if row is None:
|
||||
db.add(SystemConfig(config_key=key, config_value=value, updated_by=admin_id))
|
||||
else:
|
||||
row.config_value = value
|
||||
row.updated_by = admin_id
|
||||
await db.commit()
|
||||
await redis.delete(CACHE_KEY)
|
||||
@@ -0,0 +1,324 @@
|
||||
"""激励服务:资产钱包 / 推荐绑定 / 提现(对应《03-接口文档》第 9 章、《02-技术文档》6.6)"""
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import func, select
|
||||
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.incentive import AssetTransaction, AssetWallet, Referral, Withdrawal
|
||||
from app.models.user import User
|
||||
from app.services import config_service, membership_service
|
||||
|
||||
# 资产流水业务类型(与 asset_transactions.biz_type 注释一致)
|
||||
ASSET_BIZ_REBATE = 1 # 充值返利
|
||||
ASSET_BIZ_COMPLETE = 2 # 完整观看收益
|
||||
ASSET_BIZ_LIKE = 3 # 点赞收益
|
||||
ASSET_BIZ_WITHDRAW = 4 # 提现
|
||||
ASSET_BIZ_WITHDRAW_BACK = 5 # 提现退回
|
||||
ASSET_BIZ_CLAWBACK = 6 # 返利扣回
|
||||
ASSET_BIZ_ADJUST = 7 # 后台调整
|
||||
|
||||
# 提现单在途锁兜底过期(正常由后台审核后释放)
|
||||
WD_LOCK_TTL = 7 * 24 * 3600
|
||||
# 补绑邀请码时限:注册后 24h 内
|
||||
BIND_WINDOW_HOURS = 24
|
||||
|
||||
REFERRAL_STATUS_TEXT = {0: "待激活", 1: "有效", 2: "无效", 3: "风控冻结"}
|
||||
WITHDRAW_STATUS_TEXT = {0: "待审核", 1: "已打款", 2: "已拒绝"}
|
||||
|
||||
|
||||
def _fmt(dt: datetime | None) -> str | None:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
|
||||
|
||||
|
||||
def _asset_cfg() -> dict:
|
||||
return biz_config.get("asset") or {}
|
||||
|
||||
|
||||
def _withdraw_cfg() -> dict:
|
||||
return biz_config.get("withdraw") or {}
|
||||
|
||||
|
||||
# ---------- 资产钱包变更(与积分钱包同一套"行锁 + 流水 + 乐观锁"规范) ----------
|
||||
|
||||
async def get_or_create_asset_wallet(db: AsyncSession, user_id: int, for_update: bool = False) -> AssetWallet:
|
||||
stmt = select(AssetWallet).where(AssetWallet.user_id == user_id)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
wallet = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if wallet is None:
|
||||
wallet = AssetWallet(user_id=user_id)
|
||||
db.add(wallet)
|
||||
await db.flush()
|
||||
if for_update:
|
||||
wallet = (await db.execute(stmt)).scalar_one()
|
||||
return wallet
|
||||
|
||||
|
||||
async def change_assets(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount: Decimal,
|
||||
biz_type: int,
|
||||
biz_id: str = "",
|
||||
remark: str = "",
|
||||
allow_negative: bool = False,
|
||||
) -> Decimal:
|
||||
"""变更资产余额,返回变动后余额。
|
||||
|
||||
- amount 正增负减;扣减默认不允许透支(退款/返利扣回场景传 allow_negative=True)
|
||||
- 同事务内:行锁 → 校验/更新余额与累计 → 写流水
|
||||
"""
|
||||
wallet = await get_or_create_asset_wallet(db, user_id, for_update=True)
|
||||
new_balance = wallet.assets + amount
|
||||
if amount < 0 and new_balance < 0 and not allow_negative:
|
||||
raise BizException(errors.ASSET_NOT_ENOUGH)
|
||||
|
||||
wallet.assets = new_balance
|
||||
wallet.version += 1
|
||||
if amount > 0:
|
||||
wallet.total_earned += amount
|
||||
elif biz_type == ASSET_BIZ_WITHDRAW:
|
||||
wallet.total_withdrawn += -amount
|
||||
|
||||
db.add(
|
||||
AssetTransaction(
|
||||
user_id=user_id,
|
||||
change_amount=amount,
|
||||
balance_after=new_balance,
|
||||
biz_type=biz_type,
|
||||
biz_id=biz_id,
|
||||
remark=remark,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return new_balance
|
||||
|
||||
|
||||
# ---------- 推荐 ----------
|
||||
|
||||
async def get_my_referral(db: AsyncSession, redis: aioredis.Redis, user_id: int) -> dict:
|
||||
"""我的邀请(9.1):邀请码 + 统计 + 当前会员身份(eligible)"""
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise BizException(errors.UNAUTHORIZED, http_status=401)
|
||||
|
||||
base_url = await config_service.get_config(db, redis, "app.invite_base_url", "https://app.example.com/r/")
|
||||
total_invitees = (await db.execute(
|
||||
select(func.count(Referral.id)).where(Referral.inviter_id == user_id)
|
||||
)).scalar() or 0
|
||||
activated_count = (await db.execute(
|
||||
select(func.count(Referral.id)).where(
|
||||
Referral.inviter_id == user_id, Referral.status == 1
|
||||
)
|
||||
)).scalar() or 0
|
||||
# 邀请积分奖励累计(point_transactions biz_type=8)
|
||||
from app.models.billing import PointTransaction
|
||||
|
||||
reward_points_total = (await db.execute(
|
||||
select(func.coalesce(func.sum(PointTransaction.change_amount), 0)).where(
|
||||
PointTransaction.user_id == user_id, PointTransaction.biz_type == 8
|
||||
)
|
||||
)).scalar() or 0
|
||||
# 充值返利资产累计(asset_transactions biz_type=1)
|
||||
rebate_assets_total = (await db.execute(
|
||||
select(func.coalesce(func.sum(AssetTransaction.change_amount), 0)).where(
|
||||
AssetTransaction.user_id == user_id, AssetTransaction.biz_type == ASSET_BIZ_REBATE
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
return {
|
||||
"invite_code": user.invite_code,
|
||||
"invite_url": f"{base_url}{user.invite_code}",
|
||||
"eligible": await membership_service.is_member(db, user_id),
|
||||
"total_invitees": total_invitees,
|
||||
"activated_count": activated_count,
|
||||
"reward_points_total": int(reward_points_total),
|
||||
"rebate_assets_total": float(rebate_assets_total),
|
||||
}
|
||||
|
||||
|
||||
async def bind_invite_code(
|
||||
db: AsyncSession, redis: aioredis.Redis, user_id: int, invite_code: str, device_id: str | None, ip: str
|
||||
) -> dict:
|
||||
"""补绑邀请码(9.2):注册 24h 内仅一次;无效码/已绑定/绑自己/同设备均 2101"""
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise BizException(errors.UNAUTHORIZED, http_status=401)
|
||||
|
||||
bound = (await db.execute(
|
||||
select(Referral.id).where(Referral.invitee_id == user_id)
|
||||
)).first()
|
||||
if bound:
|
||||
raise BizException(errors.INVITE_CODE_INVALID, "已绑定过邀请码")
|
||||
if user.created_at < datetime.now() - timedelta(hours=BIND_WINDOW_HOURS):
|
||||
raise BizException(errors.INVITE_CODE_INVALID, "超过绑定时限(注册后24小时内可绑定)")
|
||||
|
||||
inviter = (await db.execute(
|
||||
select(User).where(User.invite_code == invite_code)
|
||||
)).scalar_one_or_none()
|
||||
if inviter is None:
|
||||
raise BizException(errors.INVITE_CODE_INVALID, "邀请码无效")
|
||||
if inviter.id == user_id:
|
||||
raise BizException(errors.INVITE_CODE_INVALID, "不能绑定自己的邀请码")
|
||||
if device_id and await redis.exists(f"ref:device:{device_id}"):
|
||||
old = int(await redis.get(f"ref:device:{device_id}") or 0)
|
||||
if old and old != user_id:
|
||||
raise BizException(errors.INVITE_CODE_INVALID, "当前设备已注册其他账号")
|
||||
|
||||
# 风控规则与注册时一致(邀请人单日超限冻结 / 同 IP 批量注册标记)
|
||||
status, risk_flag = 0, ""
|
||||
risk_key = f"ref:risk:{inviter.id}:{datetime.now():%Y%m%d}"
|
||||
bind_count = await redis.incr(risk_key)
|
||||
await redis.expire(risk_key, 48 * 3600)
|
||||
max_bind = (biz_config.get("referral") or {}).get("risk_max_bind_per_day", 10)
|
||||
if bind_count > max_bind:
|
||||
status, risk_flag = 3, "inviter_burst"
|
||||
else:
|
||||
max_reg = (biz_config.get("referral") or {}).get("risk_max_register_per_ip", 5)
|
||||
cnt = (await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.register_ip == ip, User.created_at > datetime.now() - timedelta(hours=24)
|
||||
)
|
||||
)).scalar() or 0
|
||||
if cnt > max_reg:
|
||||
status, risk_flag = 3, "batch_register"
|
||||
|
||||
db.add(Referral(
|
||||
inviter_id=inviter.id,
|
||||
invitee_id=user_id,
|
||||
invite_code=invite_code,
|
||||
status=status,
|
||||
risk_flag=risk_flag,
|
||||
register_ip=ip,
|
||||
))
|
||||
await db.commit()
|
||||
return {"bound": True, "status": status, "status_text": REFERRAL_STATUS_TEXT[status]}
|
||||
|
||||
|
||||
# ---------- 资产 / 提现 ----------
|
||||
|
||||
async def get_my_assets(db: AsyncSession, redis: aioredis.Redis, user_id: int) -> dict:
|
||||
"""资产钱包(9.3)"""
|
||||
wallet = await get_or_create_asset_wallet(db, user_id)
|
||||
per_yuan = Decimal(str(_asset_cfg().get("assets_per_yuan", 10)))
|
||||
has_pending = bool(await redis.exists(f"wd:pending:{user_id}")) or bool(
|
||||
(await db.execute(
|
||||
select(Withdrawal.id).where(Withdrawal.user_id == user_id, Withdrawal.status == 0)
|
||||
)).first()
|
||||
)
|
||||
return {
|
||||
"assets": float(wallet.assets),
|
||||
"assets_value_yuan": float(wallet.assets / per_yuan),
|
||||
"total_earned": float(wallet.total_earned),
|
||||
"total_withdrawn": float(wallet.total_withdrawn),
|
||||
"min_withdraw_assets": int(_withdraw_cfg().get("min_assets", 100)),
|
||||
"has_pending_withdraw": has_pending,
|
||||
}
|
||||
|
||||
|
||||
async def list_asset_records(
|
||||
db: AsyncSession, user_id: int, page: int, page_size: int, biz_type: int | None
|
||||
) -> dict:
|
||||
"""资产流水(9.4)"""
|
||||
stmt = select(AssetTransaction).where(AssetTransaction.user_id == user_id)
|
||||
if biz_type is not None:
|
||||
stmt = stmt.where(AssetTransaction.biz_type == biz_type)
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
|
||||
rows = (
|
||||
await db.execute(
|
||||
stmt.order_by(AssetTransaction.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
)
|
||||
).scalars().all()
|
||||
return {
|
||||
"total": total,
|
||||
"list": [
|
||||
{
|
||||
"id": r.id,
|
||||
"change_amount": float(r.change_amount),
|
||||
"balance_after": float(r.balance_after),
|
||||
"biz_type": r.biz_type,
|
||||
"remark": r.remark,
|
||||
"created_at": _fmt(r.created_at),
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def apply_withdraw(
|
||||
db: AsyncSession, redis: aioredis.Redis, user_id: int, assets: Decimal, channel: int, account: str
|
||||
) -> dict:
|
||||
"""申请提现(9.5):在途锁防并发 → 事务内冻结扣减 + 建单;审核拒绝由后台退回并释放锁"""
|
||||
min_assets = int(_withdraw_cfg().get("min_assets", 100))
|
||||
if assets < min_assets:
|
||||
raise BizException(errors.WITHDRAW_TOO_LOW)
|
||||
|
||||
lock_key = f"wd:pending:{user_id}"
|
||||
if not await redis.set(lock_key, 1, nx=True, ex=WD_LOCK_TTL):
|
||||
raise BizException(errors.WITHDRAW_PENDING)
|
||||
try:
|
||||
# 双保险:DB 层存在待审核单也拦截(锁丢失场景)
|
||||
if (await db.execute(
|
||||
select(Withdrawal.id).where(Withdrawal.user_id == user_id, Withdrawal.status == 0)
|
||||
)).first():
|
||||
raise BizException(errors.WITHDRAW_PENDING)
|
||||
|
||||
per_yuan = Decimal(str(_asset_cfg().get("assets_per_yuan", 10)))
|
||||
amount_fen = int((assets / per_yuan) * 100) # 10资产=1元 → 1资产=10分
|
||||
# 冻结扣减(余额不足抛 2102,事务回滚)
|
||||
await change_assets(
|
||||
db, user_id, -assets, ASSET_BIZ_WITHDRAW,
|
||||
remark=f"申请提现冻结({account})",
|
||||
)
|
||||
wd = Withdrawal(
|
||||
withdraw_no=membership_service.gen_order_no("W"),
|
||||
user_id=user_id,
|
||||
assets=assets,
|
||||
amount_fen=amount_fen,
|
||||
channel=channel,
|
||||
account=account,
|
||||
)
|
||||
db.add(wd)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await redis.delete(lock_key) # 建单失败释放锁
|
||||
raise
|
||||
|
||||
return {
|
||||
"withdraw_no": wd.withdraw_no,
|
||||
"assets": float(wd.assets),
|
||||
"amount_yuan": float(Decimal(wd.amount_fen) / 100),
|
||||
"status": 0,
|
||||
"status_text": WITHDRAW_STATUS_TEXT[0],
|
||||
}
|
||||
|
||||
|
||||
async def list_withdraw_records(db: AsyncSession, user_id: int, page: int, page_size: int) -> dict:
|
||||
"""提现记录(9.6)"""
|
||||
stmt = select(Withdrawal).where(Withdrawal.user_id == user_id)
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
|
||||
rows = (
|
||||
await db.execute(
|
||||
stmt.order_by(Withdrawal.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
)
|
||||
).scalars().all()
|
||||
return {
|
||||
"total": total,
|
||||
"list": [
|
||||
{
|
||||
"withdraw_no": w.withdraw_no,
|
||||
"assets": float(w.assets),
|
||||
"amount_yuan": float(Decimal(w.amount_fen) / 100),
|
||||
"channel": w.channel,
|
||||
"status": w.status,
|
||||
"status_text": WITHDRAW_STATUS_TEXT.get(w.status, ""),
|
||||
"created_at": _fmt(w.created_at),
|
||||
}
|
||||
for w in rows
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
"""互动服务:点赞 / 收藏 / 评论 / 我的收藏 / 观看历史(对应《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}
|
||||
@@ -0,0 +1,169 @@
|
||||
"""推广归因服务:点击埋点 / 模糊匹配 / H5 中间页
|
||||
|
||||
三段式自建方案的辅助路径:
|
||||
1. 分享口令(剪贴板)→ 注册时显式 invite_code,准确率 100%(主路径,App 端解析)
|
||||
2. H5 中间页点击埋点(code+IP+UA 存 Redis 24h)→ App 首启上报 device_id 模糊匹配(本模块)
|
||||
3. 注册页邀请码手填(兜底,沿用 /auth/register 的 invite_code 字段)
|
||||
|
||||
安全设计:模糊匹配结果由服务端按 device_id 缓存(invite:attr:{device_id}),
|
||||
注册时服务端自取并一次性消费,不信任客户端回传,防止伪造归因。
|
||||
"""
|
||||
import html
|
||||
import json
|
||||
import time
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
from app.services import config_service
|
||||
|
||||
CLICK_TTL = 24 * 3600 # 点击记录有效期(超过 24h 未安装不再归因)
|
||||
ATTR_TTL = 7 * 24 * 3600 # 归因结果缓存(命中后允许延迟安装注册)
|
||||
CLICK_DUP_TTL = 60 # 同 IP+code 60s 内重复埋点去重
|
||||
MATCH_CANDIDATES = 5 # 同 IP 最近点击候选数
|
||||
|
||||
|
||||
def _ip_key(ip: str) -> str:
|
||||
return f"invite:ip:{ip}"
|
||||
|
||||
|
||||
def _clk_key(code: str, ip: str) -> str:
|
||||
return f"invite:clk:{code}:{ip}"
|
||||
|
||||
|
||||
def attr_key(device_id: str) -> str:
|
||||
return f"invite:attr:{device_id}"
|
||||
|
||||
|
||||
# ---------- 点击埋点 ----------
|
||||
|
||||
async def record_click(redis: aioredis.Redis, code: str, ip: str, ua: str) -> dict:
|
||||
"""H5 中间页点击埋点:IP 维度 zset(score=时间戳)+ 点击详情"""
|
||||
if not ip:
|
||||
return {"recorded": False}
|
||||
# 60s 内同 IP+code 重复点击去重(页面刷新/重复打开)
|
||||
dup = await redis.set(f"invite:dup:{ip}:{code}", 1, nx=True, ex=CLICK_DUP_TTL)
|
||||
if not dup:
|
||||
return {"recorded": True, "dedup": True}
|
||||
now = time.time()
|
||||
await redis.zadd(_ip_key(ip), {code: now})
|
||||
await redis.expire(_ip_key(ip), CLICK_TTL)
|
||||
await redis.set(_clk_key(code, ip), json.dumps({"ua": ua, "ts": now}), ex=CLICK_TTL)
|
||||
return {"recorded": True}
|
||||
|
||||
|
||||
# ---------- 归因匹配 ----------
|
||||
|
||||
async def match_and_cache(redis: aioredis.Redis, device_id: str, ip: str, ua: str) -> dict:
|
||||
"""App 首启归因:按 IP 取最近一次点击的邀请码,缓存到 device_id 维度(幂等)"""
|
||||
cached = await redis.get(attr_key(device_id))
|
||||
if cached:
|
||||
return {"matched": True, "invite_code": cached}
|
||||
|
||||
code = None
|
||||
if ip:
|
||||
# 最近点击优先(同 IP 可能点击过多个推广链接,时间近者为准)
|
||||
for c in await redis.zrevrange(_ip_key(ip), 0, MATCH_CANDIDATES - 1):
|
||||
if await redis.get(_clk_key(c, ip)):
|
||||
code = c
|
||||
break
|
||||
if not code:
|
||||
return {"matched": False}
|
||||
await redis.set(attr_key(device_id), code, ex=ATTR_TTL)
|
||||
return {"matched": True, "invite_code": code}
|
||||
|
||||
|
||||
async def pop_attribution(redis: aioredis.Redis, device_id: str) -> str | None:
|
||||
"""注册时取归因结果并一次性消费"""
|
||||
key = attr_key(device_id)
|
||||
code = await redis.get(key)
|
||||
if code:
|
||||
await redis.delete(key)
|
||||
return code
|
||||
|
||||
|
||||
# ---------- H5 中间页 ----------
|
||||
|
||||
_LANDING_HTML = """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
||||
<title>邀请你加入短视频</title>
|
||||
<style>
|
||||
* {{ margin:0; padding:0; box-sizing:border-box; }}
|
||||
body {{ background:#0f0f14; color:#fff; font-family:-apple-system,"PingFang SC","Helvetica Neue",sans-serif;
|
||||
min-height:100vh; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:32px 24px; }}
|
||||
.logo {{ width:88px; height:88px; border-radius:20px; display:flex; align-items:center; justify-content:center;
|
||||
background:linear-gradient(135deg,#fe2c55,#7c4dff); font-size:44px; margin-bottom:20px; }}
|
||||
h1 {{ font-size:22px; margin-bottom:8px; }}
|
||||
.sub {{ color:#9a9aa5; font-size:14px; margin-bottom:32px; text-align:center; line-height:1.6; }}
|
||||
.inviter {{ color:#ffd700; }}
|
||||
.btn {{ width:100%; max-width:320px; padding:15px 0; border:none; border-radius:12px; font-size:16px;
|
||||
font-weight:600; cursor:pointer; margin-bottom:14px; }}
|
||||
.btn-primary {{ background:linear-gradient(135deg,#fe2c55,#7c4dff); color:#fff; }}
|
||||
.btn-ghost {{ background:#1e1e28; color:#e8e8ec; border:1px solid #33333f; }}
|
||||
.code {{ color:#9a9aa5; font-size:13px; margin-top:10px; }}
|
||||
.code b {{ color:#ffd700; font-size:16px; letter-spacing:2px; }}
|
||||
#toast {{ position:fixed; left:50%; bottom:60px; transform:translateX(-50%); background:#333; color:#fff;
|
||||
padding:10px 20px; border-radius:8px; font-size:14px; opacity:0; transition:opacity .3s; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="logo">▶</div>
|
||||
<h1>{title}</h1>
|
||||
<p class="sub">新人注册立送体验会员<br>复制口令后打开 App 即可自动绑定邀请</p>
|
||||
<button class="btn btn-primary" onclick="copyCmd()">复制邀请口令</button>
|
||||
{download_btn}
|
||||
<p class="code">我的邀请码 <b>{code}</b></p>
|
||||
<div id="toast"></div>
|
||||
<script>
|
||||
var CODE = "{code}";
|
||||
var CMD = "#短视频邀请码" + CODE + "#";
|
||||
// 打开页面即埋点(记录 code+IP+UA,安装首启后据此归因)
|
||||
fetch("/api/v1/invite/click", {{
|
||||
method: "POST",
|
||||
headers: {{ "Content-Type": "application/json" }},
|
||||
body: JSON.stringify({{ code: CODE }})
|
||||
}}).catch(function() {{}});
|
||||
function toast(t) {{
|
||||
var el = document.getElementById("toast");
|
||||
el.textContent = t; el.style.opacity = 1;
|
||||
setTimeout(function() {{ el.style.opacity = 0; }}, 1800);
|
||||
}}
|
||||
function copyCmd() {{
|
||||
var done = function() {{ toast("口令已复制,打开 App 自动识别"); }};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {{
|
||||
navigator.clipboard.writeText(CMD).then(done, function() {{ fallback(); }});
|
||||
}} else {{ fallback(); }}
|
||||
function fallback() {{
|
||||
var ta = document.createElement("textarea");
|
||||
ta.value = CMD; document.body.appendChild(ta);
|
||||
ta.select(); try {{ document.execCommand("copy"); done(); }}
|
||||
catch (e) {{ toast("复制失败,请手动输入邀请码"); }}
|
||||
document.body.removeChild(ta);
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
async def render_landing_page(db: AsyncSession, redis: aioredis.Redis, code: str) -> str:
|
||||
"""推广落地页:展示邀请人 + 复制口令 + (可选)下载入口;页面加载即自动埋点"""
|
||||
inviter = (await db.execute(select(User).where(User.invite_code == code))).scalar_one_or_none()
|
||||
if inviter is not None:
|
||||
title = f'<span class="inviter">{html.escape(inviter.nickname)}</span> 邀请你加入短视频'
|
||||
else:
|
||||
title = "邀请你加入短视频"
|
||||
|
||||
download_url = await config_service.get_config(db, redis, "app.download_url", "")
|
||||
download_btn = (
|
||||
f'<button class="btn btn-ghost" onclick="location.href=\'{html.escape(download_url, quote=True)}\'">下载 APP</button>'
|
||||
if download_url else ""
|
||||
)
|
||||
safe_code = html.escape(code)
|
||||
return _LANDING_HTML.format(title=title, code=safe_code, download_btn=download_btn)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""会员服务:状态查询与开通(叠加/互斥规则对齐《01-开发文档》2.4.3)"""
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
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.user import MembershipOrder, UserMembership
|
||||
|
||||
# 会员开通记录来源
|
||||
SOURCE_POINTS = 1 # 积分购买
|
||||
SOURCE_GIFT = 2 # 活动赠送
|
||||
|
||||
PLAN_NAMES = {"trial": "体验会员", "month": "月卡", "year": "年卡", "lifetime": "终身卡"}
|
||||
|
||||
# 套餐展示元数据(价格以 config.yaml / 后台配置为准,前端不硬编码)
|
||||
PLAN_META = {
|
||||
"trial": {"name": "体验会员", "desc": "1天体验,活动同享"},
|
||||
"month": {"name": "月卡", "desc": "30天无限观看"},
|
||||
"year": {"name": "年卡", "desc": "365天无限观看"},
|
||||
"lifetime": {"name": "终身卡", "desc": "永久无限观看"},
|
||||
}
|
||||
|
||||
PLAN_BENEFITS = ["无限观看视频", "优先上传(转码/审核优先)"]
|
||||
|
||||
|
||||
def gen_order_no(prefix: str = "M") -> str:
|
||||
"""业务单号:前缀 + 时间戳 + 随机"""
|
||||
return f"{prefix}{datetime.now():%Y%m%d%H%M%S}{random.randint(100000, 999999)}"
|
||||
|
||||
|
||||
async def get_membership(db: AsyncSession, user_id: int) -> UserMembership:
|
||||
ms = await db.get(UserMembership, user_id)
|
||||
if ms is None:
|
||||
ms = UserMembership(user_id=user_id)
|
||||
db.add(ms)
|
||||
await db.flush()
|
||||
return ms
|
||||
|
||||
|
||||
def membership_active(ms: UserMembership, now: datetime | None = None) -> bool:
|
||||
"""会员有效性判定(以 expire_at 为准,终身卡永久有效)"""
|
||||
now = now or datetime.now()
|
||||
if ms.is_lifetime:
|
||||
return True
|
||||
return ms.expire_at is not None and ms.expire_at > now
|
||||
|
||||
|
||||
async def is_member(db: AsyncSession, user_id: int) -> bool:
|
||||
ms = await db.get(UserMembership, user_id)
|
||||
return ms is not None and membership_active(ms)
|
||||
|
||||
|
||||
async def grant_membership(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
plan_code: str,
|
||||
points_cost: int,
|
||||
source: int,
|
||||
days: int | None = None,
|
||||
) -> MembershipOrder:
|
||||
"""开通会员并写开通记录,返回订单。
|
||||
|
||||
- 有效期内重复开通:有效期叠加(max(now, expire_at) + days)
|
||||
- 体验会员期间开通付费卡:按付费卡生效,体验剩余时间不累加
|
||||
- 终身卡不可再开通其它套餐
|
||||
"""
|
||||
plan_cfg = (biz_config.get("membership") or {}).get(plan_code)
|
||||
if not plan_cfg or not plan_cfg.get("enabled", False):
|
||||
raise BizException(errors.PARAM_ERROR, "套餐不存在或未上架")
|
||||
duration_days = int(days if days is not None else plan_cfg.get("days", 0))
|
||||
|
||||
now = datetime.now()
|
||||
ms = await get_membership(db, user_id)
|
||||
|
||||
if ms.is_lifetime:
|
||||
raise BizException(errors.LIFETIME_MEMBER)
|
||||
|
||||
if duration_days == 0: # 终身卡
|
||||
start_at, end_at = now, None
|
||||
else:
|
||||
base = now
|
||||
# 付费卡叠加:当前为付费会员且未到期 → 在到期时间上叠加
|
||||
# 体验会员转付费卡:从当前时间重新计算(体验剩余不累加)
|
||||
if membership_active(ms, now) and ms.current_plan != "trial" and plan_code != "trial":
|
||||
base = max(now, ms.expire_at) # type: ignore[arg-type]
|
||||
start_at = base
|
||||
end_at = base + timedelta(days=duration_days)
|
||||
|
||||
order = MembershipOrder(
|
||||
order_no=gen_order_no(),
|
||||
user_id=user_id,
|
||||
plan_code=plan_code,
|
||||
points_cost=points_cost,
|
||||
duration_days=duration_days,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
source=source,
|
||||
)
|
||||
db.add(order)
|
||||
|
||||
ms.is_member = 1
|
||||
ms.current_plan = plan_code
|
||||
ms.is_lifetime = 1 if duration_days == 0 else 0
|
||||
ms.expire_at = end_at
|
||||
await db.flush()
|
||||
return order
|
||||
|
||||
|
||||
async def grant_trial_gift(db: AsyncSession, user_id: int, days: int) -> MembershipOrder:
|
||||
"""新注册赠送体验会员(新用户无既有会员状态,直接设置)"""
|
||||
now = datetime.now()
|
||||
end_at = now + timedelta(days=days)
|
||||
order = MembershipOrder(
|
||||
order_no=gen_order_no(),
|
||||
user_id=user_id,
|
||||
plan_code="trial",
|
||||
points_cost=0,
|
||||
duration_days=days,
|
||||
start_at=now,
|
||||
end_at=end_at,
|
||||
source=SOURCE_GIFT,
|
||||
)
|
||||
db.add(order)
|
||||
ms = await get_membership(db, user_id)
|
||||
ms.is_member = 1
|
||||
ms.current_plan = "trial"
|
||||
ms.is_lifetime = 0
|
||||
ms.expire_at = end_at
|
||||
await db.flush()
|
||||
return order
|
||||
|
||||
|
||||
# ---------- 接口查询(第 7 章) ----------
|
||||
|
||||
def _fmt(dt: datetime | None) -> str | None:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
|
||||
|
||||
|
||||
def list_plans() -> dict:
|
||||
"""套餐列表(7.1):价格实时读 config.yaml,游客可访问"""
|
||||
plans_cfg = biz_config.get("membership") or {}
|
||||
plans = []
|
||||
for code in ("trial", "month", "year", "lifetime"):
|
||||
cfg = plans_cfg.get(code) or {}
|
||||
meta = PLAN_META[code]
|
||||
plans.append({
|
||||
"plan_code": code,
|
||||
"name": meta["name"],
|
||||
"days": int(cfg.get("days", 0)),
|
||||
"points": int(cfg.get("points", 0)),
|
||||
"enabled": bool(cfg.get("enabled", False)),
|
||||
"desc": meta["desc"],
|
||||
})
|
||||
return {"plans": plans, "benefits": PLAN_BENEFITS}
|
||||
|
||||
|
||||
async def get_my_membership(db: AsyncSession, user_id: int) -> dict:
|
||||
"""我的会员(7.2):当前状态 + 历史开通记录"""
|
||||
ms = await get_membership(db, user_id)
|
||||
active = membership_active(ms)
|
||||
orders = (
|
||||
await db.execute(
|
||||
select(MembershipOrder).where(MembershipOrder.user_id == user_id)
|
||||
.order_by(MembershipOrder.id.desc()).limit(50)
|
||||
)
|
||||
).scalars().all()
|
||||
remaining_days = 0
|
||||
if active and not ms.is_lifetime and ms.expire_at:
|
||||
remaining_days = max((ms.expire_at - datetime.now()).days, 0)
|
||||
return {
|
||||
"is_member": active,
|
||||
"is_lifetime": bool(ms.is_lifetime),
|
||||
"current_plan": ms.current_plan if active else None,
|
||||
"plan_name": PLAN_NAMES.get(ms.current_plan) if active else None,
|
||||
"expire_at": _fmt(ms.expire_at) if active else None,
|
||||
"remaining_days": remaining_days,
|
||||
"history": [
|
||||
{
|
||||
"order_no": o.order_no,
|
||||
"plan_name": PLAN_NAMES.get(o.plan_code, o.plan_code),
|
||||
"points_cost": o.points_cost,
|
||||
"start_at": _fmt(o.start_at),
|
||||
"end_at": _fmt(o.end_at),
|
||||
"source": o.source,
|
||||
}
|
||||
for o in orders
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def redeem(db: AsyncSession, user_id: int, plan_code: str) -> dict:
|
||||
"""积分开通会员(7.3):扣积分 + 开通同一事务;2004 终身卡 / 2001 积分不足"""
|
||||
from app.services import wallet_service # 避免循环引用
|
||||
|
||||
plan_cfg = (biz_config.get("membership") or {}).get(plan_code)
|
||||
if not plan_cfg or not plan_cfg.get("enabled", False):
|
||||
raise BizException(errors.PARAM_ERROR, "套餐不存在或未上架")
|
||||
points_cost = int(plan_cfg.get("points", 0))
|
||||
|
||||
# 先开通(内部校验终身卡/叠加规则),再扣积分;积分不足抛 2001 整体回滚
|
||||
order = await grant_membership(db, user_id, plan_code, points_cost, SOURCE_POINTS)
|
||||
remaining = await wallet_service.get_balance(db, user_id)
|
||||
if points_cost > 0:
|
||||
remaining = await wallet_service.change_points(
|
||||
db, user_id, -points_cost, wallet_service.BIZ_MEMBERSHIP,
|
||||
biz_id=order.order_no, remark=f"开通{PLAN_NAMES.get(plan_code, plan_code)}",
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"order_no": order.order_no,
|
||||
"plan_name": PLAN_NAMES.get(plan_code, plan_code),
|
||||
"points_cost": points_cost,
|
||||
"remaining_points": remaining,
|
||||
"expire_at": _fmt(order.end_at),
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"""支付服务:充值套餐 / 下单 / 查单 / 取消 / 回调幂等入账 + 推荐返利(对应《03-接口文档》第 8 章)"""
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
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 RechargeOrder, RechargePackage
|
||||
from app.models.incentive import Referral
|
||||
from app.payment import alipay, usdt, wechat
|
||||
from app.services import config_service, incentive_service, membership_service, wallet_service
|
||||
|
||||
ORDER_STATUS_TEXT = {0: "待支付", 1: "已支付", 2: "已关闭", 3: "已退款"}
|
||||
PAY_METHODS = [
|
||||
{"method": 1, "name": "支付宝"},
|
||||
{"method": 2, "name": "微信支付"},
|
||||
{"method": 3, "name": "USDT (TRC20)"},
|
||||
]
|
||||
|
||||
|
||||
def _fmt(dt: datetime | None) -> str | None:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
|
||||
|
||||
|
||||
def _points_per_yuan() -> int:
|
||||
return int((biz_config.get("points") or {}).get("points_per_yuan", 10))
|
||||
|
||||
|
||||
def _usdt_cfg() -> dict:
|
||||
return (biz_config.get("recharge") or {}).get("usdt") or {}
|
||||
|
||||
|
||||
# ---------- 套餐 / 下单 ----------
|
||||
|
||||
async def list_packages(db: AsyncSession, redis: aioredis.Redis) -> dict:
|
||||
"""充值套餐(8.1):档位读 recharge_packages 表(后台实时可配),自定义金额按汇率换算"""
|
||||
rows = (await db.execute(
|
||||
select(RechargePackage).where(RechargePackage.status == 1).order_by(RechargePackage.sort, RechargePackage.id)
|
||||
)).scalars().all()
|
||||
custom_enabled = str(await config_service.get_config(db, redis, "recharge.custom.enabled", "1")) == "1"
|
||||
custom_min = await config_service.get_config_int(db, redis, "recharge.custom.min_yuan", 1)
|
||||
custom_max = await config_service.get_config_int(db, redis, "recharge.custom.max_yuan", 10000)
|
||||
return {
|
||||
"packages": [
|
||||
{"id": p.id, "amount_yuan": p.amount_yuan, "points": p.points, "bonus": p.bonus_points}
|
||||
for p in rows
|
||||
],
|
||||
"custom": {
|
||||
"enabled": custom_enabled,
|
||||
"min_yuan": custom_min,
|
||||
"max_yuan": custom_max,
|
||||
"points_per_yuan": _points_per_yuan(),
|
||||
},
|
||||
"pay_methods": PAY_METHODS,
|
||||
"usdt_rate": float(_usdt_cfg().get("rate", 7.2)),
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_points(db: AsyncSession, redis: aioredis.Redis, amount_yuan: int) -> int:
|
||||
"""金额 → 到账积分:优先匹配套餐档位(含赠送),否则走自定义金额按汇率换算"""
|
||||
pkg = (await db.execute(
|
||||
select(RechargePackage).where(RechargePackage.status == 1, RechargePackage.amount_yuan == amount_yuan)
|
||||
)).scalar_one_or_none()
|
||||
if pkg:
|
||||
return pkg.points + pkg.bonus_points
|
||||
custom_enabled = str(await config_service.get_config(db, redis, "recharge.custom.enabled", "1")) == "1"
|
||||
custom_min = await config_service.get_config_int(db, redis, "recharge.custom.min_yuan", 1)
|
||||
custom_max = await config_service.get_config_int(db, redis, "recharge.custom.max_yuan", 10000)
|
||||
if not custom_enabled or not (custom_min <= amount_yuan <= custom_max):
|
||||
raise BizException(errors.PARAM_ERROR, "充值金额不在可选档位")
|
||||
return amount_yuan * _points_per_yuan()
|
||||
|
||||
|
||||
async def create_recharge(db: AsyncSession, redis: aioredis.Redis, user_id: int, req, platform: str | None = None) -> dict:
|
||||
"""创建充值订单(8.2):iOS 开关校验 → 档位校验 → 建单 → 按渠道产出支付参数"""
|
||||
# iOS 虚拟支付合规开关(《01-开发文档》:审核期引导至网页端充值)
|
||||
if platform == "ios" and str(await config_service.get_config(db, redis, "ios_payment.enabled", "1")) == "0":
|
||||
raise BizException(errors.PARAM_ERROR, "iOS 端暂不支持应用内充值,请前往网页端充值")
|
||||
points = await _resolve_points(db, redis, req.amount_yuan)
|
||||
expire_min = int(_usdt_cfg().get("order_expire_min", 30))
|
||||
expire_at = datetime.now() + timedelta(minutes=expire_min)
|
||||
|
||||
order = RechargeOrder(
|
||||
order_no=membership_service.gen_order_no("R"),
|
||||
user_id=user_id,
|
||||
amount_fen=req.amount_yuan * 100,
|
||||
points=points,
|
||||
pay_method=req.pay_method,
|
||||
expire_at=expire_at,
|
||||
)
|
||||
|
||||
resp: dict = {}
|
||||
if req.pay_method == 3:
|
||||
# USDT:金额含随机尾差用于订单匹配,有效期内唯一
|
||||
rate = Decimal(str(_usdt_cfg().get("rate", 7.2)))
|
||||
for _ in range(10):
|
||||
amount = usdt.gen_usdt_amount(req.amount_yuan, rate)
|
||||
clash = (await db.execute(
|
||||
select(RechargeOrder.id).where(
|
||||
RechargeOrder.status == 0, RechargeOrder.usdt_amount == amount
|
||||
)
|
||||
)).first()
|
||||
if not clash:
|
||||
break
|
||||
else:
|
||||
raise BizException(errors.INTERNAL_ERROR, "USDT 金额分配失败,请重试")
|
||||
order.usdt_amount = amount
|
||||
order.usdt_rate = rate
|
||||
db.add(order)
|
||||
await db.commit()
|
||||
resp = {
|
||||
"usdt_rate": float(rate),
|
||||
"usdt_amount": str(amount),
|
||||
"receive_address": _usdt_cfg().get("address", ""),
|
||||
"network": _usdt_cfg().get("network", "TRC20"),
|
||||
}
|
||||
else:
|
||||
db.add(order)
|
||||
await db.commit()
|
||||
if req.pay_method == 1:
|
||||
resp = {"pay_params": alipay.create_app_pay_params(order.order_no, order.amount_fen)}
|
||||
else:
|
||||
resp = {"pay_params": await wechat.create_app_pay_params(order.order_no, order.amount_fen)}
|
||||
|
||||
return {
|
||||
"order_no": order.order_no,
|
||||
"pay_method": order.pay_method,
|
||||
"amount_yuan": req.amount_yuan,
|
||||
"points": points,
|
||||
"expire_at": _fmt(expire_at),
|
||||
**resp,
|
||||
}
|
||||
|
||||
|
||||
# ---------- 查单 / 取消 ----------
|
||||
|
||||
async def _get_own_order(db: AsyncSession, user_id: int, order_no: str) -> RechargeOrder:
|
||||
order = (await db.execute(
|
||||
select(RechargeOrder).where(RechargeOrder.order_no == order_no)
|
||||
)).scalar_one_or_none()
|
||||
if order is None or order.user_id != user_id:
|
||||
raise BizException(errors.ORDER_NOT_FOUND)
|
||||
return order
|
||||
|
||||
|
||||
async def get_order(db: AsyncSession, user_id: int, order_no: str) -> dict:
|
||||
"""查询订单(8.3):App 轮询用,附当前钱包积分"""
|
||||
order = await _get_own_order(db, user_id, order_no)
|
||||
return {
|
||||
"order_no": order.order_no,
|
||||
"status": order.status,
|
||||
"status_text": ORDER_STATUS_TEXT.get(order.status, ""),
|
||||
"points": order.points,
|
||||
"paid_at": _fmt(order.paid_at),
|
||||
"wallet_points": await wallet_service.get_balance(db, user_id),
|
||||
}
|
||||
|
||||
|
||||
async def cancel_order(db: AsyncSession, user_id: int, order_no: str) -> None:
|
||||
"""取消订单(8.4):仅待支付可取消"""
|
||||
order = await _get_own_order(db, user_id, order_no)
|
||||
if order.status != 0:
|
||||
raise BizException(errors.ORDER_STATUS_ERROR, "仅待支付订单可取消")
|
||||
order.status = 2
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ---------- 入账(回调 / 人工补单共用) ----------
|
||||
|
||||
async def settle_order(
|
||||
db: AsyncSession, order_no: str, third_trade_no: str, paid_amount_fen: int | None = None
|
||||
) -> RechargeOrder:
|
||||
"""幂等入账:行锁 → 状态/金额校验 → 置已支付 → 积分到账 → 推荐返利(事件触发,同步补偿)。
|
||||
|
||||
third_trade_no 有唯一索引,第三方支付商重复回调天然幂等。
|
||||
"""
|
||||
order = (await db.execute(
|
||||
select(RechargeOrder).where(RechargeOrder.order_no == order_no).with_for_update()
|
||||
)).scalar_one_or_none()
|
||||
if order is None:
|
||||
raise BizException(errors.ORDER_NOT_FOUND)
|
||||
if order.status != 0:
|
||||
return order # 已入账/已关闭:幂等直接返回
|
||||
if paid_amount_fen is not None and paid_amount_fen != order.amount_fen:
|
||||
raise BizException(errors.AMOUNT_MISMATCH)
|
||||
|
||||
order.status = 1
|
||||
order.third_trade_no = third_trade_no
|
||||
order.paid_at = datetime.now()
|
||||
await wallet_service.change_points(
|
||||
db, order.user_id, order.points, wallet_service.BIZ_RECHARGE,
|
||||
biz_id=order.order_no, remark="充值到账",
|
||||
)
|
||||
await _rebate_for_order(db, order)
|
||||
await db.commit()
|
||||
return order
|
||||
|
||||
|
||||
async def _rebate_for_order(db: AsyncSession, order: RechargeOrder) -> None:
|
||||
"""推荐返利(《02-技术文档》6.6):被推荐人充值成功 → 推荐人为会员则发放 充值积分×5% 资产"""
|
||||
referral = (await db.execute(
|
||||
select(Referral).where(Referral.invitee_id == order.user_id, Referral.status == 1)
|
||||
)).scalar_one_or_none()
|
||||
if referral is None:
|
||||
return
|
||||
if not await membership_service.is_member(db, referral.inviter_id):
|
||||
return # 发放时点校验推荐人会员身份
|
||||
percent = Decimal(str((biz_config.get("referral") or {}).get("rebate_percent", 5)))
|
||||
rebate = (Decimal(order.points) * percent / 100).quantize(Decimal("0.1"))
|
||||
if rebate <= 0:
|
||||
return
|
||||
await incentive_service.change_assets(
|
||||
db, referral.inviter_id, rebate, incentive_service.ASSET_BIZ_REBATE,
|
||||
biz_id=order.order_no, remark="推荐用户充值返利",
|
||||
)
|
||||
|
||||
|
||||
# ---------- 支付平台回调 ----------
|
||||
|
||||
async def handle_alipay_callback(db: AsyncSession, form: dict) -> str:
|
||||
"""支付宝回调(8.5):RSA2 验签 → 校验单号/金额 → 幂等入账 → 纯文本 success"""
|
||||
if not alipay.verify_callback(form):
|
||||
return "failure"
|
||||
if form.get("trade_status") not in ("TRADE_SUCCESS", "TRADE_FINISHED"):
|
||||
return "failure"
|
||||
order_no = form.get("out_trade_no", "")
|
||||
try:
|
||||
paid_fen = int(Decimal(form.get("total_amount", "0")) * 100)
|
||||
await settle_order(db, order_no, form.get("trade_no", ""), paid_fen)
|
||||
except BizException:
|
||||
return "failure"
|
||||
return "success"
|
||||
|
||||
|
||||
async def handle_wechat_callback(db: AsyncSession, headers: dict, body: bytes) -> dict:
|
||||
"""微信支付回调(8.6):V3 验签 + AES-GCM 解密 → 幂等入账 → {"code": "SUCCESS"}"""
|
||||
plain = wechat.verify_callback(headers, body)
|
||||
if not plain:
|
||||
return {"code": "FAIL", "message": "验签失败"}
|
||||
if plain.get("trade_state") != "SUCCESS":
|
||||
return {"code": "FAIL", "message": "非成功状态"}
|
||||
try:
|
||||
await settle_order(
|
||||
db,
|
||||
plain.get("out_trade_no", ""),
|
||||
plain.get("transaction_id", ""),
|
||||
int((plain.get("amount") or {}).get("total", 0)),
|
||||
)
|
||||
except BizException as e:
|
||||
return {"code": "FAIL", "message": e.message}
|
||||
return {"code": "SUCCESS"}
|
||||
@@ -0,0 +1,83 @@
|
||||
"""敏感词服务:DFA 算法,词库 Redis 缓存 60s(后台维护后即时生效,对齐《02-技术文档》6.7)"""
|
||||
import json
|
||||
import time
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.common import SensitiveWord
|
||||
|
||||
CACHE_KEY = "words:sensitive"
|
||||
CACHE_TTL = 60
|
||||
|
||||
|
||||
class DFAMatcher:
|
||||
"""确定性有穷自动机敏感词匹配器"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.root: dict = {}
|
||||
|
||||
def add(self, word: str) -> None:
|
||||
node = self.root
|
||||
for ch in word.strip().lower():
|
||||
if not ch:
|
||||
continue
|
||||
node = node.setdefault(ch, {})
|
||||
node["\x00"] = True # 词结束标记
|
||||
|
||||
def find(self, text: str) -> str | None:
|
||||
"""返回命中的第一个敏感词,未命中返回 None"""
|
||||
text = text.lower()
|
||||
n = len(text)
|
||||
for i in range(n):
|
||||
node = self.root
|
||||
j = i
|
||||
while j < n and text[j] in node:
|
||||
node = node[text[j]]
|
||||
j += 1
|
||||
if "\x00" in node:
|
||||
return text[i:j]
|
||||
return None
|
||||
|
||||
|
||||
# 进程内缓存(matcher, 构建时间戳)
|
||||
_local_cache: tuple[DFAMatcher, float] | None = None
|
||||
|
||||
|
||||
async def get_matcher(db: AsyncSession, redis: aioredis.Redis) -> DFAMatcher:
|
||||
"""获取 DFA 匹配器:进程缓存 → Redis → DB,60s 重建"""
|
||||
global _local_cache
|
||||
now = time.time()
|
||||
if _local_cache and now - _local_cache[1] < CACHE_TTL:
|
||||
return _local_cache[0]
|
||||
|
||||
words: list[str] = []
|
||||
cached = await redis.get(CACHE_KEY)
|
||||
if cached:
|
||||
words = json.loads(cached)
|
||||
else:
|
||||
rows = (await db.execute(select(SensitiveWord.word))).scalars().all()
|
||||
words = [w for w in rows if w]
|
||||
await redis.set(CACHE_KEY, json.dumps(words, ensure_ascii=False), ex=CACHE_TTL)
|
||||
|
||||
matcher = DFAMatcher()
|
||||
for w in words:
|
||||
matcher.add(w)
|
||||
_local_cache = (matcher, now)
|
||||
return matcher
|
||||
|
||||
|
||||
async def find_sensitive(db: AsyncSession, redis: aioredis.Redis, text: str) -> str | None:
|
||||
"""检查文本,返回命中的敏感词或 None"""
|
||||
if not text:
|
||||
return None
|
||||
matcher = await get_matcher(db, redis)
|
||||
return matcher.find(text)
|
||||
|
||||
|
||||
async def invalidate_cache(redis: aioredis.Redis) -> None:
|
||||
"""后台增删词库后调用:清 Redis 缓存,进程缓存等 60s 内自然过期"""
|
||||
global _local_cache
|
||||
_local_cache = None
|
||||
await redis.delete(CACHE_KEY)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""结算服务:周期任务(推荐激活 / 视频创作收益 / 订单超时关单)
|
||||
|
||||
由 main.py lifespan 内的 asyncio 周期协程驱动(开发环境单进程够用);
|
||||
各结算函数均为幂等扫描,测试可直接调用。
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import biz_config
|
||||
from app.database import SessionFactory
|
||||
from app.models.billing import RechargeOrder, WatchRecord
|
||||
from app.models.incentive import Referral, VideoEarning
|
||||
from app.models.user import User
|
||||
from app.services import incentive_service, membership_service, wallet_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 单轮扫描批量上限
|
||||
BATCH_SIZE = 500
|
||||
|
||||
# 周期间隔(秒)
|
||||
INTERVAL_REFERRAL = 600 # 推荐激活结算 10min
|
||||
INTERVAL_EARNINGS = 300 # 创作收益结算 5min
|
||||
INTERVAL_ORDER_CLOSE = 60 # 订单超时关单 1min
|
||||
|
||||
|
||||
def _referral_cfg() -> dict:
|
||||
return biz_config.get("referral") or {}
|
||||
|
||||
|
||||
def _asset_cfg() -> dict:
|
||||
return biz_config.get("asset") or {}
|
||||
|
||||
|
||||
# ---------- 推荐激活结算 ----------
|
||||
|
||||
async def settle_referral_activations(db: AsyncSession) -> dict:
|
||||
"""待激活推荐关系结算(《01-开发文档》2.8:72h 内观看≥N 且次日登录 → 有效;超期 → 无效)。
|
||||
|
||||
激活时点校验推荐人会员身份,会员才发放注册奖励积分(point biz_type=8)。
|
||||
"""
|
||||
cfg = _referral_cfg()
|
||||
hours = int(cfg.get("activate_hours", 72))
|
||||
watch_count = int(cfg.get("activate_watch_count", 3))
|
||||
reward = int(cfg.get("register_reward_points", 10))
|
||||
now = datetime.now()
|
||||
|
||||
rows = (await db.execute(
|
||||
select(Referral).where(Referral.status == 0).order_by(Referral.id).limit(BATCH_SIZE)
|
||||
)).scalars().all()
|
||||
activated = expired = 0
|
||||
for r in rows:
|
||||
if r.created_at + timedelta(hours=hours) < now:
|
||||
r.status = 2 # 超期未达标
|
||||
expired += 1
|
||||
continue
|
||||
watches = (await db.execute(
|
||||
select(func.count(WatchRecord.id)).where(
|
||||
WatchRecord.subject_type == 1, WatchRecord.subject_id == str(r.invitee_id)
|
||||
)
|
||||
)).scalar() or 0
|
||||
if watches < watch_count:
|
||||
continue
|
||||
invitee = await db.get(User, r.invitee_id)
|
||||
next_day_login = bool(
|
||||
invitee and invitee.last_login_at and invitee.last_login_at.date() > r.created_at.date()
|
||||
)
|
||||
if not next_day_login:
|
||||
continue
|
||||
r.status = 1
|
||||
r.activated_at = now
|
||||
activated += 1
|
||||
if reward > 0 and await membership_service.is_member(db, r.inviter_id):
|
||||
await wallet_service.change_points(
|
||||
db, r.inviter_id, reward, wallet_service.BIZ_REFERRAL,
|
||||
biz_id=str(r.id), remark="推荐新用户激活奖励",
|
||||
)
|
||||
await db.commit()
|
||||
return {"scanned": len(rows), "activated": activated, "expired": expired}
|
||||
|
||||
|
||||
# ---------- 视频创作收益结算 ----------
|
||||
|
||||
async def settle_video_earnings(db: AsyncSession) -> dict:
|
||||
"""创作收益按增量结算:完整观看每满 1000 发 1 资产、点赞每满 100 发 1 资产(余数保留)。
|
||||
|
||||
结算时点校验作者会员身份:非会员只保留计数,不推进结算刻度。
|
||||
"""
|
||||
cfg = _asset_cfg()
|
||||
views_per = int(cfg.get("complete_views_per_asset", 1000))
|
||||
likes_per = int(cfg.get("likes_per_asset", 100))
|
||||
|
||||
rows = (await db.execute(
|
||||
select(VideoEarning).order_by(VideoEarning.video_id).limit(BATCH_SIZE)
|
||||
)).scalars().all()
|
||||
settled_videos = 0
|
||||
granted = Decimal(0)
|
||||
for e in rows:
|
||||
if not await membership_service.is_member(db, e.author_id):
|
||||
continue
|
||||
view_assets = (e.complete_views - e.views_settled) // views_per if views_per > 0 else 0
|
||||
like_assets = (e.likes_accrued - e.likes_settled) // likes_per if likes_per > 0 else 0
|
||||
if view_assets <= 0 and like_assets <= 0:
|
||||
continue
|
||||
if view_assets > 0:
|
||||
await incentive_service.change_assets(
|
||||
db, e.author_id, Decimal(view_assets), incentive_service.ASSET_BIZ_COMPLETE,
|
||||
biz_id=str(e.video_id), remark="完整观看收益",
|
||||
)
|
||||
e.views_settled += view_assets * views_per
|
||||
if like_assets > 0:
|
||||
await incentive_service.change_assets(
|
||||
db, e.author_id, Decimal(like_assets), incentive_service.ASSET_BIZ_LIKE,
|
||||
biz_id=str(e.video_id), remark="点赞收益",
|
||||
)
|
||||
e.likes_settled += like_assets * likes_per
|
||||
settled_videos += 1
|
||||
granted += view_assets + like_assets
|
||||
await db.commit()
|
||||
return {"scanned": len(rows), "settled_videos": settled_videos, "granted_assets": float(granted)}
|
||||
|
||||
|
||||
# ---------- 订单超时关单 ----------
|
||||
|
||||
async def close_expired_orders(db: AsyncSession) -> dict:
|
||||
"""待支付订单超过 expire_at → 已关闭"""
|
||||
rows = (await db.execute(
|
||||
select(RechargeOrder)
|
||||
.where(RechargeOrder.status == 0, RechargeOrder.expire_at < datetime.now())
|
||||
.order_by(RechargeOrder.id)
|
||||
.limit(BATCH_SIZE)
|
||||
)).scalars().all()
|
||||
for o in rows:
|
||||
o.status = 2
|
||||
await db.commit()
|
||||
return {"closed": len(rows)}
|
||||
|
||||
|
||||
# ---------- 周期协程(lifespan 挂载) ----------
|
||||
|
||||
async def _run_loop(name: str, fn, interval: int) -> None:
|
||||
while True:
|
||||
try:
|
||||
async with SessionFactory() as db:
|
||||
result = await fn(db)
|
||||
if result:
|
||||
logger.info("周期任务[%s]完成: %s", name, result)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("周期任务[%s]执行失败", name)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
def start_periodic_tasks() -> list[asyncio.Task]:
|
||||
"""启动全部周期任务,返回 Task 列表(lifespan 退出时取消)"""
|
||||
return [
|
||||
asyncio.create_task(_run_loop("推荐激活结算", settle_referral_activations, INTERVAL_REFERRAL)),
|
||||
asyncio.create_task(_run_loop("创作收益结算", settle_video_earnings, INTERVAL_EARNINGS)),
|
||||
asyncio.create_task(_run_loop("订单超时关单", close_expired_orders, INTERVAL_ORDER_CLOSE)),
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
"""本地文件存储(开发环境):头像等小文件与上传视频存 server/uploads,经 /static 访问。
|
||||
|
||||
生产切换 OSS 时仅需替换本模块实现,调用方不变。
|
||||
"""
|
||||
import time
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
from app.config import BASE_DIR
|
||||
from app.core import errors
|
||||
from app.core.response import BizException
|
||||
|
||||
UPLOAD_DIR = BASE_DIR / "uploads"
|
||||
MAX_AVATAR_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
|
||||
# 允许上传的视频扩展名
|
||||
VIDEO_EXTS = ("mp4", "mov", "m4v", "avi", "mkv", "webm")
|
||||
# 上传视频大小上限(与 config.yaml upload.max_size_mb 对齐)
|
||||
MAX_VIDEO_SIZE = 500 * 1024 * 1024 # 500MB
|
||||
|
||||
|
||||
def video_ext_of(filename: str | None) -> str:
|
||||
return (filename or "").rsplit(".", 1)[-1].lower() if filename and "." in filename else ""
|
||||
|
||||
|
||||
async def save_avatar(file: UploadFile, user_id: int) -> str:
|
||||
"""保存头像,返回可访问的 URL 路径"""
|
||||
ext = (file.filename or "").rsplit(".", 1)[-1].lower()
|
||||
if ext not in ("jpg", "jpeg", "png"):
|
||||
raise BizException(errors.PARAM_ERROR, "仅支持 jpg/png 图片")
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise BizException(errors.PARAM_ERROR, "文件内容为空")
|
||||
if len(data) > MAX_AVATAR_SIZE:
|
||||
raise BizException(errors.PARAM_ERROR, "图片不能超过 5MB")
|
||||
(UPLOAD_DIR / "avatars").mkdir(parents=True, exist_ok=True)
|
||||
name = f"{user_id}_{int(time.time())}.{ext}"
|
||||
(UPLOAD_DIR / "avatars" / name).write_bytes(data)
|
||||
return f"/static/avatars/{name}"
|
||||
|
||||
|
||||
def validate_object_key(object_key: str) -> str:
|
||||
"""校验上传凭证下发的 object_key 格式,防路径穿越;返回扩展名"""
|
||||
if (
|
||||
not object_key.startswith("upload/tmp/")
|
||||
or ".." in object_key
|
||||
or "\\" in object_key
|
||||
or len(object_key) > 300
|
||||
):
|
||||
raise BizException(errors.PARAM_ERROR, "object_key 无效")
|
||||
ext = video_ext_of(object_key)
|
||||
if ext not in VIDEO_EXTS:
|
||||
raise BizException(errors.PARAM_ERROR, "object_key 无效")
|
||||
return ext
|
||||
|
||||
|
||||
async def save_video(file: UploadFile, object_key: str) -> tuple[str, int]:
|
||||
"""按 object_key 保存上传的视频文件,返回 (可访问 URL 路径, 文件字节数)"""
|
||||
validate_object_key(object_key)
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise BizException(errors.PARAM_ERROR, "文件内容为空")
|
||||
if len(data) > MAX_VIDEO_SIZE:
|
||||
raise BizException(errors.PARAM_ERROR, f"视频不能超过 {MAX_VIDEO_SIZE // 1024 // 1024}MB")
|
||||
path = UPLOAD_DIR / object_key
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return f"/static/{object_key}", len(data)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""用户服务:资料 / 钱包 / 积分明细 / 注销 / 公开主页(对应《03-接口文档》第 4 章)"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import func, select
|
||||
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.core.security import verify_password
|
||||
from app.models.billing import PointTransaction
|
||||
from app.models.user import User
|
||||
from app.models.video import Video
|
||||
from app.services import membership_service, sensitive_service, video_service, wallet_service
|
||||
|
||||
DEACTIVATE_FREEZE_DAYS = 7
|
||||
|
||||
|
||||
def _mask_phone(phone: str | None) -> str | None:
|
||||
"""手机号脱敏:138****8000"""
|
||||
if not phone or len(phone) < 7:
|
||||
return phone
|
||||
return f"{phone[:3]}****{phone[-4:]}"
|
||||
|
||||
|
||||
def _fmt(dt: datetime | None) -> str | None:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
|
||||
|
||||
|
||||
async def _get_user(db: AsyncSession, user_id: int) -> User:
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise BizException(errors.UNAUTHORIZED, http_status=401)
|
||||
return user
|
||||
|
||||
|
||||
async def _me_payload(db: AsyncSession, user: User) -> dict:
|
||||
"""个人信息完整结构(4.1/4.2 共用)"""
|
||||
ms = await membership_service.get_membership(db, user.id)
|
||||
active = membership_service.membership_active(ms)
|
||||
points = await wallet_service.get_balance(db, user.id)
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"phone": _mask_phone(user.phone),
|
||||
"email": user.email,
|
||||
"nickname": user.nickname,
|
||||
"avatar_url": user.avatar_url,
|
||||
"bio": user.bio,
|
||||
"gender": user.gender,
|
||||
"is_member": active,
|
||||
"member_plan": ms.current_plan if active else None,
|
||||
"member_expire_at": _fmt(ms.expire_at) if active else None,
|
||||
"points": points,
|
||||
}
|
||||
|
||||
|
||||
async def get_me(db: AsyncSession, user_id: int) -> dict:
|
||||
return await _me_payload(db, await _get_user(db, user_id))
|
||||
|
||||
|
||||
async def update_me(db: AsyncSession, redis: aioredis.Redis, user_id: int, req) -> dict:
|
||||
"""修改资料:昵称/简介过敏感词"""
|
||||
user = await _get_user(db, user_id)
|
||||
if req.nickname is not None:
|
||||
if await sensitive_service.find_sensitive(db, redis, req.nickname):
|
||||
raise BizException(errors.SENSITIVE_WORD)
|
||||
user.nickname = req.nickname
|
||||
if req.bio is not None:
|
||||
if await sensitive_service.find_sensitive(db, redis, req.bio):
|
||||
raise BizException(errors.SENSITIVE_WORD)
|
||||
user.bio = req.bio
|
||||
if req.gender is not None:
|
||||
user.gender = req.gender
|
||||
await db.commit()
|
||||
return await _me_payload(db, user)
|
||||
|
||||
|
||||
async def set_avatar(db: AsyncSession, user_id: int, avatar_url: str) -> dict:
|
||||
user = await _get_user(db, user_id)
|
||||
user.avatar_url = avatar_url
|
||||
await db.commit()
|
||||
return {"avatar_url": avatar_url}
|
||||
|
||||
|
||||
# ---------- 钱包 / 积分明细 ----------
|
||||
|
||||
async def get_wallet(db: AsyncSession, redis: aioredis.Redis, user_id: int) -> dict:
|
||||
wallet = await wallet_service.get_or_create_wallet(db, user_id)
|
||||
# 评论奖励进度(Redis Hash:count 进度 / daily 当日已发 / date 当日日期)
|
||||
prog = await redis.hgetall(f"comment:prog:{user_id}") # type: ignore[misc]
|
||||
reward_cfg = (biz_config.get("points") or {}).get("comment_reward") or {}
|
||||
today = f"{datetime.now():%Y%m%d}"
|
||||
return {
|
||||
"points": wallet.points,
|
||||
"total_recharge": wallet.total_recharge,
|
||||
"total_earned": wallet.total_earned,
|
||||
"total_consumed": wallet.total_consumed,
|
||||
"comment_reward_progress": {
|
||||
"current": int(prog.get("count", 0)),
|
||||
"target": int(reward_cfg.get("per_count", 10)),
|
||||
"today_rewarded": int(prog.get("daily", 0)) if prog.get("date") == today else 0,
|
||||
"daily_limit": int(reward_cfg.get("daily_limit", 5)),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def get_points_records(
|
||||
db: AsyncSession, user_id: int, page: int, page_size: int, biz_type: int | None
|
||||
) -> dict:
|
||||
stmt = select(PointTransaction).where(PointTransaction.user_id == user_id)
|
||||
if biz_type is not None:
|
||||
stmt = stmt.where(PointTransaction.biz_type == biz_type)
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
|
||||
rows = (
|
||||
await db.execute(
|
||||
stmt.order_by(PointTransaction.id.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
)
|
||||
).scalars().all()
|
||||
return {
|
||||
"total": total,
|
||||
"list": [
|
||||
{
|
||||
"id": r.id,
|
||||
"change_amount": r.change_amount,
|
||||
"balance_after": r.balance_after,
|
||||
"biz_type": r.biz_type,
|
||||
"remark": r.remark,
|
||||
"created_at": _fmt(r.created_at),
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------- 注销 ----------
|
||||
|
||||
async def deactivate(db: AsyncSession, redis: aioredis.Redis, user_id: int, password: str) -> dict:
|
||||
"""申请注销:密码二次确认 → 进入 7 天冻结期 → 全端 Token 立即失效。
|
||||
|
||||
冻结期内登录返回 1009 + can_revoke(见 auth_service.login);到期匿名化由定时任务执行。
|
||||
"""
|
||||
user = await _get_user(db, user_id)
|
||||
if not verify_password(password, user.password_hash):
|
||||
raise BizException(errors.PARAM_ERROR, "密码错误")
|
||||
if user.deactivate_at is not None:
|
||||
raise BizException(errors.PARAM_ERROR, "账号已在注销冻结期内")
|
||||
|
||||
now = datetime.now()
|
||||
user.deactivate_at = now
|
||||
# 全端 Token 失效:递增令牌纪元 + 清 refresh 白名单
|
||||
await redis.incr(f"auth:epoch:{user_id}")
|
||||
await redis.delete(f"auth:refresh:{user_id}")
|
||||
await db.commit()
|
||||
|
||||
revoke_before = now + timedelta(days=DEACTIVATE_FREEZE_DAYS)
|
||||
return {"deactivate_at": _fmt(now), "revoke_before": _fmt(revoke_before)}
|
||||
|
||||
|
||||
# ---------- 公开主页 ----------
|
||||
|
||||
async def get_public_profile(db: AsyncSession, user_id: int) -> dict:
|
||||
user = await db.get(User, user_id)
|
||||
if user is None or user.status != 1:
|
||||
raise BizException(errors.NOT_FOUND, "用户不存在")
|
||||
base = select(Video).where(Video.user_id == user_id, Video.status == video_service.STATUS_PUBLISHED)
|
||||
video_count = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar() or 0
|
||||
total_likes = (await db.execute(select(func.coalesce(func.sum(Video.like_count), 0)).where(
|
||||
Video.user_id == user_id, Video.status == video_service.STATUS_PUBLISHED
|
||||
))).scalar() or 0
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"nickname": user.nickname,
|
||||
"avatar_url": user.avatar_url,
|
||||
"bio": user.bio,
|
||||
"video_count": video_count,
|
||||
"total_likes": int(total_likes),
|
||||
}
|
||||
|
||||
|
||||
async def get_user_videos(
|
||||
db: AsyncSession, user_id: int, page: int, page_size: int, viewer_user_id: int | None
|
||||
) -> dict:
|
||||
"""他人作品列表:仅已发布"""
|
||||
stmt = select(Video).where(Video.user_id == user_id, Video.status == video_service.STATUS_PUBLISHED)
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
|
||||
videos = (
|
||||
await db.execute(
|
||||
stmt.order_by(Video.published_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
)
|
||||
).scalars().all()
|
||||
return {"total": total, "list": await video_service.serialize_videos(db, list(videos), viewer_user_id)}
|
||||
@@ -0,0 +1,739 @@
|
||||
"""视频服务: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)
|
||||
return await _feed_scored(db, redis, identity, feed_type, cursor, limit, category_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)}
|
||||
@@ -0,0 +1,81 @@
|
||||
"""积分钱包服务:统一余额变更入口(行锁 + 流水 + 乐观锁,对齐《02-技术文档》3.4)"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core import errors
|
||||
from app.core.response import BizException
|
||||
from app.models.billing import PointTransaction
|
||||
from app.models.user import UserWallet
|
||||
|
||||
# 流水业务类型
|
||||
BIZ_RECHARGE = 1 # 充值
|
||||
BIZ_WATCH = 2 # 观看消耗
|
||||
BIZ_COMMENT = 3 # 评论奖励
|
||||
BIZ_MEMBERSHIP = 4 # 开通会员
|
||||
BIZ_GIFT = 5 # 活动赠送
|
||||
BIZ_ADJUST = 6 # 后台调整
|
||||
BIZ_CLAWBACK = 7 # 奖励扣回/退款扣回
|
||||
BIZ_REFERRAL = 8 # 推荐奖励
|
||||
|
||||
|
||||
async def get_or_create_wallet(db: AsyncSession, user_id: int, for_update: bool = False) -> UserWallet:
|
||||
stmt = select(UserWallet).where(UserWallet.user_id == user_id)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
wallet = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if wallet is None:
|
||||
wallet = UserWallet(user_id=user_id)
|
||||
db.add(wallet)
|
||||
await db.flush()
|
||||
if for_update:
|
||||
# 新建后再加行锁,保证后续并发读到同一行
|
||||
wallet = (await db.execute(stmt)).scalar_one()
|
||||
return wallet
|
||||
|
||||
|
||||
async def change_points(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount: int,
|
||||
biz_type: int,
|
||||
biz_id: str = "",
|
||||
remark: str = "",
|
||||
allow_negative: bool = False,
|
||||
) -> int:
|
||||
"""变更积分余额,返回变动后余额。
|
||||
|
||||
- amount 正增负减;扣减默认不允许透支(退款扣回场景传 allow_negative=True,余额可为负)
|
||||
- 同事务内:行锁 → 校验/更新余额与累计 → 写流水
|
||||
"""
|
||||
wallet = await get_or_create_wallet(db, user_id, for_update=True)
|
||||
new_balance = wallet.points + amount
|
||||
if amount < 0 and new_balance < 0 and not allow_negative:
|
||||
raise BizException(errors.POINTS_NOT_ENOUGH)
|
||||
|
||||
wallet.points = new_balance
|
||||
wallet.version += 1
|
||||
if amount > 0:
|
||||
if biz_type == BIZ_RECHARGE:
|
||||
wallet.total_recharge += amount
|
||||
else:
|
||||
wallet.total_earned += amount
|
||||
else:
|
||||
wallet.total_consumed += -amount
|
||||
|
||||
db.add(
|
||||
PointTransaction(
|
||||
user_id=user_id,
|
||||
change_amount=amount,
|
||||
balance_after=new_balance,
|
||||
biz_type=biz_type,
|
||||
biz_id=biz_id,
|
||||
remark=remark,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return new_balance
|
||||
|
||||
|
||||
async def get_balance(db: AsyncSession, user_id: int) -> int:
|
||||
wallet = await get_or_create_wallet(db, user_id)
|
||||
return wallet.points
|
||||
Reference in New Issue
Block a user