Compare commits

..

17 Commits

Author SHA1 Message Date
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
guke 48037f03fd docs: OpenObserve 接口 QPS/耗时可观测设计 spec (#145)
openobserve上报

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #145
2026-07-20 18:55:38 +08:00
guke 130a7dff29 docs(welfare): 15天不活跃清零金币/现金 设计文档(spec) (#144)
修改INACTIVITY_RESET_ENABLED语义,为false时清空金币操作只记录审计日志,不执行操作。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #144
2026-07-19 00:14:03 +08:00
guke a86688ccfb feat(welfare): 15 天不活跃清零(金币+折算现金,邀请金不清)+ 预警 + admin 活跃口径统一 (#143)
背景 / 目标
连续 15 天(北京自然日)没有「首页可见 / 发起比价 / 发起领券」行为的用户判定为流失,每日自动清零其金币 + 折算现金;清零前按可配置节奏预警;全程留审计。纯登录不算活跃;邀请奖励金物理隔离、不清(产品红线)。

改了什么
活跃口径共享模块 app/repositories/activity.py:worker(清零/预警)与 admin(最近活跃)单一真源,防漂移。
清零业务逻辑 inactivity.py:选取 / 逐用户清零(行锁 + 幂等)/ 预警分档 + streak 去重 / run_once 组合,预警故障逐用户隔离、绝不阻塞清零。
每日 worker inactivity_reset_worker.py(仿 daily_exchange:文件锁 + 北京日守卫 + RUN_HOUR 门槛 + 总闸)+ main.py lifespan 接线。
可插拔通知器 notifier.py(v1 LogNotifier 日志占位,预留 JPush/短信)。
配置 INACTIVITY_*(阈值 / 预警档 / 执行点 / 通道 / 开关)。
admin 最近活跃口径改用共享模块(移除 last_login_at、纳入 show/home、以 created_at 为基线)。
审计:inactivity_reset_log + inactivity_notification_log 两表 + 钱包流水双写(biz_type=inactivity_reset,ref_id 交叉)。
文档:设计 spec / 实现 plan / docs/database/ 两表字典。
关键产品决策
邀请金不清:只清金币 + 折算现金,invite_cash_balance_cents 原封(仅快照入审计)。
活跃口径 = max(created_at, 首页可见, 比价, 领券),不含 last_login_at;首页可见 = event=show + page=home。
时间边界:北京自然日 0 点对齐(见 activity.reset_cutoff)。
总闸默认关,灰度验证后再开。
数据库变更
新表:inactivity_reset_log、inactivity_notification_log。
analytics_event 新增复合覆盖索引 ix_analytics_event_active (event, page, user_id, created_at)(活跃口径聚合热点)。
修复了 base 上的迁移多头(135e79414fd0 与 phone_rebind_log 同从 comparison_llm_cost 分叉)→ 加空 merge 修订,alembic upgrade head 恢复单头正常。
⚠️ 上线注意(合并后 / 开总闸前)
INACTIVITY_RESET_ENABLED 默认 false;开启前提 = show/home 埋点全量铺满——否则"只登录不操作"且注册满 15 天的老用户会落到 created_at 基线被误清。
前端依赖:Android 端需在首页可见上报 event=show + page=home(携带登录后的 user_id)。
admin「最近活跃」口径变化(去登录、纳入 home_view、created_at 基线):属预期变化,需产品/运营知会;与 DAU(_period_active_user_ids,仍含登录)是两套指标。
清零对 C 端「金币流水」可见(biz_type=inactivity_reset,备注「15天不活跃清零」)。
灰度:先只看预警/清零名单对不对,再开总闸。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #143
2026-07-18 19:11:45 +08:00
149 changed files with 13920 additions and 606 deletions
+65 -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
@@ -137,3 +185,19 @@ PANGLE_REPORT_SECURITY_KEY=
# GroMore AppId(报表 site_id 维度)→ 应用环境;默认取现网两个应用,按需覆盖。
PANGLE_REPORT_SITE_ID_PROD=5830519
PANGLE_REPORT_SITE_ID_TEST=5832303
# ===== 可观测(OpenObserve 接口指标)=====
# 采集每个接口 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker,见 deploy/openobserve/)。
# 默认关;开启需 ENABLED=true 且填 USER/PASSWORD(与 docker-compose 里 root 账号一致)。
# 未开/缺凭证 → 中间件透传、worker 不启动,整套 no-op,不影响业务。
OBSERVE_ENABLED=false
OBSERVE_ENDPOINT=http://localhost:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=admin@shaguabijia.local
OBSERVE_PASSWORD=Complexpass#123
# 进阶(一般不用改):攒批间隔秒 / 单批最大条数 / 有界队列上限(满则丢) / 上报超时秒
OBSERVE_FLUSH_INTERVAL_SEC=5
OBSERVE_BATCH_MAX=200
OBSERVE_QUEUE_MAX=10000
OBSERVE_TIMEOUT_SEC=5
+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,68 @@
"""add inactivity tables
Revision ID: 135e79414fd0
Revises: comparison_llm_cost
Create Date: 2026-07-16 18:31:02.105929
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '135e79414fd0'
down_revision: Union[str, Sequence[str], None] = 'comparison_llm_cost'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"inactivity_reset_log",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("coin_balance_before", sa.Integer(), nullable=False),
sa.Column("cash_balance_cents_before", sa.Integer(), nullable=False),
sa.Column("invite_cash_balance_cents_before", sa.Integer(), nullable=False),
sa.Column("last_active_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("inactive_days", sa.Integer(), nullable=False),
sa.Column("reason", sa.String(length=32), nullable=False),
sa.Column("reset_at", sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_inactivity_reset_log_user_id"), ["user_id"], unique=False)
batch_op.create_index(batch_op.f("ix_inactivity_reset_log_reset_at"), ["reset_at"], unique=False)
op.create_table(
"inactivity_notification_log",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("stage", sa.Integer(), nullable=False),
sa.Column("inactive_days", sa.Integer(), nullable=False),
sa.Column("coin_balance", sa.Integer(), nullable=False),
sa.Column("cash_balance_cents", sa.Integer(), nullable=False),
sa.Column("invite_cash_balance_cents", sa.Integer(), nullable=False),
sa.Column("channel", sa.String(length=16), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_inactivity_notification_log_user_id"), ["user_id"], unique=False)
batch_op.create_index(batch_op.f("ix_inactivity_notification_log_created_at"), ["created_at"], unique=False)
def downgrade() -> None:
with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_created_at"))
batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_user_id"))
op.drop_table("inactivity_notification_log")
with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_reset_at"))
batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_user_id"))
op.drop_table("inactivity_reset_log")
@@ -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,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")
@@ -0,0 +1,38 @@
"""analytics_event 活跃口径复合索引
Revision ID: analytics_active_idx
Revises: 135e79414fd0
Create Date: 2026-07-18 17:35:00.000000
给 analytics_event 加活跃口径热点复合索引 (event, page, user_id, created_at):
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
一侧;集成到 main 时需 `alembic merge` 合并 phone_rebind_log 那个头(与本迁移无关的既有问题)。
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "analytics_active_idx"
down_revision: Union[str, Sequence[str], None] = "135e79414fd0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"ix_analytics_event_active",
"analytics_event",
["event", "page", "user_id", "created_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_analytics_event_active", table_name="analytics_event")
@@ -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")
@@ -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,25 @@
"""merge inactivity(analytics_active_idx) + phone_rebind_log heads
Revision ID: merge_active_phone
Revises: analytics_active_idx, phone_rebind_log
Create Date: 2026-07-18 18:52:34.001148
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'merge_active_phone'
down_revision: Union[str, Sequence[str], None] = ('analytics_active_idx', 'phone_rebind_log')
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,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。
+2
View File
@@ -30,6 +30,7 @@ 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.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
@@ -109,4 +110,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:
+74 -7
View File
@@ -21,6 +21,9 @@ 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)。"""
@@ -86,7 +89,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 +113,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((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0))
rows = db.execute(
select(
CouponClaimRecord.trace_id,
succeeded.label("succeeded"),
func.count().label("tried"),
)
.where(
CouponClaimRecord.trace_id.in_(trace_ids),
CouponClaimRecord.status.in_(_SLOT_TRIED),
)
.group_by(CouponClaimRecord.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(
CouponClaimRecord.coupon_id,
CouponClaimRecord.coupon_name,
CouponClaimRecord.status,
CouponClaimRecord.reason,
)
.where(CouponClaimRecord.trace_id == trace_id)
.order_by(CouponClaimRecord.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": {
@@ -249,10 +307,17 @@ 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,
@@ -276,15 +341,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:
+144 -81
View File
@@ -5,13 +5,12 @@
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, case, desc, func, or_, select
from sqlalchemy.orm import Session
from app.admin.repositories.stats import COMPARE_START_EVENT, COUPON_START_EVENT
from app.core import rewards
from app.core.config import settings
from app.models.ad_feed_reward import AdFeedRewardRecord
@@ -32,10 +31,7 @@ from app.models.wallet import (
InviteCashTransaction,
WithdrawOrder,
)
from app.repositories import ad_ecpm
# 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券)
_ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT)
from app.repositories import activity, ad_ecpm
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
@@ -88,49 +84,6 @@ def offset_paginate(
return items, next_cursor, total
def _last_active_parts():
"""「最近活跃」的两个按 user_id 预聚合派生表(最近开始比价/领券事件、最近领券发起)。
活跃口径与大盘 DAU/留存一致(2026-07-05 产品定:进入 App≈登录 last_login_at +
发起比价 real_compare_start + 发起领券 real_coupon_start/claim_started)。
用 LEFT JOIN 预聚合而非相关标量子查询:后者在 PG 上对 users 每行各跑一个 SubPlan
(排序键、range 筛选、offset_paginate 的 count 三处叠加),埋点表大了会拖垮列表接口;
预聚合借 analytics_event.event 索引只扫两类 start 事件,每次查询聚合一次。
"""
ev_agg = (
select(
AnalyticsEvent.user_id.label("user_id"),
func.max(AnalyticsEvent.created_at).label("last_at"),
)
.where(
AnalyticsEvent.user_id.is_not(None),
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
)
.group_by(AnalyticsEvent.user_id)
.subquery()
)
eng_agg = (
select(
CouponPromptEngagement.user_id.label("user_id"),
func.max(CouponPromptEngagement.created_at).label("last_at"),
)
.where(
CouponPromptEngagement.user_id.is_not(None),
CouponPromptEngagement.engage_type == "claim_started",
)
.group_by(CouponPromptEngagement.user_id)
.subquery()
)
return ev_agg, eng_agg
def _norm_utc(dt: datetime | None) -> datetime | None:
"""naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。"""
if dt is None:
return None
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
def _attach_last_active(db: Session, users: list[User]) -> None:
"""给本页用户瞬态挂 last_active_at(非 DB 列,供 AdminUserListItem from_attributes 读)。
@@ -144,7 +97,7 @@ def _attach_last_active(db: Session, users: list[User]) -> None:
select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at))
.where(
AnalyticsEvent.user_id.in_(uids),
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
activity.active_event_condition(),
)
.group_by(AnalyticsEvent.user_id)
).all()
@@ -161,9 +114,9 @@ def _attach_last_active(db: Session, users: list[User]) -> None:
)
for u in users:
candidates = [
_norm_utc(u.last_login_at),
_norm_utc(ev_map.get(u.id)),
_norm_utc(eng_map.get(u.id)),
activity.norm_utc(u.created_at), # baseline 由 last_login_at 改为 created_at(登录不算活跃)
activity.norm_utc(ev_map.get(u.id)),
activity.norm_utc(eng_map.get(u.id)),
]
u.last_active_at = max((c for c in candidates if c is not None), default=None)
@@ -191,16 +144,12 @@ def list_users(
(口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
# 最近活跃 = max(最近登录, 最近行为事件, 最近领券发起)。PG 用 GREATEST;SQLite 标量 max()
# 任一参数 NULL 即返回 NULL,故 LEFT JOIN 未命中侧 coalesce 到 last_login_at 兜底
# (注册即登录,该列恒非空)。派生表 1:1(按 user_id 聚合),outerjoin 不放大行数,
# offset_paginate 的 count 不受影响。
ev_agg, eng_agg = _last_active_parts()
greatest = func.greatest if db.get_bind().dialect.name == "postgresql" else func.max
last_active = greatest(
User.last_login_at,
func.coalesce(ev_agg.c.last_at, User.last_login_at),
func.coalesce(eng_agg.c.last_at, User.last_login_at),
# 最近活跃 = max(注册时间, 最近行为事件, 最近领券发起)。baseline 由 last_login_at 改为 created_at
#(登录不代表在用 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(
User.created_at, ev_agg, eng_agg, db.get_bind().dialect.name
)
stmt = (
select(User)
@@ -260,6 +209,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,
*,
@@ -269,30 +257,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)),
@@ -307,6 +284,92 @@ 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 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_durations = sorted(db.execute(
select(ComparisonRecord.total_ms).where(
*conditions,
ComparisonRecord.status == "success",
ComparisonRecord.total_ms.is_not(None),
)
).scalars().all())
cancelled_durations = sorted(db.execute(
select(ComparisonRecord.total_ms).where(
*conditions,
ComparisonRecord.status == "cancelled",
ComparisonRecord.total_ms.is_not(None),
)
).scalars().all())
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": (
int(sum(success_durations) / len(success_durations) + 0.5)
if success_durations else None
),
"p5_duration_ms": _comparison_percentile(success_durations, 0.05),
"p50_duration_ms": _comparison_percentile(success_durations, 0.5),
"p95_duration_ms": _comparison_percentile(success_durations, 0.95),
"p99_duration_ms": _comparison_percentile(success_durations, 0.99),
"cancelled": cancelled,
"cancelled_rate": cancelled / started if started else None,
"cancelled_p5_ms": _comparison_percentile(cancelled_durations, 0.05),
"cancelled_p50_ms": _comparison_percentile(cancelled_durations, 0.5),
"cancelled_p95_ms": _comparison_percentile(cancelled_durations, 0.95),
}
def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。"""
rec = db.get(ComparisonRecord, record_id)
+127 -25
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,7 +25,7 @@ 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
@@ -37,14 +37,13 @@ REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward")
# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。
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",
"signin_boost",
"price_report_reward",
"feedback_reward",
)
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
MEITUAN_CPS_SETTLED_STATUS = "6"
@@ -61,7 +60,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 +93,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 +108,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 +258,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 +328,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 +504,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 +680,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 +719,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"))],
)
+10 -2
View File
@@ -19,6 +19,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",
@@ -134,7 +135,11 @@ def approve_feedback(
)
db.commit()
db.refresh(fb)
return FeedbackOut.model_validate(fb)
out = FeedbackOut.model_validate(fb)
# PRD #10 反馈奖励:采纳发金币后通知用户(站内 + push,必带官方留言)。
# 业务已 commit,通知失败只 log 不影响审核结果。
notification_events.notify_feedback_reward(db, fb)
return out
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
@@ -179,4 +184,7 @@ def reject_feedback(
)
db.commit()
db.refresh(fb)
return FeedbackOut.model_validate(fb)
out = FeedbackOut.model_validate(fb)
# PRD #9 官方回复:未采纳也回复了用户(原因/留言用户端可见),通知去反馈历史页查看。
notification_events.notify_feedback_reply(db, fb)
return out
+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)
+5 -1
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
@@ -24,6 +25,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",
@@ -84,6 +86,8 @@ def approve_price_report(
detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
)
db.commit()
# PRD #11 爆料审核通过:发金币后通知用户(站内 + push)。业务已 commit,通知失败只 log。
notification_events.notify_report_approved(db, rep)
return OkResponse()
+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)
+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"]
+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)
+10 -1
View File
@@ -115,13 +115,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(列表页「复制调试链接」靠它)。
+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,
+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(),
)
+100 -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 # 扫描周期
@@ -178,6 +230,15 @@ class Settings(BaseSettings):
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
# === 15 天不活跃清零(app.core.inactivity_reset_worker,worker 常驻)===
# ENABLED 只决定是否**真清**:false(默认)= 只记审计名单、不动钱(dry-run,灰度看名单);
# true = 真清金币 + 折算现金(邀请金不清)。看准名单后再置 true。
INACTIVITY_RESET_ENABLED: bool = False
INACTIVITY_RESET_DAYS: int = 15 # 不活跃阈值(天),第 (N+1) 日 0 点清
INACTIVITY_WARN_DAYS_BEFORE: str = "7,2" # 清零前几天各推一次;""=不推。逗号分隔
INACTIVITY_RESET_RUN_HOUR: int = 3 # 北京时间每日执行点(0-23)
INACTIVITY_NOTIFY_CHANNEL: str = "log" # log(占位) / jpush / sms
INACTIVITY_RESET_CHECK_INTERVAL_SEC: int = 1800 # worker 唤醒间隔(秒)
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
@@ -198,6 +259,19 @@ class Settings(BaseSettings):
"""免确认收款授权可用 = 微信支付凭证齐全 + 授权回调地址已配。"""
return bool(self.wxpay_configured and self.WXPAY_AUTH_NOTIFY_URL)
@property
def inactivity_warn_stages(self) -> list[int]:
"""解析 INACTIVITY_WARN_DAYS_BEFORE → 降序去重的提前天数列表。
丢弃非数字 / <=0 / >=RESET_DAYS 的项(空串 空列表 = 不推)"""
out: list[int] = []
for part in (self.INACTIVITY_WARN_DAYS_BEFORE or "").split(","):
part = part.strip()
if part.isdigit():
v = int(part)
if 0 < v < self.INACTIVITY_RESET_DAYS and v not in out:
out.append(v)
return sorted(out, reverse=True)
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。
# 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自
@@ -325,6 +399,31 @@ class Settings(BaseSettings):
return []
return [o.strip() for o in self.CORS_ALLOW_ORIGINS.split(",") if o.strip()]
# ===== 可观测(OpenObserve 接口指标)=====
# 采集每个接口的 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker)。
# 默认关(prod 安全):未开启 → 中间件透传、worker 不启动,整套 no-op。
# 开启需 ENABLED=true 且 ENDPOINT/USER/PASSWORD 齐全(见 observe_configured)。
OBSERVE_ENABLED: bool = False
OBSERVE_ENDPOINT: str = "http://localhost:5080" # OpenObserve base URL
OBSERVE_ORG: str = "default" # 组织名
OBSERVE_STREAM: str = "app_requests" # stream 名(首次上报自动建)
OBSERVE_USER: str = "" # Basic auth 邮箱
OBSERVE_PASSWORD: str = "" # Basic auth 密码/token
OBSERVE_FLUSH_INTERVAL_SEC: float = 5.0 # worker 最长攒批间隔
OBSERVE_BATCH_MAX: int = 200 # 单批最大事件数
OBSERVE_QUEUE_MAX: int = 10000 # 有界队列上限,满则丢
OBSERVE_TIMEOUT_SEC: float = 5.0 # 上报 HTTP 超时
@property
def observe_configured(self) -> bool:
"""观测上报可用 = 总开关开 且 endpoint/账号/密码齐全(缺则整套 no-op)。"""
return bool(
self.OBSERVE_ENABLED
and self.OBSERVE_ENDPOINT
and self.OBSERVE_USER
and self.OBSERVE_PASSWORD
)
@property
def is_prod(self) -> bool:
return self.APP_ENV == "prod"
-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,
+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:
+145
View File
@@ -0,0 +1,145 @@
"""15 天不活跃清零的进程内每日任务。
仿 daily_exchange_worker:App 启动自带, `INACTIVITY_RESET_CHECK_INTERVAL_SEC` 醒一次,
跨进北京新的一天且到达 `INACTIVITY_RESET_RUN_HOUR`(默认 3 )后跑一轮 `run_once`(预警 + 清零)
健壮性:
- **逐用户幂等**:清完余额=0 次日不再匹配;预警按 streak 去重启动补跑 / 多次唤醒 / 重启都安全
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑
- **常驻 + dry-run 默认**:worker 一直跑;INACTIVITY_RESET_ENABLED=false(默认)只记审计名单
不动钱(dry-run 灰度看名单),=true 才真清
这是不可逆批量资金操作(清空金币 + 折算现金,**邀请现金不清**)口径见
app.repositories.inactivity / app.repositories.activity
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from collections.abc import Iterator
from datetime import date, datetime
from pathlib import Path
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
from app.core.rewards import CN_TZ, cn_today
from app.db.session import SessionLocal
from app.integrations.notifier import get_notifier
from app.repositories import inactivity as inactivity_repo
logger = logging.getLogger("shagua.inactivity")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "inactivity_reset.lock"
def _cn_today() -> date:
return cn_today()
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]:
"""同机多进程保护:同一时间只允许一个清零 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 _run_once_entry() -> dict:
"""跑一轮(预警 + 清零)。独立开 Session。"""
notifier = get_notifier(settings.INACTIVITY_NOTIFY_CHANNEL)
with SessionLocal() as db:
return inactivity_repo.run_once(
db,
notifier=notifier,
reset_days=settings.INACTIVITY_RESET_DAYS,
warn_stages=settings.inactivity_warn_stages,
today=_cn_today(),
dry_run=not settings.INACTIVITY_RESET_ENABLED, # ENABLED=false → 只记审计名单、不清
)
async def _run_loop() -> None:
interval = max(60, int(settings.INACTIVITY_RESET_CHECK_INTERVAL_SEC))
lock_stale_after = max(interval * 3, 1800)
with _single_instance_lock(lock_stale_after) as lock_acquired:
if not lock_acquired:
logger.warning("inactivity reset skipped: another worker owns lock")
return
await _run_locked_loop(interval)
async def _run_locked_loop(interval: int) -> None:
logger.info(
"inactivity reset worker started interval=%ss run_hour=%s mode=%s",
interval,
settings.INACTIVITY_RESET_RUN_HOUR,
"clear" if settings.INACTIVITY_RESET_ENABLED else "dry-run(audit-only)",
)
# 本进程上次跑过的北京日;None=尚未跑过本进程(当天到点即补)。
last_run: date | None = None
try:
while True:
try:
_touch_lock()
today = _cn_today()
hour = datetime.now(CN_TZ).hour
if last_run != today and hour >= int(settings.INACTIVITY_RESET_RUN_HOUR):
result = await asyncio.to_thread(_run_once_entry)
last_run = today
logger.info("inactivity reset done date=%s result=%s", today, result)
except SQLAlchemyError:
logger.exception("inactivity reset db error")
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
logger.exception("inactivity reset unexpected error")
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("inactivity reset worker stopped")
raise
def start_inactivity_reset_worker() -> asyncio.Task | None:
# worker 常驻(不再有"完全关"档);INACTIVITY_RESET_ENABLED 只决定是否**真清**:
# false(默认)= 只记审计名单(dry-run,不动钱),true = 真清金币+现金。
return asyncio.create_task(_run_loop(), name="inactivity-reset")
async def stop_inactivity_reset_worker(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
+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
]
+110
View File
@@ -0,0 +1,110 @@
"""接口指标埋点:有界事件队列 + 纯 ASGI 中间件。
每个 HTTP 请求测总耗时抓路由模板 + 状态码,非阻塞塞进有界队列; observe_worker
后台批量上报到 OpenObserve请求路径上无任何 I/O未配置观测时中间件直接透传
"""
from __future__ import annotations
import asyncio
import os
import time
from starlette.routing import Match
from app.core.config import settings
# 不采集的路径(纯噪音):健康检查。
_SKIP_PATHS = frozenset({"/health"})
# 未匹配路由(404/扫描器)归一到此,防维度爆炸。
_UNMATCHED = "__unmatched__"
# service 字段:与 logging.py 同源(LOG_SERVICE_NAME),默认 app-server。
_SERVICE = os.getenv("LOG_SERVICE_NAME", "app-server")
# 有界事件队列(懒创建,见 get_queue):首次取用时在运行中的 loop 里建,避免 import 期
# 无 loop 的边角问题;put_nowait/get_nowait 不需运行中的 loop → 可在无 loop 下测试。
_queue: asyncio.Queue[dict] | None = None
# 队列满时的丢弃计数,worker 定期取出打日志。
_dropped = 0
def get_queue() -> asyncio.Queue[dict]:
"""返回全局有界事件队列(懒创建)。测试可 monkeypatch 模块级 _queue 换成小队列。"""
global _queue
if _queue is None:
_queue = asyncio.Queue(maxsize=settings.OBSERVE_QUEUE_MAX)
return _queue
def take_dropped() -> int:
"""取出并清零累计丢弃数(供 worker 打点)。"""
global _dropped
n, _dropped = _dropped, 0
return n
def record_event(event: dict) -> None:
"""非阻塞入队;队列满则丢弃当前事件并计数。永不抛异常、永不阻塞请求。"""
global _dropped
try:
get_queue().put_nowait(event)
except asyncio.QueueFull:
_dropped += 1
def _resolve_route(scope) -> str:
"""从 scope 取路由模板(如 /things/{tid})。优先 scope['route'](现代 Starlette
路由后写入);取不到则手动匹配一次(老版本兜底);仍无 __unmatched__(404/扫描器)"""
route = scope.get("route")
path = getattr(route, "path", None)
if path:
return path
app_ = scope.get("app")
router = getattr(app_, "router", None)
for candidate in getattr(router, "routes", []):
try:
match, _ = candidate.matches(scope)
except Exception: # noqa: BLE001 - 匹配兜底,任一路由异常不影响整体
continue
if match == Match.FULL and getattr(candidate, "path", None):
return candidate.path
return _UNMATCHED
class RequestMetricsMiddleware:
"""纯 ASGI 中间件:测每个 http 请求耗时,记 method/route/status/duration。
放在最外层(main.py CORS 之后 add),测到含 CORS 的完整耗时未配置观测 透传
"""
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http" or not settings.observe_configured:
await self.app(scope, receive, send)
return
if scope.get("path") in _SKIP_PATHS:
await self.app(scope, receive, send)
return
start = time.perf_counter()
status_holder = {"status": 500} # 下游异常未产出 response 时兜底 500
async def send_wrapper(message) -> None:
if message["type"] == "http.response.start":
status_holder["status"] = message["status"]
await send(message)
try:
await self.app(scope, receive, send_wrapper)
finally:
duration_ms = (time.perf_counter() - start) * 1000.0
record_event({
"_timestamp": int(time.time() * 1_000_000), # µs,OpenObserve 时间列
"service": _SERVICE,
"env": settings.APP_ENV,
"method": scope.get("method", ""),
"route": _resolve_route(scope),
"status": status_holder["status"],
"duration_ms": round(duration_ms, 3),
})
+128
View File
@@ -0,0 +1,128 @@
"""接口指标后台上报 worker:批量 drain 事件队列 → POST 到 OpenObserve。
对齐 heartbeat_monitor_worker 等的 start_*/stop_* 形态best-effort 遥测:catch 全部
异常,上报失败直接丢批不重试未配置观测 start 返回 None(不启动),整套 no-op
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import httpx
from app.core.config import settings
from app.core.observe import get_queue, take_dropped
logger = logging.getLogger("shagua.observe")
# 上报用的 httpx client,start 时建、stop 时关。
_client: httpx.AsyncClient | None = None
async def _collect_batch() -> list[dict]:
"""等到 ≥1 条(或到 flush 间隔)后,连抽到 BATCH_MAX 条或抽空。超时且空 → 返回 []。"""
queue = get_queue()
batch: list[dict] = []
try:
first = await asyncio.wait_for(
queue.get(), timeout=settings.OBSERVE_FLUSH_INTERVAL_SEC
)
except asyncio.TimeoutError: # noqa: UP041 - 3.10 兼容:该版 wait_for 抛的 asyncio.TimeoutError ≠ 内置 TimeoutError
return batch
batch.append(first)
while len(batch) < settings.OBSERVE_BATCH_MAX:
try:
batch.append(queue.get_nowait())
except asyncio.QueueEmpty:
break
return batch
async def _post_batch(client: httpx.AsyncClient, batch: list[dict]) -> None:
"""POST 一批事件到 OpenObserve 的 _json ingest 端点。非 2xx 仅告警。"""
url = f"/api/{settings.OBSERVE_ORG}/{settings.OBSERVE_STREAM}/_json"
resp = await client.post(url, json=batch)
if resp.status_code >= 300:
logger.warning(
"observe ingest failed status=%s body=%s",
resp.status_code,
resp.text[:200],
)
async def _run_loop(client: httpx.AsyncClient) -> None:
try:
while True:
batch = await _collect_batch()
dropped = take_dropped()
if dropped:
logger.warning("observe dropped %d events (queue full)", dropped)
if not batch:
continue
try:
await _post_batch(client, batch)
except Exception: # noqa: BLE001 - best-effort 遥测,失败丢批不重试、不退出
logger.warning(
"observe post batch failed, dropped %d events",
len(batch),
exc_info=True,
)
except asyncio.CancelledError:
logger.info("observe worker stopped")
raise
def start_observe_worker() -> asyncio.Task | None:
"""启动上报 worker。未配置观测 → 返回 None(no-op)。约定每进程只调一次(lifespan)。"""
global _client
if not settings.observe_configured:
return None
if _client is not None:
# 约定 start 每进程只调一次;已启动则不重复建 client(避免泄漏旧连接池)。
logger.warning("observe worker already started; ignoring duplicate start")
return None
_client = httpx.AsyncClient(
base_url=settings.OBSERVE_ENDPOINT,
auth=(settings.OBSERVE_USER, settings.OBSERVE_PASSWORD),
timeout=settings.OBSERVE_TIMEOUT_SEC,
)
logger.info(
"observe worker started endpoint=%s org=%s stream=%s",
settings.OBSERVE_ENDPOINT,
settings.OBSERVE_ORG,
settings.OBSERVE_STREAM,
)
return asyncio.create_task(_run_loop(_client), name="observe-worker")
async def stop_observe_worker(task: asyncio.Task | None) -> None:
"""收尾:cancel worker → best-effort 发最后一批 → 关 client。"""
global _client
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
dropped = take_dropped() # 收口:补记最后一个 flush 窗口累计的丢弃数,不让账丢在关停期
if dropped:
logger.warning("observe dropped %d events (queue full) before shutdown", dropped)
if _client is not None:
# worker 已停,安全 drain 剩余并 best-effort 发最后一批(短超时,不卡关停);
# 超过一批(BATCH_MAX)的剩余直接丢,不做多轮 flush(best-effort,关停从速)。
try:
queue = get_queue()
final: list[dict] = []
while len(final) < settings.OBSERVE_BATCH_MAX:
try:
final.append(queue.get_nowait())
except asyncio.QueueEmpty:
break
if final:
await asyncio.wait_for(
_post_batch(_client, final), timeout=settings.OBSERVE_TIMEOUT_SEC
)
except Exception: # noqa: BLE001 - 关停期尽力而为,失败忽略
pass
await _client.aclose()
_client = None
+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"))
+43
View File
@@ -0,0 +1,43 @@
"""不活跃预警通知器(可插拔)。
v1 仅日志占位(LogNotifier):现状无真实推送能力(极光只用于一键登录解密 + 设备心跳告警,
心跳 worker 也只打印),先把清零主流程 + 审计做扎实后续实现同协议的 JPushNotifier /
SmsNotifier 即可替换,worker/repo 不改
"""
from __future__ import annotations
import logging
from typing import Protocol
logger = logging.getLogger("shagua.inactivity")
class InactivityNotifier(Protocol):
channel: str
def warn(self, *, user_id: int, coin: int, cash_cents: int,
stage: int, days_until_reset: int) -> str:
"""发预警(只涉及会被清的金币 + 折算现金;邀请现金不清、不预警)。
返回状态:'sent' / 'failed' / 'placeholder'"""
...
class LogNotifier:
"""占位实现:只打印,不真推。参照 heartbeat_monitor_worker「本期先不接推送」先例。"""
channel = "log"
def warn(self, *, user_id: int, coin: int, cash_cents: int,
stage: int, days_until_reset: int) -> str:
logger.warning(
"[inactivity-warn] user=%s coin=%s cash_cents=%s stage=T-%s days_until_reset=%s",
user_id, coin, cash_cents, stage, days_until_reset,
)
return "placeholder"
def get_notifier(channel: str) -> InactivityNotifier:
"""按配置返回通知器。未实现的通道(jpush/sms)暂回退 LogNotifier 占位。"""
# 后续:if channel == "jpush": return JPushNotifier()
# if channel == "sms": return SmsNotifier()
return LogNotifier()
+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
+22
View File
@@ -31,8 +31,10 @@ from app.api.v1.device import router as device_router
from app.api.v1.feedback import router as feedback_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
@@ -49,7 +51,16 @@ from app.core.heartbeat_monitor_worker import (
start_heartbeat_monitor,
stop_heartbeat_monitor,
)
from app.core.inactivity_reset_worker import (
start_inactivity_reset_worker,
stop_inactivity_reset_worker,
)
from app.core.logging import setup_logging
from app.core.observe import RequestMetricsMiddleware
from app.core.observe_worker import (
start_observe_worker,
stop_observe_worker,
)
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
from app.core.withdraw_reconcile_worker import (
start_withdraw_reconcile_worker,
@@ -80,12 +91,16 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
reconcile_task = start_withdraw_reconcile_worker()
heartbeat_task = start_heartbeat_monitor()
daily_exchange_task = start_daily_exchange_worker()
observe_task = start_observe_worker()
inactivity_task = start_inactivity_reset_worker()
try:
yield
finally:
await stop_heartbeat_monitor(heartbeat_task)
await stop_withdraw_reconcile_worker(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()
logger.info("shutting down")
@@ -107,6 +122,9 @@ if settings.cors_origins_list:
allow_headers=["*"],
)
# 接口指标埋点(放在 CORS 之后 = 最外层:测到含 CORS 的完整耗时)。未配置观测时中间件自 no-op。
app.add_middleware(RequestMetricsMiddleware)
@app.get("/health", tags=["meta"])
def health() -> dict[str, str]:
@@ -132,6 +150,10 @@ app.include_router(savings_router)
app.include_router(ad_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)
+6 -1
View File
@@ -27,10 +27,15 @@ from app.models.coupon_state import ( # noqa: F401
CouponSession,
)
from app.models.feedback import Feedback # noqa: F401
from app.models.inactivity import ( # noqa: F401
InactivityNotificationLog,
InactivityResetLog,
)
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
@@ -38,7 +43,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。
+8 -1
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, BigInteger, DateTime, Integer, String, func
from sqlalchemy import JSON, BigInteger, DateTime, Index, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -23,6 +23,13 @@ from app.db.base import Base
class AnalyticsEvent(Base):
__tablename__ = "analytics_event"
__table_args__ = (
# 活跃口径聚合热点(activity.active_event_condition + last_active_subqueries):
# 按 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"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+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)
+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)
+58
View File
@@ -0,0 +1,58 @@
"""15 天不活跃清零相关表。
- inactivity_reset_log:每次清零一行,记清零前三桶余额快照 + 原因 + 判定时活跃时间/不活跃天数,
供纠纷排查(需求①)清零同时另写 2 条钱包流水(金币 + 折算现金,biz_type=inactivity_reset),
资金流可逐笔回溯**邀请现金是产品红线不清零**,invite_cash_balance_cents_before 仅为清零时
仍保留的邀请现金快照(便于排查非被清金额; wallet.CoinAccount 注释)
- inactivity_notification_log:每次预警一行,记推送时余额快照 + 档位 + 通道 + 状态,
兼作"预警去重"依据(created_at > last_active)"待推送"占位 outbox(v1 通道=log)
append-only,不更新user_id 只索引不设外键( analytics_event,避免删用户级联/历史留痕)
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class InactivityResetLog(Base):
__tablename__ = "inactivity_reset_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
coin_balance_before: Mapped[int] = mapped_column(Integer, nullable=False)
cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False)
invite_cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False)
last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
inactive_days: Mapped[int] = mapped_column(Integer, nullable=False)
reason: Mapped[str] = mapped_column(String(32), nullable=False)
reset_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<InactivityResetLog id={self.id} user_id={self.user_id} coin={self.coin_balance_before}>"
class InactivityNotificationLog(Base):
__tablename__ = "inactivity_notification_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
stage: Mapped[int] = mapped_column(Integer, nullable=False) # 提前天数档(如 7 / 2)
inactive_days: Mapped[int] = mapped_column(Integer, nullable=False)
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False)
cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False)
invite_cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False)
channel: Mapped[str] = mapped_column(String(16), nullable=False) # log / jpush / sms
status: Mapped[str] = mapped_column(String(16), nullable=False) # placeholder / sent / failed
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<InactivityNotificationLog id={self.id} user_id={self.user_id} stage={self.stage}>"
+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}>"
+95
View File
@@ -0,0 +1,95 @@
"""活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。
口径 = max(User.created_at, AnalyticsEvent[首页可见 home_visible + 比价 + 领券], CouponPromptEngagement[claim_started])
**不含 last_login_at**(登录/re-login 不代表在用 App);created_at 为恒非空基线
清零/预警按北京自然日 0 点对齐( reset_cutoff)
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ, cn_today
from app.models.analytics_event import AnalyticsEvent
from app.models.coupon_state import CouponPromptEngagement
# —— 活跃口径事件(与"用户管理"口径一致)——
# 首页可见:前端埋点确认 event=home_visible(首页进入可视区时触发,单一 event 名即可判定)。
HOME_VISIBLE_EVENT = "home_visible"
COMPARE_START_EVENT = "real_compare_start" # 发起比价(含浮窗触发)
COUPON_START_EVENT = "real_coupon_start" # 发起领券
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=home_visible)
发起比价 发起领券worker 子查询与 admin 展示共用,单一真源"""
return AnalyticsEvent.event.in_(ACTIVE_EVENTS)
def as_utc(value: datetime) -> datetime:
"""任意 datetime → tz-aware UTC(无时区按 UTC 解释)。用于与 DateTime(timezone=True) 列比较,
比较绝对时刻与会话时区无关(口径同 admin queries._as_utc)"""
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def norm_utc(dt: datetime | None) -> datetime | None:
"""naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。"""
if dt is None:
return None
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
def cn_midnight_utc(d: date) -> datetime:
"""北京 d 日 00:00 → tz-aware UTC datetime。"""
return as_utc(datetime(d.year, d.month, d.day, tzinfo=CN_TZ))
def reset_cutoff(reset_days: int, today: date | None = None) -> datetime:
"""应清零边界(tz-aware UTC):last_active < 此值 ⟺ 距末次活跃已满 reset_days 天(北京 0 点对齐)。
= 北京 00:00 of (today (reset_days 1)):reset_days=15today=1/20 北京 1/6 00:00"""
today = today or cn_today()
return cn_midnight_utc(today - timedelta(days=reset_days - 1))
def last_active_subqueries(db: Session):
"""两个按 user_id 预聚合的派生表:最近活跃事件(见 active_event_condition)、
最近领券发起(claim_started)返回 (ev_sub, eng_sub)口径同 admin,LEFT JOIN """
ev_sub = (
select(
AnalyticsEvent.user_id.label("user_id"),
func.max(AnalyticsEvent.created_at).label("last_at"),
)
.where(AnalyticsEvent.user_id.is_not(None), active_event_condition())
.group_by(AnalyticsEvent.user_id)
.subquery()
)
eng_sub = (
select(
CouponPromptEngagement.user_id.label("user_id"),
func.max(CouponPromptEngagement.created_at).label("last_at"),
)
.where(
CouponPromptEngagement.user_id.is_not(None),
CouponPromptEngagement.engage_type == ACTIVE_ENGAGE_TYPE,
)
.group_by(CouponPromptEngagement.user_id)
.subquery()
)
return ev_sub, eng_sub
def last_active_expr(base_col, ev_sub, eng_sub, dialect: str):
"""max(base_col, 最近活跃事件, 最近领券) 的 SQL 表达式。PG 用 greatest、SQLite 用 max。
子聚合缺失(未命中) coalesce base_col(= User.created_at,恒非空基线)"""
greatest = func.greatest if dialect == "postgresql" else func.max
return greatest(
base_col,
func.coalesce(ev_sub.c.last_at, base_col),
func.coalesce(eng_sub.c.last_at, base_col),
)
+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
+83 -8
View File
@@ -7,8 +7,8 @@ from __future__ import annotations
from datetime import datetime
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
@@ -375,19 +375,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 +444,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 +505,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:
+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)
+198
View File
@@ -0,0 +1,198 @@
"""15 天不活跃清零业务逻辑(纯同步,可单测)。worker 只是它的 asyncio 外壳。
活跃口径复用 app.repositories.activity;清零走 wallet.grant_*(负数出账写流水 commit)
逐用户独立事务,一个失败不影响其余
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from sqlalchemy import or_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.integrations.notifier import InactivityNotifier
from app.models.inactivity import InactivityNotificationLog, InactivityResetLog
from app.models.user import User
from app.models.wallet import CoinAccount
from app.repositories import activity
from app.repositories import wallet as wallet_repo
logger = logging.getLogger("shagua.inactivity")
RESET_BIZ_TYPE = "inactivity_reset"
RESET_REMARK = "15天不活跃清零"
# 清零候选口径:金币或折算现金有余额即入选。**邀请现金不算**——它是产品红线、不清零
# (见 wallet.CoinAccount 注释),只有邀请现金余额的用户没有可清项,故不入选。
_ANY_BALANCE = or_(
CoinAccount.coin_balance > 0,
CoinAccount.cash_balance_cents > 0,
)
def _base_query(db: Session):
"""select(user_id, last_active, 三桶余额),join CoinAccount + 两活跃子查询。"""
ev_sub, eng_sub = activity.last_active_subqueries(db)
dialect = db.get_bind().dialect.name
last_active = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect)
stmt = (
select(
User.id.label("user_id"),
last_active.label("last_active"),
CoinAccount.coin_balance,
CoinAccount.cash_balance_cents,
CoinAccount.invite_cash_balance_cents,
)
.join(CoinAccount, CoinAccount.user_id == User.id)
.outerjoin(ev_sub, ev_sub.c.user_id == User.id)
.outerjoin(eng_sub, eng_sub.c.user_id == User.id)
)
return stmt, last_active
def _cn_date(dt: datetime) -> date:
"""datetime → 北京自然日(naive 视为 UTC)。"""
return activity.norm_utc(dt).astimezone(CN_TZ).date()
def _inactive_days(last_active: datetime, today: date) -> int:
return (today - _cn_date(last_active)).days
def select_inactive_users(db: Session, *, cutoff: datetime):
"""应清零用户:last_active < cutoff 且金币/折算现金有余额(邀请现金不清、不计)。
返回 Row 列表(值已快照,可跨 commit)"""
stmt, last_active = _base_query(db)
stmt = stmt.where(_ANY_BALANCE, last_active < activity.as_utc(cutoff))
return db.execute(stmt).all()
def clear_user(db: Session, *, user_id: int, last_active: datetime, inactive_days: int,
reason: str, dry_run: bool = False) -> bool:
"""单用户清零(独立事务、行锁)。金币 + 折算现金归零 + 写审计 + 2 条流水;**邀请现金不清**
(产品红线, wallet.CoinAccount 注释),仅作快照记入审计返回是否真处理了(有可清余额)
dry_run=True:**只写审计名单不动钱不写流水**(灰度看名单) streak 去重 streak
已记过(reset_at > last_active)就跳,避免 worker 每日重复记"""
acc = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
coin, cash, invite = acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents
if coin == 0 and cash == 0: # 邀请现金不清,故不算"有可清余额"
return False
if dry_run and db.execute(
select(InactivityResetLog.id).where(
InactivityResetLog.user_id == user_id,
InactivityResetLog.reset_at > activity.as_utc(last_active),
).limit(1)
).first():
return False # dry-run:本 streak 已记过审计,不重复记
log = InactivityResetLog(
user_id=user_id, coin_balance_before=coin, cash_balance_cents_before=cash,
invite_cash_balance_cents_before=invite, last_active_at=activity.norm_utc(last_active),
inactive_days=inactive_days, reason=reason,
)
db.add(log)
db.flush() # 拿 log.id 作 ref_id 交叉链接审计↔流水
if not dry_run: # dry-run 只记审计名单,不真出账
ref = str(log.id)
if coin:
wallet_repo.grant_coins(db, user_id, -coin, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK)
if cash:
wallet_repo.grant_cash(db, user_id, -cash, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK)
# 邀请现金(invite_cash_balance_cents)刻意不动:两本账物理隔离、邀请金是产品红线。
db.commit()
return True
def run_reset_once(db: Session, *, reset_days: int, today: date, dry_run: bool = False) -> dict:
"""扫一轮清零。逐用户独立 commit,失败隔离。dry_run=True 只记审计名单、不动钱(见 clear_user)。"""
stats = {"scanned": 0, "cleared": 0, "failed": 0}
cutoff = activity.reset_cutoff(reset_days, today)
reason = f"inactive_{reset_days}d" + ("_dryrun" if dry_run else "")
rows = select_inactive_users(db, cutoff=cutoff) # 先物化,避免边遍历边 commit
for row in rows:
stats["scanned"] += 1
idays = _inactive_days(row.last_active, today)
try:
if clear_user(db, user_id=row.user_id, last_active=row.last_active,
inactive_days=idays, reason=reason, dry_run=dry_run):
stats["cleared"] += 1
except SQLAlchemyError:
db.rollback()
stats["failed"] += 1
return stats
def select_warn_candidates(db: Session, *, clear_cutoff: datetime, warn_hi: datetime):
"""预警候选:clear_cutoff <= last_active < warn_hi 且有可清余额(即已进预警窗、尚未到清零)。"""
stmt, last_active = _base_query(db)
stmt = stmt.where(
_ANY_BALANCE,
last_active >= activity.as_utc(clear_cutoff),
last_active < activity.as_utc(warn_hi),
)
return db.execute(stmt).all()
def run_warn_once(db: Session, notifier: InactivityNotifier, *,
reset_days: int, warn_stages: list[int], today: date) -> dict:
"""扫一轮预警。每人取"最紧急的已到达档",按 streak 去重(notification_log.created_at > last_active)。
预警只涉及会被清的金币 + 折算现金;邀请现金不清不预警(仅在 notification_log 记快照)
逐用户 try/except 隔离:单用户通知器抛错 / DB 错不阻断其余,也绝不能拖累后续清零"""
stats = {"warned": 0, "warn_skipped": 0, "warn_failed": 0}
if not warn_stages:
return stats
clear_cutoff = activity.reset_cutoff(reset_days, today) # 到此即清零,不再预警
warn_hi = activity.reset_cutoff(reset_days - max(warn_stages), today) # 最早预警档边界
ascending = sorted(warn_stages) # 最紧急(最小 k)在前
for row in select_warn_candidates(db, clear_cutoff=clear_cutoff, warn_hi=warn_hi):
idays = _inactive_days(row.last_active, today)
stage = next((k for k in ascending if idays >= reset_days - k), None)
if stage is None: # 防御:候选已在预警窗内、stage 必命中,此分支实际不可达
continue
try:
already = db.execute(
select(InactivityNotificationLog.id).where(
InactivityNotificationLog.user_id == row.user_id,
InactivityNotificationLog.stage == stage,
InactivityNotificationLog.created_at > activity.as_utc(row.last_active),
).limit(1)
).first()
if already:
stats["warn_skipped"] += 1
continue
status = notifier.warn(
user_id=row.user_id, coin=row.coin_balance, cash_cents=row.cash_balance_cents,
stage=stage, days_until_reset=reset_days - idays,
)
db.add(InactivityNotificationLog(
user_id=row.user_id, stage=stage, inactive_days=idays,
coin_balance=row.coin_balance, cash_balance_cents=row.cash_balance_cents,
invite_cash_balance_cents=row.invite_cash_balance_cents, # 快照,不参与"将清"额度
channel=notifier.channel, status=status,
))
db.commit()
stats["warned"] += 1
except Exception: # noqa: BLE001 - 单用户预警失败(通知器抛错/DB 错)隔离,不阻断其余、不拖累清零
db.rollback()
stats["warn_failed"] += 1
return stats
def run_once(db: Session, *, notifier: InactivityNotifier, reset_days: int,
warn_stages: list[int], today: date, dry_run: bool = False) -> dict:
"""一轮完整任务:先预警(阶段 A)再清零(阶段 B)。返回合并统计。
预警整段异常也**绝不阻塞清零**清零是核心不可逆资金操作,不能被通知故障拖住
dry_run=True(灰度默认):只记审计名单不清**也不预警**(不通知一个不会发生的清零)"""
warn = {"warned": 0, "warn_skipped": 0, "warn_failed": 0}
if not dry_run:
try:
warn = run_warn_once(db, notifier, reset_days=reset_days, warn_stages=warn_stages, today=today)
except Exception: # noqa: BLE001 - 预警阶段整体失败(如候选查询失败)也要继续清零
logger.exception("inactivity warn phase failed; proceeding to reset")
db.rollback()
warn = {"warned": 0, "warn_skipped": 0, "warn_failed": 0, "warn_phase_error": 1}
reset = run_reset_once(db, reset_days=reset_days, today=today, dry_run=dry_run)
return {**warn, **reset}
+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
+12
View File
@@ -29,6 +29,7 @@ from app.models.wallet import (
WechatTransferAuthorization,
WithdrawOrder,
)
from app.services import notification_events
# 微信转账终态:成功 / 失败(失败/取消/关闭都退款)
_WX_STATE_SUCCESS = "SUCCESS"
@@ -525,6 +526,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 +571,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 +616,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:
@@ -981,6 +990,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 +1141,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="今日已成功发奖次数")
+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
+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 检查更新,不鉴权)。
+99
View File
@@ -0,0 +1,99 @@
"""厂商推送(测试/联调)接口契约。与消息中心同族,出参统一 camelCase。"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
class _CamelModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
class PushVendorStatus(_CamelModel):
vendor: str = Field(description="厂商 key:honor / huawei / xiaomi / oppo / vivo")
label: str = Field(description="厂商中文名")
configured: bool = Field(description="服务端凭据是否齐全(齐全才能真发,mock 不受影响)")
missing_keys: list[str] = Field(description="缺失的 .env 配置键;configured=true 时为空")
class PushVendorsOut(_CamelModel):
vendors: list[PushVendorStatus] = Field(description="5 个厂商的配置状态")
class PushTemplateOut(_CamelModel):
type: str = Field(description="通知类型 key(13 种,与消息中心 type 一致)")
category: str = Field(description="分类 key")
category_label: str = Field(description="分类中文标签")
card_style: str = Field(description="站内卡片版式")
push_title: str = Field(description="push 标题(≤11 字固定文案,PRD §5)")
push_body_sample: str = Field(description="push 正文示例(模板用 PRD 示例值渲染后的效果)")
push_body_template: str = Field(description="push 正文模板原文,{var} 为变量占位")
variables: list[str] = Field(description="模板变量名列表(调 /push/test 时可在 vars 里覆盖)")
sample_vars: dict[str, str] = Field(description="各变量的 PRD 示例值(vars 未覆盖时的缺省)")
class PushTemplatesOut(_CamelModel):
templates: list[PushTemplateOut] = Field(description="13 种通知类型的 push 模板(PRD 编号顺序)")
class PushTestRequest(_CamelModel):
"""POST /api/v1/push/test 入参。三种发送内容来源(优先级从高到低):
1. 直接指定 title + content;
2. 指定 type(13 种之一) PRD §5 模板渲染,vars 可覆盖模板变量;
3. 都不传 发一条通用测试文案
推送目标:pushToken 直填, deviceId 反查该用户已注册设备(/api/v1/device/register 上报过的)
"""
vendor: str = Field(
default="",
description="厂商:honor/huawei/xiaomi/oppo/vivo(中文「华为」「小米」等别名也识别)。"
"留空时用 deviceId 对应设备上报的 push_vendor",
)
push_token: str = Field(default="", description="厂商 push token / regId;留空则走 deviceId 反查")
device_id: str = Field(default="", description="设备 id(客户端 DeviceId.get());用于反查 token")
type: str = Field(
default="",
description="通知类型 key(13 种,见 GET /push/templates);留空且未直接给 title/content 时发通用测试文案",
)
vars: dict[str, str] = Field(
default_factory=dict,
description="覆盖 push 模板变量,如 {\"coins\":\"520\",\"cash\":\"6.66\"};缺省用 PRD 示例值",
)
title: str = Field(default="", description="直接指定标题(优先于 type 模板)")
content: str = Field(default="", description="直接指定正文(优先于 type 模板)")
create_notification: bool = Field(
default=False,
description="true=同时往该用户的消息中心 mock 列表插入一条同类型未读通知,push extras 带上它的"
" notificationId → 可闭环验证「点 push → 落地 → 调 /notifications/read 消红点」联动"
"(仅 type 为 13 种类型之一时生效)",
)
mock: bool = Field(
default=True,
description="true(默认)=不真调厂商 API,返回渲染结果(联调安全);false=真发,要求该厂商凭据已配置",
)
class PushTestOut(_CamelModel):
ok: bool = Field(description="发送(或 mock 渲染)成功")
mock: bool = Field(description="本次是否 mock(未真调厂商 API)")
vendor: str = Field(description="实际使用的厂商 key(已归一化)")
title: str = Field(description="实际下发的 push 标题")
body: str = Field(description="实际下发的 push 正文")
extras: dict[str, str] = Field(
description="随 push 下发的自定义键值(客户端深链用):type 必有;createNotification=true 时带"
" notificationId 及该通知的业务参数(feedbackId / permission / …)"
)
notification_id: int | None = Field(
default=None, description="createNotification=true 时新插入的站内 mock 通知 id"
)
missing_keys: list[str] = Field(
default_factory=list,
description="该厂商仍缺失的配置键(mock 发送时提示「真发前还需配什么」;真发时必为空)",
)
vendor_response: dict[str, Any] | None = Field(
default=None, description="真发时厂商 API 的原始响应(mock 时为 null)"
)
-10
View File
@@ -223,16 +223,6 @@ class SigninResultOut(BaseModel):
coin_balance: int = Field(..., description="签到后金币余额")
class SigninBoostRequest(BaseModel):
ad_ref_id: str | None = Field(None, description="广告会话/交易号。当前开发期可空,后续接 S2S 时回填")
class SigninBoostResultOut(BaseModel):
coin_awarded: int = Field(..., description="本次膨胀补发金币")
coin_balance: int = Field(..., description="膨胀补发后金币余额")
signin_date: str = Field(..., description="被膨胀的签到日期 YYYY-MM-DD")
# ===== 任务 =====
class TaskOut(BaseModel):
+269
View File
@@ -0,0 +1,269 @@
"""消息通知中心:业务事件 → 站内消息 + 厂商 push 的统一下发口。
PRD消息通知中心真实业务触发在此收口(替代 /push/test 的样例数据),已接入:
#3 withdraw_success 提现到账(repositories/wallet 各「pending→success」转换点)
#4 withdraw_failed 提现失败/退回(repositories/wallet._refund_withdraw,含审核拒绝)
#9 feedback_reply 官方回复(admin 反馈审核「拒绝」,带用户可见原因/留言)
#10 feedback_reward 反馈奖励(admin 反馈审核「采纳」发金币,必带官方留言)
#11 report_approved 爆料审核通过(admin 上报更低价「通过」发金币)
#12 invite_order_reward 好友下单到账(repositories/invite.try_reward_on_compare 发奖后)
行为约定(调用方唯一需要知道的两条):
1. **绝不抛异常**通知只是业务的副产物,站内消息落库失败/推送失败只 log,
绝不让提现退款审核发奖等主流程回滚或报错
2. **必须在业务事务 commit 之后调用**内部会再 commit( notification );
若在业务半途调用,会把调用方未提交的脏状态一并提交
去重:各事件用业务主键做 dedup_key(提现单号/反馈 id/爆料 id/被邀请人 id),配合
notification 表的部分唯一索引,同一事件并发重复触发时未读期间只落一条只推一次
推送:向该用户所有已上报厂商 token 的设备直推(integrations/vendor_push);
厂商凭据未配置(本地/测试环境)时自动跳过推送只落站内消息extras
PRD §4 约定带 {type, notificationId, ...跳转参数},客户端点击 push 深链落地
并调 POST /notifications/read 同步置读
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import notification_catalog as catalog
from app.core.rewards import CN_TZ
from app.integrations import vendor_push
from app.models.user import User
from app.repositories import device as device_repo
from app.repositories import notification as notif_repo
if TYPE_CHECKING:
from app.models.feedback import Feedback
from app.models.notification import Notification
from app.models.price_report import PriceReport
from app.models.wallet import WithdrawOrder
logger = logging.getLogger("shagua.notification_events")
def _fmt_time(dt: datetime) -> str:
"""信息行「到账时间」的展示格式(与 repositories/notification 样例卡一致)。"""
return dt.strftime("%Y-%m-%d %H:%M")
def _yuan_trim(cents: int) -> str:
"""分 → 元,去掉多余的 0(200→"2"、1280→"12.80")。push 正文用(PRD §5 示例口径:
{2}元现金已到账);卡片数值仍走 cash_cents 由前端按两位小数渲染"""
yuan = cents / 100
return f"{yuan:.2f}".rstrip("0").rstrip(".")
def _display_name(user: User | None) -> str:
"""好友昵称展示:昵称 → 微信昵称 → 手机尾号,全无则「好友」。"""
name = ((user.nickname if user else None) or (user.wechat_nickname if user else None) or "").strip()
if not name and user and user.phone:
name = f"用户{user.phone[-4:]}"
return name or "好友"
# ---------------------------------------------------------------------------
# 内核:落站内消息 + 厂商推送(全程吞异常)
# ---------------------------------------------------------------------------
def _dispatch(
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,
dedup_key: str | None = None,
push_vars: dict[str, str] | None = None,
) -> Notification | None:
"""落一条站内消息并向该用户设备直推。返回落库行;去重命中/失败返回 None。"""
try:
row = notif_repo.create_notification(
db,
user_id=user_id,
type_key=type_key,
coins=coins,
cash_cents=cash_cents,
info_rows=info_rows,
extra=extra,
dedup_key=dedup_key,
)
except IntegrityError:
# 同 (user, type, dedup_key) 已有未读消息 = 同一事件并发/重复触发 → 不重复落、不重复推
db.rollback()
logger.info(
"notification dedup hit user_id=%s type=%s dedup_key=%s", user_id, type_key, dedup_key
)
return None
except Exception: # noqa: BLE001 — 通知失败绝不影响业务主流程
logger.exception("create notification failed user_id=%s type=%s", user_id, type_key)
try:
db.rollback()
except Exception: # noqa: BLE001 — 回滚失败也不外抛,session 由请求生命周期兜底
logger.exception("rollback after notification failure also failed")
return None
_push_to_user_devices(db, row, push_vars)
return row
def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, str] | None) -> None:
"""向消息归属用户的全部厂商推送目标直推(best-effort,单设备失败不影响其余)。"""
try:
title, body = catalog.render_push(row.type, push_vars)
# PRD §4 push 联动:extras 至少带 type + notificationId,外加该类型的跳转参数(extra 列)
extras: dict[str, str] = {"type": row.type}
extras.update({str(k): str(v) for k, v in (row.extra or {}).items()})
extras["notificationId"] = str(row.id)
for dev in device_repo.list_push_targets(db, user_id=row.user_id):
vendor = vendor_push.normalize_vendor(dev.push_vendor)
if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS:
continue
if vendor_push.missing_settings(vendor):
# 本地/测试环境凭据不齐 → 只落站内消息,不发真推送(与 push/vendors 的报缺口径一致)
logger.info(
"skip push (vendor %s not configured) user_id=%s type=%s",
vendor, row.user_id, row.type,
)
continue
try:
vendor_push.send_notification(
vendor, dev.push_token, title=title, body=body, extras=extras
)
logger.info(
"push sent user_id=%s type=%s vendor=%s notification_id=%s",
row.user_id, row.type, vendor, row.id,
)
except vendor_push.VendorPushError as e:
logger.warning(
"push failed user_id=%s type=%s vendor=%s: %s", row.user_id, row.type, vendor, e
)
except Exception: # noqa: BLE001 — 渲染/查设备等意外失败同样不外抛
logger.exception("push notification failed user_id=%s type=%s", row.user_id, row.type)
# ---------------------------------------------------------------------------
# 六个业务事件(PRD §1/§3/§5 编号见文件头)
# ---------------------------------------------------------------------------
def notify_withdraw_success(db: Session, order: WithdrawOrder) -> None:
"""#3 提现成功:款项已存入微信零钱。点击无跳转仅消红点(extra 空)。"""
_dispatch(
db,
user_id=order.user_id,
type_key="withdraw_success",
cash_cents=order.amount_cents,
info_rows=[
{"label": "到账账户", "value": "微信钱包"},
{"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))},
],
extra={},
dedup_key=order.out_bill_no,
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents)},
)
def notify_withdraw_failed(db: Session, order: WithdrawOrder) -> None:
"""#4 提现失败/退回:含微信侧失败、审核拒绝、解绑退回。点击跳提现页重新提现。
失败原因用 order.fail_reason( /withdraw/status 下发的用户可读原因同源)
"""
reason = (order.fail_reason or "").strip() or "提现未成功"
_dispatch(
db,
user_id=order.user_id,
type_key="withdraw_failed",
cash_cents=order.amount_cents,
info_rows=[
{"label": "失败原因", "value": reason},
{"label": "退回说明", "value": "款项已原路退回现金余额"},
],
extra={"withdrawId": order.out_bill_no},
dedup_key=order.out_bill_no,
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents), "reason": reason},
)
def notify_feedback_reply(db: Session, feedback: Feedback) -> None:
"""#9 官方回复:运营审核了反馈且未采纳(用户可见原因/留言落在反馈记录上)。
点击跳反馈历史页滚动高亮该条(extra.feedbackId)"""
_dispatch(
db,
user_id=feedback.user_id,
type_key="feedback_reply",
info_rows=[{"label": "说明文案", "value": "快去看看官方给您的回复吧~"}],
extra={"feedbackId": str(feedback.id)},
dedup_key=str(feedback.id),
)
def notify_feedback_reward(db: Session, feedback: Feedback) -> None:
"""#10 反馈奖励:反馈被采纳,金币已到账。PRD 约定发奖必带官方留言(admin_reply);
运营漏填时省略该信息行,不硬造文案"""
coins = int(feedback.reward_coins or 0)
info_rows = [{"label": "奖励说明", "value": "感谢您的反馈,您的金币奖励已到账"}]
reply = (feedback.admin_reply or "").strip()
if reply:
info_rows.append({"label": "官方留言", "value": reply})
info_rows.append({"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))})
_dispatch(
db,
user_id=feedback.user_id,
type_key="feedback_reward",
coins=coins,
info_rows=info_rows,
extra={"feedbackId": str(feedback.id)},
dedup_key=str(feedback.id),
push_vars={"coins": str(coins)},
)
def notify_report_approved(db: Session, report: PriceReport) -> None:
"""#11 爆料审核通过:上报的更低价过审,金币已到账。点击跳爆料记录页高亮该条。"""
coins = int(report.reward_coins or 0)
store = (report.store_name or "").strip() or "该店铺"
_dispatch(
db,
user_id=report.user_id,
type_key="report_approved",
coins=coins,
info_rows=[
{"label": "奖励说明", "value": f"您爆料的「{store}」更低价已通过审核,金币奖励已到账"},
{"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))},
],
extra={"reportId": str(report.id)},
dedup_key=str(report.id),
push_vars={"store": store, "coins": str(coins)},
)
def notify_invite_order_reward(
db: Session, *, inviter_user_id: int, invitee_user_id: int, cash_cents: int
) -> None:
"""#12 好友下单到账:被邀请好友完成首次下单(比价),现金奖励已入邀请人账户。
通知发给邀请人;每个好友只发一次奖 dedup 按被邀请人"""
invitee = db.get(User, invitee_user_id)
nickname = _display_name(invitee)
_dispatch(
db,
user_id=inviter_user_id,
type_key="invite_order_reward",
cash_cents=cash_cents,
info_rows=[
{"label": "奖励说明", "value": f"好友「{nickname}」完成首次下单"},
{"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))},
],
extra={"inviteeNickname": nickname},
dedup_key=str(invitee_user_id),
push_vars={"nickname": nickname, "amount": _yuan_trim(cash_cents)},
)
+17 -1
View File
@@ -19,7 +19,23 @@ server {
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
client_max_body_size 4m;
# 上传接口(反馈/上报截图、头像)业务上限 = 最多 6 张 × 每张 5MB
# (见 app _MAX_IMAGES / AVATAR_MAX_BYTES)≈ 30MB,留余量设 32m。
# 低于此值时带截图的反馈会在到达 uvicorn 前就被 nginx 413,表现为「提交经常失败」
# (纯文字反馈体积小、不受影响 → 呈现为「时好时坏」)。根治仍需客户端上传前压缩。
client_max_body_size 32m;
# JSON 响应压缩。nginx 默认 gzip off,且就算 on 了 gzip_types 也只含 text/html、
# gzip_proxied 默认 off(反代来的响应一律不压)—— 三个默认值凑一起 = 我们所有接口都在裸奔。
# 比价记录列表这种一次 50 条、字段名 + 中文店名/菜名高度重复的 JSON,gzip 压缩比稳定在 8~10 倍
# (几百 KB → 几十 KB),弱网下省的就是首屏那几秒。
# 只压 JSON:APK 直链(/media/shaguabijia.apk)、图片本身已是压缩格式,再压纯浪费 CPU。
gzip on;
gzip_proxied any; # 反代响应也压(默认 off = 对我们这套反代等于没开)
gzip_types application/json;
gzip_min_length 1024; # 小响应压了反而更大(gzip 头开销),不值当
gzip_comp_level 5; # 5 是体积/CPU 的常用折中点,再往上收益递减
gzip_vary on; # 给 CDN/中间缓存正确按 Accept-Encoding 分桶
location / {
proxy_pass http://127.0.0.1:8770;
+57
View File
@@ -0,0 +1,57 @@
# OpenObserve 监控台反代(observe.shaguabijia.com)。证书走 Certbot/Let's Encrypt,与 admin-web 一致。
#
# 前置(一次性):
# 1) DNS: observe.shaguabijia.com A 记录 → 本服务器公网 IP
# 2) 证书: sudo certbot certonly --nginx -d observe.shaguabijia.com
# (options-ssl-nginx.conf / ssl-dhparams.pem 首次跑 certbot 时已生成,admin-web 在用即已存在)
# 3) OpenObserve 只绑 127.0.0.1:5080(见 docker-compose.prod.yml),本文件把它反代出公网
# 4) nginx -t 通过后 systemctl reload nginx
#
# 安全:OO 有自身登录。监控台不必对全网裸开——本机办公网无固定出口 IP,故在 nginx 层加 Basic Auth 兜底;
# 将来有固定 IP 可改用【IP 白名单】块(更省事,可去掉 Basic Auth)。
server {
server_name observe.shaguabijia.com;
client_max_body_size 10m;
# —— IP 白名单:办公网无固定出口 IP,暂不用;将来有固定 IP 可改用这块(比 Basic Auth 省事)——
# allow 1.2.3.4; # ← 换成你的真实出口 IP,可多行
# deny all;
# —— Basic Auth:无固定 IP 的兜底密码(生成 .htpasswd_observe 的命令见 README/下方)——
auth_basic "OpenObserve";
auth_basic_user_file /etc/nginx/conf.d/.htpasswd_observe;
location / {
proxy_pass http://127.0.0.1:5080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# OpenObserve 有实时/流式面板,需透传 WebSocket
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}
# IPv6 这行不带 ipv6only=on:该选项对 [::]:443 全局只能设一次,admin-web 那个 server 块已设(否则 nginx 报 duplicate listen options)
listen [::]:443 ssl; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/observe.shaguabijia.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/observe.shaguabijia.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = observe.shaguabijia.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
listen [::]:80;
server_name observe.shaguabijia.com;
return 404; # managed by Certbot
}
+4
View File
@@ -0,0 +1,4 @@
# OpenObserve 落盘数据(parquet/索引/元数据),运行时产生,不入库。
data/
# 生产 compose 的密码文件(OO_ROOT_PASSWORD),含机密,不入库。
.env
+126
View File
@@ -0,0 +1,126 @@
# OpenObserve 本地部署(接口 QPS / 耗时可观测)
app-server 通过中间件采集每个接口的 QPS + 耗时 + 错误率,批量上报到这里。
设计见 [../../docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md](../../docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md)。
## 启动
```bash
cd deploy/openobserve
docker compose up -d
```
- Web UIhttp://localhost:5080
- 登录:`admin@shaguabijia.local` / `Complexpass#123`(见 `docker-compose.yml`
- 数据落 `deploy/openobserve/data/`(已挂卷持久化;该目录已 gitignore)
## 让 app-server 上报
在项目根的 `.env` 打开观测(`OBSERVE_*`,账号密码与 compose 里 root 一致):
```dotenv
OBSERVE_ENABLED=true
OBSERVE_ENDPOINT=http://localhost:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=admin@shaguabijia.local
OBSERVE_PASSWORD=Complexpass#123
```
重启 app-server,随便打几个接口。stream `app_requests` **首次上报自动创建**
在 UI 的 Logs → 选 `app_requests` 就能看到逐条请求事件(字段:`method` / `route` /
`status` / `duration_ms` / `service` / `env`)。
> 未开 `OBSERVE_ENABLED` 或缺账号密码时,中间件透传、worker 不启动,整套 no-op,不影响业务。
## 查询(Logs 页 SQL,或建 Dashboard 面板)
各接口 QPS(1 分钟分桶,面板里再除 60 得每秒):
```sql
SELECT route, histogram(_timestamp, '1 minute') AS ts, count(*) AS cnt
FROM app_requests GROUP BY route, ts ORDER BY ts
```
各接口 P95 耗时(毫秒):
```sql
SELECT route, approx_percentile_cont(duration_ms, 0.95) AS p95_ms
FROM app_requests GROUP BY route ORDER BY p95_ms DESC
```
各接口错误率(5xx 占比):
```sql
SELECT route,
count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) AS err_pct
FROM app_requests GROUP BY route ORDER BY err_pct DESC
```
## 一键导入现成仪表盘(QPS / P95 / 分位 / 错误率)
备好了 [dashboard-api-metrics.json](dashboard-api-metrics.json),4 个面板:各接口每分钟请求数(QPS 源)、
P95 耗时折线、P50/P95/P99 分位表、5xx 错误率表。
- **UI 导入**Dashboards → 右上 **Import** → 选该 JSON 文件 → Import(每次导入新建,不覆盖)。
- **或 API 导入**
```bash
curl -u admin@shaguabijia.local:Complexpass#123 -H 'Content-Type: application/json' \
-X POST 'http://localhost:5080/api/default/dashboards?folder=default' \
--data-binary @deploy/openobserve/dashboard-api-metrics.json
```
导入后进仪表盘,右上角时间调到「最近 15 分钟 / 1 小时」、开自动刷新即可。低流量下 QPS 面板看「每分钟请求数」比「每秒」直观。
## 停止 / 清数据
```bash
docker compose down # 停止(保留数据)
docker compose down -v && rm -rf data # 停止并清空数据
```
## 生产部署(单机)+ UI 访问
前提:app-server 与 OpenObserve **同机**,app→OO 走 localhost(`127.0.0.1:5080`)、不出网、无需 TLS。
唯一要防的是**别把 :5080 裸暴露公网**。硬化版编排见 [docker-compose.prod.yml](docker-compose.prod.yml)。
### 部署步骤
```bash
# 1) 密码文件(本目录,已 gitignore)
echo "OO_ROOT_PASSWORD=$(python -c 'import secrets;print(secrets.token_urlsafe(24))')" > deploy/openobserve/.env
# 2) 起 OpenObserve(只绑 127.0.0.1、命名卷持久化、mem 1g)
cd deploy/openobserve && docker compose -f docker-compose.prod.yml up -d
sudo systemctl enable docker # 开机自起
```
3) app-server 的 `.env` 打开观测并**重启**(用非 root 的专用 ingest 账号):
```dotenv
OBSERVE_ENABLED=true
OBSERVE_ENDPOINT=http://127.0.0.1:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=ingest@shaguabijia.com # UI → Users 建的非 root 账号
OBSERVE_PASSWORD=<该账号密码>
```
```bash
sudo systemctl restart shaguabijia-app-server # 日志出现 "observe worker started" 即生效
```
4) 两个必做收口(磁盘/安全):
- **保留期**:UI → Streams → `app_requests` → Data Retention 设 14/30 天(一请求一行,不封顶迟早撑爆盘)。
- **专用账号**:UI → Users 建非 root 账号给 app 上报,root 只留人工登 UI。
### UI 访问(二选一)
**A. SSH 隧道(推荐,零暴露、不用域名/证书):**
```bash
ssh -L 5080:127.0.0.1:5080 用户@服务器IP
# 然后本机浏览器开 http://localhost:5080
```
**B. nginx 子域名反代(要固定 URL / 团队常看):** 见 [../nginx/observe.shaguabijia.com.conf](../nginx/observe.shaguabijia.com.conf)。
需 DNS `observe.shaguabijia.com` → 本机 + 证书放 `/etc/nginx/ssl/`;含 IP 白名单 + TLS + WebSocket 透传。
> ⚠️ prod compose 必须保持 `127.0.0.1:5080:5080`;写成 `5080:5080`(绑 0.0.0.0)= 裸暴露公网,这是唯一真正的坑。
@@ -0,0 +1,302 @@
{
"version": 8,
"dashboardId": "api-metrics",
"title": "接口监控 (QPS / 耗时 / 错误率)",
"description": "app-server 接口 QPS、P50/P95/P99 耗时、5xx 错误率。数据流 app_requests。",
"role": "",
"tabs": [
{
"tabId": "default",
"name": "Default",
"panels": [
{
"id": "panel_qps",
"type": "line",
"title": "各接口 每分钟请求数 (QPS 源)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT histogram(_timestamp, '1 minute') as ts, route, count(*) as reqs FROM app_requests GROUP BY ts, route ORDER BY ts",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "ts",
"alias": "ts",
"column": "ts",
"color": null,
"sortBy": "ASC"
}
],
"y": [
{
"label": "reqs",
"alias": "reqs",
"column": "reqs",
"color": null
}
],
"z": [],
"breakdown": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 0,
"y": 0,
"w": 24,
"h": 9,
"i": 1
}
},
{
"id": "panel_p95",
"type": "line",
"title": "各接口 P95 耗时 (ms)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT histogram(_timestamp, '1 minute') as ts, route, approx_percentile_cont(duration_ms, 0.95) as p95_ms FROM app_requests GROUP BY ts, route ORDER BY ts",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "ts",
"alias": "ts",
"column": "ts",
"color": null,
"sortBy": "ASC"
}
],
"y": [
{
"label": "p95_ms",
"alias": "p95_ms",
"column": "p95_ms",
"color": null
}
],
"z": [],
"breakdown": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 24,
"y": 0,
"w": 24,
"h": 9,
"i": 2
}
},
{
"id": "panel_pctl",
"type": "table",
"title": "各接口 耗时分位 P50/P95/P99 (ms)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT route, approx_percentile_cont(duration_ms,0.5) as p50, approx_percentile_cont(duration_ms,0.95) as p95, approx_percentile_cont(duration_ms,0.99) as p99, count(*) as cnt FROM app_requests GROUP BY route ORDER BY p95 DESC",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"y": [
{
"label": "p50",
"alias": "p50",
"column": "p50",
"color": null
},
{
"label": "p95",
"alias": "p95",
"column": "p95",
"color": null
},
{
"label": "p99",
"alias": "p99",
"column": "p99",
"color": null
},
{
"label": "cnt",
"alias": "cnt",
"column": "cnt",
"color": null
}
],
"z": [],
"breakdown": [],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 0,
"y": 9,
"w": 24,
"h": 9,
"i": 3
}
},
{
"id": "panel_err",
"type": "table",
"title": "各接口 错误率 (5xx %)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT route, count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) as err_pct, count(*) as cnt FROM app_requests GROUP BY route ORDER BY err_pct DESC",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"y": [
{
"label": "err_pct",
"alias": "err_pct",
"column": "err_pct",
"color": null
},
{
"label": "cnt",
"alias": "cnt",
"column": "cnt",
"color": null
}
],
"z": [],
"breakdown": [],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 24,
"y": 9,
"w": 24,
"h": 9,
"i": 4
}
}
]
}
],
"variables": {
"list": []
}
}
@@ -0,0 +1,33 @@
# 生产用 OpenObserve(单机)。相对本地版 docker-compose.yml 的区别:
# - 端口只绑 127.0.0.1 → 公网/外网都到不了(UI 访问走 SSH 隧道或 nginx 反代,见 README)
# - root 密码走环境变量(放同目录 .env,已 gitignore,勿提交)
# - 数据 bind-mount 到宿主 /data 分区(需预建目录 + 确认容器可写)+ CPU/内存上限(与 app/PG 共存防抢内存)
#
# 用法:
# 1) 本目录建 .env(已 gitignore):
# OO_ROOT_PASSWORD=<强随机串> # 生成: python -c "import secrets;print(secrets.token_urlsafe(24))"
# 2) docker compose -f docker-compose.prod.yml up -d
# 3) 开机自起: sudo systemctl enable docker
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:v0.91.2
container_name: openobserve
ports:
- "127.0.0.1:5080:5080" # 只绑本机,安全
environment:
ZO_ROOT_USER_EMAIL: "admin@shaguabijia.com"
ZO_ROOT_USER_PASSWORD: "${OO_ROOT_PASSWORD:?请先在 deploy/openobserve/.env 里设 OO_ROOT_PASSWORD}"
ZO_DATA_DIR: "/data"
ZO_COMPACT_DATA_RETENTION_DAYS: "30" # 超 30 天自动删,防爆盘(默认 3650 天=10年)
ZO_TELEMETRY: "false" # 关匿名遥测(内网自用);变量名是 ZO_TELEMETRY,不是 *_ENABLED
volumes:
- /data/openobserve/data:/data # 绑定挂载到宿主机的 /data/openobserve/data 目录(建议该目录所在分区有 20G+ 空间)
restart: unless-stopped
deploy:
resources:
limits: # 硬上限:防 OO 查询/ingest 抢爆 CPU/内存,拖垮同机 PG+app
cpus: '2.0'
memory: 3G
logging: # 容器 stdout 日志上限,防爆盘
driver: json-file
options: { max-size: "10m", max-file: "3" }
+16
View File
@@ -0,0 +1,16 @@
# 本地开发用 OpenObserve(单容器 = local 模式)。用于接收 app-server 的接口指标(QPS/耗时/错误率)。
# 启动: cd deploy/openobserve && docker compose up -d
# Web UI: http://localhost:5080 (账号见下方 env)
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
container_name: openobserve
ports:
- "5080:5080"
environment:
ZO_ROOT_USER_EMAIL: "admin@shaguabijia.local"
ZO_ROOT_USER_PASSWORD: "Complexpass#123"
ZO_DATA_DIR: "/data"
volumes:
- ./data:/data
restart: unless-stopped
+22 -2
View File
@@ -1,9 +1,13 @@
# 傻瓜比价 App 后端 — API 接口文档(索引)
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**(⚠️ 例外:消息通知中心 `notifications` 族与厂商推送 `push` 族按 PRD 前端契约用 **camelCase**,见各自文档)
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
<<<<<<< HEAD
> 最后更新:2026-07-14(新增 **消息通知中心** 3 端点(M1-M3,虚拟数据阶段)与 **厂商推送测试** 3 端点(P1-P3,荣耀/华为/小米/OPPO/vivo);上一次 2026-06-23 补全 device/internal/CPS 短链等整族端点)
=======
> 最后更新:2026-07-09(① 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」(#112 尾声帧 `trace/epilogue` 一并补录);② 新端点:`user/onboarding/reset`(#114)、`GET /internal/launch-confirm-samples`(#91);③ 参数更新:提现族 `source` 分账(#82/#121)、`wallet/account` 邀请奖励金余额、美团 feed/top-sales 按城市过滤(#116)、admin 调现金 `account` 目标账户(#95);④ **Admin 索引补全到当前全量**:新家族 roles(#117/#126)/coupon-data(#99)/device-liveness(#80)/event-logs(#83)/price-reports(#94)/CPS 运营台/提现审核族,及 feedbacks 采纳拒绝(#94/#105)、marquee 模式与真实条浏览(#122/#123)等。上一次 2026-07-03
>>>>>>> origin/main
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
---
@@ -78,7 +82,6 @@
| **签到**(前缀 `/api/v1/signin` |||
| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin/signin-status.md) |
| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin/signin-do.md) |
| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin/signin-boost.md) |
| **任务**(前缀 `/api/v1/tasks` |||
| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks/tasks-list.md) |
| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks/tasks-claim.md) |
@@ -89,6 +92,7 @@
| **看广告发奖**(前缀 `/api/v1/ad` |||
| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad/ad-pangle-callback.md) |
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad/ad-reward-status.md) |
| 33a | `GET /api/v1/ad/reward-result/{ad_session_id}` | Bearer | [详情](./ad/ad-reward-result.md)(本次实发金币 + 本轮膨胀累计 `round_coin`,弹窗数字用它) |
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad/ad-test-grant.md) |
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad/ad-ecpm-report.md) |
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad/ad-feed-reward.md) |
@@ -103,18 +107,33 @@
| 36c | `POST /api/v1/user/onboarding/reset` | Bearer | [详情](./user/user-onboarding.md)(重置本设备引导标记,下次登录重走,#114 |
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) |
| **帮助与反馈**(前缀 `/api/v1/feedback` |||
<<<<<<< HEAD
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
| 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) |
| 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected(无单独文档) |
| **消息通知中心**(前缀 `/api/v1/notifications`;⚠️ 本族对外 **camelCase**;虚拟数据阶段:内存 mock,重启复位) |||
| M1 | `GET /api/v1/notifications` | Bearer | [详情](./notifications.md)(消息列表,分页;13 类型卡片字段 + sentAt/isRead;服务端已按时间倒序排好,不分组) |
| M2 | `GET /api/v1/notifications/unread-count` | Bearer | [详情](./notifications.md)(未读总数,首页铃铛角标;>99 → "99+",0 → null 隐藏) |
| M3 | `POST /api/v1/notifications/read` | Bearer | [详情](./notifications.md)(标记已读:`{ids:[...]}` 单条/多条 或 `{all:true}` 进通知中心全量清零;幂等) |
| **厂商推送测试**(前缀 `/api/v1/push`;荣耀/华为/小米/OPPO/vivo 五通道联调三件套,同为 camelCase |||
| P1 | `GET /api/v1/push/vendors` | Bearer | [详情](./push-vendor-test.md)(5 厂商服务端凭据配置状态,缺哪些 .env 键一目了然) |
| P2 | `GET /api/v1/push/templates` | Bearer | [详情](./push-vendor-test.md)(13 类通知的 push 标题/正文模板 + PRD 示例渲染效果) |
| P3 | `POST /api/v1/push/test` | Bearer | [详情](./push-vendor-test.md)(测试发送:默认 mock 不真发;mock=false 真发;可联动插一条站内 mock 通知闭环验证已读) |
=======
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) |
| 38a | `GET /api/v1/feedback/config` | Bearer | [详情](./other/feedback-config.md)(反馈页「加群二维码」卡配置:开关+二维码图+三行文案) |
| 38b | `GET /api/v1/feedback/records` | Bearer | [详情](./other/feedback-records.md)(我的反馈历史,pending/adopted/rejected |
| **埋点 & 订单上报**(前缀分散;全部 Bearer 除 analytics/events 不强制登录) |||
| E1 | `POST /api/v1/analytics/events` | 无 | [详情](./other/analytics-events.md)(批量上报埋点事件,不强制登录,每批最多200条) |
| E2 | `POST /api/v1/order/report` | Bearer | [详情](./other/order-report.md)(上报归因订单,比价后5分钟内点链接+支付金额与比价价相差≤1元) |
>>>>>>> origin/main
| **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) |||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform/platform-stats.md) |
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./savings/platform-savings-feed.md) |
| 40a | `GET /api/v1/platform/flags` | 无 | [详情](./platform/platform-flags.md)(客户端运营 feature flag,比价/领券期广告开关等,拉取后缓存) |
| 40b | `GET /api/v1/platform/ad-config` | 无 | [详情](./platform/platform-ad-config.md)(客户端拉广告配置:穿山甲 app_id+各位ID+各场景开关;不含验签密钥) |
| 40c | `GET /api/v1/platform/app-version` | 无 | [详情](./platform/platform-app-version.md)(最新 App 版本,OTA 检查更新;与本机 versionCode 比) |
| 40d | `GET /api/v1/platform/huawei-review` | 无 | [详情](./platform/platform-huawei-review.md)(华为审核开关:快速设置权限步能否被用户关闭;仅华为 ROM 客户端拉) |
| **微信支付回调**(前缀 `/api/v1/wxpay` |||
| W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) |
| **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) |||
@@ -153,6 +172,7 @@
| A12 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:分页/场景/`app_env` 筛 + **DAU/ARPU** #120;真实收益侧接穿山甲日表 #92) |
| A13 | `GET / PATCH /admin/api/ad-config` | operator/finance | 广告配置(穿山甲 ID/验签密钥/各场景开关;C 端只读版见 40b)(无单独文档,见 `app/admin/routers/ad_config.py`) |
| A14 | `GET /admin/api/config``PATCH /config/{key}` | operator/finance | 运营可配置项([app_config](../database/app_config.md):奖励常量/提现地板价等;#117 修系统配置下发)(无单独文档,见 `app/admin/routers/config.py`) |
| A16 | `GET / PATCH /admin/api/huawei-review` | operator/tech | 华为审核开关(快速设置权限步能否被用户关闭,落 `app_config.huawei_review`;C 端只读版见 40d)(无单独文档,见 `app/admin/routers/huawei_review.py`) |
| **A·管理员与角色**(super_admin):`GET`/`POST` `/admins``PATCH`/`DELETE` `/admins/{id}`(#126 删除+`pages_override`)、`GET`/`POST` `/roles``GET /roles/catalog``PATCH`/`DELETE` `/roles/{id}`(#117/#126 自定义角色) ||| [列表](./admin/admins/admin-admins-list.md) / [](./admin/admins/admin-admin-create.md) / [改+删](./admin/admins/admin-admin-update.md) / [角色](./admin/admin-roles.md) |
| A15 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
| **A·CPS 运营台**:群/活动 CRUD、`POST /referral-links``POST /orders/reconcile`(美团+京东 #90)、`GET /orders``/stats`、群 `timeseries`/`daily`/`wx-users`/`day-users`(#79) ||| [详情](./admin/admin-cps.md) |
+10 -2
View File
@@ -15,7 +15,15 @@ GroMore 以 GET 回调,关键参数:
| `trans_id` | string | 交易号(**幂等键** + **唯一参与签名的字段** |
| `reward_name` | string | 奖励名(广告位配置,入库备注) |
| `ecpm` | string\|null | GroMore 回调携带的 eCPM。普通激励视频优先用它计算金币 |
| `extra` / `gromoreExtra` / `gromore_extra` | string | 客户端透传 JSON。支持 `ad_session_id``reward_scene`;`reward_scene=signin_boost` 表示签到膨胀 |
| `extra` / `gromoreExtra` / `gromore_extra` | string | 客户端透传 JSON。支持 `ad_session_id``reward_scene``srv_env``boost_round_id` |
### `extra` 里的 `boost_round_id`
客户端生成的「这条广告属于哪一轮膨胀」标签(32 位十六进制,同 `ad_session_id` 格式),随发奖记录存进 `ad_reward_record.boost_round_id`
**它不参与任何发奖判定** —— 发多少、发不发完全不受影响,只是让 [`/ad/reward-result`](./ad-reward-result.md) 能把同一轮的 granted 记录求和成 `round_coin`(客户端「恭喜累计获得奖励」弹窗显示的数)。
轮次边界由客户端定(只有它知道用户点没点「放弃赚钱」):点「去膨胀」新生成一个 → 点「继续看视频膨胀」复用同一个 → 点「放弃赚钱」/ ✕ / 返回 / 到每日上限 / 跨天 则丢弃。不带此字段(老客户端 / GroMore 偶发丢 extra)时存 NULL,`round_coin``null`
| `mediation_rit` | string | 代码位 IDGroMore 带,目前仅入 raw 备查) |
| `prime_rit` | string | 广告位 ID(同上) |
| `adn_name` | string | 实际出广告的 ADN 名(同上,可用于收益分析) |
@@ -40,6 +48,6 @@ GroMore 以 GET 回调,关键参数:
**发奖唯一可信入口**:验签 → 取 `user_id`/`extra` → 按 `reward_scene` 分流 → 幂等处理(按 `trans_id` 去重)。客户端不直接发奖,被破解也刷不到钱。
- `reward_scene=reward_video` 或缺省:普通激励视频。金币按 `eCPM / 1000 * eCPM因子 * 当日次数因子 * 10000` 计算;若回调没有 `ecpm`,会按 `extra.ad_session_id` 查客户端 `/ad/ecpm-report` 的上报值;两边都没有 eCPM 时不发币,记录 `status=ecpm_missing`
- `reward_scene=signin_boost`:签到膨胀。要求用户当天已签到且不是 Day14;看完视频固定发 `2000` 金币,写 `signin_boost_record``coin_transaction.biz_type=signin_boost`
- ~~`reward_scene=signin_boost`~~(签到膨胀):**2026-07 已下线**。它按固定 3000 金币发、与广告实际收益脱钩,产品确认非设计内口径。签到弹窗的「看广告膨胀」现与福利页看视频同走 `reward_video`。现在传 `signin_boost` 会落到「未知场景」分支(不发币,`status=unknown_scene`)
- 未知 `reward_scene`:不发币,记录 `status=unknown_scene`,返回 `is_verify=false/reason=1`
- 验签过但参数缺/坏或 user 不存在 → 不发(`is_verify=false` + `reason`;granted / capped / ecpm_missing / 业务不满足已记录 → `is_verify=true` + `reason=0`
+66
View File
@@ -0,0 +1,66 @@
# GET /api/v1/ad/reward-result/{ad_session_id} — 查本次广告的权威发奖结果 + 本轮累计
客户端看完激励视频后轮询本接口,拿**本次实发金币**和**本轮累计**用于「恭喜累计获得奖励」弹窗。不再用余额差 / `coin_per_ad` 估算。
**纯只读**:发奖仍只由验签过的 S2S 回调完成,本接口不写库、不产生任何奖励。按 `user_id` 收窄,被刷也只能查到自己的记录。
## 鉴权
需要 Bearer token。
## 路径参数
| 参数 | 类型 | 约束 | 说明 |
|---|---|---:|---|
| `ad_session_id` | string | 长度 8~64 | 本次广告会话 id,客户端生成,与 `mediaExtra` / `ecpm-report` 同值 |
## 响应
| 字段 | 类型 | 说明 |
|---|---|---|
| `ad_session_id` | string | 回显请求值 |
| `status` | string | `pending`(S2S 未到账,继续轮询) / `granted` / `capped`(当日超限) / `ecpm_missing` / `closed_early`(提前关闭) |
| `coin` | int \| null | **本条**实发金币。granted 为真实到账额;未发奖的状态为 0;pending 为 `null` |
| `round_coin` | int \| null | **本轮累计已发金币**(含本条) ← 弹窗显示的就是它 |
```json
{ "ad_session_id": "3f2a9c1b7e4d8a60", "status": "granted", "coin": 20, "round_coin": 60 }
```
### `round_coin` 的口径
「轮」= 用户点「去膨胀」到点「放弃赚钱」之间连看的若干条广告,边界由客户端的 `boost_round_id` 定(见 [ad-pangle-callback](./ad-pangle-callback.md))。
```sql
SELECT COALESCE(SUM(coin), 0) FROM ad_reward_record
WHERE user_id = :user_id -- 恒带,轮 id 是客户端生成的不可跨用户信任
AND boost_round_id = :该会话记录的 boost_round_id
AND status = 'granted'
```
由服务端求和而非客户端自己累加:客户端进程被杀 / 低内存重建后本地累计会丢,发奖记录不会。
**要守住的不变量:弹窗数字 == 本轮实际到账之和 == 用户看到的余额涨幅。** 三者对不上,用户就会认为少发了钱。
| 情形 | `round_coin` |
|---|---|
| 本条 `granted` | 本轮累计(含本条) |
| 本条 `capped` / `closed_early` / `ecpm_missing` | **仍返本轮累计**,该条按 0 计(撞上限那下的 toast 要能显示前几条的总额,不能是空) |
| `status=pending`(没记录) | `null` —— 连属于哪一轮都不知道。**不是 0**,0 会被读成「本轮没赚到」 |
| 该记录没有 `boost_round_id`(老客户端 / extra 丢失) | `null`,客户端退回只显示单条 `coin` |
## 错误
- `401`: 未登录
- `422`: `ad_session_id` 长度不在 8~64
**查不到记录不返 404**,而是 200 + `status="pending"`。404 只应表示路由不存在;两者混在一起客户端没法区分「后端没部署」和「再等等」。
## 实现注意
同一 `ad_session_id` 可能有多条记录,取值时**显式优先 `granted`**,不能只取最近一条:
- 客户端先报 `closed_early`、S2S 随后姗姗来迟 → 两条,`granted` 反而是后写的
- 本地联调重复调 `test-grant` → 同 session 多条 `granted``trans_id` 各不相同)
都没有 `granted` 才取最近一条,让客户端知道没发的原因。
+3 -2
View File
@@ -9,7 +9,8 @@
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。`reward_video`=普通激励视频`signin_boost`=签到膨胀 |
| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。当前**只支持** `reward_video``signin_boost`签到膨胀)已于 2026-07 下线,传它返 `422` |
| `boost_round_id` | string | 否 | `null` | 本次广告属于哪一轮膨胀。正式链路走 S2S 的 `mediaExtra`,本接口不经 S2S 拿不到 extra,故由 body 补。**不传的话 debug 包 `/ad/reward-result``round_coin` 恒为 `null`**,「弹窗 40 → 60 → toast +60」那套累计验收在本地跑不起来 |
| `ad_session_id` | string(8~64) \| null | 否 | null | 本次广告会话 id(与 [ecpm-report](./ad-ecpm-report.md) 同值)。**仅 `reward_video` 场景生效**:据此查回客户端已上报的真实 eCPM,走与正式发奖相同的公式发奖;查不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍出非零金币 |
## 出参
@@ -33,4 +34,4 @@
`reward_scene=reward_video` 时按上面 `ad_session_id` 查回的真实 eCPM 走金币公式发奖(取不到兜底 200)——便于本地用 [admin 金币审计](./admin-ad-coin-audit.md) 核对「看广告→金币」是否按公式计算。
`reward_scene=signin_boost` 时复用签到膨胀业务规则:必须当天已签到、非第 14 天、当天未膨胀过,成功后写入 `signin_boost` 金币流水。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。
它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。
+3 -1
View File
@@ -28,6 +28,8 @@
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
| `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 |
| `feed_scene` | string | 全部 | `comparison`(比价)/ `coupon`(领券)/ `welfare`(福利);**全局筛选**,同时作用于明细 / 合计 / `daily`·`hourly` 趋势;不传=全部场景 |
| `app_env` | string | 全部 | `prod`=正式应用 / `test`=测试应用;同时过滤客户端预估与穿山甲汇总 |
| `revenue_scope` | string | `all` | `business`=仅业务代码位,用于同口径对账 / `all`=应用全部代码位,包含广告测试等非业务曝光 |
| `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** |
| `limit` | int(1~1000) | 500 | **每页条数**(分页大小);`total`/`total_*`/`daily`/`hourly` 按全量统计不受分页影响 |
| `offset` | int(≥0) | 0 | 分页偏移(已跳过条数)=(页码−1)×`limit` |
@@ -131,5 +133,5 @@
- **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。
- **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。
- **逐条/明细的收益是预估**:`items[].revenue_yuan` 基于客户端上报的 eCPM 折算,非穿山甲后台结算值。
- **穿山甲后台收益(汇总/趋势级)**:`total_pangle_revenue_yuan`(预估 `revenue`)与 `total_pangle_api_revenue_yuan`(收益Api `api_revenue`,更接近结算)来自穿山甲 **GroMore 数据 API**(`integrations/pangle_report` + `scripts/sync_pangle_revenue` 按天 T+1 拉取入 [ad_pangle_daily_revenue](../database/ad_pangle_daily_revenue.md))。穿山甲**不提供分用户/设备/类型/场景维度**(官方明确),最细到 日期×应用×代码位,故只用于汇总与按天趋势的对照,**不挂到逐条事件行**;且仅在全量视图(未按 user/类型/场景过滤)展示。配置见 `.env``PANGLE_REPORT_*`
- **穿山甲后台收益(汇总/趋势级)**:`total_pangle_revenue_yuan`(预估 `revenue`)与 `total_pangle_api_revenue_yuan`(收益Api `api_revenue`,更接近结算)来自穿山甲 **GroMore 数据 API**(`integrations/pangle_report` + `scripts/sync_pangle_revenue` 按天 T+1 拉取入 [ad_pangle_daily_revenue](../database/ad_pangle_daily_revenue.md))。穿山甲**不提供分用户/设备/类型/场景维度**(官方明确),最细到 日期×应用×代码位,故只用于汇总与按天趋势的对照,**不挂到逐条事件行**;且仅在未按 user/类型/场景过滤展示。`app_env``revenue_scope` 会同时过滤客户端和穿山甲数据,其中 `business` 排除广告测试等非业务代码位。配置见 `.env``PANGLE_REPORT_*`
- **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。
+3 -2
View File
@@ -1,6 +1,6 @@
# GET /admin/api/audit-logs — 审计日志(谁改了什么,游标分页)
> 所属:Admin·Audit 组(前缀 `/admin/api/audit-logs` | 鉴权:Bearer admin_token(角色:任意已登录 admin | [← 返回 API 索引](../README.md)
> 所属:Admin·Audit 组(前缀 `/admin/api/audit-logs` | 鉴权:Bearer admin_token + `audit-logs` 页面权限 | [← 返回 API 索引](../README.md)
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
@@ -29,7 +29,8 @@
## 错误码
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
- `403` 当前管理员没有 `audit-logs` 页面权限
## 说明
- 整组(`/admin/api/audit-logs`)守卫为 `get_current_admin`,任意已登录 admin 均可查看,无角色限制
- 整组(`/admin/api/audit-logs`)守卫为 `require_page("audit-logs")`,默认仅超级管理员和技术角色可查看,也可由超管给自定义角色授权
- 审计日志只增不改不删,任何写操作经 `write_audit` 落一条。数据表见 [admin_audit_log](../database/admin_audit_log.md)。
+2 -1
View File
@@ -1,6 +1,6 @@
# /admin/api/device-liveness — 设备存活监控(#80)
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [device_liveness](../../database/device_liveness.md) | [← 返回 API 索引](../README.md)
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin + `device-liveness` 页面权限 | 表 [device_liveness](../../database/device_liveness.md) | [← 返回 API 索引](../README.md)
无障碍保护存活的后台视角:哪些设备开过保护(`ever_protected`)、现在在线还是掉线(心跳超时,#107 起阈值 1 小时)、首次开启时间(`first_protected_at`)。
@@ -13,3 +13,4 @@
## 说明
- 「在线」= `last_heartbeat_at` 距今 < 超时阈值;掉线召回链路(worker 置 `kill_alert_pending` → 客户端 pull)见表文档。
- 无 `device-liveness` 页面权限时返回 `403`
+2 -1
View File
@@ -1,6 +1,6 @@
# /admin/api/event-logs — 埋点日志(#83)
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [analytics_event](../../database/analytics_event.md) | [← 返回 API 索引](../README.md)
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin + `event-logs` 页面权限 | 表 [analytics_event](../../database/analytics_event.md) | [← 返回 API 索引](../README.md)
客户端埋点(`POST /api/v1/analytics/events` 批量上报)的后台检索页。
@@ -13,3 +13,4 @@
## 说明
- 纯只读;无聚合报表(要分析导出后自己算)。
- 时间轴用 `client_ts`(事件真实发生时刻),入库时间受客户端攒批影响。
- 无 `event-logs` 页面权限时返回 `403`
+2 -2
View File
@@ -37,8 +37,8 @@
| `feed_ad_watch_count` | int | 信息流广告有效完成视频数(`ad_feed_reward_record.status=granted`) |
| `signin_coin_total` | int | 签到累计发放金币(`biz_type=signin`) |
| `signin_count` | int | 签到次数(`signin_record`) |
| `signin_boost_coin_total` | int | 签到膨胀累计发放金币(`biz_type=signin_boost`) |
| `signin_boost_watch_count` | int | 签到膨胀有效视频数(`signin_boost_record`) |
| `signin_boost_coin_total` | int | **历史口径**:签到膨胀累计发放金币(`biz_type=signin_boost`)。功能已下线,数字不再增长,保留供对账 |
| `signin_boost_watch_count` | int | **历史口径**:签到膨胀次数。膨胀 2026-07 已下线、`signin_boost_record` 表已 drop,改数 `coin_transaction.biz_type='signin_boost'` 的入账笔数(一次膨胀 = 一笔,与原口径等价),只会停在历史值不再增长 |
**DashboardCash**
| 字段 | 类型 | 说明 |
+9
View File
@@ -10,6 +10,12 @@
|---|---|---|---|---|
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
| `ordered` | bool | ❌ | null | `true`=只出「已下单」(店名命中本人真实下单)的记录;不传=不筛 |
| `keyword` | string | ❌ | null | 按店名 / 菜名模糊搜索,忽略大小写,≤64 字符;纯空白等同不传 |
| `include_trace` | bool | ❌ | false | 客户端开了本机 agent 调试模式时带 `true`,放行**本人**记录的 `trace_url` |
`ordered` / `keyword` 都在服务端过滤后再分页,客户端不要拿一页结果自己 filter ——
分页之后一页里可能一条都不命中,列表会看着像空的。
## 出参
响应 `200``{ items: ComparisonRecordOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定)
@@ -38,6 +44,9 @@
| `items` | object[] | 下单菜品 `{name, qty, specs?}` |
| `comparison_results` | object[] | 逐平台对比(price 单位元,已按 rank 升序) |
| `skipped_dish_names` | string[] | 被跳过的菜名 |
| `ordered` | bool | 「已下单」店级标记:店名命中本人 `source='compare'` 的下单记录即 `true`。**瞬态字段,不在表里**,每次查询现算 |
| `ad_coins_earned` | int | 本次比价看信息流广告实发的金币(按 `trace_id` 聚合)。同为瞬态字段 |
| `trace_url` | string \| null | pricebot 调试链接。未开 `debug_trace_enabled` 且未带 `include_trace=true` 时为 `null` |
| `created_at` | datetime | 时间 |
## 错误
+132
View File
@@ -0,0 +1,132 @@
# 消息通知中心(notifications 族)
> 所属:notifications 组(前缀 `/api/v1/notifications`,源 `app/api/v1/notifications.py`) | 鉴权:**全部 Bearer**(消息按用户隔离) | [← 返回 API 索引](./README.md)
>
> 对应 PRD《消息通知中心》(通知类型清单 / 点击跳转 / 字段元素 / 未读红点 / Push 文案)。
> Push 侧(厂商直推 + 测试)见 [push-vendor-test.md](./push-vendor-test.md)。
>
> **数据落库**:消息存 `notification` 表(`app/repositories/notification.py`,按用户隔离,`sentAt` 倒序)。业务事件统一走 `app/services/notification_events.py` 下发(站内消息 + 厂商 push 一条链路,业务事务 commit 后触发、失败只 log 不影响业务)。**已接入 6 类真实触发**:
>
> | type | 触发点 |
> |---|---|
> | `withdraw_success` | 提现单转账到账(免确认直达 / 查单归一化 / 对账兜底,`repositories/wallet.py`) |
> | `withdraw_failed` | 提现退款收口 `_refund_withdraw`(微信侧失败、审核拒绝、解绑退回) |
> | `feedback_reply` | admin 反馈审核「拒绝」(原因/留言用户可见,`admin/routers/feedback.py`) |
> | `feedback_reward` | admin 反馈审核「采纳」发金币(必带官方留言) |
> | `report_approved` | admin 上报更低价「通过」发金币(`admin/routers/price_report.py`) |
> | `invite_order_reward` | 被邀请好友首次成功比价 → 邀请人发 2 元(`repositories/invite.try_reward_on_compare`) |
>
> 其余类型(奖励过期 ×2、权限异常 ×4、好友催单)业务侧尚未接入。要造联调数据,用 [POST /api/v1/push/test](./push-vendor-test.md) 的 `createNotification:true` 逐条插入。
>
> ⚠️ **字段命名**:本组接口(含 push 测试组)对外为 **camelCase**(`sentAt` / `isRead` / `pageSize`…),与库内其他 snake_case 接口不同——按 PRD 前端契约对接,勿混用。
## 通知类型速查(13 种)
列表**服务端已排好序:全列表按时间倒序**(最新在前,**不做分类分组**——PRD §1 的"按分类分组"为笔误,2026-07-14 需求方确认取消),前端按返回顺序渲染即可。category 仅用于卡片头部的分类标签展示。
| category | 分类标签 | type | 类型 | cardStyle 版式 | actionText 操作行 | extra 里带什么 |
|---|---|---|---|---|---|---|
| withdraw_assistant | 提现助手 | `reward_expiring` | 金币现金奖励即将失效 | dual_amount 双金额卡 | 立即激活您的收益 | `batchId` |
| withdraw_assistant | 提现助手 | `reward_expired` | 金币现金奖励已失效 | dual_amount 双金额卡 | 立即赚取新收益 | — |
| withdraw_assistant | 提现助手 | `withdraw_success` | 提现成功 | withdraw 提现卡 | **null(无操作行,点击仅消红点)** | — |
| withdraw_assistant | 提现助手 | `withdraw_failed` | 提现失败,款项已退回 | withdraw 提现卡 | 重新提现 | `withdrawId` |
| system | 系统通知 | `perm_accessibility` | 比价功能异常(无障碍) | plain_text 纯文本卡 | 去开启 | `permission:"accessibility"` |
| system | 系统通知 | `perm_battery` | 比价续航保护异常(省电策略) | plain_text 纯文本卡 | 去开启 | `permission:"battery"` |
| system | 系统通知 | `perm_autostart` | 比价启动保护异常(自启动) | plain_text 纯文本卡 | 去开启 | `permission:"autostart"` |
| system | 系统通知 | `perm_overlay` | 比价按钮异常(悬浮窗) | plain_text 纯文本卡 | 去开启 | `permission:"overlay"` |
| feedback | 我的反馈 | `feedback_reply` | 官方回复 | plain_text 纯文本卡 | 查看详情 | `feedbackId` |
| feedback | 我的反馈 | `feedback_reward` | 反馈奖励(必带官方留言行) | coin_reward 金币奖励卡 | 查看反馈详情 | `feedbackId` |
| report | 我的爆料 | `report_approved` | 爆料审核通过 | coin_reward 金币奖励卡 | 查看爆料详情 | `reportId` |
| invite | 好友邀请 | `invite_order_reward` | 好友下单奖励到账 | friend_cash 好友现金卡 | 邀请更多好友赚现金 | `inviteeNickname` |
| invite | 好友邀请 | `invite_remind` | 好友催单提醒 | plain_text 纯文本卡 | 去提醒 TA | `inviteeNickname`, `scrollTo:"remind"` |
点击跳转逻辑按 PRD §2 由客户端按 `type` 分发;点击目标 = 整张卡片(不区分主体和操作行),任何点击都先调 `POST /read` 消该条红点。
## GET /api/v1/notifications — 消息列表(分页)
**入参(query)**
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `page` | int | ❌ | 页码,1 起,默认 1 |
| `pageSize` | int | ❌ | 每页条数,默认 20,最大 100 |
**出参**
```jsonc
{
"items": [
{
"id": 90001,
"category": "withdraw_assistant", // 分类 key(5 种,见上表)
"categoryLabel": "提现助手", // 卡片头部左上角分类标签
"type": "reward_expiring", // 类型 key(13 种,决定点击行为)
"cardStyle": "dual_amount", // 版式:dual_amount/withdraw/plain_text/coin_reward/friend_cash
"title": "金币现金奖励即将失效", // 卡片标题
"coins": 86, // 金币数,整数;无金币的版式为 null
"cashCents": 1280, // 现金金额(分);无现金的版式为 null
"cashYuan": "12.80", // 现金展示串(元,两位小数),与 cashCents 同源
"infoRows": [ // 信息行,已按 PRD 拼好文案,逐行 label: value 渲染
{ "label": "过期说明", "value": "您有86金币和12.80元现金即将失效,完成一次一键领券或一键比价即可激活收益" },
{ "label": "过期时间", "value": "3天后失效" }
],
"actionText": "立即激活您的收益", // 操作行;null = 无操作行(提现成功卡)
"extra": { "batchId": "batch_20260714" }, // 跳转/联动参数,按 type 取用(见上表)
"sentAt": "2026-07-14T14:59:58+08:00", // ISO8601 带时区
"isRead": false // false → 分类标签右侧显示 6px 红点(#E53935)
}
],
"page": 1,
"pageSize": 20,
"total": 16,
"hasMore": false,
"unreadCount": 12 // 与 /unread-count 同口径,进页面可顺手刷角标
}
```
**时间显示规则(前端处理 `sentAt`)**:今天 →「今天」;昨天 →「昨天」;当年 →「M月D日」(不补零);跨年 →「YYYY年M月D日」。`sentAt` 恒带 +08:00(服务端已归一,与库底层用 SQLite/PostgreSQL 无关)。
**数值约束(PRD §3)**:金币整数不带小数;现金/提现金额两位小数(直接用 `cashYuan`)。
## GET /api/v1/notifications/unread-count — 未读总数(首页铃铛角标)
无入参。**出参**:
```jsonc
{ "count": 12, "badgeText": "12" } // count>99 时 badgeText="99+";count=0 时 badgeText=null → 整个角标隐藏
```
刷新时机(PRD §4):进入首页时、从通知中心/其他页面返回首页时(原型监听 `pageshow`)。
## POST /api/v1/notifications/read — 标记已读
**入参(JSON),两种模式二选一(同时传时 `all` 优先)**
| 模式 | body | 使用场景 |
|---|---|---|
| 单条/多条 | `{ "ids": [90001, 90003] }` | ① 点击某张消息卡片(点击后无论跳转/弹窗/无动作都算已读);② 用户点击 push 直达落地页后,客户端拿 push extras 里的 `notificationId` 同步置读 |
| 全量清零 | `{ "all": true }` | 进入通知中心自动清零(只浏览列表就消红点,无需逐条点击;退出通知中心时也可再调一次兜底) |
**出参**
```jsonc
{ "ok": true, "markedCount": 2, "unreadCount": 10 } // unreadCount = 处理后剩余未读,可直接刷新角标
```
幂等:不存在/已读的 id 忽略,重复调用 `markedCount=0` 不报错。
**错误**:`400` ids 与 all 都没传(或 ids 为空数组);`401` 未鉴权。
## 联调小抄
```bash
# 1. 登录拿 token(SMS mock:任意手机号 + 任意 6 位验证码)
curl -X POST :8770/api/v1/auth/sms/send -d '{"phone":"13800001234"}'
curl -X POST :8770/api/v1/auth/sms/login -d '{"phone":"13800001234","code":"123456"}'
# 2. 列表 / 角标 / 置读
curl ":8770/api/v1/notifications?page=1&pageSize=20" -H "Authorization: Bearer $TOKEN"
curl ":8770/api/v1/notifications/unread-count" -H "Authorization: Bearer $TOKEN"
curl -X POST ":8770/api/v1/notifications/read" -d '{"all":true}' -H "Authorization: Bearer $TOKEN"
```
列表初始为空,登录后先用 [POST /api/v1/push/test](./push-vendor-test.md) 的 `createNotification:true` 插几条(可指定 `type` 覆盖不同版式),再验列表 / 角标 / 置读全流程;它同时把 `notificationId` 放进 push extras,可闭环验证「push → 站内已读联动」。
@@ -0,0 +1,36 @@
# GET /api/v1/platform/huawei-review — 华为审核开关
> 所属:Platform 组(前缀 `/api/v1/platform` | 鉴权:无 | [← 返回 API 索引](../README.md)
华为应用市场审核要求:新手引导的**「快速设置」权限步必须可被用户关闭**(引导视频页不在要求内)。本端点把运营后台配的开关下发给客户端,决定该步左上角是否出现退出按钮。
**不鉴权**:引导页在登录之前就展示,此时客户端必然没有 token。
值来自 `app_config` 表的 `huawei_review` 行(admin 页 `GET / PATCH /admin/api/huawei-review` 可改),空库回退 `default`
## 入参
无。
## 出参
响应 `200`:`HuaweiReviewOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `mode` | string | `default`(强制展示,不可关闭 = 上线至今的现状)/ `review`(可关闭,过审用)。仅供排查时看后台切成了哪态 |
| `onboarding_closable` | bool | 快速设置权限步是否允许用户退出(= `mode == "review"`)。**客户端只读这一个字段决策** |
Mock 出参:
```json
{
"mode": "review",
"onboarding_closable": true
}
```
## 说明
- **只有华为 ROM 客户端会来拉**HarmonyOS / EMUI;荣耀 MagicOS 不拉)。机型判断在客户端做(`OemDetector`),服务端不看 UA,也就不用维护机型名单。
- 客户端在进新手引导前拉一次并本地缓存;请求失败/超时用上次缓存值,从未拉到过则按 `onboarding_closable=false`(宁可不给退出按钮,也不误放开)。
- 脏值兜底:DB 里 `mode` 不在枚举内时服务端一律回退 `default`
- 切回 `default` 即可一键收回退出按钮(审核通过后无需发版)。

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