Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be5e94ed6d | |||
| 953a05a5e6 | |||
| e69788eb41 | |||
| cfc54ac2be |
@@ -0,0 +1,26 @@
|
|||||||
|
"""merge invite_fingerprint and ad_reward heads
|
||||||
|
|
||||||
|
Revision ID: a8c47fc4dc39
|
||||||
|
Revises: invite_fingerprint_table, 044dce6e9b1f
|
||||||
|
Create Date: 2026-06-10 01:03:03.699443
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a8c47fc4dc39'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ('invite_fingerprint_table', '044dce6e9b1f')
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""add user.debug_trace_enabled + comparison_record.trace_url
|
||||||
|
|
||||||
|
调试链接权限功能:
|
||||||
|
- user.debug_trace_enabled:运营后台给指定用户开「复制调试链接」权限
|
||||||
|
- comparison_record.trace_url:比价记录页「复制调试链接」的数据(pricebot done 帧给,
|
||||||
|
dir 名含落盘时分秒、前端/server 拼不出,必须落库)
|
||||||
|
|
||||||
|
注:本迁移最初基于旧 main(#31,双 head)写、down_revision 曾指向那对父;但 #33 已用
|
||||||
|
a8c47fc4dc39 合并了那对双 head,故改为线性接在 a8c47fc4dc39 之后、只负责加两列
|
||||||
|
(避免与 a8c47fc4dc39 平行再造一个双 head)。
|
||||||
|
|
||||||
|
Revision ID: f8d3b1e60a27
|
||||||
|
Revises: a8c47fc4dc39
|
||||||
|
Create Date: 2026-06-10 02:40:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "f8d3b1e60a27"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "a8c47fc4dc39"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# sa.false() 渲染成 PG 的 false / SQLite 的 0,两端兼容(避免字符串 "false" 在 SQLite 上存歪)
|
||||||
|
op.add_column(
|
||||||
|
"user",
|
||||||
|
sa.Column(
|
||||||
|
"debug_trace_enabled",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.false(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"comparison_record",
|
||||||
|
sa.Column("trace_url", sa.String(length=512), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("comparison_record", "trace_url")
|
||||||
|
op.drop_column("user", "debug_trace_enabled")
|
||||||
+2
-2
@@ -13,7 +13,6 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.admin.routers.ad_audit import router as ad_audit_router
|
|
||||||
from app.admin.routers.admins import router as admins_router
|
from app.admin.routers.admins import router as admins_router
|
||||||
from app.admin.routers.audit import router as audit_router
|
from app.admin.routers.audit import router as audit_router
|
||||||
from app.admin.routers.auth import router as auth_router
|
from app.admin.routers.auth import router as auth_router
|
||||||
@@ -22,6 +21,7 @@ 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.ops_stat_config import router as ops_stat_config_router
|
||||||
from app.admin.routers.feedback import router as feedback_router
|
from app.admin.routers.feedback import router as feedback_router
|
||||||
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
|
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
|
||||||
|
from app.admin.routers.price_report import router as price_report_router
|
||||||
from app.admin.routers.users import router as users_router
|
from app.admin.routers.users import router as users_router
|
||||||
from app.admin.routers.wallet import router as wallet_router
|
from app.admin.routers.wallet import router as wallet_router
|
||||||
from app.admin.routers.withdraw import router as withdraw_router
|
from app.admin.routers.withdraw import router as withdraw_router
|
||||||
@@ -81,8 +81,8 @@ admin_app.include_router(ops_marquee_seed_router)
|
|||||||
admin_app.include_router(users_router)
|
admin_app.include_router(users_router)
|
||||||
admin_app.include_router(wallet_router)
|
admin_app.include_router(wallet_router)
|
||||||
admin_app.include_router(withdraw_router)
|
admin_app.include_router(withdraw_router)
|
||||||
|
admin_app.include_router(price_report_router)
|
||||||
admin_app.include_router(feedback_router)
|
admin_app.include_router(feedback_router)
|
||||||
admin_app.include_router(admins_router)
|
admin_app.include_router(admins_router)
|
||||||
admin_app.include_router(audit_router)
|
admin_app.include_router(audit_router)
|
||||||
admin_app.include_router(config_router)
|
admin_app.include_router(config_router)
|
||||||
admin_app.include_router(ad_audit_router)
|
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
"""看广告金币审计:复算 expected_coin 并与实发对比。
|
|
||||||
|
|
||||||
只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移):
|
|
||||||
- 看视频:每条 granted = 1 份,第 N 份 = 当日该用户 granted 的 reward_video 顺序号
|
|
||||||
(与 ad_reward.grant_ad_reward 里 `_granted_today + 1` 一致)。
|
|
||||||
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 当日该用户已 granted 份数累计
|
|
||||||
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致)。
|
|
||||||
|
|
||||||
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.core import rewards
|
|
||||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
|
||||||
from app.models.ad_reward import AdRewardRecord
|
|
||||||
from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS
|
|
||||||
|
|
||||||
|
|
||||||
def _reward_video_rows(
|
|
||||||
db: Session, *, date: str, user_id: int | None
|
|
||||||
) -> list[dict]:
|
|
||||||
"""看视频记录复算。按 (user_id, created_at) 升序还原当日第 N 份。"""
|
|
||||||
stmt = (
|
|
||||||
select(AdRewardRecord)
|
|
||||||
.where(
|
|
||||||
AdRewardRecord.reward_date == date,
|
|
||||||
AdRewardRecord.reward_scene == "reward_video",
|
|
||||||
)
|
|
||||||
.order_by(AdRewardRecord.user_id, AdRewardRecord.created_at)
|
|
||||||
)
|
|
||||||
if user_id is not None:
|
|
||||||
stmt = stmt.where(AdRewardRecord.user_id == user_id)
|
|
||||||
|
|
||||||
granted_n: dict[int, int] = {} # user_id -> 已 granted 份数
|
|
||||||
rows: list[dict] = []
|
|
||||||
for rec in db.execute(stmt).scalars():
|
|
||||||
if rec.status == "granted":
|
|
||||||
nth = granted_n.get(rec.user_id, 0) + 1
|
|
||||||
granted_n[rec.user_id] = nth
|
|
||||||
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
|
|
||||||
rows.append({
|
|
||||||
"scene": "reward_video",
|
|
||||||
"record_id": rec.id,
|
|
||||||
"user_id": rec.user_id,
|
|
||||||
"created_at": rec.created_at,
|
|
||||||
"status": rec.status,
|
|
||||||
"ecpm": rec.ecpm_raw,
|
|
||||||
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
|
|
||||||
"units": 1,
|
|
||||||
"lt_index_start": nth,
|
|
||||||
"lt_index_end": nth,
|
|
||||||
"lt_factor_start": rewards.ad_lt_factor(nth),
|
|
||||||
"lt_factor_end": rewards.ad_lt_factor(nth),
|
|
||||||
"expected_coin": expected,
|
|
||||||
"actual_coin": rec.coin,
|
|
||||||
"matched": expected == rec.coin,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
# capped / ecpm_missing:不发金币,校验实发确为 0
|
|
||||||
rows.append({
|
|
||||||
"scene": "reward_video",
|
|
||||||
"record_id": rec.id,
|
|
||||||
"user_id": rec.user_id,
|
|
||||||
"created_at": rec.created_at,
|
|
||||||
"status": rec.status,
|
|
||||||
"ecpm": rec.ecpm_raw,
|
|
||||||
"ecpm_factor": None,
|
|
||||||
"units": 1,
|
|
||||||
"lt_index_start": None,
|
|
||||||
"lt_index_end": None,
|
|
||||||
"lt_factor_start": None,
|
|
||||||
"lt_factor_end": None,
|
|
||||||
"expected_coin": 0,
|
|
||||||
"actual_coin": rec.coin,
|
|
||||||
"matched": rec.coin == 0,
|
|
||||||
})
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
|
||||||
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用当日累计份数。"""
|
|
||||||
stmt = (
|
|
||||||
select(AdFeedRewardRecord)
|
|
||||||
.where(AdFeedRewardRecord.reward_date == date)
|
|
||||||
.order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at)
|
|
||||||
)
|
|
||||||
if user_id is not None:
|
|
||||||
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
|
|
||||||
|
|
||||||
granted_units: dict[int, int] = {} # user_id -> 已 granted 份数累计
|
|
||||||
rows: list[dict] = []
|
|
||||||
for rec in db.execute(stmt).scalars():
|
|
||||||
if rec.status == "granted":
|
|
||||||
existing = granted_units.get(rec.user_id, 0)
|
|
||||||
units = rec.unit_count
|
|
||||||
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",
|
|
||||||
"record_id": rec.id,
|
|
||||||
"user_id": rec.user_id,
|
|
||||||
"created_at": rec.created_at,
|
|
||||||
"status": rec.status,
|
|
||||||
"ecpm": rec.ecpm_raw,
|
|
||||||
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
|
|
||||||
"units": units,
|
|
||||||
"lt_index_start": start,
|
|
||||||
"lt_index_end": end,
|
|
||||||
"lt_factor_start": rewards.ad_lt_factor(start) if start else None,
|
|
||||||
"lt_factor_end": rewards.ad_lt_factor(end) if end else None,
|
|
||||||
"expected_coin": expected,
|
|
||||||
"actual_coin": rec.coin,
|
|
||||||
"matched": expected == rec.coin,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
rows.append({
|
|
||||||
"scene": "feed",
|
|
||||||
"record_id": rec.id,
|
|
||||||
"user_id": rec.user_id,
|
|
||||||
"created_at": rec.created_at,
|
|
||||||
"status": rec.status,
|
|
||||||
"ecpm": rec.ecpm_raw,
|
|
||||||
"ecpm_factor": None,
|
|
||||||
"units": rec.unit_count,
|
|
||||||
"lt_index_start": None,
|
|
||||||
"lt_index_end": None,
|
|
||||||
"lt_factor_start": None,
|
|
||||||
"lt_factor_end": None,
|
|
||||||
"expected_coin": 0,
|
|
||||||
"actual_coin": rec.coin,
|
|
||||||
"matched": rec.coin == 0,
|
|
||||||
})
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def ad_coin_audit(
|
|
||||||
db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int
|
|
||||||
) -> list[dict]:
|
|
||||||
"""返回当日发奖复算明细,按 created_at 倒序(最新在前)截断到 limit。
|
|
||||||
|
|
||||||
scene: None=两类都要 / "reward_video" / "feed"。
|
|
||||||
份序号在截断前已基于全天数据算好,故 limit 只影响展示条数、不影响 expected 复算正确性。
|
|
||||||
"""
|
|
||||||
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))
|
|
||||||
rows.sort(key=lambda r: r["created_at"], reverse=True)
|
|
||||||
return rows[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
def formula_snapshot() -> dict:
|
|
||||||
"""当前公式参数快照(给前端展示规则参照)。直接读 rewards 常量,与发奖同源。"""
|
|
||||||
return {
|
|
||||||
"coin_per_yuan": rewards.COIN_PER_YUAN,
|
|
||||||
"feed_unit_seconds": FEED_REWARD_UNIT_SECONDS,
|
|
||||||
"ecpm_factor_tiers": [list(t) for t in rewards.AD_ECPM_FACTOR_TABLE],
|
|
||||||
"lt_factor_tiers": [list(t) for t in rewards.AD_LT_FACTOR_TABLE],
|
|
||||||
}
|
|
||||||
@@ -8,9 +8,13 @@ set_user_status / update_feedback_status 支持 commit=False,让 router 把"业
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.rewards import CN_TZ
|
||||||
from app.models.feedback import Feedback
|
from app.models.feedback import Feedback
|
||||||
|
from app.models.price_report import PriceReport
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +28,20 @@ def set_user_status(db: Session, user: User, *, status: str, commit: bool = True
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_debug_trace(
|
||||||
|
db: Session, user: User, *, enabled: bool, commit: bool = True
|
||||||
|
) -> User:
|
||||||
|
"""开关用户「复制调试链接」权限。同 set_user_status:支持 commit=False 让 router 把
|
||||||
|
业务写 + 审计写放进同一事务。"""
|
||||||
|
user.debug_trace_enabled = enabled
|
||||||
|
if commit:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
else:
|
||||||
|
db.flush()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def update_feedback_status(
|
def update_feedback_status(
|
||||||
db: Session, feedback: Feedback, *, status: str, commit: bool = True
|
db: Session, feedback: Feedback, *, status: str, commit: bool = True
|
||||||
) -> Feedback:
|
) -> Feedback:
|
||||||
@@ -34,3 +52,30 @@ def update_feedback_status(
|
|||||||
else:
|
else:
|
||||||
db.flush()
|
db.flush()
|
||||||
return feedback
|
return feedback
|
||||||
|
|
||||||
|
|
||||||
|
def review_price_report(
|
||||||
|
db: Session,
|
||||||
|
report: PriceReport,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
reward_coins: int | None = None,
|
||||||
|
reject_reason: str | None = None,
|
||||||
|
commit: bool = True,
|
||||||
|
) -> PriceReport:
|
||||||
|
"""审核上报:置 approved/rejected + 记审核时间;通过填 reward_coins、拒绝填 reject_reason。
|
||||||
|
|
||||||
|
发金币(wallet.grant_coins)不在这里——由 router 在同一事务里调,涉钱逻辑不重写(见模块头注释)。
|
||||||
|
"""
|
||||||
|
report.status = status
|
||||||
|
if reward_coins is not None:
|
||||||
|
report.reward_coins = reward_coins
|
||||||
|
if reject_reason is not None:
|
||||||
|
report.reject_reason = reject_reason
|
||||||
|
report.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None) # 北京 wall-clock,同 created_at
|
||||||
|
if commit:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(report)
|
||||||
|
else:
|
||||||
|
db.flush()
|
||||||
|
return report
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.models.admin import AdminAuditLog
|
from app.models.admin import AdminAuditLog
|
||||||
from app.models.comparison import ComparisonRecord
|
from app.models.comparison import ComparisonRecord
|
||||||
from app.models.feedback import Feedback
|
from app.models.feedback import Feedback
|
||||||
|
from app.models.price_report import PriceReport
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
||||||
|
|
||||||
@@ -395,3 +396,34 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
|
|||||||
).scalar_one(),
|
).scalar_one(),
|
||||||
"feedback_total": _count(Feedback, Feedback.user_id == user_id),
|
"feedback_total": _count(Feedback, Feedback.user_id == user_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_price_reports(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
cursor: int | None = None,
|
||||||
|
) -> tuple[list[PriceReport], int | None]:
|
||||||
|
"""上报更低价列表(admin 全量,可按状态/用户筛)。游标同 feedback:id 倒序。"""
|
||||||
|
stmt = select(PriceReport)
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(PriceReport.status == status)
|
||||||
|
if user_id is not None:
|
||||||
|
stmt = stmt.where(PriceReport.user_id == user_id)
|
||||||
|
return cursor_paginate(db, stmt, PriceReport.id, limit=limit, cursor=cursor)
|
||||||
|
|
||||||
|
|
||||||
|
def price_report_summary(db: Session) -> dict:
|
||||||
|
"""上报审核台各状态计数(待审核/已通过/已拒绝/合计)。"""
|
||||||
|
rows = db.execute(
|
||||||
|
select(PriceReport.status, func.count(PriceReport.id)).group_by(PriceReport.status)
|
||||||
|
).all()
|
||||||
|
by_status = {status: int(count) for status, count in rows}
|
||||||
|
return {
|
||||||
|
"pending": by_status.get("pending", 0),
|
||||||
|
"approved": by_status.get("approved", 0),
|
||||||
|
"rejected": by_status.get("rejected", 0),
|
||||||
|
"total": sum(by_status.values()),
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
"""admin 看广告金币审计:只读对账,核对发奖金币是否按公式计算。
|
|
||||||
|
|
||||||
任意已登录 admin 可看(只读,不涉及资金操作)。复算逻辑在 app/admin/repositories/ad_audit.py。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
|
|
||||||
from app.admin.deps import AdminDb, get_current_admin
|
|
||||||
from app.admin.repositories import ad_audit
|
|
||||||
from app.admin.schemas.ad_audit import AdCoinAuditOut, AdCoinAuditRow, AdCoinFormulaOut
|
|
||||||
from app.core.rewards import cn_today
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/admin/api/ad-coin-audit",
|
|
||||||
tags=["admin-ad-coin-audit"],
|
|
||||||
dependencies=[Depends(get_current_admin)],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=AdCoinAuditOut, summary="看广告金币公式审计(复算对比)")
|
|
||||||
def get_ad_coin_audit(
|
|
||||||
db: AdminDb,
|
|
||||||
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;不传=两类都要")
|
|
||||||
] = None,
|
|
||||||
limit: Annotated[int, Query(ge=1, le=500)] = 100,
|
|
||||||
) -> AdCoinAuditOut:
|
|
||||||
audit_date = date or cn_today().isoformat()
|
|
||||||
rows = ad_audit.ad_coin_audit(
|
|
||||||
db, date=audit_date, user_id=user_id, scene=scene, limit=limit,
|
|
||||||
)
|
|
||||||
items = [AdCoinAuditRow(**r) for r in rows]
|
|
||||||
return AdCoinAuditOut(
|
|
||||||
date=audit_date,
|
|
||||||
formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()),
|
|
||||||
total=len(items),
|
|
||||||
mismatch_count=sum(1 for it in items if not it.matched),
|
|
||||||
items=items,
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""admin 上报更低价审核:列表 + 统计(读)+ 通过(发金币)/拒绝(写,带审计)。
|
||||||
|
|
||||||
|
数据由客户端 POST /api/v1/report 写入 price_report 表(提交即 pending);本路由是运营后台
|
||||||
|
对它的人工审核窗口。**通过** → 给上报用户钱包发固定金币(PRICE_REPORT_REWARD_COINS):
|
||||||
|
改状态 + 发金币(wallet.grant_coins)+ 审计同一事务一起 commit(原子,仿 users.grant_user_coins),
|
||||||
|
绝不只改状态不发钱或反之。客户端轮询 GET /api/v1/report/records 自动看到结果(无需推送)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, 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.repositories import mutations, queries
|
||||||
|
from app.admin.schemas.common import CursorPage, OkResponse
|
||||||
|
from app.admin.schemas.price_report import (
|
||||||
|
PriceReportOut,
|
||||||
|
PriceReportRejectRequest,
|
||||||
|
PriceReportSummary,
|
||||||
|
)
|
||||||
|
from app.core.rewards import PRICE_REPORT_REWARD_COINS
|
||||||
|
from app.models.admin import AdminUser
|
||||||
|
from app.models.price_report import PriceReport
|
||||||
|
from app.repositories import wallet as wallet_repo
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/admin/api/price-reports",
|
||||||
|
tags=["admin-price-report"],
|
||||||
|
dependencies=[Depends(get_current_admin)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)")
|
||||||
|
def list_price_reports(
|
||||||
|
db: AdminDb,
|
||||||
|
status: Annotated[str | None, Query()] = None,
|
||||||
|
user_id: Annotated[int | None, Query()] = None,
|
||||||
|
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
|
cursor: Annotated[int | None, Query()] = None,
|
||||||
|
) -> CursorPage[PriceReportOut]:
|
||||||
|
items, next_cursor = queries.list_price_reports(
|
||||||
|
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
|
||||||
|
)
|
||||||
|
return CursorPage(
|
||||||
|
items=[PriceReportOut.model_validate(r) for r in items], next_cursor=next_cursor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=PriceReportSummary, summary="上报审核统计(各状态计数)")
|
||||||
|
def price_report_summary(db: AdminDb) -> PriceReportSummary:
|
||||||
|
return PriceReportSummary.model_validate(queries.price_report_summary(db))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)")
|
||||||
|
def approve_price_report(
|
||||||
|
report_id: int,
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> OkResponse:
|
||||||
|
rep = db.get(PriceReport, report_id)
|
||||||
|
if rep is None:
|
||||||
|
raise HTTPException(status_code=404, detail="上报记录不存在")
|
||||||
|
if rep.status != "pending":
|
||||||
|
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
|
||||||
|
coins = PRICE_REPORT_REWARD_COINS
|
||||||
|
# 改状态 + 发金币 + 审计同一事务(commit=False),最后一起 commit:改了就有痕、发了就留账
|
||||||
|
mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False)
|
||||||
|
wallet_repo.grant_coins(
|
||||||
|
db, rep.user_id, coins,
|
||||||
|
biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过",
|
||||||
|
)
|
||||||
|
write_audit(
|
||||||
|
db, admin, action="price_report.approve", target_type="price_report", target_id=report_id,
|
||||||
|
detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return OkResponse()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{report_id}/reject", response_model=OkResponse, summary="拒绝上报")
|
||||||
|
def reject_price_report(
|
||||||
|
report_id: int,
|
||||||
|
body: PriceReportRejectRequest,
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> OkResponse:
|
||||||
|
rep = db.get(PriceReport, report_id)
|
||||||
|
if rep is None:
|
||||||
|
raise HTTPException(status_code=404, detail="上报记录不存在")
|
||||||
|
if rep.status != "pending":
|
||||||
|
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
|
||||||
|
reason = body.reason.strip()
|
||||||
|
mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False)
|
||||||
|
write_audit(
|
||||||
|
db, admin, action="price_report.reject", target_type="price_report", target_id=report_id,
|
||||||
|
detail={"reason": reason, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return OkResponse()
|
||||||
@@ -13,6 +13,7 @@ from app.admin.schemas.user import (
|
|||||||
AdminUserListItem,
|
AdminUserListItem,
|
||||||
AdminUserOverview,
|
AdminUserOverview,
|
||||||
GrantCoinsRequest,
|
GrantCoinsRequest,
|
||||||
|
SetDebugTraceRequest,
|
||||||
SetUserStatusRequest,
|
SetUserStatusRequest,
|
||||||
)
|
)
|
||||||
from app.models.admin import AdminUser
|
from app.models.admin import AdminUser
|
||||||
@@ -77,6 +78,28 @@ def set_user_status(
|
|||||||
return OkResponse()
|
return OkResponse()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/debug-trace", response_model=OkResponse, summary="开关调试链接权限")
|
||||||
|
def set_user_debug_trace(
|
||||||
|
user_id: int,
|
||||||
|
body: SetDebugTraceRequest,
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> OkResponse:
|
||||||
|
user = user_repo.get_user_by_id(db, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
before = user.debug_trace_enabled
|
||||||
|
# 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 set_user_status)
|
||||||
|
mutations.set_user_debug_trace(db, user, enabled=body.enabled, commit=False)
|
||||||
|
write_audit(
|
||||||
|
db, admin, action="user.debug_trace.set", target_type="user", target_id=user_id,
|
||||||
|
detail={"before": before, "after": body.enabled}, ip=get_client_ip(request), commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return OkResponse()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)")
|
@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)")
|
||||||
def grant_user_coins(
|
def grant_user_coins(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
"""看广告金币审计 schemas。
|
|
||||||
|
|
||||||
只读对账视图:把"看视频"(ad_reward_record)和"信息流"(ad_feed_reward_record)两类发奖记录,
|
|
||||||
用与正式发奖相同的公式 [app.core.rewards.calculate_ad_reward_coin] 复算一遍 expected_coin,
|
|
||||||
和实际入账的 actual_coin 对比,核对金币公式是否生效。字段 snake_case、金额按金币整数。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class AdCoinAuditRow(BaseModel):
|
|
||||||
"""单条发奖记录的复算明细。"""
|
|
||||||
|
|
||||||
scene: str = Field(..., description="reward_video(看视频) / feed(信息流)")
|
|
||||||
record_id: int = Field(..., description="对应记录表主键")
|
|
||||||
user_id: int
|
|
||||||
created_at: datetime
|
|
||||||
status: str = Field(..., description="granted / capped / ecpm_missing")
|
|
||||||
ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示,SDK getEcpm 原值)")
|
|
||||||
ecpm_factor: float | None = Field(None, description="因子1(eCPM 档)")
|
|
||||||
units: int = Field(..., description="折算份数:看视频恒为 1;信息流 = 满 10 秒的份数")
|
|
||||||
lt_index_start: int | None = Field(None, description="本条占用的当日第几份(起)")
|
|
||||||
lt_index_end: int | None = Field(None, description="本条占用的当日第几份(止);看视频 = 起")
|
|
||||||
lt_factor_start: float | None = Field(None, description="因子2(LT)起值")
|
|
||||||
lt_factor_end: float | None = Field(None, description="因子2(LT)止值;看视频 = 起值")
|
|
||||||
expected_coin: int = Field(..., description="按公式复算应发金币")
|
|
||||||
actual_coin: int = Field(..., description="实际入账金币")
|
|
||||||
matched: bool = Field(..., description="复算与实发是否一致(capped/ecpm_missing 校验是否确为 0)")
|
|
||||||
|
|
||||||
|
|
||||||
class AdCoinFormulaOut(BaseModel):
|
|
||||||
"""当前金币公式参数(给前端展示规则参照)。"""
|
|
||||||
|
|
||||||
description: str = Field(
|
|
||||||
"eCPM元 = getEcpm分 ÷ 100;单份金币 = round(eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan);"
|
|
||||||
"因子1 按 eCPM元 判档(阈值 100/200/400 元)",
|
|
||||||
description="公式说明",
|
|
||||||
)
|
|
||||||
coin_per_yuan: int = Field(..., description="金币:元 汇率")
|
|
||||||
ecpm_unit: str = Field("分/千次展示(SDK getEcpm 原值)", description="eCPM 口径")
|
|
||||||
feed_unit_seconds: int = Field(..., description="信息流每多少秒折 1 份")
|
|
||||||
# [(因子值, 区间下限, 区间上限或 null)]
|
|
||||||
ecpm_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子1 档位表")
|
|
||||||
lt_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子2 LT 档位表")
|
|
||||||
|
|
||||||
|
|
||||||
class AdCoinAuditOut(BaseModel):
|
|
||||||
"""审计响应:公式参照 + 命中条数 + 明细。"""
|
|
||||||
|
|
||||||
date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)")
|
|
||||||
formula: AdCoinFormulaOut
|
|
||||||
total: int = Field(..., description="返回的明细条数")
|
|
||||||
mismatch_count: int = Field(..., description="其中 matched=false 的条数(=0 说明公式全部生效)")
|
|
||||||
items: list[AdCoinAuditRow]
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""admin 上报更低价审核 schemas。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PriceReportOut(BaseModel):
|
||||||
|
"""admin 列表项:price_report 全字段(审核要看的快照 + 截图 + 状态 + 奖励)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
comparison_record_id: int | None = None
|
||||||
|
# 选中比价记录的快照
|
||||||
|
store_name: str | None = None
|
||||||
|
dish_summary: str | None = None
|
||||||
|
original_platform_id: str | None = None
|
||||||
|
original_platform_name: str | None = None
|
||||||
|
original_price_cents: int | None = None
|
||||||
|
# 用户上报的更低价
|
||||||
|
reported_platform_id: str
|
||||||
|
reported_platform_name: str
|
||||||
|
reported_price_cents: int
|
||||||
|
images: list[str] = []
|
||||||
|
# 审核
|
||||||
|
status: str
|
||||||
|
reject_reason: str | None = None
|
||||||
|
reward_coins: int | None = None
|
||||||
|
reviewed_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PriceReportRejectRequest(BaseModel):
|
||||||
|
reason: str = Field(min_length=1, max_length=256, description="拒绝理由,用户端记录页会看到")
|
||||||
|
|
||||||
|
|
||||||
|
class PriceReportSummary(BaseModel):
|
||||||
|
"""审核台顶部各状态计数。"""
|
||||||
|
|
||||||
|
pending: int
|
||||||
|
approved: int
|
||||||
|
rejected: int
|
||||||
|
total: int
|
||||||
@@ -15,6 +15,7 @@ class AdminUserListItem(BaseModel):
|
|||||||
nickname: str | None = None
|
nickname: str | None = None
|
||||||
register_channel: str
|
register_channel: str
|
||||||
status: str
|
status: str
|
||||||
|
debug_trace_enabled: bool = False
|
||||||
wechat_openid: str | None = None
|
wechat_openid: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
last_login_at: datetime
|
last_login_at: datetime
|
||||||
@@ -45,3 +46,7 @@ class SetUserStatusRequest(BaseModel):
|
|||||||
status: Literal["active", "disabled"] = Field(
|
status: Literal["active", "disabled"] = Field(
|
||||||
..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)"
|
..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SetDebugTraceRequest(BaseModel):
|
||||||
|
enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限")
|
||||||
|
|||||||
+2
-10
@@ -304,18 +304,10 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(rec)
|
db.refresh(rec)
|
||||||
else:
|
else:
|
||||||
# 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
|
|
||||||
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
|
|
||||||
ad_session_id = payload.ad_session_id if payload is not None else None
|
|
||||||
ecpm_val = "200"
|
|
||||||
if ad_session_id:
|
|
||||||
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id)
|
|
||||||
if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0:
|
|
||||||
ecpm_val = ecpm_rec.ecpm_raw
|
|
||||||
try:
|
try:
|
||||||
rec = crud_ad.grant_ad_reward(
|
rec = crud_ad.grant_ad_reward(
|
||||||
db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
|
db, user.id, trans_id, ecpm="200", reward_name="测试发奖",
|
||||||
reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
|
raw="client debug test-grant ecpm=200",
|
||||||
)
|
)
|
||||||
except crud_ad.UnknownUserError as e:
|
except crud_ad.UnknownUserError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
|
||||||
|
|||||||
@@ -77,10 +77,12 @@ def list_records(
|
|||||||
items, next_cursor = crud_compare.list_records(
|
items, next_cursor = crud_compare.list_records(
|
||||||
db, user.id, limit=limit, cursor=cursor
|
db, user.id, limit=limit, cursor=cursor
|
||||||
)
|
)
|
||||||
return ComparisonRecordPage(
|
outs = [ComparisonRecordOut.model_validate(it) for it in items]
|
||||||
items=[ComparisonRecordOut.model_validate(it) for it in items],
|
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)
|
||||||
next_cursor=next_cursor,
|
if not user.debug_trace_enabled:
|
||||||
)
|
for o in outs:
|
||||||
|
o.trace_url = None
|
||||||
|
return ComparisonRecordPage(items=outs, next_cursor=next_cursor)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -94,4 +96,12 @@ def get_record(
|
|||||||
rec = crud_compare.get_record(db, user.id, record_id)
|
rec = crud_compare.get_record(db, user.id, record_id)
|
||||||
if rec is None:
|
if rec is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="record not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="record not found")
|
||||||
return ComparisonRecordDetailOut.model_validate(rec)
|
out = ComparisonRecordDetailOut.model_validate(rec)
|
||||||
|
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url。
|
||||||
|
# ⚠️ raw_payload 是上报体全量(model_dump),里面也藏着一份 trace_url,必须一并抹掉——
|
||||||
|
# 否则无权限用户从详情接口的 raw_payload 绕过权限闸拿到 trace_url。
|
||||||
|
if not user.debug_trace_enabled:
|
||||||
|
out.trace_url = None
|
||||||
|
if isinstance(out.raw_payload, dict):
|
||||||
|
out.raw_payload.pop("trace_url", None)
|
||||||
|
return out
|
||||||
|
|||||||
+11
-17
@@ -77,6 +77,12 @@ def record_milestone_reward(milestone: int) -> int:
|
|||||||
return RECORD_MILESTONES[milestone - 1]
|
return RECORD_MILESTONES[milestone - 1]
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 上报更低价(人工审核通过发固定金币)=====
|
||||||
|
# 用户上报"某平台比我们算的最低价更便宜"+ 截图,经运营后台人工审核通过后发放的固定金币奖励。
|
||||||
|
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
|
||||||
|
PRICE_REPORT_REWARD_COINS: int = 1000
|
||||||
|
|
||||||
|
|
||||||
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
|
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
|
||||||
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
|
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
|
||||||
INVITE_INVITER_COINS: int = 10000
|
INVITE_INVITER_COINS: int = 10000
|
||||||
@@ -92,11 +98,7 @@ INVITE_FP_WINDOW_DAYS: int = 7
|
|||||||
|
|
||||||
|
|
||||||
# ===== 看激励视频 / 信息流广告发金币 =====
|
# ===== 看激励视频 / 信息流广告发金币 =====
|
||||||
# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
|
# 金币数值体系约定:eCPM 单位按"元/千次展示"处理,单次收入 = eCPM / 1000 元。
|
||||||
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
|
|
||||||
# 因子1 档位阈值按【元/千次】定(100/200/400 元 = ¥100/¥200/¥400 CPM,产品口径 2026-06-09)。
|
|
||||||
# 注:真实 eCPM 一般 <¥100 CPM,故多落最低档 0.1,高档基本不触发——这是产品有意的取舍。
|
|
||||||
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次)。
|
|
||||||
AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
|
AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
|
||||||
(0.1, 0, 100),
|
(0.1, 0, 100),
|
||||||
(0.3, 101, 200),
|
(0.3, 101, 200),
|
||||||
@@ -112,8 +114,8 @@ AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_ecpm_fen(ecpm: str | int | float | None) -> float:
|
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
|
||||||
"""解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0。"""
|
"""解析 eCPM 原始值。当前产品口径:SDK 返回值按"元/千次展示"处理。"""
|
||||||
if ecpm is None:
|
if ecpm is None:
|
||||||
return 0.0
|
return 0.0
|
||||||
try:
|
try:
|
||||||
@@ -123,14 +125,8 @@ def parse_ecpm_fen(ecpm: str | int | float | None) -> float:
|
|||||||
return max(0.0, value)
|
return max(0.0, value)
|
||||||
|
|
||||||
|
|
||||||
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
|
|
||||||
"""eCPM 转成元(getEcpm 原值是分,÷100)。因子档位判定与收益换算都用元。"""
|
|
||||||
return parse_ecpm_fen(ecpm) / 100.0
|
|
||||||
|
|
||||||
|
|
||||||
def ad_ecpm_factor(ecpm_yuan: float) -> float:
|
def ad_ecpm_factor(ecpm_yuan: float) -> float:
|
||||||
"""eCPM 档位因子(阈值单位=元/千次):≤100=0.1,101-200=0.3,201-400=0.4,>400=0.6。
|
"""eCPM 档位因子:0-100=0.1,101-200=0.3,201-400=0.4,>400=0.6。"""
|
||||||
产品口径(2026-06-09):阈值按元判档;真实 eCPM(<¥100 CPM)多落最低档 0.1。"""
|
|
||||||
if ecpm_yuan > 400:
|
if ecpm_yuan > 400:
|
||||||
return 0.6
|
return 0.6
|
||||||
if ecpm_yuan > 200:
|
if ecpm_yuan > 200:
|
||||||
@@ -152,9 +148,7 @@ def ad_lt_factor(today_count_after_this: int) -> float:
|
|||||||
def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int:
|
def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int:
|
||||||
"""按金币数值体系计算单份广告奖励金币。
|
"""按金币数值体系计算单份广告奖励金币。
|
||||||
|
|
||||||
eCPM 是穿山甲 getEcpm 原值,单位【分/千次展示】;先 ÷100 转成元(因子判档 + 收益换算都用元)。
|
单次奖励(元)=eCPM/1000 × 因子1(eCPM 档) × 因子2(LT);再按 1 元=10000 金币取整。
|
||||||
单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT);
|
|
||||||
再按 1 元=10000 金币取整。
|
|
||||||
"""
|
"""
|
||||||
ecpm_yuan = parse_ecpm_yuan(ecpm)
|
ecpm_yuan = parse_ecpm_yuan(ecpm)
|
||||||
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this)
|
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class AdEcpmRecord(Base):
|
|||||||
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
# 实际展示用的代码位(底层 mediation rit,非客户端配置位)
|
# 实际展示用的代码位(底层 mediation rit,非客户端配置位)
|
||||||
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
# 客户端上报的 eCPM 原始字符串(单位:分/千次展示,SDK getEcpm 原值,原样存)
|
# 客户端上报的 eCPM 原始字符串(单位:元/千次展示,原样存)
|
||||||
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
|
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
# 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较)
|
# 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较)
|
||||||
report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
|
report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ class ComparisonRecord(Base):
|
|||||||
)
|
)
|
||||||
# pricebot 侧 trace_id:关联调试落盘 + 幂等去重键
|
# pricebot 侧 trace_id:关联调试落盘 + 幂等去重键
|
||||||
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
# 本次比价的公网调试链接(price.shaguabijia.com/traces/{dir}/)。pricebot done 帧给、
|
||||||
|
# 客户端上报带上——dir 名含 pricebot 落盘的时分秒,前端/server 都拼不出,必须存。
|
||||||
|
# 查看接口按 user.debug_trace_enabled 决定返不返回。旧记录 / 未开上云为 None。
|
||||||
|
trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
|
||||||
# ===== 源平台(发起比价的那家)=====
|
# ===== 源平台(发起比价的那家)=====
|
||||||
source_platform_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
source_platform_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
|||||||
+8
-1
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Integer, String, func
|
from sqlalchemy import Boolean, DateTime, Integer, String, false, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
@@ -51,6 +51,13 @@ class User(Base):
|
|||||||
# 账号状态:active / disabled / deleted
|
# 账号状态:active / disabled / deleted
|
||||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||||
|
|
||||||
|
# 调试链接权限:开了的用户在比价完成弹窗 + 比价记录页能看到「复制调试链接」按钮
|
||||||
|
# (复制 price.shaguabijia.com 的 trace 链接发给开发排障)。运营后台按用户配置;
|
||||||
|
# /me 与登录响应里带出给前端做条件渲染。默认 false。
|
||||||
|
debug_trace_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default=false()
|
||||||
|
)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ def upsert_record(
|
|||||||
source_package=payload.source_package,
|
source_package=payload.source_package,
|
||||||
information=payload.information,
|
information=payload.information,
|
||||||
best_deeplink=payload.best_deeplink,
|
best_deeplink=payload.best_deeplink,
|
||||||
|
trace_url=payload.trace_url,
|
||||||
total_dish_count=payload.total_dish_count,
|
total_dish_count=payload.total_dish_count,
|
||||||
skipped_dish_count=payload.skipped_dish_count,
|
skipped_dish_count=payload.skipped_dish_count,
|
||||||
items=[it.model_dump(exclude_none=True) for it in payload.items],
|
items=[it.model_dump(exclude_none=True) for it in payload.items],
|
||||||
|
|||||||
+3
-8
@@ -46,11 +46,11 @@ class AdRewardStatusOut(BaseModel):
|
|||||||
class EcpmReportIn(BaseModel):
|
class EcpmReportIn(BaseModel):
|
||||||
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
|
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
|
||||||
|
|
||||||
user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串,后端按分/千次展示处理(SDK getEcpm 原值,非元)。
|
user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串,后端按元/千次展示处理。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等")
|
ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等")
|
||||||
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按分/千次展示处理(SDK getEcpm 原值,非元)")
|
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按元/千次展示处理")
|
||||||
ad_session_id: str | None = Field(
|
ad_session_id: str | None = Field(
|
||||||
None, min_length=8, max_length=64,
|
None, min_length=8, max_length=64,
|
||||||
description="客户端生成的一次广告会话 id;激励视频 S2S extra 会透传同值",
|
description="客户端生成的一次广告会话 id;激励视频 S2S extra 会透传同值",
|
||||||
@@ -89,11 +89,6 @@ class TestGrantIn(BaseModel):
|
|||||||
"reward_video",
|
"reward_video",
|
||||||
description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)",
|
description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)",
|
||||||
)
|
)
|
||||||
ad_session_id: str | None = Field(
|
|
||||||
None, min_length=8, max_length=64,
|
|
||||||
description="本次广告会话 id(与 ecpm-report 同值)。reward_video 场景下据此查回客户端"
|
|
||||||
"已上报的真实 eCPM 来按公式发奖;查不到或 eCPM≤0 时兜底 200,保证本地联调仍出非零金币",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGrantOut(BaseModel):
|
class TestGrantOut(BaseModel):
|
||||||
@@ -124,7 +119,7 @@ class FeedRewardIn(BaseModel):
|
|||||||
ad_session_id: str | None = Field(
|
ad_session_id: str | None = Field(
|
||||||
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
|
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
|
||||||
)
|
)
|
||||||
ecpm: str = Field(..., description="本条信息流广告 eCPM,按分/千次展示处理(SDK getEcpm 原值,非元)")
|
ecpm: str = Field(..., description="本条信息流广告 eCPM,按元/千次展示处理")
|
||||||
duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数")
|
duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数")
|
||||||
adn: str | None = Field(None, description="实际投放 ADN")
|
adn: str | None = Field(None, description="实际投放 ADN")
|
||||||
slot_id: str | None = Field(None, description="实际展示代码位")
|
slot_id: str | None = Field(None, description="实际展示代码位")
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class UserOut(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
last_login_at: datetime
|
last_login_at: datetime
|
||||||
|
# 调试链接权限:前端据此在比价结果弹窗/记录页显示「复制调试链接」按钮。默认 false。
|
||||||
|
debug_trace_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
# ===== Token 通用结构 =====
|
# ===== Token 通用结构 =====
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ class ComparisonRecordIn(BaseModel):
|
|||||||
status: str | None = Field(None, description="success / failed,可不传由服务端派生")
|
status: str | None = Field(None, description="success / failed,可不传由服务端派生")
|
||||||
# 最优平台商家/商品深链(客户端从 collectedLinks[best_index] 取);「再次比价」直达用
|
# 最优平台商家/商品深链(客户端从 collectedLinks[best_index] 取);「再次比价」直达用
|
||||||
best_deeplink: str | None = Field(None, description="最优平台深链,再次比价直达")
|
best_deeplink: str | None = Field(None, description="最优平台深链,再次比价直达")
|
||||||
|
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
|
||||||
|
# 时分秒前端拼不出,必须由后端透传)。
|
||||||
|
trace_url: str | None = Field(None, description="本次比价公网调试链接")
|
||||||
|
|
||||||
|
|
||||||
# ===== 读取出参 =====
|
# ===== 读取出参 =====
|
||||||
@@ -79,6 +82,8 @@ class ComparisonRecordOut(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
business_type: str
|
business_type: str
|
||||||
trace_id: str
|
trace_id: str
|
||||||
|
# 公网调试链接;仅当 user.debug_trace_enabled 时由端点填充,否则端点层置 None(权限闸)。
|
||||||
|
trace_url: str | None = None
|
||||||
source_platform_id: str | None = None
|
source_platform_id: str | None = None
|
||||||
source_platform_name: str | None = None
|
source_platform_name: str | None = None
|
||||||
source_package: str | None = None
|
source_package: str | None = None
|
||||||
|
|||||||
@@ -102,7 +102,6 @@
|
|||||||
| A24 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
|
| A24 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
|
||||||
| A25 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
|
| A25 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
|
||||||
| A26 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) |
|
| A26 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) |
|
||||||
| A27 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
|
|
||||||
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
|
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
|
||||||
|
|
||||||
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 |
|
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 |
|
||||||
| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 |
|
| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 |
|
||||||
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,单位是**分/千次展示**(非元),后端 ÷100 转元参与金币公式 |
|
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,后端按“元/千次展示”参与金币公式 |
|
||||||
| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` |
|
| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` |
|
||||||
| `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) |
|
| `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) |
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
|
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
|
||||||
| `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 |
|
| `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 |
|
||||||
| `ecpm` | string | 是 | 本条信息流广告 eCPM(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) |
|
| `ecpm` | string | 是 | 本条信息流广告 eCPM,按“元/千次展示”处理 |
|
||||||
| `duration_seconds` | int | 是 | 实际展示/播放秒数 |
|
| `duration_seconds` | int | 是 | 实际展示/播放秒数 |
|
||||||
| `adn` | string\|null | 否 | 实际投放 ADN |
|
| `adn` | string\|null | 否 | 实际投放 ADN |
|
||||||
| `slot_id` | string\|null | 否 | 实际展示代码位 |
|
| `slot_id` | string\|null | 否 | 实际展示代码位 |
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。`reward_video`=普通激励视频;`signin_boost`=签到膨胀 |
|
| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。`reward_video`=普通激励视频;`signin_boost`=签到膨胀 |
|
||||||
| `ad_session_id` | string(8~64) \| null | 否 | null | 本次广告会话 id(与 [ecpm-report](./ad-ecpm-report.md) 同值)。**仅 `reward_video` 场景生效**:据此查回客户端已上报的真实 eCPM,走与正式发奖相同的公式发奖;查不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍出非零金币 |
|
|
||||||
|
|
||||||
## 出参
|
## 出参
|
||||||
响应 `200`:`TestGrantOut`
|
响应 `200`:`TestGrantOut`
|
||||||
@@ -31,6 +30,4 @@
|
|||||||
## 说明
|
## 说明
|
||||||
没公网、穿山甲 S2S 回调打不到本地时,debug 客户端看完广告后调它,直接走与 [ad-pangle-callback](./ad-pangle-callback.md) 相同的发奖逻辑(每次新 `trans_id`,幂等 + 每日上限/今日膨胀一次)。
|
没公网、穿山甲 S2S 回调打不到本地时,debug 客户端看完广告后调它,直接走与 [ad-pangle-callback](./ad-pangle-callback.md) 相同的发奖逻辑(每次新 `trans_id`,幂等 + 每日上限/今日膨胀一次)。
|
||||||
|
|
||||||
`reward_scene=reward_video` 时按上面 `ad_session_id` 查回的真实 eCPM 走金币公式发奖(取不到兜底 200)——便于本地用 [admin 金币审计](./admin-ad-coin-audit.md) 核对「看广告→金币」是否按公式计算。
|
|
||||||
|
|
||||||
`reward_scene=signin_boost` 时复用签到膨胀业务规则:必须当天已签到、非第 14 天、当天未膨胀过,成功后写入 `signin_boost` 金币流水。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。
|
`reward_scene=signin_boost` 时复用签到膨胀业务规则:必须当天已签到、非第 14 天、当天未膨胀过,成功后写入 `signin_boost` 金币流水。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
# Admin 看广告金币审计
|
|
||||||
|
|
||||||
> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md)
|
|
||||||
|
|
||||||
把「看视频赚金币」(`ad_reward_record`)和「比价信息流广告」(`ad_feed_reward_record`)两类发奖记录,用与**正式发奖完全相同**的公式 [`app/core/rewards.py` `calculate_ad_reward_coin`](../../app/core/rewards.py) 复算一遍 `expected_coin`,与实际入账的 `actual_coin` 对比,核对金币公式是否生效。**纯只读对账**,不发币、不改任何数据。
|
|
||||||
|
|
||||||
相关表:[ad_reward_record](../database/ad_reward_record.md)、[ad_feed_reward_record](../database/ad_feed_reward_record.md)。
|
|
||||||
|
|
||||||
## 金币公式(展示参照)
|
|
||||||
|
|
||||||
```
|
|
||||||
eCPM元 = getEcpm分 ÷ 100
|
|
||||||
单份金币 = round( eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan ) # coin_per_yuan=10000
|
|
||||||
```
|
|
||||||
- **eCPM 口径**:`getEcpm()` 原值单位是分/千次展示;先 ÷100 转元,**因子判档与收益换算都用元**。
|
|
||||||
- **因子1(eCPM 档,阈值单位=元/千次)**:≤100→0.1,101–200→0.3,201–400→0.4,>400→0.6(即 ¥100/¥200/¥400 CPM 分界。**真实 eCPM 多 <¥100 CPM,故常落最低档 0.1,高档基本不触发——产品有意取舍**)。
|
|
||||||
- **因子2(LT,当日第 N 份)**:第1→2.0,第2→1.5,第3→1.3,第4–10→1.1,≥11→1.0。
|
|
||||||
- **第 N 份的计数**:看视频每条 granted 记 1 份;信息流每满 10 秒记 1 份。**两个点位各自独立计数**(看视频的份数不影响信息流的份数,反之亦然)。
|
|
||||||
|
|
||||||
## GET /admin/api/ad-coin-audit — 复算对比
|
|
||||||
|
|
||||||
- 入参(均 query,可选):
|
|
||||||
| 参数 | 类型 | 默认 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `date` | string | 今天 | 北京时间 `YYYY-MM-DD`,审计某天 |
|
|
||||||
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
|
|
||||||
| `scene` | string | 两类 | `reward_video` / `feed`;不传=两类都返回 |
|
|
||||||
| `limit` | int(1~500) | 100 | 返回明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) |
|
|
||||||
|
|
||||||
- 出参 `200`:`AdCoinAuditOut`
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `date` | string | 审计日期 |
|
|
||||||
| `formula` | object | 当前公式参数快照(见下) |
|
|
||||||
| `total` | int | 返回明细条数 |
|
|
||||||
| `mismatch_count` | int | 其中 `matched=false` 的条数;**=0 说明全部按公式发放** |
|
|
||||||
| `items` | `AdCoinAuditRow[]` | 明细(见下) |
|
|
||||||
|
|
||||||
### AdCoinFormulaOut(`formula`)
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `description` | string | 公式文字说明 |
|
|
||||||
| `coin_per_yuan` | int | 金币:元 汇率(10000) |
|
|
||||||
| `ecpm_unit` | string | eCPM 口径(分/千次展示,SDK getEcpm 原值) |
|
|
||||||
| `feed_unit_seconds` | int | 信息流每多少秒折 1 份(10) |
|
|
||||||
| `ecpm_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子1 档位表(直接读发奖常量,与发奖同源) |
|
|
||||||
| `lt_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子2 LT 档位表 |
|
|
||||||
|
|
||||||
### AdCoinAuditRow(`items[]`)
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `scene` | string | `reward_video` / `feed` |
|
|
||||||
| `record_id` | int | 对应记录表主键 |
|
|
||||||
| `user_id` | int | |
|
|
||||||
| `created_at` | datetime | |
|
|
||||||
| `status` | string | `granted` / `capped`(超每日上限未发) / `ecpm_missing`(缺 eCPM 未发) |
|
|
||||||
| `ecpm` | string \| null | 本次采用的 eCPM 原始值 |
|
|
||||||
| `ecpm_factor` | float \| null | 因子1;非 granted 为 null |
|
|
||||||
| `units` | int | 折算份数:看视频恒 1;信息流 = 满 10 秒份数 |
|
|
||||||
| `lt_index_start` / `lt_index_end` | int \| null | 本条占用「当日第几份」的起止(看视频起=止;信息流一条可跨多份) |
|
|
||||||
| `lt_factor_start` / `lt_factor_end` | float \| null | 因子2 的起止值(信息流跨份时会衰减,故给区间) |
|
|
||||||
| `expected_coin` | int | 按公式复算应发金币(非 granted 恒 0) |
|
|
||||||
| `actual_coin` | int | 实际入账金币 |
|
|
||||||
| `matched` | bool | 复算与实发是否一致;非 granted 校验实发是否确为 0 |
|
|
||||||
|
|
||||||
## 说明
|
|
||||||
- **非 granted 行**(capped/ecpm_missing)不占用份序号、应发恒 0,`matched` 用于校验「该不发的确实没发」。
|
|
||||||
- 用法建议:**按 `user_id`+`date` 定位某次具体核对**最直观;不带 `user_id` 是全用户当天概览。
|
|
||||||
- 本地联调造数:debug 包看完激励视频会调 [`/api/v1/ad/test-grant`](./ad-test-grant.md),它会用客户端按 `ad_session_id` 上报的真实 eCPM 发奖(取不到兜底 200),所以本审计能看到真实 eCPM 对应的金币。
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;用于普通激励视频在 S2S 缺 `ecpm` 时匹配发奖 |
|
| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;用于普通激励视频在 S2S 缺 `ecpm` 时匹配发奖 |
|
||||||
| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) |
|
| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) |
|
||||||
| `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) |
|
| `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) |
|
||||||
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**(穿山甲 getEcpm 原值,单位**分/千次展示**);后端 ÷100 转元参与金币公式 |
|
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**;后端按“元/千次展示”参与金币公式 |
|
||||||
| `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 |
|
| `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 |
|
||||||
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
|
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user