This commit is contained in:
macbook_max
2026-10-15 20:36:59 +08:00
commit 6bf949949c
7923 changed files with 1369582 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+331
View File
@@ -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()
+145
View File
@@ -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)
+50
View File
@@ -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)
+324
View File
@@ -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
],
}
+321
View File
@@ -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}
+169
View File
@@ -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)
+219
View File
@@ -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),
}
+255
View File
@@ -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"}
+83
View File
@@ -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)
+166
View File
@@ -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)),
]
+68
View File
@@ -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)
+192
View File
@@ -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)}
+739
View File
@@ -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)}
+81
View File
@@ -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