修复:引导视频并发刷金币 —— (user_id,seq) 唯一键 + 发币条件更新幂等

start_play 无锁 check-then-insert,并发 /start 会算出同一个 seq、各拿一个
play_token 绕过次数上限刷金币;加 (user_id, seq) 唯一键,撞键即降级为不放视频。
grant_play 改成 status='playing'→'granted' 条件更新(compare-and-set),
播完与关闭抢跑/重试只会命中一条,不再二次铸币。含迁移与测试。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
左辰勇
2026-07-24 11:50:34 +08:00
parent fee43b7398
commit 71135c4e3a
4 changed files with 393 additions and 46 deletions
+14 -3
View File
@@ -8,7 +8,10 @@
- **开播即计数**:客户端每次要展示浮层时调 `/api/v1/guide-video/start`,服务端当场
写一行(status='playing')并返回 play_token;`COUNT(*)` 即已用次数。用户中途 kill
App 也算用掉一次(产品选定口径,防反复进出刷金币)。
- **发币幂等**靠 play_token 唯一键:同一次播放重复上报只一次
- **发币幂等**靠 play_token 定位 + `status='playing'` 条件更新:并发两次上报只一次
改到行(另一次 rowcount=0),所以只发一次币。光有 play_token 唯一键挡不住 —— 发币走的是
UPDATE,不 INSERT,撞不到任何唯一键。
- **次数上限**靠 (user_id, seq) 唯一键兜底,防并发 /start 绕过 COUNT 判定(见下)。
与广告收益(ad_feed_reward_record)彻底分离:引导视频不是广告,不该进广告收益报表。
"""
@@ -16,7 +19,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -29,6 +32,13 @@ class GuideVideoPlay(Base):
__table_args__ = (
# 客户端幂等键:同一次播放重复上报奖励只发一次。
UniqueConstraint("play_token", name="uq_guide_video_play_token"),
# 次数上限的**硬约束**:start_play 是无锁 check-then-insert(读 COUNT 算 seq 再插),
# N 个并发 /start 会都读到同一个已用次数、算出同一个 seq,不拦就能各拿一个 token、
# 各发一次金币,3 次上限形同虚设(改包即可无限刷)。seq 唯一 → 并发同 seq 必撞,
# start_play 捕获 IntegrityError 降级成"这次不放视频"。
# 用 unique Index 而非 UniqueConstraint:与迁移里的 create_index 对齐(SQLite 加约束
# 要整表重建),autogenerate 才不会每次报一条假 diff。
Index("uq_guide_video_play_user_seq", "user_id", "seq", unique=True),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -39,7 +49,8 @@ class GuideVideoPlay(Base):
play_token: Mapped[str] = mapped_column(String(64), nullable=False)
# 触发场景:目前只有 coupon(领券等候浮层);留字段以便日后比价等场景复用。
scene: Mapped[str] = mapped_column(String(16), nullable=False, default="coupon")
# 本账号第几次(1-based),= 建行时已有行数 + 1。仅留痕/排查用,判定仍以 COUNT 为准
# 本账号第几次(1-based),= 建行时已有行数 + 1。日常判定仍以 COUNT 为准,但 (user_id, seq)
# 唯一键让并发 /start 只能成一个 —— 见 __table_args__。
seq: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# 当次下发的视频地址(运营换片后能回溯用户当时看的是哪支)。
video_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
+76 -43
View File
@@ -7,9 +7,13 @@
**计次**按账号(user_id)、**开播即计数**:客户端每次要展示领券等候浮层时调
`/api/v1/guide-video/start`,命中则当场写一行 guide_video_play(status='playing')。
已用次数 = 该账号的行数,达到 max_plays 后不再下发,客户端改放广告(原逻辑)。
COUNT 判定本身无锁,真正卡住次数上限的是 (user_id, seq) 唯一键:并发 /start 只能成一个。
**发币**幂等键是 play_token:同一次播放重复上报只入账一次(网络重试 / 关闭与播完同时触发
都靠它挡住)。中途关闭也照发 —— 产品拍板「中途关闭也算看完」
**发币**幂等键是 play_token,落地方式是 `status='playing''granted'` 的**条件更新**:
同一次播放重复上报只入账一次(网络重试 / 关闭与播完同时触发都靠它挡住)。
中途关闭也照发 —— 产品拍板「中途关闭也算看完」。
两处都是直接铸币的路径,改动前先看 `start_play` / `grant_play` 上的并发注释。
"""
from __future__ import annotations
@@ -17,7 +21,7 @@ import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import func, select
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -169,16 +173,19 @@ def start_play(
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),
}
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
return _miss(used)
seq = used + 1
play = GuideVideoPlay(
@@ -193,10 +200,17 @@ def start_play(
started_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
)
db.add(play)
if commit:
db.commit()
else:
db.flush()
# 上面的 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,
@@ -207,6 +221,16 @@ def start_play(
}
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]:
@@ -216,23 +240,43 @@ def grant_play(
重复上报返回 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)
# 幂等核心:把 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,
@@ -242,16 +286,5 @@ def grant_play(
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"}
db.commit()
return {"granted": True, "coin": coin, "status": "granted"}