Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 988576e14b | |||
| d94065c5af | |||
| 0bddce516f | |||
| 90fe6d6aa7 | |||
| 47d74273c9 |
@@ -0,0 +1,58 @@
|
||||
"""coupon daily completion table(今日跑完整轮领券 → 首页置灰源)
|
||||
|
||||
Revision ID: coupon_daily_completion
|
||||
Revises: f8d3b1e60a27
|
||||
Create Date: 2026-06-10 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "coupon_daily_completion"
|
||||
down_revision: str | Sequence[str] | None = "f8d3b1e60a27"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"coupon_daily_completion",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("device_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("complete_date", sa.Date(), nullable=False),
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("device_id", "complete_date", name="uq_coupon_completion_device_date"),
|
||||
)
|
||||
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_coupon_daily_completion_user_id"), ["user_id"], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_coupon_daily_completion_trace_id"), ["trace_id"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_trace_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_user_id"))
|
||||
op.drop_table("coupon_daily_completion")
|
||||
@@ -13,6 +13,7 @@ from contextlib import asynccontextmanager
|
||||
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.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
|
||||
@@ -86,3 +87,4 @@ admin_app.include_router(feedback_router)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""看广告金币审计:复算 expected_coin 并与实发对比。
|
||||
|
||||
只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移):
|
||||
- 看视频:每条 granted = 1 份,第 N 份 = 当日该用户 granted 的 reward_video 顺序号
|
||||
(与 ad_reward.grant_ad_reward 里 `_granted_today + 1` 一致)。
|
||||
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 当日该用户已 granted 份数累计
|
||||
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致)。
|
||||
|
||||
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS
|
||||
|
||||
|
||||
def _reward_video_rows(
|
||||
db: Session, *, date: str, user_id: int | None
|
||||
) -> list[dict]:
|
||||
"""看视频记录复算。按 (user_id, created_at) 升序还原当日第 N 份。"""
|
||||
stmt = (
|
||||
select(AdRewardRecord)
|
||||
.where(
|
||||
AdRewardRecord.reward_date == date,
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
)
|
||||
.order_by(AdRewardRecord.user_id, AdRewardRecord.created_at)
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdRewardRecord.user_id == user_id)
|
||||
|
||||
granted_n: dict[int, int] = {} # user_id -> 已 granted 份数
|
||||
rows: list[dict] = []
|
||||
for rec in db.execute(stmt).scalars():
|
||||
if rec.status == "granted":
|
||||
nth = granted_n.get(rec.user_id, 0) + 1
|
||||
granted_n[rec.user_id] = nth
|
||||
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
|
||||
rows.append({
|
||||
"scene": "reward_video",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
|
||||
"units": 1,
|
||||
"lt_index_start": nth,
|
||||
"lt_index_end": nth,
|
||||
"lt_factor_start": rewards.ad_lt_factor(nth),
|
||||
"lt_factor_end": rewards.ad_lt_factor(nth),
|
||||
"expected_coin": expected,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": expected == rec.coin,
|
||||
})
|
||||
else:
|
||||
# capped / ecpm_missing:不发金币,校验实发确为 0
|
||||
rows.append({
|
||||
"scene": "reward_video",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"ecpm_factor": None,
|
||||
"units": 1,
|
||||
"lt_index_start": None,
|
||||
"lt_index_end": None,
|
||||
"lt_factor_start": None,
|
||||
"lt_factor_end": None,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": rec.coin == 0,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
||||
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用当日累计份数。"""
|
||||
stmt = (
|
||||
select(AdFeedRewardRecord)
|
||||
.where(AdFeedRewardRecord.reward_date == date)
|
||||
.order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at)
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
|
||||
|
||||
granted_units: dict[int, int] = {} # user_id -> 已 granted 份数累计
|
||||
rows: list[dict] = []
|
||||
for rec in db.execute(stmt).scalars():
|
||||
if rec.status == "granted":
|
||||
existing = granted_units.get(rec.user_id, 0)
|
||||
units = rec.unit_count
|
||||
expected = sum(
|
||||
rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset)
|
||||
for offset in range(1, units + 1)
|
||||
)
|
||||
granted_units[rec.user_id] = existing + units
|
||||
start = existing + 1 if units > 0 else None
|
||||
end = existing + units if units > 0 else None
|
||||
rows.append({
|
||||
"scene": "feed",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
|
||||
"units": units,
|
||||
"lt_index_start": start,
|
||||
"lt_index_end": end,
|
||||
"lt_factor_start": rewards.ad_lt_factor(start) if start else None,
|
||||
"lt_factor_end": rewards.ad_lt_factor(end) if end else None,
|
||||
"expected_coin": expected,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": expected == rec.coin,
|
||||
})
|
||||
else:
|
||||
rows.append({
|
||||
"scene": "feed",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"ecpm_factor": None,
|
||||
"units": rec.unit_count,
|
||||
"lt_index_start": None,
|
||||
"lt_index_end": None,
|
||||
"lt_factor_start": None,
|
||||
"lt_factor_end": None,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": rec.coin == 0,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def ad_coin_audit(
|
||||
db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int
|
||||
) -> list[dict]:
|
||||
"""返回当日发奖复算明细,按 created_at 倒序(最新在前)截断到 limit。
|
||||
|
||||
scene: None=两类都要 / "reward_video" / "feed"。
|
||||
份序号在截断前已基于全天数据算好,故 limit 只影响展示条数、不影响 expected 复算正确性。
|
||||
"""
|
||||
rows: list[dict] = []
|
||||
if scene in (None, "reward_video"):
|
||||
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
|
||||
if scene in (None, "feed"):
|
||||
rows.extend(_feed_rows(db, date=date, user_id=user_id))
|
||||
rows.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def formula_snapshot() -> dict:
|
||||
"""当前公式参数快照(给前端展示规则参照)。直接读 rewards 常量,与发奖同源。"""
|
||||
return {
|
||||
"coin_per_yuan": rewards.COIN_PER_YUAN,
|
||||
"feed_unit_seconds": FEED_REWARD_UNIT_SECONDS,
|
||||
"ecpm_factor_tiers": [list(t) for t in rewards.AD_ECPM_FACTOR_TABLE],
|
||||
"lt_factor_tiers": [list(t) for t in rewards.AD_LT_FACTOR_TABLE],
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""admin 看广告金币审计:只读对账,核对发奖金币是否按公式计算。
|
||||
|
||||
任意已登录 admin 可看(只读,不涉及资金操作)。复算逻辑在 app/admin/repositories/ad_audit.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import ad_audit
|
||||
from app.admin.schemas.ad_audit import AdCoinAuditOut, AdCoinAuditRow, AdCoinFormulaOut
|
||||
from app.core.rewards import cn_today
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/ad-coin-audit",
|
||||
tags=["admin-ad-coin-audit"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=AdCoinAuditOut, summary="看广告金币公式审计(复算对比)")
|
||||
def get_ad_coin_audit(
|
||||
db: AdminDb,
|
||||
date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None,
|
||||
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
|
||||
scene: Annotated[
|
||||
str | None, Query(description="reward_video / feed;不传=两类都要")
|
||||
] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=500)] = 100,
|
||||
) -> AdCoinAuditOut:
|
||||
audit_date = date or cn_today().isoformat()
|
||||
rows = ad_audit.ad_coin_audit(
|
||||
db, date=audit_date, user_id=user_id, scene=scene, limit=limit,
|
||||
)
|
||||
items = [AdCoinAuditRow(**r) for r in rows]
|
||||
return AdCoinAuditOut(
|
||||
date=audit_date,
|
||||
formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()),
|
||||
total=len(items),
|
||||
mismatch_count=sum(1 for it in items if not it.matched),
|
||||
items=items,
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""看广告金币审计 schemas。
|
||||
|
||||
只读对账视图:把"看视频"(ad_reward_record)和"信息流"(ad_feed_reward_record)两类发奖记录,
|
||||
用与正式发奖相同的公式 [app.core.rewards.calculate_ad_reward_coin] 复算一遍 expected_coin,
|
||||
和实际入账的 actual_coin 对比,核对金币公式是否生效。字段 snake_case、金额按金币整数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AdCoinAuditRow(BaseModel):
|
||||
"""单条发奖记录的复算明细。"""
|
||||
|
||||
scene: str = Field(..., description="reward_video(看视频) / feed(信息流)")
|
||||
record_id: int = Field(..., description="对应记录表主键")
|
||||
user_id: int
|
||||
created_at: datetime
|
||||
status: str = Field(..., description="granted / capped / ecpm_missing")
|
||||
ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示,SDK getEcpm 原值)")
|
||||
ecpm_factor: float | None = Field(None, description="因子1(eCPM 档)")
|
||||
units: int = Field(..., description="折算份数:看视频恒为 1;信息流 = 满 10 秒的份数")
|
||||
lt_index_start: int | None = Field(None, description="本条占用的当日第几份(起)")
|
||||
lt_index_end: int | None = Field(None, description="本条占用的当日第几份(止);看视频 = 起")
|
||||
lt_factor_start: float | None = Field(None, description="因子2(LT)起值")
|
||||
lt_factor_end: float | None = Field(None, description="因子2(LT)止值;看视频 = 起值")
|
||||
expected_coin: int = Field(..., description="按公式复算应发金币")
|
||||
actual_coin: int = Field(..., description="实际入账金币")
|
||||
matched: bool = Field(..., description="复算与实发是否一致(capped/ecpm_missing 校验是否确为 0)")
|
||||
|
||||
|
||||
class AdCoinFormulaOut(BaseModel):
|
||||
"""当前金币公式参数(给前端展示规则参照)。"""
|
||||
|
||||
description: str = Field(
|
||||
"eCPM元 = getEcpm分 ÷ 100;单份金币 = round(eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan);"
|
||||
"因子1 按 eCPM元 判档(阈值 100/200/400 元)",
|
||||
description="公式说明",
|
||||
)
|
||||
coin_per_yuan: int = Field(..., description="金币:元 汇率")
|
||||
ecpm_unit: str = Field("分/千次展示(SDK getEcpm 原值)", description="eCPM 口径")
|
||||
feed_unit_seconds: int = Field(..., description="信息流每多少秒折 1 份")
|
||||
# [(因子值, 区间下限, 区间上限或 null)]
|
||||
ecpm_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子1 档位表")
|
||||
lt_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子2 LT 档位表")
|
||||
|
||||
|
||||
class AdCoinAuditOut(BaseModel):
|
||||
"""审计响应:公式参照 + 命中条数 + 明细。"""
|
||||
|
||||
date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)")
|
||||
formula: AdCoinFormulaOut
|
||||
total: int = Field(..., description="返回的明细条数")
|
||||
mismatch_count: int = Field(..., description="其中 matched=false 的条数(=0 说明公式全部生效)")
|
||||
items: list[AdCoinAuditRow]
|
||||
+10
-2
@@ -304,10 +304,18 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
else:
|
||||
# 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
|
||||
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
|
||||
ad_session_id = payload.ad_session_id if payload is not None else None
|
||||
ecpm_val = "200"
|
||||
if ad_session_id:
|
||||
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id)
|
||||
if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0:
|
||||
ecpm_val = ecpm_rec.ecpm_raw
|
||||
try:
|
||||
rec = crud_ad.grant_ad_reward(
|
||||
db, user.id, trans_id, ecpm="200", reward_name="测试发奖",
|
||||
raw="client debug test-grant ecpm=200",
|
||||
db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
|
||||
reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
|
||||
)
|
||||
except crud_ad.UnknownUserError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
|
||||
|
||||
+42
-1
@@ -23,7 +23,11 @@ from app.core.config import settings
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import coupon_state as coupon_repo
|
||||
from app.schemas.coupon_state import CouponPromptDismissIn, CouponPromptShouldShowOut
|
||||
from app.schemas.coupon_state import (
|
||||
CouponCompletedTodayOut,
|
||||
CouponPromptDismissIn,
|
||||
CouponPromptShouldShowOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.coupon")
|
||||
|
||||
@@ -76,6 +80,14 @@ def _record_claims_blocking(
|
||||
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
|
||||
|
||||
|
||||
def _mark_completed_blocking(
|
||||
device_id: str, user_id: int | None, trace_id: str | None
|
||||
) -> None:
|
||||
"""独立 session 写"今日已跑完整轮"(到 done 帧那刻调,best-effort 不阻塞领券)。"""
|
||||
with SessionLocal() as db:
|
||||
coupon_repo.mark_completed_today(db, device_id, user_id, trace_id)
|
||||
|
||||
|
||||
@router.post("/step", summary="领券任务步进 (透传到 pricebot)")
|
||||
async def coupon_step(
|
||||
request: Request,
|
||||
@@ -160,6 +172,19 @@ async def coupon_step(
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("coupon claim write failed: %s", e)
|
||||
|
||||
# 整轮跑完(done 帧)→ 记一条"今日已完成",首页「去领取」卡据此置灰。
|
||||
# pricebot 把中途单券 done 改写成 wait+continue=true,只有整套全跑完那帧才保留
|
||||
# command=="done"(见 pricebot main.py),故 done 已等价"整轮完成"(用户决策 A 方案:
|
||||
# 到 done 即算,不管单券成败),无需再判 continue。写库失败绝不连累领券返回。
|
||||
action = resp_json.get("action") or {}
|
||||
if device_id and action.get("command") == "done":
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
_mark_completed_blocking, device_id, user_id, trace_id
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("coupon completion write failed: %s", e)
|
||||
|
||||
return resp_json
|
||||
|
||||
|
||||
@@ -195,3 +220,19 @@ def coupon_prompt_reset(payload: CouponPromptDismissIn, db: DbSession) -> dict[s
|
||||
开发设置「重置今日领券弹窗状态」按钮调。MVP 不鉴权,按 device_id。"""
|
||||
coupon_repo.reset_today_engagement(db, payload.device_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/completed-today",
|
||||
response_model=CouponCompletedTodayOut,
|
||||
summary="这台设备今天是否已跑完整轮领券(到 done 帧)",
|
||||
)
|
||||
def coupon_completed_today(
|
||||
device_id: str, db: DbSession
|
||||
) -> CouponCompletedTodayOut:
|
||||
"""今天这台设备已跑完整轮(到 done)→ completed=true。客户端据此把首页「去领取」
|
||||
卡置灰、不可点(用户决策 A 方案:到 done 即算,不管单券成败)。判断维度 device_id
|
||||
必须与领券循环上报的一致(客户端两端都用 ANDROID_ID)。"""
|
||||
return CouponCompletedTodayOut(
|
||||
completed=coupon_repo.has_completed_today(db, device_id)
|
||||
)
|
||||
|
||||
+17
-5
@@ -98,7 +98,11 @@ INVITE_FP_WINDOW_DAYS: int = 7
|
||||
|
||||
|
||||
# ===== 看激励视频 / 信息流广告发金币 =====
|
||||
# 金币数值体系约定:eCPM 单位按"元/千次展示"处理,单次收入 = eCPM / 1000 元。
|
||||
# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
|
||||
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
|
||||
# 因子1 档位阈值按【元/千次】定(100/200/400 元 = ¥100/¥200/¥400 CPM,产品口径 2026-06-09)。
|
||||
# 注:真实 eCPM 一般 <¥100 CPM,故多落最低档 0.1,高档基本不触发——这是产品有意的取舍。
|
||||
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次)。
|
||||
AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
|
||||
(0.1, 0, 100),
|
||||
(0.3, 101, 200),
|
||||
@@ -114,8 +118,8 @@ AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
|
||||
)
|
||||
|
||||
|
||||
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
|
||||
"""解析 eCPM 原始值。当前产品口径:SDK 返回值按"元/千次展示"处理。"""
|
||||
def parse_ecpm_fen(ecpm: str | int | float | None) -> float:
|
||||
"""解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0。"""
|
||||
if ecpm is None:
|
||||
return 0.0
|
||||
try:
|
||||
@@ -125,8 +129,14 @@ def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
|
||||
return max(0.0, value)
|
||||
|
||||
|
||||
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
|
||||
"""eCPM 转成元(getEcpm 原值是分,÷100)。因子档位判定与收益换算都用元。"""
|
||||
return parse_ecpm_fen(ecpm) / 100.0
|
||||
|
||||
|
||||
def ad_ecpm_factor(ecpm_yuan: float) -> float:
|
||||
"""eCPM 档位因子:0-100=0.1,101-200=0.3,201-400=0.4,>400=0.6。"""
|
||||
"""eCPM 档位因子(阈值单位=元/千次):≤100=0.1,101-200=0.3,201-400=0.4,>400=0.6。
|
||||
产品口径(2026-06-09):阈值按元判档;真实 eCPM(<¥100 CPM)多落最低档 0.1。"""
|
||||
if ecpm_yuan > 400:
|
||||
return 0.6
|
||||
if ecpm_yuan > 200:
|
||||
@@ -148,7 +158,9 @@ def ad_lt_factor(today_count_after_this: int) -> float:
|
||||
def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int:
|
||||
"""按金币数值体系计算单份广告奖励金币。
|
||||
|
||||
单次奖励(元)=eCPM/1000 × 因子1(eCPM 档) × 因子2(LT);再按 1 元=10000 金币取整。
|
||||
eCPM 是穿山甲 getEcpm 原值,单位【分/千次展示】;先 ÷100 转成元(因子判档 + 收益换算都用元)。
|
||||
单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT);
|
||||
再按 1 元=10000 金币取整。
|
||||
"""
|
||||
ecpm_yuan = parse_ecpm_yuan(ecpm)
|
||||
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this)
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.models.comparison import ComparisonRecord # noqa: F401
|
||||
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
|
||||
from app.models.coupon_state import ( # noqa: F401
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
)
|
||||
from app.models.feedback import Feedback # noqa: F401
|
||||
|
||||
@@ -35,7 +35,7 @@ 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)
|
||||
# 客户端上报的 eCPM 原始字符串(单位:元/千次展示,原样存)
|
||||
# 客户端上报的 eCPM 原始字符串(单位:分/千次展示,SDK getEcpm 原值,原样存)
|
||||
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
# 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较)
|
||||
report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
|
||||
|
||||
@@ -93,6 +93,49 @@ class CouponClaimRecord(Base):
|
||||
)
|
||||
|
||||
|
||||
class CouponDailyCompletion(Base):
|
||||
"""按 (device, 自然日) 记"今天是否已跑完整轮领券(到 done 帧)"——首页置灰源。
|
||||
|
||||
与 engagement 区别:engagement = 用户表达过意向(点了一键领取/拒绝即记,不管跑没跑完);
|
||||
completion = 这一轮真跑到了 done(整套流程走完)。首页「去领取」卡据此置灰:跑完了
|
||||
今天就不能再领。判断口径(用户决策 2026-06-10 A 方案):**到 done 即算**,不管单券
|
||||
成败(失败/跳过常是无障碍/环境问题,重复点也补不回来)。判断维度 device_id,与
|
||||
engagement/claim 一致。
|
||||
"""
|
||||
|
||||
__tablename__ = "coupon_daily_completion"
|
||||
__table_args__ = (
|
||||
# 一台设备一天一条:今天跑完过就置灰。
|
||||
UniqueConstraint(
|
||||
"device_id", "complete_date",
|
||||
name="uq_coupon_completion_device_date",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
|
||||
# Asia/Shanghai 自然日。
|
||||
complete_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
# 哪次任务跑到 done,回指 pricebot work_logs / 排查。
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<CouponDailyCompletion device={self.device_id} "
|
||||
f"date={self.complete_date}>"
|
||||
)
|
||||
|
||||
|
||||
class CouponPromptEngagement(Base):
|
||||
"""按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。"""
|
||||
|
||||
|
||||
@@ -13,7 +13,11 @@ from sqlalchemy import delete, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.coupon_state import CouponClaimRecord, CouponPromptEngagement
|
||||
from app.models.coupon_state import (
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.coupon_state")
|
||||
|
||||
@@ -78,6 +82,47 @@ def reset_today_engagement(db: Session, device_id: str) -> int:
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
# ===== 今日跑完整轮(coupon_daily_completion)=====
|
||||
|
||||
def has_completed_today(db: Session, device_id: str) -> bool:
|
||||
"""这台设备今天是否已跑完整轮领券(到 done 帧)。有 = 首页置灰、不能再领。"""
|
||||
row = db.execute(
|
||||
select(CouponDailyCompletion.id).where(
|
||||
CouponDailyCompletion.device_id == device_id,
|
||||
CouponDailyCompletion.complete_date == today_cn(),
|
||||
)
|
||||
).first()
|
||||
return row is not None
|
||||
|
||||
|
||||
def mark_completed_today(
|
||||
db: Session, device_id: str, user_id: int | None, trace_id: str | None = None
|
||||
) -> None:
|
||||
"""记今日已跑完整轮。(device, 今天) 唯一,幂等 upsert。到 done 即记,不管单券成败。"""
|
||||
today = today_cn()
|
||||
row = db.execute(
|
||||
select(CouponDailyCompletion).where(
|
||||
CouponDailyCompletion.device_id == device_id,
|
||||
CouponDailyCompletion.complete_date == today,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is not None:
|
||||
if user_id is not None:
|
||||
row.user_id = user_id
|
||||
if trace_id is not None:
|
||||
row.trace_id = trace_id
|
||||
else:
|
||||
db.add(CouponDailyCompletion(
|
||||
device_id=device_id, user_id=user_id,
|
||||
complete_date=today, trace_id=trace_id,
|
||||
))
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。
|
||||
db.rollback()
|
||||
|
||||
|
||||
# ===== 领券记录(coupon_claim_record)=====
|
||||
|
||||
def record_claims(
|
||||
|
||||
+8
-3
@@ -46,11 +46,11 @@ class AdRewardStatusOut(BaseModel):
|
||||
class EcpmReportIn(BaseModel):
|
||||
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
|
||||
|
||||
user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串,后端按元/千次展示处理。
|
||||
user_id 不在 body 里——由 JWT 取(Bearer),防伪造。ecpm 原样上报字符串,后端按分/千次展示处理(SDK getEcpm 原值,非元)。
|
||||
"""
|
||||
|
||||
ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等")
|
||||
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按元/千次展示处理")
|
||||
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按分/千次展示处理(SDK getEcpm 原值,非元)")
|
||||
ad_session_id: str | None = Field(
|
||||
None, min_length=8, max_length=64,
|
||||
description="客户端生成的一次广告会话 id;激励视频 S2S extra 会透传同值",
|
||||
@@ -89,6 +89,11 @@ class TestGrantIn(BaseModel):
|
||||
"reward_video",
|
||||
description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)",
|
||||
)
|
||||
ad_session_id: str | None = Field(
|
||||
None, min_length=8, max_length=64,
|
||||
description="本次广告会话 id(与 ecpm-report 同值)。reward_video 场景下据此查回客户端"
|
||||
"已上报的真实 eCPM 来按公式发奖;查不到或 eCPM≤0 时兜底 200,保证本地联调仍出非零金币",
|
||||
)
|
||||
|
||||
|
||||
class TestGrantOut(BaseModel):
|
||||
@@ -119,7 +124,7 @@ class FeedRewardIn(BaseModel):
|
||||
ad_session_id: str | None = Field(
|
||||
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
|
||||
)
|
||||
ecpm: str = Field(..., description="本条信息流广告 eCPM,按元/千次展示处理")
|
||||
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="实际展示代码位")
|
||||
|
||||
@@ -19,3 +19,9 @@ class CouponPromptShouldShowOut(BaseModel):
|
||||
"""切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。"""
|
||||
|
||||
should_show: bool
|
||||
|
||||
|
||||
class CouponCompletedTodayOut(BaseModel):
|
||||
"""这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。"""
|
||||
|
||||
completed: bool
|
||||
|
||||
+27
-15
@@ -62,11 +62,11 @@
|
||||
<div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div>
|
||||
</div>
|
||||
|
||||
<button class="btn" id="dlbtn">⬇️ 下载安装包</button>
|
||||
<div class="hint" id="hint">Android 安卓版 · 安装包约 24 MB</div>
|
||||
<button class="btn" id="dlbtn">🏪 打开应用商店下载</button>
|
||||
<div class="hint" id="hint">Android 安卓版 · 应用商店安全下载</div>
|
||||
|
||||
<div class="foot">
|
||||
安装时如提示「未知来源」,请允许后继续安装。<br>
|
||||
将前往应用商店下载,安全放心。<br>
|
||||
本页为内部测试页。
|
||||
</div>
|
||||
|
||||
@@ -82,16 +82,18 @@
|
||||
<div class="wxsteps">
|
||||
<div><span class="n">1</span>点右上角的 ··· 菜单</div>
|
||||
<div><span class="n">2</span>选择「在浏览器打开」</div>
|
||||
<div><span class="n">3</span>在浏览器里点「下载安装包」</div>
|
||||
<div><span class="n">3</span>在浏览器里按提示去应用商店下载</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="closebar" id="wxclose">我知道了 ✕</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// APK 下载地址跟随当前页面 origin:本地测试时页面由笔记本 app-server(LAN:8770)托管 →
|
||||
// 下载也走 LAN;生产由 app-api.shaguabijia.com 托管 → 下载走生产域名。无需按机器改 IP。
|
||||
var APK_URL = location.origin + "/media/shaguabijia.apk";
|
||||
// 应用商店跳转:用包名(App 唯一标识 = 身份证号)定位到傻瓜比价的商店下载页。
|
||||
// 主:market:// 唤起手机自带应用市场(华为/小米/OV);兜底:应用宝网页(任何浏览器都能开)。
|
||||
var PKG = "com.jishisongfu.shaguabijia";
|
||||
var MARKET_URL = "market://details?id=" + PKG;
|
||||
var YYB_URL = "https://a.app.qq.com/o/simple.jsp?pkgname=" + PKG;
|
||||
var ua = navigator.userAgent || "";
|
||||
var isWeChat = /MicroMessenger/i.test(ua);
|
||||
var isIOS = /iPhone|iPad|iPod/i.test(ua);
|
||||
@@ -138,12 +140,20 @@
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
// 确保剪贴板写完(或最多等 400ms 兜底,防写入卡住)再触发下载。
|
||||
function startDownload() {
|
||||
var fired = false;
|
||||
function go() { if (!fired) { fired = true; window.location.href = APK_URL; } }
|
||||
copyInviteCode().then(go);
|
||||
setTimeout(go, 400);
|
||||
// 跳应用商店:先尝试 market:// 唤起手机自带商店;若 2.5s 内页面没切到后台
|
||||
//(= 没有商店接管 market://),兜底跳应用宝网页下载页,不让用户卡死。
|
||||
function openStore() {
|
||||
var jumped = false;
|
||||
function markJumped() { jumped = true; } // 页面切到后台 = 商店已唤起
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.hidden) jumped = true;
|
||||
});
|
||||
window.addEventListener("pagehide", markJumped);
|
||||
window.addEventListener("blur", markJumped);
|
||||
window.location.href = MARKET_URL; // 唤起自带应用市场
|
||||
setTimeout(function () {
|
||||
if (!jumped) window.location.href = YYB_URL; // 没唤起 → 应用宝网页兜底
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
var hint = document.getElementById("hint");
|
||||
@@ -160,8 +170,10 @@
|
||||
document.getElementById("dlbtn").addEventListener("click", function(){
|
||||
if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器
|
||||
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
|
||||
// 普通浏览器:先把邀请码写进剪贴板(写完或 400ms 兜底),再下载 APK(首启读回完成归因)
|
||||
startDownload();
|
||||
// 安卓浏览器:先把邀请码写进剪贴板(legacyCopy 同步写、最可靠),再跳应用商店。
|
||||
// 剪贴板供 App 首启归因;链路长易丢时由指纹兜底(已上报 landing-track)接住。
|
||||
copyInviteCode();
|
||||
openStore();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
| A24 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
|
||||
| A25 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
|
||||
| A26 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) |
|
||||
| A27 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
|
||||
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
|
||||
|
||||
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|---|---|---|---|
|
||||
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 |
|
||||
| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 |
|
||||
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,后端按“元/千次展示”参与金币公式 |
|
||||
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,单位是**分/千次展示**(非元),后端 ÷100 转元参与金币公式 |
|
||||
| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` |
|
||||
| `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) |
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|---|---|---:|---|
|
||||
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
|
||||
| `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 |
|
||||
| `ecpm` | string | 是 | 本条信息流广告 eCPM,按“元/千次展示”处理 |
|
||||
| `ecpm` | string | 是 | 本条信息流广告 eCPM(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) |
|
||||
| `duration_seconds` | int | 是 | 实际展示/播放秒数 |
|
||||
| `adn` | string\|null | 否 | 实际投放 ADN |
|
||||
| `slot_id` | string\|null | 否 | 实际展示代码位 |
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。`reward_video`=普通激励视频;`signin_boost`=签到膨胀 |
|
||||
| `ad_session_id` | string(8~64) \| null | 否 | null | 本次广告会话 id(与 [ecpm-report](./ad-ecpm-report.md) 同值)。**仅 `reward_video` 场景生效**:据此查回客户端已上报的真实 eCPM,走与正式发奖相同的公式发奖;查不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍出非零金币 |
|
||||
|
||||
## 出参
|
||||
响应 `200`:`TestGrantOut`
|
||||
@@ -30,4 +31,6 @@
|
||||
## 说明
|
||||
没公网、穿山甲 S2S 回调打不到本地时,debug 客户端看完广告后调它,直接走与 [ad-pangle-callback](./ad-pangle-callback.md) 相同的发奖逻辑(每次新 `trans_id`,幂等 + 每日上限/今日膨胀一次)。
|
||||
|
||||
`reward_scene=reward_video` 时按上面 `ad_session_id` 查回的真实 eCPM 走金币公式发奖(取不到兜底 200)——便于本地用 [admin 金币审计](./admin-ad-coin-audit.md) 核对「看广告→金币」是否按公式计算。
|
||||
|
||||
`reward_scene=signin_boost` 时复用签到膨胀业务规则:必须当天已签到、非第 14 天、当天未膨胀过,成功后写入 `signin_boost` 金币流水。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Admin 看广告金币审计
|
||||
|
||||
> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md)
|
||||
|
||||
把「看视频赚金币」(`ad_reward_record`)和「比价信息流广告」(`ad_feed_reward_record`)两类发奖记录,用与**正式发奖完全相同**的公式 [`app/core/rewards.py` `calculate_ad_reward_coin`](../../app/core/rewards.py) 复算一遍 `expected_coin`,与实际入账的 `actual_coin` 对比,核对金币公式是否生效。**纯只读对账**,不发币、不改任何数据。
|
||||
|
||||
相关表:[ad_reward_record](../database/ad_reward_record.md)、[ad_feed_reward_record](../database/ad_feed_reward_record.md)。
|
||||
|
||||
## 金币公式(展示参照)
|
||||
|
||||
```
|
||||
eCPM元 = getEcpm分 ÷ 100
|
||||
单份金币 = round( eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan ) # coin_per_yuan=10000
|
||||
```
|
||||
- **eCPM 口径**:`getEcpm()` 原值单位是分/千次展示;先 ÷100 转元,**因子判档与收益换算都用元**。
|
||||
- **因子1(eCPM 档,阈值单位=元/千次)**:≤100→0.1,101–200→0.3,201–400→0.4,>400→0.6(即 ¥100/¥200/¥400 CPM 分界。**真实 eCPM 多 <¥100 CPM,故常落最低档 0.1,高档基本不触发——产品有意取舍**)。
|
||||
- **因子2(LT,当日第 N 份)**:第1→2.0,第2→1.5,第3→1.3,第4–10→1.1,≥11→1.0。
|
||||
- **第 N 份的计数**:看视频每条 granted 记 1 份;信息流每满 10 秒记 1 份。**两个点位各自独立计数**(看视频的份数不影响信息流的份数,反之亦然)。
|
||||
|
||||
## GET /admin/api/ad-coin-audit — 复算对比
|
||||
|
||||
- 入参(均 query,可选):
|
||||
| 参数 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `date` | string | 今天 | 北京时间 `YYYY-MM-DD`,审计某天 |
|
||||
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
|
||||
| `scene` | string | 两类 | `reward_video` / `feed`;不传=两类都返回 |
|
||||
| `limit` | int(1~500) | 100 | 返回明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) |
|
||||
|
||||
- 出参 `200`:`AdCoinAuditOut`
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `date` | string | 审计日期 |
|
||||
| `formula` | object | 当前公式参数快照(见下) |
|
||||
| `total` | int | 返回明细条数 |
|
||||
| `mismatch_count` | int | 其中 `matched=false` 的条数;**=0 说明全部按公式发放** |
|
||||
| `items` | `AdCoinAuditRow[]` | 明细(见下) |
|
||||
|
||||
### AdCoinFormulaOut(`formula`)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `description` | string | 公式文字说明 |
|
||||
| `coin_per_yuan` | int | 金币:元 汇率(10000) |
|
||||
| `ecpm_unit` | string | eCPM 口径(分/千次展示,SDK getEcpm 原值) |
|
||||
| `feed_unit_seconds` | int | 信息流每多少秒折 1 份(10) |
|
||||
| `ecpm_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子1 档位表(直接读发奖常量,与发奖同源) |
|
||||
| `lt_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子2 LT 档位表 |
|
||||
|
||||
### AdCoinAuditRow(`items[]`)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `scene` | string | `reward_video` / `feed` |
|
||||
| `record_id` | int | 对应记录表主键 |
|
||||
| `user_id` | int | |
|
||||
| `created_at` | datetime | |
|
||||
| `status` | string | `granted` / `capped`(超每日上限未发) / `ecpm_missing`(缺 eCPM 未发) |
|
||||
| `ecpm` | string \| null | 本次采用的 eCPM 原始值 |
|
||||
| `ecpm_factor` | float \| null | 因子1;非 granted 为 null |
|
||||
| `units` | int | 折算份数:看视频恒 1;信息流 = 满 10 秒份数 |
|
||||
| `lt_index_start` / `lt_index_end` | int \| null | 本条占用「当日第几份」的起止(看视频起=止;信息流一条可跨多份) |
|
||||
| `lt_factor_start` / `lt_factor_end` | float \| null | 因子2 的起止值(信息流跨份时会衰减,故给区间) |
|
||||
| `expected_coin` | int | 按公式复算应发金币(非 granted 恒 0) |
|
||||
| `actual_coin` | int | 实际入账金币 |
|
||||
| `matched` | bool | 复算与实发是否一致;非 granted 校验实发是否确为 0 |
|
||||
|
||||
## 说明
|
||||
- **非 granted 行**(capped/ecpm_missing)不占用份序号、应发恒 0,`matched` 用于校验「该不发的确实没发」。
|
||||
- 用法建议:**按 `user_id`+`date` 定位某次具体核对**最直观;不带 `user_id` 是全用户当天概览。
|
||||
- 本地联调造数:debug 包看完激励视频会调 [`/api/v1/ad/test-grant`](./ad-test-grant.md),它会用客户端按 `ad_session_id` 上报的真实 eCPM 发奖(取不到兜底 200),所以本审计能看到真实 eCPM 对应的金币。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
|
||||
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **22 张业务表** + `alembic_version`(框架的迁移版本指针)。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **25 张业务表** + `alembic_version`(框架的迁移版本指针)。领券联动的「今日状态」三张表(`coupon_*`)同理:领券过程在 pricebot 内存态跑、**不落库**,只有结果回到 app-server 才落这三张表。
|
||||
|
||||
---
|
||||
|
||||
@@ -17,6 +17,14 @@
|
||||
| profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 |
|
||||
| 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 |
|
||||
|
||||
### 领券(每日领券联动 · 今日状态)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 领券**过程**(看屏→领券) | (无) | 在 pricebot-backend 内存态跑,**过程不落库**;结果回 app-server 才落下面三张表 |
|
||||
| 切外卖 App 时是否弹领券引导窗 | [`coupon_prompt_engagement`](./coupon_state.md) | 今天 engage 过(点领/点拒)就不再弹;判断维度 device_id |
|
||||
| 首页「去领取」卡是否置灰 | [`coupon_daily_completion`](./coupon_state.md) | 今天跑完整轮(到 done)就置灰;判断维度 device_id |
|
||||
| 每张券领取结果留痕 | [`coupon_claim_record`](./coupon_state.md) | 资产/画像/排查/CPS;当前**不参与**判断 |
|
||||
|
||||
### 钱包 / 福利(看广告赚钱闭环)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
@@ -74,6 +82,13 @@
|
||||
| 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 |
|
||||
| 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 |
|
||||
| 提交反馈 `POST /feedback` | `feedback`(C) | |
|
||||
| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, 北京日)` 幂等;best-effort |
|
||||
| 领券每帧结果 `POST /api/v1/coupon/step` | `coupon_claim_record`(C/U) | `(device_id, coupon_id, 北京日)` 幂等;best-effort |
|
||||
| 领券跑完 `POST /api/v1/coupon/step`(action.command=done) | `coupon_daily_completion`(C/U) | `(device_id, 北京日)` 幂等;best-effort |
|
||||
| 拒绝领券引导窗 `POST /api/v1/coupon/prompt/dismiss` | `coupon_prompt_engagement`(C/U `dismissed`) | 同上;客户端通知(透传链路看不到拒绝) |
|
||||
| 重置今日弹窗 `POST /api/v1/coupon/prompt/reset`(开发) | `coupon_prompt_engagement`(**D** 今日条) | 删后今天又能弹 |
|
||||
|
||||
> 领券三表写库**全 best-effort**:`/coupon/step` 里写失败只 `logger.warning`、不连累领券返回;**判断只看 `device_id`**,`user_id` 可空旁路(资产留痕)。
|
||||
|
||||
### admin 端(管理员触发,均额外写一条 `admin_audit_log`)
|
||||
| 后台操作 | 写入 | 操作 |
|
||||
@@ -120,6 +135,7 @@
|
||||
- **`comparison_record.store_name` ≈ `savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。
|
||||
- **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。
|
||||
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。
|
||||
- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。
|
||||
|
||||
### ER 关系(文字版)
|
||||
```
|
||||
@@ -132,6 +148,8 @@ user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_reco
|
||||
comparison_record ─1:N─ price_report (comparison_record_id, 可空)
|
||||
admin_user ─1:N─ admin_audit_log
|
||||
app_config (独立, 无外键, key 为主键)
|
||||
coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record
|
||||
(独立, 无硬 FK; 维度=device_id+北京日, user_id/trace_id 仅软关联)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
|
||||
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
|
||||
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。
|
||||
> 最后更新:2026-06-07(补全 22 张业务表 + 新增 [OVERVIEW 总览](./OVERVIEW.md))
|
||||
> 最后更新:2026-06-10(新增 3 张领券今日状态表 `coupon_*`;前补全 22 张业务表 + [OVERVIEW 总览](./OVERVIEW.md))
|
||||
|
||||
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
|
||||
|
||||
---
|
||||
|
||||
## 表总览(22 张业务表 + `alembic_version` 框架表)
|
||||
## 表总览(25 张业务表 + `alembic_version` 框架表)
|
||||
|
||||
### 账号 / 反馈
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
@@ -41,6 +41,13 @@
|
||||
| `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) |
|
||||
| `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) |
|
||||
|
||||
### 领券(每日领券联动 · 今日状态)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+日) | `models/coupon_state.py` | [详情](./coupon_state.md) |
|
||||
| `coupon_daily_completion` | 首页「去领取」置灰源(今日是否已跑完整轮) | `models/coupon_state.py` | [详情](./coupon_state.md) |
|
||||
| `coupon_claim_record` | 每张券领取结果沉淀(资产/画像/排查,不参与判断) | `models/coupon_state.py` | [详情](./coupon_state.md) |
|
||||
|
||||
### 首页门面数据
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
| `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,非客户端配置位) |
|
||||
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**;后端按“元/千次展示”参与金币公式 |
|
||||
| `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 | 时间 |
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# coupon_state — 领券今日状态三张表(弹窗频控 / 首页置灰 / 领券记录)
|
||||
|
||||
> 模型 `app/models/coupon_state.py` · 仓库 `app/repositories/coupon_state.py` · 接口 `app/api/v1/coupon.py`(prefix `/api/v1/coupon`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `POST /api/v1/coupon/step` 这条链路上(pricebot 跑领券,结果回 app-server 落库;**领券过程本身在 pricebot 内存态跑、不落库**)。三表各管一件事:
|
||||
|
||||
- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, 自然日)` 记「今天是否对领券引导窗表达过**意向**」(点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天 engage 过就不再弹。
|
||||
- **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。
|
||||
- **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。
|
||||
|
||||
三表共同口径:
|
||||
- **判断维度是 `device_id`,不是 `user_id`**:券发到的是设备上登录的那个外卖账号,device 比 user 更贴近「哪个登录环境」,且 `device_id` 全链路现成、不依赖领券鉴权(领券 MVP 阶段 `/coupon/step` 不鉴权)。客户端 `getOrCreateDeviceId` 生成存 SP,**卸载重装会变 → 当新设备重新弹一次**(产品预期)。
|
||||
- **日期 = `Asia/Shanghai` 自然日**(`claim_date` / `engage_date` / `complete_date`,`repositories/coupon_state.today_cn()`)。每日可领的券(签到/天天红包)靠这天然每天一条。
|
||||
- **`user_id` 可空**:领券登录态有就记(资产/画像),可空、**不进唯一键、不阻塞判断**。
|
||||
- **`trace_id` 可空**:回指 pricebot work_logs,供排查(哪次任务领的)。
|
||||
- **engagement vs completion vs claim 的区别**:engagement = 用户**表达过意向**(点了领或拒,不管跑没跑完);completion = 这一轮**真跑到了 done**(整套流程走完);claim = **每张券一条**的结果留痕。
|
||||
|
||||
> 写库全部 **best-effort**:在 `/coupon/step` 里写库失败只 `logger.warning`、**绝不连累领券主流程/返回**(见 `app/api/v1/coupon.py`)。
|
||||
|
||||
---
|
||||
|
||||
## coupon_prompt_engagement — 弹窗频控(今日是否已对引导窗表达意向)
|
||||
|
||||
`(device_id, engage_date)` 唯一,一台设备一天一条;今天 engage 过(领或拒)就不再弹。
|
||||
|
||||
### 用在哪 / 增删改查
|
||||
- **C / U(幂等 upsert)**:`mark_engagement`。两条触发:
|
||||
- `POST /api/v1/coupon/step` 且 `step==0`(领券首帧=用户已发起领券)→ 记 `claim_started`;
|
||||
- `POST /api/v1/coupon/prompt/dismiss`(用户点关闭引导窗;server 在透传链路看不到「拒绝」,必须客户端通知)→ 记 `dismissed`。
|
||||
- 已有今天那条则覆盖 `engage_type`(并补 `user_id`),否则插入。
|
||||
- **D**:`POST /api/v1/coupon/prompt/reset`(`reset_today_engagement`)—— 删这台设备今天那条,开发设置「重置今日领券弹窗状态」按钮调,测频控用;删后今天又能弹。
|
||||
- **R**:`GET /api/v1/coupon/prompt/should-show?device_id=…`(`has_engaged_today`)→ `should_show = not 今天已 engage`。客户端切外卖 App 前查,纯后台判据。
|
||||
|
||||
### 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `device_id` | String(64) | NOT NULL | 判断/聚合维度;客户端 `getOrCreateDeviceId`,重装会变 |
|
||||
| `user_id` | Integer | index, 可空 | 登录态有就记(资产);不进唯一键、不阻塞判断 |
|
||||
| `engage_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`) |
|
||||
| `engage_type` | String(16) | NOT NULL | `claim_started`(点一键领取)/ `dismissed`(点拒绝关闭);仅记录区分,**判断只看「今天有没有这条」,type 不影响弹不弹** |
|
||||
| `created_at` | DateTime(tz) | server_default now() | |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
|
||||
|
||||
### 索引与约束
|
||||
- PK `id`;index `user_id`;UNIQUE(`device_id`, `engage_date`) = `uq_coupon_engage_device_date`(一台设备一天一条)。
|
||||
|
||||
### 注意
|
||||
- `device_id` 重装会变 → 重装当新设备,今天重新弹一次(产品预期)。
|
||||
- 判断只看「今天这台设备有没有这条」,不看 `engage_type`(领或拒都算 engage 过、都不再弹)。
|
||||
|
||||
---
|
||||
|
||||
## coupon_daily_completion — 首页置灰(今日是否已跑完整轮领券)
|
||||
|
||||
`(device_id, complete_date)` 唯一,一台设备一天一条;今天跑完整轮(到 done 帧)就把首页「去领取」卡置灰。
|
||||
|
||||
### 用在哪 / 增删改查
|
||||
- **C / U(幂等 upsert)**:`mark_completed_today`,由 `POST /api/v1/coupon/step` 在 pricebot 返回 `action.command == "done"` 那帧调。pricebot 把中途单券 done 改写成 `wait+continue=true`,只有整套全跑完那帧才保留 `command=="done"`,故 **done 已等价「整轮完成」**。已有今天那条则补 `user_id`/`trace_id`,否则插入。
|
||||
- **U / D**:无业务删除。
|
||||
- **R**:`GET /api/v1/coupon/completed-today?device_id=…`(`has_completed_today`)→ `completed`。客户端据此把首页「去领取」卡置灰、不可点。
|
||||
|
||||
### 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `device_id` | String(64) | NOT NULL | 判断维度,与 engagement/claim 一致;客户端两端都用 ANDROID_ID |
|
||||
| `user_id` | Integer | index, 可空 | 登录态有就记(资产) |
|
||||
| `complete_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`) |
|
||||
| `trace_id` | String(64) | index, 可空 | 哪次任务跑到 done,回指 pricebot work_logs / 排查 |
|
||||
| `created_at` | DateTime(tz) | server_default now() | |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
|
||||
|
||||
### 索引与约束
|
||||
- PK `id`;index `user_id`、`trace_id`;UNIQUE(`device_id`, `complete_date`) = `uq_coupon_completion_device_date`(一台设备一天一条)。
|
||||
|
||||
### 注意
|
||||
- 口径(用户决策 2026-06-10 A 方案):**到 done 即算完成,不管单券成败**——失败/跳过常是无障碍/环境问题,重复点也补不回来。
|
||||
- 与 engagement 区别:engagement 是「表达过意向」(点了就记,不管跑没跑完);completion 是「真跑到了 done」。
|
||||
|
||||
---
|
||||
|
||||
## coupon_claim_record — 领券记录(每张券一天一条,资产沉淀层)
|
||||
|
||||
`(device_id, coupon_id, claim_date)` 唯一,同设备同券同一天只一条;纯沉淀,**当前不参与判断**,留作以后按券去重 / CPS 归因 / 用户画像的数据源。
|
||||
|
||||
### 用在哪 / 增删改查
|
||||
- **C / U(幂等 upsert)**:`record_claims`,由 `POST /api/v1/coupon/step` 写入。一帧的券结果来自 pricebot 的 `last_coupon_result`(最后一张)+ `action.params.coupon_results`(全量)——**会重复带同一张券**,端点 `_extract_coupon_results` 先**按 `coupon_id` 去重**(全量覆盖单张),仓库再靠唯一键幂等:已有则更新 `status`/`reason`/`claimed_count`/`extra`(以最后一次为准),否则插入。
|
||||
- **U / D**:无业务删除。
|
||||
- **R**:**当前无读取端点**(纯写入沉淀,未来做去重/归因/画像时再用)。
|
||||
|
||||
### 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `device_id` | String(64) | NOT NULL | 聚合维度;客户端 `getOrCreateDeviceId`,重装会变 |
|
||||
| `user_id` | Integer | index, 可空 | 登录态有就记(资产/画像);不进唯一键 |
|
||||
| `coupon_id` | String(64) | NOT NULL | 券标识(取自 pricebot 结果) |
|
||||
| `claim_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`);每日可领的券靠它天然每天一条 |
|
||||
| `status` | String(24) | NOT NULL | `success` / `already_claimed` / `failed` / `skipped`(原样取 pricebot coupon 结果) |
|
||||
| `vendor` | String(48) | 可空 | 券提供方 |
|
||||
| `coupon_name` | String(128) | 可空 | 取 pricebot `name` |
|
||||
| `claimed_count` | Integer | 可空 | 这张领到几张(pricebot `display_count`,给不出时 None;兼容 `claimed_count`) |
|
||||
| `trace_id` | String(64) | index, 可空 | 哪次任务领的,回指 pricebot work_logs / 排查 |
|
||||
| `reason` | String(255) | 可空 | failed / skipped 原因 |
|
||||
| `extra` | JSON(PG JSONB) | 可空 | 杂项兜底:券的结构化信息(面额/入口/关键节点摘要等),免得加字段就迁移;当前直接存这帧 pricebot 单券结果 dict |
|
||||
| `created_at` | DateTime(tz) | server_default now() | |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
|
||||
|
||||
### 索引与约束
|
||||
- PK `id`;index `user_id`、`trace_id`;UNIQUE(`device_id`, `coupon_id`, `claim_date`) = `uq_coupon_claim_device_coupon_date`;Index(`device_id`, `claim_date`) = `ix_coupon_claim_device_date`(按 device+日 取一天所有券)。
|
||||
|
||||
### 注意
|
||||
- **`extra` 别塞原始无障碍树**(几十 KB → 行膨胀);原始大树看 `trace_id` 指过去的 work_logs。
|
||||
- 同批去重很关键:`autoflush=False` 下同 `coupon_id` 两次 `add` 会撞唯一约束、`IntegrityError` 回滚整批(done/单券记录全丢),故端点 `_extract_coupon_results` + 仓库 `seen` 集合双重防御。
|
||||
- `extra` 是 `JSON().with_variant(JSONB(), "postgresql")`:PG 用 JSONB(可建 GIN 索引),SQLite 退化通用 JSON(同 `price_observation` / `comparison_record`)。
|
||||
|
||||
---
|
||||
|
||||
## 三表共性小结
|
||||
- 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。
|
||||
- 唯一键都含 `device_id` + 某个北京自然日列;`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。
|
||||
- 无硬外键:`user_id` 软指 `user.id`、`trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。
|
||||
Reference in New Issue
Block a user