From 04edb50accce6989c64b64f3a327ce6d44f29da1 Mon Sep 17 00:00:00 2001 From: ouzhou Date: Wed, 17 Jun 2026 10:37:54 +0800 Subject: [PATCH 01/20] =?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=20(#59)?= 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) --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/59 Co-authored-by: ouzhou Co-committed-by: ouzhou --- .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/api/ad-pangle-callback.md | 2 +- docs/database/app_config.md | 4 ++-- docs/integrations/pangle.md | 12 +++++++--- docs/看广告赚金币上线清单.md | 17 ++++++++++++-- tests/test_admin_config.py | 34 +++++++++++++++++++++++++++ tests/test_platform.py | 13 ++++++++++ 17 files changed, 194 insertions(+), 25 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 5c3a787..bfc4139 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 From 003fd9d9869bcd57b01e5309d350a672adf148cf Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 15:56:15 +0800 Subject: [PATCH 02/20] =?UTF-8?q?feat(compare):=20=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=AF=94=E4=BB=B7=E8=AE=B0=E5=BD=95=E6=94=AF=E6=8C=81=20trace?= =?UTF-8?q?=20=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compare.py 加 /trace/finalize 透传(按 trace_id 一致性 hash 落同一 pricebot 进程);compare_record.py list_records 加 include_trace,对本人记录在调试态(客户端 agent 调试模式)放行 trace_url。 Co-Authored-By: Claude Opus 4.8 --- app/api/v1/compare.py | 8 ++++++++ app/api/v1/compare_record.py | 11 +++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/api/v1/compare.py b/app/api/v1/compare.py index 93279b3..c05e7ca 100644 --- a/app/api/v1/compare.py +++ b/app/api/v1/compare.py @@ -116,3 +116,11 @@ async def intent_precoupon_step(request: Request) -> dict[str, Any]: @router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)") async def price_step(request: Request) -> dict[str, Any]: return await _passthrough(request, "/api/price/step") + + +@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传到 pricebot, 终止/未识别拿 trace_url)") +async def trace_finalize(request: Request) -> dict[str, Any]: + # 用户终止 / Phase1 未识别没走到 done 帧, pricebot 没上云也没回传 trace_url。客户端收尾时 + # 打这个, _passthrough 按 trace_id 一致性 hash 落到处理这条 trace 的同一 pricebot 进程 + # (dir_cache 在那, 才能算对 trace 目录), 由后者打包上云返回 {trace_url}。 + return await _passthrough(request, "/api/trace/finalize") diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 8e527dc..d53dd15 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -73,13 +73,20 @@ def list_records( db: DbSession, limit: int = Query(20, ge=1, le=100), cursor: int | None = Query(None, description="上一页末条 id"), + include_trace: bool = Query( + False, + description="客户端开了本机 agent 调试模式时带 true,放行本人记录的 trace_url", + ), ) -> ComparisonRecordPage: items, next_cursor = crud_compare.list_records( db, user.id, limit=limit, cursor=cursor ) outs = [ComparisonRecordOut.model_validate(it) for it in items] - # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它) - if not user.debug_trace_enabled: + # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)。 + # include_trace=true 例外:客户端开了本机 agent 调试模式时带上,放行**本人记录**的 trace_url + # ——list_records 只查 user.id 自己的记录,给本人看自己的调试链接无越权,与实时结果页 + # CompResultScreen「debug 权限 OR 本机 agent 调试」同口径(领导 2026-06-12 拍板)。 + if not (user.debug_trace_enabled or include_trace): for o in outs: o.trace_url = None return ComparisonRecordPage(items=outs, next_cursor=next_cursor) From 3a40f617bdc5d49eba020ef05fb0b73e27feef75 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 19:21:20 +0800 Subject: [PATCH 03/20] =?UTF-8?q?feat(cps):=20=E5=90=8E=E7=AB=AF=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=B7=98=E5=AE=9D/=E4=BA=AC=E4=B8=9C=E5=A4=9A?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0(payload=20+=20=E7=BE=A4=E5=A4=9A=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=20+=20=E5=A4=8D=E5=88=B6=E7=BB=9F=E8=AE=A1=20+=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model: cps_group.platforms 多选 / cps_activity.payload(淘口令/京东链接) / cps_link.sid 可空 + 复制统计 - admin API: 群与活动平台多选、批量生成落地页短链 referral-links、对账字段可空 - 落地页 cps_redirect: 淘宝展示淘口令(记 copy 事件) / 京东 302 / 美团原逻辑 - 迁移 cps_v2_platforms: 加 platforms/payload/event_type 列, sid 放宽可空(含 downgrade) Co-Authored-By: Claude Opus 4.8 --- alembic/versions/cps_v2_platforms.py | 61 ++++++++++ app/admin/repositories/cps.py | 103 ++++++++++------- app/admin/routers/cps.py | 165 ++++++++++++++++----------- app/admin/schemas/cps.py | 81 +++++++------ app/api/v1/cps_redirect.py | 121 ++++++++++++++++++-- app/models/cps_activity.py | 4 +- app/models/cps_group.py | 11 +- app/models/cps_link.py | 13 ++- app/repositories/cps_link.py | 35 ++++-- 9 files changed, 422 insertions(+), 172 deletions(-) create mode 100644 alembic/versions/cps_v2_platforms.py diff --git a/alembic/versions/cps_v2_platforms.py b/alembic/versions/cps_v2_platforms.py new file mode 100644 index 0000000..e80c1a6 --- /dev/null +++ b/alembic/versions/cps_v2_platforms.py @@ -0,0 +1,61 @@ +"""cps v2: 活动 payload(淘口令/京东链接) + 群多平台/sid 可空 + 点击 event_type + +Revision ID: cps_v2_platforms +Revises: cps_link_tables +Create Date: 2026-06-17 11:00:00.000000 + +接入淘宝/京东: +- cps_activity.payload 淘宝整段淘口令文本 / 京东推广链接(美团仍用 act_id) +- cps_group.platforms 该群发哪些平台(多选);sid 改可空(纯淘宝/京东无 sid) +- cps_link.sid 可空 + target_url 加长(淘口令较长) +- cps_click.event_type visit / copy(淘宝落地页"复制口令") +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "cps_v2_platforms" +down_revision: Union[str, Sequence[str], None] = "cps_link_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +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"), + ), + ) + op.alter_column("cps_group", "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)) + + # 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"), + ) + + +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) + op.drop_column("cps_activity", "payload") diff --git a/app/admin/repositories/cps.py b/app/admin/repositories/cps.py index 17516e2..6733de9 100644 --- a/app/admin/repositories/cps.py +++ b/app/admin/repositories/cps.py @@ -71,19 +71,23 @@ def list_groups( def create_group( - db: Session, *, name: str, sid: str | None = None, + db: Session, *, name: str, platforms: list[str], sid: str | None = None, member_count: int | None = None, remark: str | None = None, commit: bool = True, ) -> CpsGroup: - """建群。sid 留空 → 自动 g(先 flush 拿 id 再回填)。""" + """建群。含 meituan 才有 sid(留空自动 g);纯淘宝/京东 sid=None(那俩不支持 sid)。""" + has_meituan = "meituan" in (platforms or []) + # 含美团:用填的 sid,或临时占位待回填 g;不含美团:sid 恒 None + seed_sid = (sid or f"tmp{uuid4().hex[:20]}") if has_meituan else None group = CpsGroup( name=name, - sid=sid or f"tmp{uuid4().hex[:20]}", # 临时唯一占位,留空时下面回填 g + platforms=list(platforms or []), + sid=seed_sid, member_count=member_count, remark=remark, ) db.add(group) db.flush() - if not sid: + if has_meituan and not sid: group.sid = f"g{group.id}" db.flush() if commit: @@ -94,11 +98,14 @@ def create_group( def update_group( db: Session, group: CpsGroup, *, name: str | None = None, + platforms: list[str] | None = None, member_count: int | None = None, status: str | None = None, remark: str | None = None, commit: bool = True, ) -> CpsGroup: if name is not None: group.name = name + if platforms is not None: + group.platforms = list(platforms) if member_count is not None: group.member_count = member_count if status is not None: @@ -132,11 +139,12 @@ def list_activities( def create_activity( db: Session, *, name: str, platform: str = "meituan", act_id: str | None = None, - product_view_sign: str | None = None, remark: str | None = None, commit: bool = True, + product_view_sign: str | None = None, payload: str | None = None, + remark: str | None = None, commit: bool = True, ) -> CpsActivity: activity = CpsActivity( name=name, platform=platform, act_id=act_id, - product_view_sign=product_view_sign, remark=remark, + product_view_sign=product_view_sign, payload=payload, remark=remark, ) db.add(activity) if commit: @@ -233,55 +241,68 @@ def group_stats( """按 sid 聚合订单 + join 群信息。已建但本期无单的活跃群也列出(全 0)。 未归群的 sid(历史/其它来源)单独成行 group_id=None。按预估佣金降序。 """ - stmt = select(CpsOrder) + ostmt = select(CpsOrder) if date_from is not None: - stmt = stmt.where(CpsOrder.pay_time >= _as_utc(date_from)) + ostmt = ostmt.where(CpsOrder.pay_time >= _as_utc(date_from)) if date_to is not None: - stmt = stmt.where(CpsOrder.pay_time <= _as_utc(date_to)) - orders = list(db.execute(stmt).scalars().all()) + ostmt = ostmt.where(CpsOrder.pay_time <= _as_utc(date_to)) + orders_by_sid: dict[str | None, list[CpsOrder]] = {} + for o in db.execute(ostmt).scalars().all(): + orders_by_sid.setdefault(o.sid, []).append(o) - groups = {g.sid: g for g in db.execute(select(CpsGroup)).scalars().all()} + clicks = cps_link_repo.click_stats_by_group(db, date_from=date_from, date_to=date_to) + groups = list(db.execute(select(CpsGroup)).scalars().all()) - buckets: dict[str | None, list[CpsOrder]] = {} - for o in orders: - buckets.setdefault(o.sid, []).append(o) - - rows: list[dict] = [] - for sid, items in buckets.items(): - g = groups.get(sid) if sid else None + def _order_agg(items: list[CpsOrder]) -> dict: valid = [o for o in items if o.mt_status not in _INVALID_STATUS] settled = [o for o in items if o.mt_status == _SETTLED_STATUS] canceled = [o for o in items if o.mt_status in _INVALID_STATUS] - rows.append({ - "group_id": g.id if g else None, - "sid": sid, - "name": g.name if g else (sid or "(无 sid 归属)"), - "member_count": g.member_count if g else None, + return { "order_count": len(valid), "settled_count": len(settled), "canceled_count": len(canceled), "gmv_cents": sum(o.pay_price_cents or 0 for o in valid), "est_commission_cents": sum(o.commission_cents or 0 for o in valid), "settled_commission_cents": sum(o.commission_cents or 0 for o in settled), - }) + } - # 已建的活跃群但本期无单 → 补 0 行,让运营看到全部群 - seen = set(buckets.keys()) - for sid, g in groups.items(): - if sid not in seen and g.status == "active": - rows.append({ - "group_id": g.id, "sid": sid, "name": g.name, - "member_count": g.member_count, "order_count": 0, - "settled_count": 0, "canceled_count": 0, "gmv_cents": 0, - "est_commission_cents": 0, "settled_commission_cents": 0, - }) + # 淘宝/京东无法对账 → 对账字段全 None(前端显示 "-") + _no_recon = { + "order_count": None, "settled_count": None, "canceled_count": None, + "gmv_cents": None, "est_commission_cents": None, "settled_commission_cents": None, + } - # 合并点击数据(按 group_id):点击只对生成过我们短链的群有,未归群行(group_id=None)恒 0 - clicks = cps_link_repo.click_stats_by_group(db, date_from=date_from, date_to=date_to) - for r in rows: - c = clicks.get(r["group_id"]) if r["group_id"] is not None else None - r["click_pv"] = c["pv"] if c else 0 - r["click_uv"] = c["uv"] if c else 0 + rows: list[dict] = [] + seen_sids: set[str] = set() + for g in groups: + if g.status != "active": + continue + c = clicks.get(g.id) or {} + row = { + "group_id": g.id, "sid": g.sid, "name": g.name, + "platforms": list(g.platforms or []), "member_count": g.member_count, + "click_pv": c.get("pv", 0), "click_uv": c.get("uv", 0), + "copy_pv": c.get("copy_pv", 0), "copy_uv": c.get("copy_uv", 0), + } + # 对账只对美团群(有 sid);淘宝/京东 → None + if "meituan" in (g.platforms or []) and g.sid: + row.update(_order_agg(orders_by_sid.get(g.sid, []))) + seen_sids.add(g.sid) + else: + row.update(_no_recon) + rows.append(row) - rows.sort(key=lambda x: x["est_commission_cents"], reverse=True) + # 未归群的历史美团 sid(如 wonderableai):单列,有对账无点击 + for sid, items in orders_by_sid.items(): + if sid is None or sid in seen_sids: + continue + row = { + "group_id": None, "sid": sid, "name": sid, "platforms": ["meituan"], + "member_count": None, "click_pv": 0, "click_uv": 0, "copy_pv": 0, "copy_uv": 0, + } + row.update(_order_agg(items)) + rows.append(row) + + # 佣金降序(None 当 0),次按点击 + rows.sort(key=lambda x: ((x["est_commission_cents"] or 0), x["click_pv"]), reverse=True) return rows diff --git a/app/admin/routers/cps.py b/app/admin/routers/cps.py index 592f363..5eae8fe 100644 --- a/app/admin/routers/cps.py +++ b/app/admin/routers/cps.py @@ -1,6 +1,7 @@ -"""admin CPS 分发与对账:群(sid)管理 + 活动池 + 生成带 sid 券链接 + 美团订单对账 + 统计。 +"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。 -当前仅接美团(get_referral_link / query_order)。淘宝/京东待各自联盟凭证。 +平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。 +淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。 群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。 """ from __future__ import annotations @@ -15,8 +16,6 @@ from app.admin.audit import write_audit from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role from app.admin.repositories import cps as cps_repo from app.admin.schemas.common import CursorPage -from app.core.config import settings -from app.repositories import cps_link as cps_link_repo from app.admin.schemas.cps import ( CpsActivityCreate, CpsActivityOut, @@ -26,13 +25,18 @@ from app.admin.schemas.cps import ( CpsGroupUpdate, CpsOrderOut, CpsReconcileResult, - CpsReferralLinkOut, - CpsReferralLinkRequest, + CpsReferralLinkItem, + CpsReferralLinksOut, + CpsReferralLinksRequest, CpsStatsOut, ) +from app.core.config import settings from app.integrations import meituan from app.integrations.meituan import MeituanCpsError from app.models.admin import AdminUser +from app.models.cps_activity import CpsActivity +from app.models.cps_group import CpsGroup +from app.repositories import cps_link as cps_link_repo router = APIRouter( prefix="/admin/api/cps", @@ -40,9 +44,11 @@ router = APIRouter( dependencies=[Depends(get_current_admin)], ) +_VALID_PLATFORMS = {"meituan", "taobao", "jd"} -# ───────────── 群(sid) ───────────── -@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群(sid)列表") + +# ───────────── 群 ───────────── +@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群列表") def list_groups( db: AdminDb, keyword: Annotated[str | None, Query(max_length=100)] = None, @@ -60,22 +66,27 @@ def list_groups( ) -@router.post("/groups", response_model=CpsGroupOut, summary="新建群(分配 sid)") +@router.post("/groups", response_model=CpsGroupOut, summary="新建群") def create_group( body: CpsGroupCreate, request: Request, admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> CpsGroupOut: - if body.sid and cps_repo.get_group_by_sid(db, body.sid) is not None: + bad = [p for p in body.platforms if p not in _VALID_PLATFORMS] + if bad: + raise HTTPException(status_code=400, detail=f"非法平台: {bad}") + has_meituan = "meituan" in body.platforms + if has_meituan and body.sid and cps_repo.get_group_by_sid(db, body.sid) is not None: raise HTTPException(status_code=409, detail="sid 已存在") group = cps_repo.create_group( - db, name=body.name, sid=body.sid, member_count=body.member_count, - remark=body.remark, commit=False, + db, name=body.name, platforms=body.platforms, sid=body.sid, + member_count=body.member_count, remark=body.remark, commit=False, ) write_audit( db, admin, action="cps.group.create", target_type="cps_group", target_id=group.id, - detail={"sid": group.sid, "name": group.name}, ip=get_client_ip(request), commit=False, + detail={"name": group.name, "platforms": group.platforms, "sid": group.sid}, + ip=get_client_ip(request), commit=False, ) db.commit() db.refresh(group) @@ -93,9 +104,13 @@ def update_group( group = cps_repo.get_group(db, group_id) if group is None: raise HTTPException(status_code=404, detail="群不存在") + if body.platforms is not None: + bad = [p for p in body.platforms if p not in _VALID_PLATFORMS] + if bad: + raise HTTPException(status_code=400, detail=f"非法平台: {bad}") cps_repo.update_group( - db, group, name=body.name, member_count=body.member_count, - status=body.status, remark=body.remark, commit=False, + db, group, name=body.name, platforms=body.platforms, + member_count=body.member_count, status=body.status, remark=body.remark, commit=False, ) write_audit( db, admin, action="cps.group.update", target_type="cps_group", target_id=group_id, @@ -132,15 +147,21 @@ def create_activity( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> CpsActivityOut: - if not body.act_id and not body.product_view_sign: - raise HTTPException(status_code=400, detail="act_id 或 product_view_sign 必填其一") + if body.platform == "meituan": + if not body.act_id and not body.product_view_sign: + raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign") + else: # taobao / jd + if not body.payload: + label = "淘口令" if body.platform == "taobao" else "推广链接" + raise HTTPException(status_code=400, detail=f"{body.platform} 活动需填{label}") activity = cps_repo.create_activity( db, name=body.name, platform=body.platform, act_id=body.act_id, - product_view_sign=body.product_view_sign, remark=body.remark, commit=False, + product_view_sign=body.product_view_sign, payload=body.payload, + remark=body.remark, commit=False, ) write_audit( db, admin, action="cps.activity.create", target_type="cps_activity", target_id=activity.id, - detail={"name": activity.name, "act_id": activity.act_id, "platform": activity.platform}, + detail={"name": activity.name, "platform": activity.platform}, ip=get_client_ip(request), commit=False, ) db.commit() @@ -148,65 +169,71 @@ def create_activity( return CpsActivityOut.model_validate(activity) -# ───────────── 转链 ───────────── -@router.post("/referral-link", response_model=CpsReferralLinkOut, summary="生成群发短链(带 sid + 点击统计)") -def generate_referral_link( - body: CpsReferralLinkRequest, +# ───────────── 生成链接(批量,按平台分支) ───────────── +def _gen_one_link(db, group: CpsGroup, activity: CpsActivity): + """给一个活动生成一条落地页 link。美团:转链拿短链(需群 sid);淘宝/京东:用 payload。""" + if activity.platform == "meituan": + if not group.sid: + raise HTTPException(status_code=400, detail=f"群「{group.name}」无 sid,无法生成美团链接") + try: + resp = meituan.get_referral_link( + act_id=activity.act_id, product_view_sign=activity.product_view_sign, + sid=group.sid, link_type_list=[1, 2, 3], + ) + except MeituanCpsError as e: + raise HTTPException(status_code=502, detail=f"美团转链失败: {e}") from e + link_map = {str(k): v for k, v in (resp.get("referralLinkMap") or {}).items()} + if not link_map and resp.get("data"): + link_map = {"1": resp["data"]} + target = link_map.get("2") or link_map.get("1") or link_map.get("3") + if not target: + raise HTTPException(status_code=502, detail=f"美团未返回有效链接(活动「{activity.name}」)") + else: # taobao(淘口令) / jd(链接) + if not activity.payload: + raise HTTPException(status_code=400, detail=f"活动「{activity.name}」缺少内容") + target = activity.payload + return cps_link_repo.create_link( + db, group_id=group.id, activity_id=activity.id, sid=group.sid, + target_url=target, platform=activity.platform, commit=False, + ) + + +@router.post("/referral-links", response_model=CpsReferralLinksOut, summary="批量生成落地页短链") +def generate_referral_links( + body: CpsReferralLinksRequest, request: Request, admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, -) -> CpsReferralLinkOut: +) -> CpsReferralLinksOut: group = cps_repo.get_group(db, body.group_id) if group is None: raise HTTPException(status_code=404, detail="群不存在") - activity = cps_repo.get_activity(db, body.activity_id) - if activity is None: - raise HTTPException(status_code=404, detail="活动不存在") - if activity.platform != "meituan": - raise HTTPException(status_code=400, detail=f"当前仅支持美团转链(活动平台={activity.platform})") - try: - # 全要:短链作跳转目标(微信可打开),长链/deeplink 作参考/备用 - resp = meituan.get_referral_link( - act_id=activity.act_id, - product_view_sign=activity.product_view_sign, - sid=group.sid, - link_type_list=[1, 2, 3], - ) - except MeituanCpsError as e: - raise HTTPException(status_code=502, detail=f"美团转链失败: {e}") from e + group_platforms = set(group.platforms or []) + base = settings.CPS_REDIRECT_BASE.rstrip("/") - link_map = {str(k): v for k, v in (resp.get("referralLinkMap") or {}).items()} - if not link_map and resp.get("data"): - link_map = {"1": resp["data"]} - # 跳转目标优先短链(2,微信可打开)→ 长链(1)→ deeplink(3) - target_url = link_map.get("2") or link_map.get("1") or link_map.get("3") - if not target_url: - raise HTTPException(status_code=502, detail="美团未返回有效链接(检查 actId 是否在推广有效期)") + items: list[CpsReferralLinkItem] = [] + for aid in body.activity_ids: + activity = cps_repo.get_activity(db, aid) + if activity is None: + raise HTTPException(status_code=404, detail=f"活动 {aid} 不存在") + if activity.platform not in group_platforms: + raise HTTPException( + status_code=400, + detail=f"活动「{activity.name}」平台({activity.platform})不在群「{group.name}」范围内", + ) + link = _gen_one_link(db, group, activity) + items.append(CpsReferralLinkItem( + activity_id=activity.id, activity_name=activity.name, platform=activity.platform, + redirect_url=f"{base}/c/{link.code}" if base else f"/c/{link.code}", code=link.code, + )) - # 存我们的短链:发群用 /c/{code},用户点 → 我们记点击 → 302 跳 target_url - link = cps_link_repo.create_link( - db, group_id=group.id, activity_id=activity.id, sid=group.sid, - target_url=target_url, platform=activity.platform, commit=False, - ) write_audit( - db, admin, action="cps.referral_link.generate", target_type="cps_link", target_id=link.id, - detail={"sid": group.sid, "activity_id": activity.id, "code": link.code}, + db, admin, action="cps.referral_link.generate", target_type="cps_group", target_id=group.id, + detail={"group": group.name, "activity_ids": body.activity_ids, "count": len(items)}, ip=get_client_ip(request), commit=False, ) db.commit() - db.refresh(link) - - base = settings.CPS_REDIRECT_BASE.rstrip("/") - redirect_url = f"{base}/c/{link.code}" if base else f"/c/{link.code}" - return CpsReferralLinkOut( - redirect_url=redirect_url, - code=link.code, - sid=group.sid, - group_name=group.name, - activity_name=activity.name, - target_url=target_url, - link_map=link_map, - ) + return CpsReferralLinksOut(group_name=group.name, results=items) # ───────────── 订单对账 ───────────── @@ -262,7 +289,7 @@ def get_stats( stats = [CpsGroupStat(**r) for r in rows] return CpsStatsOut( groups=stats, - total_order_count=sum(s.order_count for s in stats), - total_est_commission_cents=sum(s.est_commission_cents for s in stats), - total_settled_commission_cents=sum(s.settled_commission_cents for s in stats), + total_order_count=sum(s.order_count or 0 for s in stats), + total_est_commission_cents=sum(s.est_commission_cents or 0 for s in stats), + total_settled_commission_cents=sum(s.settled_commission_cents or 0 for s in stats), ) diff --git a/app/admin/schemas/cps.py b/app/admin/schemas/cps.py index 9028fe1..5ca7dbb 100644 --- a/app/admin/schemas/cps.py +++ b/app/admin/schemas/cps.py @@ -1,4 +1,8 @@ -"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。""" +"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。 + +平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)。 +对账类字段对淘宝/京东为 None → 前端显示 "-"(无法对账)。 +""" from __future__ import annotations from datetime import datetime @@ -6,13 +10,14 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field -# ───────────── 群(sid) ───────────── +# ───────────── 群 ───────────── class CpsGroupOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int - sid: str + sid: str | None = None # 仅美团群有 name: str + platforms: list[str] = Field(default_factory=list) member_count: int | None = None status: str remark: str | None = None @@ -21,7 +26,9 @@ class CpsGroupOut(BaseModel): class CpsGroupCreate(BaseModel): name: str = Field(min_length=1, max_length=128) - # 留空 → 后端自动生成 g;填写则必须字母+数字(美团 sid 限制)。 + # 该群发哪些平台,至少选一个 + platforms: list[str] = Field(min_length=1) + # 仅当含 meituan 时需要(留空自动生成);纯淘宝/京东不填,后端置 None。 sid: str | None = Field(default=None, max_length=64, pattern=r"^[A-Za-z0-9]+$") member_count: int | None = Field(default=None, ge=0) remark: str | None = Field(default=None, max_length=256) @@ -29,12 +36,13 @@ class CpsGroupCreate(BaseModel): class CpsGroupUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=128) + platforms: list[str] | None = None member_count: int | None = Field(default=None, ge=0) status: str | None = Field(default=None, pattern=r"^(active|archived)$") remark: str | None = Field(default=None, max_length=256) -# ───────────── 活动(actId) ───────────── +# ───────────── 活动 ───────────── class CpsActivityOut(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -43,6 +51,7 @@ class CpsActivityOut(BaseModel): name: str act_id: str | None = None product_view_sign: str | None = None + payload: str | None = None # 淘宝淘口令 / 京东链接 status: str remark: str | None = None created_at: datetime @@ -50,36 +59,40 @@ class CpsActivityOut(BaseModel): class CpsActivityCreate(BaseModel): name: str = Field(min_length=1, max_length=128) - platform: str = Field(default="meituan", pattern=r"^(meituan|taobao|jd)$") + platform: str = Field(pattern=r"^(meituan|taobao|jd)$") + # 美团:act_id 或 product_view_sign 二选一 act_id: str | None = Field(default=None, max_length=64) product_view_sign: str | None = Field(default=None, max_length=128) + # 淘宝:整段淘口令文本 / 京东:推广链接 + payload: str | None = Field(default=None, max_length=4096) remark: str | None = Field(default=None, max_length=256) -# ───────────── 转链 ───────────── -class CpsReferralLinkRequest(BaseModel): +# ───────────── 生成链接(批量) ───────────── +class CpsReferralLinksRequest(BaseModel): group_id: int + activity_ids: list[int] = Field(min_length=1) + + +class CpsReferralLinkItem(BaseModel): activity_id: int - # 链接类型:1长链 2短链 3deeplink。默认短链(微信可打开)+deeplink。 - link_types: list[int] = Field(default_factory=lambda: [2, 3]) - - -class CpsReferralLinkOut(BaseModel): - redirect_url: str # ★ 我们的群发短链(/c/{code}),发群就用这个,点击经我们统计 - code: str - sid: str - group_name: str activity_name: str - target_url: str # 实际 302 跳转的美团短链 - link_map: dict[str, str] # 美团原始 1长链/2短链/3deeplink(参考/备用) + platform: str + redirect_url: str # 我们的落地页 /c/{code}(发群用) + code: str + + +class CpsReferralLinksOut(BaseModel): + group_name: str + results: list[CpsReferralLinkItem] # ───────────── 对账拉单 ───────────── class CpsReconcileResult(BaseModel): - fetched: int # 本次美团返回的订单条数 - inserted: int # 新入库 - updated: int # 已存在→更新状态/佣金 - pages: int # 翻了几页 + fetched: int + inserted: int + updated: int + pages: int # ───────────── 订单明细 ───────────── @@ -101,18 +114,22 @@ class CpsOrderOut(BaseModel): # ───────────── 统计 ───────────── class CpsGroupStat(BaseModel): - group_id: int | None = None # None = 未归群的 sid(历史/其它来源) + group_id: int | None = None sid: str | None = None name: str + platforms: list[str] = Field(default_factory=list) member_count: int | None = None - click_pv: int = 0 # 点击总次数 - click_uv: int = 0 # 独立点击(按 ip+ua 近似去重) - order_count: int # 有效订单数(不含取消/风控) - settled_count: int # 已结算单数 - canceled_count: int # 取消+风控单数 - gmv_cents: int # 成交额(有效单 payPrice 合计) - est_commission_cents: int # 预估佣金(有效单 profit 合计) - settled_commission_cents: int # 已结算佣金 + click_pv: int = 0 + click_uv: int = 0 + copy_pv: int = 0 # 淘宝"复制口令"次数 + copy_uv: int = 0 + # 对账类:淘宝/京东无法对账 → None(前端显示 "-");美团群为数值 + order_count: int | None = None + settled_count: int | None = None + canceled_count: int | None = None + gmv_cents: int | None = None + est_commission_cents: int | None = None + settled_commission_cents: int | None = None class CpsStatsOut(BaseModel): diff --git a/app/api/v1/cps_redirect.py b/app/api/v1/cps_redirect.py index 3b4120c..f69fab3 100644 --- a/app/api/v1/cps_redirect.py +++ b/app/api/v1/cps_redirect.py @@ -1,19 +1,24 @@ -"""CPS 群发短链跳转:用户点 /c/{code} → 记一条点击 → 302 跳美团。 +"""CPS 群发短链落地:用户点 /c/{code} → 记点击 → 按平台 302 跳 或 返回淘宝落地页。 -公网、无鉴权(群里任何人点都要能跳)。记点击失败绝不影响跳转(用户体验优先)。 +公网无鉴权(群里任何人点都要能跳/能领)。记点击失败绝不影响用户。 +- 美团/京东:记 visit + 302 跳 target(美团短链 / 京东链接) +- 淘宝:记 visit + 返回 H5 落地页(整段淘口令复制按钮);点"复制口令"→ POST /c/{code}/copy 记 copy """ from __future__ import annotations +import json + from fastapi import APIRouter, Depends, Request -from fastapi.responses import RedirectResponse +from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy.orm import Session from app.db.session import get_db +from app.models.cps_link import CpsLink from app.repositories import cps_link as cps_link_repo router = APIRouter(tags=["cps-redirect"]) -# code 不存在/失效时的兜底落地(避免用户看到报错) +# code 不存在/失效时的兜底落地 _FALLBACK_URL = "https://www.meituan.com/" @@ -24,16 +29,110 @@ def _client_ip(request: Request) -> str | None: return request.client.host if request.client else None -@router.get("/c/{code}", summary="群发短链跳转(记点击 + 302)") -def cps_redirect(code: str, request: Request, db: Session = Depends(get_db)): - link = cps_link_repo.get_by_code(db, code) - if link is None: - return RedirectResponse(_FALLBACK_URL, status_code=302) +def _record(db: Session, link: CpsLink, request: Request, event_type: str) -> None: try: cps_link_repo.record_click( db, link=link, ip=_client_ip(request), - ua=request.headers.get("user-agent"), + ua=request.headers.get("user-agent"), event_type=event_type, ) except Exception: - pass # 记点击失败不阻断跳转 + pass # 记点击失败不阻断 + + +@router.get("/c/{code}", summary="群发短链落地(记点击 + 跳转/淘宝落地页)") +def cps_landing(code: str, request: Request, db: Session = Depends(get_db)): + link = cps_link_repo.get_by_code(db, code) + if link is None: + return RedirectResponse(_FALLBACK_URL, status_code=302) + _record(db, link, request, "visit") + if link.platform == "taobao": + return HTMLResponse(_taobao_landing_html(link.target_url)) + # 美团短链 / 京东链接:直接 302 跳 return RedirectResponse(link.target_url, status_code=302) + + +@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件)") +def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict: + link = cps_link_repo.get_by_code(db, code) + if link is not None: + _record(db, link, request, "copy") + return {"ok": True} + + +def _taobao_landing_html(token: str) -> str: + """淘宝落地页 H5(红色喜庆,两步引导,底部复制淘口令按钮)。token 安全嵌入 JS。""" + return _TAOBAO_HTML.replace("__TOKEN_JS__", json.dumps(token)) + + +_TAOBAO_HTML = """ + + + + +淘宝闪购 · 天天领红包 + + + + +
+
最高 15-15 红包 天天可领!
+
1
第一步:点击下方按钮 复制淘口令
+
2
第二步:打开 淘宝 App,自动弹出领取红包
+
+
外卖红包 · 今日已到账
+
明天继续来领哦
+
¥15满15可用
外卖红包
仅限于美食外卖
去使用
+
¥7满9可用
外卖红包
仅限于美食外卖
去使用
+
+
+
+
+ + +""" diff --git a/app/models/cps_activity.py b/app/models/cps_activity.py index d52823a..383f854 100644 --- a/app/models/cps_activity.py +++ b/app/models/cps_activity.py @@ -8,7 +8,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy import DateTime, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -24,6 +24,8 @@ class CpsActivity(Base): act_id: Mapped[str | None] = mapped_column(String(64), nullable=True) # 美团商品券 productViewSign(与 act_id 二选一转链;多数活动用 act_id)。 product_view_sign: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 淘宝:整段淘口令文本(落地页原样复制);京东:推广链接(落地页 302 跳)。美团不用这个(用 act_id)。 + payload: Mapped[str | None] = mapped_column(Text, nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived remark: Mapped[str | None] = mapped_column(String(256), nullable=True) created_at: Mapped[datetime] = mapped_column( diff --git a/app/models/cps_group.py b/app/models/cps_group.py index fcd40fd..8a9e516 100644 --- a/app/models/cps_group.py +++ b/app/models/cps_group.py @@ -7,7 +7,8 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy import JSON, DateTime, Integer, String, func +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -17,9 +18,13 @@ class CpsGroup(Base): __tablename__ = "cps_group" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - # 每群唯一的渠道标识,建群时分配(留空则自动生成 g)。字母+数字,≤64。 - sid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + # 渠道标识。仅美团需要(sid 追踪);纯淘宝/京东群无 sid(那俩不支持)。含美团时建群分配。 + sid: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True) name: Mapped[str] = mapped_column(String(128), nullable=False) + # 该群发哪些平台的券(多选): ["meituan","taobao","jd"]。含 meituan 才需要 sid。 + platforms: Mapped[list] = mapped_column( + JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=list + ) # 群人数,运营填,作转化率分母(可空)。 member_count: Mapped[int | None] = mapped_column(Integer, nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived diff --git a/app/models/cps_link.py b/app/models/cps_link.py index ef53859..2702c8a 100644 --- a/app/models/cps_link.py +++ b/app/models/cps_link.py @@ -23,10 +23,11 @@ class CpsLink(Base): code: Mapped[str] = mapped_column(String(16), unique=True, index=True, nullable=False) group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) activity_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) - sid: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + # 仅美团 link 有 sid(渠道追踪);淘宝/京东无 sid。 + sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) platform: Mapped[str] = mapped_column(String(20), nullable=False, default="meituan") - # 302 跳转目标(美团短链,微信内可打开并唤起 App)。 - target_url: Mapped[str] = mapped_column(String(1024), nullable=False) + # 跳转/落地目标: 美团短链 / 京东链接(302 跳) / 淘宝整段淘口令文本(H5 落地页复制)。 + target_url: Mapped[str] = mapped_column(String(2048), nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) @@ -41,7 +42,11 @@ class CpsClick(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) link_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) # 冗余,按群聚合快 - sid: Mapped[str] = mapped_column(String(64), index=True, nullable=False) # 冗余 + sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 冗余(淘宝/京东无) + # 事件类型: visit(进落地页/被跳转) | copy(淘宝落地页点了"复制口令") + event_type: Mapped[str] = mapped_column( + String(16), nullable=False, default="visit", server_default="visit" + ) ip: Mapped[str | None] = mapped_column(String(64), nullable=True) ua: Mapped[str | None] = mapped_column(String(512), nullable=True) clicked_at: Mapped[datetime] = mapped_column( diff --git a/app/repositories/cps_link.py b/app/repositories/cps_link.py index 9991ff8..2e34970 100644 --- a/app/repositories/cps_link.py +++ b/app/repositories/cps_link.py @@ -26,7 +26,7 @@ def get_by_code(db: Session, code: str) -> CpsLink | None: def create_link( - db: Session, *, group_id: int, activity_id: int, sid: str, target_url: str, + db: Session, *, group_id: int, activity_id: int, sid: str | None, target_url: str, platform: str = "meituan", commit: bool = True, ) -> CpsLink: """生成唯一短码并存库。冲突重试 5 次,仍冲突用 12 位兜底(概率近 0)。""" @@ -50,9 +50,13 @@ def create_link( return link -def record_click(db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None) -> None: +def record_click( + db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None, + event_type: str = "visit", +) -> None: + """记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。""" db.add(CpsClick( - link_id=link.id, group_id=link.group_id, sid=link.sid, + link_id=link.id, group_id=link.group_id, sid=link.sid, event_type=event_type, ip=ip, ua=(ua[:500] if ua else None), )) db.commit() @@ -61,7 +65,8 @@ def record_click(db: Session, *, link: CpsLink, ip: str | None = None, ua: str | def click_stats_by_group( db: Session, *, date_from: datetime | None = None, date_to: datetime | None = None, ) -> dict[int, dict]: - """按 group_id 聚合点击 → {group_id: {"pv": N, "uv": M}}。uv 按 (ip, ua) 近似去重。""" + """按 group_id 聚合点击 → {group_id: {pv, uv, copy_pv, copy_uv}}。 + visit 计入 pv/uv,copy(淘宝复制口令) 计入 copy_pv/copy_uv;uv 按 (ip, ua) 近似去重。""" stmt = select(CpsClick) if date_from is not None: stmt = stmt.where(CpsClick.clicked_at >= date_from) @@ -70,13 +75,21 @@ def click_stats_by_group( clicks = list(db.execute(stmt).scalars().all()) agg: dict[int, dict] = {} - seen: dict[int, set] = {} + seen_visit: dict[int, set] = {} + seen_copy: dict[int, set] = {} for c in clicks: - a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0}) - a["pv"] += 1 - s = seen.setdefault(c.group_id, set()) + a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0, "copy_pv": 0, "copy_uv": 0}) key = (c.ip, c.ua) - if key not in s: - s.add(key) - a["uv"] += 1 + if c.event_type == "copy": + a["copy_pv"] += 1 + s = seen_copy.setdefault(c.group_id, set()) + if key not in s: + s.add(key) + a["copy_uv"] += 1 + else: + a["pv"] += 1 + s = seen_visit.setdefault(c.group_id, set()) + if key not in s: + s.add(key) + a["uv"] += 1 return agg From 7368a1ca8acca8626615b550191a315bdaea9114 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 20:53:34 +0800 Subject: [PATCH 04/20] =?UTF-8?q?feat(cps):=20=E6=B7=98=E5=AE=9D=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E9=A1=B5=E6=94=B9=E7=94=A8=E8=AE=BE=E8=AE=A1=E5=9B=BE?= =?UTF-8?q?=E4=B8=BB=E8=A7=86=E8=A7=89=20+=20=E5=A4=8D=E5=88=B6=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E4=B8=8A=E6=8F=90=E8=87=B3=2075%=20=E5=B1=8F=E9=AB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - banner/card 手画区域整体换成设计图 /media/taobao_landing.jpg(~240KB jpg) - 复制淘口令按钮从底部固定上提到 top:75vh, 上方留白 - 图入库(data/media 白名单, 同 dl.html 先例), 同域 /media serve - 淘口令注入 / 复制 / toast 逻辑不变 Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + app/api/v1/cps_redirect.py | 43 ++++------------------------------ data/media/taobao_landing.jpg | Bin 0 -> 245022 bytes 3 files changed, 6 insertions(+), 38 deletions(-) create mode 100644 data/media/taobao_landing.jpg diff --git a/.gitignore b/.gitignore index bd48023..d473d6b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ data/* !data/media/ data/media/* !data/media/dl.html +!data/media/taobao_landing.jpg secrets/* !secrets/.gitkeep diff --git a/app/api/v1/cps_redirect.py b/app/api/v1/cps_redirect.py index f69fab3..0c604dd 100644 --- a/app/api/v1/cps_redirect.py +++ b/app/api/v1/cps_redirect.py @@ -60,7 +60,7 @@ def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict def _taobao_landing_html(token: str) -> str: - """淘宝落地页 H5(红色喜庆,两步引导,底部复制淘口令按钮)。token 安全嵌入 JS。""" + """淘宝落地页 H5(主视觉为设计图 /media/taobao_landing.jpg,复制淘口令按钮在 75% 屏高)。token 安全嵌入 JS。""" return _TAOBAO_HTML.replace("__TOKEN_JS__", json.dumps(token)) @@ -72,27 +72,9 @@ _TAOBAO_HTML = """ 淘宝闪购 · 天天领红包 - -
-
最高 15-15 红包 天天可领!
-
1
第一步:点击下方按钮 复制淘口令
-
2
第二步:打开 淘宝 App,自动弹出领取红包
-
-
外卖红包 · 今日已到账
-
明天继续来领哦
-
¥15满15可用
外卖红包
仅限于美食外卖
去使用
-
¥7满9可用
外卖红包
仅限于美食外卖
去使用
-
-
+淘宝闪购 天天领红包
""" diff --git a/app/core/config.py b/app/core/config.py index bfc4139..8862476 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -95,6 +95,17 @@ class Settings(BaseSettings): """美团 CPS 凭证齐全(缺则接口返空,而非 502)。""" return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET) + # ===== 微信服务号(网页授权) ===== + # CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。 + # ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。 + WX_MP_APPID: str = "" + WX_MP_SECRET: str = "" + + @property + def wx_mp_configured(self) -> bool: + """服务号网页授权凭证齐全(缺则落地页不发起授权,降级为无 openid)。""" + return bool(self.WX_MP_APPID and self.WX_MP_SECRET) + # ===== 微信支付(商家转账到零钱 / 提现)===== # 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于 # 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。 diff --git a/app/integrations/wx_oauth.py b/app/integrations/wx_oauth.py new file mode 100644 index 0000000..0af50d6 --- /dev/null +++ b/app/integrations/wx_oauth.py @@ -0,0 +1,75 @@ +"""微信服务号网页授权:构造授权链接 + code 换 openid + 拉 userinfo。 + +用于 CPS 落地页(coupon.shaguabijia.com)在微信内拿用户身份。流程(见微信文档): + 授权链接(snsapi_base|snsapi_userinfo) → 回调带 code → sns/oauth2/access_token 换 openid + → (userinfo 时)sns/userinfo 拿昵称头像。 + +注意: + - 这里的 access_token 是【网页授权 token】,与基础 access_token(cgi-bin/token)不同, + 走 sns/* 接口,不需要 IP 白名单。 + - secret 只在服务器,不下发客户端。 + - 用 WX_MP_APPID/SECRET(已认证服务号),区别于 WECHAT_APP_ID(App 移动应用)。 +""" +from __future__ import annotations + +from urllib.parse import quote + +import httpx + +from app.core.config import settings + +_API = "https://api.weixin.qq.com" +_AUTHORIZE = "https://open.weixin.qq.com/connect/oauth2/authorize" +_TIMEOUT = 10 + + +class WxOauthError(Exception): + """网页授权调用失败(凭证/网络/code 失效等)。调用方兜底为无 openid。""" + + +def build_authorize_url(redirect_uri: str, scope: str, state: str) -> str: + """构造网页授权跳转链接。scope: 'snsapi_base'(静默,仅 openid) | 'snsapi_userinfo'(昵称头像)。 + + 微信要求 redirect_uri urlEncode、参数顺序固定、结尾 #wechat_redirect。 + """ + return ( + f"{_AUTHORIZE}?appid={settings.WX_MP_APPID}" + f"&redirect_uri={quote(redirect_uri, safe='')}" + f"&response_type=code&scope={scope}&state={quote(state, safe='')}" + f"#wechat_redirect" + ) + + +def exchange_code(code: str) -> dict: + """code 换网页授权 access_token + openid。返回含 openid/access_token/scope(/unionid)。 + + code 5 分钟内一次性有效。失败(errcode)抛 WxOauthError。 + """ + resp = httpx.get( + f"{_API}/sns/oauth2/access_token", + params={ + "appid": settings.WX_MP_APPID, + "secret": settings.WX_MP_SECRET, + "code": code, + "grant_type": "authorization_code", + }, + timeout=_TIMEOUT, + ) + data = resp.json() + if "openid" not in data: + raise WxOauthError(f"exchange_code failed: {data}") + return data + + +def get_userinfo(access_token: str, openid: str) -> dict: + """拉用户信息(nickname/headimgurl/unionid)。需 scope=snsapi_userinfo 的 access_token。""" + resp = httpx.get( + f"{_API}/sns/userinfo", + params={"access_token": access_token, "openid": openid, "lang": "zh_CN"}, + timeout=_TIMEOUT, + ) + resp.encoding = "utf-8" # 昵称含中文/emoji,强制 utf-8 避免乱码 + data = resp.json() + if "openid" not in data: + raise WxOauthError(f"get_userinfo failed: {data}") + return data diff --git a/app/models/__init__.py b/app/models/__init__.py index 1955e31..768d192 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -10,6 +10,7 @@ from app.models.cps_activity import CpsActivity # noqa: F401 from app.models.cps_group import CpsGroup # noqa: F401 from app.models.cps_link import CpsClick, CpsLink # noqa: F401 from app.models.cps_order import CpsOrder # noqa: F401 +from app.models.cps_wx_user import CpsWxUser # noqa: F401 from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 from app.models.coupon_state import ( # noqa: F401 CouponClaimRecord, diff --git a/app/models/cps_link.py b/app/models/cps_link.py index 2702c8a..bb88e6b 100644 --- a/app/models/cps_link.py +++ b/app/models/cps_link.py @@ -49,6 +49,8 @@ class CpsClick(Base): ) ip: Mapped[str | None] = mapped_column(String(64), nullable=True) ua: Mapped[str | None] = mapped_column(String(512), nullable=True) + # 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计 + openid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) clicked_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True, nullable=False ) diff --git a/app/models/cps_wx_user.py b/app/models/cps_wx_user.py new file mode 100644 index 0000000..669b26c --- /dev/null +++ b/app/models/cps_wx_user.py @@ -0,0 +1,42 @@ +"""CPS 落地页微信用户(cps_wx_user)。 + +用户在微信内打开群发短链落地页 /c/{code},经服务号网页授权: + - base 静默:拿 openid(唯一标识,统计主力) + - userinfo(点领券触发):补 nickname/headimgurl/unionid + +按 openid 唯一,记录首次来源群(first_group_id),用于群内用户级统计(谁领了券)。 +下单归因到人需 user-level sid(另一期),本表只承载身份 + 领券/点击侧。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class CpsWxUser(Base): + __tablename__ = "cps_wx_user" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 服务号下用户唯一标识(网页授权拿到) + openid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + # 开放平台 unionid(服务号绑开放平台 + scope=userinfo 才有);跨 App/服务号统一用户 + unionid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + # 昵称/头像:userinfo 授权后才有(base 阶段为空) + nickname: Mapped[str | None] = mapped_column(String(128), nullable=True) + headimgurl: Mapped[str | None] = mapped_column(String(512), nullable=True) + # 首次进入来源(从哪个群的链接授权进来) + first_code: Mapped[str | None] = mapped_column(String(16), nullable=True) + first_group_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) + first_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + last_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/repositories/cps_link.py b/app/repositories/cps_link.py index 2e34970..00b9c1f 100644 --- a/app/repositories/cps_link.py +++ b/app/repositories/cps_link.py @@ -52,12 +52,13 @@ def create_link( def record_click( db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None, - event_type: str = "visit", + event_type: str = "visit", openid: str | None = None, ) -> None: - """记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。""" + """记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。 + openid: 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计。""" db.add(CpsClick( link_id=link.id, group_id=link.group_id, sid=link.sid, event_type=event_type, - ip=ip, ua=(ua[:500] if ua else None), + ip=ip, ua=(ua[:500] if ua else None), openid=openid, )) db.commit() diff --git a/app/repositories/cps_wx_user.py b/app/repositories/cps_wx_user.py new file mode 100644 index 0000000..9fde31e --- /dev/null +++ b/app/repositories/cps_wx_user.py @@ -0,0 +1,43 @@ +"""cps_wx_user 数据访问:按 openid upsert。""" +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.cps_wx_user import CpsWxUser + + +def get_by_openid(db: Session, openid: str) -> CpsWxUser | None: + return db.execute( + select(CpsWxUser).where(CpsWxUser.openid == openid) + ).scalar_one_or_none() + + +def upsert( + db: Session, *, openid: str, code: str | None = None, group_id: int | None = None, + nickname: str | None = None, headimgurl: str | None = None, unionid: str | None = None, +) -> CpsWxUser: + """按 openid upsert。 + + - 首次:建行,记来源 code/group。 + - 已存在:仅在传入非 None 时更新昵称/头像/unionid(base 阶段为 None,不覆盖已有画像); + 并刷新 last_seen(显式 set,因 onupdate 仅在字段有变更时触发)。 + """ + u = get_by_openid(db, openid) + if u is None: + u = CpsWxUser( + openid=openid, first_code=code, first_group_id=group_id, + nickname=nickname, headimgurl=headimgurl, unionid=unionid, + ) + db.add(u) + else: + if nickname is not None: + u.nickname = nickname + if headimgurl is not None: + u.headimgurl = headimgurl + if unionid is not None: + u.unionid = unionid + u.last_seen = func.now() + db.commit() + db.refresh(u) + return u From 733a51260f79bb58be1df281a17d11311017b35a Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 19 Jun 2026 12:03:58 +0800 Subject: [PATCH 18/20] =?UTF-8?q?feat(cps):=20=E5=BE=AE=E4=BF=A1=E7=BD=91?= =?UTF-8?q?=E9=A1=B5=E6=8E=88=E6=9D=83=E5=8A=A0=E6=80=BB=E5=BC=80=E5=85=B3?= =?UTF-8?q?=20WX=5FMP=5FOAUTH=5FENABLED(=E9=BB=98=E8=AE=A4=E5=85=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务号认证审核中,先关掉落地页授权(走原逻辑不跳授权/不拿 openid),整套微信代码保留。 审核通过后 .env 置 WX_MP_OAUTH_ENABLED=true + 重启即启用,无需改代码。 Co-Authored-By: Claude Opus 4.8 --- app/api/v1/cps_redirect.py | 4 ++-- app/core/config.py | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/api/v1/cps_redirect.py b/app/api/v1/cps_redirect.py index 5270586..ee7aa0b 100644 --- a/app/api/v1/cps_redirect.py +++ b/app/api/v1/cps_redirect.py @@ -79,7 +79,7 @@ def cps_landing(code: str, request: Request, db: Session = Depends(get_db)): openid = request.cookies.get(_WX_OPENID_COOKIE) # 微信内 + 还没拿到 openid + 配了服务号 → 先 base 静默授权拿 openid,再 302 回本页。 # 非微信 / 已有 cookie / 未配服务号 → 直接走原逻辑(openid 可能为 None,绝不阻断领券)。 - if openid is None and settings.wx_mp_configured and _is_wechat(request): + if openid is None and settings.wx_oauth_active and _is_wechat(request): redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb" auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_base", f"base:{code}") return RedirectResponse(auth_url, status_code=302) @@ -147,7 +147,7 @@ def _taobao_landing_html( """ safe_img = html.escape(media.to_abs_media_url(image_url) or image_url, quote=True) uinfo_url = "" - if openid and not has_uinfo and settings.wx_mp_configured: + if openid and not has_uinfo and settings.wx_oauth_active: redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb" uinfo_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_userinfo", f"uinfo:{code}") return ( diff --git a/app/core/config.py b/app/core/config.py index 8862476..76621e1 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -100,12 +100,20 @@ class Settings(BaseSettings): # ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。 WX_MP_APPID: str = "" WX_MP_SECRET: str = "" + # 落地页微信网页授权【总开关】。默认关:服务号认证审核中/未就绪时,落地页走原逻辑 + # (不跳授权、不拿 openid),整套微信代码保留。审核通过后 .env 置 true + 重启即启用,无需改代码。 + WX_MP_OAUTH_ENABLED: bool = False @property def wx_mp_configured(self) -> bool: """服务号网页授权凭证齐全(缺则落地页不发起授权,降级为无 openid)。""" return bool(self.WX_MP_APPID and self.WX_MP_SECRET) + @property + def wx_oauth_active(self) -> bool: + """落地页是否真正发起微信授权 = 凭证齐全 且 总开关开。""" + return self.wx_mp_configured and self.WX_MP_OAUTH_ENABLED + # ===== 微信支付(商家转账到零钱 / 提现)===== # 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于 # 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。 From 95a07f75b75d0ce632526f669435f6621e12e7cc Mon Sep 17 00:00:00 2001 From: marco Date: Sat, 20 Jun 2026 01:35:17 +0800 Subject: [PATCH 19/20] =?UTF-8?q?feat(ad):=20=E7=A9=BF=E5=B1=B1=E7=94=B2?= =?UTF-8?q?=E5=B9=BF=E5=91=8A=E9=85=8D=E7=BD=AE=E5=90=8E=E5=8F=B0=E5=8C=96?= =?UTF-8?q?(app=5Fid/=E5=90=84=E4=BD=8DID/=E9=AA=8C=E7=AD=BE=E5=AF=86?= =?UTF-8?q?=E9=92=A5/=E5=90=84=E5=9C=BA=E6=99=AF=E5=BC=80=E5=85=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app_config 存 ad_config(默认=客户端当前内置 ID,空库维持现状) - GET /platform/ad-config 下发客户端(不含验签密钥 reward_mkey) - admin GET/PATCH /admin/api/ad-config 读写(operator/finance + 审计,mkey 不记明文) - pangle 回调验签 m-key 优先读后台配置、.env 兜底兼容 - 比价/领券信息流位拆分(compare/coupon_feed_code_id) - 客户端需发版接入 /platform/ad-config 才生效;本次后端就绪、不影响现状 Co-Authored-By: Claude Opus 4.8 --- app/admin/main.py | 2 ++ app/admin/routers/ad_config.py | 45 +++++++++++++++++++++++++++++++ app/admin/schemas/ad_config.py | 30 +++++++++++++++++++++ app/api/v1/ad.py | 13 +++++++-- app/api/v1/platform.py | 17 ++++++++++++ app/repositories/app_config.py | 48 ++++++++++++++++++++++++++++++++++ app/schemas/platform.py | 15 +++++++++++ 7 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 app/admin/routers/ad_config.py create mode 100644 app/admin/schemas/ad_config.py diff --git a/app/admin/main.py b/app/admin/main.py index b718441..eb46a7c 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -14,6 +14,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.admin.routers.ad_audit import router as ad_audit_router +from app.admin.routers.ad_config import router as ad_config_router from app.admin.routers.ad_revenue import router as ad_revenue_router from app.admin.routers.admins import router as admins_router from app.admin.routers.audit import router as audit_router @@ -95,4 +96,5 @@ admin_app.include_router(config_router) admin_app.include_router(comparison_router) admin_app.include_router(cps_router) admin_app.include_router(ad_audit_router) +admin_app.include_router(ad_config_router) admin_app.include_router(ad_revenue_router) diff --git a/app/admin/routers/ad_config.py b/app/admin/routers/ad_config.py new file mode 100644 index 0000000..c46068c --- /dev/null +++ b/app/admin/routers/ad_config.py @@ -0,0 +1,45 @@ +"""admin 广告配置:读/写穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。 + +存在 app_config 表的 ad_config dict(见 repositories/app_config.get_ad_config/set_ad_config)。 +客户端经 /api/v1/platform/ad-config 拉取(不含 reward_mkey)。权限 operator/finance + 审计。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Request + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.schemas.ad_config import AdConfigOut, AdConfigUpdate +from app.models.admin import AdminUser +from app.repositories import app_config + +router = APIRouter( + prefix="/admin/api/ad-config", + tags=["admin-ad-config"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get("", response_model=AdConfigOut, summary="广告配置(穿山甲ID/验签密钥/各场景开关)") +def get_ad_config(db: AdminDb) -> AdConfigOut: + return AdConfigOut(**app_config.get_ad_config(db)) + + +@router.patch("", response_model=AdConfigOut, summary="改广告配置(部分更新,带审计)") +def update_ad_config( + body: AdConfigUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))], + db: AdminDb, +) -> AdConfigOut: + data = body.model_dump(exclude_none=True) + app_config.set_ad_config(db, data, admin_id=admin.id, commit=False) + # 审计只记改了哪些字段,不记 mkey 明文(机密) + write_audit( + db, admin, action="ad_config.set", target_type="ad_config", target_id="ad_config", + detail={"changed": sorted(data.keys())}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return AdConfigOut(**app_config.get_ad_config(db)) diff --git a/app/admin/schemas/ad_config.py b/app/admin/schemas/ad_config.py new file mode 100644 index 0000000..3f5c033 --- /dev/null +++ b/app/admin/schemas/ad_config.py @@ -0,0 +1,30 @@ +"""admin 广告配置 schemas(穿山甲 app_id/各位ID/验签密钥/各场景开关)。""" +from __future__ import annotations + +from pydantic import BaseModel + + +class AdConfigOut(BaseModel): + """admin 读到的完整广告配置(含 reward_mkey;admin 有权见,但绝不经 /platform 下发客户端)。""" + + app_id: str + reward_code_id: str + compare_feed_code_id: str + coupon_feed_code_id: str + reward_mkey: str + reward_enabled: bool + compare_ad_enabled: bool + coupon_ad_enabled: bool + + +class AdConfigUpdate(BaseModel): + """部分更新:只改传入(非 None)字段。空串是合法值(如清空 mkey 回退 .env)。""" + + app_id: str | None = None + reward_code_id: str | None = None + compare_feed_code_id: str | None = None + coupon_feed_code_id: str | None = None + reward_mkey: str | None = None + reward_enabled: bool | None = None + compare_ad_enabled: bool | None = None + coupon_ad_enabled: bool | None = None diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index bb05891..cf866b2 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -24,6 +24,7 @@ from app.repositories import ad_ecpm as crud_ecpm from app.repositories import ad_feed_reward as crud_feed from app.repositories import ad_reward as crud_ad from app.repositories import ad_watch as crud_watch +from app.repositories import app_config from app.repositories import signin as crud_signin from app.schemas.ad import ( AdRewardStatusOut, @@ -80,14 +81,22 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: 验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。 granted / capped → is_verify=true + reason=0。 """ - if not settings.pangle_callback_configured: + if not settings.PANGLE_CALLBACK_ENABLED: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured" ) params = dict(request.query_params) - if not pangle.verify_callback_sign_any(params, settings.pangle_reward_secrets): + # 验签密钥:admin 后台配的 reward_mkey 优先,.env 的 PANGLE_REWARD_SECRET* 兜底(兼容/过渡)。 + # 换激励位时后台同步换 mkey 即可,无需改 .env。两者都空才视为未配置。 + ad_mkey = app_config.get_ad_config(db).get("reward_mkey") or "" + secrets = ([ad_mkey] if ad_mkey else []) + settings.pangle_reward_secrets + if not secrets: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured" + ) + if not pangle.verify_callback_sign_any(params, 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 f839a58..31908bd 100644 --- a/app/api/v1/platform.py +++ b/app/api/v1/platform.py @@ -17,6 +17,7 @@ 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 ( + AdConfigPublicOut, AppFlagsOut, AppVersionOut, PlatformStatsOut, @@ -54,6 +55,22 @@ def flags(db: DbSession) -> AppFlagsOut: ) +@router.get("/ad-config", response_model=AdConfigPublicOut, summary="客户端拉广告配置(穿山甲ID+场景开关,不鉴权)") +def ad_config(db: DbSession) -> AdConfigPublicOut: + """客户端启动/每场广告前拉,缓存后用:app_id + 各位ID + 各场景开关。 + 不含验签密钥;空库回退默认(=客户端内置值,维持现状)。""" + c = app_config.get_ad_config(db) + return AdConfigPublicOut( + app_id=c["app_id"], + reward_code_id=c["reward_code_id"], + compare_feed_code_id=c["compare_feed_code_id"], + coupon_feed_code_id=c["coupon_feed_code_id"], + reward_enabled=c["reward_enabled"], + compare_ad_enabled=c["compare_ad_enabled"], + coupon_ad_enabled=c["coupon_ad_enabled"], + ) + + @router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)") def app_version(db: DbSession) -> AppVersionOut: """客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。 diff --git a/app/repositories/app_config.py b/app/repositories/app_config.py index b6f67a4..40bfc80 100644 --- a/app/repositories/app_config.py +++ b/app/repositories/app_config.py @@ -91,3 +91,51 @@ def set_app_version(db: Session, data: dict, *, commit: bool = True) -> AppConfi else: db.flush() return row + + +# ── 穿山甲广告配置(admin 可配,客户端动态拉)────────────────────────────────── +# 同 app_version:结构化 dict,复用 AppConfig 表不进 CONFIG_DEFS(它是一组关联配置,不是散项)。 +# 默认值 = 客户端当前内置的硬编码 ID(AdConfig.kt),空库时下发默认 = 维持现状。 +# reward_mkey 是 GroMore S2S 验签密钥(机密),只后端验签用、绝不下发客户端(见 platform/ad-config)。 +AD_CONFIG_KEY = "ad_config" +_AD_CONFIG_DEFAULTS: dict[str, Any] = { + "app_id": "5830519", # 穿山甲应用ID(正式) + "reward_code_id": "104099389", # 福利页激励视频位 + "compare_feed_code_id": "104090333", # 比价信息流位 + "coupon_feed_code_id": "104090333", # 领券信息流位(初始同比价,运营可拆) + "reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*) + "reward_enabled": True, # 福利激励视频开关 + "compare_ad_enabled": True, # 比价广告开关 + "coupon_ad_enabled": True, # 领券广告开关 +} + + +def get_ad_config(db: Session) -> dict: + """读广告配置。DB 无则返回默认(=客户端内置值,维持现状);DB 有则与默认 merge + (新增字段向后兼容,老记录缺的字段用默认补)。""" + row = db.get(AppConfig, AD_CONFIG_KEY) + merged = dict(_AD_CONFIG_DEFAULTS) + if row is not None and isinstance(row.value, dict): + merged.update(row.value) + return merged + + +def set_ad_config(db: Session, data: dict, *, admin_id: int, commit: bool = True) -> AppConfig: + """admin 写广告配置。与现有值 merge(部分更新),只接受已知字段(防脏键)。""" + row = db.get(AppConfig, AD_CONFIG_KEY) + merged = dict(_AD_CONFIG_DEFAULTS) + if row is not None and isinstance(row.value, dict): + merged.update(row.value) + merged.update({k: v for k, v in data.items() if k in _AD_CONFIG_DEFAULTS}) + if row is None: + row = AppConfig(key=AD_CONFIG_KEY, value=merged, updated_by_admin_id=admin_id) + db.add(row) + else: + row.value = merged + row.updated_by_admin_id = admin_id + if commit: + db.commit() + db.refresh(row) + else: + db.flush() + return row diff --git a/app/schemas/platform.py b/app/schemas/platform.py index 6968271..4c97bd6 100644 --- a/app/schemas/platform.py +++ b/app/schemas/platform.py @@ -30,6 +30,21 @@ class AppFlagsOut(BaseModel): comparing_ad_enabled: bool # 比价/领券期是否展示信息流广告(远程 kill-switch) +class AdConfigPublicOut(BaseModel): + """下发给客户端的穿山甲广告配置(不鉴权,客户端缓存后用)。 + + ⚠️ 不含 reward_mkey(GroMore 验签密钥,机密,只后端验签用,绝不下发)。 + """ + + app_id: str # 穿山甲应用ID(改了客户端需冷启才生效,SDK init 一次性读) + reward_code_id: str # 福利页激励视频位 + compare_feed_code_id: str # 比价信息流位 + coupon_feed_code_id: str # 领券信息流位 + reward_enabled: bool # 福利激励视频开关 + compare_ad_enabled: bool # 比价广告开关 + coupon_ad_enabled: bool # 领券广告开关 + + class AppVersionOut(BaseModel): """最新 App 版本信息(OTA 检查更新,不鉴权)。 From d8358a6816fc6a359f13c0aaabb0cbb341e54280 Mon Sep 17 00:00:00 2001 From: marco Date: Sat, 20 Jun 2026 02:03:33 +0800 Subject: [PATCH 20/20] =?UTF-8?q?fix(user):=20=E6=B3=A8=E9=94=80=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=89=8D=E7=BD=AE=E6=A0=A1=E9=AA=8C=E8=B5=84=E9=87=91?= =?UTF-8?q?=E4=BD=99=E9=A2=9D=20+=20=E9=87=8A=E6=94=BE=E5=BE=AE=E4=BF=A1/?= =?UTF-8?q?=E9=82=80=E8=AF=B7=E7=A0=81=E5=94=AF=E4=B8=80=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delete_account 端点: 有可提现现金余额 / 在审提现单(reviewing)时拒绝注销(409), 防余额锁死在 deleted 行无法退出; pending 提现已在途不拦(对账 worker 不依赖 user.status) - soft_delete_account: 注销连带清 wechat_openid/nickname/avatar(释放微信绑定槽, 否则该微信永久无法再被任何账号绑定→提现通道锁死) + invite_code(释放邀请码唯一槽) - wallet 新增 get_cash_balance_cents(只读, 不建账户) 供注销前置校验; 复用 has_reviewing_withdraw - 新增 tests/test_delete_account.py Co-Authored-By: Claude Opus 4.8 --- app/api/v1/user.py | 12 +++ app/repositories/user.py | 17 ++++- app/repositories/wallet.py | 9 +++ tests/test_delete_account.py | 141 +++++++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 tests/test_delete_account.py diff --git a/app/api/v1/user.py b/app/api/v1/user.py index 6be689f..dc23388 100644 --- a/app/api/v1/user.py +++ b/app/api/v1/user.py @@ -18,6 +18,7 @@ from app.api.deps import CurrentUser, DbSession from app.core import media from app.repositories import onboarding as onboarding_repo from app.repositories import user as user_repo +from app.repositories import wallet as wallet_repo from app.schemas.auth import UserOut from app.schemas.user import ( OkResponse, @@ -87,6 +88,17 @@ def onboarding_status( @router.delete("", response_model=OkResponse, summary="注销账号(软删除)") def delete_account(user: CurrentUser, db: DbSession) -> OkResponse: + # 资金前置校验:注销是软删 + 匿名化,余额一旦留在 deleted 行就锁死(既退不出、新号也 + # 拿不回)。有可提现现金 / 在审提现单 → 拒绝注销,引导用户先把钱处理掉。 + # (pending 提现转账已在途、对账 worker 不依赖 user.status 照常到账,故不拦 pending。) + if wallet_repo.get_cash_balance_cents(db, user.id) > 0: + raise HTTPException( + status_code=409, detail="账户还有未提现的现金余额,请先提现后再注销" + ) + if wallet_repo.has_reviewing_withdraw(db, user.id): + raise HTTPException( + status_code=409, detail="有提现正在审核中,请等审核完成后再注销" + ) media.delete_avatar(user.avatar_url) user_repo.soft_delete_account(db, user) logger.info("delete account user_id=%d", user.id) diff --git a/app/repositories/user.py b/app/repositories/user.py index 33c53ae..7753e33 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -111,14 +111,27 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User: def soft_delete_account(db: Session, user: User) -> None: - """注销账号:软删除 + 匿名化。 + """注销账号:软删除 + 匿名化 + 释放唯一约束/外部绑定。 - 把 phone 改成 `deleted_` 释放唯一约束,允许同号码重新注册成全新账号; + 把 phone 改成 `deleted_` 释放手机号唯一约束,允许同号码重新注册成全新账号; 清空 PII(昵称/头像),保留行用于审计。status=deleted 后将无法再登录该行 (登录接口校验 status==active)。 + + ⚠️ 注销必须连同释放 user 上其余唯一约束/外部身份,否则 deleted 行永久占着槽位, + 对应资源再也无法被复用: + - wechat_openid: 不清 → 这个微信再也无法被任何账号绑定(提现通道彻底锁死); + - invite_code: 不清 → 邀请码槽位被占 + 裸查仍命中死号(发奖已被 bind 的 + status==active 校验兜住,清掉更干净)。 + 资金安全(未提现现金 / 在审提现单)由调用方 delete_account 端点前置校验,这里只匿名化。 """ user.status = "deleted" user.phone = f"deleted_{user.id}" user.nickname = None user.avatar_url = None + # 等价"解绑微信":释放 openid 唯一槽 + 清微信昵称头像 + user.wechat_openid = None + user.wechat_nickname = None + user.wechat_avatar_url = None + # 释放邀请码唯一槽 + user.invite_code = None db.commit() diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 615f0eb..a38ac88 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -340,6 +340,15 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict: return info +def get_cash_balance_cents(db: Session, user_id: int) -> int: + """只读查现金余额(分)。账户不存在返回 0,**不创建账户**——注销前置校验用, + 不该给一个即将被删除的用户凭空建一行空账户(get_or_create_account 会建)。""" + val = db.execute( + select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id) + ).scalar_one_or_none() + return val or 0 + + def has_reviewing_withdraw(db: Session, user_id: int) -> bool: """用户是否有「待审核(reviewing)」的提现单。 diff --git a/tests/test_delete_account.py b/tests/test_delete_account.py new file mode 100644 index 0000000..464b02c --- /dev/null +++ b/tests/test_delete_account.py @@ -0,0 +1,141 @@ +"""注销账号(DELETE /api/v1/user):软删 + 释放唯一约束 + 资金前置校验。 + +覆盖: + - 干净注销:status=deleted + phone 匿名化 + wechat_openid/invite_code 等唯一槽全部释放 + - 回归原始 bug:注销释放 openid 后,别的账号能绑同一个微信 + - 资金前置闸:有可提现现金 / 在审提现单 → 409 拒绝注销,账号不被删 + - 注销后旧 token 即时失效(status==active 闸) +""" +from __future__ import annotations + +from sqlalchemy import select + +from app.db.session import SessionLocal +from app.models.user import User +from app.models.wallet import CoinAccount + + +def _login(client, phone: str) -> str: + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _patch_userinfo(monkeypatch, openid: str) -> None: + monkeypatch.setattr( + "app.integrations.wxpay.code_to_userinfo", + lambda code: {"openid": openid, "nickname": "微信昵称", "avatar_url": "https://x/a.png", "raw": {}}, + ) + + +def _seed_cash(client, token: str, phone: str, cents: int) -> None: + """先访问 /account 触发建账户,再用 DB 直接灌现金余额。""" + client.get("/api/v1/wallet/account", headers=_auth(token)) + db = SessionLocal() + try: + user = db.execute(select(User).where(User.phone == phone)).scalar_one() + acc = db.get(CoinAccount, user.id) + acc.cash_balance_cents = cents + db.commit() + finally: + db.close() + + +def _get_user(phone: str) -> User | None: + db = SessionLocal() + try: + return db.execute(select(User).where(User.phone == phone)).scalar_one_or_none() + finally: + db.close() + + +def test_delete_clean_account_releases_all_unique_slots(client) -> None: + phone = "13900000001" + token = _login(client, phone) + # 预置:给该用户造昵称 + 邀请码(占 invite_code 唯一槽),验证注销时一并释放 + db = SessionLocal() + try: + u = db.execute(select(User).where(User.phone == phone)).scalar_one() + u.nickname = "张三" + u.invite_code = "INVCODE0001" + db.commit() + uid = u.id + finally: + db.close() + + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 200, r.text + + u = _get_user(f"deleted_{uid}") + assert u is not None + assert u.status == "deleted" + assert u.phone == f"deleted_{uid}" + assert u.nickname is None + assert u.avatar_url is None + assert u.wechat_openid is None + assert u.wechat_nickname is None + assert u.wechat_avatar_url is None + assert u.invite_code is None + + +def test_delete_releases_wechat_so_another_account_can_bind(client, monkeypatch) -> None: + """原始 bug 回归:A 绑微信 → 注销,B 必须能绑同一个 openid(修复前会 409)。""" + openid = "openid_delreuse_a1" # 独特值,避免跨测试共享库污染(如 test_withdraw 也用 openid_shared_xyz) + # A 绑微信 + _patch_userinfo(monkeypatch, openid) + token_a = _login(client, "13900000002") + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "ca"}, headers=_auth(token_a)) + assert r.status_code == 200, r.text + # A 注销(无现金、无提现单 → 允许) + r = client.delete("/api/v1/user", headers=_auth(token_a)) + assert r.status_code == 200, r.text + # B 绑同一个 openid → 必须成功(修复前会 409 "该微信已绑定其他账号") + token_b = _login(client, "13900000003") + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "cb"}, headers=_auth(token_b)) + assert r.status_code == 200, r.text + assert r.json()["bound"] is True + + +def test_delete_blocked_when_cash_balance_positive(client) -> None: + phone = "13900000004" + token = _login(client, phone) + _seed_cash(client, token, phone, 100) # 1 元未提现 + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 409, r.text + assert "现金" in r.json()["detail"] + u = _get_user(phone) # 账号未被删 + assert u is not None and u.status == "active" + + +def test_delete_blocked_when_reviewing_withdraw_exists(client, monkeypatch) -> None: + """cash 提光(=0)但有在审提现单 → 仍拒绝(命中 reviewing 闸,非 cash 闸)。""" + phone = "13900000005" + _patch_userinfo(monkeypatch, "openid_delrev_b2") + token = _login(client, phone) + _seed_cash(client, token, phone, 50) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + # 提光 50 → cash 归 0,单进 reviewing + r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token)) + assert r.status_code == 200 and r.json()["status"] == "reviewing", r.text + assert r.json()["cash_balance_cents"] == 0 + + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 409, r.text + assert "审核" in r.json()["detail"] + u = _get_user(phone) + assert u is not None and u.status == "active" + + +def test_deleted_user_old_token_is_rejected(client) -> None: + phone = "13900000006" + token = _login(client, phone) + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 200, r.text + # 注销后旧 token 立即失效(get_current_user 校验 status==active) + r = client.get("/api/v1/user/onboarding/status", params={"device_id": "d"}, headers=_auth(token)) + assert r.status_code == 401, r.text