diff --git a/alembic/versions/guide_video_scene_unique.py b/alembic/versions/guide_video_scene_unique.py new file mode 100644 index 0000000..62cb126 --- /dev/null +++ b/alembic/versions/guide_video_scene_unique.py @@ -0,0 +1,31 @@ +"""guide video play count is independent for coupon and comparison + +Revision ID: guide_video_scene_unique +Revises: 6d2309208549 +""" +from alembic import op + +revision = "guide_video_scene_unique" +down_revision = "6d2309208549" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_index("uq_guide_video_play_user_seq", table_name="guide_video_play") + op.create_index( + "uq_guide_video_play_user_scene_seq", + "guide_video_play", + ["user_id", "scene", "seq"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("uq_guide_video_play_user_scene_seq", table_name="guide_video_play") + op.create_index( + "uq_guide_video_play_user_seq", + "guide_video_play", + ["user_id", "seq"], + unique=True, + ) diff --git a/app/admin/routers/guide_video.py b/app/admin/routers/guide_video.py index 59f75ae..86c2416 100644 --- a/app/admin/routers/guide_video.py +++ b/app/admin/routers/guide_video.py @@ -9,7 +9,7 @@ client_max_body_size,见 shaguabijia-admin-web/deploy/nginx/admin.shaguabijia.co """ from __future__ import annotations -from typing import Annotated +from typing import Annotated, Literal from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile @@ -27,14 +27,21 @@ router = APIRouter( ) -def _out(db: AdminDb) -> GuideVideoConfigOut: +GuideScene = Literal["coupon", "comparison"] + + +def _out(db: AdminDb, scene: GuideScene) -> GuideVideoConfigOut: """配置 + 播放统计合成响应(四个写接口都以最新状态返回,前端一次同步到位)。""" - return GuideVideoConfigOut(**guide_video.get_config(db), **guide_video.play_stats(db)) + return GuideVideoConfigOut( + scene=scene, + **guide_video.get_config(db, scene), + **guide_video.play_stats(db, scene), + ) @router.get("", response_model=GuideVideoConfigOut, summary="新手引导视频配置(领券浮层)") -def get_config(db: AdminDb) -> GuideVideoConfigOut: - return _out(db) +def get_config(db: AdminDb, scene: GuideScene = "coupon") -> GuideVideoConfigOut: + return _out(db, scene) @router.patch("", response_model=GuideVideoConfigOut, summary="改开关/次数/金币(带审计)") @@ -43,21 +50,23 @@ def update_config( request: Request, admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, + scene: GuideScene = "coupon", ) -> GuideVideoConfigOut: before, after = guide_video.update_config( db, enabled=body.enabled, max_plays=body.max_plays, reward_coin=body.reward_coin, + scene=scene, admin_id=admin.id, commit=False, ) write_audit( db, admin, action="guide_video.update", target_type="guide_video", target_id=None, - detail={"before": before, "after": after}, ip=get_client_ip(request), commit=False, + detail={"scene": scene, "before": before, "after": after}, ip=get_client_ip(request), commit=False, ) db.commit() - return _out(db) + return _out(db, scene) @router.post("/video", response_model=GuideVideoConfigOut, summary="上传新手引导视频(MP4,带审计)") @@ -65,23 +74,26 @@ async def upload_video( request: Request, admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, - file: UploadFile = File(...), + file: Annotated[UploadFile, File()], + scene: GuideScene = "coupon", ) -> GuideVideoConfigOut: data = await file.read() try: url = media.save_guide_video(data) except media.MediaError as e: raise HTTPException(status_code=400, detail=str(e)) from e - before, after = guide_video.set_video(db, url, admin_id=admin.id, commit=False) + before, after = guide_video.set_video( + db, url, scene=scene, admin_id=admin.id, commit=False + ) write_audit( db, admin, action="guide_video.set_video", target_type="guide_video", target_id=None, - detail={"before": before.get("video_url"), "after": url, "bytes": len(data)}, + detail={"scene": scene, "before": before.get("video_url"), "after": url, "bytes": len(data)}, ip=get_client_ip(request), commit=False, ) db.commit() # 提交成功后再删旧片,避免新片没落库就把旧片丢了 media.delete_guide_video(before.get("video_url")) - return _out(db) + return _out(db, scene) @router.delete("/video", response_model=GuideVideoConfigOut, summary="移除新手引导视频(带审计)") @@ -89,13 +101,16 @@ def delete_video( request: Request, admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, + scene: GuideScene = "coupon", ) -> GuideVideoConfigOut: """移除后 /guide-video/start 一律返回 should_play=false,领券浮层回到「只放广告」。""" - before, after = guide_video.set_video(db, None, admin_id=admin.id, commit=False) + before, after = guide_video.set_video( + db, None, scene=scene, admin_id=admin.id, commit=False + ) write_audit( db, admin, action="guide_video.delete_video", target_type="guide_video", target_id=None, - detail={"before": before.get("video_url")}, ip=get_client_ip(request), commit=False, + detail={"scene": scene, "before": before.get("video_url")}, ip=get_client_ip(request), commit=False, ) db.commit() media.delete_guide_video(before.get("video_url")) - return _out(db) + return _out(db, scene) diff --git a/app/admin/schemas/guide_video.py b/app/admin/schemas/guide_video.py index 721f0a6..039084f 100644 --- a/app/admin/schemas/guide_video.py +++ b/app/admin/schemas/guide_video.py @@ -7,6 +7,7 @@ from app.repositories.guide_video import MAX_PLAYS_LIMIT, REWARD_COIN_LIMIT class GuideVideoConfigOut(BaseModel): + scene: str enabled: bool video_url: str | None = None # 相对地址 /media/guide_video/xxx.mp4;未配片 = None max_plays: int diff --git a/app/models/guide_video.py b/app/models/guide_video.py index 4ecd2a5..301261b 100644 --- a/app/models/guide_video.py +++ b/app/models/guide_video.py @@ -38,7 +38,7 @@ class GuideVideoPlay(Base): # start_play 捕获 IntegrityError 降级成"这次不放视频"。 # 用 unique Index 而非 UniqueConstraint:与迁移里的 create_index 对齐(SQLite 加约束 # 要整表重建),autogenerate 才不会每次报一条假 diff。 - Index("uq_guide_video_play_user_seq", "user_id", "seq", unique=True), + Index("uq_guide_video_play_user_scene_seq", "user_id", "scene", "seq", unique=True), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) diff --git a/app/repositories/guide_video.py b/app/repositories/guide_video.py index cd30f90..4c5e1f9 100644 --- a/app/repositories/guide_video.py +++ b/app/repositories/guide_video.py @@ -30,7 +30,11 @@ 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" +SCENES = ("coupon", "comparison") +_KEY_BY_SCENE = { + "coupon": "coupon_guide_video", + "comparison": "comparison_guide_video", +} #: 金币流水 biz_type。客户端收益明细按它显示「新手引导视频奖励」。 BIZ_TYPE = "guide_video" @@ -41,7 +45,7 @@ _DEFAULTS: dict[str, Any] = { "enabled": True, "video_url": None, # None/空 = 未配片 → 不下发,浮层照旧放广告 "max_plays": 3, # 每个账号前 N 次浮层放引导视频 - "reward_coin": 120, # 每次固定金币 + "reward_coin": 100, # 每次固定金币 } _FIELDS = tuple(_DEFAULTS.keys()) @@ -65,19 +69,28 @@ def _merge(raw: Any) -> dict[str, Any]: return out -def get_config(db: Session) -> dict[str, Any]: +def _config_key(scene: str) -> str: + if scene not in _KEY_BY_SCENE: + raise ValueError(f"unsupported guide video scene: {scene}") + return _KEY_BY_SCENE[scene] + + +def get_config(db: Session, scene: str = "coupon") -> dict[str, Any]: """完整配置 + updated_at(admin 读 / 业务读共用)。""" - row = db.get(AppConfig, _KEY) + row = db.get(AppConfig, _config_key(scene)) 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]: +def _write( + db: Session, value: dict[str, Any], *, scene: str, admin_id: int, commit: bool +) -> dict[str, Any]: """整体覆写该行(value 须为完整字段 dict),返回合并后的完整配置(含 updated_at)。""" - row = db.get(AppConfig, _KEY) + key = _config_key(scene) + row = db.get(AppConfig, key) if row is None: - row = AppConfig(key=_KEY, value=value, updated_by_admin_id=admin_id) + row = AppConfig(key=key, value=value, updated_by_admin_id=admin_id) db.add(row) else: row.value = value # 整体重新赋值,SQLAlchemy 才侦测得到变更 @@ -98,11 +111,12 @@ def update_config( enabled: bool | None = None, max_plays: int | None = None, reward_coin: int | None = None, + scene: str = "coupon", admin_id: int, commit: bool = True, ) -> tuple[dict[str, Any], dict[str, Any]]: """改开关 / 次数 / 金币(只改传了的字段;视频走 set_video)。返回 (before, after) 供审计。""" - row = db.get(AppConfig, _KEY) + row = db.get(AppConfig, _config_key(scene)) 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: @@ -111,45 +125,52 @@ def update_config( 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) + after = _write(db, new_value, scene=scene, admin_id=admin_id, commit=commit) return before, after def set_video( - db: Session, video_url: str | None, *, admin_id: int, commit: bool = True + db: Session, video_url: str | None, *, scene: str = "coupon", + admin_id: int, commit: bool = True ) -> tuple[dict[str, Any], dict[str, Any]]: """设置/清空引导视频地址。返回 (before, after);before['video_url'] 供调用方删旧文件。""" - row = db.get(AppConfig, _KEY) + row = db.get(AppConfig, _config_key(scene)) 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) + after = _write(db, new_value, scene=scene, admin_id=admin_id, commit=commit) return before, after # ===== 播放计次 ===== -def used_plays(db: Session, user_id: int) -> int: +def used_plays(db: Session, user_id: int, scene: str = "coupon") -> int: """该账号已用掉的引导视频次数(开播即算,含未发币的)。""" return int( db.execute( select(func.count()).select_from(GuideVideoPlay).where( - GuideVideoPlay.user_id == user_id + GuideVideoPlay.user_id == user_id, + GuideVideoPlay.scene == scene, ) ).scalar_one() ) -def play_stats(db: Session) -> dict[str, int]: +def play_stats(db: Session, scene: str = "coupon") -> dict[str, int]: """全站播放统计(admin 页展示):总播放次数 / 其中已发币次数。""" total = int( - db.execute(select(func.count()).select_from(GuideVideoPlay)).scalar_one() + db.execute( + select(func.count()).select_from(GuideVideoPlay).where( + GuideVideoPlay.scene == scene + ) + ).scalar_one() ) granted = int( db.execute( select(func.count()).select_from(GuideVideoPlay).where( - GuideVideoPlay.status == "granted" + GuideVideoPlay.status == "granted", + GuideVideoPlay.scene == scene, ) ).scalar_one() ) @@ -168,11 +189,12 @@ def start_play( reward_coin 播完/中途关闭都发的固定金币 seq / remaining 第几次 / 发完这次还剩几次(仅展示与排查用) """ - cfg = get_config(db) + _config_key(scene) + cfg = get_config(db, scene) 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) + used = used_plays(db, user_id, scene) def _miss(used_now: int) -> dict[str, Any]: return { @@ -194,7 +216,7 @@ def start_play( scene=scene, seq=seq, video_url=video_url, - coin=0, + coin=reward_coin, status="playing", completed=0, started_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), @@ -210,7 +232,7 @@ def start_play( db.flush() except IntegrityError: db.rollback() - return _miss(used_plays(db, user_id)) + return _miss(used_plays(db, user_id, scene)) return { "should_play": True, "video_url": video_url, @@ -241,7 +263,8 @@ def grant_play( """ token = (play_token or "").strip() # 金币额度以**服务端配置**为准,不信客户端(客户端只上报"播完/关闭")。 - coin = int(get_config(db).get("reward_coin") or 0) + play = _find_play(db, user_id, token) + coin = int(play.coin if play is not None else 0) # 幂等核心:把 status 放进 WHERE 做条件更新(compare-and-set),而不是"先读再判再写"。 # 「播完」与「✕ 关闭」抢跑、或客户端超时重试时,两个请求会都读到 status='playing', diff --git a/app/schemas/guide_video.py b/app/schemas/guide_video.py index b2e80fd..c0b2ee6 100644 --- a/app/schemas/guide_video.py +++ b/app/schemas/guide_video.py @@ -1,13 +1,15 @@ """新手引导视频(领券等候浮层前 N 次替代广告)的客户端请求/响应契约。""" from __future__ import annotations +from typing import Literal + from pydantic import BaseModel, Field class GuideVideoStartIn(BaseModel): """开播询问。scene 目前只有 coupon(领券浮层);预留给日后比价等场景。""" - scene: str = Field(default="coupon", max_length=16) + scene: Literal["coupon", "comparison"] = "coupon" class GuideVideoStartOut(BaseModel): diff --git a/tests/test_guide_video.py b/tests/test_guide_video.py index dfec688..7926506 100644 --- a/tests/test_guide_video.py +++ b/tests/test_guide_video.py @@ -201,7 +201,7 @@ def test_start_loses_seq_race_degrades_to_ad(client, guide_configured, monkeypat assert first["should_play"] is True and first["seq"] == 1 # 本次请求读到的是过期计数 → 仍会算出 seq=1 - monkeypatch.setattr(crud_guide, "used_plays", lambda db, user_id: 0) + monkeypatch.setattr(crud_guide, "used_plays", lambda db, user_id, scene="coupon": 0) with SessionLocal() as db: result = crud_guide.start_play(db, uid)