From 50da718e35dee29e2add35694b3dfebce0d95c2a Mon Sep 17 00:00:00 2001 From: guke Date: Tue, 28 Jul 2026 14:04:36 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=AF=94=E4=BB=B7=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=8D=A1=E5=B1=95=E7=A4=BA=E5=85=B7=E4=BD=93?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0(=E6=96=B0=E5=A2=9E=20fail=5Freason)=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 失败记录不再一律「网络开小差」:新增记录级 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) --------- Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/189 --- .../versions/comparison_record_fail_reason.py | 49 +++++++ app/models/comparison.py | 5 + app/repositories/comparison.py | 88 +++++++++++++ app/schemas/compare_record.py | 2 + tests/test_compare_harvest.py | 30 +++++ tests/test_fail_reason.py | 122 ++++++++++++++++++ 6 files changed, 296 insertions(+) create mode 100644 alembic/versions/comparison_record_fail_reason.py create mode 100644 tests/test_fail_reason.py diff --git a/alembic/versions/comparison_record_fail_reason.py b/alembic/versions/comparison_record_fail_reason.py new file mode 100644 index 0000000..b96f54e --- /dev/null +++ b/alembic/versions/comparison_record_fail_reason.py @@ -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') diff --git a/app/models/comparison.py b/app/models/comparison.py index 3628438..2a60d30 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -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?}] diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index eca4a33..948ae77 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -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"), diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 0c16e52..e6d3ed0 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -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 = [] diff --git a/tests/test_compare_harvest.py b/tests/test_compare_harvest.py index 9172823..82448f0 100644 --- a/tests/test_compare_harvest.py +++ b/tests/test_compare_harvest.py @@ -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: diff --git a/tests/test_fail_reason.py b/tests/test_fail_reason.py new file mode 100644 index 0000000..14b1888 --- /dev/null +++ b/tests/test_fail_reason.py @@ -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未达起送门槛" From e529112a90cf945bf3837503e9d147eba4a5a170 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Tue, 28 Jul 2026 17:57:52 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=AD=E9=80=94?= =?UTF-8?q?=E9=80=80=E5=87=BA=E6=AF=94=E4=BB=B7=E7=9A=84=20LLM=20=E6=88=90?= =?UTF-8?q?=E6=9C=AC=E5=9B=9E=E5=A1=AB=20(#191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 比价记录进入中途退出后未触发 LLM 成本回填,周期补偿也未扫描 cancelled,导致实际已有 LLM 调用的记录长期显示成本、LLM、TOKEN 为空。 ## 修改 - finalize 落库后立即追加 LLM 成本回填 - 周期补偿范围加入 cancelled - 保持无有效调用和全调用失败记录不伪造成本 - 增加即时回填和周期补偿回归测试 ## 验证 - ruff 检查通过 - 相关测试 28 项通过 - 全仓 626 项通过;主干既有失败已在未修改的 origin/main 复现 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/191 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/api/v1/compare.py | 14 +++++++++++--- app/services/comparison_llm_backfill.py | 2 +- tests/test_compare_harvest.py | 5 ++++- tests/test_comparison_llm_backfill.py | 4 ++++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/app/api/v1/compare.py b/app/api/v1/compare.py index fcb1e03..fa8268a 100644 --- a/app/api/v1/compare.py +++ b/app/api/v1/compare.py @@ -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 diff --git a/app/services/comparison_llm_backfill.py b/app/services/comparison_llm_backfill.py index c7eba8c..1687c1b 100644 --- a/app/services/comparison_llm_backfill.py +++ b/app/services/comparison_llm_backfill.py @@ -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()) diff --git a/tests/test_compare_harvest.py b/tests/test_compare_harvest.py index 82448f0..46a11ba 100644 --- a/tests/test_compare_harvest.py +++ b/tests/test_compare_harvest.py @@ -263,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 @@ -271,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: diff --git a/tests/test_comparison_llm_backfill.py b/tests/test_comparison_llm_backfill.py index fe9b925..89e2293 100644 --- a/tests/test_comparison_llm_backfill.py +++ b/tests/test_comparison_llm_backfill.py @@ -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) From 90c6fe599a8bcea6e3471504ca025c7dd6fab312 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Tue, 28 Jul 2026 17:58:23 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E7=94=A8=E6=88=B7Draw=E4=BF=A1=E6=81=AF=E6=B5=81eCPM?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=8F=A3=E5=BE=84=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 业务收益详情的平均 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 Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/190 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/repositories/ad_revenue.py | 10 +-- app/admin/repositories/queries.py | 71 +++++++++++++---- app/admin/routers/users.py | 8 ++ app/admin/schemas/user.py | 2 +- tests/test_admin_read.py | 109 +++++++++++++++++++++++++++ 5 files changed, 180 insertions(+), 20 deletions(-) diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py index 6ce16b4..b297fbf 100644 --- a/app/admin/repositories/ad_revenue.py +++ b/app/admin/repositories/ad_revenue.py @@ -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} diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 41f113c..a2158ee 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -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), } diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index 1d8b056..bb044ef 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -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, ) ) diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index aa826da..2e5a757 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -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 # 信息流广告提现(金币折现) diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 084caac..806faed 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -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)