修复:引导视频并发刷金币 —— (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:
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user