From 59fe7152453ae7722212dcc0cf22f6b0d2991780 Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Fri, 26 Jun 2026 23:56:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20=E5=9F=8B=E7=82=B9=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=8E=A5=E6=94=B6=E6=8E=A5=E5=8F=A3=20+=20admin=20?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E6=8E=A5=E5=8F=A3=20+=20=E5=9F=8B=E7=82=B9?= =?UTF-8?q?=E8=A1=A8=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/83 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- ...add_ad_type_to_ad_feed_reward_and_feed_.py | 46 +++++++++++++++++++ app/admin/repositories/ad_audit.py | 44 +++++++++++++++--- app/admin/repositories/ad_revenue.py | 35 ++++++++++---- app/admin/routers/ad_audit.py | 2 +- app/admin/schemas/ad_config.py | 8 ++-- app/admin/schemas/ad_revenue.py | 7 ++- app/api/v1/ad.py | 12 +++-- app/api/v1/auth.py | 5 +- app/api/v1/platform.py | 4 +- app/integrations/sms.py | 4 +- app/models/ad_ecpm.py | 3 ++ app/models/ad_feed_reward.py | 3 ++ app/repositories/ad_ecpm.py | 2 + app/repositories/ad_feed_reward.py | 7 ++- app/repositories/app_config.py | 8 ++-- app/schemas/ad.py | 11 +++++ app/schemas/platform.py | 4 +- tests/test_auth.py | 2 +- 18 files changed, 168 insertions(+), 39 deletions(-) create mode 100644 alembic/versions/c2874d2bf705_add_ad_type_to_ad_feed_reward_and_feed_.py diff --git a/alembic/versions/c2874d2bf705_add_ad_type_to_ad_feed_reward_and_feed_.py b/alembic/versions/c2874d2bf705_add_ad_type_to_ad_feed_reward_and_feed_.py new file mode 100644 index 0000000..0db4e29 --- /dev/null +++ b/alembic/versions/c2874d2bf705_add_ad_type_to_ad_feed_reward_and_feed_.py @@ -0,0 +1,46 @@ +"""add ad_type to ad_feed_reward and feed_scene to ad_ecpm + +把信息流广告全面改造成 Draw 信息流(draw): +- ad_feed_reward_record.ad_type:广告形态 feed(信息流) / draw(Draw 信息流)。可空,旧数据 NULL + 一律视为 feed(向后兼容);每日上限与因子2(LT)仍按本表全表 unit 累计,不按 ad_type 拆。 +- ad_ecpm_record.feed_scene:点位场景 comparison(比价) / coupon(领券) / welfare(福利),供广告 + 收益报表区分比价/领券 Draw 收益;仅信息流/Draw 上报,激励视频为 NULL。 + +注:autogenerate 会顺带探测到 analytics_event / cps_* / invite_fingerprint 等无关索引差异(本地库与 +metadata 漂移、analytics 模型尚未并入 __init__),与本次改动无关,已手工剔除——本迁移只 add 两列。 +SQLite 经 env.py 的 render_as_batch 自动走 batch_alter_table 重建表。 + +Revision ID: c2874d2bf705 +Revises: 1699fc2c069f +Create Date: 2026-06-26 16:48:52.158609 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c2874d2bf705" +down_revision: str | Sequence[str] | None = "1699fc2c069f" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # 信息流发奖记录:新增广告形态 feed/draw(可空,旧数据 NULL=feed) + op.add_column( + "ad_feed_reward_record", + sa.Column("ad_type", sa.String(length=16), nullable=True), + ) + # eCPM 展示上报:新增点位场景 comparison/coupon/welfare(可空,激励视频/旧数据 NULL) + op.add_column( + "ad_ecpm_record", + sa.Column("feed_scene", sa.String(length=16), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("ad_ecpm_record", "feed_scene") + op.drop_column("ad_feed_reward_record", "ad_type") diff --git a/app/admin/repositories/ad_audit.py b/app/admin/repositories/ad_audit.py index 2804fc3..37e1a01 100644 --- a/app/admin/repositories/ad_audit.py +++ b/app/admin/repositories/ad_audit.py @@ -128,8 +128,28 @@ def _feed_prior_granted_units( return {uid: int(n) for uid, n in db.execute(stmt).all()} -def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: - """信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。""" +def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool: + """该信息流记录是否落入请求的展示筛选 scene。 + - scene=="feed":ad_type in ("feed", NULL)(旧数据 NULL 视为 feed,向后兼容) + - scene=="draw":ad_type=="draw" + - scene 为 None:不筛(两类都要)。 + """ + if scene == "feed": + return rec.ad_type in (None, "feed") + if scene == "draw": + return rec.ad_type == "draw" + return True + + +def _feed_rows( + db: Session, *, date: str, user_id: int | None, scene: str | None = None +) -> list[dict]: + """信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。 + + **关键:LT 因子账号累计按全表 unit 累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**—— + 故无论 scene 怎么筛展示,这里都遍历当日**全部**信息流记录维持 granted_units 累加;scene 只决定 + 哪些行被**留下展示**(由 _feed_scene_matches 判断),不影响累计基线,保证复算序号与正式发奖一致。 + """ stmt = ( select(AdFeedRewardRecord) .where(AdFeedRewardRecord.reward_date == date) @@ -142,18 +162,23 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id) rows: list[dict] = [] for rec in db.execute(stmt).scalars(): + keep = _feed_scene_matches(rec, scene) # 累计照常推进,这里只决定是否展示本行 if rec.status == "granted": existing = granted_units.get(rec.user_id, 0) units = rec.unit_count + granted_units[rec.user_id] = existing + units + if not keep: + continue expected = sum( rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset) for offset in range(1, units + 1) ) - granted_units[rec.user_id] = existing + units start = existing + 1 if units > 0 else None end = existing + units if units > 0 else None rows.append({ "scene": "feed", + "ad_type": rec.ad_type or "feed", + "feed_scene": rec.feed_scene, "record_id": rec.id, "user_id": rec.user_id, "ad_session_id": rec.ad_session_id, @@ -173,8 +198,12 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: "matched": expected == rec.coin, }) else: + if not keep: + continue rows.append({ "scene": "feed", + "ad_type": rec.ad_type or "feed", + "feed_scene": rec.feed_scene, "record_id": rec.id, "user_id": rec.user_id, "ad_session_id": rec.ad_session_id, @@ -199,16 +228,19 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: def audit_rows( db: Session, *, date: str, user_id: int | None, scene: str | None = None ) -> list[dict]: - """当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed"。 + """当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed" / "draw"。 + "feed" 与 "draw" 都查 ad_feed_reward_record(同一发奖表),按 ad_type 区分:feed 含历史 NULL, + draw 仅 ad_type=="draw"。信息流行额外带 `ad_type`/`feed_scene`,供收益报表区分比价/领券 Draw 收益。 每行含 `app_env`/`our_code_id`/`expected_coin`/`actual_coin` 等,供金币审计逐条对账, 也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)。 + **LT 因子账号累计仍按全表 unit 累计(feed+draw 共享),scene 只筛展示,不拆累计。** """ rows: list[dict] = [] if scene in (None, "reward_video"): rows.extend(_reward_video_rows(db, date=date, user_id=user_id)) - if scene in (None, "feed"): - rows.extend(_feed_rows(db, date=date, user_id=user_id)) + if scene in (None, "feed", "draw"): + rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene)) return rows diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py index 71733a4..f6b85e4 100644 --- a/app/admin/repositories/ad_revenue.py +++ b/app/admin/repositories/ad_revenue.py @@ -11,12 +11,17 @@ (ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计, 不受 limit(只截断 items)影响。 -⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 feed。 -② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按 report_date / reward_date 归日。 +每行带 ad_type(reward_video/feed/draw)与 feed_scene(comparison/coupon/welfare),供前端区分 +「比价 Draw 收益」与「领券 Draw 收益」(比价/领券共用同一代码位,只能靠 feed_scene 分)。 + +⚠️ 局限:① 历史信息流/Draw 发奖 ad_type 为 NULL 的旧记录统一视为 feed(向后兼容);Draw 仅 +ad_type=="draw" 的新记录单独成类。② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按 +report_date / reward_date 归日。 """ from __future__ import annotations -from datetime import date as _date, datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta +from datetime import date as _date from sqlalchemy import select from sqlalchemy.orm import Session @@ -30,7 +35,7 @@ from app.models.user import User def _cn_hour(dt: datetime) -> int: """created_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。""" if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) + dt = dt.replace(tzinfo=UTC) return dt.astimezone(rewards.CN_TZ).hour @@ -46,8 +51,18 @@ def _date_range(date_from: str, date_to: str) -> list[str]: return out -# 审计行的 scene 与报表 ad_type 一一对应 -_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"} +# 报表 ad_type 与审计 scene 取值一致(reward_video / feed / draw):feed 与 draw 同查发奖表 +# ad_feed_reward_record,由 audit 内部按 ad_type 区分(feed 含历史 NULL,draw 仅 ad_type=="draw")。 +_AUDIT_SCENES = {"reward_video", "feed", "draw"} + + +def _event_ad_type(row: dict) -> str: + """纯发奖事件行的 ad_type:信息流行用 audit 带回的真实 ad_type(feed/draw),回退 feed; + 激励视频行恒 reward_video。不再用 scene 硬映射,避免把 draw 丢成 feed。""" + if row["scene"] == "reward_video": + return "reward_video" + return row.get("ad_type") or "feed" + # 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。 _REWARD_DETAIL_KEYS = ( @@ -84,7 +99,9 @@ def ad_revenue_report( # 同时保留全量列表,未被展示合并的成「纯发奖」事件。 reward_by_session: dict[tuple[int, str], list[dict]] = {} all_reward_rows: list[dict] = [] - audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None + # 报表 ad_type 直接当 audit scene 用(取值一致);未知/无效 ad_type 不取发奖行。draw 在此被 + # 正确传成 scene="draw",audit 会按 ad_type 筛出 Draw 发奖,不再丢成 feed。 + audit_scene = ad_type if ad_type in _AUDIT_SCENES else None if ad_type is None or audit_scene is not None: for d in _date_range(date_from, date_to): for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene): @@ -123,6 +140,7 @@ def ad_revenue_report( "report_date": rec.report_date, "user_id": rec.user_id, "ad_type": rec.ad_type, + "feed_scene": rec.feed_scene, "app_env": rec.app_env, "our_code_id": rec.our_code_id, "created_at": rec.created_at, @@ -162,7 +180,8 @@ def ad_revenue_report( "event_key": f"rwd-{row['record_id']}", "report_date": row["_report_date"], "user_id": row["user_id"], - "ad_type": _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"]), + "ad_type": _event_ad_type(row), + "feed_scene": row.get("feed_scene"), "app_env": row.get("app_env"), "our_code_id": row.get("our_code_id"), "created_at": row["created_at"], diff --git a/app/admin/routers/ad_audit.py b/app/admin/routers/ad_audit.py index 69f98f3..d4d0819 100644 --- a/app/admin/routers/ad_audit.py +++ b/app/admin/routers/ad_audit.py @@ -26,7 +26,7 @@ def get_ad_coin_audit( date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None, user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None, scene: Annotated[ - str | None, Query(description="reward_video / feed;不传=两类都要") + str | None, Query(description="reward_video / feed / draw;不传=全部") ] = None, limit: Annotated[int, Query(ge=1, le=500)] = 100, only_mismatch: Annotated[ diff --git a/app/admin/schemas/ad_config.py b/app/admin/schemas/ad_config.py index 629eb59..30de284 100644 --- a/app/admin/schemas/ad_config.py +++ b/app/admin/schemas/ad_config.py @@ -9,8 +9,8 @@ class AdConfigOut(BaseModel): app_id: str reward_code_id: str - compare_feed_code_id: str - coupon_feed_code_id: str + compare_draw_code_id: str + coupon_draw_code_id: str reward_mkey: str reward_enabled: bool compare_ad_enabled: bool @@ -23,8 +23,8 @@ class AdConfigUpdate(BaseModel): 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 + compare_draw_code_id: str | None = None + coupon_draw_code_id: str | None = None reward_mkey: str | None = None reward_enabled: bool | None = None compare_ad_enabled: bool | None = None diff --git a/app/admin/schemas/ad_revenue.py b/app/admin/schemas/ad_revenue.py index 360d285..da7baf1 100644 --- a/app/admin/schemas/ad_revenue.py +++ b/app/admin/schemas/ad_revenue.py @@ -56,7 +56,12 @@ class AdRevenueRow(BaseModel): report_date: str = Field(..., description="该事件所属日期(北京时间 YYYY-MM-DD)") user_id: int user_phone: str | None = Field(None, description="用户手机号(admin 展示用,完整;用户已删 / 查不到为空)") - ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 Draw 信息流)") + ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(Draw 信息流);历史 NULL 视为 feed") + feed_scene: str | None = Field( + None, + description="点位场景:comparison(比价) / coupon(领券) / welfare(福利);供区分比价/领券 Draw 收益;" + "激励视频与旧数据为空", + ) app_env: str | None = Field(None, description="我们的应用:prod(傻瓜比价正式) / test(测试应用);旧数据为空") our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空") hour: int | None = Field(None, description="北京时间小时 0–23(granularity=hour 时有值;按天为 null)") diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 9bc9cbe..3e39f41 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -9,8 +9,8 @@ """ from __future__ import annotations -import logging import json +import logging import uuid from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -18,8 +18,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from app.api.deps import CurrentUser, DbSession from app.core import rewards from app.core.config import settings -from app.integrations import pangle from app.core.ratelimit import rate_limit +from app.integrations import pangle 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 @@ -285,12 +285,13 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm ad_type=payload.ad_type, ecpm_raw=payload.ecpm, ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, + feed_scene=payload.feed_scene, app_env=payload.app_env, our_code_id=payload.our_code_id, ) logger.info( - "ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s", - user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id, - payload.app_env, payload.our_code_id, + "ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s", + user.id, payload.ad_type, payload.feed_scene, payload.ad_session_id, payload.ecpm, + payload.adn, payload.slot_id, payload.app_env, payload.our_code_id, ) return EcpmReportOut(ok=True) @@ -401,6 +402,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed client_event_id=payload.client_event_id, ecpm=payload.ecpm, duration_seconds=payload.duration_seconds, + ad_type=payload.ad_type, ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 1ad66ab..3c4f4bf 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -40,9 +40,9 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) # 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。 SMS_LOGIN_MAX_PER_HOUR = 5 -# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。比登录略宽(发码含正常重发); +# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。 # 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。 -SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 10 +SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 def _login_response( @@ -90,7 +90,6 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser: "/sms/send", response_model=SmsSendResponse, summary="发送短信验证码", - dependencies=[Depends(rate_limit(10, 60, "sms-send"))], # 同 IP 每分钟≤10 次(防一 IP 刷不同号) ) def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse: # 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。 diff --git a/app/api/v1/platform.py b/app/api/v1/platform.py index 87b1ff4..c7a5873 100644 --- a/app/api/v1/platform.py +++ b/app/api/v1/platform.py @@ -63,8 +63,8 @@ def ad_config(db: DbSession) -> AdConfigPublicOut: 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"], + compare_draw_code_id=c["compare_draw_code_id"], + coupon_draw_code_id=c["coupon_draw_code_id"], reward_enabled=c["reward_enabled"], compare_ad_enabled=c["compare_ad_enabled"], coupon_ad_enabled=c["coupon_ad_enabled"], diff --git a/app/integrations/sms.py b/app/integrations/sms.py index df5bffc..6b16aac 100644 --- a/app/integrations/sms.py +++ b/app/integrations/sms.py @@ -14,7 +14,9 @@ worker / 多机时内存不共享 → 冷却、每日上限、校验都会失效 防刷三层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权): 1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件) 2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限(本文件) - 3. 单 IP 频控(api 层 rate_limit 依赖)+ 极光控制台 IP 白名单/防轰炸(运维侧) + 3. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。 + ⚠️ 原「单 IP 频控(rate_limit 依赖)」2026-06-26 按产品要求删除、改设备维度;但 device_id 客户端可伪造/轮换, + 脚本轮换 id 能绕过本层 → 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)。 另:单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废(防爆破),验过即作废(一次性)。 """ from __future__ import annotations diff --git a/app/models/ad_ecpm.py b/app/models/ad_ecpm.py index fc10a2f..a766210 100644 --- a/app/models/ad_ecpm.py +++ b/app/models/ad_ecpm.py @@ -29,6 +29,9 @@ class AdEcpmRecord(Base): ) # 广告类型:reward_video(激励视频) / draw(Draw 信息流) 等;不强行统一代码位,各类型各自上报 ad_type: Mapped[str] = mapped_column(String(32), nullable=False) + # 点位场景:comparison(比价) / coupon(领券) / welfare(福利),供收益报表区分比价/领券 Draw 收益; + # 仅信息流/Draw 上报(比价与领券共用同一代码位,只能客户端各调用点显式打标),激励视频为 NULL。 + feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True) # 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值 ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt) diff --git a/app/models/ad_feed_reward.py b/app/models/ad_feed_reward.py index f213c10..aa662d4 100644 --- a/app/models/ad_feed_reward.py +++ b/app/models/ad_feed_reward.py @@ -30,6 +30,9 @@ class AdFeedRewardRecord(Base): ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) adn: Mapped[str | None] = mapped_column(String(32), nullable=True) slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 广告类型:feed(信息流) / draw(Draw 信息流)。比价与领券共用同一 Draw 代码位,靠 feed_scene + # 区分收益;ad_type 区分广告形态。旧数据(未升级客户端)为 NULL,一律视为 feed,保持向后兼容。 + ad_type: Mapped[str | None] = mapped_column(String(16), nullable=True, default="feed") # 点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。比价与领券共用同一信息流 # 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。 feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True) diff --git a/app/repositories/ad_ecpm.py b/app/repositories/ad_ecpm.py index adfc7fd..8be3d20 100644 --- a/app/repositories/ad_ecpm.py +++ b/app/repositories/ad_ecpm.py @@ -23,6 +23,7 @@ def create_ecpm_record( ad_session_id: str | None = None, adn: str | None = None, slot_id: str | None = None, + feed_scene: str | None = None, app_env: str | None = None, our_code_id: str | None = None, ) -> AdEcpmRecord: @@ -41,6 +42,7 @@ def create_ecpm_record( ad_session_id=ad_session_id, adn=adn, slot_id=slot_id, + feed_scene=feed_scene, app_env=app_env, our_code_id=our_code_id, ecpm_raw=ecpm_raw, diff --git a/app/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py index 5b8595d..ab88cbd 100644 --- a/app/repositories/ad_feed_reward.py +++ b/app/repositories/ad_feed_reward.py @@ -14,7 +14,6 @@ from app.core.rewards import cn_today from app.models.ad_feed_reward import AdFeedRewardRecord from app.repositories import wallet as crud_wallet - FEED_REWARD_UNIT_SECONDS = 10 # 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数 # (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。 @@ -65,6 +64,7 @@ def grant_feed_reward( client_event_id: str, ecpm: str, duration_seconds: int, + ad_type: str = "feed", ad_session_id: str | None = None, adn: str | None = None, slot_id: str | None = None, @@ -84,6 +84,8 @@ def grant_feed_reward( duration_seconds 是**这一条**的观看秒数。服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS、 eCPM 在 calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN;叠加每日 get_ad_daily_limit 条数上限。 feed_scene:点位场景(comparison/coupon/welfare),仅归类落库,不参与计算。 + ad_type:广告形态(feed 信息流 / draw Draw 信息流),仅归类落库;**每日上限与因子2(LT)仍按本表 + 全表 unit 累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**。 """ existing = _find_by_event(db, client_event_id) if existing is not None: @@ -106,6 +108,7 @@ def grant_feed_reward( ecpm_raw=ecpm, adn=adn, slot_id=slot_id, + ad_type=ad_type, feed_scene=feed_scene, trace_id=trace_id, app_env=app_env, @@ -126,6 +129,7 @@ def grant_feed_reward( ecpm_raw=ecpm, adn=adn, slot_id=slot_id, + ad_type=ad_type, feed_scene=feed_scene, trace_id=trace_id, app_env=app_env, @@ -147,6 +151,7 @@ def grant_feed_reward( ecpm_raw=ecpm, adn=adn, slot_id=slot_id, + ad_type=ad_type, feed_scene=feed_scene, trace_id=trace_id, app_env=app_env, diff --git a/app/repositories/app_config.py b/app/repositories/app_config.py index e1a3b42..2cca9d0 100644 --- a/app/repositories/app_config.py +++ b/app/repositories/app_config.py @@ -101,10 +101,10 @@ AD_CONFIG_KEY = "ad_config" _AD_CONFIG_DEFAULTS: dict[str, Any] = { "app_id": "5830519", # 穿山甲应用ID(正式) "reward_code_id": "104099389", # 福利页激励视频位 - # ⚠️ 2026-06-21 真机核对穿山甲后台:5830519 名下信息流真实位是 104142227「信息流 1」; - # 旧值 104090333 不在该应用名下(请求会报 44406/配置 null、出不了广告)。客户端接入下发后以本值为准。 - "compare_feed_code_id": "104142227", # 比价信息流位 - "coupon_feed_code_id": "104142227", # 领券信息流位(初始同比价,运营可拆) + # 比价与领券共用同一穿山甲 Draw 代码位(默认 104098712 = 后台「Draw信息流」位),靠 feed_scene + # (comparison/coupon)区分收益;运营可在后台把两者拆成不同代码位。字段从旧 *_feed_code_id 改名为 *_draw_code_id。 + "compare_draw_code_id": "104098712", # 比价 Draw 代码位(104098712 = Draw 信息流位) + "coupon_draw_code_id": "104098712", # 领券 Draw 代码位(初始同比价,运营可拆) "reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*) "reward_enabled": True, # 福利激励视频开关 "compare_ad_enabled": True, # 比价广告开关 diff --git a/app/schemas/ad.py b/app/schemas/ad.py index d4e91fd..5db5af3 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -57,6 +57,12 @@ class EcpmReportIn(BaseModel): ) adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle") slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)") + feed_scene: str | None = Field( + None, + max_length=16, + description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);" + "比价与领券共用同一 Draw 代码位,需客户端在各调用点显式标注,供收益报表区分比价/领券;激励视频为空", + ) app_env: str | None = Field( None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" ) @@ -128,6 +134,11 @@ class FeedRewardIn(BaseModel): """ client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id") + ad_type: str = Field( + "feed", + max_length=16, + description="广告类型:feed(信息流) / draw(Draw 信息流);默认 feed 兼容旧客户端", + ) ad_session_id: str | None = Field( None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id" ) diff --git a/app/schemas/platform.py b/app/schemas/platform.py index a9c5c48..13ca209 100644 --- a/app/schemas/platform.py +++ b/app/schemas/platform.py @@ -38,8 +38,8 @@ class AdConfigPublicOut(BaseModel): app_id: str # 穿山甲应用ID(改了客户端需冷启才生效,SDK init 一次性读) reward_code_id: str # 福利页激励视频位 - compare_feed_code_id: str # 比价信息流位 - coupon_feed_code_id: str # 领券信息流位 + compare_draw_code_id: str # 比价 Draw 代码位 + coupon_draw_code_id: str # 领券 Draw 代码位(比价/领券共用同一位,靠 feed_scene 区分收益) reward_enabled: bool # 福利激励视频开关 compare_ad_enabled: bool # 比价广告开关 coupon_ad_enabled: bool # 领券广告开关 diff --git a/tests/test_auth.py b/tests/test_auth.py index 6288b11..4968a73 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -77,7 +77,7 @@ def test_sms_send_too_frequent(client) -> None: def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None: """发码防刷:同一设备(device_id) + 同一 IP 每小时最多 N 次发码,超出 429。 用不同手机号(绕开单号 60s 冷却)证明限流按设备封顶 —— 堵「换号绕开单号冷却/每日上限」的洞。 - conftest 默认关限流;本用例临时打开 + 调小阈值(避开同 IP 10/分钟那道)+ 清计数隔离。""" + conftest 默认关限流;本用例临时打开 + 调小阈值便于测 + 清计数隔离。""" from app.api.v1 import auth from app.core import ratelimit