Compare commits

..

2 Commits

Author SHA1 Message Date
linkeyu 57731f2e8d fix(admin): 区分领券广告收益未填充与零收益 (#148)
## 修改内容
- 无 eCPM 填充记录时,领券数据接口返回 null
- 已填充但收益为 0 时保留 0.0
- 补充未填充与零收益的回归测试

## 验证
- 后端定向测试:7 passed
- Ruff 检查通过
- 线上近 30 天 740 条领券记录中,647 条无对应 eCPM 填充记录

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #148
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-21 10:18:34 +08:00
unknown 5cd1c63d8d feat(admin): aggregate dashboard comparison metrics 2026-07-20 20:02:58 +08:00
10 changed files with 194 additions and 187 deletions
+8 -3
View File
@@ -86,7 +86,12 @@ def _success_rates(rows: list) -> dict:
}
def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict:
def _session_to_row(
r,
phone: str | None = None,
nickname: str | None = None,
ad_revenue_yuan: float | None = None,
) -> dict:
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
return {
"id": r.id,
@@ -252,7 +257,7 @@ def coupon_data_report(
items = []
for r in page:
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)))
items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id)))
return {
"summary": summary,
@@ -276,7 +281,7 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
).scalar_one()
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows])
return {
"items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)) for r in rows],
"items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id)) for r in rows],
"total": int(total),
}
+102 -9
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
from collections import Counter
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal, InvalidOperation
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from sqlalchemy import case, func, select
from sqlalchemy.orm import Session
@@ -109,6 +109,23 @@ def _date_range(date_from: date, date_to: date) -> list[date]:
return [date_from + timedelta(days=i) for i in range(days + 1)]
def _duration_percentile(sorted_values: list[int], q: float) -> int | None:
"""Linear-interpolated percentile with the same half-up rounding as Math.round."""
if not sorted_values:
return None
if len(sorted_values) == 1:
return sorted_values[0]
index = (len(sorted_values) - 1) * q
lower = int(index)
upper = min(lower + 1, len(sorted_values) - 1)
fraction = Decimal(str(index - lower))
value = (
Decimal(sorted_values[lower]) * (Decimal(1) - fraction)
+ Decimal(sorted_values[upper]) * fraction
)
return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def _id_set(db: Session, stmt) -> set[int]:
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
@@ -242,16 +259,46 @@ def dashboard_overview(
ComparisonRecord.created_at >= start_local,
ComparisonRecord.created_at < end_local,
)
period_comparison_total = _count(ComparisonRecord, *period_comparison_conds)
period_comparison_success = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.status == "success",
period_comparison_stats = db.execute(
select(
func.count(ComparisonRecord.id),
func.coalesce(
func.sum(
case(
(ComparisonRecord.status.in_(("success", "failed")), 1),
else_=0,
)
),
0,
),
func.coalesce(
func.sum(
case((ComparisonRecord.status == "cancelled", 1), else_=0)
),
0,
),
func.coalesce(
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
0,
),
func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0),
).where(*period_comparison_conds)
).one()
period_comparison_total = int(period_comparison_stats[0])
period_comparison_completed = int(period_comparison_stats[1])
period_comparison_cancelled = int(period_comparison_stats[2])
period_comparison_success = int(period_comparison_stats[3])
period_comparison_token_cost_yuan = float(period_comparison_stats[4])
period_comparison_success_denominator = (
period_comparison_total - period_comparison_cancelled
)
period_comparison_success_rate = (
round(period_comparison_success / period_comparison_total, 4)
if period_comparison_total
else 0.0
round(
period_comparison_success / period_comparison_success_denominator,
4,
)
if period_comparison_success_denominator > 0
else None
)
period_saved_positive_count = _count(
ComparisonRecord,
@@ -282,6 +329,47 @@ def dashboard_overview(
if period_avg_duration_ms is not None
else None
)
completed_duration_conds = (
*period_comparison_conds,
ComparisonRecord.status.in_(("success", "failed")),
ComparisonRecord.total_ms.is_not(None),
)
if db.bind is not None and db.bind.dialect.name == "postgresql":
period_median_duration_ms, period_p95_duration_ms = db.execute(
select(
func.percentile_cont(0.5).within_group(ComparisonRecord.total_ms),
func.percentile_cont(0.95).within_group(ComparisonRecord.total_ms),
).where(*completed_duration_conds)
).one()
period_median_duration_ms = (
int(
Decimal(str(period_median_duration_ms)).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
if period_median_duration_ms is not None
else None
)
period_p95_duration_ms = (
int(
Decimal(str(period_p95_duration_ms)).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
if period_p95_duration_ms is not None
else None
)
else:
# SQLite 测试环境没有 percentile_cont;仅回退读取耗时单列,不加载完整记录。
completed_durations = list(
db.execute(
select(ComparisonRecord.total_ms)
.where(*completed_duration_conds)
.order_by(ComparisonRecord.total_ms)
).scalars()
)
period_median_duration_ms = _duration_percentile(completed_durations, 0.5)
period_p95_duration_ms = _duration_percentile(completed_durations, 0.95)
ordered_exists = (
select(SavingsRecord.id)
@@ -622,11 +710,16 @@ def dashboard_overview(
},
"comparison": {
"total": period_comparison_total,
"completed": period_comparison_completed,
"cancelled": period_comparison_cancelled,
"success": period_comparison_success,
"success_rate": period_comparison_success_rate,
"ordered": period_ordered_count,
"average_duration_ms": period_avg_duration_ms,
"median_duration_ms": period_median_duration_ms,
"p95_duration_ms": period_p95_duration_ms,
"average_saved_cents": period_avg_saved_cents,
"token_cost_total_yuan": period_comparison_token_cost_yuan,
},
"coupon": {
"started": coupon_started,
+3 -2
View File
@@ -70,8 +70,9 @@ class CouponDataRow(BaseModel):
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
claimed_count: int | None = None
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
ad_revenue_yuan: float = Field(
0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record"
ad_revenue_yuan: float | None = Field(
None,
description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record;无填充记录为 null",
)
+6 -1
View File
@@ -53,11 +53,16 @@ class DashboardPeriodUsers(BaseModel):
class DashboardPeriodComparison(BaseModel):
total: int
completed: int
cancelled: int
success: int
success_rate: float
success_rate: float | None = None
ordered: int
average_duration_ms: int | None = None
median_duration_ms: int | None = None
p95_duration_ms: int | None = None
average_saved_cents: int | None = None
token_cost_total_yuan: float = 0.0
class DashboardPeriodCoupon(BaseModel):
-19
View File
@@ -27,7 +27,6 @@ from app.repositories import ad_watch as crud_watch
from app.repositories import app_config
from app.repositories import signin as crud_signin
from app.schemas.ad import (
AdRewardResultOut,
AdRewardStatusOut,
EcpmReportIn,
EcpmReportOut,
@@ -243,24 +242,6 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
)
@router.get(
"/reward-result/{ad_session_id}",
response_model=AdRewardResultOut,
summary="按广告会话查本次服务端权威发奖结果(弹窗展示实发金币用)",
)
def reward_result(ad_session_id: str, user: CurrentUser, db: DbSession) -> AdRewardResultOut:
"""激励视频关闭后,客户端按本次 `ad_session_id` 查服务端到底发了多少金币,弹窗只展示这个权威数字。
只读、只查本人本会话的 reward_video 记录:没有记录=pending(S2S 尚未到账,客户端继续有限重试,
别把"未到账"当 0);granted 返回真实 coin;capped/closed_early/ecpm_missing 返回终态(coin=0,
不伪装成 granted)。不返回 eCPM / 回调原文 / 他人信息。
"""
rec = crud_ad.find_reward_video_result(db, user.id, ad_session_id)
if rec is None:
return AdRewardResultOut(ad_session_id=ad_session_id, status="pending", coin=None)
return AdRewardResultOut(ad_session_id=ad_session_id, status=rec.status, coin=rec.coin)
@router.post(
"/watch-report",
response_model=WatchReportOut,
-21
View File
@@ -41,27 +41,6 @@ def find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None:
return _find_by_trans(db, trans_id)
def find_reward_video_result(
db: Session, user_id: int, ad_session_id: str
) -> AdRewardRecord | None:
"""按 (user_id, ad_session_id, reward_scene='reward_video') 查**最新一条**发奖记录,
供客户端弹窗查这次广告的权威发奖结果
只读 user_id 隔离(他人查不到)取最新:同一会话可能先有 closed_early 留痕S2S 延迟
到账后又写 granted, (created_at, id) 倒序让 granted 覆盖早先的 closed_early查不到返回 None
"""
return db.execute(
select(AdRewardRecord)
.where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.ad_session_id == ad_session_id,
AdRewardRecord.reward_scene == "reward_video",
)
.order_by(AdRewardRecord.created_at.desc(), AdRewardRecord.id.desc())
.limit(1)
).scalar_one_or_none()
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
return db.execute(
select(func.count())
-18
View File
@@ -5,7 +5,6 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
@@ -44,23 +43,6 @@ class AdRewardStatusOut(BaseModel):
watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数;limit=0 时客户端不据此拦截")
class AdRewardResultOut(BaseModel):
"""客户端弹窗按 `ad_session_id` 查这次广告服务端**权威发奖结果**,只展示这个数字。
- `pending`:本人没有该会话的 reward_video 记录(S2S 尚未到账)客户端继续有限重试,
**别把"未到账"当成 0 金币**,`coin=null`
- `granted`:返回真实到账金币 `coin`(>0)
- `capped`:达每日上限,`coin=0`,客户端走上限提示而非"获得 0 金币"
- `closed_early` / `ecpm_missing`:终态,`coin=0`,**不能伪装成 granted**
不返回 eCPM回调原文或其他用户信息
"""
ad_session_id: str
status: Literal["pending", "granted", "capped", "closed_early", "ecpm_missing"]
coin: int | None = None
class EcpmReportIn(BaseModel):
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
-114
View File
@@ -356,117 +356,3 @@ def test_callback_disabled_returns_503(client, monkeypatch) -> None:
# 503 发生在验签/发奖之前,不需要真实用户
r = _callback(client, _signed(1, "trans_disabled"))
assert r.status_code == 503, r.text
# ===== 按广告会话查权威发奖结果(弹窗展示实发金币用)=====
def _reward_result(client, token: str, ad_session_id: str):
return client.get(f"/api/v1/ad/reward-result/{ad_session_id}", headers=_auth(token))
def test_reward_result_requires_auth(client) -> None:
"""按会话查发奖结果需登录。"""
assert client.get("/api/v1/ad/reward-result/sess_noauth_001").status_code == 401
def test_reward_result_pending_when_no_record(client) -> None:
"""本人没有这次会话的发奖记录 → pending、coin=null(不能把"尚未到账"当成 0 金币)。"""
phone = "13800003601"
token = _login(client, phone)
r = _reward_result(client, token, "sess_pending_never")
assert r.status_code == 200, r.text
body = r.json()
assert body["ad_session_id"] == "sess_pending_never"
assert body["status"] == "pending"
assert body["coin"] is None
def test_reward_result_granted_returns_real_coin(client) -> None:
"""本人存在 granted 记录 → 返回真实到账金币(与发奖公式一致),status=granted。"""
phone = "13800003602"
token = _login(client, phone)
uid = _user_id(phone)
session = "sess_granted_0001"
r = _callback(client, _signed(uid, "trans_rr_grant", ecpm="200",
extra=json.dumps({"ad_session_id": session})))
assert r.status_code == 200, r.text
expected = calculate_ad_reward_coin("200", 1)
assert expected > 0
assert _coin_balance(client, token) == expected
body = _reward_result(client, token, session).json()
assert body["status"] == "granted"
assert body["coin"] == expected
def test_reward_result_capped_returns_zero(client, monkeypatch) -> None:
"""达每日上限记 capped → status=capped、coin=0(客户端走上限提示,不显示"获得 0 金币")。"""
monkeypatch.setattr("app.core.rewards.get_ad_daily_limit", lambda db: 0)
phone = "13800003603"
token = _login(client, phone)
uid = _user_id(phone)
session = "sess_capped_0001"
before = _coin_balance(client, token)
r = _callback(client, _signed(uid, "trans_rr_capped", ecpm="200",
extra=json.dumps({"ad_session_id": session})))
assert r.status_code == 200, r.text
assert _coin_balance(client, token) == before # capped 未加币
body = _reward_result(client, token, session).json()
assert body["status"] == "capped"
assert body["coin"] == 0
def test_reward_result_closed_early_not_granted(client) -> None:
"""提前关闭留痕(closed_early)不是成功奖励 → 返回终态、coin=0,不能伪装成 granted。"""
phone = "13800003604"
token = _login(client, phone)
session = "sess_closed_early_0001"
r = client.post("/api/v1/ad/reward-noshow",
json={"ad_session_id": session, "watched_seconds": 2},
headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["status"] == "closed_early"
body = _reward_result(client, token, session).json()
assert body["status"] == "closed_early"
assert body["status"] != "granted"
assert body["coin"] == 0
def test_reward_result_isolated_per_user(client) -> None:
"""他人用同一会话 id 查 → 读不到,仍 pending(会话结果按 user_id 隔离)。"""
owner_phone = "13800003605"
owner_token = _login(client, owner_phone)
owner_uid = _user_id(owner_phone)
session = "sess_shared_0001"
_callback(client, _signed(owner_uid, "trans_rr_shared", ecpm="200",
extra=json.dumps({"ad_session_id": session})))
assert _reward_result(client, owner_token, session).json()["status"] == "granted"
other_token = _login(client, "13800003606")
body = _reward_result(client, other_token, session).json()
assert body["status"] == "pending"
assert body["coin"] is None
def test_reward_result_granted_supersedes_earlier_closed_early(client) -> None:
"""同一会话先留痕 closed_early、S2S 延迟到账后又 granted → 查最新返回 granted 与真实金币
(弹窗最终展示真实到账,不被早先的 closed_early 顶掉;这正是 S2S 延迟场景)"""
phone = "13800003607"
token = _login(client, phone)
uid = _user_id(phone)
session = "sess_late_grant_0001"
# 1) 客户端以为提前关闭,先留痕 closed_early
assert client.post("/api/v1/ad/reward-noshow",
json={"ad_session_id": session},
headers=_auth(token)).json()["status"] == "closed_early"
# 2) S2S 延迟到账,写入 granted(不同 trans_id)
r = _callback(client, _signed(uid, "trans_late_grant", ecpm="200",
extra=json.dumps({"ad_session_id": session})))
assert r.status_code == 200, r.text
expected = calculate_ad_reward_coin("200", 1)
body = _reward_result(client, token, session).json()
assert body["status"] == "granted"
assert body["coin"] == expected
+46
View File
@@ -1,12 +1,15 @@
"""Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。"""
from __future__ import annotations
from datetime import datetime
import pytest
from fastapi.testclient import TestClient
from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.models.feedback import Feedback
from app.models.wallet import CashTransaction, WithdrawOrder
from app.repositories import user as user_repo
@@ -69,6 +72,49 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None:
assert "jd_order_count" in data["cps"]
def test_dashboard_period_comparison_is_aggregated_by_backend(
admin_client: TestClient, admin_token: str
) -> None:
created_at = datetime(2037, 1, 15, 12)
rows = [
("dashboard-aggregate-success", "success", 101, 0.1),
("dashboard-aggregate-failed", "failed", 200, 0.2),
("dashboard-aggregate-cancelled", "cancelled", 300, 0.3),
("dashboard-aggregate-running", "running", 400, 0.4),
]
db = SessionLocal()
try:
for trace_id, status, total_ms, llm_cost_yuan in rows:
db.add(
ComparisonRecord(
trace_id=trace_id,
status=status,
total_ms=total_ms,
llm_cost_yuan=llm_cost_yuan,
created_at=created_at,
)
)
db.commit()
finally:
db.close()
response = admin_client.get(
"/admin/api/stats/overview",
params={"date_from": "2037-01-15", "date_to": "2037-01-15"},
headers=_auth(admin_token),
)
assert response.status_code == 200, response.text
comparison = response.json()["period"]["comparison"]
assert comparison["total"] == 4
assert comparison["completed"] == 2
assert comparison["cancelled"] == 1
assert comparison["success"] == 1
assert comparison["success_rate"] == 0.3333
assert comparison["median_duration_ms"] == 151
assert comparison["p95_duration_ms"] == 195
assert comparison["token_cost_total_yuan"] == pytest.approx(1.0)
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
View File
@@ -43,6 +43,35 @@ def test_coupon_data_report_includes_ad_revenue() -> None:
db.close()
def test_coupon_data_report_distinguishes_unfilled_from_zero_revenue() -> None:
"""无 eCPM 记录返回 None;已填充但 eCPM=0 返回 0.0。"""
db = SessionLocal()
try:
db.add_all([
CouponSession(
trace_id="rev-cp-unfilled", device_id="d-unfilled", status="completed", app_env="prod",
platforms=["meituan-waimai"], platform_success=["meituan-waimai"],
started_at=datetime(2020, 1, 3, tzinfo=UTC), started_date=date(2020, 1, 3),
),
CouponSession(
trace_id="rev-cp-zero", device_id="d-zero", status="completed", app_env="prod",
platforms=["meituan-waimai"], platform_success=["meituan-waimai"],
started_at=datetime(2020, 1, 3, tzinfo=UTC), started_date=date(2020, 1, 3),
),
_ecpm("rev-cp-zero", "0", "cp-sess-zero", "coupon"),
])
db.flush()
res = coupon_data_report(db, date_from="2020-01-03", date_to="2020-01-03", app_env="prod")
rows = {row["trace_id"]: row for row in res["items"]}
assert rows["rev-cp-unfilled"]["ad_revenue_yuan"] is None
assert rows["rev-cp-zero"]["ad_revenue_yuan"] == 0.0
finally:
db.rollback()
db.close()
def test_comparison_list_includes_ad_revenue() -> None:
"""比价记录列表项带本次广告收益;200 分 → 0.002 元。