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/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/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/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. 部署 + 包名 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