Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 251e48a9cf |
+2
-10
@@ -88,17 +88,9 @@ AUTO_EXCHANGE_ENABLED=true
|
||||
|
||||
# ===== 穿山甲激励视频(服务端发奖回调)=====
|
||||
# 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。
|
||||
# 穿山甲"奖励校验密钥"(m-key),验签用,从 GroMore 后台各广告位取到后填这里。
|
||||
# 配齐(任一非空)并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。
|
||||
# 每个激励位 m-key 不同但共用同一回调 URL → 各位分开一行配(留空的忽略);验签逐个试、任一通过即接受。
|
||||
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",用于验签,从后台取到后填这里;
|
||||
# 配齐并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。
|
||||
PANGLE_CALLBACK_ENABLED=false
|
||||
# 测试应用 激励位 104099649
|
||||
PANGLE_REWARD_SECRET_TEST=
|
||||
# 测试应用 专属激励位 104127529
|
||||
PANGLE_REWARD_SECRET_TEST_DEDICATED=
|
||||
# 正式应用 激励位 104099389
|
||||
PANGLE_REWARD_SECRET_PROD=
|
||||
# (旧用法,仍兼容:单个或逗号分隔的多个 m-key,会与上面三个合并去重)
|
||||
PANGLE_REWARD_SECRET=
|
||||
# ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖,
|
||||
# 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"""merge store_mapping_jd_dl_invalid and ad_revenue heads (0.1.2)
|
||||
|
||||
Revision ID: 45047b5a884c
|
||||
Revises: d4e68464761d, store_mapping_jd_dl_invalid
|
||||
Create Date: 2026-06-16 01:38:26.273226
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '45047b5a884c'
|
||||
down_revision: Union[str, Sequence[str], None] = ('d4e68464761d', 'store_mapping_jd_dl_invalid')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1,39 +0,0 @@
|
||||
"""ad revenue report columns: app_env + our_code_id
|
||||
|
||||
给 ad_ecpm_record / ad_reward_record / ad_feed_reward_record 各加两列:
|
||||
- app_env:我们的穿山甲应用环境(prod=傻瓜比价正式 / test=测试应用)
|
||||
- our_code_id:我们在穿山甲后台配置的代码位 ID(104xxx,非底层 mediation rit)
|
||||
|
||||
供「广告收益报表」按 用户/日期/广告类型/应用/代码位 聚合 展示条数/收益/金币。
|
||||
旧数据这两列为 NULL(报表里来源列留空),新数据由客户端上报/发奖时回填。
|
||||
|
||||
Revision ID: ad_revenue_report_cols
|
||||
Revises: coupon_engage_per_package
|
||||
Create Date: 2026-06-15
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "ad_revenue_report_cols"
|
||||
down_revision = "coupon_engage_per_package"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_TABLES = ("ad_ecpm_record", "ad_reward_record", "ad_feed_reward_record")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in _TABLES:
|
||||
op.add_column(table, sa.Column("app_env", sa.String(length=16), nullable=True))
|
||||
op.add_column(table, sa.Column("our_code_id", sa.String(length=64), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in _TABLES:
|
||||
op.drop_column(table, "our_code_id")
|
||||
op.drop_column(table, "app_env")
|
||||
@@ -1,26 +0,0 @@
|
||||
"""merge ad_revenue_report_cols and store_mapping_tb_dl_invalid heads
|
||||
|
||||
Revision ID: d4e68464761d
|
||||
Revises: ad_revenue_report_cols, store_mapping_tb_dl_invalid
|
||||
Create Date: 2026-06-15 21:55:58.115692
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd4e68464761d'
|
||||
down_revision: Union[str, Sequence[str], None] = ('ad_revenue_report_cols', 'store_mapping_tb_dl_invalid')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1,32 +0,0 @@
|
||||
"""store_mapping 加京东 deeplink 失效标记列 jd_deeplink_invalid_at
|
||||
|
||||
Revision ID: store_mapping_jd_dl_invalid
|
||||
Revises: store_mapping_tb_dl_invalid
|
||||
Create Date: 2026-06-15 00:00:00.000000
|
||||
|
||||
京东同淘宝: 缓存的店内搜索 deeplink 会失效(打开是"当前门店超出配送范围"页)。pricebot 比价撞到
|
||||
失效页时回退正常搜店, 并 server→server 通知把该 storeId 的 deeplink 标记失效。本列记失效时刻
|
||||
(NULL=有效); lookup 反查时过滤掉已失效的京东候选, 不再返回坏 deeplink。
|
||||
与淘宝 taobao_deeplink_invalid_at 对称; 用时间戳而非布尔: 留痕可审计、可统计失效率, 不销毁原 deeplink。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'store_mapping_jd_dl_invalid'
|
||||
down_revision: Union[str, Sequence[str], None] = 'store_mapping_tb_dl_invalid'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('jd_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
|
||||
batch_op.drop_column('jd_deeplink_invalid_at')
|
||||
@@ -14,7 +14,6 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.admin.routers.ad_audit import router as ad_audit_router
|
||||
from app.admin.routers.ad_revenue import router as ad_revenue_router
|
||||
from app.admin.routers.admins import router as admins_router
|
||||
from app.admin.routers.audit import router as audit_router
|
||||
from app.admin.routers.auth import router as auth_router
|
||||
@@ -91,4 +90,3 @@ admin_app.include_router(admins_router)
|
||||
admin_app.include_router(audit_router)
|
||||
admin_app.include_router(config_router)
|
||||
admin_app.include_router(ad_audit_router)
|
||||
admin_app.include_router(ad_revenue_router)
|
||||
|
||||
@@ -67,8 +67,6 @@ def _reward_video_rows(
|
||||
"scene": "reward_video",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
@@ -88,8 +86,6 @@ def _reward_video_rows(
|
||||
"scene": "reward_video",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
@@ -154,8 +150,6 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
||||
"scene": "feed",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
@@ -174,8 +168,6 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
||||
"scene": "feed",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
@@ -192,22 +184,6 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
||||
return rows
|
||||
|
||||
|
||||
def audit_rows(
|
||||
db: Session, *, date: str, user_id: int | None, scene: str | None = None
|
||||
) -> list[dict]:
|
||||
"""当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed"。
|
||||
|
||||
每行含 `app_env`/`our_code_id`/`expected_coin`/`actual_coin` 等,供金币审计逐条对账,
|
||||
也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)。
|
||||
"""
|
||||
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))
|
||||
return rows
|
||||
|
||||
|
||||
def ad_coin_audit(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -224,7 +200,11 @@ def ad_coin_audit(
|
||||
影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。
|
||||
份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。
|
||||
"""
|
||||
rows = audit_rows(db, date=date, user_id=user_id, scene=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))
|
||||
rows.sort(key=lambda r: (r["created_at"], r["record_id"]), reverse=True)
|
||||
|
||||
total = len(rows)
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
"""admin 广告收益报表:按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合(单表含发奖对账)。
|
||||
|
||||
只读。聚合键 = user_id × ad_type × app_env × our_code_id;每组一行同时给出:
|
||||
- 展示条数 + 收益:`ad_ecpm_record`(每行 = 客户端一次广告展示;收益 = Σ eCPM元 ÷ 1000)。
|
||||
激励视频每次展示上报一行;信息流轮播每条展示各上报一行(每条独立 id,不复用会话)。
|
||||
- 应发金币 / 实发金币:复用金币审计的**逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径,
|
||||
不另写公式),把每条发奖记录的 expected/actual 按同维度求和;`matched` = 组内**逐条**全部一致
|
||||
(任一条不符该组即不符,不用「应发和==实发和」以免互相抵消掩盖错误)。**不改发奖逻辑**,只读复算。
|
||||
|
||||
展示与发奖来自不同表,做并集:有展示无发奖(用户中途关 / 未达发奖)、有发奖无展示
|
||||
(未上报 eCPM)都各自成行。app_env/our_code_id 旧数据为 NULL → 归到「来源未知」组。
|
||||
|
||||
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 `feed`(迁移后 Draw
|
||||
不再产生新数据)。② 聚合级只能看出「某组应发≠实发」,定位到具体哪条仍需逐条审计接口(ad-coin-audit)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as _date, datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories import ad_audit
|
||||
from app.core import rewards
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
|
||||
|
||||
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)
|
||||
return dt.astimezone(rewards.CN_TZ).hour
|
||||
|
||||
|
||||
def _key(
|
||||
report_date: str,
|
||||
user_id: int,
|
||||
ad_type: str,
|
||||
app_env: str | None,
|
||||
our_code_id: str | None,
|
||||
hour: int | None,
|
||||
) -> tuple:
|
||||
return (report_date, user_id, ad_type, app_env or None, our_code_id or None, hour)
|
||||
|
||||
|
||||
def _date_range(date_from: str, date_to: str) -> list[str]:
|
||||
"""闭区间内逐日 'YYYY-MM-DD' 串(含首尾)。date_from > date_to 时返回空。"""
|
||||
d0 = _date.fromisoformat(date_from)
|
||||
d1 = _date.fromisoformat(date_to)
|
||||
out: list[str] = []
|
||||
d = d0
|
||||
while d <= d1:
|
||||
out.append(d.isoformat())
|
||||
d += timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
# 审计行的 scene 与报表 ad_type 一一对应
|
||||
_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"}
|
||||
|
||||
|
||||
def ad_revenue_report(
|
||||
db: Session,
|
||||
*,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
user_id: int | None = None,
|
||||
ad_type: str | None = None,
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
) -> dict:
|
||||
"""日期区间(北京时间,闭区间)广告收益聚合 + 发奖对账。单日时 date_from==date_to。
|
||||
|
||||
聚合键含**日期**:report_date × user × ad_type × app_env × our_code_id(× 北京小时,granularity=hour)。
|
||||
ad_type: None=全部 / reward_video / feed / draw。
|
||||
granularity: "day"=按天 / "hour"=按小时(聚合键再加北京小时 0–23,每组一行)。
|
||||
limit 只截断展示明细,total 与 total_* / daily 在全量上统计(不受 limit 影响),数字始终可信。
|
||||
|
||||
返回额外含 `daily`(按日期汇总的展示/收益/应发/实发,供前端按天趋势图;不受 limit 影响)。
|
||||
|
||||
注:按小时下,展示按 ecpm 记录的小时、金币按发奖记录的小时各自归桶——S2S 回调可能比展示晚
|
||||
一会儿,故同一次广告的展示与金币偶尔落相邻小时(按天则一致)。
|
||||
"""
|
||||
by_hour = granularity == "hour"
|
||||
groups: dict[tuple, dict] = {}
|
||||
|
||||
def _grp(key: tuple) -> dict:
|
||||
g = groups.get(key)
|
||||
if g is None:
|
||||
rdate, uid, atype, app_env, code_id, hour = key
|
||||
g = {
|
||||
"report_date": rdate,
|
||||
"user_id": uid,
|
||||
"ad_type": atype,
|
||||
"app_env": app_env,
|
||||
"our_code_id": code_id,
|
||||
"hour": hour,
|
||||
"impressions": 0,
|
||||
"revenue_yuan": 0.0,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": 0,
|
||||
"adns": set(),
|
||||
"impression_records": [], # 该组逐条展示明细(展开下钻用)
|
||||
"records": [], # 该组逐条发奖复算明细(展开下钻用)
|
||||
}
|
||||
groups[key] = g
|
||||
return g
|
||||
|
||||
# 1) 展示条数 + 收益 ← ad_ecpm_record(report_date 闭区间;字符串 YYYY-MM-DD 字典序即日期序)
|
||||
stmt = select(AdEcpmRecord).where(
|
||||
AdEcpmRecord.report_date >= date_from,
|
||||
AdEcpmRecord.report_date <= date_to,
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
|
||||
if ad_type is not None:
|
||||
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
|
||||
for rec in db.execute(stmt).scalars():
|
||||
hour = _cn_hour(rec.created_at) if by_hour else None
|
||||
g = _grp(_key(rec.report_date, rec.user_id, rec.ad_type, rec.app_env, rec.our_code_id, hour))
|
||||
g["impressions"] += 1
|
||||
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次);用与发奖同源的解析,口径一致。
|
||||
rev = rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0
|
||||
g["revenue_yuan"] += rev
|
||||
if rec.adn:
|
||||
g["adns"].add(rec.adn)
|
||||
g["impression_records"].append({
|
||||
"id": rec.id,
|
||||
"created_at": rec.created_at,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"revenue_yuan": round(rev, 6),
|
||||
"adn": rec.adn,
|
||||
"slot_id": rec.slot_id,
|
||||
})
|
||||
|
||||
# 2) 应发 / 实发金币 ← 复用金币审计逐条复算(同一公式口径),按同维度求和。
|
||||
# audit_rows 是单日的,区间逐日调用,每天的行归到当天 report_date(语义与单日报表完全一致)。
|
||||
# ad_type=draw 时审计无对应记录(scene 只有 reward_video/feed),金币侧自然为空。
|
||||
audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None 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):
|
||||
atype = _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"])
|
||||
hour = _cn_hour(row["created_at"]) if by_hour else None
|
||||
g = _grp(_key(d, row["user_id"], atype, row.get("app_env"), row.get("our_code_id"), hour))
|
||||
g["expected_coin"] += int(row["expected_coin"])
|
||||
g["actual_coin"] += int(row["actual_coin"])
|
||||
# 逐条明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致)——前端展开该组时下钻展示。
|
||||
g["records"].append({
|
||||
"record_id": row["record_id"],
|
||||
"created_at": row["created_at"],
|
||||
"status": row["status"],
|
||||
"ecpm": row["ecpm"],
|
||||
"ecpm_factor": row["ecpm_factor"],
|
||||
"units": row["units"],
|
||||
"lt_index_start": row["lt_index_start"],
|
||||
"lt_index_end": row["lt_index_end"],
|
||||
"lt_factor_start": row["lt_factor_start"],
|
||||
"lt_factor_end": row["lt_factor_end"],
|
||||
"expected_coin": row["expected_coin"],
|
||||
"actual_coin": row["actual_coin"],
|
||||
"matched": row["matched"],
|
||||
})
|
||||
|
||||
rows = list(groups.values())
|
||||
rows.sort(
|
||||
key=lambda r: (
|
||||
r["report_date"],
|
||||
r["user_id"],
|
||||
r["hour"] if r["hour"] is not None else -1,
|
||||
r["ad_type"] or "",
|
||||
r["our_code_id"] or "",
|
||||
)
|
||||
)
|
||||
|
||||
total_impressions = sum(r["impressions"] for r in rows)
|
||||
total_expected_coin = sum(r["expected_coin"] for r in rows)
|
||||
total_actual_coin = sum(r["actual_coin"] for r in rows)
|
||||
total_revenue_yuan = round(sum(r["revenue_yuan"] for r in rows), 6)
|
||||
|
||||
# 按日期汇总(全量,不受 limit):供前端按天趋势图。
|
||||
daily_map: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
d = daily_map.get(r["report_date"])
|
||||
if d is None:
|
||||
d = {
|
||||
"date": r["report_date"],
|
||||
"impressions": 0,
|
||||
"revenue_yuan": 0.0,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": 0,
|
||||
}
|
||||
daily_map[r["report_date"]] = d
|
||||
d["impressions"] += r["impressions"]
|
||||
d["revenue_yuan"] += r["revenue_yuan"]
|
||||
d["expected_coin"] += r["expected_coin"]
|
||||
d["actual_coin"] += r["actual_coin"]
|
||||
daily = [
|
||||
{**d, "revenue_yuan": round(d["revenue_yuan"], 6)}
|
||||
for d in sorted(daily_map.values(), key=lambda x: x["date"])
|
||||
]
|
||||
|
||||
items = [
|
||||
{
|
||||
"report_date": r["report_date"],
|
||||
"user_id": r["user_id"],
|
||||
"ad_type": r["ad_type"],
|
||||
"app_env": r["app_env"],
|
||||
"our_code_id": r["our_code_id"],
|
||||
"hour": r["hour"],
|
||||
"impressions": r["impressions"],
|
||||
"revenue_yuan": round(r["revenue_yuan"], 6),
|
||||
"expected_coin": r["expected_coin"],
|
||||
"actual_coin": r["actual_coin"],
|
||||
# 组内**逐条**全部一致才记一致——不能用「应发和==实发和」,否则一条多发+一条少发会互相
|
||||
# 抵消、求和相等被误判为 ✓,掩盖真实发奖错误。纯展示无发奖记录的组 all([]) → True。
|
||||
"matched": all(rec["matched"] for rec in r["records"]),
|
||||
"adns": sorted(r["adns"]),
|
||||
"impression_records": sorted(
|
||||
r["impression_records"], key=lambda x: (x["created_at"], x["id"])
|
||||
),
|
||||
"records": sorted(r["records"], key=lambda x: (x["created_at"], x["record_id"])),
|
||||
}
|
||||
for r in rows[:limit]
|
||||
]
|
||||
|
||||
return {
|
||||
"total": len(rows),
|
||||
"truncated": len(rows) > limit,
|
||||
"total_impressions": total_impressions,
|
||||
"total_revenue_yuan": total_revenue_yuan,
|
||||
"total_expected_coin": total_expected_coin,
|
||||
"total_actual_coin": total_actual_coin,
|
||||
"mismatch_count": sum(
|
||||
1 for r in rows if not all(rec["matched"] for rec in r["records"])
|
||||
),
|
||||
"daily": daily,
|
||||
"items": items,
|
||||
}
|
||||
@@ -286,11 +286,10 @@ def list_feedbacks(
|
||||
sort_order: str = "desc",
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[Feedback], int | None, int]:
|
||||
) -> tuple[list[Feedback], int | None]:
|
||||
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
|
||||
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间
|
||||
数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),total 供页码分页。
|
||||
created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
|
||||
数据变动可能错位一条——admin 低频场景可接受。created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
|
||||
stmt = select(Feedback)
|
||||
if status:
|
||||
stmt = stmt.where(Feedback.status == status)
|
||||
@@ -307,7 +306,14 @@ def list_feedbacks(
|
||||
sort_col = sort_cols.get(sort_by, Feedback.id)
|
||||
order_fn = asc if sort_order == "asc" else desc
|
||||
id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id)
|
||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||
stmt = stmt.order_by(order_fn(sort_col), id_order)
|
||||
|
||||
offset = max(cursor or 0, 0)
|
||||
rows = list(db.execute(stmt.offset(offset).limit(limit + 1)).scalars().all())
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
next_cursor = offset + limit if has_more else None
|
||||
return items, next_cursor
|
||||
|
||||
|
||||
def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None:
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
"""admin 广告收益报表:按 用户/日期/广告类型/应用/代码位 聚合 展示条数 / 收益 / 金币。
|
||||
|
||||
任意已登录 admin 可看(只读,不涉及资金操作)。聚合逻辑在 app/admin/repositories/ad_revenue.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as _date
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import ad_revenue
|
||||
from app.admin.schemas.ad_revenue import AdRevenueDaily, AdRevenueReportOut, AdRevenueRow
|
||||
from app.core.rewards import cn_today
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/ad-revenue-report",
|
||||
tags=["admin-ad-revenue-report"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
# 区间最大跨度(天);超出拒绝,避免审计页一次拉过多天(逐日审计 + 大查询)拖垮接口。
|
||||
_MAX_RANGE_DAYS = 92
|
||||
|
||||
|
||||
def _parse_day(value: str | None, *, field: str, default: _date) -> _date:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return _date.fromisoformat(value)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
|
||||
|
||||
|
||||
@router.get("", response_model=AdRevenueReportOut, summary="广告收益报表(按 日期区间/用户/类型/应用/代码位 聚合)")
|
||||
def get_ad_revenue_report(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[str | None, Query(description="起始日 北京时间 YYYY-MM-DD,默认今天")] = None,
|
||||
date_to: Annotated[str | None, Query(description="结束日 北京时间 YYYY-MM-DD,闭区间,默认=date_from")] = None,
|
||||
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
|
||||
ad_type: Annotated[
|
||||
str | None,
|
||||
Query(description="reward_video / feed / draw;不传=全部类型"),
|
||||
] = None,
|
||||
granularity: Annotated[
|
||||
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
|
||||
] = "day",
|
||||
limit: Annotated[int, Query(ge=1, le=1000)] = 500,
|
||||
) -> AdRevenueReportOut:
|
||||
today = cn_today()
|
||||
d_from = _parse_day(date_from, field="date_from", default=today)
|
||||
d_to = _parse_day(date_to, field="date_to", default=d_from)
|
||||
if d_to < d_from:
|
||||
raise HTTPException(status_code=422, detail="date_to 不能早于 date_from")
|
||||
if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS:
|
||||
raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS} 天")
|
||||
|
||||
result = ad_revenue.ad_revenue_report(
|
||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
|
||||
user_id=user_id, ad_type=ad_type, granularity=granularity, limit=limit,
|
||||
)
|
||||
return AdRevenueReportOut(
|
||||
date_from=d_from.isoformat(),
|
||||
date_to=d_to.isoformat(),
|
||||
daily=[AdRevenueDaily(**d) for d in result["daily"]],
|
||||
total=result["total"],
|
||||
truncated=result["truncated"],
|
||||
total_impressions=result["total_impressions"],
|
||||
total_revenue_yuan=result["total_revenue_yuan"],
|
||||
total_expected_coin=result["total_expected_coin"],
|
||||
total_actual_coin=result["total_actual_coin"],
|
||||
mismatch_count=result["mismatch_count"],
|
||||
items=[AdRevenueRow(**r) for r in result["items"]],
|
||||
)
|
||||
@@ -43,9 +43,6 @@ def _validate(key: str, value: Any) -> None:
|
||||
for k, v in value.items()
|
||||
):
|
||||
raise ValueError("需为 {字符串: 整数} 映射")
|
||||
elif t == "bool":
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError("需为布尔值")
|
||||
|
||||
|
||||
def _item(db, key: str) -> ConfigItemOut:
|
||||
|
||||
@@ -34,7 +34,7 @@ def list_feedbacks(
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[FeedbackOut]:
|
||||
items, next_cursor, total = queries.list_feedbacks(
|
||||
items, next_cursor = queries.list_feedbacks(
|
||||
db,
|
||||
status=status,
|
||||
user_id=user_id,
|
||||
@@ -47,9 +47,7 @@ def list_feedbacks(
|
||||
cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[FeedbackOut.model_validate(f) for f in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
items=[FeedbackOut.model_validate(f) for f in items], next_cursor=next_cursor,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ from app.admin.schemas.wallet import (
|
||||
from app.core.config import settings
|
||||
from app.integrations import wxpay
|
||||
from app.models.admin import AdminUser
|
||||
from app.repositories import app_config
|
||||
from app.repositories import wallet as wallet_repo
|
||||
|
||||
router = APIRouter(
|
||||
@@ -96,7 +95,7 @@ def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut:
|
||||
summary="提现配置健康检查",
|
||||
dependencies=[Depends(require_role("finance"))], # 暴露密钥路径/配置,限财务+super
|
||||
)
|
||||
def withdraw_health_check(db: AdminDb) -> WxpayHealthCheckOut:
|
||||
def withdraw_health_check() -> WxpayHealthCheckOut:
|
||||
private_path = wxpay._resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH) # noqa: SLF001
|
||||
public_path = wxpay._resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH) # noqa: SLF001
|
||||
issues: list[str] = []
|
||||
@@ -118,15 +117,8 @@ def withdraw_health_check(db: AdminDb) -> WxpayHealthCheckOut:
|
||||
issues.append("微信支付基础配置不完整")
|
||||
if not settings.WXPAY_AUTH_NOTIFY_URL:
|
||||
issues.append("免确认授权回调地址未配置")
|
||||
|
||||
# 实际是否自动对账 = env 部署总闸(worker 起没起)AND 运营后台 DB 开关(本轮跑不跑)。
|
||||
worker_running = settings.WITHDRAW_AUTO_RECONCILE_ENABLED
|
||||
daily_on = bool(app_config.get_value(db, "withdraw_auto_reconcile_enabled"))
|
||||
auto_reconcile_enabled = worker_running and daily_on
|
||||
if not worker_running:
|
||||
issues.append("自动对账 worker 未启动(部署侧 env WITHDRAW_AUTO_RECONCILE_ENABLED=false)")
|
||||
elif not daily_on:
|
||||
issues.append("自动对账运营开关已关闭(系统配置页可开)")
|
||||
if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED:
|
||||
issues.append("自动对账未开启")
|
||||
|
||||
return WxpayHealthCheckOut(
|
||||
ok=not issues,
|
||||
@@ -139,7 +131,7 @@ def withdraw_health_check(db: AdminDb) -> WxpayHealthCheckOut:
|
||||
public_key_exists=public_path.exists(),
|
||||
public_key_loadable=public_loadable,
|
||||
auth_notify_url_configured=bool(settings.WXPAY_AUTH_NOTIFY_URL),
|
||||
auto_reconcile_enabled=auto_reconcile_enabled,
|
||||
auto_reconcile_enabled=settings.WITHDRAW_AUTO_RECONCILE_ENABLED,
|
||||
auto_reconcile_interval_sec=settings.WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC,
|
||||
auto_reconcile_older_than_minutes=settings.WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES,
|
||||
issues=issues,
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
"""广告收益报表 schemas。
|
||||
|
||||
按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合的只读报表:展示条数、收益(元)、金币、来源。
|
||||
字段 snake_case;收益按元(float),金币按整数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AdRevenueImpression(BaseModel):
|
||||
"""聚合行下钻的单条**展示**明细(每次广告展示一条,展开该组时展示)。"""
|
||||
|
||||
id: int = Field(..., description="ad_ecpm_record 主键")
|
||||
created_at: datetime
|
||||
ecpm: str = Field(..., description="本次展示 eCPM 原始值(分/千次展示)")
|
||||
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000")
|
||||
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…)")
|
||||
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID)")
|
||||
|
||||
|
||||
class AdRevenueRecord(BaseModel):
|
||||
"""聚合行下钻的单条发奖复算明细(与金币审计同源,展开该组时展示)。"""
|
||||
|
||||
record_id: int
|
||||
created_at: datetime
|
||||
status: str = Field(..., description="granted / capped / ecpm_missing")
|
||||
ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示)")
|
||||
ecpm_factor: float | None = Field(None, description="因子1(eCPM 档);非 granted 为空")
|
||||
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="复算与实发是否一致")
|
||||
|
||||
|
||||
class AdRevenueDaily(BaseModel):
|
||||
"""按日期汇总的一天(供前端按天趋势图;全量,不受 limit 影响)。"""
|
||||
|
||||
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
||||
impressions: int = Field(..., description="当天展示条数合计")
|
||||
revenue_yuan: float = Field(..., description="当天预估收益合计(元)")
|
||||
expected_coin: int = Field(..., description="当天应发金币合计")
|
||||
actual_coin: int = Field(..., description="当天实发金币合计")
|
||||
|
||||
|
||||
class AdRevenueRow(BaseModel):
|
||||
"""一个聚合组(report_date × user × ad_type × app_env × our_code_id)的汇总。"""
|
||||
|
||||
report_date: str = Field(..., description="该组所属日期(北京时间 YYYY-MM-DD)")
|
||||
user_id: int
|
||||
ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 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)")
|
||||
impressions: int = Field(..., description="展示条数(每条广告展示一条;轮播每条各计一次)")
|
||||
revenue_yuan: float = Field(..., description="收益(元)= Σ(eCPM元 ÷ 1000);测试应用多为 0")
|
||||
expected_coin: int = Field(..., description="应发金币(按公式复算,与金币审计同源)")
|
||||
actual_coin: int = Field(..., description="实发金币(实际入账,按现发奖算法)")
|
||||
matched: bool = Field(..., description="该组应发==实发(组内任一条不符则 false)")
|
||||
adns: list[str] = Field(default_factory=list, description="实际填充的底层 ADN 子渠道集合(如 pangle/gdt)")
|
||||
impression_records: list[AdRevenueImpression] = Field(
|
||||
default_factory=list,
|
||||
description="该组逐条展示明细(时间/eCPM/收益/adn);展开下钻用,无发奖也有(只要有展示)",
|
||||
)
|
||||
records: list[AdRevenueRecord] = Field(
|
||||
default_factory=list,
|
||||
description="该组逐条发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);展开下钻用,纯展示无发奖记录的组为空",
|
||||
)
|
||||
|
||||
|
||||
class AdRevenueReportOut(BaseModel):
|
||||
"""报表响应:全量统计 + 按天趋势 + 聚合明细。"""
|
||||
|
||||
date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)")
|
||||
date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)")
|
||||
daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)")
|
||||
total: int = Field(..., description="聚合组总数(全量,不受 limit 影响)")
|
||||
truncated: bool = Field(..., description="明细是否被 limit 截断")
|
||||
total_impressions: int = Field(..., description="全量展示条数合计")
|
||||
total_revenue_yuan: float = Field(..., description="全量收益合计(元)")
|
||||
total_expected_coin: int = Field(..., description="全量应发金币合计")
|
||||
total_actual_coin: int = Field(..., description="全量实发金币合计")
|
||||
mismatch_count: int = Field(..., description="应发≠实发的组数(=0 说明全部按公式发放)")
|
||||
items: list[AdRevenueRow] = Field(..., description="聚合明细(按 用户→类型→代码位 排序)")
|
||||
@@ -10,7 +10,7 @@ class ConfigItemOut(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
group: str
|
||||
type: str # int / int_list / dict_str_int / bool
|
||||
type: str # int / int_list / dict_str_int
|
||||
help: str | None = None
|
||||
default: Any
|
||||
value: Any
|
||||
|
||||
@@ -95,14 +95,11 @@ def invalidate_store_mapping(
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
) -> StoreMappingInvalidateOut:
|
||||
_check_secret(x_internal_secret)
|
||||
if payload.platform == "taobao":
|
||||
affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id)
|
||||
elif payload.platform == "jd":
|
||||
affected = repo.mark_jd_deeplink_invalid(db, payload.shop_id)
|
||||
else:
|
||||
# 当前只接淘宝/京东; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。
|
||||
if payload.platform != "taobao":
|
||||
# 当前只接淘宝; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。
|
||||
logger.info("store_mapping invalidate 跳过: platform=%s 暂不支持", payload.platform)
|
||||
return StoreMappingInvalidateOut(ok=True, affected=0)
|
||||
affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id)
|
||||
logger.info(
|
||||
"store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d 行",
|
||||
payload.platform, payload.shop_id, affected,
|
||||
|
||||
+2
-36
@@ -32,8 +32,6 @@ from app.schemas.ad import (
|
||||
FeedRewardIn,
|
||||
FeedRewardOut,
|
||||
PangleCallbackOut,
|
||||
RewardNoShowIn,
|
||||
RewardNoShowOut,
|
||||
TestGrantIn,
|
||||
TestGrantOut,
|
||||
WatchReportIn,
|
||||
@@ -87,7 +85,7 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
|
||||
params = dict(request.query_params)
|
||||
|
||||
if not pangle.verify_callback_sign_any(params, settings.pangle_reward_secrets):
|
||||
if not pangle.verify_callback_sign(params, settings.PANGLE_REWARD_SECRET):
|
||||
logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id"))
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign")
|
||||
|
||||
@@ -244,12 +242,10 @@ 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,
|
||||
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",
|
||||
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s",
|
||||
user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
|
||||
payload.app_env, payload.our_code_id,
|
||||
)
|
||||
return EcpmReportOut(ok=True)
|
||||
|
||||
@@ -363,9 +359,6 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
|
||||
ad_session_id=payload.ad_session_id,
|
||||
adn=payload.adn,
|
||||
slot_id=payload.slot_id,
|
||||
app_env=payload.app_env,
|
||||
our_code_id=payload.our_code_id,
|
||||
aborted=payload.aborted,
|
||||
)
|
||||
logger.info(
|
||||
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
|
||||
@@ -378,30 +371,3 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
|
||||
unit_count=rec.unit_count,
|
||||
daily_limit=rewards.get_ad_daily_limit(db),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reward-noshow",
|
||||
response_model=RewardNoShowOut,
|
||||
summary="激励视频提前关闭/未发奖留痕",
|
||||
dependencies=[Depends(rate_limit(120, 60, "ad-reward-noshow"))],
|
||||
)
|
||||
def reward_noshow(payload: RewardNoShowIn, user: CurrentUser, db: DbSession) -> RewardNoShowOut:
|
||||
"""激励视频展示了但用户提前关/跳过、未触发 S2S 发奖时,客户端 best-effort 上报一条留痕,
|
||||
让广告收益报表能呈现「有展示、没发金币」的原因。不发金币;同一 session 已发奖则跳过。
|
||||
"""
|
||||
rec = crud_ad.record_reward_noshow(
|
||||
db,
|
||||
user.id,
|
||||
ad_session_id=payload.ad_session_id,
|
||||
ecpm=payload.ecpm,
|
||||
adn=payload.adn,
|
||||
slot_id=payload.slot_id,
|
||||
app_env=payload.app_env,
|
||||
our_code_id=payload.our_code_id,
|
||||
)
|
||||
logger.info(
|
||||
"ad reward noshow user_id=%d session=%s watched=%ds -> status=%s",
|
||||
user.id, payload.ad_session_id, payload.watched_seconds, rec.status,
|
||||
)
|
||||
return RewardNoShowOut(ok=True, status=rec.status)
|
||||
|
||||
+1
-14
@@ -18,7 +18,7 @@ import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.api.deps import DbSession
|
||||
from app.core.config import settings
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
from app.db.session import SessionLocal
|
||||
@@ -28,7 +28,6 @@ from app.schemas.coupon_state import (
|
||||
CouponPromptDismissIn,
|
||||
CouponPromptShouldShowOut,
|
||||
CouponPromptShownIn,
|
||||
CouponStatsOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.coupon")
|
||||
@@ -269,15 +268,3 @@ def coupon_completed_today_reset(
|
||||
与 /prompt/reset 配套:开发设置「重置今日领券弹窗状态」一键把今日状态全清。MVP 不鉴权。"""
|
||||
coupon_repo.reset_today_completion(db, payload.device_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=CouponStatsOut,
|
||||
summary="累计领券数(「我的」页战绩卡「领取优惠券 X 张」)",
|
||||
)
|
||||
def coupon_stats(user: CurrentUser, db: DbSession) -> CouponStatsOut:
|
||||
"""该登录用户累计领到的券数(SUM(claimed_count),口径见 coupon_repo.sum_claimed_count)。
|
||||
**鉴权(CurrentUser)**——区别于同文件不鉴权的 /step 透传与 /prompt 频控(那些按 device_id):
|
||||
个人战绩按 user_id 聚合,必须有登录态。"""
|
||||
return CouponStatsOut(coupon_count=coupon_repo.sum_claimed_count(db, user.id))
|
||||
|
||||
@@ -21,7 +21,7 @@ logger = logging.getLogger("shagua.feedback")
|
||||
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
|
||||
|
||||
_MAX_IMAGES = 6
|
||||
_CONTENT_MAX = 200
|
||||
_CONTENT_MAX = 2000
|
||||
_CONTACT_MAX = 128
|
||||
|
||||
|
||||
|
||||
+1
-17
@@ -2,7 +2,6 @@
|
||||
|
||||
路由前缀 `/api/v1/platform`:
|
||||
GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算。
|
||||
GET /flags 客户端运营 feature flag(比价/领券期广告开关等),客户端拉取后缓存。
|
||||
|
||||
展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/ops_stat.py。
|
||||
"""
|
||||
@@ -13,15 +12,9 @@ import logging
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.repositories import app_config
|
||||
from app.repositories import ops_marquee as marquee_crud
|
||||
from app.repositories import ops_stat as crud
|
||||
from app.schemas.platform import (
|
||||
AppFlagsOut,
|
||||
PlatformStatsOut,
|
||||
SavingsFeedItem,
|
||||
SavingsFeedOut,
|
||||
)
|
||||
from app.schemas.platform import PlatformStatsOut, SavingsFeedItem, SavingsFeedOut
|
||||
|
||||
logger = logging.getLogger("shagua.platform")
|
||||
|
||||
@@ -42,12 +35,3 @@ def stats(db: DbSession) -> PlatformStatsOut:
|
||||
def savings_feed(db: DbSession, limit: int = Query(8, ge=1, le=30)) -> SavingsFeedOut:
|
||||
items = marquee_crud.get_feed(db, limit=limit)
|
||||
return SavingsFeedOut(items=[SavingsFeedItem(**it) for it in items])
|
||||
|
||||
|
||||
@router.get("/flags", response_model=AppFlagsOut, summary="客户端运营 feature flag(不鉴权)")
|
||||
def flags(db: DbSession) -> AppFlagsOut:
|
||||
"""客户端拉取运营开关并缓存(app 启动 / 每场比价开始时刷新)。不鉴权:开关非敏感,
|
||||
且比价无障碍服务取值时未必有登录态。值来自 app_config(admin 可改),空库回退默认。"""
|
||||
return AppFlagsOut(
|
||||
comparing_ad_enabled=bool(app_config.get_value(db, "comparing_ad_enabled")),
|
||||
)
|
||||
|
||||
+3
-23
@@ -136,15 +136,8 @@ class Settings(BaseSettings):
|
||||
|
||||
# ===== 穿山甲激励视频(服务端发奖回调)=====
|
||||
# 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。
|
||||
# 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自
|
||||
# 生成),但共用同一回调 URL → 多个位的 m-key 都要配上。验签时所有非空 m-key 逐个试、任一通过
|
||||
# 即接受(见 pangle.verify_callback_sign_any / 下面的 pangle_reward_secrets)。
|
||||
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。
|
||||
PANGLE_CALLBACK_ENABLED: bool = False
|
||||
# 推荐:每个激励位的 m-key 分开一行配,清晰不混淆(留空的忽略)。
|
||||
PANGLE_REWARD_SECRET_TEST: str = "" # 测试应用 激励位 104099649
|
||||
PANGLE_REWARD_SECRET_TEST_DEDICATED: str = "" # 测试应用 专属激励位 104127529
|
||||
PANGLE_REWARD_SECRET_PROD: str = "" # 正式应用 激励位 104099389
|
||||
# 旧用法:单个或逗号分隔的多个 m-key,仍兼容(会与上面三个命名项合并去重)。
|
||||
PANGLE_REWARD_SECRET: str = ""
|
||||
|
||||
# ⚠️ 仅本地联调:打开后开放 POST /api/v1/ad/test-grant,让(已登录的)客户端在没部署公网、
|
||||
@@ -152,23 +145,10 @@ class Settings(BaseSettings):
|
||||
# 它让客户端能自助发奖 = 绕过反作弊,**生产必须保持 False**(默认 False;只在本地 .env 设 true)。
|
||||
AD_REWARD_TEST_GRANT_ENABLED: bool = False
|
||||
|
||||
@property
|
||||
def pangle_reward_secrets(self) -> list[str]:
|
||||
"""汇总所有 m-key 成列表(去空白、去空项、去重保序)。验签时逐个试、任一通过即接受
|
||||
(见 pangle.verify_callback_sign_any)。来源可混用:三个命名项 + 旧的逗号分隔 PANGLE_REWARD_SECRET。"""
|
||||
raw = [
|
||||
*self.PANGLE_REWARD_SECRET.split(","),
|
||||
self.PANGLE_REWARD_SECRET_TEST,
|
||||
self.PANGLE_REWARD_SECRET_TEST_DEDICATED,
|
||||
self.PANGLE_REWARD_SECRET_PROD,
|
||||
]
|
||||
cleaned = [s.strip() for s in raw if s and s.strip()]
|
||||
return list(dict.fromkeys(cleaned)) # 去重保序
|
||||
|
||||
@property
|
||||
def pangle_callback_configured(self) -> bool:
|
||||
"""回调开关打开且至少配了一个验签密钥,才接受发奖回调。"""
|
||||
return bool(self.PANGLE_CALLBACK_ENABLED and self.pangle_reward_secrets)
|
||||
"""回调开关打开且验签密钥已配,才接受发奖回调。"""
|
||||
return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET)
|
||||
|
||||
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
||||
# pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Any
|
||||
|
||||
from app.core import rewards as r
|
||||
|
||||
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool
|
||||
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int
|
||||
CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"signin_rewards": {
|
||||
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
|
||||
@@ -65,23 +65,4 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"group": "签到", "type": "int",
|
||||
"help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。",
|
||||
},
|
||||
"comparing_ad_enabled": {
|
||||
"default": True, "label": "比价/领券期信息流广告",
|
||||
"group": "看广告", "type": "bool",
|
||||
"help": (
|
||||
"开启后,比价进行中 + 领券等候期会在悬浮窗展示穿山甲信息流广告(变现行为);"
|
||||
"关闭则全程不出广告。客户端按 app 启动 / 每场比价开始时拉取并缓存,故为「最终一致」的"
|
||||
"远程开关(下一场比价生效),用于出问题时无需发版即可快速止血。debug 包可用本地开关覆盖。"
|
||||
),
|
||||
},
|
||||
"withdraw_auto_reconcile_enabled": {
|
||||
"default": True, "label": "提现自动对账",
|
||||
"group": "钱包", "type": "bool",
|
||||
"help": (
|
||||
"开启后后台 worker 每隔一段时间自动扫描超时仍「打款中」的提现单并归一化"
|
||||
"(查微信/退款/撤单)。需部署侧 env WITHDRAW_AUTO_RECONCILE_ENABLED=true 启动 worker "
|
||||
"进程后此开关才起效;扫描间隔/超时阈值仍由 env 控制。关掉只停自动扫描,"
|
||||
"提现页「批量对账」手动按钮不受影响。"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,11 +14,8 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.integrations.wxpay import WxPayNotConfiguredError
|
||||
from app.repositories import app_config
|
||||
from app.repositories import wallet as wallet_repo
|
||||
|
||||
_AUTO_RECONCILE_KEY = "withdraw_auto_reconcile_enabled"
|
||||
|
||||
logger = logging.getLogger("shagua.withdraw_reconcile")
|
||||
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "withdraw_reconcile.lock"
|
||||
|
||||
@@ -62,15 +59,8 @@ def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
|
||||
_LOCK_PATH.unlink()
|
||||
|
||||
|
||||
def _reconcile_once(older_than_minutes: int) -> dict | None:
|
||||
"""读运营开关(app_config):关着返回 None(本轮跳过),开着才真扫单。
|
||||
|
||||
env WITHDRAW_AUTO_RECONCILE_ENABLED 是部署级总闸(决定 worker 起不起);
|
||||
这里的 DB 开关是运营级日常开关,后台一改下一轮即生效、跨进程一致、无需重启。
|
||||
"""
|
||||
def _reconcile_once(older_than_minutes: int) -> dict:
|
||||
with SessionLocal() as db:
|
||||
if not app_config.get_value(db, _AUTO_RECONCILE_KEY):
|
||||
return None
|
||||
return wallet_repo.reconcile_pending_withdraws(db, older_than_minutes=older_than_minutes)
|
||||
|
||||
|
||||
@@ -88,18 +78,16 @@ async def _run_loop() -> None:
|
||||
|
||||
async def _run_locked_loop(interval: int, older_than: int) -> None:
|
||||
logger.info(
|
||||
"withdraw auto reconcile worker started interval=%ss older_than=%sm "
|
||||
"(runtime on/off via app_config '%s')",
|
||||
"withdraw auto reconcile started interval=%ss older_than=%sm",
|
||||
interval,
|
||||
older_than,
|
||||
_AUTO_RECONCILE_KEY,
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
_touch_lock()
|
||||
result = await asyncio.to_thread(_reconcile_once, older_than)
|
||||
if result is not None and (result["checked"] or result["resolved"]):
|
||||
if result["checked"] or result["resolved"]:
|
||||
logger.info("withdraw auto reconcile result=%s", result)
|
||||
except WxPayNotConfiguredError:
|
||||
logger.warning("withdraw auto reconcile skipped: wxpay not configured")
|
||||
@@ -115,8 +103,7 @@ async def _run_locked_loop(interval: int, older_than: int) -> None:
|
||||
|
||||
def start_withdraw_reconcile_worker() -> asyncio.Task | None:
|
||||
if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED:
|
||||
# 部署级总闸关:worker 进程不启动(运营后台开关此时无效,需先在 env 打开)。
|
||||
logger.info("withdraw auto reconcile worker not started (env master switch off)")
|
||||
logger.info("withdraw auto reconcile disabled")
|
||||
return None
|
||||
if not settings.wxpay_configured:
|
||||
logger.warning("withdraw auto reconcile enabled but wxpay not configured")
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""美团城市字典(地级市)读取。
|
||||
|
||||
数据源:app/integrations/data/meituan_cities.json,由 tools/gen_meituan_cities.py 从
|
||||
美团官方「城市字典」Excel 生成(359 个地级市:city_id / 城市名 / 省份)。
|
||||
实测一个地级市 cityId 已覆盖其下辖县级市供给(徐州 → 邳州 / 新沂 / 睢宁 …),故无区县层级。
|
||||
|
||||
- ETL(scripts/pull_meituan_coupons.py)遍历 all_cities() 全量抓取入库。
|
||||
- 读取侧将来「按城市过滤」时,也从这里取 city_id ↔ 城市名映射。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
_JSON = Path(__file__).resolve().parent / "data" / "meituan_cities.json"
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def all_cities() -> list[dict]:
|
||||
"""全部城市 [{city_id, name, province}, ...](359 个地级市)。"""
|
||||
return json.loads(_JSON.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _by_id() -> dict[str, dict]:
|
||||
return {c["city_id"]: c for c in all_cities()}
|
||||
|
||||
|
||||
def city_name(city_id: str) -> str | None:
|
||||
"""city_id → 城市名(查不到返回 None)。"""
|
||||
c = _by_id().get(city_id)
|
||||
return c["name"] if c else None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,13 +42,3 @@ def verify_callback_sign(params: dict[str, str], secret: str) -> bool:
|
||||
got = params.get("sign") or ""
|
||||
expect = build_sign(trans_id, secret)
|
||||
return hmac.compare_digest(got, expect)
|
||||
|
||||
|
||||
def verify_callback_sign_any(params: dict[str, str], secrets: list[str]) -> bool:
|
||||
"""对**多个** m-key 逐个验签,任一通过即接受。
|
||||
|
||||
多广告位场景:每个 GroMore 广告位的 m-key 由后台各自生成、互不相同,但本服务用
|
||||
同一个回调 URL 接所有位的回调。配置里放多个密钥(逗号分隔),回调到达时挨个试。
|
||||
仍然安全:伪造者必须知道其中某个 m-key 才能算出合法 sign;空列表 → 一律失败。
|
||||
"""
|
||||
return any(verify_callback_sign(params, s) for s in secrets if s)
|
||||
|
||||
@@ -35,11 +35,6 @@ class AdEcpmRecord(Base):
|
||||
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 实际展示用的代码位(底层 mediation rit,非客户端配置位)
|
||||
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 我们的穿山甲应用环境:prod(傻瓜比价正式应用) / test(测试应用)。客户端按 AdConfig.useProductionApp 上报。
|
||||
# 与底层 adn 不同:这是「我们用的是哪个 App」,adn 是「聚合后实际填充的子渠道」。旧数据为 NULL。
|
||||
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
# 我们在穿山甲后台配置的代码位 ID(AdConfig.feedCodeId/rewardCodeId 返回的 104xxx,**非** slot_id 的底层 rit)。旧数据为 NULL。
|
||||
our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 客户端上报的 eCPM 原始字符串(单位:分/千次展示,SDK getEcpm 原值,原样存)
|
||||
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
# 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较)
|
||||
|
||||
@@ -30,9 +30,6 @@ 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)
|
||||
# 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx,由客户端 feed-reward 上报带上。旧数据为 NULL。
|
||||
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted")
|
||||
|
||||
|
||||
@@ -33,10 +33,6 @@ class AdRewardRecord(Base):
|
||||
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
# 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报)
|
||||
ecpm_raw: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx。
|
||||
# S2S 回调本身不带这俩,发奖时按 ad_session_id 匹配 ad_ecpm_record 回填(查不到为 NULL)。
|
||||
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数
|
||||
reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
|
||||
# 穿山甲上报的奖励名(参考,不作发奖依据)
|
||||
|
||||
@@ -105,8 +105,6 @@ class StoreMapping(Base):
|
||||
jd_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # 3.cn 短链
|
||||
jd_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 反查出的目标 openapp.jdmobile:// deeplink
|
||||
jd_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 pages/search 店内搜索 deeplink
|
||||
# 京东 deeplink 失效标记(同 taobao_deeplink_invalid_at):撞"当前门店超出配送范围"页时被置,NULL=有效。
|
||||
jd_deeplink_invalid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# 灵活字段兜底(免得加字段就迁移)
|
||||
attrs: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
|
||||
@@ -23,14 +23,8 @@ def create_ecpm_record(
|
||||
ad_session_id: str | None = None,
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
app_env: str | None = None,
|
||||
our_code_id: str | None = None,
|
||||
) -> AdEcpmRecord:
|
||||
"""落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。
|
||||
|
||||
app_env(prod/test)与 our_code_id(我们后台配置的 104xxx 代码位)供广告收益报表按
|
||||
应用/代码位聚合;与 adn(实际填充子渠道)/slot_id(底层 rit)是两组不同口径。
|
||||
"""
|
||||
"""落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。"""
|
||||
if ad_session_id:
|
||||
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
|
||||
if existing is not None:
|
||||
@@ -41,8 +35,6 @@ def create_ecpm_record(
|
||||
ad_session_id=ad_session_id,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
ecpm_raw=ecpm_raw,
|
||||
report_date=cn_today().isoformat(),
|
||||
)
|
||||
|
||||
@@ -69,19 +69,12 @@ def grant_feed_reward(
|
||||
ad_session_id: str | None = None,
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
app_env: str | None = None,
|
||||
our_code_id: str | None = None,
|
||||
aborted: bool = False,
|
||||
) -> AdFeedRewardRecord:
|
||||
"""比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。
|
||||
"""完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。
|
||||
|
||||
发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份)。
|
||||
- aborted=True(用户中途 ✕ 关闭):整场不发,记 status='closed_early' 留痕(原因可查)。
|
||||
- 总时长不足 10 秒(unit_count==0):记 status='too_short' 不发。
|
||||
- 命中当日条数上限:记 status='capped' 不发。
|
||||
duration_seconds 是整场累计秒数。一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到
|
||||
FEED_MAX_DURATION_SECONDS 限单场份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到
|
||||
AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。
|
||||
一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS
|
||||
限单事件份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN 限单份金额;
|
||||
叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。
|
||||
"""
|
||||
existing = _find_by_event(db, client_event_id)
|
||||
if existing is not None:
|
||||
@@ -92,25 +85,6 @@ def grant_feed_reward(
|
||||
safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS))
|
||||
unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS
|
||||
|
||||
# 用户中途关闭广告:整场不发(全程不关才发),留一条 closed_early 记录原因。优先级最高。
|
||||
if aborted:
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
user_id=user_id,
|
||||
reward_date=today,
|
||||
duration_seconds=safe_duration,
|
||||
unit_count=unit_count,
|
||||
ad_session_id=ad_session_id,
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=0,
|
||||
status="closed_early",
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
@@ -122,32 +96,11 @@ def grant_feed_reward(
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=0,
|
||||
status="capped",
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
# 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。
|
||||
if unit_count == 0:
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
user_id=user_id,
|
||||
reward_date=today,
|
||||
duration_seconds=safe_duration,
|
||||
unit_count=0,
|
||||
ad_session_id=ad_session_id,
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=0,
|
||||
status="too_short",
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
coin = _unit_reward_total(db, user_id, ecpm, unit_count)
|
||||
if coin > 0:
|
||||
crud_wallet.grant_coins(
|
||||
@@ -165,8 +118,6 @@ def grant_feed_reward(
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=coin,
|
||||
status="granted",
|
||||
)
|
||||
|
||||
@@ -90,16 +90,6 @@ def grant_ad_reward(
|
||||
|
||||
today = cn_today().isoformat()
|
||||
|
||||
# 按 ad_session_id 匹配客户端 eCPM 上报:既用于缺 eCPM 时回退取值,也把「来源」
|
||||
# (我们的应用 app_env + 我们配置的代码位 our_code_id)回填到发奖记录,供广告收益报表聚合。
|
||||
# S2S 回调本身不带这俩;查不到(未上报 eCPM)则留空。
|
||||
ecpm_rec = (
|
||||
crud_ecpm.find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
|
||||
if ad_session_id else None
|
||||
)
|
||||
src_app_env = ecpm_rec.app_env if ecpm_rec is not None else None
|
||||
src_code_id = ecpm_rec.our_code_id if ecpm_rec is not None else None
|
||||
|
||||
# #3 每日上限:当前产品只保留发奖次数上限(默认 500 次)。旧的观看时长闸保留字段,
|
||||
# 但 DAILY_AD_WATCH_SECONDS_LIMIT=0 时视为停用,不能命中 capped。
|
||||
over_time = (
|
||||
@@ -112,18 +102,19 @@ def grant_ad_reward(
|
||||
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
|
||||
reward_date=today, reward_name=reward_name, raw=raw,
|
||||
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm,
|
||||
app_env=src_app_env, our_code_id=src_code_id,
|
||||
)
|
||||
return _commit_record(db, rec, trans_id)
|
||||
|
||||
ecpm_raw = ecpm or (ecpm_rec.ecpm_raw if ecpm_rec is not None else None)
|
||||
ecpm_raw = ecpm
|
||||
if not ecpm_raw and ad_session_id:
|
||||
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
|
||||
ecpm_raw = ecpm_rec.ecpm_raw if ecpm_rec is not None else None
|
||||
|
||||
if not ecpm_raw:
|
||||
rec = AdRewardRecord(
|
||||
trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing",
|
||||
reward_date=today, reward_name=reward_name, raw=raw,
|
||||
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=None,
|
||||
app_env=src_app_env, our_code_id=src_code_id,
|
||||
)
|
||||
return _commit_record(db, rec, trans_id)
|
||||
|
||||
@@ -140,55 +131,6 @@ def grant_ad_reward(
|
||||
trans_id=trans_id, user_id=user_id, coin=coin, status="granted",
|
||||
reward_date=today, reward_name=reward_name, raw=raw,
|
||||
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_raw,
|
||||
app_env=src_app_env, our_code_id=src_code_id,
|
||||
)
|
||||
return _commit_record(db, rec, trans_id)
|
||||
|
||||
|
||||
def record_reward_noshow(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
ad_session_id: str,
|
||||
ecpm: str | None = None,
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
app_env: str | None = None,
|
||||
our_code_id: str | None = None,
|
||||
reward_scene: str = "reward_video",
|
||||
) -> AdRewardRecord:
|
||||
"""客户端上报「激励视频展示了但用户提前关/跳过、未触发发奖」,落一条 coin=0 status='closed_early'
|
||||
记录,供广告收益报表把「不发金币的原因」也呈现出来(只留痕,不发币)。
|
||||
|
||||
幂等键 trans_id = 'noreward:{ad_session_id}'(每次展示唯一)。若同一 ad_session_id 已有 granted
|
||||
记录(S2S 已发奖,正常路径),说明用户其实看完了 → 跳过不写、原样返回那条,避免与正常发奖重复。
|
||||
app_env/our_code_id 由客户端直接带上(它本就持有);查不到 user 抛 UnknownUserError。
|
||||
"""
|
||||
if db.get(User, user_id) is None:
|
||||
raise UnknownUserError
|
||||
# 同一次展示已正常发奖 → 不再记 closed_early(防与 S2S granted 重复)
|
||||
granted = db.execute(
|
||||
select(AdRewardRecord)
|
||||
.where(
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.ad_session_id == ad_session_id,
|
||||
AdRewardRecord.status == "granted",
|
||||
)
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if granted is not None:
|
||||
return granted
|
||||
|
||||
trans_id = f"noreward:{ad_session_id}"
|
||||
existing = _find_by_trans(db, trans_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
rec = AdRewardRecord(
|
||||
trans_id=trans_id, user_id=user_id, coin=0, status="closed_early",
|
||||
reward_date=cn_today().isoformat(), reward_name=None, raw=None,
|
||||
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm,
|
||||
app_env=app_env, our_code_id=our_code_id,
|
||||
)
|
||||
return _commit_record(db, rec, trans_id)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
from datetime import date, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -206,29 +206,3 @@ def record_claims(
|
||||
)
|
||||
return 0
|
||||
return written
|
||||
|
||||
|
||||
# ===== 累计领券数(「我的」页战绩卡「领取优惠券 X 张」)=====
|
||||
|
||||
def sum_claimed_count(db: Session, user_id: int) -> int:
|
||||
"""该用户累计领到的优惠券张数。口径(2026-06-15 用户定):SUM(claimed_count) ——
|
||||
各成功领券记录的 pricebot 展示张数(claimed_count 列,存的是 display_count)之和,
|
||||
与领券完成时给用户看的「本次领了 N 张」同源。
|
||||
|
||||
- 只算 status ∈ {success, already_claimed}:already_claimed=今日已领过,协议里算「已领到」;
|
||||
failed / skipped 不计。
|
||||
- claimed_count 为 0 的保持 0:那是同 count_group 合并去重项(pricebot 只让一条出数),不重复计;
|
||||
为 NULL 的兜底成 1(成功领到至少 1 张;实际 pricebot to_dict 恒下发 display_count,NULL 基本不出现)。
|
||||
- 维度 user_id:登录态领的券才归入。登录前匿名领的(user_id 为空)不算(产品可接受)。
|
||||
"""
|
||||
total = db.execute(
|
||||
select(
|
||||
func.coalesce(
|
||||
func.sum(func.coalesce(CouponClaimRecord.claimed_count, 1)), 0
|
||||
)
|
||||
).where(
|
||||
CouponClaimRecord.user_id == user_id,
|
||||
CouponClaimRecord.status.in_(("success", "already_claimed")),
|
||||
)
|
||||
).scalar_one()
|
||||
return int(total or 0)
|
||||
|
||||
@@ -124,23 +124,6 @@ def mark_taobao_deeplink_invalid(db: Session, shop_id: str) -> int:
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
def mark_jd_deeplink_invalid(db: Session, store_id: str) -> int:
|
||||
"""把所有 id_jd=store_id 的行标记京东 deeplink 失效(置 invalid_at=now)。返回本次新标记的行数。
|
||||
|
||||
同 mark_taobao_deeplink_invalid:按 storeId 标记**所有**行(撞京东"当前门店超出配送范围"页),
|
||||
全标掉才能让后续 lookup 不再返回它。幂等:已标记的行(invalid_at 非 NULL)跳过。"""
|
||||
result = db.execute(
|
||||
update(StoreMapping)
|
||||
.where(
|
||||
StoreMapping.id_jd == store_id,
|
||||
StoreMapping.jd_deeplink_invalid_at.is_(None),
|
||||
)
|
||||
.values(jd_deeplink_invalid_at=func.now())
|
||||
)
|
||||
db.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 缓存查询: 比价前按"源平台店名"反查已沉淀的各目标平台店铺 id, 命中就让 pricebot 直接
|
||||
# deeplink 跳店内搜索, 省掉"开平台→进店→分享反查"整段。
|
||||
@@ -198,9 +181,6 @@ def lookup_nearest(
|
||||
if tgt == "taobao":
|
||||
# 失效的淘宝 deeplink(撞过错误页被 invalidate)整条排除 = 当没缓存, pricebot 走正常搜店。
|
||||
cands = [r for r in cands if r.taobao_deeplink_invalid_at is None]
|
||||
elif tgt == "jd":
|
||||
# 同上:失效的京东 deeplink(撞"当前门店超出配送范围"被 invalidate)整条排除。
|
||||
cands = [r for r in cands if r.jd_deeplink_invalid_at is None]
|
||||
if not cands:
|
||||
continue
|
||||
best = _pick_best(cands, lat, lng)
|
||||
|
||||
+5
-49
@@ -57,12 +57,6 @@ class EcpmReportIn(BaseModel):
|
||||
)
|
||||
adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle")
|
||||
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)")
|
||||
app_env: str | None = Field(
|
||||
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
|
||||
)
|
||||
our_code_id: str | None = Field(
|
||||
None, max_length=64, description="我们后台配置的代码位 ID(AdConfig 的 104xxx,非底层 rit)"
|
||||
)
|
||||
|
||||
|
||||
class EcpmReportOut(BaseModel):
|
||||
@@ -121,62 +115,24 @@ class TestGrantOut(BaseModel):
|
||||
|
||||
|
||||
class FeedRewardIn(BaseModel):
|
||||
"""比价/领券一整场信息流(轮播多条)结束后结算奖励。
|
||||
"""信息流广告完成后结算奖励。
|
||||
|
||||
规则:全程不关广告才发,金额按整场**总观看时长**折份(每 10 秒 1 份)。client_event_id 用于
|
||||
客户端超时重试幂等。中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)。
|
||||
每展示满 10 秒累计一份奖励,视频完成后一次性入账。client_event_id 用于客户端超时重试幂等。
|
||||
"""
|
||||
|
||||
client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id")
|
||||
ad_session_id: str | None = Field(
|
||||
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
|
||||
)
|
||||
ecpm: str = Field(..., description="本场信息流 eCPM(代表值,按分/千次展示处理;SDK getEcpm 原值,非元)")
|
||||
duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)")
|
||||
ecpm: str = Field(..., description="本条信息流广告 eCPM,按分/千次展示处理(SDK getEcpm 原值,非元)")
|
||||
duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数")
|
||||
adn: str | None = Field(None, description="实际投放 ADN")
|
||||
slot_id: str | None = Field(None, description="实际展示代码位")
|
||||
app_env: str | None = Field(
|
||||
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
|
||||
)
|
||||
our_code_id: str | None = Field(
|
||||
None, max_length=64, description="我们后台配置的代码位 ID(AdConfig 的 104xxx,非底层 rit)"
|
||||
)
|
||||
aborted: bool = Field(
|
||||
False, description="用户中途 ✕ 关闭广告(未走完比价):整场不发,记 closed_early"
|
||||
)
|
||||
|
||||
|
||||
class FeedRewardOut(BaseModel):
|
||||
granted: bool = Field(..., description="本次是否入账。达上限时 false")
|
||||
status: str = Field(..., description="granted / capped / too_short / closed_early")
|
||||
status: str = Field(..., description="granted / capped")
|
||||
coin: int = Field(..., description="本次发放金币")
|
||||
unit_count: int = Field(..., description="按 10 秒折算出的奖励份数")
|
||||
daily_limit: int = Field(..., description="每日信息流展示次数上限")
|
||||
|
||||
|
||||
class RewardNoShowIn(BaseModel):
|
||||
"""激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。
|
||||
|
||||
供广告收益报表把「有展示、没发金币」的原因也记录下来。user_id 由 JWT 取,不在 body。
|
||||
"""
|
||||
|
||||
ad_session_id: str = Field(
|
||||
..., min_length=8, max_length=64, description="本次展示会话 id(与 ecpm 上报、S2S extra 一致)"
|
||||
)
|
||||
watched_seconds: int = Field(0, ge=0, description="关闭前已观看秒数(仅留痕参考,不入库)")
|
||||
ecpm: str | None = Field(None, description="本次展示 eCPM(分/千次,SDK getEcpm 原值);可空")
|
||||
adn: str | None = Field(None, description="实际投放 ADN")
|
||||
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)")
|
||||
app_env: str | None = Field(
|
||||
None, max_length=16, description="我们的穿山甲应用环境:prod / test"
|
||||
)
|
||||
our_code_id: str | None = Field(
|
||||
None, max_length=64, description="我们后台配置的代码位 ID(104xxx)"
|
||||
)
|
||||
|
||||
|
||||
class RewardNoShowOut(BaseModel):
|
||||
ok: bool = Field(..., description="是否处理成功(落库或幂等命中)")
|
||||
status: str = Field(
|
||||
..., description="closed_early(已留痕) / granted(该次其实已发奖,跳过未写)"
|
||||
)
|
||||
|
||||
@@ -39,13 +39,3 @@ class CouponCompletedTodayOut(BaseModel):
|
||||
"""这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。"""
|
||||
|
||||
completed: bool
|
||||
|
||||
|
||||
class CouponStatsOut(BaseModel):
|
||||
"""「我的」页战绩卡「领取优惠券 X 张」数据源:该登录用户累计领到的券数。
|
||||
|
||||
口径(2026-06-15 用户定):SUM(claimed_count) —— 各成功领券记录的 pricebot 展示张数之和,
|
||||
与领券完成时给用户看的「本次领了 N 张」同源。详见 repositories.coupon_state.sum_claimed_count。
|
||||
"""
|
||||
|
||||
coupon_count: int
|
||||
|
||||
@@ -22,9 +22,3 @@ class SavingsFeedItem(BaseModel):
|
||||
|
||||
class SavingsFeedOut(BaseModel):
|
||||
items: list[SavingsFeedItem]
|
||||
|
||||
|
||||
class AppFlagsOut(BaseModel):
|
||||
"""客户端拉取的运营 feature flag(不鉴权,登录前也能拉)。客户端缓存后按需读。"""
|
||||
|
||||
comparing_ad_enabled: bool # 比价/领券期是否展示信息流广告(远程 kill-switch)
|
||||
|
||||
@@ -63,7 +63,6 @@
|
||||
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
|
||||
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) |
|
||||
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) |
|
||||
| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) |
|
||||
| **用户资料**(前缀 `/api/v1/user`) |||
|
||||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) |
|
||||
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) |
|
||||
@@ -105,7 +104,6 @@
|
||||
| A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
|
||||
| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) |
|
||||
| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
|
||||
| A29 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin-ad-revenue-report.md)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) |
|
||||
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
|
||||
|
||||
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
||||
|
||||
@@ -7,13 +7,11 @@
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `feed`(信息流) / `draw`(历史 Draw 信息流) 等 |
|
||||
| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;激励视频与穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配。**信息流轮播每条展示用各自独立 id**(不复用比价会话 id,否则 `uq_ad_ecpm_record_session` 去重只留一条) |
|
||||
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 |
|
||||
| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 |
|
||||
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,单位是**分/千次展示**(非元),后端 ÷100 转元参与金币公式 |
|
||||
| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle`(聚合后实际填充的子渠道) |
|
||||
| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` |
|
||||
| `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) |
|
||||
| `app_env` | str\|null | 否 | **我们的**穿山甲应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) |
|
||||
| `our_code_id` | str\|null | 否 | **我们后台配置的**代码位 ID(`AdConfig` 的 104xxx,**非** `slot_id` 的底层 rit) |
|
||||
|
||||
`user_id` 不在 body 里——由 JWT 取(Bearer),防伪造。
|
||||
|
||||
@@ -25,7 +23,7 @@
|
||||
| `ok` | bool | 落库即 `true` |
|
||||
|
||||
## 说明
|
||||
客户端在广告**展示后**(`onAdShow` 读 `getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。激励视频每次展示上报一条;**信息流轮播每条展示各上报一条**(每条独立 `ad_session_id`),作为「广告收益报表」展示条数/收益的数据源(见 [admin-ad-revenue-report](./admin-ad-revenue-report.md))。`app_env` + `our_code_id` 供报表按「我们的应用 / 我们配置的代码位」聚合,与 `adn`/`slot_id`(底层填充渠道/rit)是两组不同口径。
|
||||
客户端在广告**展示后**(`onAdShow` 读 `getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。
|
||||
|
||||
- 普通激励视频发奖会先用 S2S 回调自带 `ecpm`;若缺失,再按 `ad_session_id` 读取本接口上报的 eCPM;两边都没有则不发并记录异常。
|
||||
- **best-effort**:客户端 fire-and-forget,但普通激励视频若 S2S 缺 eCPM,这条上报会成为发奖依据。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币
|
||||
|
||||
点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发。
|
||||
点位 2:比价等待 / 领券信息流广告。每展示满 10 秒累计一份奖励,视频完成后一次性入账。
|
||||
|
||||
## 鉴权
|
||||
|
||||
@@ -12,27 +12,24 @@
|
||||
|---|---|---:|---|
|
||||
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
|
||||
| `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 |
|
||||
| `ecpm` | string | 是 | 本场信息流 eCPM 代表值(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) |
|
||||
| `duration_seconds` | int | 是 | **整场累计观看秒数**(轮播各条相加) |
|
||||
| `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充的子渠道) |
|
||||
| `slot_id` | string\|null | 否 | 实际展示代码位(底层 mediation rit) |
|
||||
| `app_env` | string\|null | 否 | **我们的**应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) |
|
||||
| `our_code_id` | string\|null | 否 | **我们后台配置的**代码位 ID(104xxx,非底层 rit);供广告收益报表按代码位聚合金币 |
|
||||
| `aborted` | bool | 否 | 用户中途 ✕ 关闭广告(未走完比价):整场不发,仅记 `closed_early`。默认 `false` |
|
||||
| `ecpm` | string | 是 | 本条信息流广告 eCPM(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) |
|
||||
| `duration_seconds` | int | 是 | 实际展示/播放秒数 |
|
||||
| `adn` | string\|null | 否 | 实际投放 ADN |
|
||||
| `slot_id` | string\|null | 否 | 实际展示代码位 |
|
||||
|
||||
## 响应
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `granted` | bool | 本次是否入账;未发(任一非 granted 状态)时为 `false` |
|
||||
| `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途关闭) |
|
||||
| `coin` | int | 本次发放金币;非 granted 为 0 |
|
||||
| `granted` | bool | 本次是否入账;达上限时为 `false` |
|
||||
| `status` | string | `granted` / `capped` |
|
||||
| `coin` | int | 本次发放金币 |
|
||||
| `unit_count` | int | 按 10 秒折算出的奖励份数 |
|
||||
| `daily_limit` | int | 每日信息流展示次数上限,默认 500 |
|
||||
|
||||
## 计算口径
|
||||
|
||||
- 奖励份数:`整场总时长 // 10`。
|
||||
- 奖励份数:`duration_seconds // 10`。
|
||||
- 单份奖励:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(当天累计份序号) × 10000`,四舍五入为整数金币。
|
||||
- eCPM 档:`0-100=0.1`,`101-200=0.3`,`201-400=0.4`,`>400=0.6`。
|
||||
- LT 档:第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`。
|
||||
|
||||
@@ -22,7 +22,7 @@ GroMore 以 GET 回调,关键参数:
|
||||
| `ecpm` | string | 本次广告 eCPM(同上,可用于收益分析) |
|
||||
| `sign` | string | 签名,见下 |
|
||||
|
||||
**验签**:`sign = SHA256("{m-key}:{trans_id}")` 十六进制(只签 `trans_id`,其余参数不参与)。多激励位共用同一回调 URL → 服务端把各位的 m-key 都配上,`verify_callback_sign_any` 逐个试、任一过即接受。算法细节、m-key 配置项(`PANGLE_REWARD_SECRET_TEST/_TEST_DEDICATED/_PROD`)、为什么这样设计 → 见集成文档 [integrations/pangle](../integrations/pangle.md)。
|
||||
**验签**:`sign = SHA256("{m-key}:{trans_id}")` 十六进制(只签 `trans_id`,其余参数不参与)。算法细节、m-key 来源、为什么这样设计 → 见集成文档 [integrations/pangle](../integrations/pangle.md)。
|
||||
|
||||
## 出参
|
||||
响应 `200`,**响应体必须是 `{"is_verify": bool, "reason": int}`**(GroMore 规范)。
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# POST /api/v1/ad/reward-noshow — 激励视频提前关闭/未发奖留痕
|
||||
|
||||
激励视频**展示了但用户提前关闭/跳过、未触发 S2S 发奖**时,客户端 best-effort 上报一条留痕记录,
|
||||
让运营后台「广告数据」能呈现「有展示、没发金币」的原因。**不发金币**。
|
||||
|
||||
## 鉴权
|
||||
|
||||
需要 Bearer token。`user_id` 由 JWT 取,不在 body。
|
||||
|
||||
## 请求体
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `ad_session_id` | string | 是 | 本次展示会话 id(与 eCPM 上报、S2S `extra.ad_session_id` 一致),8-64 字符 |
|
||||
| `watched_seconds` | int | 否 | 关闭前已观看秒数(仅留痕参考,**不入库**)。默认 0 |
|
||||
| `ecpm` | string\|null | 否 | 本次展示 eCPM(穿山甲 getEcpm 原值,分/千次展示) |
|
||||
| `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充子渠道) |
|
||||
| `slot_id` | string\|null | 否 | 实际展示代码位(底层 mediation rit) |
|
||||
| `app_env` | string\|null | 否 | **我们的**应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) |
|
||||
| `our_code_id` | string\|null | 否 | **我们后台配置的**代码位 ID(104xxx,非底层 rit) |
|
||||
|
||||
## 响应
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `ok` | bool | 是否处理成功(落库或幂等命中) |
|
||||
| `status` | string | `closed_early`(已留痕) / `granted`(该次其实已发奖,跳过未写) |
|
||||
|
||||
## 说明
|
||||
|
||||
- 在 `onAdClose` 时若本次**未触发发奖**(无 `onRewardVerify`/`onRewardArrived`)调用。
|
||||
- **幂等**:写入 `ad_reward_record`,`trans_id = noreward:{ad_session_id}`(每次展示唯一),重试不重复。
|
||||
- **防与正常发奖重复**:若同一 `ad_session_id` 已有 `status='granted'` 记录(S2S 已发,说明用户其实看完了),
|
||||
则跳过不写,返回 `status='granted'`。
|
||||
- best-effort:客户端 fire-and-forget,失败只 log,不影响主流程。
|
||||
- 信息流的「用户中途关闭」走 [ad-feed-reward](./ad-feed-reward.md) 的 `aborted=true`(记 `closed_early`),不走本接口。
|
||||
|
||||
## 数据写入
|
||||
|
||||
- `ad_reward_record` 新增一行(`coin=0`、`status='closed_early'`、`reward_scene='reward_video'`)。
|
||||
- **不**写 `coin_account` / `coin_transaction`(不发币)。
|
||||
@@ -1,108 +0,0 @@
|
||||
# Admin 广告收益报表
|
||||
|
||||
> 所属:Admin 组(前缀 `/admin/api/ad-revenue-report`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md)
|
||||
|
||||
按 **用户 × 日期 × 广告类型 × 我们的应用 × 我们的代码位** 聚合,回答「每个用户某天、每类广告(激励视频 / 信息流 / 历史 Draw)分别**看了多少条**、**收益多少**、按现算法**发了多少金币**、广告来自**哪个应用的哪个代码位**」。**纯只读**,不发币、不改数据,也**不改发奖逻辑**。
|
||||
|
||||
相关表:[ad_ecpm_record](../database/ad_ecpm_record.md)、[ad_reward_record](../database/ad_reward_record.md)、[ad_feed_reward_record](../database/ad_feed_reward_record.md)。
|
||||
|
||||
## 数据来源(三流合并,聚合键 = user × ad_type × app_env × our_code_id)
|
||||
|
||||
| 指标 | 来源表 | 口径 |
|
||||
|---|---|---|
|
||||
| 展示条数 `impressions` | `ad_ecpm_record` | 每行 = 客户端一次广告展示。激励视频每次展示上报一条;**信息流轮播每条展示各上报一条**(每条独立 `ad_session_id`) |
|
||||
| 收益 `revenue_yuan` | `ad_ecpm_record` | `Σ(eCPM元 ÷ 1000)`,即每条展示预估收益累加(eCPM 原值是分,÷100 转元;÷1000 是每千次→单次)。**预估口径,非结算;测试应用多为 0** |
|
||||
| 应发/实发金币 `expected_coin`/`actual_coin` | `ad_reward_record`(reward_video)+ `ad_feed_reward_record`(feed) | **复用金币审计逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径,不另写公式),按同维度求和;`matched = 应发==实发`。**只读复算,不改发奖** |
|
||||
| 来源应用/代码位 `app_env`/`our_code_id` | 上述各表回填 | `prod`(傻瓜比价)/`test`(测试);代码位是**我们后台配的 104xxx**,非底层 rit |
|
||||
| 底层渠道 `adns` | `ad_ecpm_record` | 实际填充的 ADN 子渠道集合(pangle/gdt/...),附加参考 |
|
||||
|
||||
展示与金币来自不同表,做**并集**:有展示无金币(用户中途关、未达发奖)、有金币无展示(未上报 eCPM)各自成行。
|
||||
|
||||
## GET /admin/api/ad-revenue-report — 聚合报表
|
||||
|
||||
- 入参(均 query,可选):
|
||||
| 参数 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `date_from` | string | 今天 | 起始日 北京时间 `YYYY-MM-DD` |
|
||||
| `date_to` | string | =`date_from` | 结束日 北京时间 `YYYY-MM-DD`,**闭区间**;单日时与 `date_from` 相同 |
|
||||
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
|
||||
| `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 |
|
||||
| `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** |
|
||||
| `limit` | int(1~1000) | 500 | **展示**明细组数(截断;`total`/`total_*`/`daily` 按全量统计不受影响) |
|
||||
|
||||
约束:`date_to` 不早于 `date_from`、区间最长 **92 天**、日期须 `YYYY-MM-DD`,否则 `422`。
|
||||
|
||||
- 出参 `200`:`AdRevenueReportOut`
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `date_from` / `date_to` | string | 报表起止日期(闭区间) |
|
||||
| `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受 `limit` 影响) |
|
||||
| `total` | int | 聚合组**总数**(全量,不受 `limit` 影响) |
|
||||
| `truncated` | bool | 明细是否被 `limit` 截断 |
|
||||
| `total_impressions` | int | 全量展示条数合计 |
|
||||
| `total_revenue_yuan` | float | 全量收益合计(元) |
|
||||
| `total_expected_coin` | int | 全量应发金币合计 |
|
||||
| `total_actual_coin` | int | 全量实发金币合计 |
|
||||
| `mismatch_count` | int | 应发≠实发的组数(=0 说明全部按公式发放) |
|
||||
| `items` | `AdRevenueRow[]` | 聚合明细(按 日期→用户→类型→代码位 排序) |
|
||||
|
||||
### AdRevenueDaily(`daily[]` — 按天趋势)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `date` | string | 北京时间 `YYYY-MM-DD` |
|
||||
| `impressions` | int | 当天展示条数合计 |
|
||||
| `revenue_yuan` | float | 当天预估收益合计(元) |
|
||||
| `expected_coin` | int | 当天应发金币合计 |
|
||||
| `actual_coin` | int | 当天实发金币合计 |
|
||||
|
||||
### AdRevenueRow(`items[]`)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `report_date` | string | 该组所属日期 北京时间 `YYYY-MM-DD` |
|
||||
| `user_id` | int | |
|
||||
| `ad_type` | string | `reward_video` / `feed` / `draw` |
|
||||
| `app_env` | string \| null | 我们的应用:`prod`(傻瓜比价)/`test`(测试);旧数据为空 |
|
||||
| `our_code_id` | string \| null | 我们配置的代码位 104xxx;旧数据为空 |
|
||||
| `hour` | int \| null | 北京时间小时 0–23(`granularity=hour` 时有值;按天为 null) |
|
||||
| `impressions` | int | 展示条数 |
|
||||
| `revenue_yuan` | float | 收益(元),预估口径 |
|
||||
| `expected_coin` | int | 应发金币(公式复算,与金币审计同源) |
|
||||
| `actual_coin` | int | 实发金币(实际入账) |
|
||||
| `matched` | bool | 该组应发==实发(组内任一条不符则 false) |
|
||||
| `adns` | string[] | 底层填充 ADN 子渠道集合 |
|
||||
| `impression_records` | `AdRevenueImpression[]` | 该组**逐条展示明细**(前端展开下钻);只要有展示就非空 |
|
||||
| `records` | `AdRevenueRecord[]` | 该组**逐条发奖复算明细**(前端展开下钻);纯展示无发奖的组为空 |
|
||||
|
||||
### AdRevenueImpression(`items[].impression_records[]` — 展开「展示明细」)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int | ad_ecpm_record 主键 |
|
||||
| `created_at` | datetime | |
|
||||
| `ecpm` | string | 本次展示 eCPM 原始值(分/千次展示) |
|
||||
| `revenue_yuan` | float | 本次展示预估收益(元)= eCPM元 ÷ 1000 |
|
||||
| `adn` | string \| null | 实际填充 ADN 子渠道 |
|
||||
| `slot_id` | string \| null | 底层 mediation rit(非我们配置的广告位 ID) |
|
||||
|
||||
### AdRevenueRecord(`items[].records[]` — 展开「发奖明细」)
|
||||
还原金币审计的逐条列,与发奖同一复算口径。
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `record_id` | int | 发奖记录主键 |
|
||||
| `created_at` | datetime | |
|
||||
| `status` | string | `granted` / `capped` / `ecpm_missing` |
|
||||
| `ecpm` | string \| null | 本次采用的 eCPM 原始值 |
|
||||
| `ecpm_factor` | float \| null | 因子1(eCPM 档);非 granted 为空 |
|
||||
| `units` | int | 折算份数:激励视频恒 1;信息流 = 满 10 秒份数 |
|
||||
| `lt_index_start` / `lt_index_end` | int \| null | 占用「账号累计第几份」的起止 |
|
||||
| `lt_factor_start` / `lt_factor_end` | float \| null | 因子2(LT)起止值 |
|
||||
| `expected_coin` | int | 应发金币 |
|
||||
| `actual_coin` | int | 实发金币 |
|
||||
| `matched` | bool | 该条复算与实发是否一致 |
|
||||
|
||||
## 说明与局限
|
||||
|
||||
- **展示 vs 发奖分离**:信息流轮播一会话可展示多条(都计入 `impressions`),但发奖仍按现规则(一会话发一次),`coin` 不因展示条数变化——这是有意设计(用户中途关只记展示不发奖)。
|
||||
- **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。
|
||||
- **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。
|
||||
- **收益是预估**:基于客户端上报的 eCPM,非穿山甲后台结算值;以后台报表为结算权威。
|
||||
- **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。
|
||||
@@ -6,7 +6,7 @@
|
||||
**multipart/form-data**:
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `content` | string | ✓ | 反馈正文,**1-200 字**(strip 后) |
|
||||
| `content` | string | ✓ | 反馈正文,**1-2000 字**(strip 后) |
|
||||
| `contact` | string | ✗ | 联系方式(微信/QQ/手机号),**≤128 字**。原型改版后客户端已不再采集、不传该字段(后端默认空串);保留字段兼容旧端 |
|
||||
| `images` | file[] | ✗ | 截图,**最多 6 张**,每张走头像同款校验(JPEG/PNG/WebP,≤ 5 MB,魔数嗅探) |
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
> 不返回上传的 image URL——这是给运营后台看的,客户端通常不需要。
|
||||
|
||||
## 错误码
|
||||
- `400` 内容为空 / 内容超 200 字 / 联系方式超 128 字 / 图片超 6 张 / 单图非法(空/过大/格式不对)
|
||||
- `400` 内容为空 / 内容超 2000 字 / 联系方式超 128 字 / 图片超 6 张 / 单图非法(空/过大/格式不对)
|
||||
- `401` 未带 token / token 无效或过期 / 用户被禁用
|
||||
- `422` 缺 `content` 字段
|
||||
|
||||
|
||||
@@ -7,19 +7,17 @@
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /ad/ecpm-report`(`create_ecpm_record`)。每次广告展示上报一条;best-effort,丢一两条不影响业务。鉴权接口已确保 user 存在。
|
||||
- **U / D**:无。
|
||||
- **R**:内部收益统计/对账(按 `(user_id, report_date)` 聚合);`count_today` 排查辅助;广告收益报表 [admin-ad-revenue-report](../api/admin-ad-revenue-report.md) 的展示条数/收益数据源(按 `app_env`/`our_code_id` 聚合)。当前无面向 C 端用户的读接口。
|
||||
- **R**:内部收益统计/对账(按 `(user_id, report_date)` 聚合);`count_today` 排查辅助。当前无面向 C 端用户的读接口。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
|
||||
| `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `feed`(信息流)/ `draw`(历史 Draw 信息流);各类型各自上报,不强行统一代码位 |
|
||||
| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;激励视频用于 S2S 缺 `ecpm` 时匹配发奖。**信息流轮播每条展示用各自独立 id**(不复用比价会话 id,否则 UNIQUE 去重只留一条) |
|
||||
| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`;聚合后实际填充的子渠道) |
|
||||
| `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `draw`(Draw 信息流);各类型各自上报,不强行统一代码位 |
|
||||
| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;用于普通激励视频在 S2S 缺 `ecpm` 时匹配发奖 |
|
||||
| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) |
|
||||
| `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) |
|
||||
| `app_env` | String(16) | nullable | **我们的**穿山甲应用环境:`prod`(傻瓜比价正式)/`test`(测试应用);旧数据 NULL。广告收益报表按它聚合「来源应用」 |
|
||||
| `our_code_id` | String(64) | nullable | **我们后台配置的**代码位 ID(`AdConfig` 的 104xxx,**非** `slot_id` 底层 rit);旧数据 NULL |
|
||||
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**(穿山甲 getEcpm 原值,单位**分/千次展示**);后端 ÷100 转元参与金币公式 |
|
||||
| `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
|
||||
|
||||
@@ -11,15 +11,13 @@
|
||||
| `ad_session_id` | String(64) | index, nullable | 客户端生成的一次信息流广告会话 id |
|
||||
| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 |
|
||||
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` |
|
||||
| `duration_seconds` | Integer | NOT NULL | 整场比价累计观看秒数(轮播各条相加) |
|
||||
| `duration_seconds` | Integer | NOT NULL | 实际展示/播放秒数 |
|
||||
| `unit_count` | Integer | NOT NULL | `duration_seconds // 10` 得到的奖励份数 |
|
||||
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报 eCPM 原始值 |
|
||||
| `adn` | String(32) | nullable | 实际投放 ADN(聚合后实际填充子渠道) |
|
||||
| `slot_id` | String(64) | nullable | 实际展示代码位(底层 mediation rit) |
|
||||
| `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试),客户端 feed-reward 上报;旧数据 NULL。广告收益报表金币侧按它聚合 |
|
||||
| `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx,客户端上报;旧数据 NULL |
|
||||
| `coin` | Integer | NOT NULL | 实发金币,非 `granted` 时为 0 |
|
||||
| `status` | String(16) | NOT NULL | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途 ✕ 关闭,全程未看完) |
|
||||
| `adn` | String(32) | nullable | 实际投放 ADN |
|
||||
| `slot_id` | String(64) | nullable | 实际展示代码位 |
|
||||
| `coin` | Integer | NOT NULL | 实发金币,`capped` 时为 0 |
|
||||
| `status` | String(16) | NOT NULL | `granted` / `capped` |
|
||||
| `created_at` | DateTime | index, NOT NULL | 创建时间 |
|
||||
|
||||
## 约束
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
每条 = 穿山甲一次**服务端激励回调**。`trans_id` 唯一做幂等键(穿山甲会重试,同号只处理一次)。`reward_scene` 区分普通激励视频、签到膨胀等场景;`reward_date`(北京时间日期串)给普通激励视频"每日上限"计数用。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward` 或场景业务处理)或 `POST /ad/test-grant`(本地联调)。普通激励视频三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`,默认 500)到顶** → 记一行 `status='capped'`、`coin=0`、不发币。否则按 eCPM 公式发币。另:`POST /ad/reward-noshow`(`record_reward_noshow`,Bearer)在用户提前关/未发奖时记一行 `status='closed_early'`、`coin=0` 留痕(同 session 已 granted 则跳过)。
|
||||
- **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward` 或场景业务处理)或 `POST /ad/test-grant`(本地联调)。普通激励视频三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`,默认 500)到顶** → 记一行 `status='capped'`、`coin=0`、不发币。否则按 eCPM 公式发币。
|
||||
- **U / D**:无。
|
||||
- **R**:`GET /ad/reward-status`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。
|
||||
|
||||
@@ -13,15 +13,13 @@
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等)。`closed_early` 留痕记录无 S2S 交易号,用合成键 `noreward:{ad_session_id}` |
|
||||
| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等) |
|
||||
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回;不存在抛 UnknownUserError) |
|
||||
| `reward_scene` | String(32) | NOT NULL, default `reward_video` | 奖励场景:`reward_video` 普通激励视频;`signin_boost` 签到膨胀 |
|
||||
| `ad_session_id` | String(64) | index, nullable | 客户端广告会话 ID,来自 `extra.ad_session_id`;用于匹配 `ad_ecpm_record` |
|
||||
| `ecpm_raw` | String(32) | nullable | 本次发奖采用的 eCPM 原始值;可来自 S2S `ecpm` 或客户端上报 |
|
||||
| `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试);S2S 不带,发奖时按 `ad_session_id` 匹配 `ad_ecpm_record` 回填,查不到 NULL。广告收益报表金币侧按它聚合 |
|
||||
| `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx(同上回填) |
|
||||
| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/`closed_early`/业务不满足时为 0 |
|
||||
| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `not_signed`/`already_boosted`/`last_day` |
|
||||
| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/业务不满足时为 0 |
|
||||
| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `not_signed`/`already_boosted`/`last_day` |
|
||||
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 |
|
||||
| `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) |
|
||||
| `raw` | String(1024) | nullable | 回调原始参数(审计排查) |
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` / `signin_boost_coin` / `withdraw_auto_reconcile_enabled` |
|
||||
| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards / `bool` 如 withdraw_auto_reconcile_enabled) |
|
||||
| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` |
|
||||
| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards) |
|
||||
| `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 id(= `admin_user.id`,软引用,无 FK) |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后修改时间 |
|
||||
|
||||
|
||||
@@ -16,18 +16,12 @@
|
||||
| 函数 | 说明 |
|
||||
|---|---|
|
||||
| `build_sign(trans_id, secret) -> str` | 计算 `SHA256("{secret}:{trans_id}")` hex。自验签测试 / 模拟回调脚本共用,保证两端一致 |
|
||||
| `verify_callback_sign(params, secret) -> bool` | 用**单个** m-key 校验回调签名。密钥空 / 缺 `trans_id` 一律失败;比较用 `hmac.compare_digest` 定长比较防时序侧信道 |
|
||||
| `verify_callback_sign_any(params, secrets) -> bool` | 用**一组** m-key 逐个验签、任一通过即接受(多激励位共用同一回调 URL 时用)。空列表 → 失败 |
|
||||
|
||||
## 配置(多激励位 = 多 m-key)
|
||||
每个 GroMore 激励位的 m-key 由后台各自生成、互不相同,但本服务用**同一个回调 URL** 接所有位的回调,因此把用到的位的 m-key 都配上;验签时 `verify_callback_sign_any` 逐个试、任一过即接受(仍安全:伪造者须知道其中某个 m-key 才能造出合法 sign)。m-key 在后台「GroMore 聚合管理 → 搜广告位 ID → 编辑」处获取。`settings.pangle_reward_secrets` 把下列来源汇总去重:
|
||||
| `verify_callback_sign(params, secret) -> bool` | 校验回调签名。密钥空 / 缺 `trans_id` 一律失败;比较用 `hmac.compare_digest` 定长比较防时序侧信道 |
|
||||
|
||||
## 配置
|
||||
| 配置项 | 说明 |
|
||||
|---|---|
|
||||
| `PANGLE_REWARD_SECRET_TEST` | 测试应用 激励位 104099649 的 m-key |
|
||||
| `PANGLE_REWARD_SECRET_TEST_DEDICATED` | 测试应用 专属激励位 104127529 的 m-key |
|
||||
| `PANGLE_REWARD_SECRET_PROD` | 正式应用 激励位 104099389 的 m-key |
|
||||
| `PANGLE_REWARD_SECRET` | 旧用法:单个或逗号分隔多个 m-key,仍兼容(与上面三个命名项合并去重) |
|
||||
| `PANGLE_REWARD_SECRET` | m-key(安全密钥)。后台「GroMore 聚合管理 → 搜广告位 ID → 编辑」处获取 |
|
||||
|
||||
## 踩坑
|
||||
- **别用联盟代码位那套**:联盟代码位层级用的是另一套 Security Key + `isValid` 响应体,与 GroMore 广告位层级不通用。我们走 GroMore,别接错。
|
||||
|
||||
+2
-15
@@ -41,31 +41,18 @@
|
||||
> 已确认走 **GroMore 广告位层级**回调(客户端 useMediation(true);规范 supportcenter/26240),
|
||||
> **不是**联盟代码位层级(5416)。验签算法 / 响应格式 / reward_amount 解析**代码已按 GroMore 规范实现**
|
||||
> (2026-05-27),剩下的是后台配置 + 填密钥。
|
||||
>
|
||||
> 🔴 **2026-06-16 实测的头号阻塞:穿山甲打线上后端会超时**。真机看广告时 SDK 日志
|
||||
> `onRewardArrived valid=false errorCode=50002`,errorMsg =
|
||||
> `Get "https://app-api.shaguabijia.com/api/v1/ad/pangle-callback...": context deadline exceeded`。
|
||||
> 而我们自己 curl 线上是秒回。说明**穿山甲机房到线上后端这条网络路径有问题**(疑似 nginx/云防火墙对
|
||||
> 机房/境外 IP 限流或拦截)。**m-key 配对了也救不了——穿山甲根本没把请求送达。** 上线前必须单独验
|
||||
> 「线上 `app-api.shaguabijia.com` 对穿山甲机房可达且响应够快」,否则用户看完发不了奖。
|
||||
>
|
||||
> ✅ **多激励位已支持**(2026-06-16):内部测试期 测试位/专属位/正式位 并存,m-key 各配一行
|
||||
> (`PANGLE_REWARD_SECRET_TEST/_TEST_DEDICATED/_PROD`),`verify_callback_sign_any` 逐个试。本地已验通。
|
||||
|
||||
- [ ] **GroMore 后台配回调**:GroMore 聚合管理 → 搜广告位ID → 编辑 → 勾选「服务端激励回调」→
|
||||
回调 URL 填 `https://app-api.shaguabijia.com/api/v1/ad/pangle-callback`(用域名,别用 IP)。
|
||||
⚠️ 广告位层级配了就**别再在代码位层级重复配**(会导致发奖出问题)。
|
||||
- [ ] **拿 m-key(安全密钥)**:就在上面"编辑广告位"页获取(每个激励位各一把),填到生产 `.env` 的命名变量
|
||||
(正式位填 `PANGLE_REWARD_SECRET_PROD`;内部测试期 测试位/专属位 也并存就填 `_TEST`/`_TEST_DEDICATED`)。
|
||||
- [ ] **拿 m-key(安全密钥)**:就在上面"编辑广告位"页获取,填到生产 `.env` 的 `PANGLE_REWARD_SECRET`。
|
||||
(注:这是 GroMore 广告位的 m-key,**不是**联盟代码位那个 Security Key。)
|
||||
- [x] ~~换验签~~:`app/integrations/pangle.py` 已实现 `sign = SHA256("{m-key}:{trans_id}")`(GroMore 真实算法)。
|
||||
- [x] ~~响应格式~~:已返回 GroMore 要求的 `{"is_verify": bool, "reason": int}`。
|
||||
- [x] ~~reward_amount~~:回调按 `reward_amount` 发金币(`rewards.resolve_ad_reward_coin`,带回退/夹紧);
|
||||
**后台广告位"奖励数量"须配成与 `AD_REWARD_COIN`(=100)一致**,保证"广告内展示/进度预告/到账"三者一致。
|
||||
- [x] ~~透传 user_id~~:客户端已 `setUserID(userId)` + `setMediaExtra("uid:...")`。
|
||||
- [ ] 生产 `.env`:`PANGLE_CALLBACK_ENABLED=true` + 至少一个 m-key 命名变量(配齐才不返 503)+ 确认
|
||||
`AD_REWARD_TEST_GRANT_ENABLED=false`(绕过反作弊,生产红线)。改 `.env` 后**重启 uvicorn**
|
||||
(watchfiles 不监听 .env)。验证:外部 curl 回调 URL 从 `503` 变 `403 bad sign` 即生效。
|
||||
- [ ] 生产 `.env`:`PANGLE_CALLBACK_ENABLED=true` + `PANGLE_REWARD_SECRET=<m-key>`(配齐才不返 503)
|
||||
|
||||
## C. 部署 + 包名
|
||||
|
||||
|
||||
+50
-165
@@ -1,74 +1,45 @@
|
||||
"""美团 CPS 券定时抓取入库(全国 359 个地级市)。
|
||||
"""美团 CPS 券定时抓取入库(北京试点)。
|
||||
|
||||
把每个城市的 3 路券抓进 meituan_coupon 表,供「智能推荐 / 销量最高」从库里捞、本地排序,
|
||||
不再实时打美团:
|
||||
把 3 路券抓进 meituan_coupon 表,供「销量/佣金排序」从库里捞、本地排序,不再实时打美团:
|
||||
1. search_waimai : 到家/外卖, 搜「外卖」 翻到尽头
|
||||
2. search_meishi : 到家/外卖, 搜「美食」 翻到尽头
|
||||
3. store_supply : 到店, 多业务线供给(到餐+到综+酒店+门票) 翻到尽头
|
||||
|
||||
城市来自 app/integrations/data/meituan_cities.json(美团官方城市字典,359 地级市)。
|
||||
实测一个地级市 cityId 已覆盖其下辖县级市(徐州 → 邳州/新沂…),故按地级市抓即可。
|
||||
|
||||
并发:城市级并发(每城内部仍顺序跑 3 路),主线程串行入库(Session 不跨线程)。
|
||||
实测单城全量 ~110s/~2300 条;15 并发抓完全国一轮 ~50–60min,402 占比 ~3% 且退避全消化。
|
||||
mentor 要求每 3h 全量一轮,窗口充裕,故默认 12 并发 + 启动错峰,把瞬时峰值与 402 压更低。
|
||||
|
||||
按 (source, product_view_sign) upsert 存最新态;last_seen 每轮刷新。带文件锁,防止
|
||||
上一轮没跑完下一轮又起。
|
||||
上一轮没跑完下一轮又起(本地 5~10min、跨进程 cron 都安全)。
|
||||
|
||||
用法:
|
||||
# 单轮全量(给 cron 用,线上每 3h 一次)
|
||||
# 单轮(打通验证 / 给 cron 用,线上每 1h 一次)
|
||||
python -m scripts.pull_meituan_coupons --once
|
||||
|
||||
# 常驻循环(每 3h 一轮)
|
||||
python -m scripts.pull_meituan_coupons --loop --interval 10800
|
||||
|
||||
# 本地测试:只抓前 N 个城市 / 指定城市
|
||||
python -m scripts.pull_meituan_coupons --once --limit 3
|
||||
python -m scripts.pull_meituan_coupons --once --city-ids OCZOBCJDEXKE7KBN3BD7AYQG2Q
|
||||
|
||||
部署(服务器):推荐 cron 跑 --once(每 3h),避免长驻进程孤儿:
|
||||
0 */3 * * * cd /path/to/app-server && .venv/bin/python -m scripts.pull_meituan_coupons --once >> data/etl.log 2>&1
|
||||
# 本地循环(默认每 10min 一轮)
|
||||
python -m scripts.pull_meituan_coupons --loop --interval 600
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# Windows 控制台按 UTF-8 输出中文/¥;line_buffering=True 让 print 每行即时 flush——
|
||||
# 否则 stdout 重定向到 cron/后台日志文件时是块缓冲,要攒到 ~4KB 或进程退出才落盘,
|
||||
# 常驻(--loop)时几乎看不到每轮进度。
|
||||
# Windows 控制台按 UTF-8 输出中文/¥
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", line_buffering=True) # type: ignore[attr-defined]
|
||||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
from sqlalchemy import delete, func, select # noqa: E402
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert # noqa: E402
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from app.db.session import SessionLocal, engine # noqa: E402
|
||||
from app.integrations.cities import all_cities # noqa: E402
|
||||
from app.integrations.meituan import MeituanCpsError, _call # noqa: E402
|
||||
from app.models.meituan_coupon import MeituanCoupon # noqa: E402
|
||||
|
||||
# dev 默认 create_engine(echo=True) 会逐条打 SQL,本脚本逐城 upsert 会把日志刷爆;
|
||||
# ETL 不需要 SQL 日志,显式关掉(入库不受影响;线上 prod 本就 echo=False)。
|
||||
engine.echo = False
|
||||
|
||||
# 美团调用偶发错误(本机走代理高并发时的 SSL EOF / 上游 code=5 等)会被 meituan._call 的
|
||||
# logger.exception 打完整 traceback,并发抓取下单轮可刷数十 KB 日志。ETL 自己用 _STATS 统计
|
||||
# 「放弃页数」,无需逐条 traceback,故把美团 logger 压到 CRITICAL(线上直连少见此类错误)。
|
||||
logging.getLogger("shagua.meituan").setLevel(logging.CRITICAL)
|
||||
from app.db.session import SessionLocal
|
||||
from app.integrations.meituan import MeituanCpsError, _call
|
||||
from app.models.meituan_coupon import MeituanCoupon
|
||||
|
||||
CITY_BEIJING = "WKV2HMXUEK634WP64CUCUQGM64"
|
||||
QUERY_PATH = "/cps_open/common/api/v1/query_coupon"
|
||||
PAGE_SIZE = 20
|
||||
MAX_PAGES = 80 # 单路安全上限(搜索 ~52 页、供给 ~70 页)
|
||||
@@ -78,12 +49,7 @@ RETRY = 7
|
||||
# 无 sudo 部署时 data/ 常属 root,cps 写不了会导致每轮 PermissionError、cron 抓不进数据。
|
||||
# 需要指定位置时用环境变量 MEITUAN_ETL_LOCK 覆盖。
|
||||
LOCK_FILE = os.environ.get("MEITUAN_ETL_LOCK") or os.path.join(tempfile.gettempdir(), "meituan_etl.lock")
|
||||
# 多城全量一轮 ~50–60min,锁陈旧阈值放宽到 90min,避免把「正在跑的轮次」误判为残留而接管。
|
||||
LOCK_STALE_SEC = 90 * 60
|
||||
|
||||
DEFAULT_CONCURRENCY = 12 # 并发城市数(实测 15 并发 402 占 3% 可退避消化;12 更稳,3h 窗口充裕)
|
||||
STARTUP_STAGGER = 0.3 # 首批城市启动错峰间隔秒(削平瞬时峰值,实测能压低 402)
|
||||
PRUNE_FAIL_RATIO_MAX = 0.05 # 失败城占比超此值则本轮跳过 prune(避免大面积抓取失败误删库)
|
||||
LOCK_STALE_SEC = 30 * 60 # 锁超过 30min 视为陈旧(进程异常退出残留),自动接管
|
||||
|
||||
SOURCES = [
|
||||
{"code": "search_waimai", "label": "外卖·搜外卖", "kind": "search", "platform": 1, "keyword": "外卖"},
|
||||
@@ -92,44 +58,33 @@ SOURCES = [
|
||||
"platform": 2, "biz_lines": [1, 2, 3, 4]},
|
||||
]
|
||||
|
||||
# 跨线程统计(并发抓取下汇总请求量 / 402 / 放弃页数,供观测限流)
|
||||
_STATS_LOCK = threading.Lock()
|
||||
_STATS = {"req": 0, "r402": 0, "err": 0}
|
||||
|
||||
|
||||
def _bump(key: str) -> None:
|
||||
with _STATS_LOCK:
|
||||
_STATS[key] += 1
|
||||
|
||||
|
||||
# ───────────────────────── 美团调用 ─────────────────────────
|
||||
|
||||
def _call_retry(body: dict) -> dict | None:
|
||||
"""打美团,402/频繁退避重试;其它错误放弃本页。并发下不逐条 print(避免刷屏),计入 _STATS。"""
|
||||
"""打美团,402/频繁退避重试;其它错误打印并放弃本页。"""
|
||||
for a in range(RETRY):
|
||||
_bump("req")
|
||||
try:
|
||||
return _call(QUERY_PATH, body)
|
||||
except MeituanCpsError as e:
|
||||
msg = str(e)
|
||||
if "402" in msg or "频繁" in msg:
|
||||
_bump("r402")
|
||||
time.sleep(2.5 * (a + 1))
|
||||
continue
|
||||
_bump("err")
|
||||
print(f" [warn] meituan: {msg[:80]}")
|
||||
return None
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" [warn] {type(e).__name__}: {str(e)[:60]}")
|
||||
time.sleep(2.0 * (a + 1))
|
||||
_bump("err")
|
||||
return None
|
||||
|
||||
|
||||
def _pull_search(city_id: str, platform: int, keyword: str) -> list[dict]:
|
||||
def _pull_search(platform: int, keyword: str) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
sid = None
|
||||
pg = 1
|
||||
while pg <= MAX_PAGES:
|
||||
body = {"platform": platform, "searchText": keyword, "cityId": city_id, "pageSize": PAGE_SIZE}
|
||||
body = {"platform": platform, "searchText": keyword, "cityId": CITY_BEIJING, "pageSize": PAGE_SIZE}
|
||||
if sid:
|
||||
body["searchId"] = sid
|
||||
else:
|
||||
@@ -147,14 +102,14 @@ def _pull_search(city_id: str, platform: int, keyword: str) -> list[dict]:
|
||||
return rows
|
||||
|
||||
|
||||
def _pull_supply(city_id: str, platform: int, biz_lines: list[int]) -> list[dict]:
|
||||
def _pull_supply(platform: int, biz_lines: list[int]) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
sid = None
|
||||
biz_param = [{"bizLine": b} for b in biz_lines]
|
||||
for _ in range(MAX_PAGES):
|
||||
body = {
|
||||
"multipleSupplyList": [{"platform": platform, "bizLineParamList": biz_param}],
|
||||
"cityId": city_id,
|
||||
"cityId": CITY_BEIJING,
|
||||
"sortField": 2, # 供给查询 sortField 必填;我们入库后本地再排,这里给个默认
|
||||
"pageSize": PAGE_SIZE,
|
||||
}
|
||||
@@ -202,7 +157,7 @@ def _to_cents(yuan) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_item(item: dict, source: dict, city_id: str) -> dict | None:
|
||||
def _parse_item(item: dict, source: dict) -> dict | None:
|
||||
cpd = item.get("couponPackDetail") or {}
|
||||
br = item.get("brandInfo") or {}
|
||||
ci = item.get("commissionInfo") or {}
|
||||
@@ -235,7 +190,7 @@ def _parse_item(item: dict, source: dict, city_id: str) -> dict | None:
|
||||
"source": source["code"],
|
||||
"platform": source["platform"],
|
||||
"biz_line": item.get("bizLine") or cpd.get("bizLine"),
|
||||
"city_id": city_id,
|
||||
"city_id": CITY_BEIJING,
|
||||
"product_view_sign": str(sign)[:128],
|
||||
"sku_view_id": cpd.get("skuViewId"),
|
||||
"name": (name[:256] or None),
|
||||
@@ -255,27 +210,6 @@ def _parse_item(item: dict, source: dict, city_id: str) -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
def _pull_one_city(city: dict, index: int, concurrency: int, stagger: float) -> list[dict]:
|
||||
"""抓单个城市的 3 路券并解析。worker 线程内执行(只抓取+解析,不碰 DB)。
|
||||
|
||||
启动错峰:首批并发的 `concurrency` 个城市按 index 错开首请求,削平瞬时峰值(压低 402)。
|
||||
"""
|
||||
if stagger:
|
||||
time.sleep((index % concurrency) * stagger)
|
||||
cid = city["city_id"]
|
||||
parsed: list[dict] = []
|
||||
for src in SOURCES:
|
||||
if src["kind"] == "search":
|
||||
items = _pull_search(cid, src["platform"], src["keyword"])
|
||||
else:
|
||||
items = _pull_supply(cid, src["platform"], src["biz_lines"])
|
||||
for it in items:
|
||||
p = _parse_item(it, src, cid)
|
||||
if p:
|
||||
parsed.append(p)
|
||||
return parsed
|
||||
|
||||
|
||||
# ───────────────────────── 入库(upsert) ─────────────────────────
|
||||
|
||||
def _upsert(db, rows: list[dict], now: datetime) -> tuple[int, int]:
|
||||
@@ -338,53 +272,30 @@ def _release_lock() -> None:
|
||||
|
||||
# ───────────────────────── 主流程 ─────────────────────────
|
||||
|
||||
def run_once(
|
||||
prune_hours: int = 24,
|
||||
concurrency: int = DEFAULT_CONCURRENCY,
|
||||
stagger: float = STARTUP_STAGGER,
|
||||
cities: list[dict] | None = None,
|
||||
) -> None:
|
||||
def run_once(prune_hours: int = 24) -> None:
|
||||
if not _acquire_lock():
|
||||
print(f"[{datetime.now():%H:%M:%S}] 上一轮还在跑(锁占用),跳过本轮")
|
||||
return
|
||||
t0 = time.time()
|
||||
now = datetime.now(timezone.utc)
|
||||
with _STATS_LOCK:
|
||||
_STATS.update(req=0, r402=0, err=0)
|
||||
cities = cities if cities is not None else all_cities()
|
||||
n_city = len(cities)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
total_up = 0
|
||||
done = 0
|
||||
fails: list[str] = []
|
||||
# 城市级并发抓取(worker 只抓取+解析),主线程逐城串行入库(Session 不跨线程)。
|
||||
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
||||
futs = {
|
||||
pool.submit(_pull_one_city, city, i, concurrency, stagger): city
|
||||
for i, city in enumerate(cities)
|
||||
}
|
||||
for fut in as_completed(futs):
|
||||
city = futs[fut]
|
||||
try:
|
||||
parsed = fut.result()
|
||||
except Exception as e: # noqa: BLE001
|
||||
fails.append(city["name"])
|
||||
print(f" [fail] {city['name']}: {type(e).__name__}: {str(e)[:60]}")
|
||||
continue
|
||||
up, _dup = _upsert(db, parsed, now)
|
||||
total_up += up
|
||||
done += 1
|
||||
if done % 20 == 0 or done == n_city:
|
||||
print(f" [{done}/{n_city}] {city['name']:10} 抓{len(parsed):5} 入库{up:5} "
|
||||
f"(累计入库 {total_up}, 用时 {time.time() - t0:.0f}s)")
|
||||
|
||||
# 清理陈旧券(美团 sign 轮换 / 券下架残留),默认 24h 宽限。两道护栏防误删:
|
||||
# ① total_up>0:本轮 0 入库(疑似上游整体故障,脚本不抛异常只抓回空)时跳过,否则
|
||||
# 会按 last_seen 把全表删空;
|
||||
# ② 失败城占比 ≤5%:大面积城市抓取失败(限流/网络)时跳过,避免误删还在架上的券。
|
||||
fail_ratio = len(fails) / max(1, n_city)
|
||||
if prune_hours and prune_hours > 0 and total_up > 0 and fail_ratio <= PRUNE_FAIL_RATIO_MAX:
|
||||
total = 0
|
||||
for src in SOURCES:
|
||||
ts = time.time()
|
||||
if src["kind"] == "search":
|
||||
items = _pull_search(src["platform"], src["keyword"])
|
||||
else:
|
||||
items = _pull_supply(src["platform"], src["biz_lines"])
|
||||
parsed = [p for p in (_parse_item(it, src) for it in items) if p]
|
||||
up, dup = _upsert(db, parsed, now)
|
||||
total += up
|
||||
print(f" {src['label']:18} 抓{len(items):5} 解析{len(parsed):5} "
|
||||
f"入库{up:5} (本源去重{dup:3}) {time.time() - ts:4.0f}s")
|
||||
# 清理长期未再出现的陈旧券(美团 sign 轮换 / 券下架后的残留),默认 24h 宽限。
|
||||
# 护栏:仅在本轮确有入库(total>0)时才清理。美团整体故障时本轮可能 0 入库
|
||||
# (脚本不抛异常、只是抓回空),若仍照常 prune,连续故障会按 last_seen 把全表删空。
|
||||
if prune_hours and prune_hours > 0 and total > 0:
|
||||
cutoff = now - timedelta(hours=prune_hours)
|
||||
pruned = db.execute(
|
||||
delete(MeituanCoupon).where(MeituanCoupon.last_seen < cutoff)
|
||||
@@ -392,62 +303,36 @@ def run_once(
|
||||
db.commit()
|
||||
if pruned:
|
||||
print(f" 清理陈旧券(>{prune_hours}h 未再出现): {pruned} 条")
|
||||
elif prune_hours and prune_hours > 0:
|
||||
reason = "本轮 0 入库" if total_up == 0 else f"失败城占比 {fail_ratio:.0%}>{PRUNE_FAIL_RATIO_MAX:.0%}"
|
||||
print(f" 跳过 prune({reason},防误删)")
|
||||
|
||||
elif prune_hours and prune_hours > 0 and total == 0:
|
||||
print(" 本轮 0 入库(疑似上游故障),跳过清理以防误删全表")
|
||||
cnt = db.execute(select(func.count()).select_from(MeituanCoupon)).scalar()
|
||||
with _STATS_LOCK:
|
||||
req, r402, err = _STATS["req"], _STATS["r402"], _STATS["err"]
|
||||
fail_tail = (": " + ",".join(fails[:10])) if fails else ""
|
||||
print(f"[{datetime.now():%H:%M:%S}] 本轮完成: {done}/{n_city} 城, 入库 {total_up} 条, "
|
||||
f"表总计 {cnt} 行, 请求 {req}(402 {r402} / 放弃 {err}), "
|
||||
f"失败城 {len(fails)}{fail_tail}, 用时 {time.time() - t0:.0f}s")
|
||||
print(f"[{datetime.now():%H:%M:%S}] 本轮完成: 入库 {total} 条, 表总计 {cnt} 行, "
|
||||
f"用时 {time.time() - t0:.0f}s")
|
||||
finally:
|
||||
db.close()
|
||||
_release_lock()
|
||||
|
||||
|
||||
def _select_cities(limit: int, city_ids: str) -> list[dict]:
|
||||
cities = all_cities()
|
||||
if city_ids.strip():
|
||||
want = {c.strip() for c in city_ids.split(",") if c.strip()}
|
||||
return [c for c in cities if c["city_id"] in want]
|
||||
if limit and limit > 0:
|
||||
return cities[:limit]
|
||||
return cities
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="美团 CPS 券定时抓取入库(全国 359 地级市)")
|
||||
ap = argparse.ArgumentParser(description="美团 CPS 券定时抓取入库")
|
||||
ap.add_argument("--once", action="store_true", help="只跑一轮(默认)")
|
||||
ap.add_argument("--loop", action="store_true", help="循环跑")
|
||||
ap.add_argument("--interval", type=int, default=10800,
|
||||
help="循环间隔秒(默认 10800=3h,对齐每 3h 全量一轮)")
|
||||
ap.add_argument("--interval", type=int, default=600, help="循环间隔秒(默认 600=10min)")
|
||||
ap.add_argument("--prune-hours", type=int, default=24,
|
||||
help="清理超过 N 小时未再出现的陈旧券(默认 24;0=不清理)")
|
||||
ap.add_argument("--concurrency", type=int, default=DEFAULT_CONCURRENCY,
|
||||
help=f"并发城市数(默认 {DEFAULT_CONCURRENCY};实测 15 并发 402 占 3%% 可退避消化)")
|
||||
ap.add_argument("--stagger", type=float, default=STARTUP_STAGGER,
|
||||
help=f"首批城市启动错峰间隔秒(默认 {STARTUP_STAGGER};削平瞬时峰值压低 402)")
|
||||
ap.add_argument("--limit", type=int, default=0, help="只抓前 N 个城市(测试用,0=全部)")
|
||||
ap.add_argument("--city-ids", default="", help="只抓这些 cityId(逗号分隔,测试用,优先于 --limit)")
|
||||
args = ap.parse_args()
|
||||
|
||||
cities = _select_cities(args.limit, args.city_ids)
|
||||
print(f"城市数: {len(cities)} / 并发: {args.concurrency} / 错峰: {args.stagger}s / 间隔: {args.interval}s")
|
||||
|
||||
if args.loop:
|
||||
print(f"循环模式: 每 {args.interval}s 一轮 (Ctrl-C 退出)")
|
||||
while True:
|
||||
try:
|
||||
run_once(args.prune_hours, args.concurrency, args.stagger, cities)
|
||||
run_once(args.prune_hours)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[{datetime.now():%H:%M:%S}] 本轮异常: {type(e).__name__}: {e}")
|
||||
_release_lock()
|
||||
time.sleep(args.interval)
|
||||
else:
|
||||
run_once(args.prune_hours, args.concurrency, args.stagger, cities)
|
||||
run_once(args.prune_hours)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -117,40 +117,6 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N
|
||||
db.close()
|
||||
|
||||
|
||||
def test_update_bool_config(admin_client: TestClient, token: str) -> None:
|
||||
# 提现自动对账开关默认 True
|
||||
items = {
|
||||
i["key"]: i
|
||||
for i in admin_client.get("/admin/api/config", headers=_auth(token)).json()
|
||||
}
|
||||
assert items["withdraw_auto_reconcile_enabled"]["type"] == "bool"
|
||||
assert items["withdraw_auto_reconcile_enabled"]["value"] is True
|
||||
|
||||
# 关掉 → DB 落 False、业务读到 False
|
||||
r = admin_client.patch(
|
||||
"/admin/api/config/withdraw_auto_reconcile_enabled",
|
||||
json={"value": False},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["value"] is False and r.json()["overridden"] is True
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from app.repositories import app_config
|
||||
|
||||
assert app_config.get_value(db, "withdraw_auto_reconcile_enabled") is False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# bool 项不接受非布尔值
|
||||
assert admin_client.patch(
|
||||
"/admin/api/config/withdraw_auto_reconcile_enabled",
|
||||
json={"value": 1},
|
||||
headers=_auth(token),
|
||||
).status_code == 400
|
||||
|
||||
|
||||
def test_config_validation(admin_client: TestClient, token: str) -> None:
|
||||
# 签到档位长度≠7
|
||||
assert admin_client.patch(
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"""平台公开端点测试(不鉴权):/api/v1/platform/flags 等。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_flags_default_comparing_ad_enabled(client: TestClient) -> None:
|
||||
"""空配置库下,/flags 返回 comparing_ad_enabled 的默认值 True;不需要鉴权。"""
|
||||
r = client.get("/api/v1/platform/flags")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert "comparing_ad_enabled" in body
|
||||
assert body["comparing_ad_enabled"] is True
|
||||
@@ -16,13 +16,11 @@ _SECRET = "test-internal-secret-only-for-pytest"
|
||||
|
||||
|
||||
def _mk_row(db, *, trace_id, name_meituan=None, name_taobao=None,
|
||||
id_taobao=None, deeplink=None, lat=None, lng=None,
|
||||
name_jd=None, id_jd=None, jd_deeplink=None):
|
||||
id_taobao=None, deeplink=None, lat=None, lng=None):
|
||||
row = StoreMapping(
|
||||
trace_id=trace_id, business_type="food",
|
||||
name_meituan=name_meituan, name_taobao=name_taobao,
|
||||
id_taobao=id_taobao, taobao_deeplink=deeplink, lat=lat, lng=lng,
|
||||
name_jd=name_jd, id_jd=id_jd, jd_deeplink=jd_deeplink,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
@@ -112,45 +110,9 @@ def test_invalidate_endpoint_marks_and_lookup_miss(client, db, monkeypatch):
|
||||
assert "taobao" not in lk.json()
|
||||
|
||||
|
||||
def test_invalidate_endpoint_unsupported_platform_noop(client, monkeypatch):
|
||||
# 当前支持 taobao/jd;其它平台(如 meituan)no-op 返 affected=0
|
||||
def test_invalidate_endpoint_non_taobao_noop(client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
|
||||
r = client.post("/internal/store-mapping/invalidate",
|
||||
json={"platform": "meituan", "shop_id": "X"},
|
||||
json={"platform": "jd", "shop_id": "X"},
|
||||
headers={"X-Internal-Secret": _SECRET})
|
||||
assert r.status_code == 200 and r.json()["affected"] == 0
|
||||
|
||||
|
||||
# ---------- 京东(对称淘宝)----------
|
||||
|
||||
def test_mark_jd_invalid_marks_all_rows_with_store_id(db):
|
||||
_mk_row(db, trace_id="j1", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl_a")
|
||||
_mk_row(db, trace_id="j2", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl_b")
|
||||
_mk_row(db, trace_id="j3", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JGOOD", jd_deeplink="dl_g")
|
||||
assert repo.mark_jd_deeplink_invalid(db, "JBAD") == 2
|
||||
assert repo.mark_jd_deeplink_invalid(db, "JBAD") == 0 # 幂等
|
||||
rows = {r.trace_id: r for r in db.query(StoreMapping).all()}
|
||||
assert rows["j1"].jd_deeplink_invalid_at is not None
|
||||
assert rows["j2"].jd_deeplink_invalid_at is not None
|
||||
assert rows["j3"].jd_deeplink_invalid_at is None # 不同 storeId 不动
|
||||
|
||||
|
||||
def test_lookup_filters_invalid_jd(db):
|
||||
_mk_row(db, trace_id="j1", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl")
|
||||
assert repo.lookup_nearest(db, "meituan", "兰州拉面")["jd"]["store_id"] == "JBAD"
|
||||
repo.mark_jd_deeplink_invalid(db, "JBAD")
|
||||
assert "jd" not in repo.lookup_nearest(db, "meituan", "兰州拉面")
|
||||
|
||||
|
||||
def test_invalidate_endpoint_jd_marks_and_lookup_miss(client, db, monkeypatch):
|
||||
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
|
||||
_mk_row(db, trace_id="j1", name_meituan="店J", name_jd="店J", id_jd="JBADX", jd_deeplink="dl")
|
||||
_mk_row(db, trace_id="j2", name_meituan="店J", name_jd="店J", id_jd="JBADX", jd_deeplink="dl2")
|
||||
r = client.post("/internal/store-mapping/invalidate",
|
||||
json={"platform": "jd", "shop_id": "JBADX"},
|
||||
headers={"X-Internal-Secret": _SECRET})
|
||||
assert r.status_code == 200 and r.json()["affected"] == 2
|
||||
lk = client.get("/internal/store-mapping/lookup",
|
||||
params={"source_platform": "meituan", "name": "店J"},
|
||||
headers={"X-Internal-Secret": _SECRET})
|
||||
assert "jd" not in lk.json()
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""从美团「城市字典」Excel 生成随仓库的 JSON(app/integrations/data/meituan_cities.json)。
|
||||
|
||||
本地一次性工具:美团每年更新城市字典时,把新 Excel 放进来重跑即可。
|
||||
Excel 不入库、不进仓库(二进制、需 openpyxl);生成的 JSON 随仓库提交,部署到服务器后
|
||||
ETL(scripts/pull_meituan_coupons.py)直接读它遍历全部城市。
|
||||
|
||||
字典口径:359 个地级市,经实测一个地级市 cityId 已覆盖其下辖县级市(如徐州→邳州/新沂),
|
||||
故无需区县层级。
|
||||
|
||||
用法:
|
||||
python tools/gen_meituan_cities.py ["城市字典xxx.xlsx" 路径]
|
||||
(不传则用默认 e:\\codes\\城市字典2025 (1).xlsx)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import openpyxl
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
DEFAULT_EXCEL = r"e:\codes\城市字典2025 (1).xlsx"
|
||||
OUT = os.path.join(ROOT, "app", "integrations", "data", "meituan_cities.json")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
excel = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_EXCEL
|
||||
wb = openpyxl.load_workbook(excel, data_only=True)
|
||||
ws = wb.worksheets[0]
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
header = rows[0]
|
||||
|
||||
cities = []
|
||||
seen = set()
|
||||
for r in rows[1:]:
|
||||
cid = str(r[0]).strip() if r[0] else ""
|
||||
name = str(r[1]).strip() if len(r) > 1 and r[1] else ""
|
||||
prov = str(r[2]).strip() if len(r) > 2 and r[2] else ""
|
||||
if not cid or cid in seen:
|
||||
continue
|
||||
seen.add(cid)
|
||||
cities.append({"city_id": cid, "name": name, "province": prov})
|
||||
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
with open(OUT, "w", encoding="utf-8") as f:
|
||||
json.dump(cities, f, ensure_ascii=False, indent=1)
|
||||
|
||||
print(f"表头: {header}")
|
||||
print(f"写出 {len(cities)} 城 → {OUT}")
|
||||
print("样例:", cities[:3])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user