Compare commits

...

4 Commits

Author SHA1 Message Date
unknown 252a2fdc5d 功能:支持动态配置每日比价次数上限 2026-07-28 21:49:24 +08:00
linkeyu 90c6fe599a 修复:统一用户Draw信息流eCPM统计口径 (#190)
## 问题

业务收益详情的平均 Draw eCPM 仅平均成功发奖记录,会排除未发奖的真实展示,导致数值系统性偏高,且与广告收益页口径不一致。

## 修复

- `feed_avg_ecpm` 改为从 `ad_ecpm_record` 的全部 `draw/feed` 实际展示计算
- 成功发奖、未发奖展示均纳入,每次展示等权
- 日期、正式/测试环境、业务代码位、领券/比价场景支持与广告收益页对齐
- 奖励份数仍基于成功发奖表,不混用展示数据源
- 复用广告收益报表的业务代码位集合

## 线上数据复算

2026-07-25、正式业务、用户 #33:

- 旧口径(只看成功发奖):`29.9117 元/千次`
- 新口径(333 次真实展示):`19.9926 元/千次`
- 新值与广告收益报表一致

## 验证

- 新增成功/未发奖、场景、环境、业务代码位回归用例
- `tests/test_admin_read.py` + `tests/test_admin_ad_revenue_scope.py`:29 项全通过
- Ruff 改动文件检查通过

## 上线顺序

本 PR 需先于管理后台配套 PR 上线。

---------

Co-authored-by: guke <guke@wonderable.ai>
Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #190
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-28 17:58:23 +08:00
linkeyu e529112a90 修复中途退出比价的 LLM 成本回填 (#191)
## 问题

比价记录进入中途退出后未触发 LLM 成本回填,周期补偿也未扫描 cancelled,导致实际已有 LLM 调用的记录长期显示成本、LLM、TOKEN 为空。

## 修改

- finalize 落库后立即追加 LLM 成本回填
- 周期补偿范围加入 cancelled
- 保持无有效调用和全调用失败记录不伪造成本
- 增加即时回填和周期补偿回归测试

## 验证

- ruff 检查通过
- 相关测试 28 项通过
- 全仓 626 项通过;主干既有失败已在未修改的 origin/main 复现

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #191
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-28 17:57:52 +08:00
guke 50da718e35 比价记录失败卡展示具体原因(新增 fail_reason) (#189)
失败记录不再一律「网络开小差」:新增记录级 fail_reason 派生列——information
具体则直出,笼统则从 platform_results 救出业务原因(找不到店/菜、未起送、打烊、
单点不配送等),纯系统失败为 None → 端侧品牌兜底。store_closed/no_delivery 被
pricebot 漏成 status=failed 的按 reason 补判,打烊脏店名统一简短模板。接入
harvest_done 与灰度期 upsert_record 两条写路径。

- models: comparison_record.fail_reason 列
- repositories: _derive_fail_display + 补判/清洗 helper,两条写路径接入
- schemas: ComparisonRecordOut 暴露 fail_reason
- alembic: 加列 + 回填老 specific 失败记录
- tests: _derive_fail_display 单测(8 例)+ harvest 失败落库集成测试

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #189
2026-07-28 14:04:36 +08:00
19 changed files with 570 additions and 38 deletions
@@ -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')
+5 -5
View File
@@ -43,7 +43,7 @@ _KNOWN_PROD_BUSINESS_CODE_IDS = frozenset({"104098712", "104099389"})
_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"}) _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 聚合代码位。""" """返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。"""
prod_config = app_config.get_ad_config(db) prod_config = app_config.get_ad_config(db)
prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | { prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | {
@@ -320,10 +320,10 @@ def ad_revenue_report(
# 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试 # 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试
# demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。 # demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。
business_code_ids: set[str] | None = None business_ids: set[str] | None = None
if revenue_scope == "business": if revenue_scope == "business":
business_code_ids = _business_code_ids(db, app_env) business_ids = business_code_ids(db, app_env)
events = [e for e in events if e.get("our_code_id") in business_code_ids] events = [e for e in events if e.get("our_code_id") in business_ids]
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排; # 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。 # 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
@@ -381,7 +381,7 @@ def ad_revenue_report(
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,
app_env=app_env, app_env=app_env,
our_code_ids=business_code_ids, our_code_ids=business_ids,
) )
if pangle_aggs: if pangle_aggs:
by_date = {a["date"]: a for a in pangle_aggs} by_date = {a["date"]: a for a in pangle_aggs}
+57 -14
View File
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
from app.core import rewards from app.core import rewards
from app.core.config import settings 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_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord from app.models.ad_reward import AdRewardRecord
from app.models.admin import AdminAuditLog from app.models.admin import AdminAuditLog
@@ -1257,11 +1258,15 @@ def user_reward_stats(
date_from: datetime | None = None, date_from: datetime | None = None,
date_to: datetime | None = None, date_to: datetime | None = None,
withdraw_source: str | None = None, withdraw_source: str | None = None,
app_env: str | None = None,
revenue_scope: str = "all",
feed_scene: str | None = None,
) -> dict: ) -> dict:
"""提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。 """提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。
口径:激励视频/信息流只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加); 口径:激励视频/信息流奖励数量只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加)
平均 eCPM 用原始分值(分/千次)按记录取算术平均;各「提现」= 该来源累计金币折现。 平均 Draw eCPM 与广告收益报表一致:基于 ad_ecpm_record 的全部 draw/feed 展示记录计算,
不以是否发奖为筛选条件。各「提现」= 该来源累计金币折现。
传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。 传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。
""" """
withdraw_source_conds = ( withdraw_source_conds = (
@@ -1296,30 +1301,68 @@ def user_reward_stats(
# 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时, # 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时,
# SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。 # 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( rv = db.execute(
select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where( select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where(
AdRewardRecord.user_id == user_id, *rv_conds,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
) )
).all() ).all()
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw] 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) 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( select(
AdFeedRewardRecord.unit_count, AdFeedRewardRecord.unit_count,
AdFeedRewardRecord.ecpm_raw,
AdFeedRewardRecord.coin, AdFeedRewardRecord.coin,
).where( ).where(
AdFeedRewardRecord.user_id == user_id, *feed_reward_conds,
AdFeedRewardRecord.status == "granted",
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
) )
).all() ).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_rewards)
feed_coins = sum(f.coin for f in feed)
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( trad_coins = db.execute(
select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where( select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
@@ -1338,7 +1381,7 @@ def user_reward_stats(
"reward_video_count": len(rv), "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_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), "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_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0,
"feed_cash_cents": _coins_to_cents(feed_coins), "feed_cash_cents": _coins_to_cents(feed_coins),
} }
+8
View File
@@ -88,6 +88,11 @@ def get_user_reward_stats(
withdraw_source: Annotated[ withdraw_source: Annotated[
str | None, Query(pattern="^(coin_cash|invite_cash)$") str | None, Query(pattern="^(coin_cash|invite_cash)$")
] = None, ] = 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: ) -> UserRewardStats:
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。""" """提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
if not user_repo.user_exists(db, user_id): if not user_repo.user_exists(db, user_id):
@@ -99,6 +104,9 @@ def get_user_reward_stats(
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,
withdraw_source=withdraw_source, withdraw_source=withdraw_source,
app_env=app_env,
revenue_scope=revenue_scope,
feed_scene=feed_scene,
) )
) )
+1 -1
View File
@@ -23,7 +23,7 @@ class RiskMonitorSummary(BaseModel):
class RiskRuleConfig(BaseModel): class RiskRuleConfig(BaseModel):
sms_hourly_threshold: int = Field(ge=1, le=5) sms_hourly_threshold: int = Field(ge=1, le=5)
oneclick_daily_threshold: int = Field(ge=1, le=100_000) oneclick_daily_threshold: int = Field(ge=1, le=100_000)
compare_daily_threshold: int = Field(ge=1, le=100) compare_daily_threshold: int = Field(ge=1, le=100_000)
class RiskIncidentItem(BaseModel): class RiskIncidentItem(BaseModel):
+1 -1
View File
@@ -60,7 +60,7 @@ class UserRewardStats(BaseModel):
reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次) reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次)
reward_video_cash_cents: int # 激励视频提现(金币折现) reward_video_cash_cents: int # 激励视频提现(金币折现)
feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加) feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加)
feed_avg_ecpm: float # 平均信息流广告 eCPM(分/千次) feed_avg_ecpm: float # 全部 Draw/feed 实际展示的平均 eCPM(分/千次,含未发奖展示)
feed_cash_cents: int # 信息流广告提现(金币折现) feed_cash_cents: int # 信息流广告提现(金币折现)
+11 -3
View File
@@ -108,7 +108,7 @@ def _harvest_done_blocking(
def _harvest_abort_blocking( def _harvest_abort_blocking(
trace_id: str, status_hint: str, reason: str | None, trace_url: str | None, trace_id: str, status_hint: str, reason: str | None, trace_url: str | None,
) -> None: ) -> int | None:
with SessionLocal() as db: with SessionLocal() as db:
rec = crud_compare.harvest_abort( rec = crud_compare.harvest_abort(
db, trace_id=trace_id, status=status_hint, reason=reason, trace_url=trace_url, 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", extra={"phase": "harvest_abort",
"status": (rec.status if rec else None), "reason": reason}, "status": (rec.status if rec else None), "reason": reason},
) )
return rec.id if rec is not None else None
async def _forward( async def _forward(
@@ -291,7 +292,10 @@ async def trace_epilogue(
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)") @router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)")
async def trace_finalize( async def trace_finalize(
request: Request, user: OptionalUser, db: DbSession request: Request,
background_tasks: BackgroundTasks,
user: OptionalUser,
db: DbSession,
) -> dict[str, Any]: ) -> dict[str, Any]:
_ensure_compare_allowed(user, db) _ensure_compare_allowed(user, db)
# 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url}; # 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url};
@@ -302,12 +306,16 @@ async def trace_finalize(
request, "/api/trace/finalize", user, harvest_first_frame=False, request, "/api/trace/finalize", user, harvest_first_frame=False,
) )
try: try:
await run_in_threadpool( record_id = await run_in_threadpool(
_harvest_abort_blocking, trace_id, _harvest_abort_blocking, trace_id,
(meta.get("status") or "cancelled"), (meta.get("status") or "cancelled"),
(meta.get("reason") or meta.get("information")), (meta.get("reason") or meta.get("information")),
(resp.get("trace_url") if isinstance(resp, dict) else None), (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 except Exception as e: # noqa: BLE001
logger.warning("harvest_abort failed trace=%s: %s", trace_id, e) logger.warning("harvest_abort failed trace=%s: %s", trace_id, e)
return resp return resp
+7 -5
View File
@@ -38,13 +38,14 @@ router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
@router.post( @router.post(
"/start", "/start",
response_model=CompareStartReserveOut, response_model=CompareStartReserveOut,
summary="预占一次当日比价发起次数(每人每天最多100次", summary="预占一次当日比价发起次数(上限由风控监控配置",
) )
def reserve_compare_start( def reserve_compare_start(
payload: CompareStartReserveIn, payload: CompareStartReserveIn,
user: CurrentUser, user: CurrentUser,
db: DbSession, db: DbSession,
) -> CompareStartReserveOut: ) -> CompareStartReserveOut:
daily_limit = risk_repo.get_rule_threshold(db, risk_repo.RULE_COMPARE_DAILY)
if risk_repo.is_restricted( if risk_repo.is_restricted(
db, db,
subject_type="user", subject_type="user",
@@ -57,25 +58,26 @@ def reserve_compare_start(
db, db,
user_id=user.id, user_id=user.id,
trace_id=payload.trace_id, trace_id=payload.trace_id,
daily_limit=daily_limit,
business_type=payload.business_type, business_type=payload.business_type,
device_id=payload.device_id, device_id=payload.device_id,
) )
except crud_compare.DailyCompareStartLimitExceeded: except crud_compare.DailyCompareStartLimitExceeded:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="今日已比价超过100次,请明天再试", detail=f"今日已比价超过{daily_limit}次,请明天再试",
) from None ) from None
except crud_compare.ComparisonTraceOwnershipError: except crud_compare.ComparisonTraceOwnershipError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="比价任务标识冲突,请重新发起", detail="比价任务标识冲突,请重新发起",
) from None ) from None
# 风控阈值由后台动态配置,不能再只在固定 100 次业务上限处同步 # 告警和业务限流共用同一动态阈值,避免后台已修改但用户侧仍固定 100 次。
risk_repo.sync_compare_incident(db, user_id=user.id, at=rec.created_at) risk_repo.sync_compare_incident(db, user_id=user.id, at=rec.created_at)
return CompareStartReserveOut( return CompareStartReserveOut(
limit=crud_compare.DAILY_COMPARE_START_LIMIT, limit=daily_limit,
used=used, used=used,
remaining=max(crud_compare.DAILY_COMPARE_START_LIMIT - used, 0), remaining=max(daily_limit - used, 0),
) )
+3 -3
View File
@@ -134,12 +134,12 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
}, },
RISK_COMPARE_DAILY_THRESHOLD_KEY: { RISK_COMPARE_DAILY_THRESHOLD_KEY: {
"default": 100, "default": 100,
"label": "比价账户每日告警阈值", "label": "比价账户每日上限与告警阈值",
"group": "风控", "group": "风控",
"type": "int", "type": "int",
"min": 1, "min": 1,
"max": 100, "max": 100_000,
"hidden": True, "hidden": True,
"help": "同一账户北京时间同一自然日发起比价达到该次数时告警。", "help": "同一账户北京时间自然日最多可发起比价次数;达到该次数时同步告警。",
}, },
} }
+5
View File
@@ -102,6 +102,11 @@ class ComparisonRecord(Base):
# done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…"; # done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…";
# 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。 # 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。
information: Mapped[str | None] = mapped_column(String(256), nullable=True) 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,越详细越好)===== # ===== 明细(JSON,越详细越好)=====
# 下单菜品 [{name, qty, specs?}] # 下单菜品 [{name, qty, specs?}]
+91 -4
View File
@@ -17,8 +17,6 @@ from app.models.savings import SavingsRecord
from app.models.user import User from app.models.user import User
from app.schemas.compare_record import ComparisonRecordIn from app.schemas.compare_record import ComparisonRecordIn
DAILY_COMPARE_START_LIMIT = 100
class DailyCompareStartLimitExceeded(Exception): class DailyCompareStartLimitExceeded(Exception):
"""The authenticated user has consumed today's comparison-start quota.""" """The authenticated user has consumed today's comparison-start quota."""
@@ -52,6 +50,81 @@ def _product_names_from_items(items: list | None) -> str | None:
return joined[:500] or 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: def _derive(payload: ComparisonRecordIn) -> dict:
"""从上报 payload 派生结构化列(best/saved/is_source_best/status)。""" """从上报 payload 派生结构化列(best/saved/is_source_best/status)。"""
results = payload.comparison_results results = payload.comparison_results
@@ -105,6 +178,11 @@ def _derive(payload: ComparisonRecordIn) -> dict:
"saved_amount_cents": saved_amount_cents, "saved_amount_cents": saved_amount_cents,
"is_source_best": is_source_best, "is_source_best": is_source_best,
"status": status, "status": status,
"fail_reason": (
_derive_fail_display(payload.information, _pr)
if status == "failed"
else None
),
} }
@@ -283,11 +361,12 @@ def reserve_daily_start(
*, *,
user_id: int, user_id: int,
trace_id: str, trace_id: str,
daily_limit: int,
business_type: str = "food", business_type: str = "food",
device_id: str | None = None, device_id: str | None = None,
now: datetime | None = None, now: datetime | None = None,
) -> tuple[ComparisonRecord, int]: ) -> tuple[ComparisonRecord, int]:
"""Atomically reserve one of a user's 100 Beijing-day comparison starts. """Atomically reserve one of a user's configured Beijing-day comparison starts.
``trace_id`` makes client retries idempotent. Locking the user row serializes ``trace_id`` makes client retries idempotent. Locking the user row serializes
concurrent starts for one account, so parallel requests cannot both consume concurrent starts for one account, so parallel requests cannot both consume
@@ -333,7 +412,7 @@ def reserve_daily_start(
ComparisonRecord.created_at < day_end, ComparisonRecord.created_at < day_end,
) )
) or 0 ) or 0
if used >= DAILY_COMPARE_START_LIMIT: if used >= daily_limit:
raise DailyCompareStartLimitExceeded raise DailyCompareStartLimitExceeded
rec = ComparisonRecord( rec = ComparisonRecord(
@@ -414,11 +493,19 @@ def harvest_done(
行不存在(理论上帧0已建;防御)则新建""" 行不存在(理论上帧0已建;防御)则新建"""
results = done_params.get("comparison_results") or [] results = done_params.get("comparison_results") or []
derived = _derive_from_results(results, done_params.get("platform_results")) 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 # 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
items = next((r.get("items") or [] for r in results if r.get("is_source")), []) items = next((r.get("items") or [] for r in results if r.get("is_source")), [])
fields = dict( fields = dict(
business_type=business_type or "food", business_type=business_type or "food",
information=done_params.get("information") or None, information=done_params.get("information") or None,
fail_reason=fail_reason,
# best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补; # best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补;
# 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。 # 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。
trace_url=trace_url or done_params.get("trace_url"), trace_url=trace_url or done_params.get("trace_url"),
+2
View File
@@ -173,6 +173,8 @@ class ComparisonRecordOut(BaseModel):
skipped_dish_count: int | None = None skipped_dish_count: int | None = None
status: str status: str
information: str | None = None information: str | None = None
# 失败卡「原因」文案:具体失败给具体原因,纯系统失败为 None(端侧品牌兜底)。见模型 fail_reason。
fail_reason: str | None = None
items: list = [] items: list = []
comparison_results: list = [] comparison_results: list = []
skipped_dish_names: list = [] skipped_dish_names: list = []
+1 -1
View File
@@ -135,7 +135,7 @@ def repair_missing_comparison_llm_costs(
select(ComparisonRecord.id, ComparisonRecord.trace_id) select(ComparisonRecord.id, ComparisonRecord.trace_id)
.where( .where(
*date_conditions, *date_conditions,
ComparisonRecord.status.in_(("success", "failed")), ComparisonRecord.status.in_(("success", "failed", "cancelled")),
ComparisonRecord.llm_cost_yuan.is_(None), ComparisonRecord.llm_cost_yuan.is_(None),
) )
.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc()) .order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc())
+109
View File
@@ -219,6 +219,115 @@ def test_user_reward_stats_can_scope_withdrawals_by_account(
assert invite.json()["cash_balance_cents"] == 456 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: def test_user_coin_record_sort_accepts_mixed_timezone_datetimes() -> None:
"""线上 PostgreSQL 返回 awareSQLite/历史转换可能返回 naive,二者必须可混排。""" """线上 PostgreSQL 返回 awareSQLite/历史转换可能返回 naive,二者必须可混排。"""
naive = datetime(2038, 1, 1, 8, 0) naive = datetime(2038, 1, 1, 8, 0)
+48
View File
@@ -3,14 +3,32 @@ from __future__ import annotations
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
import pytest
from sqlalchemy import func, select from sqlalchemy import func, select
from app.core.config_schema import RISK_COMPARE_DAILY_THRESHOLD_KEY
from app.core.rewards import CN_TZ from app.core.rewards import CN_TZ
from app.core.security import decode_token from app.core.security import decode_token
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.app_config import AppConfig
from app.models.comparison import ComparisonRecord from app.models.comparison import ComparisonRecord
@pytest.fixture(autouse=True)
def _reset_compare_daily_limit():
with SessionLocal() as db:
db.query(AppConfig).filter(
AppConfig.key == RISK_COMPARE_DAILY_THRESHOLD_KEY
).delete()
db.commit()
yield
with SessionLocal() as db:
db.query(AppConfig).filter(
AppConfig.key == RISK_COMPARE_DAILY_THRESHOLD_KEY
).delete()
db.commit()
def _login(client) -> tuple[str, int]: def _login(client) -> tuple[str, int]:
phone = f"137{int(time.time() * 1000) % 100000000:08d}" phone = f"137{int(time.time() * 1000) % 100000000:08d}"
sent = client.post("/api/v1/auth/sms/send", json={"phone": phone}) sent = client.post("/api/v1/auth/sms/send", json={"phone": phone})
@@ -118,3 +136,33 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
ComparisonRecord.trace_id == rejected_trace ComparisonRecord.trace_id == rejected_trace
) )
) == 0 ) == 0
def test_compare_start_uses_dynamic_risk_monitor_limit(client) -> None:
with SessionLocal() as db:
db.add(AppConfig(key=RISK_COMPARE_DAILY_THRESHOLD_KEY, value=2))
db.commit()
token, user_id = _login(client)
first = client.post(
"/api/v1/compare/start",
json={"trace_id": f"quota-dynamic-{user_id}-1"},
headers=_headers(token),
)
second = client.post(
"/api/v1/compare/start",
json={"trace_id": f"quota-dynamic-{user_id}-2"},
headers=_headers(token),
)
rejected = client.post(
"/api/v1/compare/start",
json={"trace_id": f"quota-dynamic-{user_id}-3"},
headers=_headers(token),
)
assert first.status_code == 200
assert first.json() == {"limit": 2, "used": 1, "remaining": 1}
assert second.status_code == 200
assert second.json() == {"limit": 2, "used": 2, "remaining": 0}
assert rejected.status_code == 429
assert rejected.json()["detail"] == "今日已比价超过2次,请明天再试"
+34 -1
View File
@@ -102,6 +102,7 @@ def test_harvest_done_derives_and_newly_success_once(client) -> None:
assert rec.is_source_best is False assert rec.is_source_best is False
assert rec.store_name == "测试店" assert rec.store_name == "测试店"
assert rec.information == "美团更便宜" assert rec.information == "美团更便宜"
assert rec.fail_reason is None # 成功记录不派生失败原因
assert rec.items == [{"name": "肥牛饭", "qty": 1}] assert rec.items == [{"name": "肥牛饭", "qty": 1}]
assert rec.trace_url.endswith("/done/") assert rec.trace_url.endswith("/done/")
# 再来一次(重试 done)→ 已 success,newly_success=False(发奖不重复触发) # 再来一次(重试 done)→ 已 success,newly_success=False(发奖不重复触发)
@@ -110,6 +111,35 @@ def test_harvest_done_derives_and_newly_success_once(client) -> None:
assert newly2 is False 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: def test_harvest_abort_cancels_running(client) -> None:
tid = _tid() tid = _tid()
with SessionLocal() as db: with SessionLocal() as db:
@@ -233,7 +263,9 @@ def test_trace_finalize_harvests_abort(client) -> None:
with SessionLocal() as db: # 先有 running 行(帧0建的) with SessionLocal() as db: # 先有 running 行(帧0建的)
crud.harvest_running(db, trace_id=tid, user_id=None) crud.harvest_running(db, trace_id=tid, user_id=None)
p, _cap = _mock_pricebot({"trace_url": "https://price.shaguabijia.com/traces/fin/"}) 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", r = client.post("/api/v1/trace/finalize",
json={"trace_id": tid, "status": "cancelled", "reason": "用户终止"}) json={"trace_id": tid, "status": "cancelled", "reason": "用户终止"})
assert r.status_code == 200 assert r.status_code == 200
@@ -241,6 +273,7 @@ def test_trace_finalize_harvests_abort(client) -> None:
rec = _get(db, tid) rec = _get(db, tid)
assert rec is not None and rec.status == "cancelled" assert rec is not None and rec.status == "cancelled"
assert rec.trace_url.endswith("/fin/") assert rec.trace_url.endswith("/fin/")
backfill.assert_called_once_with(rec.id, tid)
def test_price_step_binds_user_when_authed(client) -> None: def test_price_step_binds_user_when_authed(client) -> None:
+4
View File
@@ -72,6 +72,7 @@ def test_backfill_retries_then_persists_cost(monkeypatch):
def test_repair_batch_only_targets_terminal_missing_rows(monkeypatch): def test_repair_batch_only_targets_terminal_missing_rows(monkeypatch):
missing_id = _record("llm-repair-missing") missing_id = _record("llm-repair-missing")
cancelled_id = _record("llm-repair-cancelled", status="cancelled")
running_id = _record("llm-repair-running", status="running") running_id = _record("llm-repair-running", status="running")
calls = [ calls = [
{ {
@@ -96,12 +97,15 @@ def test_repair_batch_only_targets_terminal_missing_rows(monkeypatch):
) )
assert result["repaired"] >= 1 assert result["repaired"] >= 1
assert "llm-repair-missing" in seen assert "llm-repair-missing" in seen
assert "llm-repair-cancelled" in seen
assert "llm-repair-running" not in seen assert "llm-repair-running" not in seen
with SessionLocal() as db: with SessionLocal() as db:
assert db.get(ComparisonRecord, missing_id).llm_cost_yuan is not None 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 assert db.get(ComparisonRecord, running_id).llm_cost_yuan is None
finally: finally:
_delete(missing_id) _delete(missing_id)
_delete(cancelled_id)
_delete(running_id) _delete(running_id)
+122
View File
@@ -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未达起送门槛"
+12
View File
@@ -529,6 +529,18 @@ def test_admin_can_edit_rules_and_current_window_is_reconciled() -> None:
now + timedelta(seconds=2) now + timedelta(seconds=2)
).replace(tzinfo=None) ).replace(tzinfo=None)
compare_limit = client.patch(
"/admin/api/risk-monitor/rules",
headers=headers,
json={
"sms_hourly_threshold": 3,
"oneclick_daily_threshold": 20,
"compare_daily_threshold": 120,
},
)
assert compare_limit.status_code == 200
assert compare_limit.json()["compare_daily_threshold"] == 120
invalid = client.patch( invalid = client.patch(
"/admin/api/risk-monitor/rules", "/admin/api/risk-monitor/rules",
headers=headers, headers=headers,