Compare commits

...

6 Commits

Author SHA1 Message Date
linkeyu 8fc2cd411c fix: 区分跳过事件与无逐券结果 2026-08-01 14:12:30 +08:00
linkeyu f37d800da3 fix: 补齐中途退出单券成功率 2026-08-01 14:06:46 +08:00
linkeyu 37fba3cffc fix: 领券成功率剔除中途退出 2026-08-01 13:52:22 +08:00
marco 2e91c9f72f ecpm保底是1 2026-08-01 02:57:45 +08:00
marco a69b7d777d feat(compare/coupon): trace_id 统一由后端签发,前端不再本地生成 (#212)
Reviewed-on: #212
2026-07-31 23:52:19 +08:00
marco 0fc8521c3b feat(compare/coupon): trace_id 统一由后端签发,前端不再本地生成 (#210)
一次比价/领券的 trace_id 改由后端签发,让前端 SLS 运行日志(trace_id 索引列)、
app-server 比价记录与领券流水、pricebot trace 目录/run.log/trace_url 全链共用同一个
id 查到底(此前前端各业务自己 randomUUID,虽同链但非后端签发、也无单一签发点)。

- POST /api/v1/compare/start(预占额度,任务第一个请求,签发与建 running 行合一):
  请求 trace_id 改可选,缺省时服务端签发 uuid;响应新增 trace_id 字段返回(签发的或
  回显客户端带来的)。客户端带值则沿用——老客户端兼容 + 同 trace 重试幂等。
- POST /api/v1/coupon/session:started 帧缺 trace_id 时签发并随响应返回(签发不依赖
  写库成功);非 started 帧缺 trace_id 不签发、不写库(收尾没有 id 只能是异常调用,
  签发新 id 只会造出查不到发起信息的孤儿行)。新增 CouponSessionOut 响应模型——原
  dict[str,bool] 注解无法承载字符串 trace_id,FastAPI 响应校验会炸。
- 测试:更新 2 处旧断言(响应体多出 trace_id 字段),新增 compare 不带 id 签发用例 +
  coupon started 签发/回显、终尾缺 id 跳过写库 3 个用例。全量 30 passed + ruff clean。

配合 shaguabijia-app-android 同名分支 feat-unify-trace-id-backend-issued 的前端换源改动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Reviewed-on: #210
2026-07-31 23:11:35 +08:00
17 changed files with 500 additions and 51 deletions
+29 -10
View File
@@ -166,6 +166,18 @@ def _session_to_row(
point_stats: dict | None = None,
) -> dict:
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
# 中途退出可能发生在第一张券产生终态之前,此时没有逐券事件。
# 明确返回 0/0,让前端区分「退出前无单券结果」与其它状态的埋点缺失。
if point_stats is not None:
point_success_count = point_stats["succeeded"]
point_total_count = point_stats["tried"]
elif r.status == "abandoned":
point_success_count = 0
point_total_count = 0
else:
point_success_count = None
point_total_count = None
point_event_count = point_stats["events"] if point_stats is not None else 0
return {
"id": r.id,
"trace_id": r.trace_id,
@@ -182,8 +194,9 @@ def _session_to_row(
"app_env": r.app_env,
"started_at": r.started_at,
"claimed_count": r.claimed_count,
"point_success_count": point_stats["succeeded"] if point_stats else None,
"point_total_count": point_stats["tried"] if point_stats else None,
"point_success_count": point_success_count,
"point_total_count": point_total_count,
"point_event_count": point_event_count,
"trace_url": r.trace_url,
"ad_revenue_yuan": ad_revenue_yuan,
}
@@ -194,21 +207,24 @@ def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict[
if not trace_ids:
return {}
succeeded = func.sum(case((CouponClaimEvent.status.in_(_SLOT_OK), 1), else_=0))
tried = func.sum(case((CouponClaimEvent.status.in_(_SLOT_TRIED), 1), else_=0))
rows = db.execute(
select(
CouponClaimEvent.trace_id,
succeeded.label("succeeded"),
func.count().label("tried"),
)
.where(
CouponClaimEvent.trace_id.in_(trace_ids),
CouponClaimEvent.status.in_(_SLOT_TRIED),
tried.label("tried"),
func.count().label("events"),
)
.where(CouponClaimEvent.trace_id.in_(trace_ids))
.group_by(CouponClaimEvent.trace_id)
).all()
return {
trace_id: {"succeeded": int(success_count or 0), "tried": int(tried or 0)}
for trace_id, success_count, tried in rows
trace_id: {
"succeeded": int(success_count or 0),
"tried": int(tried_count or 0),
"events": int(event_count or 0),
}
for trace_id, success_count, tried_count, event_count in rows
if trace_id is not None
}
@@ -400,12 +416,15 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
total = db.execute(
select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id)
).scalar_one()
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows])
trace_ids = [r.trace_id for r in rows]
rev_map = crud_ecpm.revenue_yuan_by_trace(db, trace_ids)
point_stats_map = _point_scores_by_trace(db, trace_ids)
return {
"items": [
_session_to_row(
r,
ad_revenue_yuan=rev_map.get(r.trace_id, 0.0),
point_stats=point_stats_map.get(r.trace_id),
)
for r in rows
],
+9 -1
View File
@@ -566,6 +566,10 @@ def dashboard_overview(
)
).all()
coupon_started = len(period_coupon_sessions)
coupon_abandoned = sum(s.status == "abandoned" for s in period_coupon_sessions)
# 用户主动中途退出不代表领券流程失败,不进入整场成功率样本。
# started / failed 仍留在分母:前者是尚未形成终态的流失,后者是实际执行失败。
coupon_success_denominator = coupon_started - coupon_abandoned
coupon_completed_elapsed = sorted(
s.elapsed_ms
for s in period_coupon_sessions
@@ -731,9 +735,13 @@ def dashboard_overview(
},
"coupon": {
"started": coupon_started,
"abandoned": coupon_abandoned,
"success_denominator": coupon_success_denominator,
"all_success": coupon_all_success,
"success_rate": (
round(coupon_all_success / coupon_started, 4) if coupon_started else None
round(coupon_all_success / coupon_success_denominator, 4)
if coupon_success_denominator
else None
),
"point_success": coupon_point_success,
"points_per_session": coupon_points_per_session,
+8 -2
View File
@@ -79,10 +79,16 @@ class CouponDataRow(BaseModel):
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
claimed_count: int | None = None
point_success_count: int | None = Field(
None, description="本次成功单券数(success+already_claimed);无逐券事件为空"
None,
description="本次成功单券数(success+already_claimed);中途退出且无逐券结果为0,其它无事件为空",
)
point_total_count: int | None = Field(
None, description="本次尝试单券数(success+already_claimed+failed,不含 skipped);无逐券事件为空"
None,
description="本次尝试单券数(success+already_claimed+failed,不含 skipped);中途退出且无逐券结果为0,其它无事件为空",
)
point_event_count: int = Field(
0,
description="本次全部逐券事件数(含 skipped);用于区分无有效计分事件与完全无事件",
)
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
ad_revenue_yuan: float = Field(
+3
View File
@@ -70,6 +70,9 @@ class DashboardPeriodCoupon(BaseModel):
成功口径 success+already_claimed(与「我的」页累计领券一致)。"""
started: int = 0
# 用户主动中途退出,不计入整场成功率分母。
abandoned: int = 0
success_denominator: int = 0
# 全部领成功的次数:completed 且当日该设备全部点位成功
all_success: int = 0
success_rate: float | None = None
+2 -2
View File
@@ -21,7 +21,6 @@ from __future__ import annotations
import json
import logging
import time
import uuid
from typing import Any
import httpx
@@ -32,6 +31,7 @@ from app.api.deps import DbSession, OptionalUser
from app.core.config import settings
from app.core.logging import trace_id_ctx
from app.core.pricebot_client import get_pricebot_client
from app.core.trace_ids import new_trace_id
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
from app.repositories import comparison as crud_compare
@@ -142,7 +142,7 @@ async def _forward(
trace_id = meta.get("trace_id")
minted = False
if not trace_id:
trace_id = str(uuid.uuid4())
trace_id = new_trace_id()
meta["trace_id"] = trace_id
raw = json.dumps(meta).encode() # 仅首帧重新序列化(注入 trace_id);后续帧走原始 bytes
minted = True
+7 -1
View File
@@ -17,6 +17,7 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
from app.api.deps import CurrentUser, DbSession
from app.core import limit_policy
from app.core.trace_ids import new_trace_id
from app.repositories import comparison as crud_compare
from app.repositories import risk as risk_repo
from app.schemas.compare_record import (
@@ -53,6 +54,10 @@ def reserve_compare_start(
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
):
raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用")
# trace_id 统一由服务端签发(客户端不带时):预占额度本就是任务的第一个请求,
# 签发与建 running 行合一,此后 Phase1/Phase2/记录/前端日志全链用同一个 id。
# 客户端带了则沿用——老客户端兼容 + 同 trace 重试幂等(reserve_daily_start 按 trace_id 去重)。
trace_id = payload.trace_id or new_trace_id()
try:
policy = limit_policy.resolve(
db,
@@ -63,7 +68,7 @@ def reserve_compare_start(
rec, used = crud_compare.reserve_daily_start(
db,
user_id=user.id,
trace_id=payload.trace_id,
trace_id=trace_id,
business_type=payload.business_type,
device_id=payload.device_id,
limit=policy.limit,
@@ -94,6 +99,7 @@ def reserve_compare_start(
limit=policy.limit,
used=used,
remaining=max(policy.limit - used, 0) if policy.limit is not None else None,
trace_id=trace_id,
)
+31 -5
View File
@@ -21,6 +21,7 @@ from fastapi.concurrency import run_in_threadpool
from app.api.deps import CurrentUser, DbSession
from app.core.config import settings
from app.core.pricebot_client import get_pricebot_client
from app.core.trace_ids import new_trace_id
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo
@@ -30,6 +31,7 @@ from app.schemas.coupon_state import (
CouponPromptShouldShowOut,
CouponPromptShownIn,
CouponSessionIn,
CouponSessionOut,
CouponStatsOut,
)
@@ -175,6 +177,12 @@ async def coupon_step(
)
resp_json = resp.json()
# 每帧响应顶层回传本次任务 trace_id(对齐 compare _forward 的 setdefault):客户端任一帧
# 都能从响应拿到全链 id。**只回显请求里带的、不 mint**——step 是循环接口,每帧签新 id
# 会把一次任务打散;领券 trace_id 的唯一签发点在 /coupon/session (status=started)。
# pricebot 响应顶层本无 trace_id(只有 trace_url),setdefault 不会覆盖任何上游值。
if isinstance(resp_json, dict) and trace_id:
resp_json.setdefault("trace_id", trace_id)
# 领券结果沉淀:每日资产 + 逐次事件;中间帧和 done 全量帧均幂等写库。
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
@@ -204,15 +212,33 @@ async def coupon_step(
return resp_json
@router.post("/session", summary="领券任务流水上报(admin 领券数据看板数据源)")
def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
@router.post(
"/session",
response_model=CouponSessionOut,
summary="领券任务流水上报(admin 领券数据看板数据源;started 兼签发本轮 trace_id)",
)
def coupon_session(payload: CouponSessionIn, db: DbSession) -> CouponSessionOut:
"""客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert
到 coupon_session。不鉴权(同领券循环 MVP,按 device_id/trace_id);供 admin「领券数据」看板算
发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。"""
发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。
trace_id 统一由后端签发:started 不带 trace_id → 签发 uuid 并随响应返回,客户端全程用它
(领券 step 循环 / 收尾上报 / 前端运行日志)。签发不依赖写库成功——写库失败照样返回 trace_id,
后续收尾上报 upsert 会补建行。非 started 缺 trace_id 不签发(收尾没有 id 只能是异常调用,
签发新 id 只会造出一行查不到发起信息的孤儿),不写库、trace_id=null 返回。
"""
trace_id = payload.trace_id or (
new_trace_id() if payload.status == "started" else None
)
if trace_id is None:
logger.warning(
"coupon session missing trace_id for status=%s (skip write)", payload.status
)
return CouponSessionOut(ok=True, trace_id=None)
try:
coupon_repo.upsert_coupon_session(
db,
trace_id=payload.trace_id,
trace_id=trace_id,
device_id=payload.device_id,
status=payload.status,
started_at_ms=payload.started_at_ms,
@@ -229,7 +255,7 @@ def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
)
except Exception as e: # noqa: BLE001
logger.warning("coupon session write failed: %s", e)
return {"ok": True}
return CouponSessionOut(ok=True, trace_id=trace_id)
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
+8 -4
View File
@@ -234,13 +234,17 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: i
会铸天量金币;钳在这唯一入口,feed 与 reward_video 回退客户端 eCPM 的路径都护住,且阈值高于
所有真实值,不影响正规发奖。
下限(2026-07):有真实正 eCPM 时单份至少 1 金币——低 eCPM 单份收益四舍五入成 0 时兜底为 1,
避免用户看了广告却因数值太小被记 too_short 零发。eCPM 缺失/为 0/非法(没有真实广告价值)仍返 0,
不凭空铸币、不破坏 ecpm_missing 语义;防刷上限仍由 AD_ECPM_MAX_FEN 钳顶把守。
下限(2026-08,产品口径「看了就保底 1」):**任何输入**都至少 1 金币,与前端展示公式
FeedRewardFormula.singleUnitCoin 完全对齐(那边注释:"无论 eCPM 是否为空、非法或非正数,
单条广告最低都发 1 金币,不能出现 +0")。此前 eCPM 缺失/为 0 返 0,造成两端不一致:
小球显示 +1、后端信息流记 too_short 零发;激励视频侧 "0" 字符串还是 truthy、绕过
ecpm_missing 的 `if not ecpm_raw` 判定,落成 granted 0 币且白占当日额度/LT 计数。
防刷影响:伪造 eCPM≤0 每天至多多骗 每日上限×1 金币(500 金币=0.05 元),量级可控;
天价伪造仍由 AD_ECPM_MAX_FEN 钳顶把守。
"""
ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0)
if ecpm_yuan <= 0:
return 0
return 1 # 保底:缺失/为 0/非法也发 1(镜像前端 validEcpmFen 判非法 → 直接返 1)
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this)
return max(1, round(yuan * COIN_PER_YUAN))
+29
View File
@@ -0,0 +1,29 @@
"""trace_id 签发(全后端唯一签发口径, 2026-07 起替代裸 uuid4)。
格式: "YYYYMMDD_HHMMSS_" + 12 位小写 hex 随机, 共 28 字符, 如
20260731_162254_a1b2c3d4e5f6
Why 带时间前缀: pricebot 落盘目录名/trace_url 尾段**直接用 trace_id 本身**
(见 pricebot app/utils/trace_ids.py), id/目录/URL 三者合一——此前 uuid trace_id
{首帧时刻}_{uuid[:16]} 目录名是两套标识, URL 只有 pricebot 能拼、按前缀反查
还有同秒歧义。时间用北京时间(CN_TZ)——不依赖各机器 TZ 配置, 与业务时区一致。
唯一性: 秒级前缀 + 48bit 随机(hex12), 同一秒内碰撞概率可忽略(比价/领券发起 QPS
远低于产生生日碰撞的量级); pricebot 侧同秒多 trace 靠随机段区分(目录精确匹配、
llm jsonl 按尾 12 分文件, 不做前缀模糊匹配)。
兼容: 三个签发点(compare/start、coupon/session started、compare.py _forward mint)
统一走这里; 客户端自带 trace_id(老客户端/重试幂等)仍原样沿用——pricebot 对老
uuid 格式保持既有目录/短标识行为, 两代 id 并行不冲突。
"""
from __future__ import annotations
import uuid
from datetime import datetime
from app.core.rewards import CN_TZ
def new_trace_id() -> str:
"""签发一个自描述 trace_id: 北京时间前缀 + 12 位 hex 随机。"""
return f"{datetime.now(CN_TZ):%Y%m%d_%H%M%S}_{uuid.uuid4().hex[:12]}"
+9 -2
View File
@@ -214,9 +214,13 @@ class ComparisonRecordCreatedOut(BaseModel):
class CompareStartReserveIn(BaseModel):
"""Reserve one authenticated comparison start before the agent begins."""
"""Reserve one authenticated comparison start before the agent begins.
trace_id: str = Field(..., min_length=1, max_length=64)
trace_id 可选:不带 = 请服务端签发(统一 trace_id 由后端下发,前端/SLS 日志/
pricebot 全链用同一个 id);带 = 沿用客户端值(老客户端兼容 + 网络重试幂等)。
"""
trace_id: str | None = Field(default=None, min_length=1, max_length=64)
business_type: str = Field(default="food", min_length=1, max_length=16)
device_id: str | None = Field(default=None, max_length=64)
@@ -225,6 +229,9 @@ class CompareStartReserveOut(BaseModel):
limit: int | None
used: int
remaining: int | None
# 本次比价全链 trace_id(服务端签发的,或回显客户端带来的)。客户端必须以它为准,
# 贯穿 Phase1/Phase2 step、比价记录、trace 收尾与前端运行日志上报。
trace_id: str
class CompareStatsOut(BaseModel):
+18 -1
View File
@@ -58,9 +58,13 @@ class CouponSessionIn(BaseModel):
- 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒)。
- 收尾(completed/failed/abandoned):带 elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count。
不鉴权(同领券循环 MVP,按 device_id/trace_id),user_id 登录态带上做留痕(可空)。
trace_id 可选:started 不带 = 请服务端签发本轮领券 trace_id(统一 trace_id 由后端下发,
响应 CouponSessionOut.trace_id 返回,客户端全程用它);带 = 沿用客户端值(老客户端兼容)。
非 started 缺 trace_id 不签发(防孤儿行),返回 trace_id=null 且不写库。
"""
trace_id: str
trace_id: str | None = None
device_id: str
status: str # started / completed / failed / abandoned
started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis)
@@ -74,3 +78,16 @@ class CouponSessionIn(BaseModel):
platform_elapsed: dict[str, int] | None = None
claimed_count: int | None = None
trace_url: str | None = None
class CouponSessionOut(BaseModel):
"""POST /api/v1/coupon/session 响应。
trace_id = 本轮领券全链 id(服务端签发的,或回显客户端带来的);客户端以它为准贯穿
/coupon/step 循环、收尾上报与前端运行日志。⚠️ 不能沿用旧的 dict[str, bool] 返回注解——
FastAPI 会按注解校验响应,字符串 trace_id 过 bool 校验必炸,故显式建模。
非 started 且缺 trace_id 时为 null(不签发防孤儿行)。
"""
ok: bool = True
trace_id: str | None = None
+48 -11
View File
@@ -1,8 +1,13 @@
"""发奖公式下限:有真实正 eCPM 时最低发 1 金币,eCPM 缺失/为 0 仍发 0
"""发奖公式下限:看了就保底 1 金币,与前端展示公式完全对齐
针对「eCPM 过低时公式四舍五入成 0 金币 → 被记 too_short 不发」的问题:只要广告有真实
正 eCPM,单份金币至少 1(不再 0);但 eCPM 缺失/为 0/非法(没有真实广告价值)仍发 0,
不凭空铸币、不破坏 ecpm_missing 语义。公式是发奖与后台审计对账的唯一口径,改这一处两边同源
产品口径(2026-08「看了就保底 1」):`calculate_ad_reward_coin` 对**任何输入**都至少返回 1,
镜像客户端 FeedRewardFormula.singleUnitCoin(那边:eCPM 空/非法/非正数都返 1)。此前 eCPM
缺失/为 0 返 0,与前端小球显示的 +1 不一致,且信息流侧把这类看满一份的广告记成 too_short 零发
公式是发奖与后台审计对账的唯一口径,改这一处两边同源。
注:激励视频(S2S 回调)路径在公式之前还有一道 `if not ecpm_raw` 早退——**完全没上报 eCPM**
的回调仍记 ecpm_missing 零发(见 test_ad_reward.test_callback_without_ecpm_records_exception),
那是"回调缺字段"的数据完整性闸,区别于"广告如实上报 eCPM=0"(=看了真广告 → 保底 1)。
"""
from __future__ import annotations
@@ -10,19 +15,23 @@ from app.core.rewards import calculate_ad_reward_coin
def test_low_positive_ecpm_floors_to_one_coin() -> None:
"""真实但极低的 eCPM 原本四舍五入成 0 金币,现在兜底为 1(线上 record 4667 的 43 分场景)。"""
"""真实但极低的 eCPM 原本四舍五入成 0 金币,兜底为 1(线上 record 4667 的 43 分场景)。"""
# 43 分 = ¥0.43 CPM,因子1=0.1,重度用户 LT 第 69 条=1.0 → 0.43/1000×0.1×1.0×10000=0.43 → 旧口径 round=0
assert calculate_ad_reward_coin("43", 69) == 1
# 更低的 5 分同理:算出来 <0.5,旧口径也是 0
assert calculate_ad_reward_coin("5", 11) == 1
def test_zero_or_missing_ecpm_stays_zero() -> None:
"""eCPM 缺失 / 为 0 / 非法(没有真实广告价值)不兜底,仍发 0。"""
assert calculate_ad_reward_coin("0", 1) == 0
assert calculate_ad_reward_coin(None, 1) == 0
assert calculate_ad_reward_coin("", 1) == 0
assert calculate_ad_reward_coin("abc", 1) == 0
def test_zero_or_missing_ecpm_also_floors_to_one() -> None:
"""eCPM 为 0 / 缺失 / 非法都保底 1(2026-08「看了就保底 1」,与前端 FeedRewardFormula 对齐)。
尤其 "0"(广告如实上报零价值)此前返 0,导致小球显示 +1、后端信息流记 too_short 零发的
前后端不一致 —— 现在两端都 1。
"""
assert calculate_ad_reward_coin("0", 1) == 1
assert calculate_ad_reward_coin(None, 1) == 1
assert calculate_ad_reward_coin("", 1) == 1
assert calculate_ad_reward_coin("abc", 1) == 1
def test_normal_ecpm_value_unchanged() -> None:
@@ -57,3 +66,31 @@ def test_feed_reward_low_ecpm_grants_one_coin_instead_of_too_short() -> None:
assert rec.coin == 1
finally:
db.close()
def test_feed_reward_zero_ecpm_grants_one_coin() -> None:
"""端到端:eCPM 如实上报 0(用户反馈的真实广告返回 eCPM=0 场景),看满一份也保底 1、
状态 granted —— 与前端小球显示的 +1 一致,不再前显示后零发。"""
from app.db.session import SessionLocal
from app.models.user import User
from app.repositories.ad_feed_reward import grant_feed_reward
db = SessionLocal()
try:
user = User(phone="19900000044", username="feedzero44", register_channel="sms")
db.add(user)
db.commit()
db.refresh(user)
rec = grant_feed_reward(
db, user.id,
client_event_id="feed-zero-ecpm-0001",
ecpm="0", # 广告如实上报 eCPM=0
duration_seconds=15, # 看满一份
ad_type="draw",
feed_scene="comparison",
)
assert rec.status == "granted"
assert rec.coin == 1
finally:
db.close()
+82 -1
View File
@@ -1,7 +1,7 @@
"""Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。"""
from __future__ import annotations
from datetime import UTC, datetime
from datetime import UTC, date, datetime
import pytest
from fastapi.testclient import TestClient
@@ -12,6 +12,7 @@ from app.admin.repositories import admin_user as admin_repo
from app.admin.repositories import queries
from app.db.session import SessionLocal, engine
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponClaimRecord, CouponSession
from app.models.feedback import Feedback
from app.models.invite import InviteRelation
from app.models.savings import SavingsRecord
@@ -120,6 +121,86 @@ def test_dashboard_period_comparison_is_aggregated_by_backend(
assert comparison["token_cost_total_yuan"] == pytest.approx(1.0)
def test_dashboard_coupon_success_rate_excludes_abandoned_sessions(
admin_client: TestClient, admin_token: str
) -> None:
started_date = date(2038, 1, 16)
started_at = datetime(2038, 1, 16, 8, tzinfo=UTC)
sessions = [
("coupon-rate-completed-1", "coupon-rate-device-1", "completed"),
("coupon-rate-completed-2", "coupon-rate-device-2", "completed"),
("coupon-rate-failed", "coupon-rate-device-3", "failed"),
("coupon-rate-abandoned", "coupon-rate-device-4", "abandoned"),
]
db = SessionLocal()
try:
for trace_id, device_id, status in sessions:
db.add(
CouponSession(
trace_id=trace_id,
device_id=device_id,
status=status,
app_env="prod",
platforms=["meituan-waimai"],
started_at=started_at,
started_date=started_date,
)
)
for index, device_id in enumerate(("coupon-rate-device-1", "coupon-rate-device-2")):
db.add(
CouponClaimRecord(
device_id=device_id,
coupon_id=f"mt_dashboard_rate_{index}",
claim_date=started_date,
status="success",
app_env="prod",
)
)
db.commit()
finally:
db.close()
response = admin_client.get(
"/admin/api/stats/overview",
params={"date_from": "2038-01-16", "date_to": "2038-01-16"},
headers=_auth(admin_token),
)
assert response.status_code == 200, response.text
coupon = response.json()["period"]["coupon"]
assert coupon["started"] == 4
assert coupon["abandoned"] == 1
assert coupon["success_denominator"] == 3
assert coupon["all_success"] == 2
assert coupon["success_rate"] == pytest.approx(2 / 3, abs=0.0001)
db = SessionLocal()
try:
db.add(
CouponSession(
trace_id="coupon-rate-only-abandoned",
device_id="coupon-rate-device-only-abandoned",
status="abandoned",
app_env="prod",
platforms=["meituan-waimai"],
started_at=datetime(2038, 1, 17, 8, tzinfo=UTC),
started_date=date(2038, 1, 17),
)
)
db.commit()
finally:
db.close()
empty_denominator_response = admin_client.get(
"/admin/api/stats/overview",
params={"date_from": "2038-01-17", "date_to": "2038-01-17"},
headers=_auth(admin_token),
)
assert empty_denominator_response.status_code == 200
only_abandoned = empty_denominator_response.json()["period"]["coupon"]
assert only_abandoned["success_denominator"] == 0
assert only_abandoned["success_rate"] is None
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
uid = _seed_user_with_data("13800000002")
r = admin_client.get("/admin/api/users", headers=_auth(admin_token))
+29 -2
View File
@@ -48,7 +48,10 @@ def test_compare_start_is_idempotent_by_trace_id(client) -> None:
retry = client.post("/api/v1/compare/start", json=payload, headers=_headers(token))
assert first.status_code == 200, first.text
assert first.json() == {"limit": 100, "used": 1, "remaining": 99}
# trace_id 回显客户端带来的值(老协议幂等路径)
assert first.json() == {
"limit": 100, "used": 1, "remaining": 99, "trace_id": payload["trace_id"],
}
assert retry.status_code == 200, retry.text
assert retry.json() == first.json()
with SessionLocal() as db:
@@ -69,6 +72,28 @@ def test_compare_start_is_idempotent_by_trace_id(client) -> None:
assert record.device_id == "quota-device"
def test_compare_start_issues_trace_id_when_absent(client) -> None:
"""新客户端不带 trace_id → 服务端签发并随响应返回,running 行以签发 id 建。"""
token, user_id = _login(client)
response = client.post(
"/api/v1/compare/start",
json={"business_type": "food", "device_id": "quota-device-issue"},
headers=_headers(token),
)
assert response.status_code == 200, response.text
body = response.json()
issued = body["trace_id"]
assert issued # 非空签发
assert body["used"] == 1
with SessionLocal() as db:
record = db.execute(
select(ComparisonRecord).where(ComparisonRecord.trace_id == issued)
).scalar_one()
assert record.user_id == user_id
assert record.status == "running"
assert record.device_id == "quota-device-issue"
def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
token, user_id = _login(client)
now = datetime.now(CN_TZ).replace(tzinfo=None)
@@ -101,7 +126,9 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
headers=_headers(token),
)
assert allowed.status_code == 200, allowed.text
assert allowed.json() == {"limit": 100, "used": 100, "remaining": 0}
assert allowed.json() == {
"limit": 100, "used": 100, "remaining": 0, "trace_id": final_allowed_trace,
}
rejected_trace = f"quota-rejected-{user_id}"
response = client.post(
+111 -3
View File
@@ -10,6 +10,7 @@ from app.admin.repositories.coupon_data import (
_point_scores_by_trace,
coupon_data_report,
coupon_point_details,
coupon_user_records,
)
from app.admin.security import create_admin_token
from app.db.session import SessionLocal
@@ -38,6 +39,7 @@ def test_point_scores_by_trace() -> None:
stats = _point_scores_by_trace(db, [trace])[trace]
assert stats["succeeded"] == 2
assert stats["tried"] == 3
assert stats["events"] == 4
details = coupon_point_details(db, trace_id=trace)
assert [item["status"] for item in details] == [
"success", "already_claimed", "failed", "skipped"
@@ -48,8 +50,8 @@ def test_point_scores_by_trace() -> None:
db.close()
def test_skipped_detail_does_not_create_a_score() -> None:
"""仅有 skipped 时按需明细仍可查到,但列表没有虚假的 0/0 分数"""
def test_skipped_detail_is_distinguished_from_no_events() -> None:
"""仅有 skipped 时分数仍为0/0,但保留事件数供前端开放明细"""
db = SessionLocal()
trace = "point-score-skipped"
try:
@@ -63,7 +65,7 @@ def test_skipped_detail_does_not_create_a_score() -> None:
db.flush()
scores = _point_scores_by_trace(db, [trace, "missing-trace"])
assert trace not in scores
assert scores[trace] == {"succeeded": 0, "tried": 0, "events": 1}
assert "missing-trace" not in scores
assert coupon_point_details(db, trace_id=trace)[0]["status"] == "skipped"
finally:
@@ -114,6 +116,112 @@ def test_coupon_data_report_returns_scores_without_embedding_details() -> None:
db.close()
def test_coupon_data_report_marks_abandoned_without_point_results() -> None:
"""中途退出且没有逐券终态时返回0/0,其他状态缺埋点仍保持为空。"""
db = SessionLocal()
report_date = date(2020, 1, 6)
user_id = 910006
try:
db.add_all([
CouponSession(
trace_id="point-score-abandoned-without-result",
device_id="score-abandoned-device",
user_id=user_id,
status="abandoned",
app_env="prod",
platforms=["meituan-waimai"],
started_at=datetime(2020, 1, 6, tzinfo=UTC),
started_date=report_date,
),
CouponSession(
trace_id="point-score-completed-without-result",
device_id="score-completed-device",
user_id=user_id,
status="completed",
app_env="prod",
platforms=["meituan-waimai"],
started_at=datetime(2020, 1, 6, 1, tzinfo=UTC),
started_date=report_date,
),
CouponSession(
trace_id="point-score-abandoned-with-result",
device_id="score-abandoned-result-device",
user_id=user_id,
status="abandoned",
app_env="prod",
platforms=["meituan-waimai"],
started_at=datetime(2020, 1, 6, 2, tzinfo=UTC),
started_date=report_date,
),
CouponSession(
trace_id="point-score-abandoned-skipped-only",
device_id="score-abandoned-skipped-device",
user_id=user_id,
status="abandoned",
app_env="prod",
platforms=["meituan-waimai"],
started_at=datetime(2020, 1, 6, 3, tzinfo=UTC),
started_date=report_date,
),
])
db.add_all([
CouponClaimEvent(
trace_id="point-score-abandoned-with-result",
device_id="score-abandoned-result-device",
coupon_id=f"mt-abandoned-{status}",
claim_date=report_date,
status=status,
)
for status in ("success", "failed")
])
db.add(CouponClaimEvent(
trace_id="point-score-abandoned-skipped-only",
device_id="score-abandoned-skipped-device",
coupon_id="mt-abandoned-skipped",
claim_date=report_date,
status="skipped",
))
db.flush()
report = coupon_data_report(
db,
date_from=report_date.isoformat(),
date_to=report_date.isoformat(),
app_env="prod",
)
rows = {item["trace_id"]: item for item in report["items"]}
abandoned = rows["point-score-abandoned-without-result"]
assert abandoned["point_success_count"] == 0
assert abandoned["point_total_count"] == 0
assert abandoned["point_event_count"] == 0
abandoned_with_result = rows["point-score-abandoned-with-result"]
assert abandoned_with_result["point_success_count"] == 1
assert abandoned_with_result["point_total_count"] == 2
assert abandoned_with_result["point_event_count"] == 2
abandoned_skipped = rows["point-score-abandoned-skipped-only"]
assert abandoned_skipped["point_success_count"] == 0
assert abandoned_skipped["point_total_count"] == 0
assert abandoned_skipped["point_event_count"] == 1
completed = rows["point-score-completed-without-result"]
assert completed["point_success_count"] is None
assert completed["point_total_count"] is None
user_rows = {
item["trace_id"]: item
for item in coupon_user_records(db, user_id=user_id)["items"]
}
assert user_rows["point-score-abandoned-without-result"]["point_total_count"] == 0
assert user_rows["point-score-abandoned-with-result"]["point_total_count"] == 2
assert user_rows["point-score-abandoned-skipped-only"]["point_event_count"] == 1
assert user_rows["point-score-completed-without-result"]["point_total_count"] is None
finally:
db.rollback()
db.close()
def test_coupon_point_details_endpoint() -> None:
"""前端点击使用的接口按约定返回 trace_id 和逐券 items。"""
db = SessionLocal()
+12 -6
View File
@@ -8,6 +8,7 @@ mock 掉对 pricebot 的 httpx 调用,验证:
"""
from __future__ import annotations
import json
import time
from unittest.mock import MagicMock, patch
@@ -62,7 +63,8 @@ def test_coupon_step_no_auth_required(client) -> None:
def test_coupon_step_passes_body_through(client, access_token) -> None:
"""带 token + pricebot 200 → 响应原样透传,请求 body 原样转发到 /api/coupon/step"""
"""带 token + pricebot 200 → 请求 body 原样转发到 /api/coupon/step;
响应在透传基础上顶层回显本次任务 trace_id(setdefault 注入,其余字段原样)"""
fake_pricebot_resp = {
"success": True,
"action": {
@@ -83,9 +85,12 @@ def test_coupon_step_passes_body_through(client, access_token) -> None:
captured: dict = {}
async def fake_post(self, url, json=None, **kw):
# ⚠️ coupon_step 转发用 content=raw(原始字节透传,不重新 dumps),不是 json= ——
# fake 必须捕 content。旧 fake 只捕 json= 导致 captured["json"] 恒 None,本测试
# 自 content=raw 优化后一直红着(pre-existing),本次顺手修正。
async def fake_post(self, url, content=None, **kw):
captured["url"] = url
captured["json"] = json
captured["content"] = content
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json = lambda: fake_pricebot_resp
@@ -99,9 +104,10 @@ def test_coupon_step_passes_body_through(client, access_token) -> None:
)
assert r.status_code == 200, r.text
assert r.json() == fake_pricebot_resp
# 验证请求被原样转发(body 不动 + URL 指向 pricebot)
assert captured["json"] == _stub_request_body()
# 顶层多出 trace_id 回显(值=请求带的;不 mint,签发点唯一在 /coupon/session started)
assert r.json() == {**fake_pricebot_resp, "trace_id": "test-trace-1"}
# 验证请求被原样转发(body 字节不动 + URL 指向 pricebot)
assert json.loads(captured["content"]) == _stub_request_body()
assert captured["url"].endswith("/api/coupon/step")
+65
View File
@@ -0,0 +1,65 @@
"""POST /api/v1/coupon/session 的 trace_id 签发行为(统一 trace_id 由后端下发)。
- started 不带 trace_id 服务端签发并返回,行以签发 id ;
- started trace_id 回显沿用(老客户端兼容);
- started trace_id 不签发不写库,trace_id=null(防孤儿行)
"""
from __future__ import annotations
from sqlalchemy import func, select
from app.db.session import SessionLocal
from app.models.coupon_state import CouponSession
def _base_payload(**overrides) -> dict:
payload = {
"device_id": "cs-issue-device",
"status": "started",
"started_at_ms": 1_722_000_000_000,
"platforms": ["meituan-waimai"],
"app_env": "dev",
}
payload.update(overrides)
return payload
def test_session_started_issues_trace_id_when_absent(client) -> None:
response = client.post("/api/v1/coupon/session", json=_base_payload())
assert response.status_code == 200, response.text
body = response.json()
assert body["ok"] is True
issued = body["trace_id"]
assert issued
with SessionLocal() as db:
row = db.execute(
select(CouponSession).where(CouponSession.trace_id == issued)
).scalar_one()
assert row.device_id == "cs-issue-device"
assert row.status == "started"
def test_session_started_echoes_client_trace_id(client) -> None:
response = client.post(
"/api/v1/coupon/session", json=_base_payload(trace_id="cs-legacy-1")
)
assert response.status_code == 200, response.text
assert response.json()["trace_id"] == "cs-legacy-1"
with SessionLocal() as db:
count = db.scalar(
select(func.count(CouponSession.id)).where(
CouponSession.trace_id == "cs-legacy-1"
)
)
assert count == 1
def test_session_terminal_without_trace_id_skips_write(client) -> None:
response = client.post(
"/api/v1/coupon/session",
json=_base_payload(status="completed", elapsed_ms=1234, claimed_count=2),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["ok"] is True
assert body["trace_id"] is None