From c01cc15bb0a1909c4998efe4c32d321f1d02b302 Mon Sep 17 00:00:00 2001 From: zzhyyyyy <2685922758@qq.com> Date: Fri, 26 Jun 2026 19:11:23 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(analytics):=20=E5=9F=8B=E7=82=B9?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=8E=A5=E6=94=B6=E6=8E=A5=E5=8F=A3=20+=20ad?= =?UTF-8?q?min=20=E6=9F=A5=E8=AF=A2=E6=8E=A5=E5=8F=A3=20+=20=E5=9F=8B?= =?UTF-8?q?=E7=82=B9=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新手引导埋点的服务端: - 表 analytics_event(五维硬性列 + props JSON 扩展字段;event/device_id/user_id/session_id/created_at 带索引) - POST /api/v1/analytics/events:客户端批量上报(不鉴权、body 读可选 user_id、补 client_ip + server_at) - admin GET /admin/api/event-logs:列表 + 按 事件/设备/用户/会话/时间 筛选(offset 分页,照 list_feedbacks) - alembic migration 建表(autogenerate 顺带检出的 ad/cps 历史索引漂移已手动剔除) app 主后端 :8770 与 admin :8771 共用同一 SQLite,admin 同库直接查、无需跨库。 配套客户端五维上报 + admin 日志页(另两仓库 PR)。 Co-Authored-By: Claude Opus 4.8 --- .../1699fc2c069f_add_analytics_event_table.py | 65 +++++++++++++++++++ app/admin/main.py | 2 + app/admin/repositories/queries.py | 39 +++++++++++ app/admin/routers/event_logs.py | 52 +++++++++++++++ app/admin/schemas/analytics.py | 33 ++++++++++ app/api/v1/analytics.py | 31 +++++++++ app/main.py | 2 + app/models/__init__.py | 1 + app/models/analytics_event.py | 60 +++++++++++++++++ app/repositories/analytics.py | 34 ++++++++++ app/schemas/analytics.py | 42 ++++++++++++ 11 files changed, 361 insertions(+) create mode 100644 alembic/versions/1699fc2c069f_add_analytics_event_table.py create mode 100644 app/admin/routers/event_logs.py create mode 100644 app/admin/schemas/analytics.py create mode 100644 app/api/v1/analytics.py create mode 100644 app/models/analytics_event.py create mode 100644 app/repositories/analytics.py create mode 100644 app/schemas/analytics.py diff --git a/alembic/versions/1699fc2c069f_add_analytics_event_table.py b/alembic/versions/1699fc2c069f_add_analytics_event_table.py new file mode 100644 index 0000000..3e51ca5 --- /dev/null +++ b/alembic/versions/1699fc2c069f_add_analytics_event_table.py @@ -0,0 +1,65 @@ +"""add analytics_event table + +Revision ID: 1699fc2c069f +Revises: bcfcaf07152b +Create Date: 2026-06-26 16:35:16.133975 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '1699fc2c069f' +down_revision: str | Sequence[str] | None = 'bcfcaf07152b' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('analytics_event', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('event', sa.String(length=64), nullable=False), + sa.Column('props', sa.JSON(), nullable=True), + sa.Column('device_id', sa.String(length=64), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('session_id', sa.String(length=64), nullable=True), + sa.Column('client_ts', sa.BigInteger(), nullable=False), + sa.Column('sent_at', sa.BigInteger(), nullable=True), + sa.Column('page', sa.String(length=64), nullable=True), + sa.Column('client_ip', sa.String(length=64), nullable=True), + sa.Column('oem', sa.String(length=32), nullable=True), + sa.Column('os', sa.String(length=32), nullable=True), + sa.Column('model', sa.String(length=64), nullable=True), + sa.Column('app_ver', sa.String(length=32), nullable=True), + sa.Column('network', sa.String(length=16), nullable=True), + sa.Column('channel', sa.String(length=32), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('analytics_event', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_analytics_event_created_at'), ['created_at'], unique=False) + batch_op.create_index(batch_op.f('ix_analytics_event_device_id'), ['device_id'], unique=False) + batch_op.create_index(batch_op.f('ix_analytics_event_event'), ['event'], unique=False) + batch_op.create_index(batch_op.f('ix_analytics_event_session_id'), ['session_id'], unique=False) + batch_op.create_index(batch_op.f('ix_analytics_event_user_id'), ['user_id'], unique=False) + + # 注:autogenerate 顺带检出 ad_ecpm/cps_*/invite_fingerprint 的历史索引漂移, + # 与本次「新增埋点表」无关,已手动移除,避免本迁移误改他人表。 + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('analytics_event', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_analytics_event_user_id')) + batch_op.drop_index(batch_op.f('ix_analytics_event_session_id')) + batch_op.drop_index(batch_op.f('ix_analytics_event_event')) + batch_op.drop_index(batch_op.f('ix_analytics_event_device_id')) + batch_op.drop_index(batch_op.f('ix_analytics_event_created_at')) + + op.drop_table('analytics_event') + # ### end Alembic commands ### diff --git a/app/admin/main.py b/app/admin/main.py index 8dfdf77..33e472d 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -24,6 +24,7 @@ from app.admin.routers.config import router as config_router from app.admin.routers.cps import router as cps_router from app.admin.routers.dashboard import router as dashboard_router from app.admin.routers.ops_stat_config import router as ops_stat_config_router +from app.admin.routers.event_logs import router as event_logs_router from app.admin.routers.feedback import router as feedback_router from app.admin.routers.feedback_qr import router as feedback_qr_router from app.admin.routers.onboarding import router as onboarding_router @@ -91,6 +92,7 @@ admin_app.include_router(wallet_router) admin_app.include_router(withdraw_router) admin_app.include_router(price_report_router) admin_app.include_router(feedback_router) +admin_app.include_router(event_logs_router) admin_app.include_router(feedback_qr_router) admin_app.include_router(admins_router) admin_app.include_router(audit_router) diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 9c05600..6527700 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -15,6 +15,7 @@ from app.core import rewards from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.ad_reward import AdRewardRecord from app.models.admin import AdminAuditLog +from app.models.analytics_event import AnalyticsEvent from app.models.comparison import ComparisonRecord from app.models.feedback import Feedback from app.models.onboarding import OnboardingCompletion @@ -412,6 +413,44 @@ def list_feedbacks( return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) +def list_analytics_events( + db: Session, + *, + event: str | None = None, + device_id: str | None = None, + user_id: int | None = None, + session_id: str | None = None, + created_from: datetime | None = None, + created_to: datetime | None = None, + sort_by: str = "id", + sort_order: str = "desc", + limit: int = 20, + cursor: int | None = None, +) -> tuple[list[AnalyticsEvent], int | None, int]: + """埋点日志列表(admin 全量)。按 事件名 / 设备ID前缀 / 用户ID / 会话ID / 接收时间范围 筛选, + 按 id·接收时间排序。offset 分页(同 [list_feedbacks])。created_at 为 timestamptz, + 日期入参统一转 tz-aware UTC 比较。""" + stmt = select(AnalyticsEvent) + if event: + stmt = stmt.where(AnalyticsEvent.event == event) + if device_id and device_id.strip(): + stmt = stmt.where(AnalyticsEvent.device_id.like(f"{device_id.strip()}%")) + if user_id is not None: + stmt = stmt.where(AnalyticsEvent.user_id == user_id) + if session_id and session_id.strip(): + stmt = stmt.where(AnalyticsEvent.session_id == session_id.strip()) + if created_from is not None: + stmt = stmt.where(AnalyticsEvent.created_at >= _as_utc(created_from)) + if created_to is not None: + stmt = stmt.where(AnalyticsEvent.created_at <= _as_utc(created_to)) + + sort_cols = {"id": AnalyticsEvent.id, "created_at": AnalyticsEvent.created_at} + sort_col = sort_cols.get(sort_by, AnalyticsEvent.id) + order_fn = asc if sort_order == "asc" else desc + id_order = asc(AnalyticsEvent.id) if sort_order == "asc" else desc(AnalyticsEvent.id) + return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) + + def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None: """按商户单号查提现单(admin 重试打款先拿 user_id 用,M3)。""" return db.execute( diff --git a/app/admin/routers/event_logs.py b/app/admin/routers/event_logs.py new file mode 100644 index 0000000..eceb155 --- /dev/null +++ b/app/admin/routers/event_logs.py @@ -0,0 +1,52 @@ +"""admin 埋点日志:列表 + 按事件 / 设备 / 用户 / 会话 / 时间筛选(只读,同库直接查 analytics_event)。""" +from __future__ import annotations + +from datetime import datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, Query + +from app.admin.deps import AdminDb, get_current_admin +from app.admin.repositories import queries +from app.admin.schemas.analytics import AnalyticsEventOut +from app.admin.schemas.common import CursorPage + +router = APIRouter( + prefix="/admin/api/event-logs", + tags=["admin-event-logs"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get("", response_model=CursorPage[AnalyticsEventOut], summary="埋点日志列表") +def list_event_logs( + db: AdminDb, + event: Annotated[str | None, Query(max_length=64)] = None, + device_id: Annotated[str | None, Query(max_length=64)] = None, + user_id: Annotated[int | None, Query()] = None, + session_id: Annotated[str | None, Query(max_length=64)] = None, + created_from: Annotated[datetime | None, Query()] = None, + created_to: Annotated[datetime | None, Query()] = None, + sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id", + sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[int | None, Query()] = None, +) -> CursorPage[AnalyticsEventOut]: + items, next_cursor, total = queries.list_analytics_events( + db, + event=event, + device_id=device_id, + user_id=user_id, + session_id=session_id, + created_from=created_from, + created_to=created_to, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, + cursor=cursor, + ) + return CursorPage( + items=[AnalyticsEventOut.model_validate(e) for e in items], + next_cursor=next_cursor, + total=total, + ) diff --git a/app/admin/schemas/analytics.py b/app/admin/schemas/analytics.py new file mode 100644 index 0000000..2aef672 --- /dev/null +++ b/app/admin/schemas/analytics.py @@ -0,0 +1,33 @@ +"""admin 埋点日志列表响应。""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class AnalyticsEventOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + event: str + # Who + device_id: str + user_id: int | None + # When + session_id: str | None + client_ts: int + sent_at: int | None + created_at: datetime # 服务端接收时间(server_at) + # Where + page: str | None + client_ip: str | None + # How + oem: str | None + os: str | None + model: str | None + app_ver: str | None + network: str | None + channel: str | None + # What 专属 + props: dict | None diff --git a/app/api/v1/analytics.py b/app/api/v1/analytics.py new file mode 100644 index 0000000..265954d --- /dev/null +++ b/app/api/v1/analytics.py @@ -0,0 +1,31 @@ +"""客户端埋点上报接口。 + +POST /api/v1/analytics/events — 批量接收新手引导(及后续)埋点,append 落 analytics_event 表。 +**不强制登录**(未登录态也要采集行为):user_id 由客户端在 body 里可选带上,不靠 Bearer。 +服务端补 client_ip(X-Forwarded-For)与 created_at(接收时间 = server_at)。 +""" +from __future__ import annotations + +from fastapi import APIRouter, Request + +from app.api.deps import DbSession +from app.repositories import analytics as analytics_repo +from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut + +router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"]) + + +def _client_ip(request: Request) -> str: + """取客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 第一段,否则直连 IP(同 admin get_client_ip)。""" + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else "" + + +@router.post("/events", response_model=AnalyticsIngestOut, summary="批量上报埋点事件") +def ingest_events( + batch: AnalyticsBatchIn, request: Request, db: DbSession +) -> AnalyticsIngestOut: + n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request)) + return AnalyticsIngestOut(received=n) diff --git a/app/main.py b/app/main.py index 5a4a6bd..dd7ebbd 100644 --- a/app/main.py +++ b/app/main.py @@ -15,6 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from app.api.v1.ad import router as ad_router +from app.api.v1.analytics import router as analytics_router from app.api.v1.auth import router as auth_router from app.api.v1.compare import router as compare_router from app.api.v1.compare_milestone import router as compare_milestone_router @@ -105,6 +106,7 @@ def health() -> dict[str, str]: app.include_router(auth_router) app.include_router(user_router) app.include_router(feedback_router) +app.include_router(analytics_router) app.include_router(invite_router) app.include_router(coupon_router) app.include_router(device_router) diff --git a/app/models/__init__.py b/app/models/__init__.py index 09ce54a..f665cc5 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -4,6 +4,7 @@ from app.models.ad_feed_reward import AdFeedRewardRecord # noqa: F401 from app.models.ad_reward import AdRewardRecord # noqa: F401 from app.models.ad_watch_log import AdWatchLog # noqa: F401 from app.models.admin import AdminAuditLog, AdminUser # noqa: F401 +from app.models.analytics_event import AnalyticsEvent # noqa: F401 from app.models.app_config import AppConfig # noqa: F401 from app.models.comparison import ComparisonRecord # noqa: F401 from app.models.cps_activity import CpsActivity # noqa: F401 diff --git a/app/models/analytics_event.py b/app/models/analytics_event.py new file mode 100644 index 0000000..63110c3 --- /dev/null +++ b/app/models/analytics_event.py @@ -0,0 +1,60 @@ +"""新手引导(及后续)埋点事件表。 + +每行 = 客户端上报的一条行为埋点,按「Who / When / Where / What / How」五维组织: +- What :event(事件名,如 video_play)+ props(事件专属属性,JSON) +- Who :device_id(硬件级设备标识)+ user_id(登录后才有,可空) +- When :client_ts(端事件时间 epoch ms)+ session_id(本次引导会话)+ sent_at(端上报时间) + + created_at(服务端接收时间 = server_at) +- Where:page(引导步/页面)+ client_ip(服务端从 X-Forwarded-For 取) +- How :oem / os / model / app_ver / network / channel(设备与环境) + +append-only,不更新;客户端批量上报(见 app/api/v1/analytics.py),admin 同库直接查(见 +app/admin/routers/event_logs.py)。未登录态也允许上报(user_id 为空),故 user_id 不设外键、只索引。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import JSON, BigInteger, DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AnalyticsEvent(Base): + __tablename__ = "analytics_event" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + # ---- What:做了什么 ---- + event: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + props: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + # ---- Who:谁 ---- + device_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) + + # ---- When:何时 ---- + session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + client_ts: Mapped[int] = mapped_column(BigInteger, nullable=False) # 端事件时间 epoch ms + sent_at: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 端上报时间 epoch ms + + # ---- Where:何地 ---- + page: Mapped[str | None] = mapped_column(String(64), nullable=True) + client_ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + + # ---- How:用什么环境 ---- + oem: Mapped[str | None] = mapped_column(String(32), nullable=True) + os: Mapped[str | None] = mapped_column(String(32), nullable=True) + model: Mapped[str | None] = mapped_column(String(64), nullable=True) + app_ver: Mapped[str | None] = mapped_column(String(32), nullable=True) + network: Mapped[str | None] = mapped_column(String(16), nullable=True) + channel: Mapped[str | None] = mapped_column(String(32), nullable=True) + + # 服务端接收时间(= When.server_at);客户端时间不可信,以此为权威落库时刻。 + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/repositories/analytics.py b/app/repositories/analytics.py new file mode 100644 index 0000000..a43dcb3 --- /dev/null +++ b/app/repositories/analytics.py @@ -0,0 +1,34 @@ +"""埋点事件批量落库。append-only,一次 commit 提交整批。""" +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.models.analytics_event import AnalyticsEvent +from app.schemas.analytics import AnalyticsBatchIn + + +def record_batch(db: Session, batch: AnalyticsBatchIn, *, client_ip: str | None) -> int: + """把一批上报事件展开成多行落库(公共维度复制到每行),返回写入条数。""" + rows = [ + AnalyticsEvent( + event=e.event, + props=e.props or None, + device_id=batch.device_id, + user_id=batch.user_id, + session_id=e.session_id, + client_ts=e.client_ts, + sent_at=batch.sent_at, + page=e.page, + client_ip=client_ip or None, + oem=batch.oem, + os=batch.os, + model=batch.model, + app_ver=batch.app_ver, + network=e.network, + channel=batch.channel, + ) + for e in batch.events + ] + db.add_all(rows) + db.commit() + return len(rows) diff --git a/app/schemas/analytics.py b/app/schemas/analytics.py new file mode 100644 index 0000000..f0347c0 --- /dev/null +++ b/app/schemas/analytics.py @@ -0,0 +1,42 @@ +"""客户端埋点上报 schema(批量)。 + +客户端把「设备固定维度」(device_id / user_id / oem / os / model / app_ver / channel / sent_at) +放批次外层只传一次,events 列表里每条只带「事件维度」(event / client_ts / session_id / page / +network / props);服务端展开成多行 AnalyticsEvent 落库(见 app/repositories/analytics.py)。 +""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class AnalyticsEventIn(BaseModel): + """单条事件维度。""" + + event: str = Field(max_length=64) + client_ts: int = Field(description="端事件发生时间 epoch ms") + session_id: str | None = Field(default=None, max_length=64) + page: str | None = Field(default=None, max_length=64) + network: str | None = Field(default=None, max_length=16) + props: dict[str, str] = Field(default_factory=dict) + + +class AnalyticsBatchIn(BaseModel): + """一批上报:公共维度 + 事件列表。""" + + # Who(整批共享) + device_id: str = Field(max_length=64) + user_id: int | None = None + # When(本批上报时刻 epoch ms) + sent_at: int | None = None + # How(设备固定维度,整批共享) + oem: str | None = Field(default=None, max_length=32) + os: str | None = Field(default=None, max_length=32) + model: str | None = Field(default=None, max_length=64) + app_ver: str | None = Field(default=None, max_length=32) + channel: str | None = Field(default=None, max_length=32) + events: list[AnalyticsEventIn] = Field(min_length=1, max_length=200) + + +class AnalyticsIngestOut(BaseModel): + ok: bool = True + received: int -- 2.52.0 From b59145c7842c1c4d9f8125b48add4ef2b37bf409 Mon Sep 17 00:00:00 2001 From: zzhyyyyy <2685922758@qq.com> Date: Fri, 26 Jun 2026 22:58:23 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(ad):=20=E4=BF=A1=E6=81=AF=E6=B5=81?= =?UTF-8?q?=E5=B9=BF=E5=91=8A=E5=88=87=E6=8D=A2=E4=B8=BA=20Draw=20?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E6=B5=81=E5=B9=B6=E5=8C=BA=E5=88=86=E6=AF=94?= =?UTF-8?q?=E4=BB=B7/=E9=A2=86=E5=88=B8=E6=94=B6=E7=9B=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端支持 Draw 信息流广告: - ad_feed_reward_record 新增 ad_type(feed/draw),ad_ecpm_record 新增 feed_scene(comparison/coupon/welfare)+ Alembic 迁移 - ecpm-report / feed-reward 接口接收并落 ad_type=draw 与 feed_scene - 广告配置字段 compare/coupon_feed_code_id 改名为 compare/coupon_draw_code_id(默认 Draw 位 104098712) - 收益报表与对账(ad_revenue / ad_audit)支持 draw 类型,按 feed_scene 区分比价/领券收益 Co-Authored-By: Claude Opus 4.8 --- ...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/platform.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 +- 15 files changed, 162 insertions(+), 34 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/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/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 # 领券广告开关 -- 2.52.0 From e1e390a406f6c05ac03f0f1cd28e518bde9b9a59 Mon Sep 17 00:00:00 2001 From: zzhyyyyy <2685922758@qq.com> Date: Fri, 26 Jun 2026 23:29:12 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(auth):=20=E5=8F=91=E7=A0=81=E9=98=B2?= =?UTF-8?q?=E5=88=B7=E7=BB=9F=E4=B8=80=E8=B5=B0=E8=AE=BE=E5=A4=87=E7=BB=B4?= =?UTF-8?q?=E5=BA=A6=20=E2=80=94=20=E5=88=A0=20IP=20=E9=82=A3=E9=81=93=20+?= =?UTF-8?q?=20=E9=98=88=E5=80=BC=2010=E2=86=925?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /sms/send 去掉路由上的 IP 维度限流(rate_limit 依赖):统一由设备(device_id)那道兜 (产品决议:IP 那道会风控同 IP 的测试号,而测试号已豁免设备那道)。 - 设备发码限流阈值 10 → 5(与登录一致)。 - 同步 sms.py 防刷分层注释。 ⚠️ 取舍:device_id 客户端可伪造/轮换,脚本轮换 id 可绕过发码限流;挡脚本狂发改主要靠极光控制台侧。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/auth.py | 5 ++--- app/integrations/sms.py | 4 +++- tests/test_auth.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) 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/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/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 -- 2.52.0