Compare commits

...

22 Commits

Author SHA1 Message Date
lowmaster-chen 51ee9f738e 接入厂商直推服务端并补充推送验收接口
- 新增荣耀、vivo、小米、OPPO 服务端推送分发
- 设备注册和心跳支持 push_vendor/push_token
- 增加 /api/v1/device/push-test 延迟验收接口
- 补充数据库迁移、配置示例和厂商推送测试
- 保留本分支已有微信登录联调改动

验证:python -m pytest tests/test_device_push.py tests/test_auth.py tests/test_health.py
2026-07-02 22:44:58 +08:00
lowmaster-chen 21fc930c0a fix: 修复美团订单时间入库并校准金币大盘口径
改了什么:补全美团订单时间戳转换,避免 pay_time 为空导致昨日 CPS 收益漏算;同时让金币经济的本期/累计发放总数按领券、比价、激励视频、常规任务四项加总。
为什么改:产品核对发现昨日美团有收益但大盘未显示,且发放金币总数与分类卡片不一致。
验证方式:后端聚合自检确认 2026-06-30 美团佣金 86 分、有效订单 2 单,金币总数等于四项分类之和;py_compile 通过。
2026-07-01 14:56:22 +08:00
lowmaster-chen a7d8562ddd fix: 调整数据大盘 DAU 为开始比价和开始领券口径 2026-06-30 19:49:49 +08:00
lowmaster-chen ad6a579ff8 fix: 数据大盘京东 CPS 仅统计实际佣金 2026-06-30 15:50:32 +08:00
lowmaster-chen 08a16c7632 feat: 接入京东联盟订单到数据大盘
改了什么:新增京东联盟订单查询客户端、cps_order 京东字段迁移、CPS 对账接口 platform=all/jd、数据大盘京东订单与佣金聚合。
为什么改:数据大盘需要展示京东 CPS 真实订单佣金,不再保持占位。
验证方式:python -m pytest tests\\test_admin_read.py::test_dashboard_overview tests\\test_cps_admin.py::test_jd_reconcile_updates_dashboard -q;本地真实拉取 2026-06-27 京东订单 3 行,页面显示京东有效 1 单/佣金 ¥0.13。
2026-06-28 17:25:25 +08:00
xiebing 2ed62c789f feat(h5): 我的页改 H5(mine 页 + shared bridge/api) (#89)
- h5/mine/index.html: 个人中心「我的页」H5 实现
- h5/shared/bridge.js, api.js: H5↔客户端桥与 API 封装(mine 依赖)

Reviewed-on: #89
Co-authored-by: xiebing <xiebing@wonderable.ai>
Co-committed-by: xiebing <xiebing@wonderable.ai>
2026-06-28 14:16:32 +08:00
chenshuobo 1548406f29 美团 feed 改用离线库 + 三 tab 降级兜底(替换 main 的实时版 feed) (#88)
Co-authored-by: lowmaster-chen <1119780489@qq.com>
Co-authored-by: chenshuobo <1119780489@qq.com>
Reviewed-on: #88
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-28 10:38:31 +08:00
guke b7b958ed58 perf(pricebot): 透传复用共享 httpx 单例,免每请求重建 SSL 上下文 + 绕过进程代理 (#87)
coupon/compare 透传原先每请求 async with httpx.AsyncClient(...) 新建:
每次重建 SSL 上下文(加载 certifi CA)实测 ~1s+,而 pricebot 是纯 HTTP
透传根本用不到 TLS;且 trust_env 默认 True 会把 http://localhost:8000
经进程代理(Clash)再绕几秒。

抽 app/core/pricebot_client.py 共享单例:trust_env=False 直连,lifespan
启动预热(SSL 一次性成本付在启动)、关停 aclose;timeout 下放到 .post()
保留 coupon 30s / compare 60s 差异。keep-alive 复用 TCP,每帧降到个位数 ms。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #87
Co-authored-by: guke <guke@wonderable.ai>
Co-committed-by: guke <guke@wonderable.ai>
2026-06-28 09:36:31 +08:00
wuqi f3cd97a190 feat: 信息流广告改"所见即所得"发奖——直接发客户端小球显示金币 (#86)
Reviewed-on: #86
Co-authored-by: wuqi <wuqi@wonderable.ai>
Co-committed-by: wuqi <wuqi@wonderable.ai>
2026-06-27 23:49:51 +08:00
zhuzihao b4c27f4d88 fix(ad): eCPM 上报撞会话唯一约束不再抛 500,按全局口径兜底去重 (#85)
create_ecpm_record 撞 uq_ad_ecpm_record_session(只含 ad_session_id、全局唯一)
时,改用与约束同口径的全局查找 _find_by_session_global(仅按 ad_session_id)兜底,
并去掉末尾的 raise。原逻辑兜底用带 user_id 的 find_by_session:不同 user 上报了
同一 ad_session_id 时二次查找仍为 None → raise → 接口 500,违背该接口
best-effort / fire-and-forget 约定(丢一两条不影响业务,穿山甲后台才是结算权威)。
find_by_session 签名与行为不变,另两个调用方(ad_reward、ad.py)不受影响。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #85
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-27 22:42:58 +08:00
zhuzihao e38120ad49 feat(ad-revenue): 收益报表分页/场景筛选/倒序,并修复信息流金币审计复算口径+ 大盘改版 (#84)
收益报表(/admin/api/ad-revenue-report):
- 明细改按时间倒序;新增 offset 真分页(limit 作每页大小、total 为全量),可翻页看当前筛选下全部数据,突破原 1000 条上限。
- 「场景」(feed_scene)下推后端做全局筛选,同时作用于明细/合计/daily·hourly 趋势(原为前端仅过滤明细)。
- 新增全量 hourly 序列,按小时趋势改用它,不再受分页截断影响。

修复信息流金币审计复算口径漂移(ad_audit):
- 发奖侧 grant_feed_reward 早已是「一条广告=1份、LT 按账号累计条数(COUNT)」,但审计仍按 unit_count 逐份累加 + SUM(unit_count) 做 LT 基线,导致单条停留>20s(份数>1)时应发虚高、必然「✗ 不符」。
- 审计改为每条 granted 按 1 份复算、LT 基线用 COUNT,与发奖对齐;金币审计页与收益报表(复用同一复算)一并恢复正确。纯复算口径修正,不改实际发奖、不动钱。

文档同步更新 admin-ad-revenue-report.md。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #84
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-27 22:42:54 +08:00
marco 3d67749101 fix(compare-record): specs 兼容 pricebot 嵌套规格对象
pricebot 嵌套规格统一(2026-06-27)后 calibration.specs 可能是规格对象
[{name, qty, sub_specs}] 而非字符串。ComparisonItemIn.specs 加 before-validator
把对象拍平成可读字符串"主项(子1,子2)",兼容新旧输入、保持 list[str] 契约(下游零改动)。
防两坑:① 直接声明 list[str] → 对象 specs 整条 422 被拒、不入库;
② 仅放宽成裸 list → 下游 join 出"[object Object]"乱码。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 09:26:00 +08:00
marco f1c2ea662b fix(migration): merge alembic 3-way 分叉为单 head(修本地 upgrade head 报 Multiple head revisions)
#82(invite_cash)/埋点(c2874d2bf705)/device_first_protected_at 三个 PR 并行合 main 留下 3 个 head,
`alembic upgrade head`(单数)报 Multiple head revisions。加一个空 merge 迁移把三支并成单 head,
deploy 的 `upgrade heads`(复数)和本地 `upgrade head` 都干净。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:06:53 +08:00
marco be15a119a7 fix(migration): 缩短 revision id 至 26 字符(修 alembic_version varchar(32) 截断致 0.2.1 部署失败)
invite_cash_account_and_compare_reward(38字符) > alembic_version.version_num varchar(32),
INSERT 时 psycopg StringDataRightTruncation → 部署 alembic 迁移失败。改为 invite_cash_compare_reward(26)。
该迁移是链末端(无后续 down_revision 依赖)、首次部署(任何环境都没应用过、PG DDL 事务失败已回滚无残留), 改 id 安全。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 02:58:29 +08:00
zhuzihao 59fe715245 feat(analytics): 埋点事件接收接口 + admin 查询接口 + 埋点表 (#83)
Reviewed-on: #83
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-26 23:56:29 +08:00
zhuzihao 667cda566f feat(analytics): 埋点事件接收接口 + admin 查询接口 + 埋点表 (#81)
新手引导埋点的服务端:
- 表 analytics_event(五维硬性列 + props JSON 扩展字段;event/device_id/user_id/session_id/created_at 带索引)
- POST /api/v1/analytics/events:客户端批量上报(不鉴权、body 读可选 user_id、补 client_ip + server_at)
- admin GET /admin/api/event-logs:列表 + 按 事件/设备/用户/会话/时间 筛选(offset 分页,照 list_feedbacks)
- alembic migration 建表(autogenerate 顺带检出的 ad/cps 历史索引漂移已手动剔除)

app 主后端 :8770 与 admin :8771 共用同一 SQLite,admin 同库直接查、无需跨库。
配套客户端五维上报 + admin 日志页(另两仓库 PR)。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #81
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-26 23:33:31 +08:00
xiebing 19f5987436 feat(invite): 邀请奖励金账户(与金币隔离) + 比价发奖 + 奖励金提现 (#82)
Reviewed-on: #82
Co-authored-by: xiebing <xiebing@wonderable.ai>
Co-committed-by: xiebing <xiebing@wonderable.ai>
2026-06-26 21:10:21 +08:00
chenshirui 1d6432d8bb feat(admin): 设备存活监控接口 + 首次无障碍开启时间(first_protected_at) (#80)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------

Co-authored-by: 陈世睿 <2839904623@qq.com>
Reviewed-on: #80
Co-authored-by: chenshirui <chenshirui@wonderable.ai>
Co-committed-by: chenshirui <chenshirui@wonderable.ai>
2026-06-26 15:28:16 +08:00
guke 2eb44fe947 feat(cps): 每日明细按天按用户领券下钻接口 + /daily date 改全日期 (#79)
- 新增 GET /admin/api/cps/groups/{id}/day-users?date=YYYY-MM-DD:
  按 openid 聚合当天领券(copy)/点击(visit) + 每人 visit 过的券(券×次数)

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #79
2026-06-26 15:17:37 +08:00
zhuzihao ea22a11ef9 fix(ad): 激励视频发奖按环境隔离,堵测试包看广告给生产同 id 用户串号发币 (#77)
## 问题
激励视频发奖走穿山甲 S2S 回调 /api/v1/ad/pangle-callback,发币时只认回调里的裸数字
user_id(grant_ad_reward 仅校验「本库存在该 id」),无任何环境归属校验。

同一 user_id 在测试库(本地 localhost)与正式库(线上)是两个不同的人;穿山甲 S2S 回调
地址只配一个(指向生产),测试包(dev)看广告的回调也会打到生产 → 生产把奖发给「本库里
同 user_id 的另一个真人」,造成跨库串号:测试看一次广告,正式库同号用户也被发币(线上
广告收益页因此多出一条本不该有的发奖记录)。

## 修复(服务端,按环境隔离)
pangle_callback 解析激励视频 mediaExtra 透传的 srv_env(dev/prod),与本服 settings.APP_ENV
不符则受理但不发币、不写记录(is_verify=true 防穿山甲重试)。不带 srv_env 的旧客户端按本
环境放行(兼容,不误伤存量正式用户)。

- 新增回归测试 test_callback_foreign_env_skipped(srv_env=prod 打到 APP_ENV=dev 的服务 → 不发)
- sim_pangle_callback 增加 --srv-env 便于本地手验

客户端配套(mediaExtra 写入 srv_env)见 app-android 对应 PR。

## 生效前提(三者缺一仍漏)
1. 生产 app-server 部署本代码并重启
2. 生产 .env 为 APP_ENV=prod
3. app 重新构建(旧包不带 srv_env 仍走兼容放行)

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #77
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-26 13:17:27 +08:00
zhuzihao 2edebfad83 feat(auth): 短信登录防刷 — 同一设备id同IP 每小时限 5 次 (#78)
Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #78
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-26 13:17:07 +08:00
guke 3dca126411 fix(dev): 启动脚本钉定 .venv 解释器 + watchfiles 降噪 (#75)
微信优惠券增加美团/京东中间页,领券前获取微信头像授权(主功能)

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #75
Co-authored-by: guke <guke@wonderable.ai>
Co-committed-by: guke <guke@wonderable.ai>
2026-06-25 17:47:30 +08:00
99 changed files with 14648 additions and 419 deletions
+48 -4
View File
@@ -18,7 +18,7 @@ JWT_ACCESS_TOKEN_EXPIRE_MINUTES=120
# refresh token 有效期(天),默认 30 天
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
# ===== 极光一键登录 =====
# ===== 极光一键登录 / 短信 =====
# 控制台 → 应用设置 → 应用信息 拿到
JG_APP_KEY=
JG_MASTER_SECRET=
@@ -27,7 +27,43 @@ 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 后置检测;本期不接推送)=====
# ===== 厂商直推(无障碍保护存活告警)=====
# 敏感密钥只放 .env / 服务器环境变量,不要提交到 git。
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
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
# ===== 无障碍保护存活监控(推送 + pull 后置兜底)=====
HEARTBEAT_MONITOR_ENABLED=true
HEARTBEAT_TIMEOUT_MINUTES=10
HEARTBEAT_SCAN_INTERVAL_SEC=60
@@ -58,6 +94,13 @@ MT_CPS_DEFAULT_SID=sgbjia
# 线上国内服务器留空(=直连)。留空且本机直连失败时 /feed、/coupons、/top-sales 会返回空。
MT_CPS_PROXY=
# ===== 京东联盟 CPS =====
# 京东联盟/京东宙斯开放平台创建应用后填写。AUTH_KEY 是工具商授权 key,自有应用可留空。
JD_UNION_APP_KEY=
JD_UNION_APP_SECRET=
JD_UNION_SITE_ID=
JD_UNION_AUTH_KEY=
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
@@ -82,8 +125,9 @@ INTERNAL_API_SECRET=
# 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类
CORS_ALLOW_ORIGINS=
# ===== 微信支付(商家转账到零钱 / 提现)=====
# appid/secret 来自微信开放平台移动应用;mch/序列号/公钥ID 来自微信支付商户平台
# ===== 微信开放平台 App + 微信支付(登录 / 商家转账到零钱 / 提现)=====
# WECHAT_APP_ID/WECHAT_APP_SECRET 来自微信开放平台移动应用,用于 App 微信一键登录(code 换 openid)
# mch/序列号/公钥ID 来自微信支付商户平台,仅提现/转账需要。
# 证书 .pem 放 secrets/(已 gitignore)。
WECHAT_APP_ID=wxxxxxxxxxxxxxxxxx
WECHAT_APP_SECRET=your_app_secret
+116
View File
@@ -0,0 +1,116 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.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,65 @@
"""add analytics_event table
Revision ID: 1699fc2c069f
Revises: bcfcaf07152b
Create Date: 2026-06-26 16:35:16.133975
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '1699fc2c069f'
down_revision: str | Sequence[str] | None = 'bcfcaf07152b'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('analytics_event',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('event', sa.String(length=64), nullable=False),
sa.Column('props', sa.JSON(), nullable=True),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('session_id', sa.String(length=64), nullable=True),
sa.Column('client_ts', sa.BigInteger(), nullable=False),
sa.Column('sent_at', sa.BigInteger(), nullable=True),
sa.Column('page', sa.String(length=64), nullable=True),
sa.Column('client_ip', sa.String(length=64), nullable=True),
sa.Column('oem', sa.String(length=32), nullable=True),
sa.Column('os', sa.String(length=32), nullable=True),
sa.Column('model', sa.String(length=64), nullable=True),
sa.Column('app_ver', sa.String(length=32), nullable=True),
sa.Column('network', sa.String(length=16), nullable=True),
sa.Column('channel', sa.String(length=32), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('analytics_event', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_analytics_event_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_device_id'), ['device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_event'), ['event'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_session_id'), ['session_id'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_user_id'), ['user_id'], unique=False)
# 注:autogenerate 顺带检出 ad_ecpm/cps_*/invite_fingerprint 的历史索引漂移,
# 与本次「新增埋点表」无关,已手动移除,避免本迁移误改他人表。
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('analytics_event', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_analytics_event_user_id'))
batch_op.drop_index(batch_op.f('ix_analytics_event_session_id'))
batch_op.drop_index(batch_op.f('ix_analytics_event_event'))
batch_op.drop_index(batch_op.f('ix_analytics_event_device_id'))
batch_op.drop_index(batch_op.f('ix_analytics_event_created_at'))
op.drop_table('analytics_event')
# ### end Alembic commands ###
@@ -0,0 +1,26 @@
"""merge invite_cash + analytics + device heads
Revision ID: 7db22acee504
Revises: c2874d2bf705, device_first_protected_at, invite_cash_compare_reward
Create Date: 2026-06-27 03:06:52.594401
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7db22acee504'
down_revision: Union[str, Sequence[str], None] = ('c2874d2bf705', 'device_first_protected_at', 'invite_cash_compare_reward')
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,46 @@
"""add ad_type to ad_feed_reward and feed_scene to ad_ecpm
把信息流广告全面改造成 Draw 信息流(draw):
- ad_feed_reward_record.ad_type:广告形态 feed(信息流) / draw(Draw 信息流)。可空,旧数据 NULL
一律视为 feed(向后兼容);每日上限与因子2(LT)仍按本表全表 unit 累计,不按 ad_type 拆。
- ad_ecpm_record.feed_scene:点位场景 comparison(比价) / coupon(领券) / welfare(福利),供广告
收益报表区分比价/领券 Draw 收益;仅信息流/Draw 上报,激励视频为 NULL。
注:autogenerate 会顺带探测到 analytics_event / cps_* / invite_fingerprint 等无关索引差异(本地库与
metadata 漂移、analytics 模型尚未并入 __init__),与本次改动无关,已手工剔除——本迁移只 add 两列。
SQLite 经 env.py 的 render_as_batch 自动走 batch_alter_table 重建表。
Revision ID: c2874d2bf705
Revises: 1699fc2c069f
Create Date: 2026-06-26 16:48:52.158609
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c2874d2bf705"
down_revision: str | Sequence[str] | None = "1699fc2c069f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# 信息流发奖记录:新增广告形态 feed/draw(可空,旧数据 NULL=feed)
op.add_column(
"ad_feed_reward_record",
sa.Column("ad_type", sa.String(length=16), nullable=True),
)
# eCPM 展示上报:新增点位场景 comparison/coupon/welfare(可空,激励视频/旧数据 NULL)
op.add_column(
"ad_ecpm_record",
sa.Column("feed_scene", sa.String(length=16), nullable=True),
)
def downgrade() -> None:
op.drop_column("ad_ecpm_record", "feed_scene")
op.drop_column("ad_feed_reward_record", "ad_type")
+52 -27
View File
@@ -23,39 +23,64 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 活动:淘宝淘口令 / 京东链接
op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True))
def _has_column(insp: sa.Inspector, table: str, column: str) -> bool:
return any(c["name"] == column for c in insp.get_columns(table))
# 群:多平台(现有群都是美团,server_default 回填)+ sid 可空
op.add_column(
"cps_group",
sa.Column(
"platforms",
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
nullable=False,
server_default=sa.text("'[\"meituan\"]'::jsonb"),
),
def upgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
# SQLite 不认 ::jsonb cast(会报 "unrecognized token: :"),只有 PG 用显式 cast。
# 两边列类型都靠上面的 with_variant 区分,这里只管 server_default 文本。
platforms_default = (
sa.text("'[\"meituan\"]'::jsonb")
if bind.dialect.name == "postgresql"
else sa.text("'[\"meituan\"]'")
)
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=True)
# 活动:淘宝淘口令 / 京东链接
# 加 add_column 存在性守卫:本迁移历史上在 SQLite 第一次跑时加完 payload 即在
# platforms 那步炸掉(::jsonb),DDL 非事务不回滚 → 半应用状态。守卫让其可自愈重跑。
if not _has_column(insp, "cps_activity", "payload"):
op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True))
# 群:多平台(现有群都是美团,server_default 回填)+ sid 可空。
# SQLite 不支持 ALTER COLUMN,必须用 batch(重建表)才能改 nullable;PG 走原生 ALTER。
with op.batch_alter_table("cps_group") as batch_op:
if not _has_column(insp, "cps_group", "platforms"):
batch_op.add_column(
sa.Column(
"platforms",
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
nullable=False,
server_default=platforms_default,
)
)
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
# link:sid 可空 + target 加长
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=True)
op.alter_column("cps_link", "target_url", existing_type=sa.String(1024), type_=sa.String(2048))
with op.batch_alter_table("cps_link") as batch_op:
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
batch_op.alter_column("target_url", existing_type=sa.String(1024), type_=sa.String(2048))
# click:sid 可空 + 事件类型
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=True)
op.add_column(
"cps_click",
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit"),
)
with op.batch_alter_table("cps_click") as batch_op:
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
if not _has_column(insp, "cps_click", "event_type"):
batch_op.add_column(
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit")
)
def downgrade() -> None:
op.drop_column("cps_click", "event_type")
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=False)
op.alter_column("cps_link", "target_url", existing_type=sa.String(2048), type_=sa.String(1024))
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=False)
op.drop_column("cps_group", "platforms")
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=False)
op.drop_column("cps_activity", "payload")
with op.batch_alter_table("cps_click") as batch_op:
batch_op.drop_column("event_type")
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
with op.batch_alter_table("cps_link") as batch_op:
batch_op.alter_column("target_url", existing_type=sa.String(2048), type_=sa.String(1024))
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
with op.batch_alter_table("cps_group") as batch_op:
batch_op.drop_column("platforms")
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
with op.batch_alter_table("cps_activity") as batch_op:
batch_op.drop_column("payload")
@@ -0,0 +1,31 @@
"""device_liveness 加 first_protected_at(首次开无障碍时刻)
Revision ID: device_first_protected_at
Revises: bcfcaf07152b
Create Date: 2026-06-25 00:00:00.000000
admin 设备存活页要展示「首次无障碍开启时间」。touch_heartbeat 在 ever_protected 首次翻 true
时记一次(后续心跳不覆盖)。仅新增可空列,SQLite 原生支持 add_column、不用 batch;downgrade
的 drop_column 在 SQLite 走 batch_alter_table 兜底。老设备无此时刻 → 留 NULL(无法准确回填)。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "device_first_protected_at"
down_revision: Union[str, Sequence[str], None] = "bcfcaf07152b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"device_liveness",
sa.Column("first_protected_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
with op.batch_alter_table("device_liveness") as batch_op:
batch_op.drop_column("first_protected_at")
@@ -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,88 @@
"""invite cash account isolation + compare reward tracking (🅱-1)
Revision ID: invite_cash_compare_reward
Revises: feedback_review_fields
Create Date: 2026-06-23 00:00:00.000000
邀请功能 v2 账户隔离 + 比价发奖追踪:
- coin_account 加 invite_cash_balance_cents(邀请奖励金独立余额,与金币兑换的 cash 物理隔离)
- withdraw_order 加 source(标记提现扣哪个账户,退款退回对应账户;旧单默认 coin_cash)
- invite_relation 加比价发奖追踪三列(好友比价多次只发一次)
- 新增 invite_cash_transaction 表(邀请奖励金独立流水账本)
加列均 NOT NULL + server_default,存量行自动填默认值,安全。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'invite_cash_compare_reward'
down_revision: Union[str, Sequence[str], None] = 'feedback_review_fields'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. 邀请奖励金独立余额(与金币兑换的 cash_balance_cents 物理隔离;红线:两本账不可累加)
op.add_column(
'coin_account',
sa.Column('invite_cash_balance_cents', sa.Integer(), nullable=False, server_default='0'),
)
# 2. 提现单标记账户来源:coin_cash / invite_cash,退款退回对应账户(旧单默认 coin_cash)
op.add_column(
'withdraw_order',
sa.Column('source', sa.String(length=16), nullable=False, server_default='coin_cash'),
)
# 3. 邀请关系加比价发奖追踪(好友"下载+登录+比价一次"→ 给邀请人发奖,只发一次)
op.add_column(
'invite_relation',
sa.Column('compare_reward_granted', sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
'invite_relation',
sa.Column('compare_reward_cents', sa.Integer(), nullable=False, server_default='0'),
)
op.add_column(
'invite_relation',
sa.Column('compare_rewarded_at', sa.DateTime(timezone=True), nullable=True),
)
# 4. 邀请奖励金独立流水表(结构同 cash_transaction;balance_after 记 invite_cash_balance_cents)
op.create_table(
'invite_cash_transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('amount_cents', sa.Integer(), nullable=False),
sa.Column('balance_after_cents', sa.Integer(), nullable=False),
sa.Column('biz_type', sa.String(length=32), nullable=False),
sa.Column('ref_id', sa.String(length=64), nullable=True),
sa.Column('remark', sa.String(length=128), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_invite_cash_transaction_user_id', 'invite_cash_transaction', ['user_id'])
op.create_index('ix_invite_cash_transaction_created_at', 'invite_cash_transaction', ['created_at'])
# 提现退款幂等:一个提现单只退一次(partial unique,对齐 cash_transaction 的 withdraw_refund 去重)
op.create_index(
'ux_invite_cash_txn_refund_ref',
'invite_cash_transaction',
['ref_id'],
unique=True,
sqlite_where=sa.text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
postgresql_where=sa.text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index('ux_invite_cash_txn_refund_ref', table_name='invite_cash_transaction')
op.drop_index('ix_invite_cash_transaction_created_at', table_name='invite_cash_transaction')
op.drop_index('ix_invite_cash_transaction_user_id', table_name='invite_cash_transaction')
op.drop_table('invite_cash_transaction')
op.drop_column('invite_relation', 'compare_rewarded_at')
op.drop_column('invite_relation', 'compare_reward_cents')
op.drop_column('invite_relation', 'compare_reward_granted')
op.drop_column('withdraw_order', 'source')
op.drop_column('coin_account', 'invite_cash_balance_cents')
+56
View File
@@ -0,0 +1,56 @@
"""add jd cps order fields
Revision ID: jd_cps_order_fields
Revises: 7db22acee504
Create Date: 2026-06-28 20:30:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "jd_cps_order_fields"
down_revision = "7db22acee504"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("cps_order") as batch_op:
batch_op.add_column(
sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan")
)
batch_op.add_column(sa.Column("external_order_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("external_row_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("estimated_commission_cents", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("actual_commission_cents", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("jd_valid_code", sa.String(length=16), nullable=True))
batch_op.add_column(sa.Column("settle_month", sa.String(length=16), nullable=True))
batch_op.add_column(sa.Column("site_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("position_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("pid", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("sub_union_id", sa.String(length=128), nullable=True))
batch_op.create_index("ix_cps_order_platform", ["platform"])
batch_op.create_index("ix_cps_order_external_order_id", ["external_order_id"])
batch_op.create_index("ix_cps_order_external_row_id", ["external_row_id"])
batch_op.create_index("ix_cps_order_jd_valid_code", ["jd_valid_code"])
def downgrade() -> None:
with op.batch_alter_table("cps_order") as batch_op:
batch_op.drop_index("ix_cps_order_jd_valid_code")
batch_op.drop_index("ix_cps_order_external_row_id")
batch_op.drop_index("ix_cps_order_external_order_id")
batch_op.drop_index("ix_cps_order_platform")
batch_op.drop_column("sub_union_id")
batch_op.drop_column("pid")
batch_op.drop_column("position_id")
batch_op.drop_column("site_id")
batch_op.drop_column("settle_month")
batch_op.drop_column("jd_valid_code")
batch_op.drop_column("actual_commission_cents")
batch_op.drop_column("estimated_commission_cents")
batch_op.drop_column("external_row_id")
batch_op.drop_column("external_order_id")
batch_op.drop_column("platform")
+4
View File
@@ -23,7 +23,9 @@ from app.admin.routers.comparison import router as comparison_router
from app.admin.routers.config import router as config_router
from app.admin.routers.cps import router as cps_router
from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.device_liveness import router as device_liveness_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_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.onboarding import router as onboarding_router
@@ -83,6 +85,7 @@ def health() -> dict[str, str]:
admin_app.include_router(auth_router)
admin_app.include_router(dashboard_router)
admin_app.include_router(device_liveness_router)
admin_app.include_router(ops_stat_config_router)
admin_app.include_router(ops_marquee_seed_router)
admin_app.include_router(users_router)
@@ -91,6 +94,7 @@ admin_app.include_router(wallet_router)
admin_app.include_router(withdraw_router)
admin_app.include_router(price_report_router)
admin_app.include_router(feedback_router)
admin_app.include_router(event_logs_router)
admin_app.include_router(feedback_qr_router)
admin_app.include_router(admins_router)
admin_app.include_router(audit_router)
+61 -27
View File
@@ -4,9 +4,9 @@
- 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号
(与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置,
故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 该用户 granted 份数**账号累计**
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致;同样不按天重置,
复算需叠加本日之前的累计数)。
- 信息流:**每条 granted = 1 份**(与 ad_feed_reward.grant_feed_reward 同口径:看满一份即发该条
满额,**不按 unit_count 逐份累加**),LT 序号 = 该用户 granted **条数**账号累计
(与 ad_feed_reward.granted_unit_total 的 COUNT 一致;不按天重置,复算需叠加本日之前的累计数)。
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
"""
@@ -108,14 +108,18 @@ def _reward_video_rows(
return rows
def _feed_prior_granted_units(
def _feed_prior_granted_count(
db: Session, *, date: str, user_id: int | None
) -> dict[int, int]:
"""各用户在 date **之前** granted 的信息流份数累计,作为当日复算的 LT 序号起点。"""
"""各用户在 date **之前** granted 的信息流**条数**累计,作为当日复算的 LT 序号起点。
与发奖侧 ad_feed_reward.granted_unit_total(COUNT status=granted)对齐:一条广告 = 1 份,
LT 按账号累计**条数**递进。**不再用 SUM(unit_count)**——那是「一条按时长折多份」的过时口径,
与现行发奖(每条 1 份)漂移,会让 unit_count>1 的记录复算虚高、对账恒「不符」。"""
stmt = (
select(
AdFeedRewardRecord.user_id,
func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0),
func.count(),
)
.where(
AdFeedRewardRecord.reward_date < date,
@@ -128,8 +132,29 @@ def _feed_prior_granted_units(
return {uid: int(n) for uid, n in db.execute(stmt).all()}
def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。"""
def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool:
"""信息流记录是否落入请求的展示筛选 scene。
- scene=="feed":ad_type in ("feed", NULL)(旧数据 NULL 视为 feed,向后兼容)
- scene=="draw":ad_type=="draw"
- scene 为 None:不筛(两类都要)。
"""
if scene == "feed":
return rec.ad_type in (None, "feed")
if scene == "draw":
return rec.ad_type == "draw"
return True
def _feed_rows(
db: Session, *, date: str, user_id: int | None, scene: str | None = None
) -> list[dict]:
"""信息流记录复算。**每条 granted = 1 份**(与发奖同口径,不按 unit_count 累加),
LT 序号沿用账号累计**条数**(含本日之前)。
**关键:LT 因子账号累计按全表 granted 条数累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**——
故无论 scene 怎么筛展示,这里都遍历当日**全部**信息流记录维持 granted_count 累加;scene 只决定
哪些行被**留下展示**(由 _feed_scene_matches 判断),不影响累计基线,保证复算序号与正式发奖一致。
"""
stmt = (
select(AdFeedRewardRecord)
.where(AdFeedRewardRecord.reward_date == date)
@@ -138,22 +163,24 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
if user_id is not None:
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
# 本日之前的累计份数做起点,与 _unit_reward_total 的 existing_units(累计)对齐
granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id)
# 本日之前的累计**条数**做起点,与发奖侧 granted_unit_total(COUNT granted)对齐
granted_count: dict[int, int] = _feed_prior_granted_count(db, date=date, user_id=user_id)
rows: list[dict] = []
for rec in db.execute(stmt).scalars():
keep = _feed_scene_matches(rec, scene) # 累计照常推进,这里只决定是否展示本行
if rec.status == "granted":
existing = granted_units.get(rec.user_id, 0)
units = rec.unit_count
expected = sum(
rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset)
for offset in range(1, units + 1)
)
granted_units[rec.user_id] = existing + units
start = existing + 1 if units > 0 else None
end = existing + units if units > 0 else None
# 一条广告 = 1 份(与 grant_feed_reward 同口径:看满一份即发该条满额,不按 unit_count 累加)。
# nth = 账号累计第几**条**(含本日之前),与发奖侧 granted_unit_total+1 对齐;累计照常推进
# (即便 scene 不匹配不展示也要 +1,保证序号与正式发奖一致)。
nth = granted_count.get(rec.user_id, 0) + 1
granted_count[rec.user_id] = nth
if not keep:
continue
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
rows.append({
"scene": "feed",
"ad_type": rec.ad_type or "feed",
"feed_scene": rec.feed_scene,
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
@@ -163,18 +190,22 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
"units": units,
"lt_index_start": start,
"lt_index_end": end,
"lt_factor_start": rewards.ad_lt_factor(start) if start else None,
"lt_factor_end": rewards.ad_lt_factor(end) if end else None,
"units": 1,
"lt_index_start": nth,
"lt_index_end": nth,
"lt_factor_start": rewards.ad_lt_factor(nth),
"lt_factor_end": rewards.ad_lt_factor(nth),
"expected_coin": expected,
"actual_coin": rec.coin,
"matched": expected == rec.coin,
})
else:
if not keep:
continue
rows.append({
"scene": "feed",
"ad_type": rec.ad_type or "feed",
"feed_scene": rec.feed_scene,
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
@@ -199,16 +230,19 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
def audit_rows(
db: Session, *, date: str, user_id: int | None, scene: str | None = None
) -> list[dict]:
"""当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed"
"""当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed" / "draw"
"feed""draw" 都查 ad_feed_reward_record(同一发奖表),按 ad_type 区分:feed 含历史 NULL,
draw 仅 ad_type=="draw"。信息流行额外带 `ad_type`/`feed_scene`,供收益报表区分比价/领券 Draw 收益。
每行含 `app_env`/`our_code_id`/`expected_coin`/`actual_coin` 等,供金币审计逐条对账,
也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)。
**LT 因子账号累计仍按全表 unit 累计(feed+draw 共享),scene 只筛展示,不拆累计。**
"""
rows: list[dict] = []
if scene in (None, "reward_video"):
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
if scene in (None, "feed"):
rows.extend(_feed_rows(db, date=date, user_id=user_id))
if scene in (None, "feed", "draw"):
rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene))
return rows
+94 -12
View File
@@ -11,17 +11,23 @@
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
不受 limit(只截断 items)影响。
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 feed。
② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按 report_date / reward_date 归日
每行带 ad_type(reward_video/feed/draw)与 feed_scene(comparison/coupon/welfare),供前端区分
「比价 Draw 收益」与「领券 Draw 收益」(比价/领券共用同一代码位,只能靠 feed_scene 分)
⚠️ 局限:① 历史信息流/Draw 发奖 ad_type 为 NULL 的旧记录统一视为 feed(向后兼容);Draw 仅
ad_type=="draw" 的新记录单独成类。② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按
report_date / reward_date 归日。
"""
from __future__ import annotations
from datetime import date as _date, datetime, timedelta, timezone
from datetime import datetime, timedelta, timezone
from datetime import date as _date
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.admin.repositories import ad_audit
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
@@ -46,8 +52,18 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
return out
# 审计行的 scene 与报表 ad_type 一一对应
_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"}
# 报表 ad_type 与审计 scene 取值一致(reward_video / feed / draw):feed 与 draw 同查发奖表
# ad_feed_reward_record,由 audit 内部按 ad_type 区分(feed 含历史 NULL,draw 仅 ad_type=="draw")。
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
def _event_ad_type(row: dict) -> str:
"""纯发奖事件行的 ad_type:信息流行用 audit 带回的真实 ad_type(feed/draw),回退 feed;
激励视频行恒 reward_video。不再用 scene 硬映射,避免把 draw 丢成 feed。"""
if row["scene"] == "reward_video":
return "reward_video"
return row.get("ad_type") or "feed"
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
_REWARD_DETAIL_KEYS = (
@@ -69,14 +85,20 @@ def ad_revenue_report(
date_to: str,
user_id: int | None = None,
ad_type: str | None = None,
feed_scene: str | None = None,
granularity: str = "day",
limit: int = 500,
offset: int = 0,
sort: str = "time",
) -> dict:
"""日期区间(北京时间,闭区间)**逐条广告事件**列表 + 发奖对账。单日时 date_from==date_to。
每个 item = 一次广告事件(展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行)。
ad_type: None=全部 / reward_video / feed / draw。granularity=hour 时每行带北京小时(由各自时间算)。
limit 只截断 items(事件明细),total 与 total_* / daily 在全量上统计,数字始终可信
ad_type: None=全部 / reward_video / feed / draw。feed_scene: None=全部 /
comparison / coupon / welfare,作为全局筛选(同时作用于明细、合计与 daily/hourly 趋势)
granularity=hour 时每行带北京小时(由各自时间算),并额外返回全量 hourly 序列。
事件按时间倒序(新→旧)排列;limit/offset 对排序后的全量做分页切片(items 为当前页),
total 与 total_* / daily / hourly 在全量上统计,不受分页影响。
"""
by_hour = granularity == "hour"
@@ -84,7 +106,9 @@ def ad_revenue_report(
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
reward_by_session: dict[tuple[int, str], list[dict]] = {}
all_reward_rows: list[dict] = []
audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None
# 报表 ad_type 直接当 audit scene 用(取值一致);未知/无效 ad_type 不取发奖行。draw 在此被
# 正确传成 scene="draw",audit 会按 ad_type 筛出 Draw 发奖,不再丢成 feed。
audit_scene = ad_type if ad_type in _AUDIT_SCENES else None
if ad_type is None or audit_scene is not None:
for d in _date_range(date_from, date_to):
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
@@ -123,6 +147,7 @@ def ad_revenue_report(
"report_date": rec.report_date,
"user_id": rec.user_id,
"ad_type": rec.ad_type,
"feed_scene": rec.feed_scene,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
@@ -162,7 +187,8 @@ def ad_revenue_report(
"event_key": f"rwd-{row['record_id']}",
"report_date": row["_report_date"],
"user_id": row["user_id"],
"ad_type": _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"]),
"ad_type": _event_ad_type(row),
"feed_scene": row.get("feed_scene"),
"app_env": row.get("app_env"),
"our_code_id": row.get("our_code_id"),
"created_at": row["created_at"],
@@ -181,7 +207,17 @@ def ad_revenue_report(
"reward_detail": _reward_detail(row),
})
events.sort(key=lambda e: (e["report_date"], e["user_id"], e["created_at"]))
# 「场景」作为全局筛选(与 user_id/ad_type 一致):同时作用于明细、合计与 daily/hourly 趋势。
# feed_scene 仅信息流 / Draw 有值,激励视频与旧数据为 None;选中后只保留该场景事件。
if feed_scene is not None:
events = [e for e in events if e.get("feed_scene") == feed_scene]
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
if sort == "ecpm":
events.sort(key=lambda e: rewards.parse_ecpm_fen(e["ecpm"]), reverse=True)
else:
events.sort(key=lambda e: (e["report_date"], e["created_at"]), reverse=True)
# 补手机号(admin 展示用,完整不脱敏,与用户 / 钱包 / 比价记录页一致):批量一次查,避免 N+1。
uids = {e["user_id"] for e in events}
@@ -219,14 +255,60 @@ def ad_revenue_report(
for d in sorted(daily_map.values(), key=lambda x: x["date"])
]
# 按小时汇总(全量,不受分页 limit/offset 影响):供前端按小时趋势图(单日 granularity=hour 时用)。
# 只在 by_hour 下聚合(此时每个 event 带 hour);否则空。前端按天趋势仍用 daily。
hourly: list[dict] = []
if by_hour:
hour_map: dict[int, dict] = {}
for e in events:
h = e["hour"]
if h is None:
continue
hd = hour_map.get(h)
if hd is None:
hd = {"hour": h, "impressions": 0, "revenue_yuan": 0.0,
"expected_coin": 0, "actual_coin": 0}
hour_map[h] = hd
hd["impressions"] += e["impressions"]
hd["revenue_yuan"] += e["revenue_yuan"]
hd["expected_coin"] += e["expected_coin"]
hd["actual_coin"] += e["actual_coin"]
hourly = [
{**hd, "revenue_yuan": round(hd["revenue_yuan"], 6)}
for hd in sorted(hour_map.values(), key=lambda x: x["hour"])
]
# 分广告类型小计(按 ad_type:展示条数 + 预估收益;eCPM 由前端用 收益÷展示×1000 算)。
# 基于全量(已按 feed_scene 过滤)events;前端只取 draw / reward_video 两类展示。
type_map: dict[str, dict] = {}
for e in events:
t = type_map.get(e["ad_type"])
if t is None:
t = {"impressions": 0, "revenue_yuan": 0.0}
type_map[e["ad_type"]] = t
t["impressions"] += e["impressions"]
t["revenue_yuan"] += e["revenue_yuan"]
type_stats = {
k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)}
for k, v in type_map.items()
}
# DAU:复用大盘「今日活跃」口径(stats.today_dau,last_login_at)。该口径只能算今日,
# 故仅当查询=今日单天时给值;历史 / 多天区间返回 None,前端显示「-」。
is_today = date_from == date_to == rewards.cn_today().isoformat()
dau = admin_stats.today_dau(db) if is_today else None
return {
"total": len(events),
"truncated": len(events) > limit,
"truncated": len(events) > offset + limit,
"total_impressions": total_impressions,
"total_revenue_yuan": total_revenue_yuan,
"total_expected_coin": total_expected_coin,
"total_actual_coin": total_actual_coin,
"mismatch_count": mismatch_count,
"daily": daily,
"items": events[:limit],
"hourly": hourly,
"type_stats": type_stats,
"dau": dau,
"items": events[offset:offset + limit],
}
+275 -3
View File
@@ -7,23 +7,29 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from typing import Any
from uuid import uuid4
from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session
from app.admin.repositories.queries import _as_utc, offset_paginate
from app.integrations import meituan
from app.integrations import jd_union, meituan
from app.repositories import cps_link as cps_link_repo
from app.models.cps_activity import CpsActivity
from app.models.cps_group import CpsGroup
from app.models.cps_link import CpsClick
from app.models.cps_link import CpsClick, CpsLink
from app.models.cps_order import CpsOrder
from app.models.cps_wx_user import CpsWxUser
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
_INVALID_STATUS = {"4", "5"}
_SETTLED_STATUS = "6"
_JD_INVALID_CODES = {
"2", "3", "4", "5", "6", "7", "8", "9", "11", "13", "14", "19", "20", "21",
"22", "23", "25", "26", "27", "28", "29", "30", "31", "34", "35", "36",
}
_JD_UNPAID_CODES = {"15"}
# CPS 点击时序按北京时区分桶(运营看的是北京时间)
_BJ_TZ = timezone(timedelta(hours=8))
@@ -45,8 +51,58 @@ def _yuan_to_cents(v: object) -> int | None:
def _ts_to_dt(ts: object) -> datetime | None:
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
if not ts:
if ts is None:
return None
if isinstance(ts, datetime):
return ts if ts.tzinfo else ts.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
s = str(ts).strip()
if not s or s.lower() == "null":
return None
try:
seconds = float(Decimal(s))
except (InvalidOperation, ValueError):
return None
if seconds == 0:
return None
# 美团文档是秒级时间戳,这里顺手兼容毫秒/微秒,避免上游格式变化导致时间再次落空。
if abs(seconds) > 10_000_000_000_000:
seconds /= 1_000_000
elif abs(seconds) > 10_000_000_000:
seconds /= 1_000
try:
return datetime.fromtimestamp(seconds, tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
def _jd_dt_to_utc(value: object) -> datetime | None:
"""京东时间字符串(北京时间) → UTC aware datetime。"""
if value is None:
return None
s = str(value).strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
try:
dt = datetime.strptime(s, fmt)
return dt.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
except ValueError:
continue
return None
def _text(value: object) -> str | None:
if value is None:
return None
s = str(value).strip()
return s or None
def _pick(row: dict[str, Any], *keys: str) -> Any:
for key in keys:
if key in row and row[key] is not None:
return row[key]
return None
try:
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
except (ValueError, OSError, TypeError):
@@ -249,6 +305,80 @@ def _map_order_fields(r: dict) -> dict:
}
def _jd_order_key(r: dict[str, Any]) -> str | None:
row_id = _text(_pick(r, "id", "rowId", "orderRowId"))
if row_id:
return f"jd:{row_id}"
order_id = _text(_pick(r, "orderId", "parentOrderId"))
sku_id = _text(_pick(r, "skuId"))
if order_id and sku_id:
return f"jd:{order_id}:{sku_id}"
if order_id:
return f"jd:{order_id}"
return None
def _map_jd_order_fields(r: dict[str, Any]) -> dict:
"""京东 order.row.query 单条订单行 → CpsOrder 字段。"""
sku_name = _text(_pick(r, "skuName", "goodsName", "productName"))
if sku_name and len(sku_name) > 500:
sku_name = sku_name[:500]
valid_code = _text(_pick(r, "validCode", "valid_code"))
actual_fee = _yuan_to_cents(_pick(r, "actualFee", "actual_fee"))
estimate_fee = _yuan_to_cents(_pick(r, "estimateFee", "estimate_fee"))
commission = actual_fee if actual_fee not in (None, 0) else estimate_fee
order_time = _jd_dt_to_utc(_pick(r, "orderTime", "order_time"))
return {
"platform": "jd",
"external_order_id": _text(_pick(r, "orderId", "parentOrderId")),
"external_row_id": _text(_pick(r, "id", "rowId", "orderRowId")),
"sid": _text(_pick(r, "subUnionId", "sub_union_id")),
"act_id": None,
"biz_line": None,
"trade_type": None,
"pay_price_cents": _yuan_to_cents(
_pick(r, "actualCosPrice", "estimateCosPrice", "price")
),
"commission_cents": commission,
"commission_rate": _text(_pick(r, "commissionRate", "commission_rate")),
"refund_price_cents": None,
"refund_profit_cents": None,
"estimated_commission_cents": estimate_fee,
"actual_commission_cents": actual_fee,
"mt_status": None,
"jd_valid_code": valid_code,
"invalid_reason": None if _is_jd_valid_code(valid_code) else f"validCode={valid_code}",
"product_name": sku_name,
"settle_month": _text(_pick(r, "payMonth", "settleMonth", "pay_month")),
"site_id": _text(_pick(r, "siteId", "site_id")),
"position_id": _text(_pick(r, "positionId", "position_id")),
"pid": _text(_pick(r, "pid")),
"sub_union_id": _text(_pick(r, "subUnionId", "sub_union_id")),
"pay_time": order_time,
"mt_update_time": _jd_dt_to_utc(_pick(r, "modifyTime", "updateTime", "modify_time"))
or order_time,
"raw": r,
}
def _is_jd_valid_code(valid_code: str | None) -> bool:
code = str(valid_code).strip() if valid_code is not None else ""
return bool(code and code not in _JD_INVALID_CODES and code not in _JD_UNPAID_CODES)
def is_jd_order_valid(order: CpsOrder) -> bool:
return _is_jd_valid_code(order.jd_valid_code)
def effective_commission_cents(order: CpsOrder) -> int:
if order.platform == "jd":
if order.actual_commission_cents not in (None, 0):
return order.actual_commission_cents or 0
if order.estimated_commission_cents is not None:
return order.estimated_commission_cents or 0
return order.commission_cents or 0
def reconcile_orders(
db: Session, *, start_time: int, end_time: int,
query_time_type: int = 1, sid: str | None = None, max_pages: int = 200,
@@ -274,6 +404,11 @@ def reconcile_orders(
continue
fetched += 1
fields = _map_order_fields(r)
fields.setdefault("platform", "meituan")
fields.setdefault("external_order_id", order_id)
fields.setdefault("external_row_id", None)
fields.setdefault("estimated_commission_cents", fields.get("commission_cents"))
fields.setdefault("actual_commission_cents", None)
existing = db.execute(
select(CpsOrder).where(CpsOrder.order_id == order_id)
).scalar_one_or_none()
@@ -291,6 +426,56 @@ def reconcile_orders(
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
def reconcile_jd_orders(
db: Session, *, start_time: datetime, end_time: datetime,
query_time_type: int = 3, max_pages: int = 100,
) -> dict:
"""调京东 order.row.query 拉单 → 按订单行 upsert。
京东单次查询窗口最多 1 小时,这里按北京自然时间切窗并逐页拉取。
"""
fetched = inserted = updated = pages = 0
cur = start_time
while cur < end_time:
win_end = min(cur + timedelta(hours=1), end_time)
page = 1
while page <= max_pages:
resp = jd_union.query_order_rows(
start_time=cur,
end_time=win_end,
query_time_type=query_time_type,
page_index=page,
page_size=200,
)
rows = resp.get("rows") or []
has_more = bool(resp.get("has_more"))
if not rows:
break
pages += 1
for r in rows:
order_id = _jd_order_key(r)
if not order_id:
continue
fetched += 1
fields = _map_jd_order_fields(r)
existing = db.execute(
select(CpsOrder).where(CpsOrder.order_id == order_id)
).scalar_one_or_none()
if existing is None:
db.add(CpsOrder(order_id=order_id, **fields))
inserted += 1
else:
for k, v in fields.items():
setattr(existing, k, v)
updated += 1
if not has_more or len(rows) < 200:
break
page += 1
cur = win_end
db.commit()
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
def list_orders(
db: Session, *, sid: str | None = None, mt_status: str | None = None,
limit: int = 20, cursor: int | None = None,
@@ -524,3 +709,90 @@ def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict
]
result.sort(key=lambda x: x["first_seen"], reverse=True)
return result[:limit]
def group_day_users(
db: Session, *, group_id: int, start: datetime, end: datetime, limit: int = 200,
) -> list[dict]:
"""该群某天(北京)以用户为单位的领券/点击 + 每人 visit 过的券。
时间窗为半开区间 [start, end)(end=次日 00:00),避免午夜双计。只统计 openid 非空
(可归属到人)的点击 —— 匿名点击(美团/京东 302 多为匿名)不计入。券名 = 该点击 link
对应活动名;活动被硬删则兜底 活动#{id}。copy=领券次数、visit=点击次数;coupons 仅
取 visit 事件按活动分组、按次数倒序(合计 = visit_count)。排序:领券 desc、再点击 desc。
与 group_wx_users 同风格(Python 侧聚合,跨 PG/SQLite 无方言坑)。
注:每日明细行的 click_pv/copy_pv 计全部点击(含匿名、UV 按 ip,ua);本函数只计 openid
用户,故各用户求和 <= 当天行总数,二者口径不同、不必相等。
"""
rows = db.execute(
select(CpsClick.openid, CpsClick.event_type, CpsClick.link_id)
.where(CpsClick.group_id == group_id)
.where(CpsClick.clicked_at >= _as_utc(start))
.where(CpsClick.clicked_at < _as_utc(end))
.where(CpsClick.openid.is_not(None))
).all()
if not rows:
return []
# link_id -> activity_id -> 券名(活动名)
link_ids = {r.link_id for r in rows}
link_to_act = dict(
db.execute(
select(CpsLink.id, CpsLink.activity_id).where(CpsLink.id.in_(link_ids))
).all()
)
act_ids = {aid for aid in link_to_act.values() if aid is not None}
act_name = (
dict(
db.execute(
select(CpsActivity.id, CpsActivity.name).where(CpsActivity.id.in_(act_ids))
).all()
)
if act_ids
else {}
)
def _coupon_name(link_id: int) -> str:
aid = link_to_act.get(link_id)
if aid is None:
return f"链接#{link_id}"
return act_name.get(aid) or f"活动#{aid}"
stat: dict[str, dict] = {}
for openid, event_type, link_id in rows:
s = stat.setdefault(openid, {"copy": 0, "visit": 0, "coupons": {}})
if event_type == "copy":
s["copy"] += 1
else:
s["visit"] += 1
name = _coupon_name(link_id)
s["coupons"][name] = s["coupons"].get(name, 0) + 1
openids = list(stat.keys())
users = {
u.openid: u
for u in db.execute(
select(CpsWxUser).where(CpsWxUser.openid.in_(openids))
).scalars().all()
}
result = [
{
"openid": openid,
"nickname": users[openid].nickname if openid in users else None,
"headimgurl": users[openid].headimgurl if openid in users else None,
"copy_count": s["copy"],
"visit_count": s["visit"],
"coupons": [
{"name": name, "count": cnt}
# 次数倒序;同次数按券名升序兜底,保证 PG 无 ORDER BY 行序下输出稳定
for name, cnt in sorted(
s["coupons"].items(), key=lambda kv: (-kv[1], kv[0])
)
],
}
for openid, s in stat.items()
]
result.sort(key=lambda x: (x["copy_count"], x["visit_count"]), reverse=True)
return result[:limit]
+192 -1
View File
@@ -8,14 +8,17 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, desc, func, or_, select
from sqlalchemy import Select, asc, case, desc, func, or_, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.core.config import settings
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.admin import AdminAuditLog
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord
from app.models.device import DeviceLiveness
from app.models.feedback import Feedback
from app.models.onboarding import OnboardingCompletion
from app.models.price_report import PriceReport
@@ -199,6 +202,156 @@ def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]:
]
def _heartbeat_seconds_ago(last: datetime | None) -> int | None:
"""距上次心跳的秒数(兼容 SQLite 取回的 naive datetime,按 UTC 处理)。None = 从没心跳。"""
if last is None:
return None
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
return int((datetime.now(timezone.utc) - last).total_seconds())
def _device_model_from_id(device_id: str) -> str:
"""从 device_id(格式 device_<机型>_<hash>)解析机型;不规范则回退原 id。"""
parts = device_id.split("_")
if len(parts) >= 3 and parts[0] == "device":
return "_".join(parts[1:-1]).replace("_", " ")
return device_id
def _attach_device_derived(devices: list[DeviceLiveness]) -> None:
"""给每台设备瞬态挂 device_model / online / display_state / offline_seconds(供 schema 读)。
在线判定:开过无障碍(ever_protected)且距上次心跳 ≤ HEARTBEAT_TIMEOUT_MINUTES。
从没开过无障碍(ever_protected=False)= never(不算掉线,避免把装了没用的设备误报掉线)。
offline_seconds 仅 offline 时给值(=掉线时长),在线/从未启用为 None。"""
timeout_sec = max(1, int(settings.HEARTBEAT_TIMEOUT_MINUTES)) * 60
for d in devices:
d.device_model = _device_model_from_id(d.device_id)
secs = _heartbeat_seconds_ago(d.last_heartbeat_at)
if not d.ever_protected:
d.online = False
d.display_state = "never"
d.offline_seconds = None
elif secs is not None and secs <= timeout_sec:
d.online = True
d.display_state = "online"
d.offline_seconds = None
else:
d.online = False
d.display_state = "offline"
d.offline_seconds = secs
def _attach_device_user_info(db: Session, devices: list[DeviceLiveness]) -> None:
"""给每台设备瞬态挂归属用户 phone/nickname(同 _attach_user_info,供 admin schema 读)。"""
uids = {d.user_id for d in devices}
if not uids:
return
rows = db.execute(
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
).all()
umap = {uid: (phone, nick) for uid, phone, nick in rows}
for d in devices:
phone, nick = umap.get(d.user_id, (None, None))
d.phone = phone
d.nickname = nick
def _liveness_cutoff() -> datetime:
"""掉线判定分界:此刻 - HEARTBEAT_TIMEOUT_MINUTES。心跳早于它 = 掉线(同 list_overdue 口径)。"""
timeout_min = max(1, int(settings.HEARTBEAT_TIMEOUT_MINUTES))
return datetime.now(timezone.utc) - timedelta(minutes=timeout_min)
def list_device_liveness(
db: Session,
*,
status: str | None = None,
device_id: str | None = None,
phone: str | None = None,
user_id: int | None = None,
sort_by: str = "status",
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[DeviceLiveness], int | None, int]:
"""设备存活列表(admin 全量)。按 在线情况(online/offline/never)/ 设备id(包含)/
归属用户(手机号前缀 或 user_id)筛,offset 分页。join user 取 phone/nickname,派生
device_model/online/display_state/offline_seconds 挂行上。
默认排序 status=掉线置顶(offline → online → never;掉线组内掉得最久在前)。"""
cutoff = _liveness_cutoff()
stmt = select(DeviceLiveness)
# 在线情况派生筛选(口径同 _attach_device_derived:ever_protected + 心跳是否过阈值)
if status == "online":
stmt = stmt.where(
DeviceLiveness.ever_protected.is_(True),
DeviceLiveness.last_heartbeat_at.is_not(None),
DeviceLiveness.last_heartbeat_at >= cutoff,
)
elif status == "offline":
stmt = stmt.where(
DeviceLiveness.ever_protected.is_(True),
DeviceLiveness.last_heartbeat_at.is_not(None),
DeviceLiveness.last_heartbeat_at < cutoff,
)
elif status == "never":
stmt = stmt.where(DeviceLiveness.ever_protected.is_(False))
if device_id and device_id.strip():
stmt = stmt.where(DeviceLiveness.device_id.like(f"%{device_id.strip()}%"))
if user_id is not None:
stmt = stmt.where(DeviceLiveness.user_id == user_id)
if phone:
stmt = stmt.where(
DeviceLiveness.user_id.in_(select(User.id).where(User.phone.like(f"{phone}%")))
)
if sort_by in ("last_heartbeat_at", "created_at"):
col = (
DeviceLiveness.last_heartbeat_at
if sort_by == "last_heartbeat_at"
else DeviceLiveness.created_at
)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(DeviceLiveness.id) if sort_order == "asc" else desc(DeviceLiveness.id)
sort_clause: tuple = (order_fn(col), id_order)
else:
# 默认「掉线置顶」:offline(0) → online(1) → never(2);掉线组内按心跳最旧(掉得最久)在前
rank = case(
(DeviceLiveness.ever_protected.is_(False), 2),
(DeviceLiveness.last_heartbeat_at < cutoff, 0),
else_=1,
)
sort_clause = (asc(rank), asc(DeviceLiveness.last_heartbeat_at), desc(DeviceLiveness.id))
items, next_cursor, total = offset_paginate(db, stmt, sort_clause, limit=limit, cursor=cursor)
_attach_device_user_info(db, items)
_attach_device_derived(items)
return items, next_cursor, total
def device_liveness_stats(db: Session) -> dict:
"""顶部卡片:总设备数 + 在线 / 已掉线 / 未启用(按心跳阈值派生,口径同列表)。"""
cutoff = _liveness_cutoff()
def _count(*conds) -> int:
return int(db.execute(select(func.count(DeviceLiveness.id)).where(*conds)).scalar_one())
total = int(db.execute(select(func.count(DeviceLiveness.id))).scalar_one())
never = _count(DeviceLiveness.ever_protected.is_(False))
online = _count(
DeviceLiveness.ever_protected.is_(True),
DeviceLiveness.last_heartbeat_at.is_not(None),
DeviceLiveness.last_heartbeat_at >= cutoff,
)
offline = _count(
DeviceLiveness.ever_protected.is_(True),
DeviceLiveness.last_heartbeat_at.is_not(None),
DeviceLiveness.last_heartbeat_at < cutoff,
)
return {"total": total, "online": online, "offline": offline, "never": never}
def list_all_coin_transactions(
db: Session,
*,
@@ -412,6 +565,44 @@ def list_feedbacks(
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
def list_analytics_events(
db: Session,
*,
event: str | None = None,
device_id: str | None = None,
user_id: int | None = None,
session_id: str | None = None,
created_from: datetime | None = None,
created_to: datetime | None = None,
sort_by: str = "id",
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[AnalyticsEvent], int | None, int]:
"""埋点日志列表(admin 全量)。按 事件名 / 设备ID前缀 / 用户ID / 会话ID / 接收时间范围 筛选,
按 id·接收时间排序。offset 分页(同 [list_feedbacks])。created_at 为 timestamptz,
日期入参统一转 tz-aware UTC 比较。"""
stmt = select(AnalyticsEvent)
if event:
stmt = stmt.where(AnalyticsEvent.event == event)
if device_id and device_id.strip():
stmt = stmt.where(AnalyticsEvent.device_id.like(f"{device_id.strip()}%"))
if user_id is not None:
stmt = stmt.where(AnalyticsEvent.user_id == user_id)
if session_id and session_id.strip():
stmt = stmt.where(AnalyticsEvent.session_id == session_id.strip())
if created_from is not None:
stmt = stmt.where(AnalyticsEvent.created_at >= _as_utc(created_from))
if created_to is not None:
stmt = stmt.where(AnalyticsEvent.created_at <= _as_utc(created_to))
sort_cols = {"id": AnalyticsEvent.id, "created_at": AnalyticsEvent.created_at}
sort_col = sort_cols.get(sort_by, AnalyticsEvent.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(AnalyticsEvent.id) if sort_order == "asc" else desc(AnalyticsEvent.id)
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None:
"""按商户单号查提现单(admin 重试打款先拿 user_id 用,M3)。"""
return db.execute(
+490 -11
View File
@@ -5,20 +5,46 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal, InvalidOperation
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponPromptEngagement
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.user import User
from app.models.wallet import CoinTransaction, WithdrawOrder
_BEIJING = timezone(timedelta(hours=8))
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "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,
)
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
MEITUAN_CPS_SETTLED_STATUS = "6"
COMPARE_START_EVENT = "real_compare_start"
COUPON_START_EVENT = "real_coupon_start"
JD_CPS_INVALID_CODES = {
"2", "3", "4", "5", "6", "7", "8", "9", "11", "13", "14", "19", "20", "21",
"22", "23", "25", "26", "27", "28", "29", "30", "31", "34", "35", "36",
}
JD_CPS_UNPAID_CODES = {"15"}
def _beijing_today_start_utc() -> datetime:
@@ -28,20 +54,130 @@ def _beijing_today_start_utc() -> datetime:
return start_bj.astimezone(timezone.utc)
def dashboard_overview(db: Session) -> dict:
def today_dau(db: Session) -> int:
"""今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重。
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。
"""
today_bj = datetime.now(_BEIJING).date()
today_start = _beijing_today_start_utc()
tomorrow_start = today_start + timedelta(days=1)
login_user_ids = _id_set(
db,
select(User.id).where(User.last_login_at >= today_start, User.last_login_at < tomorrow_start),
)
compare_start_user_ids = _event_user_ids(
db, (COMPARE_START_EVENT,), today_start, tomorrow_start
)
coupon_event_user_ids = _event_user_ids(
db, (COUPON_START_EVENT,), today_start, tomorrow_start
)
coupon_claim_user_ids = _id_set(
db,
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date == today_bj,
CouponPromptEngagement.engage_type == "claim_started",
),
)
return len(login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids)
def _default_period_end() -> date:
"""新版大盘不含今日,默认窗口结束日=北京时间昨天。"""
return datetime.now(_BEIJING).date() - timedelta(days=1)
def _normalize_period(date_from: date | None, date_to: date | None) -> tuple[date, date]:
end = date_to or _default_period_end()
start = date_from or end
if start > end:
start, end = end, start
return start, end
def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime, datetime, datetime]:
"""返回同一北京自然日窗口的 UTC aware 边界和北京 naive 边界。
user.created_at / last_login_at 是 UTC aware 口径;比较/金币等历史上有北京 naive
写入,所以两套边界同时保留。
"""
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)
return (
start_utc,
end_utc,
start_bj.replace(tzinfo=None),
end_bj.replace(tzinfo=None),
)
def _date_range(date_from: date, date_to: date) -> list[date]:
days = (date_to - date_from).days
return [date_from + timedelta(days=i) for i in range(days + 1)]
def _id_set(db: Session, stmt) -> set[int]:
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
def _event_user_ids(
db: Session, event_names: tuple[str, ...], start_utc: datetime, end_utc: datetime
) -> set[int]:
return _id_set(
db,
select(AnalyticsEvent.user_id).where(
AnalyticsEvent.user_id.is_not(None),
AnalyticsEvent.event.in_(event_names),
AnalyticsEvent.created_at >= start_utc,
AnalyticsEvent.created_at < end_utc,
),
)
def _commission_rate_percent(raw: str | None) -> Decimal | None:
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%""""
if raw is None:
return None
s = str(raw).strip()
if not s:
return None
try:
if s.endswith("%"):
return Decimal(s[:-1])
val = Decimal(s)
except (InvalidOperation, ValueError):
return None
return val / Decimal("100")
def _jd_valid_order(order: CpsOrder) -> bool:
code = str(order.jd_valid_code).strip() if order.jd_valid_code is not None else ""
return bool(code and code not in JD_CPS_INVALID_CODES and code not in JD_CPS_UNPAID_CODES)
def dashboard_overview(
db: Session, *, date_from: date | None = None, date_to: date | None = None
) -> dict:
today_start = _beijing_today_start_utc()
period_from, period_to = _normalize_period(date_from, date_to)
start_utc, end_utc, start_local, end_local = _period_bounds(period_from, period_to)
def _count(model, *conds) -> int:
stmt = select(func.count(model.id))
if conds:
stmt = stmt.where(*conds)
return db.execute(stmt).scalar_one()
return int(db.execute(stmt).scalar_one())
def _sum(col, *conds) -> int:
stmt = select(func.coalesce(func.sum(col), 0))
if conds:
stmt = stmt.where(*conds)
return db.execute(stmt).scalar_one()
return int(db.execute(stmt).scalar_one())
def _user_id_set(stmt) -> set[int]:
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
# ===== 用户 =====
by_status = dict(
@@ -61,6 +197,279 @@ def dashboard_overview(db: Session) -> dict:
comparison_total = _count(ComparisonRecord)
comparison_success = _count(ComparisonRecord, ComparisonRecord.status == "success")
success_rate = round(comparison_success / comparison_total, 4) if comparison_total else 0.0
period_comparison_conds = (
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_success_rate = (
round(period_comparison_success / period_comparison_total, 4)
if period_comparison_total
else 0.0
)
period_saved_positive_count = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
)
period_saved_positive_sum = _sum(
ComparisonRecord.saved_amount_cents,
*period_comparison_conds,
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
)
period_avg_saved_cents = (
round(period_saved_positive_sum / period_saved_positive_count)
if period_saved_positive_count
else None
)
period_avg_duration_ms = db.execute(
select(func.avg(ComparisonRecord.total_ms)).where(
*period_comparison_conds,
ComparisonRecord.total_ms.is_not(None),
ComparisonRecord.total_ms > 0,
)
).scalar_one()
period_avg_duration_ms = (
round(float(period_avg_duration_ms))
if period_avg_duration_ms is not None
else None
)
ordered_exists = (
select(SavingsRecord.id)
.where(
SavingsRecord.user_id == ComparisonRecord.user_id,
SavingsRecord.source == "compare",
SavingsRecord.shop_name.is_not(None),
SavingsRecord.shop_name == ComparisonRecord.store_name,
)
.exists()
)
period_ordered_count = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.store_name.is_not(None),
ordered_exists,
)
# ===== 日期窗口用户 =====
period_new_user_ids = _user_id_set(
select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc)
)
login_user_ids = _user_id_set(
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc)
)
compare_start_user_ids = _event_user_ids(
db, (COMPARE_START_EVENT,), start_utc, end_utc
)
coupon_event_user_ids = _event_user_ids(
db, (COUPON_START_EVENT,), start_utc, end_utc
)
coupon_claim_user_ids = _user_id_set(
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date >= period_from,
CouponPromptEngagement.engage_date <= period_to,
CouponPromptEngagement.engage_type == "claim_started",
)
)
period_active_user_ids = (
login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids
)
period_retained_new_user_ids = period_new_user_ids & period_active_user_ids
period_retention_rate = (
round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4)
if period_new_user_ids
else None
)
trend_points: list[dict] = []
for cur_date in _date_range(period_from, period_to):
day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds(
cur_date, cur_date
)
daily_comparison_conds = (
ComparisonRecord.created_at >= day_start_local,
ComparisonRecord.created_at < day_end_local,
)
daily_login_user_ids = _user_id_set(
select(User.id).where(
User.last_login_at >= day_start_utc,
User.last_login_at < day_end_utc,
)
)
daily_compare_start_user_ids = _event_user_ids(
db, (COMPARE_START_EVENT,), day_start_utc, day_end_utc
)
daily_coupon_event_user_ids = _event_user_ids(
db, (COUPON_START_EVENT,), day_start_utc, day_end_utc
)
daily_coupon_claim_user_ids = _user_id_set(
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date == cur_date,
CouponPromptEngagement.engage_type == "claim_started",
)
)
trend_points.append(
{
"date": cur_date,
"active_users": len(
daily_login_user_ids
| daily_compare_start_user_ids
| daily_coupon_event_user_ids
| daily_coupon_claim_user_ids
),
"new_users": _count(
User,
User.created_at >= day_start_utc,
User.created_at < day_end_utc,
),
"comparisons": _count(ComparisonRecord, *daily_comparison_conds),
}
)
period_coin_conds = (
CoinTransaction.created_at >= start_local,
CoinTransaction.created_at < end_local,
CoinTransaction.amount > 0,
)
period_reward_video_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
)
period_feed_ad_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type == "feed_ad_reward",
)
period_feed_ad_conds = (
AdFeedRewardRecord.created_at >= start_utc,
AdFeedRewardRecord.created_at < end_utc,
AdFeedRewardRecord.status == "granted",
)
period_coupon_feed_coin_total = _sum(
AdFeedRewardRecord.coin,
*period_feed_ad_conds,
AdFeedRewardRecord.feed_scene == "coupon",
)
period_comparison_feed_coin_total = _sum(
AdFeedRewardRecord.coin,
*period_feed_ad_conds,
AdFeedRewardRecord.feed_scene == "comparison",
)
period_signin_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type == "signin",
)
period_signin_boost_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type == "signin_boost",
)
period_task_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.like("task_%"),
)
period_coupon_reward_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
) + period_coupon_feed_coin_total
period_comparison_reward_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
) + period_comparison_feed_coin_total
period_regular_task_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
)
period_classified_coin_total = (
period_coupon_reward_coin_total
+ period_comparison_reward_coin_total
+ period_reward_video_coin_total
+ period_regular_task_coin_total
)
period_cps_orders = list(
db.execute(
select(CpsOrder).where(
CpsOrder.pay_time >= start_utc,
CpsOrder.pay_time < end_utc,
)
).scalars()
)
period_meituan_orders = [
o for o in period_cps_orders if (o.platform or "meituan") == "meituan"
]
period_meituan_valid_orders = [
o for o in period_meituan_orders if o.mt_status not in MEITUAN_CPS_INVALID_STATUSES
]
period_jd_orders = [o for o in period_cps_orders if o.platform == "jd"]
period_jd_valid_orders = [o for o in period_jd_orders if _jd_valid_order(o)]
period_jd_invalid_orders = [
o for o in period_jd_orders if o.jd_valid_code and not _jd_valid_order(o)
]
period_meituan_hit_count = 0
period_meituan_miss_count = 0
period_meituan_unknown_rate_count = 0
for order in period_meituan_valid_orders:
rate = _commission_rate_percent(order.commission_rate)
if rate is None:
period_meituan_unknown_rate_count += 1
elif rate < Decimal("1"):
period_meituan_miss_count += 1
else:
period_meituan_hit_count += 1
period_meituan_hit_denominator = period_meituan_hit_count + period_meituan_miss_count
period_meituan_hit_rate = (
round(period_meituan_hit_count / period_meituan_hit_denominator, 4)
if period_meituan_hit_denominator
else None
)
total_reward_video_coin_total = _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
)
total_coupon_reward_coin_total = _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
) + _sum(
AdFeedRewardRecord.coin,
AdFeedRewardRecord.status == "granted",
AdFeedRewardRecord.feed_scene == "coupon",
)
total_comparison_reward_coin_total = _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
) + _sum(
AdFeedRewardRecord.coin,
AdFeedRewardRecord.status == "granted",
AdFeedRewardRecord.feed_scene == "comparison",
)
total_regular_task_coin_total = _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
)
total_classified_coin_total = (
total_coupon_reward_coin_total
+ total_comparison_reward_coin_total
+ total_reward_video_coin_total
+ total_regular_task_coin_total
)
return {
"users": {
@@ -69,15 +478,15 @@ def dashboard_overview(db: Session) -> dict:
"disabled": by_status.get("disabled", 0),
"deleted": by_status.get("deleted", 0),
"new_today": _count(User, User.created_at >= today_start),
"dau": _count(User, User.last_login_at >= today_start),
"dau": today_dau(db),
},
"coins": {
# 累计发放金币(coin_transaction 里所有 amount>0 之和;负数是兑换/扣减不计)
"granted_total": _sum(CoinTransaction.amount, CoinTransaction.amount > 0),
# 累计发放金币按大盘 4 个展示分类加总;未分类/运营手动/邀请奖励不进入本指标。
"granted_total": total_classified_coin_total,
"reward_video_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
),
"reward_video_watch_count": _count(
AdRewardRecord,
@@ -105,6 +514,10 @@ def dashboard_overview(db: Session) -> dict:
CoinTransaction.biz_type == "signin_boost",
),
"signin_boost_watch_count": _count(SigninBoostRecord),
"task_coin_total": total_regular_task_coin_total,
"coupon_reward_coin_total": total_coupon_reward_coin_total,
"comparison_reward_coin_total": total_comparison_reward_coin_total,
"regular_task_coin_total": total_regular_task_coin_total,
},
"cash": {
"withdraw_success_cents": _sum(
@@ -119,7 +532,73 @@ def dashboard_overview(db: Session) -> dict:
"success": comparison_success,
"success_rate": success_rate,
},
"feedback": {"new": _count(Feedback, Feedback.status.in_(("pending", "new")))},
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
"period": {
"date_from": period_from,
"date_to": period_to,
"users": {
"new": len(period_new_user_ids),
"active": len(period_active_user_ids),
"retained_new_users": len(period_retained_new_user_ids),
"retention_rate": period_retention_rate,
"retention_note": (
"口径:登录(last_login_at)+开始比价(real_compare_start)+"
"开始领券(real_coupon_start/claim_started),按用户去重"
),
},
"comparison": {
"total": period_comparison_total,
"success": period_comparison_success,
"success_rate": period_comparison_success_rate,
"ordered": period_ordered_count,
"average_duration_ms": period_avg_duration_ms,
"average_saved_cents": period_avg_saved_cents,
},
"coins": {
"granted_total": period_classified_coin_total,
"reward_video_coin_total": period_reward_video_coin_total,
"feed_ad_coin_total": period_feed_ad_coin_total,
"signin_coin_total": period_signin_coin_total,
"signin_boost_coin_total": period_signin_boost_coin_total,
"task_coin_total": period_task_coin_total,
"coupon_reward_coin_total": period_coupon_reward_coin_total,
"comparison_reward_coin_total": period_comparison_reward_coin_total,
"regular_task_coin_total": period_regular_task_coin_total,
},
"cash": {
"withdraw_success_cents": _sum(
WithdrawOrder.amount_cents,
WithdrawOrder.status == "success",
WithdrawOrder.created_at >= start_local,
WithdrawOrder.created_at < end_local,
),
},
"trend": trend_points,
},
"feedback": {
"new": _count(Feedback, Feedback.status.in_(("pending", "new"))),
},
"cps": {
"available": True,
"note": "美团/JD CPS 读 cps_order 对账订单;淘宝佣金暂空",
"meituan_order_count": len(period_meituan_valid_orders),
"meituan_commission_cents": sum(
o.commission_cents or 0 for o in period_meituan_valid_orders
),
"meituan_hit_count": period_meituan_hit_count,
"meituan_miss_count": period_meituan_miss_count,
"meituan_unknown_rate_count": period_meituan_unknown_rate_count,
"meituan_hit_rate": period_meituan_hit_rate,
"jd_order_count": len(period_jd_valid_orders),
# 数据大盘京东 CPS 只看实际佣金,不再用预估佣金兜底。
"jd_commission_cents": sum(
o.actual_commission_cents or 0 for o in period_jd_valid_orders
),
"jd_actual_commission_cents": sum(
o.actual_commission_cents or 0 for o in period_jd_valid_orders
),
"jd_estimated_commission_cents": sum(
o.estimated_commission_cents or 0 for o in period_jd_valid_orders
),
"jd_invalid_count": len(period_jd_invalid_orders),
},
}
+1 -1
View File
@@ -26,7 +26,7 @@ def get_ad_coin_audit(
date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None,
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
scene: Annotated[
str | None, Query(description="reward_video / feed;不传=两类都要")
str | None, Query(description="reward_video / feed / draw;不传=全部")
] = None,
limit: Annotated[int, Query(ge=1, le=500)] = 100,
only_mismatch: Annotated[
+24 -3
View File
@@ -11,7 +11,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import ad_revenue
from app.admin.schemas.ad_revenue import AdRevenueDaily, AdRevenueReportOut, AdRevenueRow
from app.admin.schemas.ad_revenue import (
AdRevenueDaily,
AdRevenueHourly,
AdRevenueReportOut,
AdRevenueRow,
AdRevenueTypeStat,
)
from app.core.rewards import cn_today
router = APIRouter(
@@ -43,10 +49,21 @@ def get_ad_revenue_report(
str | None,
Query(description="reward_video / feed / draw;不传=全部类型"),
] = None,
feed_scene: Annotated[
str | None,
Query(
description="comparison(比价) / coupon(领券) / welfare(福利);不传=全部场景。"
"全局筛选,同时影响明细 / 合计 / 趋势"
),
] = None,
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
] = "day",
limit: Annotated[int, Query(ge=1, le=1000)] = 500,
limit: Annotated[int, Query(ge=1, le=1000, description="每页条数(分页大小)")] = 500,
offset: Annotated[int, Query(ge=0, description="分页偏移(已跳过的条数)=(页码-1)×每页条数")] = 0,
sort: Annotated[
str, Query(description="排序:time=时间倒序(默认) / ecpm=按 eCPM 数值倒序")
] = "time",
) -> AdRevenueReportOut:
today = cn_today()
d_from = _parse_day(date_from, field="date_from", default=today)
@@ -58,12 +75,16 @@ 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, granularity=granularity, limit=limit,
user_id=user_id, ad_type=ad_type, feed_scene=feed_scene,
granularity=granularity, limit=limit, offset=offset, sort=sort,
)
return AdRevenueReportOut(
date_from=d_from.isoformat(),
date_to=d_to.isoformat(),
daily=[AdRevenueDaily(**d) for d in result["daily"]],
hourly=[AdRevenueHourly(**h) for h in result["hourly"]],
type_stats={k: AdRevenueTypeStat(**v) for k, v in result["type_stats"].items()},
dau=result["dau"],
total=result["total"],
truncated=result["truncated"],
total_impressions=result["total_impressions"],
+121 -11
View File
@@ -1,13 +1,13 @@
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 联盟订单对账 + 统计。
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。
淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"
淘宝暂未接 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"
群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。
"""
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
from datetime import date as _date, datetime, time as _dt_time, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
@@ -34,6 +34,7 @@ from app.admin.schemas.cps import (
from app.core import media
from app.core.config import settings
from app.integrations import meituan
from app.integrations.jd_union import JdUnionError
from app.integrations.meituan import MeituanCpsError
from app.models.admin import AdminUser
from app.models.cps_activity import CpsActivity
@@ -340,24 +341,108 @@ def generate_referral_links(
# ───────────── 订单对账 ─────────────
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账")
_BEIJING = timezone(timedelta(hours=8))
def _parse_day(value: str | None, *, field: str) -> _date | None:
if value is None:
return None
try:
return _date.fromisoformat(value)
except ValueError as e:
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
def _reconcile_range_to_ts(
date_from: _date | None, date_to: _date | None, days: int
) -> tuple[int, int]:
if date_from is None and date_to is None:
now = int(time.time())
return now - days * 86400, now
start_day = date_from or date_to
end_day = date_to or date_from
if start_day is None or end_day is None:
raise HTTPException(status_code=422, detail="日期参数不完整")
if start_day > end_day:
start_day, end_day = end_day, start_day
if (end_day - start_day).days + 1 > 90:
raise HTTPException(status_code=422, detail="美团订单查询最长 90 天")
start_dt = datetime.combine(start_day, _dt_time.min, tzinfo=_BEIJING)
end_dt = datetime.combine(end_day + timedelta(days=1), _dt_time.min, tzinfo=_BEIJING)
return int(start_dt.timestamp()), int(end_dt.timestamp())
def _reconcile_range_to_bj_dt(
date_from: _date | None, date_to: _date | None, days: int
) -> tuple[datetime, datetime]:
start_ts, end_ts = _reconcile_range_to_ts(date_from, date_to, days)
return (
datetime.fromtimestamp(start_ts, tz=_BEIJING),
datetime.fromtimestamp(end_ts, tz=_BEIJING),
)
def _merge_reconcile_result(total: dict, current: dict) -> None:
for key in ("fetched", "inserted", "updated", "pages"):
total[key] = int(total.get(key, 0)) + int(current.get(key, 0))
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取联盟订单对账")
def reconcile_orders(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
days: Annotated[int, Query(ge=1, le=90)] = 7,
date_from: Annotated[str | None, Query(description="起始日 YYYY-MM-DD")] = None,
date_to: Annotated[str | None, Query(description="结束日 YYYY-MM-DD")] = None,
days: Annotated[int, Query(ge=1, le=90, description="未传日期时默认拉近 N 天")] = 7,
sid: Annotated[str | None, Query(max_length=64)] = None,
query_time_type: Annotated[int, Query(ge=1, le=3)] = 1,
platform: Annotated[str, Query(pattern="^(all|meituan|jd)$")] = "all",
) -> CpsReconcileResult:
now = int(time.time())
parsed_from = _parse_day(date_from, field="date_from")
parsed_to = _parse_day(date_to, field="date_to")
result = {"fetched": 0, "inserted": 0, "updated": 0, "pages": 0}
try:
result = cps_repo.reconcile_orders(
db, start_time=now - days * 86400, end_time=now, sid=sid,
)
if platform in {"all", "meituan"}:
start_ts, end_ts = _reconcile_range_to_ts(parsed_from, parsed_to, days)
mt_result = cps_repo.reconcile_orders(
db,
start_time=start_ts,
end_time=end_ts,
query_time_type=query_time_type if query_time_type in (1, 2) else 2,
sid=sid,
)
_merge_reconcile_result(result, mt_result)
if platform in {"all", "jd"}:
if sid:
raise HTTPException(status_code=422, detail="京东订单刷新不支持 sid 筛选")
start_dt, end_dt = _reconcile_range_to_bj_dt(parsed_from, parsed_to, days)
jd_result = cps_repo.reconcile_jd_orders(
db,
start_time=start_dt,
end_time=end_dt,
query_time_type=query_time_type,
)
_merge_reconcile_result(result, jd_result)
except MeituanCpsError as e:
raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e
except JdUnionError as e:
raise HTTPException(status_code=502, detail=f"京东拉单失败: {e}") from e
write_audit(
db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None,
detail={"days": days, "sid": sid, **result}, ip=get_client_ip(request), commit=True,
detail={
"platform": platform,
"date_from": date_from,
"date_to": date_to,
"days": days,
"sid": sid,
"query_time_type": query_time_type,
**result,
},
ip=get_client_ip(request),
commit=True,
)
return CpsReconcileResult(**result)
@@ -468,7 +553,7 @@ def group_daily(
while cur <= last:
cp = click_points[idx] if idx < len(click_points) else None
row = {
"date": cur.strftime("%m-%d"),
"date": cur.strftime("%Y-%m-%d"),
"click_pv": cp["click_pv"] if cp else 0,
"click_uv": cp["click_uv"] if cp else 0,
"copy_pv": cp["copy_pv"] if cp else 0,
@@ -496,3 +581,28 @@ def group_wx_users(group_id: int, db: AdminDb) -> dict:
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
return {"users": cps_repo.group_wx_users(db, group_id=group_id)}
@router.get("/groups/{group_id}/day-users", summary="某天该群按用户的领券/点击 + 每人点过的券")
def group_day_users(
group_id: int,
db: AdminDb,
date: Annotated[str, Query(description="北京日期 YYYY-MM-DD")],
) -> dict:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
bj = timezone(timedelta(hours=8))
try:
day0 = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=bj)
except ValueError as e:
raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD") from e
start = day0.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=1)
users = cps_repo.group_day_users(db, group_id=group_id, start=start, end=end)
return {
"group_id": group.id,
"group_name": group.name,
"date": date,
"users": users,
}
+11 -3
View File
@@ -1,7 +1,9 @@
"""admin 数据大盘(只读聚合)。"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from datetime import date
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import stats
@@ -15,5 +17,11 @@ router = APIRouter(
@router.get("/overview", response_model=DashboardOverview, summary="大盘核心指标")
def overview(db: AdminDb) -> DashboardOverview:
return DashboardOverview.model_validate(stats.dashboard_overview(db))
def overview(
db: AdminDb,
date_from: date | None = Query(None, description="北京时间自然日起始日 YYYY-MM-DD"),
date_to: date | None = Query(None, description="北京时间自然日结束日 YYYY-MM-DD"),
) -> DashboardOverview:
return DashboardOverview.model_validate(
stats.dashboard_overview(db, date_from=date_from, date_to=date_to)
)
+63
View File
@@ -0,0 +1,63 @@
"""admin 设备存活监控(只读):列设备心跳/在线掉线 + 顶部统计卡片。
数据源 device_liveness (心跳 last_heartbeat_at + liveness_state + kill_alert_pending,
app/models/device.py)在线/掉线掉线时长由 repo HEARTBEAT_TIMEOUT_MINUTES 阈值派生
纯读:无写无审计任意登录管理员可看(同大盘/设备管理,无角色门)
"""
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.repositories import queries
from app.admin.schemas.common import CursorPage
from app.admin.schemas.device import DeviceLivenessItem, DeviceLivenessStats
router = APIRouter(
prefix="/admin/api/device-liveness",
tags=["admin-device-liveness"],
dependencies=[Depends(get_current_admin)],
)
@router.get("/stats", response_model=DeviceLivenessStats, summary="设备存活统计(顶部卡片)")
def device_stats(db: AdminDb) -> DeviceLivenessStats:
return DeviceLivenessStats(**queries.device_liveness_stats(db))
@router.get(
"",
response_model=CursorPage[DeviceLivenessItem],
summary="设备存活列表(在线情况/设备id/归属用户 筛选 + 排序 + 分页,默认掉线置顶)",
)
def list_devices(
db: AdminDb,
status: Annotated[str | None, Query(pattern="^(online|offline|never)$")] = None,
device_id: Annotated[str | None, Query(max_length=128)] = None,
phone: Annotated[str | None, Query(max_length=20)] = None,
user_id: Annotated[int | None, Query()] = None,
sort_by: Annotated[
str, Query(pattern="^(status|last_heartbeat_at|created_at)$")
] = "status",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[DeviceLivenessItem]:
items, next_cursor, total = queries.list_device_liveness(
db,
status=status,
device_id=device_id,
phone=phone,
user_id=user_id,
sort_by=sort_by,
sort_order=sort_order,
limit=limit,
cursor=cursor,
)
return CursorPage(
items=[DeviceLivenessItem.model_validate(d) for d in items],
next_cursor=next_cursor,
total=total,
)
+52
View File
@@ -0,0 +1,52 @@
"""admin 埋点日志:列表 + 按事件 / 设备 / 用户 / 会话 / 时间筛选(只读,同库直接查 analytics_event)。"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import queries
from app.admin.schemas.analytics import AnalyticsEventOut
from app.admin.schemas.common import CursorPage
router = APIRouter(
prefix="/admin/api/event-logs",
tags=["admin-event-logs"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=CursorPage[AnalyticsEventOut], summary="埋点日志列表")
def list_event_logs(
db: AdminDb,
event: Annotated[str | None, Query(max_length=64)] = None,
device_id: Annotated[str | None, Query(max_length=64)] = None,
user_id: Annotated[int | None, Query()] = None,
session_id: Annotated[str | None, Query(max_length=64)] = None,
created_from: Annotated[datetime | None, Query()] = None,
created_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AnalyticsEventOut]:
items, next_cursor, total = queries.list_analytics_events(
db,
event=event,
device_id=device_id,
user_id=user_id,
session_id=session_id,
created_from=created_from,
created_to=created_to,
sort_by=sort_by,
sort_order=sort_order,
limit=limit,
cursor=cursor,
)
return CursorPage(
items=[AnalyticsEventOut.model_validate(e) for e in items],
next_cursor=next_cursor,
total=total,
)
+4 -4
View File
@@ -9,8 +9,8 @@ class AdConfigOut(BaseModel):
app_id: str
reward_code_id: str
compare_feed_code_id: str
coupon_feed_code_id: str
compare_draw_code_id: str
coupon_draw_code_id: str
reward_mkey: str
reward_enabled: bool
compare_ad_enabled: bool
@@ -23,8 +23,8 @@ class AdConfigUpdate(BaseModel):
app_id: str | None = None
reward_code_id: str | None = None
compare_feed_code_id: str | None = None
coupon_feed_code_id: str | None = None
compare_draw_code_id: str | None = None
coupon_draw_code_id: str | None = None
reward_mkey: str | None = None
reward_enabled: bool | None = None
compare_ad_enabled: bool | None = None
+38 -4
View File
@@ -40,7 +40,7 @@ class AdRevenueRecord(BaseModel):
class AdRevenueDaily(BaseModel):
"""按日期汇总的一天(供前端按天趋势图;全量,不受 limit 影响)。"""
"""按日期汇总的一天(供前端按天趋势图;全量,不受分页影响)。"""
date: str = Field(..., description="北京时间 YYYY-MM-DD")
impressions: int = Field(..., description="当天展示条数合计")
@@ -49,6 +49,23 @@ class AdRevenueDaily(BaseModel):
actual_coin: int = Field(..., description="当天实发金币合计")
class AdRevenueHourly(BaseModel):
"""按北京小时(0–23)汇总的一小时(供前端按小时趋势图;全量,不受分页影响,单日 granularity=hour 时非空)。"""
hour: int = Field(..., description="北京时间小时 023")
impressions: int = Field(..., description="该小时展示条数合计")
revenue_yuan: float = Field(..., description="该小时预估收益合计(元)")
expected_coin: int = Field(..., description="该小时应发金币合计")
actual_coin: int = Field(..., description="该小时实发金币合计")
class AdRevenueTypeStat(BaseModel):
"""按广告类型(ad_type)的小计:展示条数 + 预估收益(eCPM 由前端用 收益÷展示×1000 算)。"""
impressions: int = Field(..., description="该类型展示条数合计")
revenue_yuan: float = Field(..., description="该类型预估收益合计(元)")
class AdRevenueRow(BaseModel):
"""一次广告事件(逐条一行):激励视频展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行。"""
@@ -56,7 +73,12 @@ class AdRevenueRow(BaseModel):
report_date: str = Field(..., description="该事件所属日期(北京时间 YYYY-MM-DD)")
user_id: int
user_phone: str | None = Field(None, description="用户手机号(admin 展示用,完整;用户已删 / 查不到为空)")
ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 Draw 信息流)")
ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(Draw 信息流);历史 NULL 视为 feed")
feed_scene: str | None = Field(
None,
description="点位场景:comparison(比价) / coupon(领券) / welfare(福利);供区分比价/领券 Draw 收益;"
"激励视频与旧数据为空",
)
app_env: str | None = Field(None, description="我们的应用:prod(傻瓜比价正式) / test(测试应用);旧数据为空")
our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空")
hour: int | None = Field(None, description="北京时间小时 023(granularity=hour 时有值;按天为 null)")
@@ -86,8 +108,20 @@ class AdRevenueReportOut(BaseModel):
date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)")
date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)")
daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)")
total: int = Field(..., description="广告事件总数(全量,不受 limit 影响)")
truncated: bool = Field(..., description="明细是否被 limit 截断")
hourly: list[AdRevenueHourly] = Field(
default_factory=list,
description="按小时汇总序列(全量,供按小时趋势图;按天查询时为空)",
)
type_stats: dict[str, AdRevenueTypeStat] = Field(
default_factory=dict,
description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘",
)
dau: int | None = Field(
None,
description="今日活跃用户数(复用大盘口径,last_login_at);**仅查询=今日单天时有值**,历史/多天为 null",
)
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
total_impressions: int = Field(..., description="全量展示条数合计")
total_revenue_yuan: float = Field(..., description="全量收益合计(元)")
total_expected_coin: int = Field(..., description="全量应发金币合计")
+33
View File
@@ -0,0 +1,33 @@
"""admin 埋点日志列表响应。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class AnalyticsEventOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
event: str
# Who
device_id: str
user_id: int | None
# When
session_id: str | None
client_ts: int
sent_at: int | None
created_at: datetime # 服务端接收时间(server_at)
# Where
page: str | None
client_ip: str | None
# How
oem: str | None
os: str | None
model: str | None
app_ver: str | None
network: str | None
channel: str | None
# What 专属
props: dict | None
+9 -2
View File
@@ -1,7 +1,7 @@
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)
对账类字段对淘宝/京东 None 前端显示 "-"(无法对账)
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接 + 订单 API 对账)
对账类字段对淘宝为 None 前端显示 "-"(暂未对账)
"""
from __future__ import annotations
@@ -116,15 +116,22 @@ class CpsOrderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
platform: str = "meituan"
order_id: str
external_order_id: str | None = None
external_row_id: str | None = None
sid: str | None = None
act_id: str | None = None
pay_price_cents: int | None = None
commission_cents: int | None = None
estimated_commission_cents: int | None = None
actual_commission_cents: int | None = None
commission_rate: str | None = None
mt_status: str | None = None
jd_valid_code: str | None = None
invalid_reason: str | None = None
product_name: str | None = None
settle_month: str | None = None
pay_time: datetime | None = None
+64
View File
@@ -1,6 +1,8 @@
"""admin 大盘 schemas(对应 stats.dashboard_overview 的嵌套结构)。"""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel
@@ -38,6 +40,56 @@ class DashboardComparison(BaseModel):
success_rate: float
class DashboardPeriodUsers(BaseModel):
new: int
active: int
retained_new_users: int
retention_rate: float | None = None
retention_note: str
class DashboardPeriodComparison(BaseModel):
total: int
success: int
success_rate: float
ordered: int
average_duration_ms: int | None = None
average_saved_cents: int | None = None
class DashboardPeriodCoins(BaseModel):
granted_total: int
reward_video_coin_total: int = 0
feed_ad_coin_total: int = 0
signin_coin_total: int = 0
signin_boost_coin_total: int = 0
task_coin_total: int = 0
coupon_reward_coin_total: int = 0
comparison_reward_coin_total: int = 0
regular_task_coin_total: int = 0
class DashboardPeriodCash(BaseModel):
withdraw_success_cents: int
class DashboardTrendPoint(BaseModel):
date: date
active_users: int
new_users: int
comparisons: int
class DashboardPeriod(BaseModel):
date_from: date
date_to: date
users: DashboardPeriodUsers
comparison: DashboardPeriodComparison
coins: DashboardPeriodCoins
cash: DashboardPeriodCash
trend: list[DashboardTrendPoint] = []
class DashboardFeedback(BaseModel):
new: int
@@ -45,6 +97,17 @@ class DashboardFeedback(BaseModel):
class DashboardCps(BaseModel):
available: bool
note: str
meituan_order_count: int = 0
meituan_commission_cents: int = 0
meituan_hit_count: int = 0
meituan_miss_count: int = 0
meituan_unknown_rate_count: int = 0
meituan_hit_rate: float | None = None
jd_order_count: int = 0
jd_commission_cents: int = 0
jd_actual_commission_cents: int = 0
jd_estimated_commission_cents: int = 0
jd_invalid_count: int = 0
class DashboardOverview(BaseModel):
@@ -52,5 +115,6 @@ class DashboardOverview(BaseModel):
coins: DashboardCoins
cash: DashboardCash
comparison: DashboardComparison
period: DashboardPeriod
feedback: DashboardFeedback
cps: DashboardCps
+56
View File
@@ -0,0 +1,56 @@
"""admin 设备存活监控 schemas(只读)。
数据源 device_liveness ( app/models/device.py)online / display_state /
offline_seconds 为后端按 HEARTBEAT_TIMEOUT_MINUTES 阈值派生的瞬态字段( DB ),
repo 里算好挂到行上, from_attributes 读出
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class DeviceLivenessItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
# join user 取(瞬态;理论上 user 恒存在,留空仅防脏数据)
phone: str | None = None
nickname: str | None = None
device_id: str
device_model: str | None = None # 由 device_id 解析(device_<机型>_<hash>);非 DB 列
platform: str
app_version: str | None = None
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)
last_heartbeat_at: datetime | None = None
last_report_protection_on: bool # 心跳里上报的无障碍开关(恒 true,仅观测)
liveness_state: str # unknown / alive / silent(当前未用) / notified
notified_at: datetime | None = None
kill_alert_pending: bool # 掉线召回待客户端 ack(与 state 解耦)
created_at: datetime
updated_at: datetime
# ===== 后端派生(非 DB 列)=====
online: bool = False # ever_protected 且距上次心跳 ≤ 阈值
# 显示态:online=在线 / offline=已掉线 / never=从未启用(没开过无障碍)
display_state: str = "never"
# 掉线时长(秒):仅 offline 时有值;在线 / 从未启用为 None
offline_seconds: int | None = None
class DeviceLivenessStats(BaseModel):
"""顶部卡片:总设备数 + 在线 / 已掉线 / 未启用(按心跳阈值派生的 operator 口径,
不暴露内部状态机 unknown/alive/silent/notified)"""
total: int
online: int
offline: int
never: int
+23 -5
View File
@@ -9,8 +9,8 @@
"""
from __future__ import annotations
import logging
import json
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -18,8 +18,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.api.deps import CurrentUser, DbSession
from app.core import rewards
from app.core.config import settings
from app.integrations import pangle
from app.core.ratelimit import rate_limit
from app.integrations import pangle
from app.repositories import ad_ecpm as crud_ecpm
from app.repositories import ad_feed_reward as crud_feed
from app.repositories import ad_reward as crud_ad
@@ -120,6 +120,21 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
ad_session_id = extra.get("ad_session_id")
ecpm = params.get("ecpm")
# 环境隔离:激励视频 mediaExtra 里带「这次观看属于哪个后端环境」(srv_env=dev/prod,客户端按
# BuildConfig.DEBUG 决定),穿山甲 S2S 回调原样带回。回调 URL 在穿山甲后台只配一个(指向生产),
# 所以测试包(dev)看广告的 S2S 也会打到生产——若不拦,生产会把奖发给「本库里同 user_id 的另一个
# 真人」(user_id 是跨库不隔离的裸数字 → 跨库串号)。这里只处理「属于本服环境」的回调:环境不符
# 直接受理但不发币、不写任何记录,保证各环境后台广告收益页只含本环境用户。is_verify=true 让穿山甲
# 不再重试(测试用户的币由 localhost 的 test-grant 单独发,不依赖这条 S2S)。
# 兼容:旧客户端不带 srv_env(取不到)→ 视为本环境,照常处理,不误伤存量正式用户。
callback_env = extra.get("srv_env")
if callback_env and callback_env != settings.APP_ENV:
logger.info(
"pangle callback foreign env skip: callback_env=%s self_env=%s user_id=%d trans_id=%s",
callback_env, settings.APP_ENV, user_id, trans_id,
)
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
existing = crud_ad.find_by_trans(db, trans_id)
if existing is not None:
logger.info(
@@ -270,12 +285,13 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
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,
app_env=payload.app_env, our_code_id=payload.our_code_id,
)
logger.info(
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s",
user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
payload.app_env, payload.our_code_id,
"ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%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,
)
return EcpmReportOut(ok=True)
@@ -386,6 +402,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
client_event_id=payload.client_event_id,
ecpm=payload.ecpm,
duration_seconds=payload.duration_seconds,
ad_type=payload.ad_type,
ad_session_id=payload.ad_session_id,
adn=payload.adn,
slot_id=payload.slot_id,
@@ -394,6 +411,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
app_env=payload.app_env,
our_code_id=payload.our_code_id,
aborted=payload.aborted,
display_coin=payload.display_coin,
)
logger.info(
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
+31
View File
@@ -0,0 +1,31 @@
"""客户端埋点上报接口。
POST /api/v1/analytics/events 批量接收新手引导(及后续)埋点,append analytics_event
**不强制登录**(未登录态也要采集行为):user_id 由客户端在 body 里可选带上,不靠 Bearer
服务端补 client_ip(X-Forwarded-For) created_at(接收时间 = server_at)
"""
from __future__ import annotations
from fastapi import APIRouter, Request
from app.api.deps import DbSession
from app.repositories import analytics as analytics_repo
from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut
router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
def _client_ip(request: Request) -> str:
"""取客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 第一段,否则直连 IP(同 admin get_client_ip)。"""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else ""
@router.post("/events", response_model=AnalyticsIngestOut, summary="批量上报埋点事件")
def ingest_events(
batch: AnalyticsBatchIn, request: Request, db: DbSession
) -> AnalyticsIngestOut:
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
return AnalyticsIngestOut(received=n)
+74 -5
View File
@@ -2,6 +2,7 @@
路由前缀 `/api/v1/auth`,包含:
POST /jverify-login 极光一键登录(loginToken 手机号 注册即登录 JWT)
POST /wechat-login 微信 App 授权登录(code openid 注册即登录 JWT)
POST /sms/send 发短信验证码(mock 阶段任意 6 位通过)
POST /sms/login 手机号 + 验证码登录
POST /refresh refresh_token 换新的 token
@@ -12,14 +13,15 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.api.deps import CurrentUser, DbSession
from app.core import test_account
from app.core.ratelimit import rate_limit
from app.core.ratelimit import enforce_rate_limit, rate_limit
from app.core.security import TokenError, decode_token, issue_token_pair
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.integrations.sms import SmsError, send_code, verify_code
from app.integrations.wechat_login import WechatLoginError, code_to_userinfo
from app.repositories import onboarding as onboarding_repo
from app.repositories import user as user_repo
from app.schemas.auth import (
@@ -32,12 +34,19 @@ from app.schemas.auth import (
TokenPair,
TokenWithUser,
UserOut,
WechatLoginRequest,
)
logger = logging.getLogger("shagua.auth")
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
SMS_LOGIN_MAX_PER_HOUR = 5
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
# 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
def _login_response(
user, *, onboarding_completed: bool, force_onboarding: bool = False
@@ -78,21 +87,67 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
return _login_response(user, onboarding_completed=completed)
# ===================== 微信一键登录 =====================
@router.post(
"/wechat-login",
response_model=TokenWithUser,
summary="微信一键登录",
dependencies=[Depends(rate_limit(20, 60, "wechat-login"))],
)
def wechat_login(req: WechatLoginRequest, db: DbSession) -> TokenWithUser:
try:
info = code_to_userinfo(req.code)
except WechatLoginError as e:
logger.warning("wechat_login failed: %s", e)
raise HTTPException(status_code=502, detail=str(e)) from e
user = user_repo.upsert_user_for_wechat_login(
db,
openid=info["openid"],
nickname=info.get("nickname"),
avatar_url=info.get("avatar_url"),
)
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
logger.info(
"wechat_login ok user_id=%d openid=%s*** onboarded=%s",
user.id,
info["openid"][:6],
completed,
)
return _login_response(user, onboarding_completed=completed)
# ===================== 短信登录 =====================
@router.post(
"/sms/send",
response_model=SmsSendResponse,
summary="发送短信验证码",
dependencies=[Depends(rate_limit(10, 60, "sms-send"))], # 同 IP 每分钟≤10 次(防一 IP 刷不同号)
)
def sms_send(req: SmsSendRequest) -> SmsSendResponse:
def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
# 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。
# 真正的"免验证码"在 /sms/login 跳过校验;每日上限也在 login 处算(send 不耗额度)。
# (也不受下面设备发码限流约束:QA 联调要反复发码。)
if test_account.is_test_account(req.phone):
logger.info("test_account sms_send short-circuit (不真发)")
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。
# 补「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞(那两道是单号维度,一机换号能绕);设备维度按机器封顶,
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。与路由上 IP 维度(10次/分钟)互补。
enforce_rate_limit(
request,
scope="sms-send-device",
subject=req.device_id,
limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE,
window_sec=3600,
detail="操作过于频繁,请稍后再试",
)
try:
cooldown = send_code(req.phone)
except SmsError as e:
@@ -109,9 +164,10 @@ def sms_send(req: SmsSendRequest) -> SmsSendResponse:
summary="手机号+验证码登录",
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
)
def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser:
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
# 测试账号走自己的每日额度、不受下面 (设备+IP) 每小时限流约束(QA 需在一小时内反复登录联调)。
if test_account.is_test_account(req.phone):
if not test_account.try_consume_quota():
raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
@@ -123,6 +179,19 @@ def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
# 不读/不写 onboarding_completion 表。
return _login_response(user, onboarding_completed=False, force_onboarding=True)
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试。放在验证码校验
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破。与路由上 IP 维度的 sms-login 限流(同 IP)互补。
# ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时
# 退化为该 IP 下所有空设备聚一桶,仍受限。
enforce_rate_limit(
request,
scope="sms-login-device",
subject=req.device_id,
limit=SMS_LOGIN_MAX_PER_HOUR,
window_sec=3600,
detail="登录尝试过于频繁,请稍后再试",
)
if not verify_code(req.phone, req.code):
raise HTTPException(status_code=400, detail="invalid sms code")
+5 -4
View File
@@ -26,6 +26,7 @@ import httpx
from fastapi import APIRouter, HTTPException, Request, status
from app.core.config import settings
from app.core.pricebot_client import get_pricebot_client
from app.core.pricebot_router import pick_pricebot
logger = logging.getLogger("shagua.compare")
@@ -65,10 +66,10 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
client = get_pricebot_client()
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
+13
View File
@@ -21,6 +21,7 @@ from app.api.deps import CurrentUser, DbSession
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.repositories import comparison as crud_compare
from app.repositories import invite as crud_invite
from app.services.pricebot_llm_calls import fetch_llm_calls
from app.schemas.compare_record import (
CompareStatsOut,
@@ -52,6 +53,18 @@ def report_record(
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id)
# 邀请 v2 发奖:被邀请人完成一次【成功】比价 → 给邀请人发邀请奖励金(幂等,只发一次)。
# best-effort:发奖异常不影响比价上报本身(rec 已 commit),只 log;邀请人补偿靠后续对账。
if rec.status == "success":
try:
reward = crud_invite.try_reward_on_compare(db, user.id)
if reward.status == "granted":
logger.info(
"invite compare reward granted inviter=%s invitee=%s cents=%s",
reward.inviter_user_id, user.id, reward.reward_cents,
)
except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞上报
logger.warning("invite compare reward failed invitee=%s: %s", user.id, e)
logger.info(
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
user.id,
+5 -4
View File
@@ -20,6 +20,7 @@ from fastapi.concurrency import run_in_threadpool
from app.api.deps import CurrentUser, DbSession
from app.core.config import settings
from app.core.pricebot_client import get_pricebot_client
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo
@@ -141,10 +142,10 @@ async def coupon_step(
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
client = get_pricebot_client()
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
+93 -5
View File
@@ -94,16 +94,35 @@ def cps_landing(code: str, request: Request, db: Session = Depends(get_db)):
return HTMLResponse(
_taobao_landing_html(link.target_url, image_url, code, openid, has_uinfo)
)
# 美团短链 / 京东链接:直接 302 跳
# 美团短链 / 京东链接:微信内 + 已拿 openid + 还没授权过头像 → 先出「领券前授权」插页,
# 点按钮(交互手势,避免微信快照页)走 snsapi_userinfo 拿昵称头像,授权回来经
# /c/{code}?authed=1 自动 302 跳券。其余(非微信 / 未配授权 / 无 openid / 已授权)直接 302。
# 产品决定:每次未授权都提示(不做冷却、不种"已提示"cookie)。取消授权由插页前端自动
# 转跳目标券页(无单独"暂不授权"出口),拒绝也能拿到券(对齐「绝不阻断领券」)。
if (
settings.wx_oauth_active
and _is_wechat(request)
and openid is not None
and request.cookies.get(_WX_UINFO_COOKIE) != "1"
):
return HTMLResponse(_coupon_auth_landing_html(link.target_url, code))
return RedirectResponse(link.target_url, status_code=302)
@router.get("/wx/oauth/cb", include_in_schema=False)
def wx_oauth_cb(code: str, state: str, db: Session = Depends(get_db)):
def wx_oauth_cb(
code: str | None = None, state: str | None = None, db: Session = Depends(get_db)
):
"""微信网页授权回调。state='base:{原code}'(静默拿 openid) 或 'uinfo:{原code}'(补昵称头像)。
openid(+userinfo) upsert 用户 cookie 302 回落地页任何失败兜底回落地页,
绝不阻断用户领券"""
kind, _, orig_code = state.partition(":")
kind, _, orig_code = (state or "").partition(":")
# 授权弹窗点「取消」时,部分微信版本会带空 code 回调本端点(而非退回落地页)。无 code 换
# 不到 openid,直接兜底跳回落地页(美团/京东落地页前端会再转跳目标券页),不报 422。
if not code:
return RedirectResponse(
f"/c/{orig_code}" if orig_code else _FALLBACK_URL, status_code=302
)
try:
token = wx_oauth.exchange_code(code) # {openid, access_token, scope, ...}
openid = token["openid"]
@@ -139,6 +158,12 @@ def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict
return {"ok": True}
def _js_str(s: str) -> str:
"""把字符串安全嵌进 <script> 的 JS 字面量:json.dumps 不转义 '<',值里含 '</script>'
会逃逸标签 '<' 转成 \\u003c(JS 仍解析为 '<')auth/target/淘口令等都走它"""
return json.dumps(s).replace("<", "\\u003c")
def _taobao_landing_html(
token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool
) -> str:
@@ -156,8 +181,8 @@ def _taobao_landing_html(
return (
_TAOBAO_HTML
.replace("__IMAGE_URL__", safe_img)
.replace("__UINFO_URL__", json.dumps(uinfo_url))
.replace("__TOKEN_JS__", json.dumps(token))
.replace("__UINFO_URL__", _js_str(uinfo_url))
.replace("__TOKEN_JS__", _js_str(token))
)
@@ -210,3 +235,66 @@ if(location.search.indexOf('authed=1')>=0){doCopy();history.replaceState(null,''
</script>
</body>
</html>"""
def _coupon_auth_landing_html(target_url: str, code: str) -> str:
"""美团/京东「领券前先授权头像」插页(单按钮)。
唯一按钮立即领取优惠券 snsapi_userinfo 授权(用户点击=交互手势,避免微信快照页)
- 允许 回调拿昵称头像 + wx_uinfo cookie /c/{code}?authed=1 自动 302 跳券
- 取消 微信退回本插页,前端 pageshow/visibilitychange 检测到"刚点过领取"即直接
转跳 target(不再设单独的"暂不授权"出口),保证拒绝也能拿到券(对齐绝不阻断领券)
AUTH/TARGET json.dumps 安全嵌入 JS 字符串(URL 里的 & 不会被 HTML 转义,直跳更稳)
"""
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_userinfo", f"uinfo:{code}")
return (
_COUPON_AUTH_HTML
.replace("__AUTH_URL__", _js_str(auth_url))
.replace("__TARGET_URL__", _js_str(target_url))
)
_COUPON_AUTH_HTML = """<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>领取优惠券</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color:#333;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.card{width:100%;max-width:340px;background:#fff;border-radius:18px;padding:34px 24px;text-align:center;box-shadow:0 8px 24px rgba(255,59,59,.12)}
.title{font-size:22px;font-weight:800;color:#ff3b3b;margin-bottom:10px}
.sub{font-size:14px;color:#999;line-height:1.6;margin-bottom:28px}
.btn{display:block;width:100%;background:linear-gradient(90deg,#ff5b5b,#ff3b3b);color:#fff;font-size:18px;font-weight:800;font-family:inherit;text-align:center;padding:15px;border:none;border-radius:28px;cursor:pointer;box-shadow:0 6px 16px rgba(255,59,59,.4)}
.btn:active{transform:scale(.98)}
</style>
</head>
<body>
<div class="card">
<div class="title">🎁 优惠券待领取</div>
<div class="sub">点击下方按钮领取你的专属优惠券</div>
<button class="btn" onclick="claim()">立即领取优惠券</button>
</div>
<script>
var AUTH=__AUTH_URL__,TARGET=__TARGET_URL__;
function claim(){
try{sessionStorage.setItem('mt_auth_ts',String(Date.now()))}catch(e){}
location.href=AUTH;
}
// 从授权页返回(点了取消/未完成授权):若刚点过领取(3 分钟内) 直接进目标券页,
// 保证拒绝也能拿到券(绝不阻断领券)pageshow 覆盖 BFCache 与整页重载,visibilitychange
// 兜底"页面保活只切可见性"的情况; sessionStorage 时间戳防陈旧/误触发
function maybeForward(){
var ts=0;try{ts=parseInt(sessionStorage.getItem('mt_auth_ts')||'0',10)}catch(e){}
if(ts&&Date.now()-ts<180000){
try{sessionStorage.removeItem('mt_auth_ts')}catch(e){}
location.href=TARGET;
}
}
window.addEventListener('pageshow',maybeForward);
document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible')maybeForward()});
</script>
</body>
</html>"""
+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,
+9 -2
View File
@@ -38,7 +38,7 @@ logger = logging.getLogger("shagua.invite")
router = APIRouter(prefix="/api/v1/invite", tags=["invite"])
_BIND_MESSAGES = {
"success": "邀请绑定成功,金币已到账",
"success": "邀请绑定成功",
"already_bound": "你已绑定过邀请人",
"invalid_code": "邀请码无效",
"self_invite": "不能填写自己的邀请码",
@@ -84,6 +84,8 @@ def _parse_device_model(ua: str) -> str:
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
code = invite_repo.ensure_code(db, user)
invited, coins = invite_repo.get_stats(db, user.id)
reward_balance, reward_withdrawn = invite_repo.get_reward_stats(db, user.id)
days_left, is_fresh_round, countdown_text = invite_repo.compute_invite_countdown(user.created_at)
sep = "&" if "?" in settings.INVITE_LANDING_URL else "?"
share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}"
return InviteInfoOut(
@@ -91,6 +93,11 @@ def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
share_url=share_url,
invited_count=invited,
coins_earned=coins,
reward_balance_cents=reward_balance,
reward_withdrawn_cents=reward_withdrawn,
countdown_days_left=days_left,
countdown_is_fresh_round=is_fresh_round,
countdown_text=countdown_text,
)
@@ -154,7 +161,7 @@ def landing_track(
return LandingTrackOut(status="ok")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,绑定不发奖)")
def bind_invite(
req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request
) -> BindInviteOut:
+2 -2
View File
@@ -63,8 +63,8 @@ def ad_config(db: DbSession) -> AdConfigPublicOut:
return AdConfigPublicOut(
app_id=c["app_id"],
reward_code_id=c["reward_code_id"],
compare_feed_code_id=c["compare_feed_code_id"],
coupon_feed_code_id=c["coupon_feed_code_id"],
compare_draw_code_id=c["compare_draw_code_id"],
coupon_draw_code_id=c["coupon_draw_code_id"],
reward_enabled=c["reward_enabled"],
compare_ad_enabled=c["compare_ad_enabled"],
coupon_ad_enabled=c["coupon_ad_enabled"],
+4 -2
View File
@@ -200,7 +200,8 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
try:
order = crud_wallet.create_withdraw(
db, user.id, req.amount_cents, user_name=req.user_name, out_bill_no=req.out_bill_no
db, user.id, req.amount_cents, source=req.source,
user_name=req.user_name, out_bill_no=req.out_bill_no,
)
except crud_wallet.InvalidWithdrawAmountError as e:
raise HTTPException(
@@ -274,10 +275,11 @@ def withdraw_status(
def withdraw_orders(
user: CurrentUser,
db: DbSession,
source: str | None = Query(None, description="按账户来源过滤:coin_cash / invite_cash;不传=全部"),
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> WithdrawOrderPage:
items, next_cursor = crud_wallet.list_withdraw_orders(db, user.id, limit=limit, cursor=cursor)
items, next_cursor = crud_wallet.list_withdraw_orders(db, user.id, source=source, limit=limit, cursor=cursor)
return WithdrawOrderPage(
items=[WithdrawOrderOut.model_validate(it) for it in items],
next_cursor=next_cursor,
+56 -4
View File
@@ -9,7 +9,7 @@ from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field, model_validator
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
@@ -59,14 +59,51 @@ class Settings(BaseSettings):
return []
return [ip.strip() for ip in self.ADMIN_IP_ALLOWLIST.split(",") if ip.strip()]
# ===== 极光 =====
# ===== 极光一键登录 / 短信 =====
JG_APP_KEY: str = ""
JG_MASTER_SECRET: str = ""
JG_PRIVATE_KEY_PATH: str = "./secrets/jverify_rsa_private.pem"
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"
)
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"
)
# 无障碍保护存活监控后台任务(推送 + pull 后置兜底)
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
HEARTBEAT_TIMEOUT_MINUTES: int = 10 # 多久没心跳算掉线(≈3 个客户端心跳周期)
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
@@ -113,6 +150,21 @@ class Settings(BaseSettings):
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET)
# ===== 京东联盟 CPS =====
# app_key/app_secret 来自京东联盟应用;site_id 是推广管理里的 APP/网站 ID;
# auth_key 是工具商授权 key,自有应用查询可留空。
JD_UNION_APP_KEY: str = ""
JD_UNION_APP_SECRET: str = ""
JD_UNION_SITE_ID: str = ""
JD_UNION_AUTH_KEY: str = ""
JD_UNION_GATEWAY: str = "https://api.jd.com/routerjson"
JD_UNION_TIMEOUT_SEC: int = 15
@property
def jd_union_configured(self) -> bool:
"""京东联盟订单查询凭证齐全。"""
return bool(self.JD_UNION_APP_KEY and self.JD_UNION_APP_SECRET)
# ===== 微信服务号(网页授权) =====
# CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
@@ -272,7 +324,7 @@ class Settings(BaseSettings):
return self.APP_ENV == "prod"
@model_validator(mode="after")
def _enforce_prod_secrets(self) -> "Settings":
def _enforce_prod_secrets(self) -> Settings:
"""prod 下强校验 JWT secret,弱/默认/空即启动报错(fail-fast,挡住 token 被伪造)。
只校验两个签发凭证:App 用户的 JWT_SECRET_KEY后台的 ADMIN_JWT_SECRET它们沿用默认值
+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:
+4
View File
@@ -87,5 +87,9 @@ def setup_logging(debug: bool = False) -> None:
# 第三方库降噪
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
# uvicorn --reload 的文件监视器: DEBUG 下会把每次"检测到变更"打出来。我们的 root 文件
# handler 又把这行写回 logs/app-server.log → watchfiles 再次检测 → 自我喂食死循环。
# 降到 WARNING 同时消除噪音和这个回环(真正的 .py 热重载不受影响)。
logging.getLogger("watchfiles").setLevel(logging.WARNING)
_CONFIGURED = True
+35
View File
@@ -0,0 +1,35 @@
"""透传到 pricebot 的共享 httpx.AsyncClient 单例。
为什么不能每请求新建(coupon.py / compare.py 老写法 async with httpx.AsyncClient(...)):
每次构造都重建一套 SSL 上下文(httpx.create_ssl_context 加载 certifi CA),实测
~1s+/; pricebot 是纯 http 透传,根本用不到 TLS 纯浪费,且每帧重交一次
trust_env 默认 True 会读进程 HTTP_PROXY, http://localhost:8000 这条本地透传整个
塞进本机代理( Clash 7897),恒定再多几秒
单例:启动只建一次(SSL/连接池一次性),keep-alive 复用 TCP,每帧降到个位数 ms
trust_env=False:对齐 integrations/meituan.py 的既有约定,不被进程代理误导,直连 pricebot
"""
from __future__ import annotations
import httpx
_client: httpx.AsyncClient | None = None
def get_pricebot_client() -> httpx.AsyncClient:
"""取透传单例。lifespan 启动会预热;未预热(如测试态)懒建兜底。
超时不在此固化(coupon 30s / compare 60s 不同),由调用点 client.post(timeout=...)
懒建无 await,asyncio 单线程下不会有并发竞态
"""
global _client
if _client is None:
_client = httpx.AsyncClient(trust_env=False)
return _client
async def aclose_pricebot_client() -> None:
"""lifespan 关停时调,优雅关连接池。"""
global _client
if _client is not None:
await _client.aclose()
_client = None
+26
View File
@@ -57,3 +57,29 @@ def rate_limit(limit: int, window_sec: float, scope: str):
)
return _dep
def enforce_rate_limit(
request: Request,
scope: str,
subject: str,
limit: int,
window_sec: float,
*,
detail: str = "操作过于频繁,请稍后再试",
) -> None:
"""在路由内部手动限流,按 (subject, 客户端 IP) 计数。
用于限流 key 需要请求体字段(如手机号)Depends 阶段还拿不到 body
此时无法用 [rate_limit] 依赖,改在 handler 解析完 body 后调用本函数
key = `scope:subject:client_ip`;同一 (subject, IP) window_sec 内超过 limit 429
[settings.RATE_LIMIT_ENABLED] 总开关控制( [rate_limit] 一致)
"""
if not settings.RATE_LIMIT_ENABLED:
return
key = f"{scope}:{subject}:{_client_ip(request)}"
if not _hit(key, limit, window_sec):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=detail,
)
+10 -4
View File
@@ -113,10 +113,9 @@ PRICE_REPORT_REWARD_COINS: int = 1000
FEEDBACK_REWARD_MAX_COINS: int = 10000
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
INVITE_INVITER_COINS: int = 10000
INVITE_INVITEE_COINS: int = 10000
# ===== 邀请好友(绑定即生效,绑定只建归因关系、双方都不发钱)=====
# v3(冰 2026-06-26):废除 v1 的"绑定双方各发金币"——被邀请人无奖励、邀请人改比价后发现金
# (见下方 INVITE_COMPARE_REWARD_CENTS)。原 INVITE_INVITER_COINS / INVITE_INVITEE_COINS 已删。
# "新用户闸":被邀请人必须在注册后此窗口内绑定才发奖(挡存量老用户互相填码薅羊毛)。
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
INVITE_NEW_USER_WINDOW_HOURS: int = 72
@@ -127,6 +126,13 @@ INVITE_NEW_USER_WINDOW_HOURS: int = 72
INVITE_FP_WINDOW_DAYS: int = 7
# ===== 邀请好友 v2(好友"下载+登录+比价一次"→ 给邀请人发邀请奖励金·现金)=====
# v2 新规则:不再注册即发金币,改"好友完成首次成功比价"才给【邀请人】发奖,发的是【现金·分】
# 进独立的邀请奖励金账户(coin_account.invite_cash_balance_cents),与金币体系物理隔离。
# 200 分 = 2 元。⚠️ 金额待产品定准:邀请主页=2元 / 福利入口=3.5元 不一致,定后改此处。
INVITE_COMPARE_REWARD_CENTS: int = 200
# ===== 看激励视频 / 信息流广告发金币 =====
# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
+163
View File
@@ -0,0 +1,163 @@
"""京东联盟 OpenAPI 客户端。
当前只接数据大盘需要的订单明细接口:
`jd.union.open.order.row.query`京东要求订单查询时间窗最长 1 小时,
调用方负责切窗分页
"""
from __future__ import annotations
import hashlib
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
_BEIJING = timezone(timedelta(hours=8))
class JdUnionError(RuntimeError):
"""京东联盟 API 调用失败。"""
def _parse_json_maybe(value: Any) -> Any:
if not isinstance(value, str):
return value
text = value.strip()
if not text:
return value
try:
return json.loads(text)
except json.JSONDecodeError:
return value
def _sign(params: dict[str, Any], secret: str) -> str:
pieces = [secret]
for key in sorted(k for k in params if k != "sign"):
value = params[key]
if value is None:
continue
pieces.append(f"{key}{value}")
pieces.append(secret)
raw = "".join(pieces)
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
def _unwrap_response(data: dict[str, Any]) -> dict[str, Any]:
if "error_response" in data:
err = data["error_response"] or {}
msg = err.get("zh_desc") or err.get("en_desc") or err.get("msg") or err
raise JdUnionError(f"京东 API 错误: {msg}")
body: Any = data
for key, value in data.items():
if key.endswith("_responce") or key.endswith("_response"):
body = value
break
body = _parse_json_maybe(body)
if not isinstance(body, dict):
raise JdUnionError("京东 API 返回格式异常")
result = body.get("queryResult", body.get("result", body))
result = _parse_json_maybe(result)
if not isinstance(result, dict):
raise JdUnionError("京东 API 业务结果格式异常")
code = str(result.get("code", result.get("resultCode", "200")))
if code not in {"0", "200"}:
msg = result.get("message") or result.get("msg") or result.get("resultMsg") or result
raise JdUnionError(f"京东 API 业务错误: {msg}")
return result
def call(method: str, payload: dict[str, Any], *, version: str = "1.0") -> dict[str, Any]:
if not settings.jd_union_configured:
raise JdUnionError("京东联盟凭证未配置")
biz_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
params: dict[str, Any] = {
"method": method,
"app_key": settings.JD_UNION_APP_KEY,
"timestamp": datetime.now(_BEIJING).strftime("%Y-%m-%d %H:%M:%S"),
"format": "json",
"v": version,
"sign_method": "md5",
"360buy_param_json": biz_json,
}
params["sign"] = _sign(params, settings.JD_UNION_APP_SECRET)
try:
with httpx.Client(timeout=settings.JD_UNION_TIMEOUT_SEC, trust_env=False) as client:
resp = client.post(settings.JD_UNION_GATEWAY, data=params)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as e:
raise JdUnionError(f"京东 API 网络错误: {e}") from e
except json.JSONDecodeError as e:
raise JdUnionError("京东 API 返回非 JSON") from e
return _unwrap_response(data)
def _extract_rows(result: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]:
payload = _parse_json_maybe(result.get("data", result.get("result", result)))
has_more = bool(result.get("hasMore") or result.get("has_more"))
if isinstance(payload, dict):
for key in ("orderRowResp", "orderRows", "orderList", "orders", "list", "rows"):
rows = _parse_json_maybe(payload.get(key))
if isinstance(rows, list):
return [r for r in rows if isinstance(r, dict)], bool(
payload.get("hasMore") or payload.get("has_more") or has_more
)
return [], bool(payload.get("hasMore") or payload.get("has_more") or has_more)
if isinstance(payload, list):
return [r for r in payload if isinstance(r, dict)], has_more
return [], has_more
def query_order_rows(
*,
start_time: datetime,
end_time: datetime,
query_time_type: int = 3,
page_index: int = 1,
page_size: int = 200,
) -> dict[str, Any]:
"""查询京东 CPS 订单行。
query_time_type: 1 下单时间, 2 完成时间, 3 更新时间
start_time/end_time 用北京时间展示给京东;调用方需保证窗口不超过 1 小时
"""
start_bj = start_time.astimezone(_BEIJING)
end_bj = end_time.astimezone(_BEIJING)
if end_bj <= start_bj:
return {"rows": [], "has_more": False}
if end_bj - start_bj > timedelta(hours=1):
raise JdUnionError("京东订单查询单次时间窗不能超过 1 小时")
order_req: dict[str, Any] = {
"pageIndex": page_index,
"pageSize": min(max(page_size, 1), 200),
"type": query_time_type,
"startTime": start_bj.strftime("%Y-%m-%d %H:%M:%S"),
"endTime": end_bj.strftime("%Y-%m-%d %H:%M:%S"),
}
if settings.JD_UNION_AUTH_KEY:
order_req["key"] = settings.JD_UNION_AUTH_KEY
result = call("jd.union.open.order.row.query", {"orderReq": order_req})
rows, has_more = _extract_rows(result)
logger.info(
"jd.union.open.order.row.query fetched rows=%s page=%s has_more=%s",
len(rows),
page_index,
has_more,
)
return {"rows": rows, "has_more": has_more, "raw": result}
+3 -1
View File
@@ -14,7 +14,9 @@ worker / 多机时内存不共享 → 冷却、每日上限、校验都会失效
防刷三层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限(本文件)
3. IP 频控(api rate_limit 依赖)+ 极光控制台 IP 白名单/防轰炸(运维侧)
3. 设备(device_id)每小时频控(api auth.sms_send enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)
IP 频控(rate_limit 依赖)2026-06-26 按产品要求删除改设备维度; device_id 客户端可伪造/轮换,
脚本轮换 id 能绕过本层 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)
:单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废(防爆破),验过即作废(一次性)
"""
from __future__ import annotations
+380
View File
@@ -0,0 +1,380 @@
"""厂商直推集成。
服务端不再经由 JPush Push API 发送无障碍召回通知,而是按客户端上报的
push_vendor + push_token 分发到各手机厂商的服务端 API
"""
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
import httpx
from app.core.config import settings
logger = logging.getLogger("shagua.vendor_push")
TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled"
SUPPORTED_VENDORS = frozenset({"honor", "vivo", "xiaomi", "oppo"})
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",
"mi": "xiaomi",
"小米": "xiaomi",
"oneplus": "oppo",
"realme": "oppo",
}
return aliases.get(vendor, vendor)
def send_accessibility_disabled(
push_vendor: str,
push_token: str,
*,
title: str = "保护已关闭",
alert: str = "傻瓜比价的无障碍保护被关了,点此重新开启,继续帮你自动比价省钱。",
) -> dict[str, Any]:
"""按厂商 token 向单台设备发送无障碍掉线通知。"""
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")
dispatch: dict[str, Callable[[str, str, str], dict[str, Any]]] = {
"honor": _send_honor,
"vivo": _send_vivo,
"xiaomi": _send_xiaomi,
"oppo": _send_oppo,
}
return dispatch[vendor](token, title, alert)
def _extras() -> dict[str, str]:
return {"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, alert: str) -> dict[str, Any]:
app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID")
access_token = _honor_access_token()
payload = {
"data": json.dumps(_extras(), ensure_ascii=False),
"notification": {"title": title, "body": alert},
"android": {
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
"targetUserType": 1,
"notification": {
"title": title,
"body": alert,
"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 _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, alert: str) -> dict[str, Any]:
app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID")
auth_token = _vivo_auth_token()
body: dict[str, Any] = {
"appId": app_id,
"regId": token,
"notifyType": settings.VIVO_PUSH_NOTIFY_TYPE,
"title": title,
"content": alert,
"timeToLive": settings.PUSH_TIME_TO_LIVE_SEC,
"skipType": 1,
"requestId": uuid.uuid4().hex,
"pushMode": settings.VIVO_PUSH_MODE,
"clientCustomMap": _extras(),
}
if settings.VIVO_PUSH_CATEGORY:
body["category"] = settings.VIVO_PUSH_CATEGORY
data = _request_json(
"POST",
settings.VIVO_PUSH_SEND_ENDPOINT,
json=body,
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, alert: 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 alert
body = {
"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),
"extra.notify_effect": "1",
}
if settings.XIAOMI_PUSH_CHANNEL_ID:
body["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip()
if settings.XIAOMI_PUSH_TEMPLATE_ID:
body["extra.template_id"] = settings.XIAOMI_PUSH_TEMPLATE_ID.strip()
if settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON:
body["extra.template_param"] = _xiaomi_template_param(title, alert)
data = _request_form(
"POST",
settings.XIAOMI_PUSH_SEND_ENDPOINT,
data=body,
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 _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, alert: str) -> dict[str, Any]:
auth_token = _oppo_auth_token()
ttl_hours = max(1, min(72, settings.PUSH_TIME_TO_LIVE_SEC // 3600))
message = {
"target_type": 2,
"target_value": token,
"notification": {
"app_message_id": f"accessibility_disabled_{uuid.uuid4().hex}",
"title": title,
"content": alert,
"click_action_type": 0,
"off_line": True,
"off_line_ttl": ttl_hours,
"action_parameters": json.dumps(_extras(), ensure_ascii=False),
},
}
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
+89
View File
@@ -0,0 +1,89 @@
"""微信开放平台 App 登录集成。
客户端只把微信 SDK 回调拿到的临时 code 传给服务端;AppSecretaccess_token
与用户 openid 都留在服务端处理,避免敏感凭证落到 APK
"""
from __future__ import annotations
import certifi
import httpx
from app.core.config import settings
_API_BASE = "https://api.weixin.qq.com"
class WechatLoginError(Exception):
"""微信授权换取用户信息失败。"""
def _ensure_configured() -> None:
if not settings.WECHAT_APP_ID or not settings.WECHAT_APP_SECRET:
raise WechatLoginError("wechat app not configured")
def _http_client() -> httpx.Client:
return httpx.Client(verify=certifi.where())
def code_to_userinfo(code: str) -> dict:
"""用授权 code 换 openid/unionid,尽力补昵称头像。
返回字段:
- openid: 同一开放平台移动应用下的用户唯一标识
- unionid: 同一开放平台主体下的用户唯一标识,微信可能不返回
- nickname/avatar_url: sns/userinfo 可用时返回,失败不阻断登录
- raw: userinfo 原始响应,便于排障
"""
_ensure_configured()
try:
with _http_client() as client:
token_resp = client.get(
f"{_API_BASE}/sns/oauth2/access_token",
params={
"appid": settings.WECHAT_APP_ID,
"secret": settings.WECHAT_APP_SECRET,
"code": code,
"grant_type": "authorization_code",
},
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
token_data = token_resp.json()
except Exception as e: # noqa: BLE001
raise WechatLoginError("微信授权请求失败,请稍后重试") from e
if "openid" not in token_data or "access_token" not in token_data:
raise WechatLoginError(f"微信授权失败: {token_data.get('errmsg', token_data)}")
openid = token_data["openid"]
unionid = token_data.get("unionid")
nickname = None
avatar_url = None
raw: dict = {}
try:
with _http_client() as client:
info_resp = client.get(
f"{_API_BASE}/sns/userinfo",
params={
"access_token": token_data["access_token"],
"openid": openid,
"lang": "zh_CN",
},
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
raw = info_resp.json()
if "errcode" not in raw:
nickname = raw.get("nickname") or None
avatar_url = raw.get("headimgurl") or None
unionid = unionid or raw.get("unionid")
except Exception: # noqa: BLE001
# openid 已拿到即可登录;昵称头像只是展示增强。
pass
return {
"openid": openid,
"unionid": unionid,
"nickname": nickname,
"avatar_url": avatar_url,
"raw": raw,
}
+5
View File
@@ -15,6 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.v1.ad import router as ad_router
from app.api.v1.analytics import router as analytics_router
from app.api.v1.auth import router as auth_router
from app.api.v1.compare import router as compare_router
from app.api.v1.compare_milestone import router as compare_milestone_router
@@ -48,6 +49,7 @@ from app.core.daily_exchange_worker import (
stop_daily_exchange_worker,
)
from app.core.logging import setup_logging
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
from app.core.withdraw_reconcile_worker import (
start_withdraw_reconcile_worker,
stop_withdraw_reconcile_worker,
@@ -67,6 +69,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.APP_DEBUG,
settings.DATABASE_URL.split("://", 1)[0],
)
get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热
reconcile_task = start_withdraw_reconcile_worker()
heartbeat_task = start_heartbeat_monitor()
daily_exchange_task = start_daily_exchange_worker()
@@ -76,6 +79,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
await stop_heartbeat_monitor(heartbeat_task)
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
await aclose_pricebot_client()
logger.info("shutting down")
@@ -105,6 +109,7 @@ def health() -> dict[str, str]:
app.include_router(auth_router)
app.include_router(user_router)
app.include_router(feedback_router)
app.include_router(analytics_router)
app.include_router(invite_router)
app.include_router(coupon_router)
app.include_router(device_router)
+3
View File
@@ -4,6 +4,7 @@ from app.models.ad_feed_reward import AdFeedRewardRecord # noqa: F401
from app.models.ad_reward import AdRewardRecord # noqa: F401
from app.models.ad_watch_log import AdWatchLog # noqa: F401
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
from app.models.analytics_event import AnalyticsEvent # noqa: F401
from app.models.app_config import AppConfig # noqa: F401
from app.models.comparison import ComparisonRecord # noqa: F401
from app.models.cps_activity import CpsActivity # noqa: F401
@@ -18,6 +19,7 @@ from app.models.coupon_state import ( # noqa: F401
CouponDailyCompletion,
CouponPromptEngagement,
)
from app.models.cps_order import CpsOrder # noqa: F401
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
@@ -37,5 +39,6 @@ from app.models.wallet import ( # noqa: F401
CashTransaction,
CoinAccount,
CoinTransaction,
InviteCashTransaction,
WithdrawOrder,
)
+3
View File
@@ -29,6 +29,9 @@ class AdEcpmRecord(Base):
)
# 广告类型:reward_video(激励视频) / draw(Draw 信息流) 等;不强行统一代码位,各类型各自上报
ad_type: Mapped[str] = mapped_column(String(32), nullable=False)
# 点位场景:comparison(比价) / coupon(领券) / welfare(福利),供收益报表区分比价/领券 Draw 收益;
# 仅信息流/Draw 上报(比价与领券共用同一代码位,只能客户端各调用点显式打标),激励视频为 NULL。
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt)
+3
View File
@@ -30,6 +30,9 @@ class AdFeedRewardRecord(Base):
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 广告类型:feed(信息流) / draw(Draw 信息流)。比价与领券共用同一 Draw 代码位,靠 feed_scene
# 区分收益;ad_type 区分广告形态。旧数据(未升级客户端)为 NULL,一律视为 feed,保持向后兼容。
ad_type: Mapped[str | None] = mapped_column(String(16), nullable=True, default="feed")
# 点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。比价与领券共用同一信息流
# 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
+60
View File
@@ -0,0 +1,60 @@
"""新手引导(及后续)埋点事件表。
每行 = 客户端上报的一条行为埋点,Who / When / Where / What / How五维组织:
- What :event(事件名, video_play)+ props(事件专属属性,JSON)
- Who :device_id(硬件级设备标识)+ user_id(登录后才有,可空)
- When :client_ts(端事件时间 epoch ms)+ session_id(本次引导会话)+ sent_at(端上报时间)
+ created_at(服务端接收时间 = server_at)
- Where:page(引导步/页面)+ client_ip(服务端从 X-Forwarded-For )
- How :oem / os / model / app_ver / network / channel(设备与环境)
append-only,不更新;客户端批量上报( app/api/v1/analytics.py),admin 同库直接查(
app/admin/routers/event_logs.py)未登录态也允许上报(user_id 为空), user_id 不设外键只索引
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, BigInteger, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AnalyticsEvent(Base):
__tablename__ = "analytics_event"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# ---- What:做了什么 ----
event: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
props: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# ---- Who:谁 ----
device_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# ---- When:何时 ----
session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
client_ts: Mapped[int] = mapped_column(BigInteger, nullable=False) # 端事件时间 epoch ms
sent_at: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 端上报时间 epoch ms
# ---- Where:何地 ----
page: Mapped[str | None] = mapped_column(String(64), nullable=True)
client_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
# ---- How:用什么环境 ----
oem: Mapped[str | None] = mapped_column(String(32), nullable=True)
os: Mapped[str | None] = mapped_column(String(32), nullable=True)
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
app_ver: Mapped[str | None] = mapped_column(String(32), nullable=True)
network: Mapped[str | None] = mapped_column(String(16), nullable=True)
channel: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 服务端接收时间(= When.server_at);客户端时间不可信,以此为权威落库时刻。
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"<AnalyticsEvent id={self.id} event={self.event} device={self.device_id}>"
+22 -4
View File
@@ -1,11 +1,13 @@
"""CPS 对账订单(cps_order)。
美团联盟 query_order 按时间窗拉回 sid 归群的订单明细字段对齐 query_order
从联盟 API 按时间窗拉回平台落库的 CPS 订单明细字段最初对齐美团 query_order,
后续兼容京东订单报表:
实测返回:
- payPrice / profit 字符串 入库统一转(与全站口径一致)
- payTime / updateTime 是秒级时间戳 入库转 tz-aware datetime
- status: 2付款 3完成 4取消 5风控 6结算(取消/风控不计佣金)
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)京东订单用
`jd:<row_id>` 前缀避免与美团订单号碰撞
"""
from __future__ import annotations
@@ -22,8 +24,13 @@ class CpsOrder(Base):
__tablename__ = "cps_order"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 美团订单号(加密串),全局唯一,upsert 幂等键
# 平台:meituan / jd。历史数据迁移默认 meituan
platform: Mapped[str] = mapped_column(String(20), default="meituan", index=True, nullable=False)
# 平台订单号/行号包装后的全局唯一键,upsert 幂等。
order_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
# 平台原始订单号/行号。京东一笔订单多 SKU 时可按行号区分。
external_order_id: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
external_row_id: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
# 渠道追踪位 = 群 sid(历史无 sid 订单为空)。按它归群聚合。
sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
act_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
@@ -35,11 +42,21 @@ class CpsOrder(Base):
commission_rate: Mapped[str | None] = mapped_column(String(16), nullable=True) # "300"=3% "10"=0.1%
refund_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
refund_profit_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 通用佣金拆分。美团只有预估 profit;京东有预估/实际佣金。
estimated_commission_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
actual_commission_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 美团订单状态: 2付款 3完成 4取消 5风控 6结算
mt_status: Mapped[str | None] = mapped_column(String(8), index=True, nullable=True)
# 京东订单有效码(validCode),用于判断是否有效/已完成。
jd_valid_code: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
invalid_reason: Mapped[str | None] = mapped_column(String(128), nullable=True)
product_name: Mapped[str | None] = mapped_column(String(512), nullable=True)
settle_month: Mapped[str | None] = mapped_column(String(16), nullable=True)
site_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
position_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
pid: Mapped[str | None] = mapped_column(String(128), nullable=True)
sub_union_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
pay_time: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True, nullable=True
@@ -59,5 +76,6 @@ class CpsOrder(Base):
def __repr__(self) -> str: # pragma: no cover
return (
f"<CpsOrder id={self.id} order_id={self.order_id!r} "
f"sid={self.sid!r} status={self.mt_status} profit_cents={self.commission_cents}>"
f"platform={self.platform!r} sid={self.sid!r} "
f"status={self.mt_status or self.jd_valid_code} profit_cents={self.commission_cents}>"
)
+14 -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)
@@ -51,6 +55,11 @@ class DeviceLiveness(Base):
ever_protected: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# 首次开无障碍(首次收到 accessibility_enabled 心跳)的时刻;ever_protected 第一次翻 true 时记一次,
# 后续心跳不覆盖。老设备(迁移前已 protected)无此值 → NULL。
first_protected_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# 最近一次 service 心跳时间(存活证明);超时即视为保护掉线
last_heartbeat_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True, nullable=True
+15 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, false, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -35,6 +35,20 @@ class InviteRelation(Base):
inviter_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
invitee_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# ===== v2 比价发奖追踪(好友"下载+登录+比价一次"→ 给邀请人发邀请奖励金)=====
# 是否已因"好友完成比价"发过奖:好友比价多次只发一次(防重复发,与 invitee 唯一约束双保险)
compare_reward_granted: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=false()
)
# 实发给邀请人的邀请奖励金(分);未发为 0
compare_reward_cents: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
# 发奖时间(未发为 None)
compare_rewarded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
+48
View File
@@ -25,6 +25,11 @@ class CoinAccount(Base):
)
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 邀请奖励金余额(分)——与金币兑换来的 cash_balance_cents **物理隔离**(产品红线:
# 邀请奖励金 ≠ 看广告/金币现金,两本账不可累加)。好友比价发奖入账、提现出账走它。
invite_cash_balance_cents: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
# 累计赚取的金币(只增不减),用于"历史总收益"类展示
total_coin_earned: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
@@ -108,6 +113,11 @@ class WithdrawOrder(Base):
# 商户单号(我们生成,微信查单的 out_bill_no),唯一
out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
# 这笔提现扣的是哪个账户:coin_cash(金币兑换的现金) / invite_cash(邀请奖励金)。
# 退款时据此退回**对应**账户,两本账不串。旧单默认 coin_cash。
source: Mapped[str] = mapped_column(
String(16), nullable=False, default="coin_cash", server_default="coin_cash"
)
# 提现实名(微信达额转账要求):审核后异步打款时要用,发起提现时存下,可空
user_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 归一化状态:reviewing(待审核) / pending(打款在途) / success / failed(打款失败已退) / rejected(审核拒绝已退)
@@ -200,3 +210,41 @@ class CashTransaction(Base):
def __repr__(self) -> str: # pragma: no cover
return f"<CashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>"
class InviteCashTransaction(Base):
"""邀请奖励金流水(单位:分)。与 cash_transaction(金币兑换现金)**物理隔离**——
产品红线:邀请奖励金 看广告/金币现金,两本账不可累加各自提现
入账=好友比价发奖(invite_reward),出账=提现(invite_withdraw)/退款(invite_withdraw_refund)
结构与 cash_transaction 同构,balance_after_cents 记的是 coin_account.invite_cash_balance_cents"""
__tablename__ = "invite_cash_transaction"
__table_args__ = (
# 提现退款幂等:一个提现单只退一次(同 cash_transaction 的 withdraw_refund 去重)
Index(
"ux_invite_cash_txn_refund_ref",
"ref_id",
unique=True,
sqlite_where=text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
postgresql_where=text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
),
)
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
)
# 正数=入账(发奖),负数=出账(提现)
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
balance_after_cents: Mapped[int] = mapped_column(Integer, nullable=False)
# 业务类型:invite_reward / invite_withdraw / invite_withdraw_refund
biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
remark: Mapped[str | None] = mapped_column(String(128), nullable=True)
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"<InviteCashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>"
+26 -5
View File
@@ -23,6 +23,7 @@ def create_ecpm_record(
ad_session_id: str | None = None,
adn: str | None = None,
slot_id: str | None = None,
feed_scene: str | None = None,
app_env: str | None = None,
our_code_id: str | None = None,
) -> AdEcpmRecord:
@@ -41,6 +42,7 @@ def create_ecpm_record(
ad_session_id=ad_session_id,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env,
our_code_id=our_code_id,
ecpm_raw=ecpm_raw,
@@ -51,11 +53,16 @@ def create_ecpm_record(
db.commit()
except IntegrityError:
db.rollback()
if ad_session_id:
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
if existing is not None:
return existing
raise
# 撞唯一约束 uq_ad_ecpm_record_session(全局按 ad_session_id、不含 user_id):并发同会话重复上报,
# 或同一 ad_session_id 已被先到的上报占用。本接口 fire-and-forget、best-effort —— 丢一条不影响业务
# (穿山甲后台才是结算权威),绝不向客户端抛 500。兜底查找须与唯一约束**同口径**(只按 ad_session_id、
# 不带 user_id):否则不同 user 上报了同一 ad_session_id 时,带 user_id 的查找会漏掉那条别人的记录 →
# 旧逻辑在此 raise 成 500(本应静默吞掉)。
existing = _find_by_session_global(db, ad_session_id)
if existing is not None:
return existing
# 极少:rollback 后既存记录又查不到(并发删除 / 竞态)。吞掉、返回未入库的内存对象(调用方不读返回值)。
return rec
db.refresh(rec)
return rec
@@ -74,6 +81,20 @@ def find_by_session(
).scalar_one_or_none()
def _find_by_session_global(db: Session, ad_session_id: str | None) -> AdEcpmRecord | None:
"""按 ad_session_id **全局**查找(与唯一约束 uq_ad_ecpm_record_session 同口径,不含 user_id)。
create_ecpm_record 撞约束后兜底用:此时撞的是全局会话约束,既存记录可能属于**另一个 user**,
user_id find_by_session 会漏掉它导致误判查无 raise 500其它业务查 user 的某次
展示 eCPM仍用 find_by_session( user_id,语义更准),不走这里
"""
if not ad_session_id:
return None
return db.execute(
select(AdEcpmRecord).where(AdEcpmRecord.ad_session_id == ad_session_id)
).scalar_one_or_none()
def count_today(db: Session, user_id: int) -> int:
"""该用户今日(北京时间)上报的 eCPM 条数,排查/对账辅助用。"""
return db.execute(
+34 -18
View File
@@ -14,7 +14,6 @@ from app.core.rewards import cn_today
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.repositories import wallet as crud_wallet
FEED_REWARD_UNIT_SECONDS = 10
# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数
# (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。
@@ -65,6 +64,7 @@ def grant_feed_reward(
client_event_id: str,
ecpm: str,
duration_seconds: int,
ad_type: str = "feed",
ad_session_id: str | None = None,
adn: str | None = None,
slot_id: str | None = None,
@@ -73,17 +73,22 @@ def grant_feed_reward(
app_env: str | None = None,
our_code_id: str | None = None,
aborted: bool = False,
display_coin: int = 0,
) -> AdFeedRewardRecord:
"""**每条**信息流广告(客户端每条各上报一次)结算奖励。client_event_id 幂等,同号重试不重复发。
发奖规则:**一条广告 = 一个单次公式值**(rewards.calculate_ad_reward_coin),因子2(LT)按账号累计
****数递进;看满一份时长(unit_count>=1, 10 )才发,**不逐份累加**
发奖规则(所见即所得, 2026-06-27 用户拍板显示多少给多少):优先**直接发客户端小球显示的金币
display_coin**;防刷钳到本条1 份满额(eCPM 已钳 AD_ECPM_MAX_FEN, 因子2 按账号累计已发条数取档),
合法显示(实际因子2 × 进度 p 1 )不被砍, 只挡伪造天价值旧客户端不传 display_coin 时退回
看满 10 秒发整份(兼容不断币)因子2(LT)**客户端** granted COUNT(拉自 /feed-reward/units)
算进 display_coin, 后端只记 granted 行让该计数自增, 不再服务端重算份值
- aborted=True(用户中途 关闭这条):本条不发, status='closed_early'
- 时长不足 10 (unit_count==0): status='too_short' 不发
- display_coin 0 且时长不足一份: status='too_short' 不发(不计 LT / 当日上限)
- 命中当日条数上限: status='capped' 不发
duration_seconds **这一条**的观看秒数服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS
eCPM calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN;叠加每日 get_ad_daily_limit 条数上限
duration_seconds 落库留痕(unit_count 字段), 旧端兼容路径据它判是否满 1
feed_scene:点位场景(comparison/coupon/welfare),仅归类落库,不参与计算
ad_type:广告形态(feed 信息流 / draw Draw 信息流),仅归类落库;**每日上限与因子2(LT)仍按本表
全表 unit 累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**
"""
existing = _find_by_event(db, client_event_id)
if existing is not None:
@@ -106,6 +111,7 @@ def grant_feed_reward(
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
ad_type=ad_type,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
@@ -126,6 +132,7 @@ def grant_feed_reward(
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
ad_type=ad_type,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
@@ -135,18 +142,31 @@ def grant_feed_reward(
)
return _commit_record(db, rec, client_event_id)
# 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。
if unit_count == 0:
# 所见即所得(用户 2026-06-27「显示多少给多少」): 优先发**客户端小球显示**的金币 display_coin,
# 钳到本条「1 份满额」防刷(eCPM 已钳 AD_ECPM_MAX_FEN; 合法显示=因子2×p≤1份, 不会被砍)。
# 因子2(LT)按账号累计已发条数(granted 行 COUNT), 第 existing_ads+1 条。
existing_ads = granted_unit_total(db, user_id)
unit_cap = rewards.calculate_ad_reward_coin(ecpm, existing_ads + 1)
if display_coin > 0:
coin = min(display_coin, unit_cap) # 新端: 所见即所得(直接发小球显示金币)
elif unit_count >= 1:
coin = unit_cap # 旧端没传 display_coin: 退回「看满 1 份发整份」(兼容)
else:
coin = 0
# 显示金币为 0 且没满一份 → 不发, 记 too_short 留痕(不写 granted 行 → 不计 LT / 当日上限)。
if coin <= 0:
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
reward_date=today,
duration_seconds=safe_duration,
unit_count=0,
unit_count=unit_count,
ad_session_id=ad_session_id,
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
ad_type=ad_type,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
@@ -156,15 +176,11 @@ def grant_feed_reward(
)
return _commit_record(db, rec, client_event_id)
# 一条广告 = 一个「单次公式值」(因子2 按账号累计**条**数, 即第 existing_ads+1 条);看满一份(unit_count>=1)即发,不逐份累加。
existing_ads = granted_unit_total(db, user_id)
coin = rewards.calculate_ad_reward_coin(ecpm, existing_ads + 1)
if coin > 0:
crud_wallet.grant_coins(
db, user_id, coin,
biz_type="feed_ad_reward", ref_id=client_event_id,
remark="信息流广告奖励",
)
crud_wallet.grant_coins(
db, user_id, coin,
biz_type="feed_ad_reward", ref_id=client_event_id,
remark="信息流广告奖励",
)
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
+34
View File
@@ -0,0 +1,34 @@
"""埋点事件批量落库。append-only,一次 commit 提交整批。"""
from __future__ import annotations
from sqlalchemy.orm import Session
from app.models.analytics_event import AnalyticsEvent
from app.schemas.analytics import AnalyticsBatchIn
def record_batch(db: Session, batch: AnalyticsBatchIn, *, client_ip: str | None) -> int:
"""把一批上报事件展开成多行落库(公共维度复制到每行),返回写入条数。"""
rows = [
AnalyticsEvent(
event=e.event,
props=e.props or None,
device_id=batch.device_id,
user_id=batch.user_id,
session_id=e.session_id,
client_ts=e.client_ts,
sent_at=batch.sent_at,
page=e.page,
client_ip=client_ip or None,
oem=batch.oem,
os=batch.os,
model=batch.model,
app_ver=batch.app_ver,
network=e.network,
channel=batch.channel,
)
for e in batch.events
]
db.add_all(rows)
db.commit()
return len(rows)
+4 -4
View File
@@ -101,10 +101,10 @@ AD_CONFIG_KEY = "ad_config"
_AD_CONFIG_DEFAULTS: dict[str, Any] = {
"app_id": "5830519", # 穿山甲应用ID(正式)
"reward_code_id": "104099389", # 福利页激励视频位
# ⚠️ 2026-06-21 真机核对穿山甲后台:5830519 名下信息流真实位是 104142227「信息流 1」;
# 旧值 104090333 不在该应用名下(请求会报 44406/配置 null、出不了广告)。客户端接入下发后以本值为准
"compare_feed_code_id": "104142227", # 比价信息流位
"coupon_feed_code_id": "104142227", # 领券信息流位(初始同比价,运营可拆)
# 比价与领券共用同一穿山甲 Draw 代码位(默认 104098712 = 后台「Draw信息流」位),靠 feed_scene
# (comparison/coupon)区分收益;运营可在后台把两者拆成不同代码位。字段从旧 *_feed_code_id 改名为 *_draw_code_id
"compare_draw_code_id": "104098712", # 比价 Draw 代码位(104098712 = Draw 信息流位)
"coupon_draw_code_id": "104098712", # 领券 Draw 代码位(初始同比价,运营可拆)
"reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*)
"reward_enabled": True, # 福利激励视频开关
"compare_ad_enabled": True, # 比价广告开关
+48 -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,9 +81,17 @@ 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:
if not device.ever_protected:
device.first_protected_at = now # 首次开无障碍记一次,后续心跳不覆盖
device.last_heartbeat_at = now
device.ever_protected = True
device.liveness_state = "alive"
@@ -85,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(
@@ -122,3 +142,27 @@ 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 _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)
+111 -27
View File
@@ -1,13 +1,14 @@
"""好友邀请 CRUD(注册即生效,邀请人 + 被邀请人各发金币)。
"""好友邀请 CRUD(注册即生效,绑定只建归因关系、双方都不发钱)。
发奖规则(v3, 2026-06-26 拍板):
- 绑定时双方都不发钱(被邀请人无奖励邀请人不发金币)
- 邀请人的钱由 try_reward_on_compare 在好友"成功比价一次"后发 2 元邀请奖励金(防刷)
- v1 "绑定双方各发金币"已废;invite_relation inviter_coin/invitee_coin 列保留恒 0(待清)
防重复发奖三道(仿 ad_reward / 提现的资金安全思路):
1. invitee_user_id 唯一 一个被邀请人只能被绑定一次(幂等键)
2. 自邀屏蔽 inviter == invitee 直接拒
3. 现成的手机号唯一(每个被邀请人 = 一个真实手机号账号)= 天然限制刷量规模
发金币复用 wallet.grant_coins(grant flush commit),与建关系记录在**同一事务**
commit,保证"建关系 + 双方加金币"原子奖励额 = rewards.INVITE_INVITER_COINS /
INVITE_INVITEE_COINS
"""
from __future__ import annotations
@@ -34,6 +35,30 @@ def _gen_code() -> str:
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LEN))
# ===== v2 邀请倒计时(7 天 1 轮,锚点=注册日,自然日差,东八区)=====
# 中国不用夏令时,固定 +8 偏移即可(不依赖 tzdata,Windows 本地联调也稳)。
_CST = timezone(timedelta(hours=8))
def compute_invite_countdown(register_at: datetime) -> tuple[int, bool, str]:
"""按 7 天 1 轮算邀请页倒计时。
锚点 = 用户注册日(user.created_at),按东八区自然日差算
(跨自然日才减同日多次登录不减,天然满足)返回:
(本轮剩余天数 1..7, 是否刚进入新一轮[非首轮第1天], 展示文案)
"""
reg = register_at if register_at.tzinfo else register_at.replace(tzinfo=timezone.utc)
days_since = max(0, (datetime.now(_CST).date() - reg.astimezone(_CST).date()).days)
day_in_cycle = days_since % 7 # 0..6(本轮第几天,0-based)
days_left = 7 - day_in_cycle # 7..1(第1天=7、第7天=1)
is_fresh_round = days_since >= 7 and day_in_cycle == 0 # 非首轮的第1天
if is_fresh_round:
text = "恭喜您进入新一轮邀请!\n距离本轮结束还有7天"
else:
text = f"距本轮结束还有{days_left}"
return days_left, is_fresh_round, text
def ensure_code(db: Session, user: User) -> str:
"""保证 user 有邀请码(懒生成),返回它。唯一约束碰撞则换码重试。
@@ -90,15 +115,17 @@ def _is_new_user(user: User) -> bool:
class BindResult:
status: str # success / already_bound / invalid_code / self_invite / not_eligible
relation: InviteRelation | None = None
invitee_coin: int = 0 # 本次给被邀请人发的金币(success 时 >0)
invitee_coin: int = 0 # v3 起恒 0(绑定不再发金币);保留字段兼容响应
def bind(
db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard"
) -> BindResult:
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效 + 双方发金币
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效。
幂等:invitee 已被绑过 already_bound(不重复发奖)
v3 发奖( 2026-06-26 拍板):绑定双方都不发钱被邀请人无奖励,邀请人改"好友成功比价一次
才发 2 元邀请奖励金"(见 try_reward_on_compare,防刷)。绑定只建归因关系 + 跑防刷闸。
幂等:已绑过 already_bound
"""
# 幂等:已绑过直接返回(不重复发奖)
existing = _relation_of_invitee(db, invitee.id)
@@ -114,27 +141,18 @@ def bind(
if not _is_new_user(invitee):
return BindResult("not_eligible")
inviter_coin = rewards.INVITE_INVITER_COINS
invitee_coin = rewards.INVITE_INVITEE_COINS
# v3(冰 2026-06-26 拍板):绑定双方都不发钱。被邀请人不再发新人金币(去掉拉新即时激励);
# 邀请人的钱由 try_reward_on_compare 在好友成功比价后发 2 元邀请奖励金(防刷)。
rel = InviteRelation(
inviter_user_id=inviter.id,
invitee_user_id=invitee.id,
channel=(channel or "clipboard")[:16],
status="effective",
inviter_coin=inviter_coin,
invitee_coin=invitee_coin,
inviter_coin=0, # v1 金币线已停用,列保留恒 0(待清)
invitee_coin=0, # v3:被邀请人绑定不再发金币
)
db.add(rel)
# 双方发金币(同事务,与建关系一起 commit)。ref_id 互指对方便于对账
crud_wallet.grant_coins(
db, inviter.id, inviter_coin,
biz_type="invite_inviter", ref_id=str(invitee.id), remark="邀请好友奖励",
)
crud_wallet.grant_coins(
db, invitee.id, invitee_coin,
biz_type="invite_invitee", ref_id=str(inviter.id), remark="新人受邀奖励",
)
# 绑定不发任何金币(邀请人改比价发现金、被邀请人无奖励),仅建归因关系
try:
db.commit()
except IntegrityError:
@@ -145,16 +163,62 @@ def bind(
return BindResult("already_bound", existing)
raise
except Exception:
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证"建关系 + 双方发币"
# 原子(要么全成要么全无),不依赖 get_db 关闭时的隐式回滚,语义更硬。
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证建关系原子,
# 不依赖 get_db 关闭时的隐式回滚,语义更硬。
db.rollback()
raise
db.refresh(rel)
return BindResult("success", rel, invitee_coin)
return BindResult("success", rel)
@dataclass
class CompareRewardResult:
status: str # granted / no_relation / already_granted / inviter_inactive
inviter_user_id: int | None = None
reward_cents: int = 0
def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardResult:
"""被邀请人完成一次成功比价时调用:若其有邀请关系且尚未发过比价奖,给【邀请人】发邀请奖励金。
v2 发奖规则核心(替代 v1 "注册即发金币"):好友"下载+登录+比价一次" 邀请人得 2 元现金
幂等:compare_reward_granted 标记保证好友比价多次只发一次无邀请关系 / 已发过 / 邀请人失效
空操作发奖(grant_invite_cash 入账独立账户)+ 置标记同事务 commit,保证原子
"""
rel = _relation_of_invitee(db, invitee_user_id)
if rel is None:
return CompareRewardResult("no_relation")
if rel.compare_reward_granted:
return CompareRewardResult("already_granted", rel.inviter_user_id)
inviter = db.get(User, rel.inviter_user_id)
if inviter is None or inviter.status != "active":
# 邀请人注销 / 封禁:本次不发、不置标记,待其恢复后下次比价再试(保守,不吞奖励)
return CompareRewardResult("inviter_inactive", rel.inviter_user_id)
reward = rewards.INVITE_COMPARE_REWARD_CENTS
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,
biz_type="invite_reward", ref_id=str(invitee_user_id), remark="好友比价奖励",
)
try:
db.commit()
except Exception:
db.rollback()
raise
return CompareRewardResult("granted", inviter.id, reward)
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。"""
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。
金币口径(inviter_coin 之和) v3 起恒 0(邀请人收益改走邀请奖励金, get_reward_stats /
try_reward_on_compare);保留返回位兼容旧响应字段 coins_earned
"""
count = db.execute(
select(func.count())
.select_from(InviteRelation)
@@ -167,6 +231,26 @@ def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
return int(count), int(coins)
def get_reward_stats(db: Session, inviter_id: int) -> tuple[int, int]:
"""v2 邀请奖励金战绩:(可提现余额/分, 累计提现成功/分)。
余额 = coin_account.invite_cash_balance_cents;累计提现 = 该用户 source=invite_cash
status=success 的提现单金额之和(成功打款才算) /invite/me 提现板块展示
"""
from app.models.wallet import CoinAccount, WithdrawOrder
balance = db.execute(
select(CoinAccount.invite_cash_balance_cents).where(CoinAccount.user_id == inviter_id)
).scalar_one_or_none()
withdrawn = db.execute(
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
WithdrawOrder.user_id == inviter_id,
WithdrawOrder.source == "invite_cash",
WithdrawOrder.status == "success",
)
).scalar_one()
return int(balance or 0), int(withdrawn)
def _mask_phone(phone: str) -> str:
"""手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。
@@ -210,7 +294,7 @@ def get_invitees(
items.append({
"display_name": u.nickname or u.wechat_nickname or _mask_phone(u.phone),
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
"coins": rel.inviter_coin,
"coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金)
"invited_at": rel.created_at,
})
has_more = offset + len(rows) < int(total)
+62
View File
@@ -6,6 +6,7 @@ from __future__ import annotations
import secrets
import string
import hashlib
from datetime import datetime, timezone
from sqlalchemy import select
@@ -70,6 +71,21 @@ def get_user_by_phone(db: Session, phone: str) -> User | None:
return db.execute(stmt).scalar_one_or_none()
def get_user_by_wechat_openid(db: Session, openid: str) -> User | None:
stmt = select(User).where(User.wechat_openid == openid)
return db.execute(stmt).scalar_one_or_none()
def _wechat_placeholder_phone(openid: str) -> str:
"""给纯微信注册用户生成内部登录号。
当前 user.phone 非空唯一,而微信 App 授权不会返回手机号这里生成一个不可能
命中手机号正则的内部值,只用于满足既有表结构和客户端登录态判断
"""
digest = hashlib.sha256(openid.encode("utf-8")).hexdigest()[:16]
return f"wx_{digest}"
def upsert_user_for_login(
db: Session,
*,
@@ -99,6 +115,52 @@ def upsert_user_for_login(
return user
def upsert_user_for_wechat_login(
db: Session,
*,
openid: str,
nickname: str | None = None,
avatar_url: str | None = None,
) -> User:
"""微信 App 登录:openid 已存在则回到原账号,不存在则注册新账号。
如果用户之前在提现页绑定过微信, openid 已在 user.wechat_openid ,因此会
直接登录到同一账号;新微信用户则创建 register_channel=wechat 的账号
"""
user = get_user_by_wechat_openid(db, openid)
now = datetime.now(timezone.utc)
if user is None:
phone = _wechat_placeholder_phone(openid)
# 极小概率 hash 前缀碰撞时,后缀追加随机段兜底。
if get_user_by_phone(db, phone) is not None:
phone = f"wx_{secrets.token_hex(8)}"
user = User(
phone=phone,
username=_gen_unique_username(db),
nickname=nickname or _gen_nickname(),
avatar_url=avatar_url,
register_channel="wechat",
wechat_openid=openid,
wechat_nickname=nickname,
wechat_avatar_url=avatar_url,
last_login_at=now,
)
db.add(user)
else:
user.last_login_at = now
if nickname:
user.wechat_nickname = nickname
if not user.nickname:
user.nickname = nickname
if avatar_url:
user.wechat_avatar_url = avatar_url
if not user.avatar_url:
user.avatar_url = avatar_url
db.commit()
db.refresh(user)
return user
def update_nickname(db: Session, user: User, *, nickname: str) -> User:
user.nickname = nickname
db.commit()
+84 -32
View File
@@ -24,6 +24,7 @@ from app.models.wallet import (
CashTransaction,
CoinAccount,
CoinTransaction,
InviteCashTransaction,
WechatTransferAuthorization,
WithdrawOrder,
)
@@ -166,6 +167,36 @@ def grant_cash(
return acc, txn
def grant_invite_cash(
db: Session,
user_id: int,
amount_cents: int,
*,
biz_type: str,
ref_id: str | None = None,
remark: str | None = None,
) -> tuple[CoinAccount, InviteCashTransaction]:
"""邀请奖励金变动入口(正数入账 / 负数出账)。更新 invite_cash_balance_cents + 写
invite_cash_transaction, commit与金币兑换的 cash_balance_cents **物理隔离**
(产品红线:邀请奖励金 金币现金,两本账不可累加)返回 (account, transaction),
调用方负责 commit不在此校验扣成负由调用方按业务保护"""
acc = get_or_create_account(db, user_id, commit=False)
acc.invite_cash_balance_cents += amount_cents
txn = InviteCashTransaction(
user_id=user_id,
amount_cents=amount_cents,
balance_after_cents=acc.invite_cash_balance_cents,
biz_type=biz_type,
ref_id=ref_id,
remark=remark,
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
)
db.add(txn)
db.flush()
return acc, txn
def list_coin_transactions(
db: Session,
user_id: int,
@@ -411,33 +442,43 @@ def refund_reviewing_withdraws_on_unbind(db: Session, user_id: int) -> int:
_OUT_BILL_NO_RE = re.compile(r"^[0-9A-Za-z_-]{8,32}$")
def _try_deduct_cash(db: Session, user_id: int, amount_cents: int) -> bool:
"""原子扣减现金:仅当余额足够时扣,返回是否成功
def _balance_col(source: str):
"""提现账户来源 → CoinAccount 余额列。invite_cash=邀请奖励金;否则金币兑换的现金
两账户物理隔离,提现扣款/退款都按 source 走对应列,互不串"""
return (
CoinAccount.invite_cash_balance_cents
if source == "invite_cash"
else CoinAccount.cash_balance_cents
)
用带条件的 UPDATE(`WHERE cash_balance_cents >= amount`)避免"读-判断-写"竞态
并发/重试时不会两次都通过余额检查导致超额扣款(SQLite 串行写Postgres 行级,均安全)
def _try_deduct_cash(db: Session, user_id: int, amount_cents: int, source: str = "coin_cash") -> bool:
"""原子扣减指定账户余额:仅当余额足够时扣,返回是否成功。
用带条件的 UPDATE(`WHERE <col> >= amount`)避免"读-判断-写"竞态并发/重试时不会两次
都通过余额检查导致超额扣款(SQLite 串行写Postgres 行级,均安全)source 决定扣
cash_balance_cents(coin_cash) 还是 invite_cash_balance_cents(invite_cash)
"""
col = _balance_col(source)
res = db.execute(
update(CoinAccount)
.where(
CoinAccount.user_id == user_id,
CoinAccount.cash_balance_cents >= amount_cents,
)
.values(cash_balance_cents=CoinAccount.cash_balance_cents - amount_cents)
.where(CoinAccount.user_id == user_id, col >= amount_cents)
.values({col: col - amount_cents})
)
return res.rowcount == 1
def _add_cash(db: Session, user_id: int, amount_cents: int) -> int:
"""原子增加现金(退款用),返回加后余额。"""
def _add_cash(db: Session, user_id: int, amount_cents: int, source: str = "coin_cash") -> int:
"""原子增加指定账户余额(退款用),返回加后余额。source 决定退回哪个账户(两账户隔离)。"""
col = _balance_col(source)
db.execute(
update(CoinAccount)
.where(CoinAccount.user_id == user_id)
.values(cash_balance_cents=CoinAccount.cash_balance_cents + amount_cents)
.values({col: col + amount_cents})
)
db.flush()
bal = db.execute(
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
select(col).where(CoinAccount.user_id == user_id)
).scalar_one()
return bal
@@ -456,11 +497,15 @@ def _refund_withdraw(
"""
if order.status in ("failed", "rejected"):
return # 防重复退款(并发/对账与查单/重复拒绝同时触发)
# 账户隔离:按 order.source 退回对应账户 + 写对应退款流水表
is_invite = order.source == "invite_cash"
txn_model = InviteCashTransaction if is_invite else CashTransaction
refund_biz = "invite_withdraw_refund" if is_invite else "withdraw_refund"
refunded_txn_id = db.execute(
select(CashTransaction.id).where(
CashTransaction.user_id == order.user_id,
CashTransaction.biz_type == "withdraw_refund",
CashTransaction.ref_id == order.out_bill_no,
select(txn_model.id).where(
txn_model.user_id == order.user_id,
txn_model.biz_type == refund_biz,
txn_model.ref_id == order.out_bill_no,
).limit(1)
).scalar_one_or_none()
if refunded_txn_id is not None:
@@ -468,13 +513,13 @@ def _refund_withdraw(
order.fail_reason = reason[:256]
db.commit()
return
bal = _add_cash(db, order.user_id, order.amount_cents)
bal = _add_cash(db, order.user_id, order.amount_cents, order.source)
db.add(
CashTransaction(
txn_model(
user_id=order.user_id,
amount_cents=order.amount_cents,
balance_after_cents=bal,
biz_type="withdraw_refund",
biz_type=refund_biz,
ref_id=order.out_bill_no,
# 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
remark=(
@@ -492,13 +537,13 @@ def _refund_withdraw(
db.commit()
except IntegrityError:
# 并发退款兜底:唯一退款流水已被另一事务写入时,回滚本事务的加钱和流水,
# 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,金最多退一次。
# 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,金最多退一次。
db.rollback()
refunded_txn_id = db.execute(
select(CashTransaction.id).where(
CashTransaction.user_id == user_id,
CashTransaction.biz_type == "withdraw_refund",
CashTransaction.ref_id == out_bill_no,
select(txn_model.id).where(
txn_model.user_id == user_id,
txn_model.biz_type == refund_biz,
txn_model.ref_id == out_bill_no,
).limit(1)
).scalar_one_or_none()
if refunded_txn_id is None:
@@ -562,6 +607,7 @@ def create_withdraw(
user_id: int,
amount_cents: int,
*,
source: str = "coin_cash",
user_name: str | None = None,
out_bill_no: str | None = None,
) -> WithdrawOrder:
@@ -608,20 +654,23 @@ def create_withdraw(
# 账户须存在(原子扣款的 UPDATE 不会建账户)
get_or_create_account(db, user_id, commit=True)
# #1 原子扣款:余额不足时影响行数为 0
if not _try_deduct_cash(db, user_id, amount_cents):
# #1 原子扣款:余额不足时影响行数为 0(按 source 扣对应账户)
if not _try_deduct_cash(db, user_id, amount_cents, source):
db.rollback()
raise InsufficientCashError
is_invite = source == "invite_cash"
txn_model = InviteCashTransaction if is_invite else CashTransaction
withdraw_biz = "invite_withdraw" if is_invite else "withdraw"
bal = db.execute(
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
select(_balance_col(source)).where(CoinAccount.user_id == user_id)
).scalar_one()
db.add(
CashTransaction(
txn_model(
user_id=user_id,
amount_cents=-amount_cents,
balance_after_cents=bal,
biz_type="withdraw",
biz_type=withdraw_biz,
ref_id=out_bill_no,
remark="提现到微信零钱(待审核)",
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
@@ -631,6 +680,7 @@ def create_withdraw(
user_id=user_id,
out_bill_no=out_bill_no,
amount_cents=amount_cents,
source=source,
user_name=user_name,
status="reviewing",
)
@@ -1007,10 +1057,12 @@ def reconcile_pending_withdraws(db: Session, *, older_than_minutes: int = 15) ->
def list_withdraw_orders(
db: Session, user_id: int, *, limit: int = 20, cursor: int | None = None
db: Session, user_id: int, *, source: str | None = None, limit: int = 20, cursor: int | None = None
) -> tuple[list[WithdrawOrder], int | None]:
"""提现单分页(按 id 倒序,游标式)。"""
"""提现单分页(按 id 倒序,游标式)。source 非空时只返回该账户来源的单(coin_cash / invite_cash)。"""
stmt = select(WithdrawOrder).where(WithdrawOrder.user_id == user_id)
if source is not None:
stmt = stmt.where(WithdrawOrder.source == source)
if cursor is not None:
stmt = stmt.where(WithdrawOrder.id < cursor)
stmt = stmt.order_by(WithdrawOrder.id.desc()).limit(limit)
+16
View File
@@ -57,6 +57,12 @@ class EcpmReportIn(BaseModel):
)
adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle")
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)")
feed_scene: str | None = Field(
None,
max_length=16,
description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);"
"比价与领券共用同一 Draw 代码位,需客户端在各调用点显式标注,供收益报表区分比价/领券;激励视频为空",
)
app_env: str | None = Field(
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
)
@@ -128,6 +134,11 @@ class FeedRewardIn(BaseModel):
"""
client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id")
ad_type: str = Field(
"feed",
max_length=16,
description="广告类型:feed(信息流) / draw(Draw 信息流);默认 feed 兼容旧客户端",
)
ad_session_id: str | None = Field(
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
)
@@ -156,6 +167,11 @@ class FeedRewardIn(BaseModel):
aborted: bool = Field(
False, description="用户中途 ✕ 关闭广告(未走完比价):整场不发,记 closed_early"
)
display_coin: int = Field(
0, ge=0,
description="客户端金币小球**本条显示**的金币(所见即所得):后端直接发这个数,钳到本条最大 1 份"
"满额防刷。缺省 0 = 旧客户端不传,退回服务端「看满 10 秒发整份」",
)
class FeedRewardOut(BaseModel):
+42
View File
@@ -0,0 +1,42 @@
"""客户端埋点上报 schema(批量)。
客户端把设备固定维度(device_id / user_id / oem / os / model / app_ver / channel / sent_at)
放批次外层只传一次,events 列表里每条只带事件维度(event / client_ts / session_id / page /
network / props);服务端展开成多行 AnalyticsEvent 落库( app/repositories/analytics.py)
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class AnalyticsEventIn(BaseModel):
"""单条事件维度。"""
event: str = Field(max_length=64)
client_ts: int = Field(description="端事件发生时间 epoch ms")
session_id: str | None = Field(default=None, max_length=64)
page: str | None = Field(default=None, max_length=64)
network: str | None = Field(default=None, max_length=16)
props: dict[str, str] = Field(default_factory=dict)
class AnalyticsBatchIn(BaseModel):
"""一批上报:公共维度 + 事件列表。"""
# Who(整批共享)
device_id: str = Field(max_length=64)
user_id: int | None = None
# When(本批上报时刻 epoch ms)
sent_at: int | None = None
# How(设备固定维度,整批共享)
oem: str | None = Field(default=None, max_length=32)
os: str | None = Field(default=None, max_length=32)
model: str | None = Field(default=None, max_length=64)
app_ver: str | None = Field(default=None, max_length=32)
channel: str | None = Field(default=None, max_length=32)
events: list[AnalyticsEventIn] = Field(min_length=1, max_length=200)
class AnalyticsIngestOut(BaseModel):
ok: bool = True
received: int
+12
View File
@@ -67,10 +67,22 @@ class JverifyLoginRequest(BaseModel):
)
class WechatLoginRequest(BaseModel):
code: str = Field(..., min_length=1, description="微信 SDK SendAuth.Resp 返回的一次性授权 code")
device_id: str = Field(
"", max_length=64,
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
)
# ===== 短信验证码 =====
class SmsSendRequest(BaseModel):
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
device_id: str = Field(
"", max_length=64,
description="硬件级设备标识(Android ANDROID_ID),用于发码防刷按 设备+IP 限流;空=按 IP 聚一桶",
)
class SmsSendResponse(BaseModel):
+31 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
# ===== 上报请求 =====
@@ -21,8 +21,36 @@ class ComparisonItemIn(BaseModel):
name: str
qty: int = 1
# specs 仅供比价记录展示(admin-web 详情 / app 记录页都按字符串数组渲染并 join)。
# pricebot(2026-06-27 嵌套规格统一)起 calibration 的 specs 可能是规格对象
# [{name, qty, sub_specs}] 而非字符串 → 下面的 before-validator 统一拍平成可读字符串,
# 兼容新旧两种输入、保持 list[str] 契约不变(下游零改动)。
# ⚠️ 两个坑都踩过, 必须"拍平"而非别的: ① 直接声明 list[str] 不拍平 → 对象 specs 整条
# 422 被拒、不入库(同下方 platform_results list→dict 同类事故); ② 仅放宽成裸 list 又会让
# 下游 join 出 "[object Object]"/对象 toString 的乱码。
specs: list[str] | None = None
@field_validator("specs", mode="before")
@classmethod
def _flatten_specs(cls, v: object) -> object:
"""pricebot 规格对象 [{name, qty, sub_specs}] → 可读字符串数组; 字符串元素原样保留;
list 原样交还( pydantic 照常报类型错)嵌套规格拼成 '主项(子1,子2)'"""
if not isinstance(v, list):
return v
out: list[str] = []
for it in v:
if isinstance(it, str):
s = it
elif isinstance(it, dict):
name = str(it.get("name") or "").strip()
subs = [str(x).strip() for x in (it.get("sub_specs") or []) if str(x).strip()]
s = f"{name}({','.join(subs)})" if name and subs else (name or ",".join(subs))
else:
continue
if s:
out.append(s)
return out
class AppliedCouponIn(BaseModel):
"""单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。"""
@@ -95,6 +123,7 @@ class ComparisonRecordIn(BaseModel):
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
# 时分秒前端拼不出,必须由后端透传)。
trace_url: str | None = Field(None, description="本次比价公网调试链接")
total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)")
# ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)=====
# 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。
@@ -144,6 +173,7 @@ class ComparisonRecordOut(BaseModel):
items: list = []
comparison_results: list = []
skipped_dish_names: list = []
total_ms: int | None = None
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
# 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。
ordered: bool = False
+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
+6 -1
View File
@@ -10,7 +10,12 @@ class InviteInfoOut(BaseModel):
invite_code: str # 我的邀请码
share_url: str # 落地页链接(含 ?ref=),前端据此生成二维码 + 复制分享
invited_count: int # 已成功邀请人数
coins_earned: int # 累计从邀请获得的金币
coins_earned: int # 累计从邀请获得的金币(v1 口径;v2 邀请人改发奖励金)
reward_balance_cents: int = 0 # v2 可提现邀请奖励金(分)
reward_withdrawn_cents: int = 0 # v2 累计提现成功的邀请奖励金(分)
countdown_days_left: int = 7 # v2 本轮剩余天数(7 天 1 轮)
countdown_is_fresh_round: bool = False # 是否刚进入新一轮(非首轮第1天)
countdown_text: str = "" # 倒计时展示文案(前端直接显示,新轮含换行)
class LandingTrackIn(BaseModel):
+2 -2
View File
@@ -38,8 +38,8 @@ class AdConfigPublicOut(BaseModel):
app_id: str # 穿山甲应用ID(改了客户端需冷启才生效,SDK init 一次性读)
reward_code_id: str # 福利页激励视频位
compare_feed_code_id: str # 比价信息流
coupon_feed_code_id: str # 领券信息流位
compare_draw_code_id: str # 比价 Draw 代码
coupon_draw_code_id: str # 领券 Draw 代码位(比价/领券共用同一位,靠 feed_scene 区分收益)
reward_enabled: bool # 福利激励视频开关
compare_ad_enabled: bool # 比价广告开关
coupon_ad_enabled: bool # 领券广告开关
+3
View File
@@ -16,6 +16,7 @@ class CoinAccountOut(BaseModel):
coin_balance: int = Field(..., description="当前金币余额")
cash_balance_cents: int = Field(..., description="当前现金余额(分)")
invite_cash_balance_cents: int = Field(0, description="邀请奖励金余额(分,与现金隔离)")
total_coin_earned: int = Field(..., description="累计赚取金币")
@@ -123,6 +124,7 @@ class UnbindWechatResultOut(BaseModel):
class WithdrawRequest(BaseModel):
amount_cents: int = Field(..., gt=0, description="提现金额(分)")
source: str = Field("coin_cash", description="提现账户:coin_cash(金币现金) / invite_cash(邀请奖励金)")
user_name: str | None = Field(None, description="实名(达额时微信要求,可空)")
out_bill_no: str | None = Field(
None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成"
@@ -166,6 +168,7 @@ class WithdrawOrderOut(BaseModel):
id: int
out_bill_no: str
amount_cents: int
source: str = Field("coin_cash", description="提现账户:coin_cash / invite_cash")
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
wechat_state: str | None = None
fail_reason: str | None = None
+224 -117
View File
@@ -8,122 +8,231 @@
<style>
* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html,body { height:100%; }
button { border:0; background:none; color:inherit; font:inherit; cursor:pointer; }
body {
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
background:linear-gradient(165deg,#FF7A3D 0%,#FF3B30 52%,#E0245E 100%);
color:#fff; min-height:100%; display:flex; flex-direction:column;
align-items:center; justify-content:center; padding:40px 26px; text-align:center;
overflow-x:hidden;
min-height:100vh;
display:flex; align-items:center; justify-content:center;
background:#000;
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",sans-serif;
}
.logo {
width:104px; height:104px; border-radius:26px; background:#fff;
display:flex; align-items:center; justify-content:center; font-size:52px;
box-shadow:0 14px 34px rgba(0,0,0,.22); margin-bottom:24px;
/* 设备框:桌面预览成 375×667 卡片;真机(≤430)铺满全屏 */
.device {
position:relative; flex:0 0 auto;
width:375px; height:667px;
overflow:hidden; border-radius:32px;
background:#FFF4CD;
box-shadow:0 20px 60px rgba(0,0,0,.5);
color:#fff;
}
h1 { font-size:30px; font-weight:800; letter-spacing:1px; }
.slogan { margin-top:12px; font-size:16px; line-height:1.7; opacity:.95; max-width:300px; }
.feats { margin-top:26px; display:flex; flex-direction:column; gap:12px; width:100%; max-width:320px; }
.feat { background:rgba(255,255,255,.16); border-radius:14px; padding:13px 16px; font-size:15px; display:flex; align-items:center; gap:10px; }
.feat b { font-weight:700; }
.btn {
margin-top:34px; width:100%; max-width:320px; border:none; cursor:pointer;
background:#fff; color:#FF3B30; font-size:19px; font-weight:800;
padding:17px 0; border-radius:999px; box-shadow:0 10px 26px rgba(0,0,0,.22);
display:flex; align-items:center; justify-content:center; gap:9px;
.download-landing {
position:absolute; inset:0; z-index:0; overflow:hidden;
background:
url('coupon-page-bg.png') center center / cover no-repeat,
#FFF4CD;
color:#1A1A1A; text-align:center;
}
.download-content {
position:relative; z-index:1; height:100%;
padding:82px 20px 0;
display:flex; flex-direction:column; align-items:center; overflow:hidden;
}
.brand-lockup {
display:flex; align-items:center; justify-content:center; gap:16px;
}
.ad-logo {
width:53px; height:53px; border-radius:13px; display:block;
box-shadow:0 10px 24px rgba(255,179,0,.24);
}
.ad-title {
color:#1A1A1A; font-size:32px; font-weight:800; line-height:1.15;
letter-spacing:0; white-space:nowrap;
}
.ad-subtitle {
margin-top:22px; max-width:100%;
color:#000; font-size:18px; font-weight:400; line-height:1.25;
display:flex; align-items:center; justify-content:center; gap:10px; white-space:nowrap;
}
.ad-subtitle::before, .ad-subtitle::after {
content:""; width:5px; height:5px; border-radius:50%; background:#000; flex:0 0 auto;
}
.bottom-area {
position:relative; z-index:2; width:100%; max-width:266px;
margin-top:11px;
display:grid; grid-template-columns:1fr; justify-items:stretch; align-content:center; gap:10px;
}
.download-btn {
width:100%; height:39px; border-radius:22px; color:#1A1A1A;
font-family:inherit; font-size:14px; line-height:1; font-weight:700; letter-spacing:0;
display:flex; align-items:center; justify-content:center;
}
.download-btn.primary {
background:linear-gradient(180deg,#FFE066 0%,#FFC400 100%);
box-shadow:inset 0 1px 0 rgba(255,255,255,.82), 0 7px 18px rgba(255,179,0,.24);
}
.download-btn.secondary {
background:#fff; border:1px solid #DDD; color:#1A1A1A; font-size:13px;
box-shadow:0 2px 8px rgba(122,79,0,.08);
}
.download-btn:active { transform:translateY(1px); }
/* 底部两条卖点文案,压在背景插画两张卡片下方 */
/* 卖点卡:CSS 实体卡片(白底+图标),不再靠底图死框,字自适应(对齐 WeChat.html PR siyi 改版)*/
.download-feature-card {
position:absolute; z-index:2; pointer-events:none;
height:62px; padding:0 10px; border-radius:22px;
background:#FFFAEE;
box-shadow:inset 0 1px 0 rgba(255,255,255,.86), 0 6px 14px rgba(122,79,0,.08);
display:flex; align-items:center; gap:8px; color:#1A1A1A;
}
.download-feature-card.left { left:24px; top:576px; width:146px; }
.download-feature-card.right { left:199px; top:576px; width:156px; }
.download-feature-icon {
width:27px; height:27px; flex:0 0 auto; display:block; color:#FFAE00;
}
.download-feature-icon svg {
display:block; width:100%; height:100%; filter:drop-shadow(0 1px 0 rgba(255,255,255,.7));
}
.download-feature-copy {
min-width:0; flex:1 1 auto; text-align:left; white-space:nowrap; letter-spacing:0;
}
.download-feature-title {
display:block; font-size:13px; font-weight:800; line-height:1.12; letter-spacing:0;
}
.download-feature-desc {
display:block; margin-top:5px; color:#5A3A00; font-size:10px; font-weight:400; line-height:1.1;
}
.btn:active { transform:translateY(1px); opacity:.92; }
.hint { margin-top:16px; font-size:13px; opacity:.85; }
.foot { margin-top:30px; font-size:12px; opacity:.6; line-height:1.6; max-width:320px; }
/* 微信内"去浏览器打开"引导蒙层 */
#wxmask {
display:none; position:fixed; inset:0; z-index:9999;
background:rgba(0,0,0,.86); padding:18px;
.download-guide-layer {
position:absolute; inset:0; z-index:30; display:none;
background:rgba(0,0,0,.85); color:#fff; /* 半透明黑:透出底层(新版)下载页,隐约可见 */
}
.download-guide-layer.show { display:block; }
.download-guide-arrow {
position:absolute; top:17px; right:9px; width:80px; height:60px;
}
.download-guide-arrow-svg {
display:block; width:100%; height:100%; overflow:hidden; shape-rendering:geometricPrecision;
}
.download-guide-title {
position:absolute; top:129px; right:16px; width:218px; margin:0;
color:#fff; font-size:17px; font-weight:800; line-height:1.32; letter-spacing:0;
text-align:right; text-shadow:0 2px 8px rgba(0,0,0,.36);
}
.download-guide-title .guide-dots { color:#FFD95A; letter-spacing:4px; }
.download-guide-title .guide-highlight { color:#FFD95A; white-space:nowrap; }
.download-guide-title .guide-final {
display:block; margin-top:10px; color:rgba(255,255,255,.94);
font-size:13px; font-weight:700; line-height:1.38;
}
.download-guide-title .guide-target { color:#FFD95A; white-space:nowrap; }
.guide-dismiss {
position:absolute; bottom:30px; left:0; right:0; text-align:center;
font-size:14px; color:#fff; opacity:.75;
}
.toast {
position:absolute; left:50%; bottom:118px; z-index:20;
transform:translateX(-50%) translateY(12px);
padding:9px 14px; border-radius:12px; background:rgba(0,0,0,.78);
color:#fff; font-size:14px; opacity:0; pointer-events:none;
transition:opacity .2s ease, transform .2s ease;
}
.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
@media (max-width:430px) {
/* 真机:宽满屏、高按 375:667 锁比例(不拉伸变形),顶对齐、底部留白用底色填。
这样底图(含价格卡)与卖点卡片同处 667 坐标基准,卡片用回 top:576,不再相互错位/遮挡。 */
body { align-items:flex-start; background:#FFF4CD; }
.device { width:100vw; height:calc(100vw * 667 / 375); border-radius:0; box-shadow:none; }
}
#wxmask.show { display:block; }
.arrow { position:absolute; top:8px; right:14px; width:120px; }
.wxtip { position:absolute; top:150px; right:18px; left:18px; text-align:right; }
.wxtip .big { font-size:21px; font-weight:800; line-height:1.5; }
.wxtip .big em { color:#FFD24D; font-style:normal; }
.wxtip .sub { margin-top:14px; font-size:15px; line-height:1.8; opacity:.9; }
.wxsteps { margin-top:26px; text-align:left; background:rgba(255,255,255,.1); border-radius:14px; padding:18px 18px; font-size:15px; line-height:2; }
.wxsteps .n { display:inline-block; width:22px; height:22px; line-height:22px; text-align:center; border-radius:50%; background:#FFD24D; color:#333; font-weight:800; font-size:13px; margin-right:8px; }
.closebar { position:absolute; bottom:30px; left:0; right:0; text-align:center; font-size:14px; opacity:.7; }
</style>
</head>
<body>
<div class="logo">🛒</div>
<h1>傻瓜比价</h1>
<div class="slogan">买什么都先比一比<br>自动帮你找全网最低价</div>
<div class="feats">
<div class="feat">🍔 <span>点外卖前一键比价,<b>美团/京东/淘宝</b>到手价一目了然</span></div>
<div class="feat">🎟️ <span>自动领遍各平台<b>红包券</b>,能省的一分不漏</span></div>
<div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div>
</div>
<button class="btn" id="dlbtn">🏪 打开应用商店下载</button>
<div class="hint" id="hint">Android 安卓版 · 应用商店安全下载</div>
<div class="foot">
将前往应用商店下载,安全放心。<br>
本页为内部测试页。
</div>
<!-- 微信内引导:跳出微信去浏览器 -->
<div id="wxmask">
<svg class="arrow" viewBox="0 0 120 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M30 120 C 30 70, 55 40, 95 28" stroke="#FFD24D" stroke-width="6" stroke-linecap="round" fill="none" stroke-dasharray="2 13"/>
<path d="M95 28 L 78 30 M95 28 L 92 46" stroke="#FFD24D" stroke-width="6" stroke-linecap="round"/>
</svg>
<div class="wxtip">
<div class="big">点击右上角 <em>···</em><br>选择「<em>在浏览器打开</em></div>
<div class="sub">微信里无法直接下载安装包<br>需在系统浏览器中完成下载</div>
<div class="wxsteps">
<div><span class="n">1</span>点右上角的 ··· 菜单</div>
<div><span class="n">2</span>选择「在浏览器打开」</div>
<div><span class="n">3</span>在浏览器里按提示去应用商店下载</div>
<main class="device" aria-label="傻瓜比价下载页">
<section class="download-landing" aria-label="傻瓜比价下载页">
<div class="download-content">
<div class="brand-lockup">
<img class="ad-logo" src="sb-brand.png" alt="傻瓜比价">
<h1 class="ad-title">傻瓜比价</h1>
</div>
<p class="ad-subtitle">跨平台比价,用傻瓜</p>
<div class="bottom-area" aria-label="下载入口">
<button class="download-btn primary" id="dlbtn" type="button">应用商店下载</button>
<button class="download-btn secondary" id="dlbtn2" type="button">官网下载</button>
</div>
</div>
<div class="closebar" id="wxclose">我知道了 ✕</div>
<div class="download-feature-card left" aria-label="优惠券轻松领,羊毛全都不错过">
<span class="download-feature-icon" aria-hidden="true">
<svg viewBox="0 0 40 32" focusable="false">
<path d="M5 5h30a3 3 0 0 1 3 3v5.2a4.8 4.8 0 0 0 0 9.6V24a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3v-1.2a4.8 4.8 0 0 0 0-9.6V8a3 3 0 0 1 3-3Z" fill="currentColor"/>
<path d="M20 10v12" fill="none" stroke="#FFF7CF" stroke-width="3" stroke-linecap="round"/>
</svg>
</span>
<span class="download-feature-copy">
<span class="download-feature-title">优惠券轻松领</span>
<span class="download-feature-desc">羊毛全都不错过</span>
</span>
</div>
<div class="download-feature-card right" aria-label="一键全网找底价,再也不用费力切屏">
<span class="download-feature-icon" aria-hidden="true">
<svg viewBox="0 0 40 32" focusable="false">
<rect x="5" y="16" width="8" height="11" rx="2" fill="currentColor"/>
<rect x="16" y="9" width="8" height="18" rx="2" fill="currentColor"/>
<rect x="27" y="3" width="8" height="24" rx="2" fill="currentColor"/>
</svg>
</span>
<span class="download-feature-copy">
<span class="download-feature-title">一键全网找底价</span>
<span class="download-feature-desc">再也不用费力切屏</span>
</span>
</div>
</section>
<!-- 微信内引导:跳出微信去浏览器 -->
<div class="download-guide-layer" id="wxGuide" role="dialog" aria-modal="true" aria-labelledby="wxGuideTitle">
<div class="download-guide-arrow" aria-hidden="true">
<svg class="download-guide-arrow-svg" viewBox="0 0 80 60" focusable="false">
<path d="M0 60 C16 32 39 15 66 15" fill="none" stroke="#FFD95A" stroke-width="5.5" stroke-linecap="round"/>
<path d="M59 5 L77 14 L63 31" fill="none" stroke="#FFD95A" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h2 class="download-guide-title" id="wxGuideTitle">
点击右上角 <span class="guide-dots">···</span><br>
选择「<span class="guide-highlight">在浏览器打开</span>
<span class="guide-final">在浏览器里按提示<span class="guide-target">去应用商店下载</span></span>
</h2>
<div class="guide-dismiss" id="wxGuideDismiss">我知道了 ✕</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</main>
<script>
// 应用商店跳转:用包名(App 唯一标识 = 身份证号)定位到傻瓜比价的商店下载页
// 主:market:// 唤起手机自带应用市场(华为/小米/OV);兜底:应用宝网页(任何浏览器都能开)
// ===== 应用商店跳转用包名App 唯一标识定位到傻瓜比价的商店下载页 =====
// 主market:// 唤起手机自带应用市场华为/小米/OV);兜底应用宝网页任何浏览器都能开
var PKG = "com.jishisongfu.shaguabijia";
var MARKET_URL = "market://details?id=" + PKG;
var YYB_URL = "https://a.app.qq.com/o/simple.jsp?pkgname=" + PKG;
var ua = navigator.userAgent || "";
var isWeChat = /MicroMessenger/i.test(ua);
var isIOS = /iPhone|iPad|iPod/i.test(ua);
var isAndroid = /Android/i.test(ua);
var ref = new URLSearchParams(location.search).get("ref"); // 邀请码(来自二维码 URL ?ref=)
var ref = new URLSearchParams(location.search).get("ref"); // 邀请码(来自二维码 URL ?ref=)
// 【任务 3】指纹归因兜底:页面加载即上报访问者指纹,后端存 invite_fingerprint 表
// 当 APK 首启读剪贴板失败(被覆盖)时,客户端用 (IP+屏幕+UA 解析的手机型) 反查
// 本表 7 天内最近一条匹配 → 撞库出原邀请人 → 走原 bind 流程。
// 任何失败都 silent(不影响下载主流程);后端 invalid_code/no_ip 也只返 200。
// ===== 指纹归因兜底页面加载即上报访问者指纹后端存 invite_fingerprint 表 =====
// 当 APK 首启读剪贴板失败被覆盖)时,客户端用 (IP+屏幕+UA 机型) 反查 7 天内最近一条 → 撞出原邀请人。
// 任何失败都 silent,不影响下载主流程。
if (ref) {
// screen 报【物理像素】= CSS 像素 × devicePixelRatio,跟 Android dm.widthPixels(物理像素)对齐
// 不同设备 DPR 不同(常见 2/2.5/3/3.5),CSS 像素直接报会跟客户端不对齐 → 撞不上库。
// screen 报【物理像素】= CSS 像素 × devicePixelRatio跟 Android dm.widthPixels 对齐,否则撞不上库
var _dpr = window.devicePixelRatio || 1;
var _sw = Math.round(screen.width * _dpr);
var _sh = Math.round(screen.height * _dpr);
fetch("/api/v1/invite/landing-track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ref: ref,
screen: _sw + "x" + _sh,
// IP / UA 服务端从 HTTP 头自动拿,无需 JS 上报
}),
}).catch(function () {}); // silent,绝不阻断下载
body: JSON.stringify({ ref: ref, screen: _sw + "x" + _sh }),
}).catch(function () {}); // silent,绝不阻断下载
}
// 把邀请码写进剪贴板,APK 首启读出完成归因(deferred deeplink 的关键一步)。
// 浏览器要求:必须在用户点击手势里调用 + HTTPS 下才允许写。
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
// ===== 把邀请码写进剪贴板APK 首启读出完成归因deferred deeplink=====
// 浏览器要求必须在用户点击手势里调用 + HTTPS 下才允许写。
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
try {
var ta = document.createElement("textarea");
ta.value = payload; ta.style.position = "fixed"; ta.style.top = "-1000px"; ta.style.opacity = "0";
@@ -131,50 +240,48 @@
document.execCommand("copy"); document.body.removeChild(ta);
} catch (e) {}
}
function copyInviteCode() { // 返回 Promise(完成后才下载,避免异步写入被打断)
function copyInviteCode() { // 返回 Promise完成后才下载避免异步写入被打断
if (!ref) return Promise.resolve();
var payload = "SGBJ_INVITE:" + ref;
legacyCopy(payload); // 同步兜底:在用户手势内立刻 execCommand 写一次(最可靠)
legacyCopy(payload); // 同步兜底在用户手势内立刻 execCommand 写一次最可靠
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(payload).catch(function () {}); // 现代 API 锦上添花,失败无妨
return navigator.clipboard.writeText(payload).catch(function () {});
}
return Promise.resolve();
}
// 跳应用商店:先尝试 market:// 唤起手机自带商店;若 2.5s 内页面没切后台
//(= 没有商店接管 market://),兜底跳应用宝网页下载页,不让用户卡死。
// 跳应用商店:先 market:// 唤起自带商店2.5s 内页面没切后台 → 兜底跳应用宝网页,不让用户卡死。
function openStore() {
var jumped = false;
function markJumped() { jumped = true; } // 页面切到后台 = 商店已唤起
document.addEventListener("visibilitychange", function () {
if (document.hidden) jumped = true;
});
window.addEventListener("pagehide", markJumped);
window.addEventListener("blur", markJumped);
window.location.href = MARKET_URL; // 唤起自带应用市场
setTimeout(function () {
if (!jumped) window.location.href = YYB_URL; // 没唤起 → 应用宝网页兜底
}, 2500);
document.addEventListener("visibilitychange", function () { if (document.hidden) jumped = true; });
window.addEventListener("pagehide", function () { jumped = true; });
window.addEventListener("blur", function () { jumped = true; });
window.location.href = MARKET_URL;
setTimeout(function () { if (!jumped) window.location.href = YYB_URL; }, 2500);
}
var hint = document.getElementById("hint");
if (isIOS) hint.textContent = "检测到 iPhone · iOS 版请前往 App Store";
// ===== 微信内引导蒙层 =====
var wxGuide = document.getElementById("wxGuide");
function showWxGuide() { wxGuide.classList.add("show"); }
function hideWxGuide() { wxGuide.classList.remove("show"); }
document.getElementById("wxGuideDismiss").addEventListener("click", hideWxGuide);
if (isWeChat) showWxGuide(); // 微信里一进页面就提示去浏览器(微信内下载必被拦)
var mask = document.getElementById("wxmask");
function showMask(){ mask.classList.add("show"); }
function hideMask(){ mask.classList.remove("show"); }
document.getElementById("wxclose").addEventListener("click", hideMask);
function showToast(text) {
var toast = document.getElementById("toast");
toast.textContent = text; toast.classList.add("show");
clearTimeout(showToast.timer);
showToast.timer = setTimeout(function () { toast.classList.remove("show"); }, 1400);
}
// 微信里一进页面就提示去浏览器打开(下载在微信内必被拦)
if (isWeChat) showMask();
document.getElementById("dlbtn").addEventListener("click", function(){
if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
// 安卓浏览器:先把邀请码写进剪贴板(legacyCopy 同步写、最可靠),再跳应用商店。
// 剪贴板供 App 首启归因;链路长易丢时由指纹兜底(已上报 landing-track)接住。
copyInviteCode();
// ===== 下载按钮:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
function handleDownload() {
if (isWeChat) { showWxGuide(); return; }
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
copyInviteCode(); // 先把邀请码写进剪贴板(供 App 首启归因),链路丢了还有 landing-track 指纹兜底
openStore();
});
}
document.getElementById("dlbtn").addEventListener("click", handleDownload);
document.getElementById("dlbtn2").addEventListener("click", handleDownload);
</script>
</body>
</html>
+26 -5
View File
@@ -27,8 +27,11 @@
| `date_to` | string | =`date_from` | 结束日 北京时间 `YYYY-MM-DD`,**闭区间**;单日时与 `date_from` 相同 |
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
| `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 |
| `feed_scene` | string | 全部 | `comparison`(比价)/ `coupon`(领券)/ `welfare`(福利);**全局筛选**,同时作用于明细 / 合计 / `daily`·`hourly` 趋势;不传=全部场景 |
| `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** |
| `limit` | int(1~1000) | 500 | **展示**明细组数(截断;`total`/`total_*`/`daily` 按全量统计不受影响) |
| `limit` | int(1~1000) | 500 | **每页条数**(分页大小);`total`/`total_*`/`daily`/`hourly` 按全量统计不受分页影响 |
| `offset` | int(≥0) | 0 | 分页偏移(已跳过条数)=(页码−1)×`limit` |
| `sort` | string | `time` | 明细排序:`time`=按时间倒序(新→旧) / `ecpm`=按 eCPM 数值倒序 |
约束:`date_to` 不早于 `date_from`、区间最长 **92 天**、日期须 `YYYY-MM-DD`,否则 `422`
@@ -36,15 +39,18 @@
| 字段 | 类型 | 说明 |
|---|---|---|
| `date_from` / `date_to` | string | 报表起止日期(闭区间) |
| `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受 `limit` 影响) |
| `total` | int | 聚合组**总数**(全量,不受 `limit` 影响) |
| `truncated` | bool | 明细是否被 `limit` 截断 |
| `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受分页影响) |
| `hourly` | `AdRevenueHourly[]` | 按小时汇总序列(全量,供按小时趋势图;**仅 `granularity=hour` 时非空**;不受分页影响) |
| `type_stats` | `{[ad_type]: AdRevenueTypeStat}` | 按广告类型(`ad_type`)小计(全量);前端取 `draw` / `reward_video` 做分类大盘 |
| `dau` | int \| null | 今日活跃用户数(复用大盘口径 `last_login_at`,今日登录过);**仅查询=今日单天时有值**,历史/多天为 `null` |
| `total` | int | 当前筛选下的**分页总条数**(全量,不受分页影响;= 前端分页器 total) |
| `truncated` | bool | 当前页之后是否还有更多事件(`len(events) > offset + limit`) |
| `total_impressions` | int | 全量展示条数合计 |
| `total_revenue_yuan` | float | 全量收益合计(元) |
| `total_expected_coin` | int | 全量应发金币合计 |
| `total_actual_coin` | int | 全量实发金币合计 |
| `mismatch_count` | int | 应发≠实发的组数(=0 说明全部按公式发放) |
| `items` | `AdRevenueRow[]` | 聚合明细(按 日期→用户→类型→代码位 排序) |
| `items` | `AdRevenueRow[]` | 逐条广告事件(**按时间倒序:新→旧**);`limit`/`offset` 对全量做分页切片,返回当前页 |
### AdRevenueDaily(`daily[]` — 按天趋势)
| 字段 | 类型 | 说明 |
@@ -55,6 +61,21 @@
| `expected_coin` | int | 当天应发金币合计 |
| `actual_coin` | int | 当天实发金币合计 |
### AdRevenueHourly(`hourly[]` — 按小时趋势,仅 `granularity=hour` 时非空)
| 字段 | 类型 | 说明 |
|---|---|---|
| `hour` | int | 北京时间小时 023 |
| `impressions` | int | 该小时展示条数合计 |
| `revenue_yuan` | float | 该小时预估收益合计(元) |
| `expected_coin` | int | 该小时应发金币合计 |
| `actual_coin` | int | 该小时实发金币合计 |
### AdRevenueTypeStat(`type_stats[ad_type]` — 分广告类型小计,供大盘第二行)
| 字段 | 类型 | 说明 |
|---|---|---|
| `impressions` | int | 该类型展示条数合计 |
| `revenue_yuan` | float | 该类型预估收益合计(元);eCPM 由前端用 收益÷展示×1000 算 |
### AdRevenueRow(`items[]`)
| 字段 | 类型 | 说明 |
|---|---|---|
@@ -0,0 +1,820 @@
# 傻瓜比价 数据表字典(产品参考)
> 面向产品/运营的全量数据表说明,按业务域分组。来源:`app/models/` 下全部 ORM 模型(共 **40 张表**)。
> 字段名为数据库真实列名;本文档由模型源码整理,后端改表后以模型为准。
## 通用约定(先读)
1. **金额单位**:所有 `*_cents` 字段单位是「**分**」,展示时 ÷100 为「元」。金币(coin)是「个数」,另有自己的兑换比例。
2. **时间**:存储统一为带时区的 UTC;展示按北京时间。部分「按天聚合」字段直接存北京日期串 `'YYYY-MM-DD'` 或日期类型,避免跨时区比较。
3. **JSON 字段**:线上 PostgreSQL 用 JSONB、本地/测试 SQLite 用普通 JSON,业务无差别。
4. **device_id 有两种含义**(重要):
- **per-install**(存手机本地,卸载重装会变):领券、比价、设备存活用。
- **硬件级稳定**(ANDROID_ID,卸载重装不变):新手引导用。
5. **审计字段**:多数表有 `created_at`(创建时间)/ `updated_at`(更新时间),下文仅在有特殊含义时展开。
6. **约束标记**:说明里 **主键** / **唯一** / **外键→表** / **可空** 表示该列的关键约束。
---
## 表总览
| 业务域 | 表名 | 中文名 | 一句话用途 |
|---|---|---|---|
| 用户 | `user` | 用户 | App 用户主表 |
| 钱包 | `coin_account` | 金币账户 | 每用户余额快照(金币/现金) |
| 钱包 | `coin_transaction` | 金币流水 | 金币每笔变动账本 |
| 钱包 | `cash_transaction` | 现金流水 | 现金(分)每笔变动账本 |
| 钱包 | `withdraw_order` | 提现单 | 现金→微信零钱提现 |
| 钱包 | `wechat_transfer_authorization` | 微信转账免确认授权 | 用户授权后转账免逐笔确认 |
| 激励 | `signin_record` | 签到记录 | 每日签到 |
| 激励 | `signin_boost_record` | 签到膨胀记录 | 签到后看广告翻倍补发 |
| 激励 | `user_task` | 一次性任务完成 | 只能领一次的任务 |
| 激励 | `comparison_milestone_claim` | 比价战绩领取 | 比价次数里程碑奖励 |
| 比价 | `comparison_record` | 比价记录 | 用户视角「我的比价记录」 |
| 比价 | `savings_record` | 省钱记录 | 「累计帮你省了」数据源 |
| 比价 | `price_observation` | 价格观测 | 平台/门店价格事实沉淀(后端) |
| 比价 | `store_mapping` | 平台店铺映射 | 跨平台同店身份映射(后端) |
| 比价 | `price_report` | 上报更低价 | 用户举报更低价、审核奖励 |
| 广告 | `ad_reward_record` | 激励视频发奖 | 穿山甲 S2S 回调发金币 |
| 广告 | `ad_ecpm_record` | 广告 eCPM 上报 | 展示收益统计/对账 |
| 广告 | `ad_feed_reward_record` | 信息流广告奖励 | 比价等待/领券信息流广告金币 |
| 广告 | `ad_watch_log` | 看广告时长 | 观看秒数记录(旧版/排查) |
| 领券 | `coupon_claim_record` | 领券记录 | App 内自动领券每张券结果 |
| 领券 | `coupon_daily_completion` | 领券每日完成 | 今日是否跑完整轮领券(置灰源) |
| 领券 | `coupon_prompt_engagement` | 领券弹窗频控 | 控制领券引导窗弹不弹 |
| CPS | `cps_group` | CPS 推广群 | 微信群(渠道追踪位 sid) |
| CPS | `cps_activity` | CPS 活动池 | 可推广的券(美团/淘宝/京东) |
| CPS | `cps_link` | CPS 群发短链 | `/c/{code}` 短链 |
| CPS | `cps_click` | CPS 点击事件 | 落地页点击/复制 |
| CPS | `cps_order` | CPS 对账订单 | 美团订单/佣金 |
| CPS | `cps_wx_user` | CPS 落地页微信用户 | 群发落地页授权用户 |
| CPS | `meituan_coupon` | 美团 CPS 券缓存 | 定时抓券本地缓存排序 |
| 邀请 | `invite_relation` | 邀请关系 | 邀请绑定与发奖 |
| 邀请 | `invite_fingerprint` | 邀请指纹归因 | 剪贴板失效时的兜底归因 |
| 设备 | `device_liveness` | 设备无障碍存活 | 无障碍掉线检测+推送提醒 |
| 设备 | `onboarding_completion` | 新手引导完成 | 引导只跑一次 |
| 反馈 | `feedback` | 用户反馈 | 帮助与反馈 |
| 后台 | `admin_user` | 管理员账号 | 后台账号(角色) |
| 后台 | `admin_audit_log` | 管理员操作审计 | 后台写操作留痕 |
| 配置 | `app_config` | 运营可配置项 | 把硬编码规则挪到 DB |
| 配置 | `ops_marquee_seed` | 首页轮播种子 | 滚动条兜底假数据规则 |
| 配置 | `ops_stat_config` | 首页三统计配置 | 门面数字展示模式 |
| 技术 | `launch_confirm_sample` | 启动确认窗样本 | 比价 agent 兜底样本(内部) |
---
# 一、用户
## `user` — 用户表
App 用户主表。两种登录(极光一键 / 短信验证码)都映射到同一行,按 `phone` 唯一;注册即登录。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| phone | 字符串 | 手机号,**唯一**,登录主键 |
| username | 字符串 | 对外展示账号 ID:11 位纯数字、首位非 1、**唯一**、创建时随机生成、不可变、不参与登录 |
| register_channel | 字符串 | 注册渠道:jverify(极光)/ sms |
| nickname | 字符串 | 昵称,可空 |
| avatar_url | 字符串 | 头像 URL,可空 |
| invite_code | 字符串 | 邀请码,每用户一个稳定短码(懒生成),**唯一**、可空 |
| wechat_openid | 字符串 | 微信 openid(提现到零钱用),**唯一**、可空 |
| wechat_nickname | 字符串 | 微信昵称(提现绑定卡展示),与通用 nickname 分开 |
| wechat_avatar_url | 字符串 | 微信头像,同上 |
| status | 字符串 | 账号状态:active / disabled / deleted |
| debug_trace_enabled | 布尔 | 调试链接权限:开了的用户能看到「复制调试链接」按钮,默认 false |
| created_at | 时间 | 注册时间 |
| last_login_at | 时间 | 最后登录时间 |
---
# 二、钱包
## `coin_account` — 金币账户
一个用户一行,余额快照(读取用)。
| 字段 | 类型 | 说明 |
|---|---|---|
| user_id | 整数 | **主键 + 外键→user** |
| coin_balance | 整数 | 当前金币余额 |
| cash_balance_cents | 金额(分) | 当前现金余额 |
| total_coin_earned | 整数 | 累计赚取金币(只增不减),用于「历史总收益」展示 |
| updated_at | 时间 | 更新时间 |
## `coin_transaction` — 金币流水
金币每笔变动账本,每次余额变动写一笔并记变动后余额,可逐笔对账。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| amount | 整数 | 变动额:正=入账(赚),负=出账(花/兑换) |
| balance_after | 整数 | 本笔变动后金币余额(对账用) |
| biz_type | 字符串 | 业务类型:signin / task_<key> / exchange_out / … |
| ref_id | 字符串 | 关联业务 id(签到日期、任务 key 等),可空;可重复任务按它去重防双发 |
| remark | 字符串 | 备注,可空 |
| created_at | 时间 | 时间 |
## `cash_transaction` — 现金流水
现金(分)每笔变动账本。金币兑现金、提现记这里。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| amount_cents | 金额(分) | 正=入账(兑入),负=出账(提现) |
| balance_after_cents | 金额(分) | 本笔后现金余额 |
| biz_type | 字符串 | 业务类型:exchange_in(兑入)/ withdraw(提现)/ withdraw_refund(提现退回) |
| ref_id | 字符串 | 关联业务 id,可空 |
| remark | 字符串 | 备注,可空 |
| created_at | 时间 | 时间 |
## `withdraw_order` — 提现单
现金 → 微信零钱。状态机:reviewing(待审核)→ pending(打款在途)→ success / failedreviewing →(拒绝)→ rejected(已退款)。同一用户同时只能有一笔进行中。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| out_bill_no | 字符串 | 商户单号(我方生成,微信查单用),**唯一** |
| amount_cents | 金额(分) | 提现金额 |
| user_name | 字符串 | 提现实名(微信达额转账要求),可空 |
| status | 字符串 | reviewing / pending / success / failed / rejected |
| wechat_state | 字符串 | 微信侧原始状态(WAIT_USER_CONFIRM / SUCCESS / FAIL / CANCELLED…),可空 |
| transfer_bill_no | 字符串 | 微信转账单号,可空 |
| package_info | 字符串 | 待用户确认时给 App 拉起确认页的参数,可空 |
| fail_reason | 字符串 | 失败原因,可空 |
| created_at / updated_at | 时间 | 创建 / 更新时间 |
## `wechat_transfer_authorization` — 微信转账免确认授权
用户授权「免确认收款」后,后续转账免逐笔确认。一个用户一条。状态机:pending(待用户确认)→ active(已生效可免确认)→ closed(已关闭需重开)。
| 字段 | 类型 | 说明 |
|---|---|---|
| user_id | 整数 | **主键 + 外键→user** |
| openid | 字符串 | 用户微信 openid |
| out_authorization_no | 字符串 | 商户侧授权单号(我方生成),**唯一** |
| authorization_id | 字符串 | 微信侧授权单号(生效后返回,免确认转账要用),可空 |
| state | 字符串 | pending / active / closed |
| created_at / updated_at | 时间 | 创建 / 更新时间 |
---
# 三、激励(签到 · 任务 · 比价奖励)
## `signin_record` — 签到记录
每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| signin_date | 日期 | 签到日期(北京时间),与 user_id 组成**唯一** |
| cycle_day | 整数 | 1..7,7 天循环里今天第几档(决定发多少金币),断签重置回 1 |
| streak | 整数 | 连续签到天数(不封顶),断签重置回 1 |
| coin_awarded | 整数 | 本次发放金币 |
| created_at | 时间 | 时间 |
## `signin_boost_record` — 签到膨胀记录
签到后看广告「膨胀」翻倍,一天最多一次,补发金额=当天签到原始奖励。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| signin_date | 日期 | 与 user_id 组成**唯一**(防并发重复补发) |
| coin_awarded | 整数 | 补发金币 |
| ad_ref_id | 字符串 | 广告会话/交易号,可空 |
| created_at | 时间 | 时间 |
## `user_task` — 一次性任务完成记录
「打开消息提醒」这类只能领一次的任务,完成写一行,(user_id, task_key) 唯一。可循环任务(签到)走专表。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| task_key | 字符串 | 任务标识,与 user_id 组成**唯一** |
| status | 字符串 | 状态,默认 completed |
| coin_awarded | 整数 | 发放金币 |
| completed_at | 时间 | 完成时间 |
## `comparison_milestone_claim` — 比价战绩里程碑领取
「记录比价战绩」每档(第 1~6 次)只能领一次。解锁进度由成功比价条数决定,本表只记「哪几档已领」。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| milestone | 整数 | 档位序号(1-based),与 user_id 组成**唯一** |
| coin_awarded | 整数 | 发放金币 |
| claimed_at | 时间 | 领取时间 |
---
# 四、比价 · 省钱
## `comparison_record` — 比价记录
用户视角的「我的比价记录」,每完成一次比价客户端登录后上报一条。(user_id, trace_id) 唯一(重试幂等)。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| device_id | 字符串 | 设备号,可空 |
| business_type | 字符串 | food(外卖,当前唯一)/ ecom(电商)/ coupon(领券) |
| trace_id | 字符串 | 比价追踪号,与 user_id 组成**唯一** |
| trace_url | 字符串 | 公网调试链接(按用户 debug 权限决定是否返回),可空 |
| source_platform_id / _name | 字符串 | 源平台(发起比价那家)id / 名 |
| source_package | 字符串 | 源平台 App 包名 |
| source_price_cents | 金额(分) | 源平台价 |
| best_platform_id / _name | 字符串 | 最优(最便宜)平台 id / 名 |
| best_price_cents | 金额(分) | 最优价 |
| best_deeplink | 字符串 | 最优平台深链(「再次比价」直达),可空 |
| saved_amount_cents | 金额(分) | 省下金额=源价−最优价(可为 0/负) |
| is_source_best | 布尔 | 源平台就是最便宜(这次没省到),可空 |
| store_name | 字符串 | 店名 |
| total_dish_count / skipped_dish_count | 整数 | 下单菜数 / 跳过菜数 |
| status | 字符串 | success(有效对比)/ failed |
| information | 字符串 | done 文案:成功的到手价说明 / 失败原因 |
| items | JSON | 下单菜品明细 |
| comparison_results | JSON | 逐平台对比明细(含优惠/名次) |
| skipped_dish_names | JSON | 未找到跳过的菜名 |
| raw_payload | JSON | 客户端原始上报全量(取数兜底),可空 |
| device_model / device_manufacturer | 字符串 | 机型 / 厂商(排障),可空 |
| rom_vendor / rom_name / rom_version | 字符串/整数 | ROM 厂商/名/版本(排障),可空 |
| android_version / android_sdk | 字符串/整数 | 安卓版本,可空 |
| app_version / app_version_code | 字符串/整数 | 我方 App 版本,可空 |
| source_app_version | 字符串 | 源平台 App 版本(排适配失效),可空 |
| longitude / latitude | 小数 | 比价时定位,可空 |
| total_ms / step_count | 整数 | 整场耗时 / agent 步数(排障),可空 |
| llm_call_count / retry_count | 整数 | LLM 调用 / 重试次数,可空 |
| input_tokens / output_tokens | 整数 | LLM 累计 token,可空 |
| llm_calls | JSON | 每次 LLM 调用明细,可空 |
| created_at | 时间 | 时间 |
## `savings_record` — 省钱记录
profile「累计帮你省了」「省钱战绩」的唯一数据源。(user_id, client_event_id) 唯一(真实上报幂等)。当前由 demo seeder 灌入,比价真接入后改上报写入。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| order_amount_cents | 金额(分) | 订单实付金额 |
| saved_amount_cents | 金额(分) | 省下金额 |
| platform | 字符串 | 平台,可空 |
| title | 字符串 | 标题,可空 |
| shop_name | 字符串 | 店铺名(订单卡标题),可空 |
| dishes | JSON | 菜品名列表(前 2 道直接展示) |
| source | 字符串 | demo(演示)/ compare(真实比价上报) |
| original_price_cents | 金额(分) | 源平台原价,可空(真实上报才有) |
| compared_price_cents | 金额(分) | 我们给出的比价价,可空 |
| pay_channel | 字符串 | 支付渠道:wechat / alipay,可空 |
| platform_package | 字符串 | 实际下单平台包名,可空 |
| source_platform_name | 字符串 | 源平台展示名(如「美团」),可空 |
| source_deeplink | 字符串 | 源平台重进链接(预留),可空 |
| client_event_id | 字符串 | 客户端幂等键(UUID),demo 行为空 |
| device_id | 字符串 | 设备号,可空 |
| created_at | 时间 | 时间 |
## `price_observation` — 价格观测(后端数据资产)
平台/门店视角的「价格事实」。比价跑到 done 后由服务端无条件沉淀(与登录无关、匿名也记),是未来「查过同店秒回价」的源头。(trace_id, platform, scope) 唯一。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| observed_at | 时间 | 观测时刻(≈比价 done 时) |
| trace_id | 字符串 | 比价追踪号(溯源+幂等) |
| business_type | 字符串 | food / ecom |
| platform | 字符串 | 平台 |
| platform_store_id | 字符串 | 平台内门店 ID,可空 |
| store_name / city | 字符串 | 门店名 / 城市,可空 |
| geohash / lng / lat | 字符串/小数 | 地理位置,可空 |
| is_source | 布尔 | 是否源平台 |
| scope | 字符串 | order(整单到手价,当前唯一)/ dish(单菜价) |
| price_cents | 金额(分) | 该口径到手价;空=采集失败/打烊 |
| coupon_saved_cents | 金额(分) | 优惠额,可空 |
| coupon_name | 字符串 | 优惠名,可空 |
| store_closed | 字符串 | 打烊原因(非空时无有效价),可空 |
| rank | 整数 | 本次比价全局名次(1=最便宜),可空 |
| dishes | JSON | 菜篮明细,可空 |
| attrs | JSON | 灵活字段兜底,可空 |
| source_device_id / source_user_id | 字符串/整数 | 来源设备 / 用户(不鉴权时为空) |
| confidence | 小数 | 置信度,默认 1.0 |
| created_at | 时间 | 时间 |
## `store_mapping` — 平台店铺映射(后端数据资产)
跨平台「同一家店」在淘宝/美团/京东各自的店铺 id 与店名映射。每次淘宝比价沉淀一行。trace_id 唯一。⚠️ 数据来自 LLM 店铺匹配,可能匹配错。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| id_taobao / name_taobao | 字符串 | 淘宝店铺 idshopId)/ 名,可空 |
| id_meituan / name_meituan | 字符串 | 美团店铺 id(预留稳定 poi)/ 名,可空 |
| id_jd / name_jd | 字符串 | 京东店铺 idstoreId)/ 名,可空 |
| city / geohash / lng / lat | 字符串/小数 | 地理位置,可空 |
| taobao_address | 字符串 | 淘宝门店地址,可空 |
| source_platform | 字符串 | 源平台,可空 |
| business_type | 字符串 | 业务类型,默认 food |
| trace_id | 字符串 | 比价追踪号(溯源+**唯一**) |
| source_device_id / source_user_id | 字符串/整数 | 来源设备 / 用户,可空 |
| taobao_share_url / taobao_resolved_url / taobao_deeplink | 字符串/文本 | 淘宝短链 / 解析 URL / 深链 |
| taobao_deeplink_invalid_at | 时间 | 淘宝深链失效标记(空=有效) |
| meituan_poi_id_str | 字符串 | 美团一次性票据(非稳定主键),可空 |
| meituan_share_url / meituan_resolved_url / meituan_deeplink | 字符串/文本 | 美团短链 / 解析 URL / 深链 |
| jd_vender_id | 字符串 | 京东商家 id(深链要用),可空 |
| jd_share_url / jd_resolved_url / jd_deeplink | 字符串/文本 | 京东短链 / 解析 URL / 深链 |
| jd_deeplink_invalid_at | 时间 | 京东深链失效标记(空=有效) |
| attrs | JSON | 灵活字段兜底,可空 |
| created_at | 时间 | 时间 |
## `price_report` — 上报更低价
用户在比价记录里举报「某平台有更低价」+ 截图,人工审核通过奖励金币。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| comparison_record_id | 整数 | **外键→comparison_record**,关联的比价记录,可空(记录删后保留上报) |
| store_name / dish_summary | 字符串 | 比价记录快照:店名 / 菜品摘要,可空 |
| original_platform_id / _name | 字符串 | 原最低价平台快照 |
| original_price_cents | 金额(分) | 原最低价快照 |
| reported_platform_id / _name | 字符串 | 用户上报的平台 |
| reported_price_cents | 金额(分) | 用户上报的更低价 |
| images | JSON | 截图 URL 列表 |
| status | 字符串 | pending(审核中)/ approved / rejected |
| reject_reason | 字符串 | 驳回原因,可空 |
| reward_coins | 整数 | 通过奖励金币,可空 |
| reviewed_at | 时间 | 审核时间,可空 |
| created_at | 时间 | 提交时间 |
---
# 五、广告变现
> 看广告有三条并列数据流:`ad_reward_record`(后端 S2S 发金币)、`ad_ecpm_record`(前端报展示收益)、`ad_watch_log`(前端报观看时长);外加点位 2 的 `ad_feed_reward_record`(信息流)。
## `ad_reward_record` — 激励视频发奖记录
每条=穿山甲/GroMore 一次服务端激励回调。`trans_id` 唯一做幂等键。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| trans_id | 字符串 | 穿山甲交易号,**唯一**(幂等键) |
| user_id | 整数 | **外键→user** |
| coin | 整数 | 实发金币(超限为 0) |
| status | 字符串 | granted(已发)/ capped(当日超限)/ ecpm_missing(缺 eCPM |
| reward_scene | 字符串 | reward_video(福利页看视频)/ signin_boost(签到膨胀) |
| ad_session_id | 字符串 | 广告会话 id,可空 |
| ecpm_raw | 字符串 | 本次发奖采用的 eCPM 原始值,可空 |
| app_env | 字符串 | 应用环境 prod/test(回填),可空 |
| our_code_id | 字符串 | 我方配置代码位 104xxx(回填),可空 |
| reward_date | 字符串 | 北京日期串,按它统计当日发奖次数 |
| reward_name | 字符串 | 穿山甲上报奖励名(参考),可空 |
| raw | 字符串 | 回调原始参数(审计),可空 |
| created_at | 时间 | 时间 |
## `ad_ecpm_record` — 广告 eCPM 上报
每条=客户端一次广告展示后读到的 eCPM。`ad_session_id` 唯一,把展示收益与奖励完成绑定。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| ad_type | 字符串 | reward_video / draw 等 |
| ad_session_id | 字符串 | 广告会话 id,**唯一**、可空 |
| adn | 字符串 | 实际 ADNpangle / gdt…),可空 |
| slot_id | 字符串 | 底层代码位 rit,可空 |
| app_env | 字符串 | 应用环境 prod/test,可空 |
| our_code_id | 字符串 | 我方配置代码位 104xxx,可空 |
| ecpm_raw | 字符串 | eCPM 原始字符串(单位:分/千次展示) |
| report_date | 字符串 | 北京日期串(按天聚合) |
| created_at | 时间 | 时间 |
## `ad_feed_reward_record` — 信息流广告奖励
点位 2:比价等待 / 领券信息流广告,每满 10 秒累计一份,视频完成一次性入账。`client_event_id` 唯一。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| client_event_id | 字符串 | 客户端事件 id,**唯一**(幂等) |
| ad_session_id | 字符串 | 广告会话 id,可空 |
| user_id | 整数 | **外键→user** |
| reward_date | 字符串 | 北京日期串 |
| duration_seconds | 整数 | 本次展示秒数 |
| unit_count | 整数 | 累计份数 |
| ecpm_raw | 字符串 | eCPM 原始值 |
| adn / slot_id | 字符串 | ADN / 代码位,可空 |
| feed_scene | 字符串 | comparison(比价等待)/ coupon(领券)/ welfare(福利页),可空 |
| trace_id | 字符串 | 比价场景的 trace_id(归属本场比价金币),可空 |
| app_env / our_code_id | 字符串 | 应用环境 / 代码位,可空 |
| coin | 整数 | 实发金币 |
| status | 字符串 | 状态,默认 granted |
| created_at | 时间 | 时间 |
## `ad_watch_log` — 看广告时长记录
每条=客户端上报的一次激励视频观看秒数。当前发奖以次数上限为准,本表保留作旧客户端兼容/排查。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| watch_seconds | 整数 | 本次观看秒数(服务端已夹区间) |
| watch_date | 字符串 | 北京日期串(按天聚合总时长) |
| created_at | 时间 | 时间 |
---
# 六、领券(App 内自动领券)
> 判断维度是 **device_id**per-install,重装会变);日期为北京自然日。
## `coupon_claim_record` — 领券记录
按 (设备, 券, 自然日) 记每张券领取结果,纯沉淀(资产/画像/排查),当前不参与「要不要领」判断。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| device_id | 字符串 | 设备号(与 coupon_id、claim_date 组成**唯一** |
| user_id | 整数 | 登录态用户,可空(不进唯一键) |
| coupon_id | 字符串 | 券 id |
| claim_date | 日期 | 北京自然日 |
| status | 字符串 | success / already_claimed / failed / skipped |
| vendor | 字符串 | 券来源,可空 |
| coupon_name | 字符串 | 券名,可空 |
| claimed_count | 整数 | 领到几张,可空 |
| trace_id | 字符串 | 哪次任务领的(排查),可空 |
| reason | 字符串 | 失败/跳过原因,可空 |
| extra | JSON | 券结构化信息兜底,可空 |
| created_at / updated_at | 时间 | 创建 / 更新时间 |
## `coupon_daily_completion` — 领券每日完成
按 (设备, 自然日) 记「今天是否跑完整轮领券(到 done)」——首页「去领取」卡置灰源。到 done 即算(不管单券成败)。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| device_id | 字符串 | 设备号(与 complete_date 组成**唯一** |
| user_id | 整数 | 登录态用户,可空 |
| complete_date | 日期 | 北京自然日 |
| trace_id | 字符串 | 哪次任务跑到 done(排查),可空 |
| created_at / updated_at | 时间 | 创建 / 更新时间 |
## `coupon_prompt_engagement` — 领券弹窗频控
按 (设备, App, 自然日) 记「今天这个 App 是否对领券引导窗表达过意向」——弹窗频控源。美团/淘宝/京东各自独立。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| device_id | 字符串 | 设备号(与 package、engage_date 组成**唯一** |
| package | 字符串 | 目标 App 包名(频控维度,各 App 独立) |
| user_id | 整数 | 登录态用户,可空 |
| engage_date | 日期 | 北京自然日 |
| engage_type | 字符串 | claim_started(点一键领取)/ dismissed(拒绝/关闭)/ shown(自动弹出) |
| created_at / updated_at | 时间 | 创建 / 更新时间 |
---
# 七、CPS 群发分发(微信群发券)
## `cps_group` — CPS 推广群
每个微信群一条;`sid` 是美团联盟渠道追踪位(仅美团需要)。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| sid | 字符串 | 渠道追踪位,**唯一**、可空(纯淘宝/京东群无) |
| name | 字符串 | 群名 |
| platforms | JSON | 该群发哪些平台:["meituan","taobao","jd"] |
| member_count | 整数 | 群人数(转化率分母),可空 |
| status | 字符串 | active / archived |
| remark | 字符串 | 备注,可空 |
| created_at | 时间 | 时间 |
## `cps_activity` — CPS 活动池
可推广的券(运营预存)。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| platform | 字符串 | meituan / taobao / jd |
| name | 字符串 | 活动名 |
| act_id | 字符串 | 美团活动物料 ID,可空 |
| product_view_sign | 字符串 | 美团商品券标识(与 act_id 二选一),可空 |
| payload | 文本 | 淘宝整段淘口令 / 京东推广链接,可空 |
| image_url | 字符串 | 淘宝落地页主视觉图(绝对 URL),可空 |
| status | 字符串 | active / archived |
| remark | 字符串 | 备注,可空 |
| created_at | 时间 | 时间 |
## `cps_link` — CPS 群发短链
群发的 `/c/{code}` 短链,每个活动每次生成一条。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| code | 字符串 | 短码(`/c/{code}`),**唯一** |
| group_id | 整数 | 所属群 id |
| activity_id | 整数 | 所属活动 id |
| sid | 字符串 | 渠道追踪位(仅美团),可空 |
| platform | 字符串 | meituan / taobao / jd |
| target_url | 字符串 | 跳转目标:美团短链 / 京东链接 / 淘宝淘口令 |
| created_at | 时间 | 时间 |
## `cps_click` — CPS 点击事件
落地页点击/复制事件,统计 PV/UV 及用户级领券。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| link_id | 整数 | 所属短链 id |
| group_id | 整数 | 所属群 id(冗余,按群聚合快) |
| sid | 字符串 | 渠道追踪位(冗余),可空 |
| event_type | 字符串 | visit(进页/被跳转)/ copy(淘宝点复制口令) |
| ip / ua | 字符串 | 客户端 IP / UA(UV 近似去重),可空 |
| openid | 字符串 | 微信授权用户标识(非微信/未授权为空) |
| clicked_at | 时间 | 点击时间 |
## `cps_order` — CPS 对账订单
从美团联盟拉回、按 sid 归群的订单明细。`order_id` 唯一(upsert 幂等)。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| order_id | 字符串 | 美团订单号,**唯一** |
| sid | 字符串 | 渠道追踪位(按它归群),可空 |
| act_id | 字符串 | 活动物料 id,可空 |
| biz_line | 整数 | 业务线(1=外卖),可空 |
| trade_type | 整数 | 1=cps 2=cpa,可空 |
| pay_price_cents | 金额(分) | 付款金额,可空 |
| commission_cents | 金额(分) | 预估佣金,可空 |
| commission_rate | 字符串 | 佣金率("300"=3%),可空 |
| refund_price_cents / refund_profit_cents | 金额(分) | 退款金额 / 退款佣金,可空 |
| mt_status | 字符串 | 美团状态:2 付款 3 完成 4 取消 5 风控 6 结算 |
| invalid_reason | 字符串 | 失效原因,可空 |
| product_name | 字符串 | 商品名,可空 |
| pay_time / mt_update_time | 时间 | 付款 / 美团更新时间,可空 |
| raw | JSON | 原始订单返回 |
| first_seen / updated_at | 时间 | 首次拉到 / 更新时间 |
## `cps_wx_user` — CPS 落地页微信用户
群发落地页经服务号网页授权拿到的用户。`openid` 唯一。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| openid | 字符串 | 服务号下用户唯一标识,**唯一** |
| unionid | 字符串 | 开放平台 unionid,可空 |
| nickname / headimgurl | 字符串 | 昵称 / 头像(userinfo 授权后才有),可空 |
| first_code | 字符串 | 首次进入来源短码,可空 |
| first_group_id | 整数 | 首次授权来源群,可空 |
| first_seen / last_seen | 时间 | 首次 / 最近出现时间 |
## `meituan_coupon` — 美团 CPS 券本地缓存
定时把美团联盟券抓进本地库,供本地排序(销量/佣金)。(source, product_view_sign) 唯一;查询用 `dedup_key` 跨源去重。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| source | 字符串 | search_waimai / search_meishi / store_supply(与 product_view_sign 组成**唯一** |
| platform | 整数 | 1 到家/外卖,2 到店 |
| biz_line | 整数 | 到店细分:1 到餐 2 到综 3 酒店 4 门票,可空 |
| city_id | 字符串 | 城市 id |
| product_view_sign | 字符串 | 召回标识(换推广链用) |
| sku_view_id | 字符串 | sku 标识,可空 |
| name / brand_name | 字符串 | 商品名 / 品牌名,可空 |
| sell_price_cents / original_price_cents | 金额(分) | 售价 / 原价,可空 |
| head_url | 字符串 | 头图 URL,可空 |
| image_size / image_type | 整数/字符串 | 头图字节大小 / MIME(分析用),可空 |
| sale_volume | 字符串 | 销量档位文本(如「热销 1w+」),可空 |
| sale_volume_num | 整数 | 销量排序用数值,可空 |
| commission_percent | 小数 | 佣金率(1.4=1.4%),可空 |
| commission_amount_cents | 金额(分) | 佣金额,可空 |
| poi_name | 字符串 | 门店名,可空 |
| available_poi_num | 整数 | 可用门店数,可空 |
| delivery_distance_m | 小数 | 配送距离(米),可空 |
| dedup_key | 字符串 | 跨源去重键 md5(brand\|name\|price) |
| raw | JSON | 原始返回 |
| first_seen / last_seen / updated_at | 时间 | 首次 / 最近 / 更新时间 |
---
# 八、邀请裂变
## `invite_relation` — 邀请关系
一行=一次成功邀请绑定。`invitee_user_id` 唯一(一人只能被邀一次,防重复发奖)。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| inviter_user_id | 整数 | **外键→user**,邀请人 |
| invitee_user_id | 整数 | **外键→user**,被邀请人,**唯一** |
| channel | 字符串 | clipboard(剪贴板自动)/ manual(手动填码)/ fingerprint(指纹兜底) |
| status | 字符串 | effective(注册即生效),预留 pending |
| inviter_coin / invitee_coin | 整数 | 各自发放金币 |
| created_at | 时间 | 时间 |
## `invite_fingerprint` — 邀请指纹归因
剪贴板归因失效时的兜底:落地页访问留指纹,装机后用 (IP, 屏幕, 机型) 7 天内反查。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| inviter_user_id | 整数 | **外键→user**,邀请人 |
| ip | 字符串 | 落地页访问者 IP |
| device_model | 字符串 | 机型(Build.MODEL / UA 解析) |
| screen | 字符串 | 屏幕分辨率(如 1080x2400 |
| user_agent | 文本 | 完整 UAdebug 用) |
| created_at | 时间 | 时间 |
---
# 九、设备 · 新手引导
## `device_liveness` — 设备无障碍存活监控
每条=一个用户的一台设备。无障碍存活时上报心跳,掉线超时则极光推送提醒。(user_id, device_id) 唯一。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| device_id | 字符串 | per-install 设备号(与 user_id 组成**唯一** |
| registration_id | 字符串 | 极光推送目标 id,可空 |
| platform | 字符串 | 平台,默认 android |
| app_version | 字符串 | App 版本,可空 |
| ever_protected | 布尔 | 是否开过无障碍(功能对它有意义) |
| last_heartbeat_at | 时间 | 最近心跳时间(超时=掉线),可空 |
| last_report_protection_on | 布尔 | 最近上报的无障碍开关状态 |
| liveness_state | 字符串 | unknown / alive / silent / notified |
| notified_at | 时间 | 最近推送告警时间,可空 |
| kill_alert_pending | 布尔 | 掉线告警待客户端提醒标记 |
| created_at / updated_at | 时间 | 创建 / 更新时间 |
## `onboarding_completion` — 新手引导完成
同一台设备+同一账号,新手引导只跑一次。device_id 用**硬件级稳定标识**(重装不变)。(user_id, device_id) 唯一。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | 登录态用户(与 device_id 组成**唯一** |
| device_id | 字符串 | 硬件级稳定设备号(ANDROID_ID |
| completed_at | 时间 | 完成时间 |
---
# 十、反馈
## `feedback` — 用户反馈
帮助与反馈,人工审核可奖励金币。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| content | 文本 | 反馈内容(必填) |
| contact | 字符串 | 联系方式(改版后客户端不再采集,新数据存空串) |
| images | JSON | 截图 URL 列表,可空 |
| status | 字符串 | pending(审核中)/ adopted(已采纳)/ rejected |
| reject_reason | 字符串 | 驳回原因,可空 |
| reward_coins | 整数 | 采纳奖励金币,可空 |
| review_note | 字符串 | 审核批注,可空 |
| reviewed_by_admin_id | 整数 | **外键→admin_user**,审核人,可空 |
| reviewed_at | 时间 | 审核时间,可空 |
| created_at | 时间 | 提交时间 |
---
# 十一、运营后台 · 配置
## `admin_user` — 管理员账号
后台账号,与 App 用户完全隔离(独立 JWT)。账号密码(bcrypt)登录。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| username | 字符串 | 用户名,**唯一** |
| password_hash | 字符串 | bcrypt 密码哈希 |
| role | 字符串 | super_admin(全权)/ finance(钱)/ operator(用户+反馈+大盘) |
| status | 字符串 | active / disabled |
| created_at | 时间 | 创建时间 |
| last_login_at | 时间 | 最后登录时间,可空 |
## `admin_audit_log` — 管理员操作审计
每个后台写操作落一条,记前后值,不可删,用于追溯「谁在何时改了谁的钱/状态」。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| admin_id | 整数 | **外键→admin_user** |
| admin_username | 字符串 | 操作者用户名(冗余存,改名后仍可追溯) |
| action | 字符串 | 操作类型(如 user.coins.grant / withdraw.refresh |
| target_type | 字符串 | 被操作对象类型 |
| target_id | 字符串 | 被操作对象 id(字符串以兼容非整型主键),可空 |
| detail | JSON | 上下文 + 前后值,可空 |
| ip | 字符串 | 操作 IP,可空 |
| created_at | 时间 | 时间 |
## `app_config` — 运营可配置项
把硬编码规则(奖励数值等)挪到 DB,运营后台可改。空表=现有行为完全不变(fallback 到代码默认)。
| 字段 | 类型 | 说明 |
|---|---|---|
| key | 字符串 | **主键**,配置标识 |
| value | JSON | 配置值(任意 int/list/dict |
| updated_by_admin_id | 整数 | 最后修改管理员,可空 |
| updated_at | 时间 | 更新时间 |
## `ops_marquee_seed` — 首页轮播种子
首页「用户 xxx 比价省了 xx 元」滚动条的兜底假数据**规则**:真实记录不够时用它补齐混播。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| masked_user | 字符串 | 脱敏用户名;留空则展示时随机合成,可空 |
| min_cents / max_cents | 金额(分) | 节省金额区间(展示时区间内随机取值) |
| enabled | 布尔 | 是否启用(停用不参与混播) |
| sort_order | 整数 | 仅后台列表排序(feed 公平随机,不看此字段) |
| created_at | 时间 | 时间 |
## `ops_stat_config` — 首页三统计配置
首页门面三数字(帮助用户/完成比价/累计节省),每指标独立选展示模式。一行一指标。
| 字段 | 类型 | 说明 |
|---|---|---|
| metric | 字符串 | **主键**help_users / total_compares / total_saved |
| mode | 字符串 | real(真实)/ manual(手填固定值)/ random(只增不减伪增长) |
| manual_value | 整数 | manual 模式固定值(total_saved 为分),可空 |
| random_mult_min / random_mult_max | 整数 | random 倍率区间(千分比,≥1000 只增不减) |
| random_tick_seconds | 整数 | random 刷新周期(秒) |
| random_anchor_minutes | 整数 | 刷新触发时刻对齐偏移(距北京 0 点分钟数) |
| random_current | 整数 | random 当前值,可空 |
| random_last_tick_at | 时间 | 上次 tick 时刻,可空 |
| random_kind | 字符串 | mult(×倍率)/ add(+绝对增量) |
| random_step_min / random_step_max | 整数 | add 模式增量区间 |
| real_offset | 整数 | real 模式保底值(展示=max(真实,保底)) |
| allow_decrease | 布尔 | 是否允许下调(默认只增不减) |
| updated_by_admin_id | 整数 | 最后修改管理员,可空 |
| updated_at | 时间 | 更新时间 |
---
# 十二、技术沉淀(内部)
## `launch_confirm_sample` — 启动确认窗样本
比价 agent 用 LLM 兜底放行跨 App 启动确认窗时落的样本,研发人工沉淀进静态规则。内部排障用,不去重。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| created_at | 时间 | 时间 |
| trace_id | 字符串 | 比价追踪号(溯源),可空 |
| device_id | 字符串 | 设备号,可空 |
| host_package | 字符串 | 弹窗宿主包(聚合键),可空 |
| target_app | 字符串 | 被打开的目标 App 包名,可空 |
| system_locale | 字符串 | 系统语言地区(zh-TW / en-US…),可空 |
| exec_success | 布尔 | 本次兜底是否成功放行(研发只沉淀 true 的) |
| dialog_title | 字符串 | 弹窗标题全文,可空 |
| payload | JSON | 完整样本(弹窗树 + LLM plan + 设备信息),可空 |
---
*文档生成基于 `app/models/` 全量模型。如需单表更详细的索引/迁移信息,见 `docs/database/` 下对应同名文件。*
@@ -37,7 +37,7 @@ B 安装并首启 App
└─ POST /api/v1/invite/bind { invite_code, channel="clipboard" }
后端 repositories/invite.py bind()
└─ 过四道防线 → 建 invite_relation + 给 A、B 各发金币(同事务原子提交)
└─ 过四道防线 → 建 invite_relation(邀请金币已下线,不写金币流水)
```
手动填码这条:B 在邀请页输码 → `InviteRepository.bindManual()``POST /bind { channel="manual" }` → 同一个 `bind()`
@@ -52,20 +52,20 @@ B 安装并首启 App
|---|---|
| 端点 | `app/api/v1/invite.py`:`GET /api/v1/invite/me`(返回 `invite_code` + `share_url` + 战绩)、`POST /api/v1/invite/bind`(绑定,`channel` = `clipboard` / `manual`)。**均需 Bearer 鉴权**。 |
| share_url 构造 | `invite.py``my_invite`:`settings.INVITE_LANDING_URL + "?ref=" + code``INVITE_LANDING_URL``app/core/config.py`(默认 `https://app-api.shaguabijia.com/media/dl.html`)。 |
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 累计金币)。 |
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 兼容累计金币字段,当前恒为 0)。 |
| 数据模型 | `app/models/invite.py``InviteRelation`(`inviter_user_id` / `invitee_user_id` / `channel` / `status` / `inviter_coin` / `invitee_coin` / `created_at`)+ `app/models/user.py``User.invite_code` 列。 |
| 迁移 | `alembic/versions/invite_code_and_relation.py`:给 `user``invite_code`(唯一索引)+ 建 `invite_relation` 表。`down_revision = 11a1d08c6f55`。 |
| 收发模型 | `app/schemas/invite.py`:`InviteInfoOut` / `BindInviteIn` / `BindInviteOut`。 |
| 奖励常量 | `app/core/rewards.py`:`INVITE_INVITER_COINS` / `INVITE_INVITEE_COINS`(各 10000 = 1 元)、`INVITE_NEW_USER_WINDOW_HOURS`(72)。 |
| 新人窗口 | `app/core/rewards.py`:`INVITE_NEW_USER_WINDOW_HOURS`(72)。邀请金币已下线,不再配置邀请金币常量。 |
**`bind()` 的四道防线(防重复 / 防刷,看 `repositories/invite.py`):**
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复发奖)。
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复绑定)。
2. **自邀屏蔽**:`inviter == invitee``self_invite`
3. **新人闸**:`_is_new_user`(B 的 `created_at``INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才发奖,挡存量老用户互相填码薅羊毛 → 否则 `not_eligible`
3. **新人闸**:`_is_new_user`(B 的 `created_at``INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才生效,挡存量老用户互相填码刷关系 → 否则 `not_eligible`
4. **手机号唯一**(天然限量):每个 B = 一个真实手机号账号。
发金币复用 `repositories/wallet.py``grant_coins`,与建关系记录在**同一事务**提交,保证"建关系 + 双方加金币"原子
邀请金币已下线:`bind()` 只记录绑定关系,不再写 `coin_transaction`;响应里的金币字段保留兼容旧客户端,当前恒为 0
### 3.2 前端(shaguabijia-app-android)
@@ -120,7 +120,7 @@ B 安装并首启 App
### 4.4 测试硬约束 / 坑(都是机制,不是 bug)
- **B 必须用新手机号**:`invitee_user_id` 唯一,一个 B 只能绑一次;反复测要换号(或手删 `invite_relation` 那行 + 回滚金币)。
- **72h 新人闸**:B 注册后 72 小时内绑才发奖(刚注册肯定满足)。
- **72h 新人闸**:B 注册后 72 小时内绑定才生效(刚注册肯定满足)。
- **A ≠ B**:自邀被屏蔽。
- **B 从点下载到首启 App 之间别复制别的东西**:剪贴板会被覆盖 → 归因丢(剪贴板 deferred deeplink 的固有脆弱性)。
- **笔记本 IP 别变**:debug 包把 `BASE_URL` 的 IP 烧死在编译期,DHCP 一换就连不上 → 给笔记本固定个 LAN IP。
+7771
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
/**
* SGApi H5 app-server 后端的薄封装
*
* 同源H5 app-server /media/h5/ 托管后端在 /api/v1 host:port 用相对路径 CORS
* 鉴权JWT Bearertoken SGBridge.getToken() 从原生取(原生持登录态)浏览器调试走 bridge mock token
* 401交原生拉登录(requestLogin)兜底本次请求按失败 reject正式的 refresh 重试策略阶段2 再补
*
* 依赖 shared/bridge.js 先加载( token)
*/
(function (global) {
'use strict';
var BASE = '/api/v1';
function authHeaders() {
var t = (global.SGBridge && global.SGBridge.getToken()) || '';
var h = { 'Content-Type': 'application/json' };
if (t) h['Authorization'] = 'Bearer ' + t;
return h;
}
function handle(res) {
if (res.status === 401) {
// 未授权:拉原生登录(异步),本次请求按失败处理,调用方自行决定是否重试
if (global.SGBridge) global.SGBridge.requestLogin();
return Promise.reject(new Error('unauthorized'));
}
if (!res.ok) {
return res.text().then(function (t) {
return Promise.reject(new Error('http ' + res.status + ' ' + t));
});
}
// 204 / 空体兜底
return res.text().then(function (t) { return t ? JSON.parse(t) : null; });
}
/** GET /api/v1<path>。path 以 / 开头,如 '/savings/battle'。 */
function apiGet(path) {
return fetch(BASE + path, { method: 'GET', headers: authHeaders() }).then(handle);
}
/** POST /api/v1<path>body 自动 JSON 序列化。 */
function apiPost(path, body) {
return fetch(BASE + path, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(body || {}),
}).then(handle);
}
global.SGApi = { base: BASE, get: apiGet, post: apiPost };
})(window);
+162
View File
@@ -0,0 +1,162 @@
/**
* SGBridge 傻瓜比价 H5 Android 原生 的桥
*
* 背景四个主 tab(首页/福利/记录/我的)由原生 Compose 改造为 WebView 加载本工程 H5
* H5 只负责"画 + 取后端数据"凡需要原生能力(登录态 / 跳转 / 跳外卖 App / 比价领券 /
* 权限 / 定位 / Toast / 激励视频)一律经本桥调用原生
*
* 协议两个方向
* H5 原生Android WebView.addJavascriptInterface(obj, "SGBridgeNative")
* obj 方法都是同步查询类返回 String(JSON 或纯串)动作类无返回
* 原生 H5原生执行 evaluateJavascript("window.SGBridge._emit('<event>', '<json>')")
* 事件onAuthChange(登录态变) / onBalanceChange(余额变) / onSigninChange(签到态变) / onResume(回前台刷新)
*
* 离线兜底浏览器里( SGBridgeNative) MOCK便于不装 App 直接在本地 server 调样式 / 渲染
* 与原生实现对应 shaguabijia-app-android SGBridge.kt(方法名逐个对齐本文件)
*/
(function (global) {
'use strict';
var native = global.SGBridgeNative || null;
var hasNative = !!native;
// ---- 离线 MOCK(仅无原生时生效,便于浏览器调试渲染;真机一律走 native) ----
var MOCK = {
authState: { loggedIn: true, userId: 1, nickname: '冰', avatarUrl: '', phone: '188****8888' },
token: 'mock-token-for-browser-debug',
deviceId: 'browser-debug-device',
appVersion: '0.0.0-debug',
};
function safeParse(s, fallback) {
try { return s ? JSON.parse(s) : fallback; } catch (e) { return fallback; }
}
// ====== 查询类(同步返回) ======
/** 当前登录态 + 用户基本信息 → {loggedIn, userId, nickname, avatarUrl, phone}。 */
function getAuthState() {
if (hasNative && native.getAuthState) return safeParse(native.getAuthState(), { loggedIn: false });
return MOCK.authState;
}
/** 后端鉴权用的 access token(空串=未登录)。原生持登录态,H5 调后端前取它拼 Bearer。 */
function getToken() {
if (hasNative && native.getToken) return native.getToken() || '';
return MOCK.token;
}
/** 设备唯一标识(心跳 / 领券状态查询等用)。 */
function getDeviceId() {
if (hasNative && native.getDeviceId) return native.getDeviceId() || '';
return MOCK.deviceId;
}
/** App 版本号。 */
function getAppVersion() {
if (hasNative && native.getAppVersion) return native.getAppVersion() || '';
return MOCK.appVersion;
}
/** 已安装的目标电商/外卖 App 包名数组(原生 InstalledApps 探测)。H5 选平台弹窗据此判真实装机态。 */
function getInstalledApps() {
if (hasNative && native.getInstalledApps) return safeParse(native.getInstalledApps(), []);
// MOCK(浏览器无原生):给主流已装,便于本地预览选平台弹窗正常显示"有"。
return ['com.sankuai.meituan', 'com.taobao.taobao', 'com.jingdong.app.mall', 'me.ele'];
}
/** 今日是否已领券(置灰「去领取」→「去查看」)。原生读 CompareButtonState(SP 按天);无桥默认 false。 */
function getCouponClaimedToday() {
if (hasNative && native.getCouponClaimedToday) return !!native.getCouponClaimedToday();
return false;
}
// ====== 动作类(无返回;异步结果走事件) ======
/** 跳原生页。route 取值对齐安卓 Routes(invite / settings / feedback / withdrawal / compareRecords / reportFlow / guideVideo / compareResult / coinHistory / cashHistory / welfareRules ...)。 */
function navigate(route) {
if (hasNative && native.navigate) native.navigate(route);
else console.log('[SGBridge mock] navigate →', route);
}
/** 拉起极光一键登录。结果异步经 onAuthChange 事件回来(不在此函数返回)。 */
function requestLogin() {
if (hasNative && native.requestLogin) native.requestLogin();
else console.log('[SGBridge mock] requestLogin');
}
/** 原生居中 Toast。 */
function toast(msg) {
if (hasNative && native.toast) native.toast(String(msg));
else console.log('[SGBridge mock] toast →', msg);
}
/** 跳美团/外卖 App(deeplink 优先;空则原生按包名启动,未装可跳应用商店)。 */
function openMeituan(deeplink) {
if (hasNative && native.openMeituan) native.openMeituan(deeplink || '');
else console.log('[SGBridge mock] openMeituan →', deeplink);
}
/** 触发 agent 比价流程(原生起无障碍引擎)。 */
function startCompare() {
if (hasNative && native.startCompare) native.startCompare();
else console.log('[SGBridge mock] startCompare');
}
/** 触发一键领券(原生先校验悬浮窗/无障碍权限,再起前台服务)。platforms: string[]。 */
function startCouponClaim(platforms) {
var json = JSON.stringify(platforms || []);
if (hasNative && native.startCouponClaim) native.startCouponClaim(json);
else console.log('[SGBridge mock] startCouponClaim →', json);
}
/** App HomePicker :
* getLaunchIntentForPackage 的拉起(NEW_TASK|CLEAR_TASK 冷启到平台首页)packages: string[](一个平台一组候选包,任一可拉即拉) */
function launchApp(packages) {
var json = JSON.stringify(packages || []);
if (hasNative && native.launchApp) native.launchApp(json);
else console.log('[SGBridge mock] launchApp →', json);
}
// ====== 原生 → H5 事件总线 ======
var listeners = {}; // event → [fn]
/** 订阅原生事件。返回取消订阅函数。 */
function on(event, fn) {
(listeners[event] || (listeners[event] = [])).push(fn);
return function off() {
listeners[event] = (listeners[event] || []).filter(function (f) { return f !== fn; });
};
}
/** 供原生回调:window.SGBridge._emit('onAuthChange', '{...}')。payload 可为 JSON 串或对象。 */
function _emit(event, payload) {
var data = typeof payload === 'string' ? safeParse(payload, payload) : payload;
(listeners[event] || []).forEach(function (fn) {
try { fn(data); } catch (e) { console.error('[SGBridge] listener error', event, e); }
});
}
global.SGBridge = {
hasNative: hasNative,
// 查询
getAuthState: getAuthState,
getToken: getToken,
getDeviceId: getDeviceId,
getAppVersion: getAppVersion,
getInstalledApps: getInstalledApps,
getCouponClaimedToday: getCouponClaimedToday,
// 动作
navigate: navigate,
requestLogin: requestLogin,
toast: toast,
openMeituan: openMeituan,
startCompare: startCompare,
startCouponClaim: startCouponClaim,
launchApp: launchApp,
// 事件
on: on,
_emit: _emit,
};
})(window);
+8 -2
View File
@@ -17,6 +17,12 @@ REM avoids CRLF/encoding fights every time someone bash-runs the .sh on Win.
cd /d "%~dp0"
REM Prefer the project virtualenv (.venv) so we never inherit a wrong
REM global/conda interpreter. FastAPI<0.115 on Pydantic 2.12 crashes at import
REM with "'FieldInfo' object has no attribute 'in_'". Falls back to PATH python.
set "PY=python"
if exist "%~dp0.venv\Scripts\python.exe" set "PY=%~dp0.venv\Scripts\python.exe"
if not exist .env (
echo [X] Missing .env. Run: copy .env.example .env and fill JWT_SECRET_KEY ^(plus MT_CPS_* if you test Meituan^)
exit /b 1
@@ -25,11 +31,11 @@ if not exist .env (
if not exist data mkdir data
REM Build/upgrade SQLite schema (idempotent; no-op if already at head)
call alembic upgrade head
call "%PY%" -m alembic upgrade head
if errorlevel 1 (
echo [X] alembic upgrade head failed
exit /b %errorlevel%
)
REM Long-running foreground process. Ctrl+C to stop.
uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload
"%PY%" -m uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload
+10 -2
View File
@@ -7,12 +7,20 @@
set -e
cd "$(dirname "$0")"
# 优先用项目 venv 的解释器,避免误用全局/conda 里不兼容的 FastAPI/Pydantic
# (FastAPI<0.115 撞 Pydantic 2.12 会在导入期崩 'FieldInfo' has no attribute 'in_')。
PY=python
[ -x .venv/bin/python ] && PY=.venv/bin/python
[ -x .venv/Scripts/python.exe ] && PY=.venv/Scripts/python.exe
if [ ! -f .env ]; then
echo "❌ 缺 .env:先 cp .env.example .env 并填值(至少 JWT_SECRET_KEY;测美团再填 MT_CPS_*"
exit 1
fi
mkdir -p data # sqlite 文件所在目录
alembic upgrade head # 确保表已建(幂等,已是最新则 no-op)
"$PY" -m alembic upgrade head # 确保表已建(幂等,已是最新则 no-op)
exec uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload
# --reload 只盯源码目录 app/:别去监视 logs/(日志写入触发"检测→再写日志"回环)和
# data/(sqlite 频繁写)。改 alembic/、.env、本脚本后请手动重启。
exec "$PY" -m uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload --reload-dir app
+337
View File
@@ -0,0 +1,337 @@
"""一次性 mock:造 CPS「按群对账统计」+「群内微信用户(领券画像)」所需假数据。
覆盖两个后台接口:
get_stats (GET /admin/cps/stats) cps_repo.group_stats
group_wx_users (GET /admin/cps/groups/{id}/wx-users) cps_repo.group_wx_users
涉及四张表:
- cps_group (active 才入统计;sid 仅美团群有)
- cps_order 对账订单( sid 归群;status 2付款 3完成=有效 / 4取消 5风控=无效 / 6结算)
- cps_click 点击(group_stats group_id 聚合 pv/uv;group_wx_users 只看带 openid 的点击)
- cps_wx_user 落地页微信用户画像(nickname/headimgurl;userinfo 授权后才有)
覆盖 group_stats 全部分支: active 美团群(对账) 美团+淘宝多平台 纯淘宝/京东( sid,
对账字段 None) archived (被过滤) 未归群历史 sid(group_id=None 单列)
覆盖 group_wx_users 全部分支(挂在淘宝系群 B/C 仅淘宝落地页有"复制口令"触发 userinfo):
有完整画像(昵称+可加载头像)的领券用户(visit+copy)
visit copy 的用户(无领券意向)
跨群用户( C 首次授权又来 B 活动) 验证"按本群点击判定归属,而非 first_group_id"
openid 但无画像(base 静默授权) nickname/headimgurl 返回 None(前端走色块兜底)
头像:本地手写纯色 PNG data/media/cps_wx/,headimgurl 存绝对 URL( App 后端 :8770
/media 托管),后台 <img> 可直接加载时间均落最近几天(默认窗口内),tz-aware UTC
幂等:重跑先按 mock 标识(群名前缀MOCK/ 订单号前缀MOCKCPS-/ openid 前缀
oMOCKcps)清旧再重建;点击/短链按 mock id ,头像文件按 glob --clean-only 只清
python -m scripts.seed_mock_cps_stats
python -m scripts.seed_mock_cps_stats --clean-only
看数据:后台CPS / 按群对账统计 + 群详情群内微信用户,:
GET /admin/cps/stats?days=30
GET /admin/cps/groups/{B或C的id}/wx-users
"""
from __future__ import annotations
import argparse
import struct
import sys
import zlib
from datetime import datetime, timedelta, timezone
from pathlib import Path
from sqlalchemy import delete, select
from app.core.config import settings
from app.db.session import SessionLocal
from app.models.cps_group import CpsGroup
from app.models.cps_link import CpsClick, CpsLink
from app.models.cps_order import CpsOrder
from app.models.cps_wx_user import CpsWxUser
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥
# ── mock 标识(只动带这些标识的数据,便于幂等清理)──
NAME_PREFIX = "【MOCK】" # 群名前缀
ORDER_PREFIX = "MOCKCPS-" # 订单号前缀
OPENID_PREFIX = "oMOCKcps" # 微信用户 openid 前缀
SID_MT_A = "mockcpsmta" # 群A sid(美团);sid 仅字母+数字
SID_MT_B = "mockcpsmtb" # 群B sid(美团+淘宝)
SID_ARCHIVED = "mockcpsmtz" # 已归档群 sid
SID_ORPHAN = "mockcpsold" # 未归群历史 sid(不属于任何群)
# 头像:本地纯色 PNG 落 media/cps_wx/,headimgurl 存「绝对 URL」(微信场景本就是完整外链,
# 后台直接当 <img src>);本地由 App 后端 :8770 托管 /media。
APP_MEDIA_BASE = "http://localhost:8770"
_AVATAR_DIR = Path(settings.MEDIA_ROOT) / "cps_wx"
_AVATAR_GLOB = "mock_wx_*.png"
# 微信用户画像: (openid 后缀, 昵称|None=无画像, 头像色, 首次授权群 key)
WX_PROFILES = [
("c1", "省钱小张", (250, 173, 20), "C"),
("c2", "薅羊毛阿珍", (82, 196, 26), "C"),
("c3", "随便看看", (24, 144, 255), "C"),
("sh", "比价小能手", (114, 46, 209), "C"), # 跨群:C 首授权,也来 B
("b1", "深夜下单选手", (245, 34, 45), "B"),
("b2", "团购队长", (19, 194, 194), "B"),
("anon", None, None, "C"), # 无画像(base 静默授权,只有 openid)
]
# 每个用户在各群的点击活动: (群 key, openid 后缀, visit 次数, copy 次数)
WX_ACTIVITY = [
("C", "c1", 3, 2), ("C", "c2", 2, 1), ("C", "c3", 1, 0),
("C", "sh", 5, 3), ("C", "anon", 2, 1),
("B", "b1", 2, 1), ("B", "b2", 3, 0), ("B", "sh", 2, 1), # sh 跨群
]
def _now() -> datetime:
return datetime.now(timezone.utc)
def _openid(suffix: str) -> str:
return f"{OPENID_PREFIX}_{suffix}"
def _solid_png(rgb: tuple[int, int, int], size: int = 96) -> bytes:
"""纯色 PNG(truecolor RGB)字节,无需 Pillow。色块即可肉眼判断头像加载出来了。"""
def _chunk(typ: bytes, data: bytes) -> bytes:
body = typ + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # 8bit/通道, color type 2 = RGB
idat = zlib.compress((b"\x00" + bytes(rgb) * size) * size, 9)
return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"")
def _write_avatar(suffix: str, rgb: tuple[int, int, int]) -> str:
"""写一张 mock 头像到 media/cps_wx/,返回可加载的绝对 URL。"""
_AVATAR_DIR.mkdir(parents=True, exist_ok=True)
name = f"mock_wx_{suffix}.png"
(_AVATAR_DIR / name).write_bytes(_solid_png(rgb))
return f"{APP_MEDIA_BASE}{settings.MEDIA_URL_PREFIX}/cps_wx/{name}"
def clean(db) -> dict:
"""按 mock 标识清旧数据。点击/短链按 mock 群 id 清,订单按号前缀清。"""
mock_gids = list(
db.execute(select(CpsGroup.id).where(CpsGroup.name.like(f"{NAME_PREFIX}%"))).scalars()
)
n_click = n_link = n_group = 0
if mock_gids:
n_click = db.execute(
delete(CpsClick).where(CpsClick.group_id.in_(mock_gids))
).rowcount or 0
n_link = db.execute(
delete(CpsLink).where(CpsLink.group_id.in_(mock_gids))
).rowcount or 0
n_group = db.execute(
delete(CpsGroup).where(CpsGroup.id.in_(mock_gids))
).rowcount or 0
n_order = db.execute(
delete(CpsOrder).where(CpsOrder.order_id.like(f"{ORDER_PREFIX}%"))
).rowcount or 0
n_wx = db.execute(
delete(CpsWxUser).where(CpsWxUser.openid.like(f"{OPENID_PREFIX}%"))
).rowcount or 0
db.commit()
# 删 mock 头像文件
if _AVATAR_DIR.exists():
for f in _AVATAR_DIR.glob(_AVATAR_GLOB):
f.unlink(missing_ok=True)
return {"group": n_group, "order": n_order, "click": n_click, "link": n_link, "wx_user": n_wx}
def _add_clicks(db, *, group_id: int, sid: str | None, link_id: int,
visit: int, visit_uv: int, copy: int = 0, copy_uv: int = 0,
now: datetime) -> None:
"""造点击:visit 条 visit 事件(visit_uv 个去重身份循环)+ copy 条 copy 事件。
pv = 总条数,uv = distinct(ip,ua) = min(uv_arg, 条数)clicked_at 摊在最近 6 """
def emit(n: int, uv: int, event: str) -> None:
uv = max(1, min(uv, n)) if n else 0
for k in range(n):
ident = k % uv # 循环复用身份 → pv>uv
db.add(CpsClick(
link_id=link_id, group_id=group_id, sid=sid, event_type=event,
ip=f"10.{group_id}.{event[0]}.{ident}",
ua=f"MockUA/{event}-{ident}",
clicked_at=now - timedelta(days=k % 6, hours=k),
))
emit(visit, visit_uv, "visit")
emit(copy, copy_uv, "copy")
def _add_orders(db, *, sid: str, specs: list[tuple[str, int, int, str]], now: datetime,
tag: str) -> None:
"""specs: [(mt_status, pay_price_cents, commission_cents, product_name), ...]"""
for i, (status, pay, comm, name) in enumerate(specs):
db.add(CpsOrder(
order_id=f"{ORDER_PREFIX}{tag}-{i:02d}",
sid=sid, act_id="100001", biz_line=1, trade_type=1,
pay_price_cents=pay, commission_cents=comm, commission_rate="500",
refund_price_cents=None, refund_profit_cents=None,
mt_status=status, product_name=name,
pay_time=now - timedelta(days=i % 6, hours=i),
mt_update_time=now - timedelta(days=i % 6),
raw={"mock": True, "status": status},
))
def seed(db) -> dict:
now = _now()
# ── 1) 建群(覆盖 active 美团 / 美团+淘宝 / 纯淘宝 / 纯京东 / archived)──
groups = [
CpsGroup(sid=SID_MT_A, name=f"{NAME_PREFIX}美团外卖群A",
platforms=["meituan"], member_count=480, status="active",
remark="mock-纯美团,走对账"),
CpsGroup(sid=SID_MT_B, name=f"{NAME_PREFIX}美团+淘宝群B",
platforms=["meituan", "taobao"], member_count=320, status="active",
remark="mock-多平台,对账+复制口令"),
CpsGroup(sid=None, name=f"{NAME_PREFIX}淘宝券群C",
platforms=["taobao"], member_count=210, status="active",
remark="mock-无 sid,对账字段 None"),
CpsGroup(sid=None, name=f"{NAME_PREFIX}京东券群D",
platforms=["jd"], member_count=150, status="active",
remark="mock-无 sid,对账字段 None"),
CpsGroup(sid=SID_ARCHIVED, name=f"{NAME_PREFIX}已归档美团群E",
platforms=["meituan"], member_count=90, status="archived",
remark="mock-archived,应被统计排除"),
]
db.add_all(groups)
db.flush() # 拿 group.id
g_a, g_b, g_c, g_d, g_e = groups
# ── 2) 每群一条短链(cps_click.link_id 非空;无 FK,activity_id 占位即可)──
links = {}
for g in groups:
link = CpsLink(
code=f"MK{g.id:05d}", group_id=g.id, activity_id=0,
sid=g.sid, platform=(g.platforms[0] if g.platforms else "meituan"),
target_url=f"https://example.com/mock/{g.id}",
)
db.add(link)
links[g.id] = link
db.flush() # 拿 link.id
# ── 3) 点击(pv>uv;淘宝群带 copy 复制口令)──
_add_clicks(db, group_id=g_a.id, sid=g_a.sid, link_id=links[g_a.id].id,
visit=5, visit_uv=3, now=now)
_add_clicks(db, group_id=g_b.id, sid=g_b.sid, link_id=links[g_b.id].id,
visit=8, visit_uv=5, copy=3, copy_uv=2, now=now)
_add_clicks(db, group_id=g_c.id, sid=None, link_id=links[g_c.id].id,
visit=6, visit_uv=4, copy=4, copy_uv=3, now=now)
_add_clicks(db, group_id=g_d.id, sid=None, link_id=links[g_d.id].id,
visit=4, visit_uv=3, now=now)
_add_clicks(db, group_id=g_e.id, sid=g_e.sid, link_id=links[g_e.id].id,
visit=3, visit_uv=2, now=now) # archived,统计里看不到
# ── 3.5) 微信用户画像 + 带 openid 的点击(挂淘宝系群 B/C;这些点击也计入 stats 的 pv/uv)──
groups_by_key = {"A": g_a, "B": g_b, "C": g_c, "D": g_d, "E": g_e}
# 建画像;无昵称的 anon 不建 CpsWxUser → group_wx_users 里 nickname/headimgurl 为 None
for i, (suf, nickname, rgb, fg_key) in enumerate(WX_PROFILES):
if nickname is None:
continue
fg = groups_by_key[fg_key]
db.add(CpsWxUser(
openid=_openid(suf), nickname=nickname, headimgurl=_write_avatar(suf, rgb),
first_code=links[fg.id].code, first_group_id=fg.id,
first_seen=now - timedelta(days=3, hours=i), last_seen=now - timedelta(hours=1),
))
# 逐条 openid 点击:同一 openid 固定一个 (ip,ua) 身份(跨群复用=同人同设备)
idx_of = {_openid(suf): i for i, (suf, *_rest) in enumerate(WX_PROFILES)}
counter = [0]
def _wx_clicks(group: CpsGroup, openid: str, visit: int, copy: int) -> None:
ident = idx_of[openid]
ip, ua = f"100.64.{ident}.{ident}", f"WeChat/8.0 {openid}"
for event, n in (("visit", visit), ("copy", copy)):
for _ in range(n):
counter[0] += 1
db.add(CpsClick(
link_id=links[group.id].id, group_id=group.id, sid=group.sid,
event_type=event, ip=ip, ua=ua, openid=openid,
clicked_at=now - timedelta(hours=counter[0]),
))
for gk, suf, visit, copy in WX_ACTIVITY:
_wx_clicks(groups_by_key[gk], _openid(suf), visit, copy)
# ── 4) 订单(按 sid 归群;状态覆盖 有效/无效/结算)──
# 群A:有效5(2,3,3,6,6) 结算2 取消2(4,5)
_add_orders(db, sid=SID_MT_A, tag="A", now=now, specs=[
("2", 3580, 180, "麦当劳 板烧鸡腿堡套餐"),
("3", 4200, 210, "瑞幸 生椰拿铁×2"),
("3", 2890, 140, "肯德基 早餐组合"),
("6", 5100, 255, "海底捞 双人套餐"),
("6", 3300, 165, "蜜雪冰城 多杯装"),
("4", 2600, 130, "(取消)星巴克 美式"),
("5", 1990, 90, "(风控)某外卖单"),
])
# 群B:有效3(3,6,2) 结算1 取消1(4)
_add_orders(db, sid=SID_MT_B, tag="B", now=now, specs=[
("3", 6800, 340, "必胜客 双人餐"),
("6", 4500, 225, "喜茶 多肉葡萄×3"),
("2", 3120, 150, "华莱士 全鸡套餐"),
("4", 2200, 110, "(取消)某甜品单"),
])
# 未归群历史 sid(不属于任何群)→ 结果里 group_id=None 单列
_add_orders(db, sid=SID_ORPHAN, tag="OLD", now=now, specs=[
("6", 2800, 140, "历史结算单"),
("3", 3600, 180, "历史完成单"),
])
db.commit()
return {
"groups": len(groups), "orders": 7 + 4 + 2,
"anon_clicks": 5 + (8 + 3) + (6 + 4) + 4 + 3,
"wx_clicks": sum(v + c for *_, v, c in WX_ACTIVITY),
"wx_users": len({suf for _, suf, *_ in WX_ACTIVITY}),
"wx_profiles": sum(1 for _, nn, *_ in WX_PROFILES if nn is not None),
}
def main() -> None:
parser = argparse.ArgumentParser(description="造 CPS 按群对账统计 mock 数据(后台 get_stats 用)")
parser.add_argument("--clean-only", action="store_true", help="只清理 mock 数据,不重建")
args = parser.parse_args()
db = SessionLocal()
try:
removed = clean(db)
if any(removed.values()):
print(f"🧹 已清理旧 mock:群{removed['group']} 订单{removed['order']} "
f"点击{removed['click']} 短链{removed['link']} 微信用户{removed['wx_user']}")
if args.clean_only:
print("✅ 仅清理,已完成。")
return
r = seed(db)
print(f"\n✅ 已生成:{r['groups']} 群 / {r['orders']} 订单 / "
f"{r['anon_clicks'] + r['wx_clicks']} 点击(其中带 openid {r['wx_clicks']}) / "
f"{r['wx_users']} 微信用户(含画像 {r['wx_profiles']})\n")
print("① 预期 get_stats(days=30)(B/C 的 pv/uv 已含带 openid 的用户点击):")
print(" 群A(美团,sid=%s) : 有效5 结算2 取消2 | GMV ¥190.70 预估 ¥9.50 已结算 ¥4.20 | 点击 pv5/uv3"
% SID_MT_A)
print(" 群B(美团+淘宝,sid=%s): 有效3 结算1 取消1 | GMV ¥144.20 预估 ¥7.15 已结算 ¥2.25 | 点击 pv15/uv8 复制 pv5/uv4"
% SID_MT_B)
print(" 群C(淘宝,无 sid) : 对账字段 '-' | 点击 pv19/uv9 复制 pv11/uv7")
print(" 群D(京东,无 sid) : 对账字段 '-' | 点击 pv4/uv3")
print(" 群E(已归档) : 不出现在结果(status 过滤)")
print(" 未归群 sid=%s : group_id=None 单列 | 有效2 结算1 | GMV ¥64.00 预估 ¥3.20 已结算 ¥1.40"
% SID_ORPHAN)
print(" 合计:订单 10 | 预估佣金 ¥19.85 | 已结算佣金 ¥7.85")
print("\n② 预期 group_wx_users(按本群首次点击倒序):")
print(" 群C(5 人): 省钱小张 v3/c2 | 薅羊毛阿珍 v2/c1 | 随便看看 v1/c0 | "
"比价小能手 v5/c3 | (匿名·无画像) v2/c1")
print(" 群B(3 人): 深夜下单选手 v2/c1 | 团购队长 v3/c0 | 比价小能手 v2/c1(跨群,首授权在 C)")
print(" 群A/D/E : 无带 openid 点击 → 返回 []")
print("\n👉 验收:GET /admin/cps/stats?days=30")
print(" GET /admin/cps/groups/{群B或C的id}/wx-users(头像经 :8770 /media 加载,App 后端需在跑)")
finally:
db.close()
if __name__ == "__main__":
main()
+9
View File
@@ -12,6 +12,7 @@ FastAPI TestClient 在进程内打**真实** endpoint(`/api/v1/ad/pangle-callbac
from __future__ import annotations
import argparse
import json
import os
import sys
import uuid
@@ -56,6 +57,11 @@ def main() -> None:
p = argparse.ArgumentParser(description="模拟穿山甲发奖回调")
p.add_argument("user_id", nargs="?", type=int, default=None, help="目标 user_id(默认最近注册)")
p.add_argument("--trans", default=None, help="交易号(默认随机);同号重跑验幂等")
p.add_argument(
"--srv-env", default=None,
help="模拟客户端 mediaExtra 里的后端环境标记(dev/prod)。与本服 APP_ENV 不符时"
"应被环境隔离拦下(Δ0、不串号);留空=不带该字段(旧客户端,照常发)",
)
args = p.parse_args()
if not settings.pangle_callback_configured:
@@ -75,6 +81,9 @@ def main() -> None:
# 镜像生产:后台代码位"奖励数量"应配成 AD_REWARD_COIN,回调据此发金币
"reward_amount": str(AD_REWARD_COIN),
}
# 镜像穿山甲透传 mediaExtra:把后端环境标记放进 gromoreExtra(服务端据此做环境隔离)
if args.srv_env:
params["gromoreExtra"] = json.dumps({"srv_env": args.srv_env})
params["sign"] = pangle.build_sign(trans_id, settings.PANGLE_REWARD_SECRET)
resp = TestClient(app).get("/api/v1/ad/pangle-callback", params=params)
+36 -1
View File
@@ -5,6 +5,8 @@
"""
from __future__ import annotations
import json
from sqlalchemy import select
from app.core.config import settings
@@ -179,6 +181,38 @@ def test_callback_unknown_user(client) -> None:
assert r.json() == {"is_verify": False, "reason": 2}
def test_callback_foreign_env_skipped(client) -> None:
"""回调 srv_env 与本服 APP_ENV 不符(测试包的 S2S 打到生产)→ 受理但不发币、不串号。
防的是跨库串号:同一 user_id 在测试库 / 正式库是两个人,回调地址只配生产,测试包(dev)看广告
S2S 也会打到生产测试环境 APP_ENV=dev:srv_env=prod 视为别的环境 跳过,余额不变
不写记录;srv_env=dev(本环境)照常发srv_env gromoreExtra 透传(穿山甲带回 mediaExtra 的字段)
兼容:不带 srv_env 的旧客户端按本环境处理(其余用例已覆盖)
"""
phone = "13800003501"
token = _login(client, phone)
uid = _user_id(phone)
# 外环境(prod)回调:受理(is_verify=true 防穿山甲重试)但不发币、不留记录
foreign = _signed(uid, "trans_foreign_env", ecpm="200",
gromoreExtra=json.dumps({"srv_env": "prod"}))
r = _callback(client, foreign)
assert r.status_code == 200, r.text
assert r.json() == {"is_verify": True, "reason": 0}
assert _coin_balance(client, token) == 0
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
assert st["used_today"] == 0
# 本环境(dev)回调:照常发币
same = _signed(uid, "trans_same_env", ecpm="200",
gromoreExtra=json.dumps({"srv_env": "dev"}))
r = _callback(client, same)
assert r.status_code == 200, r.text
assert _coin_balance(client, token) == calculate_ad_reward_coin("200", 1)
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
assert st["used_today"] == 1
def test_reward_status_requires_auth(client) -> None:
"""客户端进度接口需登录。"""
r = client.get("/api/v1/ad/reward-status")
@@ -271,11 +305,12 @@ def test_feed_reward_grants_by_10_second_units(client) -> None:
"duration_seconds": 30,
"adn": "pangle",
"slot_id": "slot_feed",
"display_coin": 4,
}
r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
expected = sum(calculate_ad_reward_coin("200", i) for i in range(1, 4))
expected = 4
assert body["granted"] is True
assert body["status"] == "granted"
assert body["unit_count"] == 3
+3 -1
View File
@@ -64,7 +64,9 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None:
assert data["users"]["total"] >= 1
assert data["coins"]["granted_total"] >= 5000
assert "success_rate" in data["comparison"]
assert data["cps"]["available"] is False
assert data["cps"]["available"] is True
assert "meituan_order_count" in data["cps"]
assert "jd_order_count" in data["cps"]
def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None:
+125
View File
@@ -68,12 +68,137 @@ def test_sms_login_and_me_flow(client) -> None:
assert r.json()["ok"] is True
def test_wechat_login_creates_and_reuses_user(client, monkeypatch) -> None:
"""微信 code 登录:新 openid 注册,同 openid 重登回同一账号。"""
monkeypatch.setattr(
"app.api.v1.auth.code_to_userinfo",
lambda code: {
"openid": "openid_login_1",
"unionid": "union_1",
"nickname": "微信用户A",
"avatar_url": "https://wx/avatar-a.png",
"raw": {},
},
)
r = client.post("/api/v1/auth/wechat-login", json={"code": "code-a", "device_id": "dev-wx"})
assert r.status_code == 200, r.text
first = r.json()
assert first["user"]["register_channel"] == "wechat"
assert first["user"]["phone"].startswith("wx_")
assert first["user"]["nickname"] == "微信用户A"
r = client.post("/api/v1/auth/wechat-login", json={"code": "code-b", "device_id": "dev-wx"})
assert r.status_code == 200, r.text
second = r.json()
assert second["user"]["id"] == first["user"]["id"]
def test_wechat_login_uses_existing_bound_user(client, monkeypatch) -> None:
"""如果该微信已在提现页绑定过,微信登录应回到原手机号账号。"""
from app.db.session import SessionLocal
from app.repositories import user as user_repo
db = SessionLocal()
try:
user = user_repo.upsert_user_for_login(db, phone="13600136010", register_channel="sms")
user.wechat_openid = "openid_bound_1"
db.commit()
uid = user.id
finally:
db.close()
monkeypatch.setattr(
"app.api.v1.auth.code_to_userinfo",
lambda code: {
"openid": "openid_bound_1",
"unionid": None,
"nickname": "绑定微信",
"avatar_url": None,
"raw": {},
},
)
r = client.post("/api/v1/auth/wechat-login", json={"code": "code-bound", "device_id": "dev-wx"})
assert r.status_code == 200, r.text
body = r.json()
assert body["user"]["id"] == uid
assert body["user"]["phone"] == "13600136010"
def test_sms_send_too_frequent(client) -> None:
phone = "13900139000"
assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 200
assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 429
def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None:
"""发码防刷:同一设备(device_id) + 同一 IP 每小时最多 N 次发码,超出 429。
用不同手机号(绕开单号 60s 冷却)证明限流按设备封顶 换号绕开单号冷却/每日上限的洞
conftest 默认关限流;本用例临时打开 + 调小阈值便于测 + 清计数隔离"""
from app.api.v1 import auth
from app.core import ratelimit
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 3)
ratelimit._buckets.clear()
device = "dev-send-A"
# 同设备、每次换不同手机号(绕开单号冷却)发码,前 3 次 200
for i in range(3):
r = client.post(
"/api/v1/auth/sms/send",
json={"phone": f"137001370{i:02d}", "device_id": device},
)
assert r.status_code == 200, f"{i + 1} 次应放行: {r.text}"
# 第 4 次:同设备超限 → 429(即便又换了手机号)
r = client.post(
"/api/v1/auth/sms/send",
json={"phone": "13700137003", "device_id": device},
)
assert r.status_code == 429, r.text
# 换个设备 → 独立计数(限流键含 device_id),放行
r = client.post(
"/api/v1/auth/sms/send",
json={"phone": "13700137004", "device_id": "dev-send-B"},
)
assert r.status_code == 200, r.text
def test_sms_login_device_ip_rate_limit(client, monkeypatch) -> None:
"""防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试,超出 429。
conftest 默认 RATE_LIMIT_ENABLED=false(内存计数跨用例累加),本用例临时打开并清空计数隔离"""
from app.api.v1 import auth
from app.core import ratelimit
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
ratelimit._buckets.clear() # 隔离:清掉跨用例累加的内存计数
device = "dev-rl-A"
# 前 N 次故意每次换手机号、固定设备:mock 校验放行 → 200。证明限流按设备而非手机号,同设备共用一个桶
for i in range(auth.SMS_LOGIN_MAX_PER_HOUR):
r = client.post(
"/api/v1/auth/sms/login",
json={"phone": f"135331533{i:02d}", "code": "123456", "device_id": device},
)
assert r.status_code == 200, f"{i + 1} 次应放行: {r.text}"
# 第 N+1 次:同设备同 IP 超限 → 429(即便又换了个手机号)
r = client.post(
"/api/v1/auth/sms/login",
json={"phone": "13533153399", "code": "123456", "device_id": device},
)
assert r.status_code == 429, r.text
assert "频繁" in r.json()["detail"]
# 同 IP 换个设备 → 独立计数(限流键含 device_id、非纯 IP/手机号),仍放行
r = client.post(
"/api/v1/auth/sms/login",
json={"phone": "13533153300", "code": "123456", "device_id": "dev-rl-B"},
)
assert r.status_code == 200, r.text
def test_sms_login_bad_code(client) -> None:
"""mock 下校验位数:5 位过 schema 但业务要 6 位 → 400。"""
phone = "13700137000"
+3
View File
@@ -44,6 +44,7 @@ def _food_payload(trace_id: str) -> dict:
"skipped_dish_names": ["黑牛肉卷"],
"total_dish_count": 3,
"information": "在美团找到同店,到手价 ¥123.50",
"total_ms": 12345,
}
@@ -71,6 +72,8 @@ def test_report_and_derive(client) -> None:
assert d["information"] == "在美团找到同店,到手价 ¥123.50"
assert d["store_name"] == "海底捞(朝阳店)"
assert d["total_dish_count"] == 3
assert d["total_ms"] == 12345
assert d["raw_payload"]["total_ms"] == 12345
assert d["skipped_dish_count"] == 1
assert d["skipped_dish_names"] == ["黑牛肉卷"]
assert len(d["comparison_results"]) == 3
+253
View File
@@ -0,0 +1,253 @@
"""admin CPS 端点测试:每日明细 date 格式 + 按天按用户领券下钻(/day-users)。"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo
from app.admin.repositories import cps as cps_repo
from app.db.session import SessionLocal
from app.models.cps_link import CpsClick
from app.models.cps_order import CpsOrder
from app.models.cps_wx_user import CpsWxUser
from app.repositories import cps_link as cps_link_repo
_BJ = timezone(timedelta(hours=8))
@pytest.fixture()
def admin_client() -> TestClient:
return TestClient(admin_app)
@pytest.fixture()
def admin_token() -> str:
db = SessionLocal()
try:
if admin_repo.get_by_username(db, "cps_test_admin") is None:
admin_repo.create_admin(
db, username="cps_test_admin", password="cps-pass", role="super_admin"
)
finally:
db.close()
c = TestClient(admin_app)
r = c.post("/admin/api/auth/login", json={"username": "cps_test_admin", "password": "cps-pass"})
return r.json()["access_token"]
def _auth(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def _seed_cps_day(day_bj: date) -> tuple[int, str]:
"""造淘宝群 + 2 个活动(券) + 2 条 link + day_bj 当天点击(含匿名 + 次日各 1) + 授权画像。
o_user(本群唯一):券A visit×3券B visit×2copy×2 visit_count=5copy_count=2
另造 1 条匿名 visit(openid=None) 1 条次日 visit,均不应计入当天该用户聚合
clicked_at 统一存 tz-aware UTC(SQLite DateTime 按字段渲染忽略 tzinfo;
UTC 后字段即 UTC 墙钟, repo _as_utc 比较口径一致)返回 (group_id, openid)
"""
db = SessionLocal()
try:
gid = cps_repo.create_group(db, name="下钻测试群", platforms=["taobao"]).id
openid = f"od_user_{gid}" # 按群唯一,规避 CpsWxUser.openid 唯一约束跨用例冲突
a1 = cps_repo.create_activity(db, name="618神券", platform="taobao", payload="tkl-1")
a2 = cps_repo.create_activity(db, name="买一送一", platform="taobao", payload="tkl-2")
link1 = cps_link_repo.create_link(
db, group_id=gid, activity_id=a1.id, sid=None, target_url="t1", platform="taobao"
)
link2 = cps_link_repo.create_link(
db, group_id=gid, activity_id=a2.id, sid=None, target_url="t2", platform="taobao"
)
db.add(CpsWxUser(openid=openid, nickname="张三", headimgurl="https://h/1"))
db.commit()
def at(hour: int) -> datetime:
return datetime(day_bj.year, day_bj.month, day_bj.day, hour, tzinfo=_BJ).astimezone(
timezone.utc
)
for _ in range(3):
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="visit", openid=openid, clicked_at=at(10)))
for _ in range(2):
db.add(CpsClick(link_id=link2.id, group_id=gid, sid=None,
event_type="visit", openid=openid, clicked_at=at(11)))
for _ in range(2):
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="copy", openid=openid, clicked_at=at(12)))
# 匿名点击 — 不计入
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="visit", openid=None, clicked_at=at(13)))
# 次日点击 — 验证时间窗,不计入当天
nxt = (datetime(day_bj.year, day_bj.month, day_bj.day, 10, tzinfo=_BJ)
+ timedelta(days=1)).astimezone(timezone.utc)
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="visit", openid=openid, clicked_at=nxt))
db.commit()
return gid, openid
finally:
db.close()
def test_daily_date_is_full_iso(admin_client: TestClient, admin_token: str) -> None:
"""/daily 每行 date 改为 YYYY-MM-DD(10 字符、两个连字符),不再是 MM-DD。"""
gid, _ = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/daily", params={"days": 3}, headers=_auth(admin_token)
)
assert r.status_code == 200, r.text
rows = r.json()["rows"]
assert rows, "应有按天补零行"
for row in rows:
assert len(row["date"]) == 10 and row["date"].count("-") == 2, row["date"]
def test_day_users_aggregates(admin_client: TestClient, admin_token: str) -> None:
"""当天该群:仅授权用户;领券=copy、点击=visit;coupons=visit 券×次数倒序、合计=点击次数。"""
gid, openid = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2026-06-25"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
body = r.json()
assert body["group_id"] == gid
assert body["date"] == "2026-06-25"
users = body["users"]
assert len(users) == 1 # 匿名不计、次日不计
u = users[0]
assert u["openid"] == openid
assert u["nickname"] == "张三"
assert u["headimgurl"] == "https://h/1"
assert u["copy_count"] == 2
assert u["visit_count"] == 5
assert [c["name"] for c in u["coupons"]] == ["618神券", "买一送一"]
assert [c["count"] for c in u["coupons"]] == [3, 2]
assert sum(c["count"] for c in u["coupons"]) == u["visit_count"]
def test_day_users_group_not_found(admin_client: TestClient, admin_token: str) -> None:
r = admin_client.get(
"/admin/api/cps/groups/999999/day-users",
params={"date": "2026-06-25"},
headers=_auth(admin_token),
)
assert r.status_code == 404
def test_day_users_bad_date(admin_client: TestClient, admin_token: str) -> None:
gid, _ = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2026/06/25"}, # 非 YYYY-MM-DD
headers=_auth(admin_token),
)
assert r.status_code == 400
def test_day_users_requires_auth(admin_client: TestClient) -> None:
r = admin_client.get(
"/admin/api/cps/groups/1/day-users", params={"date": "2026-06-25"}
)
assert r.status_code == 401
def test_day_users_empty_when_no_clicks(admin_client: TestClient, admin_token: str) -> None:
gid, _ = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2026-06-20"}, # 该群当天无任何点击
headers=_auth(admin_token),
)
assert r.status_code == 200
assert r.json()["users"] == []
def test_day_users_cross_year(admin_client: TestClient, admin_token: str) -> None:
"""跨年:YYYY-MM-DD 才能精确定位 12-31(MM-DD 会丢年份);次日(次年 01-01)不计入。"""
gid, openid = _seed_cps_day(date(2025, 12, 31))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2025-12-31"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
users = r.json()["users"]
assert len(users) == 1
assert users[0]["openid"] == openid
assert users[0]["visit_count"] == 5 # 次年 01-01 那条被时间窗排除
def test_jd_reconcile_updates_dashboard(
admin_client: TestClient, admin_token: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""京东拉单:按订单行入库;大盘只统计有效 validCode 的佣金。"""
def fake_query_order_rows(**kwargs):
start = kwargs["start_time"].astimezone(_BJ)
if start.hour != 10 or kwargs["page_index"] != 1:
return {"rows": [], "has_more": False}
return {
"rows": [
{
"id": "pytest-jd-row-valid",
"orderId": "pytest-jd-order-1",
"skuId": "sku-1",
"skuName": "京东测试商品",
"orderTime": "2026-06-25 10:10:00",
"modifyTime": "2026-06-25 10:20:00",
"validCode": "16",
"estimateCosPrice": "19.90",
"estimateFee": "1.23",
"actualFee": "2.34",
"commissionRate": "10.00",
},
{
"id": "pytest-jd-row-invalid",
"orderId": "pytest-jd-order-2",
"skuId": "sku-2",
"skuName": "京东无效订单",
"orderTime": "2026-06-25 10:15:00",
"modifyTime": "2026-06-25 10:25:00",
"validCode": "4",
"estimateCosPrice": "9.90",
"estimateFee": "0.50",
"commissionRate": "5.00",
},
],
"has_more": False,
}
monkeypatch.setattr("app.integrations.jd_union.query_order_rows", fake_query_order_rows)
r = admin_client.post(
"/admin/api/cps/orders/reconcile",
params={
"platform": "jd",
"date_from": "2026-06-25",
"date_to": "2026-06-25",
"query_time_type": 1,
},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
assert r.json()["fetched"] == 2
with SessionLocal() as db:
rows = db.query(CpsOrder).filter(CpsOrder.platform == "jd").all()
assert len([o for o in rows if o.order_id.startswith("jd:pytest-jd-row")]) == 2
overview = admin_client.get(
"/admin/api/stats/overview",
params={"date_from": "2026-06-25", "date_to": "2026-06-25"},
headers=_auth(admin_token),
)
assert overview.status_code == 200, overview.text
cps = overview.json()["cps"]
assert cps["jd_order_count"] >= 1
assert cps["jd_commission_cents"] >= 234
assert cps["jd_invalid_count"] >= 1
+87
View File
@@ -0,0 +1,87 @@
"""美团/京东 CPS 落地页「领券前授权头像」插页(/c/{code})。
验证:微信内未授权头像 出插页(snsapi_userinfo 按钮 + 直接领券出口);
已授权(wx_uinfo cookie) / 非微信 直接 302 target
插页只渲染 HTML不真调微信,故无需 mock 微信网络
"""
from __future__ import annotations
import pytest
from app.core.config import settings
from app.db.session import SessionLocal
from app.repositories import cps_link as cps_link_repo
_WECHAT_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) MicroMessenger/8.0.40"
_OTHER_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) Safari/605.1"
@pytest.fixture()
def _wx_oauth_on(monkeypatch):
"""打开微信网页授权(插页只在 settings.wx_oauth_active 时出)。"""
monkeypatch.setattr(settings, "WX_MP_APPID", "wxtestappid")
monkeypatch.setattr(settings, "WX_MP_SECRET", "wxtestsecret")
monkeypatch.setattr(settings, "WX_MP_OAUTH_ENABLED", True)
monkeypatch.setattr(settings, "CPS_REDIRECT_BASE", "https://coupon.example.com")
def _make_link(platform: str, target: str) -> tuple[str, str]:
with SessionLocal() as db:
link = cps_link_repo.create_link(
db, group_id=1, activity_id=1,
sid="g1" if platform == "meituan" else None,
target_url=target, platform=platform,
)
return link.code, link.target_url
@pytest.mark.parametrize("platform,target", [
("meituan", "https://dpurl.cn/mt-abc"),
("jd", "https://u.jd.com/jd-xyz"),
])
def test_wechat_unauthorized_shows_interstitial(client, _wx_oauth_on, platform, target):
"""微信内 + 有 openid + 无 wx_uinfo → 200 插页:userinfo 授权按钮 + 直接领券出口。"""
code, tgt = _make_link(platform, target)
r = client.get(
f"/c/{code}",
headers={"user-agent": _WECHAT_UA, "cookie": "wx_openid=o_test"},
follow_redirects=False,
)
assert r.status_code == 200, r.text
body = r.text
assert "snsapi_userinfo" in body # 唯一按钮走 userinfo 授权(交互手势触发)
assert code in body # 授权 state=uinfo:{code}
assert tgt in body # 取消授权时前端转跳的目标券页(绝不阻断领券)
assert "暂不授权" not in body # 已去掉单独的"暂不授权"出口,改为取消即自动转跳
def test_authorized_redirects_to_target(client, _wx_oauth_on):
"""已授权头像(wx_uinfo=1)→ 不再出插页,直接 302 跳 target。"""
code, tgt = _make_link("meituan", "https://dpurl.cn/mt-ok")
r = client.get(
f"/c/{code}",
headers={"user-agent": _WECHAT_UA, "cookie": "wx_openid=o_test; wx_uinfo=1"},
follow_redirects=False,
)
assert r.status_code == 302
assert r.headers["location"] == tgt
def test_non_wechat_redirects_to_target(client, _wx_oauth_on):
"""非微信浏览器(拿不了授权)→ 直接 302 跳 target,不出插页。"""
code, tgt = _make_link("meituan", "https://dpurl.cn/mt-other")
r = client.get(
f"/c/{code}",
headers={"user-agent": _OTHER_UA, "cookie": "wx_openid=o_test"},
follow_redirects=False,
)
assert r.status_code == 302
assert r.headers["location"] == tgt
def test_oauth_cb_cancel_no_code_redirects_back(client):
"""授权弹窗点「取消」、微信带空 code 回调 → 兜底 302 回落地页(不报 422)。
落地页再由前端转跳目标券页,保证拒绝也能拿到券"""
r = client.get("/wx/oauth/cb?state=uinfo:ABC123", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/c/ABC123"
+317
View File
@@ -0,0 +1,317 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
from app.api.v1 import device as device_api
from app.core import heartbeat_monitor_worker
from app.db.session import SessionLocal
from app.integrations import vendor_push
from app.models.device import DeviceLiveness
from app.repositories import user as user_repo
class _Resp:
status_code = 200
text = "{}"
def __init__(self, data: dict) -> None:
self._data = data
def json(self) -> dict:
return self._data
def test_xiaomi_accessibility_payload(monkeypatch) -> None:
captured: dict = {}
def _fake_request(method, url, **kwargs): # noqa: ANN001
captured.update(method=method, url=url, **kwargs)
return _Resp({"code": 0, "result": "ok", "data": {"id": "xm-msg"}})
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret")
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_CHANNEL_ID", "")
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_ID", "")
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_TITLE", "")
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_DESCRIPTION", "")
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_PARAM_JSON", "")
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
data = vendor_push.send_accessibility_disabled("xiaomi", "xm-regid")
assert data["data"]["id"] == "xm-msg"
assert captured["method"] == "POST"
assert captured["url"] == vendor_push.settings.XIAOMI_PUSH_SEND_ENDPOINT
assert captured["headers"]["Authorization"] == "key=xiaomi-secret"
body = captured["data"]
assert body["registration_id"] == "xm-regid"
assert body["restricted_package_name"] == "com.jishisongfu.shaguabijia"
assert json.loads(body["payload"]) == {"type": "accessibility_disabled"}
assert "extra.channel_id" not in body
def test_xiaomi_payload_with_channel_and_template(monkeypatch) -> None:
captured: dict = {}
def _fake_request(method, url, **kwargs): # noqa: ANN001
captured.update(method=method, url=url, **kwargs)
return _Resp({"code": 0, "result": "ok", "data": {"id": "xm-msg"}})
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret")
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_CHANNEL_ID", "130")
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_ID", "1001")
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_TITLE", "{$app_name$}提醒")
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_DESCRIPTION", "{$content$}")
monkeypatch.setattr(
vendor_push.settings,
"XIAOMI_PUSH_TEMPLATE_PARAM_JSON",
'{"app_name":"傻瓜比价","content":"{alert}"}',
)
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
vendor_push.send_accessibility_disabled(
"xiaomi",
"xm-regid",
title="测试标题",
alert="测试内容",
)
body = captured["data"]
assert body["title"] == "{$app_name$}提醒"
assert body["description"] == "{$content$}"
assert body["extra.channel_id"] == "130"
assert body["extra.template_id"] == "1001"
assert body["extra.template_param"] == '{"app_name":"傻瓜比价","content":"测试内容"}'
def test_vivo_auth_and_send_payload(monkeypatch) -> None:
vendor_push._token_cache.clear()
calls: list[dict] = []
def _fake_request(method, url, **kwargs): # noqa: ANN001
calls.append({"method": method, "url": url, **kwargs})
if url == vendor_push.settings.VIVO_PUSH_AUTH_ENDPOINT:
return _Resp({"result": 0, "authToken": "vivo-auth"})
return _Resp({"result": 0, "taskId": "vivo-task"})
monkeypatch.setattr(vendor_push.settings, "VIVO_PUSH_APP_ID", "106072775")
monkeypatch.setattr(vendor_push.settings, "VIVO_PUSH_APP_KEY", "vivo-key")
monkeypatch.setattr(vendor_push.settings, "VIVO_PUSH_APP_SECRET", "vivo-secret")
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
data = vendor_push.send_accessibility_disabled("vivo", "vivo-regid")
assert data["taskId"] == "vivo-task"
assert calls[0]["url"] == vendor_push.settings.VIVO_PUSH_AUTH_ENDPOINT
assert calls[0]["json"]["appId"] == "106072775"
assert calls[0]["json"]["sign"]
assert calls[1]["url"] == vendor_push.settings.VIVO_PUSH_SEND_ENDPOINT
assert calls[1]["headers"]["authToken"] == "vivo-auth"
body = calls[1]["json"]
assert body["regId"] == "vivo-regid"
assert body["pushMode"] == vendor_push.settings.VIVO_PUSH_MODE
assert body["clientCustomMap"] == {"type": "accessibility_disabled"}
def test_oppo_auth_and_send_payload(monkeypatch) -> None:
vendor_push._token_cache.clear()
calls: list[dict] = []
def _fake_request(method, url, **kwargs): # noqa: ANN001
calls.append({"method": method, "url": url, **kwargs})
if url == vendor_push.settings.OPPO_PUSH_AUTH_ENDPOINT:
return _Resp({"code": 0, "data": {"auth_token": "oppo-auth"}})
return _Resp({"code": 0, "data": {"message_id": "oppo-msg"}})
monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_APP_KEY", "oppo-key")
monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_MASTER_SECRET", "oppo-master")
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
data = vendor_push.send_accessibility_disabled("oppo", "oppo-regid")
assert data["data"]["message_id"] == "oppo-msg"
assert calls[0]["data"]["app_key"] == "oppo-key"
assert calls[0]["data"]["sign"]
message = json.loads(calls[1]["data"]["message"])
assert calls[1]["data"]["auth_token"] == "oppo-auth"
assert message["target_type"] == 2
assert message["target_value"] == "oppo-regid"
assert json.loads(message["notification"]["action_parameters"]) == {
"type": "accessibility_disabled"
}
def test_honor_auth_and_send_payload(monkeypatch) -> None:
vendor_push._token_cache.clear()
calls: list[dict] = []
def _fake_request(method, url, **kwargs): # noqa: ANN001
calls.append({"method": method, "url": url, **kwargs})
if url == vendor_push.settings.HONOR_PUSH_TOKEN_ENDPOINT:
return _Resp({"access_token": "honor-access", "expires_in": 3600})
return _Resp({"code": 200, "message": "successful!", "data": {"sendResult": True}})
monkeypatch.setattr(vendor_push.settings, "HONOR_PUSH_APP_ID", "104559789")
monkeypatch.setattr(vendor_push.settings, "HONOR_PUSH_CLIENT_ID", "honor-client")
monkeypatch.setattr(vendor_push.settings, "HONOR_PUSH_CLIENT_SECRET", "honor-secret")
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
data = vendor_push.send_accessibility_disabled("honor", "honor-token")
assert data["code"] == 200
assert calls[0]["data"]["client_id"] == "honor-client"
assert calls[1]["headers"]["Authorization"] == "Bearer honor-access"
assert calls[1]["headers"]["timestamp"]
assert calls[1]["url"].endswith("/api/v1/104559789/sendMessage")
body = calls[1]["json"]
assert body["token"] == ["honor-token"]
assert body["android"]["targetUserType"] == 1
assert body["android"]["notification"]["clickAction"] == {"type": 3}
assert json.loads(body["data"]) == {"type": "accessibility_disabled"}
def _seed_overdue_device(
*,
phone: str,
device_id: str,
push_vendor: str | None,
push_token: str | None,
) -> int:
with SessionLocal() as db:
user = user_repo.upsert_user_for_login(db, phone=phone, register_channel="sms")
device = DeviceLiveness(
user_id=user.id,
device_id=device_id,
push_vendor=push_vendor,
push_token=push_token,
platform="android",
ever_protected=True,
last_heartbeat_at=datetime.now(timezone.utc) - timedelta(minutes=30), # noqa: UP017
last_report_protection_on=True,
liveness_state="alive",
kill_alert_pending=False,
)
db.add(device)
db.commit()
db.refresh(device)
return device.id
def _login(client: TestClient, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def test_heartbeat_monitor_pushes_overdue_device(monkeypatch) -> None:
device_pk = _seed_overdue_device(
phone="13900009001",
device_id="dev-push-honor",
push_vendor="honor",
push_token="honor-token-1",
)
calls: list[tuple[str, str]] = []
def _fake_send(push_vendor: str, push_token: str) -> dict:
calls.append((push_vendor, push_token))
return {"msg_id": "m1"}
monkeypatch.setattr(
heartbeat_monitor_worker.vendor_push,
"send_accessibility_disabled",
_fake_send,
)
result = heartbeat_monitor_worker._scan_once(timeout_minutes=10)
assert result["pushed"] >= 1
assert ("honor", "honor-token-1") in calls
with SessionLocal() as db:
device = db.get(DeviceLiveness, device_pk)
assert device is not None
assert device.liveness_state == "notified"
assert device.kill_alert_pending is True
def test_heartbeat_monitor_skips_push_without_vendor_token(monkeypatch) -> None:
device_pk = _seed_overdue_device(
phone="13900009002",
device_id="dev-push-no-token",
push_vendor=None,
push_token=None,
)
def _fake_send(push_vendor: str, push_token: str) -> dict:
raise AssertionError(f"should not push without token: {push_vendor}/{push_token}")
monkeypatch.setattr(
heartbeat_monitor_worker.vendor_push,
"send_accessibility_disabled",
_fake_send,
)
result = heartbeat_monitor_worker._scan_once(timeout_minutes=10)
assert result["checked"] >= 1
with SessionLocal() as db:
device = db.get(DeviceLiveness, device_pk)
assert device is not None
assert device.liveness_state == "notified"
assert device.kill_alert_pending is True
def test_push_test_endpoint_schedules_vendor_push(client: TestClient, monkeypatch) -> None:
token = _login(client, "13900009003")
calls: list[tuple[str, str, str, str]] = []
def _fake_send(push_vendor: str, push_token: str, *, title: str, alert: str) -> dict:
calls.append((push_vendor, push_token, title, alert))
return {"msg_id": "m-test"}
monkeypatch.setattr(device_api.vendor_push, "send_accessibility_disabled", _fake_send)
r = client.post(
"/api/v1/device/push-test",
json={
"device_id": "dev-push-test",
"push_vendor": "honor",
"push_token": "honor-test-token",
"delay_seconds": 0,
},
headers=_auth(token),
)
assert r.status_code == 200, r.text
assert r.json() == {
"ok": True,
"delay_seconds": 0,
"has_push_token": True,
}
assert calls == [
(
"honor",
"honor-test-token",
"测试推送",
"这是一条厂商通道测试推送。收到它说明 App 被划掉后仍可通过系统通知栏触达。",
)
]
def test_push_test_endpoint_requires_vendor_token(client: TestClient) -> None:
token = _login(client, "13900009004")
r = client.post(
"/api/v1/device/push-test",
json={"device_id": "dev-push-test-no-token", "delay_seconds": 0},
headers=_auth(token),
)
assert r.status_code == 409
assert r.json()["detail"] == "push vendor token not ready"
+1
View File
@@ -14,6 +14,7 @@ def test_openapi_loads(client) -> None:
paths = resp.json()["paths"]
# auth endpoints 都注册了
assert "/api/v1/auth/jverify-login" in paths
assert "/api/v1/auth/wechat-login" in paths
assert "/api/v1/auth/sms/send" in paths
assert "/api/v1/auth/sms/login" in paths
assert "/api/v1/auth/refresh" in paths
+14 -15
View File
@@ -1,4 +1,4 @@
"""好友邀请测试:邀请码、绑定双方发金币、幂等、自邀/无效码屏蔽、指纹兜底归因。
"""好友邀请测试:邀请码、绑定(双方不发钱)、幂等、自邀/无效码屏蔽、指纹兜底归因。
sms mock 登录拿 token( test_welfare),再跑邀请闭环
"""
@@ -10,8 +10,6 @@ from sqlalchemy import select
from app.core.rewards import (
INVITE_FP_WINDOW_DAYS,
INVITE_INVITEE_COINS,
INVITE_INVITER_COINS,
INVITE_NEW_USER_WINDOW_HOURS,
)
from app.db.session import SessionLocal
@@ -56,8 +54,8 @@ def test_invite_me_returns_stable_code(client) -> None:
assert _my_code(client, token) == body["invite_code"]
def test_bind_flow_both_get_coins(client) -> None:
"""B 用 A 的码绑定 → 双方各得 1 万金币;A 战绩 +1"""
def test_bind_flow_no_coins_either_side(client) -> None:
"""v3:B 用 A 的码绑定 → 双方都不发钱(被邀请人无奖励、邀请人改比价后发现金)"""
a = _login(client, "13800002002")
b = _login(client, "13800002003")
a_code = _my_code(client, a)
@@ -69,16 +67,16 @@ def test_bind_flow_both_get_coins(client) -> None:
assert r.status_code == 200, r.text
res = r.json()
assert res["status"] == "success"
assert res["coins_awarded"] == INVITE_INVITEE_COINS
assert res["coins_awarded"] == 0
# 双方金币到账
assert _coin_balance(client, b) == INVITE_INVITEE_COINS
assert _coin_balance(client, a) == INVITE_INVITER_COINS
# 绑定后双方金币都不增(邀请人收益改走"好友比价发 2 元邀请奖励金")
assert _coin_balance(client, b) == 0
assert _coin_balance(client, a) == 0
# A 的战绩:已邀 1 人,累计获得 = 邀请人那份
# A 的战绩:已邀 1 人;金币口径收益恒 0(邀请人收益走邀请奖励金,见 try_reward_on_compare)
r = client.get("/api/v1/invite/me", headers=_auth(a))
assert r.json()["invited_count"] == 1
assert r.json()["coins_earned"] == INVITE_INVITER_COINS
assert r.json()["coins_earned"] == 0
def test_bind_idempotent_no_double_reward(client) -> None:
@@ -217,9 +215,9 @@ def test_bind_by_fingerprint_success(client) -> None:
)
assert r2.status_code == 200, r2.text
assert r2.json()["status"] == "success"
# 双方各发金币
assert _coin_balance(client, a) == INVITE_INVITER_COINS
assert _coin_balance(client, b) == INVITE_INVITEE_COINS
# v3:绑定双方都不发钱(被邀请人无奖励、邀请人改比价发现金)
assert _coin_balance(client, a) == 0
assert _coin_balance(client, b) == 0
def test_bind_by_fingerprint_not_found(client) -> None:
@@ -322,7 +320,8 @@ def test_invitees_basic(client) -> None:
names = {it["display_name"] for it in body["items"]}
assert names == expected_names
assert all(it["avatar_url"] is None for it in body["items"])
assert all(it["coins"] == INVITE_INVITER_COINS for it in body["items"])
# v2:绑定时邀请人那份为 0(收益改"好友比价才发奖"),故每条 coins 字段=0
assert all(it["coins"] == 0 for it in body["items"])
def test_invitees_order_desc(client) -> None:
+193
View File
@@ -0,0 +1,193 @@
"""邀请奖励金提现账户隔离测试:提现扣 invite_cash 账户、退款退回 invite_cash、与金币现金不串、
/invite/me 返回奖励金战绩提现单 source 过滤复用 test_withdraw wxpay monkeypatch 模式
"""
from __future__ import annotations
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.user import User
from app.models.wallet import CoinAccount, InviteCashTransaction
from app.repositories import wallet as crud_wallet
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _patch_userinfo(monkeypatch, openid: str) -> None:
monkeypatch.setattr(
"app.integrations.wxpay.code_to_userinfo",
lambda code: {"openid": openid, "nickname": None, "avatar_url": None, "raw": {}},
)
def _seed_balances(client, token: str, phone: str, *, cash: int = 0, invite_cash: int = 0) -> None:
"""访问 /account 触发建账户,再 DB 直接灌两个账户余额(没有"加钱"接口,正常靠兑换/发奖)。"""
client.get("/api/v1/wallet/account", headers=_auth(token))
db = SessionLocal()
try:
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
acc = db.get(CoinAccount, user.id)
acc.cash_balance_cents = cash
acc.invite_cash_balance_cents = invite_cash
db.commit()
finally:
db.close()
def _balances(client, token: str) -> tuple[int, int]:
j = client.get("/api/v1/wallet/account", headers=_auth(token)).json()
return j["cash_balance_cents"], j["invite_cash_balance_cents"]
def _reject(bill: str, reason: str = "测试拒绝") -> None:
db = SessionLocal()
try:
crud_wallet.reject_withdraw(db, bill, reason)
finally:
db.close()
def test_invite_cash_withdraw_deducts_invite_account(client, monkeypatch) -> None:
"""source=invite_cash 提现 → 扣 invite_cash_balance_cents,不动 cash;流水落 invite_cash_transaction。"""
_patch_userinfo(monkeypatch, "openid_ic_1")
token = _login(client, "13800004001")
_seed_balances(client, token, "13800004001", cash=300, invite_cash=500)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 200, "source": "invite_cash"},
headers=_auth(token),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "reviewing"
cash, invite_cash = _balances(client, token)
assert invite_cash == 300 # 扣了邀请奖励金
assert cash == 300 # 金币现金没动
db = SessionLocal()
try:
user = db.execute(select(User).where(User.phone == "13800004001")).scalar_one()
txns = db.execute(
select(InviteCashTransaction).where(
InviteCashTransaction.user_id == user.id,
InviteCashTransaction.biz_type == "invite_withdraw",
)
).scalars().all()
assert len(txns) == 1 and txns[0].amount_cents == -200
finally:
db.close()
orders = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token)).json()["items"]
assert orders[0]["source"] == "invite_cash"
def test_invite_cash_reject_refunds_invite_account(client, monkeypatch) -> None:
"""拒绝 invite_cash 提现 → 退回 invite_cash,不串金币现金;退款流水落 invite_cash_transaction。"""
_patch_userinfo(monkeypatch, "openid_ic_2")
token = _login(client, "13800004002")
_seed_balances(client, token, "13800004002", cash=0, invite_cash=500)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 200, "source": "invite_cash"},
headers=_auth(token),
)
bill = r.json()["out_bill_no"]
cash, invite_cash = _balances(client, token)
assert invite_cash == 300 and cash == 0 # 扣后
_reject(bill)
cash, invite_cash = _balances(client, token)
assert invite_cash == 500 # 退回邀请奖励金
assert cash == 0 # 没串进现金
db = SessionLocal()
try:
user = db.execute(select(User).where(User.phone == "13800004002")).scalar_one()
refunds = db.execute(
select(InviteCashTransaction).where(
InviteCashTransaction.user_id == user.id,
InviteCashTransaction.biz_type == "invite_withdraw_refund",
)
).scalars().all()
assert len(refunds) == 1 and refunds[0].amount_cents == 200
finally:
db.close()
def test_two_accounts_withdraw_independent(client, monkeypatch) -> None:
"""两账户各提各的不串:先提 invite_cash(拒绝结清),再提 cash,各扣各账户。
:一个用户同一时间只能一个活跃提现单(跨账户),故第二笔需先结清第一笔"""
_patch_userinfo(monkeypatch, "openid_ic_3")
token = _login(client, "13800004003")
_seed_balances(client, token, "13800004003", cash=400, invite_cash=500)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r1 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 200, "source": "invite_cash"},
headers=_auth(token),
)
_reject(r1.json()["out_bill_no"]) # 退回 invite_cash + 结清活跃单
r2 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 100, "source": "coin_cash"},
headers=_auth(token),
)
assert r2.json()["status"] == "reviewing"
cash, invite_cash = _balances(client, token)
assert invite_cash == 500 # 已退回
assert cash == 300 # 扣了 cash 100
def test_invite_me_returns_reward_stats(client) -> None:
"""/invite/me 返回 reward_balance_cents(可提现奖励金)。"""
token = _login(client, "13800004004")
_seed_balances(client, token, "13800004004", invite_cash=350)
j = client.get("/api/v1/invite/me", headers=_auth(token)).json()
assert j["reward_balance_cents"] == 350
assert j["reward_withdrawn_cents"] == 0
def test_withdraw_orders_source_filter(client, monkeypatch) -> None:
"""/wallet/withdraw-orders?source=invite_cash 只返回邀请奖励金提现单。"""
_patch_userinfo(monkeypatch, "openid_ic_5")
token = _login(client, "13800004005")
_seed_balances(client, token, "13800004005", cash=400, invite_cash=500)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r1 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 200, "source": "invite_cash"},
headers=_auth(token),
)
_reject(r1.json()["out_bill_no"]) # 结清,才能提第二笔
client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 100, "source": "coin_cash"},
headers=_auth(token),
)
all_orders = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token)).json()["items"]
invite_orders = client.get(
"/api/v1/wallet/withdraw-orders", params={"source": "invite_cash"}, headers=_auth(token)
).json()["items"]
coin_orders = client.get(
"/api/v1/wallet/withdraw-orders", params={"source": "coin_cash"}, headers=_auth(token)
).json()["items"]
assert len(all_orders) == 2
assert len(invite_orders) == 1 and invite_orders[0]["source"] == "invite_cash"
assert len(coin_orders) == 1 and coin_orders[0]["source"] == "coin_cash"
+114
View File
@@ -0,0 +1,114 @@
"""邀请 v3 比价发奖测试:好友完成成功比价 → 邀请人得邀请奖励金(独立账户),幂等只发一次,
账户隔离不串金币/现金被邀请人绑定不得任何奖励由 test_invite.py 覆盖
"""
from __future__ import annotations
from sqlalchemy import select
from app.core.rewards import INVITE_COMPARE_REWARD_CENTS
from app.db.session import SessionLocal
from app.models.wallet import CoinAccount, InviteCashTransaction
from app.repositories.user import get_user_by_phone
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _my_code(client, token: str) -> str:
return client.get("/api/v1/invite/me", headers=_auth(token)).json()["invite_code"]
def _bind(client, token: str, code: str):
return client.post("/api/v1/invite/bind", json={"invite_code": code}, headers=_auth(token))
def _report_compare(client, token: str, trace_id: str, status: str = "success"):
return client.post(
"/api/v1/compare/record",
json={"trace_id": trace_id, "status": status},
headers=_auth(token),
)
def _invite_cash(phone: str) -> int:
"""被试用户的邀请奖励金余额(分)。专用查询端点见小步3,这里直接查 DB。"""
with SessionLocal() as db:
u = get_user_by_phone(db, phone)
acc = db.get(CoinAccount, u.id) if u else None
return acc.invite_cash_balance_cents if acc else 0
def test_compare_reward_granted_to_inviter(client) -> None:
"""B 被 A 邀请后完成一次成功比价 → A 得邀请奖励金 2 元(独立账户),B 不得该奖。"""
a = _login(client, "13800003001")
b = _login(client, "13800003002")
_bind(client, b, _my_code(client, a))
assert _invite_cash("13800003001") == 0 # 发奖前
r = _report_compare(client, b, "trace-reward-1")
assert r.status_code == 200, r.text
assert _invite_cash("13800003001") == INVITE_COMPARE_REWARD_CENTS # 邀请人到账
assert _invite_cash("13800003002") == 0 # 被邀请人不得此奖
def test_compare_reward_idempotent(client) -> None:
"""好友比价多次 → 只发一次(compare_reward_granted 幂等)。"""
a = _login(client, "13800003003")
b = _login(client, "13800003004")
_bind(client, b, _my_code(client, a))
_report_compare(client, b, "trace-idem-1")
_report_compare(client, b, "trace-idem-2") # 第二次比价(不同 trace)
_report_compare(client, b, "trace-idem-1") # 重复上报同 trace
assert _invite_cash("13800003003") == INVITE_COMPARE_REWARD_CENTS # 仍只发一次
def test_compare_no_relation_no_reward(client) -> None:
"""没有邀请关系的人比价 → 不发奖(没人是他的邀请人)。"""
x = _login(client, "13800003005")
r = _report_compare(client, x, "trace-norel-1")
assert r.status_code == 200, r.text
assert _invite_cash("13800003005") == 0
def test_compare_failed_no_reward(client) -> None:
"""失败的比价(status=failed)不触发发奖。"""
a = _login(client, "13800003006")
b = _login(client, "13800003007")
_bind(client, b, _my_code(client, a))
_report_compare(client, b, "trace-fail-1", status="failed")
assert _invite_cash("13800003006") == 0
def test_compare_reward_isolated_from_coin_cash(client) -> None:
"""账户隔离:邀请奖励金进 invite_cash,不串 cash_balance_cents;流水落 invite_cash_transaction。"""
a = _login(client, "13800003008")
b = _login(client, "13800003009")
_bind(client, b, _my_code(client, a))
_report_compare(client, b, "trace-iso-1")
with SessionLocal() as db:
ua = get_user_by_phone(db, "13800003008")
acc = db.get(CoinAccount, ua.id)
assert acc.invite_cash_balance_cents == INVITE_COMPARE_REWARD_CENTS # 奖励金到账
assert acc.cash_balance_cents == 0 # 没串进金币现金
txns = db.execute(
select(InviteCashTransaction).where(
InviteCashTransaction.user_id == ua.id,
InviteCashTransaction.biz_type == "invite_reward",
)
).scalars().all()
assert len(txns) == 1
assert txns[0].amount_cents == INVITE_COMPARE_REWARD_CENTS
+48
View File
@@ -142,6 +142,54 @@ def test_send_does_not_consume_quota(client, enabled, monkeypatch) -> None:
assert client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "1234"}).status_code == 429
# ============================ 登录限流豁免 ============================
def test_exempt_from_device_ip_login_rate_limit(client, enabled, monkeypatch) -> None:
"""测试号豁免 (设备+IP) 每小时登录限流。
普通设备同设备同 IP SMS_LOGIN_MAX_PER_HOUR 次即被拦( test_auth.test_sms_login_device_ip_rate_limit);
测试号走自己的每日额度不受这道限流这里临时打开 RATE_LIMIT_ENABLED,同一设备连登远超该上限仍全 200
回归用:防有人把 enforce_rate_limit 挪到测试账号 early-return 之前而破坏豁免"""
from app.api.v1 import auth
from app.core import ratelimit
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
ratelimit._buckets.clear() # 隔离:清掉跨用例累加的内存计数
# 同一设备连登 (每小时上限 + 3) 次,远超普通设备会被拦的阈值;测试号豁免 → 全部放行
for i in range(auth.SMS_LOGIN_MAX_PER_HOUR + 3):
r = client.post(
"/api/v1/auth/sms/login",
json={"phone": TEST_PHONE, "code": "000000", "device_id": "dev-test-exempt"},
)
assert r.status_code == 200, (
f"测试号第 {i + 1} 次登录应放行(豁免每小时限流),实际 {r.status_code}: {r.text}"
)
def test_exempt_from_device_ip_send_rate_limit(client, enabled, monkeypatch) -> None:
"""测试号豁免 (设备+IP) 每小时**发码**限流(与登录那道同样靠早返回豁免)。
普通设备同设备到 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码即被拦( test_auth.test_sms_send_device_ip_rate_limit);
测试号发码本就短路(不真发)且在限流之前早返回 同一设备连发远超上限仍全 200
回归用:防有人把发码限流挪到测试账号 early-return 之前而把 QA 联调也风控了"""
from app.api.v1 import auth
from app.core import ratelimit
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 2) # 调小:不豁免的话第 3 次就该被拦
ratelimit._buckets.clear()
for i in range(auth.SMS_SEND_MAX_PER_HOUR_PER_DEVICE + 2):
r = client.post(
"/api/v1/auth/sms/send",
json={"phone": TEST_PHONE, "device_id": "dev-test-send"},
)
assert r.status_code == 200, (
f"测试号第 {i + 1} 次发码应放行(豁免每小时限流),实际 {r.status_code}: {r.text}"
)
# ============================ 计数单元逻辑 ============================
def test_quota_resets_across_day(enabled, monkeypatch) -> None:
+6 -1
View File
@@ -46,7 +46,12 @@ def test_account_auto_created_empty(client) -> None:
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
assert body == {"coin_balance": 0, "cash_balance_cents": 0, "total_coin_earned": 0}
assert body == {
"coin_balance": 0,
"cash_balance_cents": 0,
"invite_cash_balance_cents": 0, # v2 账户隔离新增(邀请奖励金,与现金隔离)
"total_coin_earned": 0,
}
def test_signin_flow(client) -> None:
+2 -1
View File
@@ -52,7 +52,8 @@ def call_raw(path: str, body_obj: dict) -> dict:
}
url = f"{settings.MT_CPS_HOST}{path}"
t0 = time.time()
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC)
# trust_env=False: 美团是国内域名,强制直连绕开本机代理(代理会掐断 TLS 握手,报 SSL EOF)
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC, trust_env=False)
ms = int((time.time() - t0) * 1000)
try:
j = resp.json()