Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94a2c04398 | |||
| 90c6fe599a | |||
| e529112a90 | |||
| 50da718e35 |
@@ -0,0 +1,49 @@
|
||||
"""comparison_record.fail_reason (失败卡展示原因)
|
||||
|
||||
Revision ID: comparison_record_fail_reason
|
||||
Revises: user_manual_risk_fields
|
||||
Create Date: 2026-07-28 12:00:00.000000
|
||||
|
||||
失败记录的展示原因:information 具体则=它;笼统则由写路径从 platform_results 捞出的
|
||||
业务原因;纯系统失败为 None(端侧品牌兜底)。见 repositories.comparison._derive_fail_display。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'comparison_record_fail_reason'
|
||||
down_revision: Union[str, Sequence[str], None] = 'user_manual_risk_fields'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('fail_reason', sa.String(length=256), nullable=True))
|
||||
# 回填老失败记录:information 具体的直接搬过来(笼统/系统失败留 None → 端侧品牌兜底)。
|
||||
# 新记录由写路径 _derive_fail_display 落库(含 platform_results 救援/补判),不走这条。
|
||||
# platform_results 只在 raw_payload 里,SQL 里不易解析,故老记录不做救援/补判(可接受:
|
||||
# 老 mixed/打烊记录回退品牌兜底);具体 information 的老记录本次即可显示真实原因。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE comparison_record
|
||||
SET fail_reason = information
|
||||
WHERE status = 'failed'
|
||||
AND information IS NOT NULL
|
||||
AND information <> ''
|
||||
AND information NOT IN (
|
||||
'比价过程出错,请稍后重试',
|
||||
'比价出错',
|
||||
'比价未完成',
|
||||
'done 参数缺少可验证的目标平台结果'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
|
||||
batch_op.drop_column('fail_reason')
|
||||
@@ -43,7 +43,7 @@ _KNOWN_PROD_BUSINESS_CODE_IDS = frozenset({"104098712", "104099389"})
|
||||
_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"})
|
||||
|
||||
|
||||
def _business_code_ids(db: Session, app_env: str | None) -> set[str]:
|
||||
def business_code_ids(db: Session, app_env: str | None) -> set[str]:
|
||||
"""返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。"""
|
||||
prod_config = app_config.get_ad_config(db)
|
||||
prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | {
|
||||
@@ -320,10 +320,10 @@ def ad_revenue_report(
|
||||
|
||||
# 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试
|
||||
# demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。
|
||||
business_code_ids: set[str] | None = None
|
||||
business_ids: set[str] | None = None
|
||||
if revenue_scope == "business":
|
||||
business_code_ids = _business_code_ids(db, app_env)
|
||||
events = [e for e in events if e.get("our_code_id") in business_code_ids]
|
||||
business_ids = business_code_ids(db, app_env)
|
||||
events = [e for e in events if e.get("our_code_id") in business_ids]
|
||||
|
||||
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
|
||||
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
|
||||
@@ -381,7 +381,7 @@ def ad_revenue_report(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
app_env=app_env,
|
||||
our_code_ids=business_code_ids,
|
||||
our_code_ids=business_ids,
|
||||
)
|
||||
if pangle_aggs:
|
||||
by_date = {a["date"]: a for a in pangle_aggs}
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.core.config import settings
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.admin import AdminAuditLog
|
||||
@@ -1257,11 +1258,15 @@ def user_reward_stats(
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
withdraw_source: str | None = None,
|
||||
app_env: str | None = None,
|
||||
revenue_scope: str = "all",
|
||||
feed_scene: str | None = None,
|
||||
) -> dict:
|
||||
"""提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。
|
||||
|
||||
口径:激励视频/信息流只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加);
|
||||
平均 eCPM 用原始分值(分/千次)按记录取算术平均;各「提现」= 该来源累计金币折现。
|
||||
口径:激励视频/信息流奖励数量只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加)。
|
||||
平均 Draw eCPM 与广告收益报表一致:基于 ad_ecpm_record 的全部 draw/feed 展示记录计算,
|
||||
不以是否发奖为筛选条件。各「提现」= 该来源累计金币折现。
|
||||
传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。
|
||||
"""
|
||||
withdraw_source_conds = (
|
||||
@@ -1296,30 +1301,68 @@ def user_reward_stats(
|
||||
|
||||
# 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时,
|
||||
# SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。
|
||||
business_ids: set[str] | None = None
|
||||
if revenue_scope == "business":
|
||||
# 与广告收益报表共用正式/测试业务代码位集合,避免两个页面随配置切换后再次漂移。
|
||||
from app.admin.repositories.ad_revenue import business_code_ids
|
||||
|
||||
business_ids = business_code_ids(db, app_env)
|
||||
|
||||
rv_conds = [
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
AdRewardRecord.status == "granted",
|
||||
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
||||
]
|
||||
if app_env is not None:
|
||||
rv_conds.append(AdRewardRecord.app_env == app_env)
|
||||
if business_ids is not None:
|
||||
rv_conds.append(AdRewardRecord.our_code_id.in_(business_ids))
|
||||
rv = db.execute(
|
||||
select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where(
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
AdRewardRecord.status == "granted",
|
||||
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
||||
*rv_conds,
|
||||
)
|
||||
).all()
|
||||
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
|
||||
rv_coins = sum(r.coin for r in rv)
|
||||
|
||||
feed = db.execute(
|
||||
feed_reward_conds = [
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
||||
]
|
||||
if app_env is not None:
|
||||
feed_reward_conds.append(AdFeedRewardRecord.app_env == app_env)
|
||||
if feed_scene is not None:
|
||||
feed_reward_conds.append(AdFeedRewardRecord.feed_scene == feed_scene)
|
||||
if business_ids is not None:
|
||||
feed_reward_conds.append(AdFeedRewardRecord.our_code_id.in_(business_ids))
|
||||
feed_rewards = db.execute(
|
||||
select(
|
||||
AdFeedRewardRecord.unit_count,
|
||||
AdFeedRewardRecord.ecpm_raw,
|
||||
AdFeedRewardRecord.coin,
|
||||
).where(
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
||||
*feed_reward_conds,
|
||||
)
|
||||
).all()
|
||||
feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw]
|
||||
feed_coins = sum(f.coin for f in feed)
|
||||
feed_coins = sum(f.coin for f in feed_rewards)
|
||||
|
||||
feed_impression_conds = [
|
||||
AdEcpmRecord.user_id == user_id,
|
||||
AdEcpmRecord.ad_type.in_(("draw", "feed")),
|
||||
*_window_conds(AdEcpmRecord.created_at, date_from, date_to),
|
||||
]
|
||||
if app_env is not None:
|
||||
feed_impression_conds.append(AdEcpmRecord.app_env == app_env)
|
||||
if feed_scene is not None:
|
||||
feed_impression_conds.append(AdEcpmRecord.feed_scene == feed_scene)
|
||||
if business_ids is not None:
|
||||
feed_impression_conds.append(AdEcpmRecord.our_code_id.in_(business_ids))
|
||||
feed_impressions = db.execute(
|
||||
select(AdEcpmRecord.ecpm_raw).where(*feed_impression_conds)
|
||||
).all()
|
||||
# 与 ad_revenue.category_stats 相同:每次展示权重相同,非法原值按 parse_ecpm_fen 记 0。
|
||||
feed_ecpms = [rewards.parse_ecpm_fen(row.ecpm_raw) for row in feed_impressions]
|
||||
|
||||
trad_coins = db.execute(
|
||||
select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
|
||||
@@ -1338,7 +1381,7 @@ def user_reward_stats(
|
||||
"reward_video_count": len(rv),
|
||||
"reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0,
|
||||
"reward_video_cash_cents": _coins_to_cents(rv_coins),
|
||||
"feed_count": int(sum(f.unit_count for f in feed)),
|
||||
"feed_count": int(sum(f.unit_count for f in feed_rewards)),
|
||||
"feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0,
|
||||
"feed_cash_cents": _coins_to_cents(feed_coins),
|
||||
}
|
||||
|
||||
@@ -88,6 +88,11 @@ def get_user_reward_stats(
|
||||
withdraw_source: Annotated[
|
||||
str | None, Query(pattern="^(coin_cash|invite_cash)$")
|
||||
] = None,
|
||||
app_env: Annotated[str | None, Query(pattern="^(prod|test)$")] = None,
|
||||
revenue_scope: Annotated[str, Query(pattern="^(business|all)$")] = "all",
|
||||
feed_scene: Annotated[
|
||||
str | None, Query(pattern="^(comparison|coupon|welfare)$")
|
||||
] = None,
|
||||
) -> UserRewardStats:
|
||||
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
|
||||
if not user_repo.user_exists(db, user_id):
|
||||
@@ -99,6 +104,9 @@ def get_user_reward_stats(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
withdraw_source=withdraw_source,
|
||||
app_env=app_env,
|
||||
revenue_scope=revenue_scope,
|
||||
feed_scene=feed_scene,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ class UserRewardStats(BaseModel):
|
||||
reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次)
|
||||
reward_video_cash_cents: int # 激励视频提现(金币折现)
|
||||
feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加)
|
||||
feed_avg_ecpm: float # 平均信息流广告 eCPM(分/千次)
|
||||
feed_avg_ecpm: float # 全部 Draw/feed 实际展示的平均 eCPM(分/千次,含未发奖展示)
|
||||
feed_cash_cents: int # 信息流广告提现(金币折现)
|
||||
|
||||
|
||||
|
||||
+11
-3
@@ -108,7 +108,7 @@ def _harvest_done_blocking(
|
||||
|
||||
def _harvest_abort_blocking(
|
||||
trace_id: str, status_hint: str, reason: str | None, trace_url: str | None,
|
||||
) -> None:
|
||||
) -> int | None:
|
||||
with SessionLocal() as db:
|
||||
rec = crud_compare.harvest_abort(
|
||||
db, trace_id=trace_id, status=status_hint, reason=reason, trace_url=trace_url,
|
||||
@@ -118,6 +118,7 @@ def _harvest_abort_blocking(
|
||||
extra={"phase": "harvest_abort",
|
||||
"status": (rec.status if rec else None), "reason": reason},
|
||||
)
|
||||
return rec.id if rec is not None else None
|
||||
|
||||
|
||||
async def _forward(
|
||||
@@ -291,7 +292,10 @@ async def trace_epilogue(
|
||||
|
||||
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)")
|
||||
async def trace_finalize(
|
||||
request: Request, user: OptionalUser, db: DbSession
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: OptionalUser,
|
||||
db: DbSession,
|
||||
) -> dict[str, Any]:
|
||||
_ensure_compare_allowed(user, db)
|
||||
# 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url};
|
||||
@@ -302,12 +306,16 @@ async def trace_finalize(
|
||||
request, "/api/trace/finalize", user, harvest_first_frame=False,
|
||||
)
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
record_id = await run_in_threadpool(
|
||||
_harvest_abort_blocking, trace_id,
|
||||
(meta.get("status") or "cancelled"),
|
||||
(meta.get("reason") or meta.get("information")),
|
||||
(resp.get("trace_url") if isinstance(resp, dict) else None),
|
||||
)
|
||||
if record_id is not None:
|
||||
background_tasks.add_task(
|
||||
backfill_comparison_llm_cost, record_id, trace_id
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("harvest_abort failed trace=%s: %s", trace_id, e)
|
||||
return resp
|
||||
|
||||
@@ -104,6 +104,7 @@ class Settings(BaseSettings):
|
||||
|
||||
XIAOMI_PUSH_APP_SECRET: str = ""
|
||||
XIAOMI_PUSH_SEND_ENDPOINT: str = "https://api.xmpush.xiaomi.com/v3/message/regid"
|
||||
XIAOMI_PUSH_MODE: int = 1 # 0=正式推送,1=测试推送
|
||||
XIAOMI_PUSH_CHANNEL_ID: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_ID: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_TITLE: str = ""
|
||||
|
||||
@@ -424,6 +424,7 @@ def _send_xiaomi(token: str, title: str, body: str, extras: dict[str, str]) -> d
|
||||
"payload": json.dumps(extras, ensure_ascii=False),
|
||||
"pass_through": "0",
|
||||
"notify_type": "-1",
|
||||
"pushMode": settings.XIAOMI_PUSH_MODE,
|
||||
"time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000),
|
||||
}
|
||||
# 点击落地:带 notificationId 的消息中心推送 → notify_effect=2 + intent_uri,MiPush 直接打开
|
||||
|
||||
@@ -102,6 +102,11 @@ class ComparisonRecord(Base):
|
||||
# done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…";
|
||||
# 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。
|
||||
information: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
# 失败卡「原因」行的展示文案(仅 status=failed 时非空):information 具体则=它;笼统则从
|
||||
# platform_results 捞出的业务原因(打烊/未起送/找不到店或菜/单点不配送);纯系统失败为 None
|
||||
# → 端侧显示品牌兜底「网络开小差…」。写路径(harvest_done / upsert_record)落库时派生。
|
||||
# 见 repositories.comparison._derive_fail_display。
|
||||
fail_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
|
||||
# ===== 明细(JSON,越详细越好)=====
|
||||
# 下单菜品 [{name, qty, specs?}]
|
||||
|
||||
@@ -52,6 +52,81 @@ def _product_names_from_items(items: list | None) -> str | None:
|
||||
return joined[:500] or None
|
||||
|
||||
|
||||
# ---- 失败记录的展示文案(记录页失败卡「原因」行)------------------------------
|
||||
# information 具体就直出;笼统(_GENERIC_INFO)则从 platform_results 捞一条用户可读的业务
|
||||
# 原因;捞不到 → None(端侧显示品牌兜底「网络开小差…」)。pricebot 把 store_closed /
|
||||
# no_delivery 漏成了 status=failed,这里按 reason 关键字补判;打烊类 reason 常带一坨脏店名
|
||||
# (店名+月售+起送+配送…),统一成简短模板。自动化黑话(搜索失败/读价失败/购物车残留/裸
|
||||
# FAILED…)不给用户看 → 归入品牌兜底。
|
||||
|
||||
# pricebot 组不出具体原因时的笼统 information(线上统计的大头),一律走品牌兜底。
|
||||
_GENERIC_INFO = {
|
||||
"比价过程出错,请稍后重试",
|
||||
"比价出错",
|
||||
"比价未完成",
|
||||
"done 参数缺少可验证的目标平台结果",
|
||||
}
|
||||
# 干净业务结局 status(直接可信),按展示优先级(越靠前越先选)。
|
||||
_BIZ_STATUS_PRIORITY = (
|
||||
"below_minimum",
|
||||
"no_delivery",
|
||||
"store_closed",
|
||||
"items_not_found",
|
||||
"store_not_found",
|
||||
)
|
||||
|
||||
|
||||
def _store_closed_text(reason: str | None) -> str:
|
||||
"""打烊/暂停营业/休息类 reason 常带脏店名元数据 → 只留结论,套简短模板。"""
|
||||
r = reason or ""
|
||||
if "暂停营业" in r:
|
||||
state = "暂停营业"
|
||||
elif "休息" in r:
|
||||
state = "休息中"
|
||||
else:
|
||||
state = "已打烊"
|
||||
return f"门店{state},无法比价"
|
||||
|
||||
|
||||
def _target_display_reason(platform_results: dict | None) -> str | None:
|
||||
"""从逐平台结果里挑一条"可展示给用户"的失败原因;挑不到返回 None。
|
||||
① status 命中干净业务结局集 → 直接采信(打烊套模板,其余用 reason);
|
||||
② 补判 pricebot 漏成 status=failed 的两类:打烊(套模板)、单点不配送(reason 本身干净);
|
||||
自动化黑话(搜索失败/读价失败/购物车残留/裸 FAILED…)一律不展示 → None。"""
|
||||
pr = platform_results or {}
|
||||
targets = [
|
||||
v for v in pr.values() if isinstance(v, dict) and not v.get("is_source")
|
||||
]
|
||||
for want in _BIZ_STATUS_PRIORITY: # ① 干净 status 优先
|
||||
for v in targets:
|
||||
if v.get("status") == want:
|
||||
if want == "store_closed":
|
||||
return _store_closed_text(v.get("reason"))
|
||||
if v.get("reason"):
|
||||
return v["reason"]
|
||||
for v in targets: # ② 漏成 failed 的业务结局补判
|
||||
if v.get("status") != "failed":
|
||||
continue
|
||||
reason = (v.get("reason") or "").strip()
|
||||
if any(k in reason for k in ("打烊", "暂停营业", "休息")):
|
||||
return _store_closed_text(reason)
|
||||
if "单点不配送" in reason:
|
||||
return reason
|
||||
return None
|
||||
|
||||
|
||||
def _derive_fail_display(
|
||||
information: str | None, platform_results: dict | None
|
||||
) -> str | None:
|
||||
"""失败记录展示文案:information 具体则直出;笼统则从 platform_results 捞/补判;
|
||||
都拿不到 → None(端侧品牌兜底)。仅在 status=failed 时调用。"""
|
||||
info = (information or "").strip()
|
||||
text = info if (info and info not in _GENERIC_INFO) else _target_display_reason(
|
||||
platform_results
|
||||
)
|
||||
return text[:256] if text else None
|
||||
|
||||
|
||||
def _derive(payload: ComparisonRecordIn) -> dict:
|
||||
"""从上报 payload 派生结构化列(best/saved/is_source_best/status)。"""
|
||||
results = payload.comparison_results
|
||||
@@ -105,6 +180,11 @@ def _derive(payload: ComparisonRecordIn) -> dict:
|
||||
"saved_amount_cents": saved_amount_cents,
|
||||
"is_source_best": is_source_best,
|
||||
"status": status,
|
||||
"fail_reason": (
|
||||
_derive_fail_display(payload.information, _pr)
|
||||
if status == "failed"
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -414,11 +494,19 @@ def harvest_done(
|
||||
行不存在(理论上帧0已建;防御)则新建。"""
|
||||
results = done_params.get("comparison_results") or []
|
||||
derived = _derive_from_results(results, done_params.get("platform_results"))
|
||||
fail_reason = (
|
||||
_derive_fail_display(
|
||||
done_params.get("information"), done_params.get("platform_results")
|
||||
)
|
||||
if derived["status"] == "failed"
|
||||
else None
|
||||
)
|
||||
# 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
|
||||
items = next((r.get("items") or [] for r in results if r.get("is_source")), [])
|
||||
fields = dict(
|
||||
business_type=business_type or "food",
|
||||
information=done_params.get("information") or None,
|
||||
fail_reason=fail_reason,
|
||||
# best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补;
|
||||
# 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。
|
||||
trace_url=trace_url or done_params.get("trace_url"),
|
||||
|
||||
@@ -173,6 +173,8 @@ class ComparisonRecordOut(BaseModel):
|
||||
skipped_dish_count: int | None = None
|
||||
status: str
|
||||
information: str | None = None
|
||||
# 失败卡「原因」文案:具体失败给具体原因,纯系统失败为 None(端侧品牌兜底)。见模型 fail_reason。
|
||||
fail_reason: str | None = None
|
||||
items: list = []
|
||||
comparison_results: list = []
|
||||
skipped_dish_names: list = []
|
||||
|
||||
@@ -135,7 +135,7 @@ def repair_missing_comparison_llm_costs(
|
||||
select(ComparisonRecord.id, ComparisonRecord.trace_id)
|
||||
.where(
|
||||
*date_conditions,
|
||||
ComparisonRecord.status.in_(("success", "failed")),
|
||||
ComparisonRecord.status.in_(("success", "failed", "cancelled")),
|
||||
ComparisonRecord.llm_cost_yuan.is_(None),
|
||||
)
|
||||
.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc())
|
||||
|
||||
@@ -219,6 +219,115 @@ def test_user_reward_stats_can_scope_withdrawals_by_account(
|
||||
assert invite.json()["cash_balance_cents"] == 456
|
||||
|
||||
|
||||
def test_user_reward_stats_draw_ecpm_uses_all_filtered_impressions(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
"""Draw 平均 eCPM 应与收益报表一致,不能只平均成功发奖记录。"""
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
|
||||
uid = _seed_user_with_data("13800000024")
|
||||
created_at = datetime(2038, 1, 15, 4, tzinfo=UTC)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add_all(
|
||||
[
|
||||
AdFeedRewardRecord(
|
||||
client_event_id="reward-stats-granted-high",
|
||||
ad_session_id="reward-stats-granted-high",
|
||||
user_id=uid,
|
||||
reward_date="2038-01-15",
|
||||
duration_seconds=10,
|
||||
unit_count=1,
|
||||
ecpm_raw="9000",
|
||||
ad_type="draw",
|
||||
feed_scene="coupon",
|
||||
app_env="prod",
|
||||
our_code_id="104098712",
|
||||
coin=9,
|
||||
status="granted",
|
||||
created_at=created_at,
|
||||
),
|
||||
AdEcpmRecord(
|
||||
user_id=uid,
|
||||
ad_type="draw",
|
||||
feed_scene="coupon",
|
||||
ad_session_id="reward-stats-impression-low",
|
||||
app_env="prod",
|
||||
our_code_id="104098712",
|
||||
ecpm_raw="1000",
|
||||
report_date="2038-01-15",
|
||||
created_at=created_at,
|
||||
),
|
||||
AdEcpmRecord(
|
||||
user_id=uid,
|
||||
ad_type="feed",
|
||||
feed_scene="coupon",
|
||||
ad_session_id="reward-stats-impression-mid",
|
||||
app_env="prod",
|
||||
our_code_id="104098712",
|
||||
ecpm_raw="3000",
|
||||
report_date="2038-01-15",
|
||||
created_at=created_at,
|
||||
),
|
||||
# 同用户但不同场景/环境/非业务代码位,均不应进入本次详情筛选。
|
||||
AdEcpmRecord(
|
||||
user_id=uid,
|
||||
ad_type="draw",
|
||||
feed_scene="comparison",
|
||||
ad_session_id="reward-stats-other-scene",
|
||||
app_env="prod",
|
||||
our_code_id="104098712",
|
||||
ecpm_raw="7000",
|
||||
report_date="2038-01-15",
|
||||
created_at=created_at,
|
||||
),
|
||||
AdEcpmRecord(
|
||||
user_id=uid,
|
||||
ad_type="draw",
|
||||
feed_scene="coupon",
|
||||
ad_session_id="reward-stats-test-env",
|
||||
app_env="test",
|
||||
our_code_id="104127529",
|
||||
ecpm_raw="8000",
|
||||
report_date="2038-01-15",
|
||||
created_at=created_at,
|
||||
),
|
||||
AdEcpmRecord(
|
||||
user_id=uid,
|
||||
ad_type="draw",
|
||||
feed_scene="coupon",
|
||||
ad_session_id="reward-stats-non-business",
|
||||
app_env="prod",
|
||||
our_code_id="demo-slot",
|
||||
ecpm_raw="9000",
|
||||
report_date="2038-01-15",
|
||||
created_at=created_at,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
response = admin_client.get(
|
||||
f"/admin/api/users/{uid}/reward-stats",
|
||||
params={
|
||||
"date_from": "2038-01-15T00:00:00Z",
|
||||
"date_to": "2038-01-15T23:59:59Z",
|
||||
"app_env": "prod",
|
||||
"revenue_scope": "business",
|
||||
"feed_scene": "coupon",
|
||||
},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["feed_count"] == 1
|
||||
# 全部真实展示 (1000 + 3000) / 2;不能返回成功发奖记录的 9000。
|
||||
assert data["feed_avg_ecpm"] == 2000.0
|
||||
|
||||
|
||||
def test_user_coin_record_sort_accepts_mixed_timezone_datetimes() -> None:
|
||||
"""线上 PostgreSQL 返回 aware,SQLite/历史转换可能返回 naive,二者必须可混排。"""
|
||||
naive = datetime(2038, 1, 1, 8, 0)
|
||||
|
||||
@@ -102,6 +102,7 @@ def test_harvest_done_derives_and_newly_success_once(client) -> None:
|
||||
assert rec.is_source_best is False
|
||||
assert rec.store_name == "测试店"
|
||||
assert rec.information == "美团更便宜"
|
||||
assert rec.fail_reason is None # 成功记录不派生失败原因
|
||||
assert rec.items == [{"name": "肥牛饭", "qty": 1}]
|
||||
assert rec.trace_url.endswith("/done/")
|
||||
# 再来一次(重试 done)→ 已 success,newly_success=False(发奖不重复触发)
|
||||
@@ -110,6 +111,35 @@ def test_harvest_done_derives_and_newly_success_once(client) -> None:
|
||||
assert newly2 is False
|
||||
|
||||
|
||||
def test_harvest_done_failed_derives_fail_reason(client) -> None:
|
||||
"""failed 记录:记录级 information 笼统,但 fail_reason 从 platform_results 救出具体原因
|
||||
(id 3030 型:美团系统失败 + 京东 items_not_found → 展示京东那条)。"""
|
||||
tid = _tid()
|
||||
done_failed = {
|
||||
"comparison_results": [
|
||||
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购",
|
||||
"package": "com.taobao.taobao", "price": 23.04, "is_source": True, "rank": 1,
|
||||
"items": [{"name": "肥牛饭", "qty": 1}]},
|
||||
],
|
||||
"platform_results": {
|
||||
"taobao_flash": {"is_source": True, "status": "source", "price": 23.04},
|
||||
"meituan_waimai": {"is_source": False, "status": "failed",
|
||||
"reason": "搜索店铺失败, 无法跳转到搜索页"},
|
||||
"jd_waimai_standalone": {"is_source": False, "status": "items_not_found",
|
||||
"reason": "京东外卖此店内未找到这些菜品"},
|
||||
},
|
||||
"information": "比价过程出错,请稍后重试",
|
||||
}
|
||||
with SessionLocal() as db:
|
||||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||||
rec, newly = crud.harvest_done(db, trace_id=tid, user_id=None,
|
||||
done_params=done_failed)
|
||||
assert newly is False # 没落成 success
|
||||
assert rec.status == "failed"
|
||||
assert rec.fail_reason == "京东外卖此店内未找到这些菜品"
|
||||
assert rec.information == "比价过程出错,请稍后重试" # 原文案仍留存
|
||||
|
||||
|
||||
def test_harvest_abort_cancels_running(client) -> None:
|
||||
tid = _tid()
|
||||
with SessionLocal() as db:
|
||||
@@ -233,7 +263,9 @@ def test_trace_finalize_harvests_abort(client) -> None:
|
||||
with SessionLocal() as db: # 先有 running 行(帧0建的)
|
||||
crud.harvest_running(db, trace_id=tid, user_id=None)
|
||||
p, _cap = _mock_pricebot({"trace_url": "https://price.shaguabijia.com/traces/fin/"})
|
||||
with p:
|
||||
with p, patch(
|
||||
"app.api.v1.compare.backfill_comparison_llm_cost"
|
||||
) as backfill:
|
||||
r = client.post("/api/v1/trace/finalize",
|
||||
json={"trace_id": tid, "status": "cancelled", "reason": "用户终止"})
|
||||
assert r.status_code == 200
|
||||
@@ -241,6 +273,7 @@ def test_trace_finalize_harvests_abort(client) -> None:
|
||||
rec = _get(db, tid)
|
||||
assert rec is not None and rec.status == "cancelled"
|
||||
assert rec.trace_url.endswith("/fin/")
|
||||
backfill.assert_called_once_with(rec.id, tid)
|
||||
|
||||
|
||||
def test_price_step_binds_user_when_authed(client) -> None:
|
||||
|
||||
@@ -72,6 +72,7 @@ def test_backfill_retries_then_persists_cost(monkeypatch):
|
||||
|
||||
def test_repair_batch_only_targets_terminal_missing_rows(monkeypatch):
|
||||
missing_id = _record("llm-repair-missing")
|
||||
cancelled_id = _record("llm-repair-cancelled", status="cancelled")
|
||||
running_id = _record("llm-repair-running", status="running")
|
||||
calls = [
|
||||
{
|
||||
@@ -96,12 +97,15 @@ def test_repair_batch_only_targets_terminal_missing_rows(monkeypatch):
|
||||
)
|
||||
assert result["repaired"] >= 1
|
||||
assert "llm-repair-missing" in seen
|
||||
assert "llm-repair-cancelled" in seen
|
||||
assert "llm-repair-running" not in seen
|
||||
with SessionLocal() as db:
|
||||
assert db.get(ComparisonRecord, missing_id).llm_cost_yuan is not None
|
||||
assert db.get(ComparisonRecord, cancelled_id).llm_cost_yuan is not None
|
||||
assert db.get(ComparisonRecord, running_id).llm_cost_yuan is None
|
||||
finally:
|
||||
_delete(missing_id)
|
||||
_delete(cancelled_id)
|
||||
_delete(running_id)
|
||||
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ def test_xiaomi_accessibility_payload(monkeypatch) -> None:
|
||||
body = captured["data"]
|
||||
assert body["registration_id"] == "xm-regid"
|
||||
assert body["restricted_package_name"] == "com.jishisongfu.shaguabijia"
|
||||
assert body["pushMode"] == 1
|
||||
assert json.loads(body["payload"]) == {"type": "accessibility_disabled"}
|
||||
assert "extra.channel_id" not in body
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""失败卡展示原因派生(repositories.comparison._derive_fail_display)单元测试。
|
||||
|
||||
用例取自线上真实 failed 记录(platform_results 形态),覆盖:
|
||||
- information 具体 → 直出
|
||||
- information 笼统 + platform_results 有干净业务结局 → 救援出该原因(id 3030/2964 型)
|
||||
- information 笼统 + 仅系统失败(搜索失败等黑话)→ None(端侧品牌兜底,id 3027 型)
|
||||
- store_closed / no_delivery 被 pricebot 漏成 status=failed → 按 reason 关键字补判
|
||||
- 打烊类脏店名 blob → 统一简短模板
|
||||
- platform_results 为空 / 非对象 → None
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.repositories import comparison as crud
|
||||
|
||||
|
||||
def test_specific_information_passthrough() -> None:
|
||||
# information 本身具体(未达起送/找不到菜等)→ 直出,不看 platform_results
|
||||
assert (
|
||||
crud._derive_fail_display("淘宝闪购未达起送门槛,可加菜凑单后下单", {})
|
||||
== "淘宝闪购未达起送门槛,可加菜凑单后下单"
|
||||
)
|
||||
assert crud._derive_fail_display("未识别到商品", {}) == "未识别到商品"
|
||||
|
||||
|
||||
def test_generic_info_rescued_from_items_not_found() -> None:
|
||||
# id 3030 型:美团系统失败 + 京东 items_not_found,记录级 information 笼统 → 救出京东那条
|
||||
pr = {
|
||||
"eleme": {"is_source": True, "status": "source", "price": 23.04},
|
||||
"meituan_waimai": {
|
||||
"is_source": False, "status": "failed",
|
||||
"reason": "搜索店铺失败, 无法跳转到搜索页",
|
||||
},
|
||||
"jd_waimai_standalone": {
|
||||
"is_source": False, "status": "items_not_found",
|
||||
"reason": "京东外卖此店内未找到这些菜品",
|
||||
},
|
||||
}
|
||||
assert (
|
||||
crud._derive_fail_display("比价过程出错,请稍后重试", pr)
|
||||
== "京东外卖此店内未找到这些菜品"
|
||||
)
|
||||
|
||||
|
||||
def test_generic_info_rescued_from_store_not_found() -> None:
|
||||
# id 2964 型:美团系统失败 + 京东 store_not_found → 救出京东相似店铺文案
|
||||
pr = {
|
||||
"taobao_flash": {"is_source": True, "status": "source", "price": 127.98},
|
||||
"meituan": {
|
||||
"is_source": False, "status": "failed",
|
||||
"reason": "比价过程出错,请稍后重试",
|
||||
},
|
||||
"jd_waimai": {
|
||||
"is_source": False, "status": "store_not_found",
|
||||
"reason": "未在京东找到「黔珍味·贵州牛肉蘸水健康菜 (望京店)」相似店铺",
|
||||
},
|
||||
}
|
||||
assert (
|
||||
crud._derive_fail_display("比价过程出错,请稍后重试", pr)
|
||||
== "未在京东找到「黔珍味·贵州牛肉蘸水健康菜 (望京店)」相似店铺"
|
||||
)
|
||||
|
||||
|
||||
def test_generic_info_pure_system_failure_returns_none() -> None:
|
||||
# id 3027 型:唯一目标平台是自动化黑话失败 → 不给用户看 → None(端侧品牌兜底)
|
||||
pr = {
|
||||
"eleme": {"is_source": True, "status": "source", "price": 18.83},
|
||||
"meituan_waimai": {
|
||||
"is_source": False, "status": "failed",
|
||||
"reason": "搜索店铺失败, 无法跳转到搜索页",
|
||||
},
|
||||
}
|
||||
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) is None
|
||||
|
||||
|
||||
def test_store_closed_leaked_to_failed_is_rescued_and_cleaned() -> None:
|
||||
# 打烊被漏成 status=failed;reason 常带脏店名 blob → 统一简短模板
|
||||
pr = {
|
||||
"jd_waimai": {"is_source": True, "status": "source", "price": 25},
|
||||
"taobao_flash": {
|
||||
"is_source": False, "status": "failed",
|
||||
"reason": "淘宝闪购「沙胆彪炭炉牛杂煲(...),蜂鸟准时达,月售300+,起送¥20」本店已休息,无法比价",
|
||||
},
|
||||
}
|
||||
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) == "门店休息中,无法比价"
|
||||
|
||||
pr2 = {
|
||||
"taobao_flash": {"is_source": True, "status": "source", "price": 31.83},
|
||||
"meituan": {
|
||||
"is_source": False, "status": "failed",
|
||||
"reason": "美团「奈雪的茶(北京王府井奥莱·香江」门店已打烊,无法比价",
|
||||
},
|
||||
}
|
||||
assert crud._derive_fail_display("比价出错", pr2) == "门店已打烊,无法比价"
|
||||
|
||||
|
||||
def test_no_delivery_leaked_to_failed_is_rescued() -> None:
|
||||
# 单点不配送被漏成 status=failed;reason 本身干净 → 直接用
|
||||
reason = "京东外卖该商家所选商品单点不配送,无法进入结算比价"
|
||||
pr = {
|
||||
"taobao_flash": {"is_source": True, "status": "source", "price": 20.1},
|
||||
"jd_waimai_standalone": {
|
||||
"is_source": False, "status": "failed", "reason": reason,
|
||||
},
|
||||
}
|
||||
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) == reason
|
||||
|
||||
|
||||
def test_empty_or_missing_platform_results_returns_none() -> None:
|
||||
# 「比价出错」+ 空 {} / None / 非对象:引擎早夭,无可展示原因 → None
|
||||
assert crud._derive_fail_display("比价出错", {}) is None
|
||||
assert crud._derive_fail_display("比价过程出错,请稍后重试", None) is None
|
||||
assert crud._derive_fail_display("比价出错", []) is None # 老 array 形态,防御
|
||||
|
||||
|
||||
def test_clean_status_wins_over_priority_order() -> None:
|
||||
# 多个业务结局同现时按 _BIZ_STATUS_PRIORITY 选(below_minimum 优先于 store_not_found)
|
||||
pr = {
|
||||
"src": {"is_source": True, "status": "source", "price": 30},
|
||||
"a": {"is_source": False, "status": "store_not_found", "reason": "未找到店铺A"},
|
||||
"b": {"is_source": False, "status": "below_minimum", "reason": "B未达起送门槛"},
|
||||
}
|
||||
assert crud._derive_fail_display("比价过程出错,请稍后重试", pr) == "B未达起送门槛"
|
||||
Reference in New Issue
Block a user