Compare commits

..

43 Commits

Author SHA1 Message Date
guke 9f804bf0cb Merge branch 'main' into feat-compare-fail-alert
冲突解决:compare_alert_worker.py / test_compare_alert_stuck_worker.py /
2026-08-05-compare-stuck-detection-design.md 三处均以本地(ours)为准——
保留「cancelled 可读但无卡点时回退耗时兜底(超长放弃不漏报)」的修复,
覆盖 main 带入的旧行为(可读无卡点→不报)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 13:59:17 +08:00
guke 63daeeaf9b fix(compare-alert): cancelled trace 可读但无卡点时回退耗时兜底(超长放弃不再漏报)
线上 trace 几乎总可读,旧逻辑「可读但没判出卡点 → 不报」使 total_ms>90s 阈值形同
虚设,超长放弃(实测 113s / 516s)一条都报不出。改为只有判出卡点才独占带卡点的 T5,
其余(可读没卡点 / 读不到 / 无 trace)一律回退耗时/步数兜底,超长照报(卡点列留空)。
同步更新 stuck-detection 设计文档 6.1 + 修订说明。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 13:52:48 +08:00
guke e40e078691 feat(admin): 比价记录展示口径共享模块(外部缺失判为成功) (#219)
Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #219
2026-08-05 17:30:30 +08:00
guke 523d970c45 chore(compare-alert): 扫描间隔默认 30min→15min + 同步 test_defaults
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 17:09:45 +08:00
guke 44beb3b8de Merge branch 'main' of https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server into feat-compare-fail-alert
# Conflicts:
#	app/admin/repositories/queries.py
2026-08-05 17:03:48 +08:00
guke 62b30342ed refactor(compare-alert): 补 _fmt_stuck 类型注解 + 卡片 docstring 列序 + failed 卡点注释
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 16:51:06 +08:00
guke 347c4c7de4 feat(compare-alert): 卡点独立成列(AlertHit.stuck_point + 卡片第5列),reason 去重简化
- AlertHit 加 stuck_point: str | None = None 字段(格式化好的「平台·环节 帧/s」)
- classify_cancelled_fallback reason 简化为「深度放弃」(耗时/步数已在「用时」列,不重复)
- build_hits 加 _fmt_stuck helper;cancelled 卡死 stuck_point=「美团·加菜 110帧/32s」;
  failed stuck_point=环节标签、reason 不再附「卡在 X」
- format_alert_card 列序改为 时间/手机号/用时/失败原因/卡点/版本/trace (7列)
- 同步更新 test_compare_alert_stuck_worker / _fallback / _format / _rules 断言

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 16:47:23 +08:00
guke e135ba9a84 fix(compare-alert): stuck_ms 负值(时钟回退)降级为 None
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 16:33:30 +08:00
guke dd96fc2151 feat(compare-alert): StuckPoint 加 stuck_ms(末段卡住时长,读帧 timestamp)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 16:27:07 +08:00
guke fed3541a51 refactor(compare-alert): 抽 _build_card 消除卡片三路径重复 + grouped 显式化
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 15:47:49 +08:00
guke 924e40a84e feat(compare-alert): 固化飞书卡片 table 格式(format_alert_card + send_feishu_card),worker 切换
- feishu_notifier: 新增 send_feishu_card(interactive msg_type,复用 _post_feishu)
- compare_alert_format: 新增 format_alert_card(schema 2.0, header red, markdown摘要+table 6列)
  - 列序: 时间/手机号/用时/失败原因/版本/trace(lark_md);无 width 属性
  - cost 列 helper _cost_cell: total_ms→Ns / step_count→M步 / 两者用" / "连 / 都无给"-"
  - 截断: 超 max_total 只出摘要; 空 hits 返回「本期无异常」卡片
  - 保留 format_alert_message / format_alert_post(有测试依赖)
- compare_alert_worker: _send(post) → _send_card(card); _scan_and_alert 调 format_alert_card
  - SEND_EMPTY 分支: 传空 hits 给 format_alert_card 得「本期无异常」卡片
  - webhook 空降级保留; build_hits/水位逻辑不动
- tests: format/feishu/worker 测试全适配新接口,86 passed 零回归
- 删除临时脚本 scripts/_test_alert_card.py

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 15:37:13 +08:00
guke 9598c7a1da feat(compare-alert): AlertHit 加 total_ms/step_count(卡片用时列数据源)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 15:27:47 +08:00
guke 0663ee5542 fix(compare-alert): worker import 排序 + build_hits 返回类型 + 共享预算测试
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 14:52:47 +08:00
guke 45a8e7b972 feat(compare-alert): worker 编排 build_hits(cancelled trace 优先+保底、failed 附卡点)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 14:41:13 +08:00
guke 02d2e56ef6 feat(compare-alert): 抽出 classify_cancelled_fallback + 公开 make_hit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 14:32:53 +08:00
guke a7e8141497 fix(compare-alert): trace_stuck _read_head 防损坏帧 UnicodeDecodeError 崩溃
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 14:29:25 +08:00
guke c930957e90 feat(compare-alert): trace_stuck 卡死判据(末段原地打转)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 14:19:53 +08:00
guke 6c143dc9f2 feat(compare-alert): 卡死定位 4 个配置项
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 14:14:06 +08:00
guke b6ece681f3 docs(compare-alert): 卡死定位实现计划(4 tasks, TDD)
trace_stuck 模块 + compare_alert fallback 抽取 + worker build_hits 编排;
每 task 含完整测试代码、精确文件路径与命令。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 14:07:38 +08:00
guke 818ae1c9e1 docs(compare-alert): 卡死定位报警增强设计 spec
cancelled 判据从整场耗时/帧数改为 trace 末段原地打转(逐平台判、
读到确认没卡则信 trace、仅读不到才回退保底);failed 类附卡点;
同机直读 pricebot work_logs、不改 pricebot、不落库。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 13:49:39 +08:00
zuochenyong b39d918cda feat(提现): 新增 100 元档并向客户端下发每档每日次数上限 (#218)
Co-authored-by: exinglang <exinglang@qq.com>
Reviewed-on: #218
Co-authored-by: zuochenyong <zuochenyong@wonderable.ai>
Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
2026-08-05 13:47:19 +08:00
guke 4becde8d75 fix(compare-alert): 开关/webhook 默认值加注释提醒放 .env + conftest 隔离 test_defaults
飞书 webhook(敏感)和 ENABLED 不该硬编码进 config.py 默认值(会泄露进仓库/误带到生产默认开),
统一放 .env(gitignore)。conftest 强制 COMPARE_ALERT_ENABLED=false, test_defaults 不受 .env 干扰。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-05 10:20:49 +08:00
guke af229e2a7b feat(compare-alert): 飞书消息改行式富文本(明细含手机号/版本/原因/trace超链接)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 10:12:49 +08:00
guke 576b94b4bb fix(compare-alert): 飞书时间标签用北京时区(CN_TZ) + main.py import 排序
final review 发现 window_label 用 datetime.now() 本地时钟,UTC 服务器上飞书标题时间差 8h;改用 CN_TZ。
顺带 ruff --fix 修 main.py 挂载 worker 时引入的 import 排序(isort)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 19:56:48 +08:00
guke 46247fb3a9 feat(compare-alert): lifespan 挂载 compare-alert worker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:38:11 +08:00
guke d0169ffb54 feat(compare-alert): 扫描 worker(水位/冷启动/发送失败不推进)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:34:02 +08:00
guke bc321c1c64 feat(compare-alert): 飞书群机器人 notifier(关键词验证,无签名)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:30:17 +08:00
guke 7891984cd1 feat(compare-alert): 飞书汇总消息格式化(分组/两级截断/关键词)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:28:11 +08:00
guke 20cbc9e35e feat(compare-alert): 记录级报警规则纯函数(T1/T2/T5/T6)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:25:44 +08:00
guke 02d6300442 feat(compare-alert): 加 COMPARE_ALERT_* 配置项与关键词解析
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:22:44 +08:00
guke 46ffa41931 feat(compare-alert): comparison_record 加 updated_at 列+索引+回填(报警水位)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:20:23 +08:00
guke 03129e059f feat(admin): 比价记录改「技术成功/失败」口径,外部缺失记为成功 (#217)
背景
admin 比价记录页 / 概览 / 大盘此前把「未找到店、未找到菜、门店打烊、单点不配送、平台不支持、未满起送」统统显示/统计为失败——根因是 #209 把这些业务结局归一化成记录级 status='failed' 落库。但它们其实是比价流程正常跑完、只是外部原因导致结果缺失;与「系统技术故障」混为一谈后,管理员排查时无法区分「是我们系统的锅」还是「目标平台本来就没这家店/这些菜」。

方案
admin 后台改用技术完成率口径:流程跑完(非 running)且非纯技术故障 → 记为成功,有外部缺失的前端标绿「成功」+ ⚠(hover 看具体原因);只有真正的技术故障 failed 才是失败。

口径收敛到新模块 app/admin/repositories/comparison_outcome.py,被列表下发 / 概览 / 大盘 / 状态筛选共同消费(单一真相源)。
原始业务结局取自 raw_payload.record_status(3 级 coalesce 兜底,兼容历史残留;Python 派生与 SQL 判定 bit 一致)。
仅 admin,不碰 C 端 / #209 落库 / 奖励逻辑——admin 关心「系统跑没跑成」,C 端关心「省没省到钱」,刻意分层。

口径映射
原始 record_status | admin 状态 | hover 提示
-- | -- | --
success | 🟢 成功 | —
below_minimum | 🟢 成功 ⚠ | 未满起送
store_closed | 🟢 成功 ⚠ | 门店打烊
store_not_found | 🟢 成功 ⚠ | 未找到店
items_not_found | 🟢 成功 ⚠ | 未找到菜
no_delivery | 🟢 成功 ⚠ | 单点不配送
unsupported | 🟢 成功 ⚠ | 平台·场景不支持
failed(纯技术故障) | 🔴 失败 | —
cancelled / running |  中途退出 / 🔵 进行中 | —

改动清单
新增 comparison_outcome.py:derive_admin_outcome(列表 Python 派生) + admin_success_sql(聚合/筛选 SQL 判定)。
列表/详情 下发 admin_status + outcome_hint(瞬态挂载,零额外查询)。
概览 comparison_records_summary:success / completed / 耗时分位改 admin 口径。
大盘 dashboard_overview:比价成功率改 admin 口径(顺带补齐 #209 未同步大盘的 below_minimum)。
状态筛选 _comparison_status_condition:筛「成功」含 6 类、筛「失败」仅纯技术故障;清理 #209 遗留死常量。
文档 补 admin 口径说明。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #217
2026-08-04 18:49:30 +08:00
guke bc2ed5de56 feat(compare): 新增只读当日比价额度查询 GET /compare/quota (#216)
## 概述
新增只读接口 `GET /api/v1/compare/quota`,供客户端「跳外卖 App」型比价入口在点击时**前置查询当日比价是否已达上限**(100 次/日),超限就地提示、不进入比价流程。

配套客户端 PR:比价/领券异常提示统一 + 4 入口上限拦截(shaguabijia-app-android 同名分支)。

## 改动
- `app/repositories/comparison.py`:新增只读 `get_daily_compare_used(db, user_id, reset_at)` —— 按 user_id + 北京时间自然日 COUNT,**窗口计算逐行复刻写路径 `reserve_daily_start`**,保证前置查询与真发起的 429 gate 口径不漂移。
- `app/schemas/compare_record.py`:新增 `CompareQuotaOut(exhausted, used, limit)`。
- `app/api/v1/compare_record.py`:新增 `GET /quota` 端点,硬鉴权 `CurrentUser`、只读不预占;`limit_policy.resolve` 传 `phone + device_id`(与 `/compare/start` 一致,命中 device 白名单)。

## 测试
- `pytest tests/test_compare_daily_limit.py`:**8 passed**(4 既有 + 4 新增,含 device 白名单 parity 测试,锁定 `/quota` 与 `/start` 口径一致)。

## 合并 / 部署注意 ⚠️
- 本 PR 应**先于客户端 PR 合并 + 部署**(客户端点击前置拦截依赖此接口;未部署时客户端 fail-open 放行)。
- 只读、无副作用、不改写路径逻辑,风险低。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #216
2026-08-04 16:38:46 +08:00
guke 01f97e72a4 docs: 新增比价失败报警机制设计文档
结合 comparison_record 全量数据分析(3867行)设计记录级失败报警:
T1系统技术失败 / T2超时启动 / T6商品识别失败 / T5 cancelled深度放弃,
常驻 worker 周期扫描 + updated_at 水位(零漏报) + 飞书汇总。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 16:04:26 +08:00
guke 5a66c302cb fix(admin): 修 queries import 排序 + 补比价详情 admin 字段断言
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 16:01:24 +08:00
guke 09b9381d03 feat(admin): 比价记录列表/详情下发 admin_status + outcome_hint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 15:48:55 +08:00
guke 7419f35f4b fix(admin): 口径模块 SQL 侧 nullif 对齐空串,消除 Python/SQL 分歧
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 15:44:58 +08:00
guke 9de73152ec feat(admin): 比价记录展示口径共享模块(外部缺失判为成功)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 15:35:32 +08:00
guke 9036bc5a08 fix: admin 比价记录列表兼容 user_id 为空的孤儿行
软鉴权/匿名下 pricebot 帧0 建行时 user_id 可空(见 models.comparison),admin
全看含孤儿行;但 AdminComparisonListItem.user_id 声明为必填 int,Pydantic v2
对 None 抛 ValidationError,致 GET /admin/api/comparison-records 列表接口 500。
改为 int|None(详情接口继承一并修复),并补回归测试
test_comparison_records_list_tolerates_orphan_null_user。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 10:49:57 +08:00
linkeyu 1a61cb5a65 修复 DeepSeek V4 Flash TOKEN 成本高估 (#214)
## 改动
- 为 deepseek-v4-flash 配置 DashScope 华北 2 官方单价:输入 ¥1 / 输出 ¥2(每百万 Token)
- 定向重算历史上误用 3/15 兜底价冻结的成本和价格快照
- 保留已有人工单价及配置生效时间,避免影响历史缺失成本回填
- 增加迁移升级、降级和原配置保留测试

## 验证
- 相关测试:17 passed
- ruff check:通过

---------

Co-authored-by: guke <guke@wonderable.ai>
Co-authored-by: linkeyu <798648091@qq.com>
Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #214
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-08-01 23:25:27 +08:00
linkeyu 67ac2dcbbb fix: 领券成功率剔除中途退出 (#213)
## 改动说明
- 领券成功率分母改为:发起数 - 中途退出数
- failed 与 started 仍保留在分母
- 接口新增 abandoned 和 success_denominator 字段
- 中途退出已有单券结果时返回真实成功/尝试数
- 中途退出且没有逐券终态时明确返回 0/0
- 新增 point_event_count,区分「只有 skipped、无有效计分结果」和「完全无逐券事件」
- 用户领券记录接口同步聚合逐券结果

## 验证
- 相关后端测试:21 passed
- Ruff:通过
- 线上数据库只读核对:空白记录确实没有 coupon_claim_event

配套前端:WonderableAI/shaguabijia-admin-web#101

---------

Co-authored-by: guke <guke@wonderable.ai>
Co-authored-by: linkeyu <798648091@qq.com>
Reviewed-on: #213
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-08-01 23:24:54 +08:00
zuochenyong 08a49504fa fix(反馈): 优化图片处理与静态资源缓存 (#211)
Co-authored-by: guke <guke@wonderable.ai>
Co-authored-by: exinglang <exinglang@qq.com>
Reviewed-on: #211
Co-authored-by: zuochenyong <zuochenyong@wonderable.ai>
Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
2026-08-01 23:24:17 +08:00
linkeyu ab2de6ec79 修复:统一比价记录状态枚举口径 (#209)
## 变更说明

- below_minimum 归入成功,保留原始业务结局
- store_closed/store_not_found/items_not_found/no_delivery/unsupported 归入失败
- running 保持进行中生命周期状态
- 历史细分状态迁移为三态终态,迁移可逆
- 后台成功/失败筛选及汇总兼容迁移前历史值

## 验证

- 相关回归:48 passed
- Ruff:通过
- Alembic:单一 head

---------

Co-authored-by: guke <guke@wonderable.ai>
Co-authored-by: linkeyu <798648091@qq.com>
Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #209
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-08-01 23:09:21 +08:00
54 changed files with 5245 additions and 139 deletions
@@ -0,0 +1,76 @@
"""normalize granular comparison outcomes into terminal record statuses
Revision ID: comparison_below_min_success
Revises: limit_policy_global_bundle
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "comparison_below_min_success"
down_revision: str | Sequence[str] | None = "limit_policy_global_bundle"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
_FAILED_OUTCOMES = (
"store_closed",
"store_not_found",
"items_not_found",
"no_delivery",
"unsupported",
)
def _comparison_record() -> sa.TableClause:
return sa.table(
"comparison_record",
sa.column("status", sa.String(16)),
sa.column("fail_reason", sa.String(256)),
sa.column("raw_payload", _JSON),
)
def upgrade() -> None:
comparison_record = _comparison_record()
op.execute(
comparison_record.update()
.where(comparison_record.c.status == "below_minimum")
.values(status="success", fail_reason=None)
)
op.execute(
comparison_record.update()
.where(comparison_record.c.status.in_(_FAILED_OUTCOMES))
.values(status="failed")
)
def downgrade() -> None:
comparison_record = _comparison_record()
# The write paths deliberately preserve the granular outcome. Restore only
# rows that this change normalized, without touching ordinary successes.
raw_outcome = sa.func.coalesce(
comparison_record.c.raw_payload["record_status"].as_string(),
comparison_record.c.raw_payload["status"].as_string(),
)
op.execute(
comparison_record.update()
.where(
comparison_record.c.status == "success",
raw_outcome == "below_minimum",
)
.values(status="below_minimum")
)
op.execute(
comparison_record.update()
.where(
comparison_record.c.status == "failed",
raw_outcome.in_(_FAILED_OUTCOMES),
)
.values(status=raw_outcome)
)
+50
View File
@@ -0,0 +1,50 @@
"""comparison_record 加 updated_at 列(报警水位)+ 回填现有行 + 索引
新增 updated_at:server_default + onupdate = func.now()(DB 时钟)。比价失败报警 worker 用它做
单调水位(WHERE updated_at > watermark)。加列后回填现有行 = created_at,避免冷启动 max(updated_at)
为 NULL;再置 NOT NULL + 建索引 ix_comparison_updated(水位查询按它)。batch 模式兼容 SQLite。
Revision ID: comparison_updated_at
Revises: deepseek_v4_flash_price
Create Date: 2026-08-04 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "comparison_updated_at"
down_revision: str | Sequence[str] | None = "deepseek_v4_flash_price"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# 1) 先加可空列(不带 default,避免各库对 add-column-with-default 的差异)
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
batch_op.add_column(sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True))
# 2) 回填现有行 = created_at(全新环境表为空,回填 no-op)
op.get_bind().execute(
sa.text(
"UPDATE comparison_record SET updated_at = created_at WHERE updated_at IS NULL"
)
)
# 3) 置 NOT NULL + server_default + 建索引
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
batch_op.alter_column(
"updated_at",
existing_type=sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
)
batch_op.create_index("ix_comparison_updated", ["updated_at"], unique=False)
def downgrade() -> None:
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
batch_op.drop_index("ix_comparison_updated")
batch_op.drop_column("updated_at")
+211
View File
@@ -0,0 +1,211 @@
"""correct DeepSeek V4 Flash token price and frozen historical costs
Revision ID: deepseek_v4_flash_price
Revises: comparison_below_min_success
"""
from __future__ import annotations
import json
from collections.abc import Sequence
from typing import Any
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "deepseek_v4_flash_price"
down_revision: str | Sequence[str] | None = "comparison_below_min_success"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
_CONFIG_KEY = "llm_token_price"
_MODEL = "deepseek-v4-flash"
_OLD_INPUT_PRICE = 3.0
_OLD_OUTPUT_PRICE = 15.0
_NEW_INPUT_PRICE = 1.0
_NEW_OUTPUT_PRICE = 2.0
_CORRECTION_MARKER = "deepseek_v4_flash_price"
_CONFIG_MARKER_KEY = "migration_deepseek_v4_flash_price"
def _decode_object(value: Any) -> dict[str, Any] | None:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except (TypeError, ValueError):
return None
return parsed if isinstance(parsed, dict) else None
return None
def _model_tokens(calls: Any) -> tuple[int, int]:
if not isinstance(calls, list):
return 0, 0
input_tokens = 0
output_tokens = 0
for call in calls:
if not isinstance(call, dict) or call.get("error") or call.get("model") != _MODEL:
continue
usage = _decode_object(call.get("usage"))
if usage is None:
continue
input_tokens += int(usage.get("prompt_tokens") or 0)
output_tokens += int(usage.get("completion_tokens") or 0)
return input_tokens, output_tokens
def _app_config_table() -> sa.TableClause:
return sa.table(
"app_config",
sa.column("key", sa.String(64)),
sa.column("value", _JSON),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
def _comparison_table() -> sa.TableClause:
return sa.table(
"comparison_record",
sa.column("id", sa.Integer),
sa.column("llm_calls", _JSON),
sa.column("llm_cost_yuan", sa.Float),
sa.column("llm_price_snapshot", _JSON),
)
def _update_config(conn, *, upgrade: bool) -> None:
table = _app_config_table()
row = conn.execute(
sa.select(table.c.value).where(table.c.key == _CONFIG_KEY)
).mappings().first()
if row is None or not isinstance(row["value"], dict):
return
config = dict(row["value"])
per_model = dict(config.get("per_model") or {})
current = per_model.get(_MODEL)
if upgrade:
# Preserve an operator's explicit model price. The production defect is specifically
# the missing key falling through to the generic 3/15 price.
if current is not None:
return
per_model[_MODEL] = {
"input_per_1m": _NEW_INPUT_PRICE,
"output_per_1m": _NEW_OUTPUT_PRICE,
}
conn.execute(table.insert().values(key=_CONFIG_MARKER_KEY, value=True))
else:
marker_exists = conn.execute(
sa.select(table.c.key).where(table.c.key == _CONFIG_MARKER_KEY)
).scalar_one_or_none()
if marker_exists is None:
return
if current != {
"input_per_1m": _NEW_INPUT_PRICE,
"output_per_1m": _NEW_OUTPUT_PRICE,
}:
conn.execute(table.delete().where(table.c.key == _CONFIG_MARKER_KEY))
return
per_model.pop(_MODEL, None)
conn.execute(table.delete().where(table.c.key == _CONFIG_MARKER_KEY))
config["per_model"] = per_model
conn.execute(
table.update()
.where(table.c.key == _CONFIG_KEY)
# 这是对历史误配置的追溯修正,不是从部署时刻开始的新价格。保留原 updated_at,
# 否则缺失成本回填会把部署前的记录全部排除。
.values(value=config)
)
def _correct_frozen_costs(conn, *, upgrade: bool) -> None:
table = _comparison_table()
rows = conn.execute(
sa.select(
table.c.id,
table.c.llm_calls,
table.c.llm_cost_yuan,
table.c.llm_price_snapshot,
).where(
table.c.llm_cost_yuan.is_not(None),
table.c.llm_price_snapshot.is_not(None),
)
).mappings()
for row in rows:
snapshot = row["llm_price_snapshot"]
if not isinstance(snapshot, dict):
continue
prices = snapshot.get("prices")
if not isinstance(prices, dict):
continue
model_price = prices.get(_MODEL)
if not isinstance(model_price, dict):
continue
if upgrade:
if not (
model_price.get("_source") == "default"
and model_price.get("input_per_1m") == _OLD_INPUT_PRICE
and model_price.get("output_per_1m") == _OLD_OUTPUT_PRICE
):
continue
elif snapshot.get("pricing_correction") != _CORRECTION_MARKER:
continue
input_tokens, output_tokens = _model_tokens(row["llm_calls"])
if input_tokens == 0 and output_tokens == 0:
continue
if upgrade:
delta = (
input_tokens / 1_000_000 * (_OLD_INPUT_PRICE - _NEW_INPUT_PRICE)
+ output_tokens / 1_000_000 * (_OLD_OUTPUT_PRICE - _NEW_OUTPUT_PRICE)
)
corrected_price = {
"input_per_1m": _NEW_INPUT_PRICE,
"output_per_1m": _NEW_OUTPUT_PRICE,
"_source": "per_model",
}
snapshot["pricing_correction"] = _CORRECTION_MARKER
new_cost = max(0.0, float(row["llm_cost_yuan"]) - delta)
else:
delta = (
input_tokens / 1_000_000 * (_OLD_INPUT_PRICE - _NEW_INPUT_PRICE)
+ output_tokens / 1_000_000 * (_OLD_OUTPUT_PRICE - _NEW_OUTPUT_PRICE)
)
corrected_price = {
"input_per_1m": _OLD_INPUT_PRICE,
"output_per_1m": _OLD_OUTPUT_PRICE,
"_source": "default",
}
snapshot.pop("pricing_correction", None)
new_cost = float(row["llm_cost_yuan"]) + delta
updated_prices = dict(prices)
updated_prices[_MODEL] = corrected_price
updated_snapshot = dict(snapshot)
updated_snapshot["prices"] = updated_prices
conn.execute(
table.update()
.where(table.c.id == row["id"])
.values(
llm_cost_yuan=round(new_cost, 6),
llm_price_snapshot=updated_snapshot,
)
)
def upgrade() -> None:
conn = op.get_bind()
_update_config(conn, upgrade=True)
_correct_frozen_costs(conn, upgrade=True)
def downgrade() -> None:
conn = op.get_bind()
_correct_frozen_costs(conn, upgrade=False)
_update_config(conn, upgrade=False)
@@ -0,0 +1,61 @@
"""admin 比价记录展示口径:把「流程跑完、外部原因致结果缺失」的记录判为成功。
#209 / C 端落库把 store_not_found / items_not_found / store_closed / no_delivery /
unsupported 归一化成 status='failed';admin 排查视角改按记录级原始业务结局
raw_payload.record_status)重判——这些「外部缺失」算成功(附缺失提示),
只有纯技术故障才是失败。仅 admin 用,不碰 C 端 / #209 落库。
"""
from __future__ import annotations
from sqlalchemy import func
from app.models.comparison import ComparisonRecord
# 记录级原始业务结局里算「成功(流程跑完)」的集合;其余(failed / 未知)才是技术故障。
ADMIN_SUCCESS_OUTCOMES = frozenset({
"success", "below_minimum", "store_closed",
"store_not_found", "items_not_found", "no_delivery", "unsupported",
})
# 有缺失的成功 → 感叹号 hover 提示;success 本身无提示。
OUTCOME_HINTS = {
"below_minimum": "未满起送",
"store_closed": "门店打烊",
"store_not_found": "未找到店",
"items_not_found": "未找到菜",
"no_delivery": "单点不配送",
"unsupported": "平台·场景不支持",
}
def derive_admin_outcome(raw_payload: dict | None, status: str) -> tuple[str, str | None]:
"""(admin_status, outcome_hint)。列表 Python 层派生(raw_payload 已随 ORM 加载)。"""
if status in ("cancelled", "running"):
return status, None
raw = raw_payload or {}
# 原始结局:优先 raw.record_status,其次 raw.status,兜底 status 列
# (兼容迁移未覆盖、细分值残留在 status 列的老记录;与下方 SQL 口径一致)。
original = raw.get("record_status") or raw.get("status") or status
if original in ADMIN_SUCCESS_OUTCOMES:
return "success", OUTCOME_HINTS.get(original)
return "failed", None
def _original_expr():
"""SQL:原始结局 = coalesce(nullif(raw.record_status,''), nullif(raw.status,''), status 列)。
nullif('') 让空串与 Python `or` 口径一致地跳过(as_string 跨方言,#209 迁移已验证)。"""
return func.coalesce(
func.nullif(ComparisonRecord.raw_payload["record_status"].as_string(), ""),
func.nullif(ComparisonRecord.raw_payload["status"].as_string(), ""),
ComparisonRecord.status,
)
def admin_success_sql():
"""SQL 层 admin 成功判定(概览 / 大盘的 case / where 共用)。
排除 cancelled / running(生命周期态,不看结局);其余按原始结局 ∈ S。
coalesce 兜底 status 列 → original 永非 NULL、且兼容 status 列残留的细分值。
"""
return ComparisonRecord.status.notin_(("cancelled", "running")) & _original_expr().in_(
tuple(ADMIN_SUCCESS_OUTCOMES)
)
+29 -10
View File
@@ -166,6 +166,18 @@ def _session_to_row(
point_stats: dict | None = None,
) -> dict:
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
# 中途退出可能发生在第一张券产生终态之前,此时没有逐券事件。
# 明确返回 0/0,让前端区分「退出前无单券结果」与其它状态的埋点缺失。
if point_stats is not None:
point_success_count = point_stats["succeeded"]
point_total_count = point_stats["tried"]
elif r.status == "abandoned":
point_success_count = 0
point_total_count = 0
else:
point_success_count = None
point_total_count = None
point_event_count = point_stats["events"] if point_stats is not None else 0
return {
"id": r.id,
"trace_id": r.trace_id,
@@ -182,8 +194,9 @@ def _session_to_row(
"app_env": r.app_env,
"started_at": r.started_at,
"claimed_count": r.claimed_count,
"point_success_count": point_stats["succeeded"] if point_stats else None,
"point_total_count": point_stats["tried"] if point_stats else None,
"point_success_count": point_success_count,
"point_total_count": point_total_count,
"point_event_count": point_event_count,
"trace_url": r.trace_url,
"ad_revenue_yuan": ad_revenue_yuan,
}
@@ -194,21 +207,24 @@ def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict[
if not trace_ids:
return {}
succeeded = func.sum(case((CouponClaimEvent.status.in_(_SLOT_OK), 1), else_=0))
tried = func.sum(case((CouponClaimEvent.status.in_(_SLOT_TRIED), 1), else_=0))
rows = db.execute(
select(
CouponClaimEvent.trace_id,
succeeded.label("succeeded"),
func.count().label("tried"),
)
.where(
CouponClaimEvent.trace_id.in_(trace_ids),
CouponClaimEvent.status.in_(_SLOT_TRIED),
tried.label("tried"),
func.count().label("events"),
)
.where(CouponClaimEvent.trace_id.in_(trace_ids))
.group_by(CouponClaimEvent.trace_id)
).all()
return {
trace_id: {"succeeded": int(success_count or 0), "tried": int(tried or 0)}
for trace_id, success_count, tried in rows
trace_id: {
"succeeded": int(success_count or 0),
"tried": int(tried_count or 0),
"events": int(event_count or 0),
}
for trace_id, success_count, tried_count, event_count in rows
if trace_id is not None
}
@@ -400,12 +416,15 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
total = db.execute(
select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id)
).scalar_one()
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows])
trace_ids = [r.trace_id for r in rows]
rev_map = crud_ecpm.revenue_yuan_by_trace(db, trace_ids)
point_stats_map = _point_scores_by_trace(db, trace_ids)
return {
"items": [
_session_to_row(
r,
ad_revenue_yuan=rev_map.get(r.trace_id, 0.0),
point_stats=point_stats_map.get(r.trace_id),
)
for r in rows
],
+41 -30
View File
@@ -5,13 +5,14 @@
"""
from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone
from datetime import UTC, date, datetime, time, timedelta
from decimal import ROUND_HALF_UP, Decimal
from zoneinfo import ZoneInfo
from sqlalchemy import Select, and_, asc, case, desc, func, or_, select
from sqlalchemy.orm import Session
from app.admin.repositories.comparison_outcome import admin_success_sql, derive_admin_outcome
from app.core import rewards
from app.core.config import settings
from app.models.ad_ecpm import AdEcpmRecord
@@ -39,6 +40,16 @@ from app.repositories import activity, ad_ecpm
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
def _comparison_status_condition(status: str):
"""列表/概览「状态」筛选:success/failed 按 admin 口径(与显示/统计一致);
cancelled/running 按 status 列生命周期。"""
if status == "success":
return admin_success_sql()
if status == "failed":
return ~admin_success_sql() & ComparisonRecord.status.notin_(("cancelled", "running"))
return ComparisonRecord.status == status
# 信息流点位场景 → 金币记录「赚取途径」展示名;NULL/未知 = 历史未分类。
_FEED_SCENE_LABEL = {
"comparison": "比价信息流",
@@ -334,7 +345,7 @@ def _comparison_conditions(
)
)
if status:
conditions.append(ComparisonRecord.status == status)
conditions.append(_comparison_status_condition(status))
if business_type:
conditions.append(ComparisonRecord.business_type == business_type)
if store:
@@ -343,10 +354,10 @@ def _comparison_conditions(
conditions.append(ComparisonRecord.product_names.like(f"%{product}%"))
beijing = ZoneInfo("Asia/Shanghai")
if date_from is not None:
start_utc = datetime.combine(date_from, time.min, tzinfo=beijing).astimezone(timezone.utc)
start_utc = datetime.combine(date_from, time.min, tzinfo=beijing).astimezone(UTC)
conditions.append(ComparisonRecord.created_at >= start_utc)
if date_to is not None:
end_utc = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=beijing).astimezone(timezone.utc)
end_utc = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=beijing).astimezone(UTC)
conditions.append(ComparisonRecord.created_at < end_utc)
return conditions
@@ -386,6 +397,7 @@ def list_comparison_records(
rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items])
for it in items:
it.ad_revenue_yuan = rev.get(it.trace_id, 0.0)
it.admin_status, it.outcome_hint = derive_admin_outcome(it.raw_payload, it.status)
return items, next_cursor, total
@@ -409,8 +421,8 @@ def _round_duration_ms(value) -> int | None:
return int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def _comparison_duration_aggregate_stmt(conditions: list, status: str, quantiles: tuple[float, ...]):
"""PostgreSQL 耗时聚合语句;每种状态只返回一行。"""
def _comparison_duration_aggregate_stmt(conditions: list, status_filter, quantiles: tuple[float, ...]):
"""PostgreSQL 耗时聚合语句;status_filter 为已构造的口径条件表达式,每口径只返回一行。"""
return select(
func.avg(ComparisonRecord.total_ms),
*(
@@ -419,7 +431,7 @@ def _comparison_duration_aggregate_stmt(conditions: list, status: str, quantiles
),
).where(
*conditions,
ComparisonRecord.status == status,
status_filter,
ComparisonRecord.total_ms.is_not(None),
)
@@ -428,13 +440,13 @@ def _comparison_duration_aggregates(
db: Session,
*,
conditions: list,
status: str,
status_filter,
quantiles: tuple[float, ...],
) -> list[int | None]:
"""返回平均值和各分位数;生产 PG 在数据库内聚合,SQLite 仅作测试回退。"""
if db.bind is not None and db.bind.dialect.name == "postgresql":
row = db.execute(
_comparison_duration_aggregate_stmt(conditions, status, quantiles)
_comparison_duration_aggregate_stmt(conditions, status_filter, quantiles)
).one()
return [_round_duration_ms(value) for value in row]
@@ -444,7 +456,7 @@ def _comparison_duration_aggregates(
select(ComparisonRecord.total_ms)
.where(
*conditions,
ComparisonRecord.status == status,
status_filter,
ComparisonRecord.total_ms.is_not(None),
)
.order_by(ComparisonRecord.total_ms)
@@ -471,16 +483,14 @@ def comparison_records_summary(
user_id=user_id, phone=phone, status=status, business_type=business_type,
store=store, product=product, date_from=date_from, date_to=date_to,
)
_success = admin_success_sql()
row = db.execute(
select(
func.count(ComparisonRecord.id),
func.sum(case((ComparisonRecord.status.in_(("success", "failed")), 1), else_=0)),
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
func.sum(case((_success | (ComparisonRecord.status == "failed"), 1), else_=0)),
func.sum(case((_success, 1), else_=0)),
func.avg(ComparisonRecord.llm_cost_yuan),
func.sum(case((
(ComparisonRecord.status == "success")
& (ComparisonRecord.saved_amount_cents > 0), 1
), else_=0)),
func.sum(case((_success & (ComparisonRecord.saved_amount_cents > 0), 1), else_=0)),
func.sum(case((ComparisonRecord.status == "cancelled", 1), else_=0)),
).where(*conditions)
).one()
@@ -492,13 +502,13 @@ def comparison_records_summary(
success_duration_stats = _comparison_duration_aggregates(
db,
conditions=conditions,
status="success",
status_filter=admin_success_sql(),
quantiles=(0.05, 0.5, 0.95, 0.99),
)
cancelled_duration_stats = _comparison_duration_aggregates(
db,
conditions=conditions,
status="cancelled",
status_filter=(ComparisonRecord.status == "cancelled"),
quantiles=(0.05, 0.5, 0.95),
)
success_rate_denominator = started - cancelled
@@ -523,12 +533,13 @@ def comparison_records_summary(
def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。"""
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname + admin 口径瞬态)。"""
rec = db.get(ComparisonRecord, record_id)
if rec is not None:
_attach_user_info(db, [rec])
_attach_comparison_order_status(db, [rec])
_attach_comparison_device_details([rec])
rec.admin_status, rec.outcome_hint = derive_admin_outcome(rec.raw_payload, rec.status)
return rec
@@ -557,8 +568,8 @@ def _heartbeat_seconds_ago(last: datetime | None) -> int | None:
if last is None:
return None
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
return int((datetime.now(timezone.utc) - last).total_seconds())
last = last.replace(tzinfo=UTC)
return int((datetime.now(UTC) - last).total_seconds())
def _device_model_from_id(device_id: str) -> str:
@@ -611,7 +622,7 @@ def _attach_device_user_info(db: Session, devices: list[DeviceLiveness]) -> None
def _liveness_cutoff() -> datetime:
"""掉线判定分界:此刻 - HEARTBEAT_TIMEOUT_MINUTES。心跳早于它 = 掉线(同 list_overdue 口径)。"""
timeout_min = max(1, int(settings.HEARTBEAT_TIMEOUT_MINUTES))
return datetime.now(timezone.utc) - timedelta(minutes=timeout_min)
return datetime.now(UTC) - timedelta(minutes=timeout_min)
def list_device_liveness(
@@ -787,11 +798,11 @@ def list_all_withdraw_orders(
stmt = stmt.where(date_col <= _as_utc(date_to))
# tz-aware:列为 timestamptz,比较绝对时刻、与 DB 会话时区无关(同 _as_utc / stats.py)
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
today_start = (
datetime.now(ZoneInfo("Asia/Shanghai"))
.replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(timezone.utc)
.astimezone(UTC)
)
if quick_filter == "abnormal":
stmt = stmt.where(
@@ -838,8 +849,8 @@ def _as_utc(value: datetime) -> datetime:
生产会话非 UTC 时筛选边界会整体偏移——故统一 tz-aware(与 stats.py / withdraw_summary 一致)。
无时区入参按 UTC 解释。"""
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def withdraw_list_enrichment(
@@ -1004,7 +1015,7 @@ def withdraw_summary(db: Session, *, source: str | None = None) -> dict:
today_start = (
datetime.now(ZoneInfo("Asia/Shanghai"))
.replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(timezone.utc)
.astimezone(UTC)
)
def _today_count(status: str) -> int:
@@ -1179,8 +1190,8 @@ def withdraw_risk_flags(
if user and user.status != "active":
flags.append(f"账号状态:{user.status}")
if user and user.created_at:
created_at = user.created_at.replace(tzinfo=timezone.utc) if user.created_at.tzinfo is None else user.created_at
if datetime.now(timezone.utc) - created_at < timedelta(hours=24):
created_at = user.created_at.replace(tzinfo=UTC) if user.created_at.tzinfo is None else user.created_at
if datetime.now(UTC) - created_at < timedelta(hours=24):
flags.append("新注册用户")
# 历史异常提现拆「拒绝」「失败」两类(口径不同:拒绝=人工驳回退款,失败=打款失败退款)
rejected_n = sum(1 for item in recent_withdraws if item.status == "rejected")
@@ -1391,7 +1402,7 @@ def _cn_wall_to_utc(dt: datetime) -> datetime:
"""coin_transaction 存的是北京 wall-clock(naive,见 wallet.grant_coins「存北京 wall-clock」),转成 UTC naive,
与广告表(func.now() UTC)统一 —— 让本函数按同一绝对时刻排序、且前端 apiTime(把无时区时间当 UTC 再 +8 展示)
口径一致;否则签到会比实际多显示 8 小时(北京时间又被 +8)。"""
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None)
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(UTC).replace(tzinfo=None)
def _coin_record_sort_key(row: dict) -> datetime:
+13 -3
View File
@@ -12,6 +12,7 @@ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import Session
from app.admin.repositories.comparison_outcome import admin_success_sql
from app.admin.repositories.coupon_data import _percentile
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
@@ -257,13 +258,14 @@ def dashboard_overview(
ComparisonRecord.created_at >= start_local,
ComparisonRecord.created_at < end_local,
)
_period_success = admin_success_sql()
period_comparison_stats = db.execute(
select(
func.count(ComparisonRecord.id),
func.coalesce(
func.sum(
case(
(ComparisonRecord.status.in_(("success", "failed")), 1),
(_period_success | (ComparisonRecord.status == "failed"), 1),
else_=0,
)
),
@@ -276,7 +278,7 @@ def dashboard_overview(
0,
),
func.coalesce(
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
func.sum(case((_period_success, 1), else_=0)),
0,
),
func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0),
@@ -566,6 +568,10 @@ def dashboard_overview(
)
).all()
coupon_started = len(period_coupon_sessions)
coupon_abandoned = sum(s.status == "abandoned" for s in period_coupon_sessions)
# 用户主动中途退出不代表领券流程失败,不进入整场成功率样本。
# started / failed 仍留在分母:前者是尚未形成终态的流失,后者是实际执行失败。
coupon_success_denominator = coupon_started - coupon_abandoned
coupon_completed_elapsed = sorted(
s.elapsed_ms
for s in period_coupon_sessions
@@ -731,9 +737,13 @@ def dashboard_overview(
},
"coupon": {
"started": coupon_started,
"abandoned": coupon_abandoned,
"success_denominator": coupon_success_denominator,
"all_success": coupon_all_success,
"success_rate": (
round(coupon_all_success / coupon_started, 4) if coupon_started else None
round(coupon_all_success / coupon_success_denominator, 4)
if coupon_success_denominator
else None
),
"point_success": coupon_point_success,
"points_per_session": coupon_points_per_session,
+2 -2
View File
@@ -35,7 +35,7 @@ def list_comparison_records(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled|running)$")] = None,
business_type: Annotated[str | None, Query()] = None,
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None,
@@ -66,7 +66,7 @@ def comparison_records_summary(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled|running)$")] = None,
business_type: Annotated[str | None, Query()] = None,
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None,
+8 -2
View File
@@ -13,14 +13,19 @@ class AdminComparisonListItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
# 软鉴权/匿名下帧0 建行时 user_id 可能暂缺(见 models.comparison 注释);admin 全看含孤儿行,故可空。
user_id: int | None = None
phone: str | None = None # join User 瞬态(非 DB 列)
nickname: str | None = None # join User 瞬态
business_type: str
trace_id: str
# admin 是 debug 工具,无条件下发 trace_url(不看 user.debug_trace_enabled)
trace_url: str | None = None
status: str
status: str # success / failed / cancelled / running;旧细分值由前端兼容映射
# admin 展示口径(见 repositories/comparison_outcome):成功含「跑完但外部缺失」,
# 纯技术故障才 failed;outcome_hint 非空=有缺失,前端标感叹号。
admin_status: str = "success"
outcome_hint: str | None = None
information: str | None = None
store_name: str | None = None
product_names: str | None = None # 下单商品名派生串(顿号分隔;「商品」列展示 + 商品搜索)
@@ -83,6 +88,7 @@ class AdminComparisonDetail(AdminComparisonListItem):
skipped_dish_count: int | None = None
device_id: str | None = None
items: list = []
platforms: list = [] # pricebot 渲染就绪的逐平台卡片模型(status=ok/业务失败细分)
comparison_results: list = [] # 逐平台对比(价格/rank/coupon/打烊...)
skipped_dish_names: list = []
# 全量环境
+8 -2
View File
@@ -79,10 +79,16 @@ class CouponDataRow(BaseModel):
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
claimed_count: int | None = None
point_success_count: int | None = Field(
None, description="本次成功单券数(success+already_claimed);无逐券事件为空"
None,
description="本次成功单券数(success+already_claimed);中途退出且无逐券结果为0,其它无事件为空",
)
point_total_count: int | None = Field(
None, description="本次尝试单券数(success+already_claimed+failed,不含 skipped);无逐券事件为空"
None,
description="本次尝试单券数(success+already_claimed+failed,不含 skipped);中途退出且无逐券结果为0,其它无事件为空",
)
point_event_count: int = Field(
0,
description="本次全部逐券事件数(含 skipped);用于区分无有效计分事件与完全无事件",
)
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
ad_revenue_yuan: float = Field(
+3
View File
@@ -70,6 +70,9 @@ class DashboardPeriodCoupon(BaseModel):
成功口径 success+already_claimed(与「我的」页累计领券一致)。"""
started: int = 0
# 用户主动中途退出,不计入整场成功率分母。
abandoned: int = 0
success_denominator: int = 0
# 全部领成功的次数:completed 且当日该设备全部点位成功
all_success: int = 0
success_rate: float | None = None
+23 -5
View File
@@ -21,6 +21,7 @@ from app.core.trace_ids import new_trace_id
from app.repositories import comparison as crud_compare
from app.repositories import risk as risk_repo
from app.schemas.compare_record import (
CompareQuotaOut,
CompareStartReserveIn,
CompareStartReserveOut,
CompareStatsOut,
@@ -36,6 +37,8 @@ logger = logging.getLogger("shagua.compare_record")
router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
COMPARE_DAILY_LIMIT_MESSAGE = "今日比价额度用完啦,明天再来吧~"
@router.post(
"/start",
@@ -77,11 +80,7 @@ def reserve_compare_start(
except crud_compare.DailyCompareStartLimitExceeded:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=(
f"今日已比价超过{policy.limit}次,请明天再试"
if policy.limit is not None
else "今日比价次数已达上限,请明天再试"
),
detail=COMPARE_DAILY_LIMIT_MESSAGE,
) from None
except crud_compare.ComparisonTraceOwnershipError:
raise HTTPException(
@@ -155,6 +154,25 @@ def stats(user: CurrentUser, db: DbSession) -> CompareStatsOut:
return CompareStatsOut(compare_count=count, discovered_saved_cents=saved)
@router.get(
"/quota",
response_model=CompareQuotaOut,
summary="查询今天的比价次数配额(只读,不预占)",
)
def get_compare_quota(
user: CurrentUser,
db: DbSession,
device_id: str | None = Query(default=None),
) -> CompareQuotaOut:
"""按登录用户查今日比价配额,口径与 /compare/start 同源。
used 按 user_id 计数;limit/reset_at 按 phone+device 解析(与 /start 一致,device 白名单能命中)。
exhausted=true → 已达今日上限。客户端①④入口点击时前置查此,超限就地 toast 不跳转。"""
policy = limit_policy.resolve(db, "compare.start.daily", phone=user.phone, device=device_id)
used = crud_compare.get_daily_compare_used(db, user.id, reset_at=policy.reset_at)
exhausted = policy.limit is not None and used >= policy.limit
return CompareQuotaOut(exhausted=exhausted, used=used, limit=policy.limit)
@router.get(
"/records",
response_model=ComparisonRecordPage,
+4 -2
View File
@@ -54,11 +54,13 @@ def _app_status(db_status: str) -> str:
def _record_out(fb) -> FeedbackRecordOut:
images = fb.images or []
return FeedbackRecordOut(
id=fb.id,
content=fb.content,
scene=getattr(fb, "scene", None),
images=fb.images or [],
images=images,
image_thumbnails=[media.feedback_thumbnail_url(url) for url in images],
status=_app_status(fb.status),
reject_reason=getattr(fb, "reject_reason", None),
reward_coins=getattr(fb, "reward_coins", None),
@@ -84,7 +86,7 @@ async def submit_feedback(
device_model: str = Form(default=""),
rom_name: str = Form(default=""),
android_version: str = Form(default=""),
images: list[UploadFile] = File(default=[]),
images: list[UploadFile] = File(default=[]), # noqa: B008 - FastAPI dependency declaration
) -> FeedbackOut:
content = content.strip()
contact = contact.strip()
+314
View File
@@ -0,0 +1,314 @@
"""比价失败报警后台任务:周期扫 comparison_record 新落定记录 → 规则命中 → 飞书汇总。
结构仿 heartbeat_monitor_worker(单实例文件锁 + asyncio 轮询 + 优雅退出);发送与 DB 全同步,
放 asyncio.to_thread。水位存 app_config(key=compare_alert.last_watermark,值=上次处理的最大
updated_at ISO 串),查询用 updated_at 自身比较、规避时区。见 spec 第 5/6 节。
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from collections.abc import Iterator
from dataclasses import replace as _dc_replace
from datetime import datetime
from pathlib import Path
from sqlalchemy import func, select
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
from app.core.rewards import CN_TZ
from app.db.session import SessionLocal
from app.integrations import feishu_notifier
from app.models.app_config import AppConfig
from app.models.comparison import ComparisonRecord
from app.models.user import User
from app.services import trace_stuck
from app.services.compare_alert import (
AlertHit,
classify_cancelled_fallback,
classify_record,
make_hit,
)
from app.services.compare_alert_format import format_alert_card
logger = logging.getLogger("shagua.compare_alert")
WATERMARK_KEY = "compare_alert.last_watermark"
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "compare_alert.lock"
def _read_watermark(db) -> datetime | None:
row = db.get(AppConfig, WATERMARK_KEY)
if row is None or not row.value:
return None
try:
return datetime.fromisoformat(row.value)
except (ValueError, TypeError):
return None
def _write_watermark(db, value: datetime) -> None:
iso = value.isoformat()
row = db.get(AppConfig, WATERMARK_KEY)
if row is None:
db.add(AppConfig(key=WATERMARK_KEY, value=iso, updated_by_admin_id=None))
else:
row.value = iso
db.commit()
def _send_card(card: dict) -> None:
"""发飞书交互卡片(webhook 为空则只打日志、不外发)。失败抛 FeishuNotifyError 由调用方处理。"""
webhook = settings.COMPARE_ALERT_FEISHU_WEBHOOK
if not webhook:
title = card.get("header", {}).get("title", {}).get("content", "")
logger.info("[compare-alert] webhook 未配置,仅打印: title=%s", title)
return
feishu_notifier.send_feishu_card(
webhook, card, timeout=settings.COMPARE_ALERT_FEISHU_TIMEOUT_SEC
)
def _trace_dir(base: Path, trace_url: str | None) -> Path | None:
"""URL → trace 目录 Path;trace_url 缺失/无法解析 → None。"""
name = trace_stuck.dir_name_from_trace_url(trace_url)
if not name:
return None
return base / name
def _fmt_stuck(sp: trace_stuck.StuckPoint) -> str:
"""StuckPoint → 「平台·环节 110帧/32s」;stuck_ms 为 None 时省略时长。"""
s = f"{sp.label()} {sp.frames}"
if sp.stuck_ms is not None:
s += f"/{round(sp.stuck_ms / 1000)}s"
return s
def build_hits(
records: list,
*,
work_log_dir: str,
stuck_threshold: int,
max_tail: int,
max_trace_reads: int,
cancelled_ms_threshold: int,
cancelled_step_threshold: int,
timeout_keywords: tuple[str, ...],
unrecognized_keywords: tuple[str, ...],
biz_exclude_keywords: tuple[str, ...],
) -> list[AlertHit]:
"""编排:cancelled 先读 trace,判出原地卡点就带卡点报 T5,否则(可读没卡点/读不到)一律回退
耗时/步数兜底——超长放弃照报(卡点列留空);failed 命中后附末段卡点。
trace 读取限量 max_trace_reads 次/轮;任何 trace 异常都在 trace_stuck 内部降级为「读不到」,
cancelled 因而走耗时兜底、failed 不附卡点,绝不影响报警发送。trace 只做「锦上添花」标注卡点,
绝不因「可读但没判出卡点」把超长放弃吞掉(线上 trace 几乎总可读,否则 total_ms 阈值形同虚设)。
"""
base = Path(work_log_dir) if work_log_dir else None
reads = 0
hits: list = []
for rec in records:
if rec.status == "cancelled":
res = None
if base is not None and reads < max_trace_reads:
td = _trace_dir(base, rec.trace_url)
if td is not None:
res = trace_stuck.read_stuck_points(
td, threshold=stuck_threshold, max_tail=max_tail
)
reads += 1
if res is not None and res.readable and res.points:
stuck = "".join(_fmt_stuck(sp) for sp in res.points)
hit = _dc_replace(make_hit(rec, "T5", "深度放弃"), stuck_point=stuck)
else:
# trace 判出卡点 → 上面带卡点报。其余一律回退耗时/步数兜底:可读但没判出卡点、
# 读不到、无 trace,都过 total_ms/step 阈值——超长放弃照报(卡点列留空),不再因
# 「trace 可读但不原地卡」把超长放弃整条吞掉(线上 trace 几乎总可读,否则耗时阈值形同虚设)。
hit = classify_cancelled_fallback(
rec,
cancelled_ms_threshold=cancelled_ms_threshold,
cancelled_step_threshold=cancelled_step_threshold,
)
if hit is not None:
hits.append(hit)
else:
hit = classify_record(
rec,
cancelled_ms_threshold=cancelled_ms_threshold,
cancelled_step_threshold=cancelled_step_threshold,
timeout_keywords=timeout_keywords,
unrecognized_keywords=unrecognized_keywords,
biz_exclude_keywords=biz_exclude_keywords,
)
if (
hit is not None
and hit.alert_type in ("T1", "T2", "T6")
and base is not None
and reads < max_trace_reads
):
td = _trace_dir(base, rec.trace_url)
if td is not None:
sp = trace_stuck.last_step(td)
reads += 1
if sp is not None:
# failed 是「末帧停在哪」:last_step 返回的 frames 是该平台总帧数(非"卡住"帧数)、
# stuck_ms=None,带上帧数/时长会误导,故只用 label() 显示环节
hit = _dc_replace(hit, stuck_point=sp.label())
if hit is not None:
hits.append(hit)
return hits
def _scan_and_alert() -> None:
"""一轮:读水位 → 查有更新记录 → 规则 → 有命中发飞书 → 成功推进水位。同步,放 to_thread 调。"""
with SessionLocal() as db:
watermark = _read_watermark(db)
if watermark is None:
# 冷启动:水位 = 当前 max(updated_at),不回溯历史失败。空表则本轮不建水位、下轮再说——
# 不用 datetime.now():那是本地时钟,与 SQLite 的 updated_at(UTC CURRENT_TIMESTAMP)
# 不同源、会差 8h,导致新记录永远追不上水位。只用 DB 产出的 updated_at 值。
max_updated = db.scalar(select(func.max(ComparisonRecord.updated_at)))
if max_updated is None:
logger.info("[compare-alert] 冷启动:表空,待有记录后再建水位")
return
_write_watermark(db, max_updated)
logger.info("[compare-alert] 冷启动,水位初始化=%s", max_updated)
return
records = list(
db.scalars(
select(ComparisonRecord)
.where(ComparisonRecord.updated_at > watermark)
.order_by(ComparisonRecord.updated_at.asc())
)
)
if not records:
return
batch_max = max(r.updated_at for r in records)
hits = build_hits(
records,
work_log_dir=settings.COMPARE_ALERT_PRICEBOT_WORK_LOG_DIR,
stuck_threshold=settings.COMPARE_ALERT_STUCK_FRAME_THRESHOLD,
max_tail=settings.COMPARE_ALERT_TRACE_MAX_TAIL_FRAMES,
max_trace_reads=settings.COMPARE_ALERT_TRACE_MAX_RECORDS,
cancelled_ms_threshold=settings.COMPARE_ALERT_CANCELLED_MS_THRESHOLD,
cancelled_step_threshold=settings.COMPARE_ALERT_CANCELLED_STEP_THRESHOLD,
timeout_keywords=settings.compare_alert_timeout_keywords,
unrecognized_keywords=settings.compare_alert_unrecognized_keywords,
biz_exclude_keywords=settings.compare_alert_biz_exclude_keywords,
)
if hits or settings.COMPARE_ALERT_SEND_EMPTY:
label = datetime.now(CN_TZ).strftime("%Y-%m-%d %H:%M")
interval_min = max(1, settings.COMPARE_ALERT_SCAN_INTERVAL_SEC // 60)
if hits:
# join User 取手机号
uids = {h.user_id for h in hits if h.user_id is not None}
if uids:
users = db.scalars(select(User).where(User.id.in_(uids)))
phone_map = {u.id: u.phone for u in users}
else:
phone_map = {}
else:
phone_map = {}
card = format_alert_card(
hits,
window_label=label,
phone_map=phone_map,
interval_min=interval_min,
max_detail_per_type=settings.COMPARE_ALERT_MAX_DETAIL_PER_TYPE,
max_total=settings.COMPARE_ALERT_MAX_TOTAL,
)
try:
_send_card(card)
except feishu_notifier.FeishuNotifyError:
logger.warning("[compare-alert] 发送失败,水位不推进、下轮补发", exc_info=True)
return # 不推进水位
_write_watermark(db, batch_max)
if hits:
logger.info("[compare-alert] 本轮命中 %d 条,水位推进到 %s", len(hits), batch_max)
# ---- 单实例锁 + 轮询循环(结构同 heartbeat_monitor_worker)---------------------
def _touch_lock() -> None:
with contextlib.suppress(FileNotFoundError):
os.utime(_LOCK_PATH, None)
@contextlib.contextmanager
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
fd: int | None = None
try:
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
try:
age = time.time() - _LOCK_PATH.stat().st_mtime
except FileNotFoundError:
age = stale_after_sec + 1
if age > stale_after_sec:
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
fd = None
if fd is None:
yield False
return
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
yield True
finally:
if fd is not None:
os.close(fd)
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
async def _run_loop() -> None:
interval = max(60, int(settings.COMPARE_ALERT_SCAN_INTERVAL_SEC))
lock_stale_after = max(interval * 3, 600)
with _single_instance_lock(lock_stale_after) as lock_acquired:
if not lock_acquired:
logger.warning("compare-alert skipped: another worker owns lock")
return
logger.info("compare-alert worker started interval=%ss", interval)
try:
while True:
try:
_touch_lock()
await asyncio.to_thread(_scan_and_alert)
except SQLAlchemyError:
logger.exception("compare-alert db error")
except Exception: # noqa: BLE001 - 后台任务不因单次异常退出
logger.exception("compare-alert unexpected error")
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("compare-alert worker stopped")
raise
def start_compare_alert_worker() -> asyncio.Task | None:
if not settings.COMPARE_ALERT_ENABLED:
logger.info("compare-alert worker disabled")
return None
return asyncio.create_task(_run_loop(), name="compare-alert-worker")
async def stop_compare_alert_worker(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
+36
View File
@@ -132,6 +132,26 @@ class Settings(BaseSettings):
HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
# ===== 比价失败报警(常驻 worker 周期扫 comparison_record → 飞书汇总)=====
COMPARE_ALERT_ENABLED: bool = False # 总开关(默认关;启用用 .env COMPARE_ALERT_ENABLED=true 覆盖,别改这默认值);关时 worker 不启动
COMPARE_ALERT_SCAN_INTERVAL_SEC: int = 900 # 扫描间隔(默认 15min,可配 1800=30min)
COMPARE_ALERT_FEISHU_WEBHOOK: str = "" # 群机器人 webhook(敏感,放 .env 别硬编码进代码);空则 worker 仅打日志不外发
COMPARE_ALERT_FEISHU_TIMEOUT_SEC: float = 10.0 # 飞书 POST 读/连超时
COMPARE_ALERT_CANCELLED_MS_THRESHOLD: int = 90000 # T5 耗时阈值(ms)
COMPARE_ALERT_CANCELLED_STEP_THRESHOLD: int = 30 # T5 步数阈值
COMPARE_ALERT_TIMEOUT_KEYWORDS: str = "超时,启动,加载" # T2 关键词(逗号分隔)
COMPARE_ALERT_UNRECOGNIZED_KEYWORDS: str = "未识别" # T6 关键词
COMPARE_ALERT_BIZ_EXCLUDE_KEYWORDS: str = "未找到,打烊,起送,门店,店内,不配送,这些菜,未入驻,休息" # T1 information 业务词排除
COMPARE_ALERT_MAX_DETAIL_PER_TYPE: int = 20 # 单类型明细截断
COMPARE_ALERT_MAX_TOTAL: int = 50 # 本期总命中截断(超则只给计数)
COMPARE_ALERT_SEND_EMPTY: bool = False # 无命中是否发「本期无异常」简讯
# ===== 卡死定位(读 pricebot trace 末段判原地打转)=====
COMPARE_ALERT_PRICEBOT_WORK_LOG_DIR: str = "" # pricebot work_logs 绝对路径(敏感,放 .env);空=跳过 trace、cancelled 全走保底
COMPARE_ALERT_STUCK_FRAME_THRESHOLD: int = 15 # 末段连续同环节达此帧数判卡死
COMPARE_ALERT_TRACE_MAX_TAIL_FRAMES: int = 40 # 每平台最多往前读多少帧
COMPARE_ALERT_TRACE_MAX_RECORDS: int = 30 # 每轮最多对多少条命中记录读 trace(限量)
# ===== 短信 =====
SMS_MOCK: bool = True
SMS_CODE_TTL_SEC: int = 300
@@ -217,6 +237,22 @@ class Settings(BaseSettings):
phones.add(self.test_account_phone)
return frozenset(phones)
def _csv(self, raw: str) -> tuple[str, ...]:
"""逗号分隔字符串 → 去空白非空元组(报警关键词解析共用)。"""
return tuple(w.strip() for w in raw.split(",") if w.strip())
@property
def compare_alert_timeout_keywords(self) -> tuple[str, ...]:
return self._csv(self.COMPARE_ALERT_TIMEOUT_KEYWORDS)
@property
def compare_alert_unrecognized_keywords(self) -> tuple[str, ...]:
return self._csv(self.COMPARE_ALERT_UNRECOGNIZED_KEYWORDS)
@property
def compare_alert_biz_exclude_keywords(self) -> tuple[str, ...]:
return self._csv(self.COMPARE_ALERT_BIZ_EXCLUDE_KEYWORDS)
# ===== 美团联盟 CPS =====
# 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。
MT_CPS_APP_KEY: str = ""
+5 -1
View File
@@ -227,7 +227,11 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
# 编辑框;set_value 不校验类型,嵌套 JSON 照存。
"llm_token_price": {
"default": {
"per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}},
"per_model": {
"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0},
# DashScope 华北 2 公网调用原价;必须显式配置,不能落到 3/15 的未知模型兜底价。
"deepseek-v4-flash": {"input_per_1m": 1.0, "output_per_1m": 2.0},
},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
"currency": "CNY", "unit": "per_1m_tokens",
},
+90 -2
View File
@@ -11,6 +11,7 @@
from __future__ import annotations
import json
import logging
import os
import secrets
import subprocess
@@ -18,8 +19,17 @@ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from pathlib import Path
from typing import Any
from PIL import Image, ImageOps
from app.core.config import settings
logger = logging.getLogger("shagua.media")
_FEEDBACK_DIR = "feedback"
_FEEDBACK_THUMB_DIR = "feedback_thumbs"
_FEEDBACK_THUMB_MAX_PX = 256
_FEEDBACK_THUMB_QUALITY = 78
class MediaError(Exception):
"""上传文件不合法(类型/大小)。调用方转 400。"""
@@ -68,8 +78,86 @@ def save_avatar(user_id: int, data: bytes) -> str:
def save_feedback_image(user_id: int, data: bytes) -> str:
"""保存反馈截图,返回相对 URL(`/media/feedback/<file>`)"""
return _save_image("feedback", user_id, data)
"""保存反馈截图并预生成历史页缩略图,返回原图相对 URL"""
url = _save_image(_FEEDBACK_DIR, user_id, data)
# 缩略图失败不影响反馈受理;读取缩略图 URL 时会按需重试并回退原图。
ensure_feedback_thumbnail(url)
return url
def feedback_thumbnail_url(image_url: str) -> str:
"""把反馈原图 URL 映射成确定的缩略图 URL,不在 records 接口内做图片解码。
上传文件名由服务端生成且不会覆盖;旧数据在客户端真正请求可见图片时按需补图。
"""
paths = _feedback_thumbnail_paths(image_url)
return paths[2] if paths is not None else image_url
def _feedback_thumbnail_paths(image_url: str) -> tuple[Path, Path, str] | None:
prefix = f"{settings.MEDIA_URL_PREFIX}/{_FEEDBACK_DIR}/"
if not image_url.startswith(prefix):
return None
filename = image_url.removeprefix(prefix)
# 只接受当前目录下的单个文件名,避免数据库脏数据造成路径穿越。
if not filename or Path(filename).name != filename:
return None
source = _media_dir(_FEEDBACK_DIR) / filename
thumb_name = f"{Path(filename).stem}.jpg"
thumb = _media_dir(_FEEDBACK_THUMB_DIR) / thumb_name
thumb_url = f"{settings.MEDIA_URL_PREFIX}/{_FEEDBACK_THUMB_DIR}/{thumb_name}"
return source, thumb, thumb_url
def ensure_feedback_thumbnail(image_url: str) -> Path | None:
"""确保缩略图存在并返回文件;生成失败时回退原图,供动态缩略图路由使用。"""
paths = _feedback_thumbnail_paths(image_url)
if paths is None:
return None
source, thumb, _ = paths
if thumb.is_file():
return thumb
if not source.is_file():
return None
temp = thumb.with_name(f".{thumb.name}.{secrets.token_hex(4)}.tmp")
try:
with Image.open(source) as opened:
image = ImageOps.exif_transpose(opened)
image.thumbnail(
(_FEEDBACK_THUMB_MAX_PX, _FEEDBACK_THUMB_MAX_PX),
Image.Resampling.LANCZOS,
)
if image.mode != "RGB":
image = image.convert("RGB")
image.save(
temp,
format="JPEG",
quality=_FEEDBACK_THUMB_QUALITY,
optimize=True,
)
os.replace(temp, thumb)
return thumb
except (Image.DecompressionBombError, OSError, ValueError):
logger.warning("生成反馈缩略图失败: %s", source, exc_info=True)
return source
finally:
temp.unlink(missing_ok=True)
def feedback_thumbnail_file(filename: str) -> Path | None:
"""由缩略图文件名找到原反馈图并按需生成,非法/不存在返回 None。"""
if not filename or Path(filename).name != filename or Path(filename).suffix.lower() != ".jpg":
return None
stem = Path(filename).stem
for ext in (".jpg", ".png", ".webp"):
original = _media_dir(_FEEDBACK_DIR) / f"{stem}{ext}"
if original.is_file():
original_url = f"{settings.MEDIA_URL_PREFIX}/{_FEEDBACK_DIR}/{original.name}"
return ensure_feedback_thumbnail(original_url)
return None
def save_report_image(user_id: int, data: bytes) -> str:
+2 -1
View File
@@ -58,7 +58,7 @@ WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
# 规则(2026-07-09 拍板):
# - 新人档(is_newbie):账号历史一次性,"发起就算用过"(任意状态含被拒),用过即不再下发;
# 0.1 与 0.3 各自独立同天可各提一次,且不参与常规档"每日选一个额度"互斥。
# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1),档每天只能选一个。
# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1 / 100×1),档每天只能选一个。
# invite_cash(邀请页)本轮无档位概念,不在此表。改档位=改这里发版。
class WithdrawTier(NamedTuple):
amount_cents: int
@@ -74,6 +74,7 @@ WITHDRAW_TIERS_COIN_CASH: tuple[WithdrawTier, ...] = (
WithdrawTier(50, "0.5", None, 3, False),
WithdrawTier(1000, "10", None, 1, False),
WithdrawTier(2000, "20", None, 1, False),
WithdrawTier(10000, "100", None, 1, False),
)
+48
View File
@@ -0,0 +1,48 @@
"""飞书群自定义机器人发送(text / post 消息)。
自定义机器人关键词验证:消息 content.text 必须含机器人配置的关键词,否则飞书返回 code!=0
( 19024 Key Words Not Found)本项目消息由 compare_alert_format 生成,标题已含比价失败报警
不需要签名(sign)/IP 白名单文档:https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
"""
from __future__ import annotations
import httpx
class FeishuNotifyError(Exception):
"""飞书发送失败(网络错误 / 非 2xx / 业务 code!=0,含关键词不匹配)。"""
def _post_feishu(webhook_url: str, payload: dict, timeout: float) -> None:
"""内部 helper:POST payload 到飞书 webhook 并校验响应。失败抛 FeishuNotifyError。"""
try:
resp = httpx.post(webhook_url, json=payload, timeout=timeout)
except httpx.HTTPError as e:
raise FeishuNotifyError(f"feishu request failed: {e}") from e
if resp.status_code >= 300:
raise FeishuNotifyError(f"feishu http {resp.status_code}: {resp.text[:200]}")
try:
data = resp.json()
except ValueError as e:
raise FeishuNotifyError(f"feishu bad json: {resp.text[:200]}") from e
code = data.get("code", data.get("StatusCode", 0))
if code not in (0, None):
raise FeishuNotifyError(f"feishu code={code} msg={data.get('msg') or data.get('StatusMessage')}")
def send_feishu_text(webhook_url: str, text: str, *, timeout: float = 10.0) -> None:
"""POST 一条 text 消息到飞书群机器人 webhook。失败(网络/HTTP/业务 code)抛 FeishuNotifyError。"""
payload = {"msg_type": "text", "content": {"text": text}}
_post_feishu(webhook_url, payload, timeout)
def send_feishu_post(webhook_url: str, title: str, content: list, *, timeout: float = 10.0) -> None:
"""发飞书富文本(post)。content 是段落数组,每段是元素数组[{tag:text/a,...}]。失败抛 FeishuNotifyError。"""
payload = {"msg_type": "post", "content": {"post": {"zh_cn": {"title": title, "content": content}}}}
_post_feishu(webhook_url, payload, timeout)
def send_feishu_card(webhook_url: str, card: dict, *, timeout: float = 10.0) -> None:
"""发飞书交互卡片(interactive)。card 为 schema 2.0 卡片 dict。失败抛 FeishuNotifyError。"""
payload = {"msg_type": "interactive", "card": card}
_post_feishu(webhook_url, payload, timeout)
+38 -2
View File
@@ -10,7 +10,7 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
@@ -44,6 +44,11 @@ from app.api.v1.tasks import router as tasks_router
from app.api.v1.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.api.v1.wxpay import router as wxpay_router
from app.core import media
from app.core.compare_alert_worker import (
start_compare_alert_worker,
stop_compare_alert_worker,
)
from app.core.config import settings
from app.core.cps_reconcile_worker import (
start_cps_reconcile_worker,
@@ -82,6 +87,19 @@ setup_logging(debug=settings.APP_DEBUG)
logger = logging.getLogger("shagua.main")
class FeedbackMediaStaticFiles(StaticFiles):
"""反馈原图/缩略图文件名不可变,可长期缓存,避免列表反复回源。"""
async def get_response(self, path: str, scope):
response = await super().get_response(path, scope)
media_path = path.replace("\\", "/").lstrip("/")
if response.status_code == 200 and media_path.startswith(
("feedback/", "feedback_thumbs/")
):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
# 提示而非强制建表:生产用 alembic upgrade head,本地 dev 也建议先跑一次 migration。
@@ -109,6 +127,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
observe_task = start_observe_worker()
inactivity_task = start_inactivity_reset_worker()
llm_cost_backfill_task = start_llm_cost_backfill_worker()
compare_alert_task = start_compare_alert_worker()
try:
yield
finally:
@@ -119,6 +138,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
await stop_observe_worker(observe_task)
await stop_inactivity_reset_worker(inactivity_task)
await stop_llm_cost_backfill_worker(llm_cost_backfill_task)
await stop_compare_alert_worker(compare_alert_task)
await aclose_pricebot_client()
mt_meituan.close_client()
logger.info("shutting down")
@@ -212,8 +232,24 @@ def download_apk() -> FileResponse:
)
@app.get(
f"{settings.MEDIA_URL_PREFIX}/feedback_thumbs/{{filename}}",
tags=["feedback"],
include_in_schema=False,
)
def feedback_thumbnail(filename: str) -> FileResponse:
"""旧反馈图按首次可见请求补缩略图;新图上传时已预生成。"""
path = media.feedback_thumbnail_file(filename)
if path is None:
raise HTTPException(status_code=404, detail="图片不存在")
return FileResponse(
path,
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
app.mount(
settings.MEDIA_URL_PREFIX,
StaticFiles(directory=str(_media_root)),
FeedbackMediaStaticFiles(directory=str(_media_root)),
name="media",
)
+13 -1
View File
@@ -49,6 +49,9 @@ class ComparisonRecord(Base):
# 单列 user_id 索引只能过滤,排序仍要把该用户全部记录取出来排一遍;这条复合索引的**反向扫**
# 恰好等于 (created_at DESC, id DESC),PG 直接取前 n 条、免排序。列序不能动。
Index("ix_comparison_user_created", "user_id", "created_at", "id"),
# 比价报警 worker 的水位查询 WHERE updated_at > watermark 走它。显式命名(不用列上
# index=True 的自动名 ix_comparison_record_updated_at)以与迁移 create_index 同名、免 autogenerate 漂移。
Index("ix_comparison_updated", "updated_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -97,7 +100,7 @@ class ComparisonRecord(Base):
total_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
skipped_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# success(拿到有效对比)/ failed(出错或没采到目标价)
# success(流程正常完成,含 below_minimum)/ failed(技术异常或未形成可比报价,含店铺打烊等)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="success")
# done 帧 information 文案。成功:"在美团找到同店,到手价 ¥X…";
# 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。
@@ -160,6 +163,15 @@ class ComparisonRecord(Base):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
# 记录任一次更新(建 running 行 / done / abort 落终态)的 DB 时钟时间。比价报警 worker 的
# 水位列:按 updated_at 单调推进扫描,任何记录落定/更新都刷新它 > 水位、必被下轮扫到,
# 根治 created_at 水位漏掉「慢失败」(落定延迟 p99 达 7-10min)。onupdate 在 ORM UPDATE 时自动刷新。
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return (
+66 -10
View File
@@ -76,6 +76,30 @@ _BIZ_STATUS_PRIORITY = (
)
def _normalize_record_status(status: str | None) -> str | None:
"""Map granular business outcomes onto the record lifecycle status.
``record_status`` describes the business outcome, while
``comparison_record.status`` is also the completed-comparison flag used by
milestones, stats and idempotent rewards. ``below_minimum`` is a completed
success because the target cart produced a trustworthy conclusion. Other
known target-side outcomes did not produce a comparable quote and belong
to the failed record bucket. The granular outcome remains in ``raw_payload``
and ``platform_results`` for result rendering.
"""
if status == "below_minimum":
return "success"
if status in {
"store_closed",
"store_not_found",
"items_not_found",
"no_delivery",
"unsupported",
}:
return "failed"
return status
def _store_closed_text(reason: str | None) -> str:
"""打烊/暂停营业/休息类 reason 常带脏店名元数据 → 只留结论,套简短模板。"""
r = reason or ""
@@ -164,9 +188,10 @@ def _derive(payload: ComparisonRecordIn) -> dict:
is_source_best = best.is_source if best is not None else None
# status:优先 pricebot record_status(区分 below_minimum/store_closed) → 客户端显式 status
# → 兜底"非源且有价"=success/否则 failed。record_status 让"未满起送"不再塌缩成 failed。
status = payload.record_status or payload.status
# status:优先 pricebot record_status → 客户端显式 status → 兜底派生。
# below_minimum 是已形成可信结论的正常完成态,记录级归 success;细分结局仍完整保留在
# raw_payload/platform_results,供结果卡展示"未满起送"。
status = _normalize_record_status(payload.record_status or payload.status)
if status is None:
has_valid_target = any(
(not r.is_source) and r.price is not None for r in results
@@ -201,7 +226,9 @@ def upsert_record(
# 单源派生: 与 harvest_done 一致, payload 带 platforms 时从它派生(唯一真相源
# _derive_from_platforms), 老客户端不带 platforms 时回退 _derive(从 comparison_results)。
if payload.platforms:
derived = _derive_from_platforms(payload.platforms, payload.record_status)
derived = _derive_from_platforms(
payload.platforms, payload.record_status or payload.status
)
# 对齐 _derive 返回键(#189 fail_reason): 两路径 fields 键集一致, 覆盖已有行时不残留旧值
derived["fail_reason"] = (
_derive_fail_display(payload.information, payload.platform_results or {})
@@ -356,10 +383,10 @@ def _derive_from_results(
"saved_amount_cents": saved_amount_cents,
"is_source_best": best.get("is_source") if best else None,
"store_name": (src_row or {}).get("store_name") or None,
# 记录级结局: 优先用 pricebot 下发的 record_status(区分 below_minimum/store_closed,
# 不再把"未满起送"塌缩成 failed → 记录页不再误报"网络开小差"); 旧 pricebot 未下发时
# 回退老的 success/failed 二态派生, 向后兼容。
"status": record_status or ("success" if has_valid_target else "failed"),
# below_minimum 已完成到购物车并形成可信结论,记录级计 success;细分结局仍在
# raw_payload/platform_results。旧 pricebot 未下发 record_status 时回退二态派生。
"status": _normalize_record_status(record_status)
or ("success" if has_valid_target else "failed"),
}
@@ -412,7 +439,8 @@ def _derive_from_platforms(
"saved_amount_cents": saved_amount_cents,
"is_source_best": (best.get("role") == "source") if best else None,
"store_name": store_name or None,
"status": record_status or ("success" if has_valid_target else "failed"),
"status": _normalize_record_status(record_status)
or ("success" if has_valid_target else "failed"),
}
@@ -520,6 +548,33 @@ def reserve_daily_start(
return rec, int(used) + 1
def get_daily_compare_used(
db: Session,
user_id: int,
reset_at: datetime | None = None,
) -> int:
"""今日(北京时间自然日)该用户已发起的比价次数。只读,不改任何数据。
口径必须与 reserve_daily_start 完全一致( day_start/day_end/reset_at)"""
current = datetime.now(CN_TZ)
if current.tzinfo is not None:
current = current.astimezone(CN_TZ).replace(tzinfo=None)
day_start = current.replace(hour=0, minute=0, second=0, microsecond=0)
if reset_at is not None:
reset_start = reset_at
if reset_start.tzinfo is not None:
reset_start = reset_start.astimezone(CN_TZ).replace(tzinfo=None)
day_start = max(day_start, reset_start)
day_end = day_start + timedelta(days=1)
used = db.scalar(
select(func.count(ComparisonRecord.id)).where(
ComparisonRecord.user_id == user_id,
ComparisonRecord.created_at >= day_start,
ComparisonRecord.created_at < day_end,
)
) or 0
return int(used)
def harvest_running(
db: Session,
*,
@@ -584,7 +639,8 @@ def harvest_done(
行不存在(理论上帧0已建;防御)则新建"""
results = done_params.get("comparison_results") or []
# 展示模型统一数组(pricebot 新增, 每平台一行自带 status/is_best): 原样存, 记录页据此直渲染。
# record_status: 记录级结局(success/below_minimum/store_closed/failed), 覆盖老二态派生
# record_status: 记录级业务结局(success/below_minimum/store_closed/failed)。其中
# below_minimum 是正常完成态,持久化 status 归 success,原值仍随 done_params 落 raw_payload。
platforms = done_params.get("platforms") or []
record_status = done_params.get("record_status")
# 单源派生: platforms(含 pricebot 权威 is_best)是唯一真相源, best_*/source_*/saved/status
+6 -4
View File
@@ -735,8 +735,8 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -
- 新人档(0.1/0.3):账号历史一次性进行中(reviewing/pending)或成功打款(success)即视为
已用,直接**从返回列表消失**;被拒/转账失败/解绑退回(均已退款钱没到手)则恢复可提,不永久
占用资格两档各自独立互不影响,不参与"每日选一个额度"互斥
- 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
都计入,被拒/失败不退当天名额);档每天只能选一个,选定后其余档当天 other_tier_selected
- 常规档(0.5×3 / 10×1 / 20×1 / 100×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
都计入,被拒/失败不退当天名额);档每天只能选一个,选定后其余档当天 other_tier_selected
- invite_cash 本轮无档位概念 返回空列表(邀请页客户端仍用本地写死档位,行为不变)
余额是否足够由客户端本地判断(余额随兑换实时变化,不在此快照)
"""
@@ -785,7 +785,7 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -
out.append({
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
"is_newbie": True, "available": True, "disabled_reason": None,
"remaining_today": 1,
"remaining_today": 1, "daily_limit": t.daily_limit,
})
continue
used = today_counts.get(t.amount_cents, 0)
@@ -798,7 +798,9 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -
out.append({
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
"is_newbie": False, "available": available, "disabled_reason": reason,
"remaining_today": remaining,
# daily_limit 一起下发:客户端要靠它区分「本档能提 3 次」和「本档只能提 1 次」——
# 只看 remaining_today 分不出「0.5 已提两次剩 1」与「10 元一次没提剩 1」。
"remaining_today": remaining, "daily_limit": t.daily_limit,
})
return out
+11 -2
View File
@@ -111,8 +111,9 @@ class ComparisonRecordIn(BaseModel):
# status/is_best/display/display_order,记录页据此直渲染。宽松 list[dict] 存(结构由
# pricebot 定,server 只原样落库),前端读它、老记录空时回退 comparison_results。
platforms: list[dict] = Field(default_factory=list)
# 记录级结局(pricebot 下发): success/below_minimum/store_closed/failed。让"未满起送"不再
# 被塌缩成 failed。_derive 优先用它、其次客户端 status、再兜底二态派生。
# 记录级业务结局(pricebot 下发): success/below_minimum/store_closed/failed。
# below_minimum 表示流程正常完成,持久化主状态归 success;store_closed/items_not_found 等
# 已知无报价结局归 failed。原值仍随 raw_payload 落库,admin/记录页从 platform_results 展示细分结论。
record_status: str | None = None
# 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/
# unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列),
@@ -259,3 +260,11 @@ class MilestoneClaimResultOut(BaseModel):
milestone: int = Field(..., description="本次领取的档位序号")
coin_awarded: int = Field(..., description="本次发放金币")
coin_balance: int = Field(..., description="领奖后金币余额")
class CompareQuotaOut(BaseModel):
"""今天的比价次数配额状态(只读)。"""
exhausted: bool = Field(..., description="是否已达今日上限,无法再比价")
used: int = Field(..., description="今天已用次数")
limit: int | None = Field(..., description="今天的配额上限(None=无限制)")
+2
View File
@@ -33,6 +33,8 @@ class FeedbackRecordOut(BaseModel):
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
scene: str | None = None
images: list[str] = Field(default_factory=list)
# 与 images 下标一一对应;生成失败时该项回退原图 URL,兼容历史数据。
image_thumbnails: list[str] = Field(default_factory=list)
status: str
reject_reason: str | None = None
reward_coins: int | None = None
+8
View File
@@ -88,6 +88,14 @@ class WithdrawTierOut(BaseModel):
description="不可提原因:quota_exhausted(今日次数满) / other_tier_selected(今日已选其他额度)",
)
remaining_today: int = Field(0, description="今日剩余可提次数")
daily_limit: int = Field(
1,
description=(
"该档每日可提次数上限(rewards.WITHDRAW_TIERS_COIN_CASH.daily_limit)。"
"客户端据此判「本档可提多次」——只有 daily_limit>1 且 remaining_today<daily_limit"
"(= 今天已提过至少一次)才展示「今日还可提N次」角标。"
),
)
class WithdrawInfoOut(BaseModel):
+98
View File
@@ -0,0 +1,98 @@
"""比价失败报警规则:一条记录 → 命中的 AlertHit(或 None)。
纯函数不碰 DB(阈值/关键词由调用方从 config 传入),便于单测与调阈值判定顺序保证四类互斥:
failed fail_reason ( information 非业务)=T1 / 含未识别=T6 / 含超时词=T2 / 其余业务不报;
cancelled 且耗时或步数超阈值=T5(深度放弃);success/running 不报
口径依据见 docs/superpowers/specs/2026-08-04-compare-fail-alert-design.md 3
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any
ALERT_TYPE_LABELS: dict[str, str] = {
"T1": "系统技术失败",
"T6": "商品识别失败",
"T2": "启动/超时失败",
"T5": "深度放弃(cancelled)",
}
@dataclass(frozen=True)
class AlertHit:
trace_id: str
alert_type: str
reason: str
app_version: str | None
created_at: datetime | None
trace_url: str | None
user_id: int | None
total_ms: int | None = None
step_count: int | None = None
stuck_point: str | None = None
def make_hit(rec: Any, alert_type: str, reason: str) -> AlertHit:
return AlertHit(
trace_id=rec.trace_id,
alert_type=alert_type,
reason=reason,
app_version=getattr(rec, "app_version", None),
created_at=getattr(rec, "created_at", None),
trace_url=getattr(rec, "trace_url", None),
user_id=getattr(rec, "user_id", None),
total_ms=getattr(rec, "total_ms", None),
step_count=getattr(rec, "step_count", None),
)
def classify_cancelled_fallback(
rec: Any,
*,
cancelled_ms_threshold: int,
cancelled_step_threshold: int,
) -> AlertHit | None:
"""cancelled 保底判定(读不到 trace 时用):超耗时或步数阈值 → T5 深度放弃,否则 None。纯函数。"""
ms = rec.total_ms
step = rec.step_count
deep = (ms is not None and ms > cancelled_ms_threshold) or (
step is not None and step > cancelled_step_threshold
)
if deep:
return make_hit(rec, "T5", "深度放弃")
return None
def classify_record(
rec: Any,
*,
cancelled_ms_threshold: int,
cancelled_step_threshold: int,
timeout_keywords: tuple[str, ...],
unrecognized_keywords: tuple[str, ...],
biz_exclude_keywords: tuple[str, ...],
) -> AlertHit | None:
"""判定单条记录是否触发报警。rec 需有 status/fail_reason/information/total_ms/step_count/
trace_id/app_version 属性(ComparisonRecord 或等价对象)"""
status = rec.status
if status == "failed":
fail_reason = rec.fail_reason
if fail_reason is None:
info = (rec.information or "").strip()
if info and any(w in info for w in biz_exclude_keywords):
return None
return make_hit(rec, "T1", f"技术失败·{info[:80] or '比价过程出错'}")
if any(w in fail_reason for w in unrecognized_keywords):
return make_hit(rec, "T6", f"识别失败·{fail_reason[:80]}")
if any(w in fail_reason for w in timeout_keywords):
return make_hit(rec, "T2", fail_reason[:80])
return None
if status == "cancelled":
return classify_cancelled_fallback(
rec,
cancelled_ms_threshold=cancelled_ms_threshold,
cancelled_step_threshold=cancelled_step_threshold,
)
return None
+243
View File
@@ -0,0 +1,243 @@
"""AlertHit[] → 飞书群机器人消息。
提供三个格式化函数:
- format_alert_message: 纯文本(保留,已有集成测试依赖)
- format_alert_post: 富文本 post(行式明细:时间手机版本原因trace 超链接)
- format_alert_card: schema 2.0 卡片 + table 组件(正式发送格式)
按触发类型分组,每类给计数 + 明细(trace/版本/原因)两级截断防报警风暴:单类型超
max_detail_per_type 只列前 N + 另有 M ;本期总量超 max_total 只给各类型计数提示去分析库查
标题含关键词比价失败报警飞书自定义机器人用关键词验证,消息必须含它,否则被拒收
"""
from __future__ import annotations
from app.services.compare_alert import ALERT_TYPE_LABELS, AlertHit
ALERT_KEYWORD = "比价失败报警"
_TYPE_ORDER = ("T1", "T6", "T2", "T5")
def _detail_line(h: AlertHit) -> str:
ver = h.app_version or "?"
return f" - trace {h.trace_id} | {ver} | {h.reason}"
def format_alert_message(
hits: list[AlertHit],
*,
window_label: str,
max_detail_per_type: int,
max_total: int,
) -> str:
total = len(hits)
header = f"🚨 {ALERT_KEYWORD} · {window_label} · 本期触发 {total}"
grouped: dict[str, list[AlertHit]] = {t: [] for t in _TYPE_ORDER}
for h in hits:
grouped.setdefault(h.alert_type, []).append(h)
lines = [header]
counts_only = total > max_total
for t in _TYPE_ORDER:
bucket = grouped.get(t) or []
if not bucket:
continue
lines.append(f"{ALERT_TYPE_LABELS[t]} {len(bucket)}")
if counts_only:
continue
shown = bucket[:max_detail_per_type]
lines.extend(_detail_line(h) for h in shown)
if len(bucket) > max_detail_per_type:
lines.append(f" …另有 {len(bucket) - max_detail_per_type}")
if counts_only:
lines.append(f"(本期命中超 {max_total} 条,仅列计数,明细见分析库 comparison_record)")
return "\n".join(lines)
def format_alert_post(
hits: list[AlertHit],
*,
window_label: str,
phone_map: dict[int, str],
max_detail_per_type: int,
max_total: int,
) -> tuple[str, list]:
"""行式富文本:返回 (title, content)。title 含 ALERT_KEYWORD(飞书关键词验证)。
content: 摘要段(各类型计数) + 表头段 + 明细行(每条时间手机版本原因+ trace 超链接 a 元素)
phone_map: {user_id: phone};明细手机号取 phone_map.get(hit.user_id) or "-"
截断规则:总命中 > max_total 只出摘要+各类型计数(不列明细);
否则明细最多列前 max_detail_per_type ,超出加另有 N
"""
total = len(hits)
title = f"🚨 {ALERT_KEYWORD} · {window_label}"
grouped: dict[str, list[AlertHit]] = {t: [] for t in _TYPE_ORDER}
for h in hits:
grouped.setdefault(h.alert_type, []).append(h)
# 摘要段:各类型计数
summary_parts = [f"{ALERT_TYPE_LABELS[t]} {len(grouped[t])}" for t in _TYPE_ORDER if grouped.get(t)]
summary_text = f"合计 {total} 条:" + " ".join(summary_parts)
content: list[list[dict]] = [
[{"tag": "text", "text": summary_text}],
]
counts_only = total > max_total
if counts_only:
content.append([{"tag": "text", "text": f"(本期命中超 {max_total} 条,仅列计数,明细见分析库 comparison_record)"}])
return title, content
# 表头段
content.append([{"tag": "text", "text": "时间 | 手机号 | 版本 | 失败原因 | trace"}])
# 明细行(按类型顺序展开,每条一段)
shown_count = 0
for t in _TYPE_ORDER:
bucket = grouped.get(t) or []
if not bucket:
continue
for h in bucket[:max_detail_per_type]:
time_str = h.created_at.strftime("%m-%d %H:%M") if h.created_at else "-"
phone = phone_map.get(h.user_id) if h.user_id is not None else None
phone = phone or "-"
ver = h.app_version or "-"
row: list[dict] = [{"tag": "text", "text": f"{time_str} {phone} {ver} {h.reason} "}]
if h.trace_url:
row.append({"tag": "a", "text": "trace", "href": h.trace_url})
else:
row.append({"tag": "text", "text": h.trace_id[:16]})
content.append(row)
shown_count += 1
if len(bucket) > max_detail_per_type:
content.append([{"tag": "text", "text": f"…另有 {len(bucket) - max_detail_per_type}"}])
return title, content
# ---- format_alert_card (schema 2.0 卡片 + table 组件) ----
def _cost_cell(total_ms: int | None, step_count: int | None) -> str:
"""组合「用时」列值。有 ms → '{N}s',有 step_count → '{M}',两者用 ' / ' 连;都无 → '-'"""
parts = []
if total_ms is not None:
parts.append(f"{round(total_ms / 1000)}s")
if step_count is not None:
parts.append(f"{step_count}")
return " / ".join(parts) or "-"
def _build_card(title_text: str, elements: list[dict]) -> dict:
"""组装 schema 2.0 红色 header 卡片;三条路径只需决定 elements。"""
return {
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": title_text},
"template": "red",
},
"body": {"elements": elements},
}
def _build_table_rows(
hits: list[AlertHit],
*,
phone_map: dict[int, str],
max_detail_per_type: int,
) -> list[dict]:
"""按 _TYPE_ORDER 顺序展开,每类型最多 max_detail_per_type 条。"""
grouped: dict[str, list[AlertHit]] = {t: [] for t in _TYPE_ORDER}
for h in hits:
if h.alert_type in grouped:
grouped[h.alert_type].append(h)
# 非 _TYPE_ORDER 类型静默跳过(与既有行为一致)
rows = []
for t in _TYPE_ORDER:
bucket = grouped.get(t) or []
for h in bucket[:max_detail_per_type]:
time_str = h.created_at.strftime("%m-%d %H:%M") if h.created_at else "-"
phone = (phone_map.get(h.user_id) if h.user_id is not None else None) or "-"
trace = (
f"[链接]({h.trace_url})" if h.trace_url
else (h.trace_id or "")[:12]
)
rows.append({
"time": time_str,
"phone": phone,
"cost": _cost_cell(h.total_ms, h.step_count),
"reason": h.reason,
"stuck": h.stuck_point or "-",
"ver": h.app_version or "-",
"trace": trace,
})
return rows
_TABLE_COLUMNS = [
{"name": "time", "display_name": "时间", "data_type": "text"},
{"name": "phone", "display_name": "手机号", "data_type": "text"},
{"name": "cost", "display_name": "用时", "data_type": "text"},
{"name": "reason", "display_name": "失败原因", "data_type": "text"},
{"name": "stuck", "display_name": "卡点", "data_type": "text"},
{"name": "ver", "display_name": "版本", "data_type": "text"},
{"name": "trace", "display_name": "trace", "data_type": "lark_md"},
]
def format_alert_card(
hits: list[AlertHit],
*,
window_label: str,
phone_map: dict[int, str],
interval_min: int,
max_detail_per_type: int,
max_total: int,
) -> dict:
"""返回飞书 schema 2.0 卡片 dict(配合 send_feishu_card 发送)。
- header: template=redtitle ALERT_KEYWORD飞书关键词验证必须
- body 第一个元素: markdown 摘要数据范围 + 合计 + 各类型计数
- hits: 只有摘要本期无异常
- total > max_total: 只有摘要提示去 comparison_record 不加 table
- 否则: 第二个元素为 table列序 time/phone/cost/reason/stuck/ver/trace
"""
total = len(hits)
title_text = f"🚨 {ALERT_KEYWORD} · {window_label}"
# ---------- 空 hits ----------
if total == 0:
md_content = f"数据范围:近 {interval_min} 分钟\n本期无异常"
return _build_card(title_text, [{"tag": "markdown", "content": md_content}])
# ---------- 摘要 ----------
grouped_count: dict[str, int] = {}
for h in hits:
grouped_count[h.alert_type] = grouped_count.get(h.alert_type, 0) + 1
count_parts = [
f"{ALERT_TYPE_LABELS[t]} {grouped_count[t]}"
for t in _TYPE_ORDER
if grouped_count.get(t)
]
md_content = (
f"数据范围:近 {interval_min} 分钟\n"
f"**合计 {total} 条**" + " ".join(count_parts)
)
# ---------- 截断:超 max_total 只出摘要 ----------
if total > max_total:
md_content += f"\n{max_total} 条仅列计数,明细见分析库 comparison_record"
return _build_card(title_text, [{"tag": "markdown", "content": md_content}])
# ---------- 常规:摘要 + table ----------
rows = _build_table_rows(hits, phone_map=phone_map, max_detail_per_type=max_detail_per_type)
table_element = {
"tag": "table",
"page_size": 10,
"row_height": "low",
"header_style": {"background_style": "grey", "bold": True},
"columns": _TABLE_COLUMNS,
"rows": rows,
}
return _build_card(title_text, [{"tag": "markdown", "content": md_content}, table_element])
+159
View File
@@ -0,0 +1,159 @@
"""比价卡死定位:读 pricebot trace 末段,判某平台是否原地打转(卡死)。
同机直读 {WORK_LOG_DIR}/{dir_name}/{platform}/step_*.json,只取头部字段
(pipeline_step/detected_page),不解析后面的无障碍树(windows,占单帧 99% 体积)
判据与降级见 docs/superpowers/specs/2026-08-05-compare-stuck-detection-design.md
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
# pipeline_step / 平台名 → 中文(映射不到原样显示英文,不阻断)。按 pricebot 实际枚举补全。
PIPELINE_STEP_LABELS: dict[str, str] = {
"set_address": "定位",
"enter_store": "进店",
"add_one_dish": "加菜",
"match_dish": "找菜",
"checkout": "结算",
}
PLATFORM_LABELS: dict[str, str] = {
"meituan": "美团",
"eleme": "饿了么",
"jd_waimai": "京东外卖",
}
_PIPE_RE = re.compile(r'"pipeline_step":\s*"([^"]*)"')
_PAGE_RE = re.compile(r'"detected_page":\s*"([^"]*)"')
_TS_RE = re.compile(r'"timestamp":\s*"([^"]*)"')
_STEP_NUM_RE = re.compile(r"step_(\d+)")
def _parse_ts(s: str | None) -> datetime | None:
if not s:
return None
try:
return datetime.fromisoformat(s)
except (ValueError, TypeError):
return None
@dataclass(frozen=True)
class StuckPoint:
platform: str
pipeline_step: str
frames: int # 末段连续困住的帧数(上限 max_tail)
stuck_ms: int | None = None # 末段连续卡住的时长(ms);无 timestamp 时 None
def label(self) -> str:
p = PLATFORM_LABELS.get(self.platform, self.platform)
s = PIPELINE_STEP_LABELS.get(self.pipeline_step, self.pipeline_step)
return f"{p}·{s}"
@dataclass(frozen=True)
class StuckResult:
readable: bool # trace 是否读到(区分「读不到」与「读到但没卡」)
points: list[StuckPoint] # 卡死平台列表;readable=True 且空 = 确认没卡死
def dir_name_from_trace_url(trace_url: str | None) -> str | None:
""".../traces/{dir_name}/ → dir_name;空/异常 → None。"""
if not trace_url:
return None
name = trace_url.rstrip("/").rsplit("/", 1)[-1]
return name or None
def _step_num(path: Path) -> int:
m = _STEP_NUM_RE.search(path.name)
return int(m.group(1)) if m else -1
def _read_head(path: Path, nbytes: int = 4096) -> tuple[str | None, str | None, str | None]:
"""只读文件头部,抠 (pipeline_step, detected_page, timestamp)。它们在 json 最前面。"""
try:
with open(path, encoding="utf-8", errors="replace") as f:
head = f.read(nbytes)
except OSError:
return None, None, None
ps = _PIPE_RE.search(head)
pg = _PAGE_RE.search(head)
ts = _TS_RE.search(head)
return (ps.group(1) if ps else None, pg.group(1) if pg else None, ts.group(1) if ts else None)
def _platform_stuck(
platform: str, step_files: list[Path], threshold: int, max_tail: int
) -> StuckPoint | None:
"""末帧往前数连续同 (pipeline_step, detected_page) 的帧数 ≥threshold → 卡死。"""
tail = step_files[-max_tail:]
heads = [_read_head(p) for p in tail] # [(ps, pg, ts), ...]
last_ps, last_pg, _ = heads[-1]
if last_ps is None:
return None
seg_ts: list[str | None] = [] # 连续段的 timestamp(逆序:末帧在前)
for ps, pg, ts in reversed(heads):
if ps == last_ps and pg == last_pg:
seg_ts.append(ts)
else:
break
count = len(seg_ts)
if count < threshold:
return None
# seg_ts[0]=末帧, seg_ts[-1]=段首帧;两端都能解析才算时长
t_last, t_first = _parse_ts(seg_ts[0]), _parse_ts(seg_ts[-1])
stuck_ms = round((t_last - t_first).total_seconds() * 1000) if t_last and t_first else None
if stuck_ms is not None and stuck_ms < 0:
stuck_ms = None # 时钟不单调(帧 timestamp 回退)→ 降级为不显示时长
return StuckPoint(platform, last_ps, count, stuck_ms)
def read_stuck_points(trace_dir: Path, *, threshold: int, max_tail: int) -> StuckResult:
"""逐平台判卡死。目录不存在/异常/无平台帧 → StuckResult(readable=False, points=[])。"""
try:
if not trace_dir.is_dir():
return StuckResult(readable=False, points=[])
points: list[StuckPoint] = []
any_frames = False
for pdir in sorted(trace_dir.iterdir()):
if not pdir.is_dir():
continue
step_files = sorted(pdir.glob("step_*.json"), key=_step_num)
if not step_files:
continue
any_frames = True
sp = _platform_stuck(pdir.name, step_files, threshold, max_tail)
if sp is not None:
points.append(sp)
if not any_frames:
return StuckResult(readable=False, points=[])
return StuckResult(readable=True, points=points)
except OSError:
return StuckResult(readable=False, points=[])
def last_step(trace_dir: Path) -> StuckPoint | None:
"""failed 用:取帧数最多平台的末帧环节(不要求原地打转)。读不到 → None。"""
try:
if not trace_dir.is_dir():
return None
best: tuple[int, str, list[Path]] | None = None
for pdir in sorted(trace_dir.iterdir()):
if not pdir.is_dir():
continue
step_files = sorted(pdir.glob("step_*.json"), key=_step_num)
# 平局(同帧数)时取字典序第一个平台(sorted 保证稳定)
if step_files and (best is None or len(step_files) > best[0]):
best = (len(step_files), pdir.name, step_files)
if best is None:
return None
_, platform, step_files = best
ps, _pg, _ts = _read_head(step_files[-1])
if ps is None:
return None
return StuckPoint(platform, ps, len(step_files)) # stuck_ms=None(failed 不算时长)
except OSError:
return None
@@ -0,0 +1,156 @@
# 比价结果卡片 · 状态口径与交互参考
> **定位**:把「比价结果页每张平台卡片」的 11 种展示分类,逐一映射到后端状态与判定字段,供 **App 端做互动/文案调整时参考**
> **适用**:外卖比价结果页(`CompResultScreen`)。
> **数据源**:基于三仓代码梳理(pricebot 判定 → app-server 透传/落库 → android 映射/渲染)。
> **整理日期**:2026-07-31。代码行号会漂移,改动后以实际代码为准。
---
## 0. 快速须知(三个容易踩的坑)
1. **`ok` 不是 `success`**:逐平台卡片的成功态字符串是 **`ok`**(端侧 `rowToUi``row.status == "ok"`);`success/failed/cancelled/running` 是**记录级** `ComparisonRecord.status`(admin 后台用)。两层状态串不同,别混。
2. **未知状态一律兜底成 `Failed`#9)**:端侧映射用 `else -> CompareResultStatus.Failed`,所以后端 `unsupported``failed`、以及**任何端侧没显式处理的新状态**都会显示成 #9。pricebot 以后加状态,端侧不同步就会「消失」进 #9
3. **状态判定分三层来源**(见 §2):不是所有卡都由后端 status 决定,#10/#11 完全是端侧本地判定,后端零感知。
---
## 1. 主对照表:11 类卡片 ↔ 后端状态
映射函数:`CompareProgressRepository.kt` — 新路径 `rowToUi:339-375`、老路径 `summaryToUi:509-544`、端侧补齐 `onComparisonResultsReady:266-315`
| # | 分类 | Android 枚举 | 后端逐平台 `status` | `Found` 内细分依据 | 状态来源 | 当前交互 |
|---|---|---|---|---|---|---|
| 1 | 全网最低赢家 | `Found` | `ok`(有价) | `is_best && !has_dish_diff` → isLowest | pricebot | 去购买(黄) |
| 2 | 没有可用优惠(原选择) | `Found` | `ok` | `role=source`/is_user_original + 无可用优惠 | pricebot | 查看(灰) |
| 3 | 其他成功 | `Found` | `ok` | `role=target`,非 best | pricebot | 查看(灰) |
| 4 | 少菜 / 部分缺 | `Found` + 提示条 | `ok` | `skipped_dish_names` 非空 / `skipped_dish_count>0` | pricebot | 随主卡 |
| 5 | 未满起送 | `BelowMinimum` | `below_minimum` | — | pricebot | 查看(灰) |
| 6 | 门店打烊 | `StoreClosed` | `store_closed` | — | pricebot | 查看(灰) |
| 7 | 没有您点的商品 | `ItemsNotFound` | `items_not_found` | — | pricebot | 查看(灰) |
| 8 | 无对应商家 | `StoreNotFound` | `store_not_found` | — | pricebot **或端侧补齐** | 查看(灰) |
| 9 | 比价失败兜底 | `Failed` | `failed`/`unsupported`/**任何未知值**`else` | — | pricebot **或端侧**(整场没跑成) | 无 |
| 10 | 未安装 | `NotInstalled` | **无**(后端零感知) | 端侧:该平台没装(`getPackageInfo` | **端侧本地** | 去安装 |
| 11 | 未选择 / 本次未比 | `NotComparedThisTime` | **无**(后端零感知) | 端侧:`selectedPlatformIds` 没勾这家 | **端侧本地** | 无(设计要「重试」,未接) |
| 表外 | 单点不配送 | `NoDelivery` | `no_delivery` | — | pricebot | 查看(灰) |
> **表外提醒**`no_delivery`(单点不配送/需搭配主食)在代码里是独立状态(复用未起送灰框样式),但**不在原 11 类分类表内**——做分类时需给它安个位置或明确并入 #5/#7
---
## 2. 状态来源三分层(做互动最该记住的)
**A. pricebot 逐平台 `status` 直接决定(5/6/7/8/9 + no_delivery**
端侧只做「字符串 → 枚举」映射,互动依附后端判定。
**B. `ok` 成功态再靠 `platforms[]` 字段细分(1/2/3/4**
同一个 `Found`,靠 `is_best``has_dish_diff``role``skipped_dish_names` 分出四种。这些字段都是 pricebot 在 `platforms[]` 下发的。
**C. 纯端侧本地判定,后端完全无感知(10/11,以及 8 的补齐分支)**
依据:**装机态**`getPackageInfo`+ **勾选态**`selectedPlatformIds`+ **整场是否跑成**`failureReason`)。
`onComparisonResultsReady:266-315`:走了选平台弹窗没勾 → #11;装了 App 没找到店 → #8;都没装 → #10`marketPackage` 指向子 App 包名,引导下载子 App)。
---
## 3. 菜品差异口径(有相似菜品 / 少菜 / 规格近似)
「有相似菜品」**不是独立卡**,而是 `status=Found`(有价)成功卡上、因「菜品对不齐」触发的黄色提醒模块 `MismatchBox`
### 3.1 开关:`hasDishDiff`
定义在 `CompareMockResults.kt:101-109``status=Found` 且满足**任一**即为 true
- `items` 里有 `similar=true` 的菜(近似替代)
- `approxDishNames` 非空(规格近似)
- `skippedDishNames` 非空(完全缺失)
- `skippedDishCount > 0`(只有数量)
**权威值优先取后端** `platforms[].has_dish_diff`(端侧 `dishDiffOverride``CompareMockResults.kt:91-93`),老路径才端侧本地算。与后端 `is_best` 竞选同判据。
### 3.2 `MismatchBox` 的三种行(`CompRowCard.kt:236-250` 触发 / `460-516` 渲染)
| 展示文案 | 触发字段 | 语义 |
|---|---|---|
| **本平台没有「orig」 / 已换成近似「name」** | `items``similar=true && orig!=null`similarPairs | **已发生近似替换**(有 orig→name 替换对) |
| 规格近似「X」,请下单前核对 | `approxDishNames` | 已近似,但提示核对 |
| 本平台没有「X」,也无相似菜 | `skippedDishNames` | 完全缺失、无替代 |
| 本平台缺少 N 个菜品,已按可购买商品计价 | `skippedDishCount>0` 且无菜名 | 只有数量时的兜底 |
> **易混近亲**`complexSpecDishNames``CompareMockResults.kt:86-88`)走的是**另一个浅黄条** `ComplexSpecNotice`,文案「规格较复杂,请核对」,语义是"需人工核对规格、**不改价格有效性**",与"已换成近似"不是一回事。
### 3.3 连锁后果(为什么降级成"查看")
`has_dish_diff=true``isLowest = is_best && !has_dish_diff` 必为 false → **踢出「全网最低」评定**,排到"仅供参考"分组,不给绶带/省¥红章/去购买黄按钮,只能灰"查看"。
### 3.4 边界
- **vs #4「完全缺失」**:同一个 `MismatchBox` 的不同行,**可并存**(一个平台既有近似替换又有完全缺失)。它们是同一张成功卡里分段列出,不是两张卡。
- **vs #7「该店没有您点的商品(items_not_found)」**:分水岭是**有没有比出价**。能凑出单、有价 → `Found` + MismatchBox;整单找不到菜、无价 → `items_not_found` 灰框无价卡。
- **vs #1 赢家**:互斥。
---
## 4. 交互调整参考要点
1. **文案/提示权已在前端**:卡片副文案由端侧 `fullReasonForStatus(status)` 按状态本地生成(`CompareProgressRepository.kt:51-59`,注释明写"标题/文案决策收回前端")。后端逐平台原因字段 `notFoundReason`= `platform_results[].reason`)**线上常空**,端侧兜底。→ **改提示文案、加互动引导、加动效,纯端侧就能做,不用改 pricebot。**
2. **可空字段决定互动形态**`price` 可空(6/7/8/9/10/11 无价 → `¥??` 或隐藏);`store_name` 可空(退化平台名)。`LOCATED_STATUSES``CompareProgressRepository.kt:46-47``ok/below_minimum/no_delivery/store_closed/items_not_found`)决定 header 用店名还是平台名。做点击/跳转互动前要判空。
3. **#10/#11 端侧完全自主**:引导安装、引导勾选、重试这类互动无需后端配合,随便调。
4. **区分"真没店"vs"没跑成"**`failureReason` 非空(网络/上游 502)时,补齐卡统一兜底文案而非"未找到店"(`CompareProgressRepository.kt:104-107`,避免谎报)。"重试整场"的互动应挂这个场景,而不是 #8
5. **带动态数字的文案要后端补字段**:如"还差 ¥8 起送"——`platforms[]` 目前不下发起送差额,想要这种提示得让 pricebot 补字段。
6. **#4 少菜 / 有相似菜品是成功卡上的模块,不是独立卡**:它们的互动依附主卡(1/2/3)。
---
## 5. 关键代码位置索引
### Android`shaguabijia-app-android`
| 关注点 | 文件:行 |
|---|---|
| 后端 status → 枚举映射(新/老路径) | `CompareProgressRepository.kt:339-375` / `509-544` |
| 端侧文案本地生成 | `CompareProgressRepository.kt:51-59``fullReasonForStatus` |
| header 店名/平台名切换 | `CompareProgressRepository.kt:46-47``LOCATED_STATUSES` |
| 端侧补齐 #8/#10/#11(装机+勾选+failureReason | `CompareProgressRepository.kt:266-315` |
| 卡片 when 主分支 | `CompRowCard.kt:83-128` |
| `MismatchBox`(菜品差异条) | `CompRowCard.kt:236-250`(触发)/ `460-516`(渲染) |
| 未安装卡 / 本次未比卡 | `CompRowCard.kt:596-697` / `720-790` |
| 按钮(去购买/查看/重试) | `CompRowCard.kt:1148-1180` / `1210` / `1214`;枚举 `GrayCardCta:132` |
| 数据模型 `CompareResult` / `hasDishDiff` / `CompareDish` | `CompareMockResults.kt:34-110` / `101-109` / `116` |
| 网络 DTO 解析 | `Protocol.kt:530-549` / `576-665` |
| 生产结果页 / VM | `CompResultScreen.kt` / `CompResultViewModel.kt` |
### 后端(`shaguabijia-app-server`
| 关注点 | 文件:行 |
|---|---|
| 逐平台 `status` 枚举 | `app/schemas/compare_record.py:81-85` |
| 记录模型 / `status` / `skipped_dish_names` / `raw_payload` | `app/models/comparison.py:39-169` / `:101` / `:121` / `:123` |
| 落库与状态派生(running/done/abort | `app/repositories/comparison.py``harvest_*``_derive*` |
| `has_dish_diff` 兜底判 is_best | `app/repositories/comparison.py:379` |
| 记录级失败原因派生 | `app/repositories/comparison.py:118-127``_derive_fail_display` |
| admin 读取接口 | `app/admin/routers/comparison.py` / `app/admin/repositories/queries.py` |
---
## 6. 当前实现现状与「目标设计」的差异(待办)
11 类骨架**都已实现、生产页 `CompResultScreen` 真实可达**(非 mock)。但对照原 11 类目标设计,仍有以下差异:
| 项 | 现状 | 差异 / 待办 |
|---|---|---|
| #11「重试」按钮 | `NotComparedCard` 无任何 CTA`GrayCardCta.Retry`+`RetryCta` 定义了但**没挂到任何卡**(死代码) | 唯一**功能级缺口**:要么接上,要么把设计改成"无按钮" |
| 各类文案措辞 | 偏功能陈述(如"该店当前已打烊,暂时无法比价") | 与目标口语+emoji"…打烊休息啦😴…")不一致;改端侧 `fullReasonForStatus` 即可(后端 `reason` 常空、无需动 pricebot |
| #1 优惠区 | 已改**聚合**"已自动帮您应用优惠,共减 ¥X" | 与"优惠 tags(逐项标签)"不同(产品已决策聚合) |
| #4 少菜 | 成功卡上的 `MismatchBox` 提示条 | 非独立卡(按"原生为准"这多为有意) |
| `no_delivery` | 代码有独立状态 | 原 11 类表未含,需补位或并入 |
---
## 7. admin 记录页口径(与 C 端不同,勿混)
admin 比价记录页 / 概览 / 大盘用**技术完成率**口径:`below_minimum / store_closed /
store_not_found / items_not_found / no_delivery / unsupported`(流程跑完、只是外部原因致结果缺失)
**记为成功**(前端标绿「成功」+ 感叹号,hover 显示缺失原因),只有纯技术故障 `failed` 才算失败。
派生见 `app/admin/repositories/comparison_outcome.py`(原始结局取 `raw_payload.record_status`
`admin_success_sql()` 供概览/大盘/列表筛选共用,`derive_admin_outcome()` 供列表逐行下发 `outcome_hint`)。
这**刻意宽于** C 端 / #209 落库口径(那边 not_found 类归 `failed`)——admin 关心「系统有没有跑成」,
C 端关心「有没有省到钱」。故 **admin 成功率 ≠ C 端 / 首页轮播口径**,对不上是设计使然、非 bug。
---
*本文档为跨端口径参考,非契约。字段/行号以三仓实际代码为准。*
@@ -0,0 +1,735 @@
# 比价卡死定位报警增强 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让 cancelled 报警用 trace 末段「原地打转」判卡死并定位卡在哪个环节,读不到 trace 回退耗时/帧数保底;failed 类附卡点。
**Architecture:** 判定保持纯函数(`compare_alert.py`),trace 读取单独成层(`trace_stuck.py`,同机直读 pricebot work_logs、只读帧头部字段),worker 编排(cancelled trace 优先 + 保底、failed 附卡点)。卡点拼进 `AlertHit.reason`,复用现有 `format_alert_post` 展示,不依赖卡片 table 固化。
**Tech Stack:** Python 3.11+、FastAPI、SQLAlchemy、pytest。无新依赖(仅标准库 `re`/`json`/`pathlib`/`dataclasses`)。
参考 spec`docs/superpowers/specs/2026-08-05-compare-stuck-detection-design.md`
---
## File Structure
- **Create** `app/services/trace_stuck.py` — 卡死判据 + 薄 IO。`StuckPoint`/`StuckResult``read_stuck_points``last_step``dir_name_from_trace_url`、文案表。
- **Create** `tests/test_trace_stuck.py` — trace_stuck 单测。
- **Modify** `app/core/config.py` — 加 4 个配置项。
- **Modify** `app/services/compare_alert.py``_hit` 改公开 `make_hit`;新增纯函数 `classify_cancelled_fallback``classify_record` 的 cancelled 分支改调它(行为不变)。
- **Create** `tests/test_compare_alert_fallback.py``classify_cancelled_fallback`/`make_hit` 单测。
- **Modify** `app/core/compare_alert_worker.py` — 新增 `build_hits`/`_trace_dir` 编排;`_scan_and_alert``build_hits` 替换 `classify_batch`
- **Create** `tests/test_compare_alert_stuck_worker.py``build_hits` 集成测。
---
## Task 1: 配置项
**Files:**
- Modify: `app/core/config.py:147`(在 `COMPARE_ALERT_SEND_EMPTY` 行后追加)
- [ ] **Step 1: 加 4 个配置字段**
`app/core/config.py` 第 147 行 `COMPARE_ALERT_SEND_EMPTY: bool = False ...` 之后,紧接着追加:
```python
# ===== 卡死定位(读 pricebot trace 末段判原地打转)=====
COMPARE_ALERT_PRICEBOT_WORK_LOG_DIR: str = "" # pricebot work_logs 绝对路径(敏感,放 .env);空=跳过 trace、cancelled 全走保底
COMPARE_ALERT_STUCK_FRAME_THRESHOLD: int = 15 # 末段连续同环节达此帧数判卡死
COMPARE_ALERT_TRACE_MAX_TAIL_FRAMES: int = 40 # 每平台最多往前读多少帧
COMPARE_ALERT_TRACE_MAX_RECORDS: int = 30 # 每轮最多对多少条命中记录读 trace(限量)
```
- [ ] **Step 2: 跑现有测试确认不破**
Run: `pytest tests/ -q -k "config or defaults"`
Expected: PASS(新增字段都有默认值,不影响 `test_defaults`
- [ ] **Step 3: Commit**
```bash
git add app/core/config.py
git commit -m "feat(compare-alert): 卡死定位 4 个配置项
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 2: trace_stuck 模块
**Files:**
- Create: `app/services/trace_stuck.py`
- Test: `tests/test_trace_stuck.py`
- [ ] **Step 1: 写失败测试**
创建 `tests/test_trace_stuck.py`
```python
"""trace_stuck 单测:用 tmp 造 step_*.json(只含头部字段)验证卡死判据。"""
import json
from pathlib import Path
from app.services.trace_stuck import (
StuckPoint,
dir_name_from_trace_url,
last_step,
read_stuck_points,
)
def _frame(pdir: Path, idx: int, step: str, page: str) -> None:
"""造一帧 step json:头部放 pipeline_step/detected_page,尾部塞大 windows 模拟真实。"""
pdir.mkdir(parents=True, exist_ok=True)
body = {
"trace_id": "t", "step": idx, "platform": pdir.name,
"pipeline_step": step, "detected_page": page,
"windows": [{"nodes": ["x" * 200]}],
}
(pdir / f"step_{idx:03d}.json").write_text(
json.dumps(body, ensure_ascii=False), encoding="utf-8"
)
def test_stuck_when_tail_repeats_same_step(tmp_path):
pdir = tmp_path / "meituan"
for i in range(20):
_frame(pdir, i, "add_one_dish", "meal_detail_popup")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is True
assert res.points == [StuckPoint("meituan", "add_one_dish", 20)]
def test_not_stuck_when_progressing(tmp_path):
pdir = tmp_path / "eleme"
_frame(pdir, 0, "set_address", "home")
for i in range(1, 8):
_frame(pdir, i, "enter_store", "store")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is True
assert res.points == []
def test_adding_many_dishes_not_stuck_when_page_changes(tmp_path):
# add_one_dish 重复但 detected_page 在跳(换菜)=推进,不判卡死
pdir = tmp_path / "meituan"
for i in range(20):
_frame(pdir, i, "add_one_dish", "menu" if i % 2 == 0 else "dish_popup")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.points == []
def test_below_threshold_not_stuck(tmp_path):
pdir = tmp_path / "meituan"
for i in range(10): # < 15
_frame(pdir, i, "add_one_dish", "meal_detail_popup")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.points == []
def test_missing_dir_not_readable(tmp_path):
res = read_stuck_points(tmp_path / "nope", threshold=15, max_tail=40)
assert res.readable is False
assert res.points == []
def test_empty_dir_no_platform_frames_not_readable(tmp_path):
(tmp_path / "emptysub").mkdir()
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is False
def test_per_platform_one_stuck_one_normal(tmp_path):
m = tmp_path / "meituan"
for i in range(18):
_frame(m, i, "add_one_dish", "meal_detail_popup")
e = tmp_path / "eleme"
_frame(e, 0, "set_address", "home")
for i in range(1, 6):
_frame(e, i, "enter_store", "store")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is True
assert res.points == [StuckPoint("meituan", "add_one_dish", 18)]
def test_last_step_returns_busiest_platform_last_env(tmp_path):
m = tmp_path / "meituan"
for i in range(20):
_frame(m, i, "add_one_dish", "meal_detail_popup")
e = tmp_path / "eleme"
for i in range(3):
_frame(e, i, "enter_store", "store")
sp = last_step(tmp_path)
assert sp == StuckPoint("meituan", "add_one_dish", 20)
def test_dir_name_from_trace_url():
assert dir_name_from_trace_url("https://x/traces/20260804_1_abc/") == "20260804_1_abc"
assert dir_name_from_trace_url("https://x/traces/20260804_1_abc") == "20260804_1_abc"
assert dir_name_from_trace_url("") is None
assert dir_name_from_trace_url(None) is None
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pytest tests/test_trace_stuck.py -q`
Expected: FAIL`ModuleNotFoundError: app.services.trace_stuck`
- [ ] **Step 3: 实现 trace_stuck.py**
创建 `app/services/trace_stuck.py`
```python
"""比价卡死定位:读 pricebot trace 末段,判某平台是否原地打转(卡死)。
同机直读 {WORK_LOG_DIR}/{dir_name}/{platform}/step_*.json,只取头部字段
(pipeline_step/detected_page),不解析后面的无障碍树(windows,占单帧 99% 体积)。
判据与降级见 docs/superpowers/specs/2026-08-05-compare-stuck-detection-design.md。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
# pipeline_step / 平台名 → 中文(映射不到原样显示英文,不阻断)。按 pricebot 实际枚举补全。
PIPELINE_STEP_LABELS: dict[str, str] = {
"set_address": "定位",
"enter_store": "进店",
"add_one_dish": "加菜",
"match_dish": "找菜",
"checkout": "结算",
}
PLATFORM_LABELS: dict[str, str] = {
"meituan": "美团",
"eleme": "饿了么",
"jd_waimai": "京东外卖",
}
_PIPE_RE = re.compile(r'"pipeline_step":\s*"([^"]*)"')
_PAGE_RE = re.compile(r'"detected_page":\s*"([^"]*)"')
_STEP_NUM_RE = re.compile(r"step_(\d+)")
@dataclass(frozen=True)
class StuckPoint:
platform: str
pipeline_step: str
frames: int # 末段连续困住的帧数(上限 max_tail)
def label(self) -> str:
p = PLATFORM_LABELS.get(self.platform, self.platform)
s = PIPELINE_STEP_LABELS.get(self.pipeline_step, self.pipeline_step)
return f"{p}·{s}"
@dataclass(frozen=True)
class StuckResult:
readable: bool # trace 是否读到(区分「读不到」与「读到但没卡」)
points: list[StuckPoint] # 卡死平台列表;readable=True 且空 = 确认没卡死
def dir_name_from_trace_url(trace_url: str | None) -> str | None:
""".../traces/{dir_name}/ → dir_name;空/异常 → None。"""
if not trace_url:
return None
name = trace_url.rstrip("/").rsplit("/", 1)[-1]
return name or None
def _step_num(path: Path) -> int:
m = _STEP_NUM_RE.search(path.name)
return int(m.group(1)) if m else -1
def _read_head(path: Path, nbytes: int = 4096) -> tuple[str | None, str | None]:
"""只读文件头部,抠 (pipeline_step, detected_page)。它们在 json 最前面。"""
with open(path, "r", encoding="utf-8") as f:
head = f.read(nbytes)
ps = _PIPE_RE.search(head)
pg = _PAGE_RE.search(head)
return (ps.group(1) if ps else None, pg.group(1) if pg else None)
def _platform_stuck(
platform: str, step_files: list[Path], threshold: int, max_tail: int
) -> StuckPoint | None:
"""末帧往前数连续同 (pipeline_step, detected_page) 的帧数 ≥threshold → 卡死。"""
tail = step_files[-max_tail:]
heads = [_read_head(p) for p in tail]
last_ps, last_pg = heads[-1]
if last_ps is None:
return None
count = 0
for ps, pg in reversed(heads):
if ps == last_ps and pg == last_pg:
count += 1
else:
break
if count >= threshold:
return StuckPoint(platform, last_ps, count)
return None
def read_stuck_points(trace_dir: Path, *, threshold: int, max_tail: int) -> StuckResult:
"""逐平台判卡死。目录不存在/异常/无平台帧 → StuckResult(readable=False, points=[])。"""
try:
if not trace_dir.is_dir():
return StuckResult(readable=False, points=[])
points: list[StuckPoint] = []
any_frames = False
for pdir in sorted(trace_dir.iterdir()):
if not pdir.is_dir():
continue
step_files = sorted(pdir.glob("step_*.json"), key=_step_num)
if not step_files:
continue
any_frames = True
sp = _platform_stuck(pdir.name, step_files, threshold, max_tail)
if sp is not None:
points.append(sp)
if not any_frames:
return StuckResult(readable=False, points=[])
return StuckResult(readable=True, points=points)
except OSError:
return StuckResult(readable=False, points=[])
def last_step(trace_dir: Path) -> StuckPoint | None:
"""failed 用:取帧数最多平台的末帧环节(不要求原地打转)。读不到 → None。"""
try:
if not trace_dir.is_dir():
return None
best: tuple[int, str, list[Path]] | None = None
for pdir in sorted(trace_dir.iterdir()):
if not pdir.is_dir():
continue
step_files = sorted(pdir.glob("step_*.json"), key=_step_num)
if step_files and (best is None or len(step_files) > best[0]):
best = (len(step_files), pdir.name, step_files)
if best is None:
return None
_, platform, step_files = best
ps, _pg = _read_head(step_files[-1])
if ps is None:
return None
return StuckPoint(platform, ps, len(step_files))
except OSError:
return None
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pytest tests/test_trace_stuck.py -q`
Expected: PASS9 passed
- [ ] **Step 5: Commit**
```bash
git add app/services/trace_stuck.py tests/test_trace_stuck.py
git commit -m "feat(compare-alert): trace_stuck 卡死判据(末段原地打转)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 3: compare_alert 抽出 fallback + 公开 make_hit
**Files:**
- Modify: `app/services/compare_alert.py``_hit``make_hit`;新增 `classify_cancelled_fallback`cancelled 分支改调它)
- Test: `tests/test_compare_alert_fallback.py`
- [ ] **Step 1: 写失败测试**
创建 `tests/test_compare_alert_fallback.py`
```python
"""classify_cancelled_fallback / make_hit 单测。"""
from app.services.compare_alert import classify_cancelled_fallback, make_hit
class _Rec:
def __init__(self, **kw):
self.trace_id = kw.get("trace_id", "t")
self.status = kw.get("status", "cancelled")
self.total_ms = kw.get("total_ms")
self.step_count = kw.get("step_count")
self.fail_reason = kw.get("fail_reason")
self.information = kw.get("information")
self.app_version = kw.get("app_version")
self.created_at = kw.get("created_at")
self.trace_url = kw.get("trace_url")
self.user_id = kw.get("user_id")
def test_fallback_deep_by_ms():
hit = classify_cancelled_fallback(
_Rec(total_ms=95000, step_count=5),
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
)
assert hit is not None and hit.alert_type == "T5" and "深度放弃" in hit.reason
def test_fallback_deep_by_step():
hit = classify_cancelled_fallback(
_Rec(total_ms=1000, step_count=35),
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
)
assert hit is not None and hit.alert_type == "T5"
def test_fallback_shallow_none():
hit = classify_cancelled_fallback(
_Rec(total_ms=5000, step_count=3),
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
)
assert hit is None
def test_make_hit_carries_fields():
hit = make_hit(_Rec(trace_id="tx", app_version="0.6.0"), "T5", "卡在 美团·加菜")
assert hit.trace_id == "tx"
assert hit.reason == "卡在 美团·加菜"
assert hit.app_version == "0.6.0"
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pytest tests/test_compare_alert_fallback.py -q`
Expected: FAIL`ImportError: cannot import name 'classify_cancelled_fallback'`
- [ ] **Step 3: 改 compare_alert.py**
`app/services/compare_alert.py`
(a) 把 `def _hit(` 改名为 `def make_hit(`(第 33 行),并把 `classify_record` 内 4 处 `_hit(` 调用改成 `make_hit(`(原 T1/T6/T2/T5 分支)。
(b) 在 `make_hit` 之后、`classify_record` 之前,新增:
```python
def classify_cancelled_fallback(
rec: Any,
*,
cancelled_ms_threshold: int,
cancelled_step_threshold: int,
) -> AlertHit | None:
"""cancelled 保底判定(读不到 trace 时用):超耗时或步数阈值 → T5 深度放弃,否则 None。纯函数。"""
ms = rec.total_ms
step = rec.step_count
deep = (ms is not None and ms > cancelled_ms_threshold) or (
step is not None and step > cancelled_step_threshold
)
if deep:
return make_hit(
rec, "T5",
f"深度放弃·等待 {round((ms or 0) / 1000)}s / {step or 0} 步后退出",
)
return None
```
(c) 把 `classify_record` 里的 cancelled 分支(原 `if status == "cancelled":` 那整段)替换为:
```python
if status == "cancelled":
return classify_cancelled_fallback(
rec,
cancelled_ms_threshold=cancelled_ms_threshold,
cancelled_step_threshold=cancelled_step_threshold,
)
return None
```
`classify_record` 行为不变,只是把 cancelled 逻辑抽到 `classify_cancelled_fallback`。)
- [ ] **Step 4: 跑测试确认通过(含现有 rules 测试不回归)**
Run: `pytest tests/test_compare_alert_fallback.py tests/test_compare_alert_rules.py -q`
Expected: PASS(新测试 4 passed,现有 rules 测试仍全 PASS
- [ ] **Step 5: Commit**
```bash
git add app/services/compare_alert.py tests/test_compare_alert_fallback.py
git commit -m "feat(compare-alert): 抽出 classify_cancelled_fallback + 公开 make_hit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 4: worker 编排 build_hits
**Files:**
- Modify: `app/core/compare_alert_worker.py`(加 imports、`_trace_dir``build_hits``_scan_and_alert` 改用 `build_hits`
- Test: `tests/test_compare_alert_stuck_worker.py`
- [ ] **Step 1: 写失败测试**
创建 `tests/test_compare_alert_stuck_worker.py`
```python
"""build_hits 集成测:cancelled trace 优先/保底切换、failed 附卡点、限量。"""
import json
from pathlib import Path
from app.core.compare_alert_worker import build_hits
class _Rec:
def __init__(self, **kw):
self.trace_id = kw.get("trace_id", "t")
self.status = kw.get("status", "cancelled")
self.total_ms = kw.get("total_ms")
self.step_count = kw.get("step_count")
self.fail_reason = kw.get("fail_reason")
self.information = kw.get("information")
self.app_version = kw.get("app_version")
self.created_at = kw.get("created_at")
self.trace_url = kw.get("trace_url")
self.user_id = kw.get("user_id")
def _frame(pdir: Path, idx: int, step: str, page: str) -> None:
pdir.mkdir(parents=True, exist_ok=True)
body = {"pipeline_step": step, "detected_page": page, "windows": [{"n": ["x" * 200]}]}
(pdir / f"step_{idx:03d}.json").write_text(
json.dumps(body, ensure_ascii=False), encoding="utf-8"
)
_KW = dict(
stuck_threshold=15, max_tail=40, max_trace_reads=30,
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
timeout_keywords=("超时",), unrecognized_keywords=("未识别",), biz_exclude_keywords=(),
)
def test_cancelled_stuck_reports_via_trace(tmp_path):
for i in range(18):
_frame(tmp_path / "20260804_x" / "meituan", i, "add_one_dish", "meal_detail_popup")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260804_x/",
total_ms=5000, step_count=3) # 保底不会中,靠 trace 判卡死
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T5"
assert "卡在" in hits[0].reason and "美团·加菜" in hits[0].reason
def test_cancelled_readable_not_stuck_no_report(tmp_path):
# trace 确认没卡(在推进);即便 total_ms/step 超阈值也不报(信 trace,不回退保底)
p = tmp_path / "20260804_y" / "eleme"
_frame(p, 0, "set_address", "home")
for i in range(1, 6):
_frame(p, i, "enter_store", "store")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260804_y/",
total_ms=95000, step_count=40)
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert hits == []
def test_cancelled_unreadable_falls_back(tmp_path):
rec = _Rec(status="cancelled", trace_url="https://x/traces/nope/",
total_ms=95000, step_count=3)
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T5" and "深度放弃" in hits[0].reason
def test_no_work_log_dir_uses_fallback(tmp_path):
rec = _Rec(status="cancelled", trace_url="https://x/traces/y/",
total_ms=95000, step_count=3)
hits = build_hits([rec], work_log_dir="", **_KW)
assert len(hits) == 1 and "深度放弃" in hits[0].reason
def test_failed_gets_stuck_point_appended(tmp_path):
for i in range(20):
_frame(tmp_path / "20260804_f" / "meituan", i, "add_one_dish", "meal_detail_popup")
rec = _Rec(status="failed", fail_reason="启动超时",
trace_url="https://x/traces/20260804_f/")
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T2"
assert "卡在 美团·加菜" in hits[0].reason
def test_max_trace_reads_zero_skips_trace(tmp_path):
for i in range(18):
_frame(tmp_path / "20260804_z" / "meituan", i, "add_one_dish", "meal_detail_popup")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260804_z/",
total_ms=95000, step_count=3)
kw = {**_KW, "max_trace_reads": 0}
hits = build_hits([rec], work_log_dir=str(tmp_path), **kw)
# 没读 trace → 回退保底 → deep(95s) → 深度放弃
assert len(hits) == 1 and "深度放弃" in hits[0].reason
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pytest tests/test_compare_alert_stuck_worker.py -q`
Expected: FAIL`ImportError: cannot import name 'build_hits'`
- [ ] **Step 3: 改 compare_alert_worker.py**
(a) 顶部 imports 段,把
```python
from app.services.compare_alert import classify_batch
from app.services.compare_alert_format import ALERT_KEYWORD, format_alert_post
```
改为
```python
from dataclasses import replace as _dc_replace
from app.services import trace_stuck
from app.services.compare_alert import (
classify_cancelled_fallback,
classify_record,
make_hit,
)
from app.services.compare_alert_format import ALERT_KEYWORD, format_alert_post
```
(b) 在 `_scan_and_alert` 之前新增两个函数:
```python
def _trace_dir(base: Path, trace_url: str | None) -> Path | None:
name = trace_stuck.dir_name_from_trace_url(trace_url)
if not name:
return None
return base / name
def build_hits(
records: list,
*,
work_log_dir: str,
stuck_threshold: int,
max_tail: int,
max_trace_reads: int,
cancelled_ms_threshold: int,
cancelled_step_threshold: int,
timeout_keywords: tuple[str, ...],
unrecognized_keywords: tuple[str, ...],
biz_exclude_keywords: tuple[str, ...],
) -> list:
"""编排:cancelled 走 trace 优先(读到确认没卡则不报,读不到回退保底);failed 附卡点。
trace 读取限量 max_trace_reads 次/轮;任何 trace 异常都在 trace_stuck 内部降级为
「读不到」,cancelled 因而回退保底、failed 不附卡点,绝不影响报警发送。
"""
base = Path(work_log_dir) if work_log_dir else None
reads = 0
hits: list = []
for rec in records:
if rec.status == "cancelled":
res = None
if base is not None and reads < max_trace_reads:
td = _trace_dir(base, rec.trace_url)
if td is not None:
res = trace_stuck.read_stuck_points(
td, threshold=stuck_threshold, max_tail=max_tail
)
reads += 1
if res is not None and res.readable:
if res.points:
reason = "卡在 " + "、".join(sp.label() for sp in res.points)
hit = make_hit(rec, "T5", reason)
else:
hit = None # 读到且确认没卡 → 不报
else:
hit = classify_cancelled_fallback(
rec,
cancelled_ms_threshold=cancelled_ms_threshold,
cancelled_step_threshold=cancelled_step_threshold,
)
if hit is not None:
hits.append(hit)
else:
hit = classify_record(
rec,
cancelled_ms_threshold=cancelled_ms_threshold,
cancelled_step_threshold=cancelled_step_threshold,
timeout_keywords=timeout_keywords,
unrecognized_keywords=unrecognized_keywords,
biz_exclude_keywords=biz_exclude_keywords,
)
if (
hit is not None
and hit.alert_type in ("T1", "T2", "T6")
and base is not None
and reads < max_trace_reads
):
td = _trace_dir(base, rec.trace_url)
if td is not None:
sp = trace_stuck.last_step(td)
reads += 1
if sp is not None:
hit = _dc_replace(hit, reason=f"{hit.reason}|卡在 {sp.label()}")
if hit is not None:
hits.append(hit)
return hits
```
(c) 在 `_scan_and_alert` 里,把
```python
hits = classify_batch(
records,
cancelled_ms_threshold=settings.COMPARE_ALERT_CANCELLED_MS_THRESHOLD,
cancelled_step_threshold=settings.COMPARE_ALERT_CANCELLED_STEP_THRESHOLD,
timeout_keywords=settings.compare_alert_timeout_keywords,
unrecognized_keywords=settings.compare_alert_unrecognized_keywords,
biz_exclude_keywords=settings.compare_alert_biz_exclude_keywords,
)
```
替换为
```python
hits = build_hits(
records,
work_log_dir=settings.COMPARE_ALERT_PRICEBOT_WORK_LOG_DIR,
stuck_threshold=settings.COMPARE_ALERT_STUCK_FRAME_THRESHOLD,
max_tail=settings.COMPARE_ALERT_TRACE_MAX_TAIL_FRAMES,
max_trace_reads=settings.COMPARE_ALERT_TRACE_MAX_RECORDS,
cancelled_ms_threshold=settings.COMPARE_ALERT_CANCELLED_MS_THRESHOLD,
cancelled_step_threshold=settings.COMPARE_ALERT_CANCELLED_STEP_THRESHOLD,
timeout_keywords=settings.compare_alert_timeout_keywords,
unrecognized_keywords=settings.compare_alert_unrecognized_keywords,
biz_exclude_keywords=settings.compare_alert_biz_exclude_keywords,
)
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pytest tests/test_compare_alert_stuck_worker.py -q`
Expected: PASS6 passed
- [ ] **Step 5: 跑报警相关全量测试确认不回归**
Run: `pytest tests/ -q -k "compare_alert or trace_stuck"`
Expected: PASS(全绿)
- [ ] **Step 6: Commit**
```bash
git add app/core/compare_alert_worker.py tests/test_compare_alert_stuck_worker.py
git commit -m "feat(compare-alert): worker 编排 build_hits(cancelled trace 优先+保底、failed 附卡点)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## 收尾
- [ ] **全量测试**`pytest -q`(对齐 preexisting 失败基线,不新增失败)
- [ ] **lint**`ruff check app/services/trace_stuck.py app/core/compare_alert_worker.py app/services/compare_alert.py`
- [ ] **本地联调(可选)**:把 `.env``COMPARE_ALERT_PRICEBOT_WORK_LOG_DIR` 指向本地 `E:\project\pricebot-backend\data\work_logs`,用真实 cancelled trace 目录验证卡点文案。
- [ ] **清理临时脚本**`git rm --cached` 无关,直接删 `scripts/_probe_trace_timing.py`(若确认不再用,另行确认 `scripts/_test_alert_card.py`)。
## 不在本 plan(后续单独排)
- 卡片 schema 2.0 table 组件固化(`format_alert_card` + `send_feishu_card`,当前仍在 `scripts/_test_alert_card.py`)——卡点已随 `reason` 在现有 `format_alert_post` 展示,不阻塞本功能。
- pricebot 侧改动(本方案零改 pricebot)。
- `PIPELINE_STEP_LABELS` 全枚举补全(映射不到原样英文,可随线上观察增量补)。
@@ -0,0 +1,197 @@
# 比价失败报警机制 · 设计文档
- **日期**2026-08-04
- **状态**:设计待评审(v2,含数据复审修订)
- **范围**app-server`shaguabijia-app-server`
- **数据源**`comparison_record` 单表(线上快照已导入本地 `cr_analysis` 分析库,3867 行,覆盖 2026-06-09 ~ 08-04
## 复审修订记录(2026-08-04, v2
结合 `cr_analysis` 实测数据复审后的改动:
1. **[必修] 水位改用 `updated_at`**:原 `created_at` 水位会系统性漏报慢失败(落定延迟 p99 达 7.5–10min)。改为给 `comparison_record``updated_at` 列、水位按 `updated_at` 单调推进(§5/§6)。
2. **新增规则 T6「商品识别失败」**:「未识别到商品」96 条纳入报警,作为识别能力信号(§3)。
3. **T1 加业务词排除**:清掉 `fail_reason IS NULL` 里 7 条 `information` 实为业务的误报(§3)。
4. 补充:NULL 语义、时区口径、`business_type` 复核、单窗口截断阈值(§7/§9)。
5. 数据证伪、未采纳的改动:T2 关键词已完备(35 条技术词全被「超时/启动/加载」覆盖),不扩。
---
## 1. 背景与目标
比价(外卖 `business_type=food`)由客户端无障碍自动化 + pricebot 多平台 LLM 驱动,链路长、失败形态多。目前**没有任何主动发现失败的手段**——只能人工翻库或等用户反馈。
**目标**:新增一个**近实时、记录级**的比价失败报警机制。每隔 15–30 分钟扫描新落定的比价记录,逐条按预定规则判定「是否属于需要关注的失败」,把命中的记录**汇总成一条飞书消息**发到告警群,并**报出每条触发的原因**。
**关键设计取向**(均由数据分析与评审确认):
- **粒度是「记录」不是「失败率」**:逐条判定,不算比率、不设样本量门槛、不做基线对比。日均比价量小(完成约 45 条/天),比率方案在小窗口会剧烈抖动;记录级方案规避了这个问题。
- **只报「技术性失败」「识别失败」与「深度放弃」**,不报正常业务结局。
- **有触发才发,无触发静默**:不刷屏。
## 2. 数据分析依据(基线)
全量 3867 条记录级 `status` 分布(详见附录 A):
| status | 数量 | 占比 | 说明 |
|---|---|---|---|
| success | 1501 | 38.8% | 成功(含 `below_minimum` 未满起送,被归一为 success) |
| cancelled | 1394 | 36.0% | 用户中途退出 |
| failed | 957 | 24.7% | 失败(T1 技术 386 + T2 超时 35 + T6 识别 96 + 业务 440 |
| running | 15 | 0.4% | 悬挂未收尾 |
支撑规则设计的关键事实:
- **`failed` 是混合桶**`fail_reason IS NULL` 的 393 条是纯系统技术失败(其中「比价过程出错,请稍后重试」占 294,是 `_GENERIC_INFO` 兜底黑话);`fail_reason` 非空的多为业务结局,但夹杂「启动淘宝超时」等技术问题 35 条、「未识别到商品」96 条。
- **业务失败不该报**:打烊、无此店、无此菜、未起送、单点不配送是正常结局。
- **落定延迟很长**`total_ms`(≈ 记录从建行到落定的时长)p99 = failed 449s、cancelled 602smax 16min。**这是水位必须用 `updated_at` 而非 `created_at` 的直接依据**。
- **cancelled 缺退出上下文**:99.3% 终止原因就一句「用户终止比价」,且 100% 没有 `platforms`/结果数据(都在 `running` 阶段被中止)。唯一可用信号是退出时机(`total_ms`/`step_count`)。cancelled 的 `step_count` 中位 5、p90 31`total_ms` 中位 24s、p90 124s。参照系:一次成功比价中位 113s / 38 步。
## 3. 报警规则(v1
worker 每轮查询「上次水位之后有更新」的记录,对每条按下表判定;命中任一即计入本期汇总。四个规则互斥(一条记录最多归一类)。
| 类型 | 判定条件(SQL 语义) | 触发原因文案 | 历史量(2月) |
|---|---|---|---|
| **T1 系统技术失败** | `status='failed' AND fail_reason IS NULL AND (information IS NULL OR information !~ 业务词)` | `技术失败·{information 去空白截断; 空则"比价过程出错"}` | ≈386 |
| **T6 商品识别失败** | `status='failed' AND fail_reason ~ '未识别'` | `识别失败·未识别到商品` | ≈96 |
| **T2 超时/启动失败** | `status='failed' AND fail_reason IS NOT NULL AND fail_reason ~ 超时关键词` | `{fail_reason}`(如「启动淘宝超时」) | ≈35 |
| **T5 cancelled 深度放弃** | `status='cancelled' AND (total_ms > 90000 OR step_count > 30)` | `深度放弃·等待 {total_ms/1000 取整}s / {step_count} 步后退出` | ≈250 |
**判定顺序(保证互斥)**
1. `status='failed'`
- `fail_reason IS NULL` → 若 `information` 命中**业务词**`未找到|打烊|起送|门店|店内|不配送|这些菜|未入驻|休息`)则**不报**(业务失败漏派生 fail_reason,约 7 条);否则 **T1**
- `fail_reason` 含「未识别」→ **T6**
- `fail_reason` 含超时关键词(`超时|启动|加载`)→ **T2**
- 其余(干净业务原因)→ **不报**
2. `status='cancelled'` 且(`total_ms>90000``step_count>30`)→ **T5**;否则不报。
3. `status IN ('success','running')` → 不报。
**阈值/关键词(可配初值)**:T5 的 `90000ms`/`30步` 取自 cancelled 分布约 p90(评审选定「B 中档」)。超时关键词 `超时,启动,加载`、识别关键词 `未识别`、业务排除词均可配。
每条命中记录在汇总里附带:`trace_id``app_version``business_type`、触发原因文案、`created_at`
## 4. 非目标与暂缓项
| 项 | 处理 | 原因 |
|---|---|---|
| 业务失败(打烊/无店/无菜/未起送/单点不配送) | **不报** | 正常业务结局 |
| success、早退 cancelled(≤90s 且 ≤30 步) | **不报** | 无报警价值 |
| **T3 running 悬挂** | **本期暂缓** | 评审决定先不报;但见下方 🔴 |
| **T4 单平台适配失效**`platforms[].status='failed'`) | 暂不纳入(未来增强) | 量大、与整体失败重叠、噪音高 |
| 失败率 / cancelled 率等**比率型**指标 | 不做 | 本设计是记录级 |
> 🔴 **待独立排查的回归线索**(非本报警范围,留档):`running` 悬挂 15 条**全部集中在 2026-07-28 之后**,此前两个月几乎为 0。强烈提示某次发版后 `harvest_done`/`harvest_abort` 收尾链路(`app/repositories/comparison.py`)回归,建议单独开 issue。排查确认后可在 v2 把 T3 作为独立高频告警加回。
## 5. 架构与组件
沿用项目现有**常驻 asyncio worker** 范式(与 `heartbeat_monitor_worker` 等一致)。各组件单一职责、可独立测试:
| 组件 | 路径 | 职责 |
|---|---|---|
| **数据模型改动** | `app/models/comparison.py` + alembic 迁移 | `comparison_record` 新增 `updated_at``server_default=func.now()`, `onupdate=func.now()`+ 索引 `ix_comparison_updated`。为水位提供单调递增的落定时间。 |
| **规则模块** | `app/services/compare_alert.py` | 纯函数:输入一批 ORM 记录 → 输出 `[(记录, 触发类型, 原因文案)]`。判定逻辑与阈值全在此,无 I/O,易测易调。 |
| **扫描 worker** | `app/core/compare_alert_worker.py` | 仿 `heartbeat_monitor_worker`:单实例文件锁 + `asyncio` 轮询 + 优雅退出。每轮:读水位 → 查有更新记录 → 调规则 → 有命中则格式化并发飞书 → 推进水位。 |
| **飞书通知器** | `app/integrations/feishu_notifier.py` | 实现群机器人 webhook 发送。发送失败抛异常由 worker 处理。 |
| **水位存储** | 复用 `app_config` 表 | key=`compare_alert.last_watermark`value=上次处理的最大 `updated_at`。 |
| **启停挂载** | `app/main.py` lifespan | `start_compare_alert_worker()` / `stop_compare_alert_worker()`,与现有 worker 同处注册。 |
> **`onupdate` 生效前提**:现有 `harvest_done`/`harvest_abort`/`upsert_record` 均走 ORM `setattr`+`commit` 更新,`onupdate=func.now()` 会自动刷新 `updated_at`,无需改写路径。
## 6. 数据流与水位管理(updated_at 方案)
```
每 interval 秒:
读 app_config['compare_alert.last_watermark'] → watermark
(空 → 冷启动:watermark = 当前 max(updated_at),只报之后新落定的,不回溯历史)
查 comparison_record
WHERE updated_at > watermark
ORDER BY updated_at ASC
逐条套 T1/T6/T2/T5 规则 → 命中集合(按类型分组)
若命中集合非空:
格式化飞书消息 → feishu_notifier.send()
成功 → 水位 = 本批 max(updated_at)
失败 → 不更新水位(log),下一轮重扫补发
若命中集合为空:
水位 = 本批 max(updated_at)(无记录则不动;可选 SEND_EMPTY 发简讯)
```
- **零漏报**:任何记录落定/更新时 `updated_at` 刷新为当前 DB 时钟 > 水位,必被下一轮扫到——无论 `created_at` 多早、落定多慢(根治了 `created_at` 水位漏掉慢失败的问题)。
- **规避时区**:水位存的是 DB 产出的 `updated_at` 值,查询用 `updated_at` 自身比较,**不依赖 worker 本地时钟与 DB 时钟对齐**(`created_at` 存 naive 北京、`func.now()` 为 DB 时钟,二者口径不同,但本方案只用 `updated_at` 自比较,不受影响)。
- **发送失败不推进水位**:保证不漏;恢复后一次补发。
- **一条记录可能被扫多次**running 更新→落定更新,`updated_at` 变两次):但只有落定后 `status` 才命中规则,running 阶段扫到不命中,无副作用;不会重复报。
## 7. 飞书消息格式
群机器人消息(文本或富文本 `post`),按类型分组:
```
🚨 比价失败报警 · 2026-08-04 08:0008:30 · 本期触发 7 条
• 系统技术失败 3 条
- trace abc123 | v0.3.4 | 比价过程出错
• 商品识别失败 2 条
- trace abc200 | v0.5.1 | 未识别到商品
• 启动/超时失败 1 条
- trace def456 | v0.3.4 | 启动淘宝超时
• 深度放弃(cancelled) 1 条
- trace ghi789 | v0.6.3 | 等待 98s / 26 步后退出
```
- **截断阈值**:单类型明细超 `MAX_DETAIL_PER_TYPE`(默认 20)条时,只列前 20 条 + 「另有 N 条」;本期总命中超 `MAX_TOTAL`(默认 50)条时降级为只给各类型计数,提示去分析库查(防报警风暴,如 07-14 那种高失败日)。
## 8. 配置项
`app/core/config.py``pydantic-settings`):
| 配置 | 默认 | 说明 |
|---|---|---|
| `COMPARE_ALERT_ENABLED` | `False` | 总开关;关时 worker 不启动 |
| `COMPARE_ALERT_SCAN_INTERVAL_SEC` | `1800` | 扫描间隔,可配 90015min |
| `COMPARE_ALERT_FEISHU_WEBHOOK` | `""` | 群机器人 webhook;空则 worker 仅打日志不外发 |
| `COMPARE_ALERT_CANCELLED_MS_THRESHOLD` | `90000` | T5 耗时阈值(ms |
| `COMPARE_ALERT_CANCELLED_STEP_THRESHOLD` | `30` | T5 步数阈值 |
| `COMPARE_ALERT_TIMEOUT_KEYWORDS` | `"超时,启动,加载"` | T2 关键词 |
| `COMPARE_ALERT_UNRECOGNIZED_KEYWORDS` | `"未识别"` | T6 关键词 |
| `COMPARE_ALERT_BIZ_EXCLUDE_KEYWORDS` | `"未找到,打烊,起送,门店,店内,不配送,这些菜,未入驻,休息"` | T1 的 information 业务词排除 |
| `COMPARE_ALERT_MAX_DETAIL_PER_TYPE` | `20` | 单类型明细截断 |
| `COMPARE_ALERT_MAX_TOTAL` | `50` | 本期总命中截断(超则只给计数) |
| `COMPARE_ALERT_SEND_EMPTY` | `False` | 无命中是否发「本期无异常」简讯 |
> **`business_type` 复核**:当前数据全为 `food`,规则未按 `business_type` 限定。接入 `ecom`/`coupon` 时需复核各规则(尤其 T2/T6 关键词与 T5 阈值是否仍适用)。
## 9. 错误处理与边界
- **worker 单轮异常吞掉不退出**`except Exception: logger.exception`),仿 heartbeat。
- **DB / 飞书发送异常**:log,本轮不推进水位,下轮重试补发。
- **单实例锁**:文件锁 `data/compare_alert.lock`O_CREAT|O_EXCL + stale 检测)。
- **NULL 语义**T5 中 `total_ms`/`step_count` 为 NULL 的 cancelledSQL 比较 `NULL>90000` 为 false → 不命中(无数据不报,符合预期)。T1 中 `information IS NULL` 时业务词排除不触发(视为非业务)→ 仍属 T1,原因文案兜底「比价过程出错」。
- **冷启动不回溯历史**:首次启动水位=当前 `max(updated_at)`,避免把历史失败一次性全报。
## 10. 测试策略
仿现有 `tests/` 风格(`TestClient` + monkeypatch 外部依赖,SQLite 临时库):
- `test_compare_alert_rules.py`:喂各类记录(T1/T6/T2/T5 命中样本 + 业务失败/success/早退 cancelled/running 反例 + T1 业务词误入反例),断言分类与原因文案;覆盖阈值边界(`total_ms=90000` 不命中、`90001` 命中)与 NULL 语义。
- `test_compare_alert_worker.py`monkeypatch notifier 与 `SessionLocal`,验证 `updated_at` 水位推进、发送失败不推进、冷启动=max、命中汇总、截断逻辑。
- `test_feishu_notifier.py`monkeypatch HTTP,验证消息体格式与发送失败抛异常。
- 迁移测试:`updated_at` 列 + 索引存在,`onupdate` 在 ORM 更新时刷新。
## 11. 未来增强
1. **T3 running 悬挂告警**:待第 4 节 🔴 回归排查后,作为独立高频告警加回。
2. **T4 单平台适配失效**`platforms[].status='failed'` 逐平台维度。
3. **cancelled 退出上下文埋点**:客户端终止时上报退出阶段、已比出平台数、是否已看到中间结果——让 cancelled 从「只有时机」升级为「可归因」。
4. **分维度统计**:汇总附带按 `app_version`/`source_platform` 的命中分布,辅助定位回归版本/平台。
5. **趋势型报警**:记录级之上叠加比率/环比(需另设样本量保护)。
## 附录 A:分析数据来源与复现
- **来源**:线上 PostgreSQL 16 `pg_dump` 单表 `comparison_record`plain SQL187MB)。
- **本地环境**Docker 容器 `shaguabijia-pg`postgres:16-alpine),独立分析库 `cr_analysis`(用户 `shaguabijia_app`)。导入:`docker cp` dump 进容器后 `psql -f`(末尾外键引用 `public.user` 报错属预期,单表 dump 无 user 表,不影响数据与索引)。
- **样本**3867 行,2026-06-09 ~ 08-04。
- **关键分布**(供实现期回归对照):
| 指标 | success | failed | cancelled |
|---|---|---|---|
| 数量 | 1501 | 957 | 1394 |
| step_count 中位 / p90 | 38 / 61 | 22 / 47 | 5 / 31 |
| total_ms 中位 / p90 / p99 | 113s / 200s / 391s | 77s / 168s / 449s | 24s / 124s / 602s |
- **failed 细分**(合计 957):T1 系统技术 386(`fail_reason IS NULL` 393 − 业务误入 7)、T2 超时/启动 35、T6 识别失败 96、业务失败 440(含误入的 7 条)。
@@ -0,0 +1,168 @@
# 比价「卡死定位」报警增强设计
- 日期:2026-08-05
- 分支:feat-compare-fail-alert(延续一期)
- 关联:`docs/superpowers/specs/2026-08-04-compare-fail-alert-design.md`(一期报警)
## 1. 背景与问题
一期报警对 **cancelled(中途退出)** 的判据(`app/services/compare_alert.py``classify_record`):
```
cancelled 且 (total_ms > 90s 或 step_count > 30步) → T5 深度放弃
```
这个判据量的是「投入多少」,不是「卡没卡」,两头都错:
- **漏报**:一进平台就卡在登录墙 / 加载失败,5 秒 2 步就退 → 判「不深度」→ 不报。但这是真卡死。
- **误报**:用户正常挑了 100 秒、点了 40 步,比完价不满意退了 → `>90s` → 报「深度放弃」。但根本没卡。
根因:`total_ms`/`step_count` 是**整场**的量,把「卡在一步反复失败」和「正常深度使用」混为一谈。
### 1.1 数据佐证(真实 trace
- **卡死例**`20260804_114703` meituan):`pipeline_step``set_address`(step 0-1) → `enter_store`(3-8) → **`add_one_dish`(9 一路到 120+,110+ 帧全困在这一个环节)**。且 `timing.json` 里根本没有 meituan——`step_profiler` 只在平台 `is_done` 时落 timing,卡死平台永不 done。
- **正常例**`20260803_165239` eleme):`set_address`(0) → `enter_store`(1-7) → done,每个环节 ≤7 帧就推进走了。
**卡死的结构特征**:某 `pipeline_step` 连续几十上百帧不变(原地打转);正常则是逐环节推进、单环节 ≤7 帧。两者空档极大(7 vs 110+),可用一个帧数阈值干净区分,且**不需要大量数据归纳环节语义**。
## 2. 目标
- cancelled 判据:从「整场耗时/帧数阈值」→「trace 末段原地打转」,抓真卡死(含短时卡死)、不误报正常深度使用。
- **判定与展示一体**:直接报「卡在 平台·环节」。
- failed 类(T1/T2/T6):判定不变,best-effort 补卡点定位。
- 稳:读不到 trace 回退原耗时/帧数保底,**绝不阻断报警发送**。
## 3. 取数:同机直读(不改 pricebot)
app-server 与 pricebot **同机**。trace 落盘在 `{WORK_LOG_DIR}/{dir_name}/`
- **dir_name 从 `comparison_record.trace_url` 尾段抠**`trace_url = {base}/traces/{dir_name}/`,尾段就是磁盘目录名,新老格式都对得上,规避从 `trace_id` 反推老格式「首帧时刻」的难题。
- 只读末段帧的**头部字段**`pipeline_step` / `detected_page`),不解析后面的无障碍树(`windows`,占单帧 99% 体积)。
- 不改 pricebot、不需要 `INTERNAL_API_SECRET`、不走网络。
> 备选途径(已否决):pricebot 加内部接口(要改两仓 + secret)、公网 `trace_url` GET timing.json(本地 SSL 大面积超时 + timing.json 缺卡死平台)。同机直读最优。
## 4. 架构分层
保持判定纯函数、IO 单独成层:
| 模块 | 职责 | 性质 |
|---|---|---|
| `services/compare_alert.py`(微调) | failed 判定不变;cancelled 只保留**回退保底**判定(`>90s`/`>30步` | 纯函数 |
| `services/trace_stuck.py`(新) | 给定 trace 目录 → 逐平台读末段 → 判「原地打转」→ 返回卡点列表 | 薄 IO + 纯逻辑 |
| `core/compare_alert_worker.py`(编排) | 先跑纯 `classify_batch` 出候选,再对候选调 `trace_stuck` 增强 | 编排 |
## 5. trace_stuck 模块
### 5.1 卡死判据(逐平台)
对某平台的 `step_*.json` 序列,从**末帧往前**数,连续 `(pipeline_step, detected_page)` 都相同的帧数 ≥ N → 判该平台卡死,卡点 = 该 `pipeline_step`
- `N = COMPARE_ALERT_STUCK_FRAME_THRESHOLD`(默认 **15**;正常环节 ≤7 帧、卡死 110+ 帧,空档极大)。
- **「无推进」= `(pipeline_step, detected_page)` 双不变**(页面没跳转、环节没变)。这样能区分:
- 「加多菜」:`pipeline_step` 相同但 `detected_page` 在跳(换菜/回菜单)= 推进 → 不判卡死;
- 「卡在一步」:两者都不变 = 原地打转 → 卡死。
- 从末帧往前最多读 `COMPARE_ALERT_TRACE_MAX_TAIL_FRAMES`(默认 **40**)帧,够判 ≥N 即停,防超长 trace 全读。
### 5.2 逐平台聚合(B 方案:不漏)
一条 trace **逐平台**判,所有卡死平台都收集——不只「帧数最多」的那个。因为「帧数最多」会在**卡死平台帧数不是最多**时漏报(如另一平台正常加了 8 道菜跑了 30 帧、卡死平台一进就卡登录 5 帧退),而那恰是短时卡死。多个卡死平台都列进 reason。
### 5.3 接口
```python
@dataclass(frozen=True)
class StuckPoint:
platform: str
pipeline_step: str
frames: int # 末段连续困住的帧数
@dataclass(frozen=True)
class StuckResult:
readable: bool # trace 是否读到(区分「读不到」与「读到但没卡」)
points: list[StuckPoint] # 卡死平台列表;readable=True 且空 = 确认没卡死
def read_stuck_points(trace_dir: Path, *, threshold: int, max_tail: int) -> StuckResult:
"""逐平台判卡死。目录不存在/异常/无平台帧 → StuckResult(readable=False, points=[])。"""
def last_step(trace_dir: Path) -> StuckPoint | None:
"""failed 用:取帧数最多平台的末帧环节(不要求原地打转)。读不到 → None。"""
```
## 6. 判定流
### 6.1 cancelledtrace 优先 → 保底)
```
worker 对 cancelled 候选:
res = read_stuck_points(dir)
if res.readable and res.points: # 读到且有卡死平台 → 报卡死(带卡点环节)
报 T5, reason="深度放弃", stuck_point = "、".join(f"{平台}·{环节}" for res.points)
else: # 其余一律回退耗时/步数兜底(见下方 2026-08-06 修订)
>90s或>30步 → T5「深度放弃」(卡点列留空); 否则不报
```
> **2026-08-06 修订(compare-fail-alert 排查)**:原设计「读到且没卡死 → 不报」在线上是死路——
> **线上 trace 几乎总可读**(WORK_LOG_DIR 已配、同机直读),于是耗时兜底那条分支基本永不触发,
> `total_ms>90s` 阈值形同虚设,**超长放弃(实测 113s / 516s)一条都报不出来**。改为:只有「判出卡点」
> 独占带卡点的 T5;**其余(可读没卡点 / 读不到 / 无 trace)一律回退耗时兜底**,超长照报(卡点留空)。
> 权衡:这会重新引入第 1 节「误报」——用户正常浏览 90s+ 后退出也会报。若噪音大,调高
> `COMPARE_ALERT_CANCELLED_MS_THRESHOLD`(如 180s/300s)收敛,不动代码。
### 6.2 failedT1/T2/T6,判定不变 + 附卡点)
```
worker 对 failed 命中:
sp = last_step(dir) # 读不到 → None
if sp: reason += f"|卡在 {平台}·{环节}"
```
`failed` 只取「末帧停在哪」,不要求原地打转(它已失败、末帧即失败点)。intent 阶段就失败(无平台目录,典型 T6)→ 不附,reason 原样。
## 7. 卡点文案映射
`PIPELINE_STEP_LABELS`(小映射表,映射不到原样显示英文、不阻断):
| pipeline_step | 中文 |
|---|---|
| `set_address` | 定位 |
| `enter_store` | 进店 |
| `add_one_dish` | 加菜 |
| …(实现时按 pricebot 实际枚举补全) | |
平台名同样映射(`meituan`→美团、`eleme`→饿了么、`jd_waimai`→京东外卖)。
## 8. 配置(`app/core/config.py`;路径敏感项放 `.env`
| 配置 | 默认 | 说明 |
|---|---|---|
| `COMPARE_ALERT_PRICEBOT_WORK_LOG_DIR` | `""` | pricebot work_logs 绝对路径;**空 = 跳过 trace、全走保底**(行为等同一期) |
| `COMPARE_ALERT_STUCK_FRAME_THRESHOLD` | `15` | N:末段连续同环节达此帧数判卡死 |
| `COMPARE_ALERT_TRACE_MAX_TAIL_FRAMES` | `40` | 每平台最多往前读多少帧 |
| `COMPARE_ALERT_TRACE_MAX_RECORDS` | `30` | 每轮最多对多少条命中记录读 trace(限量) |
## 9. 展示
卡片「失败原因」列下附一行卡点小字,**不新增列**。cancelled 卡死时卡点即 reason 本身;failed 的卡点附在原因后。
> 依赖:本期展示复用一期卡片的「失败原因」列。若一期卡片(schema 2.0 table 组件,当前仍在临时脚本 `scripts/_test_alert_card.py`)尚未固化为正式 `format_alert_card` + `send_feishu_card`,本期实现时一并固化。
## 10. 降级与成本
- 只对命中记录读、限量 `MAX_RECORDS`、每平台只读末段头部字段、单文件读加超时。
- **任何异常降级**cancelled 回退保底、failed 不附卡点,绝不阻断报警。
- `work_log_dir` 未配 → 整个 trace 增强跳过,行为等同一期(纯保底)。
## 11. 测试
- **trace_stuck 单测**:卡死正例(meituan 目录 → 判出 `add_one_dish`)、正常负例(eleme → 不判卡死)、加多菜不误判(`detected_page` 在变)、末段不足 N 帧、读不到目录降级。
- **worker 集成**:trace 优先命中 vs 读不到回退保底切换;failed 附卡点;限量 `MAX_RECORDS` 生效。
- fixture 用 tmp 造 `step_*.json`**只含头部字段**trace_id/step/platform/pipeline_step/detected_page)即可,不需无障碍树。
## 12. 不做(YAGNI
- 不改 pricebot(不加内部接口)。
- 不落库(不加 `comparison_record` 列、不做迁移)。
- 不做每帧耗时(`timing.json` 缺卡死平台,且报警用不上逐帧耗时)。
- 不做环节黑白名单 / 语义分类(结构判据已够,且需大数据)。
+3
View File
@@ -35,6 +35,9 @@ dependencies = [
# multipart form (FastAPI 表单上传依赖)
"python-multipart>=0.0.9",
# 用户反馈截图缩略图,避免 App 历史页为 48dp 小图下载数 MB 原图
"pillow>=11.0.0",
# admin 后台账号密码 hash(用户侧是手机号+验证码登录,不需要密码;admin 才用)
"bcrypt>=4.0.0",
+1
View File
@@ -31,6 +31,7 @@ os.environ.setdefault("WXPAY_MCH_ID", "test-mch")
os.environ.setdefault("WXPAY_MCH_SERIAL_NO", "test-serial")
os.environ.setdefault("WXPAY_PUBLIC_KEY_ID", "test-pubkey-id")
os.environ.setdefault("RATE_LIMIT_ENABLED", "false") # 限流内存计数会跨用例累加,测试关掉
os.environ.setdefault("COMPARE_ALERT_ENABLED", "false") # 报警 worker 测试不启动(避免 .env 的 true 干扰 test_defaults)
# 穿山甲发奖回调:测试里开启 + 给个 mock 验签密钥,test 内自签自验闭环
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
os.environ.setdefault("PANGLE_REWARD_SECRET", "test-pangle-secret-only-for-pytest")
+62
View File
@@ -0,0 +1,62 @@
"""admin 展示口径派生单测:记录级原始结局 → (admin_status, outcome_hint)。"""
from __future__ import annotations
import pytest
from sqlalchemy import select
from app.admin.repositories.comparison_outcome import admin_success_sql, derive_admin_outcome
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
@pytest.mark.parametrize(
("raw_payload", "status", "expected"),
[
# 真实形态:status 已 normalize,细分在 raw_payload.record_status
({"record_status": "success"}, "success", ("success", None)),
({"record_status": "below_minimum"}, "success", ("success", "未满起送")),
({"record_status": "store_closed"}, "failed", ("success", "门店打烊")),
({"record_status": "store_not_found"}, "failed", ("success", "未找到店")),
({"record_status": "items_not_found"}, "failed", ("success", "未找到菜")),
({"record_status": "no_delivery"}, "failed", ("success", "单点不配送")),
({"record_status": "unsupported"}, "failed", ("success", "平台·场景不支持")),
({"record_status": "failed"}, "failed", ("failed", None)), # 纯技术故障
# 空字符串视作缺失,兜到下一级(与 SQL nullif 对齐)
({"record_status": "", "status": "store_closed"}, "failed", ("success", "门店打烊")),
# POST 路径:细分在 raw_payload.status
({"status": "store_not_found"}, "failed", ("success", "未找到店")),
# 兜底 status 列:raw_payload 缺失(极老记录)或残留细分值
(None, "success", ("success", None)),
(None, "failed", ("failed", None)),
(None, "store_closed", ("success", "门店打烊")), # 迁移未覆盖的残留
# 生命周期态优先,不看结局
({}, "cancelled", ("cancelled", None)),
({"record_status": "success"}, "running", ("running", None)),
],
)
def test_derive_admin_outcome(raw_payload, status, expected):
assert derive_admin_outcome(raw_payload, status) == expected
def test_admin_success_sql_matches_python_on_empty_string() -> None:
"""record_status 为空串时,SQL 侧(nullif)与 Python 侧(or)都应兜到 status 列结局、判为成功。"""
db = SessionLocal()
try:
rec = ComparisonRecord(
trace_id="outcome-empty-record-status",
status="failed",
raw_payload={"record_status": "", "status": "store_closed"},
)
db.add(rec)
db.flush()
matched = db.execute(
select(ComparisonRecord.id).where(
ComparisonRecord.trace_id == "outcome-empty-record-status",
admin_success_sql(),
)
).scalar_one_or_none()
assert matched is not None # SQL 侧判成功
assert derive_admin_outcome(rec.raw_payload, rec.status) == ("success", "门店打烊") # Python 侧一致
finally:
db.rollback()
db.close()
+193 -13
View File
@@ -1,7 +1,7 @@
"""Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。"""
from __future__ import annotations
from datetime import UTC, datetime
from datetime import UTC, date, datetime
import pytest
from fastapi.testclient import TestClient
@@ -12,6 +12,7 @@ from app.admin.repositories import admin_user as admin_repo
from app.admin.repositories import queries
from app.db.session import SessionLocal, engine
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponClaimRecord, CouponSession
from app.models.feedback import Feedback
from app.models.invite import InviteRelation
from app.models.savings import SavingsRecord
@@ -81,21 +82,25 @@ def test_dashboard_period_comparison_is_aggregated_by_backend(
admin_client: TestClient, admin_token: str
) -> None:
created_at = datetime(2037, 1, 15, 12)
# (trace_id, status 列, record_status, total_ms, cost)
rows = [
("dashboard-aggregate-success", "success", 101, 0.1),
("dashboard-aggregate-failed", "failed", 200, 0.2),
("dashboard-aggregate-cancelled", "cancelled", 300, 0.3),
("dashboard-aggregate-running", "running", 400, 0.4),
("dashboard-success", "success", "success", 101, 0.1),
("dashboard-below-min", "success", "below_minimum", 120, 0.2),
("dashboard-store-not-found", "failed", "store_not_found", 130, 0.0),
("dashboard-failed", "failed", "failed", 200, 0.3),
("dashboard-cancelled", "cancelled", None, 300, 0.4),
("dashboard-running", "running", None, 400, 0.5),
]
db = SessionLocal()
try:
for trace_id, status, total_ms, llm_cost_yuan in rows:
for trace_id, status, record_status, total_ms, llm_cost_yuan in rows:
db.add(
ComparisonRecord(
trace_id=trace_id,
status=status,
total_ms=total_ms,
llm_cost_yuan=llm_cost_yuan,
raw_payload={"record_status": record_status} if record_status else None,
created_at=created_at,
)
)
@@ -110,14 +115,95 @@ def test_dashboard_period_comparison_is_aggregated_by_backend(
)
assert response.status_code == 200, response.text
comparison = response.json()["period"]["comparison"]
assert comparison["total"] == 4
assert comparison["completed"] == 2
assert comparison["total"] == 6
# admin 成功 = success + below_minimum + store_not_found = 3
assert comparison["success"] == 3
# completed = 3 成功 + 1 纯 failed = 4
assert comparison["completed"] == 4
assert comparison["cancelled"] == 1
assert comparison["success"] == 1
assert comparison["success_rate"] == 0.3333
assert comparison["median_duration_ms"] == 151
assert comparison["p95_duration_ms"] == 195
assert comparison["token_cost_total_yuan"] == pytest.approx(1.0)
# 分母 = total - cancelled = 5
assert comparison["success_rate"] == 0.6
assert comparison["token_cost_total_yuan"] == pytest.approx(1.5)
def test_dashboard_coupon_success_rate_excludes_abandoned_sessions(
admin_client: TestClient, admin_token: str
) -> None:
started_date = date(2038, 1, 16)
started_at = datetime(2038, 1, 16, 8, tzinfo=UTC)
sessions = [
("coupon-rate-completed-1", "coupon-rate-device-1", "completed"),
("coupon-rate-completed-2", "coupon-rate-device-2", "completed"),
("coupon-rate-failed", "coupon-rate-device-3", "failed"),
("coupon-rate-abandoned", "coupon-rate-device-4", "abandoned"),
]
db = SessionLocal()
try:
for trace_id, device_id, status in sessions:
db.add(
CouponSession(
trace_id=trace_id,
device_id=device_id,
status=status,
app_env="prod",
platforms=["meituan-waimai"],
started_at=started_at,
started_date=started_date,
)
)
for index, device_id in enumerate(("coupon-rate-device-1", "coupon-rate-device-2")):
db.add(
CouponClaimRecord(
device_id=device_id,
coupon_id=f"mt_dashboard_rate_{index}",
claim_date=started_date,
status="success",
app_env="prod",
)
)
db.commit()
finally:
db.close()
response = admin_client.get(
"/admin/api/stats/overview",
params={"date_from": "2038-01-16", "date_to": "2038-01-16"},
headers=_auth(admin_token),
)
assert response.status_code == 200, response.text
coupon = response.json()["period"]["coupon"]
assert coupon["started"] == 4
assert coupon["abandoned"] == 1
assert coupon["success_denominator"] == 3
assert coupon["all_success"] == 2
assert coupon["success_rate"] == pytest.approx(2 / 3, abs=0.0001)
db = SessionLocal()
try:
db.add(
CouponSession(
trace_id="coupon-rate-only-abandoned",
device_id="coupon-rate-device-only-abandoned",
status="abandoned",
app_env="prod",
platforms=["meituan-waimai"],
started_at=datetime(2038, 1, 17, 8, tzinfo=UTC),
started_date=date(2038, 1, 17),
)
)
db.commit()
finally:
db.close()
empty_denominator_response = admin_client.get(
"/admin/api/stats/overview",
params={"date_from": "2038-01-17", "date_to": "2038-01-17"},
headers=_auth(admin_token),
)
assert empty_denominator_response.status_code == 200
only_abandoned = empty_denominator_response.json()["period"]["coupon"]
assert only_abandoned["success_denominator"] == 0
assert only_abandoned["success_rate"] is None
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
@@ -720,6 +806,16 @@ def test_comparison_records_show_readable_device_and_rom_version(
rom_name="OriginOS",
rom_version=4,
android_version="14",
platforms=[
{
"platform_id": "meituan",
"platform_name": "美团",
"status": "ok",
"role": "target",
"price": 18.8,
"is_best": True,
}
],
)
db.add(record)
db.commit()
@@ -747,6 +843,38 @@ def test_comparison_records_show_readable_device_and_rom_version(
assert detail.status_code == 200, detail.text
assert detail.json()["device_model_name"] == "vivo Y77e"
assert detail.json()["rom_version"] == 4
assert detail.json()["platforms"][0]["status"] == "ok"
assert detail.json()["platforms"][0]["is_best"] is True
def test_comparison_records_list_tolerates_orphan_null_user(
admin_client: TestClient, admin_token: str
) -> None:
"""帧0 建行但 user_id 暂缺的孤儿记录(软鉴权/匿名),admin 列表必须能序列化、不 500。"""
db = SessionLocal()
try:
db.add(
ComparisonRecord(
user_id=None,
trace_id="comparison-orphan-null-user",
status="success",
store_name="孤儿比价专用店ZZZ",
)
)
db.commit()
finally:
db.close()
response = admin_client.get(
"/admin/api/comparison-records",
params={"store": "孤儿比价专用店ZZZ"},
headers=_auth(admin_token),
)
assert response.status_code == 200, response.text
items = response.json()["items"]
assert len(items) == 1
assert items[0]["user_id"] is None
assert items[0]["trace_id"] == "comparison-orphan-null-user"
def test_comparison_records_show_real_order_status(
@@ -1079,3 +1207,55 @@ def test_period_signin_boost_moves_to_reward_video_without_double_count(
assert coins["reward_video_coin_total"] == 300
assert coins["regular_task_coin_total"] == 50
assert coins["signin_boost_coin_total"] == 200
def test_comparison_records_expose_admin_outcome(
admin_client: TestClient, admin_token: str
) -> None:
"""列表 / 详情下发 admin_status + outcome_hint:外部缺失=成功⚠,纯故障=失败。"""
db = SessionLocal()
try:
user = user_repo.upsert_user_for_login(
db, phone="13800009051", register_channel="sms"
)
# 未找到店:status 已 normalize 成 failed,细分在 raw_payload.record_status
gap_rec = ComparisonRecord(
user_id=user.id, trace_id="admin-outcome-store-not-found",
status="failed", store_name="缺店排查店ZZZ",
raw_payload={"record_status": "store_not_found"},
)
db.add(gap_rec)
# 纯技术故障
db.add(ComparisonRecord(
user_id=user.id, trace_id="admin-outcome-tech-failed",
status="failed", store_name="缺店排查店ZZZ",
raw_payload={"record_status": "failed"},
))
db.commit()
uid = user.id
gap_id = gap_rec.id
finally:
db.close()
resp = admin_client.get(
"/admin/api/comparison-records",
params={"user_id": uid, "store": "缺店排查店ZZZ"},
headers=_auth(admin_token),
)
assert resp.status_code == 200, resp.text
by_trace = {it["trace_id"]: it for it in resp.json()["items"]}
gap = by_trace["admin-outcome-store-not-found"]
assert gap["admin_status"] == "success"
assert gap["outcome_hint"] == "未找到店"
tech = by_trace["admin-outcome-tech-failed"]
assert tech["admin_status"] == "failed"
assert tech["outcome_hint"] is None
detail = admin_client.get(
f"/admin/api/comparison-records/{gap_id}", headers=_auth(admin_token)
)
assert detail.status_code == 200, detail.text
assert detail.json()["admin_status"] == "success"
assert detail.json()["outcome_hint"] == "未找到店"
+17
View File
@@ -0,0 +1,17 @@
"""比价报警配置默认值与关键词解析。"""
from __future__ import annotations
from app.core.config import settings
def test_defaults() -> None:
assert settings.COMPARE_ALERT_ENABLED is False
assert settings.COMPARE_ALERT_SCAN_INTERVAL_SEC == 900
assert settings.COMPARE_ALERT_CANCELLED_MS_THRESHOLD == 90000
assert settings.COMPARE_ALERT_CANCELLED_STEP_THRESHOLD == 30
def test_keyword_parsing() -> None:
assert settings.compare_alert_timeout_keywords == ("超时", "启动", "加载")
assert settings.compare_alert_unrecognized_keywords == ("未识别",)
assert "打烊" in settings.compare_alert_biz_exclude_keywords
+62
View File
@@ -0,0 +1,62 @@
"""classify_cancelled_fallback / make_hit 单测。"""
from app.services.compare_alert import classify_cancelled_fallback, make_hit
class _Rec:
def __init__(self, **kw):
self.trace_id = kw.get("trace_id", "t")
self.status = kw.get("status", "cancelled")
self.total_ms = kw.get("total_ms")
self.step_count = kw.get("step_count")
self.fail_reason = kw.get("fail_reason")
self.information = kw.get("information")
self.app_version = kw.get("app_version")
self.created_at = kw.get("created_at")
self.trace_url = kw.get("trace_url")
self.user_id = kw.get("user_id")
def test_fallback_deep_by_ms():
hit = classify_cancelled_fallback(
_Rec(total_ms=95000, step_count=5),
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
)
assert hit is not None and hit.alert_type == "T5" and "深度放弃" in hit.reason
def test_fallback_deep_by_step():
hit = classify_cancelled_fallback(
_Rec(total_ms=1000, step_count=35),
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
)
assert hit is not None and hit.alert_type == "T5"
def test_fallback_shallow_none():
hit = classify_cancelled_fallback(
_Rec(total_ms=5000, step_count=3),
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
)
assert hit is None
def test_make_hit_carries_fields():
hit = make_hit(_Rec(trace_id="tx", app_version="0.6.0"), "T5", "卡在 美团·加菜")
assert hit.trace_id == "tx"
assert hit.reason == "卡在 美团·加菜"
assert hit.app_version == "0.6.0"
def test_make_hit_carries_total_ms_and_step():
hit = make_hit(_Rec(trace_id="tx", total_ms=602000, step_count=157), "T5", "深度放弃")
assert hit.total_ms == 602000
assert hit.step_count == 157
def test_make_hit_total_ms_step_default_none():
# rec 没有这两个属性时安全降级为 None(不报错)
class _Bare:
trace_id = "b"; status = "cancelled"; reason = None
app_version = None; created_at = None; trace_url = None; user_id = None
hit = make_hit(_Bare(), "T1", "技术失败")
assert hit.total_ms is None and hit.step_count is None
+572
View File
@@ -0,0 +1,572 @@
"""AlertHit[] → 飞书消息:分组 / 截断 / 含关键词(text + post 两种格式)。"""
from __future__ import annotations
from datetime import datetime
from app.services.compare_alert import AlertHit
from app.services.compare_alert_format import ALERT_KEYWORD, format_alert_message, format_alert_post
def _hits(n, alert_type="T1"):
return [
AlertHit(
trace_id=f"t{i}",
alert_type=alert_type,
reason="比价过程出错",
app_version="v0.3.4",
created_at=None,
trace_url=None,
user_id=None,
)
for i in range(n)
]
# ---- format_alert_message(纯文本,保留原有测试) ----
def test_contains_keyword_and_count():
msg = format_alert_message(
_hits(2), window_label="2026-08-04 08:0008:30",
max_detail_per_type=20, max_total=50,
)
assert ALERT_KEYWORD in msg
assert "本期触发 2 条" in msg
assert "系统技术失败 2 条" in msg
assert "t0" in msg and "v0.3.4" in msg
def test_group_by_type():
hits = _hits(1, "T1") + _hits(1, "T6") + _hits(1, "T5")
msg = format_alert_message(hits, window_label="w", max_detail_per_type=20, max_total=50)
assert "系统技术失败 1 条" in msg
assert "商品识别失败 1 条" in msg
assert "深度放弃(cancelled) 1 条" in msg
def test_per_type_truncation():
msg = format_alert_message(_hits(25), window_label="w", max_detail_per_type=20, max_total=50)
assert msg.count("t0") == 1
assert "另有 5 条" in msg
def test_total_truncation_counts_only():
msg = format_alert_message(_hits(60), window_label="w", max_detail_per_type=20, max_total=50)
assert "系统技术失败 60 条" in msg
assert "t0" not in msg
assert "分析库" in msg
# ---- format_alert_post(富文本 post) ----
def _hits_with_meta(n, alert_type="T1", *, user_id=None, trace_url=None, created_at=None):
return [
AlertHit(
trace_id=f"tr{i}",
alert_type=alert_type,
reason="比价过程出错",
app_version="v1.2.3",
created_at=created_at or datetime(2026, 8, 4, 10, 30),
trace_url=trace_url,
user_id=user_id,
)
for i in range(n)
]
def test_post_title_contains_keyword():
hits = _hits_with_meta(1)
title, content = format_alert_post(
hits, window_label="2026-08-04 10:00", phone_map={}, max_detail_per_type=20, max_total=50,
)
assert ALERT_KEYWORD in title
def test_post_content_is_list():
hits = _hits_with_meta(2)
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
assert isinstance(content, list)
assert len(content) >= 1
# 每个段落是 list[dict]
for para in content:
assert isinstance(para, list)
for elem in para:
assert "tag" in elem
def test_post_summary_count():
hits = _hits_with_meta(3, "T1") + _hits_with_meta(2, "T6")
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
# 摘要第一段含合计数和类型计数
first_para_text = "".join(e.get("text", "") for e in content[0])
assert "合计 5 条" in first_para_text
assert "系统技术失败 3" in first_para_text
assert "商品识别失败 2" in first_para_text
def test_post_phone_map_applied():
hits = _hits_with_meta(1, user_id=42)
title, content = format_alert_post(
hits, window_label="w", phone_map={42: "13800138000"}, max_detail_per_type=20, max_total=50,
)
# 找明细行(非摘要非表头)中含手机号
all_text = " ".join(
e.get("text", "") for para in content for e in para
)
assert "13800138000" in all_text
def test_post_no_user_id_shows_dash():
hits = _hits_with_meta(1, user_id=None)
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
all_text = " ".join(e.get("text", "") for para in content for e in para)
assert " - " in all_text
def test_post_trace_url_becomes_a_element():
hits = _hits_with_meta(1, trace_url="https://trace.example.com/tr0")
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
# 找 tag=a 的元素
a_elements = [e for para in content for e in para if e.get("tag") == "a"]
assert len(a_elements) == 1
assert a_elements[0]["href"] == "https://trace.example.com/tr0"
assert a_elements[0]["text"] == "trace"
def test_post_no_trace_url_shows_trace_id_prefix():
hits = _hits_with_meta(1, trace_url=None)
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
# 无 trace_url 时:tag=text, text=trace_id[:16]
detail_texts = [e.get("text", "") for para in content for e in para if e.get("tag") == "text"]
# trace_id 是 tr0,截 16 位
assert any("tr0" in t for t in detail_texts)
def test_post_total_truncation_no_detail():
hits = _hits_with_meta(60)
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
# 超 max_total:只有摘要+截断提示,无表头、无明细
all_text = " ".join(e.get("text", "") for para in content for e in para)
assert "合计 60 条" in all_text
assert "时间 手机号" not in all_text
assert "tr0" not in all_text
assert "分析库" in all_text
def test_post_per_type_truncation():
hits = _hits_with_meta(25)
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
all_text = " ".join(e.get("text", "") for para in content for e in para)
assert "另有 5 条" in all_text
def test_post_created_at_formatting():
hits = [
AlertHit(
trace_id="tx1",
alert_type="T1",
reason="测试",
app_version="v2.0",
created_at=datetime(2026, 8, 4, 10, 30),
trace_url=None,
user_id=None,
)
]
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
all_text = " ".join(e.get("text", "") for para in content for e in para)
assert "08-04 10:30" in all_text
def test_post_no_created_at_shows_dash():
hits = [
AlertHit(
trace_id="tx2",
alert_type="T1",
reason="测试",
app_version=None,
created_at=None,
trace_url=None,
user_id=None,
)
]
title, content = format_alert_post(
hits, window_label="w", phone_map={}, max_detail_per_type=20, max_total=50,
)
all_text = " ".join(e.get("text", "") for para in content for e in para)
# 无 created_at 时间显示 "-"
assert "- " in all_text
# ---- format_alert_card(schema 2.0 卡片) ----
from app.services.compare_alert_format import format_alert_card # noqa: E402
def _card_hits(n, alert_type="T1", *, total_ms=None, step_count=None, user_id=None,
trace_url=None, created_at=None):
return [
AlertHit(
trace_id=f"card{i}",
alert_type=alert_type,
reason="比价过程出错",
app_version="v1.5.0",
created_at=created_at or datetime(2026, 8, 4, 10, 30),
trace_url=trace_url,
user_id=user_id,
total_ms=total_ms,
step_count=step_count,
)
for i in range(n)
]
def test_card_schema_and_header():
card = format_alert_card(
_card_hits(1),
window_label="2026-08-04 10:00",
phone_map={},
interval_min=15,
max_detail_per_type=20,
max_total=50,
)
assert card["schema"] == "2.0"
assert card["header"]["template"] == "red"
assert ALERT_KEYWORD in card["header"]["title"]["content"]
def test_card_body_markdown_summary():
card = format_alert_card(
_card_hits(3),
window_label="2026-08-04 10:00",
phone_map={},
interval_min=15,
max_detail_per_type=20,
max_total=50,
)
elements = card["body"]["elements"]
md = elements[0]
assert md["tag"] == "markdown"
assert "近 15 分钟" in md["content"]
assert "合计 3 条" in md["content"]
def test_card_has_table_seven_columns():
card = format_alert_card(
_card_hits(2),
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
elements = card["body"]["elements"]
# 第二个元素是 table
assert len(elements) >= 2
table = elements[1]
assert table["tag"] == "table"
cols = table["columns"]
assert len(cols) == 7
display_names = [c["display_name"] for c in cols]
assert display_names == ["时间", "手机号", "用时", "失败原因", "卡点", "版本", "trace"]
# 「卡点」列在「失败原因」后、「版本」前
names = [c["name"] for c in cols]
reason_idx = names.index("reason")
stuck_idx = names.index("stuck")
ver_idx = names.index("ver")
assert reason_idx < stuck_idx < ver_idx
# trace 列用 lark_md
trace_col = next(c for c in cols if c["name"] == "trace")
assert trace_col["data_type"] == "lark_md"
# 其余列 data_type 均为 text
for c in cols:
if c["name"] != "trace":
assert c["data_type"] == "text"
def test_card_cost_cell_format():
"""cost 格式: total_ms 和 step_count 均有值时 '{Ns} / {M步}'"""
hits = [
AlertHit(
trace_id="t1",
alert_type="T1",
reason="r",
app_version="v1",
created_at=datetime(2026, 8, 4, 10, 0),
trace_url=None,
user_id=None,
total_ms=602_000,
step_count=157,
)
]
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
assert row["cost"] == "602s / 157步"
def test_card_cost_only_ms():
hits = [
AlertHit(
trace_id="t2",
alert_type="T1",
reason="r",
app_version="v1",
created_at=None,
trace_url=None,
user_id=None,
total_ms=30_000,
step_count=None,
)
]
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
assert row["cost"] == "30s"
def test_card_cost_only_steps():
hits = [
AlertHit(
trace_id="t3",
alert_type="T1",
reason="r",
app_version="v1",
created_at=None,
trace_url=None,
user_id=None,
total_ms=None,
step_count=42,
)
]
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
assert row["cost"] == "42步"
def test_card_cost_none_when_both_missing():
hits = _card_hits(1, total_ms=None, step_count=None)
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
assert row["cost"] == "-"
def test_card_total_truncation_no_table():
"""超 max_total 时只有 markdown 摘要,没有 table 元素"""
card = format_alert_card(
_card_hits(60),
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
elements = card["body"]["elements"]
assert len(elements) == 1
assert elements[0]["tag"] == "markdown"
assert "comparison_record" in elements[0]["content"]
def test_card_empty_hits():
"""空 hits 返回含「本期无异常」的卡片(无 table)"""
card = format_alert_card(
[],
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
assert card["schema"] == "2.0"
elements = card["body"]["elements"]
assert len(elements) == 1
assert elements[0]["tag"] == "markdown"
assert "本期无异常" in elements[0]["content"]
def test_card_rows_count_respects_per_type_limit():
"""每类型最多 max_detail_per_type 条"""
hits = _card_hits(25, "T1")
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
assert len(table["rows"]) == 20
def test_card_trace_url_becomes_markdown_link():
hits = _card_hits(1, trace_url="https://trace.example.com/t0")
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
assert row["trace"] == "[链接](https://trace.example.com/t0)"
def test_card_no_trace_url_shows_trace_id_prefix():
hits = _card_hits(1, trace_url=None)
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
# trace_id = "card0"[:12]
assert row["trace"] == "card0"
def test_card_phone_from_map():
hits = _card_hits(1, user_id=7)
card = format_alert_card(
hits,
window_label="w",
phone_map={7: "13912345678"},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
assert row["phone"] == "13912345678"
def test_card_version_not_truncated():
"""版本完整显示,不缩写"""
hits = [
AlertHit(
trace_id="tv1",
alert_type="T1",
reason="r",
app_version="v2.15.3-release",
created_at=None,
trace_url=None,
user_id=None,
)
]
card = format_alert_card(
hits,
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
row = table["rows"][0]
assert row["ver"] == "v2.15.3-release"
def test_card_table_has_page_size_and_header_style():
card = format_alert_card(
_card_hits(1),
window_label="w",
phone_map={},
interval_min=10,
max_detail_per_type=20,
max_total=50,
)
table = card["body"]["elements"][1]
assert "page_size" in table
assert "header_style" in table
assert table["header_style"].get("background_style") == "grey"
assert table["header_style"].get("bold") is True
def test_card_stuck_point_shown_in_row():
"""stuck_point 有值时行中 stuck 列正确显示;无值时显示「-」。"""
import dataclasses
from app.services.compare_alert import make_hit
class _Rec:
trace_id = "sp1"
status = "cancelled"
total_ms = 90000
step_count = 20
fail_reason = None
information = None
app_version = "v1.0"
created_at = datetime(2026, 8, 5, 10, 0)
trace_url = None
user_id = None
hit_with = dataclasses.replace(
make_hit(_Rec(), "T5", "深度放弃"),
stuck_point="美团·加菜 110帧/32s",
)
hit_without = make_hit(_Rec(), "T5", "深度放弃") # stuck_point=None
card_with = format_alert_card(
[hit_with],
window_label="w", phone_map={}, interval_min=5,
max_detail_per_type=20, max_total=50,
)
row_with = card_with["body"]["elements"][1]["rows"][0]
assert row_with["stuck"] == "美团·加菜 110帧/32s"
card_without = format_alert_card(
[hit_without],
window_label="w", phone_map={}, interval_min=5,
max_detail_per_type=20, max_total=50,
)
row_without = card_without["body"]["elements"][1]["rows"][0]
assert row_without["stuck"] == "-"
+46
View File
@@ -0,0 +1,46 @@
"""updated_at 列存在、回填 = created_at、onupdate 在 ORM 更新时刷新。"""
from __future__ import annotations
import time
from sqlalchemy import inspect
from app.db.session import SessionLocal, engine
from app.models.comparison import ComparisonRecord
def test_updated_at_column_and_index_exist() -> None:
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("comparison_record")}
assert "updated_at" in cols
idx_names = {i["name"] for i in insp.get_indexes("comparison_record")}
# 迁移环境手动建索引名为 ix_comparison_updated;
# 测试环境 create_all() 按 SQLAlchemy 命名约定生成 ix_comparison_record_updated_at。
# 两种环境都验通过即可。
assert (
"ix_comparison_updated" in idx_names
or "ix_comparison_record_updated_at" in idx_names
), f"updated_at index not found; available indexes: {idx_names}"
def test_onupdate_refreshes_updated_at() -> None:
db = SessionLocal()
try:
rec = ComparisonRecord(trace_id="alert-updated-at-onupdate", status="running")
db.add(rec)
db.commit()
db.refresh(rec)
first = rec.updated_at
assert first is not None
time.sleep(1.1) # SQLite CURRENT_TIMESTAMP 秒级精度,睡过 1 秒才看得出变化
rec.status = "failed"
db.commit()
db.refresh(rec)
assert rec.updated_at > first
finally:
db.rollback()
db.query(ComparisonRecord).filter(
ComparisonRecord.trace_id == "alert-updated-at-onupdate"
).delete()
db.commit()
db.close()
+61
View File
@@ -0,0 +1,61 @@
"""比价报警规则分类:记录 → AlertHit | None(纯函数,不碰 DB)。"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.services.compare_alert import classify_record
KW = dict(
cancelled_ms_threshold=90000,
cancelled_step_threshold=30,
timeout_keywords=("超时", "启动", "加载"),
unrecognized_keywords=("未识别",),
biz_exclude_keywords=("未找到", "打烊", "起送", "门店", "店内", "不配送", "这些菜", "未入驻", "休息"),
)
def _rec(**kw):
base = dict(
status="failed", fail_reason=None, information=None,
total_ms=None, step_count=None, trace_id="t", app_version=None, business_type="food",
)
base.update(kw)
return SimpleNamespace(**base)
@pytest.mark.parametrize(
("rec", "expected_type"),
[
(_rec(status="failed", fail_reason=None, information="比价过程出错,请稍后重试"), "T1"),
(_rec(status="failed", fail_reason=None, information=None), "T1"),
(_rec(status="failed", fail_reason=None, information="美团外卖门店已打烊,无法比价"), None),
(_rec(status="failed", fail_reason="未识别到商品"), "T6"),
(_rec(status="failed", fail_reason="启动淘宝超时, 请稍后重试"), "T2"),
(_rec(status="failed", fail_reason="淘宝闪购店内未找到这些菜品"), None),
(_rec(status="cancelled", total_ms=98000, step_count=10), "T5"),
(_rec(status="cancelled", total_ms=20000, step_count=31), "T5"),
(_rec(status="cancelled", total_ms=20000, step_count=5), None),
(_rec(status="cancelled", total_ms=None, step_count=None), None),
(_rec(status="success"), None),
(_rec(status="running"), None),
],
)
def test_classify_record_type(rec, expected_type):
hit = classify_record(rec, **KW)
assert (hit.alert_type if hit else None) == expected_type
def test_t5_boundary_exclusive():
assert classify_record(_rec(status="cancelled", total_ms=90000, step_count=30), **KW) is None
assert classify_record(_rec(status="cancelled", total_ms=90001, step_count=30), **KW).alert_type == "T5"
def test_reason_texts():
t1 = classify_record(_rec(status="failed", fail_reason=None, information=" 比价过程出错 "), **KW)
assert t1.reason == "技术失败·比价过程出错"
t1_empty = classify_record(_rec(status="failed", fail_reason=None, information=None), **KW)
assert t1_empty.reason == "技术失败·比价过程出错"
t5 = classify_record(_rec(status="cancelled", total_ms=98000, step_count=26), **KW)
assert t5.reason == "深度放弃"
+145
View File
@@ -0,0 +1,145 @@
"""build_hits 集成测:cancelled trace 优先/保底切换、failed 附卡点、限量。"""
import json
from pathlib import Path
from app.core.compare_alert_worker import _fmt_stuck, build_hits
from app.services.trace_stuck import StuckPoint
class _Rec:
def __init__(self, **kw):
self.trace_id = kw.get("trace_id", "t")
self.status = kw.get("status", "cancelled")
self.total_ms = kw.get("total_ms")
self.step_count = kw.get("step_count")
self.fail_reason = kw.get("fail_reason")
self.information = kw.get("information")
self.app_version = kw.get("app_version")
self.created_at = kw.get("created_at")
self.trace_url = kw.get("trace_url")
self.user_id = kw.get("user_id")
def _frame(pdir: Path, idx: int, step: str, page: str) -> None:
pdir.mkdir(parents=True, exist_ok=True)
body = {"pipeline_step": step, "detected_page": page, "windows": [{"n": ["x" * 200]}]}
(pdir / f"step_{idx:03d}.json").write_text(
json.dumps(body, ensure_ascii=False), encoding="utf-8"
)
_KW = dict(
stuck_threshold=15, max_tail=40, max_trace_reads=30,
cancelled_ms_threshold=90000, cancelled_step_threshold=30,
timeout_keywords=("超时",), unrecognized_keywords=("未识别",), biz_exclude_keywords=(),
)
def test_cancelled_stuck_reports_via_trace(tmp_path):
for i in range(18):
_frame(tmp_path / "20260804_x" / "meituan", i, "add_one_dish", "meal_detail_popup")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260804_x/",
total_ms=5000, step_count=3) # 保底不会中,靠 trace 判卡死
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T5"
assert hits[0].reason == "深度放弃"
assert "美团·加菜" in hits[0].stuck_point
assert "" in hits[0].stuck_point
def test_cancelled_readable_not_stuck_long_duration_reports(tmp_path):
# trace 可读但没判出原地卡点:仍过耗时/步数阈值兜底,超长(>90s)照报 T5,卡点列留空。
# (线上 trace 几乎总可读,若不回退则 total_ms 阈值形同虚设、超长放弃永不报——见 compare-fail-alert 排查。)
p = tmp_path / "20260804_y" / "eleme"
_frame(p, 0, "set_address", "home")
for i in range(1, 6):
_frame(p, i, "enter_store", "store")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260804_y/",
total_ms=95000, step_count=40)
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T5"
assert hits[0].reason == "深度放弃"
assert hits[0].stuck_point is None # 没卡点 → 卡片卡点列显 "-"
def test_cancelled_readable_not_stuck_short_no_report(tmp_path):
# trace 可读没卡点、且耗时/步数都没超阈值 → 正常早退,不报(兜底阈值把住,不误报)。
p = tmp_path / "20260804_ys" / "eleme"
_frame(p, 0, "set_address", "home")
for i in range(1, 6):
_frame(p, i, "enter_store", "store")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260804_ys/",
total_ms=5000, step_count=3)
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert hits == []
def test_cancelled_unreadable_falls_back(tmp_path):
rec = _Rec(status="cancelled", trace_url="https://x/traces/nope/",
total_ms=95000, step_count=3)
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T5" and "深度放弃" in hits[0].reason
def test_no_work_log_dir_uses_fallback(tmp_path):
rec = _Rec(status="cancelled", trace_url="https://x/traces/y/",
total_ms=95000, step_count=3)
hits = build_hits([rec], work_log_dir="", **_KW)
assert len(hits) == 1 and "深度放弃" in hits[0].reason
def test_failed_gets_stuck_point_appended(tmp_path):
for i in range(20):
_frame(tmp_path / "20260804_f" / "meituan", i, "add_one_dish", "meal_detail_popup")
rec = _Rec(status="failed", fail_reason="启动超时",
trace_url="https://x/traces/20260804_f/")
hits = build_hits([rec], work_log_dir=str(tmp_path), **_KW)
assert len(hits) == 1
assert hits[0].alert_type == "T2"
assert "美团·加菜" in hits[0].stuck_point
assert "卡在" not in hits[0].reason
def test_max_trace_reads_zero_skips_trace(tmp_path):
for i in range(18):
_frame(tmp_path / "20260804_z" / "meituan", i, "add_one_dish", "meal_detail_popup")
rec = _Rec(status="cancelled", trace_url="https://x/traces/20260804_z/",
total_ms=95000, step_count=3)
kw = {**_KW, "max_trace_reads": 0}
hits = build_hits([rec], work_log_dir=str(tmp_path), **kw)
# 没读 trace → 回退保底 → deep(95s) → 深度放弃
assert len(hits) == 1 and "深度放弃" in hits[0].reason
def test_shared_reads_budget_across_branches(tmp_path):
# cancelled 和 failed 共用 max_trace_reads 预算;预算=1 时 cancelled 先消耗,failed 拿不到卡点
for i in range(18):
_frame(tmp_path / "20260804_c" / "meituan", i, "add_one_dish", "meal_detail_popup")
for i in range(20):
_frame(tmp_path / "20260804_d" / "meituan", i, "add_one_dish", "meal_detail_popup")
cancelled = _Rec(status="cancelled", trace_url="https://x/traces/20260804_c/",
total_ms=5000, step_count=3)
failed = _Rec(status="failed", fail_reason="启动超时",
trace_url="https://x/traces/20260804_d/")
kw = {**_KW, "max_trace_reads": 1}
hits = build_hits([cancelled, failed], work_log_dir=str(tmp_path), **kw)
assert len(hits) == 2
# cancelled 消耗了唯一预算 → 报卡死,卡点在 stuck_point
assert hits[0].alert_type == "T5" and hits[0].stuck_point is not None
# failed 超预算 → 仍报 T2,但 stuck_point 为 None
assert hits[1].alert_type == "T2" and hits[1].stuck_point is None
# ---- _fmt_stuck 单测 ----
def test_fmt_stuck_with_ms():
sp = StuckPoint(platform="meituan", pipeline_step="add_one_dish", frames=110, stuck_ms=32000)
assert _fmt_stuck(sp) == "美团·加菜 110帧/32s"
def test_fmt_stuck_without_ms():
sp = StuckPoint(platform="meituan", pipeline_step="add_one_dish", frames=110, stuck_ms=None)
assert _fmt_stuck(sp) == "美团·加菜 110帧"
+93
View File
@@ -0,0 +1,93 @@
"""扫描 worker 的同步核心 _scan_and_alert:水位读写 / 冷启动 / 发送成功推进、失败不推进。
monkeypatch 掉真正的飞书发送;用真实 SQLite(SessionLocal)插入几条记录
"""
from __future__ import annotations
import time
import pytest
from app.core import compare_alert_worker as w
from app.db.session import SessionLocal
from app.models.app_config import AppConfig
from app.models.comparison import ComparisonRecord
WM_KEY = w.WATERMARK_KEY
@pytest.fixture()
def clean_db():
db = SessionLocal()
db.query(ComparisonRecord).delete()
db.query(AppConfig).filter(AppConfig.key == WM_KEY).delete()
db.commit()
yield db
db.query(ComparisonRecord).delete()
db.query(AppConfig).filter(AppConfig.key == WM_KEY).delete()
db.commit()
db.close()
def _add(db, trace, status, **kw):
rec = ComparisonRecord(trace_id=trace, status=status, **kw)
db.add(rec)
db.commit()
db.refresh(rec)
return rec
def _card_title(card: dict) -> str:
return card.get("header", {}).get("title", {}).get("content", "")
def _card_md_content(card: dict) -> str:
elements = card.get("body", {}).get("elements", [])
return elements[0].get("content", "") if elements else ""
def test_cold_start_sets_watermark_no_alert(clean_db, monkeypatch):
# 冷启动:表非空 → 水位=当前 max(updated_at),不回溯已有历史失败、不报
_add(clean_db, "old-fail", "failed", fail_reason=None, information="比价过程出错")
sent = []
monkeypatch.setattr(w, "_send_card", lambda card: sent.append(card))
w._scan_and_alert()
assert sent == [] # 冷启动不报历史
row = clean_db.get(AppConfig, WM_KEY)
assert row is not None # 水位已初始化
def test_alerts_on_new_failed_and_advances(clean_db, monkeypatch):
# seed 一条 + 冷启动建水位;sleep 1.1s 拉开时间(SQLite 秒级精度,否则新记录同秒、追不上水位)
_add(clean_db, "seed", "running")
monkeypatch.setattr(w, "_send_card", lambda card: None)
w._scan_and_alert() # 冷启动,水位=seed.updated_at
time.sleep(1.1)
sent = []
monkeypatch.setattr(w, "_send_card", lambda card: sent.append(card))
_add(clean_db, "new-fail", "failed", fail_reason=None, information="比价过程出错")
w._scan_and_alert()
assert len(sent) == 1
card = sent[0]
# title 含关键词
assert "比价失败报警" in _card_title(card)
# markdown 摘要含"系统技术失败"
assert "系统技术失败 1" in _card_md_content(card)
def test_send_failure_does_not_advance_watermark(clean_db, monkeypatch):
_add(clean_db, "seed2", "running")
monkeypatch.setattr(w, "_send_card", lambda card: None)
w._scan_and_alert() # 冷启动建水位
wm_before = clean_db.get(AppConfig, WM_KEY).value
time.sleep(1.1)
_add(clean_db, "fail-send", "failed", fail_reason=None, information="比价过程出错")
def boom(card):
raise w.feishu_notifier.FeishuNotifyError("down")
monkeypatch.setattr(w, "_send_card", boom)
w._scan_and_alert() # 发送失败
clean_db.expire_all()
wm_after = clean_db.get(AppConfig, WM_KEY).value
assert wm_after == wm_before # 未推进,下轮补发
+126 -2
View File
@@ -1,14 +1,16 @@
from __future__ import annotations
import time
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from app.core.limit_policy import MODE_UNLIMITED
from app.core.rewards import CN_TZ
from app.core.security import decode_token
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.models.limit_policy import LimitPolicyOverride
def _login(client) -> tuple[str, int]:
@@ -138,10 +140,132 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
)
assert response.status_code == 429
assert response.json()["detail"] == "今日比价超过100次,请明天再试"
assert response.json()["detail"] == "今日比价额度用完啦,明天再来吧~"
with SessionLocal() as db:
assert db.scalar(
select(func.count(ComparisonRecord.id)).where(
ComparisonRecord.trace_id == rejected_trace
)
) == 0
def test_compare_quota_fresh_user(client) -> None:
"""新用户今天没有比价记录 → exhausted=False, used=0, limit=100。"""
token, _user_id = _login(client)
response = client.get("/api/v1/compare/quota", headers=_headers(token))
assert response.status_code == 200, response.text
assert response.json() == {"exhausted": False, "used": 0, "limit": 100}
def test_compare_quota_exhausted(client) -> None:
"""今日已有 100 条记录 → exhausted=True, used=100, limit=100。"""
token, user_id = _login(client)
now = datetime.now(CN_TZ).replace(tzinfo=None)
with SessionLocal() as db:
db.add_all(
[
ComparisonRecord(
user_id=user_id,
trace_id=f"quota-exhausted-{user_id}-{i}",
status="failed",
created_at=now,
)
for i in range(100)
]
)
db.commit()
response = client.get("/api/v1/compare/quota", headers=_headers(token))
assert response.status_code == 200, response.text
assert response.json() == {"exhausted": True, "used": 100, "limit": 100}
def test_compare_quota_yesterday_rows_not_counted(client) -> None:
"""昨天的记录不计入今日配额 → exhausted=False, used=0。"""
token, user_id = _login(client)
yesterday = datetime.now(CN_TZ).replace(tzinfo=None) - timedelta(days=1)
with SessionLocal() as db:
db.add_all(
[
ComparisonRecord(
user_id=user_id,
trace_id=f"quota-yesterday-window-{user_id}-{i}",
status="success",
created_at=yesterday,
)
for i in range(100)
]
)
db.commit()
response = client.get("/api/v1/compare/quota", headers=_headers(token))
assert response.status_code == 200, response.text
assert response.json() == {"exhausted": False, "used": 0, "limit": 100}
def test_compare_quota_device_whitelist_parity(client) -> None:
"""device 白名单下 /quota?device_id=X 与 /start 的 policy 完全一致。
场景:
- 设备 whitelisted-device-001 unlimited 覆盖 /quota?device_id= 应报
exhausted=False, limit=null,哪怕同用户今天已发起 100
- 不带 device_id(或带非白名单设备) 同用户走全局 100 上限,exhausted=True
"""
token, user_id = _login(client)
device_id = f"whitelisted-device-{user_id}"
# 种 100 条今日记录:此时不带白名单设备 /quota 应报 exhausted=True
now = datetime.now(CN_TZ).replace(tzinfo=None)
with SessionLocal() as db:
db.add_all(
[
ComparisonRecord(
user_id=user_id,
trace_id=f"quota-parity-{user_id}-{i}",
status="failed",
created_at=now,
)
for i in range(100)
]
)
# 种 device 白名单覆盖:unlimited、无失效时间(永久白名单用 expires_at=None)
# 注意:validate_override 要求 unlimited+有 expires_at,但这里直接写 ORM 跳过
# 该验证——测试意图是覆盖"设备白名单已存在"的生产状态,expires_at=None 代表永久。
db.add(
LimitPolicyOverride(
subject_type="device",
subject_value=device_id,
rule_code="compare.start.daily",
mode=MODE_UNLIMITED,
enabled=True,
expires_at=None,
)
)
db.commit()
# 带白名单 device_id → unlimited,不受 100 条记录限制
resp_with_device = client.get(
f"/api/v1/compare/quota?device_id={device_id}",
headers=_headers(token),
)
assert resp_with_device.status_code == 200, resp_with_device.text
body_with = resp_with_device.json()
assert body_with["exhausted"] is False, f"whitelisted device should not be exhausted: {body_with}"
assert body_with["limit"] is None, f"whitelisted device should have null limit: {body_with}"
assert body_with["used"] == 100
# 不带 device_id → 走全局 100 上限,已有 100 条 → exhausted=True
resp_no_device = client.get("/api/v1/compare/quota", headers=_headers(token))
assert resp_no_device.status_code == 200, resp_no_device.text
body_no = resp_no_device.json()
assert body_no["exhausted"] is True, f"without device should be exhausted: {body_no}"
assert body_no["limit"] == 100
assert body_no["used"] == 100
# 带非白名单 device_id → 同样走全局 100 上限
resp_other_device = client.get(
"/api/v1/compare/quota?device_id=unknown-device-xyz",
headers=_headers(token),
)
assert resp_other_device.status_code == 200, resp_other_device.text
body_other = resp_other_device.json()
assert body_other["exhausted"] is True, f"non-whitelisted device should be exhausted: {body_other}"
assert body_other["limit"] == 100
+131
View File
@@ -13,6 +13,7 @@ import uuid
from unittest.mock import MagicMock, patch
import httpx
import pytest
from sqlalchemy import select
from app.db.session import SessionLocal
@@ -25,6 +26,26 @@ def _tid() -> str:
return uuid.uuid4().hex
@pytest.mark.parametrize(
("raw_status", "record_status"),
[
("success", "success"),
("below_minimum", "success"),
("failed", "failed"),
("store_closed", "failed"),
("store_not_found", "failed"),
("items_not_found", "failed"),
("no_delivery", "failed"),
("unsupported", "failed"),
("cancelled", "cancelled"),
("running", "running"),
(None, None),
],
)
def test_record_status_normalization(raw_status, record_status) -> None:
assert crud._normalize_record_status(raw_status) == record_status
def _done_params() -> dict:
"""一份典型 done 帧 params:美团 25 元 vs 源淘宝闪购 30 元 → 省 5 元、success。"""
return {
@@ -111,6 +132,116 @@ def test_harvest_done_derives_and_newly_success_once(client) -> None:
assert newly2 is False
def test_harvest_done_below_minimum_counts_as_completed_success(client) -> None:
"""未达起送是可信业务结论:主状态/完成奖励归 success,细分结局仍留在 raw_payload。"""
tid = _tid()
done_below_minimum = {
"record_status": "below_minimum",
"comparison_results": [
{
"platform_id": "meituan",
"platform_name": "美团",
"package": "com.sankuai.meituan",
"price": 59.0,
"is_source": True,
"rank": 1,
"store_name": "测试店",
"items": [{"name": "红乌苏", "qty": 1}],
},
],
"platform_results": {
"meituan": {"is_source": True, "status": "source", "price": 59.0},
"taobao_flash": {
"is_source": False,
"status": "below_minimum",
"reason": "购物车未达起送门槛(差 ¥25.2",
},
},
"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_below_minimum
)
assert rec.status == "success"
assert newly is True
assert rec.fail_reason is None
assert rec.raw_payload["record_status"] == "below_minimum"
assert (
rec.raw_payload["platform_results"]["taobao_flash"]["status"]
== "below_minimum"
)
def test_harvest_done_platforms_below_minimum_counts_as_success(client) -> None:
"""新 platforms 单源派生路径也必须执行同一 below_minimum → success 归一化。"""
tid = _tid()
with SessionLocal() as db:
rec, newly = crud.harvest_done(
db,
trace_id=tid,
user_id=None,
done_params={
"record_status": "below_minimum",
"platforms": [
{
"role": "source",
"platform_id": "meituan",
"platform_name": "美团",
"price": 59.0,
"store_name": "测试店",
"items": [{"name": "红乌苏", "qty": 1}],
},
{
"role": "target",
"platform_id": "taobao_flash",
"platform_name": "淘宝",
"status": "below_minimum",
"price": None,
},
],
"platform_results": {
"taobao_flash": {
"is_source": False,
"status": "below_minimum",
"reason": "购物车未达起送门槛",
}
},
},
)
assert rec.status == "success"
assert newly is True
assert rec.raw_payload["record_status"] == "below_minimum"
def test_legacy_upsert_below_minimum_counts_as_success(client) -> None:
"""灰度期客户端直报路径无论走 status 还是 record_status 都不能落第四种主状态。"""
tid = _tid()
payload = ComparisonRecordIn(
trace_id=tid,
business_type="food",
status="below_minimum",
comparison_results=[],
platform_results={
"taobao_flash": {
"is_source": False,
"status": "below_minimum",
"reason": "购物车未达起送门槛",
}
},
information="淘宝未达起送门槛,可加菜凑单后下单",
)
with SessionLocal() as db:
rec = crud.upsert_record(db, user_id=987654, payload=payload)
assert rec.status == "success"
assert rec.fail_reason is None
assert rec.raw_payload["status"] == "below_minimum"
def test_harvest_done_failed_derives_fail_reason(client) -> None:
"""failed 记录:记录级 information 笼统,但 fail_reason 从 platform_results 救出具体原因
(id 3030 :美团系统失败 + 京东 items_not_found 展示京东那条)"""
+80 -34
View File
@@ -1,4 +1,4 @@
"""比价记录页后端分页与概览聚合。"""
"""比价记录页后端概览聚合admin 口径:外部缺失记为成功)"""
from __future__ import annotations
from datetime import UTC, date, datetime
@@ -7,13 +7,14 @@ import pytest
from sqlalchemy.dialects import postgresql
from app.admin.repositories import queries
from app.admin.repositories.comparison_outcome import admin_success_sql
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
def test_postgresql_duration_summary_uses_ordered_set_aggregates() -> None:
stmt = queries._comparison_duration_aggregate_stmt(
[], "success", (0.05, 0.5, 0.95, 0.99)
[], admin_success_sql(), (0.05, 0.5, 0.95, 0.99)
)
sql = str(
stmt.compile(
@@ -21,61 +22,106 @@ def test_postgresql_duration_summary_uses_ordered_set_aggregates() -> None:
compile_kwargs={"literal_binds": True},
)
)
assert sql.count("percentile_cont") == 4
assert "comparison_record.status = 'success'" in sql
# 口径按原始结局 coalesce,而非直接读 status 列
assert "coalesce" in sql.lower()
def test_summary_uses_only_success_durations_and_filters_beijing_date() -> None:
def test_summary_counts_external_gaps_as_success() -> None:
db = SessionLocal()
try:
# (trace_id, status 列[已 normalize], record_status[原始结局], total_ms, cost, saved)
rows = [
("summary-success-a", "success", 1000, 1.0, 100),
("summary-success-b", "success", 3000, 2.0, 0),
("summary-failed", "failed", 100_000, 3.0, 0),
("summary-cancelled", "cancelled", 5000, 4.0, 0),
("sum-success", "success", "success", 1000, 1.0, 100),
("sum-below-min", "success", "below_minimum", 2000, 2.0, 0),
("sum-store-closed", "failed", "store_closed", 3000, None, 0),
("sum-store-not-found", "failed", "store_not_found", 4000, None, 0),
("sum-failed", "failed", "failed", 100_000, 3.0, 0),
("sum-cancelled", "cancelled", None, 5000, None, 0),
("sum-running", "running", None, 6000, None, 0),
]
for trace_id, status, total_ms, cost, saved in rows:
for trace_id, status, record_status, total_ms, cost, saved in rows:
db.add(ComparisonRecord(
trace_id=trace_id,
status=status,
total_ms=total_ms,
llm_cost_yuan=cost,
saved_amount_cents=saved,
raw_payload={"record_status": record_status} if record_status else None,
created_at=datetime(2038, 1, 15, 12, tzinfo=UTC),
))
db.add(ComparisonRecord(
trace_id="summary-outside-day",
status="success",
total_ms=999_999,
created_at=datetime(2038, 1, 16, 16, tzinfo=UTC),
))
db.flush()
summary = queries.comparison_records_summary(
db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15)
)
assert summary["started"] == 4
assert summary["completed"] == 3
assert summary["success"] == 2
assert summary["success_rate"] == pytest.approx(2 / 3)
assert summary["avg_token_cost"] == pytest.approx(2.5)
assert summary["lower_price_rate"] == 0.5
assert summary["avg_duration_ms"] == 2000
assert summary["p5_duration_ms"] == 1100
assert summary["p50_duration_ms"] == 2000
assert summary["p95_duration_ms"] == 2900
assert summary["p99_duration_ms"] == 2980
# admin 成功 = success + below_minimum + store_closed + store_not_found = 4
assert summary["started"] == 7
assert summary["success"] == 4
assert summary["completed"] == 5 # 4 成功 + 1 纯 failed
assert summary["cancelled"] == 1
assert summary["cancelled_rate"] == 0.25
assert summary["success_rate"] == pytest.approx(4 / 6) # 分母 started - cancelled
assert summary["avg_token_cost"] == pytest.approx(2.0) # (1+2+3)/3
assert summary["lower_price_rate"] == pytest.approx(1 / 4) # 仅 sum-success saved>0
# 耗时统计集 = admin 成功的 total_ms [1000,2000,3000,4000]
assert summary["avg_duration_ms"] == 2500
assert summary["p5_duration_ms"] == 1150
assert summary["p50_duration_ms"] == 2500
assert summary["p95_duration_ms"] == 3850
assert summary["p99_duration_ms"] == 3970
assert summary["cancelled_p50_ms"] == 5000
items, _next_cursor, total = queries.list_comparison_records(
db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15), limit=20
)
assert total == 4
assert {item.trace_id for item in items} == {row[0] for row in rows}
finally:
db.rollback()
db.close()
def test_list_status_filter_uses_admin_outcome() -> None:
"""列表「状态」筛选走 admin 口径:筛成功含 6 类、筛失败仅纯技术故障;并验日期边界排除。"""
db = SessionLocal()
try:
rows = [
("flt-success", "success", "success"),
("flt-below-min", "success", "below_minimum"),
("flt-store-not-found", "failed", "store_not_found"),
("flt-failed", "failed", "failed"),
("flt-cancelled", "cancelled", None),
]
for trace_id, status, record_status in rows:
db.add(ComparisonRecord(
trace_id=trace_id,
status=status,
raw_payload={"record_status": record_status} if record_status else None,
created_at=datetime(2039, 3, 10, 12, tzinfo=UTC),
))
# 日期边界:窗口外一条 admin 成功记录(次日),应被日期筛选排除
db.add(ComparisonRecord(
trace_id="flt-out-of-window",
status="success",
raw_payload={"record_status": "success"},
created_at=datetime(2039, 3, 11, 12, tzinfo=UTC),
))
db.flush()
succ, _c1, succ_total = queries.list_comparison_records(
db, status="success", date_from=date(2039, 3, 10), date_to=date(2039, 3, 10)
)
# 3 条当天 admin 成功(含 store_not_found);窗口外 flt-out-of-window 被日期排除
assert succ_total == 3
assert {it.trace_id for it in succ} == {
"flt-success", "flt-below-min", "flt-store-not-found",
}
fail, _c2, fail_total = queries.list_comparison_records(
db, status="failed", date_from=date(2039, 3, 10), date_to=date(2039, 3, 10)
)
assert fail_total == 1
assert {it.trace_id for it in fail} == {"flt-failed"}
_canc, _c3, canc_total = queries.list_comparison_records(
db, status="cancelled", date_from=date(2039, 3, 10), date_to=date(2039, 3, 10)
)
assert canc_total == 1
finally:
db.rollback()
db.close()
@@ -0,0 +1,95 @@
"""Regression coverage for comparison terminal-status normalization."""
from __future__ import annotations
import importlib.util
from pathlib import Path
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
def _migration_module():
path = (
Path(__file__).parents[1]
/ "alembic"
/ "versions"
/ "comparison_below_minimum_as_success.py"
)
spec = importlib.util.spec_from_file_location(path.stem, path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_business_status_migration_upgrade_and_downgrade() -> None:
engine = sa.create_engine("sqlite:///:memory:")
metadata = sa.MetaData()
records = sa.Table(
"comparison_record",
metadata,
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("status", sa.String(16), nullable=False),
sa.Column("fail_reason", sa.String(256)),
sa.Column("raw_payload", sa.JSON, nullable=False),
)
metadata.create_all(engine)
module = _migration_module()
with engine.begin() as connection:
connection.execute(
records.insert(),
[
{
"status": "below_minimum",
"fail_reason": "旧失败原因",
"raw_payload": {"record_status": "below_minimum"},
},
{
"status": "success",
"fail_reason": None,
"raw_payload": {"record_status": "success"},
},
{
"status": "failed",
"fail_reason": "技术异常",
"raw_payload": {"status": "failed"},
},
{
"status": "store_closed",
"fail_reason": "店铺打烊",
"raw_payload": {"record_status": "store_closed"},
},
{
"status": "items_not_found",
"fail_reason": "商品未找到",
"raw_payload": {"status": "items_not_found"},
},
],
)
module.op = Operations(MigrationContext.configure(connection))
module.upgrade()
upgraded = connection.execute(
sa.select(records.c.status, records.c.fail_reason).order_by(records.c.id)
).all()
assert upgraded == [
("success", None),
("success", None),
("failed", "技术异常"),
("failed", "店铺打烊"),
("failed", "商品未找到"),
]
module.downgrade()
downgraded = connection.execute(
sa.select(records.c.status, records.c.fail_reason).order_by(records.c.id)
).all()
assert downgraded == [
("below_minimum", None),
("success", None),
("failed", "技术异常"),
("store_closed", "店铺打烊"),
("items_not_found", "商品未找到"),
]
+111 -3
View File
@@ -10,6 +10,7 @@ from app.admin.repositories.coupon_data import (
_point_scores_by_trace,
coupon_data_report,
coupon_point_details,
coupon_user_records,
)
from app.admin.security import create_admin_token
from app.db.session import SessionLocal
@@ -38,6 +39,7 @@ def test_point_scores_by_trace() -> None:
stats = _point_scores_by_trace(db, [trace])[trace]
assert stats["succeeded"] == 2
assert stats["tried"] == 3
assert stats["events"] == 4
details = coupon_point_details(db, trace_id=trace)
assert [item["status"] for item in details] == [
"success", "already_claimed", "failed", "skipped"
@@ -48,8 +50,8 @@ def test_point_scores_by_trace() -> None:
db.close()
def test_skipped_detail_does_not_create_a_score() -> None:
"""仅有 skipped 时按需明细仍可查到,但列表没有虚假的 0/0 分数"""
def test_skipped_detail_is_distinguished_from_no_events() -> None:
"""仅有 skipped 时分数仍为0/0,但保留事件数供前端开放明细"""
db = SessionLocal()
trace = "point-score-skipped"
try:
@@ -63,7 +65,7 @@ def test_skipped_detail_does_not_create_a_score() -> None:
db.flush()
scores = _point_scores_by_trace(db, [trace, "missing-trace"])
assert trace not in scores
assert scores[trace] == {"succeeded": 0, "tried": 0, "events": 1}
assert "missing-trace" not in scores
assert coupon_point_details(db, trace_id=trace)[0]["status"] == "skipped"
finally:
@@ -114,6 +116,112 @@ def test_coupon_data_report_returns_scores_without_embedding_details() -> None:
db.close()
def test_coupon_data_report_marks_abandoned_without_point_results() -> None:
"""中途退出且没有逐券终态时返回0/0,其他状态缺埋点仍保持为空。"""
db = SessionLocal()
report_date = date(2020, 1, 6)
user_id = 910006
try:
db.add_all([
CouponSession(
trace_id="point-score-abandoned-without-result",
device_id="score-abandoned-device",
user_id=user_id,
status="abandoned",
app_env="prod",
platforms=["meituan-waimai"],
started_at=datetime(2020, 1, 6, tzinfo=UTC),
started_date=report_date,
),
CouponSession(
trace_id="point-score-completed-without-result",
device_id="score-completed-device",
user_id=user_id,
status="completed",
app_env="prod",
platforms=["meituan-waimai"],
started_at=datetime(2020, 1, 6, 1, tzinfo=UTC),
started_date=report_date,
),
CouponSession(
trace_id="point-score-abandoned-with-result",
device_id="score-abandoned-result-device",
user_id=user_id,
status="abandoned",
app_env="prod",
platforms=["meituan-waimai"],
started_at=datetime(2020, 1, 6, 2, tzinfo=UTC),
started_date=report_date,
),
CouponSession(
trace_id="point-score-abandoned-skipped-only",
device_id="score-abandoned-skipped-device",
user_id=user_id,
status="abandoned",
app_env="prod",
platforms=["meituan-waimai"],
started_at=datetime(2020, 1, 6, 3, tzinfo=UTC),
started_date=report_date,
),
])
db.add_all([
CouponClaimEvent(
trace_id="point-score-abandoned-with-result",
device_id="score-abandoned-result-device",
coupon_id=f"mt-abandoned-{status}",
claim_date=report_date,
status=status,
)
for status in ("success", "failed")
])
db.add(CouponClaimEvent(
trace_id="point-score-abandoned-skipped-only",
device_id="score-abandoned-skipped-device",
coupon_id="mt-abandoned-skipped",
claim_date=report_date,
status="skipped",
))
db.flush()
report = coupon_data_report(
db,
date_from=report_date.isoformat(),
date_to=report_date.isoformat(),
app_env="prod",
)
rows = {item["trace_id"]: item for item in report["items"]}
abandoned = rows["point-score-abandoned-without-result"]
assert abandoned["point_success_count"] == 0
assert abandoned["point_total_count"] == 0
assert abandoned["point_event_count"] == 0
abandoned_with_result = rows["point-score-abandoned-with-result"]
assert abandoned_with_result["point_success_count"] == 1
assert abandoned_with_result["point_total_count"] == 2
assert abandoned_with_result["point_event_count"] == 2
abandoned_skipped = rows["point-score-abandoned-skipped-only"]
assert abandoned_skipped["point_success_count"] == 0
assert abandoned_skipped["point_total_count"] == 0
assert abandoned_skipped["point_event_count"] == 1
completed = rows["point-score-completed-without-result"]
assert completed["point_success_count"] is None
assert completed["point_total_count"] is None
user_rows = {
item["trace_id"]: item
for item in coupon_user_records(db, user_id=user_id)["items"]
}
assert user_rows["point-score-abandoned-without-result"]["point_total_count"] == 0
assert user_rows["point-score-abandoned-with-result"]["point_total_count"] == 2
assert user_rows["point-score-abandoned-skipped-only"]["point_event_count"] == 1
assert user_rows["point-score-completed-without-result"]["point_total_count"] is None
finally:
db.rollback()
db.close()
def test_coupon_point_details_endpoint() -> None:
"""前端点击使用的接口按约定返回 trace_id 和逐券 items。"""
db = SessionLocal()
+184
View File
@@ -0,0 +1,184 @@
from __future__ import annotations
import importlib.util
from datetime import datetime
from pathlib import Path
import sqlalchemy as sa
def _load_migration():
path = (
Path(__file__).parents[1]
/ "alembic"
/ "versions"
/ "deepseek_v4_flash_price.py"
)
spec = importlib.util.spec_from_file_location("deepseek_v4_flash_price", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_migration_follows_comparison_status_normalization():
migration = _load_migration()
assert migration.down_revision == "comparison_below_min_success"
def test_migration_adds_price_and_corrects_only_mispriced_snapshot(monkeypatch):
migration = _load_migration()
engine = sa.create_engine("sqlite://")
metadata = sa.MetaData()
app_config = sa.Table(
"app_config",
metadata,
sa.Column("key", sa.String(64), primary_key=True),
sa.Column("value", sa.JSON, nullable=False),
sa.Column("updated_at", sa.DateTime),
)
comparison = sa.Table(
"comparison_record",
metadata,
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("llm_calls", sa.JSON),
sa.Column("llm_cost_yuan", sa.Float),
sa.Column("llm_price_snapshot", sa.JSON),
)
metadata.create_all(engine)
calls = [
{
"model": "deepseek-v4-flash",
"error": None,
"usage": {"prompt_tokens": 12031, "completion_tokens": 125},
},
{
"model": "qwen3.5-flash",
"error": None,
"usage": {"prompt_tokens": 2416, "completion_tokens": 119},
},
]
snapshot = {
"mode": "per_model",
"prices": {
"deepseek-v4-flash": {
"input_per_1m": 3.0,
"output_per_1m": 15.0,
"_source": "default",
},
"qwen3.5-flash": {
"input_per_1m": 0.2,
"output_per_1m": 2.0,
"_source": "per_model",
},
},
}
with engine.begin() as conn:
original_updated_at = datetime(2026, 7, 13, 18, 20, 10)
conn.execute(
app_config.insert().values(
key="llm_token_price",
value={
"per_model": {
"qwen3.5-flash": {
"input_per_1m": 0.2,
"output_per_1m": 2.0,
}
},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
},
updated_at=original_updated_at,
)
)
conn.execute(
comparison.insert().values(
id=1,
llm_calls=calls,
llm_cost_yuan=0.038689,
llm_price_snapshot=snapshot,
)
)
monkeypatch.setattr(migration.op, "get_bind", lambda: conn)
migration.upgrade()
config = conn.execute(
sa.select(app_config.c.value).where(
app_config.c.key == "llm_token_price"
)
).scalar_one()
assert config["per_model"]["deepseek-v4-flash"] == {
"input_per_1m": 1.0,
"output_per_1m": 2.0,
}
assert conn.execute(
sa.select(app_config.c.updated_at).where(
app_config.c.key == "llm_token_price"
)
).scalar_one() == original_updated_at
corrected = conn.execute(sa.select(comparison)).mappings().one()
assert corrected["llm_cost_yuan"] == 0.013002
assert corrected["llm_price_snapshot"]["prices"]["deepseek-v4-flash"] == {
"input_per_1m": 1.0,
"output_per_1m": 2.0,
"_source": "per_model",
}
assert corrected["llm_price_snapshot"]["pricing_correction"] == (
"deepseek_v4_flash_price"
)
migration.downgrade()
reverted_config = conn.execute(
sa.select(app_config.c.value).where(
app_config.c.key == "llm_token_price"
)
).scalar_one()
assert "deepseek-v4-flash" not in reverted_config["per_model"]
reverted = conn.execute(sa.select(comparison)).mappings().one()
assert reverted["llm_cost_yuan"] == 0.038689
assert "pricing_correction" not in reverted["llm_price_snapshot"]
def test_downgrade_preserves_price_that_existed_before_upgrade(monkeypatch):
migration = _load_migration()
engine = sa.create_engine("sqlite://")
metadata = sa.MetaData()
app_config = sa.Table(
"app_config",
metadata,
sa.Column("key", sa.String(64), primary_key=True),
sa.Column("value", sa.JSON, nullable=False),
sa.Column("updated_at", sa.DateTime),
)
sa.Table(
"comparison_record",
metadata,
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("llm_calls", sa.JSON),
sa.Column("llm_cost_yuan", sa.Float),
sa.Column("llm_price_snapshot", sa.JSON),
)
metadata.create_all(engine)
explicit_price = {"input_per_1m": 1.0, "output_per_1m": 2.0}
with engine.begin() as conn:
conn.execute(
app_config.insert().values(
key="llm_token_price",
value={
"per_model": {"deepseek-v4-flash": explicit_price},
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
},
)
)
monkeypatch.setattr(migration.op, "get_bind", lambda: conn)
migration.upgrade()
migration.downgrade()
config = conn.execute(sa.select(app_config.c.value)).scalar_one()
assert config["per_model"]["deepseek-v4-flash"] == explicit_price
+169
View File
@@ -0,0 +1,169 @@
"""飞书群机器人发送:text/post 消息体格式 / 关键词失败 / 网络失败,均 monkeypatch httpx 不真发。"""
from __future__ import annotations
import httpx
import pytest
from app.integrations import feishu_notifier
# ---- send_feishu_text ----
def test_send_posts_text_payload(monkeypatch):
captured = {}
def fake_post(url, json, timeout):
captured["url"] = url
captured["json"] = json
return httpx.Response(200, json={"code": 0, "msg": "success"})
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
feishu_notifier.send_feishu_text("https://open.feishu.cn/hook/xxx", "比价失败报警 · test", timeout=5.0)
assert captured["url"] == "https://open.feishu.cn/hook/xxx"
assert captured["json"] == {"msg_type": "text", "content": {"text": "比价失败报警 · test"}}
def test_send_raises_on_keyword_rejection(monkeypatch):
def fake_post(url, json, timeout):
return httpx.Response(200, json={"code": 19024, "msg": "Key Words Not Found"})
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_text("https://hook", "无关键词文本", timeout=5.0)
def test_send_raises_on_http_error(monkeypatch):
def fake_post(url, json, timeout):
return httpx.Response(500, text="boom")
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_text("https://hook", "比价失败报警", timeout=5.0)
# ---- send_feishu_post ----
def test_send_post_payload_structure(monkeypatch):
"""send_feishu_post 发出 msg_type=post 的 payload,结构符合飞书 post 格式。"""
captured = {}
def fake_post(url, json, timeout):
captured["url"] = url
captured["json"] = json
return httpx.Response(200, json={"code": 0, "msg": "success"})
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
content = [[{"tag": "text", "text": "合计 3 条"}]]
feishu_notifier.send_feishu_post(
"https://open.feishu.cn/hook/yyy",
"🚨 比价失败报警 · 2026-08-04",
content,
timeout=5.0,
)
assert captured["url"] == "https://open.feishu.cn/hook/yyy"
payload = captured["json"]
assert payload["msg_type"] == "post"
zh_cn = payload["content"]["post"]["zh_cn"]
assert zh_cn["title"] == "🚨 比价失败报警 · 2026-08-04"
assert zh_cn["content"] == content
def test_send_post_raises_on_code_nonzero(monkeypatch):
"""send_feishu_post code!=0 时抛 FeishuNotifyError。"""
def fake_post(url, json, timeout):
return httpx.Response(200, json={"code": 19024, "msg": "Key Words Not Found"})
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_post(
"https://hook", "🚨 比价失败报警", [[{"tag": "text", "text": "test"}]], timeout=5.0
)
def test_send_post_raises_on_http_error(monkeypatch):
"""send_feishu_post 非 2xx 抛 FeishuNotifyError。"""
def fake_post(url, json, timeout):
return httpx.Response(500, text="internal server error")
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_post(
"https://hook", "🚨 比价失败报警", [[{"tag": "text", "text": "test"}]], timeout=5.0
)
def test_send_post_raises_on_network_error(monkeypatch):
"""send_feishu_post 网络异常抛 FeishuNotifyError。"""
def fake_post(url, json, timeout):
raise httpx.ConnectError("connection refused")
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_post(
"https://hook", "🚨 比价失败报警", [[{"tag": "text", "text": "test"}]], timeout=5.0
)
# ---- send_feishu_card ----
def test_send_card_payload_structure(monkeypatch):
"""send_feishu_card 发出 msg_type=interactive + card 字段的 payload。"""
captured = {}
def fake_post(url, json, timeout):
captured["url"] = url
captured["json"] = json
return httpx.Response(200, json={"code": 0, "msg": "success"})
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
card = {
"schema": "2.0",
"header": {"title": {"tag": "plain_text", "content": "🚨 比价失败报警 · test"}, "template": "red"},
"body": {"elements": [{"tag": "markdown", "content": "近 15 分钟"}]},
}
feishu_notifier.send_feishu_card("https://open.feishu.cn/hook/zzz", card, timeout=5.0)
assert captured["url"] == "https://open.feishu.cn/hook/zzz"
assert captured["json"]["msg_type"] == "interactive"
assert captured["json"]["card"] is card
def test_send_card_uses_default_timeout(monkeypatch):
"""send_feishu_card 默认 timeout=10.0。"""
captured = {}
def fake_post(url, json, timeout):
captured["timeout"] = timeout
return httpx.Response(200, json={"code": 0, "msg": "success"})
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
feishu_notifier.send_feishu_card("https://hook", {"schema": "2.0"})
assert captured["timeout"] == 10.0
def test_send_card_raises_on_code_nonzero(monkeypatch):
"""send_feishu_card code!=0 时抛 FeishuNotifyError。"""
def fake_post(url, json, timeout):
return httpx.Response(200, json={"code": 19024, "msg": "Key Words Not Found"})
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_card("https://hook", {"schema": "2.0"})
def test_send_card_raises_on_http_error(monkeypatch):
"""send_feishu_card 非 2xx 抛 FeishuNotifyError。"""
def fake_post(url, json, timeout):
return httpx.Response(500, text="boom")
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_card("https://hook", {"schema": "2.0"})
def test_send_card_raises_on_network_error(monkeypatch):
"""send_feishu_card 网络异常抛 FeishuNotifyError。"""
def fake_post(url, json, timeout):
raise httpx.ConnectError("connection refused")
monkeypatch.setattr(feishu_notifier.httpx, "post", fake_post)
with pytest.raises(feishu_notifier.FeishuNotifyError):
feishu_notifier.send_feishu_card("https://hook", {"schema": "2.0"})
+4
View File
@@ -108,6 +108,10 @@ def test_get_llm_prices_falls_back_to_default_then_uses_override():
# 无 override → CONFIG_DEFS 默认(含 per_model / default)
prices = get_llm_prices(db)
assert "per_model" in prices and "default" in prices
assert prices["per_model"]["deepseek-v4-flash"] == {
"input_per_1m": 1.0,
"output_per_1m": 2.0,
}
# 有 override → 用 DB 值
app_config.set_value(
db, "llm_token_price",
+154
View File
@@ -0,0 +1,154 @@
"""trace_stuck 单测:用 tmp 造 step_*.json(只含头部字段)验证卡死判据。"""
import json
from pathlib import Path
from app.services.trace_stuck import (
StuckPoint,
dir_name_from_trace_url,
last_step,
read_stuck_points,
)
def _frame(pdir: Path, idx: int, step: str, page: str) -> None:
"""造一帧 step json:头部放 pipeline_step/detected_page,尾部塞大 windows 模拟真实。"""
pdir.mkdir(parents=True, exist_ok=True)
body = {
"trace_id": "t", "step": idx, "platform": pdir.name,
"pipeline_step": step, "detected_page": page,
"windows": [{"nodes": ["x" * 200]}],
}
(pdir / f"step_{idx:03d}.json").write_text(
json.dumps(body, ensure_ascii=False), encoding="utf-8"
)
def test_stuck_when_tail_repeats_same_step(tmp_path):
pdir = tmp_path / "meituan"
for i in range(20):
_frame(pdir, i, "add_one_dish", "meal_detail_popup")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is True
assert res.points == [StuckPoint("meituan", "add_one_dish", 20)]
def test_not_stuck_when_progressing(tmp_path):
pdir = tmp_path / "eleme"
_frame(pdir, 0, "set_address", "home")
for i in range(1, 8):
_frame(pdir, i, "enter_store", "store")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is True
assert res.points == []
def test_adding_many_dishes_not_stuck_when_page_changes(tmp_path):
# add_one_dish 重复但 detected_page 在跳(换菜)=推进,不判卡死
pdir = tmp_path / "meituan"
for i in range(20):
_frame(pdir, i, "add_one_dish", "menu" if i % 2 == 0 else "dish_popup")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.points == []
def test_below_threshold_not_stuck(tmp_path):
pdir = tmp_path / "meituan"
for i in range(10): # < 15
_frame(pdir, i, "add_one_dish", "meal_detail_popup")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.points == []
def test_missing_dir_not_readable(tmp_path):
res = read_stuck_points(tmp_path / "nope", threshold=15, max_tail=40)
assert res.readable is False
assert res.points == []
def test_empty_dir_no_platform_frames_not_readable(tmp_path):
(tmp_path / "emptysub").mkdir()
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is False
def test_per_platform_one_stuck_one_normal(tmp_path):
m = tmp_path / "meituan"
for i in range(18):
_frame(m, i, "add_one_dish", "meal_detail_popup")
e = tmp_path / "eleme"
_frame(e, 0, "set_address", "home")
for i in range(1, 6):
_frame(e, i, "enter_store", "store")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert res.readable is True
assert res.points == [StuckPoint("meituan", "add_one_dish", 18)]
def test_last_step_returns_busiest_platform_last_env(tmp_path):
m = tmp_path / "meituan"
for i in range(20):
_frame(m, i, "add_one_dish", "meal_detail_popup")
e = tmp_path / "eleme"
for i in range(3):
_frame(e, i, "enter_store", "store")
sp = last_step(tmp_path)
assert sp == StuckPoint("meituan", "add_one_dish", 20)
def test_dir_name_from_trace_url():
assert dir_name_from_trace_url("https://x/traces/20260804_1_abc/") == "20260804_1_abc"
assert dir_name_from_trace_url("https://x/traces/20260804_1_abc") == "20260804_1_abc"
assert dir_name_from_trace_url("") is None
assert dir_name_from_trace_url(None) is None
def test_corrupt_frame_does_not_crash(tmp_path):
# 截断的 UTF-8(pricebot 被 SIGTERM 打断的末帧)不应抛 UnicodeDecodeError
pdir = tmp_path / "meituan"
pdir.mkdir()
(pdir / "step_000.json").write_bytes(b'{"pipeline_step": "add\xff')
res = read_stuck_points(tmp_path, threshold=1, max_tail=40)
assert res.points == [] # 抠不出字段 → 不判卡死;关键是没崩溃
def _frame_ts(pdir: Path, idx: int, step: str, page: str, ts: str) -> None:
pdir.mkdir(parents=True, exist_ok=True)
body = {"pipeline_step": step, "detected_page": page, "timestamp": ts,
"windows": [{"n": ["x" * 200]}]}
(pdir / f"step_{idx:03d}.json").write_text(
json.dumps(body, ensure_ascii=False), encoding="utf-8"
)
def test_stuck_ms_computed_from_timestamps(tmp_path):
# 末段 16 帧都卡在 add_one_dish,timestamp 从 :00 到 :30(每帧+2s) → 卡住 30s
pdir = tmp_path / "meituan"
for i in range(16):
_frame_ts(pdir, i, "add_one_dish", "meal_detail_popup",
f"2026-08-04T12:00:{i * 2:02d}.000000")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert len(res.points) == 1
sp = res.points[0]
assert sp.frames == 16
assert sp.stuck_ms == 30000 # (第15帧:30 - 第0帧:00) = 30s
def test_stuck_ms_none_when_timestamp_missing(tmp_path):
# 帧无 timestamp 字段 → stuck_ms 为 None(不报错)
pdir = tmp_path / "meituan"
for i in range(16):
_frame(pdir, i, "add_one_dish", "meal_detail_popup") # 现有 _frame,无 timestamp
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert len(res.points) == 1
assert res.points[0].stuck_ms is None
def test_stuck_ms_none_when_clock_goes_backwards(tmp_path):
# 帧 timestamp 非单调(时钟回退):step_000=:30 ... step_015=:00 → 末帧早于段首 → 负时长 → 降级 None
pdir = tmp_path / "meituan"
for i in range(16):
_frame_ts(pdir, i, "add_one_dish", "meal_detail_popup",
f"2026-08-04T12:00:{(30 - i * 2):02d}.000000")
res = read_stuck_points(tmp_path, threshold=15, max_tail=40)
assert len(res.points) == 1
assert res.points[0].stuck_ms is None # 负时长降级为 None
+12 -8
View File
@@ -1,9 +1,9 @@
"""福利页(coin_cash)提现档位规则测试(7-9提现ui对齐)。
规则(2026-07-09 拍板):
- 档位 0.1/0.3(新人,历史一次性,免广告)+ 0.5(日3次)/10/20(日1次)
- 档位 0.1/0.3(新人,历史一次性,免广告)+ 0.5(日3次)/10/20/100(日1次)
- 计次口径"发起就算":当天创建的单不论最终状态(含被拒)都占名额;新人档任何状态都算用过
- 常规档每天只能选一个;新人档不参与该互斥,两个新人档同天可各提一次
- 常规档每天只能选一个;新人档不参与该互斥,两个新人档同天可各提一次
- invite_cash 无档位概念:tiers 为空下单不走档位闸(邀请页行为不变)
wxpay 调用全部 monkeypatch;现金余额 DB 直灌( test_withdraw.py 套路)
"""
@@ -74,15 +74,18 @@ def _tiers(client, token: str, source: str = "coin_cash") -> list[dict]:
def test_withdraw_info_tiers_full_and_invite_empty(client, monkeypatch) -> None:
"""新用户 coin_cash 下发 5 档(新人角标齐);invite_cash tiers 为空。"""
"""新用户 coin_cash 下发 6 档(新人角标齐);invite_cash tiers 为空。"""
token = _login(client, "13800006001")
tiers = _tiers(client, token)
assert [t["amount_cents"] for t in tiers] == [10, 30, 50, 1000, 2000]
assert [t["label"] for t in tiers] == ["0.1", "0.3", "0.5", "10", "20"]
assert [t["amount_cents"] for t in tiers] == [10, 30, 50, 1000, 2000, 10000]
assert [t["label"] for t in tiers] == ["0.1", "0.3", "0.5", "10", "20", "100"]
assert tiers[0]["badge"] == "新人福利" and tiers[0]["is_newbie"] is True
assert tiers[1]["badge"] == "新人福利" and tiers[1]["is_newbie"] is True
assert all(t["available"] for t in tiers)
assert tiers[2]["remaining_today"] == 3 # 0.5 日 3 次
# daily_limit 一起下发:客户端只有在 daily_limit>1 且 remaining_today<daily_limit
# (= 今天已提过)时才画「今日还可提N次」角标,光看 remaining_today 分不出单次档没提的情况。
assert [t["daily_limit"] for t in tiers] == [1, 1, 3, 1, 1, 1]
assert _tiers(client, token, source="invite_cash") == []
@@ -101,9 +104,9 @@ def test_newbie_tiers_independent_and_once_forever(client, monkeypatch) -> None:
amounts = [t["amount_cents"] for t in tiers]
assert 10 not in amounts # 0.1 消失
assert 30 in amounts # 0.3 还在,同天仍可提
# 新人档不参与"选一个额度":常规档全部仍可提
# 新人档不参与"选一个额度":常规档全部仍可提
regular = {t["amount_cents"]: t for t in tiers if not t["is_newbie"]}
assert all(regular[a]["available"] for a in (50, 1000, 2000))
assert all(regular[a]["available"] for a in (50, 1000, 2000, 10000))
r = _withdraw(client, token, 30) # 同天 0.3 照提
assert r.status_code == 200, r.text
@@ -115,7 +118,7 @@ def test_newbie_tiers_independent_and_once_forever(client, monkeypatch) -> None:
def test_regular_daily_select_one_tier(client, monkeypatch) -> None:
"""当天提过 0.5 → 10/20 置灰 other_tier_selected,下单 409;0.5 还能继续提(3 次内)。"""
"""当天提过 0.5 → 10/20/100 置灰 other_tier_selected,下单 409;0.5 还能继续提(3 次内)。"""
_patch_userinfo(monkeypatch, "openid_tier_3")
token = _login(client, "13800006003")
_seed_balances(client, token, "13800006003", cash=10_000)
@@ -129,6 +132,7 @@ def test_regular_daily_select_one_tier(client, monkeypatch) -> None:
assert tiers[50]["available"] and tiers[50]["remaining_today"] == 2
assert not tiers[1000]["available"] and tiers[1000]["disabled_reason"] == "other_tier_selected"
assert not tiers[2000]["available"] and tiers[2000]["disabled_reason"] == "other_tier_selected"
assert not tiers[10000]["available"] and tiers[10000]["disabled_reason"] == "other_tier_selected"
r = _withdraw(client, token, 1000) # 选一额度互斥 → 409
assert r.status_code == 409, r.text