From eb5f0dc4607f6585aaedf6ffc492288fa2d94581 Mon Sep 17 00:00:00 2001 From: OuYingJun1024 <1034284404@qq.com> Date: Sun, 31 May 2026 14:52:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(ad):=20eCPM=20=E4=B8=8A=E6=8A=A5=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=20+=20=E5=86=B7=E5=8D=B4=E7=AD=96=E7=95=A5=E8=A7=A3?= =?UTF-8?q?=E8=80=A6=E7=BA=AF=E5=87=BD=E6=95=B0=20+=20savings=20JSONB=20?= =?UTF-8?q?=E8=B7=A8=E5=BA=93=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 POST /api/v1/ad/ecpm-report:激励视频展示后客户端上报本次 eCPM,落 ad_ecpm_record 做内部收益统计(model/repository/schema/alembic 迁移 + 接口文档) - 看广告冷却策略抽到 app/core/ad_cooldown.py 纯函数;ad_reward.today_status 只取数据,换策略只改这一处 - savings.dishes 改 JSON().with_variant(JSONB,"postgresql"):SQLite 无 visit_JSONB 会让 create_all 编译崩,修复后测试套件恢复 Co-Authored-By: Claude Opus 4.8 --- alembic/versions/ad_ecpm_record.py | 47 +++++++++++++++++++++ app/api/v1/ad.py | 33 ++++++++++++++- app/core/ad_cooldown.py | 54 +++++++++++++++++++++++++ app/models/__init__.py | 1 + app/models/ad_ecpm.py | 49 ++++++++++++++++++++++ app/models/savings.py | 10 +++-- app/repositories/ad_ecpm.py | 49 ++++++++++++++++++++++ app/repositories/ad_reward.py | 65 ++++++++++++------------------ app/schemas/ad.py | 18 +++++++++ docs/api/README.md | 1 + docs/api/ad-ecpm-report.md | 36 +++++++++++++++++ 11 files changed, 320 insertions(+), 43 deletions(-) create mode 100644 alembic/versions/ad_ecpm_record.py create mode 100644 app/core/ad_cooldown.py create mode 100644 app/models/ad_ecpm.py create mode 100644 app/repositories/ad_ecpm.py create mode 100644 docs/api/ad-ecpm-report.md diff --git a/alembic/versions/ad_ecpm_record.py b/alembic/versions/ad_ecpm_record.py new file mode 100644 index 0000000..a810674 --- /dev/null +++ b/alembic/versions/ad_ecpm_record.py @@ -0,0 +1,47 @@ +"""ad_ecpm_record table (广告展示 eCPM 上报记录,内部收益统计/对账) + +Revision ID: a1b2c3d4e5f6 +Revises: f01db5d77dac +Create Date: 2026-05-31 11:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, Sequence[str], None] = 'f01db5d77dac' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'ad_ecpm_record', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('ad_type', sa.String(length=32), nullable=False), + sa.Column('adn', sa.String(length=32), nullable=True), + sa.Column('slot_id', sa.String(length=64), nullable=True), + sa.Column('ecpm_raw', sa.String(length=32), nullable=False), + sa.Column('report_date', sa.String(length=10), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('ad_ecpm_record', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_ad_ecpm_record_user_id'), ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_ad_ecpm_record_report_date'), ['report_date'], unique=False) + batch_op.create_index(batch_op.f('ix_ad_ecpm_record_created_at'), ['created_at'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('ad_ecpm_record', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_ad_ecpm_record_created_at')) + batch_op.drop_index(batch_op.f('ix_ad_ecpm_record_report_date')) + batch_op.drop_index(batch_op.f('ix_ad_ecpm_record_user_id')) + + op.drop_table('ad_ecpm_record') diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index e4441d3..51a0732 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -19,8 +19,15 @@ 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.repositories import ad_ecpm as crud_ecpm from app.repositories import ad_reward as crud_ad -from app.schemas.ad import AdRewardStatusOut, PangleCallbackOut, TestGrantOut +from app.schemas.ad import ( + AdRewardStatusOut, + EcpmReportIn, + EcpmReportOut, + PangleCallbackOut, + TestGrantOut, +) logger = logging.getLogger("shagua.ad") @@ -95,6 +102,30 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut: ) +@router.post( + "/ecpm-report", + response_model=EcpmReportOut, + summary="上报本次广告展示的 eCPM(内部收益统计)", + dependencies=[Depends(rate_limit(120, 60, "ad-ecpm-report"))], +) +def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> EcpmReportOut: + """客户端在广告展示后(onAdShow 读 getShowEcpm)上报 eCPM,落库做内部收益统计/对账。 + + Bearer 鉴权,user_id 取自 JWT(不信 body)。best-effort:落库即 ok,客户端 fire-and-forget, + 丢一两条不影响业务(穿山甲后台报表是结算权威)。eCPM 与发奖(S2S)是两条独立流,不逐条关联。 + """ + crud_ecpm.create_ecpm_record( + db, user.id, + ad_type=payload.ad_type, ecpm_raw=payload.ecpm, + adn=payload.adn, slot_id=payload.slot_id, + ) + logger.info( + "ad ecpm report user_id=%d type=%s ecpm=%s adn=%s slot=%s", + user.id, payload.ad_type, payload.ecpm, payload.adn, payload.slot_id, + ) + return EcpmReportOut(ok=True) + + @router.post( "/test-grant", response_model=TestGrantOut, diff --git a/app/core/ad_cooldown.py b/app/core/ad_cooldown.py new file mode 100644 index 0000000..80e26d5 --- /dev/null +++ b/app/core/ad_cooldown.py @@ -0,0 +1,54 @@ +"""看激励视频冷却策略 —— 与发奖记录查询解耦的纯计算。 + +当前策略:**每 N 次一轮,看满一轮后强制冷却若干秒**(N / 秒数 取自 [rewards] 常量)。 +[repositories.ad_reward.today_status] 只负责取数据(今日 granted 的 created_at 列表), +把"本轮已看几次 + 冷却到几点"的策略判断委托到这里。 + +⚠️ 这是临时策略,后续要调。换策略(间隔式 / 每日配额式 / 指数退避 …)**只改本文件**, +repository 不碰——这就是把它独立出来的目的。保持 [compute_cooldown] 签名稳定即可。 +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from app.core.rewards import VIDEO_ROUND_COOLDOWN_SECONDS, VIDEO_ROUND_REQUIRED_COUNT + + +@dataclass(frozen=True) +class CooldownState: + """冷却策略的输出。""" + + round_count: int # 本轮已看次数 0..N-1(展示用) + cooldown_until: datetime | None # 本轮冷却结束时间(UTC);None = 不在冷却 + + +def compute_cooldown( + granted_times_desc: list[datetime], + now: datetime, + *, + round_size: int = VIDEO_ROUND_REQUIRED_COUNT, + cooldown_seconds: int = VIDEO_ROUND_COOLDOWN_SECONDS, +) -> CooldownState: + """按"每 round_size 次一轮、看满一轮后冷却 cooldown_seconds 秒"算本轮进度 + 冷却结束时间。 + + :param granted_times_desc: 今日 status=granted 记录的 created_at,**按时间倒序**(最新在前)。 + :param now: 当前时间(UTC,带 tzinfo),用于判断冷却是否已过。 + :param round_size / cooldown_seconds: 策略参数,默认取 rewards 常量,可注入便于测试/调参。 + + 纯函数,不碰 DB。冷却派生算法:把今日 granted 倒序,跳过当前未完成轮的 round_count 条, + 下一条即"上一轮最后一次"的时间,+ cooldown_seconds 仍 > now 则在冷却中。 + SQLite 上 created_at 可能是 naive,按 UTC 解读再比较。 + """ + used = len(granted_times_desc) + round_count = used % round_size + cooldown_until: datetime | None = None + if used >= round_size: + # round_count 必 < round_size <= used,索引合法 + last_round_end = granted_times_desc[round_count] + if last_round_end.tzinfo is None: + last_round_end = last_round_end.replace(tzinfo=timezone.utc) + cd_end = last_round_end + timedelta(seconds=cooldown_seconds) + if cd_end > now: + cooldown_until = cd_end + return CooldownState(round_count=round_count, cooldown_until=cooldown_until) diff --git a/app/models/__init__.py b/app/models/__init__.py index 047a2a6..82b9d10 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,4 +1,5 @@ """所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。""" +from app.models.ad_ecpm import AdEcpmRecord # noqa: F401 from app.models.ad_reward import AdRewardRecord # noqa: F401 from app.models.feedback import Feedback # noqa: F401 from app.models.savings import SavingsRecord # noqa: F401 diff --git a/app/models/ad_ecpm.py b/app/models/ad_ecpm.py new file mode 100644 index 0000000..e3a5c14 --- /dev/null +++ b/app/models/ad_ecpm.py @@ -0,0 +1,49 @@ +"""广告展示 eCPM 上报记录(内部收益统计/对账)。 + +每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM 信息。和发奖记录 +[ad_reward.AdRewardRecord] 是**两条独立的数据流**: +- 发奖走穿山甲 S2S 回调(后端 → 有 trans_id、无 ecpm); +- eCPM 走客户端上报(客户端 → 有 ecpm、无 trans_id)。 +两者没有公共键,无法逐条一一对应,所以本表用于**按用户/按天聚合收益**口径的对账, +不做"这条发奖 = 这条 ecpm"的精确关联。穿山甲后台报表才是结算权威,本表是细粒度补充。 + +⚠️ `ecpm_raw` 原样存客户端上报的字符串——eCPM 单位(分 / 元)截至 2026-05-31 尚未最终确认, +确认后再加一列解析好的数值;在此之前对账按"待定单位"处理。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AdEcpmRecord(Base): + __tablename__ = "ad_ecpm_record" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + # 广告类型:reward_video(激励视频) / draw(Draw 信息流) 等;不强行统一代码位,各类型各自上报 + ad_type: Mapped[str] = mapped_column(String(32), nullable=False) + # 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt) + adn: Mapped[str | None] = mapped_column(String(32), nullable=True) + # 实际展示用的代码位(底层 mediation rit,非客户端配置位) + slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 客户端上报的 eCPM 原始字符串(单位待确认,原样存) + ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) + # 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较) + report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False) + + 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/models/savings.py b/app/models/savings.py index 317a670..7ed5d21 100644 --- a/app/models/savings.py +++ b/app/models/savings.py @@ -8,7 +8,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column @@ -29,8 +29,12 @@ class SavingsRecord(Base): title: Mapped[str | None] = mapped_column(String(128), nullable=True) # 店铺名(订单明细卡标题,如「窑鸡王(王府井店)」) shop_name: Mapped[str | None] = mapped_column(String(128), nullable=True) - # 菜品名列表(JSONB),PG 上可建 GIN 索引,前 2 道直接展示,其余收进「还有 N 道菜」展开。 - dishes: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list) + # 菜品名列表。PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON—— + # 否则 SQLite 无 visit_JSONB,Base.metadata.create_all 编译报错(测试套件 setup 全崩)。 + # 前 2 道直接展示,其余收进「还有 N 道菜」展开。 + dishes: Mapped[list[str]] = mapped_column( + JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=list + ) # 来源:demo(演示) / compare(真实比价上报) source: Mapped[str] = mapped_column(String(16), nullable=False, default="compare") diff --git a/app/repositories/ad_ecpm.py b/app/repositories/ad_ecpm.py new file mode 100644 index 0000000..8af5f14 --- /dev/null +++ b/app/repositories/ad_ecpm.py @@ -0,0 +1,49 @@ +"""广告 eCPM 上报 CRUD(内部收益统计/对账)。 + +客户端在广告展示后(onAdShow)读到 eCPM,经鉴权接口上报,这里落库。鉴权接口已确保 +user 存在(JWT),故不做 UnknownUser 校验。best-effort 上报:丢一两条不影响业务, +穿山甲后台报表是结算权威兜底。 +""" +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.rewards import cn_today +from app.models.ad_ecpm import AdEcpmRecord + + +def create_ecpm_record( + db: Session, + user_id: int, + *, + ad_type: str, + ecpm_raw: str, + adn: str | None = None, + slot_id: str | None = None, +) -> AdEcpmRecord: + """落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。""" + rec = AdEcpmRecord( + user_id=user_id, + ad_type=ad_type, + adn=adn, + slot_id=slot_id, + ecpm_raw=ecpm_raw, + report_date=cn_today().isoformat(), + ) + db.add(rec) + db.commit() + db.refresh(rec) + return rec + + +def count_today(db: Session, user_id: int) -> int: + """该用户今日(北京时间)上报的 eCPM 条数,排查/对账辅助用。""" + return db.execute( + select(func.count()) + .select_from(AdEcpmRecord) + .where( + AdEcpmRecord.user_id == user_id, + AdEcpmRecord.report_date == cn_today().isoformat(), + ) + ).scalar_one() diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index a901443..d42aab5 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -10,17 +10,16 @@ """ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from app.core.ad_cooldown import compute_cooldown from app.core.rewards import ( AD_REWARD_COIN, DAILY_AD_REWARD_LIMIT, - VIDEO_ROUND_COOLDOWN_SECONDS, - VIDEO_ROUND_REQUIRED_COUNT, cn_today, ) from app.repositories import wallet as crud_wallet @@ -110,26 +109,19 @@ def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardR return rec -def _last_completed_round_end_at( - db: Session, user_id: int, reward_date: str, round_count: int -) -> datetime | None: - """当日 granted 记录中**最近一个已完成轮**末尾那次的 created_at。 - - 思路:把当日 granted 按时间倒序排,跳过当前未完成轮的 round_count 条,下一条 - 即"上一轮最后一次"。round_count==0 且 used>=N 时跳 0 条直接取最近一条。 - used list[datetime]: + """当日 status=granted 记录的 created_at,按时间倒序(最新在前)——冷却策略的输入数据。""" + return list( + db.execute( + select(AdRewardRecord.created_at) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.reward_date == reward_date, + AdRewardRecord.status == "granted", + ) + .order_by(AdRewardRecord.created_at.desc()) + ).scalars() + ) def today_status( @@ -138,21 +130,16 @@ def today_status( """客户端查"今日看广告发奖"进度。 返回 (今日已发次数, 每日上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC))。 - - round_count = used % VIDEO_ROUND_REQUIRED_COUNT,展示用(0..N-1) - - cooldown_until 计算:取最近一个已完成轮末尾的 created_at + 10 min;若仍 > now 则返回, - 否则返回 None。冷却 = UX 约束(客户端 CTA 倒计时不可点),后端发奖逻辑不受影响。 + 本函数只**取数据**(今日 granted 的 created_at 倒序),把"本轮已看几次 + 冷却到几点"的 + **策略判断**委托给 [app.core.ad_cooldown.compute_cooldown](纯函数)——换冷却策略只动那个模块。 """ today = cn_today().isoformat() - used = _granted_today(db, user_id, today) - round_count = used % VIDEO_ROUND_REQUIRED_COUNT - cooldown_until: datetime | None = None - if used >= VIDEO_ROUND_REQUIRED_COUNT: - last_end = _last_completed_round_end_at(db, user_id, today, round_count) - if last_end is not None: - # SQLAlchemy 在 SQLite 上拿到的 created_at 可能是 naive,统一按 UTC 解读再比较 - if last_end.tzinfo is None: - last_end = last_end.replace(tzinfo=timezone.utc) - cd_end = last_end + timedelta(seconds=VIDEO_ROUND_COOLDOWN_SECONDS) - if cd_end > datetime.now(timezone.utc): - cooldown_until = cd_end - return used, DAILY_AD_REWARD_LIMIT, AD_REWARD_COIN, round_count, cooldown_until + granted_desc = _granted_times_today_desc(db, user_id, today) + state = compute_cooldown(granted_desc, datetime.now(timezone.utc)) + return ( + len(granted_desc), + DAILY_AD_REWARD_LIMIT, + AD_REWARD_COIN, + state.round_count, + state.cooldown_until, + ) diff --git a/app/schemas/ad.py b/app/schemas/ad.py index 47635e9..7ed42d0 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -39,6 +39,24 @@ class AdRewardStatusOut(BaseModel): ) +class EcpmReportIn(BaseModel): + """客户端上报一次广告展示的 eCPM(内部收益统计/对账)。 + + user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串(单位待确认)。 + """ + + ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等") + ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,单位待确认,原样上报") + adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle") + slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)") + + +class EcpmReportOut(BaseModel): + """eCPM 上报结果。best-effort,落库即 ok。""" + + ok: bool = True + + class TestGrantOut(BaseModel): """[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。""" diff --git a/docs/api/README.md b/docs/api/README.md index d1f4d08..9a87a5d 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -52,6 +52,7 @@ | 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad-pangle-callback.md) | | 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) | | 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) | +| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) | | **用户资料**(前缀 `/api/v1/user`) ||| | 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) | | 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) | diff --git a/docs/api/ad-ecpm-report.md b/docs/api/ad-ecpm-report.md new file mode 100644 index 0000000..d58afee --- /dev/null +++ b/docs/api/ad-ecpm-report.md @@ -0,0 +1,36 @@ +# POST /api/v1/ad/ecpm-report — 上报本次广告展示的 eCPM(内部收益统计) + +> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) + +## 入参 +请求体:`EcpmReportIn` + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 | +| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,**单位待确认(分/元)**,原样上报 | +| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` | +| `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) | + +`user_id` 不在 body 里——由 JWT 取(Bearer),防伪造。 + +## 出参 +响应 `200`:`EcpmReportOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `ok` | bool | 落库即 `true` | + +## 说明 +客户端在广告**展示后**(`onAdShow` 读 `getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。 + +- **best-effort**:客户端 fire-and-forget,丢一两条不影响业务;穿山甲后台报表是结算权威兜底。 +- 落 `ad_ecpm_record` 表,`report_date` 用北京时间当天,供「按用户/按天聚合」对账。 +- **与发奖是两条独立流**:发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S,有 `trans_id`、无 ecpm),本接口客户端上报(有 ecpm、无 `trans_id`)。两者无公共键,**不逐条一一对应**,只做按用户/按天聚合口径。 +- eCPM 是**每千次展示预估**,单条展示预估收益 ≈ `ecpm / 1000`,且为客户端预估口径,非最终结算。 +- ⚠️ eCPM **单位(分/元)截至 2026-05-31 未最终确认**,故 `ecpm_raw` 原样存字符串;确认后再加一列解析好的数值。 +- 限流:同 IP 120 次/分钟。 + +## 相关 +- [ad-pangle-callback](./ad-pangle-callback.md) — 穿山甲 S2S 发奖回调 +- [ad-reward-status](./ad-reward-status.md) — 今日看广告发奖进度