3f7b5167fa
Co-authored-by: guke <guke@wonderable.ai> Co-authored-by: 左辰勇 <exinglang@gmail.com> Reviewed-on: #167 Co-authored-by: zuochenyong <zuochenyong@wonderable.ai> Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
291 lines
11 KiB
Python
291 lines
11 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 后不再下发,客户端改放广告(原逻辑)。
|
|
COUNT 判定本身无锁,真正卡住次数上限的是 (user_id, seq) 唯一键:并发 /start 只能成一个。
|
|
|
|
**发币**幂等键是 play_token,落地方式是 `status='playing' → 'granted'` 的**条件更新**:
|
|
同一次播放重复上报只入账一次(网络重试 / 关闭与播完同时触发都靠它挡住)。
|
|
中途关闭也照发 —— 产品拍板「中途关闭也算看完」。
|
|
|
|
两处都是直接铸币的路径,改动前先看 `start_play` / `grant_play` 上的并发注释。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func, select, update
|
|
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)
|
|
|
|
def _miss(used_now: int) -> dict[str, Any]:
|
|
return {
|
|
"should_play": False,
|
|
"video_url": None,
|
|
"play_token": "",
|
|
"reward_coin": reward_coin,
|
|
"seq": used_now,
|
|
"remaining": max(0, max_plays - used_now),
|
|
}
|
|
|
|
if not cfg.get("enabled") or not video_url or max_plays <= 0 or used >= max_plays:
|
|
return _miss(used)
|
|
|
|
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)
|
|
# 上面的 COUNT 判定是无锁 check-then-insert:并发 /start 会都算出同一个 seq。
|
|
# (user_id, seq) 唯一键让只有一个能落库,其余撞键 → 回滚后按"这次不放视频"降级,
|
|
# 客户端照旧走广告链路。没有它,并发就能绕过 max_plays 无限刷金币。
|
|
try:
|
|
if commit:
|
|
db.commit()
|
|
else:
|
|
db.flush()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
return _miss(used_plays(db, user_id))
|
|
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 _find_play(db: Session, user_id: int, token: str) -> GuideVideoPlay | None:
|
|
"""按 (play_token, user_id) 取播放行 —— 带 user_id 是防拿别人的 token 来兑。"""
|
|
return db.execute(
|
|
select(GuideVideoPlay).where(
|
|
GuideVideoPlay.play_token == token,
|
|
GuideVideoPlay.user_id == user_id,
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
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()
|
|
# 金币额度以**服务端配置**为准,不信客户端(客户端只上报"播完/关闭")。
|
|
coin = int(get_config(db).get("reward_coin") or 0)
|
|
|
|
# 幂等核心:把 status 放进 WHERE 做条件更新(compare-and-set),而不是"先读再判再写"。
|
|
# 「播完」与「✕ 关闭」抢跑、或客户端超时重试时,两个请求会都读到 status='playing',
|
|
# 无锁的话就都往下发币、都 commit,金币入账两次(不用恶意,重试就会中招)。改成条件更新后
|
|
# 并发里只有一条 rowcount=1,另一条拿 0 → 按已发返回,不二次铸币。
|
|
# (PG READ COMMITTED 下后到的 UPDATE 阻塞到对手提交,再按新版本重判 status;SQLite 写串行。)
|
|
#
|
|
# 别指望 IntegrityError 兜底:这里只 UPDATE 不 INSERT,撞不到 uq_guide_video_play_token;
|
|
# 而 biz_type='guide_video' 的金币流水也不在 ux_coin_transaction_task_ref 的谓词
|
|
# (biz_type LIKE 'task%')覆盖范围内 —— 两个唯一键在这条路径上都是不生效的。
|
|
won = db.execute(
|
|
update(GuideVideoPlay)
|
|
.where(
|
|
GuideVideoPlay.play_token == token,
|
|
GuideVideoPlay.user_id == user_id,
|
|
GuideVideoPlay.status == "playing",
|
|
)
|
|
.values(
|
|
status="granted",
|
|
coin=coin,
|
|
completed=1 if completed else 0,
|
|
granted_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
|
|
)
|
|
.execution_options(synchronize_session=False)
|
|
).rowcount
|
|
|
|
if not won:
|
|
# 没抢到:token 不存在 / 不是本人的 / 已被另一次上报发过。回滚拿干净快照再区分两者
|
|
# (对手此时必然已提交 —— 我们就是被它挡下的,所以读得到它写的 coin)。
|
|
db.rollback()
|
|
play = _find_play(db, user_id, token)
|
|
if play is None:
|
|
return {"granted": False, "coin": 0, "status": "not_found"}
|
|
return {"granted": False, "coin": play.coin, "status": "already_granted"}
|
|
|
|
if coin > 0:
|
|
crud_wallet.grant_coins(
|
|
db,
|
|
user_id,
|
|
coin,
|
|
biz_type=BIZ_TYPE,
|
|
ref_id=token,
|
|
remark="新手引导视频奖励",
|
|
)
|
|
db.commit()
|
|
return {"granted": True, "coin": coin, "status": "granted"}
|