Compare commits

..

31 Commits

Author SHA1 Message Date
guke 946efe7d64 test(withdraw): 补 invite_cash 并发在途用例 + 清理过期注释
审查发现:取消「一人一单」后,create_withdraw 的档位闸注释与
test_invite_cash_withdraw 中若干注释仍引用已删除的「在途互斥」。
- create_withdraw 注释去掉「在途互斥」措辞
- test_two_accounts_withdraw_independent 文案修正;source_filter
  用例移除已非必需的 reject(断言不变)
- 新增 test_invite_cash_multiple_in_flight_allowed:两笔 invite_cash
  在途并存、各扣邀请奖励金、不串金币现金

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:12:25 +08:00
guke 93350f4d9b feat(withdraw): 允许在途提现时继续提交新申请
取消「同一用户同时仅一笔在途提现」限制:删应用层在途单检查 +
删 DB 分区唯一索引 ux_withdraw_order_user_active + 清理死代码
(_WITHDRAW_ACTIVE_STATUSES / WithdrawTooFrequentError)。
先扣款、coin_cash 每日档位次数、out_bill_no 幂等等既有约束不变。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:49:17 +08:00
guke beead5cf1e docs: 提现允许在途并存 实现计划
单任务 TDD 计划:删应用层在途检查 + 删 DB 分区唯一索引 + 迁移 + 测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:38:42 +08:00
guke 429204312b docs: 提现允许在途时继续提交申请 设计spec
取消「同一用户同时仅一笔在途提现」限制的设计文档:
删应用层在途单检查 + 删 DB 分区唯一索引 + 清理死代码。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:27:39 +08:00
guke addc30817f 修复:coupon_claim_event 回填按 (trace_id, coupon_id) 去重,避免上线迁移唯一约束冲突 (#172)
旧回填 INSERT ... SELECT 从 coupon_claim_record 整表 1:1 复制进 coupon_claim_event。
两表唯一键不同:源表按 (device_id, coupon_id, claim_date) 去重,trace_id 可空且不在
唯一键里;新表按 (trace_id, coupon_id) 唯一。一次会话的 /step 帧跨零点(Asia/Shanghai)
时,同一 (trace_id, coupon_id) 会落在相邻两天两行,复制时撞 uq_coupon_claim_event_trace_coupon,
整个迁移事务回滚,线上发车中断(线上命中 (1581db0e..., mt_banjia_zhoumo))。

回填改为按 (trace_id, coupon_id) 只取 id 最大(最近写入)的一行,SQLite/PostgreSQL 通用。
运行期写入路径(record_claims 按 (trace_id, coupon_id) upsert)本就无重复,仅此一次性回填未做防御。

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

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #172
2026-07-24 15:12:20 +08:00
linkeyu e165fcfc5e 修复:自动查单关闭不再标记为支付异常 (#170)
## 改动说明

- 自动查单关闭时不再写入微信提现配置异常列表。
- 保留自动查单状态字段、部署开关和运营开关,不改变 worker 实际行为。
- 明确自动查单属于非阻断运维能力,避免与“无法微信打款”混淆。

## 验证

- `pytest tests/test_withdraw_ledger_check.py tests/test_admin_config.py -q`
- 8 项测试全部通过。
- 本地管理 API 健康检查确认不再返回自动查单关闭问题。

---------

Co-authored-by: guke <guke@wonderable.ai>
Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #170
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-24 14:51:31 +08:00
zuochenyong 3f7b5167fa 功能:新手引导视频 + 美团券首页分页索引 (#167)
Co-authored-by: guke <guke@wonderable.ai>
Co-authored-by: 左辰勇 <exinglang@gmail.com>
Reviewed-on: #167
Co-authored-by: zuochenyong <zuochenyong@wonderable.ai>
Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
2026-07-24 14:48:40 +08:00
linkeyu 9e88ca72d3 修复提现审核详情点击用户后提示操作失败 (#171)
## 问题现象

在“提现审核”页点击用户所在行后,提现单主详情可以打开,但用户统计和金币记录区域为空,页面连续提示“操作失败”。

## 原因说明

打开抽屉时前端会继续请求两个子接口:

- `/admin/api/users/{user_id}/reward-stats`
- `/admin/api/users/{user_id}/coin-records`

这两个接口原来都使用 `select(AdRewardRecord)` 加载完整 ORM 对象。SQLAlchemy 会把模型映射的所有列自动展开到 SQL 中,其中包括后来新增的 `boost_round_id`。当旧本地数据库或滚动发布中的数据库尚未补齐该列时,即使提现详情本身完全不使用这个字段,查询仍会报 `no such column: ad_reward_record.boost_round_id`,两个接口均返回 500。

前端的统一错误处理只会展示响应 JSON 中字符串类型的 `detail`;该 500 返回的是普通 `Internal Server Error`,因此最终回退成通用文案“操作失败”。本地前端开启了 React Strict Mode,初始化副作用在开发环境会执行两次,所以两个失败接口会形成截图中的四条“操作失败”提示。

## 修复方案

- 用户奖励统计只查询实际需要的 `ecpm_raw`、`coin` 等字段。
- 金币记录只查询页面展示、排序所需字段。
- 同步缩小信息流广告和签到记录的字段投影,避免将来新增无关 ORM 列再次拖垮详情页。
- 增加 SQL 级回归测试:主动拦截任何包含 `ad_reward_record.boost_round_id` 的详情查询,并验证两个接口仍返回 200。

该改动不会改变统计口径或返回结构。数据库迁移仍应正常执行;这里增加的是旧库及滚动发布期间的向后兼容保护。

## 验证结果

- `pytest tests/test_admin_read.py -q`:17 passed
- 新增回归测试覆盖 `reward-stats` 与 `coin-records`
- `git diff --check`:通过
- 本地实际提现用户接口验证:两个接口均返回 200

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #171
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-24 14:04:04 +08:00
linkeyu 21a4d0af5b 后台审核新增批量处理接口 (#164)
## 改动
- 新增低价审核与用户反馈的批量通过、批量拒绝接口
- 单条仍保持独立事务、审计、发奖和通知;单项失败不影响同批其它记录
- 批量响应返回每条记录的成功状态或失败原因,供前端保留失败项重试
- 反馈审核补充行锁,降低并发重复发奖风险

## 验证
- `ruff check`(相关路由、Schema、测试)
- `pytest tests/test_admin_write.py -q`:22 passed

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #164
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-24 12:04:41 +08:00
guke f7a7a49281 fix(withdraw): 免确认授权已开启判定加 authorization_id 非空,与打款一致 (#168)
免确认授权已开启判定加 authorization_id 非空,与打款一致

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #168
2026-07-24 11:17:09 +08:00
linkeyu 71aef455f4 修复:签到膨胀金币归入看视频分类 (#169)
## 修改内容

- 将历史 `signin_boost` 金币从“常规任务金币”分类移出。
- 将 `signin_boost` 与 `reward_video/ad_reward` 一起计入“看视频金币”。
- 保留独立的 `signin_boost_coin_total` 历史审计字段。
- 增加不重不漏回归测试,确认分类调整前后本期发放总额保持不变。

## 本地验证

- `tests/test_admin_read.py`:16 项通过。
- Ruff 与 `git diff --check`:通过。
- 全量后端测试:505 项通过;8 项为 `main` 现有无关失败。

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #169
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-24 11:16:38 +08:00
linkeyu 66527f6cdc 修复:领券单券成功率按任务独立统计 (#166)
## 修改内容

- 新增按 `trace_id + coupon_id` 幂等的逐次单券事件表。
- 保留每日资产记录,同时独立保存每次领券事件,避免同设备同日重跑串场。
- 后台逐场成功率和单券明细改为读取逐次事件。
- 增加历史数据回填迁移、本地 mock 脚本和回归测试。

## 验证结果

- 领券相关测试:22 项通过。
- 全新 SQLite 数据库执行 `alembic upgrade head`:通过。
- 全量测试:508 项通过;另外 8 项为现有无关失败。

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #166
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-24 11:15:09 +08:00
linkeyu b4a2a8c31d 功能:限制每位用户每天最多发起 100 次比价 (#165)
## 变更内容

- 登录用户按北京时间自然日计算比价发起次数,每人每天最多 100 次。
- 第 101 次起返回 HTTP 429,并提示“今日已比价超过100次,请明天再试”。
- 同一 trace_id 的网络重试按幂等处理,不会重复计数。
- 使用用户行锁串行化同一账号的并发请求,避免并发突破上限。
- 复用现有 comparison_record 的 running 记录,无需新增数据库迁移。

## 本地验证

- 比价额度专项测试 11 项通过。
- 本次修改涉及文件的 Ruff 检查通过。
- 已覆盖未登录、重复 trace_id、跨自然日、第 100 次放行及第 101 次拒绝。

---------

Co-authored-by: CodexSandboxOffline <798648091@qq.com>
Reviewed-on: #165
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-24 11:14:31 +08:00
marco ceceeb3458 fix(db): 合并 3 个 alembic 迁移 head(发车 0.4.4 前置,no-op merge 节点) 2026-07-23 17:55:35 +08:00
linkeyu 31f61f6aad 增强:CPS 每日自动对账增加可回查日志 (#163)
## 背景

PR #162 已合并。本 PR 为其后续日志增强,方便人工按一次任务完整回查美团、京东每日自动对账。

## 日志内容

- 每次运行生成唯一 `run_id`,记录 `scheduled` / `startup_catchup` 触发来源
- 记录北京时间计划日期、近 3 天回拉窗口、环境、数据库方言、主机名和 PID
- 分平台记录开始、成功、跳过、失败及执行耗时
- 成功结果记录 fetched / inserted / updated / pages / api_requests,京东额外记录小时窗口数
- 京东上游失败记录具体小时窗口、页码和请求序号;美团记录失败页码
- 最终汇总记录 success / partial_success / failed、失败平台、是否需要人工补跑和下次执行时间
- 日志使用现有 `extra` 结构化字段写入 JSON 日志,便于 SLS/人工检索

## 兼容性

- 手动对账接口、事务和返回模型不变
- 不记录密钥、Token、完整上游响应或订单明细
- 仓储层仅增加可选审计上下文和请求计数;不改变拉取及 upsert 逻辑

## 验证

- `pytest tests/test_cps_reconcile_worker.py tests/test_cps_admin.py tests/test_admin_read.py tests/test_observe.py -q`:44 passed
- 相关文件 Ruff 检查通过(忽略文件原有 UP017 提示)
- `git diff --check` 通过

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #163
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-23 11:57:37 +08:00
linkeyu b7cfcf7495 功能:美团和京东 CPS 每日自动对账 (#162)
## 需求

- 保留现有后台手动对账逻辑不变
- 每天北京时间 05:00 自动刷新 CPS 对账
- 当前仅处理美团和京东
- 每次按更新时间回拉近 3 天,覆盖延迟更新和订单状态变化

## 实现

- 新增进程内 CPS 自动对账 worker,并接入应用生命周期
- 美团使用更新时间查询类型 2,京东使用更新时间查询类型 3
- 复用现有仓储层对账及 order_id 幂等更新逻辑
- 美团和京东独立会话、独立异常处理,单个平台失败不阻塞另一平台
- 增加单实例锁、开关、执行小时、回拉天数和轮询间隔配置
- 服务在 05:00 后重启时会补跑当天任务

## 验证

- `pytest tests/test_cps_reconcile_worker.py tests/test_cps_admin.py tests/test_admin_read.py -q`:27 passed
- 相关文件 Ruff 检查通过
- `git diff --check` 通过

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #162
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-23 10:51:20 +08:00
linkeyu 77f772f47c 性能:比价和领券分位数改为 PostgreSQL 聚合 (#161)
## 背景
- 比价记录页和领券记录页前端已经只拉当前页,并直接展示后端 summary。
- 原后端仍会将筛选区间内的全部耗时值取回 Python 计算分位数,生产数据量增大后会放大数据库读取和应用内存开销。

## 修改内容
- 比价记录:成功耗时 P5/P50/P95/P99、平均耗时以及中途退出耗时 P5/P50/P95 改用 PostgreSQL percentile_cont/AVG 聚合。
- 领券记录:发起数、完成数、平均耗时和完成耗时 P5/P50/P95/P99 合并为一条 PostgreSQL 聚合查询。
- 日期、用户、环境、状态、店铺和商品等筛选条件继续与列表共用,统计口径不变。
- SQLite 不支持 percentile_cont,仅在本地和测试环境回退读取耗时单列;不加载完整业务记录。
- API 字段与前端展示保持不变,无需前端改动。

## 验证
- 比价/领券及关联广告收益、点位、按券统计测试:26 passed。
- 本次涉及文件 ruff 检查通过。
- PostgreSQL SQL 编译测试确认使用 ordered-set percentile_cont 聚合。
- 全量测试:497 passed;8 个现有失败集中在邀请奖励、提现档位和代理转发等无关模块。

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #161
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-23 10:35:41 +08:00
linkeyu cb8e8ccc1d 修复:激励视频未完成时预估收益归零 (#160)
## 问题
激励视频在 onAdShow 时已经上报 eCPM,用户随后提前关闭或播放时长不足时,报表仍按 eCPM/1000 计入预估收益,导致明细、合计、趋势和分类统计虚高。

## 修改
- reward_video 的 closed_early / too_short 有效预估收益统一归零
- capped / granted 保持原收益口径
- 更新 API 字段说明
- 增加明细、日汇总、小时汇总、类型汇总回归测试

## 验证
- ruff check(本次修改文件)通过
- pytest tests/test_admin_ad_revenue_scope.py tests/test_admin.py -q:10 passed
- 全量 pytest:489 passed,7 个失败已在未修改的 origin/main 基线复现,与本次改动无关

## 关联前端
WonderableAI/shaguabijia-admin-web#64

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #160
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-22 17:38:51 +08:00
linkeyu fda82fe313 后台:新增监控审计权限分组并加强接口鉴权 (#159)
## 变更
- 权限目录新增一级分组“监控审计”,统一设备存活、埋点成功率、埋点日志和审计日志。
- 补齐 `analytics-health` 页面权限,技术角色默认拥有四项监控审计权限;运营默认仅保留设备存活。
- 新增服务端 `require_page` 守卫,四组 API 不再只依赖前端隐藏导航,直接调用也会校验角色或个人页面权限。
- 增加迁移,为存量技术角色补上 `analytics-health` 权限,并同步接口文档。

## 验证
- 改动文件 `ruff check` 通过。
- `tests/test_admin_roles.py tests/test_analytics_health.py`: 24 passed。
- Alembic 从空库 upgrade 到 head,再 downgrade 本迁移:通过。
- 全量测试:443 passed、6 failed;6 项失败在干净 `origin/main` 上原样复现(主干为 442 passed、6 failed),与本 PR 无关。

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #159
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-22 17:22:09 +08:00
zuochenyong 28a86c3b2c feat(compare): 比价记录列表增加分页 (#156)
GET /api/v1/compare/records 新增 ordered / keyword 两个查询参数,过滤全部下推到 SQL。
不能分页之后再由客户端 filter —— 一页里可能一条都不命中,列表看着就是空的,
得翻很多页才蹦出一条。

顺带修掉这条链路上几处随数据量线性变慢的地方:

- 列表查询 defer raw_payload / llm_calls / llm_price_snapshot 三个重型 JSON 列。
  出参 ComparisonRecordOut 根本不读,却是每页几百 KB~几 MB 的白读 + 白反序列化,
  是「比价记录/全部记录」页慢的主要来源;详情接口不 defer,raw_payload 照常返回。
- 「已下单」标记改为只按本页店名(≤ limit 条)反查 savings,不再把该用户全部下单
  店名捞进内存跟 50 条记录取交集。
- 新增 (user_id, created_at, id) 复合索引:反向扫恰好等于列表的
  ORDER BY created_at DESC, id DESC,PG 免排序直接取前 n 条。
  迁移走 CREATE INDEX CONCURRENTLY,不阻塞线上 harvest 写入。
- keyword 转义 LIKE 通配符后再匹配,避免搜一个「%」把整表拉回来。
- nginx 对 application/json 开 gzip:此前 gzip off + gzip_types 只含 text/html
  + gzip_proxied off 三个默认值凑一起,等于所有接口都在裸奔;记录列表这种
  字段名和中文店名高度重复的 JSON 压缩比稳定 8~10 倍。

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

---------

Co-authored-by: 左辰勇 <exinglang@gmail.com>
Reviewed-on: #156
Co-authored-by: zuochenyong <zuochenyong@wonderable.ai>
Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
2026-07-22 17:18:26 +08:00
Ghost 0717c09721 基于 main 接入各厂商直推服务端 (#118)
改动:新增厂商推送配置、设备 push_vendor/push_token 字段、device push-test 接口、心跳超时厂商直推发送逻辑和对应测试。

验证:python -m pytest tests/test_device_push.py tests/test_auth.py tests/test_health.py 通过。

---------

Co-authored-by: guke <guke@wonderable.ai>
Co-authored-by: 左辰勇 <exinglang@gmail.com>
Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #118
Co-authored-by: Ghost <>
Co-committed-by: Ghost <>
2026-07-22 15:42:25 +08:00
linkeyu 2eb36b44c8 fix(admin): 按任务白名单聚合常规任务金币 (#157)
## 背景

大盘“常规任务金币”原先采用“全部正向金币减排除清单”的反向口径。线上新增 `feed_ad_reward_coupon` / `feed_ad_reward_comparison` 后未同步加入排除清单,导致领券和比价奖励误计入常规任务金币。

## 修改

- 改为明确白名单:`signin`、历史 `signin_boost`、全部 `task_` 任务、`price_report_reward`、`feedback_reward`
- 未知新 `biz_type` 默认不进入常规任务桶
- 增加覆盖领券、比价、广告、邀请、管理员及未知类型的回归测试
- 顺带修复改动文件已有的 Ruff `UP017`

## 验证

- 线上只读 PostgreSQL:新口径全量为 108,022,领券/比价误计差额为 450,675
- Ruff:通过
- `pytest tests/test_admin_read.py tests/test_cps_admin.py -q`:23 passed

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #157
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-22 12:09:06 +08:00
linkeyu 510df176b3 feat(admin): 返回逐场领券点位分数与明细 (#153)
## 变更内容
- 按 trace_id 批量统计每场领券成功数/尝试数
- success、already_claimed 计成功,failed 计尝试,skipped 排除
- 返回每个点位的名称、ID、状态和失败原因
- 无有效逐券埋点时返回空值,不伪造 0/0
- 用户领券记录抽屉同步返回点位分数及明细

## 性能
- 当前页全部 trace_id 一次批量查询,不产生逐行请求

## 验证
- 16 项后端测试通过
- 覆盖成功、已领、失败、跳过及失败原因

---------

Co-authored-by: guke <guke@wonderable.ai>
Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #153
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-22 11:46:00 +08:00
zuochenyong 73970087ff feat(ad): 膨胀弹窗改用服务端权威金额 + 本轮累计口径,下线 signin_boost (#154)
Co-authored-by: guke <guke@wonderable.ai>
Co-authored-by: 左辰勇 <exinglang@gmail.com>
Reviewed-on: #154
Co-authored-by: zuochenyong <zuochenyong@wonderable.ai>
Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
2026-07-22 10:53:21 +08:00
linkeyu 9286b82b6d feat(admin): 优化比价记录概览统计与加载 (#152)
## 变更内容
- 新增比价记录概览聚合接口,主耗时均值及 P5/P50/P95/P99 仅统计 success
- 比价列表支持按北京自然日过滤,概览和列表复用同一筛选口径
- 后端聚合成功率、成本、低价率及中途退出指标
- 修复单条样本分位数错误返回 0 的边界问题
- 增加成功/失败/退出及日期分页回归测试

## 验证
- Python compileall
- SQLite 聚合、日期边界和分页检查

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #152
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-22 10:42:24 +08:00
linkeyu f9a62bffbe 功能:广告收益支持环境与业务代码位筛选 (#155)
## 修改内容
- 客户端预估与穿山甲汇总统一支持正式、测试、全部环境筛选
- 支持业务代码位与全部代码位两种对账范围
- 保留已上线正式业务代码位,避免配置切换后历史报表漏数
- 补充接口文档和筛选口径测试

## 本地验证
- 相关后端测试 10 项通过
- Ruff 检查通过
- 本地后台页面联调通过

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #155
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-22 10:41:52 +08:00
linkeyu beadce31ed fix(ad): 服务端强制不足一秒曝光收益为零 (#150)
## 改动
- eCPM 上报新增可选 exposure_ms,兼容旧客户端
- exposure_ms < 1000 时保留展示记录并强制有效 eCPM 为 0
- 失败领券任务允许保留短曝光零收益 trace,后台显示 0 而不是未填充
- 其他失败后的迟到曝光仍按原规则解绑 trace

## 验证
- 相关 pytest:10 passed
- Ruff:通过
- compileall:通过

依赖:先合并 Server #149。

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #150
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-21 13:53:10 +08:00
guke f39467ec08 docs(welfare): 15天不活跃清零金币/现金 设计文档(spec) (#151)
对齐前端首页可见事件home_visible

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #151
2026-07-21 13:52:40 +08:00
linkeyu 1f874819fd fix(ad): 失败领券任务不再归属迟到广告收益 (#149)
## 修复内容
- coupon 广告上报到达时校验对应领券 session 状态
- session 已 failed 时保留全局广告收益记录,但清空 trace 归属,失败明细不再显示收益
- 增加失败 trace、其他场景和未知 trace 的回归测试

## 验证
- 相关 pytest:7 passed
- Ruff:通过
- compileall:通过

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #149
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-21 13:41:28 +08:00
zuochenyong c53ce896f7 feat(huawei-review): 华为审核开关(admin 可切 + 客户端下发 + 审计) (#147)
华为应用市场审核要求新手引导的「快速设置」权限步必须可被用户关闭,平时又要
保住权限开启率,故做成后台可切的两态开关,送审期间切开、过审后收回。

- app_config 新增 huawei_review 行(default / review),空库与脏值一律回退
  default = 上线至今的现状,宁可不给退出按钮也不误放开
- admin: GET/PATCH /admin/api/huawei-review,权限 operator/tech,切换写审计
- 客户端: GET /api/v1/platform/huawei-review 不鉴权(引导页在登录前就展示),
  下发 onboarding_closable;机型 gate 由客户端做,故此处不判 ROM

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: 左辰勇 <exinglang@gmail.com>
Reviewed-on: #147
Co-authored-by: zuochenyong <zuochenyong@wonderable.ai>
Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
2026-07-21 10:43:58 +08:00
linkeyu ed26935b14 feat(admin): 数据大盘比价指标改为后端聚合 (#146)
## 修改内容

- 数据大盘比价指标改由后端按日期区间聚合
- 增加完成数、中途退出数、成功率、中位数、P95 和 TOKEN 总成本
- 成功率分母排除中途退出,耗时仅统计 success/failed
- 增加后端聚合口径测试

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #146
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-21 10:11:39 +08:00
181 changed files with 13759 additions and 894 deletions
+56 -1
View File
@@ -27,7 +27,55 @@ JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem
JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify
JG_REQUEST_TIMEOUT_SEC=15
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
# ===== 厂商直推(无障碍保护存活告警 + 消息中心 13 类通知)=====
# 敏感密钥只放 .env / 服务器环境变量,不要提交到 git。
# 各厂商配置状态可随时 GET /api/v1/push/vendors 查看(缺哪些键一目了然)。
ANDROID_PACKAGE_NAME=com.jishisongfu.shaguabijia
PUSH_REQUEST_TIMEOUT_SEC=15
PUSH_TIME_TO_LIVE_SEC=86400
HONOR_PUSH_APP_ID=
HONOR_PUSH_CLIENT_ID=
HONOR_PUSH_CLIENT_SECRET=
HONOR_PUSH_TOKEN_ENDPOINT=https://iam.developer.honor.com/auth/token
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage
# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,AppId + AppSecret
HUAWEI_PUSH_APP_ID=
HUAWEI_PUSH_APP_SECRET=
HUAWEI_PUSH_TOKEN_ENDPOINT=https://oauth-login.cloud.huawei.com/oauth2/v3/token
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.huawei.com/v1/{app_id}/messages:send
VIVO_PUSH_APP_ID=
VIVO_PUSH_APP_KEY=
VIVO_PUSH_APP_SECRET=
VIVO_PUSH_AUTH_ENDPOINT=https://api-push.vivo.com.cn/message/auth
VIVO_PUSH_SEND_ENDPOINT=https://api-push.vivo.com.cn/message/send
# vivo 未上架测试时可用 push_mode=1; 上架正式推送改为 0。
VIVO_PUSH_MODE=1
VIVO_PUSH_NOTIFY_TYPE=4
VIVO_PUSH_CATEGORY=DEVICE_REMINDER
XIAOMI_PUSH_APP_SECRET=
XIAOMI_PUSH_SEND_ENDPOINT=https://api.xmpush.xiaomi.com/v3/message/regid
XIAOMI_PUSH_CHANNEL_ID=
XIAOMI_PUSH_TEMPLATE_ID=
XIAOMI_PUSH_TEMPLATE_TITLE=
XIAOMI_PUSH_TEMPLATE_DESCRIPTION=
# 可选: JSON 字符串,支持 {title}/{alert} 占位符,例如 {"title":"{title}","content":"{alert}"}
XIAOMI_PUSH_TEMPLATE_PARAM_JSON=
OPPO_PUSH_APP_KEY=
OPPO_PUSH_MASTER_SECRET=
OPPO_PUSH_AUTH_ENDPOINT=https://api.push.oppomobile.com/server/v1/auth
OPPO_PUSH_SEND_ENDPOINT=https://api.push.oppomobile.com/server/v1/message/notification/unicast
# OPPO 新消息分类(2024-11-20 后创建的应用必须携带 category;channel_id 为后台「通道ID」;
# notify_level 0=不传走默认,内容营销类仅支持 1/2)
OPPO_PUSH_CHANNEL_ID=
OPPO_PUSH_CATEGORY=
OPPO_PUSH_NOTIFY_LEVEL=0
# ===== 无障碍保护存活监控(推送 + pull 后置兜底)=====
HEARTBEAT_MONITOR_ENABLED=true
HEARTBEAT_TIMEOUT_MINUTES=60
HEARTBEAT_SCAN_INTERVAL_SEC=60
@@ -65,6 +113,13 @@ JD_UNION_APP_SECRET=
JD_UNION_SITE_ID=
JD_UNION_AUTH_KEY=
# 美团 + 京东订单每天北京时间 05:00 自动对账;按更新时间回拉近 3 天,重叠防漏单并刷新状态。
# 手动对账按钮不受该开关影响。通常保持开启;临时停自动任务时设为 false。
CPS_AUTO_RECONCILE_ENABLED=true
CPS_AUTO_RECONCILE_RUN_HOUR=5
CPS_AUTO_RECONCILE_LOOKBACK_DAYS=3
CPS_AUTO_RECONCILE_CHECK_INTERVAL_SEC=60
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
+116
View File
@@ -0,0 +1,116 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project overview
Shaguabijia (傻瓜比价) App backend — FastAPI + SQLAlchemy 2.0 + JWT. Covers user auth (Jiguang one-click / SMS), welfare wallet (coins/cash/signin/tasks/savings), WeChat Pay withdrawals, ad-reward callbacks (Pangle/GroMore S2S), Meituan CPS (coupon forwarding / price comparison), and an admin backend.
## Commands
```bash
# Install
pip install -e ".[dev]"
# Run app server (port 8770, auto-migrates, auto-reload)
./run.sh # or: uvicorn app.main:app --reload --port 8770
# Run admin server (port 8771, separate process)
uvicorn app.admin.main:admin_app --reload --port 8771
# Database
alembic upgrade head # apply all migrations (idempotent)
alembic revision --autogenerate -m "description" # generate new migration
# Tests
pytest -q # all tests
pytest tests/test_auth.py -q # single file
pytest -k "test_sms_login" -q # single test by name
# Lint
ruff check .
ruff check --fix .
```
## Architecture: two FastAPI apps
This repo runs **two separate FastAPI processes** sharing the same `app/` codebase (models, repositories, integrations, config):
| | App server | Admin server |
|---|---|---|
| Entry | `app/main.py``app:app` | `app/admin/main.py``admin_app` |
| Port | 8770 | 8771 |
| Auth | User JWT (`JWT_SECRET_KEY`) | Admin JWT (`ADMIN_JWT_SECRET`, independent) |
| Audience | Mobile app clients | Internal admin dashboard |
| Docs | `/docs` (non-prod only) | `/admin/docs` (non-prod only) |
The two apps are intentionally decoupled — `app.main` never imports `app.admin`. Admin has its own auth chain (`app/admin/deps.py`, `app/admin/security.py`), role-based guards (`require_role`), and routers under `app/admin/routers/`.
## Layered request flow
```
api/v1/ (thin: parse → delegate → respond + HTTP errors)
├── integrations/ (external SDKs: signature, encryption, HTTP calls)
└── repositories/ (data access + transactions)
└── models/ (SQLAlchemy ORM, DeclarativeBase)
```
- **`api/v1/`**: Route handlers. Keep these thin — parse request, call repository or integration, return response. Never put business logic or external HTTP here.
- **`api/deps.py`**: Shared FastAPI dependencies — `get_current_user` (Bearer JWT → User ORM object), `get_db` (request-scoped session).
- **`integrations/`**: All external service logic — Jiguang REST + RSA decryption, WeChat Pay V3 signing/encryption, Meituan CPS gateway signing, Pangle callback signature verification, SMS sending. This is the layer you change when swapping vendors.
- **`repositories/`**: Data access. Each file wraps SQLAlchemy queries + transactions for one domain (user, wallet, signin, savings, ad_reward, etc.). Some repositories also call integrations (e.g., `wallet.py` calls `integrations/wxpay.py` for withdrawals).
- **`models/`**: ORM table definitions (SQLAlchemy 2.0 `Mapped` style, `DeclarativeBase`). Every new model must be imported in `app/models/__init__.py` so Alembic can discover it.
- **`schemas/`**: Pydantic request/response contracts.
- **`core/`**: Infrastructure — config (`pydantic-settings`), JWT (`security.py`), in-memory rate limiter (`ratelimit.py`), reward constants (`rewards.py`), logging setup, pricebot router (consistent-hash load balancing), withdraw reconcile worker.
## Internal (server-to-server) endpoints
Endpoints under `app/api/internal/` are for server-to-server communication (pricebot → app-server), NOT for clients. They use a shared secret header `X-Internal-Secret` (compared via `hmac.compare_digest`) instead of user JWT. If `INTERNAL_API_SECRET` is empty, these endpoints return 503.
## Auth system
- **User login**: Jiguang one-click (`integrations/jiguang.py` — REST token verification + RSA decryption with multi-padding retry) or SMS code (mock by default; `SMS_MOCK=true`).
- **Tokens**: JWT access (2h) + refresh (30d). Both are JWT with `typ` claim (`"access"` vs `"refresh"`) to prevent refresh-as-access. See `core/security.py`.
- **Admin auth**: Separate JWT secret (`ADMIN_JWT_SECRET`), 12h expiry, no refresh. Username + bcrypt password login. Role-based access via `require_role()` guard in `app/admin/deps.py` (`super_admin` bypasses all role checks).
- **Rate limiting**: In-memory fixed-window by client IP (`core/ratelimit.py`). Single-worker only; disabled in tests via `RATE_LIMIT_ENABLED=false`.
## Database
- **Dev**: SQLite (`sqlite:///./data/app.db`), `check_same_thread=False`, no connection pool.
- **Prod**: PostgreSQL — just change `DATABASE_URL` in `.env`. Pool size 10 + max overflow 20, pool_recycle 3600.
- **Migrations**: Alembic with `render_as_batch` for SQLite compatibility. ~60+ migration files in `alembic/versions/` (filenames are descriptive, not hex prefixes). Migration chain uses `down_revision` within each file.
- **New models**: Define in `app/models/`, import in `app/models/__init__.py`, then run `alembic revision --autogenerate`.
## Config
All config via `pydantic-settings` in `app/core/config.py`. Single `Settings` class with env vars / `.env` file. Access anywhere via `from app.core.config import settings`. Key patterns:
- `*_configured` properties gate features gracefully (e.g., `mt_cps_configured`, `wxpay_configured`, `pangle_callback_configured`) — missing credentials → endpoints return empty/503 rather than crashing at startup.
- Prod validation: `_enforce_prod_secrets` model validator blocks startup if `APP_ENV=prod` with weak JWT secrets.
## Testing
- `tests/conftest.py`: Sets env vars BEFORE imports, creates temp SQLite file, builds all tables with `Base.metadata.create_all()`, tears down with `drop_all()` + unlink.
- External integrations are monkeypatched in tests (e.g., WeChat Pay, Jiguang, Pangle callbacks) — tests never make real HTTP calls.
- `TestClient` from FastAPI is used for all tests. Rate limiting is disabled globally in tests.
## Key integration details
- **Jiguang one-click login**: REST call to verify `loginToken`, then RSA decrypt the returned phone number. Multiple padding schemes tried in order (PKCS1v15, OAEP with SHA1/SHA256) because Jiguang's encryption padding varies.
- **WeChat Pay withdrawals**: V3 API merchant transfer to user WeChat balance. Lazy-loads merchant certificates from `secrets/`. Withdrawal flow: bind WeChat → create withdraw order → auto-reconcile worker polls pending orders.
- **Pangle ad rewards**: S2S callback verification via SHA256 signature. Multiple `m-key` secrets supported (one per ad placement). Callback is idempotent by `trans_id`. Test grant endpoint (`AD_REWARD_TEST_GRANT_ENABLED`) for local debugging — must be false in prod.
- **Meituan CPS**: Gateway signature-based API calls. Proxy support (`MT_CPS_PROXY`) for local dev (direct connection causes SSL EOF). Coupon endpoints gracefully return empty when credentials are missing.
- **Pricebot forwarding**: `/api/v1/coupon/step` and `/api/v1/compare/*` proxy to pricebot-backend. Multi-instance support with consistent-hash routing by `trace_id` (see `core/pricebot_router.py`).
- **CPS redirect**: `/c/{code}` is a public (no auth) short-link redirect — records a click then 302s to Meituan. Click recording failure never blocks the redirect.
## Money and units
All monetary amounts are in **cents** (`*_cents` fields). Coins/gold have their own unit. Conversion constants are in `core/rewards.py`.
## Scripts
Key operational scripts in `scripts/`:
- `migrate.sh` — run migrations standalone
- `create_admin.py` — create admin user
- `daily_auto_exchange.py` — auto-convert coins to cash (triggered by systemd timer)
- `reconcile_withdraws.py` — reconcile withdrawal orders with WeChat Pay
- `sim_pangle_callback.py` — simulate Pangle S2S callback for testing
@@ -0,0 +1,26 @@
"""merge direct_vendor_push and feedback_type_reply heads
Revision ID: 1a924c274fce
Revises: direct_vendor_push_fields, feedback_type_reply
Create Date: 2026-07-14 18:53:02.856979
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1a924c274fce'
down_revision: Union[str, Sequence[str], None] = ('direct_vendor_push_fields', 'feedback_type_reply')
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,26 @@
"""merge notification/comparison_user_idx/monitoring_audit heads
Revision ID: 8e04cc13a211
Revises: comparison_user_created_idx, monitoring_audit_rbac, notification_table
Create Date: 2026-07-23 15:37:26.967540
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8e04cc13a211'
down_revision: Union[str, Sequence[str], None] = ('comparison_user_created_idx', 'monitoring_audit_rbac', 'notification_table')
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,47 @@
"""ad_reward_record.boost_round_id(金币膨胀「本轮累计」标签)
看完一条激励视频后客户端要弹「本轮累计获得 N 金币」,N 必须等于这一轮实际到账之和(否则用户
会认为少发了钱)。单条到账额 reward-result 已经能给,但「一轮」的边界只有客户端知道(点「放弃
赚钱」才算结束),客户端自己累加又会在进程被杀后丢失。
解法:客户端把轮次 id 随 mediaExtra 透传,穿山甲 S2S 原样带回,发奖时打在记录上;
reward-result 按 (user_id, boost_round_id) 对 granted 记录求和返回 round_coin。
本列是**纯标签**:不参与发奖判定,发多少/发不发完全不受影响。客户端就算一直复用同一个 id,
也只是把展示数字滚大,不产生任何新入账(求和的是已发生的发奖记录),无资损风险。
Revision ID: ad_reward_boost_round_id
Revises: comparison_llm_cost
Create Date: 2026-07-20
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "ad_reward_boost_round_id"
down_revision: str | Sequence[str] | None = "comparison_llm_cost"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# 可空、无默认:SQLite 原生支持 ADD COLUMN,不需要 batch_alter_table(同 comparison_llm_cost)。
# 存量行留 NULL = 「不属于任何一轮」,求和时天然不参与,老客户端行为不变。
op.add_column(
"ad_reward_record",
sa.Column("boost_round_id", sa.String(length=64), nullable=True),
)
# 求和恒带 user_id(轮 id 是客户端生成的,不能跨用户信任),故建复合索引而非单列
op.create_index(
"ix_ad_reward_user_boost_round",
"ad_reward_record",
["user_id", "boost_round_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_ad_reward_user_boost_round", table_name="ad_reward_record")
op.drop_column("ad_reward_record", "boost_round_id")
@@ -5,8 +5,10 @@ Revises: 135e79414fd0
Create Date: 2026-07-18 17:35:00.000000
给 analytics_event 加活跃口径热点复合索引 (event, page, user_id, created_at):
activity.active_event_condition 按 (event=show & page=home) 比价 领券 过滤后
group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频 show 事件全表扫。
activity.active_event_condition 按 event IN (home_visible 比价 领券) 过滤后
group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频活跃事件全表扫。
(历史:早期首页可见用 event=show+page=home 组合,故索引含 page 列;现改单一 home_visible、
不再按 page 过滤 → page 列成冗余,索引仍靠 event 前缀生效;如需更优可后续新迁移瘦成 (event,user_id,created_at)。)
⚠️ 本分支迁移树有**既有多头**:135e79414fd0(不活跃两表)与 phone_rebind_log 同从
comparison_llm_cost 分叉,`alembic upgrade head` 会多头报错。本迁移挂在 135e79414fd0
@@ -0,0 +1,52 @@
"""add composite index (user_id, created_at, id) on comparison_record
C 端「我的比价记录」列表(GET /api/v1/compare/records)是
`WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT n` —— 原来只有单列 user_id 索引,
过滤完还要把该用户的**全部**记录取出来排序才能拿前 n 条,重度用户随记录数线性变慢。
本复合索引的反向扫恰好等于 (created_at DESC, id DESC),规划器直接取前 n 条、免排序。
列序 (user_id, created_at, id) 与查询一一对应,不要调整。
Revision ID: comparison_user_created_idx
Revises: merge_active_phone
Create Date: 2026-07-21
"""
from __future__ import annotations
from alembic import op
revision = "comparison_user_created_idx"
down_revision = "merge_active_phone"
branch_labels = None
depends_on = None
INDEX_NAME = "ix_comparison_user_created"
COLUMNS = ["user_id", "created_at", "id"]
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == "postgresql":
# 线上 comparison_record 已有数据量,普通 CREATE INDEX 持表写锁会阻塞比价 harvest 写入;
# 用 CONCURRENTLY 不锁表(须脱离事务,autocommit_block 切到自动提交)。
# 同 comparison_status_created_idx 的做法。
with op.get_context().autocommit_block():
op.create_index(
INDEX_NAME, "comparison_record", COLUMNS,
unique=False, postgresql_concurrently=True,
)
else:
op.create_index(INDEX_NAME, "comparison_record", COLUMNS, unique=False)
def downgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == "postgresql":
with op.get_context().autocommit_block():
op.drop_index(
INDEX_NAME, table_name="comparison_record",
postgresql_concurrently=True,
)
else:
op.drop_index(INDEX_NAME, table_name="comparison_record")
+103
View File
@@ -0,0 +1,103 @@
"""add per-session coupon claim event table
Revision ID: coupon_claim_event
Revises: 8e04cc13a211
Create Date: 2026-07-23
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "coupon_claim_event"
down_revision: str | Sequence[str] | None = "8e04cc13a211"
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:
op.create_table(
"coupon_claim_event",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("trace_id", sa.String(length=64), nullable=False),
sa.Column("device_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("coupon_id", sa.String(length=64), nullable=False),
sa.Column("claim_date", sa.Date(), nullable=False),
sa.Column("status", sa.String(length=24), nullable=False),
sa.Column("app_env", sa.String(length=16), nullable=True),
sa.Column("vendor", sa.String(length=48), nullable=True),
sa.Column("coupon_name", sa.String(length=128), nullable=True),
sa.Column("claimed_count", sa.Integer(), nullable=True),
sa.Column("reason", sa.String(length=255), nullable=True),
sa.Column("extra", _JSON, 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(
"trace_id", "coupon_id",
name="uq_coupon_claim_event_trace_coupon",
),
)
op.create_index(
"ix_coupon_claim_event_date_env",
"coupon_claim_event",
["claim_date", "app_env"],
unique=False,
)
op.create_index(
op.f("ix_coupon_claim_event_app_env"),
"coupon_claim_event",
["app_env"],
unique=False,
)
op.create_index(
op.f("ix_coupon_claim_event_trace_id"),
"coupon_claim_event",
["trace_id"],
unique=False,
)
op.create_index(
op.f("ix_coupon_claim_event_user_id"),
"coupon_claim_event",
["user_id"],
unique=False,
)
# 旧表按 (device, coupon, 自然日) 去重,trace_id 可空且不在唯一键里:同一
# (trace_id, coupon_id) 可能散落在多行(如一次会话的 /step 帧跨零点,把同一张券
# 写进相邻两天)。新表按 (trace_id, coupon_id) 唯一,整表 1:1 复制会撞
# uq_coupon_claim_event_trace_coupon。回填时按 (trace_id, coupon_id) 只取 id 最大
# (最近写入)的一行。历史上已被每日去重覆盖的关联仍无法恢复。
op.execute(
"""
INSERT INTO coupon_claim_event (
trace_id, device_id, user_id, coupon_id, claim_date, status, app_env,
vendor, coupon_name, claimed_count, reason, extra, created_at, updated_at
)
SELECT
trace_id, device_id, user_id, coupon_id, claim_date, status, app_env,
vendor, coupon_name, claimed_count, reason, extra, created_at, updated_at
FROM coupon_claim_record
WHERE trace_id IS NOT NULL
AND id IN (
SELECT MAX(id)
FROM coupon_claim_record
WHERE trace_id IS NOT NULL
GROUP BY trace_id, coupon_id
)
"""
)
def downgrade() -> None:
op.drop_index(op.f("ix_coupon_claim_event_user_id"), table_name="coupon_claim_event")
op.drop_index(op.f("ix_coupon_claim_event_trace_id"), table_name="coupon_claim_event")
op.drop_index(op.f("ix_coupon_claim_event_app_env"), table_name="coupon_claim_event")
op.drop_index("ix_coupon_claim_event_date_env", table_name="coupon_claim_event")
op.drop_table("coupon_claim_event")
@@ -0,0 +1,26 @@
"""merge guide_video seq_uq and coupon_claim_event heads
Revision ID: d8dd2106e438
Revises: coupon_claim_event, guide_video_user_seq_uq
Create Date: 2026-07-24 11:52:18.290731
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd8dd2106e438'
down_revision: Union[str, Sequence[str], None] = ('coupon_claim_event', 'guide_video_user_seq_uq')
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,26 @@
"""merge guide_video and main alembic heads
Revision ID: d9c03cc3ea07
Revises: 8e04cc13a211, guide_video_play_table
Create Date: 2026-07-23 22:57:40.998161
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd9c03cc3ea07'
down_revision: Union[str, Sequence[str], None] = ('8e04cc13a211', 'guide_video_play_table')
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,30 @@
"""add direct vendor push fields
Revision ID: direct_vendor_push_fields
Revises: jd_cps_order_fields
Create Date: 2026-07-01 16:30:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "direct_vendor_push_fields"
down_revision = "jd_cps_order_fields"
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_vendor", sa.String(length=32), nullable=True))
batch_op.add_column(sa.Column("push_token", sa.String(length=256), nullable=True))
batch_op.create_index("ix_device_liveness_push_vendor", ["push_vendor"])
def downgrade() -> None:
with op.batch_alter_table("device_liveness") as batch_op:
batch_op.drop_index("ix_device_liveness_push_vendor")
batch_op.drop_column("push_token")
batch_op.drop_column("push_vendor")
@@ -0,0 +1,56 @@
"""下线签到膨胀:drop signin_boost_record
产品 2026-07 确认「固定 3000 金币的签到膨胀」从来不是设计内的口径 —— 奖励只有「签到」和
「看视频」两种。签到弹窗里的「看广告膨胀」改与福利页看视频走同一条 reward_video 路径
(按 eCPM 公式发,记在 ad_reward_record),signin_boost 场景整体摘除。
⚠️ **只 drop 这张表,不动 coin_transaction**:`biz_type='signin_boost'` 的金币流水是真发过的
钱,账必须留得住(admin 大盘的 signin_boost_coin_total / signin_boost_watch_count 改为从
coin_transaction 统计,继续能查回历史)。本表只是「哪天膨胀过」的业务留痕,金额与去向都能
从流水还原,drop 掉不影响对账。
downgrade 只重建空表结构,**不恢复数据** —— 真要回滚得先从备份捞行。
Revision ID: drop_signin_boost_record
Revises: ad_reward_boost_round_id
Create Date: 2026-07-20
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "drop_signin_boost_record"
down_revision: str | Sequence[str] | None = "ad_reward_boost_round_id"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("signin_boost_record", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_signin_boost_record_user_id"))
op.drop_table("signin_boost_record")
def downgrade() -> None:
# 只还结构不还数据(见模块 docstring)
op.create_table(
"signin_boost_record",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("signin_date", sa.Date(), nullable=False),
sa.Column("coin_awarded", sa.Integer(), nullable=False),
sa.Column("ad_ref_id", sa.String(length=64), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "signin_date", name="uq_signin_boost_user_date"),
)
with op.batch_alter_table("signin_boost_record", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_signin_boost_record_user_id"), ["user_id"], unique=False
)
@@ -0,0 +1,37 @@
"""drop withdraw active-order partial unique index (allow multiple in-flight withdrawals)
Revision ID: drop_withdraw_active_unique_index
Revises: d8dd2106e438
Create Date: 2026-07-24 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "drop_withdraw_active_unique_index"
down_revision: Union[str, Sequence[str], None] = "d8dd2106e438"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 取消「同一用户同一时刻仅一笔在途提现」:允许 reviewing/pending 并存。
# 仅删本索引;姊妹索引 ux_cash_transaction_withdraw_refund_ref(退款幂等)保持不动。
op.drop_index("ux_withdraw_order_user_active", table_name="withdraw_order")
def downgrade() -> None:
# 回滚重建分区唯一索引。注意:若届时某用户已有 ≥2 张在途单,重建会因唯一冲突失败——
# 属预期的回滚代价(取消限制后本就允许多单),需先人工收敛在途单再回滚。
op.create_index(
"ux_withdraw_order_user_active",
"withdraw_order",
["user_id"],
unique=True,
sqlite_where=sa.text("status IN ('reviewing', 'pending')"),
postgresql_where=sa.text("status IN ('reviewing', 'pending')"),
)
@@ -0,0 +1,50 @@
"""新手引导视频播放记录表(领券浮层前 N 次替代广告)
见 app/models/guide_video.py:按账号计次(开播即计数)、play_token 幂等发币。
配置(开关 / 视频地址 / 次数 / 金币)复用既有 app_config 表,无需建表。
Revision ID: guide_video_play_table
Revises: meituan_coupon_feed_indexes
Create Date: 2026-07-23 12:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "guide_video_play_table"
down_revision: Union[str, Sequence[str], None] = "meituan_coupon_feed_indexes"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"guide_video_play",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("play_token", sa.String(length=64), nullable=False),
sa.Column("scene", sa.String(length=16), nullable=False, server_default="coupon"),
sa.Column("seq", sa.Integer(), nullable=False, server_default="1"),
sa.Column("video_url", sa.String(length=512), nullable=True),
sa.Column("coin", sa.Integer(), nullable=False, server_default="0"),
sa.Column("status", sa.String(length=16), nullable=False, server_default="playing"),
sa.Column("completed", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"started_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column("granted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("play_token", name="uq_guide_video_play_token"),
)
op.create_index("ix_guide_video_play_user_id", "guide_video_play", ["user_id"])
op.create_index("ix_guide_video_play_started_at", "guide_video_play", ["started_at"])
def downgrade() -> None:
op.drop_index("ix_guide_video_play_started_at", table_name="guide_video_play")
op.drop_index("ix_guide_video_play_user_id", table_name="guide_video_play")
op.drop_table("guide_video_play")
@@ -0,0 +1,40 @@
"""guide_video_play 加 (user_id, seq) 唯一约束:堵住并发 /start 绕过次数上限
start_play 是无锁 check-then-insert(读 COUNT(*) 算 seq=used+1 再插一行),N 个并发
/start 会都读到同一个 used、算出同一个 seq、各插一行拿到各自的 play_token,于是 3 次
上限被绕过、每个 token 都能换 120 金币。加唯一键后并发同 seq 必撞,start_play 捕获
IntegrityError 降级为 should_play=false(客户端照旧放广告)。
用 unique index 而不是 batch_alter_table 加 UniqueConstraint:SQLite 加约束要整表重建,
而 CREATE UNIQUE INDEX 两边都原生支持,回滚也干净。
注:若库里已有并发产生的重复 (user_id, seq),建索引会失败 —— 本功能尚未上线,表通常是空的;
真撞上了先按 seq 去重(留 id 最小的一行,多发的金币按 scripts/reset_guide_video.py 的口径退)。
Revision ID: guide_video_user_seq_uq
Revises: d9c03cc3ea07
Create Date: 2026-07-24 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "guide_video_user_seq_uq"
down_revision: Union[str, Sequence[str], None] = "d9c03cc3ea07"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"uq_guide_video_play_user_seq",
"guide_video_play",
["user_id", "seq"],
unique=True,
)
def downgrade() -> None:
op.drop_index("uq_guide_video_play_user_seq", table_name="guide_video_play")
@@ -0,0 +1,52 @@
"""meituan_coupon 首页 feed 分页复合索引(销量最高 / 智能推荐)
「销量最高」「智能推荐」两个 tab 都是
WHERE city_id = ? [+ 过滤] → DISTINCT ON (dedup_key) ORDER BY dedup_key, <排序键> DESC
的形状。列顺序对齐后 Postgres 可以顺着索引流式去重,免掉「每翻一页就把该城全部券重排一遍」,
这是首页下滑到底越来越慢的根因之一(另一半在 app 层:见 api/v1/meituan.py 的 _paged_dedup_ids)。
⚠️ 本文件同时是一个 **merge 迁移**:主干此前有 3 个并行 head
(comparison_user_created_idx / monitoring_audit_rbac / notification_table),
`alembic upgrade head` 会因 multiple heads 报错。这里一并收敛回单 head。
Revision ID: meituan_coupon_feed_indexes
Revises: comparison_user_created_idx, monitoring_audit_rbac, notification_table
Create Date: 2026-07-23 10:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "meituan_coupon_feed_indexes"
down_revision: Union[str, Sequence[str], None] = (
"comparison_user_created_idx",
"monitoring_audit_rbac",
"notification_table",
)
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 销量最高:WHERE city_id=? AND sale_volume_num IS NOT NULL
# ORDER BY dedup_key, sale_volume_num DESC, commission_percent DESC
op.create_index(
"ix_meituan_coupon_city_dedup_sales",
"meituan_coupon",
["city_id", "dedup_key", sa.text("sale_volume_num DESC"), sa.text("commission_percent DESC")],
)
# 智能推荐:WHERE city_id=? AND commission_percent>=3.0
# ORDER BY dedup_key, commission_percent DESC
op.create_index(
"ix_meituan_coupon_city_dedup_comm",
"meituan_coupon",
["city_id", "dedup_key", sa.text("commission_percent DESC")],
)
def downgrade() -> None:
op.drop_index("ix_meituan_coupon_city_dedup_comm", table_name="meituan_coupon")
op.drop_index("ix_meituan_coupon_city_dedup_sales", table_name="meituan_coupon")
@@ -0,0 +1,32 @@
"""合并两个 alembic head:drop_signin_boost_record(本分支)+ merge_active_phone(main)。
两条线同从 comparison_llm_cost 分叉——本分支的 ad_reward_boost_round_id → drop_signin_boost_record
走「金币膨胀本轮累计 + 下线签到膨胀」;main 侧的 phone_rebind_log / analytics_active_idx 两支已由
merge_active_phone 收敛。88f2380 把 main 合进本分支后,两条迁移线在 git 上汇合了、在 alembic 图上
却没有,于是 `alembic upgrade head`(单数)报 "Multiple head revisions are present"——按 CLAUDE.md
run.sh 启动即自动迁移,app server 会直接起不来。
本迁移仅把二者收敛成单 head;**不含任何表结构 / 数据改动**(纯 merge)。
Revision ID: merge_signin_boost_main
Revises: drop_signin_boost_record, merge_active_phone
Create Date: 2026-07-21 00:00:00.000000
"""
from collections.abc import Sequence
revision: str = "merge_signin_boost_main"
down_revision: str | Sequence[str] | None = (
"drop_signin_boost_record",
"merge_active_phone",
)
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""纯合并 head,无 schema 改动。"""
def downgrade() -> None:
"""拆回两个 head,无 schema 改动。"""
+56
View File
@@ -0,0 +1,56 @@
"""补齐监控审计页面权限。
Revision ID: monitoring_audit_rbac
Revises: merge_signin_boost_main
Create Date: 2026-07-22 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "monitoring_audit_rbac"
down_revision: str | Sequence[str] | None = "merge_signin_boost_main"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
_PAGE = "analytics-health"
def _role_table() -> sa.TableClause:
return sa.table(
"admin_role",
sa.column("name", sa.String),
sa.column("pages", _JSON),
)
def upgrade() -> None:
role = _role_table()
conn = op.get_bind()
pages = conn.execute(
sa.select(role.c.pages).where(role.c.name == "tech")
).scalar_one_or_none()
if pages is not None and _PAGE not in pages:
conn.execute(
role.update()
.where(role.c.name == "tech")
.values(pages=[*pages, _PAGE])
)
def downgrade() -> None:
role = _role_table()
conn = op.get_bind()
pages = conn.execute(
sa.select(role.c.pages).where(role.c.name == "tech")
).scalar_one_or_none()
if pages is not None and _PAGE in pages:
conn.execute(
role.update()
.where(role.c.name == "tech")
.values(pages=[page for page in pages if page != _PAGE])
)
+68
View File
@@ -0,0 +1,68 @@
"""notification table (消息通知中心 站内消息)
Revision ID: notification_table
Revises: 1a924c274fce
Create Date: 2026-07-15 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'notification_table'
down_revision: Union[str, Sequence[str], None] = '1a924c274fce'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# PG 用 JSONB,SQLite 退化为通用 JSON(与 models/notification._JSON 一致)。
_JSON = sa.JSON().with_variant(postgresql.JSONB(), 'postgresql')
def upgrade() -> None:
op.create_table(
'notification',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('type', sa.String(length=32), nullable=False),
sa.Column('coins', sa.Integer(), nullable=True),
sa.Column('cash_cents', sa.Integer(), nullable=True),
sa.Column('info_rows', _JSON, nullable=False),
sa.Column('extra', _JSON, nullable=False),
sa.Column('is_read', sa.Boolean(), nullable=False),
sa.Column('read_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('dedup_key', sa.String(length=64), nullable=True),
sa.Column('sent_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('notification', schema=None) as batch_op:
batch_op.create_index('ix_notification_type', ['type'], unique=False)
# 列表分页:按用户取 + sent_at 倒序
batch_op.create_index('ix_notification_user_sent', ['user_id', 'sent_at'], unique=False)
# 铃铛角标:count where user_id=? and is_read=false —— 部分索引只覆盖未读行
batch_op.create_index(
'ix_notification_user_unread', ['user_id'], unique=False,
sqlite_where=sa.text('is_read = 0'),
postgresql_where=sa.text('is_read = false'),
)
# 去重/合并:同一 (user, type, dedup_key) 未读期间只允许一条(已读后可再生成)
batch_op.create_index(
'uq_notification_user_type_dedup', ['user_id', 'type', 'dedup_key'], unique=True,
sqlite_where=sa.text('dedup_key IS NOT NULL AND is_read = 0'),
postgresql_where=sa.text('dedup_key IS NOT NULL AND is_read = false'),
)
def downgrade() -> None:
with op.batch_alter_table('notification', schema=None) as batch_op:
batch_op.drop_index('uq_notification_user_type_dedup')
batch_op.drop_index('ix_notification_user_unread')
batch_op.drop_index('ix_notification_user_sent')
batch_op.drop_index('ix_notification_type')
op.drop_table('notification')
+29
View File
@@ -10,6 +10,8 @@ from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.admin.permissions import ALL_PAGE_KEYS, CUSTOM_ROLE, SUPER_ADMIN_ROLE, sanitize_pages
from app.admin.repositories import admin_role as role_repo
from app.admin.repositories import admin_user as admin_repo
from app.admin.security import AdminTokenError, decode_admin_token
from app.db.session import get_db
@@ -72,6 +74,33 @@ def require_role(*roles: str):
return _checker
def require_page(page: str):
"""页面权限守卫依赖工厂。
左侧导航隐藏只是 UI,这个守卫确保直接调用 API 也必须持有对应页面权限。
super_admin 恒通过;custom 读个人 pages_override;其余角色读 admin_role.pages。
"""
if page not in ALL_PAGE_KEYS:
raise ValueError(f"unknown admin page permission: {page}")
def _checker(admin: CurrentAdmin, db: AdminDb) -> AdminUser:
if admin.role == SUPER_ADMIN_ROLE:
return admin
pages = (
sanitize_pages(admin.pages_override)
if admin.role == CUSTOM_ROLE
else role_repo.effective_pages_of(db, admin.role)
)
if page not in pages:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"page '{page}' not allowed",
)
return admin
return _checker
def get_client_ip(request: Request) -> str:
"""取客户端 IP(审计日志用)。生产经 nginx 反代,优先 X-Forwarded-For 第一段;否则直连 IP。
+7
View File
@@ -30,6 +30,8 @@ from app.admin.routers.analytics_health import router as analytics_health_router
from app.admin.routers.event_logs import router as event_logs_router
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.onboarding import router as onboarding_router
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
from app.admin.routers.price_report import router as price_report_router
@@ -39,6 +41,7 @@ from app.admin.routers.wallet import router as wallet_router
from app.admin.routers.withdraw import router as withdraw_router
from app.core.config import settings
from app.core.logging import setup_logging
from app.integrations import meituan as mt_meituan
setup_logging(debug=settings.APP_DEBUG)
logger = logging.getLogger("shagua.admin")
@@ -52,6 +55,8 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.DATABASE_URL.split("://", 1)[0],
)
yield
# CPS 后台页会打美团(routers/cps.py),那条共享 client 若被建过要在这里关掉连接池
mt_meituan.close_client()
logger.info("admin app shutting down")
@@ -100,6 +105,7 @@ admin_app.include_router(feedback_router)
admin_app.include_router(event_logs_router)
admin_app.include_router(analytics_health_router)
admin_app.include_router(feedback_qr_router)
admin_app.include_router(guide_video_router)
admin_app.include_router(admins_router)
admin_app.include_router(roles_router)
admin_app.include_router(audit_router)
@@ -109,4 +115,5 @@ admin_app.include_router(cps_router)
admin_app.include_router(coupon_data_router)
admin_app.include_router(ad_audit_router)
admin_app.include_router(ad_config_router)
admin_app.include_router(huawei_review_router)
admin_app.include_router(ad_revenue_router)
+10 -5
View File
@@ -21,7 +21,6 @@ PERMISSION_CATALOG: list[dict] = [
{"key": "ad-revenue-report", "label": "广告收益"},
{"key": "comparison-records", "label": "比价记录"},
{"key": "cps", "label": "CPS收益"},
{"key": "device-liveness", "label": "设备存活"},
]},
{"group": "奖励审核", "pages": [
{"key": "withdraws", "label": "提现审核"},
@@ -31,12 +30,17 @@ PERMISSION_CATALOG: list[dict] = [
{"group": "数据配置", "pages": [
{"key": "config", "label": "系统配置"},
{"key": "ad-revenue", "label": "广告配置"},
{"key": "huawei-review", "label": "华为审核开关"},
{"key": "users", "label": "用户管理"},
]},
{"group": "监控审计", "pages": [
{"key": "device-liveness", "label": "设备存活"},
{"key": "analytics-health", "label": "埋点成功率"},
{"key": "event-logs", "label": "埋点日志"},
{"key": "audit-logs", "label": "审计日志"},
]},
{"group": "其他", "pages": [
{"key": "admins", "label": "权限管理"},
{"key": "event-logs", "label": "埋点日志"},
{"key": "audit-logs", "label": "审计日志"},
]},
]
@@ -51,13 +55,14 @@ BUILTIN_ROLES: list[dict] = [
{"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []},
{"name": "operator", "label": "运营", "pages": [
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
"cps", "device-liveness", "price-reports", "feedbacks",
"cps", "device-liveness", "price-reports", "feedbacks", "huawei-review",
]},
{"name": "finance", "label": "财务", "pages": [
"dashboard", "ad-revenue-report", "cps", "withdraws",
]},
{"name": "tech", "label": "技术", "pages": [
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
"dashboard", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review",
"event-logs", "audit-logs",
]},
]
+49 -7
View File
@@ -33,7 +33,29 @@ from app.admin.repositories import stats as admin_stats
from app.core import rewards
from app.models.ad_ecpm import AdEcpmRecord
from app.models.user import User
from app.repositories import ad_pangle_revenue
from app.repositories import ad_pangle_revenue, app_config
# 已上线过的正式业务代码位要永久保留,避免运营切换当前配置后,历史报表把旧业务位误判成测试流量。
_KNOWN_PROD_BUSINESS_CODE_IDS = frozenset({"104098712", "104099389"})
# 测试应用中实际承载业务链路的代码位。广告测试 demo 的插屏/半屏/信息流测试位不在这里,
# 避免“业务口径”把开发诊断曝光混进客户端与穿山甲对账。
_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"})
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) | {
str(prod_config.get(key) or "").strip()
for key in ("reward_code_id", "compare_draw_code_id", "coupon_draw_code_id")
}
prod_ids.discard("")
if app_env == "prod":
return prod_ids
if app_env == "test":
return set(_TEST_BUSINESS_CODE_IDS)
return prod_ids | set(_TEST_BUSINESS_CODE_IDS)
def _cn_hour(dt: datetime) -> int:
@@ -59,6 +81,10 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
# ad_feed_reward_record,由 audit 内部按 ad_type 区分(feed 含历史 NULL,draw 仅 ad_type=="draw")。
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
# 激励视频未满足有效播放条件时不计客户端预估收益。客户端仍会在 onAdShow
# 上报 eCPM,随后才在关闭时补报以下终态,因此必须在展示/发奖合并后修正收益。
_ZERO_REVENUE_REWARD_VIDEO_STATUSES = frozenset({"closed_early", "too_short"})
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
_REWARD_DETAIL_KEYS = (
@@ -82,6 +108,7 @@ def ad_revenue_report(
ad_type: str | None = None,
feed_scene: str | None = None,
app_env: str | None = None,
revenue_scope: str = "all",
granularity: str = "day",
limit: int = 500,
offset: int = 0,
@@ -179,6 +206,11 @@ def ad_revenue_report(
"matched": bool(rwd["matched"]),
"reward_detail": _reward_detail(rwd),
})
if (
rec.ad_type == "reward_video"
and rwd["status"] in _ZERO_REVENUE_REWARD_VIDEO_STATUSES
):
ev["revenue_yuan"] = 0.0
else:
# 纯展示(信息流逐条展示、激励视频缺发奖记录):不计对账,matched=True。
ev.update({
@@ -277,14 +309,18 @@ def ad_revenue_report(
if feed_scene is not None:
events = [e for e in events if e.get("feed_scene") == feed_scene]
# app_env 过滤(2026-06-29 新增能力,修隐患:测试应用上报的假 eCPM 如 ¥678 CPM 会污染正式收益合计/平均):
# 显式传 "prod"/"test" 只看该环境;不传=全部(维持现状)。**不擅自把默认改成排除 test**——本地 dev 库多为
# test 数据、默认排除会使本地报表空,且「正式报表是否含 test」属产品口径。建议前端报表页加 app_env 筛选器
# (默认选 prod),或产品确认后再把默认改成排除 test。注:穿山甲后台收益列(total_pangle_*)暂未联动此过滤
# (它是独立对照列,且 pangle 的 test 是真实小额、非客户端那种假值)。
# app_env 过滤:显式传 "prod"/"test" 只看该环境;不传=全部。该参数也会传给下方穿山甲聚合,
# 保证客户端预估与 GroMore 汇总使用同一应用环境口径。
if app_env is not None:
events = [e for e in events if e.get("app_env") == app_env]
# 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试
# demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。
business_code_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]
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
if sort == "ecpm":
@@ -336,7 +372,13 @@ def ad_revenue_report(
total_pangle_revenue_yuan: float | None = None
total_pangle_api_revenue_yuan: float | None = None
if pangle_filterable:
pangle_aggs = ad_pangle_revenue.aggregate_by_date(db, date_from=date_from, date_to=date_to)
pangle_aggs = ad_pangle_revenue.aggregate_by_date(
db,
date_from=date_from,
date_to=date_to,
app_env=app_env,
our_code_ids=business_code_ids,
)
if pangle_aggs:
by_date = {a["date"]: a for a in pangle_aggs}
for d in daily:
+154 -26
View File
@@ -1,7 +1,7 @@
"""admin「领券数据」看板聚合:发起/完成数、耗时均值与分位、按天/小时趋势、逐条明细。
数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。量级不大,全量拉
区间数据后 Python 聚合(分位 SQLite 无 percentile,统一 Python 算,PG 上也一致)
数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。生产 PostgreSQL
使用 percentile_cont 聚合耗时分位SQLite 本地/测试环境回退读取耗时单列计算
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
@@ -16,11 +16,14 @@ from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.coupon_state import CouponClaimRecord, CouponSession
from app.models.coupon_state import CouponClaimEvent, CouponClaimRecord, CouponSession
from app.models.user import User
from app.repositories import ad_ecpm as crud_ecpm
from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform
_SLOT_OK = ("success", "already_claimed")
_SLOT_TRIED = ("success", "already_claimed", "failed")
def _cn_hour(dt: datetime) -> int:
"""started_at(UTC 口径)→ 北京时间小时(023)。naive 当 UTC(sqlite),tz-aware 直接换算(pg)。"""
@@ -42,6 +45,75 @@ def _percentile(sorted_vals: list[int], q: float) -> int | None:
return round(sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac)
def _round_duration_ms(value) -> int | None:
"""将数据库聚合结果按既有 Python round 口径转为整数毫秒。"""
if value is None:
return None
return int(round(value))
def _coupon_summary_aggregate_stmt(conditions: list):
"""PostgreSQL 汇总卡聚合语句;计数、均值与四个分位一次返回。"""
completed = CouponSession.status == "completed"
completed_elapsed = completed & CouponSession.elapsed_ms.is_not(None)
return select(
func.count(CouponSession.id),
func.sum(case((completed, 1), else_=0)),
func.avg(CouponSession.elapsed_ms).filter(completed_elapsed),
*(
func.percentile_cont(q)
.within_group(CouponSession.elapsed_ms)
.filter(completed_elapsed)
for q in (0.05, 0.5, 0.95, 0.99)
),
).where(*conditions)
def _coupon_summary_aggregates(db: Session, conditions: list) -> dict:
"""汇总卡基础指标;生产 PG 全部在数据库内完成,SQLite 仅作测试回退。"""
if db.bind is not None and db.bind.dialect.name == "postgresql":
row = db.execute(_coupon_summary_aggregate_stmt(conditions)).one()
return {
"started_count": int(row[0] or 0),
"completed_count": int(row[1] or 0),
"avg_elapsed_ms": _round_duration_ms(row[2]),
"p5_ms": _round_duration_ms(row[3]),
"p50_ms": _round_duration_ms(row[4]),
"p95_ms": _round_duration_ms(row[5]),
"p99_ms": _round_duration_ms(row[6]),
}
counts = db.execute(
select(
func.count(CouponSession.id),
func.sum(case((CouponSession.status == "completed", 1), else_=0)),
).where(*conditions)
).one()
# SQLite 没有 percentile_cont;本地/测试只回退读取耗时单列,不加载完整记录。
completed_elapsed = list(
db.execute(
select(CouponSession.elapsed_ms)
.where(
*conditions,
CouponSession.status == "completed",
CouponSession.elapsed_ms.is_not(None),
)
.order_by(CouponSession.elapsed_ms)
).scalars()
)
return {
"started_count": int(counts[0] or 0),
"completed_count": int(counts[1] or 0),
"avg_elapsed_ms": _round_duration_ms(
sum(completed_elapsed) / len(completed_elapsed)
) if completed_elapsed else None,
"p5_ms": _percentile(completed_elapsed, 5),
"p50_ms": _percentile(completed_elapsed, 50),
"p95_ms": _percentile(completed_elapsed, 95),
"p99_ms": _percentile(completed_elapsed, 99),
}
def _avg(vals: list[int]) -> int | None:
return round(sum(vals) / len(vals)) if vals else None
@@ -86,7 +158,13 @@ def _success_rates(rows: list) -> dict:
}
def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict:
def _session_to_row(
r,
phone: str | None = None,
nickname: str | None = None,
ad_revenue_yuan: float = 0.0,
point_stats: dict | None = None,
) -> dict:
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
return {
"id": r.id,
@@ -104,11 +182,60 @@ def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad
"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,
"trace_url": r.trace_url,
"ad_revenue_yuan": ad_revenue_yuan,
}
def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict[str, int]]:
"""聚合查询批量返回逐场点位分数,不加载逐券明细。"""
if not trace_ids:
return {}
succeeded = func.sum(case((CouponClaimEvent.status.in_(_SLOT_OK), 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),
)
.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
if trace_id is not None
}
def coupon_point_details(db: Session, *, trace_id: str) -> list[dict]:
"""按单个 trace 查询逐券结果;仅在后台用户点击分数时调用。"""
rows = db.execute(
select(
CouponClaimEvent.coupon_id,
CouponClaimEvent.coupon_name,
CouponClaimEvent.status,
CouponClaimEvent.reason,
)
.where(CouponClaimEvent.trace_id == trace_id)
.order_by(CouponClaimEvent.id)
).all()
return [
{
"coupon_id": coupon_id,
"coupon_name": coupon_name,
"status": status,
"reason": reason,
}
for coupon_id, coupon_name, status, reason in rows
]
def _empty_result() -> dict:
return {
"summary": {
@@ -157,30 +284,22 @@ def coupon_data_report(
if not user_ids:
return _empty_result()
stmt = select(CouponSession).where(
conditions = [
CouponSession.started_date >= d_from,
CouponSession.started_date <= d_to,
)
]
if app_env is not None:
stmt = stmt.where(CouponSession.app_env == app_env)
conditions.append(CouponSession.app_env == app_env)
if statuses:
stmt = stmt.where(CouponSession.status.in_(statuses))
conditions.append(CouponSession.status.in_(statuses))
if user_ids is not None:
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
conditions.append(CouponSession.user_id.in_(user_ids))
stmt = select(CouponSession).where(*conditions)
rows = list(db.execute(stmt).scalars())
# ── 汇总卡 ──
completed_elapsed = sorted(
r.elapsed_ms for r in rows if r.status == "completed" and r.elapsed_ms is not None
)
summary = {
"started_count": len(rows),
"completed_count": sum(1 for r in rows if r.status == "completed"),
"avg_elapsed_ms": _avg(completed_elapsed),
"p5_ms": _percentile(completed_elapsed, 5),
"p50_ms": _percentile(completed_elapsed, 50),
"p95_ms": _percentile(completed_elapsed, 95),
"p99_ms": _percentile(completed_elapsed, 99),
**_coupon_summary_aggregates(db, conditions),
**_success_rates(rows),
}
@@ -249,16 +368,23 @@ def coupon_data_report(
).all()
}
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page])
point_stats_map = _point_scores_by_trace(db, [r.trace_id for r in page])
items = []
for r in page:
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)))
items.append(_session_to_row(
r,
phone,
nickname,
ad_revenue_yuan=rev_map.get(r.trace_id, 0.0),
point_stats=point_stats_map.get(r.trace_id),
))
return {
"summary": summary,
"daily": daily,
"hourly": hourly,
"total": len(rows),
"total": summary["started_count"],
"items": items,
}
@@ -276,15 +402,17 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
).scalar_one()
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows])
return {
"items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)) for r in rows],
"items": [
_session_to_row(
r,
ad_revenue_yuan=rev_map.get(r.trace_id, 0.0),
)
for r in rows
],
"total": int(total),
}
_SLOT_OK = ("success", "already_claimed")
_SLOT_TRIED = ("success", "already_claimed", "failed")
def coupon_slot_report(
db: Session, *, date_from: str, date_to: str, app_env: str | None = None
) -> dict:
+71 -16
View File
@@ -5,6 +5,7 @@
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from typing import Any
@@ -15,12 +16,14 @@ from sqlalchemy.orm import Session
from app.admin.repositories.queries import _as_utc, offset_paginate
from app.integrations import jd_union, meituan
from app.repositories import cps_link as cps_link_repo
from app.models.cps_activity import CpsActivity
from app.models.cps_group import CpsGroup
from app.models.cps_link import CpsClick, CpsLink
from app.models.cps_order import CpsOrder
from app.models.cps_wx_user import CpsWxUser
from app.repositories import cps_link as cps_link_repo
logger = logging.getLogger("shagua.cps_reconcile")
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
_INVALID_STATUS = {"4", "5"}
@@ -378,18 +381,35 @@ def effective_commission_cents(order: CpsOrder) -> int:
def reconcile_orders(
db: Session, *, start_time: int, end_time: int,
query_time_type: int = 1, sid: str | None = None, max_pages: int = 200,
audit_context: dict[str, Any] | None = None,
) -> dict:
"""调美团 query_order 分页拉单 → 按 order_id upsert。返回 {fetched, inserted, updated, pages}。
订单状态会随时间变(付款→完成→结算/退款),重复拉同一单则更新。max_pages 防异常死循环。
"""
fetched = inserted = updated = pages = 0
fetched = inserted = updated = pages = api_requests = 0
page = 1
while page <= max_pages:
resp = meituan.query_order(
sid=sid, start_time=start_time, end_time=end_time,
query_time_type=query_time_type, page=page, limit=100,
)
api_requests += 1
try:
resp = meituan.query_order(
sid=sid, start_time=start_time, end_time=end_time,
query_time_type=query_time_type, page=page, limit=100,
)
except Exception: # noqa: BLE001 - 记录失败页后保持原异常类型继续抛出
if audit_context is not None:
logger.exception(
"CPS reconcile upstream request failed platform=meituan page=%s",
page,
extra={
**audit_context,
"event": "cps_reconcile.request_failed",
"platform": "meituan",
"failed_page": page,
"api_request_number": api_requests,
},
)
raise
rows = ((resp.get("data") or {}).get("dataList")) or []
if not rows:
break
@@ -419,30 +439,58 @@ def reconcile_orders(
break
page += 1
db.commit()
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
return {
"fetched": fetched,
"inserted": inserted,
"updated": updated,
"pages": pages,
"api_requests": api_requests,
}
def reconcile_jd_orders(
db: Session, *, start_time: datetime, end_time: datetime,
query_time_type: int = 3, max_pages: int = 100,
audit_context: dict[str, Any] | None = None,
) -> dict:
"""调京东 order.row.query 拉单 → 按订单行 upsert。
京东单次查询窗口最多 1 小时,这里按北京自然时间切窗并逐页拉取。
"""
fetched = inserted = updated = pages = 0
fetched = inserted = updated = pages = api_requests = windows = 0
cur = start_time
while cur < end_time:
win_end = min(cur + timedelta(hours=1), end_time)
windows += 1
page = 1
while page <= max_pages:
resp = jd_union.query_order_rows(
start_time=cur,
end_time=win_end,
query_time_type=query_time_type,
page_index=page,
page_size=200,
)
api_requests += 1
try:
resp = jd_union.query_order_rows(
start_time=cur,
end_time=win_end,
query_time_type=query_time_type,
page_index=page,
page_size=200,
)
except Exception: # noqa: BLE001 - 记录失败窗口后保持原异常类型继续抛出
if audit_context is not None:
logger.exception(
"CPS reconcile upstream request failed platform=jd window=%s..%s page=%s",
cur.isoformat(),
win_end.isoformat(),
page,
extra={
**audit_context,
"event": "cps_reconcile.request_failed",
"platform": "jd",
"failed_window_start": cur.isoformat(),
"failed_window_end": win_end.isoformat(),
"failed_page": page,
"api_request_number": api_requests,
},
)
raise
rows = resp.get("rows") or []
has_more = bool(resp.get("has_more"))
if not rows:
@@ -469,7 +517,14 @@ def reconcile_jd_orders(
page += 1
cur = win_end
db.commit()
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
return {
"fetched": fetched,
"inserted": inserted,
"updated": updated,
"pages": pages,
"api_requests": api_requests,
"windows": windows,
}
def list_orders(
+211 -32
View File
@@ -5,7 +5,8 @@
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, time, timedelta, timezone
from decimal import ROUND_HALF_UP, Decimal
from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, case, desc, func, or_, select
@@ -145,7 +146,7 @@ def list_users(
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
# 最近活跃 = max(注册时间, 最近行为事件, 最近领券发起)。baseline 由 last_login_at 改为 created_at
#(登录不代表在用 App;口径统一到 activity.py,含 home_view + 比价 + 领券,见 activity.ACTIVE_EVENTS)。
#(登录不代表在用 App;口径统一到 activity.py,含 home_visible + 比价 + 领券,见 activity.ACTIVE_EVENTS)。
# 未命中侧 coalesce 到 created_at(恒非空基线)。派生表 1:1,outerjoin 不放大行数。
ev_agg, eng_agg = activity.last_active_subqueries(db)
last_active = activity.last_active_expr(
@@ -209,6 +210,45 @@ def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | P
r.nickname = nick
def _comparison_conditions(
*,
user_id: int | None = None,
phone: str | None = None,
status: str | None = None,
business_type: str | None = None,
store: str | None = None,
product: str | None = None,
date_from: date | None = None,
date_to: date | None = None,
) -> list:
"""比价列表与概览共用筛选条件;日期按北京自然日闭区间解释。"""
conditions = []
if user_id is not None:
conditions.append(ComparisonRecord.user_id == user_id)
if phone:
conditions.append(
ComparisonRecord.user_id.in_(
select(User.id).where(User.phone.like(f"{phone}%"))
)
)
if status:
conditions.append(ComparisonRecord.status == status)
if business_type:
conditions.append(ComparisonRecord.business_type == business_type)
if store:
conditions.append(ComparisonRecord.store_name.like(f"%{store}%"))
if product:
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)
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)
conditions.append(ComparisonRecord.created_at < end_utc)
return conditions
def list_comparison_records(
db: Session,
*,
@@ -218,30 +258,19 @@ def list_comparison_records(
business_type: str | None = None,
store: str | None = None,
product: str | None = None,
date_from: date | None = None,
date_to: date | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[ComparisonRecord], int | None, int]:
"""admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛,
store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。
join User 取 phone/nickname 瞬态挂记录上。"""
stmt = select(ComparisonRecord)
if user_id is not None:
stmt = stmt.where(ComparisonRecord.user_id == user_id)
if phone:
stmt = stmt.where(
ComparisonRecord.user_id.in_(
select(User.id).where(User.phone.like(f"{phone}%"))
)
)
if status:
stmt = stmt.where(ComparisonRecord.status == status)
if business_type:
stmt = stmt.where(ComparisonRecord.business_type == business_type)
if store:
stmt = stmt.where(ComparisonRecord.store_name.like(f"%{store}%"))
if product:
# 商品名搜 product_names 派生文本列(非 items JSON:SQLite 下 JSON 中文被转义无法直接 LIKE)。
stmt = stmt.where(ComparisonRecord.product_names.like(f"%{product}%"))
conditions = _comparison_conditions(
user_id=user_id, phone=phone, status=status, business_type=business_type,
store=store, product=product, date_from=date_from, date_to=date_to,
)
stmt = select(ComparisonRecord).where(*conditions)
items, next_cursor, total = offset_paginate(
db, stmt,
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
@@ -256,6 +285,139 @@ def list_comparison_records(
return items, next_cursor, total
def _comparison_percentile(sorted_values: list[int], q: float) -> int | None:
"""线性插值分位数(非负毫秒值四舍五入;单条数据返回自身)。"""
if not sorted_values:
return None
if len(sorted_values) == 1:
return sorted_values[0]
index = (len(sorted_values) - 1) * q
lower = int(index)
upper = min(lower + 1, len(sorted_values) - 1)
value = sorted_values[lower] * (upper - index) + sorted_values[upper] * (index - lower)
return int(value + 0.5)
def _round_duration_ms(value) -> int | None:
"""将数据库聚合结果按既有口径四舍五入为整数毫秒。"""
if value is None:
return 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 耗时聚合语句;每种状态只返回一行。"""
return select(
func.avg(ComparisonRecord.total_ms),
*(
func.percentile_cont(q).within_group(ComparisonRecord.total_ms)
for q in quantiles
),
).where(
*conditions,
ComparisonRecord.status == status,
ComparisonRecord.total_ms.is_not(None),
)
def _comparison_duration_aggregates(
db: Session,
*,
conditions: list,
status: str,
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)
).one()
return [_round_duration_ms(value) for value in row]
# SQLite 没有 percentile_cont;本地/测试只回退读取耗时单列,不加载完整记录。
values = list(
db.execute(
select(ComparisonRecord.total_ms)
.where(
*conditions,
ComparisonRecord.status == status,
ComparisonRecord.total_ms.is_not(None),
)
.order_by(ComparisonRecord.total_ms)
).scalars()
)
average = _round_duration_ms(sum(values) / len(values)) if values else None
return [average, *(_comparison_percentile(values, q) for q in quantiles)]
def comparison_records_summary(
db: Session,
*,
user_id: int | None = None,
phone: str | None = None,
status: str | None = None,
business_type: str | None = None,
store: str | None = None,
product: str | None = None,
date_from: date | None = None,
date_to: date | None = None,
) -> dict:
"""比价记录页概览聚合;主耗时均值及分位数只取成功记录。"""
conditions = _comparison_conditions(
user_id=user_id, phone=phone, status=status, business_type=business_type,
store=store, product=product, date_from=date_from, date_to=date_to,
)
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.avg(ComparisonRecord.llm_cost_yuan),
func.sum(case((
(ComparisonRecord.status == "success")
& (ComparisonRecord.saved_amount_cents > 0), 1
), else_=0)),
func.sum(case((ComparisonRecord.status == "cancelled", 1), else_=0)),
).where(*conditions)
).one()
started = int(row[0] or 0)
completed = int(row[1] or 0)
success = int(row[2] or 0)
lower_price = int(row[4] or 0)
cancelled = int(row[5] or 0)
success_duration_stats = _comparison_duration_aggregates(
db,
conditions=conditions,
status="success",
quantiles=(0.05, 0.5, 0.95, 0.99),
)
cancelled_duration_stats = _comparison_duration_aggregates(
db,
conditions=conditions,
status="cancelled",
quantiles=(0.05, 0.5, 0.95),
)
success_rate_denominator = started - cancelled
return {
"started": started,
"completed": completed,
"success": success,
"success_rate": success / success_rate_denominator if success_rate_denominator else None,
"avg_token_cost": float(row[3]) if row[3] is not None else None,
"lower_price_rate": lower_price / success if success else None,
"avg_duration_ms": success_duration_stats[0],
"p5_duration_ms": success_duration_stats[1],
"p50_duration_ms": success_duration_stats[2],
"p95_duration_ms": success_duration_stats[3],
"p99_duration_ms": success_duration_stats[4],
"cancelled": cancelled,
"cancelled_rate": cancelled / started if started else None,
"cancelled_p5_ms": cancelled_duration_stats[1],
"cancelled_p50_ms": cancelled_duration_stats[2],
"cancelled_p95_ms": cancelled_duration_stats[3],
}
def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。"""
rec = db.get(ComparisonRecord, record_id)
@@ -1009,24 +1171,30 @@ def user_reward_stats(
acc = db.get(CoinAccount, user_id) # 现金余额:当前快照,不随窗口
cash_balance = acc.cash_balance_cents if acc else 0
rv = list(db.execute(
select(AdRewardRecord).where(
# 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时,
# SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。
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),
)
).scalars())
).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 = list(db.execute(
select(AdFeedRewardRecord).where(
feed = 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),
)
).scalars())
).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)
@@ -1084,8 +1252,14 @@ def user_coin_records(
signin_from = date_from + timedelta(hours=8) if date_from is not None else None
signin_to = date_to + timedelta(hours=8) if date_to is not None else None
# 三类来源都只取页面需要的列,避免无关 ORM 新列造成旧库查询失败。
for rec in db.execute(
select(AdRewardRecord)
select(
AdRewardRecord.reward_scene,
AdRewardRecord.created_at,
AdRewardRecord.ecpm_raw,
AdRewardRecord.coin,
)
.where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.status == "granted",
@@ -1093,7 +1267,7 @@ def user_coin_records(
)
.order_by(AdRewardRecord.created_at.desc())
.limit(fetch)
).scalars():
).all():
is_video = rec.reward_scene == "reward_video"
rows.append({
"source": rec.reward_scene,
@@ -1104,7 +1278,12 @@ def user_coin_records(
})
for rec in db.execute(
select(AdFeedRewardRecord)
select(
AdFeedRewardRecord.feed_scene,
AdFeedRewardRecord.created_at,
AdFeedRewardRecord.ecpm_raw,
AdFeedRewardRecord.coin,
)
.where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
@@ -1112,7 +1291,7 @@ def user_coin_records(
)
.order_by(AdFeedRewardRecord.created_at.desc())
.limit(fetch)
).scalars():
).all():
rows.append({
"source": "feed",
"source_label": _FEED_SCENE_LABEL.get(rec.feed_scene, "信息流广告"),
@@ -1122,7 +1301,7 @@ def user_coin_records(
})
for rec in db.execute(
select(CoinTransaction)
select(CoinTransaction.created_at, CoinTransaction.amount)
.where(
CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
@@ -1130,7 +1309,7 @@ def user_coin_records(
)
.order_by(CoinTransaction.created_at.desc())
.limit(fetch)
).scalars():
).all():
rows.append({
"source": "signin",
"source_label": "签到",
+129 -28
View File
@@ -6,10 +6,10 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
from __future__ import annotations
from collections import Counter
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal, InvalidOperation
from datetime import UTC, date, datetime, time, timedelta, timezone
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from sqlalchemy import case, func, select
from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import Session
from app.admin.repositories.coupon_data import _percentile
@@ -25,26 +25,24 @@ from app.models.coupon_state import (
from app.models.cps_order import CpsOrder
from app.models.feedback import Feedback
from app.models.savings import SavingsRecord
from app.models.signin import SigninBoostRecord, SigninRecord
from app.models.signin import SigninRecord
from app.models.user import User
from app.models.wallet import CoinTransaction, WithdrawOrder
_BEIJING = timezone(timedelta(hours=8))
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward")
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward", "signin_boost")
# 领券/比价奖励金币的真实来源是信息流广告发奖(ad_feed_reward_record,按 feed_scene 分场景);
# coin_transaction 里只有扁平的 feed_ad_reward、biz_type 不分 coupon/comparison,故这俩桶历史从未
# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。reward_video/
# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)
# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。
# reward_video/ad_reward 及历史 signin_boost 均归看视频桶,不再混进领券奖励或常规任务
COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward")
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
*REWARD_VIDEO_BIZ_TYPES,
*COUPON_REWARD_BIZ_TYPES,
*COMPARISON_REWARD_BIZ_TYPES,
*EXCLUDED_REWARD_BIZ_TYPES,
*UNCLASSIFIED_FEED_BIZ_TYPES,
# 常规任务必须按明确来源相加;不能从全部正向流水反减排除项,否则新增广告/运营
# biz_type 时会在排除清单更新前自动混入该桶。task_ 前缀在查询处单独覆盖现有及未来任务。
REGULAR_TASK_EXACT_BIZ_TYPES = (
"signin",
"price_report_reward",
"feedback_reward",
)
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
MEITUAN_CPS_SETTLED_STATUS = "6"
@@ -61,7 +59,7 @@ def _beijing_today_start_utc() -> datetime:
"""北京时间今天 0 点对应的 UTC 时刻(DAU / 今日新增按北京时区切天)。"""
now_bj = datetime.now(_BEIJING)
start_bj = now_bj.replace(hour=0, minute=0, second=0, microsecond=0)
return start_bj.astimezone(timezone.utc)
return start_bj.astimezone(UTC)
def today_dau(db: Session) -> int:
@@ -94,8 +92,8 @@ def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime,
"""
start_bj = datetime.combine(date_from, time.min, tzinfo=_BEIJING)
end_bj = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=_BEIJING)
start_utc = start_bj.astimezone(timezone.utc)
end_utc = end_bj.astimezone(timezone.utc)
start_utc = start_bj.astimezone(UTC)
end_utc = end_bj.astimezone(UTC)
return (
start_utc,
end_utc,
@@ -109,6 +107,23 @@ def _date_range(date_from: date, date_to: date) -> list[date]:
return [date_from + timedelta(days=i) for i in range(days + 1)]
def _duration_percentile(sorted_values: list[int], q: float) -> int | None:
"""Linear-interpolated percentile with the same half-up rounding as Math.round."""
if not sorted_values:
return None
if len(sorted_values) == 1:
return sorted_values[0]
index = (len(sorted_values) - 1) * q
lower = int(index)
upper = min(lower + 1, len(sorted_values) - 1)
fraction = Decimal(str(index - lower))
value = (
Decimal(sorted_values[lower]) * (Decimal(1) - fraction)
+ Decimal(sorted_values[upper]) * fraction
)
return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def _id_set(db: Session, stmt) -> set[int]:
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
@@ -242,16 +257,46 @@ def dashboard_overview(
ComparisonRecord.created_at >= start_local,
ComparisonRecord.created_at < end_local,
)
period_comparison_total = _count(ComparisonRecord, *period_comparison_conds)
period_comparison_success = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.status == "success",
period_comparison_stats = db.execute(
select(
func.count(ComparisonRecord.id),
func.coalesce(
func.sum(
case(
(ComparisonRecord.status.in_(("success", "failed")), 1),
else_=0,
)
),
0,
),
func.coalesce(
func.sum(
case((ComparisonRecord.status == "cancelled", 1), else_=0)
),
0,
),
func.coalesce(
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
0,
),
func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0),
).where(*period_comparison_conds)
).one()
period_comparison_total = int(period_comparison_stats[0])
period_comparison_completed = int(period_comparison_stats[1])
period_comparison_cancelled = int(period_comparison_stats[2])
period_comparison_success = int(period_comparison_stats[3])
period_comparison_token_cost_yuan = float(period_comparison_stats[4])
period_comparison_success_denominator = (
period_comparison_total - period_comparison_cancelled
)
period_comparison_success_rate = (
round(period_comparison_success / period_comparison_total, 4)
if period_comparison_total
else 0.0
round(
period_comparison_success / period_comparison_success_denominator,
4,
)
if period_comparison_success_denominator > 0
else None
)
period_saved_positive_count = _count(
ComparisonRecord,
@@ -282,6 +327,47 @@ def dashboard_overview(
if period_avg_duration_ms is not None
else None
)
completed_duration_conds = (
*period_comparison_conds,
ComparisonRecord.status.in_(("success", "failed")),
ComparisonRecord.total_ms.is_not(None),
)
if db.bind is not None and db.bind.dialect.name == "postgresql":
period_median_duration_ms, period_p95_duration_ms = db.execute(
select(
func.percentile_cont(0.5).within_group(ComparisonRecord.total_ms),
func.percentile_cont(0.95).within_group(ComparisonRecord.total_ms),
).where(*completed_duration_conds)
).one()
period_median_duration_ms = (
int(
Decimal(str(period_median_duration_ms)).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
if period_median_duration_ms is not None
else None
)
period_p95_duration_ms = (
int(
Decimal(str(period_p95_duration_ms)).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
if period_p95_duration_ms is not None
else None
)
else:
# SQLite 测试环境没有 percentile_cont;仅回退读取耗时单列,不加载完整记录。
completed_durations = list(
db.execute(
select(ComparisonRecord.total_ms)
.where(*completed_duration_conds)
.order_by(ComparisonRecord.total_ms)
).scalars()
)
period_median_duration_ms = _duration_percentile(completed_durations, 0.5)
period_p95_duration_ms = _duration_percentile(completed_durations, 0.95)
ordered_exists = (
select(SavingsRecord.id)
@@ -417,7 +503,10 @@ def dashboard_overview(
period_regular_task_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
or_(
CoinTransaction.biz_type.in_(REGULAR_TASK_EXACT_BIZ_TYPES),
CoinTransaction.biz_type.like(r"task\_%", escape="\\"),
),
)
period_cps_orders = list(
db.execute(
@@ -590,7 +679,14 @@ def dashboard_overview(
CoinTransaction.amount > 0,
CoinTransaction.biz_type == "signin_boost",
),
"signin_boost_watch_count": _count(SigninBoostRecord),
# 签到膨胀 2026-07 已下线,signin_boost_record 表随之 drop。这两项保留为**历史口径**
# (钱是真发过的,账要能查回)。次数改数金币流水:一次膨胀 = 一笔 signin_boost 流水,
# 与原来数 signin_boost_record 行数等价。
"signin_boost_watch_count": _count(
CoinTransaction,
CoinTransaction.biz_type == "signin_boost",
CoinTransaction.amount > 0,
),
},
"cash": {
"withdraw_success_cents": _sum(
@@ -622,11 +718,16 @@ def dashboard_overview(
},
"comparison": {
"total": period_comparison_total,
"completed": period_comparison_completed,
"cancelled": period_comparison_cancelled,
"success": period_comparison_success,
"success_rate": period_comparison_success_rate,
"ordered": period_ordered_count,
"average_duration_ms": period_avg_duration_ms,
"median_duration_ms": period_median_duration_ms,
"p95_duration_ms": period_p95_duration_ms,
"average_saved_cents": period_avg_saved_cents,
"token_cost_total_yuan": period_comparison_token_cost_yuan,
},
"coupon": {
"started": coupon_started,
+9 -1
View File
@@ -5,7 +5,7 @@
from __future__ import annotations
from datetime import date as _date
from typing import Annotated
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -63,6 +63,13 @@ def get_ad_revenue_report(
"建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均"
),
] = None,
revenue_scope: Annotated[
Literal["business", "all"],
Query(
description="business=仅业务代码位(用于客户端与穿山甲同口径对账)/ "
"all=穿山甲应用全部代码位(包含广告测试等非业务曝光)"
),
] = "all",
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
] = "day",
@@ -83,6 +90,7 @@ def get_ad_revenue_report(
result = ad_revenue.ad_revenue_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
user_id=user_id, ad_type=ad_type, feed_scene=feed_scene, app_env=app_env,
revenue_scope=revenue_scope,
granularity=granularity, limit=limit, offset=offset, sort=sort,
)
return AdRevenueReportOut(
+2 -2
View File
@@ -6,7 +6,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.deps import AdminDb, require_page
from app.admin.repositories import analytics_health as repo
from app.admin.schemas.analytics_health import (
HealthBreakdownRow,
@@ -17,7 +17,7 @@ from app.admin.schemas.analytics_health import (
router = APIRouter(
prefix="/admin/api/analytics-health",
tags=["admin-analytics-health"],
dependencies=[Depends(get_current_admin)],
dependencies=[Depends(require_page("analytics-health"))],
)
+3 -3
View File
@@ -1,11 +1,11 @@
"""admin 操作审计日志查询(所有 admin 可看:谁在何时对什么做了什么)。"""
"""admin 操作审计日志查询(需要 audit-logs 页面权限)。"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.deps import AdminDb, require_page
from app.admin.repositories import audit_log as audit_repo
from app.admin.schemas.admin import AdminAuditLogOut
from app.admin.schemas.common import CursorPage
@@ -13,7 +13,7 @@ from app.admin.schemas.common import CursorPage
router = APIRouter(
prefix="/admin/api/audit-logs",
tags=["admin-audit"],
dependencies=[Depends(get_current_admin)],
dependencies=[Depends(require_page("audit-logs"))],
)
+32 -1
View File
@@ -5,6 +5,7 @@ trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.de
"""
from __future__ import annotations
from datetime import date
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -12,7 +13,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import queries
from app.admin.schemas.common import CursorPage
from app.admin.schemas.comparison import AdminComparisonDetail, AdminComparisonListItem
from app.admin.schemas.comparison import (
AdminComparisonDetail,
AdminComparisonListItem,
AdminComparisonSummary,
)
router = APIRouter(
prefix="/admin/api/comparison-records",
@@ -34,12 +39,15 @@ def list_comparison_records(
business_type: Annotated[str | None, Query()] = None,
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None,
date_from: Annotated[date | None, Query(description="北京自然日起始日")] = None,
date_to: Annotated[date | None, Query(description="北京自然日结束日")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminComparisonListItem]:
items, next_cursor, total = queries.list_comparison_records(
db, user_id=user_id, phone=phone, status=status,
business_type=business_type, store=store, product=product,
date_from=date_from, date_to=date_to,
limit=limit, cursor=cursor,
)
return CursorPage(
@@ -49,6 +57,29 @@ def list_comparison_records(
)
@router.get(
"/summary",
response_model=AdminComparisonSummary,
summary="比价记录概览聚合",
)
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,
business_type: Annotated[str | None, Query()] = None,
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None,
date_from: Annotated[date | None, Query(description="北京自然日起始日")] = None,
date_to: Annotated[date | None, Query(description="北京自然日结束日")] = None,
) -> AdminComparisonSummary:
return AdminComparisonSummary(**queries.comparison_records_summary(
db, user_id=user_id, phone=phone, status=status,
business_type=business_type, store=store, product=product,
date_from=date_from, date_to=date_to,
))
@router.get(
"/{record_id}",
response_model=AdminComparisonDetail,
+18
View File
@@ -18,6 +18,8 @@ from app.admin.schemas.coupon_data import (
CouponDataOut,
CouponDataRow,
CouponDataSummary,
CouponPointDetail,
CouponPointDetailsOut,
CouponSlotRow,
CouponSlotsOut,
CouponUserRecordsOut,
@@ -122,6 +124,22 @@ def get_coupon_slots(
)
@router.get(
"/point-details",
response_model=CouponPointDetailsOut,
summary="按 trace 查询单次领券任务的逐券点位明细",
)
def get_coupon_point_details(
db: AdminDb,
trace_id: Annotated[str, Query(min_length=1, max_length=64, description="领券 trace_id")],
) -> CouponPointDetailsOut:
items = coupon_data.coupon_point_details(db, trace_id=trace_id)
return CouponPointDetailsOut(
trace_id=trace_id,
items=[CouponPointDetail(**item) for item in items],
)
@router.get(
"/user-records",
response_model=CouponUserRecordsOut,
+3 -3
View File
@@ -2,7 +2,7 @@
数据源 device_liveness (心跳 last_heartbeat_at + liveness_state + kill_alert_pending,
app/models/device.py)在线/掉线掉线时长由 repo HEARTBEAT_TIMEOUT_MINUTES 阈值派生
纯读:无写无审计任意登录管理员可看(同大盘/设备管理,无角色门)
纯读:无写无审计需要 device-liveness 页面权限
"""
from __future__ import annotations
@@ -10,7 +10,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.deps import AdminDb, require_page
from app.admin.repositories import queries
from app.admin.schemas.common import CursorPage
from app.admin.schemas.device import DeviceLivenessItem, DeviceLivenessStats
@@ -18,7 +18,7 @@ from app.admin.schemas.device import DeviceLivenessItem, DeviceLivenessStats
router = APIRouter(
prefix="/admin/api/device-liveness",
tags=["admin-device-liveness"],
dependencies=[Depends(get_current_admin)],
dependencies=[Depends(require_page("device-liveness"))],
)
+2 -2
View File
@@ -6,7 +6,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.deps import AdminDb, require_page
from app.admin.repositories import queries
from app.admin.schemas.analytics import AnalyticsEventOut
from app.admin.schemas.common import CursorPage
@@ -14,7 +14,7 @@ from app.admin.schemas.common import CursorPage
router = APIRouter(
prefix="/admin/api/event-logs",
tags=["admin-event-logs"],
dependencies=[Depends(get_current_admin)],
dependencies=[Depends(require_page("event-logs"))],
)
+168 -78
View File
@@ -12,6 +12,10 @@ from app.admin.repositories import mutations, queries
from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.feedback import (
FeedbackApproveRequest,
FeedbackBulkApproveRequest,
FeedbackBulkItemResult,
FeedbackBulkRejectRequest,
FeedbackBulkResult,
FeedbackOut,
FeedbackRejectRequest,
FeedbackSummary,
@@ -19,6 +23,7 @@ from app.admin.schemas.feedback import (
from app.models.admin import AdminUser
from app.models.feedback import Feedback
from app.repositories import wallet as wallet_repo
from app.services import notification_events
router = APIRouter(
prefix="/admin/api/feedbacks",
@@ -32,6 +37,123 @@ def _ensure_pending(fb: Feedback) -> None:
raise HTTPException(status_code=400, detail="反馈已审核")
def _approve_feedback(
db: AdminDb,
admin: AdminUser,
feedback_id: int,
payload: FeedbackApproveRequest | FeedbackBulkApproveRequest,
ip: str,
*,
bulk: bool = False,
) -> FeedbackOut:
fb = db.get(Feedback, feedback_id, with_for_update=True)
if fb is None:
raise HTTPException(status_code=404, detail="反馈不存在")
_ensure_pending(fb)
before = fb.status
mutations.review_feedback(
db,
fb,
status="adopted",
reward_coins=payload.reward_coins,
review_note=payload.note,
admin_reply=payload.reply,
reviewed_by_admin_id=admin.id,
commit=False,
)
wallet_repo.grant_coins(
db,
fb.user_id,
payload.reward_coins,
biz_type="feedback_reward",
ref_id=str(fb.id),
remark="意见反馈被采纳",
)
detail = {
"before": before,
"after": "adopted",
"reward_coins": payload.reward_coins,
"note": payload.note,
"reply": payload.reply,
}
if bulk:
detail["bulk"] = True
write_audit(
db,
admin,
action="feedback.approve",
target_type="feedback",
target_id=feedback_id,
detail=detail,
ip=ip,
commit=False,
)
db.commit()
db.refresh(fb)
out = FeedbackOut.model_validate(fb)
notification_events.notify_feedback_reward(db, fb)
return out
def _reject_feedback(
db: AdminDb,
admin: AdminUser,
feedback_id: int,
payload: FeedbackRejectRequest | FeedbackBulkRejectRequest,
ip: str,
*,
bulk: bool = False,
) -> FeedbackOut:
fb = db.get(Feedback, feedback_id, with_for_update=True)
if fb is None:
raise HTTPException(status_code=404, detail="反馈不存在")
_ensure_pending(fb)
before = fb.status
mutations.review_feedback(
db,
fb,
status="rejected",
reject_reason=payload.reason,
review_note=payload.note,
admin_reply=payload.reply,
reviewed_by_admin_id=admin.id,
commit=False,
)
detail = {
"before": before,
"after": "rejected",
"reason": payload.reason,
"note": payload.note,
"reply": payload.reply,
}
if bulk:
detail["bulk"] = True
write_audit(
db,
admin,
action="feedback.reject",
target_type="feedback",
target_id=feedback_id,
detail=detail,
ip=ip,
commit=False,
)
db.commit()
db.refresh(fb)
out = FeedbackOut.model_validate(fb)
notification_events.notify_feedback_reply(db, fb)
return out
def _bulk_result(items: list[FeedbackBulkItemResult]) -> FeedbackBulkResult:
success = sum(1 for item in items if item.ok)
return FeedbackBulkResult(
total=len(items), success=success, failed=len(items) - success, items=items,
)
@router.get("", response_model=CursorPage[FeedbackOut], summary="反馈工单列表")
def list_feedbacks(
db: AdminDb,
@@ -72,6 +194,50 @@ def feedback_summary(db: AdminDb) -> FeedbackSummary:
return FeedbackSummary.model_validate(queries.feedback_summary(db))
@router.post("/bulk/approve", response_model=FeedbackBulkResult, summary="批量采纳反馈并发金币")
def bulk_approve_feedbacks(
body: FeedbackBulkApproveRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackBulkResult:
results: list[FeedbackBulkItemResult] = []
ip = get_client_ip(request)
for feedback_id in body.ids:
try:
out = _approve_feedback(db, admin, feedback_id, body, ip, bulk=True)
results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status))
except HTTPException as exc:
db.rollback()
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail)))
except Exception: # noqa: BLE001 - 单笔失败不打断整批
db.rollback()
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常"))
return _bulk_result(results)
@router.post("/bulk/reject", response_model=FeedbackBulkResult, summary="批量拒绝采纳反馈")
def bulk_reject_feedbacks(
body: FeedbackBulkRejectRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackBulkResult:
results: list[FeedbackBulkItemResult] = []
ip = get_client_ip(request)
for feedback_id in body.ids:
try:
out = _reject_feedback(db, admin, feedback_id, body, ip, bulk=True)
results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status))
except HTTPException as exc:
db.rollback()
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail)))
except Exception: # noqa: BLE001 - 单笔失败不打断整批
db.rollback()
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常"))
return _bulk_result(results)
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
def handle_feedback(
feedback_id: int,
@@ -92,49 +258,7 @@ def approve_feedback(
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackOut:
fb = db.get(Feedback, feedback_id)
if fb is None:
raise HTTPException(status_code=404, detail="反馈不存在")
_ensure_pending(fb)
before = fb.status
mutations.review_feedback(
db,
fb,
status="adopted",
reward_coins=payload.reward_coins,
review_note=payload.note,
admin_reply=payload.reply,
reviewed_by_admin_id=admin.id,
commit=False,
)
wallet_repo.grant_coins(
db,
fb.user_id,
payload.reward_coins,
biz_type="feedback_reward",
ref_id=str(fb.id),
remark="意见反馈被采纳",
)
write_audit(
db,
admin,
action="feedback.approve",
target_type="feedback",
target_id=feedback_id,
detail={
"before": before,
"after": "adopted",
"reward_coins": payload.reward_coins,
"note": payload.note,
"reply": payload.reply,
},
ip=get_client_ip(request),
commit=False,
)
db.commit()
db.refresh(fb)
return FeedbackOut.model_validate(fb)
return _approve_feedback(db, admin, feedback_id, payload, get_client_ip(request))
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
@@ -145,38 +269,4 @@ def reject_feedback(
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackOut:
fb = db.get(Feedback, feedback_id)
if fb is None:
raise HTTPException(status_code=404, detail="反馈不存在")
_ensure_pending(fb)
before = fb.status
mutations.review_feedback(
db,
fb,
status="rejected",
reject_reason=payload.reason,
review_note=payload.note,
admin_reply=payload.reply,
reviewed_by_admin_id=admin.id,
commit=False,
)
write_audit(
db,
admin,
action="feedback.reject",
target_type="feedback",
target_id=feedback_id,
detail={
"before": before,
"after": "rejected",
"reason": payload.reason,
"note": payload.note,
"reply": payload.reply,
},
ip=get_client_ip(request),
commit=False,
)
db.commit()
db.refresh(fb)
return FeedbackOut.model_validate(fb)
return _reject_feedback(db, admin, feedback_id, payload, get_client_ip(request))
+101
View File
@@ -0,0 +1,101 @@
"""admin 新手引导视频配置:读 / 改开关次数金币 / 上传视频 / 删视频(带审计)。
整份配置存通用 app_config ( app/repositories/guide_video.py),App 领券等候浮层
每次展示前调 POST /api/v1/guide-video/start 同步权限:operator 可改(运营维护),
super 恒可;读为只读(任意已登录 admin)
视频上限 100MB(settings.GUIDE_VIDEO_MAX_BYTES),已在 admin nginx 为本接口单独放宽
client_max_body_size, shaguabijia-admin-web/deploy/nginx/admin.shaguabijia.com.conf
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
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.guide_video import GuideVideoConfigOut, GuideVideoConfigUpdate
from app.core import media
from app.models.admin import AdminUser
from app.repositories import guide_video
router = APIRouter(
prefix="/admin/api/guide-video",
tags=["admin-guide-video"],
dependencies=[Depends(get_current_admin)],
)
def _out(db: AdminDb) -> GuideVideoConfigOut:
"""配置 + 播放统计合成响应(四个写接口都以最新状态返回,前端一次同步到位)。"""
return GuideVideoConfigOut(**guide_video.get_config(db), **guide_video.play_stats(db))
@router.get("", response_model=GuideVideoConfigOut, summary="新手引导视频配置(领券浮层)")
def get_config(db: AdminDb) -> GuideVideoConfigOut:
return _out(db)
@router.patch("", response_model=GuideVideoConfigOut, summary="改开关/次数/金币(带审计)")
def update_config(
body: GuideVideoConfigUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> 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,
)
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,
)
db.commit()
return _out(db)
@router.post("/video", response_model=GuideVideoConfigOut, summary="上传新手引导视频(MP4,带审计)")
async def upload_video(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
file: UploadFile = File(...),
) -> GuideVideoConfigOut:
data = await file.read()
try:
url = 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)
@router.delete("/video", response_model=GuideVideoConfigOut, summary="移除新手引导视频(带审计)")
def delete_video(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> GuideVideoConfigOut:
"""移除后 /guide-video/start 一律返回 should_play=false,领券浮层回到「只放广告」。"""
before, after = guide_video.set_video(db, None, 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,
)
db.commit()
media.delete_guide_video(before.get("video_url"))
return _out(db)
+58
View File
@@ -0,0 +1,58 @@
"""admin 华为审核开关:控制新手引导页(快速设置)在华为 ROM 客户端能否被用户关闭。
存在 app_config 表的 huawei_review dict( repositories/app_config.get_huawei_review/set_huawei_review)
客户端经 /api/v1/platform/huawei-review 拉取(且只有华为 ROM 机型会去拉)权限 operator/tech + 审计
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
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.huawei_review import HuaweiReviewOut, HuaweiReviewUpdate
from app.models.admin import AdminUser
from app.models.app_config import AppConfig
from app.repositories import app_config
router = APIRouter(
prefix="/admin/api/huawei-review",
tags=["admin-huawei-review"],
dependencies=[Depends(get_current_admin)],
)
def _out(db: Session) -> HuaweiReviewOut:
row = db.get(AppConfig, app_config.HUAWEI_REVIEW_KEY)
return HuaweiReviewOut(
mode=app_config.get_huawei_review(db)["mode"],
updated_at=row.updated_at.isoformat() if row is not None else None,
)
@router.get("", response_model=HuaweiReviewOut, summary="华为审核开关当前状态")
def get_huawei_review(db: AdminDb) -> HuaweiReviewOut:
return _out(db)
@router.patch("", response_model=HuaweiReviewOut, summary="切换华为审核开关(带审计)")
def update_huawei_review(
body: HuaweiReviewUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator", "tech"))],
db: AdminDb,
) -> HuaweiReviewOut:
before = app_config.get_huawei_review(db)["mode"]
try:
app_config.set_huawei_review(db, body.mode, admin_id=admin.id, commit=False)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
write_audit(
db, admin, action="huawei_review.set", target_type="huawei_review",
target_id=app_config.HUAWEI_REVIEW_KEY,
detail={"before": before, "after": body.mode}, ip=get_client_ip(request), commit=False,
)
db.commit()
return _out(db)
+106 -32
View File
@@ -3,7 +3,8 @@
数据由客户端 POST /api/v1/report 写入 price_report (提交即 pending);本路由是运营后台
对它的人工审核窗口**通过** 给上报用户钱包发固定金币(PRICE_REPORT_REWARD_COINS):
改状态 + 发金币(wallet.grant_coins)+ 审计同一事务一起 commit(原子,仿 users.grant_user_coins),
绝不只改状态不发钱或反之客户端轮询 GET /api/v1/report/records 自动看到结果(无需推送)
绝不只改状态不发钱或反之通过后下发爆料审核通过通知(站内 + push,PRD #11);
客户端也可轮询 GET /api/v1/report/records 看到结果
"""
from __future__ import annotations
@@ -16,6 +17,10 @@ from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_ro
from app.admin.repositories import mutations, queries
from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.price_report import (
PriceReportBulkItemResult,
PriceReportBulkRejectRequest,
PriceReportBulkRequest,
PriceReportBulkResult,
PriceReportOut,
PriceReportRejectRequest,
PriceReportSummary,
@@ -24,6 +29,7 @@ from app.core.rewards import PRICE_REPORT_REWARD_COINS
from app.models.admin import AdminUser
from app.models.price_report import PriceReport
from app.repositories import wallet as wallet_repo
from app.services import notification_events
router = APIRouter(
prefix="/admin/api/price-reports",
@@ -32,6 +38,59 @@ router = APIRouter(
)
def _approve_price_report(
db: AdminDb, admin: AdminUser, report_id: int, ip: str, *, bulk: bool = False
) -> PriceReport:
rep = db.get(PriceReport, report_id, with_for_update=True)
if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending":
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
coins = PRICE_REPORT_REWARD_COINS
mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False)
wallet_repo.grant_coins(
db, rep.user_id, coins,
biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过",
)
detail = {"reward_coins": coins, "user_id": rep.user_id}
if bulk:
detail["bulk"] = True
write_audit(
db, admin, action="price_report.approve", target_type="price_report", target_id=report_id,
detail=detail, ip=ip, commit=False,
)
db.commit()
notification_events.notify_report_approved(db, rep)
return rep
def _reject_price_report(
db: AdminDb, admin: AdminUser, report_id: int, reason: str, ip: str, *, bulk: bool = False
) -> PriceReport:
rep = db.get(PriceReport, report_id, with_for_update=True)
if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending":
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False)
detail = {"reason": reason, "user_id": rep.user_id}
if bulk:
detail["bulk"] = True
write_audit(
db, admin, action="price_report.reject", target_type="price_report", target_id=report_id,
detail=detail, ip=ip, commit=False,
)
db.commit()
return rep
def _bulk_result(items: list[PriceReportBulkItemResult]) -> PriceReportBulkResult:
success = sum(1 for item in items if item.ok)
return PriceReportBulkResult(
total=len(items), success=success, failed=len(items) - success, items=items,
)
@router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)")
def list_price_reports(
db: AdminDb,
@@ -58,6 +117,50 @@ def price_report_summary(db: AdminDb) -> PriceReportSummary:
return PriceReportSummary.model_validate(queries.price_report_summary(db))
@router.post("/bulk/approve", response_model=PriceReportBulkResult, summary="批量通过上报(发固定金币)")
def bulk_approve_price_reports(
body: PriceReportBulkRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> PriceReportBulkResult:
results: list[PriceReportBulkItemResult] = []
ip = get_client_ip(request)
for report_id in body.ids:
try:
rep = _approve_price_report(db, admin, report_id, ip, bulk=True)
results.append(PriceReportBulkItemResult(id=report_id, ok=True, status=rep.status))
except HTTPException as exc:
db.rollback()
results.append(PriceReportBulkItemResult(id=report_id, ok=False, error=str(exc.detail)))
except Exception: # noqa: BLE001 - 单笔失败不打断整批
db.rollback()
results.append(PriceReportBulkItemResult(id=report_id, ok=False, error="系统异常"))
return _bulk_result(results)
@router.post("/bulk/reject", response_model=PriceReportBulkResult, summary="批量拒绝上报")
def bulk_reject_price_reports(
body: PriceReportBulkRejectRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> PriceReportBulkResult:
results: list[PriceReportBulkItemResult] = []
ip = get_client_ip(request)
for report_id in body.ids:
try:
rep = _reject_price_report(db, admin, report_id, body.reason, ip, bulk=True)
results.append(PriceReportBulkItemResult(id=report_id, ok=True, status=rep.status))
except HTTPException as exc:
db.rollback()
results.append(PriceReportBulkItemResult(id=report_id, ok=False, error=str(exc.detail)))
except Exception: # noqa: BLE001 - 单笔失败不打断整批
db.rollback()
results.append(PriceReportBulkItemResult(id=report_id, ok=False, error="系统异常"))
return _bulk_result(results)
@router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)")
def approve_price_report(
report_id: int,
@@ -65,25 +168,7 @@ def approve_price_report(
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
# 行锁(SELECT FOR UPDATE):并发/连点双请求会都读到 pending → 各发一次金币双倍发奖,
# 锁住该行串行化,第二个请求拿锁后看到 approved → 走 400。SQLite 下 FOR UPDATE 为 no-op。
rep = db.get(PriceReport, report_id, with_for_update=True)
if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending":
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
coins = PRICE_REPORT_REWARD_COINS
# 改状态 + 发金币 + 审计同一事务(commit=False),最后一起 commit:改了就有痕、发了就留账
mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False)
wallet_repo.grant_coins(
db, rep.user_id, coins,
biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过",
)
write_audit(
db, admin, action="price_report.approve", target_type="price_report", target_id=report_id,
detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
)
db.commit()
_approve_price_report(db, admin, report_id, get_client_ip(request))
return OkResponse()
@@ -95,16 +180,5 @@ def reject_price_report(
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
rep = db.get(PriceReport, report_id, with_for_update=True) # 行锁,同 approve(防并发重复审核)
if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending":
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
reason = body.reason.strip()
mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False)
write_audit(
db, admin, action="price_report.reject", target_type="price_report", target_id=report_id,
detail={"reason": reason, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
)
db.commit()
_reject_price_report(db, admin, report_id, body.reason, get_client_ip(request))
return OkResponse()
+2 -4
View File
@@ -129,13 +129,11 @@ def withdraw_health_check(db: AdminDb) -> WxpayHealthCheckOut:
issues.append("免确认授权回调地址未配置")
# 实际是否自动对账 = env 部署总闸(worker 起没起)AND 运营后台 DB 开关(本轮跑不跑)。
# 自动查单属于非阻断运维能力:状态继续返回给调用方,但关闭时不计入微信提现配置 issues,
# 避免把“没有自动扫单”误报成“无法打款”。
worker_running = settings.WITHDRAW_AUTO_RECONCILE_ENABLED
daily_on = bool(app_config.get_value(db, "withdraw_auto_reconcile_enabled"))
auto_reconcile_enabled = worker_running and daily_on
if not worker_running:
issues.append("自动对账 worker 未启动(部署侧 env WITHDRAW_AUTO_RECONCILE_ENABLED=false)")
elif not daily_on:
issues.append("自动对账运营开关已关闭(系统配置页可开)")
return WxpayHealthCheckOut(
ok=not issues,
+10 -4
View File
@@ -26,7 +26,10 @@ class AdRevenueRecord(BaseModel):
record_id: int
created_at: datetime
status: str = Field(..., description="granted / capped / ecpm_missing")
status: str = Field(
...,
description="granted / capped / ecpm_missing / closed_early / too_short",
)
ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示)")
ecpm_factor: float | None = Field(None, description="因子1(eCPM 档);非 granted 为空")
units: int = Field(..., description="折算份数:激励视频恒 1;信息流 = 满 10 秒份数")
@@ -44,7 +47,7 @@ class AdRevenueDaily(BaseModel):
date: str = Field(..., description="北京时间 YYYY-MM-DD")
impressions: int = Field(..., description="当天展示条数合计")
revenue_yuan: float = Field(..., description="当天客户端预估收益合计(元;eCPM 折算)")
revenue_yuan: float = Field(..., description="当天客户端有效预估收益合计(元;eCPM 折算)")
pangle_revenue_yuan: float | None = Field(
None, description="当天穿山甲后台预估收益(元;GroMore revenue);非全量视图/无数据为空"
)
@@ -93,7 +96,10 @@ class AdRevenueRow(BaseModel):
has_impression: bool = Field(..., description="是否有广告展示(信息流逐条展示=True,纯发奖行=False)")
impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)")
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000;纯发奖行=0")
revenue_yuan: float = Field(
...,
description="本次有效展示预估收益(元)= eCPM元 ÷ 1000;纯发奖、激励视频提前关闭/时长不足=0",
)
row_revenue_yuan: float | None = Field(
None,
description="主表逐行展示用的预估收益(元):一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;"
@@ -150,7 +156,7 @@ class AdRevenueReportOut(BaseModel):
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
total_impressions: int = Field(..., description="全量展示条数合计")
total_revenue_yuan: float = Field(..., description="全量客户端预估收益合计(元;eCPM 折算)")
total_revenue_yuan: float = Field(..., description="全量客户端有效预估收益合计(元;eCPM 折算)")
total_pangle_revenue_yuan: float | None = Field(
None,
description="全量穿山甲后台预估收益合计(元;GroMore revenue)。穿山甲无用户/类型/场景维度,"
+21
View File
@@ -47,6 +47,27 @@ class AdminComparisonListItem(BaseModel):
created_at: datetime
class AdminComparisonSummary(BaseModel):
"""比价记录页概览;主耗时指标仅统计 status=success。"""
started: int
completed: int
success: int
success_rate: float | None = None
avg_token_cost: float | None = None
lower_price_rate: float | None = None
avg_duration_ms: int | None = None
p5_duration_ms: int | None = None
p50_duration_ms: int | None = None
p95_duration_ms: int | None = None
p99_duration_ms: int | None = None
cancelled: int
cancelled_rate: float | None = None
cancelled_p5_ms: int | None = None
cancelled_p50_ms: int | None = None
cancelled_p95_ms: int | None = None
class AdminComparisonDetail(AdminComparisonListItem):
"""详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。"""
+22
View File
@@ -49,6 +49,15 @@ class CouponDataHourly(BaseModel):
avg_elapsed_ms: int | None = None
class CouponPointDetail(BaseModel):
"""一次领券任务中的单券点位结果。"""
coupon_id: str
coupon_name: str | None = None
status: str = Field(..., description="success / already_claimed / failed / skipped")
reason: str | None = None
class CouponDataRow(BaseModel):
"""一条领券明细(一次领券任务)。"""
@@ -69,6 +78,12 @@ class CouponDataRow(BaseModel):
app_env: str | None = None
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
claimed_count: int | None = None
point_success_count: int | None = Field(
None, description="本次成功单券数(success+already_claimed);无逐券事件为空"
)
point_total_count: int | None = Field(
None, description="本次尝试单券数(success+already_claimed+failed,不含 skipped);无逐券事件为空"
)
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
ad_revenue_yuan: float = Field(
0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record"
@@ -89,6 +104,13 @@ class CouponDataOut(BaseModel):
items: list[CouponDataRow] = Field(..., description="逐条领券明细(当前页)")
class CouponPointDetailsOut(BaseModel):
"""单次领券任务的逐券点位结果,供点击分数时按需加载。"""
trace_id: str
items: list[CouponPointDetail] = Field(default_factory=list)
class CouponUserRecordsOut(BaseModel):
"""某用户全部领券记录(点手机号抽屉用):total=该用户领券总次数,items=记录列表(UserRecordsDrawer 渲染)。"""
+6 -1
View File
@@ -53,11 +53,16 @@ class DashboardPeriodUsers(BaseModel):
class DashboardPeriodComparison(BaseModel):
total: int
completed: int
cancelled: int
success: int
success_rate: float
success_rate: float | None = None
ordered: int
average_duration_ms: int | None = None
median_duration_ms: int | None = None
p95_duration_ms: int | None = None
average_saved_cents: int | None = None
token_cost_total_yuan: float = 0.0
class DashboardPeriodCoupon(BaseModel):
+3 -1
View File
@@ -24,7 +24,9 @@ class DeviceLivenessItem(BaseModel):
device_model: str | None = None # 由 device_id 解析(device_<机型>_<hash>);非 DB 列
platform: str
app_version: str | None = None
registration_id: str | None = None # 非空 = 拿到极光 token、可推送
registration_id: str | None = None # 旧极光字段,仅兼容历史数据
push_vendor: str | None = None
push_token: str | None = None
ever_protected: bool # 是否开过无障碍(=该设备对功能有意义)
first_protected_at: datetime | None = None # 首次开无障碍时刻(老设备为 null)
+49 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.rewards import FEEDBACK_REWARD_MAX_COINS
@@ -55,6 +55,54 @@ class FeedbackRejectRequest(BaseModel):
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
class FeedbackBulkRequest(BaseModel):
ids: list[int] = Field(min_length=1, max_length=50, description="待审核反馈 ID 列表")
@field_validator("ids")
@classmethod
def _ids_must_be_unique(cls, ids: list[int]) -> list[int]:
if len(ids) != len(set(ids)):
raise ValueError("反馈 ID 不能重复")
return ids
class FeedbackBulkApproveRequest(FeedbackBulkRequest):
reward_coins: int = Field(
ge=1,
le=FEEDBACK_REWARD_MAX_COINS,
description="每条采纳反馈发放的金币数",
)
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注(内部)")
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
class FeedbackBulkRejectRequest(FeedbackBulkRequest):
reason: str = Field(min_length=1, max_length=256, description="批量未采纳原因,用户端可见")
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
@field_validator("reason")
@classmethod
def _reason_not_blank(cls, value: str) -> str:
if not value.strip():
raise ValueError("未采纳原因不能为空")
return value.strip()
class FeedbackBulkItemResult(BaseModel):
id: int
ok: bool
status: str | None = None
error: str | None = None
class FeedbackBulkResult(BaseModel):
total: int
success: int
failed: int
items: list[FeedbackBulkItemResult]
class FeedbackSummary(BaseModel):
"""审核台顶部各状态计数(pending 含历史 new 态)。"""
+25
View File
@@ -0,0 +1,25 @@
"""admin 新手引导视频配置 schemas(开关 / 视频地址 / 前几次 / 每次金币)。"""
from __future__ import annotations
from pydantic import BaseModel, Field
from app.repositories.guide_video import MAX_PLAYS_LIMIT, REWARD_COIN_LIMIT
class GuideVideoConfigOut(BaseModel):
enabled: bool
video_url: str | None = None # 相对地址 /media/guide_video/xxx.mp4;未配片 = None
max_plays: int
reward_coin: 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)
+19
View File
@@ -0,0 +1,19 @@
"""admin 华为审核开关 schemas(两态:default / review)。"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel
class HuaweiReviewOut(BaseModel):
"""当前开关状态。updated_at 给后台展示「谁什么时候切的」提供时间锚点。"""
mode: Literal["default", "review"]
updated_at: str | None = None # ISO 字符串;从未切过为 None
class HuaweiReviewUpdate(BaseModel):
"""切换开关。整值覆盖,不做部分更新(就一个字段)。"""
mode: Literal["default", "review"]
+36
View File
@@ -56,6 +56,42 @@ class PriceReportRejectRequest(BaseModel):
return v.strip()
class PriceReportBulkRequest(BaseModel):
ids: list[int] = Field(min_length=1, max_length=50, description="待审核上报 ID 列表")
@field_validator("ids")
@classmethod
def _ids_must_be_unique(cls, ids: list[int]) -> list[int]:
if len(ids) != len(set(ids)):
raise ValueError("上报 ID 不能重复")
return ids
class PriceReportBulkRejectRequest(PriceReportBulkRequest):
reason: str = Field(min_length=1, max_length=256, description="批量拒绝理由,用户端记录页会看到")
@field_validator("reason")
@classmethod
def _reason_not_blank(cls, value: str) -> str:
if not value.strip():
raise ValueError("拒绝理由不能为空")
return value.strip()
class PriceReportBulkItemResult(BaseModel):
id: int
ok: bool
status: str | None = None
error: str | None = None
class PriceReportBulkResult(BaseModel):
total: int
success: int
failed: int
items: list[PriceReportBulkItemResult]
class PriceReportSummary(BaseModel):
"""审核台顶部各状态计数。"""
+94 -99
View File
@@ -3,6 +3,8 @@
路由前缀 `/api/v1/ad`:
GET /pangle-callback 穿山甲 S2S 发奖回调(** JWT,靠验签**),穿山甲服务器调
GET /reward-status 客户端查今日看广告发奖进度(Bearer)
GET /reward-result/{ad_session_id}
客户端按会话查本次广告实发金币(Bearer,只读,弹窗金额用)
发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币客户端只负责
看完后刷新余额,不参与发奖,被破解也刷不到钱
@@ -13,7 +15,7 @@ import json
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
from app.api.deps import CurrentUser, DbSession
from app.core import rewards
@@ -25,8 +27,8 @@ from app.repositories import ad_feed_reward as crud_feed
from app.repositories import ad_reward as crud_ad
from app.repositories import ad_watch as crud_watch
from app.repositories import app_config
from app.repositories import signin as crud_signin
from app.schemas.ad import (
AdRewardResultOut,
AdRewardStatusOut,
EcpmReportIn,
EcpmReportOut,
@@ -52,11 +54,14 @@ REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字
REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
REWARD_SCENE_REWARD_VIDEO = "reward_video"
REWARD_SCENE_SIGNIN_BOOST = "signin_boost"
# 提现看视频:看完才能提现的「硬门槛」广告,**不发金币**,只记一条幂等记录(收益由 eCPM 上报口径
# ad_type="withdrawal_video" 单独统计)。故意不放进 SUPPORTED_REWARD_SCENES——它不走发币分支。
REWARD_SCENE_WITHDRAWAL_AD = "withdrawal_ad"
SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO, REWARD_SCENE_SIGNIN_BOOST}
# 2026-07 下线 signin_boost(签到膨胀):它按固定 3000 金币发,与广告实际收益脱钩,产品确认
# 从来不是设计内的口径。签到弹窗里的「看广告膨胀」现在与福利页看视频走同一条 reward_video
# 路径(按 eCPM 公式发),奖励只剩「签到」+「看视频」两种。历史发币流水(coin_transaction
# .biz_type='signin_boost')保留不动——钱是真发过的,账必须留。
SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO}
def _parse_extra(raw_extra: str | None) -> dict[str, str]:
@@ -118,6 +123,11 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
extra.update(_parse_extra(params.get(extra_key)))
reward_scene = extra.get("reward_scene") or REWARD_SCENE_REWARD_VIDEO
ad_session_id = extra.get("ad_session_id")
# 「这条广告属于哪一轮膨胀」。纯标签:不参与发奖判定,只让 reward-result 能把同一轮求和成
# 弹窗要显示的累计值(见 crud_ad.round_coin_total)。老客户端不带 → NULL → 累计值返 null。
boost_round_id = (extra.get("boost_round_id") or None)
if boost_round_id is not None:
boost_round_id = boost_round_id[:64]
ecpm = params.get("ecpm")
# 环境隔离:激励视频 mediaExtra 里带「这次观看属于哪个后端环境」(srv_env=dev/prod,客户端按
@@ -169,50 +179,11 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
user_id, trans_id, reward_scene,
)
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
if reward_scene == REWARD_SCENE_SIGNIN_BOOST:
try:
boost, _balance = crud_signin.boost_today_signin(
db, user_id, ad_ref_id=trans_id, commit=False
)
except crud_signin.NotSignedTodayError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="not_signed",
)
except crud_signin.AlreadyBoostedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="already_boosted",
)
except crud_signin.LastCycleDayBoostBlockedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="last_day",
)
else:
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=boost.coin_awarded,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
commit=False,
)
db.commit()
db.refresh(rec)
else:
rec = crud_ad.grant_ad_reward(
db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id,
reward_scene=REWARD_SCENE_REWARD_VIDEO,
reward_name=params.get("reward_name"), raw=raw[:1024],
)
rec = crud_ad.grant_ad_reward(
db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id,
reward_scene=REWARD_SCENE_REWARD_VIDEO, boost_round_id=boost_round_id,
reward_name=params.get("reward_name"), raw=raw[:1024],
)
except crud_ad.UnknownUserError:
logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id)
return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER)
@@ -242,6 +213,46 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
)
@router.get(
"/reward-result/{ad_session_id}",
response_model=AdRewardResultOut,
summary="按 ad_session_id 查本次广告的权威发奖结果",
dependencies=[Depends(rate_limit(120, 60, "ad-reward-result"))],
)
def reward_result(
user: CurrentUser,
db: DbSession,
ad_session_id: str = Path(..., min_length=8, max_length=64, description="本次广告会话 id"),
) -> AdRewardResultOut:
"""客户端看完激励视频后轮询本接口拿**本次实发金币 + 本轮累计**用于弹窗,不再用余额差 /
coin_per_ad 估算(弹窗数值与真实金币对不上)
round_coin 恭喜累计获得奖励弹窗真正显示的数:本轮(= 客户端的 boost_round_id)所有
granted 记录之和由服务端求和而不是客户端自己累加客户端进程被杀/重建后本地累计会丢,
发奖记录不会取不到轮 id(pending / 老客户端 / extra 丢失)时为 null,客户端退回显示单条
S2S 回调异步:查不到记录 = 回调还没到 200 + status='pending' 让客户端继续重试,
**不返 404**(404 只表示路由不存在)纯只读:发奖仍只由验签过的 S2S 回调完成,
这里不写库不产生任何奖励,被刷也只是查自己的记录
"""
rec = crud_ad.find_by_session(db, user.id, ad_session_id)
if rec is None:
# 连记录都没有 → 不知道属于哪一轮,round_coin 一并为 null(不是 0,0 会被当成"本轮没赚到")
return AdRewardResultOut(
ad_session_id=ad_session_id, status="pending", coin=None, round_coin=None,
)
# 本条不是 granted 时**仍返本轮累计**(这条按 0 计):第 3 条撞每日上限那下,客户端的限额
# toast 要显示的是前两条已到账的总额,不是空。
round_coin = (
crud_ad.round_coin_total(db, user.id, rec.boost_round_id)
if rec.boost_round_id
else None
)
return AdRewardResultOut(
ad_session_id=ad_session_id, status=rec.status, coin=rec.coin, round_coin=round_coin,
)
@router.post(
"/watch-report",
response_model=WatchReportOut,
@@ -280,19 +291,31 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
Bearer 鉴权,user_id 取自 JWT(不信 body)best-effort:落库即 ok,客户端 fire-and-forget,
丢一两条不影响业务(穿山甲后台报表是结算权威)eCPM 与发奖(S2S)是两条独立流,不逐条关联
"""
attributed_trace_id = crud_ecpm.attributable_trace_id(
db,
feed_scene=payload.feed_scene,
trace_id=payload.trace_id,
exposure_ms=payload.exposure_ms,
)
if payload.trace_id and attributed_trace_id is None:
logger.info(
"detach late coupon ad impression from failed trace user_id=%d trace=%s session=%s",
user.id, payload.trace_id, payload.ad_session_id,
)
crud_ecpm.create_ecpm_record(
db, user.id,
ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
ad_session_id=payload.ad_session_id,
adn=payload.adn, slot_id=payload.slot_id,
feed_scene=payload.feed_scene,
trace_id=payload.trace_id,
trace_id=attributed_trace_id,
app_env=payload.app_env, our_code_id=payload.our_code_id,
exposure_ms=payload.exposure_ms,
)
logger.info(
"ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s",
"ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s exposure_ms=%s adn=%s slot=%s app=%s code=%s",
user.id, payload.ad_type, payload.feed_scene, payload.ad_session_id, payload.ecpm,
payload.adn, payload.slot_id, payload.app_env, payload.our_code_id,
payload.exposure_ms, payload.adn, payload.slot_id, payload.app_env, payload.our_code_id,
)
return EcpmReportOut(ok=True)
@@ -317,55 +340,27 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
if reward_scene not in SUPPORTED_REWARD_SCENES:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="bad reward_scene")
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限/今日膨胀一次)
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限)
trans_id = f"test-{user.id}-{uuid.uuid4().hex}"
if reward_scene == REWARD_SCENE_SIGNIN_BOOST:
try:
boost, _balance = crud_signin.boost_today_signin(
db, user.id, ad_ref_id=trans_id, commit=False
)
except crud_signin.NotSignedTodayError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
raw="client debug test-grant signin_boost", status="not_signed",
)
except crud_signin.AlreadyBoostedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
raw="client debug test-grant signin_boost", status="already_boosted",
)
except crud_signin.LastCycleDayBoostBlockedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
raw="client debug test-grant signin_boost", status="last_day",
)
else:
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=boost.coin_awarded,
reward_scene=reward_scene, reward_name="测试签到膨胀",
raw="client debug test-grant signin_boost", commit=False,
)
db.commit()
db.refresh(rec)
else:
# 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
ad_session_id = payload.ad_session_id if payload is not None else None
ecpm_val = "200"
if ad_session_id:
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id)
if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0:
ecpm_val = ecpm_rec.ecpm_raw
try:
rec = crud_ad.grant_ad_reward(
db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
)
except crud_ad.UnknownUserError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
# 正式链路的轮次 id 走 S2S 的 mediaExtra;本接口不经 S2S,只能由 body 补,否则 debug 包
# 的 reward-result 恒返 round_coin=null,「弹窗 40 → 60」那套累计验收在本地跑不起来。
boost_round_id = (payload.boost_round_id if payload is not None else None) or None
# 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
ad_session_id = payload.ad_session_id if payload is not None else None
ecpm_val = "200"
if ad_session_id:
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id)
if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0:
ecpm_val = ecpm_rec.ecpm_raw
try:
rec = crud_ad.grant_ad_reward(
db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
boost_round_id=boost_round_id,
reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
)
except crud_ad.UnknownUserError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
(used, limit, coin_per, round_count, cooldown_until,
_watched, _watch_limit) = crud_ad.today_status(db, user.id)
+47 -1
View File
@@ -20,6 +20,8 @@ from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.repositories import comparison as crud_compare
from app.schemas.compare_record import (
CompareStartReserveIn,
CompareStartReserveOut,
CompareStatsOut,
ComparisonRecordCreatedOut,
ComparisonRecordDetailOut,
@@ -35,6 +37,41 @@ logger = logging.getLogger("shagua.compare_record")
router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
@router.post(
"/start",
response_model=CompareStartReserveOut,
summary="预占一次当日比价发起次数(每人每天最多100次)",
)
def reserve_compare_start(
payload: CompareStartReserveIn,
user: CurrentUser,
db: DbSession,
) -> CompareStartReserveOut:
try:
_, used = crud_compare.reserve_daily_start(
db,
user_id=user.id,
trace_id=payload.trace_id,
business_type=payload.business_type,
device_id=payload.device_id,
)
except crud_compare.DailyCompareStartLimitExceeded:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="今日已比价超过100次,请明天再试",
) from None
except crud_compare.ComparisonTraceOwnershipError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="比价任务标识冲突,请重新发起",
) from None
return CompareStartReserveOut(
limit=crud_compare.DAILY_COMPARE_START_LIMIT,
used=used,
remaining=max(crud_compare.DAILY_COMPARE_START_LIMIT - used, 0),
)
@router.post(
"/record",
response_model=ComparisonRecordCreatedOut,
@@ -115,13 +152,22 @@ def list_records(
db: DbSession,
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
ordered: bool | None = Query(
None,
description="true=只看「已下单」(店名命中本人真实下单)的记录;不传=全部",
),
keyword: str | None = Query(
None,
max_length=64,
description="按店名 / 菜名模糊搜索,忽略大小写;空白串等同不传",
),
include_trace: bool = Query(
False,
description="客户端开了本机 agent 调试模式时带 true,放行本人记录的 trace_url",
),
) -> ComparisonRecordPage:
items, next_cursor = crud_compare.list_records(
db, user.id, limit=limit, cursor=cursor
db, user.id, limit=limit, cursor=cursor, ordered=ordered, keyword=keyword
)
outs = [ComparisonRecordOut.model_validate(it) for it in items]
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)。
+2 -2
View File
@@ -81,7 +81,7 @@ def _record_claims_blocking(
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
) -> None:
with SessionLocal() as db:
# 取本次 session 环境,给 coupon_claim_record 打 app_env 标(每券成功率表按它过滤;设计 §13)
# 取本次 session 环境,给每日资产和逐次事件同时打环境标
app_env = coupon_repo.session_app_env(db, trace_id)
coupon_repo.record_claims(db, device_id, user_id, trace_id, results, app_env=app_env)
# 顺带把本帧「成功平台」并入 coupon_session.platform_success(admin 领券数据 ②整单/③点位成功率;
@@ -176,7 +176,7 @@ async def coupon_step(
resp_json = resp.json()
# 领券结果沉淀(资产):中间帧 last_coupon_result + done 帧 coupon_results 幂等写库。
# 领券结果沉淀:每日资产 + 逐次事件;中间帧和 done 全量帧均幂等写库。
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
if device_id:
results = _extract_coupon_results(resp_json)
+93 -4
View File
@@ -1,19 +1,22 @@
"""设备注册 / 心跳 endpoint(无障碍保护存活检测)。
路由前缀 /api/v1/device, Bearer 鉴权(设备绑登录用户)
POST /register 注册设备 / 更新 registration_id(App 前台拿到 push token 时调)
POST /register 注册设备 / 更新厂商 push token(App 前台拿到 push token 时调)
POST /heartbeat 上报心跳(无障碍服务存活时周期调,刷新存活)
POST /push-test 开发验收:延迟发送厂商通道测试推送
后端 heartbeat_monitor_worker 据此发现心跳超时的设备并极光推送告警
后端 heartbeat_monitor_worker 据此发现心跳超时的设备并厂商直推告警
spec: spec/accessibility-liveness-push.md
"""
from __future__ import annotations
import logging
import time
from fastapi import APIRouter
from fastapi import APIRouter, BackgroundTasks, HTTPException, status
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 (
DeviceOut,
@@ -22,6 +25,8 @@ from app.schemas.device import (
LivenessAckRequest,
LivenessOut,
OkResponse,
PushTestOut,
PushTestRequest,
)
logger = logging.getLogger("shagua.device")
@@ -29,6 +34,37 @@ logger = logging.getLogger("shagua.device")
router = APIRouter(prefix="/api/v1/device", tags=["device"])
def _send_push_test_after_delay(
push_vendor: str,
push_token: str,
delay_seconds: int,
user_id: int,
device_id: str,
) -> None:
if delay_seconds > 0:
time.sleep(delay_seconds)
try:
vendor_push.send_accessibility_disabled(
push_vendor,
push_token,
title="测试推送",
alert="这是一条厂商通道测试推送。收到它说明 App 被划掉后仍可通过系统通知栏触达。",
)
logger.info(
"push test sent user_id=%d device_id=%s delay=%ds",
user_id,
device_id,
delay_seconds,
)
except vendor_push.VendorPushError as e:
logger.warning(
"push test failed user_id=%d device_id=%s error=%s",
user_id,
device_id,
e,
)
@router.post("/register", response_model=DeviceOut, summary="注册设备/更新推送token")
def register_device(
req: DeviceRegisterRequest,
@@ -40,13 +76,17 @@ def register_device(
user_id=user.id,
device_id=req.device_id,
registration_id=req.registration_id,
push_vendor=req.push_vendor,
push_token=req.push_token,
platform=req.platform,
app_version=req.app_version,
)
logger.info(
"device register user_id=%d device_id=%s reg=%s",
"device register user_id=%d device_id=%s vendor=%s token=%s legacy_reg=%s",
user.id,
req.device_id,
req.push_vendor,
bool(req.push_token),
bool(req.registration_id),
)
return DeviceOut.model_validate(device)
@@ -64,10 +104,59 @@ def report_heartbeat(
device_id=req.device_id,
accessibility_enabled=req.accessibility_enabled,
registration_id=req.registration_id,
push_vendor=req.push_vendor,
push_token=req.push_token,
)
return OkResponse()
@router.post("/push-test", response_model=PushTestOut, summary="延迟发送厂商通道测试推送")
def request_push_test(
req: PushTestRequest,
background_tasks: BackgroundTasks,
user: CurrentUser,
db: DbSession,
) -> PushTestOut:
"""开发验收用:App 内点一次,服务端延迟发厂商直推,验证离线通道。"""
push_vendor = req.push_vendor.strip() if req.push_vendor else None
push_token = req.push_token.strip() if req.push_token else None
if push_vendor and push_token:
device_repo.register_or_update(
db,
user_id=user.id,
device_id=req.device_id,
registration_id=req.registration_id,
push_vendor=push_vendor,
push_token=push_token,
)
else:
device = device_repo.get_device(db, user_id=user.id, device_id=req.device_id)
push_vendor = device.push_vendor if device is not None else None
push_token = device.push_token if device is not None else None
if not push_vendor or not push_token:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="push vendor token not ready",
)
background_tasks.add_task(
_send_push_test_after_delay,
push_vendor,
push_token,
req.delay_seconds,
user.id,
req.device_id,
)
logger.info(
"push test scheduled user_id=%d device_id=%s delay=%ds",
user.id,
req.device_id,
req.delay_seconds,
)
return PushTestOut(delay_seconds=req.delay_seconds, has_push_token=True)
@router.get("/liveness", response_model=LivenessOut, summary="查询本机掉线告警(后置检测)")
def get_liveness(
device_id: str,
+64
View File
@@ -0,0 +1,64 @@
"""新手引导视频(领券等候浮层前 N 次替代广告)。
路由前缀 `/api/v1/guide-video`(均需 Bearer):
POST /start 这次浮层放引导视频还是放广告?命中则**当场计次**并下发 play_token
POST /reward 播完 / 中途关闭都调, play_token 幂等发固定金币
发币额度以**服务端配置**为准(运营后台可改),客户端只报"播完/关闭",报不了金额,
所以被破解也刷不到超额金币;次数上限由 guide_video_play 行数(按账号)硬卡
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends
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 (
GuideVideoRewardIn,
GuideVideoRewardOut,
GuideVideoStartIn,
GuideVideoStartOut,
)
logger = logging.getLogger("shagua.guide_video")
router = APIRouter(prefix="/api/v1/guide-video", tags=["guide-video"])
@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")
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"],
)
return GuideVideoStartOut(**result)
@router.post(
"/reward",
response_model=GuideVideoRewardOut,
summary="引导视频发金币(播完/中途关闭都发,play_token 幂等)",
dependencies=[Depends(rate_limit(60, 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
)
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"],
)
return GuideVideoRewardOut(**result)
+154 -75
View File
@@ -5,11 +5,12 @@
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import ThreadPoolExecutor
from typing import TYPE_CHECKING, Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import nullslast, select
from sqlalchemy.orm import Session, aliased
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_db
@@ -25,8 +26,14 @@ from app.schemas.meituan import (
ReferralLinkResponse,
TopSalesRequest,
)
from app.utils import mt_search_cursor
from app.utils.meituan_city import get_meituan_city
if TYPE_CHECKING: # 仅供类型标注(本模块已开 from __future__ import annotations)
from collections.abc import Callable
from sqlalchemy import ColumnElement
logger = logging.getLogger("shagua.meituan")
@@ -109,6 +116,86 @@ def _commission_pct(card: CouponCard) -> float:
return 0.0
# ────────────── 离线库分页(智能推荐 / 销量最高 共用) ──────────────
# 去重+排序阶段**只投影这几列**:够 DISTINCT ON 分组、够排序、够回表定位,且全是定长小字段。
# ⚠️ 关键性能点:`raw` 是整条美团原始返回(JSONB,每行数 KB)。原实现用 select(MeituanCoupon)
# 做子查询,等于把整城几千行连 raw 一起塞进两次排序(DISTINCT ON 一次 + 分页一次),
# 体量轻松超过 work_mem → Postgres 落盘做外部归并排序,而且**每翻一页都要重来一遍**。
# 拆成「先在小列上排出本页 id,再按 id 回表取 raw」后,排序数据量降到原来的百分之几,
# JSONB 只解析当前页 ~20 行。
_DEDUP_COLS = (
MeituanCoupon.id,
MeituanCoupon.dedup_key,
MeituanCoupon.sale_volume_num,
MeituanCoupon.commission_percent,
)
def _paged_dedup_ids(
db: Session,
*,
conds: list[ColumnElement[bool]],
dedup_order: list[ColumnElement],
page_order: Callable[[Any], list[ColumnElement]],
page: int,
page_size: int,
) -> tuple[list[int], bool]:
"""DISTINCT ON(dedup_key) 跨源去重 → 整体排序 → 分页,返回 (本页 id 列表, 是否还有下一页)。
- `dedup_order`:同一个 dedup_key 的多条里留哪条(如销量最高/佣金最高)
- `page_order`:接收去重子查询的列集合(`sub.c`),返回去重后的整体排序
多取 1 条用于判断 has_next
"""
deduped = (
select(*_DEDUP_COLS)
.where(*conds)
.distinct(MeituanCoupon.dedup_key)
.order_by(MeituanCoupon.dedup_key, *dedup_order)
.subquery()
)
ids = db.execute(
select(deduped.c.id)
.order_by(*page_order(deduped.c))
.offset((page - 1) * page_size)
.limit(page_size + 1)
).scalars().all()
return list(ids[:page_size]), len(ids) > page_size
def _load_raws(db: Session, ids: list[int]) -> list[dict]:
"""按给定 id 顺序取 raw(只回表本页 ~20 行)。缺行(被 ETL 清掉)静默跳过。"""
if not ids:
return []
raw_by_id = {
row_id: raw
for row_id, raw in db.execute(
select(MeituanCoupon.id, MeituanCoupon.raw).where(MeituanCoupon.id.in_(ids))
).all()
}
return [raw_by_id[i] for i in ids if i in raw_by_id]
def _cards_from_raws(raws: list[dict], *, hide_distance: bool) -> list[CouponCard]:
"""raw → CouponCard;解析失败的单条跳过,不整页失败。
hide_distance:离线库里的距离是相对城市默认点算的,对用户无意义且误导 智能推荐 /
销量最高两个 tab 一律置空,前端距离 店名那行只剩店名自动顶到最左
"""
cards: list[CouponCard] = []
for raw in raws:
try:
card = CouponCard.from_raw(raw or {})
except Exception: # noqa: BLE001
continue
if not card.product_view_sign:
continue
if hide_distance:
card.distance_text = None
card.distance_meters = None
cards.append(card)
return cards
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近")
def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
lon, lat = req.longitude, req.latitude
@@ -133,17 +220,21 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
return [], True
# 距离最近:搜索召回(外卖搜"外卖" + 到店搜"美食",都 sortField=6 离我最近)一页页拉。
# 搜索翻页必须用 searchId(pageNo 翻不动),所以每个 feed 页顺序翻到第 N 页;两路并行、page 1 最快。
# 无状态、不改 APP(传页码即可);按你位置实时算距离(库里没存 POI 经纬度,只能实时)
# 搜索翻页必须用 searchId(pageNo 翻不动),而接口是无状态的(客户端只传页码)—— 原实现因此
# 每次都从第 1 页顺序重放到第 N 页,取第 N 页要向美团发 N 次请求,越往下滑越慢
# 现在把沿途 searchId 记进 [mt_search_cursor],稳态下每翻一页恒定 1 次请求;两路仍并行。
# 按你位置实时算距离(库里没存 POI 经纬度,只能实时)。
if tab == "distance":
lon_i, lat_i = int(lon * 1_000_000), int(lat * 1_000_000)
def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool, bool]:
"""顺序翻到第 n 页(搜索须 searchId 续页),返回(第 n 页 items, 是否还有下一页, 是否调用失败)。"""
sid: str | None = None
def _replay(
platform: int, biz_line: int | None, keyword: str,
key: mt_search_cursor.RouteKey, start: int, sid: str | None, n: int,
) -> tuple[list[dict], bool, bool]:
"""从第 start 页(用 sid 取)顺序翻到第 n 页。start==1 时 sid 应为 None(走 pageNo=1)。"""
data: list[dict] = []
has_next = False
for pg in range(1, n + 1):
for pg in range(start, n + 1):
body: dict = {
"platform": platform, "searchText": keyword, "sortField": 6,
"longitude": lon_i, "latitude": lat_i, "pageSize": 20,
@@ -161,10 +252,27 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
data = r.get("data") or []
sid = r.get("searchId")
has_next = bool(r.get("hasNext")) and bool(data)
# 记下「下一页要用哪个 searchId」;没有下一页就别记,免得存进死游标。
if sid and has_next:
mt_search_cursor.remember(key, pg + 1, sid)
if not data or (not has_next and pg < n):
return [], False, False # 没那么多页了(非错误)
return data, has_next, False
def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool, bool]:
"""取第 n 页,返回(第 n 页 items, 是否还有下一页, 是否调用失败)。
优先用缓存游标一发直达;缓存未命中/过期才从最近的已知页往后重放,并把沿途游标补进缓存
"""
key = mt_search_cursor.route_key(lat, lon, platform, keyword)
start, sid = mt_search_cursor.lookup(key, n)
data, has_next, failed = _replay(platform, biz_line, keyword, key, start, sid, n)
# 用缓存游标却打不通,多半是上游 searchId 过期:作废整条路线,回到第 1 页重放一次。
if failed and start > 1:
mt_search_cursor.drop(key)
data, has_next, failed = _replay(platform, biz_line, keyword, key, 1, None, n)
return data, has_next, failed
with ThreadPoolExecutor(max_workers=2) as pool:
f_wm = pool.submit(_search_page_n, 1, None, "外卖", req.page)
f_dd = pool.submit(_search_page_n, 2, 1, "美食", req.page)
@@ -194,39 +302,25 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
PAGE = 20
try:
base = select(MeituanCoupon).where(
MeituanCoupon.commission_percent >= 3.0,
MeituanCoupon.city_id == city_id,
)
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
MeituanCoupon.dedup_key,
MeituanCoupon.commission_percent.desc(),
).subquery()
m = aliased(MeituanCoupon, deduped)
start = (req.page - 1) * PAGE
rows = db.execute(
select(m)
ids, has_next = _paged_dedup_ids(
db,
conds=[
MeituanCoupon.commission_percent >= 3.0,
MeituanCoupon.city_id == city_id,
],
# 同一去重键留佣金最高那条
dedup_order=[MeituanCoupon.commission_percent.desc()],
# 销量高的优先(无销量档排后),同档佣金高优先,id 兜底稳定分页
.order_by(nullslast(m.sale_volume_num.desc()), m.commission_percent.desc(), m.id)
.offset(start)
.limit(PAGE + 1)
).scalars().all()
page_order=lambda c: [
nullslast(c.sale_volume_num.desc()), c.commission_percent.desc(), c.id,
],
page=req.page, page_size=PAGE,
)
raws = _load_raws(db, ids)
except Exception: # noqa: BLE001
logger.exception("[feed] rec 库查询失败,降级返空")
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
has_next = len(rows) > PAGE
cards: list[CouponCard] = []
for row in rows[:PAGE]:
try:
card = CouponCard.from_raw(row.raw or {})
except Exception: # noqa: BLE001
continue
if card.product_view_sign:
# 智能推荐不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。
# 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。
card.distance_text = None
card.distance_meters = None
cards.append(card)
cards = _cards_from_raws(raws, hide_distance=True)
if not cards and req.page == 1:
# 命中城市却 0 券:该城确无 ≥3% 券,或 ETL 灌的 city_id 与 city_dict 口径不一致。
logger.info("[feed] rec city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
@@ -282,51 +376,36 @@ def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponList
if not city_id:
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
# 去重 + 排序 + 分页全在 SQL 做,每页只并解析当前页 ~20 条。
# (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。)
# 去重 + 排序 + 分页全在 SQL 做,每页只回表并解析当前页 ~20 条(见 _paged_dedup_ids 的性能说明)
# 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。
conds = [
MeituanCoupon.sale_volume_num.isnot(None),
MeituanCoupon.city_id == city_id,
]
if req.platform is not None:
conds.append(MeituanCoupon.platform == req.platform)
try:
# 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
base = select(MeituanCoupon).where(
MeituanCoupon.sale_volume_num.isnot(None),
MeituanCoupon.city_id == city_id,
)
if req.platform is not None:
base = base.where(MeituanCoupon.platform == req.platform)
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
MeituanCoupon.dedup_key,
MeituanCoupon.sale_volume_num.desc(),
MeituanCoupon.commission_percent.desc(),
).subquery()
# 2) 对去重结果按销量降序分页;多取 1 条判断 has_next,只对本页做 from_raw
m = aliased(MeituanCoupon, deduped)
start = (req.page - 1) * req.page_size
rows = db.execute(
select(m)
ids, has_next = _paged_dedup_ids(
db,
conds=conds,
# 每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
dedup_order=[
MeituanCoupon.sale_volume_num.desc(),
MeituanCoupon.commission_percent.desc(),
],
# 加 id 作稳定 tiebreaker:同销量同佣金的并列项排序确定,避免跨页重复/漏项
.order_by(m.sale_volume_num.desc(), m.commission_percent.desc(), m.id)
.offset(start)
.limit(req.page_size + 1)
).scalars().all()
page_order=lambda c: [
c.sale_volume_num.desc(), c.commission_percent.desc(), c.id,
],
page=req.page, page_size=req.page_size,
)
raws = _load_raws(db, ids)
except Exception: # noqa: BLE001
logger.exception("[top-sales] 库查询失败,降级返空")
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
has_next = len(rows) > req.page_size
cards: list[CouponCard] = []
for row in rows[:req.page_size]:
try:
card = CouponCard.from_raw(row.raw or {})
except Exception: # noqa: BLE001
continue
if card.product_view_sign:
# 不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。
# 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。
# 逻辑与推荐流保持一致
card.distance_text = None
card.distance_meters = None
cards.append(card)
# 不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导),与推荐流口径一致。
cards = _cards_from_raws(raws, hide_distance=True)
if not cards and req.page == 1:
# 命中城市却 0 券:可能该城确无券,也可能 ETL 灌的 city_id 与 city_dict 口径不一致(静默降级的隐患)。
logger.info("[top-sales] city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
+124
View File
@@ -0,0 +1,124 @@
"""消息通知中心 endpoint(PRD《消息通知中心》)。
路由前缀 `/api/v1/notifications`, Bearer 鉴权(消息按用户隔离)
GET / 消息列表(分页;全列表时间倒序,不分组PRD 原文的分组已取消)
GET /unread-count 未读总数(首页铃铛角标)
POST /read 标记已读({ids:[...]} 单条/多条 {all:true} 全量清零)
数据落库 `notification` (repositories/notification.py,按用户隔离)业务事件(奖励过期
提现回执反馈回复) `create_notification` 下发;未接入业务前列表为空,可用
`/api/v1/push/test` createNotification 造联调数据
字段命名:本组接口对外为 **camelCase**(sentAt / isRead / pageSize,PRD 前端契约),
详见 schemas/notification.py 顶部说明
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Query
from app.api.deps import CurrentUser, DbSession
from app.core import notification_catalog as catalog
from app.models.notification import Notification
from app.repositories import notification as notif_repo
from app.schemas.notification import (
InfoRow,
MarkReadOut,
MarkReadRequest,
NotificationItem,
NotificationListOut,
UnreadCountOut,
)
logger = logging.getLogger("shagua.notifications")
router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"])
def _to_item(n: Notification) -> NotificationItem:
"""通知行 + 类型静态目录 → 接口出参。"""
ntype = catalog.get_type(n.type)
return NotificationItem(
id=n.id,
category=ntype.category,
category_label=catalog.category_label(ntype.category),
type=ntype.key,
card_style=ntype.card_style,
title=ntype.card_title,
coins=n.coins,
cash_cents=n.cash_cents,
cash_yuan=notif_repo.cash_yuan(n.cash_cents),
info_rows=[InfoRow(**row) for row in n.info_rows],
action_text=ntype.action_text,
extra=n.extra,
sent_at=notif_repo.as_cst(n.sent_at),
is_read=n.is_read,
)
@router.get("", response_model=NotificationListOut, summary="消息列表(分页)")
def list_notifications(
user: CurrentUser,
db: DbSession,
page: int = Query(default=1, ge=1, description="页码,1 起"),
page_size: int = Query(
default=20, ge=1, le=100, alias="pageSize", description="每页条数,默认 20,最大 100"
),
) -> NotificationListOut:
"""通知中心消息列表。
- 排序服务端已做好:**全列表按时间倒序**(最新在前,不做分类分组;PRD §1
"按分类分组"为笔误,已与需求方确认取消),前端按返回顺序渲染即可
- 每条的字段构成与各版式说明见 NotificationItem schema
- 响应同时带 unreadCount,进页面时可顺手刷新角标
- 无消息时返回空列表(total=0);数据由业务事件下发,联调可用 /push/test
"""
items, total, unread = notif_repo.list_notifications(
db, user.id, page=page, page_size=page_size
)
return NotificationListOut(
items=[_to_item(n) for n in items],
page=page,
page_size=page_size,
total=total,
has_more=page * page_size < total,
unread_count=unread,
)
@router.get("/unread-count", response_model=UnreadCountOut, summary="未读总数(铃铛角标)")
def get_unread_count(user: CurrentUser, db: DbSession) -> UnreadCountOut:
"""首页铃铛角标数据源。刷新时机(PRD §4):进入首页时、从通知中心/其他页面返回首页时。
- count:精确未读条数;
- badgeText:直接可展示的角标文案超过 99 返回 "99+",等于 0 返回 null(隐藏整个角标)
"""
count = notif_repo.unread_count(db, user.id)
badge = None if count == 0 else ("99+" if count > 99 else str(count))
return UnreadCountOut(count=count, badge_text=badge)
@router.post("/read", response_model=MarkReadOut, summary="标记已读(单条/多条/全量)")
def mark_read(req: MarkReadRequest, user: CurrentUser, db: DbSession) -> MarkReadOut:
"""红点消除(PRD §4),两种调用模式:
1. `{"ids": [90001]}` 点击某张消息卡片(无论点击后是跳转/弹窗/无动作都算已读);
用户点击 push 直达落地页时,客户端也用它把对应站内消息同步置读(push extras 里带
notificationId);
2. `{"all": true}` 进入通知中心自动清零(只是浏览列表就消红点,无需逐条点击)
幂等:不存在或已读的 id 忽略;重复调用 markedCount 0不报错
响应带 unreadCount(处理后剩余未读),可直接刷新铃铛角标
"""
if not req.all and not req.ids:
raise HTTPException(status_code=400, detail="ids 与 all 至少传一个:{ids:[...]} 或 {all:true}")
marked, unread = notif_repo.mark_read(db, user.id, ids=req.ids, mark_all=req.all)
logger.info(
"notifications read user_id=%d mode=%s marked=%d unread_left=%d",
user.id,
"all" if req.all else f"ids×{len(req.ids or [])}",
marked,
unread,
)
return MarkReadOut(ok=True, marked_count=marked, unread_count=unread)
+10
View File
@@ -20,6 +20,7 @@ from app.schemas.platform import (
AdConfigPublicOut,
AppFlagsOut,
AppVersionOut,
HuaweiReviewOut,
PlatformStatsOut,
SavingsFeedItem,
SavingsFeedOut,
@@ -72,6 +73,15 @@ def ad_config(db: DbSession) -> AdConfigPublicOut:
)
@router.get("/huawei-review", response_model=HuaweiReviewOut, summary="华为审核开关(不鉴权)")
def huawei_review(db: DbSession) -> HuaweiReviewOut:
"""客户端进新手引导前拉一次,决定「快速设置」权限步左上角要不要给退出按钮。
不鉴权:引导页在登录之前就展示,此时必然没有 token空库回退 default(=不给退出按钮,维持现状)
只有华为 ROM 客户端会来拉(荣耀 MagicOS 不拉),故这里不做机型判断,由客户端自己 gate"""
mode = app_config.get_huawei_review(db)["mode"]
return HuaweiReviewOut(mode=mode, onboarding_closable=(mode == "review"))
@router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)")
def app_version(db: DbSession) -> AppVersionOut:
"""客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。
+187
View File
@@ -0,0 +1,187 @@
"""厂商推送 测试/联调 endpoint。
路由前缀 `/api/v1/push`, Bearer 鉴权围绕消息中心 13 类通知的厂商直推提供三件套:
GET /vendors 5 个厂商(荣耀/华为/小米/OPPO/vivo)服务端凭据配置状态,缺哪些键一目了然
GET /templates 13 种通知类型的 push 标题/正文模板 + PRD 示例渲染效果
POST /test 测试发送:默认 mock(不真调厂商 API,回显渲染结果);mock=false 真发到手机
`/api/v1/device/push-test`(无障碍召回通道的延迟自测)互补:本组面向消息中心 13
push 的文案/参数/厂商通道联调真实业务触发统一走 services/notification_events
(提现回执/反馈审核/爆料通过/好友下单已接入),底层与本测试端点同一条
integrations.vendor_push.send_notification 发送链路
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.core import notification_catalog as catalog
from app.integrations import vendor_push
from app.repositories import device as device_repo
from app.repositories import notification as notif_repo
from app.schemas.push import (
PushTemplateOut,
PushTemplatesOut,
PushTestOut,
PushTestRequest,
PushVendorsOut,
PushVendorStatus,
)
logger = logging.getLogger("shagua.push")
router = APIRouter(prefix="/api/v1/push", tags=["push"])
# /vendors 的展示顺序(荣耀/华为/小米/OPPO/vivo)
_VENDOR_ORDER = ("honor", "huawei", "xiaomi", "oppo", "vivo")
_GENERIC_TEST_TITLE = "傻瓜比价测试推送"
_GENERIC_TEST_BODY = "这是一条{label}通道的测试推送,收到说明服务端 → {label}厂商通道已打通。"
@router.get("/vendors", response_model=PushVendorsOut, summary="厂商推送配置状态")
def vendor_status(user: CurrentUser) -> PushVendorsOut:
"""检查 5 个厂商的服务端推送凭据是否配齐(读 .env,不打厂商接口)。
missingKeys 列出的即还需要在 .env 里补的配置键;全空说明该厂商随时可真发
mock 测试(POST /test 默认模式)不依赖任何凭据
"""
return PushVendorsOut(
vendors=[
PushVendorStatus(
vendor=v,
label=vendor_push.VENDOR_LABELS[v],
configured=not vendor_push.missing_settings(v),
missing_keys=vendor_push.missing_settings(v),
)
for v in _VENDOR_ORDER
]
)
@router.get("/templates", response_model=PushTemplatesOut, summary="13 类通知的 push 模板预览")
def push_templates(user: CurrentUser) -> PushTemplatesOut:
"""PRD §5 的 13 条 push 文案模板 + 用示例值渲染后的效果,联调对文案用。
标题固定(11 字不带变量);正文里 {var} 为变量,POST /test vars 字段可覆盖
"""
templates: list[PushTemplateOut] = []
for key, ntype in catalog.TYPES.items():
title, body_sample = catalog.render_push(key)
templates.append(
PushTemplateOut(
type=key,
category=ntype.category,
category_label=catalog.category_label(ntype.category),
card_style=ntype.card_style,
push_title=title,
push_body_sample=body_sample,
push_body_template=ntype.push_body_template,
variables=catalog.push_variable_names(key),
sample_vars=ntype.sample_vars,
)
)
return PushTemplatesOut(templates=templates)
@router.post("/test", response_model=PushTestOut, summary="测试发送厂商推送(默认 mock)")
def send_test_push(req: PushTestRequest, user: CurrentUser, db: DbSession) -> PushTestOut:
"""向指定厂商 token(或本用户已注册设备)发一条测试 push。
- **mock=true(默认)**:不真调厂商 API校验参数渲染文案后原样返回,并在
missingKeys 里提示真发前还缺哪些配置虚拟数据阶段随便打,不会骚扰真机
- **mock=false**:真发要求该厂商凭据已配置(缺则 400 报缺失键);厂商 API 报错回 502
注意 vivo 未上架前是测试推送模式(VIVO_PUSH_MODE=1),目标手机要先在 vivo 后台加为测试设备
- **createNotification=true**:同时往消息中心(notification )插一条同类型未读通知并把
notificationId 放进 push extras 客户端点击 push 后调 POST /notifications/read
{ids:[notificationId]} 即可闭环验证 PRD §4 push 已读联动
"""
# ---- 1. 解析推送目标(vendor + token):直填优先,缺则按 deviceId 反查已注册设备 ----
vendor_raw = req.vendor.strip()
push_token = req.push_token.strip()
if (not vendor_raw or not push_token) and req.device_id.strip():
device = device_repo.get_device(db, user_id=user.id, device_id=req.device_id.strip())
if device is not None:
vendor_raw = vendor_raw or (device.push_vendor or "")
push_token = push_token or (device.push_token or "")
vendor = vendor_push.normalize_vendor(vendor_raw)
if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"vendor 无效或无法从设备推断,支持: {', '.join(_VENDOR_ORDER)}",
)
if not push_token:
# mock 模式给个占位 token,让「只想看看渲染结果」的调用免造数据;真发必须给真 token。
if req.mock:
push_token = "mock-token"
else:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="push token 未知:请直传 pushToken,或先用该设备调 /api/v1/device/register 上报",
)
# ---- 2. 组装文案与 extras:直填 > type 模板 > 通用测试文案 ----
extras: dict[str, str] = {}
notification_id: int | None = None
if req.type:
try:
title, body = catalog.render_push(req.type, req.vars or None)
except catalog.UnknownNotificationType as e:
raise HTTPException(status_code=400, detail=str(e)) from e
extras["type"] = req.type
if req.create_notification:
item = notif_repo.insert_sample(db, user.id, req.type)
notification_id = item.id
extras.update({str(k): str(v) for k, v in item.extra.items()})
extras["notificationId"] = str(item.id)
else:
label = vendor_push.VENDOR_LABELS[vendor]
title = _GENERIC_TEST_TITLE
body = _GENERIC_TEST_BODY.format(label=label)
extras["type"] = "push_test"
if req.title.strip():
title = req.title.strip()
if req.content.strip():
body = req.content.strip()
# ---- 3. 发送(mock / 真发) ----
missing = vendor_push.missing_settings(vendor)
vendor_response = None
if req.mock:
vendor_push.send_notification(
vendor, push_token, title=title, body=body, extras=extras, mock=True
)
else:
if missing:
raise HTTPException(
status_code=400,
detail=f"{vendor_push.VENDOR_LABELS[vendor]}推送凭据未配置,先在 .env 补上: "
f"{', '.join(missing)}",
)
try:
vendor_response = vendor_push.send_notification(
vendor, push_token, title=title, body=body, extras=extras
)
except vendor_push.VendorPushError as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"厂商推送失败: {e}"
) from e
logger.info(
"push test user_id=%d vendor=%s type=%s mock=%s notification_id=%s",
user.id, vendor, req.type or "generic", req.mock, notification_id,
)
return PushTestOut(
ok=True,
mock=req.mock,
vendor=vendor,
title=title,
body=body,
extras=extras,
notification_id=notification_id,
missing_keys=missing,
vendor_response=vendor_response,
)
+6 -41
View File
@@ -1,9 +1,12 @@
"""签到 endpoint。
路由前缀 `/api/v1/signin`:
GET /status 今日签到状态 + 14 天档位
GET /status 今日签到状态 + 7 天档位
POST / 执行今日签到
POST /boost 签到后看广告膨胀金币
2026-07 下线 `POST /boost`(签到膨胀):它按固定 3000 金币补发与广告实际收益脱钩
签到弹窗里的看广告膨胀改与福利页看视频走同一条 reward_video 路径( eCPM ,
`/ad/pangle-callback` `/ad/reward-result` 取金额),奖励只剩签到+看视频两种
"""
from __future__ import annotations
@@ -12,15 +15,8 @@ import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.repositories import ad_reward as crud_ad
from app.repositories import signin as crud_signin
from app.repositories import wallet as crud_wallet
from app.schemas.welfare import (
SigninBoostRequest,
SigninBoostResultOut,
SigninResultOut,
SigninStatusOut,
)
from app.schemas.welfare import SigninResultOut, SigninStatusOut
logger = logging.getLogger("shagua.signin")
@@ -50,34 +46,3 @@ def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut:
streak=record.streak,
coin_balance=balance,
)
@router.post("/boost", response_model=SigninBoostResultOut, summary="签到后看广告膨胀金币")
def boost_signin(
payload: SigninBoostRequest, user: CurrentUser, db: DbSession
) -> SigninBoostResultOut:
if not payload.ad_ref_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward required")
ad_rec = crud_ad.find_by_trans(db, payload.ad_ref_id)
if (
ad_rec is None
or ad_rec.user_id != user.id
or ad_rec.reward_scene != "signin_boost"
or ad_rec.status != "granted"
):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward not verified")
record = crud_signin.boost_by_ad_ref(db, user.id, payload.ad_ref_id)
if record is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="signin boost not granted")
acc = crud_wallet.get_or_create_account(db, user.id)
balance = acc.coin_balance
logger.info(
"signin boost ok user_id=%d date=%s coin=%d",
user.id, record.signin_date, record.coin_awarded,
)
return SigninBoostResultOut(
coin_awarded=record.coin_awarded,
coin_balance=balance,
signin_date=record.signin_date.isoformat(),
)
+3 -7
View File
@@ -192,7 +192,7 @@ def withdraw_info(
wechat_bound=bool(u and u.wechat_openid),
wechat_nickname=u.wechat_nickname if u else None,
wechat_avatar_url=u.wechat_avatar_url if u else None,
transfer_auth_enabled=bool(auth and auth.state == "active"),
transfer_auth_enabled=bool(auth and auth.state == "active" and auth.authorization_id),
tiers=[WithdrawTierOut(**t) for t in crud_wallet.withdraw_tier_states(db, user.id, source)],
)
@@ -222,11 +222,6 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
) from e
except crud_wallet.WechatNotBoundError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先绑定微信") from e
except crud_wallet.WithdrawTooFrequentError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="已有提现申请正在审核或打款中,请处理完成后再申请",
) from e
except crud_wallet.WithdrawTierUnavailableError as e:
# 福利页档位闸(7-9):次数满/已选其他额度。正常客户端已按 tiers 预拦,此处兜底防绕过。
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="今日额度已达上限") from e
@@ -335,7 +330,8 @@ def open_transfer_auth(user: CurrentUser, db: DbSession) -> TransferAuthResultOu
def transfer_auth_status(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut:
auth = crud_wallet.sync_transfer_auth(db, user.id)
state = auth.state if auth else "none"
return TransferAuthStatusOut(state=state, enabled=(state == "active"))
enabled = bool(auth and auth.state == "active" and auth.authorization_id)
return TransferAuthStatusOut(state=state, enabled=enabled)
@router.post(
+63 -1
View File
@@ -71,7 +71,59 @@ class Settings(BaseSettings):
JG_VERIFY_ENDPOINT: str = "https://api.verification.jpush.cn/v1/web/loginTokenVerify"
JG_REQUEST_TIMEOUT_SEC: int = 15
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
# ===== 厂商直推(无障碍保护存活告警)=====
ANDROID_PACKAGE_NAME: str = "com.jishisongfu.shaguabijia"
PUSH_REQUEST_TIMEOUT_SEC: int = 15
PUSH_TIME_TO_LIVE_SEC: int = 86400
HONOR_PUSH_APP_ID: str = ""
HONOR_PUSH_CLIENT_ID: str = ""
HONOR_PUSH_CLIENT_SECRET: str = ""
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"
)
# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,取 AppId + AppSecret
# (OAuth 换 token 时 client_id 即 AppId)。发送走 v1 messages:send,成功码 80000000。
HUAWEI_PUSH_APP_ID: str = ""
HUAWEI_PUSH_APP_SECRET: str = ""
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"
)
VIVO_PUSH_APP_ID: str = ""
VIVO_PUSH_APP_KEY: str = ""
VIVO_PUSH_APP_SECRET: str = ""
VIVO_PUSH_AUTH_ENDPOINT: str = "https://api-push.vivo.com.cn/message/auth"
VIVO_PUSH_SEND_ENDPOINT: str = "https://api-push.vivo.com.cn/message/send"
VIVO_PUSH_MODE: int = 1 # 0=正式推送,1=测试推送(未上架 vivo 时用)
VIVO_PUSH_NOTIFY_TYPE: int = 4 # 1=无,2=响铃,3=振动,4=响铃+振动
VIVO_PUSH_CATEGORY: str = "DEVICE_REMINDER"
XIAOMI_PUSH_APP_SECRET: str = ""
XIAOMI_PUSH_SEND_ENDPOINT: str = "https://api.xmpush.xiaomi.com/v3/message/regid"
XIAOMI_PUSH_CHANNEL_ID: str = ""
XIAOMI_PUSH_TEMPLATE_ID: str = ""
XIAOMI_PUSH_TEMPLATE_TITLE: str = ""
XIAOMI_PUSH_TEMPLATE_DESCRIPTION: str = ""
XIAOMI_PUSH_TEMPLATE_PARAM_JSON: str = ""
OPPO_PUSH_APP_KEY: str = ""
OPPO_PUSH_MASTER_SECRET: str = ""
OPPO_PUSH_AUTH_ENDPOINT: str = "https://api.push.oppomobile.com/server/v1/auth"
OPPO_PUSH_SEND_ENDPOINT: str = (
"https://api.push.oppomobile.com/server/v1/message/notification/unicast"
)
# OPPO 新消息分类(2024-11-20 后创建的应用必须携带,否则可能被拒/限):
# channel_id=通知栏通道(OPPO 后台「通道ID」),category=消息分类 code(如 MARKETING 内容营销)。
# notify_level=提醒方式(0=不传走 OPPO 默认;内容营销类仅支持 1 通知栏/2 通知栏+锁屏)。
OPPO_PUSH_CHANNEL_ID: str = ""
OPPO_PUSH_CATEGORY: str = ""
OPPO_PUSH_NOTIFY_LEVEL: int = 0
# 无障碍保护存活监控后台任务(推送 + pull 后置兜底)
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
@@ -136,6 +188,13 @@ class Settings(BaseSettings):
"""京东联盟订单查询凭证齐全。"""
return bool(self.JD_UNION_APP_KEY and self.JD_UNION_APP_SECRET)
# 美团 + 京东 CPS 订单自动对账:进程内 worker 每天北京时间 05:00 后跑一轮。
# 按更新时间回拉近 N 天(重叠窗口防漏单并刷新状态),order_id 幂等更新;手动接口不受影响。
CPS_AUTO_RECONCILE_ENABLED: bool = True
CPS_AUTO_RECONCILE_RUN_HOUR: int = 5
CPS_AUTO_RECONCILE_LOOKBACK_DAYS: int = 3
CPS_AUTO_RECONCILE_CHECK_INTERVAL_SEC: int = 60
# ===== 微信服务号(网页授权) =====
# CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
@@ -325,6 +384,9 @@ class Settings(BaseSettings):
MEDIA_ROOT: str = "./data/media"
MEDIA_URL_PREFIX: str = "/media"
AVATAR_MAX_BYTES: int = 5 * 1024 * 1024 # 头像最大 5MB
# 运营后台上传的新手引导视频上限。视频比图片大一个量级,单独一档;
# ⚠️ 改大时同步放宽网关 client_max_body_size(实测 QA 4MiB / prod 32MiB),否则 nginx 先挡下。
GUIDE_VIDEO_MAX_BYTES: int = 100 * 1024 * 1024 # 引导视频最大 100MB
# ===== 邀请好友 =====
# 分享落地页(二维码 / 分享链接指向这里;扫码 → 落地页 → 引导浏览器下载 APK)。
-5
View File
@@ -66,11 +66,6 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
"group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。",
},
"signin_boost_coin": {
"default": r.SIGNIN_BOOST_COIN, "label": "签到膨胀固定金币",
"group": "签到", "type": "int",
"help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。",
},
"comparing_ad_enabled": {
"default": True, "label": "比价/领券期信息流广告",
"group": "看广告", "type": "bool", "hidden": True,
+428
View File
@@ -0,0 +1,428 @@
"""美团、京东 CPS 订单每日自动对账任务。
每天北京时间 `CPS_AUTO_RECONCILE_RUN_HOUR`默认 05:00后执行一次按更新时间
回拉最近若干天订单并复用 admin CPS 仓储层的幂等 upsert手动对账接口保持独立不受影响
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import socket
import time
from collections.abc import Callable, Iterator
from datetime import date, datetime, timedelta
from pathlib import Path
from uuid import uuid4
from app.admin.repositories import cps as cps_repo
from app.core.config import settings
from app.core.rewards import CN_TZ
from app.db.session import SessionLocal, engine
logger = logging.getLogger("shagua.cps_reconcile")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "cps_reconcile.lock"
def _cn_now() -> datetime:
return datetime.now(CN_TZ)
def _new_run_id(now: datetime) -> str:
return f"{now:%Y%m%d-%H%M%S}-{uuid4().hex[:8]}"
def _next_run_at(now: datetime, run_hour: int) -> datetime:
scheduled = now.replace(hour=run_hour, minute=0, second=0, microsecond=0)
return scheduled if now < scheduled else scheduled + timedelta(days=1)
def _trigger_for_run(worker_started_at: datetime, now: datetime, run_hour: int) -> str:
if worker_started_at.date() == now.date() and worker_started_at.hour >= run_hour:
return "startup_catchup"
return "scheduled"
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]:
"""同机多进程保护:同一时间只允许一个 CPS 自动对账 worker 运行。"""
_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()
def _empty_result() -> dict:
return {
"fetched": 0,
"inserted": 0,
"updated": 0,
"pages": 0,
"api_requests": 0,
}
def _platform_log_fields(platform: str, result: dict) -> dict:
fields = {
"platform": platform,
"platform_status": result["status"],
"duration_ms": result["duration_ms"],
}
for key in ("fetched", "inserted", "updated", "pages", "api_requests", "windows"):
if key in result:
fields[key] = result[key]
return fields
def _run_platform(
*,
platform: str,
query_time_type: int,
common: dict,
reconcile: Callable[[], dict],
) -> tuple[dict, str | None]:
started = time.perf_counter()
logger.info(
"CPS auto reconcile platform started run_id=%s platform=%s",
common["run_id"],
platform,
extra={
**common,
"event": "cps_reconcile.platform_started",
"platform": platform,
"query_time_type": query_time_type,
},
)
try:
raw_result = reconcile()
except Exception as exc: # noqa: BLE001 - 单平台失败不能阻断另一平台
result = {
"status": "failed",
"duration_ms": round((time.perf_counter() - started) * 1000, 3),
"error_type": type(exc).__name__,
"error_summary": str(exc)[:500],
}
logger.exception(
"CPS auto reconcile platform failed run_id=%s platform=%s error_type=%s",
common["run_id"],
platform,
type(exc).__name__,
extra={
**common,
"event": "cps_reconcile.platform_failed",
**_platform_log_fields(platform, result),
"error_type": type(exc).__name__,
"error_summary": str(exc)[:500],
},
)
return result, str(exc)
result = {
**raw_result,
"status": "success",
"duration_ms": round((time.perf_counter() - started) * 1000, 3),
}
logger.info(
"CPS auto reconcile platform completed run_id=%s platform=%s fetched=%s inserted=%s updated=%s",
common["run_id"],
platform,
result.get("fetched", 0),
result.get("inserted", 0),
result.get("updated", 0),
extra={
**common,
"event": "cps_reconcile.platform_completed",
**_platform_log_fields(platform, result),
},
)
return result, None
def _skip_platform(platform: str, common: dict) -> dict:
result = {
**_empty_result(),
"status": "skipped",
"skipped": "not_configured",
"duration_ms": 0.0,
}
logger.warning(
"CPS auto reconcile platform skipped run_id=%s platform=%s reason=not_configured",
common["run_id"],
platform,
extra={
**common,
"event": "cps_reconcile.platform_skipped",
**_platform_log_fields(platform, result),
"skip_reason": "not_configured",
},
)
return result
def _reconcile_once(
now: datetime | None = None,
*,
run_id: str | None = None,
trigger: str = "scheduled",
) -> dict:
"""独立拉取美团和京东;单平台异常只记日志,不影响另一平台。"""
end = (now or _cn_now()).astimezone(CN_TZ)
run_id = run_id or _new_run_id(end)
lookback_days = max(1, int(settings.CPS_AUTO_RECONCILE_LOOKBACK_DAYS))
start = end - timedelta(days=lookback_days)
task_started = time.perf_counter()
common = {
"run_id": run_id,
"trigger": trigger,
"scheduled_date": end.date().isoformat(),
"app_env": settings.APP_ENV,
"db_dialect": engine.dialect.name,
"hostname": socket.gethostname(),
"pid": os.getpid(),
"window_start": start.isoformat(),
"window_end": end.isoformat(),
"lookback_days": lookback_days,
}
result = {
"run_id": run_id,
"trigger": trigger,
"window_start": start.isoformat(),
"window_end": end.isoformat(),
"meituan": None,
"jd": None,
"errors": {},
}
logger.info(
"CPS auto reconcile started run_id=%s trigger=%s window=%s..%s",
run_id,
trigger,
result["window_start"],
result["window_end"],
extra={**common, "event": "cps_reconcile.started"},
)
if settings.mt_cps_configured:
def reconcile_meituan() -> dict:
with SessionLocal() as db:
return cps_repo.reconcile_orders(
db,
start_time=int(start.timestamp()),
end_time=int(end.timestamp()),
query_time_type=2,
audit_context=common,
)
result["meituan"], error = _run_platform(
platform="meituan",
query_time_type=2,
common=common,
reconcile=reconcile_meituan,
)
if error is not None:
result["errors"]["meituan"] = error
else:
result["meituan"] = _skip_platform("meituan", common)
_touch_lock()
if settings.jd_union_configured:
def reconcile_jd() -> dict:
with SessionLocal() as db:
return cps_repo.reconcile_jd_orders(
db,
start_time=start,
end_time=end,
query_time_type=3,
audit_context=common,
)
result["jd"], error = _run_platform(
platform="jd",
query_time_type=3,
common=common,
reconcile=reconcile_jd,
)
if error is not None:
result["errors"]["jd"] = error
else:
result["jd"] = _skip_platform("jd", common)
successes = sum(
platform_result.get("status") == "success"
for platform_result in (result["meituan"], result["jd"])
)
if result["errors"]:
task_status = "partial_success" if successes else "failed"
else:
task_status = "success"
completed_at = _cn_now()
duration_ms = round((time.perf_counter() - task_started) * 1000, 3)
next_run_at = _next_run_at(
completed_at,
min(max(int(settings.CPS_AUTO_RECONCILE_RUN_HOUR), 0), 23),
).isoformat()
result.update({
"status": task_status,
"duration_ms": duration_ms,
"manual_retry_required": bool(result["errors"]),
"next_run_at": next_run_at,
})
completion_fields = {
**common,
"event": "cps_reconcile.completed",
"task_status": task_status,
"duration_ms": duration_ms,
"manual_retry_required": result["manual_retry_required"],
"failed_platforms": sorted(result["errors"]),
"next_run_at": next_run_at,
"meituan_result": result["meituan"],
"jd_result": result["jd"],
}
log_method = logger.info if task_status == "success" else logger.warning
log_method(
"CPS auto reconcile completed run_id=%s status=%s duration_ms=%s failed_platforms=%s next_run_at=%s",
run_id,
task_status,
duration_ms,
",".join(sorted(result["errors"])) or "-",
next_run_at,
extra=completion_fields,
)
return result
def _should_run(last_run: date | None, now: datetime, run_hour: int) -> bool:
return last_run != now.date() and now.hour >= run_hour
async def _run_loop() -> None:
interval = max(30, int(settings.CPS_AUTO_RECONCILE_CHECK_INTERVAL_SEC))
run_hour = min(max(int(settings.CPS_AUTO_RECONCILE_RUN_HOUR), 0), 23)
lock_stale_after = max(interval * 3, 1800)
with _single_instance_lock(lock_stale_after) as lock_acquired:
if not lock_acquired:
logger.warning(
"CPS auto reconcile worker skipped: another worker owns lock",
extra={
"event": "cps_reconcile.worker_skipped",
"skip_reason": "lock_not_acquired",
"pid": os.getpid(),
},
)
return
await _run_locked_loop(interval, run_hour)
async def _run_locked_loop(interval: int, run_hour: int) -> None:
worker_started_at = _cn_now()
logger.info(
"CPS auto reconcile worker started run_hour=%s interval=%ss lookback_days=%s",
run_hour,
interval,
settings.CPS_AUTO_RECONCILE_LOOKBACK_DAYS,
extra={
"event": "cps_reconcile.worker_started",
"pid": os.getpid(),
"hostname": socket.gethostname(),
"app_env": settings.APP_ENV,
"db_dialect": engine.dialect.name,
"run_hour": run_hour,
"interval_sec": interval,
"lookback_days": settings.CPS_AUTO_RECONCILE_LOOKBACK_DAYS,
"next_run_at": _next_run_at(worker_started_at, run_hour).isoformat(),
},
)
last_run: date | None = None
try:
while True:
run_id: str | None = None
try:
_touch_lock()
now = _cn_now()
if _should_run(last_run, now, run_hour):
run_id = _new_run_id(now)
trigger = _trigger_for_run(worker_started_at, now, run_hour)
await asyncio.to_thread(
_reconcile_once,
now,
run_id=run_id,
trigger=trigger,
)
last_run = now.date()
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
logger.exception(
"CPS auto reconcile unexpected worker error",
extra={
"event": "cps_reconcile.unexpected_failed",
"run_id": run_id or "unassigned",
"pid": os.getpid(),
},
)
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info(
"CPS auto reconcile worker stopped",
extra={"event": "cps_reconcile.worker_stopped", "pid": os.getpid()},
)
raise
def start_cps_reconcile_worker() -> asyncio.Task | None:
if not settings.CPS_AUTO_RECONCILE_ENABLED:
logger.info(
"CPS auto reconcile disabled",
extra={
"event": "cps_reconcile.worker_skipped",
"skip_reason": "disabled",
},
)
return None
if not settings.mt_cps_configured and not settings.jd_union_configured:
logger.warning(
"CPS auto reconcile not started: Meituan and JD credentials are missing",
extra={
"event": "cps_reconcile.worker_skipped",
"skip_reason": "all_platform_credentials_missing",
},
)
return None
return asyncio.create_task(_run_loop(), name="cps-auto-reconcile")
async def stop_cps_reconcile_worker(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
+42 -7
View File
@@ -1,7 +1,7 @@
"""无障碍保护存活监控后台任务。
周期扫描曾经保护过当前 alive心跳超时的设备 = App 被彻底杀掉/无障碍已停(心跳断了),
**命中即在服务器终端打印告警**(本期先不接推送,工程量大,用终端打印代替真实通知);并把状态机
**命中即在服务器终端打印告警并尝试厂商直推**;并把状态机
推进到 notified 防每轮重复打印(心跳恢复时由 repositories.device.touch_heartbeat 重置回 alive)
结构仿 withdraw_reconcile_worker(单实例锁 + asyncio 轮询 + 优雅退出)
@@ -22,6 +22,7 @@ from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
from app.db.session import SessionLocal
from app.integrations import vendor_push
from app.repositories import device as device_repo
logger = logging.getLogger("shagua.heartbeat_monitor")
@@ -71,32 +72,66 @@ def _silent_seconds(last: datetime | None) -> int | None:
"""距上次心跳的秒数(兼容 sqlite 取回的 naive datetime)。"""
if last is None:
return None
ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow()
ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow() # noqa: UP017
return int((ref - last).total_seconds())
def _scan_once(timeout_minutes: int) -> dict:
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备,在**服务器终端打印**告警代替真实推送
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备并召回
本期不接推送(极光/厂商通道工程量大),只做服务端掉线检测:命中即 logger.warning 打印到终端,
并把状态机推进到 notified 防每轮重复打印(心跳恢复时 touch_heartbeat 会重置回 alive)
push_vendor + push_token 时先发厂商直推, token 或推送失败时仍置
kill_alert_pending,客户端下次进 App 继续走后置提醒兜底
"""
notified = 0
pushed = 0
push_failed = 0
with SessionLocal() as db:
overdue = device_repo.list_overdue(db, timeout_minutes=timeout_minutes)
for device in overdue:
silent = _silent_seconds(device.last_heartbeat_at)
logger.warning(
"[掉线检测] user_id=%s device_id=%s%s 秒无心跳(阈值 %d 分钟)"
" → 判定 App 已被杀/无障碍已停。【已置 kill_alert_pending: 用户下次进 App 将弹「开启自启动」引导(后置检测);推送本期未接】",
" → 判定 App 已被杀/无障碍已停。",
device.user_id,
device.device_id,
silent if silent is not None else "?",
timeout_minutes,
)
if device.push_vendor and device.push_token:
try:
vendor_push.send_accessibility_disabled(
device.push_vendor,
device.push_token,
)
pushed += 1
logger.info(
"[掉线检测] push sent user_id=%s device_id=%s vendor=%s",
device.user_id,
device.device_id,
device.push_vendor,
)
except vendor_push.VendorPushError as e:
push_failed += 1
logger.warning(
"[掉线检测] push failed user_id=%s device_id=%s error=%s",
device.user_id,
device.device_id,
e,
)
else:
logger.info(
"[掉线检测] device has no push vendor/token, skip push user_id=%s device_id=%s",
device.user_id,
device.device_id,
)
device_repo.mark_notified(db, device_id_pk=device.id)
notified += 1
return {"checked": len(overdue), "notified": notified}
return {
"checked": len(overdue),
"notified": notified,
"pushed": pushed,
"push_failed": push_failed,
}
async def _run_loop() -> None:
+51 -2
View File
@@ -22,13 +22,13 @@ from __future__ import annotations
import json
import logging
import os
import shutil
import sys
from contextvars import ContextVar
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
# 请求级 trace_id:入口(如 compare.py 透传壳)set 之后, 本请求上下文(含 run_in_threadpool
# 拷贝出去的线程)内所有日志自动带上。默认空串 = 非请求上下文(启动/后台 worker)。
trace_id_ctx: ContextVar[str] = ContextVar("trace_id", default="")
@@ -91,6 +91,55 @@ class TextFormatter(logging.Formatter):
return f"{base} trace={tid}" if tid else base
class SafeRotatingFileHandler(RotatingFileHandler):
"""Windows 下不会被外部句柄卡死的 RotatingFileHandler。
stdlib 轮转靠 rename 活动文件(app-server.log .1);Windows 只要有别的句柄(IDE 索引
app.admin.main 第二进程残留 --reload worker杀软扫描)开着它, rename WinError 32,
轮转永久卡死文件停在 maxBytes之后每条日志被丢这里 Windows 改用 copytruncate:把活动
文件拷进备份再通过自己的句柄原地清空, 从不 rename 活动文件, 故外部句柄开着也能转
POSIX(生产 Linux)rename 打开中的文件本就合法, 保留 stdlib 的原子轮转不变
代价:copytruncate 拷贝清空极窄窗口内并发写可能丢几行(仅跨进程;同进程 emit
handler 锁串行, 无此问题)对本地开发日志可接受
"""
def doRollover(self) -> None:
if os.name != "nt":
super().doRollover()
return
if self.stream is None:
self.stream = self._open()
else:
self.stream.flush()
try:
self._copytruncate_backups()
except OSError:
# 备份腾挪是尽力而为:任一备份被占用也绝不能挡住下面的清空, 否则活动文件继续涨、
# 轮转又卡死——那就白改了。
pass
# 通过自己独占的句柄原地清空:不涉及 rename, 外部只读句柄不受影响。
self.stream.seek(0)
self.stream.truncate()
self.stream.flush()
def _copytruncate_backups(self) -> None:
"""把 .N-1→.N 逐级腾挪, 再把活动文件拷到 .1(不动活动文件本身)。"""
if self.backupCount <= 0:
return
for i in range(self.backupCount - 1, 0, -1):
sfn = self.rotation_filename(f"{self.baseFilename}.{i}")
dfn = self.rotation_filename(f"{self.baseFilename}.{i + 1}")
if os.path.exists(sfn):
if os.path.exists(dfn):
os.remove(dfn)
os.replace(sfn, dfn)
dfn = self.rotation_filename(f"{self.baseFilename}.1")
if os.path.exists(dfn):
os.remove(dfn)
shutil.copyfile(self.baseFilename, dfn)
_CONFIGURED = False
@@ -126,7 +175,7 @@ def setup_logging(debug: bool = False) -> None:
Path(os.getenv("LOG_DIR", "logs")) / "app-server.log"
)
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
file_handler = RotatingFileHandler(
file_handler = SafeRotatingFileHandler(
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8",
)
file_handler.setFormatter(JsonFormatter(service))
+34
View File
@@ -77,6 +77,35 @@ def save_feedback_qr(data: bytes) -> str:
return _save_named("feedback_qr", "qr", data)
def _sniff_video_ext(data: bytes) -> str | None:
"""按魔数判定视频类型,返回扩展名;非支持类型返回 None。
只认 MP4 家族(ISO BMFF):`....ftyp` 在偏移 4Android ExoPlayer 与浏览器 <video>
都稳吃 H.264/AAC mp4;放开 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>`)。
与图片分开一套校验:体积上限走 [settings.GUIDE_VIDEO_MAX_BYTES],类型只认 MP4
"""
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 编码)")
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}"
def save_cps_image(admin_id: int, data: bytes) -> str:
"""保存 CPS 活动落地页图,返回相对 URL(`/media/cps/<file>`)。admin_id 入文件名便于追溯。"""
return _save_image("cps", admin_id, data)
@@ -116,3 +145,8 @@ def delete_avatar(url: str | None) -> None:
def delete_feedback_qr(url: str | None) -> None:
"""删除本服务托管的旧反馈页二维码文件;外部 URL 或空值不处理。"""
_delete_managed("feedback_qr", url)
def delete_guide_video(url: str | None) -> None:
"""删除本服务托管的旧新手引导视频文件;外部 URL 或空值不处理。"""
_delete_managed("guide_video", url)
+241
View File
@@ -0,0 +1,241 @@
"""消息通知中心:13 种通知类型的静态目录 + Push 文案模板。
对应 PRD消息通知中心:§1 类型清单 / §3 字段元素 / §5 Push 文案
这里只放**静态定义**(分类版式标题操作行Push 模板),供两处消费:
- repositories/notification.py 消息中心列表按 type 派生分类/版式/标题/操作行
- api/v1/push.py 渲染 13 push 标题/文案(厂商推送 + 测试端点)
PRD 文案规范(§5):push 标题 11 固定文案不带变量;变量只出现在正文里且尽量前置
模板变量用 `{name}` 占位,渲染时缺省回退 sample_vars(PRD 示例值),保证 mock 阶段随时可发
"""
from __future__ import annotations
from dataclasses import dataclass, field
# ---------------------------------------------------------------------------
# 分类(仅作卡片头部的分类标签展示;列表不按分类分组——PRD §1 的分组已确认取消,全表时间倒序)
# ---------------------------------------------------------------------------
CATEGORY_WITHDRAW = "withdraw_assistant"
CATEGORY_SYSTEM = "system"
CATEGORY_FEEDBACK = "feedback"
CATEGORY_REPORT = "report"
CATEGORY_INVITE = "invite"
# key → 中文标签
CATEGORIES: dict[str, str] = {
CATEGORY_WITHDRAW: "提现助手",
CATEGORY_SYSTEM: "系统通知",
CATEGORY_FEEDBACK: "我的反馈",
CATEGORY_REPORT: "我的爆料",
CATEGORY_INVITE: "好友邀请",
}
def category_label(key: str) -> str:
return CATEGORIES[key]
# ---------------------------------------------------------------------------
# 卡片版式(PRD §3「版式」列;前端按此渲染五种卡)
# ---------------------------------------------------------------------------
CARD_DUAL_AMOUNT = "dual_amount" # 双金额卡(金币数 + 现金数)
CARD_WITHDRAW = "withdraw" # 提现卡(¥金额)
CARD_PLAIN_TEXT = "plain_text" # 纯文本卡(无数值)
CARD_COIN_REWARD = "coin_reward" # 金币奖励卡(金币数 + 单位「金币」)
CARD_FRIEND_CASH = "friend_cash" # 好友现金卡(¥金额)
@dataclass(frozen=True)
class NotificationType:
"""一种通知类型的静态定义(卡片元数据 + Push 模板)。"""
key: str # 类型 key(接口 type 字段;前端按它决定点击跳转,见 PRD §2)
category: str # 分类 key(CATEGORIES 之一)
card_style: str # 卡片版式(CARD_* 之一)
card_title: str # 卡片标题(PRD §3「标题」列)
action_text: str | None # 操作行文案;None = 无操作行(如「提现成功」)
push_title: str # push 标题(≤11 字固定文案,PRD §5)
push_body_template: str # push 正文模板,`{var}` 为变量
sample_vars: dict[str, str] = field(default_factory=dict) # PRD 示例值,渲染缺省回退
# 13 种类型,编号/文案与 PRD §1/§3/§5 一一对应(插入顺序 = PRD 编号顺序)。
TYPES: dict[str, NotificationType] = {
t.key: t
for t in [
# -- 提现助手 -------------------------------------------------------
NotificationType(
key="reward_expiring",
category=CATEGORY_WITHDRAW,
card_style=CARD_DUAL_AMOUNT,
card_title="金币现金奖励即将失效",
action_text="立即激活您的收益",
push_title="您的奖励即将失效",
push_body_template="{coins}金币和{cash}元现金{days}天后失效,完成快来激活收益",
sample_vars={"coins": "86", "cash": "12.80", "days": "3"},
),
NotificationType(
key="reward_expired",
category=CATEGORY_WITHDRAW,
card_style=CARD_DUAL_AMOUNT,
card_title="金币现金奖励已失效",
action_text="立即赚取新收益",
push_title="您的奖励已失效",
push_body_template="{coins}金币和{cash}元现金已过期,完成一次一键领券或一键比价可赚取新收益",
sample_vars={"coins": "35", "cash": "0.60"},
),
NotificationType(
key="withdraw_success",
category=CATEGORY_WITHDRAW,
card_style=CARD_WITHDRAW,
card_title="提现成功",
action_text=None, # PRD §3:提现成功卡无操作行,点击也无跳转、仅消红点
push_title="提现到账提醒",
push_body_template="¥{amount}已存入您的微信钱包,点击查看到账详情",
sample_vars={"amount": "0.50"},
),
NotificationType(
key="withdraw_failed",
category=CATEGORY_WITHDRAW,
card_style=CARD_WITHDRAW,
card_title="提现失败,款项已退回",
action_text="重新提现",
push_title="提现失败,款项已退回",
push_body_template="¥{amount}{reason}退回现金余额,点击重新提现",
sample_vars={"amount": "3.50", "reason": "微信零钱未实名"},
),
# -- 系统通知(权限异常 ×4;标题里的功能名按类型写死,见 PRD §1/§3)----
NotificationType(
key="perm_accessibility",
category=CATEGORY_SYSTEM,
card_style=CARD_PLAIN_TEXT,
card_title="检测到您的比价功能已失效",
action_text="去开启",
push_title="检测到您的比价功能已失效",
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
),
NotificationType(
key="perm_battery",
category=CATEGORY_SYSTEM,
card_style=CARD_PLAIN_TEXT,
card_title="检测到您的比价续航保护已失效",
action_text="去开启",
push_title="检测到您的比价续航保护已失效",
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
),
NotificationType(
key="perm_autostart",
category=CATEGORY_SYSTEM,
card_style=CARD_PLAIN_TEXT,
card_title="检测到您的比价启动保护已失效",
action_text="去开启",
push_title="检测到您的比价启动保护已失效",
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
),
NotificationType(
key="perm_overlay",
category=CATEGORY_SYSTEM,
card_style=CARD_PLAIN_TEXT,
card_title="检测到您的比价按钮已失效",
action_text="去开启",
push_title="检测到您的比价按钮已失效",
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
),
# -- 我的反馈 -------------------------------------------------------
NotificationType(
key="feedback_reply",
category=CATEGORY_FEEDBACK,
card_style=CARD_PLAIN_TEXT,
card_title="傻瓜比价官方回复了您的反馈",
action_text="查看详情",
push_title="您的反馈有回复啦",
push_body_template="您提的建议我们认真看过了,来看看我们的回复吧~",
),
NotificationType(
key="feedback_reward",
category=CATEGORY_FEEDBACK,
card_style=CARD_COIN_REWARD,
card_title="反馈奖励",
action_text="查看反馈详情",
push_title="反馈奖励已到账",
push_body_template="谢谢您帮傻瓜比价变得更好,{coins}金币已到账,还有一条给您的留言~",
sample_vars={"coins": "300"},
),
# -- 我的爆料 -------------------------------------------------------
NotificationType(
key="report_approved",
category=CATEGORY_REPORT,
card_style=CARD_COIN_REWARD,
card_title="爆料审核通过",
action_text="查看爆料详情",
push_title="爆料审核通过",
push_body_template="您爆料的「{store}」更低价审核通过,{coins}金币已到账,感谢您的分享",
sample_vars={"store": "蜀大侠火锅", "coins": "1000"},
),
# -- 好友邀请 -------------------------------------------------------
NotificationType(
key="invite_order_reward",
category=CATEGORY_INVITE,
card_style=CARD_FRIEND_CASH,
card_title="好友比价成功,现金已到账",
action_text="邀请更多好友赚现金",
push_title="您的邀请奖励已到账",
push_body_template="您的好友「{nickname}」完成首次下单,{amount}元现金已到账",
sample_vars={"nickname": "柚子", "amount": "2"},
),
NotificationType(
key="invite_remind",
category=CATEGORY_INVITE,
card_style=CARD_PLAIN_TEXT,
card_title="你邀请的好友还差一步",
action_text="去提醒 TA",
push_title="提醒好友完成比价的奖励",
push_body_template="您的好友「{nickname}」还没完成比价下单,提醒TA完成,您可得{amount}元现金",
sample_vars={"nickname": "阿泽", "amount": "2"},
),
]
}
class UnknownNotificationType(ValueError):
"""type key 不在 13 种类型之内。"""
def get_type(type_key: str) -> NotificationType:
ntype = TYPES.get(type_key)
if ntype is None:
raise UnknownNotificationType(
f"unknown notification type: {type_key!r} (可选: {', '.join(TYPES)})"
)
return ntype
def render_push(type_key: str, variables: dict[str, str] | None = None) -> tuple[str, str]:
"""渲染某类型的 push (标题, 正文)。
variables 覆盖模板变量;缺的变量回退 sample_vars(PRD 示例值)保证虚拟数据
阶段不传变量也能发出完整文案多余的变量忽略
"""
ntype = get_type(type_key)
merged = {**ntype.sample_vars, **(variables or {})}
class _Fallback(dict):
def __missing__(self, key: str) -> str: # 模板变量既没传也没示例值 → 保留 {key} 原样
return "{" + key + "}"
body = ntype.push_body_template.format_map(_Fallback(merged))
return ntype.push_title, body
def push_variable_names(type_key: str) -> list[str]:
"""列出模板里出现的变量名(给 /push/templates 预览用)。"""
import string
ntype = get_type(type_key)
return [
fname
for _, fname, _, _ in string.Formatter().parse(ntype.push_body_template)
if fname
]
+2 -4
View File
@@ -239,8 +239,8 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: i
return max(0, round(yuan * COIN_PER_YUAN))
# 签到看广告膨胀:S2S 固定补发(原型 2026-06 由 2000 提到 3000,对应 CTA「看广告最高膨胀至3000金币」)。
SIGNIN_BOOST_COIN: int = 3000
# 签到膨胀(SIGNIN_BOOST_COIN,固定 3000)已于 2026-07 下线:它与广告实际收益脱钩,产品确认
# 非设计内口径。签到弹窗的「看广告膨胀」现与福利页看视频同走 calculate_ad_reward_coin。
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
@@ -334,5 +334,3 @@ def get_ad_cooldown_sec(db) -> int: # noqa: ANN001
return int(_cfg(db, "ad_cooldown_sec"))
def get_signin_boost_coin(db) -> int: # noqa: ANN001
return int(_cfg(db, "signin_boost_coin"))
+40 -5
View File
@@ -11,6 +11,7 @@ import hashlib
import hmac
import json
import logging
import threading
import time
from typing import Any
@@ -25,6 +26,44 @@ class MeituanCpsError(Exception):
"""美团 CPS 接口调用失败。"""
# ────────────────────── 共享 httpx.Client(连接池 + keep-alive) ──────────────────────
# 原实现每次 _call 都 `with httpx.Client(...)` 新建再关掉,等于每发一次请求就:
# ① 重建一套 SSL 上下文(加载 certifi CA,实测几百 ms) ② 重做一次 TLS 握手 ③ 用完即弃连接。
# 首页 feed 一次翻页要向美团发好几次请求(外卖/到店两路,「距离最近」还要续页),这份固定开销被成倍放大,
# 是「滑到底部加载很慢」的一大块。改成进程内单例:握手一次、后续走 keep-alive 复用。
# httpx.Client 本身线程安全,可被 feed 的 ThreadPoolExecutor 两条抓取线程共用。
_client: httpx.Client | None = None
_client_lock = threading.Lock()
def get_client() -> httpx.Client:
"""取美团 CPS 共享 client(幂等,懒建)。lifespan 启动时预热,把建 SSL 的成本摊到启动。"""
global _client
if _client is None:
with _client_lock:
if _client is None:
# 走 MT_CPS_PROXY(本机开发直连会 SSL EOF,必须走代理;线上留空=直连)。
# trust_env=False:不读进程环境的 HTTP_PROXY,只认配置,避免被错误/失效代理误导。
_client = httpx.Client(
proxy=settings.MT_CPS_PROXY or None,
trust_env=False,
timeout=settings.MT_CPS_TIMEOUT_SEC,
# 池子留足:feed 每个请求会起 2 条抓取线程,并发用户多时别在池上排队
# (排满会等到 MT_CPS_TIMEOUT_SEC 抛 PoolTimeout,表现成"又慢又降级")。
limits=httpx.Limits(max_keepalive_connections=16, max_connections=32),
)
return _client
def close_client() -> None:
"""lifespan 关停时调,优雅关连接池。"""
global _client
with _client_lock:
if _client is not None:
_client.close()
_client = None
def _content_md5(body: bytes) -> str:
return base64.b64encode(hashlib.md5(body).digest()).decode()
@@ -62,12 +101,8 @@ def _call(path: str, body_obj: dict[str, Any]) -> dict[str, Any]:
}
url = f"{settings.MT_CPS_HOST}{path}"
# 美团调用走 MT_CPS_PROXY(本机开发直连会 SSL EOF,必须走代理;线上留空=直连)。
# trust_env=False:不读进程环境的 HTTP_PROXY,只认配置,避免被错误/失效代理误导。
proxy = settings.MT_CPS_PROXY or None
try:
with httpx.Client(proxy=proxy, trust_env=False, timeout=settings.MT_CPS_TIMEOUT_SEC) as client:
resp = client.post(url, content=body, headers=headers)
resp = get_client().post(url, content=body, headers=headers)
except httpx.HTTPError as e:
logger.exception("[MT] http error calling %s", url)
raise MeituanCpsError(f"meituan http error: {e}") from e
+584
View File
@@ -0,0 +1,584 @@
"""厂商直推集成(荣耀 / 华为 / 小米 / OPPO / vivo)。
服务端不经由 JPush Push API,而是按客户端上报的 push_vendor + push_token
分发到各手机厂商的服务端 API
对外两个入口:
- send_notification() 通用:任意标题/正文/extras(消息中心 13 类推送走这里),
mock=True 时不真调厂商返回渲染结果(虚拟数据联调用)
- send_accessibility_disabled() :无障碍掉线召回(heartbeat_monitor_worker 在用),
已改为 send_notification 的薄封装,行为不变
各厂商鉴权方式:荣耀/华为 OAuth client_credentials access_token(进程内缓存);
vivo/OPPO 签名换 authToken(缓存 24h);小米直接 AppSecret Authorization
"""
from __future__ import annotations
import hashlib
import json
import logging
import time
import uuid
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote
import httpx
from app.core.config import settings
logger = logging.getLogger("shagua.vendor_push")
TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled"
SUPPORTED_VENDORS = frozenset({"honor", "huawei", "vivo", "xiaomi", "oppo"})
# vendor key → 中文名(测试/配置状态接口展示用)
VENDOR_LABELS: dict[str, str] = {
"honor": "荣耀",
"huawei": "华为",
"xiaomi": "小米",
"oppo": "OPPO",
"vivo": "vivo",
}
# 各厂商真发推送所需的 settings 键(缺任一即视为未配置;/api/v1/push/vendors 据此报缺)
REQUIRED_SETTINGS: dict[str, tuple[str, ...]] = {
"honor": ("HONOR_PUSH_APP_ID", "HONOR_PUSH_CLIENT_ID", "HONOR_PUSH_CLIENT_SECRET"),
"huawei": ("HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"),
"xiaomi": ("XIAOMI_PUSH_APP_SECRET",),
"oppo": ("OPPO_PUSH_APP_KEY", "OPPO_PUSH_MASTER_SECRET"),
"vivo": ("VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET"),
}
def missing_settings(vendor: str) -> list[str]:
"""该厂商还缺哪些配置键(全配齐返回空列表)。vendor 需已 normalize。"""
return [key for key in REQUIRED_SETTINGS.get(vendor, ()) if not getattr(settings, key, "")]
class VendorPushError(Exception):
"""厂商推送调用失败。"""
@dataclass
class _CachedToken:
value: str
expires_at: float
_token_cache: dict[str, _CachedToken] = {}
def normalize_vendor(push_vendor: str | None) -> str | None:
if not push_vendor:
return None
vendor = push_vendor.strip().lower()
aliases = {
"hihonor": "honor",
"荣耀": "honor",
"hms": "huawei",
"华为": "huawei",
"harmony": "huawei",
"harmonyos": "huawei",
"mi": "xiaomi",
"小米": "xiaomi",
"oneplus": "oppo",
"realme": "oppo",
}
return aliases.get(vendor, vendor)
def send_notification(
push_vendor: str,
push_token: str,
*,
title: str,
body: str,
extras: dict[str, str] | None = None,
mock: bool = False,
) -> dict[str, Any]:
"""按厂商 token 向单台设备发送一条通知(通用入口)。
- extras:透传给客户端的自定义键值(值统一 string,兼容各厂商限制)消息中心推送约定
至少带 {"type": <13 种类型 key>, "notificationId": <站内消息 id>},客户端据此
深链落地 + /notifications/read 同步置读(PRD §4 push 联动)
- mock=True:不真调厂商 API,校验参数后原样返回渲染结果(虚拟数据阶段联调/自动化测试用)
"""
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")
extras = {str(k): str(v) for k, v in (extras or {}).items()}
if mock:
logger.info(
"[mock push] vendor=%s token=%s... title=%s body=%s extras=%s",
vendor, token[:12], title, body, extras,
)
return {
"mock": True,
"vendor": vendor,
"title": title,
"body": body,
"extras": extras,
}
dispatch: dict[str, Callable[[str, str, str, dict[str, str]], dict[str, Any]]] = {
"honor": _send_honor,
"huawei": _send_huawei,
"vivo": _send_vivo,
"xiaomi": _send_xiaomi,
"oppo": _send_oppo,
}
return dispatch[vendor](token, title, body, extras)
def send_accessibility_disabled(
push_vendor: str,
push_token: str,
*,
title: str = "保护已关闭",
alert: str = "傻瓜比价的无障碍保护被关了,点此重新开启,继续帮你自动比价省钱。",
) -> dict[str, Any]:
"""按厂商 token 向单台设备发送无障碍掉线通知(heartbeat_monitor_worker 在用,行为不变)。"""
return send_notification(
push_vendor,
push_token,
title=title,
body=alert,
extras={"type": TYPE_ACCESSIBILITY_DISABLED},
)
def _require(value: str, name: str) -> str:
if not value:
raise VendorPushError(f"{name} not configured")
return value
def _request_json(
method: str,
url: 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
def _request_form(
method: str,
url: 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
def _cache_get(key: str) -> str | None:
cached = _token_cache.get(key)
if cached and cached.expires_at > time.time() + 60:
return cached.value
return None
def _cache_put(key: str, value: str, expires_in: int | float | None) -> str:
ttl = int(expires_in or 3600)
_token_cache[key] = _CachedToken(value=value, expires_at=time.time() + max(60, ttl - 60))
return value
def _honor_access_token() -> str:
cache_key = "honor"
cached = _cache_get(cache_key)
if cached:
return cached
client_id = _require(settings.HONOR_PUSH_CLIENT_ID, "HONOR_PUSH_CLIENT_ID")
client_secret = _require(settings.HONOR_PUSH_CLIENT_SECRET, "HONOR_PUSH_CLIENT_SECRET")
data = _request_form(
"POST",
settings.HONOR_PUSH_TOKEN_ENDPOINT,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
token = data.get("access_token")
if not token:
raise VendorPushError(f"honor auth failed: {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()
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,
"notification": {
"title": title,
"body": body,
"clickAction": {"type": 3},
"importance": "NORMAL",
},
},
"token": [token],
}
data = _request_json(
"POST",
settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
json=payload,
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 push failed: {data}")
return data
def _huawei_access_token() -> str:
"""华为 OAuth2 client_credentials 换 access_token(client_id 即 AGC 应用的 AppId)。"""
cache_key = "huawei"
cached = _cache_get(cache_key)
if cached:
return cached
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
app_secret = _require(settings.HUAWEI_PUSH_APP_SECRET, "HUAWEI_PUSH_APP_SECRET")
data = _request_form(
"POST",
settings.HUAWEI_PUSH_TOKEN_ENDPOINT,
data={
"grant_type": "client_credentials",
"client_id": app_id,
"client_secret": app_secret,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
token = data.get("access_token")
if not token:
raise VendorPushError(f"huawei auth failed: {data}")
return _cache_put(cache_key, str(token), data.get("expires_in"))
def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
"""华为 Push Kit 下行消息(v1 messages:send)。成功码 '80000000';
'80100000' 为部分成功( token 场景仍视为失败,错误里带原始响应便于排障)"""
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
access_token = _huawei_access_token()
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": {
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
"notification": {
"title": title,
"body": body,
"click_action": {"type": 3},
"importance": "NORMAL",
},
},
"token": [token],
},
}
data = _request_json(
"POST",
settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
json=payload,
headers={
"Content-Type": "application/json; charset=UTF-8",
"Authorization": f"Bearer {access_token}",
},
)
if str(data.get("code", "")) != "80000000":
raise VendorPushError(f"huawei push failed: {data}")
return data
def _vivo_auth_token() -> str:
cache_key = "vivo"
cached = _cache_get(cache_key)
if cached:
return cached
app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID")
app_key = _require(settings.VIVO_PUSH_APP_KEY, "VIVO_PUSH_APP_KEY")
app_secret = _require(settings.VIVO_PUSH_APP_SECRET, "VIVO_PUSH_APP_SECRET")
timestamp = str(int(time.time() * 1000))
sign = hashlib.md5(f"{app_id}{app_key}{timestamp}{app_secret}".encode()).hexdigest() # noqa: S324
data = _request_json(
"POST",
settings.VIVO_PUSH_AUTH_ENDPOINT,
json={
"appId": app_id,
"appKey": app_key,
"timestamp": timestamp,
"sign": sign,
},
headers={"Content-Type": "application/json"},
)
if int(data.get("result", -1)) != 0:
raise VendorPushError(f"vivo auth failed: {data}")
token = data.get("authToken")
if not token:
raise VendorPushError(f"vivo auth missing authToken: {data}")
return _cache_put(cache_key, str(token), 24 * 3600)
def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID")
auth_token = _vivo_auth_token()
payload: dict[str, Any] = {
"appId": app_id,
"regId": token,
"notifyType": settings.VIVO_PUSH_NOTIFY_TYPE,
"title": title,
"content": body,
"timeToLive": settings.PUSH_TIME_TO_LIVE_SEC,
"requestId": uuid.uuid4().hex,
"pushMode": settings.VIVO_PUSH_MODE,
"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 仅打开首页。
if extras.get("notificationId"):
payload["skipType"] = 4
payload["skipContent"] = _click_intent_uri(extras)
else:
payload["skipType"] = 1
if settings.VIVO_PUSH_CATEGORY:
payload["category"] = settings.VIVO_PUSH_CATEGORY
data = _request_json(
"POST",
settings.VIVO_PUSH_SEND_ENDPOINT,
json=payload,
headers={
"Content-Type": "application/json",
"authToken": auth_token,
},
)
if int(data.get("result", -1)) != 0:
raise VendorPushError(f"vivo push failed: {data}")
return data
def _send_xiaomi(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
app_secret = _require(settings.XIAOMI_PUSH_APP_SECRET, "XIAOMI_PUSH_APP_SECRET")
message_title = settings.XIAOMI_PUSH_TEMPLATE_TITLE.strip() or title
message_description = settings.XIAOMI_PUSH_TEMPLATE_DESCRIPTION.strip() or body
form = {
"registration_id": token,
"restricted_package_name": settings.ANDROID_PACKAGE_NAME,
"title": message_title,
"description": message_description,
"payload": json.dumps(extras, ensure_ascii=False),
"pass_through": "0",
"notify_type": "-1",
"time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000),
}
# 点击落地:带 notificationId 的消息中心推送 → notify_effect=2 + intent_uri,MiPush 直接打开
# MainActivity 并把 extras 作为 String extra 传入(客户端 MainActivity.consumeNavTarget 读
# notif_id/notif_type,兜底 notificationId/type)→ 置读 + 刷角标 + 按 type 直达对应页(PRD §5)。
# ⚠️ 早前用 notify_effect=1(仅打开 Launcher),小米自身不会把 payload 拆成普通 extra、而是塞进
# 序列化的 MiPushMessage(key_message),客户端读不到 → 点击后停在首页「没反应」。
# 无 notificationId 的系统召回类(如无障碍掉线)保持 notify_effect=1 仅拉起 App,行为不变。
if extras.get("notificationId"):
form["extra.notify_effect"] = "2"
form["extra.intent_uri"] = _click_intent_uri(extras)
else:
form["extra.notify_effect"] = "1"
if settings.XIAOMI_PUSH_CHANNEL_ID:
form["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip()
if settings.XIAOMI_PUSH_TEMPLATE_ID:
form["extra.template_id"] = settings.XIAOMI_PUSH_TEMPLATE_ID.strip()
if settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON:
form["extra.template_param"] = _xiaomi_template_param(title, body)
data = _request_form(
"POST",
settings.XIAOMI_PUSH_SEND_ENDPOINT,
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}")
if str(data.get("result", "ok")).lower() not in ("ok", "success"):
raise VendorPushError(f"xiaomi push failed: {data}")
return data
def _click_extras(extras: dict[str, str]) -> dict[str, str]:
"""点击落地参数:消息中心推送(extras 带 notificationId)补 notif_id/notif_type 别名——
客户端 MainActivity.consumeNavTarget 首选这两个键(厂商 receiver 路径的历史约定),原始键
(notificationId/type/feedbackId/reportId/)保留作兜底与业务跳转参数
notificationId(如无障碍召回)原样返回,不喂点击路由参数"""
if not extras.get("notificationId"):
return dict(extras)
merged = dict(extras)
merged.setdefault("notif_id", extras["notificationId"])
if extras.get("type"):
merged.setdefault("notif_type", extras["type"])
return merged
def _click_intent_uri(extras: dict[str, str]) -> str:
"""构造「系统直启 MainActivity 并带 extras」的 intent uri(小米 notify_effect=2 的
extra.intent_urivivo skipType=4 skipContent 共用):点击后厂商系统用 Intent.parseUri
解析并 startActivity,extras 作为 String extra 原样送达
- component 显式指向本包 MainActivity(exported=truesingleTask) 已运行则走 onNewIntent
未运行则 onCreate,两条都会执行 consumeNavTarget
- 参数 = _click_extras( notif_id/notif_type 别名 + 透传 feedbackId/reportId 等跳转参数)
- 值按 Android Uri.encode 规则百分号编码(quote(safe="")):中文/分号/等号都不会破坏 intent uri
结构;客户端 Intent.parseUri Uri.decode 无损还原表单/JSON 传输层的编码与本层相互独立
各自解码,不会双重转义(2026-07-15 小米联调结论)
"""
pkg = settings.ANDROID_PACKAGE_NAME
parts = ["intent:#Intent", f"component={pkg}/{pkg}.MainActivity"]
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
.replace("{title}", title)
.replace("{alert}", alert)
)
try:
payload = json.loads(rendered)
except ValueError as e:
raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON invalid json") from e
if not isinstance(payload, dict):
raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON must be a json object")
for key, value in payload.items():
if not isinstance(key, str) or not isinstance(value, str):
raise VendorPushError("xiaomi template params must be string key-value pairs")
if not value.strip() or len(value) > 128:
raise VendorPushError("xiaomi template param value length must be 1-128")
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def _oppo_auth_token() -> str:
cache_key = "oppo"
cached = _cache_get(cache_key)
if cached:
return cached
app_key = _require(settings.OPPO_PUSH_APP_KEY, "OPPO_PUSH_APP_KEY")
master_secret = _require(settings.OPPO_PUSH_MASTER_SECRET, "OPPO_PUSH_MASTER_SECRET")
timestamp = str(int(time.time() * 1000))
sign = hashlib.sha256(f"{app_key}{timestamp}{master_secret}".encode()).hexdigest()
data = _request_form(
"POST",
settings.OPPO_PUSH_AUTH_ENDPOINT,
data={
"app_key": app_key,
"timestamp": timestamp,
"sign": sign,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if int(data.get("code", -1)) != 0:
raise VendorPushError(f"oppo auth failed: {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}")
return _cache_put(cache_key, str(token), 24 * 3600)
def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
auth_token = _oppo_auth_token()
ttl_hours = max(1, min(72, settings.PUSH_TIME_TO_LIVE_SEC // 3600))
notification: dict[str, Any] = {
"app_message_id": f"{extras.get('type', 'notify')}_{uuid.uuid4().hex}",
"title": title,
"content": body,
"off_line": True,
"off_line_ttl": ttl_hours,
"action_parameters": json.dumps(_click_extras(extras), ensure_ascii=False),
}
# 点击落地:OPPO SDK 没有点击回调,参数只能靠服务端点击动作配置送达——action_parameters 的
# 键值对仅在 click_action_type=1/4 时才会注入目标 Activity 的 intent extras(type=0「启动应用」
# 会忽略它,extras 全丢 → 点了没反应,与小米 notify_effect=1 同款坑)。
# 消息中心推送(带 notificationId)→ type=4(打开应用内页面,Activity 全路径,exported=true);
# 无 notificationId 的召回类保持 type=0 仅打开应用。
if extras.get("notificationId"):
notification["click_action_type"] = 4
notification["click_action_activity"] = f"{settings.ANDROID_PACKAGE_NAME}.MainActivity"
else:
notification["click_action_type"] = 0
# 新消息分类(2024-11-20 后创建的 OPPO 应用必须带 category,否则可能被拒收/降级)
if settings.OPPO_PUSH_CHANNEL_ID.strip():
notification["channel_id"] = settings.OPPO_PUSH_CHANNEL_ID.strip()
if settings.OPPO_PUSH_CATEGORY.strip():
notification["category"] = settings.OPPO_PUSH_CATEGORY.strip()
if settings.OPPO_PUSH_NOTIFY_LEVEL:
notification["notify_level"] = settings.OPPO_PUSH_NOTIFY_LEVEL
message = {
"target_type": 2,
"target_value": token,
"notification": notification,
}
data = _request_form(
"POST",
settings.OPPO_PUSH_SEND_ENDPOINT,
data={
"auth_token": auth_token,
"message": json.dumps(message, ensure_ascii=False),
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if int(data.get("code", -1)) != 0:
raise VendorPushError(f"oppo push failed: {data}")
return data
+20
View File
@@ -29,10 +29,13 @@ from app.api.v1.coupon import router as coupon_router
from app.api.v1.cps_redirect import router as cps_redirect_router
from app.api.v1.device import router as device_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.guide_video import router as guide_video_router
from app.api.v1.invite import router as invite_router
from app.api.v1.meituan import router as meituan_router
from app.api.v1.notifications import router as notifications_router
from app.api.v1.order import router as order_router
from app.api.v1.platform import router as platform_router
from app.api.v1.push import router as push_router
from app.api.v1.report import router as report_router
from app.api.v1.savings import router as savings_router
from app.api.v1.signin import router as signin_router
@@ -41,6 +44,10 @@ 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.config import settings
from app.core.cps_reconcile_worker import (
start_cps_reconcile_worker,
stop_cps_reconcile_worker,
)
from app.core.daily_exchange_worker import (
start_daily_exchange_worker,
stop_daily_exchange_worker,
@@ -64,6 +71,7 @@ from app.core.withdraw_reconcile_worker import (
start_withdraw_reconcile_worker,
stop_withdraw_reconcile_worker,
)
from app.integrations import meituan as mt_meituan
setup_logging(debug=settings.APP_DEBUG)
logger = logging.getLogger("shagua.main")
@@ -80,6 +88,9 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.DATABASE_URL.split("://", 1)[0],
)
get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热
if settings.mt_cps_configured:
# 同理预热美团 CPS client(TLS 上下文 + 连接池建一次,后续 keep-alive 复用)
mt_meituan.get_client()
try:
# 预热离线地理库:首次加载 ~2.5M 行 CSV + 建 KDTree,摊到启动、不砸首个按城市过滤的请求
from app.utils import geo
@@ -87,6 +98,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
except Exception: # noqa: BLE001
logger.exception("reverse_geocoder 预热失败(城市反查将在首个请求时懒加载)")
reconcile_task = start_withdraw_reconcile_worker()
cps_reconcile_task = start_cps_reconcile_worker()
heartbeat_task = start_heartbeat_monitor()
daily_exchange_task = start_daily_exchange_worker()
observe_task = start_observe_worker()
@@ -96,10 +108,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
finally:
await stop_heartbeat_monitor(heartbeat_task)
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_cps_reconcile_worker(cps_reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
await stop_observe_worker(observe_task)
await stop_inactivity_reset_worker(inactivity_task)
await aclose_pricebot_client()
mt_meituan.close_client()
logger.info("shutting down")
@@ -146,8 +160,14 @@ app.include_router(signin_router)
app.include_router(tasks_router)
app.include_router(savings_router)
app.include_router(ad_router)
# 新手引导视频(领券等候浮层前 N 次替代广告;运营后台传片,见 repositories/guide_video.py)
app.include_router(guide_video_router)
app.include_router(order_router)
app.include_router(report_router)
# 消息通知中心(PRD;数据落库 notification 表,见 repositories/notification.py)
app.include_router(notifications_router)
# 厂商推送测试三件套(配置状态/模板预览/测试发送,支持 mock 与真发)
app.include_router(push_router)
# 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。
app.include_router(internal_price_router)
app.include_router(internal_store_router)
+4 -1
View File
@@ -21,12 +21,14 @@ from app.models.cps_wx_user import CpsWxUser # noqa: F401
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
from app.models.device import DeviceLiveness # noqa: F401
from app.models.coupon_state import ( # noqa: F401
CouponClaimEvent,
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
CouponSession,
)
from app.models.feedback import Feedback # noqa: F401
from app.models.guide_video import GuideVideoPlay # noqa: F401
from app.models.inactivity import ( # noqa: F401
InactivityNotificationLog,
InactivityResetLog,
@@ -35,6 +37,7 @@ 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.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.notification import Notification # noqa: F401
from app.models.onboarding import OnboardingCompletion # noqa: F401
from app.models.phone_rebind_log import PhoneRebindLog # noqa: F401
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
@@ -42,7 +45,7 @@ from app.models.ops_stat_config import OpsStatConfig # noqa: F401
from app.models.price_observation import PriceObservation # noqa: F401
from app.models.price_report import PriceReport # noqa: F401
from app.models.savings import SavingsRecord # noqa: F401
from app.models.signin import SigninBoostRecord, SigninRecord # noqa: F401
from app.models.signin import SigninRecord # noqa: F401
from app.models.store_mapping import StoreMapping # noqa: F401
from app.models.task import UserTask # noqa: F401
from app.models.user import User # noqa: F401
+9 -1
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -16,6 +16,10 @@ from app.db.base import Base
class AdRewardRecord(Base):
__tablename__ = "ad_reward_record"
__table_args__ = (
# 「本轮膨胀累计发了多少」= SUM(coin) WHERE user_id=? AND boost_round_id=? AND status='granted'
Index("ix_ad_reward_user_boost_round", "user_id", "boost_round_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 穿山甲交易号,幂等键(同号回调不重复发奖)
@@ -31,6 +35,10 @@ class AdRewardRecord(Base):
reward_scene: Mapped[str] = mapped_column(String(32), nullable=False, default="reward_video")
# 客户端生成并通过 extra 透传的广告会话 id
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 客户端生成并通过 extra 透传的「膨胀轮」id:一轮 = 用户点「去膨胀」到点「放弃赚钱」之间连看的
# 若干条广告。纯标签,不影响发多少/发不发,只用于把同一轮的发奖记录求和成弹窗要显示的累计值。
# 轮次边界完全由客户端定(它才知道用户点了放弃);老客户端/extra 丢失时为 NULL → 累计值返 null。
boost_round_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报)
ecpm_raw: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx。
+3 -2
View File
@@ -25,8 +25,9 @@ class AnalyticsEvent(Base):
__tablename__ = "analytics_event"
__table_args__ = (
# 活跃口径聚合热点(activity.active_event_condition + last_active_subqueries):
# 按 (event,page) 过滤 首页可见(show/home)∪比价∪领券,再 group by user_id 取
# max(created_at)。覆盖索引 → 该聚合走 index-only,避免高频 show 事件全表扫。
# 按 event IN (home_visible∪比价∪领券) 过滤,再 group by user_id 取 max(created_at)。
# 覆盖索引 → 该聚合走 index-only。注:page 列是早期 show+home 组合的遗留,现不再按 page
# 过滤(索引靠 event 前缀仍生效);后续可新迁移瘦成 (event,user_id,created_at)。
Index("ix_analytics_event_active", "event", "page", "user_id", "created_at"),
)
+4
View File
@@ -45,6 +45,10 @@ class ComparisonRecord(Base):
# 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序;
# 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。
Index("ix_comparison_status_created", "status", "created_at"),
# C 端「我的比价记录」列表:WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT n。
# 单列 user_id 索引只能过滤,排序仍要把该用户全部记录取出来排一遍;这条复合索引的**反向扫**
# 恰好等于 (created_at DESC, id DESC),PG 直接取前 n 条、免排序。列序不能动。
Index("ix_comparison_user_created", "user_id", "created_at", "id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+34
View File
@@ -96,6 +96,40 @@ class CouponClaimRecord(Base):
)
class CouponClaimEvent(Base):
"""一次领券任务中的单券结果,按 ``(trace_id, coupon_id)`` 幂等。"""
__tablename__ = "coupon_claim_event"
__table_args__ = (
UniqueConstraint(
"trace_id", "coupon_id",
name="uq_coupon_claim_event_trace_coupon",
),
Index("ix_coupon_claim_event_date_env", "claim_date", "app_env"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
trace_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
coupon_id: Mapped[str] = mapped_column(String(64), nullable=False)
claim_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[str] = mapped_column(String(24), nullable=False)
app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
vendor: Mapped[str | None] = mapped_column(String(48), nullable=True)
coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
extra: Mapped[dict | None] = mapped_column(_JSON, 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,
)
class CouponDailyCompletion(Base):
"""按 (device, 自然日) 记"今天是否已跑完整轮领券(到 done 帧)"——首页置灰源。
+9 -5
View File
@@ -1,9 +1,9 @@
"""设备表(无障碍保护存活检测 + 极光推送)。
"""设备表(无障碍保护存活检测 + 厂商直推)。
每条 = 一个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)
客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报
registration_id(极光推送目标)后端 heartbeat_monitor_worker 扫描曾经保护过
现在心跳超时的设备,通过极光推送提醒用户重开无障碍
push_vendor + push_token(厂商推送目标)后端 heartbeat_monitor_worker 扫描曾经保护过
现在心跳超时的设备,通过厂商直推提醒用户重开无障碍
liveness_state 状态机(防刷屏,一次掉线只推一条):
unknown alive(收到 service 心跳) silent/notified(扫描发现超时并已推送)
@@ -30,7 +30,7 @@ from app.db.base import Base
class DeviceLiveness(Base):
# 表名不叫 device:device 易被当成「设备信息(品牌/型号/系统)」表;本表实为**无障碍存活监控状态**
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 推送目标 registration_id),故名 device_liveness。
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 厂商推送目标),故名 device_liveness。
__tablename__ = "device_liveness"
__table_args__ = (
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
@@ -42,8 +42,12 @@ class DeviceLiveness(Base):
)
# 客户端 DeviceId.get() 生成的 per-install id(如 device_Pixel_ab12cd34)
device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False)
# 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发)
# 极光推送 registration id,仅为兼容历史客户端/数据保留;新链路使用 push_vendor + push_token。
registration_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 厂商推送类型:honor/vivo/xiaomi/oppo 等;客户端按实际 SDK token 来源上报。
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)
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android")
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
+73
View File
@@ -0,0 +1,73 @@
"""新手引导视频播放记录(领券浮层前 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
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
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),
)
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)
# 当次下发的视频地址(运营换片后能回溯用户当时看的是哪支)。
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=中途关闭)。仅留痕:两者都发币。
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
)
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}>"
)
+21 -1
View File
@@ -23,7 +23,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, DateTime, Float, Integer, String, UniqueConstraint, func
from sqlalchemy import JSON, DateTime, Float, Index, Integer, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
@@ -95,3 +95,23 @@ class MeituanCoupon(Base):
f"<MeituanCoupon id={self.id} source={self.source} "
f"name={self.name!r} sale={self.sale_volume} comm={self.commission_percent}>"
)
# 首页「销量最高 / 智能推荐」两个 tab 的分页索引(见 api/v1/meituan.py 的 _paged_dedup_ids)。
# 两条 tab 都是 `WHERE city_id=? [+过滤] ORDER BY dedup_key, <排序键> DESC` 的 DISTINCT ON 去重,
# 列顺序对齐后 Postgres 可以顺着索引流式去重,免掉整城数据的排序 —— 否则每翻一页都要把该城
# 全部券重排一遍(下滑到底越来越慢的根因之一)。
# 定义放在类外:__table_args__ 里拿不到还没建好的类属性,写不了 .desc()。
Index(
"ix_meituan_coupon_city_dedup_sales",
MeituanCoupon.city_id,
MeituanCoupon.dedup_key,
MeituanCoupon.sale_volume_num.desc(),
MeituanCoupon.commission_percent.desc(),
)
Index(
"ix_meituan_coupon_city_dedup_comm",
MeituanCoupon.city_id,
MeituanCoupon.dedup_key,
MeituanCoupon.commission_percent.desc(),
)
+95
View File
@@ -0,0 +1,95 @@
"""消息通知中心:站内消息表(一行 = 一条下发给某用户的站内消息)。
13 类通知的**静态定义**(分类 / 版式 / 标题 / 操作行 / push 模板)
`app/core/notification_catalog.py`,是代码常量,**不入库**;本表只存**每条消息的动态部分**
(与接口 NotificationItem 的动态字段一一对应):type + 金额 + 信息行 + extra + 已读态 + 时间
category / card_style / title / action_text 都由 `type` catalog 派生,不冗余存库
- :`repositories/notification.create_notification`(业务事件下发站内消息的统一入口)
- :`api/v1/notifications.py`(列表 / 未读数 / 标记已读),均按 user 隔离sent_at 倒序
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
JSON,
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
String,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 comparison_record.raw_payload 等)。
_JSON = JSON().with_variant(JSONB(), "postgresql")
class Notification(Base):
__tablename__ = "notification"
__table_args__ = (
# 列表分页:按用户取 + sent_at 倒序(核心查询,覆盖 user_id 前缀查找,故不再单独索引 user_id)
Index("ix_notification_user_sent", "user_id", "sent_at"),
# 铃铛角标:count where user_id=? and is_read=false —— 部分索引只覆盖未读行
Index(
"ix_notification_user_unread",
"user_id",
sqlite_where=text("is_read = 0"),
postgresql_where=text("is_read = false"),
),
# 去重/合并:同一 (user, type, dedup_key) 未读期间只允许一条(perm_* 权限异常、
# reward_expiring 同批次即用它);消息一旦已读即离开索引,之后可再生成新的未读消息。
Index(
"uq_notification_user_type_dedup",
"user_id",
"type",
"dedup_key",
unique=True,
sqlite_where=text("dedup_key IS NOT NULL AND is_read = 0"),
postgresql_where=text("dedup_key IS NOT NULL AND is_read = false"),
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"), nullable=False)
# 13 类之一(catalog.TYPES 的 key);category/card_style/title/action_text 由它派生,不入库
type: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
# 金币数(dual_amount / coin_reward 卡);其余类型 None
coins: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 现金,单位【分】(dual_amount / withdraw / friend_cash 卡);其余 None
cash_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 信息行 [{label, value}](已渲染好文案,前端逐行展示)
info_rows: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 点击跳转/联动参数(feedbackId / withdrawId / permission / inviteeNickname / batchId …)
extra: Mapped[dict] = mapped_column(_JSON, nullable=False, default=dict)
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# 置读时刻(未读时为 None;埋点/分析用)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# 去重键(可空):perm_*→permission、reward_expiring→batchId 等;配合部分唯一索引防重复未读
dedup_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 下发/业务时间;列表排序与展示都用它(带 +08:00 下发)
sent_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
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,
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<Notification id={self.id} user_id={self.user_id} "
f"type={self.type} read={self.is_read}>"
)
+5 -27
View File
@@ -1,6 +1,10 @@
"""签到记录表。
每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次
2026-07 下线 `signin_boost_record`(签到膨胀):膨胀按固定 3000 金币补发与广告实际收益
脱钩,产品确认非设计内口径签到弹窗的看广告膨胀改走 reward_video( eCPM ,记在
`ad_reward_record`)历史发币流水 `coin_transaction.biz_type='signin_boost'` 保留不动
- cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1
(周期长度 = rewards.SIGNIN_CYCLE_LEN,2026-06 14 天改 7 天一轮)
- streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1
@@ -9,7 +13,7 @@ from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy import Date, DateTime, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -42,29 +46,3 @@ class SigninRecord(Base):
)
class SigninBoostRecord(Base):
"""签到后看广告膨胀记录。
一天最多膨胀一次,补发金额等于当天签到原始奖励独立表用于防并发重复补发,
后续接入真实 S2S 广告 session 时可把 ad_ref_id 回填为广告会话/交易号
"""
__tablename__ = "signin_boost_record"
__table_args__ = (
UniqueConstraint("user_id", "signin_date", name="uq_signin_boost_user_date"),
)
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
)
signin_date: Mapped[date] = mapped_column(Date, nullable=False)
coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False)
ad_ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<SigninBoostRecord user_id={self.user_id} date={self.signin_date} coin={self.coin_awarded}>"
-9
View File
@@ -96,15 +96,6 @@ class WithdrawOrder(Base):
"""
__tablename__ = "withdraw_order"
__table_args__ = (
Index(
"ux_withdraw_order_user_active",
"user_id",
unique=True,
sqlite_where=text("status IN ('reviewing', 'pending')"),
postgresql_where=text("status IN ('reviewing', 'pending')"),
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
+7 -13
View File
@@ -1,6 +1,6 @@
"""活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。
口径 = max(User.created_at, AnalyticsEvent[首页可见 show/home + 比价 + 领券], CouponPromptEngagement[claim_started])
口径 = max(User.created_at, AnalyticsEvent[首页可见 home_visible + 比价 + 领券], CouponPromptEngagement[claim_started])
**不含 last_login_at**(登录/re-login 不代表在用 App);created_at 为恒非空基线
清零/预警按北京自然日 0 点对齐( reset_cutoff)
"""
@@ -8,7 +8,7 @@ from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import and_, func, or_, select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ, cn_today
@@ -16,24 +16,18 @@ from app.models.analytics_event import AnalyticsEvent
from app.models.coupon_state import CouponPromptEngagement
# —— 活跃口径事件(与"用户管理"口径一致)——
# 首页可见:前端埋点 event=show + page=home(组合判定,单 event 名不足以区分,见
# active_event_condition);其余为纯 event 名。
HOME_VIEW_EVENT = "show"
HOME_VIEW_PAGE = "home"
# 首页可见:前端埋点确认 event=home_visible(首页进入可视区时触发,单 event 名即可判定)。
HOME_VISIBLE_EVENT = "home_visible"
COMPARE_START_EVENT = "real_compare_start" # 发起比价(含浮窗触发)
COUPON_START_EVENT = "real_coupon_start" # 发起领券
# 纯 event 名即可判定的活跃事件(首页可见是 event+page 组合、不在此列)
ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT)
ACTIVE_EVENTS = (HOME_VISIBLE_EVENT, COMPARE_START_EVENT, COUPON_START_EVENT)
ACTIVE_ENGAGE_TYPE = "claim_started" # coupon_prompt_engagement 一键领取
def active_event_condition():
"""analytics_event 中算"活跃"的行为过滤:首页可见(event=show & page=home)
"""analytics_event 中算"活跃"的行为过滤:首页可见(event=home_visible)
发起比价 发起领券worker 子查询与 admin 展示共用,单一真源"""
return or_(
and_(AnalyticsEvent.event == HOME_VIEW_EVENT, AnalyticsEvent.page == HOME_VIEW_PAGE),
AnalyticsEvent.event.in_(ACTIVE_EVENTS),
)
return AnalyticsEvent.event.in_(ACTIVE_EVENTS)
def as_utc(value: datetime) -> datetime:
+39 -1
View File
@@ -13,6 +13,43 @@ from sqlalchemy.orm import Session
from app.core import rewards
from app.core.rewards import cn_today
from app.models.ad_ecpm import AdEcpmRecord
from app.models.coupon_state import CouponSession
MIN_REVENUE_EXPOSURE_MS = 1000
def effective_ecpm_raw(ecpm_raw: str, exposure_ms: int | None) -> str:
"""曝光不足一秒时保留展示记录,但把该条有效 eCPM 归零。"""
if exposure_ms is not None and exposure_ms < MIN_REVENUE_EXPOSURE_MS:
return "0"
return ecpm_raw
def attributable_trace_id(
db: Session,
*,
feed_scene: str | None,
trace_id: str | None,
exposure_ms: int | None = None,
) -> str | None:
"""返回广告展示允许归属的业务 trace。
领券任务可能在 Draw 广告异步加载完成前已经失败或被用户放弃非完成终态先落库
广告回调后到时收益仍需保留在总广告报表中但不能再挂到该死亡领券明细
因此清空关联 trace其它场景找不到 session进行中或已完成状态保持原值
由客户端生命周期修复负责主防线
"""
if feed_scene != "coupon" or not trace_id:
return trace_id
session_status = db.execute(
select(CouponSession.status).where(CouponSession.trace_id == trace_id)
).scalar_one_or_none()
if session_status not in {"failed", "abandoned"}:
return trace_id
# 已真实上墙但不足一秒的曝光要在终态明细中明确显示 0,而不是被误判成“未填充”。
if exposure_ms is not None and exposure_ms < MIN_REVENUE_EXPOSURE_MS:
return trace_id
return None
def create_ecpm_record(
@@ -28,6 +65,7 @@ def create_ecpm_record(
trace_id: str | None = None,
app_env: str | None = None,
our_code_id: str | None = None,
exposure_ms: int | None = None,
) -> AdEcpmRecord:
"""落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。
@@ -48,7 +86,7 @@ def create_ecpm_record(
trace_id=trace_id,
app_env=app_env,
our_code_id=our_code_id,
ecpm_raw=ecpm_raw,
ecpm_raw=effective_ecpm_raw(ecpm_raw, exposure_ms),
report_date=cn_today().isoformat(),
)
db.add(rec)
+4
View File
@@ -6,6 +6,7 @@
"""
from __future__ import annotations
from collections.abc import Collection
from typing import Any, TypedDict
from sqlalchemy import func, select
@@ -73,6 +74,7 @@ def aggregate_by_date(
date_to: str,
app_env: str | None = None,
our_code_id: str | None = None,
our_code_ids: Collection[str] | None = None,
) -> list[PangleDateAgg]:
"""按日期汇总穿山甲收益(闭区间,北京时间),供报表趋势 + 合计。
@@ -97,6 +99,8 @@ def aggregate_by_date(
stmt = stmt.where(AdPangleDailyRevenue.app_env == app_env)
if our_code_id is not None:
stmt = stmt.where(AdPangleDailyRevenue.our_code_id == our_code_id)
if our_code_ids is not None:
stmt = stmt.where(AdPangleDailyRevenue.our_code_id.in_(our_code_ids))
out: list[PangleDateAgg] = []
for report_date, rev, api_rev, imp in db.execute(stmt).all():
+70 -5
View File
@@ -41,6 +41,61 @@ def find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None:
return _find_by_trans(db, trans_id)
def find_by_session(db: Session, user_id: int, ad_session_id: str) -> AdRewardRecord | None:
"""按广告会话 id 查该用户本次广告的发奖记录,供客户端轮询弹窗金额(reward-result)。
同一 ad_session_id 可能命中多条,**必须显式优先 granted**,不能只取最近一条:
- 客户端先上报 closed_earlyS2S 随后才姗姗来迟 两条,granted 反而是后写的;
- record_reward_noshow 只在写入前查 granted,挡不住这种后到的竞态;
- 本地联调重复调 test-grant session 多条 granted(trans_id 各不相同)
granted 是唯一真发了钱的状态,取它才是权威金额;都没有再取最近一条,让客户端
知道没发的原因(capped/closed_early) user_id 收窄,防止拿别人的 session 探测结果
"""
granted = db.execute(
select(AdRewardRecord)
.where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.ad_session_id == ad_session_id,
AdRewardRecord.status == "granted",
)
.order_by(AdRewardRecord.created_at.desc())
.limit(1)
).scalars().first()
if granted is not None:
return granted
return db.execute(
select(AdRewardRecord)
.where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.ad_session_id == ad_session_id,
)
.order_by(AdRewardRecord.created_at.desc())
.limit(1)
).scalars().first()
def round_coin_total(db: Session, user_id: int, boost_round_id: str) -> int:
"""本轮膨胀累计已发金币 = 该轮所有 granted 记录的 coin 之和(含刚发的这条)。
客户端弹窗要显示的就是它: 1 条弹 40 2 条弹 60(=40+20),放弃赚钱后余额涨 60,
三个数必须相等之所以由服务端求和而不是客户端自己累加客户端进程被杀/低内存重建后
本地累计就丢了,而发奖记录不会丢
**必须带 user_id**:boost_round_id 是客户端生成的,不带 user_id 就等于让任何人拿别人的
id 查别人发了多少未发奖的状态(capped/closed_early/ecpm_missing)coin 本就是 0,
这里按 status 过滤只是让意图显式
"""
return int(
db.execute(
select(func.coalesce(func.sum(AdRewardRecord.coin), 0)).where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.boost_round_id == boost_round_id,
AdRewardRecord.status == "granted",
)
).scalar_one()
)
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
return db.execute(
select(func.count())
@@ -78,8 +133,12 @@ def grant_ad_reward(
reward_scene: str = "reward_video",
reward_name: str | None = None,
raw: str | None = None,
boost_round_id: str | None = None,
) -> AdRewardRecord:
"""福利页激励视频发奖(幂等 + 每日限额 + eCPM 公式)。"""
"""福利页激励视频发奖(幂等 + 每日限额 + eCPM 公式)。
boost_round_id 只是随记录存下的标签( round_coin_total),**不参与任何发奖判定**
"""
# #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发
existing = _find_by_trans(db, trans_id)
if existing is not None:
@@ -112,7 +171,7 @@ def grant_ad_reward(
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
reward_date=today, reward_name=reward_name, raw=raw,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm,
app_env=src_app_env, our_code_id=src_code_id,
app_env=src_app_env, our_code_id=src_code_id, boost_round_id=boost_round_id,
)
return _commit_record(db, rec, trans_id)
@@ -123,7 +182,7 @@ def grant_ad_reward(
trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing",
reward_date=today, reward_name=reward_name, raw=raw,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=None,
app_env=src_app_env, our_code_id=src_code_id,
app_env=src_app_env, our_code_id=src_code_id, boost_round_id=boost_round_id,
)
return _commit_record(db, rec, trans_id)
@@ -140,7 +199,7 @@ def grant_ad_reward(
trans_id=trans_id, user_id=user_id, coin=coin, status="granted",
reward_date=today, reward_name=reward_name, raw=raw,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_raw,
app_env=src_app_env, our_code_id=src_code_id,
app_env=src_app_env, our_code_id=src_code_id, boost_round_id=boost_round_id,
)
return _commit_record(db, rec, trans_id)
@@ -206,8 +265,13 @@ def record_external_reward(
raw: str | None = None,
status: str = "granted",
commit: bool = True,
boost_round_id: str | None = None,
) -> AdRewardRecord:
"""记录非普通看视频场景的 S2S 回调幂等,发币由调用方业务仓储完成。"""
"""记录非普通看视频场景的 S2S 回调幂等,发币由调用方业务仓储完成。
boost_round_id grant_ad_reward:纯标签签到膨胀场景的 coin 也会计入本轮累计
(它的 coin 就是实发额),所以这里也要存,否则一轮里混了膨胀就会漏算
"""
existing = _find_by_trans(db, trans_id)
if existing is not None:
return existing
@@ -224,6 +288,7 @@ def record_external_reward(
reward_scene=reward_scene,
ad_session_id=ad_session_id,
ecpm_raw=ecpm,
boost_round_id=boost_round_id,
)
db.add(rec)
if commit:
+44
View File
@@ -142,3 +142,47 @@ def set_ad_config(db: Session, data: dict, *, admin_id: int, commit: bool = True
else:
db.flush()
return row
# ── 华为审核开关(admin 可切,仅华为 ROM 客户端拉)────────────────────────────────
# 同 ad_config:复用 AppConfig 表但不进 CONFIG_DEFS——它由「华为审核开关」专用页管理,
# 有自己的两态语义,混进通用系统配置页只会显示成一个没头没尾的 on/off。
# default → 新手引导页(快速设置)强制展示,用户无法关闭(= 上线至今的现状)
# review → 快速设置的权限步左上角出现退出按钮,可直接进首页(过华为应用市场审核用)
# 空库 = default = 行为完全不变。客户端经 /api/v1/platform/huawei-review 拉取。
HUAWEI_REVIEW_KEY = "huawei_review"
HUAWEI_REVIEW_MODES = ("default", "review")
_HUAWEI_REVIEW_DEFAULTS: dict[str, Any] = {
"mode": "default",
}
def get_huawei_review(db: Session) -> dict:
"""读华为审核开关。DB 无 / 脏值一律回退 default(宁可不给退出按钮,也不误放开)。"""
row = db.get(AppConfig, HUAWEI_REVIEW_KEY)
merged = dict(_HUAWEI_REVIEW_DEFAULTS)
if row is not None and isinstance(row.value, dict):
merged.update(row.value)
if merged.get("mode") not in HUAWEI_REVIEW_MODES:
merged["mode"] = _HUAWEI_REVIEW_DEFAULTS["mode"]
return merged
def set_huawei_review(db: Session, mode: str, *, admin_id: int, commit: bool = True) -> AppConfig:
"""admin 切换华为审核开关。非法 mode 抛 ValueError(路由转 400)。"""
if mode not in HUAWEI_REVIEW_MODES:
raise ValueError(f"invalid mode: {mode} (expected one of {list(HUAWEI_REVIEW_MODES)})")
row = db.get(AppConfig, HUAWEI_REVIEW_KEY)
value = {"mode": mode}
if row is None:
row = AppConfig(key=HUAWEI_REVIEW_KEY, value=value, updated_by_admin_id=admin_id)
db.add(row)
else:
row.value = value
row.updated_by_admin_id = admin_id
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
return row
+167 -9
View File
@@ -5,17 +5,28 @@
"""
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, defer
from app.core.rewards import CN_TZ
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.comparison import ComparisonRecord
from app.models.savings import SavingsRecord
from app.models.user import User
from app.schemas.compare_record import ComparisonRecordIn
DAILY_COMPARE_START_LIMIT = 100
class DailyCompareStartLimitExceeded(Exception):
"""The authenticated user has consumed today's comparison-start quota."""
class ComparisonTraceOwnershipError(Exception):
"""A trace id already belongs to a different authenticated user."""
def _yuan_to_cents(yuan: float | None) -> int | None:
"""元(float)→ 分(int)。None 透传。"""
@@ -243,6 +254,78 @@ def _get_by_trace(db: Session, trace_id: str) -> ComparisonRecord | None:
).scalar_one_or_none()
def reserve_daily_start(
db: Session,
*,
user_id: int,
trace_id: str,
business_type: str = "food",
device_id: str | None = None,
now: datetime | None = None,
) -> tuple[ComparisonRecord, int]:
"""Atomically reserve one of a user's 100 Beijing-day comparison starts.
``trace_id`` makes client retries idempotent. Locking the user row serializes
concurrent starts for one account, so parallel requests cannot both consume
the final available slot. The reservation is the existing ``running``
comparison row; later result reporting updates that same row.
"""
db.execute(select(User.id).where(User.id == user_id).with_for_update()).scalar_one()
existing = _get_by_trace(db, trace_id)
if existing is not None:
if existing.user_id not in (None, user_id):
raise ComparisonTraceOwnershipError
if existing.user_id is None:
existing.user_id = user_id
if existing.device_id is None and device_id:
existing.device_id = device_id
db.commit()
db.refresh(existing)
existing_at = existing.created_at
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)
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 existing, int(used)
current = now or 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)
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
if used >= DAILY_COMPARE_START_LIMIT:
raise DailyCompareStartLimitExceeded
rec = ComparisonRecord(
trace_id=trace_id,
user_id=user_id,
business_type=business_type or "food",
device_id=device_id,
status="running",
created_at=current,
)
db.add(rec)
db.commit()
db.refresh(rec)
return rec, int(used) + 1
def harvest_running(
db: Session,
*,
@@ -375,19 +458,48 @@ def harvest_abort(
return rec
def _ordered_shop_names(db: Session, user_id: int) -> set[str]:
"""该用户「真实下单」(source='compare')覆盖到的店名集合,用来给比价记录打「已下单」
def _ordered_shop_name_select(user_id: int):
"""该用户「真实下单」(source='compare')覆盖到的店名 select,给「已下单」筛选当子查询
口径与 [_ordered_shop_names] 完全一致,只是时机不同:那边是**拿到本页之后** candidates
反查打标;这边是**分页之前**就要过滤,拿不到 candidates,只能整段下推成子查询
没有先捞成集合再展开 IN (...) 字面量 重度用户下单过的店名可能上千,展开会撞 SQLite
的绑定变量上限,而且又变回了那个随下单量线性变慢的老写法
"""
return select(SavingsRecord.shop_name).where(
SavingsRecord.user_id == user_id,
SavingsRecord.source == "compare",
SavingsRecord.shop_name.is_not(None),
)
def _like_escape(kw: str) -> str:
"""转义 LIKE 通配符(百分号 / 下划线 / 反斜杠),让用户输入只按字面量匹配(配合 escape 参数)。
不转义的话搜一个%就等于把整表拉回来
"""
return kw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _ordered_shop_names(db: Session, user_id: int, candidates: set[str]) -> set[str]:
"""[candidates] 里哪些店名被该用户「真实下单」(source='compare')覆盖过,用来打「已下单」。
只认 compare(归因命中后真实上报),demo 演示数据不算下单上报不带 trace_id,
只能按店名对齐两边店名同源(都来自比价意图识别阶段的门店名 query),精确相等即视为同店
语义=店级:同一家店比价过多次,这些记录会一并标已下单
只查**本页出现过的店名**(candidates limit ),不再把该用户全部下单店名捞回内存:
老写法随下单量线性增长,重度用户几千行全读一遍只为跟 50 条记录取交集空集合直接返回
(避免 IN () 非法)
"""
if not candidates:
return set()
rows = db.execute(
select(SavingsRecord.shop_name).where(
SavingsRecord.user_id == user_id,
SavingsRecord.source == "compare",
SavingsRecord.shop_name.is_not(None),
)
SavingsRecord.shop_name.in_(candidates),
).distinct()
).scalars().all()
return {s for s in rows if s}
@@ -415,17 +527,60 @@ def _ad_coins_by_trace(db: Session, user_id: int, trace_ids: list[str]) -> dict[
return {tid: int(coin) for tid, coin in rows if tid}
# 列表出参(ComparisonRecordOut)根本不读、但 select(ORM) 默认会一并捞回来的重型 JSON 列:
# - raw_payload:done.params 上报体全量,**每条记录都有**(harvest 与 POST 两条写路径都落)。
# 单条几 KB~几十 KB,一页 50 条就是稳定几百 KB~几 MB 的白读 + 白反序列化。
# - llm_calls:每次 LLM 调用的 input_messages + output 全文。只有走老客户端 POST /compare/record
# 的记录才有(_backfill_llm_calls 回填;harvest 路径不落),但有的时候单条就能到 MB 级 —— 一页里
# 混进几条这种记录,整个请求就被它们拖住。
# - llm_price_snapshot:逐模型单价快照,同样只在回填时落。
# 三列全部读出来再被 pydantic 丢掉,是「比价记录/全部记录」页慢的主要来源。
# ⚠️ defer 的列一旦在别处被读到会触发**逐行**懒加载(N+1);列表这条链路(ComparisonRecordOut
# 不声明这三个字段 → 不会 getattr 到)是安全的。详情接口 get_record 不 defer,raw_payload 照常返回。
_LIST_DEFERRED = (
ComparisonRecord.raw_payload,
ComparisonRecord.llm_calls,
ComparisonRecord.llm_price_snapshot,
)
def list_records(
db: Session,
user_id: int,
*,
limit: int = 20,
cursor: int | None = None,
ordered: bool | None = None,
keyword: str | None = None,
) -> tuple[list[ComparisonRecord], int | None]:
"""比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」店级标记 + 「看广告赚的金币」(瞬态,不写库)。"""
stmt = select(ComparisonRecord).where(ComparisonRecord.user_id == user_id)
stmt = (
select(ComparisonRecord)
.where(ComparisonRecord.user_id == user_id)
.options(*(defer(col) for col in _LIST_DEFERRED))
)
if cursor is not None:
stmt = stmt.where(ComparisonRecord.id < cursor)
# 「已下单」tab 与搜索框的过滤都下推到这里,不能留给客户端对整页结果 filter ——
# 分页之后一页里可能一条都不命中,列表看着就是空的/卡住的,得翻很多页才蹦出一条。
if ordered:
stmt = stmt.where(
ComparisonRecord.store_name.in_(_ordered_shop_name_select(user_id))
)
kw = (keyword or "").strip()
if kw:
# product_names 是写路径从 items[].name 派生的普通文本列(items 本身是 JSON,SQLite 下
# 中文被 ensure_ascii 转义,没法直接 LIKE)—— 搜「菜名」靠的就是它。
# ilike:PG 原生 ILIKE,SQLite 渲染成 lower() LIKE lower(),两边都忽略大小写。
pattern = f"%{_like_escape(kw)}%"
stmt = stmt.where(
or_(
ComparisonRecord.store_name.ilike(pattern, escape="\\"),
ComparisonRecord.product_names.ilike(pattern, escape="\\"),
)
)
# 排序与 ix_comparison_user_created(user_id, created_at, id)对齐 —— DESC/DESC 正好是该索引的
# 反向扫,PG 免排序直接取前 limit 条。改排序方向前先想清楚索引还吃不吃得上。
stmt = stmt.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc()).limit(limit)
items = list(db.execute(stmt).scalars().all())
@@ -433,7 +588,10 @@ def list_records(
# 「已下单」标记:本页记录的 store_name 若落在该用户真实下单的店名集合里即 True。
# ordered / ad_coins_earned 均非 ORM 列,仅挂实例上供 ComparisonRecordOut(from_attributes) 读出,不持久化。
ordered_shops = _ordered_shop_names(db, user_id)
page_shops = {it.store_name for it in items if it.store_name}
# ordered=True 时上面已按同一口径(_ordered_shop_name_select)筛过,本页必然全是已下单,
# 省掉这次反查;其余情况照旧按本页店名反查 savings。
ordered_shops = page_shops if ordered else _ordered_shop_names(db, user_id, page_shops)
# 「本次比价看广告赚的金币」:按本页 trace_id 一次性聚合(同 ordered 范式)。
ad_coins = _ad_coins_by_trace(db, user_id, [it.trace_id for it in items])
for it in items:
+39 -2
View File
@@ -14,6 +14,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.coupon_state import (
CouponClaimEvent,
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
@@ -164,11 +165,12 @@ def record_claims(
results: list[dict],
app_env: str | None = None,
) -> int:
"""一批券领取结果幂等写入,返回写入(新增 + 更新)条数
"""一批券领取结果同时写入每日资产表和逐次事件表
results 单项取自 pricebot last_coupon_result / done.coupon_results,识别字段:
coupon_id(必需)/ status(必需)/ name / vendor / reason /(display_count)
(device, coupon_id, 今天) 唯一:重复上报同张券走更新(status 以最后一次为准)
- CouponClaimRecord (device, coupon_id, 今天) 幂等供每日资产口径使用
- CouponClaimEvent (trace_id, coupon_id) 幂等 admin 逐场统计使用
"""
today = today_cn()
written = 0
@@ -208,6 +210,41 @@ def record_claims(
claimed_count=count, trace_id=trace_id, reason=r.get("reason"),
extra=r,
))
if trace_id:
event = db.execute(
select(CouponClaimEvent).where(
CouponClaimEvent.trace_id == trace_id,
CouponClaimEvent.coupon_id == coupon_id,
)
).scalar_one_or_none()
if event is not None:
event.device_id = device_id
event.status = status
event.reason = r.get("reason")
event.vendor = r.get("vendor")
event.coupon_name = r.get("name")
event.extra = r
if user_id is not None:
event.user_id = user_id
if count is not None:
event.claimed_count = count
if app_env is not None:
event.app_env = app_env
else:
db.add(CouponClaimEvent(
trace_id=trace_id,
device_id=device_id,
user_id=user_id,
coupon_id=coupon_id,
claim_date=today,
status=status,
app_env=app_env,
vendor=r.get("vendor"),
coupon_name=r.get("name"),
claimed_count=count,
reason=r.get("reason"),
extra=r,
))
written += 1
if written == 0:
return 0
+74 -4
View File
@@ -21,17 +21,23 @@ def register_or_update(
*,
user_id: int,
device_id: str,
registration_id: str | None,
registration_id: str | None = None,
push_vendor: str | None = None,
push_token: str | None = None,
platform: str = "android",
app_version: str | None = None,
) -> DeviceLiveness:
"""注册设备或更新其 registration_id / 元信息。upsert by (user_id, device_id)。"""
"""注册设备或更新其厂商 push token / 元信息。upsert by (user_id, device_id)。"""
normalized_vendor = _normalize_push_vendor(push_vendor)
normalized_token = push_token.strip() if push_token else None
device = _get(db, user_id=user_id, device_id=device_id)
if device is None:
device = DeviceLiveness(
user_id=user_id,
device_id=device_id,
registration_id=registration_id,
push_vendor=normalized_vendor,
push_token=normalized_token,
platform=platform or "android",
app_version=app_version,
)
@@ -39,6 +45,10 @@ def register_or_update(
else:
if registration_id:
device.registration_id = registration_id
if normalized_vendor:
device.push_vendor = normalized_vendor
if normalized_token:
device.push_token = normalized_token
if platform:
device.platform = platform
if app_version:
@@ -54,7 +64,9 @@ def touch_heartbeat(
user_id: int,
device_id: str,
accessibility_enabled: bool,
registration_id: str | None,
registration_id: str | None = None,
push_vendor: str | None = None,
push_token: str | None = None,
) -> DeviceLiveness:
"""处理一次心跳(心跳也能自注册)。
@@ -69,6 +81,12 @@ def touch_heartbeat(
if registration_id:
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:
device.push_vendor = normalized_vendor
if normalized_token:
device.push_token = normalized_token
device.last_report_protection_on = accessibility_enabled
if accessibility_enabled:
@@ -87,7 +105,7 @@ def touch_heartbeat(
def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]:
"""掉线设备:曾经保护过、当前 alive、心跳超时。
本期只做终端打印检测不推送 不再要求有 registration_id(没接极光 token 的设备也要检出)
即使没有厂商 token 也要检出,后续由 kill_alert_pending 走客户端进 App 后兜底提醒
"""
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
stmt = select(DeviceLiveness).where(
@@ -124,3 +142,55 @@ def ack_kill_alert(db: Session, *, user_id: int, device_id: str) -> None:
if device is not None and device.kill_alert_pending:
device.kill_alert_pending = False
db.commit()
def has_push_target(device: DeviceLiveness | None) -> bool:
"""是否已有厂商直推所需的 vendor + token。"""
return bool(device and device.push_vendor and device.push_token)
def list_push_targets(db: Session, *, user_id: int) -> list[DeviceLiveness]:
"""该用户全部可用厂商推送目标(push_vendor + push_token 双非空),最近更新在前。
(vendor, token) 只留最新一行:同一台手机重装 App device_id 会变
留下 token 相同的旧行,去重防一次业务事件对同一台手机重复推送
"""
stmt = (
select(DeviceLiveness)
.where(
DeviceLiveness.user_id == user_id,
DeviceLiveness.push_vendor.is_not(None),
DeviceLiveness.push_token.is_not(None),
)
.order_by(DeviceLiveness.updated_at.desc(), DeviceLiveness.id.desc())
)
seen: set[tuple[str, str]] = set()
targets: list[DeviceLiveness] = []
for dev in db.execute(stmt).scalars():
if not dev.push_vendor or not dev.push_token: # 空串兜底(旧数据)
continue
key = (dev.push_vendor, dev.push_token)
if key in seen:
continue
seen.add(key)
targets.append(dev)
return targets
def _normalize_push_vendor(push_vendor: str | None) -> str | None:
if not push_vendor:
return None
vendor = push_vendor.strip().lower()
aliases = {
"honor": "honor",
"hihonor": "honor",
"荣耀": "honor",
"vivo": "vivo",
"xiaomi": "xiaomi",
"mi": "xiaomi",
"小米": "xiaomi",
"oppo": "oppo",
"oneplus": "oppo",
"realme": "oppo",
}
return aliases.get(vendor, vendor)
+290
View File
@@ -0,0 +1,290 @@
"""新手引导视频:运营配置读写 + 播放计次 + 发币。
**配置**(开关 / 视频地址 / 前几次 / 每次金币)整体作为一个 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` 上的并发注释
"""
from __future__ import annotations
import uuid
from datetime import datetime
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.models.app_config import AppConfig
from app.models.guide_video import GuideVideoPlay
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, # 每次固定金币
}
_FIELDS = tuple(_DEFAULTS.keys())
# 后台可配范围的护栏:防手滑把次数/金币填成天文数字(配置直接决定发币)。
MAX_PLAYS_LIMIT = 50
REWARD_COIN_LIMIT = 10_000
# ===== 配置 =====
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
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)
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)
if row is None:
row = AppConfig(key=_KEY, value=value, updated_by_admin_id=admin_id)
db.add(row)
else:
row.value = value # 整体重新赋值,SQLAlchemy 才侦测得到变更
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
def update_config(
db: Session,
*,
enabled: bool | None = None,
max_plays: int | None = None,
reward_coin: int | None = None,
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}
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)
return before, after
def set_video(
db: Session, video_url: str | None, *, 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)
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 play_stats(db: Session) -> dict[str, int]:
"""全站播放统计(admin 页展示):总播放次数 / 其中已发币次数。"""
total = int(
db.execute(select(func.count()).select_from(GuideVideoPlay)).scalar_one()
)
granted = int(
db.execute(
select(func.count()).select_from(GuideVideoPlay).where(
GuideVideoPlay.status == "granted"
)
).scalar_one()
)
return {"total_plays": total, "granted_plays": granted}
def start_play(
db: Session, user_id: int, *, scene: str = "coupon", commit: bool = True
) -> dict[str, Any]:
"""决定这次浮层是否放引导视频;命中则**当场计次**并返回 play_token。
返回 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),
}
if not cfg.get("enabled") or not video_url or max_plays <= 0 or used >= max_plays:
return _miss(used)
seq = used + 1
play = GuideVideoPlay(
user_id=user_id,
play_token=uuid.uuid4().hex,
scene=scene,
seq=seq,
video_url=video_url,
coin=0,
status="playing",
completed=0,
started_at=datetime.now(rewards.CN_TZ).replace(tzinfo=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))
return {
"should_play": True,
"video_url": video_url,
"play_token": play.play_token,
"reward_coin": reward_coin,
"seq": seq,
"remaining": max(0, max_plays - seq),
}
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.user_id == user_id,
)
).scalar_one_or_none()
def grant_play(
db: Session, user_id: int, *, play_token: str, completed: bool
) -> dict[str, Any]:
"""按 play_token 发这次引导视频的金币(幂等)。播完 / 中途关闭都发。
返回 {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%')覆盖范围内 —— 两个唯一键在这条路径上都是不生效的。
won = db.execute(
update(GuideVideoPlay)
.where(
GuideVideoPlay.play_token == token,
GuideVideoPlay.user_id == user_id,
GuideVideoPlay.status == "playing",
)
.values(
status="granted",
coin=coin,
completed=1 if completed else 0,
granted_at=datetime.now(rewards.CN_TZ).replace(tzinfo=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"}
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"}
+8 -2
View File
@@ -25,6 +25,7 @@ from app.models.invite import InviteRelation
from app.models.invite_fingerprint import InviteFingerprint
from app.models.user import User
from app.repositories import wallet as crud_wallet
from app.services import notification_events
# 邀请码字符集:去掉易混字符(0/O/1/I/L/B/8/S/5/Z/2),用户口述/手输不易错
_CODE_ALPHABET = "ACDEFGHJKMNPQRTUVWXY34679"
@@ -197,12 +198,13 @@ def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardRes
return CompareRewardResult("inviter_inactive", rel.inviter_user_id)
reward = rewards.INVITE_COMPARE_REWARD_CENTS
inviter_id = inviter.id
rel.compare_reward_granted = True
rel.compare_reward_cents = reward
rel.compare_rewarded_at = datetime.now(timezone.utc)
# 发邀请奖励金到邀请人的独立账户(与金币隔离),ref_id 指向被邀请人便于对账
crud_wallet.grant_invite_cash(
db, inviter.id, reward,
db, inviter_id, reward,
biz_type="invite_reward", ref_id=str(invitee_user_id), remark="好友比价奖励",
)
try:
@@ -210,7 +212,11 @@ def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardRes
except Exception:
db.rollback()
raise
return CompareRewardResult("granted", inviter.id, reward)
# PRD #12 好友下单到账:发奖已 commit,通知邀请人(站内 + push;失败只 log 不影响发奖)
notification_events.notify_invite_order_reward(
db, inviter_user_id=inviter_id, invitee_user_id=invitee_user_id, cash_cents=reward
)
return CompareRewardResult("granted", inviter_id, reward)
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
+318
View File
@@ -0,0 +1,318 @@
"""消息通知中心 数据仓库(落库版,查/写 `notification` 表)。
沿用原 notification_mock 的同名函数(list_notifications / unread_count / mark_read /
insert_sample),由内存 mock 迁到落库,**API 契约不变**
- : user 隔离sent_at 倒序;未读数 / 标记已读同口径
- :`create_notification` 是落库统一入口**业务事件请走 services/notification_events**
(站内消息 + 厂商 push 一起下发,已接入提现回执/反馈审核/爆料通过/好友下单);
`build_sample_card` / `insert_sample` 按类型造样例内容,
`/api/v1/push/test` createNotification push 站内已读联动联调
排序规则:全列表按 sent_at 倒序(最新在前;同秒再按 id 倒序稳定化),不分组
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core import notification_catalog as catalog
from app.models.notification import Notification
# 北京时间:sent_at 统一带 +08:00 下发,前端直接按本地时区渲染「今天/昨天/M月D日」。
_CST = timezone(timedelta(hours=8))
def cash_yuan(cents: int | None) -> str | None:
"""分 → 保留两位小数的元字符串(PRD §3:现金/提现金额保留两位小数)。"""
if cents is None:
return None
return f"{cents // 100}.{cents % 100:02d}"
def as_cst(dt: datetime) -> datetime:
"""把库里取出的时间归一到北京时间(+08:00)再下发,保证接口 sentAt 恒带 +08:00。
SQLite DateTime 不存时区,取出为 naive(存的就是写入时的 CST 墙上时间) 直接贴 +08:00;
PostgreSQL timestamptz 取出为 aware(通常 UTC) 转到 +08:00两端下发口径一致
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=_CST)
return dt.astimezone(_CST)
def _fmt_time(dt: datetime) -> str:
"""信息行里「到账时间」等 value 的展示格式。"""
return dt.strftime("%Y-%m-%d %H:%M")
# ---------------------------------------------------------------------------
# 读:列表 / 未读数 / 标记已读
# ---------------------------------------------------------------------------
def _unread_count(db: Session, user_id: int) -> int:
return int(
db.execute(
select(func.count())
.select_from(Notification)
.where(Notification.user_id == user_id, Notification.is_read.is_(False))
).scalar_one()
)
def list_notifications(
db: Session, user_id: int, *, page: int, page_size: int
) -> tuple[list[Notification], int, int]:
"""分页取通知列表。返回 (当前页条目, 总条数, 未读条数)。"""
total = int(
db.execute(
select(func.count())
.select_from(Notification)
.where(Notification.user_id == user_id)
).scalar_one()
)
unread = _unread_count(db, user_id)
rows = (
db.execute(
select(Notification)
.where(Notification.user_id == user_id)
.order_by(Notification.sent_at.desc(), Notification.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
.scalars()
.all()
)
return list(rows), total, unread
def unread_count(db: Session, user_id: int) -> int:
"""未读总数(首页铃铛角标)。"""
return _unread_count(db, user_id)
def mark_read(
db: Session, user_id: int, *, ids: list[int] | None = None, mark_all: bool = False
) -> tuple[int, int]:
"""标记已读。mark_all=True 全量清零,否则按 ids 逐条置读(不存在的 id 忽略,幂等)。
返回 (本次实际由未读已读的条数, 剩余未读数)
"""
if not mark_all:
wanted = set(ids or [])
if not wanted:
return 0, _unread_count(db, user_id)
stmt = select(Notification).where(
Notification.user_id == user_id, Notification.is_read.is_(False)
)
if not mark_all:
stmt = stmt.where(Notification.id.in_(wanted))
now = datetime.now(timezone.utc)
marked = 0
for n in db.execute(stmt).scalars().all():
n.is_read = True
n.read_at = now
marked += 1
db.commit()
return marked, _unread_count(db, user_id)
# ---------------------------------------------------------------------------
# 写:业务下发入口
# ---------------------------------------------------------------------------
def create_notification(
db: Session,
*,
user_id: int,
type_key: str,
coins: int | None = None,
cash_cents: int | None = None,
info_rows: list[dict[str, str]] | None = None,
extra: dict[str, str] | None = None,
sent_at: datetime | None = None,
dedup_key: str | None = None,
) -> Notification:
"""下发一条站内消息(业务事件统一入口)。type_key 必须是 catalog 的 13 类之一。
dedup_key 非空时受部分唯一索引约束( user+type+dedup_key 未读期间仅一条);
需要同批次/同权限只保留一条未读的调用方,应捕获 IntegrityError 或先查已存在的未读再决定
更新 sent_at,而非重复插入( models/notification uq_notification_user_type_dedup)
"""
catalog.get_type(type_key) # 校验类型合法(未知类型抛 UnknownNotificationType)
row = Notification(
user_id=user_id,
type=type_key,
coins=coins,
cash_cents=cash_cents,
info_rows=info_rows or [],
extra=extra or {},
sent_at=sent_at or datetime.now(_CST),
dedup_key=dedup_key,
)
db.add(row)
db.commit()
db.refresh(row)
return row
# ---------------------------------------------------------------------------
# 样例内容(供 /push/test createNotification 联调;文案对齐 PRD §3)
# ---------------------------------------------------------------------------
def _card_reward_expiring(sent_at: datetime, coins: int = 86, cash: int = 1280, days: int = 3) -> dict:
return {
"coins": coins,
"cash_cents": cash,
"info_rows": [
{
"label": "过期说明",
"value": f"您有{coins}金币和{cash_yuan(cash)}元现金即将失效,"
"完成一次一键领券或一键比价即可激活收益",
},
{"label": "过期时间", "value": f"{days}天后失效"},
],
# batchId:同一批次激活成功后不再重复推送(PRD §2 激活逻辑)
"extra": {"batchId": f"batch_{sent_at:%Y%m%d}"},
}
def _card_reward_expired(sent_at: datetime, coins: int = 35, cash: int = 60) -> dict:
return {
"coins": coins,
"cash_cents": cash,
"info_rows": [
{
"label": "过期说明",
"value": f"您的{coins}金币和{cash_yuan(cash)}元现金已失效,"
"完成一次一键领券或一键比价可赚取新收益",
},
{"label": "过期时间", "value": f"已过期 {sent_at.month}{sent_at.day}日失效"},
],
"extra": {}, # 点击跳赚钱页(tab),无需参数
}
def _card_withdraw_success(sent_at: datetime, cash: int = 50) -> dict:
return {
"cash_cents": cash,
"info_rows": [
{"label": "到账账户", "value": "微信钱包"},
{"label": "到账时间", "value": _fmt_time(sent_at)},
],
"extra": {}, # 无跳转,仅消红点
}
def _card_withdraw_failed(sent_at: datetime, cash: int = 350, reason: str = "微信零钱未实名") -> dict:
return {
"cash_cents": cash,
"info_rows": [
{"label": "失败原因", "value": reason},
{"label": "退回说明", "value": "款项已原路退回现金余额"},
],
"extra": {"withdrawId": "88001"}, # 点击跳提现页
}
def _card_permission(permission: str) -> dict:
# permission ∈ accessibility(无障碍)/ battery(省电策略)/ autostart(自启动)/ overlay(悬浮窗)
# 客户端点击时按此 key 实时检测该权限并弹对应开启弹窗(PRD §2 权限逻辑)。
return {
"info_rows": [
{"label": "说明文案", "value": "未开启将导致核心功能不可用,请尽快开启"},
],
"extra": {"permission": permission},
}
def _card_feedback_reply(feedback_id: str) -> dict:
return {
"info_rows": [
{"label": "说明文案", "value": "快去看看官方给您的回复吧~"},
],
"extra": {"feedbackId": feedback_id}, # 跳反馈历史页并滚动高亮该条(PRD §2)
}
def _card_feedback_reward(sent_at: datetime, coins: int = 300,
reply: str = "感谢反馈,您说的问题已经修复上线,送您的金币请查收~") -> dict:
return {
"coins": coins,
"info_rows": [
{"label": "奖励说明", "value": "感谢您的反馈,您的金币奖励已到账"},
{"label": "官方留言", "value": reply}, # PRD §3:官方留言必填(发奖励必带留言)
{"label": "到账时间", "value": _fmt_time(sent_at)},
],
"extra": {"feedbackId": "3002"},
}
def _card_report_approved(sent_at: datetime, coins: int = 1000, store: str = "蜀大侠火锅") -> dict:
return {
"coins": coins,
"info_rows": [
{"label": "奖励说明", "value": f"您爆料的「{store}」更低价已通过审核,金币奖励已到账"},
{"label": "到账时间", "value": _fmt_time(sent_at)},
],
"extra": {"reportId": "5001"}, # 跳爆料记录页并滚动高亮该条
}
def _card_invite_order_reward(sent_at: datetime, cash: int = 200, nickname: str = "柚子") -> dict:
return {
"cash_cents": cash,
"info_rows": [
{"label": "奖励说明", "value": f"好友「{nickname}」完成首次下单"},
{"label": "到账时间", "value": _fmt_time(sent_at)},
],
"extra": {"inviteeNickname": nickname}, # 跳邀请页(welfare/invite.html?from=notifications)
}
def _card_invite_remind(nickname: str = "阿泽") -> dict:
return {
"info_rows": [
{
"label": "说明文案",
"value": f"好友「{nickname}」已注册,还没完成比价下单,提醒TA完成后你可得2元现金",
},
],
# scrollTo=remind:跳邀请页并自动滚动到底部「提醒好友」模块(PRD §2 #13)
"extra": {"inviteeNickname": nickname, "scrollTo": "remind"},
}
def build_sample_card(type_key: str, sent_at: datetime | None = None) -> dict:
"""按类型生成一份样例卡片内容({coins?, cash_cents?, info_rows, extra}),/push/test 联调用。"""
catalog.get_type(type_key) # 校验 type 合法
now = sent_at or datetime.now(_CST)
builders = {
"reward_expiring": lambda: _card_reward_expiring(now),
"reward_expired": lambda: _card_reward_expired(now),
"withdraw_success": lambda: _card_withdraw_success(now),
"withdraw_failed": lambda: _card_withdraw_failed(now),
"perm_accessibility": lambda: _card_permission("accessibility"),
"perm_battery": lambda: _card_permission("battery"),
"perm_autostart": lambda: _card_permission("autostart"),
"perm_overlay": lambda: _card_permission("overlay"),
"feedback_reply": lambda: _card_feedback_reply("3001"),
"feedback_reward": lambda: _card_feedback_reward(now),
"report_approved": lambda: _card_report_approved(now),
"invite_order_reward": lambda: _card_invite_order_reward(now),
"invite_remind": lambda: _card_invite_remind(),
}
return builders[type_key]()
def insert_sample(db: Session, user_id: int, type_key: str) -> Notification:
"""插入一条该类型的样例未读通知并落库(/push/test createNotification 联调:push extras 带上
它的 id,客户端点击 push 后调 POST /notifications/read {ids:[id]} 即闭环验证已读联动)"""
return create_notification(db, user_id=user_id, type_key=type_key, **build_sample_card(type_key))
+1 -80
View File
@@ -11,12 +11,11 @@ from dataclasses import dataclass
from datetime import timedelta
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import rewards
from app.core.rewards import SIGNIN_CYCLE_LEN, cn_today
from app.models.signin import SigninBoostRecord, SigninRecord
from app.models.signin import SigninRecord
from app.repositories import wallet as crud_wallet
@@ -24,18 +23,6 @@ class AlreadySignedError(Exception):
"""今天已经签过了。"""
class NotSignedTodayError(Exception):
"""今天尚未签到,不能膨胀。"""
class AlreadyBoostedError(Exception):
"""今天签到奖励已经膨胀过。"""
class LastCycleDayBoostBlockedError(Exception):
"""循环最后一天(第 SIGNIN_CYCLE_LEN 天)不允许签到膨胀。"""
@dataclass
class SigninStep:
day: int # 1..14
@@ -141,69 +128,3 @@ def do_signin(db: Session, user_id: int) -> tuple[SigninRecord, int]:
db.commit()
db.refresh(record)
return record, acc.coin_balance
def _today_record(db: Session, user_id: int) -> SigninRecord | None:
today = cn_today()
return db.execute(
select(SigninRecord).where(
SigninRecord.user_id == user_id,
SigninRecord.signin_date == today,
)
).scalar_one_or_none()
def boost_by_ad_ref(
db: Session, user_id: int, ad_ref_id: str
) -> SigninBoostRecord | None:
"""按广告交易号查签到膨胀记录。S2S 发奖后客户端确认用。"""
return db.execute(
select(SigninBoostRecord).where(
SigninBoostRecord.user_id == user_id,
SigninBoostRecord.ad_ref_id == ad_ref_id,
)
).scalar_one_or_none()
def boost_today_signin(
db: Session, user_id: int, *, ad_ref_id: str | None = None, commit: bool = True
) -> tuple[SigninBoostRecord, int]:
"""签到后看广告膨胀:固定补发配置金币。返回 (膨胀记录, 补发后余额)。"""
record = _today_record(db, user_id)
if record is None:
raise NotSignedTodayError
if record.cycle_day == SIGNIN_CYCLE_LEN:
raise LastCycleDayBoostBlockedError
today = record.signin_date
existing = db.execute(
select(SigninBoostRecord).where(
SigninBoostRecord.user_id == user_id,
SigninBoostRecord.signin_date == today,
)
).scalar_one_or_none()
if existing is not None:
raise AlreadyBoostedError
boost = SigninBoostRecord(
user_id=user_id,
signin_date=today,
coin_awarded=rewards.get_signin_boost_coin(db),
ad_ref_id=ad_ref_id,
)
db.add(boost)
try:
acc, _ = crud_wallet.grant_coins(
db, user_id, boost.coin_awarded,
biz_type="signin_boost", ref_id=ad_ref_id or today.isoformat(),
remark=f"签到膨胀 第{record.cycle_day}",
)
if commit:
db.commit()
else:
db.flush()
except IntegrityError as e:
db.rollback()
raise AlreadyBoostedError from e
db.refresh(boost)
return boost, acc.coin_balance
+14 -23
View File
@@ -29,12 +29,12 @@ from app.models.wallet import (
WechatTransferAuthorization,
WithdrawOrder,
)
from app.services import notification_events
# 微信转账终态:成功 / 失败(失败/取消/关闭都退款)
_WX_STATE_SUCCESS = "SUCCESS"
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
_WITHDRAW_ACTIVE_STATUSES = {"reviewing", "pending"}
# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。
# 被拒/转账失败/解绑退回(rejected/failed,均已退款、钱没到手)不在此列 → 新人档恢复可提
# (2026-07-16 修正:此前判定不看状态,解绑微信退回后 0.1 被误判已用、资格永久锁死)。
@@ -68,10 +68,6 @@ class InsufficientCashError(Exception):
"""现金余额不足。"""
class WithdrawTooFrequentError(Exception):
"""提现申请过于频繁,或已有未完成提现单。"""
class WithdrawTierUnavailableError(Exception):
"""该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。"""
@@ -525,6 +521,8 @@ def _refund_withdraw(
order.status = final_status
order.fail_reason = reason[:256]
db.commit()
# 上次退款后没走完终态(如中途崩溃)的补账路径:这里补发通知(dedup 防重)
notification_events.notify_withdraw_failed(db, order)
return
bal = _add_cash(db, order.user_id, order.amount_cents, order.source)
db.add(
@@ -568,6 +566,11 @@ def _refund_withdraw(
fresh_order.status = final_status
fresh_order.fail_reason = reason[:256]
db.commit()
notification_events.notify_withdraw_failed(db, fresh_order)
return
# PRD #4 提现失败通知:所有退款终态(failed/rejected)在此收口下发;
# dedup=out_bill_no,与上面并发路径重复触发时未读期间只落一条。
notification_events.notify_withdraw_failed(db, order)
def _wx_not_found(result: dict) -> bool:
@@ -608,6 +611,7 @@ def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> N
order.status = "success"
order.transfer_bill_no = q["data"].get("transfer_bill_no")
db.commit()
notification_events.notify_withdraw_success(db, order) # PRD #3 提现到账
elif state in _WX_STATE_FAILED:
_refund_withdraw(db, order, reason=reason)
else:
@@ -746,17 +750,8 @@ def create_withdraw(
else:
out_bill_no = uuid.uuid4().hex
active_order_id = db.execute(
select(WithdrawOrder.id).where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.status.in_(_WITHDRAW_ACTIVE_STATUSES),
).limit(1)
).scalar_one_or_none()
if active_order_id is not None:
raise WithdrawTooFrequentError
# 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过
# 客户端刷)。放在幂等返回/在途互斥之后:同号重试仍原样返回旧单,不被档位闸误杀。
# 客户端刷)。放在幂等返回之后:同号重试仍原样返回旧单,不被档位闸误杀。
# allow_sub_min(0.01 调试直发)保持原样放行,不受档位约束;invite_cash 本轮无档位概念不校验。
if source == "coin_cash" and not allow_sub_min:
tier_state = next(
@@ -806,6 +801,7 @@ def create_withdraw(
db.commit()
except IntegrityError:
db.rollback()
# 唯一冲突只可能来自 out_bill_no 幂等键并发重试:原样返回既有单;否则未知冲突,上抛。
existing = db.execute(
select(WithdrawOrder).where(
WithdrawOrder.out_bill_no == out_bill_no, WithdrawOrder.user_id == user_id
@@ -813,14 +809,6 @@ def create_withdraw(
).scalar_one_or_none()
if existing is not None:
return existing
active_order_id = db.execute(
select(WithdrawOrder.id).where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.status.in_(_WITHDRAW_ACTIVE_STATUSES),
).limit(1)
).scalar_one_or_none()
if active_order_id is not None:
raise WithdrawTooFrequentError from None
raise
db.refresh(order)
return order # 待管理员审核;**不在此处打款**
@@ -981,6 +969,8 @@ def _apply_transfer_result(db: Session, order: WithdrawOrder, data: dict) -> Wit
order.status = "success"
db.commit()
db.refresh(order)
if order.status == "success": # 免确认转账直接到账 → PRD #3 提现到账
notification_events.notify_withdraw_success(db, order)
return order
@@ -1130,6 +1120,7 @@ def refresh_withdraw_status(
if state == _WX_STATE_SUCCESS:
order.status = "success"
db.commit()
notification_events.notify_withdraw_success(db, order) # PRD #3 提现到账
elif state in _WX_STATE_FAILED:
_refund_withdraw(db, order, reason=f"微信转账状态 {state}")
elif state == _WX_STATE_WAIT_CONFIRM and cancel_if_unconfirmed:
+44 -2
View File
@@ -43,6 +43,35 @@ class AdRewardStatusOut(BaseModel):
watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数;limit=0 时客户端不据此拦截")
class AdRewardResultOut(BaseModel):
"""按 ad_session_id 查本次广告的**权威发奖结果**(福利页看完视频的弹窗金额只认它)。
S2S 回调是异步的,客户端看完广告立刻轮询多半还查不到记录 这种还没到账返回
200 + status='pending' 让客户端重试,**不返 404**:404 只应表示路由不存在,两者混在
一起客户端没法区分后端没部署再等等纯只读,不产生任何奖励
"""
ad_session_id: str = Field(..., description="回显请求的广告会话 id")
status: str = Field(
...,
description="pending(S2S 未到账,客户端应继续轮询) / granted(已发奖) / capped(当日超限未发) / "
"ecpm_missing(缺 eCPM 未发) / closed_early(提前关闭未发);其余同 AdRewardRecord.status。"
"客户端只在 granted 且 coin>0 时弹窗,其它一律不弹(不显示假数字)",
)
coin: int | None = Field(
None,
description="本次实发金币:granted 为真实到账额;未发奖的状态为 0;pending 为 null",
)
round_coin: int | None = Field(
None,
description="**本轮膨胀累计已发金币**(含本条)——客户端「恭喜累计获得奖励」弹窗显示的就是它。"
"轮 = 用户点「去膨胀」到点「放弃赚钱」之间连看的若干条广告,边界由客户端的 boost_round_id 定。"
"本条不是 granted(capped/closed_early/…)时**仍返本轮累计**,只是这条按 0 计。"
"pending(没记录,取不到轮 id)、或该记录没有 boost_round_id(老客户端 / extra 丢失)时为 null,"
"客户端见 null 退回只显示单条 coin",
)
class EcpmReportIn(BaseModel):
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
@@ -69,6 +98,12 @@ class EcpmReportIn(BaseModel):
description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券,"
"供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空",
)
exposure_ms: int | None = Field(
None,
ge=0,
le=86_400_000,
description="本条广告真实在屏曝光毫秒数;小于 1000ms 时收益强制按 0 计算。旧客户端不传则保持原口径",
)
app_env: str | None = Field(
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
)
@@ -105,13 +140,20 @@ class TestGrantIn(BaseModel):
reward_scene: str = Field(
"reward_video",
description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)",
description="模拟发奖场景。当前只支持 reward_video(普通激励视频);signin_boost(签到膨胀)"
"已于 2026-07 下线,传它会 422",
)
ad_session_id: str | None = Field(
None, min_length=8, max_length=64,
description="本次广告会话 id(与 ecpm-report 同值)。reward_video 场景下据此查回客户端"
"已上报的真实 eCPM 来按公式发奖;查不到或 eCPM≤0 时兜底 200,保证本地联调仍出非零金币",
)
boost_round_id: str | None = Field(
None, max_length=64,
description="本次广告属于哪一轮膨胀。正式链路走穿山甲 S2S 的 mediaExtra,test-grant 不经 S2S、"
"拿不到 extra,故在 body 里补一个——不传的话 debug 包 reward-result 的 round_coin 恒为 null,"
"「弹窗 40 → 60 → toast +60」那套验收在本地跑不起来",
)
class TestGrantOut(BaseModel):
@@ -119,7 +161,7 @@ class TestGrantOut(BaseModel):
granted: bool = Field(..., description="本次是否真的发了金币(达每日上限则 False)")
status: str = Field(
..., description="granted / capped / not_signed / already_boosted / last_day / unknown_scene"
..., description="granted / capped / ecpm_missing / unknown_scene"
)
coin: int = Field(..., description="本次发放金币(capped 时为 0)")
used_today: int = Field(..., description="今日已成功发奖次数")
+14 -1
View File
@@ -13,7 +13,6 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
# ===== 上报请求 =====
class ComparisonItemIn(BaseModel):
@@ -198,6 +197,20 @@ class ComparisonRecordCreatedOut(BaseModel):
id: int = Field(..., description="写入(或已存在)的记录 id")
class CompareStartReserveIn(BaseModel):
"""Reserve one authenticated comparison start before the agent begins."""
trace_id: str = Field(..., 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
used: int
remaining: int
class CompareStatsOut(BaseModel):
"""「我的」页省钱战绩卡(比价口径)聚合。"""
+22 -1
View File
@@ -3,12 +3,15 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
class DeviceRegisterRequest(BaseModel):
device_id: str
# registration_id 为旧极光字段,新推送链路统一使用 push_vendor + push_token。
registration_id: str | None = None
push_vendor: str | None = None
push_token: str | None = None
platform: str = "android"
app_version: str | None = None
@@ -18,6 +21,8 @@ class HeartbeatRequest(BaseModel):
source: str = "service" # service | app
accessibility_enabled: bool = True
registration_id: str | None = None
push_vendor: str | None = None
push_token: str | None = None
class DeviceOut(BaseModel):
@@ -26,6 +31,8 @@ class DeviceOut(BaseModel):
id: int
device_id: str
registration_id: str | None
push_vendor: str | None
push_token: str | None
ever_protected: bool
liveness_state: str
last_heartbeat_at: datetime | None
@@ -46,3 +53,17 @@ class LivenessOut(BaseModel):
class LivenessAckRequest(BaseModel):
device_id: str
class PushTestRequest(BaseModel):
device_id: str
delay_seconds: int = Field(default=10, ge=0, le=60)
push_vendor: str | None = None
push_token: str | None = None
registration_id: str | None = None
class PushTestOut(BaseModel):
ok: bool = True
delay_seconds: int
has_push_token: bool
+36
View File
@@ -0,0 +1,36 @@
"""新手引导视频(领券等候浮层前 N 次替代广告)的客户端请求/响应契约。"""
from __future__ import annotations
from pydantic import BaseModel, Field
class GuideVideoStartIn(BaseModel):
"""开播询问。scene 目前只有 coupon(领券浮层);预留给日后比价等场景。"""
scene: str = Field(default="coupon", max_length=16)
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 # 发完这次还剩几次
class GuideVideoRewardIn(BaseModel):
"""播完或中途关闭都调这个;completed 只做留痕,两者都发币。"""
play_token: str = Field(min_length=1, max_length=64)
completed: bool = False
class GuideVideoRewardOut(BaseModel):
"""granted=True 表示本次调用真的入账(重复上报为 False,coin 是已发金额)。"""
granted: bool
coin: int
status: str
+128
View File
@@ -0,0 +1,128 @@
"""消息通知中心 请求/响应契约。
命名约定:本组接口按 PRD 前端契约使用 **camelCase**(sentAt / isRead / pageSize ),
与库内其他 snake_case 接口不同PRD 与前端原型(notifications.html) camelCase 对接,
需求方接口清单亦明确写作 sentAt / isRead,故整组遵循之响应序列化走 pydantic alias
字段说明都写在 Field(description=...) ,起服务后打开 /docs 即是给前端的在线文档
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
class _CamelModel(BaseModel):
"""出参统一 camelCase(alias);populate_by_name 允许服务端代码仍用 snake_case 构造。"""
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
class InfoRow(_CamelModel):
"""卡片信息行(PRD §3「信息行」列),前端按 label: value 逐行渲染。"""
label: str = Field(description="行标签,如「过期说明」「到账账户」「失败原因」")
value: str = Field(description="行内容(已按 PRD 文案拼好变量,前端直接展示)")
class NotificationItem(_CamelModel):
"""一条通知卡片。
卡片头部三要素:categoryLabel(分类标签)+ 未读红点(isRead=false 时展示)+ 时间(sentAt)
时间显示规则(前端处理):今天今天;昨天昨天;当年M月D日;跨年YYYY年M月D日
"""
id: int = Field(description="通知 id(未读消除、push 联动都用它)")
category: str = Field(
description="分类 key:withdraw_assistant=提现助手 / system=系统通知 / "
"feedback=我的反馈 / report=我的爆料 / invite=好友邀请"
)
category_label: str = Field(description="分类中文标签(卡片头部直接展示)")
type: str = Field(
description="类型 key(13 种,决定点击行为,见 PRD §2):reward_expiring 即将失效 / "
"reward_expired 已失效 / withdraw_success 提现成功 / withdraw_failed 提现失败 / "
"perm_accessibility 无障碍异常 / perm_battery 省电策略异常 / "
"perm_autostart 自启动异常 / perm_overlay 悬浮窗异常 / "
"feedback_reply 官方回复 / feedback_reward 反馈奖励 / "
"report_approved 爆料审核通过 / invite_order_reward 好友下单奖励 / "
"invite_remind 好友催单提醒"
)
card_style: str = Field(
description="卡片版式:dual_amount 双金额卡 / withdraw 提现卡 / plain_text 纯文本卡 / "
"coin_reward 金币奖励卡 / friend_cash 好友现金卡"
)
title: str = Field(description="卡片标题(双金额/提现/金币奖励/好友现金卡标题居中)")
coins: int | None = Field(
default=None,
description="金币数(整数,不带小数)。dual_amount / coin_reward 卡有值,其余 null",
)
cash_cents: int | None = Field(
default=None,
description="现金金额,单位【分】。dual_amount / withdraw / friend_cash 卡有值,其余 null",
)
cash_yuan: str | None = Field(
default=None,
description="现金金额展示串(元,保留两位小数,如 \"12.80\"),与 cashCents 同源,可直接展示",
)
info_rows: list[InfoRow] = Field(
description="信息行列表(label: value),内容已按 PRD §3 拼好,前端逐行渲染即可"
)
action_text: str | None = Field(
default=None,
description="操作行文案(如「立即激活您的收益」「去开启」);null=无操作行(提现成功卡)。"
"注意:点击目标是整张卡片,不区分卡片主体和操作行",
)
extra: dict[str, Any] = Field(
description="点击跳转所需业务参数,按 type 取用:perm_* → {permission: accessibility|battery|"
"autostart|overlay}(点击时实时检测该权限);feedback_* → {feedbackId};"
"report_approved → {reportId};withdraw_failed → {withdrawId};"
"invite_order_reward → {inviteeNickname};invite_remind → "
"{inviteeNickname, scrollTo:\"remind\"};reward_expiring → {batchId}"
)
sent_at: datetime = Field(description="下发时间(ISO8601 带 +08:00 时区),前端按显示规则格式化")
is_read: bool = Field(description="是否已读;false 时分类标签右侧展示 6px 红点(#E53935)")
class NotificationListOut(_CamelModel):
"""GET /api/v1/notifications 出参。列表已按时间倒序排好(最新在前,**不分组**;
PRD §1 "按分类分组"为笔误,已确认取消),前端无需再排"""
items: list[NotificationItem] = Field(description="当前页通知卡片")
page: int = Field(description="当前页码(1 起)")
page_size: int = Field(description="每页条数")
total: int = Field(description="全部通知总条数(含已读)")
has_more: bool = Field(description="是否还有下一页")
unread_count: int = Field(description="当前未读总数(与 /notifications/unread-count 同口径,省一次请求)")
class UnreadCountOut(_CamelModel):
"""GET /api/v1/notifications/unread-count 出参(首页铃铛角标)。"""
count: int = Field(description="未读总条数(精确值)")
badge_text: str | None = Field(
description="角标展示文案:超过 99 返回 \"99+\";等于 0 返回 null(整个角标隐藏,不展示空红点)"
)
class MarkReadRequest(_CamelModel):
"""POST /api/v1/notifications/read 入参,两种模式二选一:
- `{"ids": [90001, 90002]}` 单条/多条置读点击某张卡片点击 push 落地后同步置读;
- `{"all": true}` 全量清零进入通知中心(或退出时)自动清零(PRD §4)
同时传时 all=true 优先;不存在/已读的 id 自动忽略(幂等,可放心重试)
"""
ids: list[int] | None = Field(default=None, description="要置为已读的通知 id 列表")
all: bool = Field(default=False, description="true=清空该用户全部未读")
class MarkReadOut(_CamelModel):
"""POST /api/v1/notifications/read 出参。"""
ok: bool = Field(description="固定 true(参数非法时走 400,不会到这里)")
marked_count: int = Field(description="本次实际由未读变为已读的条数(重复请求会是 0)")
unread_count: int = Field(description="处理后的剩余未读总数,可直接刷新铃铛角标")
+11
View File
@@ -46,6 +46,17 @@ class AdConfigPublicOut(BaseModel):
withdrawal_ad_enabled: bool # 提现激励视频开关(关=客户端直接放行提现)
class HuaweiReviewOut(BaseModel):
"""华为审核开关下发给客户端(不鉴权,引导页在登录前就要展示)。
客户端只需读 onboarding_closable 决策;mode 仅供排查问题时看后台切成了哪态
只有华为 ROM(HarmonyOS/EMUI,不含荣耀 MagicOS)的客户端才会来拉这个端点
"""
mode: str = "default" # default | review
onboarding_closable: bool = False # 快速设置权限步是否允许用户退出(mode == review)
class AppVersionOut(BaseModel):
"""最新 App 版本信息(OTA 检查更新,不鉴权)。

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