修复:引导视频并发刷金币 —— (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
@@ -0,0 +1,40 @@
"""guide_video_play 加 (user_id, seq) 唯一约束:堵住并发 /start 绕过次数上限
start_play 是无锁 check-then-insert(读 COUNT(*) 算 seq=used+1 再插一行),N 个并发
/start 会都读到同一个 used、算出同一个 seq、各插一行拿到各自的 play_token,于是 3 次
上限被绕过、每个 token 都能换 120 金币。加唯一键后并发同 seq 必撞,start_play 捕获
IntegrityError 降级为 should_play=false(客户端照旧放广告)。
用 unique index 而不是 batch_alter_table 加 UniqueConstraint:SQLite 加约束要整表重建,
而 CREATE UNIQUE INDEX 两边都原生支持,回滚也干净。
注:若库里已有并发产生的重复 (user_id, seq),建索引会失败 —— 本功能尚未上线,表通常是空的;
真撞上了先按 seq 去重(留 id 最小的一行,多发的金币按 scripts/reset_guide_video.py 的口径退)。
Revision ID: guide_video_user_seq_uq
Revises: d9c03cc3ea07
Create Date: 2026-07-24 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "guide_video_user_seq_uq"
down_revision: Union[str, Sequence[str], None] = "d9c03cc3ea07"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"uq_guide_video_play_user_seq",
"guide_video_play",
["user_id", "seq"],
unique=True,
)
def downgrade() -> None:
op.drop_index("uq_guide_video_play_user_seq", table_name="guide_video_play")
+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"}
+263
View File
@@ -0,0 +1,263 @@
"""新手引导视频:次数上限 + 发币幂等。
这条链路直接铸币,两处并发缺陷曾经都是真漏洞,所以本文件的重点不是走通 happy path,
而是**并发失败分支**:
- `/start` 并发算出同一个 seq → (user_id, seq) 唯一键挡下,降级为"这次不放视频";
- `/reward` 并发抢跑 → status 条件更新只让一个 rowcount=1,输的那个不得入账。
真并发在 SQLite 测试库里复现不了(读事务会直接把写方锁死,而不是让它读到旧快照),
所以用"把对手已经落库的状态先摆好、再让被测方按旧读数往下走"来模拟,断言的是同一件事:
输的一方必须空手而归,且账不能乱。
"""
from __future__ import annotations
import pytest
from sqlalchemy import func, select
from app.db.session import SessionLocal
from app.models.guide_video import GuideVideoPlay
from app.models.wallet import CoinAccount, CoinTransaction
from app.repositories import guide_video as crud_guide
from app.repositories.user import get_user_by_phone
VIDEO_URL = "/media/guide_video/pytest_guide.mp4"
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _user_id(phone: str) -> int:
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
return user.id
def _coin_balance(user_id: int) -> int:
with SessionLocal() as db:
acc = db.get(CoinAccount, user_id)
return acc.coin_balance if acc else 0
def _guide_txns(user_id: int) -> list[CoinTransaction]:
"""该用户的引导视频金币流水(按 id 升序)。"""
with SessionLocal() as db:
return list(db.execute(
select(CoinTransaction)
.where(
CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == crud_guide.BIZ_TYPE,
)
.order_by(CoinTransaction.id)
).scalars().all())
def _assert_ledger_balanced(user_id: int) -> None:
"""coin_balance 必须恒等于流水总和 —— 双发/丢更新都会先在这里露馅。"""
with SessionLocal() as db:
total = db.execute(
select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
CoinTransaction.user_id == user_id
)
).scalar_one()
acc = db.get(CoinAccount, user_id)
assert acc is not None
assert acc.coin_balance == total, f"余额 {acc.coin_balance} != 流水总和 {total}"
@pytest.fixture()
def guide_configured():
"""给全局配置塞一支片子(默认 max_plays=3 / reward_coin=120),用完还原成未配片。"""
with SessionLocal() as db:
crud_guide.set_video(db, VIDEO_URL, admin_id=1)
cfg = crud_guide.get_config(db)
yield cfg
with SessionLocal() as db:
crud_guide.set_video(db, None, admin_id=1)
# ===== 基本闭环 =====
def test_start_miss_when_no_video_configured(client) -> None:
"""没配片 → should_play=False,客户端照旧放广告(本功能不配视频就等于没上线)。"""
token = _login(client, "13920000001")
r = client.post("/api/v1/guide-video/start", json={"scene": "coupon"}, headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
assert body["should_play"] is False
assert body["play_token"] == ""
def test_start_then_reward_grants_once(client, guide_configured) -> None:
"""开播 → 上报 → 到账 120;重复上报不再加钱,只回已发金额。"""
cfg = guide_configured
phone = "13920000002"
token = _login(client, phone)
uid = _user_id(phone)
r = client.post("/api/v1/guide-video/start", json={"scene": "coupon"}, headers=_auth(token))
body = r.json()
assert body["should_play"] is True
assert body["video_url"] == VIDEO_URL
assert body["seq"] == 1
assert body["reward_coin"] == cfg["reward_coin"]
play_token = body["play_token"]
assert play_token
r = client.post(
"/api/v1/guide-video/reward",
json={"play_token": play_token, "completed": True},
headers=_auth(token),
)
assert r.status_code == 200, r.text
assert r.json() == {"granted": True, "coin": cfg["reward_coin"], "status": "granted"}
assert _coin_balance(uid) == cfg["reward_coin"]
# 重复上报(客户端超时重试 / 播完与 ✕ 都报了一次)
r = client.post(
"/api/v1/guide-video/reward",
json={"play_token": play_token, "completed": False},
headers=_auth(token),
)
assert r.json() == {"granted": False, "coin": cfg["reward_coin"], "status": "already_granted"}
assert _coin_balance(uid) == cfg["reward_coin"], "重复上报不得二次入账"
assert len(_guide_txns(uid)) == 1, "一个 play_token 只能有一条金币流水"
_assert_ledger_balanced(uid)
def test_reward_rejects_unknown_and_others_token(client, guide_configured) -> None:
"""乱填 token / 拿别人的 token 都发不出币(grant 按 user_id + token 双条件定位)。"""
victim = _login(client, "13920000003")
r = client.post("/api/v1/guide-video/start", json={"scene": "coupon"}, headers=_auth(victim))
stolen = r.json()["play_token"]
attacker_phone = "13920000004"
attacker = _login(client, attacker_phone)
attacker_uid = _user_id(attacker_phone)
r = client.post(
"/api/v1/guide-video/reward",
json={"play_token": stolen, "completed": True},
headers=_auth(attacker),
)
assert r.json() == {"granted": False, "coin": 0, "status": "not_found"}
r = client.post(
"/api/v1/guide-video/reward",
json={"play_token": "deadbeef" * 4, "completed": True},
headers=_auth(attacker),
)
assert r.json() == {"granted": False, "coin": 0, "status": "not_found"}
assert _coin_balance(attacker_uid) == 0
def test_start_stops_at_max_plays(client, guide_configured) -> None:
"""开播即计次:用满 max_plays 后不再下发,客户端回到广告链路。"""
max_plays = int(guide_configured["max_plays"])
token = _login(client, "13920000005")
for i in range(1, max_plays + 1):
body = client.post(
"/api/v1/guide-video/start", json={"scene": "coupon"}, headers=_auth(token)
).json()
assert body["should_play"] is True, f"{i} 次应该还能放"
assert body["seq"] == i
assert body["remaining"] == max_plays - i
body = client.post(
"/api/v1/guide-video/start", json={"scene": "coupon"}, headers=_auth(token)
).json()
assert body["should_play"] is False
assert body["remaining"] == 0
# ===== 并发失败分支(回归) =====
def test_start_loses_seq_race_degrades_to_ad(client, guide_configured, monkeypatch) -> None:
"""并发 /start 抢到同一个 seq 时,输的一方降级成"不放视频",而不是多拿一个 token。
模拟:对手已经提交了 seq=1,而本次请求读到的还是旧计数(used=0)—— 这正是无锁
check-then-insert 的race window。没有 (user_id, seq) 唯一键的话,这里会插成第二行、
换回第二个 play_token,3 次上限就能被并发无限绕过(改包即可 N 倍刷币)。
"""
phone = "13920000006"
token = _login(client, phone)
uid = _user_id(phone)
# 对手先落一行 seq=1
first = client.post(
"/api/v1/guide-video/start", json={"scene": "coupon"}, headers=_auth(token)
).json()
assert first["should_play"] is True and first["seq"] == 1
# 本次请求读到的是过期计数 → 仍会算出 seq=1
monkeypatch.setattr(crud_guide, "used_plays", lambda db, user_id: 0)
with SessionLocal() as db:
result = crud_guide.start_play(db, uid)
assert result["should_play"] is False, "撞 seq 唯一键后必须降级,不能再发一个 token"
assert result["play_token"] == ""
monkeypatch.undo()
with SessionLocal() as db:
rows = db.execute(
select(func.count()).select_from(GuideVideoPlay).where(
GuideVideoPlay.user_id == uid
)
).scalar_one()
assert rows == 1, "抢输的那次不该留下播放行"
def test_reward_loses_race_does_not_double_mint(client, guide_configured) -> None:
"""并发 /reward 抢输的一方不得入账 —— 条件更新(status 进 WHERE)的失败分支。
模拟的是真并发的**必要条件**:输的一方在对手提交之前就已经读到 status='playing',
之后带着这个旧认知继续往下走(「播完」与「✕ 关闭」抢跑、或超时重试都会造出这一幕)。
这里靠 Session 的 identity map 固化那次旧读数 —— 后续同一 Session 再查同一行,拿回的
还是这份旧快照,等价于 PG READ COMMITTED 下两个事务都读到 playing。
旧实现在这里会照发第二笔:它先读再判再写,而 `except IntegrityError` 兜底根本不可能
触发 —— 发币走 UPDATE 撞不到 uq_guide_video_play_token,biz_type='guide_video' 的流水
也不在 ux_coin_transaction_task_ref 的谓词(biz_type LIKE 'task%')覆盖内。
"""
coin = int(guide_configured["reward_coin"])
phone = "13920000007"
token = _login(client, phone)
uid = _user_id(phone)
play_token = client.post(
"/api/v1/guide-video/start", json={"scene": "coupon"}, headers=_auth(token)
).json()["play_token"]
db_loser = SessionLocal()
try:
# 输的一方先读到 playing —— 并发下两个请求都会读到它
stale = db_loser.execute(
select(GuideVideoPlay).where(GuideVideoPlay.play_token == play_token)
).scalar_one()
assert stale.status == "playing"
# 对手抢先发币并提交
with SessionLocal() as db_winner:
won = crud_guide.grant_play(db_winner, uid, play_token=play_token, completed=True)
assert won == {"granted": True, "coin": coin, "status": "granted"}
# 输的一方带着旧认知继续:必须空手而归
lost = crud_guide.grant_play(db_loser, uid, play_token=play_token, completed=False)
assert lost == {"granted": False, "coin": coin, "status": "already_granted"}
finally:
db_loser.close()
assert _coin_balance(uid) == coin, "一次播放只能发一次币"
assert len(_guide_txns(uid)) == 1, "一个 play_token 只能有一条金币流水"
_assert_ledger_balanced(uid)