diff --git a/alembic/versions/guide_video_ten_circle_v2.py b/alembic/versions/guide_video_ten_circle_v2.py new file mode 100644 index 0000000..56520a7 --- /dev/null +++ b/alembic/versions/guide_video_ten_circle_v2.py @@ -0,0 +1,164 @@ +"""guide video prepare/start/ten-circle reward state machine + +Revision ID: guide_video_ten_circle_v2 +Revises: guide_video_scene_unique +""" +from __future__ import annotations + +import json +from pathlib import Path + +import sqlalchemy as sa + +from alembic import op + +revision = "guide_video_ten_circle_v2" +down_revision = "guide_video_scene_unique" +branch_labels = None +depends_on = None + + +def _backfill_configs(connection) -> None: + from app.core import media + from app.core.config import settings + + for key in ("coupon_guide_video", "comparison_guide_video"): + row = connection.execute( + sa.text("SELECT value FROM app_config WHERE key = :key"), {"key": key} + ).first() + if not row: + continue + raw = row[0] + value = json.loads(raw) if isinstance(raw, str) else dict(raw or {}) + if value.get("guide_video_v2_migrated") is True: + continue + maximum = int(value.get("max_plays", 3) or 0) + reward = int(value.get("reward_coin", 100) or 0) + video_url = str(value.get("video_url") or "") + error = None + analysis = None + if not 1 <= maximum <= 50: + error = "旧播放次数不符合 1~50 约束" + elif not 10 <= reward <= 10_000 or reward % 10: + error = "旧金币总价不符合 10~10000 且为 10 的倍数约束" + elif not video_url: + error = "尚未上传视频" + else: + prefix = f"{settings.MEDIA_URL_PREFIX}/guide_video/" + if not video_url.startswith(prefix): + error = "旧视频不是服务端托管文件,请重新上传" + else: + path = Path(settings.MEDIA_ROOT) / "guide_video" / video_url[len(prefix):] + if not path.is_file(): + error = "旧视频文件缺失,请重新上传" + else: + try: + analysis = media._probe_guide_video(path) + except media.MediaError as exc: + error = str(exc) + value["config_version"] = int(value.get("config_version", 0) or 0) + 1 + value["guide_video_v2_migrated"] = True + if analysis: + value.update(analysis) + else: + value.update( + enabled=False, + duration_ms=None, + video_codec=None, + audio_codec=None, + analysis_status="invalid" if video_url else "missing", + analysis_error=error, + ) + statement = sa.text( + "UPDATE app_config SET value = :value WHERE key = :key" + ).bindparams(sa.bindparam("value", type_=sa.JSON())) + connection.execute(statement, {"key": key, "value": value}) + + +def _seal_legacy_plays(connection) -> None: + """旧整笔发奖 token 永久封口;旧未发奖会话终止但仍保留计次行。""" + connection.execute( + sa.text( + """ + UPDATE guide_video_play + SET status = CASE WHEN status = 'granted' THEN 'legacy_completed' ELSE 'legacy_closed' END, + settled_circles = CASE WHEN status = 'granted' THEN 10 ELSE 0 END, + prepared_at = started_at, + expires_at = started_at + WHERE status IN ('granted', 'playing') + """ + ) + ) + + +def _prepare_downgrade(connection) -> None: + """清除未起播计划,并把所有保留 token 封成旧版不可再领奖的 granted。""" + connection.execute( + sa.text("DELETE FROM guide_video_play WHERE status = 'prepared'") + ) + connection.execute( + sa.text( + """ + UPDATE guide_video_play + SET status = 'granted', + completed = CASE WHEN settled_circles >= 10 THEN 1 ELSE completed END, + granted_at = CASE + WHEN settled_circles >= 10 AND granted_at IS NULL THEN started_at + ELSE granted_at + END + WHERE status IN ( + 'started', 'completed', 'legacy_completed', 'legacy_closed' + ) + """ + ) + ) + # 防御未知/中间状态:宁可删除,也绝不让旧应用把它当 playing 整笔发奖。 + connection.execute( + sa.text( + """ + DELETE FROM guide_video_play + WHERE seq IS NULL OR started_at IS NULL OR status != 'granted' + """ + ) + ) + + +def upgrade() -> None: + with op.batch_alter_table("guide_video_play") as batch: + batch.alter_column("seq", existing_type=sa.Integer(), nullable=True) + batch.alter_column("started_at", existing_type=sa.DateTime(timezone=True), nullable=True) + batch.add_column(sa.Column("duration_ms", sa.Integer(), nullable=False, server_default="0")) + batch.add_column(sa.Column("config_version", sa.Integer(), nullable=False, server_default="0")) + batch.add_column(sa.Column("settled_circles", sa.Integer(), nullable=False, server_default="0")) + batch.add_column( + sa.Column("prepared_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()) + ) + batch.add_column(sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True)) + batch.alter_column("status", existing_type=sa.String(length=16), type_=sa.String(length=24)) + op.create_index("ix_guide_video_play_expires_at", "guide_video_play", ["expires_at"]) + op.create_index( + "ux_coin_transaction_guide_video_ref", + "coin_transaction", + ["user_id", "biz_type", "ref_id"], + unique=True, + sqlite_where=sa.text("biz_type = 'guide_video' AND ref_id IS NOT NULL"), + postgresql_where=sa.text("biz_type = 'guide_video' AND ref_id IS NOT NULL"), + ) + connection = op.get_bind() + _seal_legacy_plays(connection) + _backfill_configs(connection) + + +def downgrade() -> None: + _prepare_downgrade(op.get_bind()) + op.drop_index("ux_coin_transaction_guide_video_ref", table_name="coin_transaction") + op.drop_index("ix_guide_video_play_expires_at", table_name="guide_video_play") + with op.batch_alter_table("guide_video_play") as batch: + batch.alter_column("status", existing_type=sa.String(length=24), type_=sa.String(length=16)) + batch.drop_column("expires_at") + batch.drop_column("prepared_at") + batch.drop_column("settled_circles") + batch.drop_column("config_version") + batch.drop_column("duration_ms") + batch.alter_column("started_at", existing_type=sa.DateTime(timezone=True), nullable=False) + batch.alter_column("seq", existing_type=sa.Integer(), nullable=False) diff --git a/app/admin/main.py b/app/admin/main.py index ec64c34..18ff70c 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -1,6 +1,6 @@ """Admin 后台 FastAPI app(独立进程)。 -启动:uvicorn app.admin.main:admin_app --host 127.0.0.1 --port 8771 +启动:uvicorn app.admin.main:admin_app --host 127.0.0.1 --port 8773 复用 App 的 DB/models/repositories/integrations;鉴权独立(admin JWT,见 app/admin/security.py)。 现有 app.main:app 不 import 本模块,两进程互不影响。 """ @@ -71,8 +71,8 @@ admin_app = FastAPI( # admin 前端独立部署。生产同域(nginx)无需 CORS;本地 next dev 跨域需放行开发源。 _dev_origins = [ - "http://localhost:3001", - "http://127.0.0.1:3001", + "http://localhost:3002", + "http://127.0.0.1:3002", "http://localhost:3000", "http://127.0.0.1:3000", ] diff --git a/app/admin/routers/guide_video.py b/app/admin/routers/guide_video.py index 86c2416..8259537 100644 --- a/app/admin/routers/guide_video.py +++ b/app/admin/routers/guide_video.py @@ -31,12 +31,7 @@ GuideScene = Literal["coupon", "comparison"] def _out(db: AdminDb, scene: GuideScene) -> GuideVideoConfigOut: - """配置 + 播放统计合成响应(四个写接口都以最新状态返回,前端一次同步到位)。""" - return GuideVideoConfigOut( - scene=scene, - **guide_video.get_config(db, scene), - **guide_video.play_stats(db, scene), - ) + return GuideVideoConfigOut(scene=scene, **guide_video.get_config(db, scene)) @router.get("", response_model=GuideVideoConfigOut, summary="新手引导视频配置(领券浮层)") @@ -52,15 +47,18 @@ def update_config( 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, - ) + try: + 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, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc write_audit( db, admin, action="guide_video.update", target_type="guide_video", target_id=None, detail={"scene": scene, "before": before, "after": after}, ip=get_client_ip(request), commit=False, @@ -78,21 +76,35 @@ async def upload_video( scene: GuideScene = "coupon", ) -> GuideVideoConfigOut: data = await file.read() + url: str | None = None try: - url = media.save_guide_video(data) + url, analysis = 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, 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={"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")) + try: + before, after = guide_video.set_video( + db, url, analysis=analysis, 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={ + "scene": scene, + "before": before.get("video_url"), + "after": url, + "bytes": len(data), + "duration_ms": analysis["duration_ms"], + "video_codec": analysis["video_codec"], + "audio_codec": analysis["audio_codec"], + }, + ip=get_client_ip(request), commit=False, + ) + db.commit() + except Exception: + db.rollback() + media.delete_guide_video(url) + raise + # 旧文件仍可能被 prepare/已起播快照引用,不能在这里立即删除。 + guide_video.cleanup_old_videos(db) return _out(db, scene) @@ -112,5 +124,6 @@ def delete_video( 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")) + # 旧文件由保护期清理任务处理,避免破坏有效计划与已起播快照。 + guide_video.cleanup_old_videos(db) return _out(db, scene) diff --git a/app/admin/schemas/guide_video.py b/app/admin/schemas/guide_video.py index 039084f..e80aadb 100644 --- a/app/admin/schemas/guide_video.py +++ b/app/admin/schemas/guide_video.py @@ -1,26 +1,44 @@ -"""admin 新手引导视频配置 schemas(开关 / 视频地址 / 前几次 / 每次金币)。""" +"""后台引导视频配置协议。""" from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator -from app.repositories.guide_video import MAX_PLAYS_LIMIT, REWARD_COIN_LIMIT +from app.repositories.guide_video import ( + MAX_PLAYS_LIMIT, + MIN_PLAYS, + MIN_REWARD_COIN, + REWARD_COIN_LIMIT, +) class GuideVideoConfigOut(BaseModel): scene: str enabled: bool - video_url: str | None = None # 相对地址 /media/guide_video/xxx.mp4;未配片 = None + video_url: str | None = None max_plays: int reward_coin: int + duration_ms: int | None = None + circle_count: int = 10 + circle_duration_ms: float | None = None + reward_per_circle: int + video_codec: str | None = None + audio_codec: str | None = None + analysis_status: str + analysis_error: str | None = None + config_version: int updated_at: str | None = None - # 只读统计,后台展示用:已有多少次播放、其中已发币多少次。 - total_plays: int = 0 - granted_plays: int = 0 class GuideVideoConfigUpdate(BaseModel): - """部分更新:只改传入(非 None)字段。视频文件走 /video 上传接口。""" - enabled: bool | None = None - max_plays: int | None = Field(default=None, ge=0, le=MAX_PLAYS_LIMIT) - reward_coin: int | None = Field(default=None, ge=0, le=REWARD_COIN_LIMIT) + max_plays: int | None = Field(default=None, ge=MIN_PLAYS, le=MAX_PLAYS_LIMIT) + reward_coin: int | None = Field( + default=None, ge=MIN_REWARD_COIN, le=REWARD_COIN_LIMIT + ) + + @field_validator("reward_coin") + @classmethod + def reward_must_be_multiple_of_ten(cls, value: int | None) -> int | None: + if value is not None and value % 10: + raise ValueError("金币总价必须是 10 的倍数") + return value diff --git a/app/api/v1/guide_video.py b/app/api/v1/guide_video.py index c38251b..7d7e214 100644 --- a/app/api/v1/guide_video.py +++ b/app/api/v1/guide_video.py @@ -1,22 +1,16 @@ -"""新手引导视频(领券等候浮层前 N 次替代广告)。 - -路由前缀 `/api/v1/guide-video`(均需 Bearer): - POST /start 这次浮层放引导视频还是放广告?命中则**当场计次**并下发 play_token - POST /reward 播完 / 中途关闭都调,按 play_token 幂等发固定金币 - -发币额度以**服务端配置**为准(运营后台可改),客户端只报"播完/关闭",报不了金额, -所以被破解也刷不到超额金币;次数上限由 guide_video_play 行数(按账号)硬卡。 -""" +"""引导视频 prepare/start/reward 客户端 API。""" from __future__ import annotations import logging -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from app.api.deps import CurrentUser, DbSession from app.core.ratelimit import rate_limit from app.repositories import guide_video as crud_guide from app.schemas.guide_video import ( + GuideVideoPrepareIn, + GuideVideoPrepareOut, GuideVideoRewardIn, GuideVideoRewardOut, GuideVideoStartIn, @@ -24,25 +18,41 @@ from app.schemas.guide_video import ( ) logger = logging.getLogger("shagua.guide_video") - router = APIRouter(prefix="/api/v1/guide-video", tags=["guide-video"]) +@router.post( + "/prepare", + response_model=GuideVideoPrepareOut, + dependencies=[Depends(rate_limit(60, 60, "guide-video-prepare"))], +) +def prepare( + payload: GuideVideoPrepareIn, user: CurrentUser, db: DbSession +) -> GuideVideoPrepareOut: + result = crud_guide.prepare_play(db, user.id, scene=payload.scene) + logger.info( + "guide video prepare user_id=%d scene=%s should_play=%s reason=%s", + user.id, payload.scene, result["should_play"], result["reason"], + ) + return GuideVideoPrepareOut(**result) + + @router.post( "/start", response_model=GuideVideoStartOut, - summary="领券浮层是否放新手引导视频(命中即计次)", dependencies=[Depends(rate_limit(60, 60, "guide-video-start"))], ) -def start(payload: GuideVideoStartIn, user: CurrentUser, db: DbSession) -> GuideVideoStartOut: - """开播即计数:返回 should_play=True 时服务端已写下这一次,客户端必须真的播。 - - 没配视频 / 开关关 / 次数用完 → should_play=False,客户端照旧走广告链路(行为不变)。 - """ - result = crud_guide.start_play(db, user.id, scene=payload.scene or "coupon") +def start( + payload: GuideVideoStartIn, user: CurrentUser, db: DbSession +) -> GuideVideoStartOut: + try: + result = crud_guide.start_play(db, user.id, play_token=payload.play_token) + except crud_guide.PlayStateError as exc: + status_code = 404 if exc.code == "play_not_found" else 409 + raise HTTPException(status_code=status_code, detail=exc.detail()) from exc logger.info( - "guide video start user_id=%d scene=%s should_play=%s seq=%d remaining=%d", - user.id, payload.scene, result["should_play"], result["seq"], result["remaining"], + "guide video start user_id=%d token=%s status=%s seq=%d", + user.id, payload.play_token[:12], result["status"], result["seq"], ) return GuideVideoStartOut(**result) @@ -50,15 +60,20 @@ def start(payload: GuideVideoStartIn, user: CurrentUser, db: DbSession) -> Guide @router.post( "/reward", response_model=GuideVideoRewardOut, - summary="引导视频发金币(播完/中途关闭都发,play_token 幂等)", - dependencies=[Depends(rate_limit(60, 60, "guide-video-reward"))], + dependencies=[Depends(rate_limit(120, 60, "guide-video-reward"))], ) -def reward(payload: GuideVideoRewardIn, user: CurrentUser, db: DbSession) -> GuideVideoRewardOut: - result = crud_guide.grant_play( - db, user.id, play_token=payload.play_token, completed=payload.completed +def reward( + payload: GuideVideoRewardIn, user: CurrentUser, db: DbSession +) -> GuideVideoRewardOut: + result = crud_guide.grant_circle( + db, + user.id, + play_token=payload.play_token, + circle=payload.circle, ) logger.info( - "guide video reward user_id=%d token=%s completed=%s granted=%s coin=%d", - user.id, payload.play_token[:12], payload.completed, result["granted"], result["coin"], + "guide video reward user_id=%d token=%s circle=%d status=%s granted=%s", + user.id, payload.play_token[:12], payload.circle, + result["status"], result["granted"], ) return GuideVideoRewardOut(**result) diff --git a/app/core/config.py b/app/core/config.py index e020307..1798d00 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -436,6 +436,7 @@ class Settings(BaseSettings): # 运营后台上传的新手引导视频上限。视频比图片大一个量级,单独一档; # ⚠️ 改大时同步放宽网关 client_max_body_size(实测 QA 4MiB / prod 32MiB),否则 nginx 先挡下。 GUIDE_VIDEO_MAX_BYTES: int = 100 * 1024 * 1024 # 引导视频最大 100MB + FFPROBE_BINARY: str = "ffprobe" # ===== 邀请好友 ===== # 分享落地页(二维码 / 分享链接指向这里;扫码 → 落地页 → 引导浏览器下载 APK)。 diff --git a/app/core/media.py b/app/core/media.py index a6e84e4..597e6d4 100644 --- a/app/core/media.py +++ b/app/core/media.py @@ -10,8 +10,13 @@ """ from __future__ import annotations +import json +import os import secrets +import subprocess +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from pathlib import Path +from typing import Any from app.core.config import settings @@ -81,29 +86,91 @@ def _sniff_video_ext(data: bytes) -> str | None: """按魔数判定视频类型,返回扩展名;非支持类型返回 None。 只认 MP4 家族(ISO BMFF):`....ftyp` 在偏移 4。Android ExoPlayer 与浏览器