Compare commits

...

60 Commits

Author SHA1 Message Date
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
linkeyu 84251770b4 修复:首页轮播排除测试账号数据 (#215)
## 背景

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

## 改动

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

## 验证

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

---------

Co-authored-by: linkeyu <798648091@qq.com>
Reviewed-on: #215
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-08-01 21:35:06 +08:00
marco 2e91c9f72f ecpm保底是1 2026-08-01 02:57:45 +08:00
marco a69b7d777d feat(compare/coupon): trace_id 统一由后端签发,前端不再本地生成 (#212)
Reviewed-on: #212
2026-07-31 23:52:19 +08:00
marco 0fc8521c3b feat(compare/coupon): trace_id 统一由后端签发,前端不再本地生成 (#210)
一次比价/领券的 trace_id 改由后端签发,让前端 SLS 运行日志(trace_id 索引列)、
app-server 比价记录与领券流水、pricebot trace 目录/run.log/trace_url 全链共用同一个
id 查到底(此前前端各业务自己 randomUUID,虽同链但非后端签发、也无单一签发点)。

- POST /api/v1/compare/start(预占额度,任务第一个请求,签发与建 running 行合一):
  请求 trace_id 改可选,缺省时服务端签发 uuid;响应新增 trace_id 字段返回(签发的或
  回显客户端带来的)。客户端带值则沿用——老客户端兼容 + 同 trace 重试幂等。
- POST /api/v1/coupon/session:started 帧缺 trace_id 时签发并随响应返回(签发不依赖
  写库成功);非 started 帧缺 trace_id 不签发、不写库(收尾没有 id 只能是异常调用,
  签发新 id 只会造出查不到发起信息的孤儿行)。新增 CouponSessionOut 响应模型——原
  dict[str,bool] 注解无法承载字符串 trace_id,FastAPI 响应校验会炸。
- 测试:更新 2 处旧断言(响应体多出 trace_id 字段),新增 compare 不带 id 签发用例 +
  coupon started 签发/回显、终尾缺 id 跳过写库 3 个用例。全量 30 passed + ruff clean。

配合 shaguabijia-app-android 同名分支 feat-unify-trace-id-backend-issued 的前端换源改动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Reviewed-on: #210
2026-07-31 23:11:35 +08:00
linkeyu 15fb73791f 功能:统一限制策略与白名单管理 (#207)
## 需求背景
将比价、短信与登录、广告、引导与账号、风控免告警等限制统一配置,并支持按手机号或设备设置有有效期的临时白名单。

## 主要改动
- 新增统一限制策略注册表、全局 JSON 配置与白名单覆盖表
- 新增白名单管理、设备检索、批量追加与主体统一编辑接口
- 接入比价、短信登录、广告奖励、引导视频、账号换绑及风险告警调用链
- 保留旧配置接口兼容,并同步统一策略全局值
- 增加单主体唯一有效期、恢复全局、审计日志和风险事件自动处理
- 增加数据库迁移及完整回归测试

## 验证
- 白名单、权限、配置及风控测试 50 项通过
- 短信、登录、比价、广告关联测试 98 项通过
- Ruff 与 Python 编译检查通过
- Alembic 保持单一 head
- 已同步最新 main

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #207
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-31 17:08:06 +08:00
zuochenyong d9177cbb71 fix(推送): 隔离推送设备账号绑定并支持定向联调,新建22222222222测试账号 (#208)
Co-authored-by: exinglang <exinglang@qq.com>
Reviewed-on: #208
Co-authored-by: zuochenyong <zuochenyong@wonderable.ai>
Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
2026-07-31 14:34:07 +08:00
guke eeecb5faf0 fix(广告发奖): eCPM 过低单份金币四舍五入成 0 时兜底发 1,避免看了广告被记 too_short 零发 (#205)
## 背景 / 问题

线上 record 4667:用户在比价等候期看满一条低 eCPM(¥0.43 CPM)的信息流广告(≥10s),
但单份金币按公式 `0.43/1000 × 0.1(档位因子) × 1.0(LT因子) × 10000 = 0.43`,四舍五入成 **0**。
在 `grant_feed_reward` 里 `unit_cap=0` → `coin<=0` → 记 `too_short` 零发。

结果:**用户看满了广告却什么都没拿到**,还被标成"时长不足"。

## 改动

`app/core/rewards.py` 的 `calculate_ad_reward_coin`(发奖与后台审计对账的**唯一口径**):

- 有真实正 eCPM 时,单份金币 floor 到 1(`max(0, …)` → `max(1, …)`)。
- eCPM 缺失 / 为 0 / 非法(没有真实广告价值)时提前 `return 0` —— 不凭空铸币、不破坏 `ecpm_missing` 语义。
关键设计
if ecpm_yuan <= 0: return 0 是防铸币防线:没它的话 reward_video 路径 ecpm="0" 会被 floor 成 1。
不影响防刷:上限仍由 AD_ECPM_MAX_FEN(¥500 CPM)钳顶;LT 因子最低 1.0、从不归零,限流靠每日 500 次上限。
正常量级 eCPM 本就 ≥1,下限不改变其取值(纯回归保护)。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #205
2026-07-31 11:18:00 +08:00
guke 31bff63ed4 feat(sms): 短信验证码 极光→创蓝 容灾 fallback (#204)
背景
短信验证码已是可切换 provider 架构(极光 / 创蓝)。极光(默认)一旦供应商侧故障(欠费 / 网络 / 服务异常),/sms/send 直接 503 → 用户收不到码、登录中断。本 PR 把极光设为主、创蓝设为备,在极光供应商不可用时自动转创蓝补发,并让后台可区分每次实际走的渠道。

方案(4 个关键决策)
# | 决策 | 结论
-- | -- | --
A | fallback 触发范围 | 仅主返回「供应商不可用」(SmsError.status_code == 503:网络 / 余额 / 服务故障)才转备。本地冷却 & 超频(429)、手机号无效(400)不转——不绕过防刷、不为无效号白烧
B | 校验路由 | try-both:极光转创蓝后码在创蓝内存,校验遍历「启用的 fallback 链」(主→备),任一命中即通过;关闭 fallback 时链中只有极光、创蓝零参与
C | 后台可见性 | 成功侧 EVENT_SMS_SEND.details 记 provider / fallback + 分派层日志,风控后台可按号/设备查本次走哪家、是否 fallback
D | 默认开关 | SMS_FALLBACK_PROVIDER 默认空=关(保持现状零风险),生产设 chuanglan 开启,置空即秒回退。仅 Mode B(jiguang/chuanglan)互为主备

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #204
2026-07-31 11:17:30 +08:00
guke ef0ab9d95a fix(短信): 号码无效提示由「手机号无效」改为「请输入有效的手机号」 (#206)
极光/阿里云/创蓝三处 provider 的号码无效错误统一改成更友好的用户提示。

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

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #206
2026-07-30 19:15:45 +08:00
zuochenyong 06cd718610 修复之前错误配置的端口 (#203)
Co-authored-by: exinglang <exinglang@qq.com>
Reviewed-on: #203
Co-authored-by: zuochenyong <zuochenyong@wonderable.ai>
Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
2026-07-30 16:32:32 +08:00
zuochenyong aa1a1240f2 fix(push): 完善厂商推送排障日志 (#202)
Co-authored-by: exinglang <exinglang@qq.com>
Reviewed-on: #202
Co-authored-by: zuochenyong <zuochenyong@wonderable.ai>
Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
2026-07-30 14:05:05 +08:00
guke b6ddb275f4 fix(comparison): 比价 best 无 is_best 时兜底回落「含源最低价」 (#201)
背景
_derive_from_platforms 是比价记录的单一真相源派生函数(pricebot done 帧带 platforms 时走它),正常路径直接取 platforms 里 is_best=true 的那行当最低价。

线上出现一类帧(如记录 id 3304):全平台 has_dish_diff(菜品「相似替换 / 价格仅供参考」),pricebot 认为无法认定权威最低价,于是一个 is_best 都不标。此时旧逻辑 best=None,导致 best_platform_id / best_price_cents / saved_amount_cents / is_source_best 整条落 NULL,连锁反应:

首页比价价显示 0.00
记录页无最低价红框
省额丢失、「累计发现可省」漏计
方案
无 is_best 时兜底:在有价行里取最低价当参考 best。

关键设计点 —— 候选池含源(而非仅目标):源平台常年全菜、价可信。若源本身最便宜(其余都是更贵的相似替换),则 best 回落到源、saved=0、is_source_best=True。这与仓库老派生函数 _derive 的既有语义(「全目标缺菜 → 回落源、不虚报省」)完全一致,三个派生函数行为对齐。

⚠️ 若像分支首个提交那样排除源、强选最低目标,当源最便宜时会选中更贵目标 → saved 变负,倒扣 get_stats 的「累计发现可省」(该聚合按 status='success' 求和、不带 >0 过滤)。第二个提交据此修正为含源。

影响面 / 兼容性
只影响「带 platforms 且无任何 is_best」这一条兜底分支;正常有 is_best 的路径不变。
老客户端不带 platforms → 走 _derive,不受影响。
纯派生逻辑,无 schema / 无迁移,回滚成本低。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #201
2026-07-30 11:00:35 +08:00
zuochenyong ebacf01742 feat(新手引导): 支持十圈进度与逐圈发奖 (#200)
Co-authored-by: exinglang <exinglang@qq.com>
Reviewed-on: #200
Co-authored-by: zuochenyong <zuochenyong@wonderable.ai>
Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
2026-07-30 10:49:24 +08:00
zuochenyong 89f266419b fix(push): 完善厂商推送参数与消息通知 (#199)
Co-authored-by: exinglang <exinglang@qq.com>
Reviewed-on: #199
Co-authored-by: zuochenyong <zuochenyong@wonderable.ai>
Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
2026-07-30 10:49:08 +08:00
zuochenyong 675c7ecf81 fix(push): 完善厂商推送排障日志 (#198)
Co-authored-by: exinglang <exinglang@qq.com>
Reviewed-on: #198
Co-authored-by: zuochenyong <zuochenyong@wonderable.ai>
Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
2026-07-30 10:41:43 +08:00
zuochenyong 53c3b7f60f feat(guide-video): 支持领券和比价独立视频奖励配置 (#196)
Co-authored-by: exinglang <exinglang@qq.com>
Reviewed-on: #196
Co-authored-by: zuochenyong <zuochenyong@wonderable.ai>
Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
2026-07-29 16:12:05 +08:00
marco 4bd4e66678 重构比价结果页取数据逻辑 (#195)
Reviewed-on: #195
2026-07-29 01:59:31 +08:00
linkeyu 90c6fe599a 修复:统一用户Draw信息流eCPM统计口径 (#190)
## 问题

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

## 修复

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

## 线上数据复算

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

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

## 验证

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

## 上线顺序

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

---------

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

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

## 修改

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

## 验证

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

---------

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

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

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

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #189
2026-07-28 14:04:36 +08:00
132 changed files with 14767 additions and 843 deletions
+19 -1
View File
@@ -45,6 +45,9 @@ SMS_SEND_INTERVAL_SEC=60
# chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。
# 用 YZM 前缀验证码账号;服务器出网 IP 需在创蓝控制台加白名单(否则 117)。见 docs/integrations/chuanglan/tpl-send.md。
SMS_PROVIDER=jiguang
# 极光→创蓝 容灾:主 provider(SMS_PROVIDER)供应商不可用(503)时转投的备。
# 空=关闭 fallback(默认);生产设 chuanglan 开启极光主、创蓝备;置空即秒回退。
SMS_FALLBACK_PROVIDER=
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
ALIYUN_SMS_SIGN_NAME=
@@ -69,8 +72,10 @@ CHUANGLAN_SMS_TIMEOUT_SEC=10
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
# (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
# 逻辑见 app/core/test_account.py,与其他业务解耦。
# ⚠️ 留空 = 关闭整功能(生产默认);要启用才填号(如 11111111111)。改完重启生效,随时可清空停用
# ⚠️ 两项都留空 = 关闭整功能(生产默认)。多账号用英文逗号分隔,改完重启生效
TEST_ACCOUNT_PHONE=
# 推荐新配置;例如联调环境填 11111111111,22222222222。
TEST_ACCOUNT_PHONES=
# 该测试号每日最多登录次数,当日超过即拒绝(429),次日归零。
TEST_ACCOUNT_DAILY_LIMIT=500
@@ -175,3 +180,16 @@ PANGLE_REPORT_SITE_ID_TEST=5832303
# APPLOG_MAX_BATCH=500 # 单批最大条数(超 → 422;导入期常量,改需重启)
# APPLOG_MAX_BODY_BYTES=2097152 # 请求体上限 2MB(超 → 413;运行期可调)
# APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超此字节数截断
# ===== 荣耀 Push =====
HONOR_PUSH_APP_ID=
HONOR_PUSH_CLIENT_ID=
HONOR_PUSH_CLIENT_SECRET=
# 0=正式消息(默认);1=测试消息(仅开发联调,生产必须保持 0)
HONOR_PUSH_TARGET_USER_TYPE=0
# ===== 华为 Push =====
HUAWEI_PUSH_APP_ID=
HUAWEI_PUSH_APP_SECRET=
# 0=正式消息(默认);1=测试消息(仅开发联调,生产必须保持 0)
HUAWEI_PUSH_TARGET_USER_TYPE=0
@@ -0,0 +1,26 @@
"""merge comparison platforms + fail_reason heads
Revision ID: 6d2309208549
Revises: comparison_platforms_col, comparison_record_fail_reason
Create Date: 2026-07-29 01:48:41.868083
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6d2309208549'
down_revision: Union[str, Sequence[str], None] = ('comparison_platforms_col', 'comparison_record_fail_reason')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -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)
)
@@ -0,0 +1,47 @@
"""add platforms unified array column to comparison_record
展示模型统一数组(pricebot done.params.platforms 原样存): 每平台一行、自带
status/is_best/display, 记录页据此直接渲染, 不再靠 comparison_results + 客户端合并 + 前端派生。
纯新增列, 老记录为空 → 前端回退老 comparison_results。
Revision ID: comparison_platforms_col
Revises: user_manual_risk_fields
"""
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_platforms_col"
down_revision: str | None = "user_manual_risk_fields"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
def upgrade() -> None:
# 幂等: 线上为了提前给历史数据补 platforms(2026-07-29), 已手动
# `ALTER TABLE comparison_record ADD COLUMN IF NOT EXISTS platforms jsonb
# NOT NULL DEFAULT '[]'::jsonb`(与本 migration 定义一致)。列已存在时跳过,
# 否则上线 alembic upgrade head 会撞 DuplicateColumn 直接部署失败。
bind = op.get_bind()
cols = {c["name"] for c in sa.inspect(bind).get_columns("comparison_record")}
if "platforms" in cols:
return
with op.batch_alter_table("comparison_record") as batch_op:
batch_op.add_column(
sa.Column(
"platforms", _JSON, nullable=False,
server_default=sa.text("'[]'"),
)
)
def downgrade() -> None:
with op.batch_alter_table("comparison_record") as batch_op:
batch_op.drop_column("platforms")
@@ -0,0 +1,49 @@
"""comparison_record.fail_reason (失败卡展示原因)
Revision ID: comparison_record_fail_reason
Revises: user_manual_risk_fields
Create Date: 2026-07-28 12:00:00.000000
失败记录的展示原因:information 具体则=它;笼统则由写路径从 platform_results 捞出的
业务原因;纯系统失败为 None(端侧品牌兜底)。见 repositories.comparison._derive_fail_display。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'comparison_record_fail_reason'
down_revision: Union[str, Sequence[str], None] = 'user_manual_risk_fields'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.add_column(sa.Column('fail_reason', sa.String(length=256), nullable=True))
# 回填老失败记录:information 具体的直接搬过来(笼统/系统失败留 None → 端侧品牌兜底)。
# 新记录由写路径 _derive_fail_display 落库(含 platform_results 救援/补判),不走这条。
# platform_results 只在 raw_payload 里,SQL 里不易解析,故老记录不做救援/补判(可接受:
# 老 mixed/打烊记录回退品牌兜底);具体 information 的老记录本次即可显示真实原因。
op.execute(
"""
UPDATE comparison_record
SET fail_reason = information
WHERE status = 'failed'
AND information IS NOT NULL
AND information <> ''
AND information NOT IN (
'比价过程出错,请稍后重试',
'比价出错',
'比价未完成',
'done 参数缺少可验证的目标平台结果'
)
"""
)
def downgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.drop_column('fail_reason')
+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,31 @@
"""guide video play count is independent for coupon and comparison
Revision ID: guide_video_scene_unique
Revises: 6d2309208549
"""
from alembic import op
revision = "guide_video_scene_unique"
down_revision = "6d2309208549"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_index("uq_guide_video_play_user_seq", table_name="guide_video_play")
op.create_index(
"uq_guide_video_play_user_scene_seq",
"guide_video_play",
["user_id", "scene", "seq"],
unique=True,
)
def downgrade() -> None:
op.drop_index("uq_guide_video_play_user_scene_seq", table_name="guide_video_play")
op.create_index(
"uq_guide_video_play_user_seq",
"guide_video_play",
["user_id", "seq"],
unique=True,
)
@@ -0,0 +1,164 @@
"""guide video prepare/start/ten-circle reward state machine
Revision ID: guide_video_ten_circle_v2
Revises: guide_video_scene_unique
"""
from __future__ import annotations
import json
from pathlib import Path
import sqlalchemy as sa
from alembic import op
revision = "guide_video_ten_circle_v2"
down_revision = "guide_video_scene_unique"
branch_labels = None
depends_on = None
def _backfill_configs(connection) -> None:
from app.core import media
from app.core.config import settings
for key in ("coupon_guide_video", "comparison_guide_video"):
row = connection.execute(
sa.text("SELECT value FROM app_config WHERE key = :key"), {"key": key}
).first()
if not row:
continue
raw = row[0]
value = json.loads(raw) if isinstance(raw, str) else dict(raw or {})
if value.get("guide_video_v2_migrated") is True:
continue
maximum = int(value.get("max_plays", 3) or 0)
reward = int(value.get("reward_coin", 100) or 0)
video_url = str(value.get("video_url") or "")
error = None
analysis = None
if not 1 <= maximum <= 50:
error = "旧播放次数不符合 150 约束"
elif not 10 <= reward <= 10_000 or reward % 10:
error = "旧金币总价不符合 10~10000 且为 10 的倍数约束"
elif not video_url:
error = "尚未上传视频"
else:
prefix = f"{settings.MEDIA_URL_PREFIX}/guide_video/"
if not video_url.startswith(prefix):
error = "旧视频不是服务端托管文件,请重新上传"
else:
path = Path(settings.MEDIA_ROOT) / "guide_video" / video_url[len(prefix):]
if not path.is_file():
error = "旧视频文件缺失,请重新上传"
else:
try:
analysis = media._probe_guide_video(path)
except media.MediaError as exc:
error = str(exc)
value["config_version"] = int(value.get("config_version", 0) or 0) + 1
value["guide_video_v2_migrated"] = True
if analysis:
value.update(analysis)
else:
value.update(
enabled=False,
duration_ms=None,
video_codec=None,
audio_codec=None,
analysis_status="invalid" if video_url else "missing",
analysis_error=error,
)
statement = sa.text(
"UPDATE app_config SET value = :value WHERE key = :key"
).bindparams(sa.bindparam("value", type_=sa.JSON()))
connection.execute(statement, {"key": key, "value": value})
def _seal_legacy_plays(connection) -> None:
"""旧整笔发奖 token 永久封口;旧未发奖会话终止但仍保留计次行。"""
connection.execute(
sa.text(
"""
UPDATE guide_video_play
SET status = CASE WHEN status = 'granted' THEN 'legacy_completed' ELSE 'legacy_closed' END,
settled_circles = CASE WHEN status = 'granted' THEN 10 ELSE 0 END,
prepared_at = started_at,
expires_at = started_at
WHERE status IN ('granted', 'playing')
"""
)
)
def _prepare_downgrade(connection) -> None:
"""清除未起播计划,并把所有保留 token 封成旧版不可再领奖的 granted。"""
connection.execute(
sa.text("DELETE FROM guide_video_play WHERE status = 'prepared'")
)
connection.execute(
sa.text(
"""
UPDATE guide_video_play
SET status = 'granted',
completed = CASE WHEN settled_circles >= 10 THEN 1 ELSE completed END,
granted_at = CASE
WHEN settled_circles >= 10 AND granted_at IS NULL THEN started_at
ELSE granted_at
END
WHERE status IN (
'started', 'completed', 'legacy_completed', 'legacy_closed'
)
"""
)
)
# 防御未知/中间状态:宁可删除,也绝不让旧应用把它当 playing 整笔发奖。
connection.execute(
sa.text(
"""
DELETE FROM guide_video_play
WHERE seq IS NULL OR started_at IS NULL OR status != 'granted'
"""
)
)
def upgrade() -> None:
with op.batch_alter_table("guide_video_play") as batch:
batch.alter_column("seq", existing_type=sa.Integer(), nullable=True)
batch.alter_column("started_at", existing_type=sa.DateTime(timezone=True), nullable=True)
batch.add_column(sa.Column("duration_ms", sa.Integer(), nullable=False, server_default="0"))
batch.add_column(sa.Column("config_version", sa.Integer(), nullable=False, server_default="0"))
batch.add_column(sa.Column("settled_circles", sa.Integer(), nullable=False, server_default="0"))
batch.add_column(
sa.Column("prepared_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now())
)
batch.add_column(sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True))
batch.alter_column("status", existing_type=sa.String(length=16), type_=sa.String(length=24))
op.create_index("ix_guide_video_play_expires_at", "guide_video_play", ["expires_at"])
op.create_index(
"ux_coin_transaction_guide_video_ref",
"coin_transaction",
["user_id", "biz_type", "ref_id"],
unique=True,
sqlite_where=sa.text("biz_type = 'guide_video' AND ref_id IS NOT NULL"),
postgresql_where=sa.text("biz_type = 'guide_video' AND ref_id IS NOT NULL"),
)
connection = op.get_bind()
_seal_legacy_plays(connection)
_backfill_configs(connection)
def downgrade() -> None:
_prepare_downgrade(op.get_bind())
op.drop_index("ux_coin_transaction_guide_video_ref", table_name="coin_transaction")
op.drop_index("ix_guide_video_play_expires_at", table_name="guide_video_play")
with op.batch_alter_table("guide_video_play") as batch:
batch.alter_column("status", existing_type=sa.String(length=24), type_=sa.String(length=16))
batch.drop_column("expires_at")
batch.drop_column("prepared_at")
batch.drop_column("settled_circles")
batch.drop_column("config_version")
batch.drop_column("duration_ms")
batch.alter_column("started_at", existing_type=sa.DateTime(timezone=True), nullable=False)
batch.alter_column("seq", existing_type=sa.Integer(), nullable=False)
@@ -0,0 +1,193 @@
"""store all global limit values in one complete JSON document
Revision ID: limit_policy_global_bundle
Revises: limit_policy_whitelist
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "limit_policy_global_bundle"
down_revision: str | Sequence[str] | None = "limit_policy_whitelist"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
_BUNDLE_KEY = "limit_policy_global"
# rule_code, old sparse key, default, legacy structured key, legacy JSON field
_RULES: tuple[tuple[str, str, int, str | None, str | None], ...] = (
("compare.start.daily", "compare_daily_limit", 100, None, None),
("sms.send.hourly", "sms_send_hourly_limit", 5, None, None),
("sms.send.daily", "sms_send_daily_limit", 20, None, None),
("sms.phone.cooldown", "sms_phone_cooldown_seconds", 60, None, None),
("sms.code.failed_attempts", "sms_code_max_failed_attempts", 5, None, None),
("sms.login.hourly", "sms_login_hourly_limit", 5, None, None),
("wechat.bind.hourly", "wechat_bind_sms_hourly_limit", 5, None, None),
("wechat.conflict.hourly", "wechat_conflict_hourly_limit", 5, None, None),
(
"ad.reward_video.daily",
"ad_reward_video_daily_limit",
500,
"ad_daily_limit",
None,
),
(
"ad.feed.daily",
"ad_feed_daily_limit",
500,
"ad_daily_limit",
None,
),
("ad.reward_video.cooldown", "ad_cooldown_sec", 3, None, None),
(
"guide.video.lifetime",
"guide_video_max_plays",
3,
"coupon_guide_video",
"max_plays",
),
("phone.rebind.days", "phone_rebind_days", 30, None, None),
("risk.sms.hourly", "risk_sms_hourly_threshold", 5, None, None),
(
"risk.oneclick.daily",
"risk_oneclick_daily_threshold",
20,
None,
None,
),
(
"risk.compare.daily",
"risk_compare_daily_threshold",
100,
None,
None,
),
)
def _table() -> sa.TableClause:
return sa.table(
"app_config",
sa.column("key", sa.String(64)),
sa.column("value", _JSON),
sa.column("updated_by_admin_id", sa.Integer),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
def _row(conn, table, key: str):
return conn.execute(
sa.select(
table.c.value,
table.c.updated_by_admin_id,
).where(table.c.key == key)
).mappings().first()
def _int_or_none(value: Any) -> int | None:
if isinstance(value, bool):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def upgrade() -> None:
conn = op.get_bind()
table = _table()
values = {rule_code: default for rule_code, _, default, _, _ in _RULES}
# Old shared/structured values are the lowest-precedence compatibility
# source. Dedicated sparse keys override them.
for rule_code, _, _, legacy_key, legacy_field in _RULES:
if legacy_key is None:
continue
legacy = _row(conn, table, legacy_key)
if legacy is None:
continue
raw = legacy["value"]
if legacy_field is not None:
raw = raw.get(legacy_field) if isinstance(raw, dict) else None
parsed = _int_or_none(raw)
if parsed is not None:
values[rule_code] = parsed
for rule_code, sparse_key, _, _, _ in _RULES:
sparse = _row(conn, table, sparse_key)
parsed = _int_or_none(sparse["value"]) if sparse is not None else None
if parsed is not None:
values[rule_code] = parsed
# If a deployment already wrote the new key, preserve it over old keys.
bundle = _row(conn, table, _BUNDLE_KEY)
if bundle is not None and isinstance(bundle["value"], dict):
for rule_code, raw in bundle["value"].items():
if rule_code not in values:
continue
parsed = _int_or_none(raw)
if parsed is not None:
values[rule_code] = parsed
if bundle is None:
conn.execute(
table.insert().values(
key=_BUNDLE_KEY,
value=values,
updated_by_admin_id=None,
)
)
else:
conn.execute(
table.update()
.where(table.c.key == _BUNDLE_KEY)
.values(value=values, updated_at=sa.func.now())
)
sparse_keys = [sparse_key for _, sparse_key, _, _, _ in _RULES]
conn.execute(table.delete().where(table.c.key.in_(sparse_keys)))
def downgrade() -> None:
conn = op.get_bind()
table = _table()
bundle = _row(conn, table, _BUNDLE_KEY)
values = (
bundle["value"]
if bundle is not None and isinstance(bundle["value"], dict)
else {}
)
admin_id = bundle["updated_by_admin_id"] if bundle is not None else None
for rule_code, sparse_key, default, _, _ in _RULES:
value = _int_or_none(values.get(rule_code))
if value is None:
value = default
existing = _row(conn, table, sparse_key)
if existing is None:
conn.execute(
table.insert().values(
key=sparse_key,
value=value,
updated_by_admin_id=admin_id,
)
)
else:
conn.execute(
table.update()
.where(table.c.key == sparse_key)
.values(
value=value,
updated_by_admin_id=admin_id,
updated_at=sa.func.now(),
)
)
conn.execute(table.delete().where(table.c.key == _BUNDLE_KEY))
+128
View File
@@ -0,0 +1,128 @@
"""add per-subject limit policy whitelist
Revision ID: limit_policy_whitelist
Revises: push_binding_isolation
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "limit_policy_whitelist"
down_revision: str | Sequence[str] | None = "push_binding_isolation"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
_PAGE = "limit-whitelist"
_DEFAULT_ROLES = ("operator", "tech")
def _role_table() -> sa.TableClause:
return sa.table(
"admin_role",
sa.column("name", sa.String),
sa.column("pages", _JSON),
)
def _add_default_role_permissions() -> None:
"""Grant the page without replacing existing role customisations."""
role = _role_table()
conn = op.get_bind()
rows = conn.execute(
sa.select(role.c.name, role.c.pages).where(
role.c.name.in_(_DEFAULT_ROLES)
)
).all()
for name, pages in rows:
current_pages = list(pages or [])
if _PAGE not in current_pages:
conn.execute(
role.update()
.where(role.c.name == name)
.values(pages=[*current_pages, _PAGE])
)
def _remove_default_role_permissions() -> None:
role = _role_table()
conn = op.get_bind()
rows = conn.execute(
sa.select(role.c.name, role.c.pages).where(
role.c.name.in_(_DEFAULT_ROLES)
)
).all()
for name, pages in rows:
current_pages = list(pages or [])
if _PAGE in current_pages:
conn.execute(
role.update()
.where(role.c.name == name)
.values(
pages=[page for page in current_pages if page != _PAGE]
)
)
def upgrade() -> None:
op.create_table(
"limit_policy_override",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("subject_type", sa.String(length=16), nullable=False),
sa.Column("subject_value", sa.String(length=128), nullable=False),
sa.Column("rule_code", sa.String(length=64), nullable=False),
sa.Column("mode", sa.String(length=24), nullable=False),
sa.Column("limit_value", sa.Integer(), nullable=True),
sa.Column(
"enabled", sa.Boolean(), server_default=sa.true(), nullable=False
),
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("reset_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("reason", sa.String(length=256), nullable=True),
sa.Column("created_by_admin_id", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"subject_type",
"subject_value",
"rule_code",
name="uq_limit_policy_subject_rule",
),
)
op.create_index(
"ix_limit_policy_lookup",
"limit_policy_override",
["subject_type", "subject_value", "rule_code", "enabled"],
unique=False,
)
op.create_index(
"ix_limit_policy_expires",
"limit_policy_override",
["expires_at"],
unique=False,
)
_add_default_role_permissions()
def downgrade() -> None:
_remove_default_role_permissions()
op.drop_index("ix_limit_policy_expires", table_name="limit_policy_override")
op.drop_index("ix_limit_policy_lookup", table_name="limit_policy_override")
op.drop_table("limit_policy_override")
@@ -0,0 +1,89 @@
"""isolate vendor push binding across user accounts
Revision ID: push_binding_isolation
Revises: guide_video_ten_circle_v2
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "push_binding_isolation"
down_revision = "guide_video_ten_circle_v2"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("device_liveness") as batch_op:
batch_op.add_column(sa.Column("push_binding_id", sa.String(length=128), nullable=True))
batch_op.add_column(
sa.Column(
"push_binding_revoked",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
batch_op.create_index(
"ix_device_liveness_push_binding_id",
["push_binding_id"],
unique=False,
)
connection = op.get_bind()
device = sa.table(
"device_liveness",
sa.column("id", sa.Integer),
sa.column("push_vendor", sa.String),
sa.column("push_token", sa.String),
sa.column("updated_at", sa.DateTime),
)
connection.execute(
device.update()
.where(
sa.or_(
device.c.push_vendor == "",
device.c.push_token == "",
)
)
.values(push_vendor=None, push_token=None)
)
rows = connection.execute(
sa.select(device.c.id, device.c.push_vendor, device.c.push_token)
.where(
device.c.push_vendor.is_not(None),
device.c.push_token.is_not(None),
)
.order_by(device.c.updated_at.desc(), device.c.id.desc())
)
seen: set[tuple[str, str]] = set()
duplicate_ids: list[int] = []
for row in rows:
key = (row.push_vendor, row.push_token)
if key in seen:
duplicate_ids.append(row.id)
else:
seen.add(key)
if duplicate_ids:
connection.execute(
device.update()
.where(device.c.id.in_(duplicate_ids))
.values(push_vendor=None, push_token=None)
)
with op.batch_alter_table("device_liveness") as batch_op:
batch_op.create_unique_constraint(
"uq_device_liveness_vendor_token",
["push_vendor", "push_token"],
)
def downgrade() -> None:
with op.batch_alter_table("device_liveness") as batch_op:
batch_op.drop_constraint("uq_device_liveness_vendor_token", type_="unique")
batch_op.drop_index("ix_device_liveness_push_binding_id")
batch_op.drop_column("push_binding_revoked")
batch_op.drop_column("push_binding_id")
+3 -1
View File
@@ -1,6 +1,6 @@
"""Admin 后台 FastAPI app(独立进程)。
启动:uvicorn app.admin.main:admin_app --host 127.0.0.1 --port 8771
启动:uvicorn app.admin.main:admin_app --host 127.0.0.1 --port 8773
复用 App 的 DB/models/repositories/integrations;鉴权独立(admin JWT,见 app/admin/security.py)。
现有 app.main:app 不 import 本模块,两进程互不影响。
"""
@@ -31,6 +31,7 @@ from app.admin.routers.feedback import router as feedback_router
from app.admin.routers.feedback_qr import router as feedback_qr_router
from app.admin.routers.guide_video import router as guide_video_router
from app.admin.routers.huawei_review import router as huawei_review_router
from app.admin.routers.limit_whitelist import router as limit_whitelist_router
from app.admin.routers.onboarding import router as onboarding_router
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
@@ -103,6 +104,7 @@ admin_app.include_router(wallet_router)
admin_app.include_router(withdraw_router)
admin_app.include_router(price_report_router)
admin_app.include_router(risk_monitor_router)
admin_app.include_router(limit_whitelist_router)
admin_app.include_router(feedback_router)
admin_app.include_router(event_logs_router)
admin_app.include_router(analytics_health_router)
+3 -1
View File
@@ -40,6 +40,7 @@ PERMISSION_CATALOG: list[dict] = [
{"key": "analytics-health", "label": "埋点成功率"},
{"key": "event-logs", "label": "埋点日志"},
{"key": "audit-logs", "label": "审计日志"},
{"key": "limit-whitelist", "label": "白名单"},
]},
{"group": "其他", "pages": [
{"key": "admins", "label": "权限管理"},
@@ -58,13 +59,14 @@ BUILTIN_ROLES: list[dict] = [
{"name": "operator", "label": "运营", "pages": [
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
"cps", "risk-monitor", "device-liveness", "price-reports", "feedbacks", "huawei-review",
"limit-whitelist",
]},
{"name": "finance", "label": "财务", "pages": [
"dashboard", "ad-revenue-report", "cps", "invite-withdraws", "withdraws",
]},
{"name": "tech", "label": "技术", "pages": [
"dashboard", "risk-monitor", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review",
"event-logs", "audit-logs",
"event-logs", "audit-logs", "limit-whitelist",
]},
]
+5 -5
View File
@@ -43,7 +43,7 @@ _KNOWN_PROD_BUSINESS_CODE_IDS = frozenset({"104098712", "104099389"})
_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"})
def _business_code_ids(db: Session, app_env: str | None) -> set[str]:
def business_code_ids(db: Session, app_env: str | None) -> set[str]:
"""返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。"""
prod_config = app_config.get_ad_config(db)
prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | {
@@ -320,10 +320,10 @@ def ad_revenue_report(
# 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试
# demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。
business_code_ids: set[str] | None = None
business_ids: set[str] | None = None
if revenue_scope == "business":
business_code_ids = _business_code_ids(db, app_env)
events = [e for e in events if e.get("our_code_id") in business_code_ids]
business_ids = business_code_ids(db, app_env)
events = [e for e in events if e.get("our_code_id") in business_ids]
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
@@ -381,7 +381,7 @@ def ad_revenue_report(
date_from=date_from,
date_to=date_to,
app_env=app_env,
our_code_ids=business_code_ids,
our_code_ids=business_ids,
)
if pangle_aggs:
by_date = {a["date"]: a for a in pangle_aggs}
@@ -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
],
+583
View File
@@ -0,0 +1,583 @@
"""CRUD and presentation helpers for limit policy overrides."""
from __future__ import annotations
from datetime import UTC, datetime
from hashlib import blake2b
from sqlalchemy import String, cast, func, or_, select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import limit_policy
from app.models.app_config import AppConfig
from app.models.comparison import ComparisonRecord
from app.models.limit_policy import LimitPolicyOverride
from app.models.risk import BehaviorEvent
from app.models.user import User
from app.repositories import risk as risk_repo
class DuplicateOverrideError(Exception):
pass
def lock_subject(db: Session, *, subject_type: str, subject_value: str) -> None:
"""Serialize writes for one logical whitelist subject on PostgreSQL.
Row locks cannot protect a brand-new subject because there is no row to
lock yet. A transaction-scoped advisory lock closes that gap and also
serializes concurrent appends that touch different rule rows.
"""
bind = db.get_bind()
if bind.dialect.name != "postgresql":
return
identity = f"limit-whitelist:{subject_type}:{subject_value}".encode()
lock_id = int.from_bytes(
blake2b(identity, digest_size=8).digest(),
byteorder="big",
signed=True,
)
db.execute(
text("SELECT pg_advisory_xact_lock(:lock_id)"),
{"lock_id": lock_id},
)
_EVENT_SOURCE_LABELS = {
risk_repo.EVENT_SMS_SEND: "短信验证码",
risk_repo.EVENT_SMS_LOGIN: "短信登录",
risk_repo.EVENT_ONECLICK_LOGIN: "一键登录",
}
def _matching_users(keyword: str):
pattern = f"%{keyword}%"
return select(User.id).where(
or_(
cast(User.id, String).ilike(pattern),
User.username.ilike(pattern),
User.phone.ilike(pattern),
User.nickname.ilike(pattern),
)
)
def _user_maps(
db: Session, *, user_ids: set[int], phones: set[str]
) -> tuple[dict[int, User], dict[str, User]]:
conditions = []
if user_ids:
conditions.append(User.id.in_(user_ids))
if phones:
conditions.append(User.phone.in_(phones))
if not conditions:
return {}, {}
users = list(db.scalars(select(User).where(or_(*conditions))).all())
return {user.id: user for user in users}, {user.phone: user for user in users}
def _behavior_device_candidates(db: Session, *, keyword: str | None, limit: int) -> list[dict]:
conditions = [
BehaviorEvent.subject_type == limit_policy.SUBJECT_DEVICE,
BehaviorEvent.subject_id != "",
BehaviorEvent.subject_id.not_like(f"{limit_policy.LEGACY_IP_DEVICE_PREFIX}%"),
]
if keyword:
value = keyword.strip()
pattern = f"%{value}%"
matched_users = _matching_users(value)
conditions.append(
or_(
BehaviorEvent.subject_id.ilike(pattern),
BehaviorEvent.device_id.ilike(pattern),
BehaviorEvent.device_model.ilike(pattern),
BehaviorEvent.phone.ilike(pattern),
cast(BehaviorEvent.user_id, String).ilike(pattern),
BehaviorEvent.user_id.in_(matched_users),
BehaviorEvent.phone.in_(
select(User.phone).where(User.id.in_(_matching_users(value)))
),
)
)
ranked = (
select(
BehaviorEvent.subject_id.label("device_id"),
BehaviorEvent.event_type,
BehaviorEvent.user_id,
BehaviorEvent.phone,
BehaviorEvent.device_model,
BehaviorEvent.occurred_at.label("last_active_at"),
func.row_number()
.over(
partition_by=BehaviorEvent.subject_id,
order_by=(
BehaviorEvent.occurred_at.desc(),
BehaviorEvent.id.desc(),
),
)
.label("rank"),
)
.where(*conditions)
.subquery()
)
rows = db.execute(
select(ranked)
.where(ranked.c.rank == 1)
.order_by(ranked.c.last_active_at.desc(), ranked.c.device_id.asc())
.limit(limit)
).all()
by_id, by_phone = _user_maps(
db,
user_ids={row.user_id for row in rows if row.user_id is not None},
phones={row.phone for row in rows if row.phone},
)
result = []
for row in rows:
user = by_id.get(row.user_id) if row.user_id is not None else None
user = user or (by_phone.get(row.phone) if row.phone else None)
result.append(
{
"device_id": row.device_id,
"source": row.event_type,
"source_label": _EVENT_SOURCE_LABELS.get(row.event_type, "登录/鉴权流水"),
"user_id": user.id if user else row.user_id,
"username": user.username if user else None,
"phone": user.phone if user else row.phone,
"nickname": user.nickname if user else None,
"device_model": row.device_model,
"last_active_at": row.last_active_at,
}
)
return result
def _comparison_device_candidates(db: Session, *, keyword: str | None, limit: int) -> list[dict]:
conditions = [
ComparisonRecord.device_id.is_not(None),
ComparisonRecord.device_id != "",
]
if keyword:
value = keyword.strip()
pattern = f"%{value}%"
conditions.append(
or_(
ComparisonRecord.device_id.ilike(pattern),
ComparisonRecord.device_model.ilike(pattern),
cast(ComparisonRecord.user_id, String).ilike(pattern),
ComparisonRecord.user_id.in_(_matching_users(value)),
)
)
ranked = (
select(
ComparisonRecord.device_id,
ComparisonRecord.user_id,
ComparisonRecord.device_model,
ComparisonRecord.created_at.label("last_active_at"),
func.row_number()
.over(
partition_by=ComparisonRecord.device_id,
order_by=(
ComparisonRecord.created_at.desc(),
ComparisonRecord.id.desc(),
),
)
.label("rank"),
)
.where(*conditions)
.subquery()
)
rows = db.execute(
select(ranked)
.where(ranked.c.rank == 1)
.order_by(ranked.c.last_active_at.desc(), ranked.c.device_id.asc())
.limit(limit)
).all()
by_id, _ = _user_maps(
db,
user_ids={row.user_id for row in rows if row.user_id is not None},
phones=set(),
)
return [
{
"device_id": row.device_id,
"source": "comparison_record",
"source_label": "比价记录",
"user_id": user.id if user else row.user_id,
"username": user.username if user else None,
"phone": user.phone if user else None,
"nickname": user.nickname if user else None,
"device_model": row.device_model,
"last_active_at": row.last_active_at,
}
for row in rows
for user in [by_id.get(row.user_id) if row.user_id is not None else None]
]
def list_device_candidates(
db: Session,
*,
rule_code: str,
keyword: str | None = None,
limit: int = 30,
) -> list[dict]:
rule = limit_policy.get_rule(rule_code)
if limit_policy.SUBJECT_DEVICE not in rule.subject_types:
raise ValueError("当前限制项不支持设备白名单")
if limit_policy.device_source_scope(rule_code) == "comparison":
return _comparison_device_candidates(db, keyword=keyword, limit=limit)
return _behavior_device_candidates(db, keyword=keyword, limit=limit)
def _aware(value: datetime | None) -> datetime | None:
if value is None:
return None
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
def status_of(row: LimitPolicyOverride, *, now: datetime | None = None) -> str:
now = _aware(now) or datetime.now(UTC)
if not row.enabled:
return "disabled"
if _aware(row.starts_at) and _aware(row.starts_at) > now:
return "scheduled"
if _aware(row.expires_at) and _aware(row.expires_at) <= now:
return "expired"
return "active"
def to_dict(db: Session, row: LimitPolicyOverride) -> dict:
rule = limit_policy.get_rule(row.rule_code)
effective = limit_policy.resolve(
db,
row.rule_code,
phone=row.subject_value if row.subject_type == "phone" else None,
device=row.subject_value if row.subject_type == "device" else None,
)
return {
"id": row.id,
"subject_type": row.subject_type,
"subject_value": row.subject_value,
"rule_code": row.rule_code,
"rule_label": rule.label,
"rule_group": rule.group,
"mode": row.mode,
"limit_value": row.limit_value,
"global_limit": effective.global_limit,
"effective_limit": effective.limit,
"enabled": row.enabled,
"starts_at": row.starts_at,
"expires_at": row.expires_at,
"reset_at": row.reset_at,
"reason": row.reason,
"status": status_of(row),
"created_by_admin_id": row.created_by_admin_id,
"created_at": row.created_at,
"updated_at": row.updated_at,
}
def list_rows(
db: Session,
*,
subject_type: str | None = None,
keyword: str | None = None,
rule_code: str | None = None,
offset: int = 0,
limit: int = 100,
) -> tuple[list[LimitPolicyOverride], int]:
stmt = select(LimitPolicyOverride)
count_stmt = select(func.count(LimitPolicyOverride.id))
conditions = []
if subject_type:
conditions.append(LimitPolicyOverride.subject_type == subject_type)
if keyword:
conditions.append(LimitPolicyOverride.subject_value.ilike(f"%{keyword.strip()}%"))
if rule_code:
conditions.append(LimitPolicyOverride.rule_code == rule_code)
if conditions:
stmt = stmt.where(*conditions)
count_stmt = count_stmt.where(*conditions)
total = int(db.scalar(count_stmt) or 0)
rows = list(
db.execute(
stmt.order_by(
LimitPolicyOverride.created_at.desc(),
LimitPolicyOverride.id.desc(),
)
.offset(offset)
.limit(limit)
).scalars()
)
return rows, total
def list_subject_rows(
db: Session,
*,
subject_type: str | None = None,
keyword: str | None = None,
rule_code: str | None = None,
offset: int = 0,
limit: int = 10,
) -> tuple[list[tuple[str, str, list[LimitPolicyOverride], datetime]], int]:
"""List whitelist entries grouped and paginated by subject.
The filter decides which subjects match. Once a subject matches, all of
its rules are returned so category counts and the edit form stay complete.
Ordering uses the original creation time, therefore editing a subject does
not unexpectedly move it to the first page.
"""
conditions = [
LimitPolicyOverride.mode.in_(
[limit_policy.MODE_UNLIMITED, limit_policy.MODE_SUPPRESS_ALERT]
)
]
if subject_type:
conditions.append(LimitPolicyOverride.subject_type == subject_type)
if keyword:
conditions.append(LimitPolicyOverride.subject_value.ilike(f"%{keyword.strip()}%"))
if rule_code:
conditions.append(LimitPolicyOverride.rule_code == rule_code)
matched_subjects = (
select(
LimitPolicyOverride.subject_type.label("subject_type"),
LimitPolicyOverride.subject_value.label("subject_value"),
func.min(LimitPolicyOverride.created_at).label("subject_created_at"),
)
.where(*conditions)
.group_by(
LimitPolicyOverride.subject_type,
LimitPolicyOverride.subject_value,
)
.subquery()
)
total = int(db.scalar(select(func.count()).select_from(matched_subjects)) or 0)
subjects = list(
db.execute(
select(
matched_subjects.c.subject_type,
matched_subjects.c.subject_value,
matched_subjects.c.subject_created_at,
)
.order_by(
matched_subjects.c.subject_created_at.desc(),
matched_subjects.c.subject_type,
matched_subjects.c.subject_value,
)
.offset(offset)
.limit(limit)
).all()
)
if not subjects:
return [], total
pair_conditions = [
(
(LimitPolicyOverride.subject_type == item.subject_type)
& (LimitPolicyOverride.subject_value == item.subject_value)
)
for item in subjects
]
rows = list(
db.scalars(
select(LimitPolicyOverride)
.where(
or_(*pair_conditions),
LimitPolicyOverride.mode.in_(
[
limit_policy.MODE_UNLIMITED,
limit_policy.MODE_SUPPRESS_ALERT,
]
),
)
.order_by(
LimitPolicyOverride.created_at.desc(),
LimitPolicyOverride.id.desc(),
)
).all()
)
rows_by_subject: dict[tuple[str, str], list[LimitPolicyOverride]] = {}
for row in rows:
rows_by_subject.setdefault((row.subject_type, row.subject_value), []).append(row)
return [
(
item.subject_type,
item.subject_value,
rows_by_subject.get((item.subject_type, item.subject_value), []),
item.subject_created_at,
)
for item in subjects
], total
def rows_for_subject(
db: Session, *, subject_type: str, subject_value: str
) -> list[LimitPolicyOverride]:
return list(
db.scalars(
select(LimitPolicyOverride)
.where(
LimitPolicyOverride.subject_type == subject_type,
LimitPolicyOverride.subject_value == subject_value,
)
.order_by(
LimitPolicyOverride.created_at.desc(),
LimitPolicyOverride.id.desc(),
)
).all()
)
def create(
db: Session,
*,
subject_type: str,
subject_value: str,
rule_code: str,
mode: str,
limit_value: int | None,
enabled: bool,
starts_at: datetime | None,
expires_at: datetime | None,
reason: str,
admin_id: int,
reactivate_inactive: bool = False,
) -> LimitPolicyOverride:
rule = limit_policy.get_rule(rule_code)
subject_value = limit_policy.validate_whitelist_subject(subject_type, subject_value)
limit_policy.validate_override(
rule,
subject_type=subject_type,
mode=mode,
limit_value=limit_value,
starts_at=starts_at,
expires_at=expires_at,
)
existing = db.execute(
select(LimitPolicyOverride).where(
LimitPolicyOverride.subject_type == subject_type,
LimitPolicyOverride.subject_value == subject_value,
LimitPolicyOverride.rule_code == rule_code,
)
).scalar_one_or_none()
if existing is not None:
if not reactivate_inactive or status_of(existing) not in {"disabled", "expired"}:
raise DuplicateOverrideError
# “恢复全局”与自然过期都会保留原行供审计。再次加入白名单时
# 复用该行,避免唯一键让批量新增永久失败;完整变更仍写 admin 审计日志。
existing.mode = mode
existing.limit_value = limit_value if mode == limit_policy.MODE_OVERRIDE else None
existing.enabled = enabled
existing.starts_at = starts_at
existing.expires_at = expires_at
existing.reset_at = None
existing.reason = reason.strip() or None
db.flush()
return existing
row = LimitPolicyOverride(
subject_type=subject_type,
subject_value=subject_value,
rule_code=rule_code,
mode=mode,
limit_value=limit_value if mode == limit_policy.MODE_OVERRIDE else None,
enabled=enabled,
starts_at=starts_at,
expires_at=expires_at,
reason=reason.strip() or None,
created_by_admin_id=admin_id,
)
db.add(row)
try:
db.flush()
except IntegrityError as exc:
db.rollback()
raise DuplicateOverrideError from exc
return row
def update(
db: Session,
row: LimitPolicyOverride,
*,
enabled: bool | None,
starts_at: datetime | None,
expires_at: datetime | None,
reason: str | None,
fields_set: set[str],
) -> LimitPolicyOverride:
new_starts = starts_at if "starts_at" in fields_set else row.starts_at
new_expires = expires_at if "expires_at" in fields_set else row.expires_at
new_enabled = enabled if "enabled" in fields_set and enabled is not None else row.enabled
rule = limit_policy.get_rule(row.rule_code)
if row.mode == limit_policy.MODE_OVERRIDE and new_enabled:
raise ValueError("历史覆盖配置只能停用、恢复全局或删除")
if new_enabled:
limit_policy.validate_override(
rule,
subject_type=row.subject_type,
mode=row.mode,
limit_value=None,
starts_at=new_starts,
expires_at=new_expires,
)
if "enabled" in fields_set and enabled is not None:
row.enabled = enabled
if "starts_at" in fields_set:
row.starts_at = starts_at
if "expires_at" in fields_set:
row.expires_at = expires_at
if "reason" in fields_set:
row.reason = reason.strip() if reason else None
db.flush()
return row
def update_global_limit(
db: Session, rule_code: str, value: int, *, admin_id: int
) -> tuple[int, int]:
rule = limit_policy.get_rule(rule_code)
if not rule.min_value <= value <= rule.max_value:
raise ValueError(f"限制值必须在 {rule.min_value}{rule.max_value} 之间")
before = limit_policy.resolve(db, rule_code).global_limit
limit_policy.set_global_limits(
db,
{rule.code: value},
admin_id=admin_id,
commit=False,
)
# 引导视频仍有一个旧的专用配置页。同步其结构化配置,保证两个入口读取
# 同一数值;业务判定仍统一走 limit_policy。
if rule.legacy_config_key and rule.legacy_json_field:
legacy = db.get(AppConfig, rule.legacy_config_key)
legacy_value = (
dict(legacy.value) if legacy is not None and isinstance(legacy.value, dict) else {}
)
legacy_value[rule.legacy_json_field] = value
if legacy is None:
legacy = AppConfig(
key=rule.legacy_config_key,
value=legacy_value,
updated_by_admin_id=admin_id,
)
db.add(legacy)
else:
legacy.value = legacy_value
legacy.updated_by_admin_id = admin_id
db.flush()
return before, value
def restore_global(row: LimitPolicyOverride) -> LimitPolicyOverride:
"""Stop applying the exception while retaining its history for audit."""
row.enabled = False
row.reset_at = None
return row
+98 -44
View File
@@ -5,15 +5,17 @@
"""
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
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.admin import AdminAuditLog
@@ -38,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": "比价信息流",
@@ -333,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:
@@ -342,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
@@ -385,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
@@ -408,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),
*(
@@ -418,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),
)
@@ -427,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]
@@ -443,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)
@@ -470,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()
@@ -491,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
@@ -522,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
@@ -556,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:
@@ -610,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(
@@ -786,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(
@@ -837,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(
@@ -1003,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:
@@ -1178,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")
@@ -1257,11 +1269,15 @@ def user_reward_stats(
date_from: datetime | None = None,
date_to: datetime | None = None,
withdraw_source: str | None = None,
app_env: str | None = None,
revenue_scope: str = "all",
feed_scene: str | None = None,
) -> dict:
"""提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。
口径:激励视频/信息流只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加);
平均 eCPM 用原始分值(分/千次)按记录取算术平均;各「提现」= 该来源累计金币折现。
口径:激励视频/信息流奖励数量只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加)
平均 Draw eCPM 与广告收益报表一致:基于 ad_ecpm_record 的全部 draw/feed 展示记录计算,
不以是否发奖为筛选条件。各「提现」= 该来源累计金币折现。
传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。
"""
withdraw_source_conds = (
@@ -1296,30 +1312,68 @@ def user_reward_stats(
# 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时,
# SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。
business_ids: set[str] | None = None
if revenue_scope == "business":
# 与广告收益报表共用正式/测试业务代码位集合,避免两个页面随配置切换后再次漂移。
from app.admin.repositories.ad_revenue import business_code_ids
business_ids = business_code_ids(db, app_env)
rv_conds = [
AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
]
if app_env is not None:
rv_conds.append(AdRewardRecord.app_env == app_env)
if business_ids is not None:
rv_conds.append(AdRewardRecord.our_code_id.in_(business_ids))
rv = db.execute(
select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
*rv_conds,
)
).all()
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
rv_coins = sum(r.coin for r in rv)
feed = db.execute(
feed_reward_conds = [
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
]
if app_env is not None:
feed_reward_conds.append(AdFeedRewardRecord.app_env == app_env)
if feed_scene is not None:
feed_reward_conds.append(AdFeedRewardRecord.feed_scene == feed_scene)
if business_ids is not None:
feed_reward_conds.append(AdFeedRewardRecord.our_code_id.in_(business_ids))
feed_rewards = db.execute(
select(
AdFeedRewardRecord.unit_count,
AdFeedRewardRecord.ecpm_raw,
AdFeedRewardRecord.coin,
).where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
*feed_reward_conds,
)
).all()
feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw]
feed_coins = sum(f.coin for f in feed)
feed_coins = sum(f.coin for f in feed_rewards)
feed_impression_conds = [
AdEcpmRecord.user_id == user_id,
AdEcpmRecord.ad_type.in_(("draw", "feed")),
*_window_conds(AdEcpmRecord.created_at, date_from, date_to),
]
if app_env is not None:
feed_impression_conds.append(AdEcpmRecord.app_env == app_env)
if feed_scene is not None:
feed_impression_conds.append(AdEcpmRecord.feed_scene == feed_scene)
if business_ids is not None:
feed_impression_conds.append(AdEcpmRecord.our_code_id.in_(business_ids))
feed_impressions = db.execute(
select(AdEcpmRecord.ecpm_raw).where(*feed_impression_conds)
).all()
# 与 ad_revenue.category_stats 相同:每次展示权重相同,非法原值按 parse_ecpm_fen 记 0。
feed_ecpms = [rewards.parse_ecpm_fen(row.ecpm_raw) for row in feed_impressions]
trad_coins = db.execute(
select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
@@ -1338,7 +1392,7 @@ def user_reward_stats(
"reward_video_count": len(rv),
"reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0,
"reward_video_cash_cents": _coins_to_cents(rv_coins),
"feed_count": int(sum(f.unit_count for f in feed)),
"feed_count": int(sum(f.unit_count for f in feed_rewards)),
"feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0,
"feed_cash_cents": _coins_to_cents(feed_coins),
}
@@ -1348,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,
+75 -8
View File
@@ -12,9 +12,11 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.config import ConfigItemOut, ConfigUpdateRequest
from app.core import limit_policy
from app.core.config_schema import CONFIG_DEFS
from app.core.rewards import SIGNIN_CYCLE_LEN
from app.models.admin import AdminUser
from app.models.app_config import AppConfig
from app.repositories import app_config
router = APIRouter(
@@ -54,7 +56,35 @@ def _validate(key: str, value: Any) -> None:
raise ValueError("需为布尔值")
def _limit_item(
key: str,
values: dict[str, int],
bundle: AppConfig | None,
) -> ConfigItemOut:
definition = CONFIG_DEFS[key]
rule_code = limit_policy.RULE_CODE_BY_CONFIG_KEY[key]
return ConfigItemOut(
key=key,
value=values[rule_code],
label=definition["label"],
group=definition["group"],
type=definition["type"],
help=definition.get("help"),
default=definition["default"],
overridden=bundle is not None,
updated_at=(
bundle.updated_at.isoformat() if bundle is not None else None
),
)
def _item(db, key: str) -> ConfigItemOut:
if key in limit_policy.RULE_CODE_BY_CONFIG_KEY:
return _limit_item(
key,
limit_policy.get_global_limits(db),
db.get(AppConfig, limit_policy.LIMIT_POLICY_GLOBAL_KEY),
)
for item in app_config.list_all(db):
if item["key"] == key:
return ConfigItemOut(**item)
@@ -64,11 +94,20 @@ def _item(db, key: str) -> ConfigItemOut:
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值(不含 hidden)")
def list_config(db: AdminDb) -> list[ConfigItemOut]:
# hidden 项(已下线/由专用页管理,如福利页任务·里程碑·看广告调参、首页轮播数据源)不在本页渲染。
return [
ConfigItemOut(**item)
for item in app_config.list_all(db)
if not CONFIG_DEFS[item["key"]].get("hidden")
]
legacy_items = {
item["key"]: item for item in app_config.list_all(db)
}
values = limit_policy.get_global_limits(db)
bundle = db.get(AppConfig, limit_policy.LIMIT_POLICY_GLOBAL_KEY)
out: list[ConfigItemOut] = []
for key, definition in CONFIG_DEFS.items():
if definition.get("hidden"):
continue
if key in limit_policy.RULE_CODE_BY_CONFIG_KEY:
out.append(_limit_item(key, values, bundle))
else:
out.append(ConfigItemOut(**legacy_items[key]))
return out
@router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)")
@@ -83,11 +122,39 @@ def update_config(
raise HTTPException(status_code=404, detail="未知配置项")
try:
_validate(key, body.value)
rule_code = limit_policy.RULE_CODE_BY_CONFIG_KEY.get(key)
if rule_code is not None:
before = limit_policy.get_global_limits(db)[rule_code]
limit_policy.set_global_limits(
db,
{rule_code: body.value},
admin_id=admin.id,
commit=False,
)
else:
before = app_config.get_value(db, key)
app_config.set_value(
db,
key,
body.value,
admin_id=admin.id,
commit=False,
)
if key == "ad_daily_limit":
# 旧系统配置接口过去只有一个广告日上限。仍有人直接调用时,同时同步
# 新的激励视频/Draw 两项,避免旧入口写入后业务实际值不变。
limit_policy.set_global_limits(
db,
{
"ad.reward_video.daily": body.value,
"ad.feed.daily": body.value,
},
admin_id=admin.id,
commit=False,
)
except ValueError as e:
db.rollback()
raise HTTPException(status_code=400, detail=str(e)) from e
before = app_config.get_value(db, key)
app_config.set_value(db, key, body.value, admin_id=admin.id, commit=False)
write_audit(
db, admin, action="config.set", target_type="config", target_id=key,
detail={"before": before, "after": body.value}, ip=get_client_ip(request), commit=False,
+109 -6
View File
@@ -1,6 +1,7 @@
"""admin 反馈工单:列表筛选 + 审核采纳/拒绝(带金币发放与审计)。"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Annotated
@@ -25,6 +26,8 @@ from app.models.feedback import Feedback
from app.repositories import wallet as wallet_repo
from app.services import notification_events
logger = logging.getLogger("shagua.admin.feedback")
router = APIRouter(
prefix="/admin/api/feedbacks",
tags=["admin-feedback"],
@@ -46,10 +49,24 @@ def _approve_feedback(
*,
bulk: bool = False,
) -> FeedbackOut:
logger.info(
"feedback approve started feedback_id=%s admin_id=%s bulk=%s",
feedback_id, admin.id, bulk,
)
fb = db.get(Feedback, feedback_id, with_for_update=True)
if fb is None:
logger.warning(
"feedback approve rejected not found feedback_id=%s admin_id=%s bulk=%s",
feedback_id, admin.id, bulk,
)
raise HTTPException(status_code=404, detail="反馈不存在")
_ensure_pending(fb)
if fb.status not in {"pending", "new"}:
logger.warning(
"feedback approve rejected invalid status feedback_id=%s user_id=%s "
"admin_id=%s status=%s bulk=%s",
feedback_id, fb.user_id, admin.id, fb.status, bulk,
)
_ensure_pending(fb)
before = fb.status
mutations.review_feedback(
@@ -91,8 +108,18 @@ def _approve_feedback(
)
db.commit()
db.refresh(fb)
logger.info(
"feedback approve committed feedback_id=%s user_id=%s admin_id=%s "
"before=%s after=%s reward_coins=%s bulk=%s",
feedback_id, fb.user_id, admin.id, before, fb.status, payload.reward_coins, bulk,
)
out = FeedbackOut.model_validate(fb)
notification_events.notify_feedback_reward(db, fb)
logger.info(
"feedback approve notification dispatch returned feedback_id=%s user_id=%s "
"admin_id=%s notification_type=feedback_reward bulk=%s",
feedback_id, fb.user_id, admin.id, bulk,
)
return out
@@ -105,10 +132,24 @@ def _reject_feedback(
*,
bulk: bool = False,
) -> FeedbackOut:
logger.info(
"feedback reject started feedback_id=%s admin_id=%s bulk=%s",
feedback_id, admin.id, bulk,
)
fb = db.get(Feedback, feedback_id, with_for_update=True)
if fb is None:
logger.warning(
"feedback reject rejected not found feedback_id=%s admin_id=%s bulk=%s",
feedback_id, admin.id, bulk,
)
raise HTTPException(status_code=404, detail="反馈不存在")
_ensure_pending(fb)
if fb.status not in {"pending", "new"}:
logger.warning(
"feedback reject rejected invalid status feedback_id=%s user_id=%s "
"admin_id=%s status=%s bulk=%s",
feedback_id, fb.user_id, admin.id, fb.status, bulk,
)
_ensure_pending(fb)
before = fb.status
mutations.review_feedback(
@@ -142,8 +183,18 @@ def _reject_feedback(
)
db.commit()
db.refresh(fb)
logger.info(
"feedback reject committed feedback_id=%s user_id=%s admin_id=%s "
"before=%s after=%s bulk=%s",
feedback_id, fb.user_id, admin.id, before, fb.status, bulk,
)
out = FeedbackOut.model_validate(fb)
notification_events.notify_feedback_reply(db, fb)
logger.info(
"feedback reject notification dispatch returned feedback_id=%s user_id=%s "
"admin_id=%s notification_type=feedback_reply bulk=%s",
feedback_id, fb.user_id, admin.id, bulk,
)
return out
@@ -201,6 +252,10 @@ def bulk_approve_feedbacks(
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackBulkResult:
logger.info(
"feedback bulk approve started admin_id=%s item_count=%s",
admin.id, len(body.ids),
)
results: list[FeedbackBulkItemResult] = []
ip = get_client_ip(request)
for feedback_id in body.ids:
@@ -209,11 +264,24 @@ def bulk_approve_feedbacks(
results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status))
except HTTPException as exc:
db.rollback()
logger.warning(
"feedback bulk approve item failed feedback_id=%s admin_id=%s error=%s",
feedback_id, admin.id, exc.detail,
)
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail)))
except Exception: # noqa: BLE001 - 单笔失败不打断整批
db.rollback()
logger.exception(
"feedback bulk approve item failed feedback_id=%s admin_id=%s",
feedback_id, admin.id,
)
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常"))
return _bulk_result(results)
result = _bulk_result(results)
logger.info(
"feedback bulk approve completed admin_id=%s total=%s success=%s failed=%s",
admin.id, result.total, result.success, result.failed,
)
return result
@router.post("/bulk/reject", response_model=FeedbackBulkResult, summary="批量拒绝采纳反馈")
@@ -223,6 +291,10 @@ def bulk_reject_feedbacks(
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackBulkResult:
logger.info(
"feedback bulk reject started admin_id=%s item_count=%s",
admin.id, len(body.ids),
)
results: list[FeedbackBulkItemResult] = []
ip = get_client_ip(request)
for feedback_id in body.ids:
@@ -231,11 +303,24 @@ def bulk_reject_feedbacks(
results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status))
except HTTPException as exc:
db.rollback()
logger.warning(
"feedback bulk reject item failed feedback_id=%s admin_id=%s error=%s",
feedback_id, admin.id, exc.detail,
)
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail)))
except Exception: # noqa: BLE001 - 单笔失败不打断整批
db.rollback()
logger.exception(
"feedback bulk reject item failed feedback_id=%s admin_id=%s",
feedback_id, admin.id,
)
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常"))
return _bulk_result(results)
result = _bulk_result(results)
logger.info(
"feedback bulk reject completed admin_id=%s total=%s success=%s failed=%s",
admin.id, result.total, result.success, result.failed,
)
return result
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
@@ -258,7 +343,16 @@ def approve_feedback(
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackOut:
return _approve_feedback(db, admin, feedback_id, payload, get_client_ip(request))
try:
return _approve_feedback(db, admin, feedback_id, payload, get_client_ip(request))
except HTTPException:
raise
except Exception:
logger.exception(
"feedback approve failed feedback_id=%s admin_id=%s",
feedback_id, admin.id,
)
raise
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
@@ -269,4 +363,13 @@ def reject_feedback(
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackOut:
return _reject_feedback(db, admin, feedback_id, payload, get_client_ip(request))
try:
return _reject_feedback(db, admin, feedback_id, payload, get_client_ip(request))
except HTTPException:
raise
except Exception:
logger.exception(
"feedback reject failed feedback_id=%s admin_id=%s",
feedback_id, admin.id,
)
raise
+60 -32
View File
@@ -9,7 +9,7 @@ client_max_body_size,见 shaguabijia-admin-web/deploy/nginx/admin.shaguabijia.co
"""
from __future__ import annotations
from typing import Annotated
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
@@ -27,14 +27,16 @@ router = APIRouter(
)
def _out(db: AdminDb) -> GuideVideoConfigOut:
"""配置 + 播放统计合成响应(四个写接口都以最新状态返回,前端一次同步到位)。"""
return GuideVideoConfigOut(**guide_video.get_config(db), **guide_video.play_stats(db))
GuideScene = Literal["coupon", "comparison"]
def _out(db: AdminDb, scene: GuideScene) -> GuideVideoConfigOut:
return GuideVideoConfigOut(scene=scene, **guide_video.get_config(db, scene))
@router.get("", response_model=GuideVideoConfigOut, summary="新手引导视频配置(领券浮层)")
def get_config(db: AdminDb) -> GuideVideoConfigOut:
return _out(db)
def get_config(db: AdminDb, scene: GuideScene = "coupon") -> GuideVideoConfigOut:
return _out(db, scene)
@router.patch("", response_model=GuideVideoConfigOut, summary="改开关/次数/金币(带审计)")
@@ -43,21 +45,26 @@ def update_config(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
scene: GuideScene = "coupon",
) -> GuideVideoConfigOut:
before, after = guide_video.update_config(
db,
enabled=body.enabled,
max_plays=body.max_plays,
reward_coin=body.reward_coin,
admin_id=admin.id,
commit=False,
)
try:
before, after = guide_video.update_config(
db,
enabled=body.enabled,
max_plays=body.max_plays,
reward_coin=body.reward_coin,
scene=scene,
admin_id=admin.id,
commit=False,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
write_audit(
db, admin, action="guide_video.update", target_type="guide_video", target_id=None,
detail={"before": before, "after": after}, ip=get_client_ip(request), commit=False,
detail={"scene": scene, "before": before, "after": after}, ip=get_client_ip(request), commit=False,
)
db.commit()
return _out(db)
return _out(db, scene)
@router.post("/video", response_model=GuideVideoConfigOut, summary="上传新手引导视频(MP4,带审计)")
@@ -65,23 +72,40 @@ async def upload_video(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
file: UploadFile = File(...),
file: Annotated[UploadFile, File()],
scene: GuideScene = "coupon",
) -> GuideVideoConfigOut:
data = await file.read()
url: str | None = None
try:
url = media.save_guide_video(data)
url, analysis = media.save_guide_video(data)
except media.MediaError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
before, after = guide_video.set_video(db, url, admin_id=admin.id, commit=False)
write_audit(
db, admin, action="guide_video.set_video", target_type="guide_video", target_id=None,
detail={"before": before.get("video_url"), "after": url, "bytes": len(data)},
ip=get_client_ip(request), commit=False,
)
db.commit()
# 提交成功后再删旧片,避免新片没落库就把旧片丢了
media.delete_guide_video(before.get("video_url"))
return _out(db)
try:
before, after = guide_video.set_video(
db, url, analysis=analysis, scene=scene, admin_id=admin.id, commit=False
)
write_audit(
db, admin, action="guide_video.set_video", target_type="guide_video", target_id=None,
detail={
"scene": scene,
"before": before.get("video_url"),
"after": url,
"bytes": len(data),
"duration_ms": analysis["duration_ms"],
"video_codec": analysis["video_codec"],
"audio_codec": analysis["audio_codec"],
},
ip=get_client_ip(request), commit=False,
)
db.commit()
except Exception:
db.rollback()
media.delete_guide_video(url)
raise
# 旧文件仍可能被 prepare/已起播快照引用,不能在这里立即删除。
guide_video.cleanup_old_videos(db)
return _out(db, scene)
@router.delete("/video", response_model=GuideVideoConfigOut, summary="移除新手引导视频(带审计)")
@@ -89,13 +113,17 @@ def delete_video(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
scene: GuideScene = "coupon",
) -> GuideVideoConfigOut:
"""移除后 /guide-video/start 一律返回 should_play=false,领券浮层回到「只放广告」。"""
before, after = guide_video.set_video(db, None, admin_id=admin.id, commit=False)
before, after = guide_video.set_video(
db, None, scene=scene, admin_id=admin.id, commit=False
)
write_audit(
db, admin, action="guide_video.delete_video", target_type="guide_video", target_id=None,
detail={"before": before.get("video_url")}, ip=get_client_ip(request), commit=False,
detail={"scene": scene, "before": before.get("video_url")}, ip=get_client_ip(request), commit=False,
)
db.commit()
media.delete_guide_video(before.get("video_url"))
return _out(db)
# 旧文件由保护期清理任务处理,避免破坏有效计划与已起播快照。
guide_video.cleanup_old_videos(db)
return _out(db, scene)
+838
View File
@@ -0,0 +1,838 @@
"""Unified limit rules and per-phone/device whitelist overrides."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_page
from app.admin.repositories import limit_whitelist as repo
from app.admin.schemas.limit_whitelist import (
DeviceCandidateOut,
GlobalLimitUpdate,
LimitOverrideBulkWrite,
LimitOverrideList,
LimitOverrideOut,
LimitOverridePatch,
LimitOverrideWrite,
LimitRuleOut,
LimitSubjectEnabledPatch,
LimitSubjectList,
LimitSubjectOut,
)
from app.core import limit_policy
from app.models.limit_policy import LimitPolicyOverride
from app.repositories import risk as risk_repo
router = APIRouter(
prefix="/admin/api/limit-whitelist",
tags=["admin-limit-whitelist"],
dependencies=[Depends(require_page("limit-whitelist"))],
)
_WHITELIST_MODES = {
limit_policy.MODE_UNLIMITED,
limit_policy.MODE_SUPPRESS_ALERT,
}
def _subject_whitelist_rows(
db,
*,
subject_type: str,
subject_value: str,
) -> list[LimitPolicyOverride]:
return [
row
for row in repo.rows_for_subject(
db,
subject_type=subject_type,
subject_value=subject_value,
)
if row.mode in _WHITELIST_MODES
]
def _reconcile_risk_rule(db, rule_code: str) -> None:
now = risk_repo.utcnow()
if rule_code == "risk.sms.hourly":
risk_repo.reconcile_behavior_rule(
db,
rule_code=risk_repo.RULE_SMS_HOURLY,
at=now,
commit=False,
)
elif rule_code == "risk.oneclick.daily":
risk_repo.reconcile_behavior_rule(
db,
rule_code=risk_repo.RULE_ONECLICK_DAILY,
at=now,
commit=False,
)
elif rule_code == "risk.compare.daily":
risk_repo.reconcile_compare_rule(db, at=now, commit=False)
def _row_or_404(db, override_id: int) -> LimitPolicyOverride:
row = db.get(LimitPolicyOverride, override_id, populate_existing=True)
if row is None:
raise HTTPException(status_code=404, detail="白名单配置不存在")
return row
def _out(db, row: LimitPolicyOverride) -> LimitOverrideOut:
return LimitOverrideOut(**repo.to_dict(db, row))
def _audit_payload(value: LimitOverrideOut) -> dict:
return value.model_dump(mode="json")
def _subject_out(
db,
*,
subject_type: str,
subject_value: str,
rows: list[LimitPolicyOverride],
created_at,
) -> LimitSubjectOut:
items = [_out(db, row) for row in rows]
group_counts: dict[str, int] = {}
for item in items:
group_counts[item.rule_group] = group_counts.get(item.rule_group, 0) + 1
return LimitSubjectOut(
subject_type=subject_type,
subject_value=subject_value,
group_counts=group_counts,
total_rules=len(items),
items=items,
created_at=created_at,
updated_at=max(
(item.updated_at for item in items),
default=created_at,
),
)
@router.get("/rules", response_model=list[LimitRuleOut], summary="读取所有限制规则")
def list_rules(db: AdminDb) -> list[LimitRuleOut]:
return [LimitRuleOut(**item) for item in limit_policy.rule_catalog(db)]
@router.patch(
"/rules/{rule_code}",
response_model=LimitRuleOut,
summary="修改规则的全局限制值",
)
def update_rule(
rule_code: str,
body: GlobalLimitUpdate,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> LimitRuleOut:
try:
before, after = repo.update_global_limit(db, rule_code, body.value, admin_id=admin.id)
except KeyError as exc:
raise HTTPException(status_code=404, detail="未知限制规则") from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_reconcile_risk_rule(db, rule_code)
write_audit(
db,
admin,
action="limit.rule.update",
target_type="limit_rule",
target_id=rule_code,
detail={"before": before, "after": after},
ip=get_client_ip(request),
commit=False,
)
db.commit()
item = next(item for item in limit_policy.rule_catalog(db) if item["code"] == rule_code)
return LimitRuleOut(**item)
@router.get(
"/device-candidates",
response_model=list[DeviceCandidateOut],
summary="按限制项搜索可加入白名单的设备",
)
def list_device_candidates(
db: AdminDb,
rule_code: str = Query(..., min_length=1, max_length=64),
keyword: str | None = Query(None, max_length=128),
limit: int = Query(30, ge=1, le=50),
) -> list[DeviceCandidateOut]:
try:
rows = repo.list_device_candidates(
db,
rule_code=rule_code,
keyword=keyword,
limit=limit,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail="未知限制规则") from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return [DeviceCandidateOut(**row) for row in rows]
@router.get("", response_model=LimitOverrideList, summary="读取白名单配置")
def list_overrides(
db: AdminDb,
subject_type: str | None = Query(None, pattern="^(phone|device)$"),
keyword: str | None = Query(None, max_length=128),
rule_code: str | None = Query(None, max_length=64),
offset: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
) -> LimitOverrideList:
rows, total = repo.list_rows(
db,
subject_type=subject_type,
keyword=keyword,
rule_code=rule_code,
offset=offset,
limit=limit,
)
return LimitOverrideList(items=[_out(db, row) for row in rows], total=total)
@router.get(
"/subjects",
response_model=LimitSubjectList,
summary="按手机号或设备聚合读取白名单配置",
)
def list_override_subjects(
db: AdminDb,
subject_type: str | None = Query(None, pattern="^(phone|device)$"),
keyword: str | None = Query(None, max_length=128),
rule_code: str | None = Query(None, max_length=64),
offset: int = Query(0, ge=0),
limit: int = Query(10, ge=1, le=100),
) -> LimitSubjectList:
subjects, total = repo.list_subject_rows(
db,
subject_type=subject_type,
keyword=keyword,
rule_code=rule_code,
offset=offset,
limit=limit,
)
return LimitSubjectList(
items=[
_subject_out(
db,
subject_type=item_subject_type,
subject_value=subject_value,
rows=rows,
created_at=created_at,
)
for item_subject_type, subject_value, rows, created_at in subjects
],
total=total,
)
@router.patch(
"/subjects/enabled",
response_model=LimitSubjectOut,
summary="整体启用或停用一个手机号或设备的白名单",
)
def set_override_subject_enabled(
body: LimitSubjectEnabledPatch,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> LimitSubjectOut:
try:
subject_value = limit_policy.validate_whitelist_subject(
body.subject_type, body.subject_value
)
repo.lock_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
rows = _subject_whitelist_rows(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
if not rows:
raise HTTPException(status_code=404, detail="白名单主体不存在")
before = [_audit_payload(_out(db, row)) for row in rows]
for row in rows:
repo.update(
db,
row,
enabled=body.enabled,
starts_at=None,
expires_at=None,
reason=None,
fields_set={"enabled"},
)
for rule_code in {row.rule_code for row in rows}:
_reconcile_risk_rule(db, rule_code)
after = [_audit_payload(_out(db, row)) for row in rows]
write_audit(
db,
admin,
action="limit.override.subject_enabled",
target_type="limit_override_subject",
target_id=f"{body.subject_type}:{subject_value}",
detail={"before": before, "after": after},
ip=get_client_ip(request),
commit=False,
)
db.commit()
except HTTPException:
raise
except (KeyError, ValueError) as exc:
db.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
rows = [
row
for row in repo.rows_for_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
if row.mode
in {
limit_policy.MODE_UNLIMITED,
limit_policy.MODE_SUPPRESS_ALERT,
}
]
return _subject_out(
db,
subject_type=body.subject_type,
subject_value=subject_value,
rows=rows,
created_at=min(row.created_at for row in rows),
)
@router.put(
"/subjects",
response_model=LimitSubjectOut,
summary="整体更新一个手机号或设备的白名单限制项",
)
def replace_override_subject(
body: LimitOverrideBulkWrite,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> LimitSubjectOut:
current_rule_code = ""
try:
subject_value = limit_policy.validate_whitelist_subject(
body.subject_type, body.subject_value
)
repo.lock_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
existing_rows = repo.rows_for_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
subject_created_at = min(
(row.created_at for row in existing_rows),
default=None,
)
before = [_audit_payload(_out(db, row)) for row in existing_rows]
existing_by_rule = {row.rule_code: row for row in existing_rows}
selected_codes = set(body.rule_codes)
touched_rule_codes = set(selected_codes)
for current_rule_code in body.rule_codes:
rule = limit_policy.get_rule(current_rule_code)
mode = (
limit_policy.MODE_SUPPRESS_ALERT if rule.alert_only else limit_policy.MODE_UNLIMITED
)
row = existing_by_rule.get(current_rule_code)
if row is None:
row = repo.create(
db,
subject_type=body.subject_type,
subject_value=subject_value,
rule_code=current_rule_code,
mode=mode,
limit_value=None,
enabled=body.enabled,
starts_at=body.starts_at,
expires_at=body.expires_at,
reason=body.reason,
admin_id=admin.id,
)
# The table is ordered by the subject's original creation
# time. If an edit replaces every rule, carry that timestamp
# to the new rows so the subject does not jump to the top.
if subject_created_at is not None:
row.created_at = subject_created_at
continue
# Historical custom-value rows are converted to the only supported
# product modes when the administrator selects that rule again.
row.mode = mode
row.limit_value = None
row.reset_at = None
repo.update(
db,
row,
enabled=body.enabled,
starts_at=body.starts_at,
expires_at=body.expires_at,
reason=body.reason,
fields_set={"enabled", "starts_at", "expires_at", "reason"},
)
for row in existing_rows:
if row.rule_code not in selected_codes and row.mode in {
limit_policy.MODE_UNLIMITED,
limit_policy.MODE_SUPPRESS_ALERT,
}:
touched_rule_codes.add(row.rule_code)
db.delete(row)
db.flush()
except KeyError as exc:
raise HTTPException(status_code=404, detail="未知限制规则") from exc
except repo.DuplicateOverrideError as exc:
rule = limit_policy.get_rule(current_rule_code)
raise HTTPException(
status_code=409,
detail=f"{rule.label}”已有白名单配置,请刷新后重试",
) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
for rule_code in touched_rule_codes:
_reconcile_risk_rule(db, rule_code)
rows = [
row
for row in repo.rows_for_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
if row.mode
in {
limit_policy.MODE_UNLIMITED,
limit_policy.MODE_SUPPRESS_ALERT,
}
]
after = [_audit_payload(_out(db, row)) for row in rows]
write_audit(
db,
admin,
action="limit.override.subject_replace",
target_type="limit_override_subject",
target_id=f"{body.subject_type}:{subject_value}",
detail={"before": before, "after": after},
ip=get_client_ip(request),
commit=False,
)
db.commit()
rows = [
row
for row in repo.rows_for_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
if row.mode
in {
limit_policy.MODE_UNLIMITED,
limit_policy.MODE_SUPPRESS_ALERT,
}
]
return _subject_out(
db,
subject_type=body.subject_type,
subject_value=subject_value,
rows=rows,
created_at=min(row.created_at for row in rows),
)
@router.post(
"",
response_model=LimitOverrideOut,
status_code=status.HTTP_201_CREATED,
summary="新增白名单配置",
)
def create_override(
body: LimitOverrideWrite,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> LimitOverrideOut:
before: list[dict] = []
touched_rows: list[LimitPolicyOverride] = []
try:
subject_value = limit_policy.validate_whitelist_subject(
body.subject_type, body.subject_value
)
repo.lock_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
existing_rows = _subject_whitelist_rows(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
before = [_audit_payload(_out(db, item)) for item in existing_rows]
row = repo.create(
db,
subject_type=body.subject_type,
subject_value=subject_value,
rule_code=body.rule_code,
mode=body.mode,
enabled=body.enabled,
starts_at=body.starts_at,
expires_at=body.expires_at,
reason=body.reason,
limit_value=None,
admin_id=admin.id,
)
touched_rows = [*existing_rows, row]
if row.mode in _WHITELIST_MODES:
# Keep the legacy single-rule endpoint compatible without letting
# it create a second validity period for the same logical subject.
for existing in existing_rows:
repo.update(
db,
existing,
enabled=body.enabled,
starts_at=body.starts_at,
expires_at=body.expires_at,
reason=None,
fields_set={"enabled", "starts_at", "expires_at"},
)
except KeyError as exc:
raise HTTPException(status_code=404, detail="未知限制规则") from exc
except repo.DuplicateOverrideError as exc:
raise HTTPException(
status_code=409,
detail="该手机号或设备已配置此规则,请编辑现有配置",
) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
for rule_code in {item.rule_code for item in touched_rows}:
_reconcile_risk_rule(db, rule_code)
after_rows = _subject_whitelist_rows(
db,
subject_type=row.subject_type,
subject_value=row.subject_value,
)
write_audit(
db,
admin,
action="limit.override.create",
target_type="limit_override_subject",
target_id=f"{row.subject_type}:{row.subject_value}",
detail={
"before": before,
"after": [_audit_payload(_out(db, item)) for item in after_rows],
},
ip=get_client_ip(request),
commit=False,
)
db.commit()
return _out(db, row)
@router.post(
"/bulk",
response_model=list[LimitOverrideOut],
status_code=status.HTTP_201_CREATED,
summary="批量新增临时不限或免告警白名单",
)
def create_overrides_bulk(
body: LimitOverrideBulkWrite,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> list[LimitOverrideOut]:
rows: list[LimitPolicyOverride] = []
before: list[dict] = []
current_rule_code = ""
try:
subject_value = limit_policy.validate_whitelist_subject(
body.subject_type, body.subject_value
)
repo.lock_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
existing_rows = repo.rows_for_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
whitelist_modes = {
limit_policy.MODE_UNLIMITED,
limit_policy.MODE_SUPPRESS_ALERT,
}
existing_whitelist_rows = [row for row in existing_rows if row.mode in whitelist_modes]
before = [_audit_payload(_out(db, row)) for row in existing_whitelist_rows]
existing_by_rule = {row.rule_code: row for row in existing_rows}
selected_codes = set(body.rule_codes)
for current_rule_code in body.rule_codes:
rule = limit_policy.get_rule(current_rule_code)
mode = (
limit_policy.MODE_SUPPRESS_ALERT if rule.alert_only else limit_policy.MODE_UNLIMITED
)
row = existing_by_rule.get(current_rule_code)
if row is None:
row = repo.create(
db,
subject_type=body.subject_type,
subject_value=subject_value,
rule_code=current_rule_code,
mode=mode,
limit_value=None,
enabled=body.enabled,
starts_at=body.starts_at,
expires_at=body.expires_at,
reason=body.reason,
admin_id=admin.id,
)
else:
row.mode = mode
row.limit_value = None
row.reset_at = None
repo.update(
db,
row,
enabled=body.enabled,
starts_at=body.starts_at,
expires_at=body.expires_at,
reason=body.reason,
fields_set={
"enabled",
"starts_at",
"expires_at",
"reason",
},
)
# 同一手机号或设备在产品上是一条白名单。追加限制项时保留原规则,
# 但统一使用最后一次配置的启用状态与有效期,避免一个主体出现多套时间。
rows = [
row
for row in repo.rows_for_subject(
db,
subject_type=body.subject_type,
subject_value=subject_value,
)
if row.mode in whitelist_modes
]
for row in rows:
if row.rule_code in selected_codes:
continue
repo.update(
db,
row,
enabled=body.enabled,
starts_at=body.starts_at,
expires_at=body.expires_at,
reason=None,
fields_set={"enabled", "starts_at", "expires_at"},
)
except KeyError as exc:
raise HTTPException(status_code=404, detail="未知限制规则") from exc
except repo.DuplicateOverrideError as exc:
rule = limit_policy.get_rule(current_rule_code)
raise HTTPException(
status_code=409,
detail=f"{rule.label}”白名单配置发生并发更新,请刷新后重试",
) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
for row in rows:
_reconcile_risk_rule(db, row.rule_code)
results = [_out(db, row) for row in rows]
write_audit(
db,
admin,
action="limit.override.bulk_create",
target_type="limit_override",
target_id=",".join(str(row.id) for row in rows),
detail={
"before": before,
"after": [_audit_payload(item) for item in results],
},
ip=get_client_ip(request),
commit=False,
)
db.commit()
return [_out(db, row) for row in rows]
@router.patch(
"/{override_id}",
response_model=LimitOverrideOut,
summary="编辑白名单配置",
)
def update_override(
override_id: int,
body: LimitOverridePatch,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> LimitOverrideOut:
row = _row_or_404(db, override_id)
repo.lock_subject(
db,
subject_type=row.subject_type,
subject_value=row.subject_value,
)
row = _row_or_404(db, override_id)
subject_rows = (
_subject_whitelist_rows(
db,
subject_type=row.subject_type,
subject_value=row.subject_value,
)
if row.mode in _WHITELIST_MODES
else [row]
)
before = [_audit_payload(_out(db, item)) for item in subject_rows]
fields_set = set(body.model_fields_set)
try:
period_fields = {"enabled", "starts_at", "expires_at"}
if row.mode in _WHITELIST_MODES and fields_set & period_fields:
new_enabled = body.enabled if "enabled" in fields_set else row.enabled
new_starts = body.starts_at if "starts_at" in fields_set else row.starts_at
new_expires = body.expires_at if "expires_at" in fields_set else row.expires_at
for item in subject_rows:
item_fields = set(period_fields)
if item.id == row.id and "reason" in fields_set:
item_fields.add("reason")
repo.update(
db,
item,
enabled=new_enabled,
starts_at=new_starts,
expires_at=new_expires,
reason=body.reason if item.id == row.id else None,
fields_set=item_fields,
)
else:
repo.update(
db,
row,
**body.model_dump(),
fields_set=fields_set,
)
except (KeyError, ValueError) as exc:
db.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
for rule_code in {item.rule_code for item in subject_rows}:
_reconcile_risk_rule(db, rule_code)
after_rows = [_audit_payload(_out(db, item)) for item in subject_rows]
write_audit(
db,
admin,
action="limit.override.update",
target_type="limit_override_subject",
target_id=f"{row.subject_type}:{row.subject_value}",
detail={"before": before, "after": after_rows},
ip=get_client_ip(request),
commit=False,
)
db.commit()
return _out(db, row)
@router.post(
"/{override_id}/reset",
response_model=LimitOverrideOut,
summary="停用例外配置并恢复全局策略",
)
def restore_global_policy(
override_id: int,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> LimitOverrideOut:
row = _row_or_404(db, override_id)
repo.lock_subject(
db,
subject_type=row.subject_type,
subject_value=row.subject_value,
)
row = _row_or_404(db, override_id)
subject_rows = (
_subject_whitelist_rows(
db,
subject_type=row.subject_type,
subject_value=row.subject_value,
)
if row.mode in _WHITELIST_MODES
else [row]
)
before = [_audit_payload(_out(db, item)) for item in subject_rows]
for item in subject_rows:
repo.restore_global(item)
for rule_code in {item.rule_code for item in subject_rows}:
_reconcile_risk_rule(db, rule_code)
after = [_audit_payload(_out(db, item)) for item in subject_rows]
write_audit(
db,
admin,
action="limit.override.restore_global",
target_type="limit_override_subject",
target_id=f"{row.subject_type}:{row.subject_value}",
detail={"before": before, "after": after},
ip=get_client_ip(request),
commit=False,
)
db.commit()
return _out(db, row)
@router.delete(
"/{override_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="删除白名单配置",
)
def delete_override(
override_id: int,
request: Request,
admin: CurrentAdmin,
db: AdminDb,
) -> None:
row = _row_or_404(db, override_id)
repo.lock_subject(
db,
subject_type=row.subject_type,
subject_value=row.subject_value,
)
row = _row_or_404(db, override_id)
before = _audit_payload(_out(db, row))
target_id = str(row.id)
rule_code = row.rule_code
db.delete(row)
db.flush()
_reconcile_risk_rule(db, rule_code)
write_audit(
db,
admin,
action="limit.override.delete",
target_type="limit_override",
target_id=target_id,
detail={"before": before},
ip=get_client_ip(request),
commit=False,
)
db.commit()
+10 -14
View File
@@ -23,13 +23,8 @@ from app.admin.schemas.risk_monitor import (
RiskResetResponse,
RiskRuleConfig,
)
from app.core.config_schema import (
RISK_COMPARE_DAILY_THRESHOLD_KEY,
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
RISK_SMS_HOURLY_THRESHOLD_KEY,
)
from app.core import limit_policy
from app.models.risk import RiskIncident, SubjectRestriction
from app.repositories import app_config
from app.repositories import risk as risk_repo
router = APIRouter(
@@ -83,15 +78,16 @@ def update_rules(
) -> RiskRuleConfig:
before = _rule_config(db).model_dump()
after = body.model_dump()
values = (
(RISK_SMS_HOURLY_THRESHOLD_KEY, body.sms_hourly_threshold),
(RISK_ONECLICK_DAILY_THRESHOLD_KEY, body.oneclick_daily_threshold),
(RISK_COMPARE_DAILY_THRESHOLD_KEY, body.compare_daily_threshold),
limit_policy.set_global_limits(
db,
{
"risk.sms.hourly": body.sms_hourly_threshold,
"risk.oneclick.daily": body.oneclick_daily_threshold,
"risk.compare.daily": body.compare_daily_threshold,
},
admin_id=admin.id,
commit=False,
)
for key, value in values:
app_config.set_value(
db, key, value, admin_id=admin.id, commit=False
)
now = risk_repo.utcnow()
risk_repo.reconcile_behavior_rule(
+8
View File
@@ -88,6 +88,11 @@ def get_user_reward_stats(
withdraw_source: Annotated[
str | None, Query(pattern="^(coin_cash|invite_cash)$")
] = None,
app_env: Annotated[str | None, Query(pattern="^(prod|test)$")] = None,
revenue_scope: Annotated[str, Query(pattern="^(business|all)$")] = "all",
feed_scene: Annotated[
str | None, Query(pattern="^(comparison|coupon|welfare)$")
] = None,
) -> UserRewardStats:
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
if not user_repo.user_exists(db, user_id):
@@ -99,6 +104,9 @@ def get_user_reward_stats(
date_from=date_from,
date_to=date_to,
withdraw_source=withdraw_source,
app_env=app_env,
revenue_scope=revenue_scope,
feed_scene=feed_scene,
)
)
+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
+30 -11
View File
@@ -1,25 +1,44 @@
"""admin 新手引导视频配置 schemas(开关 / 视频地址 / 前几次 / 每次金币)"""
"""后台引导视频配置协议"""
from __future__ import annotations
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from app.repositories.guide_video import MAX_PLAYS_LIMIT, REWARD_COIN_LIMIT
from app.repositories.guide_video import (
MAX_PLAYS_LIMIT,
MIN_PLAYS,
MIN_REWARD_COIN,
REWARD_COIN_LIMIT,
)
class GuideVideoConfigOut(BaseModel):
scene: str
enabled: bool
video_url: str | None = None # 相对地址 /media/guide_video/xxx.mp4;未配片 = None
video_url: str | None = None
max_plays: int
reward_coin: int
duration_ms: int | None = None
circle_count: int = 10
circle_duration_ms: float | None = None
reward_per_circle: int
video_codec: str | None = None
audio_codec: str | None = None
analysis_status: str
analysis_error: str | None = None
config_version: int
updated_at: str | None = None
# 只读统计,后台展示用:已有多少次播放、其中已发币多少次。
total_plays: int = 0
granted_plays: int = 0
class GuideVideoConfigUpdate(BaseModel):
"""部分更新:只改传入(非 None)字段。视频文件走 /video 上传接口。"""
enabled: bool | None = None
max_plays: int | None = Field(default=None, ge=0, le=MAX_PLAYS_LIMIT)
reward_coin: int | None = Field(default=None, ge=0, le=REWARD_COIN_LIMIT)
max_plays: int | None = Field(default=None, ge=MIN_PLAYS, le=MAX_PLAYS_LIMIT)
reward_coin: int | None = Field(
default=None, ge=MIN_REWARD_COIN, le=REWARD_COIN_LIMIT
)
@field_validator("reward_coin")
@classmethod
def reward_must_be_multiple_of_ten(cls, value: int | None) -> int | None:
if value is not None and value % 10:
raise ValueError("金币总价必须是 10 的倍数")
return value
+150
View File
@@ -0,0 +1,150 @@
"""Admin contracts for global limit rules and per-subject overrides."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
SubjectType = Literal["phone", "device"]
PolicyMode = Literal["unlimited", "suppress_alert"]
class LimitRuleOut(BaseModel):
code: str
label: str
group: str
global_limit: int
default_limit: int
window_label: str
subject_types: list[str]
allowed_modes: list[str]
min_value: int
max_value: int
supports_reset: bool
alert_only: bool
class GlobalLimitUpdate(BaseModel):
value: int = Field(ge=0, le=1_000_000)
class DeviceCandidateOut(BaseModel):
device_id: str
source: str
source_label: str
user_id: int | None = None
username: str | None = None
phone: str | None = None
nickname: str | None = None
device_model: str | None = None
last_active_at: datetime
class LimitOverrideWrite(BaseModel):
model_config = ConfigDict(extra="forbid")
subject_type: SubjectType
subject_value: str = Field(min_length=1, max_length=128)
rule_code: str = Field(min_length=1, max_length=64)
mode: PolicyMode
enabled: bool = True
starts_at: datetime | None = None
expires_at: datetime | None = None
reason: str = Field("", max_length=256)
@model_validator(mode="after")
def validate_time_range(self):
if self.starts_at and self.expires_at and self.expires_at <= self.starts_at:
raise ValueError("失效时间必须晚于生效时间")
return self
class LimitOverrideBulkWrite(BaseModel):
model_config = ConfigDict(extra="forbid")
subject_type: SubjectType
subject_value: str = Field(min_length=1, max_length=128)
rule_codes: list[str] = Field(min_length=1, max_length=32)
enabled: bool = True
starts_at: datetime | None = None
expires_at: datetime
reason: str = Field("", max_length=256)
@model_validator(mode="after")
def validate_bulk_request(self):
self.rule_codes = list(dict.fromkeys(self.rule_codes))
starts_at = (
self.starts_at.replace(tzinfo=UTC)
if self.starts_at and self.starts_at.tzinfo is None
else self.starts_at
)
expires_at = (
self.expires_at.replace(tzinfo=UTC)
if self.expires_at.tzinfo is None
else self.expires_at
)
if starts_at and expires_at <= starts_at:
raise ValueError("失效时间必须晚于生效时间")
if expires_at <= datetime.now(UTC):
raise ValueError("失效时间必须晚于当前时间")
return self
class LimitOverridePatch(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool | None = None
starts_at: datetime | None = None
expires_at: datetime | None = None
reason: str | None = Field(None, max_length=256)
class LimitSubjectEnabledPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
subject_type: SubjectType
subject_value: str = Field(min_length=1, max_length=128)
enabled: bool
class LimitOverrideOut(BaseModel):
id: int
subject_type: str
subject_value: str
rule_code: str
rule_label: str
rule_group: str
mode: str
limit_value: int | None
global_limit: int
effective_limit: int | None
enabled: bool
starts_at: datetime | None
expires_at: datetime | None
reset_at: datetime | None
reason: str | None
status: str
created_by_admin_id: int | None
created_at: datetime
updated_at: datetime
class LimitOverrideList(BaseModel):
items: list[LimitOverrideOut]
total: int
class LimitSubjectOut(BaseModel):
subject_type: str
subject_value: str
group_counts: dict[str, int]
total_rules: int
items: list[LimitOverrideOut]
created_at: datetime
updated_at: datetime
class LimitSubjectList(BaseModel):
items: list[LimitSubjectOut]
total: int
+2 -2
View File
@@ -21,9 +21,9 @@ class RiskMonitorSummary(BaseModel):
class RiskRuleConfig(BaseModel):
sms_hourly_threshold: int = Field(ge=1, le=5)
sms_hourly_threshold: int = Field(ge=1, le=100_000)
oneclick_daily_threshold: int = Field(ge=1, le=100_000)
compare_daily_threshold: int = Field(ge=1, le=100)
compare_daily_threshold: int = Field(ge=1, le=100_000)
class RiskIncidentItem(BaseModel):
+1 -1
View File
@@ -60,7 +60,7 @@ class UserRewardStats(BaseModel):
reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次)
reward_video_cash_cents: int # 激励视频提现(金币折现)
feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加)
feed_avg_ecpm: float # 平均信息流广告 eCPM(分/千次)
feed_avg_ecpm: float # 全部 Draw/feed 实际展示的平均 eCPM(分/千次,含未发奖展示)
feed_cash_cents: int # 信息流广告提现(金币折现)
+13 -2
View File
@@ -18,7 +18,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
from app.api.deps import CurrentUser, DbSession
from app.core import rewards
from app.core import limit_policy, rewards
from app.core.config import settings
from app.core.ratelimit import rate_limit
from app.integrations import pangle
@@ -413,12 +413,23 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
user.id, rec.client_event_id, rec.status, rec.unit_count, rec.coin,
)
feed_policy = limit_policy.resolve_for_user(db, "ad.feed.daily", user.id)
feed_limit = (
rewards.get_ad_daily_limit(db)
if feed_policy.override_id is None
and feed_policy.bucket_version == "default"
else feed_policy.limit
)
return FeedRewardOut(
granted=(rec.status == "granted"),
status=rec.status,
coin=rec.coin,
unit_count=rec.unit_count,
daily_limit=rewards.get_ad_daily_limit(db),
daily_limit=(
feed_limit
if feed_limit is not None
else limit_policy.get_rule("ad.feed.daily").max_value
),
)
+190 -28
View File
@@ -16,7 +16,7 @@ from fastapi import APIRouter, HTTPException, Request
from sqlalchemy.exc import IntegrityError
from app.api.deps import CurrentUser, DbSession
from app.core import test_account
from app.core import limit_policy, test_account
from app.core.ratelimit import (
RateLimitRule,
check_rate_limits,
@@ -69,6 +69,7 @@ SMS_LOGIN_MAX_PER_HOUR = 5
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限
SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸)
UNLIMITED_VERIFY_ATTEMPTS = 2_147_483_647
def _client_ip(request: Request) -> str:
@@ -198,16 +199,54 @@ def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendRes
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。
# 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前
# (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。
hourly_policy = limit_policy.resolve(
db,
"sms.send.hourly",
phone=req.phone,
device=subject_id,
)
daily_policy = limit_policy.resolve(
db,
"sms.send.daily",
phone=req.phone,
device=subject_id,
)
cooldown_policy = limit_policy.resolve(
db,
"sms.phone.cooldown",
phone=req.phone,
)
hourly_limit = (
SMS_SEND_MAX_PER_HOUR_PER_DEVICE
if hourly_policy.override_id is None
and hourly_policy.bucket_version == "default"
else hourly_policy.limit
)
daily_limit = (
SMS_SEND_MAX_PER_DAY_PER_DEVICE
if daily_policy.override_id is None
and daily_policy.bucket_version == "default"
else daily_policy.limit
)
send_rules = [
RateLimitRule("sms-send-device", SMS_SEND_MAX_PER_HOUR_PER_DEVICE, 3600,
"操作过于频繁,请稍后再试"),
RateLimitRule("sms-send-device-daily", SMS_SEND_MAX_PER_DAY_PER_DEVICE, 86400,
"今日验证码发送次数过多,请明天再试"),
RateLimitRule("sms-send-device", hourly_limit, 3600,
"操作过于频繁,请稍后再试", hourly_policy.bucket_version),
RateLimitRule("sms-send-device-daily", daily_limit, 86400,
"今日验证码发送次数过多,请明天再试", daily_policy.bucket_version),
]
check_rate_limits(request, subject=req.device_id, rules=send_rules)
check_rate_limits(request, subject=subject_id, rules=send_rules)
try:
cooldown = send_code(req.phone)
send_result = send_code(
req.phone,
cooldown_sec=(
None
if cooldown_policy.override_id is None
and cooldown_policy.bucket_version == "default"
else (0 if cooldown_policy.limit is None else cooldown_policy.limit)
),
)
cooldown = send_result.cooldown_sec
except SmsError as e:
risk_repo.record_behavior_event(
db,
@@ -224,7 +263,7 @@ def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendRes
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
# 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度)
record_rate_limits(request, subject=req.device_id, rules=send_rules)
record_rate_limits(request, subject=subject_id, rules=send_rules)
from app.core.config import settings # 局部 import 避免循环
@@ -238,7 +277,9 @@ def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendRes
phone=req.phone,
client_ip=_client_ip(request),
outcome="success",
details={"mock": settings.SMS_MOCK},
details={"mock": settings.SMS_MOCK,
"provider": send_result.provider,
"fallback": send_result.fallback},
evaluate_rule=risk_repo.RULE_SMS_HOURLY,
)
@@ -256,7 +297,7 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
# 测试账号走自己的每日额度、不受下面 (设备+IP) 每小时限流约束(QA 需在一小时内反复登录联调)。
if test_account.is_test_account(req.phone):
if not test_account.try_consume_quota():
if not test_account.try_consume_quota(req.phone):
raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
risk_repo.record_behavior_event(
@@ -283,17 +324,48 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)。
# ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时
# 退化为该 IP 下所有空设备聚一桶,仍受限。
login_policy = limit_policy.resolve(
db,
"sms.login.hourly",
phone=req.phone,
device=subject_id,
)
verify_policy = limit_policy.resolve(
db,
"sms.code.failed_attempts",
phone=req.phone,
)
login_limit = (
SMS_LOGIN_MAX_PER_HOUR
if login_policy.override_id is None
and login_policy.bucket_version == "default"
else login_policy.limit
)
enforce_rate_limit(
request,
scope="sms-login-device",
subject=req.device_id,
limit=SMS_LOGIN_MAX_PER_HOUR,
subject=subject_id,
limit=login_limit,
window_sec=3600,
detail="登录尝试过于频繁,请稍后再试",
bucket_suffix=login_policy.bucket_version,
)
try:
ok = verify_code(req.phone, req.code)
ok = verify_code(
req.phone,
req.code,
max_failed_attempts=(
None
if verify_policy.override_id is None
and verify_policy.bucket_version == "default"
else (
UNLIMITED_VERIFY_ATTEMPTS
if verify_policy.limit is None
else verify_policy.limit
)
),
)
except SmsError as e: # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
if not ok:
@@ -394,15 +466,25 @@ def _finish_wechat_bind(
未占用 新建微信账号(channel=wechat,昵称头像取微信) token 登入"""
existing = user_repo.get_user_by_phone(db, phone)
if existing is not None:
from app.core.config import settings # 局部 import,避免循环
ticket = create_conflict_ticket(
openid=openid,
wechat_nickname=wechat_nickname,
wechat_avatar_url=wechat_avatar_url,
phone=phone,
)
blocked = rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
rebind_policy = limit_policy.resolve(
db,
"phone.rebind.days",
phone=phone,
device=device_id,
)
rebind_days = rebind_policy.limit or 0
blocked = rebind_repo.rebound_within_days(
db,
phone,
rebind_days,
reset_at=rebind_policy.reset_at,
)
logger.info(
"wechat bind phone occupied phone=%s by user_id=%d has_wechat=%s",
mask_phone(phone), existing.id, bool(existing.wechat_openid),
@@ -418,7 +500,12 @@ def _finish_wechat_bind(
conflict_ticket=ticket,
rebind_available=not blocked,
rebind_blocked_days=(
rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
rebind_repo.remaining_block_days(
db,
phone,
rebind_days,
reset_at=rebind_policy.reset_at,
)
if blocked else 0
),
)
@@ -451,18 +538,50 @@ def wechat_bind_phone_sms(
except TokenError as e:
raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e
subject_id = _device_subject(req.device_id, request)
# 防刷:同 sms/login,按 设备+IP 每小时限流(放在验证码校验之前,失败也计数)
bind_policy = limit_policy.resolve(
db,
"wechat.bind.hourly",
phone=req.phone,
device=subject_id,
)
verify_policy = limit_policy.resolve(
db,
"sms.code.failed_attempts",
phone=req.phone,
)
bind_limit = (
SMS_LOGIN_MAX_PER_HOUR
if bind_policy.override_id is None
and bind_policy.bucket_version == "default"
else bind_policy.limit
)
enforce_rate_limit(
request,
scope="wechat-bind-sms-device",
subject=req.device_id,
limit=SMS_LOGIN_MAX_PER_HOUR,
subject=subject_id,
limit=bind_limit,
window_sec=3600,
detail="登录尝试过于频繁,请稍后再试",
bucket_suffix=bind_policy.bucket_version,
)
try:
ok = verify_code(req.phone, req.code)
ok = verify_code(
req.phone,
req.code,
max_failed_attempts=(
None
if verify_policy.override_id is None
and verify_policy.bucket_version == "default"
else (
UNLIMITED_VERIFY_ATTEMPTS
if verify_policy.limit is None
else verify_policy.limit
)
),
)
except SmsError as e: # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
if not ok:
@@ -522,9 +641,23 @@ def wechat_conflict_continue(
except TokenError as e:
raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e
subject_id = _device_subject(req.device_id, request)
conflict_policy = limit_policy.resolve(
db,
"wechat.conflict.hourly",
phone=claims["phone"],
device=subject_id,
)
conflict_limit = (
SMS_LOGIN_MAX_PER_HOUR
if conflict_policy.override_id is None
and conflict_policy.bucket_version == "default"
else conflict_policy.limit
)
enforce_rate_limit(
request, scope="wechat-conflict-device", subject=req.device_id,
limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
request, scope="wechat-conflict-device", subject=subject_id,
limit=conflict_limit, window_sec=3600, detail="操作过于频繁,请稍后再试",
bucket_suffix=conflict_policy.bucket_version,
)
user = user_repo.get_user_by_phone(db, claims["phone"])
@@ -562,21 +695,50 @@ def wechat_conflict_continue(
def wechat_conflict_rebind(
req: WechatConflictRebindRequest, request: Request, db: DbSession
) -> WechatBindResultResponse:
from app.core.config import settings # 局部 import,避免循环
try:
claims = decode_conflict_ticket(req.conflict_ticket)
except TokenError as e:
raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e
subject_id = _device_subject(req.device_id, request)
conflict_policy = limit_policy.resolve(
db,
"wechat.conflict.hourly",
phone=claims["phone"],
device=subject_id,
)
conflict_limit = (
SMS_LOGIN_MAX_PER_HOUR
if conflict_policy.override_id is None
and conflict_policy.bucket_version == "default"
else conflict_policy.limit
)
enforce_rate_limit(
request, scope="wechat-conflict-device", subject=req.device_id,
limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
request, scope="wechat-conflict-device", subject=subject_id,
limit=conflict_limit, window_sec=3600, detail="操作过于频繁,请稍后再试",
bucket_suffix=conflict_policy.bucket_version,
)
phone = claims["phone"]
if rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS):
days = rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
rebind_policy = limit_policy.resolve(
db,
"phone.rebind.days",
phone=phone,
device=req.device_id,
)
rebind_days = rebind_policy.limit or 0
if rebind_repo.rebound_within_days(
db,
phone,
rebind_days,
reset_at=rebind_policy.reset_at,
):
days = rebind_repo.remaining_block_days(
db,
phone,
rebind_days,
reset_at=rebind_policy.reset_at,
)
raise HTTPException(status_code=409, detail=f"该手机号 {days} 天内已换绑过,暂不能再次换绑")
user = user_repo.rebind_account(
+13 -5
View File
@@ -21,7 +21,6 @@ from __future__ import annotations
import json
import logging
import time
import uuid
from typing import Any
import httpx
@@ -32,6 +31,7 @@ from app.api.deps import DbSession, OptionalUser
from app.core.config import settings
from app.core.logging import trace_id_ctx
from app.core.pricebot_client import get_pricebot_client
from app.core.trace_ids import new_trace_id
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
from app.repositories import comparison as crud_compare
@@ -108,7 +108,7 @@ def _harvest_done_blocking(
def _harvest_abort_blocking(
trace_id: str, status_hint: str, reason: str | None, trace_url: str | None,
) -> None:
) -> int | None:
with SessionLocal() as db:
rec = crud_compare.harvest_abort(
db, trace_id=trace_id, status=status_hint, reason=reason, trace_url=trace_url,
@@ -118,6 +118,7 @@ def _harvest_abort_blocking(
extra={"phase": "harvest_abort",
"status": (rec.status if rec else None), "reason": reason},
)
return rec.id if rec is not None else None
async def _forward(
@@ -141,7 +142,7 @@ async def _forward(
trace_id = meta.get("trace_id")
minted = False
if not trace_id:
trace_id = str(uuid.uuid4())
trace_id = new_trace_id()
meta["trace_id"] = trace_id
raw = json.dumps(meta).encode() # 仅首帧重新序列化(注入 trace_id);后续帧走原始 bytes
minted = True
@@ -291,7 +292,10 @@ async def trace_epilogue(
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)")
async def trace_finalize(
request: Request, user: OptionalUser, db: DbSession
request: Request,
background_tasks: BackgroundTasks,
user: OptionalUser,
db: DbSession,
) -> dict[str, Any]:
_ensure_compare_allowed(user, db)
# 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url};
@@ -302,12 +306,16 @@ async def trace_finalize(
request, "/api/trace/finalize", user, harvest_first_frame=False,
)
try:
await run_in_threadpool(
record_id = await run_in_threadpool(
_harvest_abort_blocking, trace_id,
(meta.get("status") or "cancelled"),
(meta.get("reason") or meta.get("information")),
(resp.get("trace_url") if isinstance(resp, dict) else None),
)
if record_id is not None:
background_tasks.add_task(
backfill_comparison_llm_cost, record_id, trace_id
)
except Exception as e: # noqa: BLE001
logger.warning("harvest_abort failed trace=%s: %s", trace_id, e)
return resp
+47 -5
View File
@@ -16,9 +16,12 @@ import logging
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
from app.api.deps import CurrentUser, DbSession
from app.core import limit_policy
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,
@@ -34,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",
@@ -52,18 +57,30 @@ def reserve_compare_start(
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
):
raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用")
# trace_id 统一由服务端签发(客户端不带时):预占额度本就是任务的第一个请求,
# 签发与建 running 行合一,此后 Phase1/Phase2/记录/前端日志全链用同一个 id。
# 客户端带了则沿用——老客户端兼容 + 同 trace 重试幂等(reserve_daily_start 按 trace_id 去重)。
trace_id = payload.trace_id or new_trace_id()
try:
policy = limit_policy.resolve(
db,
"compare.start.daily",
phone=user.phone,
device=payload.device_id,
)
rec, used = crud_compare.reserve_daily_start(
db,
user_id=user.id,
trace_id=payload.trace_id,
trace_id=trace_id,
business_type=payload.business_type,
device_id=payload.device_id,
limit=policy.limit,
reset_at=policy.reset_at,
)
except crud_compare.DailyCompareStartLimitExceeded:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="今日已比价超过100次,请明天再试",
detail=COMPARE_DAILY_LIMIT_MESSAGE,
) from None
except crud_compare.ComparisonTraceOwnershipError:
raise HTTPException(
@@ -71,11 +88,17 @@ def reserve_compare_start(
detail="比价任务标识冲突,请重新发起",
) from None
# 风控阈值由后台动态配置,不能再只在固定的 100 次业务上限处同步。
risk_repo.sync_compare_incident(db, user_id=user.id, at=rec.created_at)
risk_repo.sync_compare_incident(
db,
user_id=user.id,
at=rec.created_at,
device_id=payload.device_id,
)
return CompareStartReserveOut(
limit=crud_compare.DAILY_COMPARE_START_LIMIT,
limit=policy.limit,
used=used,
remaining=max(crud_compare.DAILY_COMPARE_START_LIMIT - used, 0),
remaining=max(policy.limit - used, 0) if policy.limit is not None else None,
trace_id=trace_id,
)
@@ -131,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,
+31 -5
View File
@@ -21,6 +21,7 @@ from fastapi.concurrency import run_in_threadpool
from app.api.deps import CurrentUser, DbSession
from app.core.config import settings
from app.core.pricebot_client import get_pricebot_client
from app.core.trace_ids import new_trace_id
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo
@@ -30,6 +31,7 @@ from app.schemas.coupon_state import (
CouponPromptShouldShowOut,
CouponPromptShownIn,
CouponSessionIn,
CouponSessionOut,
CouponStatsOut,
)
@@ -175,6 +177,12 @@ async def coupon_step(
)
resp_json = resp.json()
# 每帧响应顶层回传本次任务 trace_id(对齐 compare _forward 的 setdefault):客户端任一帧
# 都能从响应拿到全链 id。**只回显请求里带的、不 mint**——step 是循环接口,每帧签新 id
# 会把一次任务打散;领券 trace_id 的唯一签发点在 /coupon/session (status=started)。
# pricebot 响应顶层本无 trace_id(只有 trace_url),setdefault 不会覆盖任何上游值。
if isinstance(resp_json, dict) and trace_id:
resp_json.setdefault("trace_id", trace_id)
# 领券结果沉淀:每日资产 + 逐次事件;中间帧和 done 全量帧均幂等写库。
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
@@ -204,15 +212,33 @@ async def coupon_step(
return resp_json
@router.post("/session", summary="领券任务流水上报(admin 领券数据看板数据源)")
def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
@router.post(
"/session",
response_model=CouponSessionOut,
summary="领券任务流水上报(admin 领券数据看板数据源;started 兼签发本轮 trace_id)",
)
def coupon_session(payload: CouponSessionIn, db: DbSession) -> CouponSessionOut:
"""客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert
coupon_session不鉴权(同领券循环 MVP, device_id/trace_id); admin领券数据看板算
发起/完成数耗时分位机型维度写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok"""
发起/完成数耗时分位机型维度写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok
trace_id 统一由后端签发:started 不带 trace_id 签发 uuid 并随响应返回,客户端全程用它
(领券 step 循环 / 收尾上报 / 前端运行日志)签发不依赖写库成功写库失败照样返回 trace_id,
后续收尾上报 upsert 会补建行 started trace_id 不签发(收尾没有 id 只能是异常调用,
签发新 id 只会造出一行查不到发起信息的孤儿),不写库trace_id=null 返回
"""
trace_id = payload.trace_id or (
new_trace_id() if payload.status == "started" else None
)
if trace_id is None:
logger.warning(
"coupon session missing trace_id for status=%s (skip write)", payload.status
)
return CouponSessionOut(ok=True, trace_id=None)
try:
coupon_repo.upsert_coupon_session(
db,
trace_id=payload.trace_id,
trace_id=trace_id,
device_id=payload.device_id,
status=payload.status,
started_at_ms=payload.started_at_ms,
@@ -229,7 +255,7 @@ def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
)
except Exception as e: # noqa: BLE001
logger.warning("coupon session write failed: %s", e)
return {"ok": True}
return CouponSessionOut(ok=True, trace_id=trace_id)
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
+33
View File
@@ -19,8 +19,10 @@ from app.api.deps import CurrentUser, DbSession
from app.integrations import vendor_push
from app.repositories import device as device_repo
from app.schemas.device import (
DeferredDeviceUnregisterRequest,
DeviceOut,
DeviceRegisterRequest,
DeviceUnregisterRequest,
HeartbeatRequest,
LivenessAckRequest,
LivenessOut,
@@ -78,6 +80,7 @@ def register_device(
registration_id=req.registration_id,
push_vendor=req.push_vendor,
push_token=req.push_token,
push_binding_id=req.push_binding_id,
platform=req.platform,
app_version=req.app_version,
)
@@ -106,6 +109,36 @@ def report_heartbeat(
registration_id=req.registration_id,
push_vendor=req.push_vendor,
push_token=req.push_token,
push_binding_id=req.push_binding_id,
)
return OkResponse()
@router.post("/unregister", response_model=OkResponse, summary="解绑当前用户的本机推送目标")
def unregister_device(
req: DeviceUnregisterRequest,
user: CurrentUser,
db: DbSession,
) -> OkResponse:
device_repo.unregister_push_binding(db, user_id=user.id, device_id=req.device_id)
logger.info("device unregister user_id=%d device_id=%s", user.id, req.device_id)
return OkResponse()
@router.post(
"/unregister/deferred",
response_model=OkResponse,
summary="离线退出后按登录会话凭据幂等解绑",
)
def unregister_device_deferred(
req: DeferredDeviceUnregisterRequest,
db: DbSession,
) -> OkResponse:
# 不返回是否命中,避免公开接口泄露设备绑定状态。
device_repo.unregister_push_binding_deferred(
db,
device_id=req.device_id,
push_binding_id=req.push_binding_id,
)
return OkResponse()
+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()
+42 -27
View File
@@ -1,22 +1,16 @@
"""新手引导视频(领券等候浮层前 N 次替代广告)。
路由前缀 `/api/v1/guide-video`(均需 Bearer):
POST /start 这次浮层放引导视频还是放广告?命中则**当场计次**并下发 play_token
POST /reward 播完 / 中途关闭都调, play_token 幂等发固定金币
发币额度以**服务端配置**为准(运营后台可改),客户端只报"播完/关闭",报不了金额,
所以被破解也刷不到超额金币;次数上限由 guide_video_play 行数(按账号)硬卡
"""
"""引导视频 prepare/start/reward 客户端 API。"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from app.api.deps import CurrentUser, DbSession
from app.core.ratelimit import rate_limit
from app.repositories import guide_video as crud_guide
from app.schemas.guide_video import (
GuideVideoPrepareIn,
GuideVideoPrepareOut,
GuideVideoRewardIn,
GuideVideoRewardOut,
GuideVideoStartIn,
@@ -24,25 +18,41 @@ from app.schemas.guide_video import (
)
logger = logging.getLogger("shagua.guide_video")
router = APIRouter(prefix="/api/v1/guide-video", tags=["guide-video"])
@router.post(
"/prepare",
response_model=GuideVideoPrepareOut,
dependencies=[Depends(rate_limit(60, 60, "guide-video-prepare"))],
)
def prepare(
payload: GuideVideoPrepareIn, user: CurrentUser, db: DbSession
) -> GuideVideoPrepareOut:
result = crud_guide.prepare_play(db, user.id, scene=payload.scene)
logger.info(
"guide video prepare user_id=%d scene=%s should_play=%s reason=%s",
user.id, payload.scene, result["should_play"], result["reason"],
)
return GuideVideoPrepareOut(**result)
@router.post(
"/start",
response_model=GuideVideoStartOut,
summary="领券浮层是否放新手引导视频(命中即计次)",
dependencies=[Depends(rate_limit(60, 60, "guide-video-start"))],
)
def start(payload: GuideVideoStartIn, user: CurrentUser, db: DbSession) -> GuideVideoStartOut:
"""开播即计数:返回 should_play=True 时服务端已写下这一次,客户端必须真的播。
没配视频 / 开关关 / 次数用完 should_play=False,客户端照旧走广告链路(行为不变)
"""
result = crud_guide.start_play(db, user.id, scene=payload.scene or "coupon")
def start(
payload: GuideVideoStartIn, user: CurrentUser, db: DbSession
) -> GuideVideoStartOut:
try:
result = crud_guide.start_play(db, user.id, play_token=payload.play_token)
except crud_guide.PlayStateError as exc:
status_code = 404 if exc.code == "play_not_found" else 409
raise HTTPException(status_code=status_code, detail=exc.detail()) from exc
logger.info(
"guide video start user_id=%d scene=%s should_play=%s seq=%d remaining=%d",
user.id, payload.scene, result["should_play"], result["seq"], result["remaining"],
"guide video start user_id=%d token=%s status=%s seq=%d",
user.id, payload.play_token[:12], result["status"], result["seq"],
)
return GuideVideoStartOut(**result)
@@ -50,15 +60,20 @@ def start(payload: GuideVideoStartIn, user: CurrentUser, db: DbSession) -> Guide
@router.post(
"/reward",
response_model=GuideVideoRewardOut,
summary="引导视频发金币(播完/中途关闭都发,play_token 幂等)",
dependencies=[Depends(rate_limit(60, 60, "guide-video-reward"))],
dependencies=[Depends(rate_limit(120, 60, "guide-video-reward"))],
)
def reward(payload: GuideVideoRewardIn, user: CurrentUser, db: DbSession) -> GuideVideoRewardOut:
result = crud_guide.grant_play(
db, user.id, play_token=payload.play_token, completed=payload.completed
def reward(
payload: GuideVideoRewardIn, user: CurrentUser, db: DbSession
) -> GuideVideoRewardOut:
result = crud_guide.grant_circle(
db,
user.id,
play_token=payload.play_token,
circle=payload.circle,
)
logger.info(
"guide video reward user_id=%d token=%s completed=%s granted=%s coin=%d",
user.id, payload.play_token[:12], payload.completed, result["granted"], result["coin"],
"guide video reward user_id=%d token=%s circle=%d status=%s granted=%s",
user.id, payload.play_token[:12], payload.circle,
result["status"], result["granted"],
)
return GuideVideoRewardOut(**result)
+312
View File
@@ -0,0 +1,312 @@
"""比价失败报警后台任务:周期扫 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 优先(读到确认没卡则不报,读不到回退保底);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:
stuck = "".join(_fmt_stuck(sp) for sp in res.points)
hit = _dc_replace(make_hit(rec, "T5", "深度放弃"), stuck_point=stuck)
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:
# 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
+60 -3
View File
@@ -79,6 +79,8 @@ class Settings(BaseSettings):
HONOR_PUSH_APP_ID: str = ""
HONOR_PUSH_CLIENT_ID: str = ""
HONOR_PUSH_CLIENT_SECRET: str = ""
# 0=正式消息(默认);1=测试消息(仅开发联调,勿用于生产)。
HONOR_PUSH_TARGET_USER_TYPE: int = Field(default=0, ge=0, le=1)
HONOR_PUSH_TOKEN_ENDPOINT: str = "https://iam.developer.honor.com/auth/token"
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
"https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage"
@@ -88,6 +90,8 @@ class Settings(BaseSettings):
# (OAuth 换 token 时 client_id 即 AppId)。发送走 v1 messages:send,成功码 80000000。
HUAWEI_PUSH_APP_ID: str = ""
HUAWEI_PUSH_APP_SECRET: str = ""
# 0=正式消息(默认,受正式消息频控);1=测试消息(仅开发联调,勿用于生产)。
HUAWEI_PUSH_TARGET_USER_TYPE: int = Field(default=0, ge=0, le=1)
HUAWEI_PUSH_TOKEN_ENDPOINT: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/token"
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
"https://push-api.cloud.huawei.com/v1/{app_id}/messages:send"
@@ -128,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
@@ -146,6 +170,9 @@ class Settings(BaseSettings):
# aliyun:阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A);缺凭证时 /sms/* 返 503(优雅降级)。
# chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。
SMS_PROVIDER: Literal["jiguang", "aliyun", "chuanglan"] = "jiguang"
# 主 provider 供应商不可用(SmsError 503)时转投的备 provider;空=关闭 fallback(默认,
# 保持现状零风险)。仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。
SMS_FALLBACK_PROVIDER: Literal["jiguang", "chuanglan", ""] = ""
ALIYUN_SMS_ACCESS_KEY_ID: str = ""
ALIYUN_SMS_ACCESS_KEY_SECRET: str = ""
ALIYUN_SMS_SIGN_NAME: str = "" # 系统赠送签名(自定义签名下发易失败)
@@ -189,14 +216,43 @@ class Settings(BaseSettings):
# (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
# 猜到号后脚本滥用。两个值都能随时改 .env。逻辑全在 app/core/test_account.py,与其他业务解耦。
# ⚠️ TEST_ACCOUNT_PHONE 留空 = 整个功能关闭(生产默认安全;要启用才显式填号)。
TEST_ACCOUNT_PHONE: str = "" # 测试手机号(11 位,如 11111111111);空=关闭整功能
TEST_ACCOUNT_PHONE: str = "" # 兼容旧配置:单个测试手机号
TEST_ACCOUNT_PHONES: str = "" # 多个测试手机号,英文逗号分隔
TEST_ACCOUNT_DAILY_LIMIT: int = 500 # 该测试号每日最多登录次数,当日超过即拒绝登录
@property
def test_account_phone(self) -> str:
"""规整后的测试手机号(去空白);空串=功能关闭"""
"""兼容旧调用:规整后的单个测试手机号。"""
return self.TEST_ACCOUNT_PHONE.strip()
@property
def test_account_phones(self) -> frozenset[str]:
"""所有测试手机号;新旧配置取并集,便于线上平滑迁移。"""
phones = {
phone.strip()
for phone in self.TEST_ACCOUNT_PHONES.split(",")
if phone.strip()
}
if self.test_account_phone:
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 = ""
@@ -436,6 +492,7 @@ class Settings(BaseSettings):
# 运营后台上传的新手引导视频上限。视频比图片大一个量级,单独一档;
# ⚠️ 改大时同步放宽网关 client_max_body_size(实测 QA 4MiB / prod 32MiB),否则 nginx 先挡下。
GUIDE_VIDEO_MAX_BYTES: int = 100 * 1024 * 1024 # 引导视频最大 100MB
FFPROBE_BINARY: str = "ffprobe"
# ===== 邀请好友 =====
# 分享落地页(二维码 / 分享链接指向这里;扫码 → 落地页 → 引导浏览器下载 APK)。
@@ -488,7 +545,7 @@ class Settings(BaseSettings):
return self.APP_ENV == "prod"
@model_validator(mode="after")
def _enforce_prod_secrets(self) -> "Settings":
def _enforce_prod_secrets(self) -> Settings:
"""prod 下强校验 JWT secret,弱/默认/空即启动报错(fail-fast,挡住 token 被伪造)。
只校验两个签发凭证:App 用户的 JWT_SECRET_KEY后台的 ADMIN_JWT_SECRET它们沿用默认值
+138 -6
View File
@@ -14,12 +14,138 @@ from app.core import rewards as r
RISK_SMS_HOURLY_THRESHOLD_KEY = "risk_sms_hourly_threshold"
RISK_ONECLICK_DAILY_THRESHOLD_KEY = "risk_oneclick_daily_threshold"
RISK_COMPARE_DAILY_THRESHOLD_KEY = "risk_compare_daily_threshold"
COMPARE_DAILY_LIMIT_KEY = "compare_daily_limit"
SMS_SEND_HOURLY_LIMIT_KEY = "sms_send_hourly_limit"
SMS_SEND_DAILY_LIMIT_KEY = "sms_send_daily_limit"
SMS_LOGIN_HOURLY_LIMIT_KEY = "sms_login_hourly_limit"
WECHAT_BIND_SMS_HOURLY_LIMIT_KEY = "wechat_bind_sms_hourly_limit"
WECHAT_CONFLICT_HOURLY_LIMIT_KEY = "wechat_conflict_hourly_limit"
SMS_PHONE_COOLDOWN_SECONDS_KEY = "sms_phone_cooldown_seconds"
SMS_CODE_MAX_FAILED_ATTEMPTS_KEY = "sms_code_max_failed_attempts"
AD_REWARD_VIDEO_DAILY_LIMIT_KEY = "ad_reward_video_daily_limit"
AD_FEED_DAILY_LIMIT_KEY = "ad_feed_daily_limit"
PHONE_REBIND_DAYS_KEY = "phone_rebind_days"
GUIDE_VIDEO_MAX_PLAYS_KEY = "guide_video_max_plays"
LIMIT_POLICY_GLOBAL_KEY = "limit_policy_global"
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool / enum
# hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用
# 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由
# 专用页管理的项从福利页 Tab 收起,同时保留后端默认值与写入能力。
CONFIG_DEFS: dict[str, dict[str, Any]] = {
COMPARE_DAILY_LIMIT_KEY: {
"default": 100,
"label": "账号每日比价次数上限",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 100_000,
"hidden": True,
"help": "北京时间自然日内每个账号可发起的比价次数。",
},
SMS_SEND_HOURLY_LIMIT_KEY: {
"default": 5,
"label": "短信每小时发送上限",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 10_000,
"hidden": True,
"help": "同一设备和 IP 在固定 1 小时窗口内成功发送短信的次数。",
},
SMS_SEND_DAILY_LIMIT_KEY: {
"default": 20,
"label": "短信 24 小时发送上限",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 100_000,
"hidden": True,
"help": "同一设备和 IP 在固定 24 小时窗口内成功发送短信的次数。",
},
SMS_LOGIN_HOURLY_LIMIT_KEY: {
"default": 5,
"label": "短信登录每小时尝试上限",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 10_000,
"hidden": True,
"help": "成功与失败均计入。",
},
WECHAT_BIND_SMS_HOURLY_LIMIT_KEY: {
"default": 5,
"label": "微信短信绑定每小时尝试上限",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 10_000,
"hidden": True,
},
WECHAT_CONFLICT_HOURLY_LIMIT_KEY: {
"default": 5,
"label": "微信冲突处理每小时尝试上限",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 10_000,
"hidden": True,
"help": "继续登录与重新绑定共享此额度。",
},
SMS_PHONE_COOLDOWN_SECONDS_KEY: {
"default": 60,
"label": "同手机号短信发送冷却秒数",
"group": "限制策略",
"type": "int",
"min": 0,
"max": 86_400,
"hidden": True,
},
SMS_CODE_MAX_FAILED_ATTEMPTS_KEY: {
"default": 5,
"label": "单验证码最大失败次数",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 100,
"hidden": True,
},
AD_REWARD_VIDEO_DAILY_LIMIT_KEY: {
"default": r.DAILY_AD_REWARD_LIMIT,
"label": "激励视频每日发奖次数",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 100_000,
"hidden": True,
},
AD_FEED_DAILY_LIMIT_KEY: {
"default": r.DAILY_AD_REWARD_LIMIT,
"label": "Draw 信息流每日发奖次数",
"group": "限制策略",
"type": "int",
"min": 1,
"max": 100_000,
"hidden": True,
},
PHONE_REBIND_DAYS_KEY: {
"default": 30,
"label": "手机/微信换绑冷却天数",
"group": "限制策略",
"type": "int",
"min": 0,
"max": 3650,
"hidden": True,
},
GUIDE_VIDEO_MAX_PLAYS_KEY: {
"default": 3,
"label": "领券引导视频最大播放次数",
"group": "限制策略",
"type": "int",
"min": 0,
"max": 50,
"hidden": True,
},
"signin_rewards": {
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
"group": "签到", "type": "int_list",
@@ -56,7 +182,8 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
},
"ad_daily_limit": {
"default": r.DAILY_AD_REWARD_LIMIT, "label": "看广告每日上限(次)",
"group": "看广告", "type": "int", "help": "福利页激励视频每日可发奖次数上限,默认 500。",
"group": "看广告", "type": "int", "min": 1, "max": 100_000, "hidden": True,
"help": "历史共享上限;新配置由白名单页分别管理激励视频与 Draw 信息流。",
},
"ad_max_coin": {
"default": r.MAX_AD_REWARD_COIN, "label": "看广告单次金币上限",
@@ -68,7 +195,8 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
},
"ad_cooldown_sec": {
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
"group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。",
"group": "看广告", "type": "int", "min": 0, "max": 86_400,
"help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。",
},
"comparing_ad_enabled": {
"default": True, "label": "比价/领券期信息流广告",
@@ -99,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",
},
@@ -118,9 +250,9 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"group": "风控",
"type": "int",
"min": 1,
"max": 5,
"max": 100_000,
"hidden": True,
"help": "同一设备在北京时间同一自然小时内成功下发短信达到该次数时告警;不得高于现有每小时 5 次的发送上限",
"help": "同一设备在北京时间同一自然小时内成功下发短信达到该次数时告警。",
},
RISK_ONECLICK_DAILY_THRESHOLD_KEY: {
"default": 20,
@@ -138,7 +270,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"group": "风控",
"type": "int",
"min": 1,
"max": 100,
"max": 100_000,
"hidden": True,
"help": "同一账户在北京时间同一自然日内发起比价达到该次数时告警。",
},
+623
View File
@@ -0,0 +1,623 @@
"""Unified global limits and per-phone/device policy overrides.
The registry is the single source of truth for the whitelist page. Existing
constants remain as backwards-compatible defaults, while business call sites
resolve an effective value here.
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import delete, or_, select
from sqlalchemy.orm import Session
from app.core.config_schema import (
AD_FEED_DAILY_LIMIT_KEY,
AD_REWARD_VIDEO_DAILY_LIMIT_KEY,
COMPARE_DAILY_LIMIT_KEY,
GUIDE_VIDEO_MAX_PLAYS_KEY,
LIMIT_POLICY_GLOBAL_KEY,
PHONE_REBIND_DAYS_KEY,
RISK_COMPARE_DAILY_THRESHOLD_KEY,
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
RISK_SMS_HOURLY_THRESHOLD_KEY,
SMS_CODE_MAX_FAILED_ATTEMPTS_KEY,
SMS_LOGIN_HOURLY_LIMIT_KEY,
SMS_PHONE_COOLDOWN_SECONDS_KEY,
SMS_SEND_DAILY_LIMIT_KEY,
SMS_SEND_HOURLY_LIMIT_KEY,
WECHAT_BIND_SMS_HOURLY_LIMIT_KEY,
WECHAT_CONFLICT_HOURLY_LIMIT_KEY,
)
from app.models.app_config import AppConfig
from app.models.limit_policy import LimitPolicyOverride
from app.models.user import User
from app.repositories import app_config
MODE_INHERIT = "inherit"
MODE_OVERRIDE = "override"
MODE_UNLIMITED = "unlimited"
MODE_SUPPRESS_ALERT = "suppress_alert"
SUBJECT_PHONE = "phone"
SUBJECT_DEVICE = "device"
SUBJECT_TYPES = (SUBJECT_PHONE, SUBJECT_DEVICE)
SUBJECT_PRECEDENCE = {SUBJECT_DEVICE: 0, SUBJECT_PHONE: 1}
LEGACY_IP_DEVICE_PREFIX = "legacy-ip:"
@dataclass(frozen=True)
class RuleDefinition:
code: str
label: str
group: str
config_key: str
default_limit: int
window_label: str
subject_types: tuple[str, ...]
min_value: int = 1
max_value: int = 100_000
allow_unlimited: bool = True
supports_reset: bool = True
alert_only: bool = False
legacy_config_key: str | None = None
legacy_json_field: str | None = None
@property
def allowed_modes(self) -> tuple[str, ...]:
if self.alert_only:
return (MODE_SUPPRESS_ALERT,)
return (MODE_UNLIMITED,) if self.allow_unlimited else ()
RULES: tuple[RuleDefinition, ...] = (
RuleDefinition(
"compare.start.daily",
"每日发起比价次数",
"比价",
COMPARE_DAILY_LIMIT_KEY,
100,
"北京时间自然日",
SUBJECT_TYPES,
),
RuleDefinition(
"sms.send.hourly",
"短信每小时成功发送次数",
"短信与登录",
SMS_SEND_HOURLY_LIMIT_KEY,
5,
"固定 1 小时窗口",
SUBJECT_TYPES,
max_value=10_000,
),
RuleDefinition(
"sms.send.daily",
"短信 24 小时成功发送次数",
"短信与登录",
SMS_SEND_DAILY_LIMIT_KEY,
20,
"固定 24 小时窗口",
SUBJECT_TYPES,
),
RuleDefinition(
"sms.phone.cooldown",
"同手机号短信发送冷却",
"短信与登录",
SMS_PHONE_COOLDOWN_SECONDS_KEY,
60,
"",
(SUBJECT_PHONE,),
min_value=0,
max_value=86_400,
),
RuleDefinition(
"sms.code.failed_attempts",
"单验证码最大失败次数",
"短信与登录",
SMS_CODE_MAX_FAILED_ATTEMPTS_KEY,
5,
"单个验证码",
(SUBJECT_PHONE,),
max_value=100,
),
RuleDefinition(
"sms.login.hourly",
"短信登录每小时尝试次数",
"短信与登录",
SMS_LOGIN_HOURLY_LIMIT_KEY,
5,
"固定 1 小时窗口",
SUBJECT_TYPES,
max_value=10_000,
),
RuleDefinition(
"wechat.bind.hourly",
"微信短信绑定每小时尝试次数",
"短信与登录",
WECHAT_BIND_SMS_HOURLY_LIMIT_KEY,
5,
"固定 1 小时窗口",
SUBJECT_TYPES,
max_value=10_000,
),
RuleDefinition(
"wechat.conflict.hourly",
"微信冲突处理每小时尝试次数",
"短信与登录",
WECHAT_CONFLICT_HOURLY_LIMIT_KEY,
5,
"固定 1 小时窗口",
SUBJECT_TYPES,
max_value=10_000,
),
RuleDefinition(
"ad.reward_video.daily",
"激励视频每日发奖次数",
"广告",
AD_REWARD_VIDEO_DAILY_LIMIT_KEY,
500,
"北京时间自然日",
(SUBJECT_PHONE,),
legacy_config_key="ad_daily_limit",
),
RuleDefinition(
"ad.feed.daily",
"Draw 信息流每日发奖次数",
"广告",
AD_FEED_DAILY_LIMIT_KEY,
500,
"北京时间自然日",
(SUBJECT_PHONE,),
legacy_config_key="ad_daily_limit",
),
RuleDefinition(
"ad.reward_video.cooldown",
"激励视频发奖后冷却秒数",
"广告",
"ad_cooldown_sec",
3,
"",
(SUBJECT_PHONE,),
min_value=0,
max_value=86_400,
),
RuleDefinition(
"guide.video.lifetime",
"领券引导视频最大播放次数",
"引导与账号",
GUIDE_VIDEO_MAX_PLAYS_KEY,
3,
"账号生命周期",
(SUBJECT_PHONE,),
min_value=0,
max_value=50,
legacy_config_key="coupon_guide_video",
legacy_json_field="max_plays",
),
RuleDefinition(
"phone.rebind.days",
"手机/微信换绑冷却天数",
"引导与账号",
PHONE_REBIND_DAYS_KEY,
30,
"自然日",
(SUBJECT_PHONE,),
min_value=0,
max_value=3650,
),
RuleDefinition(
"risk.sms.hourly",
"短信设备每小时告警",
"风控免告警",
RISK_SMS_HOURLY_THRESHOLD_KEY,
5,
"北京时间自然小时",
(SUBJECT_DEVICE,),
max_value=100_000,
allow_unlimited=False,
alert_only=True,
),
RuleDefinition(
"risk.oneclick.daily",
"一键登录设备每日告警",
"风控免告警",
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
20,
"北京时间自然日",
(SUBJECT_DEVICE,),
max_value=100_000,
allow_unlimited=False,
alert_only=True,
),
RuleDefinition(
"risk.compare.daily",
"比价账号每日告警",
"风控免告警",
RISK_COMPARE_DAILY_THRESHOLD_KEY,
100,
"北京时间自然日",
(SUBJECT_PHONE,),
max_value=100_000,
allow_unlimited=False,
alert_only=True,
),
)
RULE_MAP = {rule.code: rule for rule in RULES}
RULE_CODE_BY_CONFIG_KEY = {rule.config_key: rule.code for rule in RULES}
LIMIT_CONFIG_KEYS = tuple(RULE_CODE_BY_CONFIG_KEY)
@dataclass(frozen=True)
class EffectiveLimit:
rule_code: str
global_limit: int
limit: int | None
mode: str
suppressed: bool
override_id: int | None
matched_subject_type: str | None
matched_subject_value: str | None
reset_at: datetime | None
bucket_version: str
@property
def unlimited(self) -> bool:
return self.limit is None
def normalize_subject(subject_type: str, value: str) -> str:
value = (value or "").strip()
if subject_type == SUBJECT_PHONE:
value = "".join(ch for ch in value if ch.isdigit())
if subject_type not in SUBJECT_TYPES:
raise ValueError(f"unsupported subject type: {subject_type}")
if not value:
raise ValueError("subject value is empty")
return value[:128]
def validate_whitelist_subject(subject_type: str, value: str) -> str:
"""Normalize a whitelist subject and reject unsafe pseudo-devices.
Old clients without a device ID are grouped by public IP for rate limiting.
That fallback remains valid at runtime, but it is not a stable, unique
device identity and must never be persisted as a device whitelist target.
"""
normalized = normalize_subject(subject_type, value)
if (
subject_type == SUBJECT_DEVICE
and normalized.startswith(LEGACY_IP_DEVICE_PREFIX)
):
raise ValueError(
"旧客户端未上报真实设备 ID,不能加入设备白名单,请升级客户端后重试"
)
return normalized
def get_rule(rule_code: str) -> RuleDefinition:
try:
return RULE_MAP[rule_code]
except KeyError as exc:
raise ValueError(f"unknown rule: {rule_code}") from exc
def device_source_scope(rule_code: str) -> str:
"""返回设备候选数据所属命名空间,防止一个设备 ID 跨来源误套规则。"""
rule = get_rule(rule_code)
if SUBJECT_DEVICE not in rule.subject_types:
raise ValueError("当前限制项不支持设备白名单")
return "comparison" if rule_code == "compare.start.daily" else "auth"
def default_global_limits() -> dict[str, int]:
"""Return the complete 16-rule default snapshot keyed by rule code."""
return {rule.code: rule.default_limit for rule in RULES}
def _normalise_global_limits(value: object) -> dict[str, int]:
"""Merge a stored JSON object with safe code defaults.
The migration and every admin write persist all rules. Defaults are still
merged here so a manually damaged/older partial JSON cannot take the
service down after deployment.
"""
values = default_global_limits()
if not isinstance(value, dict):
return values
for rule_code, raw in value.items():
rule = RULE_MAP.get(str(rule_code))
if rule is None or isinstance(raw, bool):
continue
try:
parsed = int(raw)
except (TypeError, ValueError):
continue
if rule.min_value <= parsed <= rule.max_value:
values[rule.code] = parsed
return values
def _legacy_global_limit(db: Session, rule: RuleDefinition) -> tuple[int, str]:
"""Read the pre-bundle representation while upgrading old/test databases."""
row = db.get(AppConfig, rule.config_key)
if row is not None:
return int(row.value), "legacy-key"
if rule.legacy_config_key:
legacy = db.get(AppConfig, rule.legacy_config_key)
if legacy is not None:
value = legacy.value
if rule.legacy_json_field:
value = value.get(rule.legacy_json_field) if isinstance(value, dict) else None
if value is not None:
return int(value), "legacy"
try:
return int(app_config.get_value(db, rule.config_key)), "default"
except KeyError:
return rule.default_limit, "default"
def get_global_limits(db: Session) -> dict[str, int]:
"""Read the complete global-limit JSON, with a pre-migration fallback."""
row = db.get(AppConfig, LIMIT_POLICY_GLOBAL_KEY)
if row is not None:
return _normalise_global_limits(row.value)
return {rule.code: _legacy_global_limit(db, rule)[0] for rule in RULES}
def set_global_limits(
db: Session,
updates: dict[str, int],
*,
admin_id: int,
commit: bool = True,
) -> dict[str, int]:
"""Atomically update selected rules inside the single complete JSON row."""
parsed_updates: dict[str, int] = {}
for rule_code, raw_value in updates.items():
rule = get_rule(rule_code)
value = int(raw_value)
if not rule.min_value <= value <= rule.max_value:
raise ValueError(
f"limit for {rule_code} must be between "
f"{rule.min_value} and {rule.max_value}"
)
parsed_updates[rule.code] = value
row = db.scalar(
select(AppConfig)
.where(AppConfig.key == LIMIT_POLICY_GLOBAL_KEY)
.with_for_update()
)
values = (
_normalise_global_limits(row.value)
if row is not None
else {rule.code: _legacy_global_limit(db, rule)[0] for rule in RULES}
)
values.update(parsed_updates)
if row is None:
row = AppConfig(
key=LIMIT_POLICY_GLOBAL_KEY,
value=values,
updated_by_admin_id=admin_id,
)
db.add(row)
else:
row.value = dict(values)
row.updated_by_admin_id = admin_id
# Once the bundle exists, stale sparse rows must not become a second source
# of truth. The data migration performs the same cleanup for production.
db.execute(delete(AppConfig).where(AppConfig.key.in_(LIMIT_CONFIG_KEYS)))
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
return values
def _global_limit(db: Session, rule: RuleDefinition) -> tuple[int, str]:
row = db.get(AppConfig, LIMIT_POLICY_GLOBAL_KEY)
if row is not None:
return _normalise_global_limits(row.value)[rule.code], "configured"
return _legacy_global_limit(db, rule)
def _aware(value: datetime | None) -> datetime | None:
if value is None:
return None
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
def _matching_overrides(
db: Session,
rule: RuleDefinition,
subjects: dict[str, str | None],
now: datetime,
) -> list[LimitPolicyOverride]:
pairs: list[tuple[str, str]] = []
for subject_type in rule.subject_types:
raw = subjects.get(subject_type)
if raw:
pairs.append((subject_type, normalize_subject(subject_type, raw)))
if not pairs:
return []
clauses = [
(
(LimitPolicyOverride.subject_type == subject_type)
& (LimitPolicyOverride.subject_value == subject_value)
)
for subject_type, subject_value in pairs
]
rows = list(
db.execute(
select(LimitPolicyOverride).where(
LimitPolicyOverride.rule_code == rule.code,
LimitPolicyOverride.enabled.is_(True),
or_(*clauses),
)
).scalars()
)
active = [
row
for row in rows
if (_aware(row.starts_at) is None or _aware(row.starts_at) <= now)
and (_aware(row.expires_at) is None or _aware(row.expires_at) > now)
]
return sorted(active, key=lambda row: SUBJECT_PRECEDENCE[row.subject_type])
def resolve(
db: Session,
rule_code: str,
*,
phone: str | None = None,
device: str | None = None,
now: datetime | None = None,
) -> EffectiveLimit:
"""Resolve global config plus the most specific active override.
Device overrides win over phone overrides when both match.
"""
rule = get_rule(rule_code)
now = _aware(now) or datetime.now(UTC)
global_limit, global_version = _global_limit(db, rule)
matches = _matching_overrides(
db, rule, {SUBJECT_PHONE: phone, SUBJECT_DEVICE: device}, now
)
row = matches[0] if matches else None
if row is None:
return EffectiveLimit(
rule.code,
global_limit,
global_limit,
MODE_INHERIT,
False,
None,
None,
None,
None,
global_version,
)
limit: int | None = global_limit
suppressed = False
if row.mode == MODE_OVERRIDE:
limit = int(row.limit_value) if row.limit_value is not None else global_limit
elif row.mode == MODE_UNLIMITED:
limit = None
elif row.mode == MODE_SUPPRESS_ALERT:
suppressed = True
# 编辑限制值/备注不应隐式清空计数;只有显式“重置状态”才切换桶。
reset_version = _aware(row.reset_at)
version = (
f"{global_version}:o:{row.id}:"
f"{reset_version.isoformat() if reset_version else '0'}"
)
return EffectiveLimit(
rule.code,
global_limit,
limit,
row.mode,
suppressed,
row.id,
row.subject_type,
row.subject_value,
_aware(row.reset_at),
version,
)
def resolve_for_user(
db: Session,
rule_code: str,
user_id: int,
*,
device: str | None = None,
) -> EffectiveLimit:
user = db.get(User, user_id)
return resolve(
db,
rule_code,
phone=user.phone if user is not None else None,
device=device,
)
def rule_catalog(db: Session) -> list[dict]:
out: list[dict] = []
values = get_global_limits(db)
for rule in RULES:
out.append(
{
"code": rule.code,
"label": rule.label,
"group": rule.group,
"global_limit": values[rule.code],
"default_limit": rule.default_limit,
"window_label": rule.window_label,
"subject_types": list(rule.subject_types),
"allowed_modes": list(rule.allowed_modes),
"min_value": rule.min_value,
"max_value": rule.max_value,
"supports_reset": rule.supports_reset,
"alert_only": rule.alert_only,
}
)
return out
def validate_override(
rule: RuleDefinition,
*,
subject_type: str,
mode: str,
limit_value: int | None,
starts_at: datetime | None,
expires_at: datetime | None,
) -> None:
if subject_type not in rule.subject_types:
raise ValueError("该规则不支持此主体类型")
if mode not in rule.allowed_modes:
raise ValueError("该规则不支持此策略模式")
if limit_value is not None:
raise ValueError("白名单不支持覆盖指定值")
if mode in {MODE_UNLIMITED, MODE_SUPPRESS_ALERT} and expires_at is None:
raise ValueError("临时白名单必须设置失效时间")
if starts_at and expires_at and _aware(expires_at) <= _aware(starts_at):
raise ValueError("失效时间必须晚于生效时间")
if (
mode in {MODE_UNLIMITED, MODE_SUPPRESS_ALERT}
and expires_at is not None
and _aware(expires_at) <= datetime.now(UTC)
):
raise ValueError("临时白名单的失效时间必须晚于当前时间")
if mode == MODE_SUPPRESS_ALERT and not rule.alert_only:
raise ValueError("免告警只支持风控监控的三项规则")
def active_overrides(
db: Session,
*,
subject_type: str | None = None,
keyword: str | None = None,
) -> Iterable[LimitPolicyOverride]:
stmt = select(LimitPolicyOverride)
if subject_type:
stmt = stmt.where(LimitPolicyOverride.subject_type == subject_type)
if keyword:
stmt = stmt.where(LimitPolicyOverride.subject_value.ilike(f"%{keyword.strip()}%"))
return db.execute(
stmt.order_by(LimitPolicyOverride.updated_at.desc(), LimitPolicyOverride.id.desc())
).scalars()
+166 -11
View File
@@ -10,11 +10,26 @@
"""
from __future__ import annotations
import json
import logging
import os
import secrets
import subprocess
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。"""
@@ -63,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:
@@ -81,29 +174,91 @@ def _sniff_video_ext(data: bytes) -> str | None:
"""按魔数判定视频类型,返回扩展名;非支持类型返回 None。
只认 MP4 家族(ISO BMFF):`....ftyp` 在偏移 4Android ExoPlayer 与浏览器 <video>
都稳吃 H.264/AAC mp4;放开 mkv/avi 只会让端上放不出来,不如在入口就挡掉
支持 H.264 HEVC/H.265 视频及 AAC 音频;放开 mkv/avi 只会让端上放不出来,
不如在入口就挡掉
"""
if len(data) >= 12 and data[4:8] == b"ftyp":
return ".mp4"
return None
def save_guide_video(data: bytes) -> str:
"""保存新手引导视频(运营后台上传的运营素材),返回相对 URL(`/media/guide_video/<file>`)。
def _probe_guide_video(path: Path) -> dict[str, Any]:
"""用 ffprobe 读取服务端可信的时长/编码信息。"""
try:
proc = subprocess.run(
[
settings.FFPROBE_BINARY,
"-v", "error",
"-show_format",
"-show_streams",
"-of", "json",
str(path),
],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except FileNotFoundError as exc:
raise MediaError("服务器未安装 ffprobe,暂时无法分析视频") from exc
except (OSError, subprocess.TimeoutExpired) as exc:
raise MediaError("ffprobe 分析视频失败") from exc
if proc.returncode != 0:
raise MediaError("无法分析视频,请确认文件是有效的 MP4")
try:
payload = json.loads(proc.stdout)
streams = payload.get("streams") or []
video = next(s for s in streams if s.get("codec_type") == "video")
audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
raw_duration = (
(payload.get("format") or {}).get("duration")
or video.get("duration")
)
duration_ms = int(
(Decimal(str(raw_duration)) * 1000).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
)
except (StopIteration, TypeError, ValueError, InvalidOperation) as exc:
raise MediaError("视频缺少可识别的视频轨或时长") from exc
video_codec = str(video.get("codec_name") or "").lower()
audio_codec = str(audio.get("codec_name") or "").lower() if audio else None
if not 30_000 <= duration_ms <= 180_000:
raise MediaError("视频时长必须在 30180 秒之间")
if video_codec not in ("h264", "hevc", "h265"):
raise MediaError("视频编码必须为 H.264 或 HEVC/H.265")
if audio_codec not in (None, "aac"):
raise MediaError("音频编码必须为 AAC")
return {
"duration_ms": duration_ms,
"video_codec": video_codec,
"audio_codec": audio_codec,
"analysis_status": "valid",
"analysis_error": None,
}
与图片分开一套校验:体积上限走 [settings.GUIDE_VIDEO_MAX_BYTES],类型只认 MP4
"""
def save_guide_video(data: bytes) -> tuple[str, dict[str, Any]]:
"""临时落盘、ffprobe 校验后原子发布,返回 URL 与分析结果。"""
if not data:
raise MediaError("空文件")
limit = settings.GUIDE_VIDEO_MAX_BYTES
if len(data) > limit:
raise MediaError(f"视频过大(上限 {limit // (1024 * 1024)}MB)")
if _sniff_video_ext(data) is None:
raise MediaError("仅支持 MP4 视频(H.264 编码)")
raise MediaError("仅支持 MP4 视频(H.264 或 HEVC/H.265 编码)")
fname = f"guide_{secrets.token_hex(8)}.mp4"
(_media_dir("guide_video") / fname).write_bytes(data)
return f"{settings.MEDIA_URL_PREFIX}/guide_video/{fname}"
directory = _media_dir("guide_video")
stem = f"guide_{secrets.token_hex(8)}"
temp_path = directory / f".{stem}.upload"
final_path = directory / f"{stem}.mp4"
try:
temp_path.write_bytes(data)
analysis = _probe_guide_video(temp_path)
os.replace(temp_path, final_path)
except Exception:
temp_path.unlink(missing_ok=True)
final_path.unlink(missing_ok=True)
raise
return f"{settings.MEDIA_URL_PREFIX}/guide_video/{final_path.name}", analysis
def save_cps_image(admin_id: int, data: bytes) -> str:
+19 -6
View File
@@ -75,10 +75,11 @@ def enforce_rate_limit(
request: Request,
scope: str,
subject: str,
limit: int,
limit: int | None,
window_sec: float,
*,
detail: str = "操作过于频繁,请稍后再试",
bucket_suffix: str = "",
) -> None:
"""在路由内部手动限流,按 (subject, 客户端 IP) 计数。
@@ -87,9 +88,9 @@ def enforce_rate_limit(
key = `scope:subject:client_ip`;同一 (subject, IP) window_sec 内超过 limit 429
[settings.RATE_LIMIT_ENABLED] 总开关控制( [rate_limit] 一致)
"""
if not settings.RATE_LIMIT_ENABLED:
if not settings.RATE_LIMIT_ENABLED or limit is None:
return
key = f"{scope}:{subject}:{_client_ip(request)}"
key = f"{scope}:{subject}:{_client_ip(request)}:{bucket_suffix}"
if not _hit(key, limit, window_sec):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
@@ -112,9 +113,10 @@ class RateLimitRule(NamedTuple):
"""
scope: str
limit: int
limit: int | None
window_sec: float
detail: str = "操作过于频繁,请稍后再试"
bucket_suffix: str = ""
def _peek(key: str, limit: int, window_sec: float) -> bool:
@@ -150,7 +152,13 @@ def check_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]
return
ip = _client_ip(request)
for rule in rules:
if not _peek(f"{rule.scope}:{subject}:{ip}", rule.limit, rule.window_sec):
if rule.limit is None:
continue
if not _peek(
f"{rule.scope}:{subject}:{ip}:{rule.bucket_suffix}",
rule.limit,
rule.window_sec,
):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=rule.detail,
@@ -167,4 +175,9 @@ def record_rate_limits(request: Request, subject: str, rules: list[RateLimitRule
return
ip = _client_ip(request)
for rule in rules:
_commit(f"{rule.scope}:{subject}:{ip}", rule.window_sec)
if rule.limit is None:
continue
_commit(
f"{rule.scope}:{subject}:{ip}:{rule.bucket_suffix}",
rule.window_sec,
)
+13 -2
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),
)
@@ -233,10 +234,20 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: i
eCPM 在此先钳到 AD_ECPM_MAX_FEN(¥500 CPM):信息流广告一期 eCPM 由客户端上报,伪造天价值
会铸天量金币;钳在这唯一入口,feed reward_video 回退客户端 eCPM 的路径都护住,且阈值高于
所有真实值,不影响正规发奖
下限(2026-08,产品口径看了就保底 1):**任何输入**都至少 1 金币,与前端展示公式
FeedRewardFormula.singleUnitCoin 完全对齐(那边注释:"无论 eCPM 是否为空、非法或非正数,
单条广告最低都发 1 金币,不能出现 +0")。此前 eCPM 缺失/为 0 返 0,造成两端不一致:
小球显示 +1后端信息流记 too_short 零发;激励视频侧 "0" 字符串还是 truthy绕过
ecpm_missing `if not ecpm_raw` 判定,落成 granted 0 币且白占当日额度/LT 计数
防刷影响:伪造 eCPM0 每天至多多骗 每日上限×1 金币(500 金币=0.05 ),量级可控;
天价伪造仍由 AD_ECPM_MAX_FEN 钳顶把守
"""
ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0)
if ecpm_yuan <= 0:
return 1 # 保底:缺失/为 0/非法也发 1(镜像前端 validEcpmFen 判非法 → 直接返 1)
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this)
return max(0, round(yuan * COIN_PER_YUAN))
return max(1, round(yuan * COIN_PER_YUAN))
# 签到膨胀(SIGNIN_BOOST_COIN,固定 3000)已于 2026-07 下线:它与广告实际收益脱钩,产品确认
+15 -16
View File
@@ -9,9 +9,8 @@
3. **每日使用次数上限**:防被人猜到这个号后写脚本一直刷当天登录数超过上限即拒绝(429),
次日自动归零
手机号与上限都在 .env (`TEST_ACCOUNT_PHONE` / `TEST_ACCOUNT_DAILY_LIMIT`),随时可改
`TEST_ACCOUNT_PHONE` 留空 = 整个功能关闭(生产默认态),`is_test_account()` 对任何号都返回
False,登录/短信回到原逻辑,零影响
手机号与上限都在 .env (`TEST_ACCOUNT_PHONES` / `TEST_ACCOUNT_DAILY_LIMIT`),随时可改
兼容旧的单号配置 `TEST_ACCOUNT_PHONE`;两项都留空 = 整个功能关闭(生产默认态)
计数存**进程内存**( worker 够用, sms.py 同款约定):重启清零 worker 不共享作为
一个测试号的粗粒度防滥用闸够用;且因所有登录都落同一个 phone 同一个 user,滥用面天然只
@@ -27,9 +26,9 @@ from app.core.config import settings
logger = logging.getLogger("shagua.test_account")
# 进程内每日计数:(date_str, 当日已登录次数)。单 worker 有效,重启清零(见模块 docstring)
# 进程内每日计数:{手机号: (date_str, 当日已登录次数)}。单 worker 有效,重启清零。
_lock = Lock()
_usage: tuple[str, int] = ("", 0)
_usage: dict[str, tuple[str, int]] = {}
def _today() -> str:
@@ -37,16 +36,16 @@ def _today() -> str:
def is_enabled() -> bool:
"""功能总开关:配了 TEST_ACCOUNT_PHONE 才启用(空=关闭)"""
return bool(settings.test_account_phone)
"""功能总开关:至少配置了一个测试手机号才启用"""
return bool(settings.test_account_phones)
def is_test_account(phone: str) -> bool:
"""该手机号是否为配置的测试账号。功能关闭时对任何号都返回 False"""
return is_enabled() and phone == settings.test_account_phone
"""该手机号是否在测试账号集合中"""
return phone in settings.test_account_phones
def try_consume_quota() -> bool:
def try_consume_quota(phone: str) -> bool:
"""测试账号登录时调:当日计数 +1。
Returns:
@@ -59,17 +58,17 @@ def try_consume_quota() -> bool:
limit = settings.TEST_ACCOUNT_DAILY_LIMIT
with _lock:
today = _today()
day, cnt = _usage
day, cnt = _usage.get(phone, ("", 0))
if day != today: # 跨天归零
cnt = 0
if cnt >= limit:
_usage = (today, cnt) # 已满,保持不变
_usage[phone] = (today, cnt) # 已满,保持不变
logger.warning(
"测试账号 %s 今日登录数已达上限 %d,拒绝", settings.test_account_phone, limit
"测试账号 %s 今日登录数已达上限 %d,拒绝", phone, limit
)
return False
_usage = (today, cnt + 1)
logger.info("测试账号 %s%d/%d 次登录", settings.test_account_phone, cnt + 1, limit)
_usage[phone] = (today, cnt + 1)
logger.info("测试账号 %s%d/%d 次登录", phone, cnt + 1, limit)
return True
@@ -77,4 +76,4 @@ def _reset_for_test() -> None:
"""仅供单测:清空进程内计数,隔离用例间状态。"""
global _usage
with _lock:
_usage = ("", 0)
_usage = {}
+29
View File
@@ -0,0 +1,29 @@
"""trace_id 签发(全后端唯一签发口径, 2026-07 起替代裸 uuid4)。
格式: "YYYYMMDD_HHMMSS_" + 12 位小写 hex 随机, 28 字符,
20260731_162254_a1b2c3d4e5f6
Why 带时间前缀: pricebot 落盘目录名/trace_url 尾段**直接用 trace_id 本身**
( pricebot app/utils/trace_ids.py), id/目录/URL 三者合一此前 uuid trace_id
{首帧时刻}_{uuid[:16]} 目录名是两套标识, URL 只有 pricebot 能拼按前缀反查
还有同秒歧义时间用北京时间(CN_TZ)不依赖各机器 TZ 配置, 与业务时区一致
唯一性: 秒级前缀 + 48bit 随机(hex12), 同一秒内碰撞概率可忽略(比价/领券发起 QPS
远低于产生生日碰撞的量级); pricebot 侧同秒多 trace 靠随机段区分(目录精确匹配
llm jsonl 按尾 12 分文件, 不做前缀模糊匹配)
兼容: 三个签发点(compare/startcoupon/session startedcompare.py _forward mint)
统一走这里; 客户端自带 trace_id(老客户端/重试幂等)仍原样沿用pricebot 对老
uuid 格式保持既有目录/短标识行为, 两代 id 并行不冲突
"""
from __future__ import annotations
import uuid
from datetime import datetime
from app.core.rewards import CN_TZ
def new_trace_id() -> str:
"""签发一个自描述 trace_id: 北京时间前缀 + 12 位 hex 随机。"""
return f"{datetime.now(CN_TZ):%Y%m%d_%H%M%S}_{uuid.uuid4().hex[:12]}"
+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)
+81 -22
View File
@@ -1,37 +1,96 @@
"""短信验证码服务 —— provider 分派入口。
"""短信验证码服务 —— provider 分派入口(极光主 + 可选创蓝备)
对外暴露 `send_code` / `verify_code` / `SmsError`,api 层无需关心用哪个 provider
provider `settings.SMS_PROVIDER` 选择(**每次调用读取**,支持运行时切换 + 灰度回退):
- `jiguang`(默认):自管码(本服务生成内存存/校验,极光只发) [jiguang.py](jiguang.py)
- `aliyun`:阿里云号码认证(阿里云生成+下发+校验,Mode A) [aliyun.py](aliyun.py)
- `chuanglan`:创蓝云智(253)模板短信,自管码 Mode B(本服务生成内存存/校验,创蓝只发) [chuanglan.py](chuanglan.py)
mock(`SMS_MOCK=true`)与各 provider 的行为差异都封在 provider 内部;本层只做路由
拆包前本模块是单文件 `sms.py`;拆包后极光逻辑迁入 `jiguang` 子模块,行为零改动
对外暴露 `send_code`返回 SendResult / `verify_code` / `SmsError` / `SendResult`
provider = settings.SMS_PROVIDER默认 jiguang = settings.SMS_FALLBACK_PROVIDER
默认空=无备**每次调用读 settings**支持运行时切换 / 灰度回退
"""
from __future__ import annotations
import logging
from app.core.config import settings
from . import aliyun, chuanglan, jiguang
from .base import SmsError
from .base import SendResult, SmsError
__all__ = ["SmsError", "send_code", "verify_code"]
__all__ = ["SmsError", "SendResult", "send_code", "verify_code"]
# provider 名 -> 模块;未知/缺省值回退 jiguang(默认兜底,防误配把登录打挂)。
_PROVIDERS = {"aliyun": aliyun, "chuanglan": chuanglan}
logger = logging.getLogger("shagua.sms")
# provider 名 <-> 模块。未知/缺省主 provider 回退 jiguang(防误配把登录打挂)。
_ALL = {"jiguang": jiguang, "aliyun": aliyun, "chuanglan": chuanglan}
_NAME = {jiguang: "jiguang", aliyun: "aliyun", chuanglan: "chuanglan"}
def _provider():
"""按配置选 provider 模块(每次调用读 settings,支持运行时切换 / 测试注入)"""
return _PROVIDERS.get(settings.SMS_PROVIDER, jiguang)
def _primary():
"""当前主 provider 模块(未知/缺省回退 jiguang"""
return _ALL.get(settings.SMS_PROVIDER, jiguang)
def send_code(phone: str) -> int:
"""发送验证码,返回距下次可发的冷却秒数;失败抛 SmsError。委托给当前 provider"""
return _provider().send_code(phone)
def _fallback():
"""启用的备 provider 模块;未配置 / 同主 / 未知名 → None= 不启用 fallback"""
name = (settings.SMS_FALLBACK_PROVIDER or "").strip()
if not name or name == settings.SMS_PROVIDER:
return None
return _ALL.get(name)
def verify_code(phone: str, code: str) -> bool:
"""校验验证码,返回是否通过;provider 异常降级抛 SmsError。委托给当前 provider。"""
return _provider().verify_code(phone, code)
def _send_with_cooldown(provider, phone: str, cooldown_sec: int | None) -> int:
if cooldown_sec is None:
return provider.send_code(phone)
return provider.send_code(phone, cooldown_sec=cooldown_sec)
def send_code(
phone: str,
*,
cooldown_sec: int | None = None,
) -> SendResult:
"""发码:主成功即返回;仅主「供应商不可用(503)」且配置了备时转备补发。
429本地冷却/超频400手机号无效不转不绕过防刷不为无效号白烧
备也失败则抛备的 SmsError调用方传入的动态冷却值在主备渠道保持一致
"""
primary = _primary()
fb = _fallback()
try:
cooldown = _send_with_cooldown(primary, phone, cooldown_sec)
return SendResult(cooldown_sec=cooldown, provider=_NAME[primary], fallback=False)
except SmsError as e:
if fb is not None and e.status_code == 503:
logger.warning("[SMS] primary=%s 不可用(%s)fallback→%s",
_NAME[primary], e, _NAME[fb])
cooldown = _send_with_cooldown(fb, phone, cooldown_sec)
return SendResult(cooldown_sec=cooldown, provider=_NAME[fb], fallback=True)
raise
def verify_code(
phone: str,
code: str,
*,
max_failed_attempts: int | None = None,
) -> bool:
"""校验:try-both,遍历「启用的 fallback 链」(主→备),任一命中即 True。
码只存在实际发码那家fallback 前主已 pop 掉自己的码另一家 rec is None False
不误判不累加其防爆破计数动态失败次数上限会一致传给主备渠道
"""
chain = [_primary()]
fb = _fallback()
if fb is not None:
chain.append(fb)
for prov in chain:
verified = (
prov.verify_code(phone, code)
if max_failed_attempts is None
else prov.verify_code(
phone,
code,
max_failed_attempts=max_failed_attempts,
)
)
if verified:
logger.info("[SMS] verify hit provider=%s", _NAME[prov])
return True
return False
+30 -9
View File
@@ -35,7 +35,7 @@ _GC_THRESHOLD = 10000 # 超此阈值,send 时顺手清老于
# 发码错误码 → (HTTP 码, 用户提示)。未列出的一律 503(供应商不可用)。
_SEND_ERRORS: dict[str, tuple[int, str]] = {
"MOBILE_NUMBER_ILLEGAL": (400, "手机号无效"),
"MOBILE_NUMBER_ILLEGAL": (400, "请输入有效的手机号"),
"BUSINESS_LIMIT_CONTROL": (429, "今日发送次数过多,请明天再试"),
"FREQUENCY_FAIL": (429, "发送过于频繁,请稍后再试"),
}
@@ -47,19 +47,26 @@ _client = None # 惰性构建的 SDK client(模块级缓存)
# ============================ 对外:发码 / 校验 ============================
def send_code(phone: str) -> int:
def send_code(phone: str, *, cooldown_sec: int | None = None) -> int:
"""发送验证码(阿里云生成+下发)。
Returns: 距下次可发的秒数(= ALIYUN_SMS_INTERVAL_SEC,冷却由阿里云 Interval 侧执行)
Raises: SmsError(手机号无效 400 / 过频·天级流控 429 / 未配置·未开通·其他 503)
"""
effective_cooldown = (
settings.ALIYUN_SMS_INTERVAL_SEC if cooldown_sec is None else cooldown_sec
)
if settings.SMS_MOCK:
logger.info("[SMS-aliyun-MOCK] to %s**** (不真发)", phone[:3])
return settings.ALIYUN_SMS_INTERVAL_SEC
return effective_cooldown
if not settings.aliyun_sms_configured:
raise SmsError("短信服务未配置(缺阿里云凭证)", status_code=503)
result = _call_send(phone) # 传输/SDK 异常在内部抛 SmsError(503)
result = (
_call_send(phone)
if cooldown_sec is None
else _call_send(phone, cooldown_sec=effective_cooldown)
)
if result["success"] and result["code"] == "OK":
now = time.time()
@@ -68,7 +75,7 @@ def send_code(phone: str) -> int:
_verify_attempts.pop(phone, None) # 新码 = 新失败预算
_verify_seen.pop(phone, None)
logger.info("[SMS-aliyun] sent to %s****", phone[:3])
return settings.ALIYUN_SMS_INTERVAL_SEC
return effective_cooldown
code = result["code"]
logger.error("[SMS-aliyun] send failed code=%s msg=%s", code, result["message"])
@@ -78,7 +85,12 @@ def send_code(phone: str) -> int:
raise SmsError(msg, status_code=status)
def verify_code(phone: str, code: str) -> bool:
def verify_code(
phone: str,
code: str,
*,
max_failed_attempts: int | None = None,
) -> bool:
"""校验验证码(阿里云裁决)。
- **mock**:放行任意 N 位数字(provider 无关,同极光)
@@ -92,8 +104,13 @@ def verify_code(phone: str, code: str) -> bool:
# 失败计数是 best-effort:网络调用不持锁(不能锁跨 IO),故并发下同号可能多放行个位数次。
# 无碍——API 层登录频控(设备+IP 5/时)是硬上限,阿里云码有效期 + DuplicatePolicy 亦兜底。
effective_max_attempts = (
settings.SMS_MAX_VERIFY_ATTEMPTS
if max_failed_attempts is None
else max_failed_attempts
)
with _lock:
if _verify_attempts.get(phone, 0) >= settings.SMS_MAX_VERIFY_ATTEMPTS:
if _verify_attempts.get(phone, 0) >= effective_max_attempts:
return False # 已作废:保持计数(直到 send_code 重置),与极光「达上限即作废」一致
result = _call_check(phone, code) # 传输/SDK 异常在内部抛 SmsError(503)
@@ -146,7 +163,7 @@ def _get_client():
return _client
def _call_send(phone: str) -> dict:
def _call_send(phone: str, *, cooldown_sec: int | None = None) -> dict:
"""调 SendSmsVerifyCode。返回归一化 {success, code, message};import/建 client/调用 任一失败抛 SmsError(503)。"""
valid_min = max(1, settings.ALIYUN_SMS_VALID_TIME_SEC // 60)
template_param = json.dumps({"code": "##code##", "min": str(valid_min)}, ensure_ascii=False)
@@ -160,7 +177,11 @@ def _call_send(phone: str) -> dict:
template_param=template_param,
code_length=settings.ALIYUN_SMS_CODE_LENGTH,
valid_time=settings.ALIYUN_SMS_VALID_TIME_SEC,
interval=settings.ALIYUN_SMS_INTERVAL_SEC,
interval=(
settings.ALIYUN_SMS_INTERVAL_SEC
if cooldown_sec is None
else cooldown_sec
),
scheme_name=settings.ALIYUN_SMS_SCHEME_NAME or None,
)
body = _get_client().send_sms_verify_code(req).body
+16
View File
@@ -2,9 +2,12 @@
provider(jiguang / aliyun) `from .base import SmsError`,api 层也从包入口拿到同一个
`SmsError` 保证无论用哪个 provider,异常类型与 HTTP 码映射语义一致
`SendResult` 是分派层 `send_code` 的返回类型,携带 cooldown实际 provider 及是否走了 fallback
"""
from __future__ import annotations
from dataclasses import dataclass
from app.core.config import settings
@@ -18,6 +21,19 @@ class SmsError(Exception):
self.status_code = status_code
@dataclass(frozen=True)
class SendResult:
"""发码结果:距下次可发秒数 + 实际发码的 provider 名 + 是否走了备(fallback)。
分派层 send_code 的返回类型provider 各自的 send_code 仍返回 int cooldown
由分派层包装auth 层据此把渠道记入风控流水 details
"""
cooldown_sec: int
provider: str
fallback: bool = False
def mock_verify(code: str) -> bool:
"""mock 模式校验:放行任意 SMS_CODE_LENGTH 位数字(provider 无关,测试/开发便利,不真校验)。"""
return len(code) == settings.SMS_CODE_LENGTH and code.isdigit()
+20 -7
View File
@@ -53,7 +53,7 @@ _GC_THRESHOLD = 10000 # 任一内存 dict 超此阈值,send 时顺手清过期
# 发码错误码(创蓝 `code`)→ (HTTP 码, 用户提示)。未列出的一律 503(供应商不可用)。
_SEND_ERRORS: dict[str, tuple[int, str]] = {
"103": (429, "发送过于频繁,请稍后再试"), # 提交速度过快
"107": (400, "手机号无效"), # 手机号码错误
"107": (400, "请输入有效的手机号"), # 手机号码错误
}
# 需运维介入的配置/开通/余额类错误:打 critical 日志(仍归 503)。
_SEND_CRITICAL_CODES = frozenset({
@@ -84,20 +84,23 @@ def _gc(now: float) -> None:
_last_sent.pop(p, None)
def send_code(phone: str) -> int:
def send_code(phone: str, *, cooldown_sec: int | None = None) -> int:
"""发送验证码。
Returns: 距下次可发的秒数(= SMS_SEND_INTERVAL_SEC)
Raises: SmsError(过频 429 / 手机号无效 400 / 供应商失败 503)
"""
now = time.time()
effective_cooldown = (
settings.SMS_SEND_INTERVAL_SEC if cooldown_sec is None else cooldown_sec
)
# --- lock 内:防刷检查 + 预占(防并发重复发烧钱)---
with _lock:
_gc(now) # 顺手清过期内存(超阈值才扫)
elapsed = now - _last_sent.get(phone, 0.0)
if elapsed < settings.SMS_SEND_INTERVAL_SEC:
remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
if elapsed < effective_cooldown:
remain = int(effective_cooldown - elapsed)
raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
code = _gen_code()
@@ -122,10 +125,15 @@ def send_code(phone: str) -> int:
logger.exception("[SMS-chuanglan] send failed phone=%s****", phone[:3])
raise SmsError("验证码发送失败,请稍后重试", status_code=503) from e
return settings.SMS_SEND_INTERVAL_SEC
return effective_cooldown
def verify_code(phone: str, code: str) -> bool:
def verify_code(
phone: str,
code: str,
*,
max_failed_attempts: int | None = None,
) -> bool:
"""校验验证码。
- **mock 模式**:放行任意 N 位数字(测试/开发便利,不真校验)
@@ -136,6 +144,11 @@ def verify_code(phone: str, code: str) -> bool:
logger.info("[SMS-chuanglan-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3])
return ok
effective_max_attempts = (
settings.SMS_MAX_VERIFY_ATTEMPTS
if max_failed_attempts is None
else max_failed_attempts
)
with _lock:
rec = _codes.get(phone)
if rec is None:
@@ -143,7 +156,7 @@ def verify_code(phone: str, code: str) -> bool:
if time.time() > rec.expires_at:
_codes.pop(phone, None)
return False
if rec.attempts >= settings.SMS_MAX_VERIFY_ATTEMPTS:
if rec.attempts >= effective_max_attempts:
_codes.pop(phone, None) # 试错过多,作废
return False
if secrets.compare_digest(code.encode("utf-8"), rec.code.encode("utf-8")):
+20 -7
View File
@@ -70,20 +70,23 @@ def _gc(now: float) -> None:
_last_sent.pop(p, None)
def send_code(phone: str) -> int:
def send_code(phone: str, *, cooldown_sec: int | None = None) -> int:
"""发送验证码。
Returns: 距下次可发的秒数(= SMS_SEND_INTERVAL_SEC)
Raises: SmsError(过频 429 / 当日超限 429 / 供应商失败 503 / 手机号无效 400)
"""
now = time.time()
effective_cooldown = (
settings.SMS_SEND_INTERVAL_SEC if cooldown_sec is None else cooldown_sec
)
# --- lock 内:防刷检查 + 预占(防并发重复发烧钱)---
with _lock:
_gc(now) # 顺手清过期内存(超阈值才扫)
elapsed = now - _last_sent.get(phone, 0.0)
if elapsed < settings.SMS_SEND_INTERVAL_SEC:
remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
if elapsed < effective_cooldown:
remain = int(effective_cooldown - elapsed)
raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
code = _gen_code()
@@ -108,10 +111,15 @@ def send_code(phone: str) -> int:
logger.exception("[SMS] send failed phone=%s****", phone[:3])
raise SmsError("验证码发送失败,请稍后重试", status_code=503) from e
return settings.SMS_SEND_INTERVAL_SEC
return effective_cooldown
def verify_code(phone: str, code: str) -> bool:
def verify_code(
phone: str,
code: str,
*,
max_failed_attempts: int | None = None,
) -> bool:
"""校验验证码。
- **mock 模式**:放行任意 N 位数字(测试/开发便利,不真校验)
@@ -122,6 +130,11 @@ def verify_code(phone: str, code: str) -> bool:
logger.info("[SMS-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3])
return ok
effective_max_attempts = (
settings.SMS_MAX_VERIFY_ATTEMPTS
if max_failed_attempts is None
else max_failed_attempts
)
with _lock:
rec = _codes.get(phone)
if rec is None:
@@ -129,7 +142,7 @@ def verify_code(phone: str, code: str) -> bool:
if time.time() > rec.expires_at:
_codes.pop(phone, None)
return False
if rec.attempts >= settings.SMS_MAX_VERIFY_ATTEMPTS:
if rec.attempts >= effective_max_attempts:
_codes.pop(phone, None) # 试错过多,作废
return False
if secrets.compare_digest(code.encode("utf-8"), rec.code.encode("utf-8")):
@@ -185,5 +198,5 @@ def _send_via_jiguang(phone: str, code: str) -> None:
if ecode == 50009: # 极光侧超频
raise SmsError("发送过于频繁,请稍后再试", status_code=429)
if ecode == 50006: # 手机号无效(schema 已挡格式,这里多是空号/停机)
raise SmsError("手机号无效", status_code=400)
raise SmsError("请输入有效的手机号", status_code=400)
raise SmsError(f"短信发送失败(code={ecode})", status_code=503)
+459 -47
View File
@@ -22,7 +22,7 @@ import uuid
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote
from urllib.parse import quote, urlsplit
import httpx
@@ -31,6 +31,7 @@ from app.core.config import settings
logger = logging.getLogger("shagua.vendor_push")
TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled"
DATA_EVENT_NOTIFICATION_CREATED = "notification_created"
SUPPORTED_VENDORS = frozenset({"honor", "huawei", "vivo", "xiaomi", "oppo"})
# vendor key → 中文名(测试/配置状态接口展示用)
@@ -69,6 +70,18 @@ class _CachedToken:
_token_cache: dict[str, _CachedToken] = {}
_LOG_SUMMARY_MAX_CHARS = 1500
_LOG_STRING_MAX_CHARS = 200
_SENSITIVE_LOG_KEYS = {
"accesstoken",
"appkey",
"authtoken",
"authorization",
"clientsecret",
"mastersecret",
"sign",
}
def normalize_vendor(push_vendor: str | None) -> str | None:
if not push_vendor:
@@ -115,8 +128,16 @@ def send_notification(
if mock:
logger.info(
"[mock push] vendor=%s token=%s... title=%s body=%s extras=%s",
vendor, token[:12], title, body, extras,
"vendor push mock vendor=%s request=%s",
vendor,
_log_summary(
{
"push_token": token,
"title": title,
"body": body,
"extras": extras,
}
),
)
return {
"mock": True,
@@ -133,7 +154,28 @@ def send_notification(
"xiaomi": _send_xiaomi,
"oppo": _send_oppo,
}
return dispatch[vendor](token, title, body, extras)
started = time.perf_counter()
try:
result = dispatch[vendor](token, title, body, extras)
except Exception:
logger.exception(
"vendor push dispatch failed vendor=%s token=%s notification_type=%s "
"elapsed_ms=%.1f",
vendor,
token,
extras.get("type", ""),
_elapsed_ms(started),
)
raise
logger.info(
"vendor push dispatch succeeded vendor=%s token=%s notification_type=%s "
"elapsed_ms=%.1f",
vendor,
token,
extras.get("type", ""),
_elapsed_ms(started),
)
return result
def send_accessibility_disabled(
@@ -153,19 +195,191 @@ def send_accessibility_disabled(
)
def send_data_event(
push_vendor: str,
push_token: str,
*,
event: str,
notification_id: str,
mock: bool = False,
) -> dict[str, Any]:
"""发送无界面的轻量事件;当前只允许站内消息创建事件。"""
vendor = normalize_vendor(push_vendor)
token = push_token.strip() if push_token else ""
if not vendor or vendor not in SUPPORTED_VENDORS:
raise VendorPushError(f"unsupported push vendor: {push_vendor}")
if not token:
raise VendorPushError("push token is empty")
if event != DATA_EVENT_NOTIFICATION_CREATED:
raise VendorPushError(f"unsupported data event: {event}")
if not notification_id:
raise VendorPushError("notification_id is empty")
payload = {"event": event, "notificationId": str(notification_id)}
if mock:
return {"mock": True, "vendor": vendor, "payload": payload}
# vivo 的通知已设置 foregroundShow=falseApp 在前台时必走
# onForegroundMessageArrived,正好就是本事件需要的刷新信号;不重复占用一次推送配额。
if vendor == "vivo":
return {"skipped": True, "reason": "foreground notification callback"}
# 小米服务端不再发送透传消息;通知栏消息仍照常发送,前台角标由客户端主动刷新。
if vendor == "xiaomi":
return {"skipped": True, "reason": "xiaomi data messages disabled"}
# OPush 当前只支持通知栏消息,没有服务端透传单推接口。
# 客户端在 OPPO 首页可见时轮询未读数;这里必须安全跳过,不能请求不存在的
# /message/transparent/unicast(该地址会稳定返回 HTTP 404)。
if vendor == "oppo":
return {"skipped": True, "reason": "oppo does not support data messages"}
dispatch: dict[str, Callable[[str, dict[str, str]], dict[str, Any]]] = {
"honor": _send_honor_data,
"huawei": _send_huawei_data,
}
return dispatch[vendor](token, payload)
def _require(value: str, name: str) -> str:
if not value:
raise VendorPushError(f"{name} not configured")
return value
def _elapsed_ms(started: float) -> float:
return (time.perf_counter() - started) * 1000
def _redacted_value(value: Any) -> str:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
digest = hashlib.sha256(raw.encode()).hexdigest()[:10]
return f"<redacted len={len(raw)} sha256={digest}>"
def _sanitize_for_log(value: Any, *, key: str = "", depth: int = 0) -> Any:
"""生成有排障价值、但不暴露服务端鉴权凭据的紧凑日志摘要。"""
normalized_key = "".join(char for char in key.lower() if char.isalnum())
if normalized_key in _SENSITIVE_LOG_KEYS or normalized_key.endswith("secret"):
return _redacted_value(value)
if depth >= 10:
return f"<{type(value).__name__}>"
if isinstance(value, dict):
items = list(value.items())
sanitized = {
str(item_key): _sanitize_for_log(item_value, key=str(item_key), depth=depth + 1)
for item_key, item_value in items[:30]
}
if len(items) > 30:
sanitized["<omitted_keys>"] = len(items) - 30
return sanitized
if isinstance(value, (list, tuple)):
items = list(value)
sanitized = [_sanitize_for_log(item, key=key, depth=depth + 1) for item in items[:20]]
if len(items) > 20:
sanitized.append(f"<omitted_items={len(items) - 20}>")
return sanitized
if isinstance(value, str):
if value.lstrip().startswith(("{", "[")):
try:
decoded = json.loads(value)
except ValueError:
pass
else:
return _sanitize_for_log(decoded, key=key, depth=depth + 1)
if len(value) > _LOG_STRING_MAX_CHARS:
return f"{value[:_LOG_STRING_MAX_CHARS]}…<len={len(value)}>"
return value
def _log_summary(value: Any) -> str:
rendered = json.dumps(
_sanitize_for_log(value),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
if len(rendered) > _LOG_SUMMARY_MAX_CHARS:
return f"{rendered[:_LOG_SUMMARY_MAX_CHARS]}…<len={len(rendered)}>"
return rendered
def _raw_log_summary(value: Any) -> str:
"""厂商响应摘要:保留原始字段和值,仅限制单条日志长度。"""
rendered = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
if len(rendered) > _LOG_SUMMARY_MAX_CHARS:
return f"{rendered[:_LOG_SUMMARY_MAX_CHARS]}…<len={len(rendered)}>"
return rendered
def _endpoint_for_log(url: str) -> str:
parsed = urlsplit(url)
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
def _request_summary(kwargs: dict[str, Any]) -> str:
summary: dict[str, Any] = {}
if "json" in kwargs:
summary["body_type"] = "json"
summary["request_payload"] = kwargs["json"]
elif "data" in kwargs:
summary["body_type"] = "form"
summary["request_payload"] = kwargs["data"]
if kwargs.get("params"):
summary["params"] = kwargs["params"]
if kwargs.get("headers"):
summary["headers"] = kwargs["headers"]
return _log_summary(summary)
def _response_summary(resp: Any, parsed: Any | None = None) -> str:
if parsed is None:
try:
parsed = resp.json()
except ValueError:
pass
if parsed is not None:
return _raw_log_summary(parsed)
return _raw_log_summary(getattr(resp, "text", ""))
def _vendor_response_failed(vendor: str, data: dict[str, Any]) -> bool:
"""识别 HTTP 200 中明确的厂商业务失败,避免将失败请求记录成 success。"""
if data.get("error") or data.get("success") is False:
return True
if vendor == "xiaomi":
code = data.get("code")
result = str(data.get("result", "ok")).lower()
return code not in (0, "0", None) or result not in ("ok", "success")
if vendor == "oppo" and data.get("code") is not None:
return int(data["code"]) != 0
if vendor == "vivo" and data.get("result") is not None:
return int(data["result"]) != 0
if vendor == "honor" and data.get("code") is not None:
return int(data["code"]) != 200
if vendor == "huawei" and data.get("code") is not None:
return str(data["code"]) != "80000000"
return False
def _request_json(
method: str,
url: str,
*,
vendor: str,
operation: str,
expected_status: tuple[int, ...] = (200,),
**kwargs: Any,
) -> dict[str, Any]:
started = time.perf_counter()
request_summary = _request_summary(kwargs)
try:
resp = httpx.request(
method,
@@ -174,41 +388,85 @@ def _request_json(
**kwargs,
)
except httpx.HTTPError as e:
logger.error(
"vendor push http completed vendor=%s operation=%s method=%s endpoint=%s "
"outcome=network_error request=%s response=%s elapsed_ms=%.1f error=%s",
vendor,
operation,
method,
_endpoint_for_log(url),
request_summary,
"<no_response>",
_elapsed_ms(started),
type(e).__name__,
)
raise VendorPushError(f"push http error: {e}") from e
if resp.status_code not in expected_status:
logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
logger.error(
"vendor push http completed vendor=%s operation=%s method=%s endpoint=%s "
"outcome=http_error http_status=%s request=%s response=%s elapsed_ms=%.1f",
vendor,
operation,
method,
_endpoint_for_log(url),
resp.status_code,
request_summary,
_response_summary(resp),
_elapsed_ms(started),
)
raise VendorPushError(f"push http {resp.status_code}")
try:
return resp.json()
data = resp.json()
except ValueError as e:
logger.error(
"vendor push http completed vendor=%s operation=%s method=%s endpoint=%s "
"outcome=invalid_json http_status=%s request=%s response=%s elapsed_ms=%.1f",
vendor,
operation,
method,
_endpoint_for_log(url),
resp.status_code,
request_summary,
_response_summary(resp),
_elapsed_ms(started),
)
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
vendor_failed = _vendor_response_failed(vendor, data)
log = logger.warning if vendor_failed else logger.info
log(
"vendor push http completed vendor=%s operation=%s method=%s endpoint=%s "
"outcome=%s http_status=%s request=%s response=%s elapsed_ms=%.1f",
vendor,
operation,
method,
_endpoint_for_log(url),
"vendor_error" if vendor_failed else "success",
resp.status_code,
request_summary,
_response_summary(resp, data),
_elapsed_ms(started),
)
return data
def _request_form(
method: str,
url: str,
*,
vendor: str,
operation: str,
expected_status: tuple[int, ...] = (200,),
**kwargs: Any,
) -> dict[str, Any]:
try:
resp = httpx.request(
method,
url,
timeout=settings.PUSH_REQUEST_TIMEOUT_SEC,
**kwargs,
)
except httpx.HTTPError as e:
raise VendorPushError(f"push http error: {e}") from e
if resp.status_code not in expected_status:
logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
raise VendorPushError(f"push http {resp.status_code}")
try:
return resp.json()
except ValueError as e:
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
return _request_json(
method,
url,
vendor=vendor,
operation=operation,
expected_status=expected_status,
**kwargs,
)
def _cache_get(key: str) -> str | None:
@@ -234,6 +492,8 @@ def _honor_access_token() -> str:
data = _request_form(
"POST",
settings.HONOR_PUSH_TOKEN_ENDPOINT,
vendor="honor",
operation="authenticate",
data={
"grant_type": "client_credentials",
"client_id": client_id,
@@ -243,25 +503,31 @@ def _honor_access_token() -> str:
)
token = data.get("access_token")
if not token:
raise VendorPushError(f"honor auth failed: {data}")
raise VendorPushError(f"honor auth failed: {_raw_log_summary(data)}")
return _cache_put(cache_key, str(token), data.get("expires_in"))
def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID")
access_token = _honor_access_token()
click_action: dict[str, Any]
if extras.get("notificationId"):
# type=3 只负责打开首页,不保证 data 会变成目标 Activity 的 extras。消息中心通知必须
# 用自定义页面(type=1)+ intent URI,把 notificationId/type/feedbackId 等直接带给
# MainActivity;否则华为/荣耀系统能展示通知,但用户点击后客户端收不到任何导航参数。
click_action = {"type": 1, "intent": _click_intent_uri(extras)}
else:
click_action = {"type": 3}
payload = {
# clickAction type=3(打开应用首页)时,荣耀点击会把 data JSON 的键值对注入启动 intent 的
# extras(与 HMS 同机制)→ MainActivity.consumeNavTarget 读 notif_id/notif_type 直达落地。
"data": json.dumps(_click_extras(extras), ensure_ascii=False),
"notification": {"title": title, "body": body},
"android": {
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
"targetUserType": 1,
"targetUserType": settings.HONOR_PUSH_TARGET_USER_TYPE,
"notification": {
"title": title,
"body": body,
"clickAction": {"type": 3},
"clickAction": click_action,
"importance": "NORMAL",
},
},
@@ -270,6 +536,8 @@ def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> di
data = _request_json(
"POST",
settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
vendor="honor",
operation="send_notification",
json=payload,
headers={
"Content-Type": "application/json; charset=UTF-8",
@@ -279,7 +547,28 @@ def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> di
)
code = data.get("code")
if code is not None and int(code) != 200:
raise VendorPushError(f"honor push failed: {data}")
raise VendorPushError(f"honor push failed: {_raw_log_summary(data)}")
return data
def _send_honor_data(token: str, payload: dict[str, str]) -> dict[str, Any]:
app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID")
access_token = _honor_access_token()
data = _request_json(
"POST",
settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
vendor="honor",
operation="send_data_event",
json={"data": json.dumps(payload, ensure_ascii=False), "token": [token]},
headers={
"Content-Type": "application/json; charset=UTF-8",
"Authorization": f"Bearer {access_token}",
"timestamp": str(int(time.time() * 1000)),
},
)
code = data.get("code")
if code is not None and int(code) != 200:
raise VendorPushError(f"honor data push failed: {_raw_log_summary(data)}")
return data
@@ -294,6 +583,8 @@ def _huawei_access_token() -> str:
data = _request_form(
"POST",
settings.HUAWEI_PUSH_TOKEN_ENDPOINT,
vendor="huawei",
operation="authenticate",
data={
"grant_type": "client_credentials",
"client_id": app_id,
@@ -303,7 +594,7 @@ def _huawei_access_token() -> str:
)
token = data.get("access_token")
if not token:
raise VendorPushError(f"huawei auth failed: {data}")
raise VendorPushError(f"huawei auth failed: {_raw_log_summary(data)}")
return _cache_put(cache_key, str(token), data.get("expires_in"))
@@ -312,18 +603,24 @@ def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> d
'80100000' 为部分成功( token 场景仍视为失败,错误里带原始响应便于排障)"""
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
access_token = _huawei_access_token()
click_action: dict[str, Any]
if extras.get("notificationId"):
# type=3 只打开首页,Mate 20/EMUI 不会把 message.data 自动拆成启动 Intent extras。
# type=1 的自定义 intent 才能稳定携带反馈记录 id,且冷启动/onNewIntent 都走同一路由。
click_action = {"type": 1, "intent": _click_intent_uri(extras)}
else:
click_action = {"type": 3}
payload = {
"validate_only": False,
"message": {
# click_action type=3(打开应用首页)时,HMS 点击会把 data JSON 的键值对注入启动 intent
# 的 extras → MainActivity.consumeNavTarget 读 notif_id/notif_type 直达落地。
"data": json.dumps(_click_extras(extras), ensure_ascii=False),
"android": {
"target_user_type": settings.HUAWEI_PUSH_TARGET_USER_TYPE,
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
"notification": {
"title": title,
"body": body,
"click_action": {"type": 3},
"click_action": click_action,
"importance": "NORMAL",
},
},
@@ -333,6 +630,8 @@ def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> d
data = _request_json(
"POST",
settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
vendor="huawei",
operation="send_notification",
json=payload,
headers={
"Content-Type": "application/json; charset=UTF-8",
@@ -340,7 +639,32 @@ def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> d
},
)
if str(data.get("code", "")) != "80000000":
raise VendorPushError(f"huawei push failed: {data}")
raise VendorPushError(f"huawei push failed: {_raw_log_summary(data)}")
return data
def _send_huawei_data(token: str, payload: dict[str, str]) -> dict[str, Any]:
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
access_token = _huawei_access_token()
data = _request_json(
"POST",
settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
vendor="huawei",
operation="send_data_event",
json={
"validate_only": False,
"message": {
"data": json.dumps(payload, ensure_ascii=False),
"token": [token],
},
},
headers={
"Content-Type": "application/json; charset=UTF-8",
"Authorization": f"Bearer {access_token}",
},
)
if str(data.get("code", "")) != "80000000":
raise VendorPushError(f"huawei data push failed: {_raw_log_summary(data)}")
return data
@@ -357,6 +681,8 @@ def _vivo_auth_token() -> str:
data = _request_json(
"POST",
settings.VIVO_PUSH_AUTH_ENDPOINT,
vendor="vivo",
operation="authenticate",
json={
"appId": app_id,
"appKey": app_key,
@@ -366,10 +692,10 @@ def _vivo_auth_token() -> str:
headers={"Content-Type": "application/json"},
)
if int(data.get("result", -1)) != 0:
raise VendorPushError(f"vivo auth failed: {data}")
raise VendorPushError(f"vivo auth failed: {_raw_log_summary(data)}")
token = data.get("authToken")
if not token:
raise VendorPushError(f"vivo auth missing authToken: {data}")
raise VendorPushError(f"vivo auth missing authToken: {_raw_log_summary(data)}")
return _cache_put(cache_key, str(token), 24 * 3600)
@@ -385,15 +711,22 @@ def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dic
"timeToLive": settings.PUSH_TIME_TO_LIVE_SEC,
"requestId": uuid.uuid4().hex,
"pushMode": settings.VIVO_PUSH_MODE,
# vivo SDK 4.1.5 只有在前台展示关闭时才调用
# OpenClientPushMessageReceiver.onForegroundMessageArrived。
# App 在该回调中刷新服务端未读数并补发一条本地通知;后台/锁屏时仍由
# vivo 系统展示,因而任何场景都只会出现一条通知。
"foregroundShow": False,
# vivo 官方 VPush 角标字段:离线收到通知时先由桌面自动 +1;
# App 启动/同步后再由统一未读数通过系统 API 精确校准。
"addBadge": True,
"clientCustomMap": extras,
}
# 点击落地:消息中心推送(带 notificationId)→ skipType=4 + skipContent=intent uri,由 vivo
# 系统直启 MainActivity 并携带 S. extras(与小米 notify_effect=2 同机制)。不依赖客户端
# VivoPushReceiver.onNotificationMessageClicked 里的后台 startActivity——Android 10+ BAL
# 会静默拦掉,receiver 路径仅作兜底。无 notificationId 的召回类保持 skipType=1 仅打开首页。
# vivo Push SDK 480+ 已不再回调 skipType=3;必须使用 skipType=4 的完整 Intent URI。
# URI 同时包含 data/scheme、显式 component 和 S. extrasOriginOS 才会把业务参数
# 原样交给 MainActivity(仅靠隐式 deeplink 会打开 App,但可能剥掉 extras)。
if extras.get("notificationId"):
payload["skipType"] = 4
payload["skipContent"] = _click_intent_uri(extras)
payload["skipContent"] = _vivo_click_intent_uri(extras)
else:
payload["skipType"] = 1
if settings.VIVO_PUSH_CATEGORY:
@@ -401,14 +734,37 @@ def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dic
data = _request_json(
"POST",
settings.VIVO_PUSH_SEND_ENDPOINT,
vendor="vivo",
operation="send_notification",
json=payload,
headers={
"Content-Type": "application/json",
"authToken": auth_token,
},
)
# 10089 = 当前 vivo 应用尚未开通 VPush「离线自动角标」能力。
# 角标只是通知中心未读数的镜像,不能因此让整条通知发送失败:去掉 addBadge
# 原样重试,通知到达/应用同步后仍由客户端系统 API 按统一未读数精确设置。
if int(data.get("result", -1)) == 10089:
logger.warning("vivo VPush badge not enabled (10089); retrying without addBadge")
fallback_payload = dict(payload)
fallback_payload.pop("addBadge", None)
fallback_payload["requestId"] = uuid.uuid4().hex
data = _request_json(
"POST",
settings.VIVO_PUSH_SEND_ENDPOINT,
vendor="vivo",
operation="send_notification",
json=fallback_payload,
headers={
"Content-Type": "application/json",
"authToken": auth_token,
},
)
if int(data.get("result", -1)) == 0:
data = {**data, "badgeFallback": True}
if int(data.get("result", -1)) != 0:
raise VendorPushError(f"vivo push failed: {data}")
raise VendorPushError(f"vivo push failed: {_raw_log_summary(data)}")
return data
@@ -446,14 +802,43 @@ def _send_xiaomi(token: str, title: str, body: str, extras: dict[str, str]) -> d
data = _request_form(
"POST",
settings.XIAOMI_PUSH_SEND_ENDPOINT,
vendor="xiaomi",
operation="send_notification",
data=form,
headers={"Authorization": f"key={app_secret}"},
)
code = data.get("code")
if code not in (0, "0", None):
raise VendorPushError(f"xiaomi push failed: {data}")
raise VendorPushError(f"xiaomi push failed: {_raw_log_summary(data)}")
if str(data.get("result", "ok")).lower() not in ("ok", "success"):
raise VendorPushError(f"xiaomi push failed: {data}")
raise VendorPushError(f"xiaomi push failed: {_raw_log_summary(data)}")
return data
def _send_xiaomi_data(token: str, payload: dict[str, str]) -> dict[str, Any]:
app_secret = _require(settings.XIAOMI_PUSH_APP_SECRET, "XIAOMI_PUSH_APP_SECRET")
form = {
"registration_id": token,
"restricted_package_name": settings.ANDROID_PACKAGE_NAME,
"payload": json.dumps(payload, ensure_ascii=False),
"pass_through": "1",
"time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000),
}
if settings.XIAOMI_PUSH_CHANNEL_ID.strip():
form["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip()
data = _request_form(
"POST",
settings.XIAOMI_PUSH_SEND_ENDPOINT,
vendor="xiaomi",
operation="send_data_event",
data=form,
headers={"Authorization": f"key={app_secret}"},
)
code = data.get("code")
if code not in (0, "0", None):
raise VendorPushError(f"xiaomi data push failed: {_raw_log_summary(data)}")
if str(data.get("result", "ok")).lower() not in ("ok", "success"):
raise VendorPushError(f"xiaomi data push failed: {_raw_log_summary(data)}")
return data
@@ -490,6 +875,24 @@ def _click_intent_uri(extras: dict[str, str]) -> str:
return ";".join(parts)
def _vivo_click_intent_uri(extras: dict[str, str]) -> str:
"""vivo skipType=4 使用官方要求的完整 intent deeplink 形态。
`?#Intent` 中的问号不能省;OriginOS 对缺少它的 URI 会退化为“仅打开应用”,
不把 S. 参数交给目标 Activity
"""
pkg = settings.ANDROID_PACKAGE_NAME
parts = [
"intent://push/detail?#Intent",
"scheme=shaguabijia",
f"component={pkg}/{pkg}.MainActivity",
"launchFlags=0x14000000",
]
parts += [f"S.{key}={quote(str(value), safe='')}" for key, value in _click_extras(extras).items()]
parts.append("end")
return ";".join(parts)
def _xiaomi_template_param(title: str, alert: str) -> str:
rendered = (
settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON
@@ -522,6 +925,8 @@ def _oppo_auth_token() -> str:
data = _request_form(
"POST",
settings.OPPO_PUSH_AUTH_ENDPOINT,
vendor="oppo",
operation="authenticate",
data={
"app_key": app_key,
"timestamp": timestamp,
@@ -530,10 +935,10 @@ def _oppo_auth_token() -> str:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if int(data.get("code", -1)) != 0:
raise VendorPushError(f"oppo auth failed: {data}")
raise VendorPushError(f"oppo auth failed: {_raw_log_summary(data)}")
token = (data.get("data") or {}).get("auth_token") or data.get("auth_token")
if not token:
raise VendorPushError(f"oppo auth missing auth_token: {data}")
raise VendorPushError(f"oppo auth missing auth_token: {_raw_log_summary(data)}")
return _cache_put(cache_key, str(token), 24 * 3600)
@@ -548,6 +953,11 @@ def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dic
"off_line_ttl": ttl_hours,
"action_parameters": json.dumps(_click_extras(extras), ensure_ascii=False),
}
if extras.get("notificationId"):
# 只有已落库的站内消息才计入角标;保活提醒等临时推送不应留下无法消除的未读数。
# 客户端打开/已读后会按服务端真实未读总数覆盖,避免长期漂移。
notification["badge_operation_type"] = 1
notification["badge_message_count"] = 1
# 点击落地:OPPO SDK 没有点击回调,参数只能靠服务端点击动作配置送达——action_parameters 的
# 键值对仅在 click_action_type=1/4 时才会注入目标 Activity 的 intent extras(type=0「启动应用」
# 会忽略它,extras 全丢 → 点了没反应,与小米 notify_effect=1 同款坑)。
@@ -573,6 +983,8 @@ def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dic
data = _request_form(
"POST",
settings.OPPO_PUSH_SEND_ENDPOINT,
vendor="oppo",
operation="send_notification",
data={
"auth_token": auth_token,
"message": json.dumps(message, ensure_ascii=False),
@@ -580,5 +992,5 @@ def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dic
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if int(data.get("code", -1)) != 0:
raise VendorPushError(f"oppo push failed: {data}")
raise VendorPushError(f"oppo push failed: {_raw_log_summary(data)}")
return data
+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",
)
+1
View File
@@ -36,6 +36,7 @@ from app.models.inactivity import ( # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
from app.models.limit_policy import LimitPolicyOverride # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.notification import Notification # noqa: F401
from app.models.onboarding import OnboardingCompletion # noqa: F401
+22 -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,17 +100,26 @@ 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…";
# 失败:具体原因(如"美团、京东外卖均未找到该商品")。前端在比价失败时当原因展示。
information: Mapped[str | None] = mapped_column(String(256), nullable=True)
# 失败卡「原因」行的展示文案(仅 status=failed 时非空):information 具体则=它;笼统则从
# platform_results 捞出的业务原因(打烊/未起送/找不到店或菜/单点不配送);纯系统失败为 None
# → 端侧显示品牌兜底「网络开小差…」。写路径(harvest_done / upsert_record)落库时派生。
# 见 repositories.comparison._derive_fail_display。
fail_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
# ===== 明细(JSON,越详细越好)=====
# 下单菜品 [{name, qty, specs?}]
items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name, applied_coupons}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名;applied_coupons=[{name,amount}] 多券明细)
comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 展示模型统一数组(pricebot done.params.platforms 原样存): 每平台一行、自带
# status/is_best/display/display_order, 记录页据此直接渲染, 不再靠 comparison_results
# + 客户端合并 + 前端派生。老记录/旧客户端为空 → 前端回退老 comparison_results 渲染。
platforms: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 目标平台未找到、跳过的菜名
skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 客户端上报的原始 payload(calibration + done.params 全量),未来取数兜底
@@ -151,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 (
+10
View File
@@ -34,6 +34,11 @@ class DeviceLiveness(Base):
__tablename__ = "device_liveness"
__table_args__ = (
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
UniqueConstraint(
"push_vendor",
"push_token",
name="uq_device_liveness_vendor_token",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -48,6 +53,11 @@ class DeviceLiveness(Base):
push_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 厂商 push token / regId / registration_id;不同厂商命名不同,后端统一存这里。
push_token: Mapped[str | None] = mapped_column(String(256), nullable=True)
# 客户端每次登录生成的高熵随机值。离线退出后只凭此值精确撤销旧会话绑定,
# 新账号/新会话注册时会替换它,故旧解绑任务绝不能误解绑新登录。
push_binding_id: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
# 被退出/转移的 binding 留作墓碑,阻止旧的在途 register/heartbeat 把 token 抢回。
push_binding_revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android")
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
+25 -41
View File
@@ -1,20 +1,4 @@
"""新手引导视频播放记录(领券浮层前 N 次用它替代广告)。
产品规则(2026-07 拍板):新用户点一键自动领取后的等候浮层,** 3 **不放广告,
改放运营后台上传的引导视频;每次固定 120 金币,中途关闭也算看完照发
口径:
- **计次按账号**(user_id),与设备无关 换设备不重新送 3
- **开播即计数**:客户端每次要展示浮层时调 `/api/v1/guide-video/start`,服务端当场
写一行(status='playing')并返回 play_token;`COUNT(*)` 即已用次数用户中途 kill
App 也算用掉一次(产品选定口径,防反复进出刷金币)
- **发币幂等** play_token 定位 + `status='playing'` 条件更新:并发两次上报只有一次
改到行(另一次 rowcount=0),所以只发一次币光有 play_token 唯一键挡不住 发币走的是
UPDATE, INSERT,撞不到任何唯一键
- **次数上限** (user_id, seq) 唯一键兜底,防并发 /start 绕过 COUNT 判定(见下)
与广告收益(ad_feed_reward_record)彻底分离:引导视频不是广告,不该进广告收益报表
"""
"""引导视频播放计划、起播快照与逐圈结算状态。"""
from __future__ import annotations
from datetime import datetime
@@ -26,48 +10,48 @@ from app.db.base import Base
class GuideVideoPlay(Base):
"""一次引导视频播放一行。开播时建(status='playing'),发币后置 'granted'"""
__tablename__ = "guide_video_play"
__table_args__ = (
# 客户端幂等键:同一次播放重复上报奖励只发一次。
UniqueConstraint("play_token", name="uq_guide_video_play_token"),
# 次数上限的**硬约束**:start_play 是无锁 check-then-insert(读 COUNT 算 seq 再插),
# N 个并发 /start 会都读到同一个已用次数、算出同一个 seq,不拦就能各拿一个 token、
# 各发一次金币,3 次上限形同虚设(改包即可无限刷)。seq 唯一 → 并发同 seq 必撞,
# start_play 捕获 IntegrityError 降级成"这次不放视频"。
# 用 unique Index 而非 UniqueConstraint:与迁移里的 create_index 对齐(SQLite 加约束
# 要整表重建),autogenerate 才不会每次报一条假 diff。
Index("uq_guide_video_play_user_seq", "user_id", "seq", unique=True),
Index(
"uq_guide_video_play_user_scene_seq",
"user_id",
"scene",
"seq",
unique=True,
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 服务端生成下发给客户端的幂等键(uuid hex)。
play_token: Mapped[str] = mapped_column(String(64), nullable=False)
# 触发场景:目前只有 coupon(领券等候浮层);留字段以便日后比价等场景复用。
scene: Mapped[str] = mapped_column(String(16), nullable=False, default="coupon")
# 本账号第几次(1-based),= 建行时已有行数 + 1。日常判定仍以 COUNT 为准,但 (user_id, seq)
# 唯一键让并发 /start 只能成一个 —— 见 __table_args__。
seq: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# 当次下发的视频地址(运营换片后能回溯用户当时看的是哪支)。
# prepare 不占次数,seq=NULLstart 成功才写入 1-based seq
seq: Mapped[int | None] = mapped_column(Integer, nullable=True)
video_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 实发金币;未发时 0。
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# playing(已开播未发币) / granted(已发币)。
status: Mapped[str] = mapped_column(String(16), nullable=False, default="playing")
# 客户端上报时是否播完(true=自然播完 / false=中途关闭)。仅留痕:两者都发币。
duration_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
config_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
settled_circles: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# prepared / started / completed / legacy_completed / legacy_closed
status: Mapped[str] = mapped_column(String(24), nullable=False, default="prepared")
completed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
prepared_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True, nullable=True
)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True, nullable=True
)
granted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
def __repr__(self) -> str: # pragma: no cover
return (
f"<GuideVideoPlay user={self.user_id} seq={self.seq} "
f"{self.status} coin={self.coin}>"
f"<GuideVideoPlay user={self.user_id} scene={self.scene} seq={self.seq} "
f"{self.status} circles={self.settled_circles}>"
)
+77
View File
@@ -0,0 +1,77 @@
"""Per-subject limit policy overrides used by the admin whitelist page."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Index,
Integer,
String,
UniqueConstraint,
func,
true,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class LimitPolicyOverride(Base):
"""One rule override for one phone or device.
``reset_at`` is a non-destructive usage baseline. Business records and
security events remain intact; quota readers only count rows at or after
this timestamp.
"""
__tablename__ = "limit_policy_override"
__table_args__ = (
UniqueConstraint(
"subject_type",
"subject_value",
"rule_code",
name="uq_limit_policy_subject_rule",
),
Index(
"ix_limit_policy_lookup",
"subject_type",
"subject_value",
"rule_code",
"enabled",
),
Index("ix_limit_policy_expires", "expires_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
subject_type: Mapped[str] = mapped_column(String(16), nullable=False)
subject_value: Mapped[str] = mapped_column(String(128), nullable=False)
rule_code: Mapped[str] = mapped_column(String(64), nullable=False)
# 产品只保留“临时不限/免告警”。即使有内部脚本绕过 API 直接建 ORM
# 对象,也不能再悄悄落成已经下线的 override 模式。
mode: Mapped[str] = mapped_column(String(24), nullable=False, default="unlimited")
limit_value: Mapped[int | None] = mapped_column(Integer, nullable=True)
enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default=true()
)
starts_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
reset_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
created_by_admin_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
+9
View File
@@ -60,6 +60,15 @@ class CoinTransaction(Base):
sqlite_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
postgresql_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
),
Index(
"ux_coin_transaction_guide_video_ref",
"user_id",
"biz_type",
"ref_id",
unique=True,
sqlite_where=text("biz_type = 'guide_video' AND ref_id IS NOT NULL"),
postgresql_where=text("biz_type = 'guide_video' AND ref_id IS NOT NULL"),
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+36 -5
View File
@@ -5,11 +5,13 @@
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import rewards
from app.core import limit_policy, rewards
from app.core.rewards import cn_today
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.repositories import wallet as crud_wallet
@@ -29,8 +31,14 @@ def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | No
).scalar_one_or_none()
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
return db.execute(
def _granted_today(
db: Session,
user_id: int,
reward_date: str,
*,
reset_at: datetime | None = None,
) -> int:
stmt = (
select(func.count())
.select_from(AdFeedRewardRecord)
.where(
@@ -38,7 +46,10 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
AdFeedRewardRecord.reward_date == reward_date,
AdFeedRewardRecord.status == "granted",
)
).scalar_one()
)
if reset_at is not None:
stmt = stmt.where(AdFeedRewardRecord.created_at >= reset_at)
return db.execute(stmt).scalar_one()
def granted_unit_total(db: Session, user_id: int) -> int:
@@ -121,7 +132,27 @@ def grant_feed_reward(
)
return _commit_record(db, rec, client_event_id)
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
daily_policy = limit_policy.resolve_for_user(
db,
"ad.feed.daily",
user_id,
)
daily_limit = (
rewards.get_ad_daily_limit(db)
if daily_policy.override_id is None
and daily_policy.bucket_version == "default"
else daily_policy.limit
)
if (
daily_limit is not None
and _granted_today(
db,
user_id,
today,
reset_at=daily_policy.reset_at,
)
>= daily_limit
):
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
+82 -17
View File
@@ -10,13 +10,13 @@
"""
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import rewards
from app.core import limit_policy, rewards
from app.core.ad_cooldown import compute_cooldown
from app.core.rewards import DAILY_AD_WATCH_SECONDS_LIMIT, cn_today
from app.models.ad_reward import AdRewardRecord
@@ -96,8 +96,14 @@ def round_coin_total(db: Session, user_id: int, boost_round_id: str) -> int:
)
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
return db.execute(
def _granted_today(
db: Session,
user_id: int,
reward_date: str,
*,
reset_at: datetime | None = None,
) -> int:
stmt = (
select(func.count())
.select_from(AdRewardRecord)
.where(
@@ -106,7 +112,10 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
AdRewardRecord.status == "granted",
AdRewardRecord.reward_scene == "reward_video",
)
).scalar_one()
)
if reset_at is not None:
stmt = stmt.where(AdRewardRecord.created_at >= reset_at)
return db.execute(stmt).scalar_one()
def _granted_cumulative(db: Session, user_id: int) -> int:
@@ -165,7 +174,27 @@ def grant_ad_reward(
DAILY_AD_WATCH_SECONDS_LIMIT > 0
and watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT
)
over_count = _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db)
daily_policy = limit_policy.resolve_for_user(
db,
"ad.reward_video.daily",
user_id,
)
daily_limit = (
rewards.get_ad_daily_limit(db)
if daily_policy.override_id is None
and daily_policy.bucket_version == "default"
else daily_policy.limit
)
over_count = (
daily_limit is not None
and _granted_today(
db,
user_id,
today,
reset_at=daily_policy.reset_at,
)
>= daily_limit
)
if over_time or over_count:
rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
@@ -321,19 +350,24 @@ def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardR
return rec
def _granted_times_today_desc(db: Session, user_id: int, reward_date: str) -> list[datetime]:
def _granted_times_today_desc(
db: Session,
user_id: int,
reward_date: str,
*,
reset_at: datetime | None = None,
) -> list[datetime]:
"""当日 status=granted 记录的 created_at,按时间倒序(最新在前)——冷却策略的输入数据。"""
return list(
db.execute(
select(AdRewardRecord.created_at)
.where(
stmt = select(AdRewardRecord.created_at).where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_date == reward_date,
AdRewardRecord.status == "granted",
AdRewardRecord.reward_scene == "reward_video",
)
.order_by(AdRewardRecord.created_at.desc())
).scalars()
if reset_at is not None:
stmt = stmt.where(AdRewardRecord.created_at >= reset_at)
return list(
db.execute(stmt.order_by(AdRewardRecord.created_at.desc())).scalars()
)
@@ -349,16 +383,47 @@ def today_status(
旧客户端兼容,当前 DAILY_AD_WATCH_SECONDS_LIMIT=0 表示不启用时长闸
"""
today = cn_today().isoformat()
granted_desc = _granted_times_today_desc(db, user_id, today)
daily_policy = limit_policy.resolve_for_user(
db,
"ad.reward_video.daily",
user_id,
)
cooldown_policy = limit_policy.resolve_for_user(
db,
"ad.reward_video.cooldown",
user_id,
)
daily_limit = (
rewards.get_ad_daily_limit(db)
if daily_policy.override_id is None
and daily_policy.bucket_version == "default"
else daily_policy.limit
)
cooldown_seconds = (
rewards.get_ad_cooldown_sec(db)
if cooldown_policy.override_id is None
and cooldown_policy.bucket_version == "default"
else (cooldown_policy.limit or 0)
)
granted_desc = _granted_times_today_desc(
db,
user_id,
today,
reset_at=daily_policy.reset_at,
)
state = compute_cooldown(
granted_desc,
datetime.now(timezone.utc),
datetime.now(UTC),
round_size=rewards.get_ad_round_count(db),
cooldown_seconds=rewards.get_ad_cooldown_sec(db),
cooldown_seconds=cooldown_seconds,
)
return (
len(granted_desc),
rewards.get_ad_daily_limit(db),
(
daily_limit
if daily_limit is not None
else limit_policy.get_rule("ad.reward_video.daily").max_value
),
0,
state.round_count,
state.cooldown_until,
+268 -13
View File
@@ -52,6 +52,105 @@ def _product_names_from_items(items: list | None) -> str | None:
return joined[:500] or None
# ---- 失败记录的展示文案(记录页失败卡「原因」行)------------------------------
# information 具体就直出;笼统(_GENERIC_INFO)则从 platform_results 捞一条用户可读的业务
# 原因;捞不到 → None(端侧显示品牌兜底「网络开小差…」)。pricebot 把 store_closed /
# no_delivery 漏成了 status=failed,这里按 reason 关键字补判;打烊类 reason 常带一坨脏店名
# (店名+月售+起送+配送…),统一成简短模板。自动化黑话(搜索失败/读价失败/购物车残留/裸
# FAILED…)不给用户看 → 归入品牌兜底。
# pricebot 组不出具体原因时的笼统 information(线上统计的大头),一律走品牌兜底。
_GENERIC_INFO = {
"比价过程出错,请稍后重试",
"比价出错",
"比价未完成",
"done 参数缺少可验证的目标平台结果",
}
# 干净业务结局 status(直接可信),按展示优先级(越靠前越先选)。
_BIZ_STATUS_PRIORITY = (
"below_minimum",
"no_delivery",
"store_closed",
"items_not_found",
"store_not_found",
)
def _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 ""
if "暂停营业" in r:
state = "暂停营业"
elif "休息" in r:
state = "休息中"
else:
state = "已打烊"
return f"门店{state},无法比价"
def _target_display_reason(platform_results: dict | None) -> str | None:
"""从逐平台结果里挑一条"可展示给用户"的失败原因;挑不到返回 None。
status 命中干净业务结局集 直接采信(打烊套模板,其余用 reason);
补判 pricebot 漏成 status=failed 的两类:打烊(套模板)单点不配送(reason 本身干净);
自动化黑话(搜索失败/读价失败/购物车残留/ FAILED)一律不展示 None"""
pr = platform_results or {}
targets = [
v for v in pr.values() if isinstance(v, dict) and not v.get("is_source")
]
for want in _BIZ_STATUS_PRIORITY: # ① 干净 status 优先
for v in targets:
if v.get("status") == want:
if want == "store_closed":
return _store_closed_text(v.get("reason"))
if v.get("reason"):
return v["reason"]
for v in targets: # ② 漏成 failed 的业务结局补判
if v.get("status") != "failed":
continue
reason = (v.get("reason") or "").strip()
if any(k in reason for k in ("打烊", "暂停营业", "休息")):
return _store_closed_text(reason)
if "单点不配送" in reason:
return reason
return None
def _derive_fail_display(
information: str | None, platform_results: dict | None
) -> str | None:
"""失败记录展示文案:information 具体则直出;笼统则从 platform_results 捞/补判;
都拿不到 None(端侧品牌兜底)仅在 status=failed 时调用"""
info = (information or "").strip()
text = info if (info and info not in _GENERIC_INFO) else _target_display_reason(
platform_results
)
return text[:256] if text else None
def _derive(payload: ComparisonRecordIn) -> dict:
"""从上报 payload 派生结构化列(best/saved/is_source_best/status)。"""
results = payload.comparison_results
@@ -89,8 +188,10 @@ def _derive(payload: ComparisonRecordIn) -> dict:
is_source_best = best.is_source if best is not None else None
# status:客户端显式给了就用;否则有"非源且有价"的结果=success,否则 failed
status = 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
@@ -105,6 +206,11 @@ def _derive(payload: ComparisonRecordIn) -> dict:
"saved_amount_cents": saved_amount_cents,
"is_source_best": is_source_best,
"status": status,
"fail_reason": (
_derive_fail_display(payload.information, _pr)
if status == "failed"
else None
),
}
@@ -117,16 +223,37 @@ def upsert_record(
灰度期老客户端 POST /compare/record 走这条,与后端 harvest trace_id reconcile;
新客户端不再 POST(改由 compare.py 透传壳 harvest 落库)
"""
derived = _derive(payload)
# 单源派生: 与 harvest_done 一致, payload 带 platforms 时从它派生(唯一真相源
# _derive_from_platforms), 老客户端不带 platforms 时回退 _derive(从 comparison_results)。
if payload.platforms:
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 {})
if derived["status"] == "failed"
else None
)
# 单源派生取自 platforms 源行(常无源平台元数据/店名)→ 空则用 payload 兜底不丢字段。
# 下面 fields 不再显式写这四个键, 统一由 derived 提供(否则 dict(store_name=..., **derived)
# 与 _derive_from_platforms 同名键撞键 TypeError)。
for _k in ("store_name", "source_platform_id", "source_platform_name", "source_package"):
if not derived.get(_k):
derived[_k] = getattr(payload, _k)
else:
derived = _derive(payload)
# _derive 只从 comparison_results 派生, 不含源平台四件套 / store_name → 从 payload 补,
# 与上面 platforms 分支键集对齐(fields 统一靠 **derived 提供这些列)。
for _k in ("store_name", "source_platform_id", "source_platform_name", "source_package"):
derived[_k] = getattr(payload, _k)
items = [it.model_dump(exclude_none=True) for it in payload.items]
fields = dict(
device_id=payload.device_id,
business_type=payload.business_type,
store_name=payload.store_name,
product_names=_product_names_from_items(items),
source_platform_id=payload.source_platform_id,
source_platform_name=payload.source_platform_name,
source_package=payload.source_package,
# store_name / source_platform_id / source_platform_name / source_package 统一由
# derived 提供(见上方两分支补齐), 不在此显式写 —— 否则与 _derive_from_platforms 撞键。
information=payload.information,
best_deeplink=payload.best_deeplink,
trace_url=payload.trace_url,
@@ -134,6 +261,7 @@ def upsert_record(
skipped_dish_count=payload.skipped_dish_count,
items=items,
comparison_results=[r.model_dump() for r in payload.comparison_results],
platforms=list(payload.platforms or []),
skipped_dish_names=list(payload.skipped_dish_names),
# 客户端环境 / 性能(debug,客户端上报;旧客户端为 None)
device_model=payload.device_model,
@@ -206,7 +334,8 @@ def upsert_record(
def _derive_from_results(
results: list[dict], platform_results: dict | None = None
results: list[dict], platform_results: dict | None = None,
record_status: str | None = None,
) -> dict:
"""从 done 帧 comparison_results(pricebot 原始 dict 列表)派生结构化列。
等价 _derive,但吃原始字段(is_source/price/rank/platform_id/store_name...)而非 pydantic 对象
@@ -254,7 +383,64 @@ 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,
"status": "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"),
}
def _derive_from_platforms(
platforms: list, record_status: str | None = None,
) -> dict:
"""从 done 帧 platforms(每平台一行、渲染就绪)派生结构化列——**单一真相源**。
best_* 直接取 platforms is_best 的那一行source_* role=source ,与前端读的
platforms 天然一致(不再像 _derive_from_results 那样从 comparison_results 二次评最优,
消除"标量列 vs platforms"双源不一致)platforms 非空时优先走这里; pricebot
platforms 时调用方回退 _derive_from_results(向后兼容)"""
rows = [p for p in (platforms or []) if isinstance(p, dict)]
src = next((p for p in rows if p.get("role") == "source"), None)
best = next((p for p in rows if p.get("is_best")), None)
if best is None:
# pricebot 没标 is_best(如全平台 has_dish_diff「相似替换/仅供参考」→ 不认定权威最低价)
# 但仍有有价行 → 兜底取有价行里最低价当参考 best,避免记录级 best_*/saved 整条落 NULL
# (否则首页价 0.00 / 记录页无最低红框 / 省额丢失 /「累计发现可省」漏计)。**候选含源**:
# 源常年全菜、价可信,源本身最便宜时 best 回落源(saved=0、is_source_best=True),与 _derive
# 「全目标缺菜回落源、不虚报省」同一语义;若排除源强选更贵目标,saved 会变负、倒扣「累计
# 发现可省」(get_stats 对 saved_amount_cents 求和不带 >0 过滤)。
priced = [p for p in rows if p.get("price") is not None]
if priced:
best = min(priced, key=lambda p: (p["price"], p.get("display_order") or 0))
source_price_cents = _yuan_to_cents(src.get("price")) if src else None
best_price_cents = _yuan_to_cents(best.get("price")) if best else None
saved_amount_cents = None
if source_price_cents is not None and best_price_cents is not None:
saved_amount_cents = source_price_cents - best_price_cents
has_valid_target = any(
p.get("role") != "source" and p.get("price") is not None for p in rows
)
# store_name: 优先源行; recompare 场景源平台自己当目标、源行被目标覆盖(pricebot
# _build_platform_rows 有意去重, platforms 无 role=source 行)→ 回退 best 行 → 首个有店名
# 的行(显示现场实际比到的店), 免得记录页店名空掉兜底显示成"比价"。正常比价有源行不走回退。
store_name = (
(src or {}).get("store_name")
or (best or {}).get("store_name")
or next((p.get("store_name") for p in rows if p.get("store_name")), None)
)
return {
"source_platform_id": (src or {}).get("platform_id"),
"source_platform_name": (src or {}).get("platform_name"),
"source_package": (src or {}).get("package"),
"source_price_cents": source_price_cents,
"best_platform_id": (best or {}).get("platform_id"),
"best_platform_name": (best or {}).get("platform_name"),
"best_price_cents": best_price_cents,
"saved_amount_cents": saved_amount_cents,
"is_source_best": (best.get("role") == "source") if best else None,
"store_name": store_name or None,
"status": _normalize_record_status(record_status)
or ("success" if has_valid_target else "failed"),
}
@@ -286,6 +472,8 @@ def reserve_daily_start(
business_type: str = "food",
device_id: str | None = None,
now: datetime | None = None,
limit: int | None = DAILY_COMPARE_START_LIMIT,
reset_at: datetime | None = None,
) -> tuple[ComparisonRecord, int]:
"""Atomically reserve one of a user's 100 Beijing-day comparison starts.
@@ -311,6 +499,11 @@ def reserve_daily_start(
if existing_at.tzinfo is not None:
existing_at = existing_at.astimezone(CN_TZ).replace(tzinfo=None)
day_start = existing_at.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(
@@ -325,6 +518,11 @@ def reserve_daily_start(
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(
@@ -333,7 +531,7 @@ def reserve_daily_start(
ComparisonRecord.created_at < day_end,
)
) or 0
if used >= DAILY_COMPARE_START_LIMIT:
if limit is not None and used >= limit:
raise DailyCompareStartLimitExceeded
rec = ComparisonRecord(
@@ -350,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,
*,
@@ -413,12 +638,41 @@ def harvest_done(
返回 (记录, 是否本次****落成 success)供调用方据此幂等发一次邀请奖
行不存在(理论上帧0已建;防御)则新建"""
results = done_params.get("comparison_results") or []
derived = _derive_from_results(results, done_params.get("platform_results"))
# 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
items = next((r.get("items") or [] for r in results if r.get("is_source")), [])
# 展示模型统一数组(pricebot 新增, 每平台一行自带 status/is_best): 原样存, 记录页据此直渲染。
# 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
# 全从它取 → 与前端读的 platforms 天然一致; 菜品也取 platforms 源行。老 pricebot 无
# platforms 时回退从 comparison_results 派生(向后兼容)。
if platforms:
derived = _derive_from_platforms(platforms, record_status)
# 菜品优先源行; recompare 无源行 → 回退 best 行 → 首个有菜品的行(同 store_name 回退)
_item_row = (
next((p for p in platforms if isinstance(p, dict) and p.get("role") == "source"), None)
or next((p for p in platforms if isinstance(p, dict) and p.get("is_best")), None)
or next((p for p in platforms if isinstance(p, dict) and p.get("items")), None)
)
items = (_item_row or {}).get("items") or []
else:
derived = _derive_from_results(
results, done_params.get("platform_results"), record_status
)
# pricebot 已把源单菜品塞进 comparison_results[源行].items
items = next((r.get("items") or [] for r in results if r.get("is_source")), [])
# 失败展示原因(#189): platforms / results 两个派生分支的 status 都可能 failed, 统一在此算
fail_reason = (
_derive_fail_display(
done_params.get("information"), done_params.get("platform_results")
)
if derived["status"] == "failed"
else None
)
fields = dict(
business_type=business_type or "food",
information=done_params.get("information") or None,
fail_reason=fail_reason,
# best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补;
# 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。
trace_url=trace_url or done_params.get("trace_url"),
@@ -426,6 +680,7 @@ def harvest_done(
skipped_dish_count=done_params.get("skipped_dish_count"),
skipped_dish_names=list(done_params.get("skipped_dish_names") or []),
comparison_results=results,
platforms=platforms,
items=items,
product_names=_product_names_from_items(items),
raw_payload=done_params,
+117 -14
View File
@@ -1,9 +1,9 @@
"""device 表读写(设备注册 / 心跳 / 超时扫描)。"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session
from app.models.device import DeviceLiveness
@@ -24,13 +24,29 @@ def register_or_update(
registration_id: str | None = None,
push_vendor: str | None = None,
push_token: str | None = None,
push_binding_id: str | None = None,
platform: str = "android",
app_version: str | None = None,
) -> DeviceLiveness:
"""注册设备或更新其厂商 push token / 元信息。upsert by (user_id, device_id)"""
"""注册设备或更新推送目标;同一设备/token 的旧用户绑定在同一事务内失效"""
normalized_vendor = _normalize_push_vendor(push_vendor)
normalized_token = push_token.strip() if push_token else None
normalized_binding = push_binding_id.strip() if push_binding_id else None
device = _get(db, user_id=user_id, device_id=device_id)
stale_binding = bool(
device
and normalized_binding
and device.push_binding_id == normalized_binding
and device.push_binding_revoked
)
if not stale_binding:
_release_conflicting_push_bindings(
db,
user_id=user_id,
device_id=device_id,
push_vendor=normalized_vendor,
push_token=normalized_token,
)
if device is None:
device = DeviceLiveness(
user_id=user_id,
@@ -38,17 +54,22 @@ def register_or_update(
registration_id=registration_id,
push_vendor=normalized_vendor,
push_token=normalized_token,
push_binding_id=normalized_binding,
push_binding_revoked=False,
platform=platform or "android",
app_version=app_version,
)
db.add(device)
else:
if registration_id:
if registration_id and not stale_binding:
device.registration_id = registration_id
if normalized_vendor:
if normalized_vendor and not stale_binding:
device.push_vendor = normalized_vendor
if normalized_token:
if normalized_token and not stale_binding:
device.push_token = normalized_token
if normalized_binding and not stale_binding:
device.push_binding_id = normalized_binding
device.push_binding_revoked = False
if platform:
device.platform = platform
if app_version:
@@ -67,26 +88,45 @@ def touch_heartbeat(
registration_id: str | None = None,
push_vendor: str | None = None,
push_token: str | None = None,
push_binding_id: str | None = None,
) -> DeviceLiveness:
"""处理一次心跳(心跳也能自注册)。
service 心跳或 accessibility_enabled=true ,刷新存活并把状态机重置回 alive
清掉 notified_at(掉线恢复 下次再断才会再推一条)
"""
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
device = _get(db, user_id=user_id, device_id=device_id)
normalized_vendor = _normalize_push_vendor(push_vendor)
normalized_token = push_token.strip() if push_token else None
normalized_binding = push_binding_id.strip() if push_binding_id else None
stale_binding = bool(
device
and normalized_binding
and device.push_binding_id == normalized_binding
and device.push_binding_revoked
)
if (normalized_token or normalized_binding) and not stale_binding:
_release_conflicting_push_bindings(
db,
user_id=user_id,
device_id=device_id,
push_vendor=normalized_vendor,
push_token=normalized_token,
)
if device is None:
device = DeviceLiveness(user_id=user_id, device_id=device_id)
db.add(device)
if registration_id:
if registration_id and not stale_binding:
device.registration_id = registration_id
normalized_vendor = _normalize_push_vendor(push_vendor)
normalized_token = push_token.strip() if push_token else None
if normalized_vendor:
if normalized_vendor and not stale_binding:
device.push_vendor = normalized_vendor
if normalized_token:
if normalized_token and not stale_binding:
device.push_token = normalized_token
if normalized_binding and not stale_binding:
device.push_binding_id = normalized_binding
device.push_binding_revoked = False
device.last_report_protection_on = accessibility_enabled
if accessibility_enabled:
@@ -107,7 +147,7 @@ def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]:
即使没有厂商 token 也要检出,后续由 kill_alert_pending 走客户端进 App 后兜底提醒
"""
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
cutoff = datetime.now(UTC) - timedelta(minutes=timeout_minutes)
stmt = select(DeviceLiveness).where(
DeviceLiveness.ever_protected.is_(True),
DeviceLiveness.liveness_state == "alive",
@@ -126,7 +166,7 @@ def mark_notified(db: Session, *, device_id_pk: int) -> None:
device = db.get(DeviceLiveness, device_id_pk)
if device is not None:
device.liveness_state = "notified"
device.notified_at = datetime.now(timezone.utc)
device.notified_at = datetime.now(UTC)
device.kill_alert_pending = True
db.commit()
@@ -177,6 +217,69 @@ def list_push_targets(db: Session, *, user_id: int) -> list[DeviceLiveness]:
return targets
def unregister_push_binding(db: Session, *, user_id: int, device_id: str) -> None:
"""当前用户主动退出:清推送归属但保留设备存活历史。"""
device = _get(db, user_id=user_id, device_id=device_id)
if device is not None:
_clear_push_binding(device)
db.commit()
def unregister_push_binding_deferred(
db: Session, *, device_id: str, push_binding_id: str
) -> None:
"""离线退出补偿;只撤销完全匹配的旧登录会话,且始终幂等。"""
stmt = select(DeviceLiveness).where(
DeviceLiveness.device_id == device_id,
DeviceLiveness.push_binding_id == push_binding_id,
)
device = db.execute(stmt).scalar_one_or_none()
if device is not None:
_clear_push_binding(device)
db.commit()
def _release_conflicting_push_bindings(
db: Session,
*,
user_id: int,
device_id: str,
push_vendor: str | None,
push_token: str | None,
) -> None:
"""把设备或 token 从其他记录转移走,当前 (user,device) 行除外。"""
conflicts = [DeviceLiveness.device_id == device_id]
if push_vendor and push_token:
conflicts.append(
and_(
DeviceLiveness.push_vendor == push_vendor,
DeviceLiveness.push_token == push_token,
)
)
stmt = select(DeviceLiveness).where(
or_(*conflicts),
~and_(
DeviceLiveness.user_id == user_id,
DeviceLiveness.device_id == device_id,
),
)
released = False
for old in db.execute(stmt).scalars():
_clear_push_binding(old)
released = True
if released:
# 唯一约束下必须先落旧行清理,再把同一 token 写给新行。
db.flush()
def _clear_push_binding(device: DeviceLiveness) -> None:
device.registration_id = None
device.push_vendor = None
device.push_token = None
# binding_id 留作撤销墓碑;同一旧会话的在途请求不能重新认领 token。
device.push_binding_revoked = bool(device.push_binding_id)
def _normalize_push_vendor(push_vendor: str | None) -> str | None:
if not push_vendor:
return None
+463 -184
View File
@@ -1,95 +1,136 @@
"""新手引导视频:运营配置读写 + 播放计次 + 发币。
**配置**(开关 / 视频地址 / 前几次 / 每次金币)整体作为一个 JSON 存进通用 app_config
(key=coupon_guide_video),写法完全对齐 feedback_qr 不进 CONFIG_DEFS,所以不会污染
系统配置页的通用列表,由本模块独占维护
**计次**按账号(user_id)**开播即计数**:客户端每次要展示领券等候浮层时调
`/api/v1/guide-video/start`,命中则当场写一行 guide_video_play(status='playing')
已用次数 = 该账号的行数,达到 max_plays 后不再下发,客户端改放广告(原逻辑)
COUNT 判定本身无锁,真正卡住次数上限的是 (user_id, seq) 唯一键:并发 /start 只能成一个
**发币**幂等键是 play_token,落地方式是 `status='playing' 'granted'` **条件更新**:
同一次播放重复上报只入账一次(网络重试 / 关闭与播完同时触发都靠它挡住)
中途关闭也照发 产品拍板中途关闭也算看完
两处都是直接铸币的路径,改动前先看 `start_play` / `grant_play` 上的并发注释
"""
"""双场景引导视频配置与 prepare/start/reward 状态机。"""
from __future__ import annotations
import uuid
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import rewards
from app.core import limit_policy, media, rewards
from app.core.config import settings
from app.models.app_config import AppConfig
from app.models.guide_video import GuideVideoPlay
from app.models.wallet import CoinAccount
from app.repositories import wallet as crud_wallet
_KEY = "coupon_guide_video"
#: 金币流水 biz_type。客户端收益明细按它显示「新手引导视频奖励」。
BIZ_TYPE = "guide_video"
# 默认值 = 「运营还没配」时的行为:video_url 为空 → 一律不下发引导视频,浮层维持现状(放广告)。
# 所以本功能上线后**不配视频就等于没上线**,不会影响存量用户。
_DEFAULTS: dict[str, Any] = {
"enabled": True,
"video_url": None, # None/空 = 未配片 → 不下发,浮层照旧放广告
"max_plays": 3, # 每个账号前 N 次浮层放引导视频
"reward_coin": 120, # 每次固定金币
SCENES = ("coupon", "comparison")
_KEY_BY_SCENE = {
"coupon": "coupon_guide_video",
"comparison": "comparison_guide_video",
}
_FIELDS = tuple(_DEFAULTS.keys())
# 后台可配范围的护栏:防手滑把次数/金币填成天文数字(配置直接决定发币)。
BIZ_TYPE = "guide_video"
CIRCLE_COUNT = 10
PLAN_TTL = timedelta(minutes=10)
# 0 表示全局暂停播放;白名单页与旧引导视频配置页必须接受同一口径,
# 否则在白名单页设为 0 后,旧页面连金币/开关等无关字段也无法保存。
MIN_PLAYS = 0
MAX_PLAYS_LIMIT = 50
MIN_REWARD_COIN = 10
REWARD_COIN_LIMIT = 10_000
_DEFAULTS: dict[str, Any] = {
"enabled": False,
"video_url": None,
"max_plays": 3,
"reward_coin": 100,
"duration_ms": None,
"video_codec": None,
"audio_codec": None,
"analysis_status": "missing",
"analysis_error": None,
"config_version": 0,
}
_FIELDS = tuple(_DEFAULTS)
# ===== 配置 =====
class PlayStateError(Exception):
def __init__(self, code: str, message: str, *, reprepare_required: bool = False):
super().__init__(message)
self.code = code
self.message = message
self.reprepare_required = reprepare_required
def detail(self) -> dict[str, Any]:
return {
"code": self.code,
"message": self.message,
"retryable": False,
"reprepare_required": self.reprepare_required,
}
def _now() -> datetime:
return datetime.now(rewards.CN_TZ).replace(tzinfo=None)
def _config_key(scene: str) -> str:
if scene not in _KEY_BY_SCENE:
raise ValueError(f"unsupported guide video scene: {scene}")
return _KEY_BY_SCENE[scene]
def _merge(raw: Any) -> dict[str, Any]:
"""DB 里(可能不全的)dict 叠加到默认上,得到完整配置(4 个字段,无 updated_at)。"""
out = dict(_DEFAULTS)
if isinstance(raw, dict):
for k in _FIELDS:
v = raw.get(k)
if v is not None:
out[k] = v
for key in _FIELDS:
if key in raw and raw[key] is not None:
out[key] = raw[key]
return out
def get_config(db: Session) -> dict[str, Any]:
"""完整配置 + updated_at(admin 读 / 业务读共用)。"""
row = db.get(AppConfig, _KEY)
cfg = _merge(row.value if row is not None else None)
def _public_config(cfg: dict[str, Any]) -> dict[str, Any]:
duration = int(cfg.get("duration_ms") or 0)
reward = int(cfg.get("reward_coin") or 0)
return {
**cfg,
"duration_ms": duration or None,
"circle_count": CIRCLE_COUNT,
"circle_duration_ms": duration / CIRCLE_COUNT if duration else None,
"reward_per_circle": reward // CIRCLE_COUNT,
}
def get_config(db: Session, scene: str = "coupon") -> dict[str, Any]:
row = db.get(AppConfig, _config_key(scene))
cfg = _public_config(_merge(row.value if row is not None else None))
if scene == "coupon":
cfg["max_plays"] = limit_policy.resolve(
db, "guide.video.lifetime"
).global_limit
cfg["updated_at"] = row.updated_at.isoformat() if row is not None and row.updated_at else None
return cfg
def _write(db: Session, value: dict[str, Any], *, admin_id: int, commit: bool) -> dict[str, Any]:
"""整体覆写该行(value 须为完整字段 dict),返回合并后的完整配置(含 updated_at)。"""
row = db.get(AppConfig, _KEY)
def _validate_settings(max_plays: int, reward_coin: int) -> None:
if not MIN_PLAYS <= max_plays <= MAX_PLAYS_LIMIT:
raise ValueError(f"播放次数必须在 {MIN_PLAYS}{MAX_PLAYS_LIMIT} 之间")
if not MIN_REWARD_COIN <= reward_coin <= REWARD_COIN_LIMIT:
raise ValueError(f"金币总价必须在 {MIN_REWARD_COIN}{REWARD_COIN_LIMIT} 之间")
if reward_coin % CIRCLE_COUNT:
raise ValueError("金币总价必须是 10 的倍数")
def _write(
db: Session, value: dict[str, Any], *, scene: str, admin_id: int, commit: bool
) -> dict[str, Any]:
key = _config_key(scene)
row = db.get(AppConfig, key)
if row is None:
row = AppConfig(key=_KEY, value=value, updated_by_admin_id=admin_id)
row = AppConfig(key=key, value=value, updated_by_admin_id=admin_id)
db.add(row)
else:
row.value = value # 整体重新赋值,SQLAlchemy 才侦测得到变更
row.value = value
row.updated_by_admin_id = admin_id
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
out = _merge(row.value)
out["updated_at"] = row.updated_at.isoformat() if row.updated_at else None
return out
return get_config(db, scene)
def update_config(
@@ -98,193 +139,431 @@ def update_config(
enabled: bool | None = None,
max_plays: int | None = None,
reward_coin: int | None = None,
scene: str = "coupon",
admin_id: int,
commit: bool = True,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""改开关 / 次数 / 金币(只改传了的字段;视频走 set_video)。返回 (before, after) 供审计。"""
row = db.get(AppConfig, _KEY)
before = _merge(row.value if row is not None else None)
new_value = {k: before[k] for k in _FIELDS}
before = get_config(db, scene)
raw = {key: before[key] for key in _FIELDS}
candidate_plays = int(max_plays if max_plays is not None else raw["max_plays"])
candidate_reward = int(reward_coin if reward_coin is not None else raw["reward_coin"])
_validate_settings(candidate_plays, candidate_reward)
if enabled is True and (
not raw.get("video_url")
or raw.get("analysis_status") != "valid"
or not raw.get("duration_ms")
):
raise ValueError("请先上传并通过分析的视频,再启用")
if enabled is not None:
new_value["enabled"] = enabled
if max_plays is not None:
new_value["max_plays"] = max(0, min(int(max_plays), MAX_PLAYS_LIMIT))
if reward_coin is not None:
new_value["reward_coin"] = max(0, min(int(reward_coin), REWARD_COIN_LIMIT))
after = _write(db, new_value, admin_id=admin_id, commit=commit)
raw["enabled"] = enabled
raw["max_plays"] = candidate_plays
raw["reward_coin"] = candidate_reward
raw["config_version"] = int(raw.get("config_version") or 0) + 1
after = _write(
db,
raw,
scene=scene,
admin_id=admin_id,
commit=False,
)
if scene == "coupon" and max_plays is not None:
# 兼容仍调用旧专用接口的客户端/脚本,并把统一策略全局值一并更新。
limit_policy.set_global_limits(
db,
{"guide.video.lifetime": candidate_plays},
admin_id=admin_id,
commit=False,
)
# set_global_limits 已 flush;即使由 admin 路由统一在外层提交,也要把
# 同一事务内的最新统一策略值写进审计 after 快照。
after = get_config(db, scene)
if commit:
db.commit()
after = get_config(db, scene)
return before, after
def set_video(
db: Session, video_url: str | None, *, admin_id: int, commit: bool = True
db: Session,
video_url: str | None,
*,
analysis: dict[str, Any] | None = None,
scene: str = "coupon",
admin_id: int,
commit: bool = True,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""设置/清空引导视频地址。返回 (before, after);before['video_url'] 供调用方删旧文件。"""
row = db.get(AppConfig, _KEY)
before = _merge(row.value if row is not None else None)
new_value = {k: before[k] for k in _FIELDS}
new_value["video_url"] = video_url
after = _write(db, new_value, admin_id=admin_id, commit=commit)
before = get_config(db, scene)
raw = {key: before[key] for key in _FIELDS}
raw["video_url"] = video_url
if video_url:
if not analysis or analysis.get("analysis_status") != "valid":
raise ValueError("视频必须先完成服务端分析")
for key in (
"duration_ms", "video_codec", "audio_codec",
"analysis_status", "analysis_error",
):
raw[key] = analysis.get(key)
else:
raw.update(
duration_ms=None,
video_codec=None,
audio_codec=None,
analysis_status="missing",
analysis_error=None,
)
raw["enabled"] = False
raw["config_version"] = int(raw.get("config_version") or 0) + 1
after = _write(db, raw, scene=scene, admin_id=admin_id, commit=commit)
return before, after
# ===== 播放计次 =====
def used_plays(db: Session, user_id: int) -> int:
"""该账号已用掉的引导视频次数(开播即算,含未发币的)。"""
return int(
db.execute(
select(func.count()).select_from(GuideVideoPlay).where(
GuideVideoPlay.user_id == user_id
)
).scalar_one()
def used_plays(
db: Session,
user_id: int,
scene: str = "coupon",
*,
reset_at: datetime | None = None,
) -> int:
stmt = select(func.count()).select_from(GuideVideoPlay).where(
GuideVideoPlay.user_id == user_id,
GuideVideoPlay.scene == scene,
GuideVideoPlay.status != "prepared",
)
if reset_at is not None:
# guide_video_play 使用北京时间 naive 墙钟;白名单重置点是带时区时间。
reset_value = reset_at.astimezone(rewards.CN_TZ).replace(tzinfo=None)
stmt = stmt.where(GuideVideoPlay.started_at >= reset_value)
return int(db.execute(stmt).scalar_one())
def play_stats(db: Session) -> dict[str, int]:
"""全站播放统计(admin 页展示):总播放次数 / 其中已发币次数。"""
total = int(
db.execute(select(func.count()).select_from(GuideVideoPlay)).scalar_one()
def _effective_quota(
db: Session,
user_id: int,
scene: str,
cfg: dict[str, Any],
) -> tuple[int | None, datetime | None]:
if scene != "coupon":
return int(cfg.get("max_plays") or 0), None
policy = limit_policy.resolve_for_user(
db, "guide.video.lifetime", user_id
)
granted = int(
db.execute(
select(func.count()).select_from(GuideVideoPlay).where(
GuideVideoPlay.status == "granted"
)
).scalar_one()
)
return {"total_plays": total, "granted_plays": granted}
return policy.limit, policy.reset_at
def start_play(
db: Session, user_id: int, *, scene: str = "coupon", commit: bool = True
def _remaining(maximum: int | None, used: int) -> int:
# API 字段保持整数兼容;不限时使用足够大的展示值,不参与服务端判定。
exposed_maximum = maximum if maximum is not None else 1_000_000
return max(0, exposed_maximum - used)
def _prepare_miss(
scene: str,
reason: str,
cfg: dict[str, Any],
used: int,
maximum: int | None,
) -> dict[str, Any]:
"""决定这次浮层是否放引导视频;命中则**当场计次**并返回 play_token。
return {
"should_play": False,
"reason": reason,
"scene": scene,
"video_url": None,
"play_token": "",
"config_version": int(cfg.get("config_version") or 0),
"duration_ms": 0,
"circle_count": CIRCLE_COUNT,
"circle_duration_ms": 0,
"reward_coin": int(cfg.get("reward_coin") or 0),
"reward_per_circle": int(cfg.get("reward_coin") or 0) // CIRCLE_COUNT,
"seq": used,
"remaining": _remaining(maximum, used),
"expires_at": None,
}
返回 dict:
should_play 是否放引导视频(False 客户端照旧放广告)
video_url 相对地址(/media/...);客户端自行拼 BASE_URL
play_token 发币幂等键(should_play=False 时为空串)
reward_coin 播完/中途关闭都发的固定金币
seq / remaining 第几次 / 发完这次还剩几次(仅展示与排查用)
"""
cfg = get_config(db)
video_url = (cfg.get("video_url") or "").strip()
max_plays = int(cfg.get("max_plays") or 0)
reward_coin = int(cfg.get("reward_coin") or 0)
used = used_plays(db, user_id)
def _miss(used_now: int) -> dict[str, Any]:
return {
"should_play": False,
"video_url": None,
"play_token": "",
"reward_coin": reward_coin,
"seq": used_now,
"remaining": max(0, max_plays - used_now),
}
def prepare_play(db: Session, user_id: int, *, scene: str = "coupon") -> dict[str, Any]:
cfg = get_config(db, scene)
maximum, reset_at = _effective_quota(db, user_id, scene, cfg)
used = used_plays(db, user_id, scene, reset_at=reset_at)
video_url = str(cfg.get("video_url") or "").strip()
duration = int(cfg.get("duration_ms") or 0)
if not cfg.get("enabled"):
return _prepare_miss(scene, "disabled", cfg, used, maximum)
if not video_url or cfg.get("analysis_status") != "valid" or duration <= 0:
return _prepare_miss(
scene, "video_unavailable", cfg, used, maximum
)
if maximum is not None and used >= maximum:
return _prepare_miss(
scene, "play_limit_reached", cfg, used, maximum
)
if not cfg.get("enabled") or not video_url or max_plays <= 0 or used >= max_plays:
return _miss(used)
seq = used + 1
now = _now()
play = GuideVideoPlay(
user_id=user_id,
play_token=uuid.uuid4().hex,
scene=scene,
seq=seq,
seq=None,
video_url=video_url,
coin=0,
status="playing",
coin=int(cfg["reward_coin"]),
duration_ms=duration,
config_version=int(cfg["config_version"]),
settled_circles=0,
status="prepared",
completed=0,
started_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
prepared_at=now,
expires_at=now + PLAN_TTL,
started_at=None,
)
db.add(play)
# 上面的 COUNT 判定是无锁 check-then-insert:并发 /start 会都算出同一个 seq。
# (user_id, seq) 唯一键让只有一个能落库,其余撞键 → 回滚后按"这次不放视频"降级,
# 客户端照旧走广告链路。没有它,并发就能绕过 max_plays 无限刷金币。
try:
if commit:
db.commit()
else:
db.flush()
except IntegrityError:
db.rollback()
return _miss(used_plays(db, user_id))
db.commit()
return {
"should_play": True,
"reason": "ready",
"scene": scene,
"video_url": video_url,
"play_token": play.play_token,
"reward_coin": reward_coin,
"seq": seq,
"remaining": max(0, max_plays - seq),
"config_version": play.config_version,
"duration_ms": duration,
"circle_count": CIRCLE_COUNT,
"circle_duration_ms": duration / CIRCLE_COUNT,
"reward_coin": play.coin,
"reward_per_circle": play.coin // CIRCLE_COUNT,
"seq": used + 1,
"remaining": _remaining(maximum, used + 1),
"expires_at": play.expires_at.isoformat(),
}
def _find_play(db: Session, user_id: int, token: str) -> GuideVideoPlay | None:
"""按 (play_token, user_id) 取播放行 —— 带 user_id 是防拿别人的 token 来兑。"""
return db.execute(
select(GuideVideoPlay).where(
GuideVideoPlay.play_token == token,
GuideVideoPlay.play_token == token.strip(),
GuideVideoPlay.user_id == user_id,
)
).scalar_one_or_none()
def grant_play(
db: Session, user_id: int, *, play_token: str, completed: bool
def _start_out(
play: GuideVideoPlay,
maximum: int | None,
used: int,
status: str,
) -> dict[str, Any]:
"""按 play_token 发这次引导视频的金币(幂等)。播完 / 中途关闭都发。
assert play.started_at is not None and play.seq is not None and play.video_url
return {
"started": True,
"status": status,
"play_token": play.play_token,
"scene": play.scene,
"video_url": play.video_url,
"config_version": play.config_version,
"duration_ms": play.duration_ms,
"circle_count": CIRCLE_COUNT,
"circle_duration_ms": play.duration_ms / CIRCLE_COUNT,
"reward_coin": play.coin,
"reward_per_circle": play.coin // CIRCLE_COUNT,
"seq": play.seq,
"remaining": _remaining(maximum, used),
"started_at": play.started_at.isoformat(),
}
返回 {granted, coin, status}:granted=True 表示**本次调用真的入账了**;
重复上报返回 granted=False + 已发金币(客户端据此不重复累加 toast 金额)
"""
token = (play_token or "").strip()
# 金币额度以**服务端配置**为准,不信客户端(客户端只上报"播完/关闭")。
coin = int(get_config(db).get("reward_coin") or 0)
# 幂等核心:把 status 放进 WHERE 做条件更新(compare-and-set),而不是"先读再判再写"。
# 「播完」与「✕ 关闭」抢跑、或客户端超时重试时,两个请求会都读到 status='playing',
# 无锁的话就都往下发币、都 commit,金币入账两次(不用恶意,重试就会中招)。改成条件更新后
# 并发里只有一条 rowcount=1,另一条拿 0 → 按已发返回,不二次铸币。
# (PG READ COMMITTED 下后到的 UPDATE 阻塞到对手提交,再按新版本重判 status;SQLite 写串行。)
#
# 别指望 IntegrityError 兜底:这里只 UPDATE 不 INSERT,撞不到 uq_guide_video_play_token;
# 而 biz_type='guide_video' 的金币流水也不在 ux_coin_transaction_task_ref 的谓词
# (biz_type LIKE 'task%')覆盖范围内 —— 两个唯一键在这条路径上都是不生效的。
def start_play(db: Session, user_id: int, *, play_token: str) -> dict[str, Any]:
play = _find_play(db, user_id, play_token)
if play is None:
raise PlayStateError("play_not_found", "播放计划不存在")
cfg = get_config(db, play.scene)
maximum, reset_at = _effective_quota(
db, user_id, play.scene, cfg
)
used = used_plays(
db, user_id, play.scene, reset_at=reset_at
)
if play.status in {"started", "completed"}:
return _start_out(play, maximum, used, "already_started")
if play.status != "prepared":
raise PlayStateError("play_not_found", "播放计划不可用")
now = _now()
if play.expires_at is None or now > play.expires_at:
raise PlayStateError(
"plan_expired", "播放计划已过期,请重新获取",
reprepare_required=True,
)
if int(cfg["config_version"]) != play.config_version:
raise PlayStateError(
"config_changed", "视频配置已变化,请重新获取",
reprepare_required=True,
)
if maximum is not None and used >= maximum:
raise PlayStateError("play_limit_reached", "播放次数已用完")
# seq 在数据库中是账号+场景生命周期唯一值;白名单重置只重置额度,
# 不能从 1 重新编号,否则会与历史记录冲突。
play.seq = used_plays(db, user_id, play.scene) + 1
play.status = "started"
play.started_at = now
try:
db.commit()
db.refresh(play)
except IntegrityError as exc:
db.rollback()
raise PlayStateError("play_limit_reached", "并发起播冲突,请重新获取") from exc
return _start_out(play, maximum, used + 1, "started")
def _coin_balance(db: Session, user_id: int) -> int:
account = db.get(CoinAccount, user_id)
return int(account.coin_balance if account else 0)
def grant_circle(
db: Session, user_id: int, *, play_token: str, circle: int
) -> dict[str, Any]:
play = _find_play(db, user_id, play_token)
balance = _coin_balance(db, user_id)
base = {
"granted": False,
"retryable": False,
"retry_after_ms": 0,
"circle": circle,
"granted_coin": 0,
"settled_circles": int(play.settled_circles if play else 0),
"coin_balance": balance,
}
if play is None:
return {**base, "status": "not_found"}
if play.status not in {"started", "completed"} or play.started_at is None:
return {**base, "status": "not_started"}
settled = int(play.settled_circles)
if circle <= settled:
return {
**base,
"status": "already_granted",
"granted_coin": play.coin // CIRCLE_COUNT,
"settled_circles": settled,
}
if settled >= CIRCLE_COUNT:
return {**base, "status": "finished", "settled_circles": settled}
if circle != settled + 1:
return {
**base,
"status": "out_of_order",
"retryable": True,
"settled_circles": settled,
}
# 向上取整到毫秒,绝不让第 N 圈早于精确 N/10 边界发奖。
boundary_ms = (circle * play.duration_ms + CIRCLE_COUNT - 1) // CIRCLE_COUNT
elapsed_ms = max(0, int((_now() - play.started_at).total_seconds() * 1000))
if elapsed_ms < boundary_ms:
return {
**base,
"status": "too_early",
"retryable": True,
"retry_after_ms": boundary_ms - elapsed_ms,
"settled_circles": settled,
}
final = circle == CIRCLE_COUNT
won = db.execute(
update(GuideVideoPlay)
.where(
GuideVideoPlay.play_token == token,
GuideVideoPlay.user_id == user_id,
GuideVideoPlay.status == "playing",
GuideVideoPlay.id == play.id,
GuideVideoPlay.status == "started",
GuideVideoPlay.settled_circles == settled,
)
.values(
status="granted",
coin=coin,
completed=1 if completed else 0,
granted_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
settled_circles=circle,
status="completed" if final else "started",
completed=1 if final else 0,
granted_at=_now() if final else None,
)
.execution_options(synchronize_session=False)
).rowcount
if not won:
# 没抢到:token 不存在 / 不是本人的 / 已被另一次上报发过。回滚拿干净快照再区分两者
# (对手此时必然已提交 —— 我们就是被它挡下的,所以读得到它写的 coin)。
db.rollback()
play = _find_play(db, user_id, token)
if play is None:
return {"granted": False, "coin": 0, "status": "not_found"}
return {"granted": False, "coin": play.coin, "status": "already_granted"}
fresh = _find_play(db, user_id, play_token)
fresh_settled = int(fresh.settled_circles if fresh else 0)
return {
**base,
"status": "already_granted" if circle <= fresh_settled else "out_of_order",
"retryable": circle > fresh_settled,
"granted_coin": (
play.coin // CIRCLE_COUNT if circle <= fresh_settled else 0
),
"settled_circles": fresh_settled,
"coin_balance": _coin_balance(db, user_id),
}
if coin > 0:
crud_wallet.grant_coins(
db,
user_id,
coin,
biz_type=BIZ_TYPE,
ref_id=token,
remark="新手引导视频奖励",
)
db.commit()
return {"granted": True, "coin": coin, "status": "granted"}
per_circle = play.coin // CIRCLE_COUNT
account, _ = crud_wallet.grant_coins(
db,
user_id,
per_circle,
biz_type=BIZ_TYPE,
ref_id=f"{play.play_token}:{circle}",
remark=f"新手引导视频{circle}奖励",
)
try:
db.commit()
except IntegrityError:
# 流水唯一键是第二道幂等保险;若撞键,回滚后返回权威状态。
db.rollback()
fresh = _find_play(db, user_id, play_token)
return {
**base,
"status": "already_granted",
"granted_coin": play.coin // CIRCLE_COUNT,
"settled_circles": int(fresh.settled_circles if fresh else settled),
"coin_balance": _coin_balance(db, user_id),
}
return {
**base,
"granted": True,
"status": "granted",
"granted_coin": per_circle,
"settled_circles": circle,
"coin_balance": int(account.coin_balance),
}
def cleanup_old_videos(db: Session, *, protection: timedelta = PLAN_TTL) -> int:
"""清理超过保护期且未被当前配置/有效计划/近期起播引用的托管旧片。"""
now = _now()
protected_urls: set[str] = set()
for scene in SCENES:
url = get_config(db, scene).get("video_url")
if url:
protected_urls.add(str(url))
protected_urls.update(
str(url)
for url in db.execute(
select(GuideVideoPlay.video_url).where(
GuideVideoPlay.video_url.is_not(None),
(
(
(GuideVideoPlay.status == "prepared")
& (GuideVideoPlay.expires_at >= now)
)
| (
(GuideVideoPlay.status == "started")
& (GuideVideoPlay.started_at >= now - protection)
)
),
)
).scalars()
if url
)
directory = Path(settings.MEDIA_ROOT) / "guide_video"
if not directory.is_dir():
return 0
cutoff = now.timestamp() - protection.total_seconds()
removed = 0
for path in directory.glob("guide_*.mp4"):
url = f"{settings.MEDIA_URL_PREFIX}/guide_video/{path.name}"
try:
if url not in protected_urls and path.stat().st_mtime <= cutoff:
media.delete_guide_video(url)
removed += 1
except OSError:
continue
return removed
+21 -7
View File
@@ -25,6 +25,7 @@ from datetime import datetime, timedelta
from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.rewards import CN_TZ
from app.models.comparison import ComparisonRecord
from app.models.ops_marquee_seed import OpsMarqueeSeed
@@ -54,7 +55,7 @@ _SEED_MAX_CENTS = 100000
# 展示层随机(抽样/金额/时间/名字合成)仍每次重算,缓存只省查询;新记录最多晚 30s 进轮播,可接受。
_REAL_ROWS_TTL_SECONDS = 30
_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用
_real_rows_cache: dict = {"at": None, "rows": None}
_real_rows_cache: dict = {"at": None, "rows": None, "test_phones": None}
# ===== 用户标识脱敏(对齐 PRD) + 种子无真实昵称时的假名合成 =====
# 脱敏规则(按字符数,中英文皆适用):有昵称→n≥5「首+***+末」、n=4「首+**+末」、n≤3「首+**」;
@@ -198,10 +199,16 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
返回纯元组(脱离 session),可安全跨请求复用极端并发下偶尔多查一次(无锁幂等),纯门面无副作用
"""
now = datetime.now(CN_TZ)
test_phones = tuple(sorted(settings.test_account_phones))
cached, at = _real_rows_cache["rows"], _real_rows_cache["at"]
if cached is not None and at is not None and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS:
if (
cached is not None
and at is not None
and _real_rows_cache["test_phones"] == test_phones
and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS
):
return cached
rows = db.execute(
stmt = (
select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents, User.nickname)
.join(User, User.id == ComparisonRecord.user_id)
.where(
@@ -211,9 +218,12 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
)
.order_by(ComparisonRecord.created_at.desc())
.limit(_REAL_ROWS_FETCH_CAP)
).all()
)
if test_phones:
stmt = stmt.where(User.phone.not_in(test_phones))
rows = db.execute(stmt).all()
out = [(int(uid), int(sc), nick) for uid, sc, nick in rows]
_real_rows_cache["rows"], _real_rows_cache["at"] = out, now
_real_rows_cache.update(rows=out, at=now, test_phones=test_phones)
return out
@@ -340,7 +350,7 @@ def list_real_records(
# pool: [(cluster_key, item)];cluster_key 供去连簇——真实=user_id、种子=各自唯一负数(互不聚簇)
pool: list[tuple[int, dict]] = []
if mode != "seed":
rows = db.execute(
stmt = (
select(
ComparisonRecord.user_id,
ComparisonRecord.saved_amount_cents,
@@ -355,7 +365,11 @@ def list_real_records(
)
.order_by(ComparisonRecord.created_at.desc())
.limit(_REAL_BROWSE_CAP)
).all()
)
test_phones = tuple(sorted(settings.test_account_phones))
if test_phones:
stmt = stmt.where(User.phone.not_in(test_phones))
rows = db.execute(stmt).all()
for uid, sc, nick, ca in rows:
pool.append((
int(uid),
+29 -7
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import math
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -10,9 +10,22 @@ from sqlalchemy.orm import Session
from app.models.phone_rebind_log import PhoneRebindLog
def rebound_within_days(db: Session, phone: str, days: int) -> bool:
def rebound_within_days(
db: Session,
phone: str,
days: int,
*,
reset_at: datetime | None = None,
) -> bool:
"""该手机号在最近 days 天内是否换绑过(命中 → 禁止再次换绑)。"""
since = datetime.now(timezone.utc) - timedelta(days=days)
since = datetime.now(UTC) - timedelta(days=days)
if reset_at is not None:
reset_value = (
reset_at.replace(tzinfo=UTC)
if reset_at.tzinfo is None
else reset_at.astimezone(UTC)
)
since = max(since, reset_value)
stmt = (
select(PhoneRebindLog.id)
.where(PhoneRebindLog.phone == phone, PhoneRebindLog.rebound_at >= since)
@@ -21,16 +34,25 @@ def rebound_within_days(db: Session, phone: str, days: int) -> bool:
return db.execute(stmt).first() is not None
def remaining_block_days(db: Session, phone: str, days: int) -> int:
def remaining_block_days(
db: Session,
phone: str,
days: int,
*,
reset_at: datetime | None = None,
) -> int:
"""距离该手机号可再次换绑还剩几天(向上取整;无记录返回 0)。"""
conditions = [PhoneRebindLog.phone == phone]
if reset_at is not None:
conditions.append(PhoneRebindLog.rebound_at >= reset_at)
last = db.execute(
select(func.max(PhoneRebindLog.rebound_at)).where(PhoneRebindLog.phone == phone)
select(func.max(PhoneRebindLog.rebound_at)).where(*conditions)
).scalar_one_or_none()
if last is None:
return 0
if last.tzinfo is None: # SQLite 取回 naive datetime,按 UTC 归一
last = last.replace(tzinfo=timezone.utc)
remaining = (last + timedelta(days=days) - datetime.now(timezone.utc)).total_seconds()
last = last.replace(tzinfo=UTC)
remaining = (last + timedelta(days=days) - datetime.now(UTC)).total_seconds()
return max(0, math.ceil(remaining / 86400))
+92 -37
View File
@@ -9,15 +9,10 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.config_schema import (
RISK_COMPARE_DAILY_THRESHOLD_KEY,
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
RISK_SMS_HOURLY_THRESHOLD_KEY,
)
from app.core import limit_policy
from app.models.app_config import AppConfig
from app.models.comparison import ComparisonRecord
from app.models.risk import BehaviorEvent, RiskIncident, SubjectRestriction
from app.repositories import app_config
CN_TZ = ZoneInfo("Asia/Shanghai")
@@ -41,7 +36,6 @@ class RuleSpec:
code: str
event_type: str
subject_type: str
threshold_key: str
window: str
count_outcomes: tuple[str, ...]
@@ -51,7 +45,6 @@ RULES: dict[str, RuleSpec] = {
code=RULE_SMS_HOURLY,
event_type=EVENT_SMS_SEND,
subject_type="device",
threshold_key=RISK_SMS_HOURLY_THRESHOLD_KEY,
window="hour",
count_outcomes=("success",),
),
@@ -59,22 +52,23 @@ RULES: dict[str, RuleSpec] = {
code=RULE_ONECLICK_DAILY,
event_type=EVENT_ONECLICK_LOGIN,
subject_type="device",
threshold_key=RISK_ONECLICK_DAILY_THRESHOLD_KEY,
window="day",
count_outcomes=("success", "failed"),
),
}
RULE_THRESHOLD_KEYS: dict[str, str] = {
RULE_SMS_HOURLY: RISK_SMS_HOURLY_THRESHOLD_KEY,
RULE_ONECLICK_DAILY: RISK_ONECLICK_DAILY_THRESHOLD_KEY,
RULE_COMPARE_DAILY: RISK_COMPARE_DAILY_THRESHOLD_KEY,
RISK_LIMIT_RULE_CODES: dict[str, str] = {
RULE_SMS_HOURLY: "risk.sms.hourly",
RULE_ONECLICK_DAILY: "risk.oneclick.daily",
RULE_COMPARE_DAILY: "risk.compare.daily",
}
def get_rule_threshold(db: Session, rule_code: str) -> int:
"""读取规则当前阈值;配置表为空时回退上线前的 5/20/100 默认值。"""
return int(app_config.get_value(db, RULE_THRESHOLD_KEYS[rule_code]))
return limit_policy.resolve(
db, RISK_LIMIT_RULE_CODES[rule_code]
).global_limit
def utcnow() -> datetime:
@@ -257,22 +251,34 @@ def _upsert_incident(
def evaluate_behavior_rule(
db: Session, *, rule_code: str, subject_id: str, at: datetime
db: Session,
*,
rule_code: str,
subject_id: str,
at: datetime,
threshold: int | None = None,
subject_reset_at: datetime | None = None,
) -> RiskIncident | None:
spec = RULES[rule_code]
threshold = get_rule_threshold(db, rule_code)
effective_threshold = threshold or get_rule_threshold(db, rule_code)
window_key, window_start, end = _window_bounds(at, spec.window)
reset_at = get_rule_reset_at(db, rule_code)
start = max(window_start, reset_at) if reset_at else window_start
baselines = [value for value in (reset_at, subject_reset_at) if value is not None]
start = max(window_start, *baselines) if baselines else window_start
count, first_at, last_at, triggered_at = _event_stats(
db,
spec,
subject_id=subject_id,
start=start,
end=end,
threshold=threshold,
threshold=effective_threshold,
)
if count < threshold or first_at is None or last_at is None or triggered_at is None:
if (
count < effective_threshold
or first_at is None
or last_at is None
or triggered_at is None
):
return None
return _upsert_incident(
db,
@@ -300,7 +306,6 @@ def reconcile_behavior_rule(
"""按当前阈值重算短信/一键登录当前窗口,并收起已不再命中的待处理告警。"""
spec = RULES[rule_code]
current = at or utcnow()
threshold = get_rule_threshold(db, rule_code)
window_key, window_start, end = _window_bounds(current, spec.window)
reset_at = get_rule_reset_at(db, rule_code)
start = max(window_start, reset_at) if reset_at else window_start
@@ -311,23 +316,39 @@ def reconcile_behavior_rule(
BehaviorEvent.occurred_at >= start,
BehaviorEvent.occurred_at < end,
)
qualifying_rows = db.execute(
subject_rows = db.execute(
select(
BehaviorEvent.subject_id,
func.max(BehaviorEvent.occurred_at),
func.max(BehaviorEvent.phone),
)
.where(*filters)
.group_by(BehaviorEvent.subject_id)
.having(func.count(BehaviorEvent.id) >= threshold)
).all()
qualifying = {str(subject_id) for subject_id, _ in qualifying_rows}
for subject_id, last_at in qualifying_rows:
evaluate_behavior_rule(
qualifying: set[str] = set()
policy_code = {
RULE_SMS_HOURLY: "risk.sms.hourly",
RULE_ONECLICK_DAILY: "risk.oneclick.daily",
}[rule_code]
for subject_id, last_at, phone in subject_rows:
policy = limit_policy.resolve(
db,
policy_code,
phone=phone,
device=str(subject_id),
)
if policy.suppressed or policy.limit is None:
continue
incident = evaluate_behavior_rule(
db,
rule_code=rule_code,
subject_id=str(subject_id),
at=last_at or current,
threshold=policy.limit,
subject_reset_at=policy.reset_at,
)
if incident is not None:
qualifying.add(str(subject_id))
open_incidents = db.scalars(
select(RiskIncident).where(
@@ -383,7 +404,29 @@ def record_behavior_event(
db.add(event)
db.flush()
if evaluate_rule:
evaluate_behavior_rule(db, rule_code=evaluate_rule, subject_id=subject_id, at=at)
policy_code = {
RULE_SMS_HOURLY: "risk.sms.hourly",
RULE_ONECLICK_DAILY: "risk.oneclick.daily",
}.get(evaluate_rule)
policy = (
limit_policy.resolve(
db,
policy_code,
phone=phone,
device=device_id or subject_id,
)
if policy_code
else None
)
if policy is None or not policy.suppressed:
evaluate_behavior_rule(
db,
rule_code=evaluate_rule,
subject_id=subject_id,
at=at,
threshold=policy.limit if policy else None,
subject_reset_at=policy.reset_at if policy else None,
)
if commit:
db.commit()
db.refresh(event)
@@ -396,20 +439,33 @@ def sync_compare_incident(
user_id: int,
at: datetime,
threshold: int | None = None,
device_id: str | None = None,
commit: bool = True,
) -> RiskIncident | None:
effective_threshold = threshold or get_rule_threshold(db, RULE_COMPARE_DAILY)
policy = limit_policy.resolve_for_user(
db,
"risk.compare.daily",
user_id,
device=device_id,
)
if policy.suppressed:
return None
effective_threshold = threshold or policy.limit or get_rule_threshold(
db, RULE_COMPARE_DAILY
)
# comparison_record 的既有写入口统一落“北京时间 naive”时间;这里必须沿用同一
# 口径,否则 SQLite/PG session timezone 不同时会把凌晨记录算到前一天。
local = at.astimezone(CN_TZ).replace(tzinfo=None) if at.tzinfo else at
window_start = local.replace(hour=0, minute=0, second=0, microsecond=0)
end = window_start + timedelta(days=1)
window_key = window_start.strftime("%Y-%m-%d")
reset_at = get_rule_reset_at(db, RULE_COMPARE_DAILY)
reset_local = (
reset_at.astimezone(CN_TZ).replace(tzinfo=None) if reset_at else None
)
start = max(window_start, reset_local) if reset_local else window_start
global_reset_at = get_rule_reset_at(db, RULE_COMPARE_DAILY)
baselines = [
value.astimezone(CN_TZ).replace(tzinfo=None)
for value in (global_reset_at, policy.reset_at)
if value is not None
]
start = max(window_start, *baselines) if baselines else window_start
filters = (
ComparisonRecord.user_id == user_id,
ComparisonRecord.created_at >= start,
@@ -464,7 +520,6 @@ def reconcile_compare_rule(
reset_at.astimezone(CN_TZ).replace(tzinfo=None) if reset_at else None
)
start = max(window_start, reset_local) if reset_local else window_start
threshold = get_rule_threshold(db, RULE_COMPARE_DAILY)
rows = db.execute(
select(
ComparisonRecord.user_id,
@@ -476,17 +531,17 @@ def reconcile_compare_rule(
ComparisonRecord.created_at < end,
)
.group_by(ComparisonRecord.user_id)
.having(func.count(ComparisonRecord.id) >= threshold)
).all()
qualifying = {str(user_id) for user_id, _ in rows}
qualifying: set[str] = set()
for user_id, last_at in rows:
sync_compare_incident(
incident = sync_compare_incident(
db,
user_id=int(user_id),
at=last_at or current,
threshold=threshold,
commit=False,
)
if incident is not None:
qualifying.add(str(user_id))
open_incidents = db.scalars(
select(RiskIncident).where(
+26 -11
View File
@@ -132,10 +132,20 @@ def get_or_create_account(
) -> CoinAccount:
"""取用户金币账户,不存在则建一个空账户。
lock=True 时对已存在的账户行加 SELECT FOR UPDATE(--写余额的调用方串行化,防并发
双写余额错位, admin set 模式连点);默认 False 不改 C 端发奖行为SQLite 下为 no-op
lock=True 时对已存在的账户行加 SELECT FOR UPDATE并强制刷新 identity map 中可能
缓存的旧余额所有金币写入口都会使用它SQLite FOR UPDATE no-op
"""
acc = db.get(CoinAccount, user_id, with_for_update=True) if lock else db.get(CoinAccount, user_id)
if lock:
# populate_existing 很关键:同一 Session 可能早已缓存旧余额。只加 FOR UPDATE 而
# 继续复用 identity map 里的旧对象,仍会在另一笔发奖提交后把余额覆盖回去。
acc = db.execute(
select(CoinAccount)
.where(CoinAccount.user_id == user_id)
.with_for_update()
.execution_options(populate_existing=True)
).scalar_one_or_none()
else:
acc = db.get(CoinAccount, user_id)
if acc is None:
acc = CoinAccount(
user_id=user_id,
@@ -165,7 +175,9 @@ def grant_coins(
返回 (account, transaction)调用方负责 commit
"""
acc = get_or_create_account(db, user_id, commit=False)
# 所有金币来源统一遵守账户行锁协议;否则 guide 锁了账户,广告/签到等未锁路径
# 仍可拿旧余额做 ORM 读改写,最终余额会小于流水累计。
acc = get_or_create_account(db, user_id, commit=False, lock=True)
acc.coin_balance += amount
if amount > 0:
acc.total_coin_earned += amount
@@ -198,7 +210,7 @@ def grant_cash(
[grant_coins] 同模式(运营手动调现金 / 测试发现金用)返回 (account, transaction),
调用方负责 commit不在此校验扣成负由调用方(admin router)按业务保护
"""
acc = get_or_create_account(db, user_id, commit=False)
acc = get_or_create_account(db, user_id, commit=False, lock=True)
acc.cash_balance_cents += amount_cents
txn = CashTransaction(
@@ -228,7 +240,7 @@ def grant_invite_cash(
invite_cash_transaction, commit与金币兑换的 cash_balance_cents **物理隔离**
(产品红线:邀请奖励金 金币现金,两本账不可累加)返回 (account, transaction),
调用方负责 commit不在此校验扣成负由调用方按业务保护"""
acc = get_or_create_account(db, user_id, commit=False)
acc = get_or_create_account(db, user_id, commit=False, lock=True)
acc.invite_cash_balance_cents += amount_cents
txn = InviteCashTransaction(
@@ -286,7 +298,8 @@ def exchange_coins_to_cash(
if coin_amount < floor_min or coin_amount % COIN_PER_CENT != 0:
raise InvalidExchangeAmountError
acc = get_or_create_account(db, user_id, commit=False)
# 余额充足校验必须和扣减遵守同一账户锁,否则并发消费都可能通过旧余额校验。
acc = get_or_create_account(db, user_id, commit=False, lock=True)
if acc.coin_balance < coin_amount:
raise InsufficientCoinError
@@ -722,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 本轮无档位概念 返回空列表(邀请页客户端仍用本地写死档位,行为不变)
余额是否足够由客户端本地判断(余额随兑换实时变化,不在此快照)
"""
@@ -772,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)
@@ -785,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
+15 -3
View File
@@ -7,9 +7,19 @@
"""
from __future__ import annotations
import re
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.config import settings
def _validate_login_phone(phone: str) -> str:
"""普通号保持大陆手机号格式;仅显式配置的测试号允许例外。"""
if re.fullmatch(r"^1\d{10}$", phone) or phone in settings.test_account_phones:
return phone
raise ValueError("invalid phone")
# ===== 用户对外信息 =====
@@ -72,7 +82,7 @@ class JverifyLoginRequest(BaseModel):
# ===== 短信验证码 =====
class SmsSendRequest(BaseModel):
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
phone: str = Field(..., min_length=11, max_length=11)
device_id: str = Field(
"", max_length=64,
description="硬件级设备标识(Android ANDROID_ID),用于发码防刷按 设备+IP 限流;空=按 IP 聚一桶",
@@ -80,6 +90,7 @@ class SmsSendRequest(BaseModel):
device_model: str = Field(
"", max_length=128, description="客户端设备型号快照,用于短信安全审计"
)
_valid_phone = field_validator("phone")(_validate_login_phone)
class SmsSendResponse(BaseModel):
@@ -89,7 +100,7 @@ class SmsSendResponse(BaseModel):
class SmsLoginRequest(BaseModel):
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
phone: str = Field(..., min_length=11, max_length=11)
code: str = Field(..., min_length=4, max_length=8)
device_id: str = Field(
"", max_length=64,
@@ -98,6 +109,7 @@ class SmsLoginRequest(BaseModel):
device_model: str = Field(
"", max_length=128, description="客户端设备型号快照,用于短信验证安全审计"
)
_valid_phone = field_validator("phone")(_validate_login_phone)
# ===== Refresh =====
+32 -4
View File
@@ -107,6 +107,14 @@ class ComparisonRecordIn(BaseModel):
# 明细
items: list[ComparisonItemIn] = Field(default_factory=list)
comparison_results: list[ComparisonResultIn] = Field(default_factory=list)
# 展示模型统一数组(pricebot done.params.platforms 原样透传): 每平台一行、自带
# 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。
# 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(不单列),
# admin「卡在哪一步」从这里读。dict{platform_id: {...}} 宽松存(结构由 pricebot 定——是
@@ -173,8 +181,13 @@ class ComparisonRecordOut(BaseModel):
skipped_dish_count: int | None = None
status: str
information: str | None = None
# 失败卡「原因」文案:具体失败给具体原因,纯系统失败为 None(端侧品牌兜底)。见模型 fail_reason。
fail_reason: str | None = None
items: list = []
comparison_results: list = []
# 展示模型统一数组(每平台一行、自带 status/is_best/display/display_order): 记录页据此
# 直渲染, 不再靠 comparison_results + 前端派生。老记录为空 → 前端回退 comparison_results。
platforms: list = []
skipped_dish_names: list = []
total_ms: int | None = None
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
@@ -202,17 +215,24 @@ class ComparisonRecordCreatedOut(BaseModel):
class CompareStartReserveIn(BaseModel):
"""Reserve one authenticated comparison start before the agent begins."""
"""Reserve one authenticated comparison start before the agent begins.
trace_id: str = Field(..., min_length=1, max_length=64)
trace_id 可选不带 = 请服务端签发统一 trace_id 由后端下发前端/SLS 日志/
pricebot 全链用同一个 id = 沿用客户端值老客户端兼容 + 网络重试幂等
"""
trace_id: str | None = Field(default=None, min_length=1, max_length=64)
business_type: str = Field(default="food", min_length=1, max_length=16)
device_id: str | None = Field(default=None, max_length=64)
class CompareStartReserveOut(BaseModel):
limit: int
limit: int | None
used: int
remaining: int
remaining: int | None
# 本次比价全链 trace_id(服务端签发的,或回显客户端带来的)。客户端必须以它为准,
# 贯穿 Phase1/Phase2 step、比价记录、trace 收尾与前端运行日志上报。
trace_id: str
class CompareStatsOut(BaseModel):
@@ -240,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=无限制)")
+18 -1
View File
@@ -58,9 +58,13 @@ class CouponSessionIn(BaseModel):
- 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒)
- 收尾(completed/failed/abandoned): elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count
不鉴权(同领券循环 MVP, device_id/trace_id),user_id 登录态带上做留痕(可空)
trace_id 可选:started 不带 = 请服务端签发本轮领券 trace_id(统一 trace_id 由后端下发,
响应 CouponSessionOut.trace_id 返回,客户端全程用它); = 沿用客户端值(老客户端兼容)
started trace_id 不签发(防孤儿行),返回 trace_id=null 且不写库
"""
trace_id: str
trace_id: str | None = None
device_id: str
status: str # started / completed / failed / abandoned
started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis)
@@ -74,3 +78,16 @@ class CouponSessionIn(BaseModel):
platform_elapsed: dict[str, int] | None = None
claimed_count: int | None = None
trace_url: str | None = None
class CouponSessionOut(BaseModel):
"""POST /api/v1/coupon/session 响应。
trace_id = 本轮领券全链 id(服务端签发的,或回显客户端带来的);客户端以它为准贯穿
/coupon/step 循环收尾上报与前端运行日志 不能沿用旧的 dict[str, bool] 返回注解
FastAPI 会按注解校验响应,字符串 trace_id bool 校验必炸,故显式建模
started 且缺 trace_id 时为 null(不签发防孤儿行)
"""
ok: bool = True
trace_id: str | None = None
+11
View File
@@ -12,6 +12,7 @@ class DeviceRegisterRequest(BaseModel):
registration_id: str | None = None
push_vendor: str | None = None
push_token: str | None = None
push_binding_id: str | None = Field(default=None, max_length=128)
platform: str = "android"
app_version: str | None = None
@@ -23,6 +24,7 @@ class HeartbeatRequest(BaseModel):
registration_id: str | None = None
push_vendor: str | None = None
push_token: str | None = None
push_binding_id: str | None = Field(default=None, max_length=128)
class DeviceOut(BaseModel):
@@ -33,6 +35,7 @@ class DeviceOut(BaseModel):
registration_id: str | None
push_vendor: str | None
push_token: str | None
push_binding_id: str | None
ever_protected: bool
liveness_state: str
last_heartbeat_at: datetime | None
@@ -43,6 +46,14 @@ class OkResponse(BaseModel):
ok: bool = True
class DeviceUnregisterRequest(BaseModel):
device_id: str = Field(min_length=1, max_length=128)
class DeferredDeviceUnregisterRequest(DeviceUnregisterRequest):
push_binding_id: str = Field(min_length=32, max_length=128)
class LivenessOut(BaseModel):
"""本机掉线告警状态(后置检测 pull 版)。客户端只需这一个布尔判断要不要弹「开启自启动」引导,
故只返回 kill_alert_pending(不暴露设备详情 / 内部 liveness_state )从未注册过 默认 False(无告警)"""
+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
+56 -20
View File
@@ -1,36 +1,72 @@
"""新手引导视频(领券等候浮层前 N 次替代广告)的客户端请求/响应契约"""
from __future__ import annotations
"""客户端引导视频三阶段协议"""
from typing import Literal
from pydantic import BaseModel, Field
GuideScene = Literal["coupon", "comparison"]
class GuideVideoPrepareIn(BaseModel):
scene: GuideScene = "coupon"
class GuideVideoPrepareOut(BaseModel):
should_play: bool
reason: str
scene: GuideScene
video_url: str | None = None
play_token: str = ""
config_version: int = 0
duration_ms: int = 0
circle_count: int = 10
circle_duration_ms: float = 0
reward_coin: int = 0
reward_per_circle: int = 0
seq: int = 0
remaining: int = 0
expires_at: str | None = None
class GuideVideoStartIn(BaseModel):
"""开播询问。scene 目前只有 coupon(领券浮层);预留给日后比价等场景。"""
scene: str = Field(default="coupon", max_length=16)
play_token: str = Field(min_length=1, max_length=64)
class GuideVideoStartOut(BaseModel):
"""should_play=False 时客户端照旧走广告链路,其余字段无意义。"""
should_play: bool
video_url: str | None = None # 相对地址 /media/...;客户端自行拼 BASE_URL
play_token: str = "" # 发奖幂等键
reward_coin: int = 0 # 播完/中途关闭都发的固定金币
seq: int = 0 # 本账号第几次
remaining: int = 0 # 发完这次还剩几次
started: bool
status: Literal["started", "already_started"]
play_token: str
scene: GuideScene
video_url: str
config_version: int
duration_ms: int
circle_count: int = 10
circle_duration_ms: float
reward_coin: int
reward_per_circle: int
seq: int
remaining: int
started_at: str
class GuideVideoRewardIn(BaseModel):
"""播完或中途关闭都调这个;completed 只做留痕,两者都发币。"""
play_token: str = Field(min_length=1, max_length=64)
completed: bool = False
circle: int = Field(ge=1, le=10)
class GuideVideoRewardOut(BaseModel):
"""granted=True 表示本次调用真的入账(重复上报为 False,coin 是已发金额)。"""
granted: bool
coin: int
status: str
status: Literal[
"granted",
"already_granted",
"too_early",
"out_of_order",
"not_started",
"not_found",
"finished",
]
retryable: bool = False
retry_after_ms: int = 0
circle: int
granted_coin: int = 0
settled_circles: int = 0
coin_balance: int = 0
+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])
+1 -1
View File
@@ -135,7 +135,7 @@ def repair_missing_comparison_llm_costs(
select(ComparisonRecord.id, ComparisonRecord.trace_id)
.where(
*date_conditions,
ComparisonRecord.status.in_(("success", "failed")),
ComparisonRecord.status.in_(("success", "failed", "cancelled")),
ComparisonRecord.llm_cost_yuan.is_(None),
)
.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc())
+143 -19
View File
@@ -83,8 +83,13 @@ def _dispatch(
extra: dict[str, str] | None = None,
dedup_key: str | None = None,
push_vars: dict[str, str] | None = None,
push_token_contains: str | None = None,
) -> Notification | None:
"""落一条站内消息并向该用户设备直推。返回落库行;去重命中/失败返回 None。"""
logger.info(
"notification dispatch started user_id=%s type=%s dedup_key=%s",
user_id, type_key, dedup_key,
)
try:
row = notif_repo.create_notification(
db,
@@ -111,11 +116,20 @@ def _dispatch(
logger.exception("rollback after notification failure also failed")
return None
_push_to_user_devices(db, row, push_vars)
logger.info(
"notification created user_id=%s type=%s notification_id=%s dedup_key=%s",
user_id, type_key, row.id, dedup_key,
)
_push_to_user_devices(db, row, push_vars, push_token_contains)
return row
def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, str] | None) -> None:
def _push_to_user_devices(
db: Session,
row: Notification,
push_vars: dict[str, str] | None,
push_token_contains: str | None = None,
) -> None:
"""向消息归属用户的全部厂商推送目标直推(best-effort,单设备失败不影响其余)。"""
try:
title, body = catalog.render_push(row.type, push_vars)
@@ -124,29 +138,103 @@ def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, s
extras.update({str(k): str(v) for k, v in (row.extra or {}).items()})
extras["notificationId"] = str(row.id)
for dev in device_repo.list_push_targets(db, user_id=row.user_id):
targets = device_repo.list_push_targets(db, user_id=row.user_id)
if push_token_contains:
normalized_filter = push_token_contains.casefold()
targets = [
target
for target in targets
if normalized_filter in (target.push_token or "").casefold()
]
logger.info(
"push targets resolved user_id=%s type=%s notification_id=%s "
"token_filter=%s target_count=%s",
row.user_id, row.type, row.id, bool(push_token_contains), len(targets),
)
if not targets:
logger.warning(
"push skipped no targets user_id=%s type=%s notification_id=%s",
row.user_id, row.type, row.id,
)
return
sent = failed = skipped = data_sent = data_failed = 0
for dev in targets:
vendor = vendor_push.normalize_vendor(dev.push_vendor)
if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS:
continue
if vendor_push.missing_settings(vendor):
# 本地/测试环境凭据不齐 → 只落站内消息,不发真推送(与 push/vendors 的报缺口径一致)
logger.info(
"skip push (vendor %s not configured) user_id=%s type=%s",
vendor, row.user_id, row.type,
skipped += 1
logger.warning(
"push target skipped unsupported vendor user_id=%s type=%s "
"notification_id=%s device_id=%s raw_vendor=%s normalized_vendor=%s",
row.user_id, row.type, row.id, dev.device_id, dev.push_vendor, vendor,
)
continue
missing = vendor_push.missing_settings(vendor)
if missing:
skipped += 1
# 本地/测试环境凭据不齐 → 只落站内消息,不发真推送(与 push/vendors 的报缺口径一致)
logger.warning(
"push target skipped vendor not configured user_id=%s type=%s "
"notification_id=%s device_id=%s vendor=%s missing_settings=%s",
row.user_id, row.type, row.id, dev.device_id, vendor, missing,
)
continue
logger.info(
"push send started user_id=%s type=%s notification_id=%s "
"device_id=%s vendor=%s",
row.user_id, row.type, row.id, dev.device_id, vendor,
)
try:
vendor_push.send_notification(
response = vendor_push.send_notification(
vendor, dev.push_token, title=title, body=body, extras=extras
)
if vendor == "huawei" and row.type in {"feedback_reply", "feedback_reward"}:
# 华为反馈推送联调日志:保留厂商返回码/requestId 等排障信息,
# 请求本身的 token、Authorization 和应用密钥不会进入 response。
logger.info(
"huawei feedback push response user_id=%s type=%s "
"notification_id=%s response=%s",
row.user_id, row.type, row.id, response,
)
logger.info(
"push sent user_id=%s type=%s vendor=%s notification_id=%s",
row.user_id, row.type, vendor, row.id,
"push sent user_id=%s type=%s vendor=%s notification_id=%s device_id=%s",
row.user_id, row.type, vendor, row.id, dev.device_id,
)
sent += 1
except vendor_push.VendorPushError as e:
failed += 1
logger.warning(
"push failed user_id=%s type=%s vendor=%s notification_id=%s "
"device_id=%s error=%s",
row.user_id, row.type, vendor, row.id, dev.device_id, e,
)
try:
data_response = vendor_push.send_data_event(
vendor,
dev.push_token,
event=vendor_push.DATA_EVENT_NOTIFICATION_CREATED,
notification_id=str(row.id),
)
data_sent += 1
logger.info(
"push data event completed user_id=%s type=%s vendor=%s "
"notification_id=%s device_id=%s response=%s",
row.user_id, row.type, vendor, row.id, dev.device_id, data_response,
)
except vendor_push.VendorPushError as e:
data_failed += 1
# 透传只负责前台铃铛实时刷新,失败不能影响通知栏消息或站内消息。
logger.warning(
"push failed user_id=%s type=%s vendor=%s: %s", row.user_id, row.type, vendor, e
"push data event failed user_id=%s type=%s vendor=%s notification_id=%s "
"device_id=%s error=%s",
row.user_id, row.type, vendor, row.id, dev.device_id, e,
)
logger.info(
"push dispatch completed user_id=%s type=%s notification_id=%s targets=%s "
"sent=%s failed=%s skipped=%s data_sent=%s data_failed=%s",
row.user_id, row.type, row.id, len(targets),
sent, failed, skipped, data_sent, data_failed,
)
except Exception: # noqa: BLE001 — 渲染/查设备等意外失败同样不外抛
logger.exception("push notification failed user_id=%s type=%s", row.user_id, row.type)
@@ -156,7 +244,12 @@ def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, s
# ---------------------------------------------------------------------------
def notify_withdraw_success(db: Session, order: WithdrawOrder) -> None:
def notify_withdraw_success(
db: Session,
order: WithdrawOrder,
*,
push_token_contains: str | None = None,
) -> None:
"""#3 提现成功:款项已存入微信零钱。点击无跳转仅消红点(extra 空)。"""
_dispatch(
db,
@@ -170,10 +263,16 @@ def notify_withdraw_success(db: Session, order: WithdrawOrder) -> None:
extra={},
dedup_key=order.out_bill_no,
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents)},
push_token_contains=push_token_contains,
)
def notify_withdraw_failed(db: Session, order: WithdrawOrder) -> None:
def notify_withdraw_failed(
db: Session,
order: WithdrawOrder,
*,
push_token_contains: str | None = None,
) -> None:
"""#4 提现失败/退回:含微信侧失败、审核拒绝、解绑退回。点击跳提现页重新提现。
失败原因用 order.fail_reason( /withdraw/status 下发的用户可读原因同源)
@@ -191,10 +290,16 @@ def notify_withdraw_failed(db: Session, order: WithdrawOrder) -> None:
extra={"withdrawId": order.out_bill_no},
dedup_key=order.out_bill_no,
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents), "reason": reason},
push_token_contains=push_token_contains,
)
def notify_feedback_reply(db: Session, feedback: Feedback) -> None:
def notify_feedback_reply(
db: Session,
feedback: Feedback,
*,
push_token_contains: str | None = None,
) -> None:
"""#9 官方回复:运营审核了反馈且未采纳(用户可见原因/留言落在反馈记录上)。
点击跳反馈历史页滚动高亮该条(extra.feedbackId)"""
_dispatch(
@@ -204,10 +309,16 @@ def notify_feedback_reply(db: Session, feedback: Feedback) -> None:
info_rows=[{"label": "说明文案", "value": "快去看看官方给您的回复吧~"}],
extra={"feedbackId": str(feedback.id)},
dedup_key=str(feedback.id),
push_token_contains=push_token_contains,
)
def notify_feedback_reward(db: Session, feedback: Feedback) -> None:
def notify_feedback_reward(
db: Session,
feedback: Feedback,
*,
push_token_contains: str | None = None,
) -> None:
"""#10 反馈奖励:反馈被采纳,金币已到账。PRD 约定发奖必带官方留言(admin_reply);
运营漏填时省略该信息行,不硬造文案"""
coins = int(feedback.reward_coins or 0)
@@ -225,10 +336,16 @@ def notify_feedback_reward(db: Session, feedback: Feedback) -> None:
extra={"feedbackId": str(feedback.id)},
dedup_key=str(feedback.id),
push_vars={"coins": str(coins)},
push_token_contains=push_token_contains,
)
def notify_report_approved(db: Session, report: PriceReport) -> None:
def notify_report_approved(
db: Session,
report: PriceReport,
*,
push_token_contains: str | None = None,
) -> None:
"""#11 爆料审核通过:上报的更低价过审,金币已到账。点击跳爆料记录页高亮该条。"""
coins = int(report.reward_coins or 0)
store = (report.store_name or "").strip() or "该店铺"
@@ -244,11 +361,17 @@ def notify_report_approved(db: Session, report: PriceReport) -> None:
extra={"reportId": str(report.id)},
dedup_key=str(report.id),
push_vars={"store": store, "coins": str(coins)},
push_token_contains=push_token_contains,
)
def notify_invite_order_reward(
db: Session, *, inviter_user_id: int, invitee_user_id: int, cash_cents: int
db: Session,
*,
inviter_user_id: int,
invitee_user_id: int,
cash_cents: int,
push_token_contains: str | None = None,
) -> None:
"""#12 好友下单到账:被邀请好友完成首次下单(比价),现金奖励已入邀请人账户。
通知发给邀请人;每个好友只发一次奖 dedup 按被邀请人"""
@@ -266,4 +389,5 @@ def notify_invite_order_reward(
extra={"inviteeNickname": nickname},
dedup_key=str(invitee_user_id),
push_vars={"nickname": nickname, "amount": _yuan_trim(cash_cents)},
push_token_contains=push_token_contains,
)
+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
+1
View File
@@ -87,6 +87,7 @@ PRD §5 的 13 条 push 文案(标题固定 ≤11 字不带变量;正文 `{var}`
**真发注意**:
- 目标手机必须先装 App 且客户端已集成对应厂商 SDK、`/device/register` 上报过 token;
- 荣耀正式环境必须使用正式消息(`HONOR_PUSH_TARGET_USER_TYPE=0`);开发联调测试设备时才设为 `1`;
- vivo 未上架前走测试推送(`VIVO_PUSH_MODE=1`),目标手机需在 vivo 开放平台加入测试设备;
- 小米新设备需在开放平台把签名/包名配好,token 才有效。
@@ -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,639 @@
# 短信 极光→创蓝 容灾 fallback 实现计划
> **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:** 极光为主、创蓝为备的短信验证码容灾——极光供应商不可用(503)时自动转创蓝补发,发/校验全程后台可区分实际渠道,由配置开关控制、可秒回退。
**Architecture:** 全部逻辑收在分派层 `app/integrations/sms/__init__.py`provider 文件 jiguang/chuanglan/aliyun 一行不动)。`send_code` 返回携带渠道的 `SendResult``verify_code` 用 try-both 遍历「启用的 fallback 链」。开关 `SMS_FALLBACK_PROVIDER` 默认空=关。
**Tech Stack:** FastAPI + pydantic-settings + pytest(monkeypatch)。SMS provider 均 Mode B(本地内存自管码)。
设计依据:`docs/superpowers/specs/2026-07-30-sms-jiguang-chuanglan-fallback-design.md`
---
## 文件结构
| 文件 | 职责 | 动作 |
|---|---|---|
| `app/integrations/sms/base.py` | 加 `SendResult` 结果类型(provider 无关,与 `SmsError` 同层) | 修改 |
| `app/core/config.py` | 加 `SMS_FALLBACK_PROVIDER` 开关 | 修改 |
| `app/integrations/sms/__init__.py` | 分派层:主/备解析 + `send_code` fallback + `verify_code` try-both | 修改(核心) |
| `app/api/v1/auth.py` | `sms_send``SendResult.cooldown_sec` + `details``provider`/`fallback` | 修改(一处) |
| `.env.example` | 加 `SMS_FALLBACK_PROVIDER=` 注释 | 修改 |
| `tests/test_sms_dispatch.py` | 现有 3 处 `== 60` 断言改读 `.cooldown_sec` | 修改 |
| `tests/test_sms_fallback.py` | fallback + try-both 全量新测试 | 新建 |
| `tests/test_auth.py` | `sms_send` 成功 `details` 含 provider/fallback | 修改(加 1 用例) |
`jiguang.py` / `chuanglan.py` / `aliyun.py` **不动**
---
## Task 1: `SendResult` 结果类型(base.py
**Files:**
- Modify: `app/integrations/sms/base.py`
- Test: `tests/test_sms_base.py`(新建)
- [ ] **Step 1: 写失败测试**
Create `tests/test_sms_base.py`:
```python
"""SMS 共享基座类型 SendResult。"""
from __future__ import annotations
import dataclasses
import pytest
from app.integrations.sms.base import SendResult
def test_send_result_fields():
r = SendResult(cooldown_sec=60, provider="jiguang", fallback=True)
assert r.cooldown_sec == 60
assert r.provider == "jiguang"
assert r.fallback is True
def test_send_result_fallback_defaults_false():
r = SendResult(cooldown_sec=60, provider="jiguang")
assert r.fallback is False
def test_send_result_is_frozen():
r = SendResult(cooldown_sec=60, provider="jiguang")
with pytest.raises(dataclasses.FrozenInstanceError):
r.provider = "chuanglan"
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_sms_base.py -q`
Expected: FAIL — `ImportError: cannot import name 'SendResult'`
- [ ] **Step 3: 最小实现**
`app/integrations/sms/base.py` 顶部 import 区加 `from dataclasses import dataclass`,并在 `SmsError` 类后新增:
```python
@dataclass(frozen=True)
class SendResult:
"""发码结果:距下次可发秒数 + 实际发码的 provider 名 + 是否走了备(fallback)。
分派层 send_code 的返回类型(provider 各自的 send_code 仍返回 int cooldown
由分派层包装)。auth 层据此把渠道记入风控流水 details。
"""
cooldown_sec: int
provider: str
fallback: bool = False
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_sms_base.py -q`
Expected: PASS (3 passed)
- [ ] **Step 5: 提交**
```bash
git add app/integrations/sms/base.py tests/test_sms_base.py
git commit -m "feat(sms): 加 SendResult 结果类型(cooldown+provider+fallback)"
```
---
## Task 2: `SMS_FALLBACK_PROVIDER` 配置开关(config.py
**Files:**
- Modify: `app/core/config.py``SMS_PROVIDER` 定义附近,约 line 150
- Test: `tests/test_sms_fallback.py`(新建,本 Task 起逐步补全)
- [ ] **Step 1: 写失败测试**
Create `tests/test_sms_fallback.py`:
```python
"""极光→创蓝 容灾 fallback(分派层 send_code / verify_code)。
monkeypatch 各 provider 的 send_code/verify_code 为标记函数,不发真网络;
断言:仅主 503 才转备;429/400 不转;verify try-both 只遍历启用的链。
"""
from __future__ import annotations
import pytest
from app.core.config import settings
from app.integrations import sms
from app.integrations.sms import aliyun, chuanglan, jiguang
from app.integrations.sms.base import SendResult, SmsError
PHONE = "13800138000"
def _raiser(exc: Exception):
"""返回一个调用即 raise exc 的 send_code 替身(lambda 不能 raise)。"""
def _f(phone):
raise exc
return _f
def test_fallback_provider_defaults_empty():
assert settings.SMS_FALLBACK_PROVIDER == ""
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_sms_fallback.py::test_fallback_provider_defaults_empty -q`
Expected: FAIL — `AttributeError: 'Settings' object has no attribute 'SMS_FALLBACK_PROVIDER'`
- [ ] **Step 3: 最小实现**
`app/core/config.py``SMS_PROVIDER` 定义行下方新增:
```python
# 主 provider 供应商不可用(SmsError 503)时转投的备 provider;空=关闭 fallback(默认,
# 保持现状零风险)。仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。
SMS_FALLBACK_PROVIDER: Literal["jiguang", "chuanglan", ""] = ""
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_sms_fallback.py::test_fallback_provider_defaults_empty -q`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add app/core/config.py tests/test_sms_fallback.py
git commit -m "feat(sms): 加 SMS_FALLBACK_PROVIDER 开关(默认空=关)"
```
---
## Task 3: 分派层 `send_code` 返回 SendResult(主路径)
先让主路径返回 `SendResult`(不含 fallback),并把现有 `test_sms_dispatch.py``== 60` 断言迁到 `.cooldown_sec`。fallback 分支在 Task 4 加。
**Files:**
- Modify: `app/integrations/sms/__init__.py`
- Modify: `tests/test_sms_dispatch.py`line 20/22/24、line 45-53
- Test: `tests/test_sms_fallback.py`
- [ ] **Step 1: 写失败测试**
`tests/test_sms_fallback.py` 追加:
```python
def test_send_code_primary_success_returns_sendresult(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "")
monkeypatch.setattr(jiguang, "send_code", lambda phone: 60)
r = sms.send_code(PHONE)
assert isinstance(r, SendResult)
assert r.cooldown_sec == 60
assert r.provider == "jiguang"
assert r.fallback is False
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_sms_fallback.py::test_send_code_primary_success_returns_sendresult -q`
Expected: FAIL — `assert isinstance(r, SendResult)`(现 send_code 返回 int 60
- [ ] **Step 3: 最小实现**
`app/integrations/sms/__init__.py` 整体替换为(本 Task 只到主路径;Task 4/5 再扩 fallback 与 try-both):
```python
"""短信验证码服务 —— provider 分派入口(极光主 + 可选创蓝备)。
对外暴露 `send_code`(返回 SendResult / `verify_code` / `SmsError` / `SendResult`
主 provider = settings.SMS_PROVIDER(默认 jiguang);备 = settings.SMS_FALLBACK_PROVIDER
(默认空=无备)。**每次调用读 settings**,支持运行时切换 / 灰度回退。
"""
from __future__ import annotations
import logging
from app.core.config import settings
from . import aliyun, chuanglan, jiguang
from .base import SendResult, SmsError
__all__ = ["SmsError", "SendResult", "send_code", "verify_code"]
logger = logging.getLogger("shagua.sms")
# provider 名 <-> 模块。未知/缺省主 provider 回退 jiguang(防误配把登录打挂)。
_ALL = {"jiguang": jiguang, "aliyun": aliyun, "chuanglan": chuanglan}
_NAME = {jiguang: "jiguang", aliyun: "aliyun", chuanglan: "chuanglan"}
def _primary():
"""当前主 provider 模块(未知/缺省回退 jiguang)。"""
return _ALL.get(settings.SMS_PROVIDER, jiguang)
def _fallback():
"""启用的备 provider 模块;未配置 / 同主 / 未知名 → None= 不启用 fallback)。"""
name = (settings.SMS_FALLBACK_PROVIDER or "").strip()
if not name or name == settings.SMS_PROVIDER:
return None
return _ALL.get(name)
def send_code(phone: str) -> SendResult:
"""发码:主成功即返回;失败抛 SmsError。返回 SendResultcooldown + 实际渠道)。"""
primary = _primary()
cooldown = primary.send_code(phone)
return SendResult(cooldown_sec=cooldown, provider=_NAME[primary], fallback=False)
def verify_code(phone: str, code: str) -> bool:
"""校验:委托当前主 providertry-both 在 Task 5 加)。"""
return _primary().verify_code(phone, code)
```
- [ ] **Step 4: 迁移现有 dispatch 测试断言**
`tests/test_sms_dispatch.py` 中,把 3 处 `assert sms.send_code("13800138000") == 60`line 20/22/24)改为:
```python
assert sms.send_code("13800138000").cooldown_sec == 60
```
line 45-53 的 `test_unknown_provider_falls_back_to_jiguang``sms.send_code(...)` 不比较返回值,无需改;`test_verify_code_routes_by_provider` 不涉及返回值,无需改。)
- [ ] **Step 5: 跑测试确认通过**
Run: `python -m pytest tests/test_sms_fallback.py tests/test_sms_dispatch.py -q`
Expected: PASS(新用例 + 原 dispatch 3 用例全绿)
- [ ] **Step 6: 提交**
```bash
git add app/integrations/sms/__init__.py tests/test_sms_dispatch.py tests/test_sms_fallback.py
git commit -m "feat(sms): send_code 返回 SendResult(主路径),迁移 dispatch 断言"
```
---
## Task 4: 分派层 `send_code` fallback 分支(仅 503 转备)
**Files:**
- Modify: `app/integrations/sms/__init__.py``send_code`
- Test: `tests/test_sms_fallback.py`
- [ ] **Step 1: 写失败测试**
`tests/test_sms_fallback.py` 追加:
```python
def test_send_code_503_falls_back_to_configured_backup(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("极光不可用", status_code=503)))
monkeypatch.setattr(chuanglan, "send_code",
lambda phone: (calls.append("chuanglan"), 60)[1])
r = sms.send_code(PHONE)
assert r.provider == "chuanglan"
assert r.fallback is True
assert r.cooldown_sec == 60
assert calls == ["chuanglan"]
@pytest.mark.parametrize("status", [429, 400])
def test_send_code_non_503_does_not_fall_back(monkeypatch, status):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("x", status_code=status)))
monkeypatch.setattr(chuanglan, "send_code",
lambda phone: (calls.append("chuanglan"), 60)[1])
with pytest.raises(SmsError) as ei:
sms.send_code(PHONE)
assert ei.value.status_code == status
assert calls == [] # 备未被调用
def test_send_code_503_without_backup_reraises(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "") # 无备
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("极光不可用", status_code=503)))
with pytest.raises(SmsError) as ei:
sms.send_code(PHONE)
assert ei.value.status_code == 503
def test_send_code_backup_also_fails_raises_backup_error(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "send_code",
_raiser(SmsError("极光不可用", status_code=503)))
monkeypatch.setattr(chuanglan, "send_code",
_raiser(SmsError("创蓝也不可用", status_code=503)))
with pytest.raises(SmsError) as ei:
sms.send_code(PHONE)
assert "创蓝" in str(ei.value)
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_sms_fallback.py -k "fall_back or 503 or non_503 or backup" -q`
Expected: FAIL — 主抛 503 时当前 `send_code` 未捕获,直接透出(`test_...falls_back...` 期望走到创蓝但没走)
- [ ] **Step 3: 实现 fallback 分支**
`app/integrations/sms/__init__.py``send_code` 替换为:
```python
def send_code(phone: str) -> SendResult:
"""发码:主成功即返回;仅主「供应商不可用(503)」且配置了备时转备补发。
429(本地冷却/超频)、400(手机号无效)不转——不绕过防刷、不为无效号白烧。
备也失败则抛备的 SmsError。返回 SendResultcooldown + 实际渠道 + 是否 fallback)。
"""
primary = _primary()
fb = _fallback()
try:
cooldown = primary.send_code(phone)
return SendResult(cooldown_sec=cooldown, provider=_NAME[primary], fallback=False)
except SmsError as e:
if fb is not None and e.status_code == 503:
logger.warning("[SMS] primary=%s 不可用(%s)fallback→%s",
_NAME[primary], e, _NAME[fb])
cooldown = fb.send_code(phone) # 备的冷却/错误码原样透出
return SendResult(cooldown_sec=cooldown, provider=_NAME[fb], fallback=True)
raise
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_sms_fallback.py -q`
Expected: PASS(含 parametrize 的 429/400 两条)
- [ ] **Step 5: 提交**
```bash
git add app/integrations/sms/__init__.py tests/test_sms_fallback.py
git commit -m "feat(sms): send_code 仅主 503 时转创蓝补发(429/400 不转)"
```
---
## Task 5: 分派层 `verify_code` try-both(只遍历启用的链)
**Files:**
- Modify: `app/integrations/sms/__init__.py``verify_code`
- Test: `tests/test_sms_fallback.py`
- [ ] **Step 1: 写失败测试**
`tests/test_sms_fallback.py` 追加:
```python
def test_verify_hits_primary_without_touching_backup(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "verify_code",
lambda p, c: (calls.append("jiguang"), True)[1])
monkeypatch.setattr(chuanglan, "verify_code",
lambda p, c: (calls.append("chuanglan"), True)[1])
assert sms.verify_code(PHONE, "123456") is True
assert calls == ["jiguang"] # 主命中即停,不查备
def test_verify_falls_through_to_backup(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "verify_code",
lambda p, c: (calls.append("jiguang"), False)[1])
monkeypatch.setattr(chuanglan, "verify_code",
lambda p, c: (calls.append("chuanglan"), True)[1])
assert sms.verify_code(PHONE, "123456") is True
assert calls == ["jiguang", "chuanglan"] # 主未命中 → 查备(码在备)
def test_verify_all_miss_returns_false(monkeypatch):
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan")
monkeypatch.setattr(jiguang, "verify_code", lambda p, c: False)
monkeypatch.setattr(chuanglan, "verify_code", lambda p, c: False)
assert sms.verify_code(PHONE, "123456") is False
def test_verify_backup_not_touched_when_fallback_off(monkeypatch):
calls = []
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "") # 关闭 fallback
monkeypatch.setattr(jiguang, "verify_code",
lambda p, c: (calls.append("jiguang"), False)[1])
monkeypatch.setattr(chuanglan, "verify_code",
lambda p, c: (calls.append("chuanglan"), True)[1])
assert sms.verify_code(PHONE, "123456") is False
assert calls == ["jiguang"] # 关闭时创蓝零参与
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_sms_fallback.py -k verify -q`
Expected: FAIL — `test_verify_falls_through_to_backup`(当前 verify 只问主,主 False 即返 False,不查备)
- [ ] **Step 3: 实现 try-both**
`app/integrations/sms/__init__.py``verify_code` 替换为:
```python
def verify_code(phone: str, code: str) -> bool:
"""校验:try-both,遍历「启用的 fallback 链」(主→备),任一命中即 True。
码只存在实际发码那家(fallback 前主已 pop 掉自己的码),另一家 rec is None 即 False、
不误判、不累加其防爆破计数。关闭 fallback 时链中只有主,备完全不参与。
"""
chain = [_primary()]
fb = _fallback()
if fb is not None:
chain.append(fb)
for prov in chain:
if prov.verify_code(phone, code): # Mode B:纯本地内存比对,不联网
logger.info("[SMS] verify hit provider=%s", _NAME[prov])
return True
return False
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_sms_fallback.py tests/test_sms_dispatch.py -q`
Expected: PASS(全部 fallback + 原 dispatch 用例)
- [ ] **Step 5: 提交**
```bash
git add app/integrations/sms/__init__.py tests/test_sms_fallback.py
git commit -m "feat(sms): verify_code try-both 遍历启用的 fallback 链"
```
---
## Task 6: auth `sms_send` 接入 SendResult + 记 provider/fallback
**Files:**
- Modify: `app/api/v1/auth.py`line 210 调用、line 231-243 成功侧 details
- Test: `tests/test_auth.py`(加 1 用例)
- [ ] **Step 1: 写失败测试**
先确认现有 `test_auth.py``sms_send` 的调用/断言与 fixture 写法(找一个已有的 sms_send 成功用例作参照),再在 `tests/test_auth.py` 末尾追加:
```python
def test_sms_send_records_provider_in_details(client, db_session, monkeypatch):
"""sms_send 成功后风控流水 details 带 provider/fallbackmock 下 jiguang/False)。"""
from app.repositories import risk as risk_repo
captured = {}
orig = risk_repo.record_behavior_event
def _spy(db, **kw):
if kw.get("event_type") == risk_repo.EVENT_SMS_SEND and kw.get("outcome") == "success":
captured.update(kw.get("details") or {})
return orig(db, **kw)
monkeypatch.setattr(risk_repo, "record_behavior_event", _spy)
resp = client.post("/api/v1/auth/sms/send",
json={"phone": "13800009999", "device_id": "dev-provider-test"})
assert resp.status_code == 200, resp.text
assert captured.get("provider") == "jiguang"
assert captured.get("fallback") is False
```
> 注:`client` / `db_session` fixture 名、请求路径前缀(`/api/v1/auth/...`)、`record_behavior_event` 的 import 路径以 `tests/test_auth.py` 现有用例为准,若不同则对齐后再写。
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_auth.py::test_sms_send_records_provider_in_details -q`
Expected: FAIL — `captured.get("provider")` 为 None(当前 details 只有 `{"mock": ...}`
- [ ] **Step 3: 实现**
`app/api/v1/auth.py``sms_send` 中:
(a) line 209-210 的
```python
try:
cooldown = send_code(req.phone)
```
改为
```python
try:
send_result = send_code(req.phone)
cooldown = send_result.cooldown_sec
```
(b) line 231-243 成功侧 `record_behavior_event``details`
```python
details={"mock": settings.SMS_MOCK},
```
改为
```python
details={"mock": settings.SMS_MOCK,
"provider": send_result.provider,
"fallback": send_result.fallback},
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_auth.py::test_sms_send_records_provider_in_details -q`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add app/api/v1/auth.py tests/test_auth.py
git commit -m "feat(sms): sms_send 记 provider/fallback 到风控流水 details"
```
---
## Task 7: `.env.example` + 全量回归 + ruff
**Files:**
- Modify: `.env.example`
- [ ] **Step 1: 加配置注释**
`.env.example``SMS_PROVIDER` 附近加:
```bash
# 极光→创蓝 容灾:主 provider(SMS_PROVIDER)供应商不可用(503)时转投的备。
# 空=关闭 fallback(默认);生产设 chuanglan 开启极光主、创蓝备;置空即秒回退。
SMS_FALLBACK_PROVIDER=
```
- [ ] **Step 2: 全量 SMS 相关回归**
Run: `python -m pytest tests/test_sms_fallback.py tests/test_sms_dispatch.py tests/test_sms_chuanglan.py tests/test_sms_aliyun.py tests/test_auth.py -q`
Expected: PASS(新增全绿;原 SMS/auth 用例不回归)
- [ ] **Step 3: lint**
Run: `python -m ruff check app/integrations/sms/ app/core/config.py app/api/v1/auth.py tests/test_sms_fallback.py tests/test_sms_base.py`
Expected: `All checks passed!`
- [ ] **Step 4: 基线对比(确认无新增失败)**
Run: `python -m pytest -q`
Expected: 失败数 ≤ 基线(已知先前债 ~8 个);本改动不新增失败。若有新增,定位并修。
- [ ] **Step 5: 提交**
```bash
git add .env.example
git commit -m "docs(sms): .env.example 加 SMS_FALLBACK_PROVIDER 说明"
```
---
## Self-Review
**Spec 覆盖核对**(对 `2026-07-30-sms-jiguang-chuanglan-fallback-design.md`):
- §2-A 仅 503 转 / 429·400 不转 → Task 4`test_send_code_503_falls_back` + `test_send_code_non_503_does_not_fall_back`)✅
- §2-B try-both 只遍历启用链 → Task 5(4 个 verify 用例,含关闭时备零参与)✅
- §2-C details 记 provider/fallback + 日志 → Task 6details+ Task 4/5logger.warning/info)✅
- §2-D 配置开关默认空、生产设开 → Task 2 + Task 7 ✅
- §4.1 主/备解析(未知/同主→None)→ Task 3`_primary`/`_fallback`)✅
- §4.2 SendResult + 只认 503 + 备失败抛备错 → Task 1 + Task 4`test_send_code_backup_also_fails`)✅
- §4.4 码只在一家/防爆破不被绕过 → provider 内部行为(不改),try-both 逻辑 Task 5 覆盖;防爆破由 provider 各自既有测试保证 ✅
- §4.5 auth 一处改、verify 调用点不变 → Task 6 ✅
- §5 配置项 → Task 2 ✅
- §6 mock 永不 fallback → mock 下主 send_code 成功不抛异常,天然不进 fallback 分支(现有 mock 测试在 Task 7 回归保证)✅
- §10 send_code int→SendResult 仅 auth 一处 + dispatch 测试 → Task 3/6 ✅
**占位扫描**:无 TBD/TODO;每个 code step 均含完整代码与命令。Task 6 Step 1 标注了「以现有 fixture 为准」——这是**对齐现有约定**的指示,非占位(fixture 名在同文件已存在,执行时照抄)。
**类型/命名一致性**`SendResult(cooldown_sec, provider, fallback)` 三字段在 Task 1/3/4/6 全程一致;`_primary()`/`_fallback()`/`_ALL`/`_NAME` 命名跨 Task 3/4/5 一致;`SMS_FALLBACK_PROVIDER` 跨 Task 2/3/4/5/7 一致。
---
## 执行顺序说明
Task 1→2→3 有依赖(后者用前者的类型/配置/分派骨架),必须顺序执行。Task 4/5 都改 `send_code`/`verify_code`,也应在 Task 3 之后顺序做。Task 6 依赖 Task 3 的 `SendResult` 返回。Task 7 收尾。全程一条功能分支 `feat/sms-jiguang-chuanglan-fallback`,每 Task 一提交。
@@ -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,208 @@
# 短信验证码 极光→创蓝 容灾 fallback — 设计方案
- 日期:2026-07-30
- 状态:待评审
- 范围:短信发送验证码服务,默认极光;**极光供应商不可用时自动转创蓝补发**;发/校验全程后台可区分本次走的是极光还是创蓝。
- 关联:`docs/superpowers/specs/2026-07-26-chuanglan-sms-verify-design.md`(创蓝 provider 接入)
## 1. 背景与目标
短信验证码已是**可切换 provider** 架构(`app/integrations/sms/``__init__` 分派 + `jiguang` / `aliyun` / `chuanglan` + `base`)。极光(默认)与创蓝都是 **Mode B(自管码)**:本服务 `secrets` 生成码 → 存**各自模块的进程内存** → provider 只负责发送;`verify_code` 是**纯本地内存比对**,不联网。
现状是"单选一个 provider"`send_code` / `verify_code` 都只走 `SMS_PROVIDER` 指定的那一家。极光一旦供应商侧故障(欠费 / 网络 / 服务异常),`/sms/send` 直接 503,用户收不到码、登录中断。
**目标**:把极光设为主、创蓝设为备,在**极光供应商不可用**时自动转创蓝补发,最大化验证码送达率;同时让后台能查到每次发码/校验实际走的是哪家、是否发生了 fallback。
**非目标**:不改极光/创蓝各自的 Mode B 机器(发码/存码/校验/防刷),不引入 DB/Redis 共享存码,不做多 worker 共享(沿用现有内存态技术债)。
## 2. 关键决策(已与需求方确认)
| # | 决策 | 结论 |
|---|---|---|
| A | **fallback 触发范围** | **仅主 provider 返回"供应商不可用"`SmsError.status_code == 503`:网络错误 / 余额不足 / 服务故障)时转备。** 本地单号 60s 冷却与极光侧超频(均 429)、手机号无效(400)**不转**——不绕过防刷、不为无效号白烧一条。 |
| B | **校验路由** | **try-both,但只遍历"当前启用的 fallback 链"。** verify 依次问链上 provider(主→备),任一命中即通过;关闭 fallback 时链中只有主,创蓝完全不参与。 |
| C | **后台可见性** | **风控行为流水 + 结构化日志。** 复用 `risk_repo.record_behavior_event(EVENT_SMS_SEND)``details``provider` / `fallback` 字段;dispatch 层对 fallback 触发、校验命中打点。不新增表、不改客户端响应。 |
| D | **默认开关** | **配置开关 opt-in,生产设开。** 新增 `SMS_FALLBACK_PROVIDER`,默认空=关(保持现状零风险);生产 `.env``chuanglan` 开启。出问题置空即秒回退、不改代码。 |
补充约束:**fallback 仅在 Mode B ↔ Mode Bjiguang / chuanglan)之间有意义**。aliyun 是 Mode A(阿里云托管码、校验走接口),不参与 fallback 链。
## 3. 现状回顾(设计依赖的事实)
- **分派层** [`app/integrations/sms/__init__.py`](../../../app/integrations/sms/__init__.py)`_provider()` 每次按 `settings.SMS_PROVIDER` 选一个模块(默认 jiguang),`send_code` / `verify_code` 委托给它。
- **极光 / 创蓝均 Mode B**,各持一份**独立**的 `_codes` / `_last_sent` / `_lock`(两个互不相通的进程内存 dict)。`verify_code` 纯本地比对(`_codes.get(phone)` + `secrets.compare_digest`),**不发网络**;provider 的网络调用只在**发送**一步。
- **`SmsError(message, status_code)`**[`base.py`](../../../app/integrations/sms/base.py)):`429`=过频/超频(客户端稍后再来)、`503`=供应商不可用、`400`=手机号无效。这是 A 决策用来判定"是否 fallback"的唯一信号。
- **极光/创蓝 `send_code` 失败时"保留冷却、清掉未发出的码"**`_last_sent[phone]=now` 保留、`_codes.pop(phone)`。此行为不改(见 §4.3 冷却交互)。
- **auth 层落点**`sms_send` 发码成功 [auth.py:231](../../../app/api/v1/auth.py) 记 `EVENT_SMS_SEND` `outcome="success" details={"mock":...}`;失败 [auth.py:212](../../../app/api/v1/auth.py) 记 `outcome="failed" reason=...``send_code` 是内部函数,全仓仅此一处调用。`verify_code``sms_login` 与换绑两处调用,签名 `(phone, code)->bool`
## 4. 设计
### 4.1 fallback 链模型(分派层,`__init__.py`
引入"主 + 备"两级链,全部在分派层组装,**provider 文件一行不动**
```python
_ALL = {"jiguang": jiguang, "aliyun": aliyun, "chuanglan": chuanglan}
_NAME = {jiguang: "jiguang", aliyun: "aliyun", chuanglan: "chuanglan"}
def _primary():
# 未知/缺省回退 jiguang(保持 test_unknown_provider_falls_back_to_jiguang 语义)
return _ALL.get(settings.SMS_PROVIDER, jiguang)
def _fallback():
"""启用的备 provider;未配置/同主/未知名 → None= 不启用 fallback)。"""
name = (settings.SMS_FALLBACK_PROVIDER or "").strip()
if not name or name == settings.SMS_PROVIDER:
return None
return _ALL.get(name) # 未知名 → None
```
### 4.2 发码 `send_code(phone) -> SendResult`(透出实际渠道)
现返回 `int`(cooldown),改为返回携带渠道信息的小结果对象(放 `base.py`provider 无关):
```python
@dataclass(frozen=True)
class SendResult:
cooldown_sec: int # 距下次可发秒数(= 实际发码 provider 的 SMS_SEND_INTERVAL_SEC
provider: str # 实际发码的 provider 名:"jiguang" / "chuanglan"
fallback: bool # 是否走了备(True=主不可用转备)
```
分派逻辑:
```python
def send_code(phone) -> SendResult:
primary, fb = _primary(), _fallback()
try:
cooldown = primary.send_code(phone)
return SendResult(cooldown, _NAME[primary], fallback=False)
except SmsError as e:
# A 决策:仅"供应商不可用(503)"且配置了备时转备;429/400 原样抛
if fb is not None and e.status_code == 503:
logger.warning("[SMS] primary=%s 不可用(%s)fallback→%s",
_NAME[primary], e, _NAME[fb])
cooldown = fb.send_code(phone) # 备也可能抛 SmsError(其冷却/错误码)→ 原样透出
return SendResult(cooldown, _NAME[fb], fallback=True)
raise
```
要点:
- **只认 `status_code == 503`**:极光的 429(本地冷却 / 超频 50009)、400(手机号无效)直接 raise,不 fallback。
- **备自带完整 Mode B 机器**:创蓝重新走自己的单号冷却 / 存码 / 错误码映射。备也失败则抛备的 `SmsError``auth` 按其 `status_code` 返回。
- **主/备文件不动**`jiguang.send_code` / `chuanglan.send_code` 仍返回 `int`;由分派层包装成 `SendResult`
### 4.3 冷却交互(已知行为,保守安全,不改 provider)
极光 `send_code` 失败(503)时**保留自己的冷却**(现有行为,防"欠费/签名失效时前端狂打极光")。在 fallback 下的连锁:
1. 首发:极光 503(记极光冷却)→ 转创蓝发成功(记创蓝冷却)。用户收到创蓝的码。
2. 60s 内重发:主=极光**冷却中 → 429**,`429 ≠ 503`**不 fallback** → 用户收到 429"过于频繁"。**这是期望行为**:挡住 60s 内重复补发烧钱;且此时创蓝也在自己冷却期。
3. 极光**持续故障**:每过 60s,极光冷却过期→再试极光(仍 503)→转创蓝补发。稳态下每 60s 白试一次极光、成功走创蓝,功能可用。
结论:现有"失败保留冷却"语义在 fallback 下自洽且保守。**不改极光/创蓝任何冷却逻辑。**
### 4.4 校验 `verify_code(phone, code) -> bool`try-both over 启用链)
```python
def verify_code(phone, code) -> bool:
chain = [_primary()]
fb = _fallback()
if fb is not None:
chain.append(fb)
for prov in chain:
if prov.verify_code(phone, code): # Mode B:纯本地内存比对,不联网、不抛异常
logger.info("[SMS] verify hit provider=%s", _NAME[prov])
return True
return False
```
要点:
- **关闭 fallback 时 `chain=[极光]`**,与现状字节级等价,**创蓝完全不参与**。
- **码只存在实际发码那家**fallback 发码前极光已 `_codes.pop` 清掉自己的码)→ 同一手机号同一时刻只有一家有码;另一家 `rec is None` 立即 False,不误判、不累加其 `attempts`。**防爆破仍在真实那家正常生效,不被绕过**。
- **命中即知渠道**(日志打点),天然满足 C 的校验侧可见性,无需显式记忆 phone→provider。
- 绝大多数登录:极光发成功→码在极光→第一家命中,创蓝那次内存查根本不发生。
- Mode B 的 `verify_code` 不抛 `SmsError`;若链中混入 Mode A(aliyun,本设计约束外)其 verify 降级抛 503,应原样透出中断——但按 §2 约束 aliyun 不入链,此路不触发。
### 4.5 auth.py 改动(最小)
- **`sms_send`**(唯一改动点):
```python
result = send_code(req.phone) # 现 SendResult
cooldown = result.cooldown_sec
# 成功侧 details 加 provider/fallbackC
details={"mock": settings.SMS_MOCK, "provider": result.provider, "fallback": result.fallback}
```
`SmsSendResponse(cooldown_sec=cooldown, ...)` 不变——**不把 provider 返回客户端**(C 选风控流水,非接口返回)。
- **`verify_code` 两处调用点不变**:签名 `(phone,code)->bool` 未改;校验命中渠道由分派层 §4.4 日志打点,auth 无需感知。
- **失败可观测**:极光 503 且创蓝也失败时,抛创蓝的 `SmsError` → 现有失败侧 `record_behavior_event(reason=str(e))` 记录;fallback 触发/失败另有 §4.2 分派层 `logger.warning`
### 4.6 可观测性汇总(满足 C)
| 事件 | 落点 | 内容 |
|---|---|---|
| 发码成功 | `EVENT_SMS_SEND` `details` | `provider`jiguang/chuanglan)、`fallback`bool |
| fallback 触发 | 分派层 `logger.warning` | 主不可用原因 + 转向的备 |
| 校验命中 | 分派层 `logger.info` | 命中的 `provider` |
| 发码彻底失败 | `EVENT_SMS_SEND` `outcome=failed` | `reason`(末端 SmsError 文案) |
## 5. 配置项(`app/core/config.py` 新增)
```python
# 主 provider 供应商不可用(503)时转投的备 provider;空=关闭 fallback(默认,保持现状)。
# 仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。
SMS_FALLBACK_PROVIDER: Literal["jiguang", "chuanglan", ""] = "" # Mode B only(aliyun 不入链)
```
- 复用现有 `SMS_PROVIDER`(主,默认 jiguang)与两家的凭证/旋钮(`CHUANGLAN_SMS_*``SMS_CODE_LENGTH` 等),**无其它新增**。
- `.env.example``SMS_FALLBACK_PROVIDER=`(注释:生产设 `chuanglan` 开启极光→创蓝容灾)。
- 生产前置:创蓝凭证齐全(`chuanglan_sms_configured`)+ 创蓝控制台 IP 白名单,否则 fallback 到创蓝仍 503。
## 6. 错误处理与边界
- **主 503 + 未配备**`_fallback()` 返 None → 原样抛主的 503(= 现状)。
- **主 503 + 备也失败**:抛备的 `SmsError`(按备错误码 429/400/503),auth 照常返回。
- **主 429/400**:不 fallback,原样抛。
- **mock 模式(`SMS_MOCK=true`**:主 `send_code` mock 分支直接成功、不抛异常 → **永不触发 fallback**`verify` mock 分支放行任意 N 位 → try-both 第一家(主)即 True。**现有 mock 测试全绿不变。**
- **未知 `SMS_FALLBACK_PROVIDER` 名**`_ALL.get` 返 None → 视为未配置、不 fallback(不崩)。
- **`SMS_FALLBACK_PROVIDER == SMS_PROVIDER`**`_fallback()` 返 None(不自我 fallback)。
## 7. 测试计划
新增 `tests/test_sms_fallback.py`monkeypatch 各 provider 的 `send_code`/`verify_code`,不真发):
1. 主发成功 → `SendResult(provider=主, fallback=False)`,不调备。
2. 主 503 + 配备 → 转备,备成功 → `SendResult(provider=备, fallback=True)`
3. 主 429 / 400 → 不 fallback,原样抛(断言备未被调用)。
4. 主 503 + 未配备 → 抛 503。
5. 主 503 + 备也 503 → 抛备的 SmsError。
6. verify:码在主 → 命中主;码在备 → 命中备;两家都无 → False。
7. **关闭 fallback**`SMS_FALLBACK_PROVIDER=""`)→ verify 只问主,**断言备 `verify_code` 未被调用**;send 主失败原样抛、不转备。
8. 防爆破:码在主,连续错到 `SMS_MAX_VERIFY_ATTEMPTS` 作废;全程备 `rec is None` 不受影响。
auth 层扩 `tests/test_auth.py``sms_send` 成功后 `EVENT_SMS_SEND``details``provider`/`fallback`mock 下 provider=jiguang、fallback=False)。
回归:`test_sms_dispatch.py`、现有 `test_auth.py` 中依赖 `send_code` 返回 `int` 的断言改为读 `SendResult.cooldown_sec`
## 8. YAGNI(明确不做)
- ❌ 不改极光/创蓝的 Mode B 机器(发/存/校验/冷却/防爆破)。
- ❌ 不做 DB/Redis 共享存码或 phone→provider 记忆表(多 worker 债维持现状、两家一起将来迁)。
- ❌ 不做 aliyunMode A)参与 fallback。
- ❌ 不做多级(>2)fallback 链、不做主备自动健康探测/熔断。
- ❌ 不把 provider 渠道透传给客户端(仅后台可见)。
- ❌ 不做发送量/成功率统计报表(C 只取"风控流水+日志",报表另议)。
## 9. 验收标准
- `SMS_FALLBACK_PROVIDER=chuanglan` 且创蓝配置齐全时:极光注入 503(欠费/网络)→ `/sms/send` 实际由创蓝下发、真机可收码并登录;`EVENT_SMS_SEND.details.provider=chuanglan``fallback=true`
- 极光正常时:走极光,`provider=jiguang``fallback=false`;创蓝零调用。
- `SMS_FALLBACK_PROVIDER=""`(默认):行为与当前完全一致,创蓝不参与发/校验。
- 极光 429/400:不转创蓝,原样返回。
- `SMS_MOCK=true`:任意 N 位通过、不真发、不触发 fallback。
- `ruff check .` 通过;新增/现有 `pytest` 全绿。
## 10. 影响面 / 回滚
- **改动文件**`sms/__init__.py`(分派层,主要)、`sms/base.py`(加 `SendResult`)、`core/config.py`(加开关)、`api/v1/auth.py``sms_send` 一处记 details)、`.env.example`、测试。**`jiguang.py` / `chuanglan.py` / `aliyun.py` 不动。**
- **破坏性接口变更**`send_code` 返回 `int → SendResult`,仅 `auth.py:210` 一处调用 + 相关测试需同步。
- **回滚**:置 `SMS_FALLBACK_PROVIDER=""` 即运行时秒回退到"纯极光",无需改代码/迁移。
@@ -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,163 @@
# 比价「卡死定位」报警增强设计
- 日期: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 not res.readable: # 读不到 trace(目录被清/生产一时读不到)→ 回退保底
>90s或>30步 → T5「深度放弃·等待Xs/Y步」; 否则不报
elif res.points: # 读到且有卡死平台 → 报卡死
报 T5, reason = "卡在 " + "、".join(f"{平台}·{环节}" for res.points)
else: # 读到且没卡死(末段在推进 = 正常深度使用后退出)→ 不报
不报
```
### 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",
+66 -10
View File
@@ -16,6 +16,10 @@ xiaomi/oppo/vivo)到该用户 device_liveness 里已注册的 push token。
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --count 1 # 各 1 条(先小量验证通道)
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --types withdraw_failed --count 3
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --types all --count 2
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --phone 22222222222 --token ABC123 --count 1
`--token` 是可选不区分大小写的 token 子串过滤器;会推送给该账号下所有 token 包含该
子串的设备没有匹配设备时脚本直接停止,不会生成站内消息
推送成败看输出里的 `shagua.vendor_push` 日志(push sent / push failed);2 台设备则每条各推 2
凭据缺失或 token 失效时 notification_events 只记日志不抛错(站内消息仍会落库)
@@ -58,33 +62,52 @@ _FAIL_REASONS = [
]
def _fire_one(db, uid: int, type_key: str, i: int) -> None:
def _fire_one(
db,
uid: int,
type_key: str,
i: int,
*,
push_token_contains: str | None = None,
) -> None:
"""构造一条该类型的瞬态业务对象(不落业务表,只为给 notify 函数读字段),触发真实推送。"""
if type_key == "withdraw_success":
order = WithdrawOrder(user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=50, source="coin_cash")
notification_events.notify_withdraw_success(db, order)
notification_events.notify_withdraw_success(
db, order, push_token_contains=push_token_contains
)
elif type_key == "withdraw_failed":
order = WithdrawOrder(
user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=350, source="coin_cash",
fail_reason=random.choice(_FAIL_REASONS),
)
notification_events.notify_withdraw_failed(db, order)
notification_events.notify_withdraw_failed(
db, order, push_token_contains=push_token_contains
)
elif type_key == "invite_order_reward":
# 假被邀请人 id(> 真实用户范围,避重):昵称回退「好友」。真实昵称请走 API 流程(见文末说明)。
fake_invitee = random.randint(900000, 999999)
notification_events.notify_invite_order_reward(
db, inviter_user_id=uid, invitee_user_id=fake_invitee, cash_cents=INVITE_COMPARE_REWARD_CENTS
db,
inviter_user_id=uid,
invitee_user_id=fake_invitee,
cash_cents=INVITE_COMPARE_REWARD_CENTS,
push_token_contains=push_token_contains,
)
elif type_key == "feedback_reward":
fb = Feedback(user_id=uid, content="(直发)", contact="", status="adopted",
reward_coins=300, admin_reply="感谢反馈,您说的问题已修复上线,金币请查收~")
fb.id = random.randint(900000, 999999)
notification_events.notify_feedback_reward(db, fb)
notification_events.notify_feedback_reward(
db, fb, push_token_contains=push_token_contains
)
elif type_key == "feedback_reply":
fb = Feedback(user_id=uid, content="(直发)", contact="", status="rejected",
admin_reply="您的建议我们记录啦,会在后续版本评估~")
fb.id = random.randint(900000, 999999)
notification_events.notify_feedback_reply(db, fb)
notification_events.notify_feedback_reply(
db, fb, push_token_contains=push_token_contains
)
elif type_key == "report_approved":
rep = PriceReport(
user_id=uid, reported_platform_id="jd", reported_platform_name="京东外卖",
@@ -92,7 +115,9 @@ def _fire_one(db, uid: int, type_key: str, i: int) -> None:
reward_coins=PRICE_REPORT_REWARD_COINS, store_name=f"测试火锅店{i:02d}",
)
rep.id = random.randint(900000, 999999)
notification_events.notify_report_approved(db, rep)
notification_events.notify_report_approved(
db, rep, push_token_contains=push_token_contains
)
else:
raise SystemExit(f"未知类型: {type_key}(可选: {', '.join(ALL_TYPES)})")
@@ -100,6 +125,11 @@ def _fire_one(db, uid: int, type_key: str, i: int) -> None:
def main() -> None:
parser = argparse.ArgumentParser(description="直接触发消息通知中心真实推送(后台驱动不了的事件用)")
parser.add_argument("--phone", default=DEFAULT_PHONE, help=f"目标用户手机号(默认 {DEFAULT_PHONE})")
parser.add_argument(
"--token",
default="",
help="可选的厂商 token 子串(不区分大小写);仅推送到该账号下所有匹配设备",
)
parser.add_argument("--count", type=int, default=10, help="每类发多少条(默认 10)")
parser.add_argument(
"--types", default=",".join(DEFAULT_TYPES),
@@ -122,14 +152,40 @@ def main() -> None:
uid = user.id
targets = device_repo.list_push_targets(db, user_id=uid)
print(f"目标用户 {args.phone}(id={uid});已注册推送设备 {len(targets)} 台:"
f"{[t.push_vendor for t in targets] or '无(手机收不到!先在 App 上报 push token)'}")
token_filter = args.token.strip()
normalized_filter = token_filter.casefold()
matched_targets = [
target
for target in targets
if not token_filter
or normalized_filter in (target.push_token or "").casefold()
]
if token_filter and not matched_targets:
print(
f"❌ 用户 {args.phone}{len(targets)} 台有效推送设备中,"
f"没有 token 包含 {token_filter!r};未生成站内消息,也未发送推送。"
)
return
print(
f"目标用户 {args.phone}(id={uid});有效推送设备 {len(targets)} 台;"
f"本次匹配 {len(matched_targets)} 台:"
f"{[target.push_vendor for target in matched_targets] or '无(手机收不到!先在 App 上报 push token)'}"
)
if token_filter:
print(f"token 子串过滤(不区分大小写): {token_filter!r}")
print(f"即将触发:{types},每类 {args.count} 条 → 共 {len(types) * args.count}\n")
for t in types:
print(f"── {t} ×{args.count} " + "" * 30)
for i in range(1, args.count + 1):
_fire_one(db, uid, t, i)
_fire_one(
db,
uid,
t,
i,
push_token_contains=token_filter or None,
)
print(f"\n✅ 已触发完。站内消息已落 notification 表(用 {args.phone} 登录 App 可在消息中心看到);"
"\n 手机推送成败见上方 `shagua.vendor_push` 日志(push sent=成功 / push failed=失败)。")
+36 -3
View File
@@ -15,6 +15,10 @@ services/notification_events.notify_invite_order_reward —— 与生产同一
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py # 随机金额发 1 条
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --cents 200 # 固定 2.00 元
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --invitee-phone 12000000001 # 真实昵称
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --phone 22222222222 --token gkha
`--token` 是可选不区分大小写的 token 子串过滤器会推送给该账号下所有 token 包含该
子串的设备没有匹配设备时脚本直接停止不会生成站内消息
结果判读(看输出日志):
push sent = 厂商接口受理成功,手机应弹好友下单奖励到账通知
@@ -62,6 +66,11 @@ def _notif_count(db, uid: int) -> int:
def main() -> None:
parser = argparse.ArgumentParser(description="#12 好友下单到账 推送联调(每次 1 条,金额默认随机)")
parser.add_argument("--phone", default="11111111111", help="邀请人(收通知方)手机号,默认 11111111111")
parser.add_argument(
"--token",
default="",
help="可选的厂商 token 子串(不区分大小写);仅推送到该账号下所有匹配设备",
)
parser.add_argument("--cents", type=int, default=None,
help="奖励金额,单位分(默认随机 1~9999;线上真实值 200)")
parser.add_argument("--invitee-phone", default="",
@@ -87,13 +96,37 @@ def main() -> None:
invitee_id = random.randint(900000, 999999) # 假 id,昵称兜底「好友」,永不去重
targets = device_repo.list_push_targets(db, user_id=user.id)
vendors = [t.push_vendor for t in targets]
print(f"邀请人 {args.phone}(id={user.id});推送设备 {len(targets)} 台:{vendors or '无 ← 手机收不到!先在 App 上报 push token'}")
token_filter = args.token.strip()
normalized_filter = token_filter.casefold()
matched_targets = [
target
for target in targets
if not token_filter
or normalized_filter in (target.push_token or "").casefold()
]
if token_filter and not matched_targets:
print(
f"❌ 用户 {args.phone}{len(targets)} 台有效推送设备中,"
f"没有 token 包含 {token_filter!r};未生成站内消息,也未发送推送。"
)
return
print(
f"邀请人 {args.phone}(id={user.id});有效推送设备 {len(targets)} 台;"
f"本次匹配 {len(matched_targets)} 台:"
f"{[target.push_vendor for target in matched_targets] or '无 ← 手机收不到!先在 App 上报 push token'}"
)
if token_filter:
print(f"token 子串过滤(不区分大小写): {token_filter!r}")
before = _notif_count(db, user.id)
print(f"→ 本次奖励 【{cents / 100:.2f} 元】(invitee_user_id={invitee_id}),手机上按金额认领这条通知")
notification_events.notify_invite_order_reward(
db, inviter_user_id=user.id, invitee_user_id=invitee_id, cash_cents=cents
db,
inviter_user_id=user.id,
invitee_user_id=invitee_id,
cash_cents=cents,
push_token_contains=token_filter or None,
)
created = _notif_count(db, user.id) - before
+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")
+96
View File
@@ -0,0 +1,96 @@
"""发奖公式下限:看了就保底 1 金币,与前端展示公式完全对齐。
产品口径(2026-08看了就保底 1):`calculate_ad_reward_coin` **任何输入**都至少返回 1,
镜像客户端 FeedRewardFormula.singleUnitCoin(那边:eCPM /非法/非正数都返 1)此前 eCPM
缺失/ 0 0,与前端小球显示的 +1 不一致,且信息流侧把这类看满一份的广告记成 too_short 零发
公式是发奖与后台审计对账的唯一口径,改这一处两边同源
:激励视频(S2S 回调)路径在公式之前还有一道 `if not ecpm_raw` 早退**完全没上报 eCPM**
的回调仍记 ecpm_missing 零发( test_ad_reward.test_callback_without_ecpm_records_exception),
那是"回调缺字段"的数据完整性闸,区别于"广告如实上报 eCPM=0"(=看了真广告 保底 1)
"""
from __future__ import annotations
from app.core.rewards import calculate_ad_reward_coin
def test_low_positive_ecpm_floors_to_one_coin() -> None:
"""真实但极低的 eCPM 原本四舍五入成 0 金币,兜底为 1(线上 record 4667 的 43 分场景)。"""
# 43 分 = ¥0.43 CPM,因子1=0.1,重度用户 LT 第 69 条=1.0 → 0.43/1000×0.1×1.0×10000=0.43 → 旧口径 round=0
assert calculate_ad_reward_coin("43", 69) == 1
# 更低的 5 分同理:算出来 <0.5,旧口径也是 0
assert calculate_ad_reward_coin("5", 11) == 1
def test_zero_or_missing_ecpm_also_floors_to_one() -> None:
"""eCPM 为 0 / 缺失 / 非法都保底 1(2026-08「看了就保底 1」,与前端 FeedRewardFormula 对齐)。
尤其 "0"(广告如实上报零价值)此前返 0,导致小球显示 +1后端信息流记 too_short 零发的
前后端不一致 现在两端都 1
"""
assert calculate_ad_reward_coin("0", 1) == 1
assert calculate_ad_reward_coin(None, 1) == 1
assert calculate_ad_reward_coin("", 1) == 1
assert calculate_ad_reward_coin("abc", 1) == 1
def test_normal_ecpm_value_unchanged() -> None:
"""正常量级 eCPM 本就 ≥1,下限不改变其取值(回归保护)。"""
# 200 分 = ¥2 CPM,因子1=0.1,LT 第 1 条=2.0 → 2/1000×0.1×2.0×10000 = 4
assert calculate_ad_reward_coin("200", 1) == 4
def test_feed_reward_low_ecpm_grants_one_coin_instead_of_too_short() -> None:
"""端到端:低 eCPM 的信息流广告看满一份(≥10s),原本单份金币算成 0 被记 too_short,
现在兜底发 1 金币状态 granted(线上 record 4667 那类看了却零发的修复)"""
from app.db.session import SessionLocal
from app.models.user import User
from app.repositories.ad_feed_reward import grant_feed_reward
db = SessionLocal()
try:
user = User(phone="19900000043", username="feedfloor43", register_channel="sms")
db.add(user)
db.commit()
db.refresh(user)
rec = grant_feed_reward(
db, user.id,
client_event_id="feed-floor-low-ecpm-0001",
ecpm="2", # ¥0.02 CPM,单份收益四舍五入 <0.5 → 旧口径算成 0
duration_seconds=15, # ≥10s → 看满一份,不走「时长不足」的 else 分支
ad_type="draw",
feed_scene="comparison",
)
assert rec.status == "granted"
assert rec.coin == 1
finally:
db.close()
def test_feed_reward_zero_ecpm_grants_one_coin() -> None:
"""端到端:eCPM 如实上报 0(用户反馈的真实广告返回 eCPM=0 场景),看满一份也保底 1、
状态 granted 与前端小球显示的 +1 一致,不再前显示后零发"""
from app.db.session import SessionLocal
from app.models.user import User
from app.repositories.ad_feed_reward import grant_feed_reward
db = SessionLocal()
try:
user = User(phone="19900000044", username="feedzero44", register_channel="sms")
db.add(user)
db.commit()
db.refresh(user)
rec = grant_feed_reward(
db, user.id,
client_event_id="feed-zero-ecpm-0001",
ecpm="0", # 广告如实上报 eCPM=0
duration_seconds=15, # 看满一份
ad_type="draw",
feed_scene="comparison",
)
assert rec.status == "granted"
assert rec.coin == 1
finally:
db.close()
+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()
+48 -4
View File
@@ -68,12 +68,12 @@ def test_list_config(admin_client: TestClient, token: str) -> None:
r = admin_client.get("/admin/api/config", headers=_auth(token))
assert r.status_code == 200, r.text
items = {i["key"]: i for i in r.json()}
# 非 hidden 项照常返回;看广告组保留可见的:每日上限 / 单次金币上限 / 关闭后冷却
assert "signin_rewards" in items and "ad_daily_limit" in items and "ad_cooldown_sec" in items
# hidden 项(任务/里程碑、首页轮播数据源、广告组的单次金币/每轮次数/信息流广告开关)不在配置页返回。
# 非 hidden 项照常返回;广告次数上限迁到「白名单」统一配置
assert "signin_rewards" in items and "ad_cooldown_sec" in items
# hidden 项(任务/里程碑、首页轮播数据源、广告次数/单次金币/每轮次数/信息流广告开关)不在配置页返回。
for hidden_key in (
"task_rewards", "record_milestones", "marquee_feed_mode",
"ad_reward_coin", "ad_round_count", "comparing_ad_enabled",
"ad_daily_limit", "ad_reward_coin", "ad_round_count", "comparing_ad_enabled",
):
assert hidden_key not in items, f"{hidden_key} 应被 hidden 过滤"
assert items["signin_rewards"]["value"] == [
@@ -124,6 +124,50 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N
db.close()
def test_list_config_reads_limit_values_from_global_bundle(
admin_client: TestClient,
token: str,
) -> None:
changed = admin_client.patch(
"/admin/api/config/ad_cooldown_sec",
json={"value": 17},
headers=_auth(token),
)
assert changed.status_code == 200, changed.text
items = {
item["key"]: item
for item in admin_client.get(
"/admin/api/config",
headers=_auth(token),
).json()
}
assert items["ad_cooldown_sec"]["value"] == 17
assert items["ad_cooldown_sec"]["overridden"] is True
@pytest.mark.parametrize(
("key", "value"),
[
("ad_daily_limit", 0),
("ad_daily_limit", 100_001),
("ad_cooldown_sec", 86_401),
],
)
def test_update_limit_config_rejects_out_of_range_values(
admin_client: TestClient,
token: str,
key: str,
value: int,
) -> None:
response = admin_client.patch(
f"/admin/api/config/{key}",
json={"value": value},
headers=_auth(token),
)
assert response.status_code == 400, response.text
def test_update_bool_config(admin_client: TestClient, token: str) -> None:
# 提现自动对账开关默认 True
items = {

Some files were not shown because too many files have changed in this diff Show More