Compare commits

...

5 Commits

Author SHA1 Message Date
guke 70533e92cd Merge branch 'main' into codex/coupon-rate-exclude-abandoned 2026-08-01 23:06:48 +08:00
linkeyu 84251770b4 修复:首页轮播排除测试账号数据 (#215)
## 背景

线上首页轮播候选记录中,测试账号 u33(11111111111)贡献约 51.8% 的真实记录,导致该用户频繁出现。

## 改动

- App 首页轮播真实记录查询排除全部已配置测试账号
- 后台首页轮播可展示记录同步采用相同过滤口径
- 同时兼容 TEST_ACCOUNT_PHONE 与 TEST_ACCOUNT_PHONES
- 测试账号配置变化时立即使轮播查询缓存失效
- 不删除历史业务数据,仅在展示查询中排除

## 验证

- ruff check:通过
- pytest tests/test_ops_marquee.py -q:1 passed

---------

Co-authored-by: linkeyu <798648091@qq.com>
Reviewed-on: #215
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-08-01 21:35:06 +08:00
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
8 changed files with 340 additions and 24 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
+21 -7
View File
@@ -25,6 +25,7 @@ from datetime import datetime, timedelta
from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.rewards import CN_TZ
from app.models.comparison import ComparisonRecord
from app.models.ops_marquee_seed import OpsMarqueeSeed
@@ -54,7 +55,7 @@ _SEED_MAX_CENTS = 100000
# 展示层随机(抽样/金额/时间/名字合成)仍每次重算,缓存只省查询;新记录最多晚 30s 进轮播,可接受。
_REAL_ROWS_TTL_SECONDS = 30
_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用
_real_rows_cache: dict = {"at": None, "rows": None}
_real_rows_cache: dict = {"at": None, "rows": None, "test_phones": None}
# ===== 用户标识脱敏(对齐 PRD) + 种子无真实昵称时的假名合成 =====
# 脱敏规则(按字符数,中英文皆适用):有昵称→n≥5「首+***+末」、n=4「首+**+末」、n≤3「首+**」;
@@ -198,10 +199,16 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
返回纯元组(脱离 session),可安全跨请求复用。极端并发下偶尔多查一次(无锁、幂等),纯门面无副作用。
"""
now = datetime.now(CN_TZ)
test_phones = tuple(sorted(settings.test_account_phones))
cached, at = _real_rows_cache["rows"], _real_rows_cache["at"]
if cached is not None and at is not None and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS:
if (
cached is not None
and at is not None
and _real_rows_cache["test_phones"] == test_phones
and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS
):
return cached
rows = db.execute(
stmt = (
select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents, User.nickname)
.join(User, User.id == ComparisonRecord.user_id)
.where(
@@ -211,9 +218,12 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
)
.order_by(ComparisonRecord.created_at.desc())
.limit(_REAL_ROWS_FETCH_CAP)
).all()
)
if test_phones:
stmt = stmt.where(User.phone.not_in(test_phones))
rows = db.execute(stmt).all()
out = [(int(uid), int(sc), nick) for uid, sc, nick in rows]
_real_rows_cache["rows"], _real_rows_cache["at"] = out, now
_real_rows_cache.update(rows=out, at=now, test_phones=test_phones)
return out
@@ -340,7 +350,7 @@ def list_real_records(
# pool: [(cluster_key, item)];cluster_key 供去连簇——真实=user_id、种子=各自唯一负数(互不聚簇)
pool: list[tuple[int, dict]] = []
if mode != "seed":
rows = db.execute(
stmt = (
select(
ComparisonRecord.user_id,
ComparisonRecord.saved_amount_cents,
@@ -355,7 +365,11 @@ def list_real_records(
)
.order_by(ComparisonRecord.created_at.desc())
.limit(_REAL_BROWSE_CAP)
).all()
)
test_phones = tuple(sorted(settings.test_account_phones))
if test_phones:
stmt = stmt.where(User.phone.not_in(test_phones))
rows = db.execute(stmt).all()
for uid, sc, nick, ca in rows:
pool.append((
int(uid),
+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))
+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()
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from app.core.config import settings
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.models.user import User
from app.repositories import ops_marquee
def _clear_real_rows_cache() -> None:
ops_marquee._real_rows_cache.update(at=None, rows=None, test_phones=None)
def test_real_marquee_records_exclude_configured_test_account(monkeypatch) -> None:
legacy_test_phone = "19900009991"
listed_test_phone = "19900009992"
real_phone = "19900009993"
monkeypatch.setattr(settings, "TEST_ACCOUNT_PHONE", legacy_test_phone)
monkeypatch.setattr(settings, "TEST_ACCOUNT_PHONES", listed_test_phone)
_clear_real_rows_cache()
try:
with SessionLocal() as db:
legacy_test_user = User(
phone=legacy_test_phone,
username="29900009991",
register_channel="sms",
)
listed_test_user = User(
phone=listed_test_phone,
username="29900009992",
register_channel="sms",
)
real_user = User(
phone=real_phone,
username="29900009993",
register_channel="sms",
)
db.add_all([legacy_test_user, listed_test_user, real_user])
db.flush()
db.add_all([
ComparisonRecord(
user_id=legacy_test_user.id,
trace_id="marquee-legacy-test-account-trace",
status="success",
saved_amount_cents=29_991,
items=[],
comparison_results=[],
),
ComparisonRecord(
user_id=listed_test_user.id,
trace_id="marquee-listed-test-account-trace",
status="success",
saved_amount_cents=29_992,
items=[],
comparison_results=[],
),
ComparisonRecord(
user_id=real_user.id,
trace_id="marquee-real-account-trace",
status="success",
saved_amount_cents=29_993,
items=[],
comparison_results=[],
),
])
db.flush()
app_rows = ops_marquee._recent_real_rows(db)
browse_rows, _ = ops_marquee.list_real_records(db, mode="real", limit=1_000)
finally:
_clear_real_rows_cache()
assert all(saved not in {29_991, 29_992} for _uid, saved, _nickname in app_rows)
assert any(saved == 29_993 for _uid, saved, _nickname in app_rows)
assert all(row["saved_amount_cents"] not in {29_991, 29_992} for row in browse_rows)
assert any(row["saved_amount_cents"] == 29_993 for row in browse_rows)