From 599432cd0527552ad9fcd739ec8448590b817fff Mon Sep 17 00:00:00 2001 From: OuYingJun1024 <1034284404@qq.com> Date: Tue, 16 Jun 2026 20:28:10 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(admin/ad):=20=E6=8F=90=E7=8E=B0?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=AF=B9=E8=B4=A6=E5=90=8E=E5=8F=B0=E5=BC=80?= =?UTF-8?q?=E5=85=B3=20+=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=B9=BF=E5=91=8A?= =?UTF-8?q?=20flags=20=E7=AB=AF=E7=82=B9=20+=20=E7=A9=BF=E5=B1=B1=E7=94=B2?= =?UTF-8?q?=E5=A4=9A=20m-key=20=E9=AA=8C=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 自动对账接入 app_config 运行时配置(新增 bool 配置类型):env 部署总闸 + DB 运营日常开关 双层;worker 每轮读 DB 即时生效,健康检查改报「env AND DB」实际生效态 - 新增 GET /api/v1/platform/flags(不鉴权)下发 comparing_ad_enabled 远程 kill-switch, 客户端拉取后缓存;空库回退默认 True - 穿山甲发奖回调支持多激励位 m-key(三命名项 PANGLE_REWARD_SECRET_TEST/_DEDICATED/_PROD + 旧逗号分隔合并去重),verify_callback_sign_any 逐个验签任一通过即受理;向后兼容旧单 key - 补 test_platform / bool config 用例;app_config 文档同步 Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 12 ++++++++-- app/admin/routers/config.py | 3 +++ app/admin/routers/withdraw.py | 16 +++++++++---- app/admin/schemas/config.py | 2 +- app/api/v1/ad.py | 2 +- app/api/v1/platform.py | 18 +++++++++++++- app/core/config.py | 26 +++++++++++++++++--- app/core/config_schema.py | 21 ++++++++++++++++- app/core/withdraw_reconcile_worker.py | 21 +++++++++++++---- app/integrations/pangle.py | 10 ++++++++ app/schemas/platform.py | 6 +++++ docs/database/app_config.md | 4 ++-- tests/test_admin_config.py | 34 +++++++++++++++++++++++++++ tests/test_platform.py | 13 ++++++++++ 14 files changed, 169 insertions(+), 19 deletions(-) create mode 100644 tests/test_platform.py diff --git a/.env.example b/.env.example index 7178490..1f53db8 100644 --- a/.env.example +++ b/.env.example @@ -88,9 +88,17 @@ AUTO_EXCHANGE_ENABLED=true # ===== 穿山甲激励视频(服务端发奖回调)===== # 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。 -# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",用于验签,从后台取到后填这里; -# 配齐并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。 +# 穿山甲"奖励校验密钥"(m-key),验签用,从 GroMore 后台各广告位取到后填这里。 +# 配齐(任一非空)并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。 +# 每个激励位 m-key 不同但共用同一回调 URL → 各位分开一行配(留空的忽略);验签逐个试、任一通过即接受。 PANGLE_CALLBACK_ENABLED=false +# 测试应用 激励位 104099649 +PANGLE_REWARD_SECRET_TEST= +# 测试应用 专属激励位 104127529 +PANGLE_REWARD_SECRET_TEST_DEDICATED= +# 正式应用 激励位 104099389 +PANGLE_REWARD_SECRET_PROD= +# (旧用法,仍兼容:单个或逗号分隔的多个 m-key,会与上面三个合并去重) PANGLE_REWARD_SECRET= # ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖, # 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。 diff --git a/app/admin/routers/config.py b/app/admin/routers/config.py index e581d24..8a65c61 100644 --- a/app/admin/routers/config.py +++ b/app/admin/routers/config.py @@ -43,6 +43,9 @@ def _validate(key: str, value: Any) -> None: for k, v in value.items() ): raise ValueError("需为 {字符串: 整数} 映射") + elif t == "bool": + if not isinstance(value, bool): + raise ValueError("需为布尔值") def _item(db, key: str) -> ConfigItemOut: diff --git a/app/admin/routers/withdraw.py b/app/admin/routers/withdraw.py index 732159f..c6f7ecc 100644 --- a/app/admin/routers/withdraw.py +++ b/app/admin/routers/withdraw.py @@ -34,6 +34,7 @@ from app.admin.schemas.wallet import ( from app.core.config import settings from app.integrations import wxpay from app.models.admin import AdminUser +from app.repositories import app_config from app.repositories import wallet as wallet_repo router = APIRouter( @@ -95,7 +96,7 @@ def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut: summary="提现配置健康检查", dependencies=[Depends(require_role("finance"))], # 暴露密钥路径/配置,限财务+super ) -def withdraw_health_check() -> WxpayHealthCheckOut: +def withdraw_health_check(db: AdminDb) -> WxpayHealthCheckOut: private_path = wxpay._resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH) # noqa: SLF001 public_path = wxpay._resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH) # noqa: SLF001 issues: list[str] = [] @@ -117,8 +118,15 @@ def withdraw_health_check() -> WxpayHealthCheckOut: issues.append("微信支付基础配置不完整") if not settings.WXPAY_AUTH_NOTIFY_URL: issues.append("免确认授权回调地址未配置") - if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED: - issues.append("自动对账未开启") + + # 实际是否自动对账 = env 部署总闸(worker 起没起)AND 运营后台 DB 开关(本轮跑不跑)。 + worker_running = settings.WITHDRAW_AUTO_RECONCILE_ENABLED + daily_on = bool(app_config.get_value(db, "withdraw_auto_reconcile_enabled")) + auto_reconcile_enabled = worker_running and daily_on + if not worker_running: + issues.append("自动对账 worker 未启动(部署侧 env WITHDRAW_AUTO_RECONCILE_ENABLED=false)") + elif not daily_on: + issues.append("自动对账运营开关已关闭(系统配置页可开)") return WxpayHealthCheckOut( ok=not issues, @@ -131,7 +139,7 @@ def withdraw_health_check() -> WxpayHealthCheckOut: public_key_exists=public_path.exists(), public_key_loadable=public_loadable, auth_notify_url_configured=bool(settings.WXPAY_AUTH_NOTIFY_URL), - auto_reconcile_enabled=settings.WITHDRAW_AUTO_RECONCILE_ENABLED, + auto_reconcile_enabled=auto_reconcile_enabled, auto_reconcile_interval_sec=settings.WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC, auto_reconcile_older_than_minutes=settings.WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES, issues=issues, diff --git a/app/admin/schemas/config.py b/app/admin/schemas/config.py index 01b56db..10b694b 100644 --- a/app/admin/schemas/config.py +++ b/app/admin/schemas/config.py @@ -10,7 +10,7 @@ class ConfigItemOut(BaseModel): key: str label: str group: str - type: str # int / int_list / dict_str_int + type: str # int / int_list / dict_str_int / bool help: str | None = None default: Any value: Any diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 9508061..bb05891 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -87,7 +87,7 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: params = dict(request.query_params) - if not pangle.verify_callback_sign(params, settings.PANGLE_REWARD_SECRET): + if not pangle.verify_callback_sign_any(params, settings.pangle_reward_secrets): logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id")) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign") diff --git a/app/api/v1/platform.py b/app/api/v1/platform.py index 5c3c9d1..063a40b 100644 --- a/app/api/v1/platform.py +++ b/app/api/v1/platform.py @@ -2,6 +2,7 @@ 路由前缀 `/api/v1/platform`: GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算。 + GET /flags 客户端运营 feature flag(比价/领券期广告开关等),客户端拉取后缓存。 展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/ops_stat.py。 """ @@ -12,9 +13,15 @@ import logging from fastapi import APIRouter, Query from app.api.deps import DbSession +from app.repositories import app_config from app.repositories import ops_marquee as marquee_crud from app.repositories import ops_stat as crud -from app.schemas.platform import PlatformStatsOut, SavingsFeedItem, SavingsFeedOut +from app.schemas.platform import ( + AppFlagsOut, + PlatformStatsOut, + SavingsFeedItem, + SavingsFeedOut, +) logger = logging.getLogger("shagua.platform") @@ -35,3 +42,12 @@ def stats(db: DbSession) -> PlatformStatsOut: def savings_feed(db: DbSession, limit: int = Query(8, ge=1, le=30)) -> SavingsFeedOut: items = marquee_crud.get_feed(db, limit=limit) return SavingsFeedOut(items=[SavingsFeedItem(**it) for it in items]) + + +@router.get("/flags", response_model=AppFlagsOut, summary="客户端运营 feature flag(不鉴权)") +def flags(db: DbSession) -> AppFlagsOut: + """客户端拉取运营开关并缓存(app 启动 / 每场比价开始时刷新)。不鉴权:开关非敏感, + 且比价无障碍服务取值时未必有登录态。值来自 app_config(admin 可改),空库回退默认。""" + return AppFlagsOut( + comparing_ad_enabled=bool(app_config.get_value(db, "comparing_ad_enabled")), + ) diff --git a/app/core/config.py b/app/core/config.py index a06ebd0..637a977 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -136,8 +136,15 @@ class Settings(BaseSettings): # ===== 穿山甲激励视频(服务端发奖回调)===== # 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。 - # PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。 + # 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自 + # 生成),但共用同一回调 URL → 多个位的 m-key 都要配上。验签时所有非空 m-key 逐个试、任一通过 + # 即接受(见 pangle.verify_callback_sign_any / 下面的 pangle_reward_secrets)。 PANGLE_CALLBACK_ENABLED: bool = False + # 推荐:每个激励位的 m-key 分开一行配,清晰不混淆(留空的忽略)。 + PANGLE_REWARD_SECRET_TEST: str = "" # 测试应用 激励位 104099649 + PANGLE_REWARD_SECRET_TEST_DEDICATED: str = "" # 测试应用 专属激励位 104127529 + PANGLE_REWARD_SECRET_PROD: str = "" # 正式应用 激励位 104099389 + # 旧用法:单个或逗号分隔的多个 m-key,仍兼容(会与上面三个命名项合并去重)。 PANGLE_REWARD_SECRET: str = "" # ⚠️ 仅本地联调:打开后开放 POST /api/v1/ad/test-grant,让(已登录的)客户端在没部署公网、 @@ -145,10 +152,23 @@ class Settings(BaseSettings): # 它让客户端能自助发奖 = 绕过反作弊,**生产必须保持 False**(默认 False;只在本地 .env 设 true)。 AD_REWARD_TEST_GRANT_ENABLED: bool = False + @property + def pangle_reward_secrets(self) -> list[str]: + """汇总所有 m-key 成列表(去空白、去空项、去重保序)。验签时逐个试、任一通过即接受 + (见 pangle.verify_callback_sign_any)。来源可混用:三个命名项 + 旧的逗号分隔 PANGLE_REWARD_SECRET。""" + raw = [ + *self.PANGLE_REWARD_SECRET.split(","), + self.PANGLE_REWARD_SECRET_TEST, + self.PANGLE_REWARD_SECRET_TEST_DEDICATED, + self.PANGLE_REWARD_SECRET_PROD, + ] + cleaned = [s.strip() for s in raw if s and s.strip()] + return list(dict.fromkeys(cleaned)) # 去重保序 + @property def pangle_callback_configured(self) -> bool: - """回调开关打开且验签密钥已配,才接受发奖回调。""" - return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET) + """回调开关打开且至少配了一个验签密钥,才接受发奖回调。""" + return bool(self.PANGLE_CALLBACK_ENABLED and self.pangle_reward_secrets) # ===== Pricebot 上游 (领券/比价业务透传目标) ===== # pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step diff --git a/app/core/config_schema.py b/app/core/config_schema.py index 65d2a9a..5a56c3a 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -11,7 +11,7 @@ from typing import Any from app.core import rewards as r -# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int +# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool CONFIG_DEFS: dict[str, dict[str, Any]] = { "signin_rewards": { "default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位", @@ -65,4 +65,23 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "group": "签到", "type": "int", "help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。", }, + "comparing_ad_enabled": { + "default": True, "label": "比价/领券期信息流广告", + "group": "看广告", "type": "bool", + "help": ( + "开启后,比价进行中 + 领券等候期会在悬浮窗展示穿山甲信息流广告(变现行为);" + "关闭则全程不出广告。客户端按 app 启动 / 每场比价开始时拉取并缓存,故为「最终一致」的" + "远程开关(下一场比价生效),用于出问题时无需发版即可快速止血。debug 包可用本地开关覆盖。" + ), + }, + "withdraw_auto_reconcile_enabled": { + "default": True, "label": "提现自动对账", + "group": "钱包", "type": "bool", + "help": ( + "开启后后台 worker 每隔一段时间自动扫描超时仍「打款中」的提现单并归一化" + "(查微信/退款/撤单)。需部署侧 env WITHDRAW_AUTO_RECONCILE_ENABLED=true 启动 worker " + "进程后此开关才起效;扫描间隔/超时阈值仍由 env 控制。关掉只停自动扫描," + "提现页「批量对账」手动按钮不受影响。" + ), + }, } diff --git a/app/core/withdraw_reconcile_worker.py b/app/core/withdraw_reconcile_worker.py index eac6dd6..6d667f7 100644 --- a/app/core/withdraw_reconcile_worker.py +++ b/app/core/withdraw_reconcile_worker.py @@ -14,8 +14,11 @@ from sqlalchemy.exc import SQLAlchemyError from app.core.config import settings from app.db.session import SessionLocal from app.integrations.wxpay import WxPayNotConfiguredError +from app.repositories import app_config from app.repositories import wallet as wallet_repo +_AUTO_RECONCILE_KEY = "withdraw_auto_reconcile_enabled" + logger = logging.getLogger("shagua.withdraw_reconcile") _LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "withdraw_reconcile.lock" @@ -59,8 +62,15 @@ def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]: _LOCK_PATH.unlink() -def _reconcile_once(older_than_minutes: int) -> dict: +def _reconcile_once(older_than_minutes: int) -> dict | None: + """读运营开关(app_config):关着返回 None(本轮跳过),开着才真扫单。 + + env WITHDRAW_AUTO_RECONCILE_ENABLED 是部署级总闸(决定 worker 起不起); + 这里的 DB 开关是运营级日常开关,后台一改下一轮即生效、跨进程一致、无需重启。 + """ with SessionLocal() as db: + if not app_config.get_value(db, _AUTO_RECONCILE_KEY): + return None return wallet_repo.reconcile_pending_withdraws(db, older_than_minutes=older_than_minutes) @@ -78,16 +88,18 @@ async def _run_loop() -> None: async def _run_locked_loop(interval: int, older_than: int) -> None: logger.info( - "withdraw auto reconcile started interval=%ss older_than=%sm", + "withdraw auto reconcile worker started interval=%ss older_than=%sm " + "(runtime on/off via app_config '%s')", interval, older_than, + _AUTO_RECONCILE_KEY, ) try: while True: try: _touch_lock() result = await asyncio.to_thread(_reconcile_once, older_than) - if result["checked"] or result["resolved"]: + if result is not None and (result["checked"] or result["resolved"]): logger.info("withdraw auto reconcile result=%s", result) except WxPayNotConfiguredError: logger.warning("withdraw auto reconcile skipped: wxpay not configured") @@ -103,7 +115,8 @@ async def _run_locked_loop(interval: int, older_than: int) -> None: def start_withdraw_reconcile_worker() -> asyncio.Task | None: if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED: - logger.info("withdraw auto reconcile disabled") + # 部署级总闸关:worker 进程不启动(运营后台开关此时无效,需先在 env 打开)。 + logger.info("withdraw auto reconcile worker not started (env master switch off)") return None if not settings.wxpay_configured: logger.warning("withdraw auto reconcile enabled but wxpay not configured") diff --git a/app/integrations/pangle.py b/app/integrations/pangle.py index aacae85..1badabb 100644 --- a/app/integrations/pangle.py +++ b/app/integrations/pangle.py @@ -42,3 +42,13 @@ def verify_callback_sign(params: dict[str, str], secret: str) -> bool: got = params.get("sign") or "" expect = build_sign(trans_id, secret) return hmac.compare_digest(got, expect) + + +def verify_callback_sign_any(params: dict[str, str], secrets: list[str]) -> bool: + """对**多个** m-key 逐个验签,任一通过即接受。 + + 多广告位场景:每个 GroMore 广告位的 m-key 由后台各自生成、互不相同,但本服务用 + 同一个回调 URL 接所有位的回调。配置里放多个密钥(逗号分隔),回调到达时挨个试。 + 仍然安全:伪造者必须知道其中某个 m-key 才能算出合法 sign;空列表 → 一律失败。 + """ + return any(verify_callback_sign(params, s) for s in secrets if s) diff --git a/app/schemas/platform.py b/app/schemas/platform.py index ec8619c..74f11ad 100644 --- a/app/schemas/platform.py +++ b/app/schemas/platform.py @@ -22,3 +22,9 @@ class SavingsFeedItem(BaseModel): class SavingsFeedOut(BaseModel): items: list[SavingsFeedItem] + + +class AppFlagsOut(BaseModel): + """客户端拉取的运营 feature flag(不鉴权,登录前也能拉)。客户端缓存后按需读。""" + + comparing_ad_enabled: bool # 比价/领券期是否展示信息流广告(远程 kill-switch) diff --git a/docs/database/app_config.md b/docs/database/app_config.md index 947831c..e5b787d 100644 --- a/docs/database/app_config.md +++ b/docs/database/app_config.md @@ -14,8 +14,8 @@ ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| -| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` | -| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards) | +| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` / `signin_boost_coin` / `withdraw_auto_reconcile_enabled` | +| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards / `bool` 如 withdraw_auto_reconcile_enabled) | | `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 id(= `admin_user.id`,软引用,无 FK) | | `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后修改时间 | diff --git a/tests/test_admin_config.py b/tests/test_admin_config.py index 4f51fbe..13c0e29 100644 --- a/tests/test_admin_config.py +++ b/tests/test_admin_config.py @@ -117,6 +117,40 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N db.close() +def test_update_bool_config(admin_client: TestClient, token: str) -> None: + # 提现自动对账开关默认 True + items = { + i["key"]: i + for i in admin_client.get("/admin/api/config", headers=_auth(token)).json() + } + assert items["withdraw_auto_reconcile_enabled"]["type"] == "bool" + assert items["withdraw_auto_reconcile_enabled"]["value"] is True + + # 关掉 → DB 落 False、业务读到 False + r = admin_client.patch( + "/admin/api/config/withdraw_auto_reconcile_enabled", + json={"value": False}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + assert r.json()["value"] is False and r.json()["overridden"] is True + + db = SessionLocal() + try: + from app.repositories import app_config + + assert app_config.get_value(db, "withdraw_auto_reconcile_enabled") is False + finally: + db.close() + + # bool 项不接受非布尔值 + assert admin_client.patch( + "/admin/api/config/withdraw_auto_reconcile_enabled", + json={"value": 1}, + headers=_auth(token), + ).status_code == 400 + + def test_config_validation(admin_client: TestClient, token: str) -> None: # 签到档位长度≠7 assert admin_client.patch( diff --git a/tests/test_platform.py b/tests/test_platform.py new file mode 100644 index 0000000..883a75f --- /dev/null +++ b/tests/test_platform.py @@ -0,0 +1,13 @@ +"""平台公开端点测试(不鉴权):/api/v1/platform/flags 等。""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def test_flags_default_comparing_ad_enabled(client: TestClient) -> None: + """空配置库下,/flags 返回 comparing_ad_enabled 的默认值 True;不需要鉴权。""" + r = client.get("/api/v1/platform/flags") + assert r.status_code == 200, r.text + body = r.json() + assert "comparing_ad_enabled" in body + assert body["comparing_ad_enabled"] is True -- 2.52.0 From d3e3516ce99680f70f5329a36e36fe7564c7cd04 Mon Sep 17 00:00:00 2001 From: OuYingJun1024 <1034284404@qq.com> Date: Tue, 16 Jun 2026 22:59:55 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs(ad):=20=E5=AF=B9=E9=BD=90=20S2S=20?= =?UTF-8?q?=E5=A4=9A=20m-key=20API=20+=20=E8=AE=B0=E5=BD=95=E7=A9=BF?= =?UTF-8?q?=E5=B1=B1=E7=94=B2=E6=89=93=E7=BA=BF=E4=B8=8A=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E9=A3=8E=E9=99=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ad-pangle-callback / integrations/pangle: 文档改为 verify_callback_sign_any + 命名变量 PANGLE_REWARD_SECRET_TEST/_TEST_DEDICATED/_PROD(多激励位共用同一回调 URL) - 看广告赚金币上线清单: 记录 2026-06-16 实测穿山甲打线上 errorCode=50002 超时 (头号上线阻塞,m-key 配对也救不了)+ 生产 .env 命名变量配置 + AD_REWARD_TEST_GRANT 红线 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/api/ad-pangle-callback.md | 2 +- docs/integrations/pangle.md | 12 +++++++++--- docs/看广告赚金币上线清单.md | 17 +++++++++++++++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/api/ad-pangle-callback.md b/docs/api/ad-pangle-callback.md index d32e90f..3d8e13c 100644 --- a/docs/api/ad-pangle-callback.md +++ b/docs/api/ad-pangle-callback.md @@ -22,7 +22,7 @@ GroMore 以 GET 回调,关键参数: | `ecpm` | string | 本次广告 eCPM(同上,可用于收益分析) | | `sign` | string | 签名,见下 | -**验签**:`sign = SHA256("{m-key}:{trans_id}")` 十六进制(只签 `trans_id`,其余参数不参与)。算法细节、m-key 来源、为什么这样设计 → 见集成文档 [integrations/pangle](../integrations/pangle.md)。 +**验签**:`sign = SHA256("{m-key}:{trans_id}")` 十六进制(只签 `trans_id`,其余参数不参与)。多激励位共用同一回调 URL → 服务端把各位的 m-key 都配上,`verify_callback_sign_any` 逐个试、任一过即接受。算法细节、m-key 配置项(`PANGLE_REWARD_SECRET_TEST/_TEST_DEDICATED/_PROD`)、为什么这样设计 → 见集成文档 [integrations/pangle](../integrations/pangle.md)。 ## 出参 响应 `200`,**响应体必须是 `{"is_verify": bool, "reason": int}`**(GroMore 规范)。 diff --git a/docs/integrations/pangle.md b/docs/integrations/pangle.md index 72837fd..7c6d825 100644 --- a/docs/integrations/pangle.md +++ b/docs/integrations/pangle.md @@ -16,12 +16,18 @@ | 函数 | 说明 | |---|---| | `build_sign(trans_id, secret) -> str` | 计算 `SHA256("{secret}:{trans_id}")` hex。自验签测试 / 模拟回调脚本共用,保证两端一致 | -| `verify_callback_sign(params, secret) -> bool` | 校验回调签名。密钥空 / 缺 `trans_id` 一律失败;比较用 `hmac.compare_digest` 定长比较防时序侧信道 | +| `verify_callback_sign(params, secret) -> bool` | 用**单个** m-key 校验回调签名。密钥空 / 缺 `trans_id` 一律失败;比较用 `hmac.compare_digest` 定长比较防时序侧信道 | +| `verify_callback_sign_any(params, secrets) -> bool` | 用**一组** m-key 逐个验签、任一通过即接受(多激励位共用同一回调 URL 时用)。空列表 → 失败 | + +## 配置(多激励位 = 多 m-key) +每个 GroMore 激励位的 m-key 由后台各自生成、互不相同,但本服务用**同一个回调 URL** 接所有位的回调,因此把用到的位的 m-key 都配上;验签时 `verify_callback_sign_any` 逐个试、任一过即接受(仍安全:伪造者须知道其中某个 m-key 才能造出合法 sign)。m-key 在后台「GroMore 聚合管理 → 搜广告位 ID → 编辑」处获取。`settings.pangle_reward_secrets` 把下列来源汇总去重: -## 配置 | 配置项 | 说明 | |---|---| -| `PANGLE_REWARD_SECRET` | m-key(安全密钥)。后台「GroMore 聚合管理 → 搜广告位 ID → 编辑」处获取 | +| `PANGLE_REWARD_SECRET_TEST` | 测试应用 激励位 104099649 的 m-key | +| `PANGLE_REWARD_SECRET_TEST_DEDICATED` | 测试应用 专属激励位 104127529 的 m-key | +| `PANGLE_REWARD_SECRET_PROD` | 正式应用 激励位 104099389 的 m-key | +| `PANGLE_REWARD_SECRET` | 旧用法:单个或逗号分隔多个 m-key,仍兼容(与上面三个命名项合并去重) | ## 踩坑 - **别用联盟代码位那套**:联盟代码位层级用的是另一套 Security Key + `isValid` 响应体,与 GroMore 广告位层级不通用。我们走 GroMore,别接错。 diff --git a/docs/看广告赚金币上线清单.md b/docs/看广告赚金币上线清单.md index 3f0ccd7..42d9448 100644 --- a/docs/看广告赚金币上线清单.md +++ b/docs/看广告赚金币上线清单.md @@ -41,18 +41,31 @@ > 已确认走 **GroMore 广告位层级**回调(客户端 useMediation(true);规范 supportcenter/26240), > **不是**联盟代码位层级(5416)。验签算法 / 响应格式 / reward_amount 解析**代码已按 GroMore 规范实现** > (2026-05-27),剩下的是后台配置 + 填密钥。 +> +> 🔴 **2026-06-16 实测的头号阻塞:穿山甲打线上后端会超时**。真机看广告时 SDK 日志 +> `onRewardArrived valid=false errorCode=50002`,errorMsg = +> `Get "https://app-api.shaguabijia.com/api/v1/ad/pangle-callback...": context deadline exceeded`。 +> 而我们自己 curl 线上是秒回。说明**穿山甲机房到线上后端这条网络路径有问题**(疑似 nginx/云防火墙对 +> 机房/境外 IP 限流或拦截)。**m-key 配对了也救不了——穿山甲根本没把请求送达。** 上线前必须单独验 +> 「线上 `app-api.shaguabijia.com` 对穿山甲机房可达且响应够快」,否则用户看完发不了奖。 +> +> ✅ **多激励位已支持**(2026-06-16):内部测试期 测试位/专属位/正式位 并存,m-key 各配一行 +> (`PANGLE_REWARD_SECRET_TEST/_TEST_DEDICATED/_PROD`),`verify_callback_sign_any` 逐个试。本地已验通。 - [ ] **GroMore 后台配回调**:GroMore 聚合管理 → 搜广告位ID → 编辑 → 勾选「服务端激励回调」→ 回调 URL 填 `https://app-api.shaguabijia.com/api/v1/ad/pangle-callback`(用域名,别用 IP)。 ⚠️ 广告位层级配了就**别再在代码位层级重复配**(会导致发奖出问题)。 -- [ ] **拿 m-key(安全密钥)**:就在上面"编辑广告位"页获取,填到生产 `.env` 的 `PANGLE_REWARD_SECRET`。 +- [ ] **拿 m-key(安全密钥)**:就在上面"编辑广告位"页获取(每个激励位各一把),填到生产 `.env` 的命名变量 + (正式位填 `PANGLE_REWARD_SECRET_PROD`;内部测试期 测试位/专属位 也并存就填 `_TEST`/`_TEST_DEDICATED`)。 (注:这是 GroMore 广告位的 m-key,**不是**联盟代码位那个 Security Key。) - [x] ~~换验签~~:`app/integrations/pangle.py` 已实现 `sign = SHA256("{m-key}:{trans_id}")`(GroMore 真实算法)。 - [x] ~~响应格式~~:已返回 GroMore 要求的 `{"is_verify": bool, "reason": int}`。 - [x] ~~reward_amount~~:回调按 `reward_amount` 发金币(`rewards.resolve_ad_reward_coin`,带回退/夹紧); **后台广告位"奖励数量"须配成与 `AD_REWARD_COIN`(=100)一致**,保证"广告内展示/进度预告/到账"三者一致。 - [x] ~~透传 user_id~~:客户端已 `setUserID(userId)` + `setMediaExtra("uid:...")`。 -- [ ] 生产 `.env`:`PANGLE_CALLBACK_ENABLED=true` + `PANGLE_REWARD_SECRET=`(配齐才不返 503) +- [ ] 生产 `.env`:`PANGLE_CALLBACK_ENABLED=true` + 至少一个 m-key 命名变量(配齐才不返 503)+ 确认 + `AD_REWARD_TEST_GRANT_ENABLED=false`(绕过反作弊,生产红线)。改 `.env` 后**重启 uvicorn** + (watchfiles 不监听 .env)。验证:外部 curl 回调 URL 从 `503` 变 `403 bad sign` 即生效。 ## C. 部署 + 包名 -- 2.52.0 From c958546e07178f2680f20f77bff2accf0df5272f Mon Sep 17 00:00:00 2001 From: OuYingJun1024 <1034284404@qq.com> Date: Wed, 17 Jun 2026 22:27:22 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(ad):=20=E4=BF=A1=E6=81=AF=E6=B5=81?= =?UTF-8?q?=E5=B9=BF=E5=91=8A=E6=8C=89=E6=9D=A1=20segments=20=E7=B2=BE?= =?UTF-8?q?=E7=A1=AE=E5=8F=91=E5=A5=96=20+=20=E5=8F=AA=E8=AF=BB=20preview?= =?UTF-8?q?=20=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compute_feed_reward/preview_feed_reward: 逐条 (eCPM,秒) 跨条进位、每满 10s 一份、 每份用所在段 eCPM × LT,封顶 12 份/场;发奖与预览同一口径 - grant_feed_reward 改收 segments(旧 ecpm/duration 单段自动回退,兼容旧客户端) - 新增 POST /api/v1/ad/feed-reward/preview(只读不入账,给比价金币小球实时显示真实即将到账额) - schemas 加 FeedSegmentIn/FeedPreviewIn/FeedPreviewOut;docs/api 补 preview 文档 + 更新 feed-reward - tests: 按条结算/12份封顶/aborted/preview==grant 等 6 个用例 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/ad.py | 46 ++++++- app/repositories/ad_feed_reward.py | 208 +++++++++++++++++------------ app/schemas/ad.py | 53 +++++++- docs/api/README.md | 1 + docs/api/ad-feed-reward-preview.md | 35 +++++ docs/api/ad-feed-reward.md | 29 ++-- tests/test_ad_reward.py | 121 +++++++++++++++++ 7 files changed, 386 insertions(+), 107 deletions(-) create mode 100644 docs/api/ad-feed-reward-preview.md diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index bb05891..9b98afc 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -29,6 +29,8 @@ from app.schemas.ad import ( AdRewardStatusOut, EcpmReportIn, EcpmReportOut, + FeedPreviewIn, + FeedPreviewOut, FeedRewardIn, FeedRewardOut, PangleCallbackOut, @@ -343,6 +345,23 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N ) +def _feed_segments(payload: FeedRewardIn) -> tuple[list[tuple[str, int]], str]: + """把 feed-reward 入参归一成 `(segments, ecpm_repr)`。 + + 优先用逐条 segments(每条自带 eCPM);未传则回退把 ecpm + duration_seconds 当单段(旧客户端)。 + ecpm_repr 取末段 eCPM(报表/审计落库代表值)。两者都缺 → 422。 + """ + if payload.segments: + segs = [(s.ecpm, max(0, s.seconds)) for s in payload.segments] + return segs, segs[-1][0] + if payload.ecpm is not None: + return [(payload.ecpm, max(0, payload.duration_seconds))], payload.ecpm + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="feed-reward 需提供 segments 或 ecpm", + ) + + @router.post( "/feed-reward", response_model=FeedRewardOut, @@ -350,16 +369,18 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N dependencies=[Depends(rate_limit(120, 60, "ad-feed-reward"))], ) def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> FeedRewardOut: - """点位 2:信息流广告每展示满 10 秒累计一份奖励,视频完成后一次性入账。 + """点位 2:信息流广告每展示满 10 秒累计一份奖励,整场结束(全程不关)一次性入账。 - 当前一期由客户端完成回调携带 eCPM / 展示秒数上报;client_event_id 做幂等键,避免重试重复发。 + 新客户端按条上报 [segments](每条 eCPM + 秒数),服务端按时序跨条进位精确结算;旧客户端只传 + ecpm + duration_seconds 时回退当单段。client_event_id 做幂等键,避免重试重复发。 """ + segments, ecpm_repr = _feed_segments(payload) rec = crud_feed.grant_feed_reward( db, user.id, client_event_id=payload.client_event_id, - ecpm=payload.ecpm, - duration_seconds=payload.duration_seconds, + segments=segments, + ecpm_repr=ecpm_repr, ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, @@ -380,6 +401,23 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed ) +@router.post( + "/feed-reward/preview", + response_model=FeedPreviewOut, + summary="信息流播放中预演此刻将发放的金币(只读,不入账)", + dependencies=[Depends(rate_limit(600, 60, "ad-feed-preview"))], +) +def feed_reward_preview(payload: FeedPreviewIn, user: CurrentUser, db: DbSession) -> FeedPreviewOut: + """比价/领券金币小球实时显示**真实即将到账金额**用:按截至此刻的逐条 segments 算,与 + /feed-reward 同一口径(同份数封顶 / 同 LT / 同 eCPM 钳),但**不入账、不写库**。 + + 限流放宽到 600/分:小球每满 10 秒(或换条)拉一次,一整场最多十几次,留足并发余量。 + """ + segments = [(s.ecpm, max(0, s.seconds)) for s in payload.segments] + result = crud_feed.preview_feed_reward(db, user.id, segments) + return FeedPreviewOut(**result) + + @router.post( "/reward-noshow", response_model=RewardNoShowOut, diff --git a/app/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py index 64fe277..2295a01 100644 --- a/app/repositories/ad_feed_reward.py +++ b/app/repositories/ad_feed_reward.py @@ -16,10 +16,13 @@ from app.repositories import wallet as crud_wallet FEED_REWARD_UNIT_SECONDS = 10 -# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数 +# 单个 feed 事件的时长上限(秒):一期时长由客户端上报,伪造超长时长会刷份数 # (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。 # 与 rewards.AD_ECPM_MAX_FEN(eCPM 钳顶)合起来,把单事件可铸金币锁进有限区间。 FEED_MAX_DURATION_SECONDS = 120 +# 单场份数上限(=120s//10):多条广告按时序「跨条进位」累计满 10 秒一份,封顶 12 份/场。 +# 时长改由逐条 segments 上报后,防刷的硬闸从「单事件时长钳」上移到这个份数上限(配合 eCPM 钳顶)。 +FEED_MAX_UNITS = FEED_MAX_DURATION_SECONDS // FEED_REWARD_UNIT_SECONDS def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | None: @@ -42,21 +45,89 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int: ).scalar_one() -def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int) -> int: - """按每个 10 秒单位逐份计算奖励,LT 使用**账号累计**奖励份序号(不按天重置)。""" - if unit_count <= 0: - return 0 - existing_units = db.execute( - select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0)) - .where( - AdFeedRewardRecord.user_id == user_id, - AdFeedRewardRecord.status == "granted", +def _existing_granted_units(db: Session, user_id: int) -> int: + """该账号历史已 granted 的累计份数(LT 因子起点,不按天重置)。""" + return int( + db.execute( + select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0)).where( + AdFeedRewardRecord.user_id == user_id, + AdFeedRewardRecord.status == "granted", + ) + ).scalar_one() + ) + + +def _walk_unit_ecpms(segments: list[tuple[str, int]]) -> list[str]: + """按时序遍历各广告段,**跨条进位**累计满 10 秒结算一份,返回每份对应的 eCPM。 + + 每份归属「它满 10 秒那一刻正在播的那条广告的 eCPM」:换条时不足一份的零头进位到下一条 + (与旧版 `总时长//10` 份数口径一致、不浪费零头,但每份 eCPM 归属精确)。封顶 [FEED_MAX_UNITS] + 份;尾部不足一份的秒数丢弃。segments 为 `[(ecpm, seconds), ...]`,seconds≤0 的段跳过。 + """ + unit_ecpms: list[str] = [] + carry = 0 + for ecpm, seconds in segments: + if seconds <= 0: + continue + carry += seconds + while carry >= FEED_REWARD_UNIT_SECONDS and len(unit_ecpms) < FEED_MAX_UNITS: + carry -= FEED_REWARD_UNIT_SECONDS + unit_ecpms.append(ecpm) + if len(unit_ecpms) >= FEED_MAX_UNITS: + break + return unit_ecpms + + +def compute_feed_reward( + db: Session, user_id: int, segments: list[tuple[str, int]] +) -> tuple[int, int, int]: + """按逐条 segments 计算「此刻若结束将发放的金币」。**只读、不入账**,发奖与预览共用此口径。 + + 返回 `(coin, unit_count, next_unit_coin)`: + - coin:已累计满的各份金币之和(每份 = 所在段 eCPM × LT(账号累计份序号) × 汇率)。 + - unit_count:已满份数(封顶 [FEED_MAX_UNITS])。 + - next_unit_coin:下一份(第 unit_count+1 份)的边际金币,按当前在播段(segments 末段)eCPM 算; + 已封顶或无段时为 0。供客户端小球在「本份未满」区间内做平滑插值显示。 + """ + unit_ecpms = _walk_unit_ecpms(segments) + unit_count = len(unit_ecpms) + existing_units = _existing_granted_units(db, user_id) + coin = sum( + rewards.calculate_ad_reward_coin(ecpm, existing_units + i) + for i, ecpm in enumerate(unit_ecpms, start=1) + ) + if unit_count < FEED_MAX_UNITS and segments: + next_ecpm = segments[-1][0] + next_unit_coin = rewards.calculate_ad_reward_coin( + next_ecpm, existing_units + unit_count + 1 ) - ).scalar_one() - total = 0 - for offset in range(1, unit_count + 1): - total += rewards.calculate_ad_reward_coin(ecpm, int(existing_units) + offset) - return total + else: + next_unit_coin = 0 + return coin, unit_count, next_unit_coin + + +def preview_feed_reward( + db: Session, user_id: int, segments: list[tuple[str, int]] +) -> dict: + """信息流播放中查询「此刻若结束将发放多少金币」(只读预演,不入账、不写库)。 + + 给比价/领券金币小球实时显示**真实即将到账金额**用。would_status 预判若此刻结束的结算状态: + daily_capped(已达每日条数上限,整场将发 0)/ too_short(凑不满一份)/ granted。 + """ + coin, unit_count, next_unit_coin = compute_feed_reward(db, user_id, segments) + if _granted_today(db, user_id, cn_today().isoformat()) >= rewards.get_ad_daily_limit(db): + would_status = "daily_capped" + elif unit_count == 0: + would_status = "too_short" + else: + would_status = "granted" + return { + "coin": coin, + "unit_count": unit_count, + "next_unit_coin": next_unit_coin, + "session_full": unit_count >= FEED_MAX_UNITS, + "would_status": would_status, + } def grant_feed_reward( @@ -64,8 +135,8 @@ def grant_feed_reward( user_id: int, *, client_event_id: str, - ecpm: str, - duration_seconds: int, + segments: list[tuple[str, int]], + ecpm_repr: str, ad_session_id: str | None = None, adn: str | None = None, slot_id: str | None = None, @@ -75,102 +146,61 @@ def grant_feed_reward( ) -> AdFeedRewardRecord: """比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。 - 发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份)。 + 发奖规则:**比价全程不关广告才发**,金额按整场逐条 [segments]「跨条进位」折份 + (每 10 秒 1 份,每份用所在段 eCPM,见 [_walk_unit_ecpms])。 - aborted=True(用户中途 ✕ 关闭):整场不发,记 status='closed_early' 留痕(原因可查)。 - 总时长不足 10 秒(unit_count==0):记 status='too_short' 不发。 - 命中当日条数上限:记 status='capped' 不发。 - duration_seconds 是整场累计秒数。一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 - FEED_MAX_DURATION_SECONDS 限单场份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 - AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。 + segments 是逐条广告 `(ecpm, seconds)` 时序列表(旧客户端只传总时长时,API 层包成单段)。 + ecpm_repr 是落库代表 eCPM(报表/审计用,通常取末段)。三道硬闸防刷:份数封顶 [FEED_MAX_UNITS] + 限单场份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN 限单份金额, + 叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。 """ existing = _find_by_event(db, client_event_id) if existing is not None: return existing today = cn_today().isoformat() - # 客户端上报时长先钳到 FEED_MAX_DURATION_SECONDS,防伪造超长时长刷份数(见常量注释)。 - safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS)) - unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS + total_seconds = sum(max(0, s) for _, s in segments) + # 份数封顶在 compute 内做(FEED_MAX_UNITS);记录的 duration 仍钳一道,纯做报表展示防异常大值。 + safe_duration = min(total_seconds, FEED_MAX_DURATION_SECONDS) + coin, unit_count, _next = compute_feed_reward(db, user_id, segments) + + def _record(coin_val: int, status: str) -> AdFeedRewardRecord: + return AdFeedRewardRecord( + client_event_id=client_event_id, + user_id=user_id, + reward_date=today, + duration_seconds=safe_duration, + unit_count=unit_count, + ad_session_id=ad_session_id, + ecpm_raw=ecpm_repr, + adn=adn, + slot_id=slot_id, + app_env=app_env, + our_code_id=our_code_id, + coin=coin_val, + status=status, + ) # 用户中途关闭广告:整场不发(全程不关才发),留一条 closed_early 记录原因。优先级最高。 if aborted: - rec = AdFeedRewardRecord( - client_event_id=client_event_id, - user_id=user_id, - reward_date=today, - duration_seconds=safe_duration, - unit_count=unit_count, - ad_session_id=ad_session_id, - ecpm_raw=ecpm, - adn=adn, - slot_id=slot_id, - app_env=app_env, - our_code_id=our_code_id, - coin=0, - status="closed_early", - ) - return _commit_record(db, rec, client_event_id) + return _commit_record(db, _record(0, "closed_early"), client_event_id) if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db): - rec = AdFeedRewardRecord( - client_event_id=client_event_id, - user_id=user_id, - reward_date=today, - duration_seconds=safe_duration, - unit_count=unit_count, - ad_session_id=ad_session_id, - ecpm_raw=ecpm, - adn=adn, - slot_id=slot_id, - app_env=app_env, - our_code_id=our_code_id, - coin=0, - status="capped", - ) - return _commit_record(db, rec, client_event_id) + return _commit_record(db, _record(0, "capped"), client_event_id) # 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。 if unit_count == 0: - rec = AdFeedRewardRecord( - client_event_id=client_event_id, - user_id=user_id, - reward_date=today, - duration_seconds=safe_duration, - unit_count=0, - ad_session_id=ad_session_id, - ecpm_raw=ecpm, - adn=adn, - slot_id=slot_id, - app_env=app_env, - our_code_id=our_code_id, - coin=0, - status="too_short", - ) - return _commit_record(db, rec, client_event_id) + return _commit_record(db, _record(0, "too_short"), client_event_id) - coin = _unit_reward_total(db, user_id, ecpm, unit_count) if coin > 0: crud_wallet.grant_coins( db, user_id, coin, biz_type="feed_ad_reward", ref_id=client_event_id, remark=f"信息流广告奖励 {unit_count}份", ) - rec = AdFeedRewardRecord( - client_event_id=client_event_id, - user_id=user_id, - reward_date=today, - duration_seconds=safe_duration, - unit_count=unit_count, - ad_session_id=ad_session_id, - ecpm_raw=ecpm, - adn=adn, - slot_id=slot_id, - app_env=app_env, - our_code_id=our_code_id, - coin=coin, - status="granted", - ) - return _commit_record(db, rec, client_event_id) + return _commit_record(db, _record(coin, "granted"), client_event_id) def _commit_record(db: Session, rec: AdFeedRewardRecord, client_event_id: str) -> AdFeedRewardRecord: diff --git a/app/schemas/ad.py b/app/schemas/ad.py index ae56aab..4e0e45d 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -120,19 +120,40 @@ class TestGrantOut(BaseModel): ) +class FeedSegmentIn(BaseModel): + """信息流轮播中的**一条广告段**:自带 eCPM + 观看秒数。 + + 新客户端按条上报(每条 eCPM 不同),服务端按时序「跨条进位」每满 10 秒结算一份、每份用所在段 + eCPM 计价(见 ad_feed_reward._walk_unit_ecpms)。 + """ + + ecpm: str = Field(..., description="该条广告 eCPM(分/千次展示,SDK getEcpm 原值,非元)") + seconds: int = Field(..., ge=0, description="该条广告的观看秒数") + + class FeedRewardIn(BaseModel): """比价/领券一整场信息流(轮播多条)结束后结算奖励。 - 规则:全程不关广告才发,金额按整场**总观看时长**折份(每 10 秒 1 份)。client_event_id 用于 - 客户端超时重试幂等。中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)。 + 规则:全程不关广告才发,金额按整场折份(每 10 秒 1 份)。client_event_id 用于客户端超时重试 + 幂等。中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)。 + + 计价口径:优先用 [segments] 逐条精确结算(每条自带 eCPM);未传 segments 时回退用 + ecpm + duration_seconds 当**单段**(旧客户端兼容)。两者都没有则 422。 """ client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id") ad_session_id: str | None = Field( None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id" ) - ecpm: str = Field(..., description="本场信息流 eCPM(代表值,按分/千次展示处理;SDK getEcpm 原值,非元)") - duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)") + segments: list[FeedSegmentIn] | None = Field( + None, description="逐条广告段(每条自带 eCPM + 观看秒数,时序)。新客户端传它做按条精确结算" + ) + ecpm: str | None = Field( + None, description="[旧客户端/回退] 本场信息流代表 eCPM(分/千次);传了 segments 时仅作落库代表值" + ) + duration_seconds: int = Field( + 0, ge=0, description="[旧客户端/回退] 整场累计观看秒数;传了 segments 时忽略" + ) adn: str | None = Field(None, description="实际投放 ADN") slot_id: str | None = Field(None, description="实际展示代码位") app_env: str | None = Field( @@ -154,6 +175,30 @@ class FeedRewardOut(BaseModel): daily_limit: int = Field(..., description="每日信息流展示次数上限") +class FeedPreviewIn(BaseModel): + """信息流播放中查询「此刻若结束将发放多少金币」(只读预演,不入账)。 + + 给比价/领券金币小球实时显示**真实即将到账金额**用。segments 含在播那条的部分秒数。 + """ + + segments: list[FeedSegmentIn] = Field( + ..., min_length=1, description="截至此刻的逐条广告段(时序;含在播那条的部分秒数)" + ) + + +class FeedPreviewOut(BaseModel): + coin: int = Field(..., description="此刻结束将发放的累计金币(已满 10 秒的各份之和)") + unit_count: int = Field(..., description="已累计满的份数(封顶 12)") + next_unit_coin: int = Field( + ..., description="下一份(第 unit_count+1 份)的金币值,按当前在播段 eCPM 算;封顶后为 0。" + "供小球在本份未满区间内做平滑插值显示" + ) + session_full: bool = Field(..., description="是否已达单场份数上限(12),小球应停涨") + would_status: str = Field( + ..., description="若此刻结束的结算预判:granted / too_short / daily_capped" + ) + + class RewardNoShowIn(BaseModel): """激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。 diff --git a/docs/api/README.md b/docs/api/README.md index 25e8ec6..69434f3 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -63,6 +63,7 @@ | 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) | | 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) | | 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) | +| 35a' | `POST /api/v1/ad/feed-reward/preview` | Bearer | [详情](./ad-feed-reward-preview.md)(播放中预演将发放金币,只读不入账,金币小球用) | | 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) | | **用户资料**(前缀 `/api/v1/user`) ||| | 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) | diff --git a/docs/api/ad-feed-reward-preview.md b/docs/api/ad-feed-reward-preview.md new file mode 100644 index 0000000..c41eb92 --- /dev/null +++ b/docs/api/ad-feed-reward-preview.md @@ -0,0 +1,35 @@ +# POST /api/v1/ad/feed-reward/preview — 信息流播放中预演将发放的金币(只读) + +比价 / 领券信息流广告**播放过程中**,查询「若此刻结束、整场将发放多少金币」。给金币小球实时显示**真实即将到账金额**用:与 [feed-reward](./ad-feed-reward.md) **同一计价口径**(同份数封顶 / 同 LT / 同 eCPM 钳),但**只读、不入账、不写库**。 + +客户端每满 10 秒观看(或换条 eCPM 变化)拉一次;两次之间用 `next_unit_coin × 本份进度` 在本地做平滑插值显示。 + +## 鉴权 + +需要 Bearer token。 + +## 请求体 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---:|---| +| `segments` | array | 是 | 截至此刻的**逐条广告段**(时序,至少 1 项;含在播那条的部分秒数),每项 `{ecpm: string, seconds: int}` | + +## 响应 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `coin` | int | 此刻结束将发放的累计金币(已满 10 秒的各份之和) | +| `unit_count` | int | 已累计满的份数(封顶 12) | +| `next_unit_coin` | int | 下一份(第 `unit_count+1` 份)的金币值,按当前在播段 eCPM 算;封顶后为 0。供小球在本份未满区间内平滑插值 | +| `session_full` | bool | 是否已达单场份数上限(12)→ 小球应停涨 | +| `would_status` | string | 若此刻结束的结算预判:`granted` / `too_short`(凑不满一份) / `daily_capped`(已达每日条数上限,整场将发 0) | + +## 与 feed-reward 的关系 + +- 同样按 `segments` 跨条进位每满 10 秒一份、每份用所在段 eCPM 计价(详见 [feed-reward 计算口径](./ad-feed-reward.md#计算口径))。 +- preview 的 `coin` 等于「用相同 segments 调 feed-reward 实发的 `coin`」——小球显示值即整场结束实发额(除非中途 `would_status` 提示 `daily_capped`,那种情况整场实发 0)。 +- preview **不消耗每日上限、不写任何表**,可高频调用(限流 600/分)。 + +## 限流 + +600 次 / 分钟(`ad-feed-preview`)。 diff --git a/docs/api/ad-feed-reward.md b/docs/api/ad-feed-reward.md index 23516a7..4d098d8 100644 --- a/docs/api/ad-feed-reward.md +++ b/docs/api/ad-feed-reward.md @@ -1,6 +1,8 @@ # POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币 -点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发。 +点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发。 + +播放过程中查询「此刻将发放多少金币」(金币小球实时显示用)见 [feed-reward/preview](./ad-feed-reward-preview.md),与本接口同一计价口径但只读不入账。 ## 鉴权 @@ -8,37 +10,44 @@ ## 请求体 +计价优先用 `segments` 逐条精确结算;未传 `segments` 时回退用 `ecpm` + `duration_seconds` 当**单段**(旧客户端兼容)。两者都不传 → 422。 + | 字段 | 类型 | 必填 | 说明 | |---|---|---:|---| | `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 | | `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 | -| `ecpm` | string | 是 | 本场信息流 eCPM 代表值(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) | -| `duration_seconds` | int | 是 | **整场累计观看秒数**(轮播各条相加) | +| `segments` | array\|null | 否* | **逐条广告段**(时序),每项 `{ecpm: string, seconds: int}`。新客户端传它做按条精确结算 | +| `ecpm` | string\|null | 否* | **[回退]** 本场代表 eCPM(穿山甲 getEcpm 原值,分/千次展示,非元);传了 `segments` 时仅作落库代表值 | +| `duration_seconds` | int | 否 | **[回退]** 整场累计观看秒数;传了 `segments` 时忽略。默认 0 | | `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充的子渠道) | | `slot_id` | string\|null | 否 | 实际展示代码位(底层 mediation rit) | | `app_env` | string\|null | 否 | **我们的**应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) | | `our_code_id` | string\|null | 否 | **我们后台配置的**代码位 ID(104xxx,非底层 rit);供广告收益报表按代码位聚合金币 | | `aborted` | bool | 否 | 用户中途 ✕ 关闭广告(未走完比价):整场不发,仅记 `closed_early`。默认 `false` | +\* `segments` 与 `ecpm` 至少传其一。 + ## 响应 | 字段 | 类型 | 说明 | |---|---|---| | `granted` | bool | 本次是否入账;未发(任一非 granted 状态)时为 `false` | -| `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途关闭) | +| `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场<10s 凑不满一份) / `closed_early`(用户中途关闭) | | `coin` | int | 本次发放金币;非 granted 为 0 | -| `unit_count` | int | 按 10 秒折算出的奖励份数 | +| `unit_count` | int | 折算出的奖励份数(封顶 12) | | `daily_limit` | int | 每日信息流展示次数上限,默认 500 | ## 计算口径 -- 奖励份数:`整场总时长 // 10`。 -- 单份奖励:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(当天累计份序号) × 10000`,四舍五入为整数金币。 -- eCPM 档:`0-100=0.1`,`101-200=0.3`,`201-400=0.4`,`>400=0.6`。 -- LT 档:第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`。 +- **奖励份数(跨条进位)**:按 `segments` 时序遍历,维护一个连续秒数计数器,每满 10 秒结算一份;换条时不足一份的零头进位到下一条。份数封顶 **12 份/场**(防刷硬闸,替代旧的「单事件时长钳 120s」)。 +- **每份归属的 eCPM**:该份「满 10 秒那一刻正在播的那条广告」的 eCPM。 +- **单份奖励**:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(账号累计份序号) × 10000`,四舍五入为整数金币。 +- eCPM 档:`0-100=0.1`,`101-200=0.3`,`201-400=0.4`,`>400=0.6`(eCPM 钳顶 ¥500 CPM)。 +- LT 档(按**账号累计已发份数**,不按天重置):第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`。 +- 旧客户端单段口径 `[(ecpm, duration_seconds)]` 是上述的特例,份数 = `min(duration//10, 12)`,结果与改造前一致。 ## 数据写入 -- `ad_feed_reward_record` 新增一行。 +- `ad_feed_reward_record` 新增一行(`ecpm_raw` 存代表 eCPM,通常为末段)。 - 入账时 `coin_account` 增加余额。 - 入账时 `coin_transaction` 写入 `biz_type=feed_ad_reward`。 diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py index 1943317..f1b28bc 100644 --- a/tests/test_ad_reward.py +++ b/tests/test_ad_reward.py @@ -315,6 +315,127 @@ def test_feed_reward_daily_display_cap(client, monkeypatch) -> None: assert _coin_balance(client, token) == before +def test_feed_reward_segments_per_ad_ecpm(client) -> None: + """按条 segments 结算:每份用「满 10 秒那刻在播段」的 eCPM,跨条进位不浪费零头。""" + phone = "13800003303" + token = _login(client, phone) + + # ad1: eCPM 200 看 25s;ad2: eCPM 400 看 7s。 + # 跨条进位:25s→2 份(@200,进位 5s);5+7=12s→第 3 份(@400,进位 2s 丢弃)。 + payload = { + "client_event_id": "feed_seg_0001", + "ad_session_id": "feedsess0001", + "segments": [ + {"ecpm": "200", "seconds": 25}, + {"ecpm": "400", "seconds": 7}, + ], + } + r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token)) + assert r.status_code == 200, r.text + body = r.json() + expected = ( + calculate_ad_reward_coin("200", 1) + + calculate_ad_reward_coin("200", 2) + + calculate_ad_reward_coin("400", 3) + ) + assert body["status"] == "granted" + assert body["unit_count"] == 3 + assert body["coin"] == expected + assert _coin_balance(client, token) == expected + + +def test_feed_reward_session_unit_cap(client) -> None: + """单场份数封顶 12:超长 segments 也只发 12 份。""" + phone = "13800003304" + token = _login(client, phone) + payload = { + "client_event_id": "feed_cap_unit_1", + "segments": [{"ecpm": "200", "seconds": 1000}], # 100 份 → 钳到 12 + } + r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["unit_count"] == 12 + expected = sum(calculate_ad_reward_coin("200", i) for i in range(1, 13)) + assert r.json()["coin"] == expected + + +def test_feed_reward_aborted_no_grant(client) -> None: + """中途关闭(aborted)即使看够时长也整场不发,记 closed_early。""" + phone = "13800003305" + token = _login(client, phone) + payload = { + "client_event_id": "feed_abort_1", + "segments": [{"ecpm": "200", "seconds": 30}], + "aborted": True, + } + r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["granted"] is False + assert r.json()["status"] == "closed_early" + assert r.json()["coin"] == 0 + assert _coin_balance(client, token) == 0 + + +def test_feed_reward_requires_segments_or_ecpm(client) -> None: + """既不传 segments 也不传 ecpm → 422。""" + phone = "13800003306" + token = _login(client, phone) + r = client.post( + "/api/v1/ad/feed-reward", + json={"client_event_id": "feed_bad_1", "duration_seconds": 30}, + headers=_auth(token), + ) + assert r.status_code == 422, r.text + + +def test_feed_preview_matches_grant(client) -> None: + """preview 与最终 feed-reward 同口径:preview 的 coin == 同 segments 实发 coin。""" + phone = "13800003307" + token = _login(client, phone) + segments = [{"ecpm": "200", "seconds": 25}, {"ecpm": "400", "seconds": 7}] + + pv = client.post( + "/api/v1/ad/feed-reward/preview", json={"segments": segments}, headers=_auth(token) + ) + assert pv.status_code == 200, pv.text + pvb = pv.json() + assert pvb["unit_count"] == 3 + assert pvb["would_status"] == "granted" + assert pvb["session_full"] is False + # 下一份(第 4 份)边际金币,按末段 eCPM=400 算 + assert pvb["next_unit_coin"] == calculate_ad_reward_coin("400", 4) + + # preview 不入账:此时余额仍为 0 + assert _coin_balance(client, token) == 0 + + # 真正结算,coin 应与 preview 一致 + gr = client.post( + "/api/v1/ad/feed-reward", + json={"client_event_id": "feed_pv_1", "segments": segments}, + headers=_auth(token), + ) + assert gr.status_code == 200, gr.text + assert gr.json()["coin"] == pvb["coin"] + assert _coin_balance(client, token) == pvb["coin"] + + +def test_feed_preview_too_short(client) -> None: + """不足 10 秒:preview would_status=too_short、coin=0、next_unit_coin>0(本份在攒)。""" + phone = "13800003308" + token = _login(client, phone) + pv = client.post( + "/api/v1/ad/feed-reward/preview", + json={"segments": [{"ecpm": "200", "seconds": 6}]}, + headers=_auth(token), + ) + assert pv.status_code == 200, pv.text + body = pv.json() + assert body["unit_count"] == 0 + assert body["coin"] == 0 + assert body["would_status"] == "too_short" + assert body["next_unit_coin"] == calculate_ad_reward_coin("200", 1) + + def test_callback_disabled_returns_503(client, monkeypatch) -> None: """未配置回调(开关关)时 → 503。""" monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False) -- 2.52.0 From 2472cfc44a3c5f49b8195ecdf8d57f45c02f1fca Mon Sep 17 00:00:00 2001 From: OuYingJun1024 <1034284404@qq.com> Date: Wed, 17 Jun 2026 22:28:05 +0800 Subject: [PATCH 4/4] =?UTF-8?q?chore(alembic):=20cps=5Fv2=5Fplatforms=20?= =?UTF-8?q?=E8=B7=A8=E6=96=B9=E8=A8=80(SQLite=20batch)=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 随本分支一并提交的工作区既有改动(非本次信息流发奖功能): batch_alter_table + 按方言区分 server_default,让迁移在 SQLite 本地也能跑。 Co-Authored-By: Claude Opus 4.8 (1M context) --- alembic/versions/cps_v2_platforms.py | 58 +++++++++++++++++----------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/alembic/versions/cps_v2_platforms.py b/alembic/versions/cps_v2_platforms.py index e80c1a6..75b53a1 100644 --- a/alembic/versions/cps_v2_platforms.py +++ b/alembic/versions/cps_v2_platforms.py @@ -28,34 +28,48 @@ def upgrade() -> None: op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True)) # 群:多平台(现有群都是美团,server_default 回填)+ sid 可空 - op.add_column( - "cps_group", - sa.Column( - "platforms", - sa.JSON().with_variant(postgresql.JSONB(), "postgresql"), - nullable=False, - server_default=sa.text("'[\"meituan\"]'::jsonb"), - ), + # server_default 按方言区分:Postgres 用 ::jsonb 转换;SQLite(本地)用纯 JSON 文本字面量 + #(SQLite 不认 ::jsonb,会报 unrecognized token: ":")。 + bind = op.get_bind() + platforms_default = ( + sa.text("'[\"meituan\"]'::jsonb") + if bind.dialect.name == "postgresql" + else sa.text("'[\"meituan\"]'") ) - op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=True) + # alter_column 在 SQLite 原生不支持 ALTER COLUMN,必须走 batch(重建表);Postgres 下 batch 直接 ALTER。 + # 用 batch_alter_table 跨方言通吃(render_as_batch 只管 autogenerate 渲染,不会自动包裹手写的 alter_column)。 + with op.batch_alter_table("cps_group") as batch_op: + batch_op.add_column( + sa.Column( + "platforms", + sa.JSON().with_variant(postgresql.JSONB(), "postgresql"), + nullable=False, + server_default=platforms_default, + ) + ) + batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True) # link:sid 可空 + target 加长 - op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=True) - op.alter_column("cps_link", "target_url", existing_type=sa.String(1024), type_=sa.String(2048)) + with op.batch_alter_table("cps_link") as batch_op: + batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True) + batch_op.alter_column("target_url", existing_type=sa.String(1024), type_=sa.String(2048)) # click:sid 可空 + 事件类型 - op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=True) - op.add_column( - "cps_click", - sa.Column("event_type", sa.String(16), nullable=False, server_default="visit"), - ) + with op.batch_alter_table("cps_click") as batch_op: + batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True) + batch_op.add_column( + sa.Column("event_type", sa.String(16), nullable=False, server_default="visit") + ) def downgrade() -> None: - op.drop_column("cps_click", "event_type") - op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=False) - op.alter_column("cps_link", "target_url", existing_type=sa.String(2048), type_=sa.String(1024)) - op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=False) - op.drop_column("cps_group", "platforms") - op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=False) + with op.batch_alter_table("cps_click") as batch_op: + batch_op.drop_column("event_type") + batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False) + with op.batch_alter_table("cps_link") as batch_op: + batch_op.alter_column("target_url", existing_type=sa.String(2048), type_=sa.String(1024)) + batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False) + with op.batch_alter_table("cps_group") as batch_op: + batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False) + batch_op.drop_column("platforms") op.drop_column("cps_activity", "payload") -- 2.52.0