a2270ee1b2
新手引导视频:运营后台上传 MP4(上限 100MB,魔数校验只认 ISO BMFF), App 端在领券等候浮层前 N 次以引导视频替代广告。新增 guide_video 的 model/schema/repository/router(App 侧 + 后台侧)与播放记录表迁移。 美团券:首页「销量最高 / 智能推荐」两个 tab 改游标分页,配套两条 (city_id, dedup_key, 排序键 DESC) 复合索引,让 Postgres 顺着索引流式 去重,免掉每翻一页重排整城券的开销。美团 CPS client 在 lifespan 预热 并在关闭时释放连接池。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
258 lines
9.2 KiB
Python
258 lines
9.2 KiB
Python
"""新手引导视频:运营配置读写 + 播放计次 + 发币。
|
|
|
|
**配置**(开关 / 视频地址 / 前几次 / 每次金币)整体作为一个 JSON 存进通用 app_config 表
|
|
(key=coupon_guide_video),写法完全对齐 feedback_qr —— 不进 CONFIG_DEFS,所以不会污染
|
|
系统配置页的通用列表,由本模块独占维护。
|
|
|
|
**计次**按账号(user_id)、**开播即计数**:客户端每次要展示领券等候浮层时调
|
|
`/api/v1/guide-video/start`,命中则当场写一行 guide_video_play(status='playing')。
|
|
已用次数 = 该账号的行数,达到 max_plays 后不再下发,客户端改放广告(原逻辑)。
|
|
|
|
**发币**幂等键是 play_token:同一次播放重复上报只入账一次(网络重试 / 关闭与播完同时触发
|
|
都靠它挡住)。中途关闭也照发 —— 产品拍板「中途关闭也算看完」。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core import rewards
|
|
from app.models.app_config import AppConfig
|
|
from app.models.guide_video import GuideVideoPlay
|
|
from app.repositories import wallet as crud_wallet
|
|
|
|
_KEY = "coupon_guide_video"
|
|
|
|
#: 金币流水 biz_type。客户端收益明细按它显示「新手引导视频奖励」。
|
|
BIZ_TYPE = "guide_video"
|
|
|
|
# 默认值 = 「运营还没配」时的行为:video_url 为空 → 一律不下发引导视频,浮层维持现状(放广告)。
|
|
# 所以本功能上线后**不配视频就等于没上线**,不会影响存量用户。
|
|
_DEFAULTS: dict[str, Any] = {
|
|
"enabled": True,
|
|
"video_url": None, # None/空 = 未配片 → 不下发,浮层照旧放广告
|
|
"max_plays": 3, # 每个账号前 N 次浮层放引导视频
|
|
"reward_coin": 120, # 每次固定金币
|
|
}
|
|
|
|
_FIELDS = tuple(_DEFAULTS.keys())
|
|
|
|
# 后台可配范围的护栏:防手滑把次数/金币填成天文数字(配置直接决定发币)。
|
|
MAX_PLAYS_LIMIT = 50
|
|
REWARD_COIN_LIMIT = 10_000
|
|
|
|
|
|
# ===== 配置 =====
|
|
|
|
|
|
def _merge(raw: Any) -> dict[str, Any]:
|
|
"""DB 里(可能不全的)dict 叠加到默认上,得到完整配置(4 个字段,无 updated_at)。"""
|
|
out = dict(_DEFAULTS)
|
|
if isinstance(raw, dict):
|
|
for k in _FIELDS:
|
|
v = raw.get(k)
|
|
if v is not None:
|
|
out[k] = v
|
|
return out
|
|
|
|
|
|
def get_config(db: Session) -> dict[str, Any]:
|
|
"""完整配置 + updated_at(admin 读 / 业务读共用)。"""
|
|
row = db.get(AppConfig, _KEY)
|
|
cfg = _merge(row.value if row is not None else None)
|
|
cfg["updated_at"] = row.updated_at.isoformat() if row is not None and row.updated_at else None
|
|
return cfg
|
|
|
|
|
|
def _write(db: Session, value: dict[str, Any], *, admin_id: int, commit: bool) -> dict[str, Any]:
|
|
"""整体覆写该行(value 须为完整字段 dict),返回合并后的完整配置(含 updated_at)。"""
|
|
row = db.get(AppConfig, _KEY)
|
|
if row is None:
|
|
row = AppConfig(key=_KEY, value=value, updated_by_admin_id=admin_id)
|
|
db.add(row)
|
|
else:
|
|
row.value = value # 整体重新赋值,SQLAlchemy 才侦测得到变更
|
|
row.updated_by_admin_id = admin_id
|
|
if commit:
|
|
db.commit()
|
|
db.refresh(row)
|
|
else:
|
|
db.flush()
|
|
out = _merge(row.value)
|
|
out["updated_at"] = row.updated_at.isoformat() if row.updated_at else None
|
|
return out
|
|
|
|
|
|
def update_config(
|
|
db: Session,
|
|
*,
|
|
enabled: bool | None = None,
|
|
max_plays: int | None = None,
|
|
reward_coin: int | None = None,
|
|
admin_id: int,
|
|
commit: bool = True,
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
"""改开关 / 次数 / 金币(只改传了的字段;视频走 set_video)。返回 (before, after) 供审计。"""
|
|
row = db.get(AppConfig, _KEY)
|
|
before = _merge(row.value if row is not None else None)
|
|
new_value = {k: before[k] for k in _FIELDS}
|
|
if enabled is not None:
|
|
new_value["enabled"] = enabled
|
|
if max_plays is not None:
|
|
new_value["max_plays"] = max(0, min(int(max_plays), MAX_PLAYS_LIMIT))
|
|
if reward_coin is not None:
|
|
new_value["reward_coin"] = max(0, min(int(reward_coin), REWARD_COIN_LIMIT))
|
|
after = _write(db, new_value, admin_id=admin_id, commit=commit)
|
|
return before, after
|
|
|
|
|
|
def set_video(
|
|
db: Session, video_url: str | None, *, admin_id: int, commit: bool = True
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
"""设置/清空引导视频地址。返回 (before, after);before['video_url'] 供调用方删旧文件。"""
|
|
row = db.get(AppConfig, _KEY)
|
|
before = _merge(row.value if row is not None else None)
|
|
new_value = {k: before[k] for k in _FIELDS}
|
|
new_value["video_url"] = video_url
|
|
after = _write(db, new_value, admin_id=admin_id, commit=commit)
|
|
return before, after
|
|
|
|
|
|
# ===== 播放计次 =====
|
|
|
|
|
|
def used_plays(db: Session, user_id: int) -> int:
|
|
"""该账号已用掉的引导视频次数(开播即算,含未发币的)。"""
|
|
return int(
|
|
db.execute(
|
|
select(func.count()).select_from(GuideVideoPlay).where(
|
|
GuideVideoPlay.user_id == user_id
|
|
)
|
|
).scalar_one()
|
|
)
|
|
|
|
|
|
def play_stats(db: Session) -> dict[str, int]:
|
|
"""全站播放统计(admin 页展示):总播放次数 / 其中已发币次数。"""
|
|
total = int(
|
|
db.execute(select(func.count()).select_from(GuideVideoPlay)).scalar_one()
|
|
)
|
|
granted = int(
|
|
db.execute(
|
|
select(func.count()).select_from(GuideVideoPlay).where(
|
|
GuideVideoPlay.status == "granted"
|
|
)
|
|
).scalar_one()
|
|
)
|
|
return {"total_plays": total, "granted_plays": granted}
|
|
|
|
|
|
def start_play(
|
|
db: Session, user_id: int, *, scene: str = "coupon", commit: bool = True
|
|
) -> dict[str, Any]:
|
|
"""决定这次浮层是否放引导视频;命中则**当场计次**并返回 play_token。
|
|
|
|
返回 dict:
|
|
should_play 是否放引导视频(False → 客户端照旧放广告)
|
|
video_url 相对地址(/media/...);客户端自行拼 BASE_URL
|
|
play_token 发币幂等键(should_play=False 时为空串)
|
|
reward_coin 播完/中途关闭都发的固定金币
|
|
seq / remaining 第几次 / 发完这次还剩几次(仅展示与排查用)
|
|
"""
|
|
cfg = get_config(db)
|
|
video_url = (cfg.get("video_url") or "").strip()
|
|
max_plays = int(cfg.get("max_plays") or 0)
|
|
reward_coin = int(cfg.get("reward_coin") or 0)
|
|
used = used_plays(db, user_id)
|
|
miss = {
|
|
"should_play": False,
|
|
"video_url": None,
|
|
"play_token": "",
|
|
"reward_coin": reward_coin,
|
|
"seq": used,
|
|
"remaining": max(0, max_plays - used),
|
|
}
|
|
if not cfg.get("enabled") or not video_url or max_plays <= 0 or used >= max_plays:
|
|
return miss
|
|
|
|
seq = used + 1
|
|
play = GuideVideoPlay(
|
|
user_id=user_id,
|
|
play_token=uuid.uuid4().hex,
|
|
scene=scene,
|
|
seq=seq,
|
|
video_url=video_url,
|
|
coin=0,
|
|
status="playing",
|
|
completed=0,
|
|
started_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
|
|
)
|
|
db.add(play)
|
|
if commit:
|
|
db.commit()
|
|
else:
|
|
db.flush()
|
|
return {
|
|
"should_play": True,
|
|
"video_url": video_url,
|
|
"play_token": play.play_token,
|
|
"reward_coin": reward_coin,
|
|
"seq": seq,
|
|
"remaining": max(0, max_plays - seq),
|
|
}
|
|
|
|
|
|
def grant_play(
|
|
db: Session, user_id: int, *, play_token: str, completed: bool
|
|
) -> dict[str, Any]:
|
|
"""按 play_token 发这次引导视频的金币(幂等)。播完 / 中途关闭都发。
|
|
|
|
返回 {granted, coin, status}:granted=True 表示**本次调用真的入账了**;
|
|
重复上报返回 granted=False + 已发金币(客户端据此不重复累加 toast 金额)。
|
|
"""
|
|
token = (play_token or "").strip()
|
|
play = db.execute(
|
|
select(GuideVideoPlay).where(
|
|
GuideVideoPlay.play_token == token,
|
|
GuideVideoPlay.user_id == user_id,
|
|
)
|
|
).scalar_one_or_none()
|
|
if play is None:
|
|
return {"granted": False, "coin": 0, "status": "not_found"}
|
|
if play.status == "granted":
|
|
return {"granted": False, "coin": play.coin, "status": "already_granted"}
|
|
|
|
# 金币额度以**服务端配置**为准,不信客户端(客户端只上报"播完/关闭")。
|
|
coin = int(get_config(db).get("reward_coin") or 0)
|
|
play.completed = 1 if completed else 0
|
|
play.status = "granted"
|
|
play.coin = coin
|
|
play.granted_at = datetime.now(rewards.CN_TZ).replace(tzinfo=None)
|
|
if coin > 0:
|
|
crud_wallet.grant_coins(
|
|
db,
|
|
user_id,
|
|
coin,
|
|
biz_type=BIZ_TYPE,
|
|
ref_id=token,
|
|
remark="新手引导视频奖励",
|
|
)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
# 并发双发(播完 + ✕ 同时到)时其中一条会撞唯一键/行锁,回滚后按已发返回。
|
|
db.rollback()
|
|
again = db.execute(
|
|
select(GuideVideoPlay).where(
|
|
GuideVideoPlay.play_token == token,
|
|
GuideVideoPlay.user_id == user_id,
|
|
)
|
|
).scalar_one_or_none()
|
|
return {"granted": False, "coin": again.coin if again else 0, "status": "already_granted"}
|
|
return {"granted": True, "coin": coin, "status": "granted"}
|