Compare commits

..

8 Commits

Author SHA1 Message Date
左辰勇 0a8cab9e7e 合并 main 并解决 API 文档冲突 2026-07-20 23:58:31 +08:00
guke 48037f03fd docs: OpenObserve 接口 QPS/耗时可观测设计 spec (#145)
openobserve上报

---------

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

---------

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

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

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #143
2026-07-18 19:11:45 +08:00
左辰勇 dd15c5dc97 'fix/实现五大厂商推送,与通知中心接口功能' 2026-07-17 11:03:17 +08:00
左辰勇 d6016c12f9 OPPO 推送补新消息分类(channel_id/category/notify_level)
2024-11-20 后创建的 OPPO 应用发送通知必须携带 category(新规),
配置项 OPPO_PUSH_CHANNEL_ID / OPPO_PUSH_CATEGORY / OPPO_PUSH_NOTIFY_LEVEL(0=不传),
均留空时 payload 与原先完全一致。凭据已到位:荣耀/小米(含 channel 154219)/OPPO/vivo
四家配齐(.env,不入库),仅剩华为待补。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:22:25 +08:00
左辰勇 a8ac5dc0c7 消息通知中心 3 接口(mock 阶段) + 五厂商推送(补华为/通用发送/测试三件套)
- GET /api/v1/notifications(分页,时间倒序不分组)/unread-count /read(ids|all),
  对外 camelCase(PRD 前端契约);数据为内存 mock(13 类型全覆盖,重启复位),
  接真实数据只换 repositories/notification_mock.py
- vendor_push 补华为 Push Kit(OAuth+messages:send),抽通用 send_notification
  (任意文案+extras+mock 模式),send_accessibility_disabled 改薄封装行为不变
- 新增 /api/v1/push/{vendors,templates,test}:凭据状态/13 类 PRD 文案模板/测试发送
  (默认 mock,mock=false 真发,可联动插站内 mock 消息闭环验证已读)
- .env.example 补 HUAWEI_* 键;docs/api 新增 notifications.md + push-vendor-test.md
- alembic merge 1a924c274fce 收拢 direct_vendor_push_fields/feedback_type_reply
  双 head,恢复 upgrade head 可用
- tests: test_notifications + test_push_center 共 35 例

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:57:04 +08:00
lowmaster-chen 2236a8b3ee 基于 main 接入各厂商直推服务端
改动:新增厂商推送配置、设备 push_vendor/push_token 字段、device push-test 接口、心跳超时厂商直推发送逻辑和对应测试。

验证:python -m pytest tests/test_device_push.py tests/test_auth.py tests/test_health.py 通过。
2026-07-02 23:01:20 +08:00
76 changed files with 10802 additions and 96 deletions
+65 -1
View File
@@ -27,7 +27,55 @@ JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem
JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify
JG_REQUEST_TIMEOUT_SEC=15
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
# ===== 厂商直推(无障碍保护存活告警 + 消息中心 13 类通知)=====
# 敏感密钥只放 .env / 服务器环境变量,不要提交到 git。
# 各厂商配置状态可随时 GET /api/v1/push/vendors 查看(缺哪些键一目了然)。
ANDROID_PACKAGE_NAME=com.jishisongfu.shaguabijia
PUSH_REQUEST_TIMEOUT_SEC=15
PUSH_TIME_TO_LIVE_SEC=86400
HONOR_PUSH_APP_ID=
HONOR_PUSH_CLIENT_ID=
HONOR_PUSH_CLIENT_SECRET=
HONOR_PUSH_TOKEN_ENDPOINT=https://iam.developer.honor.com/auth/token
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage
# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,AppId + AppSecret
HUAWEI_PUSH_APP_ID=
HUAWEI_PUSH_APP_SECRET=
HUAWEI_PUSH_TOKEN_ENDPOINT=https://oauth-login.cloud.huawei.com/oauth2/v3/token
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.huawei.com/v1/{app_id}/messages:send
VIVO_PUSH_APP_ID=
VIVO_PUSH_APP_KEY=
VIVO_PUSH_APP_SECRET=
VIVO_PUSH_AUTH_ENDPOINT=https://api-push.vivo.com.cn/message/auth
VIVO_PUSH_SEND_ENDPOINT=https://api-push.vivo.com.cn/message/send
# vivo 未上架测试时可用 push_mode=1; 上架正式推送改为 0。
VIVO_PUSH_MODE=1
VIVO_PUSH_NOTIFY_TYPE=4
VIVO_PUSH_CATEGORY=DEVICE_REMINDER
XIAOMI_PUSH_APP_SECRET=
XIAOMI_PUSH_SEND_ENDPOINT=https://api.xmpush.xiaomi.com/v3/message/regid
XIAOMI_PUSH_CHANNEL_ID=
XIAOMI_PUSH_TEMPLATE_ID=
XIAOMI_PUSH_TEMPLATE_TITLE=
XIAOMI_PUSH_TEMPLATE_DESCRIPTION=
# 可选: JSON 字符串,支持 {title}/{alert} 占位符,例如 {"title":"{title}","content":"{alert}"}
XIAOMI_PUSH_TEMPLATE_PARAM_JSON=
OPPO_PUSH_APP_KEY=
OPPO_PUSH_MASTER_SECRET=
OPPO_PUSH_AUTH_ENDPOINT=https://api.push.oppomobile.com/server/v1/auth
OPPO_PUSH_SEND_ENDPOINT=https://api.push.oppomobile.com/server/v1/message/notification/unicast
# OPPO 新消息分类(2024-11-20 后创建的应用必须携带 category;channel_id 为后台「通道ID」;
# notify_level 0=不传走默认,内容营销类仅支持 1/2)
OPPO_PUSH_CHANNEL_ID=
OPPO_PUSH_CATEGORY=
OPPO_PUSH_NOTIFY_LEVEL=0
# ===== 无障碍保护存活监控(推送 + pull 后置兜底)=====
HEARTBEAT_MONITOR_ENABLED=true
HEARTBEAT_TIMEOUT_MINUTES=60
HEARTBEAT_SCAN_INTERVAL_SEC=60
@@ -137,3 +185,19 @@ PANGLE_REPORT_SECURITY_KEY=
# GroMore AppId(报表 site_id 维度)→ 应用环境;默认取现网两个应用,按需覆盖。
PANGLE_REPORT_SITE_ID_PROD=5830519
PANGLE_REPORT_SITE_ID_TEST=5832303
# ===== 可观测(OpenObserve 接口指标)=====
# 采集每个接口 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker,见 deploy/openobserve/)。
# 默认关;开启需 ENABLED=true 且填 USER/PASSWORD(与 docker-compose 里 root 账号一致)。
# 未开/缺凭证 → 中间件透传、worker 不启动,整套 no-op,不影响业务。
OBSERVE_ENABLED=false
OBSERVE_ENDPOINT=http://localhost:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=admin@shaguabijia.local
OBSERVE_PASSWORD=Complexpass#123
# 进阶(一般不用改):攒批间隔秒 / 单批最大条数 / 有界队列上限(满则丢) / 上报超时秒
OBSERVE_FLUSH_INTERVAL_SEC=5
OBSERVE_BATCH_MAX=200
OBSERVE_QUEUE_MAX=10000
OBSERVE_TIMEOUT_SEC=5
+116
View File
@@ -0,0 +1,116 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project overview
Shaguabijia (傻瓜比价) App backend — FastAPI + SQLAlchemy 2.0 + JWT. Covers user auth (Jiguang one-click / SMS), welfare wallet (coins/cash/signin/tasks/savings), WeChat Pay withdrawals, ad-reward callbacks (Pangle/GroMore S2S), Meituan CPS (coupon forwarding / price comparison), and an admin backend.
## Commands
```bash
# Install
pip install -e ".[dev]"
# Run app server (port 8770, auto-migrates, auto-reload)
./run.sh # or: uvicorn app.main:app --reload --port 8770
# Run admin server (port 8771, separate process)
uvicorn app.admin.main:admin_app --reload --port 8771
# Database
alembic upgrade head # apply all migrations (idempotent)
alembic revision --autogenerate -m "description" # generate new migration
# Tests
pytest -q # all tests
pytest tests/test_auth.py -q # single file
pytest -k "test_sms_login" -q # single test by name
# Lint
ruff check .
ruff check --fix .
```
## Architecture: two FastAPI apps
This repo runs **two separate FastAPI processes** sharing the same `app/` codebase (models, repositories, integrations, config):
| | App server | Admin server |
|---|---|---|
| Entry | `app/main.py``app:app` | `app/admin/main.py``admin_app` |
| Port | 8770 | 8771 |
| Auth | User JWT (`JWT_SECRET_KEY`) | Admin JWT (`ADMIN_JWT_SECRET`, independent) |
| Audience | Mobile app clients | Internal admin dashboard |
| Docs | `/docs` (non-prod only) | `/admin/docs` (non-prod only) |
The two apps are intentionally decoupled — `app.main` never imports `app.admin`. Admin has its own auth chain (`app/admin/deps.py`, `app/admin/security.py`), role-based guards (`require_role`), and routers under `app/admin/routers/`.
## Layered request flow
```
api/v1/ (thin: parse → delegate → respond + HTTP errors)
├── integrations/ (external SDKs: signature, encryption, HTTP calls)
└── repositories/ (data access + transactions)
└── models/ (SQLAlchemy ORM, DeclarativeBase)
```
- **`api/v1/`**: Route handlers. Keep these thin — parse request, call repository or integration, return response. Never put business logic or external HTTP here.
- **`api/deps.py`**: Shared FastAPI dependencies — `get_current_user` (Bearer JWT → User ORM object), `get_db` (request-scoped session).
- **`integrations/`**: All external service logic — Jiguang REST + RSA decryption, WeChat Pay V3 signing/encryption, Meituan CPS gateway signing, Pangle callback signature verification, SMS sending. This is the layer you change when swapping vendors.
- **`repositories/`**: Data access. Each file wraps SQLAlchemy queries + transactions for one domain (user, wallet, signin, savings, ad_reward, etc.). Some repositories also call integrations (e.g., `wallet.py` calls `integrations/wxpay.py` for withdrawals).
- **`models/`**: ORM table definitions (SQLAlchemy 2.0 `Mapped` style, `DeclarativeBase`). Every new model must be imported in `app/models/__init__.py` so Alembic can discover it.
- **`schemas/`**: Pydantic request/response contracts.
- **`core/`**: Infrastructure — config (`pydantic-settings`), JWT (`security.py`), in-memory rate limiter (`ratelimit.py`), reward constants (`rewards.py`), logging setup, pricebot router (consistent-hash load balancing), withdraw reconcile worker.
## Internal (server-to-server) endpoints
Endpoints under `app/api/internal/` are for server-to-server communication (pricebot → app-server), NOT for clients. They use a shared secret header `X-Internal-Secret` (compared via `hmac.compare_digest`) instead of user JWT. If `INTERNAL_API_SECRET` is empty, these endpoints return 503.
## Auth system
- **User login**: Jiguang one-click (`integrations/jiguang.py` — REST token verification + RSA decryption with multi-padding retry) or SMS code (mock by default; `SMS_MOCK=true`).
- **Tokens**: JWT access (2h) + refresh (30d). Both are JWT with `typ` claim (`"access"` vs `"refresh"`) to prevent refresh-as-access. See `core/security.py`.
- **Admin auth**: Separate JWT secret (`ADMIN_JWT_SECRET`), 12h expiry, no refresh. Username + bcrypt password login. Role-based access via `require_role()` guard in `app/admin/deps.py` (`super_admin` bypasses all role checks).
- **Rate limiting**: In-memory fixed-window by client IP (`core/ratelimit.py`). Single-worker only; disabled in tests via `RATE_LIMIT_ENABLED=false`.
## Database
- **Dev**: SQLite (`sqlite:///./data/app.db`), `check_same_thread=False`, no connection pool.
- **Prod**: PostgreSQL — just change `DATABASE_URL` in `.env`. Pool size 10 + max overflow 20, pool_recycle 3600.
- **Migrations**: Alembic with `render_as_batch` for SQLite compatibility. ~60+ migration files in `alembic/versions/` (filenames are descriptive, not hex prefixes). Migration chain uses `down_revision` within each file.
- **New models**: Define in `app/models/`, import in `app/models/__init__.py`, then run `alembic revision --autogenerate`.
## Config
All config via `pydantic-settings` in `app/core/config.py`. Single `Settings` class with env vars / `.env` file. Access anywhere via `from app.core.config import settings`. Key patterns:
- `*_configured` properties gate features gracefully (e.g., `mt_cps_configured`, `wxpay_configured`, `pangle_callback_configured`) — missing credentials → endpoints return empty/503 rather than crashing at startup.
- Prod validation: `_enforce_prod_secrets` model validator blocks startup if `APP_ENV=prod` with weak JWT secrets.
## Testing
- `tests/conftest.py`: Sets env vars BEFORE imports, creates temp SQLite file, builds all tables with `Base.metadata.create_all()`, tears down with `drop_all()` + unlink.
- External integrations are monkeypatched in tests (e.g., WeChat Pay, Jiguang, Pangle callbacks) — tests never make real HTTP calls.
- `TestClient` from FastAPI is used for all tests. Rate limiting is disabled globally in tests.
## Key integration details
- **Jiguang one-click login**: REST call to verify `loginToken`, then RSA decrypt the returned phone number. Multiple padding schemes tried in order (PKCS1v15, OAEP with SHA1/SHA256) because Jiguang's encryption padding varies.
- **WeChat Pay withdrawals**: V3 API merchant transfer to user WeChat balance. Lazy-loads merchant certificates from `secrets/`. Withdrawal flow: bind WeChat → create withdraw order → auto-reconcile worker polls pending orders.
- **Pangle ad rewards**: S2S callback verification via SHA256 signature. Multiple `m-key` secrets supported (one per ad placement). Callback is idempotent by `trans_id`. Test grant endpoint (`AD_REWARD_TEST_GRANT_ENABLED`) for local debugging — must be false in prod.
- **Meituan CPS**: Gateway signature-based API calls. Proxy support (`MT_CPS_PROXY`) for local dev (direct connection causes SSL EOF). Coupon endpoints gracefully return empty when credentials are missing.
- **Pricebot forwarding**: `/api/v1/coupon/step` and `/api/v1/compare/*` proxy to pricebot-backend. Multi-instance support with consistent-hash routing by `trace_id` (see `core/pricebot_router.py`).
- **CPS redirect**: `/c/{code}` is a public (no auth) short-link redirect — records a click then 302s to Meituan. Click recording failure never blocks the redirect.
## Money and units
All monetary amounts are in **cents** (`*_cents` fields). Coins/gold have their own unit. Conversion constants are in `core/rewards.py`.
## Scripts
Key operational scripts in `scripts/`:
- `migrate.sh` — run migrations standalone
- `create_admin.py` — create admin user
- `daily_auto_exchange.py` — auto-convert coins to cash (triggered by systemd timer)
- `reconcile_withdraws.py` — reconcile withdrawal orders with WeChat Pay
- `sim_pangle_callback.py` — simulate Pangle S2S callback for testing
@@ -0,0 +1,68 @@
"""add inactivity tables
Revision ID: 135e79414fd0
Revises: comparison_llm_cost
Create Date: 2026-07-16 18:31:02.105929
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '135e79414fd0'
down_revision: Union[str, Sequence[str], None] = 'comparison_llm_cost'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"inactivity_reset_log",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("coin_balance_before", sa.Integer(), nullable=False),
sa.Column("cash_balance_cents_before", sa.Integer(), nullable=False),
sa.Column("invite_cash_balance_cents_before", sa.Integer(), nullable=False),
sa.Column("last_active_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("inactive_days", sa.Integer(), nullable=False),
sa.Column("reason", sa.String(length=32), nullable=False),
sa.Column("reset_at", sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_inactivity_reset_log_user_id"), ["user_id"], unique=False)
batch_op.create_index(batch_op.f("ix_inactivity_reset_log_reset_at"), ["reset_at"], unique=False)
op.create_table(
"inactivity_notification_log",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("stage", sa.Integer(), nullable=False),
sa.Column("inactive_days", sa.Integer(), nullable=False),
sa.Column("coin_balance", sa.Integer(), nullable=False),
sa.Column("cash_balance_cents", sa.Integer(), nullable=False),
sa.Column("invite_cash_balance_cents", sa.Integer(), nullable=False),
sa.Column("channel", sa.String(length=16), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_inactivity_notification_log_user_id"), ["user_id"], unique=False)
batch_op.create_index(batch_op.f("ix_inactivity_notification_log_created_at"), ["created_at"], unique=False)
def downgrade() -> None:
with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_created_at"))
batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_user_id"))
op.drop_table("inactivity_notification_log")
with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_reset_at"))
batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_user_id"))
op.drop_table("inactivity_reset_log")
@@ -0,0 +1,26 @@
"""merge direct_vendor_push and feedback_type_reply heads
Revision ID: 1a924c274fce
Revises: direct_vendor_push_fields, feedback_type_reply
Create Date: 2026-07-14 18:53:02.856979
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1a924c274fce'
down_revision: Union[str, Sequence[str], None] = ('direct_vendor_push_fields', 'feedback_type_reply')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,36 @@
"""analytics_event 活跃口径复合索引
Revision ID: analytics_active_idx
Revises: 135e79414fd0
Create Date: 2026-07-18 17:35:00.000000
给 analytics_event 加活跃口径热点复合索引 (event, page, user_id, created_at):
activity.active_event_condition 按 (event=show & page=home) 比价 领券 过滤后
group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频 show 事件全表扫。
⚠️ 本分支迁移树有**既有多头**:135e79414fd0(不活跃两表)与 phone_rebind_log 同从
comparison_llm_cost 分叉,`alembic upgrade head` 会多头报错。本迁移挂在 135e79414fd0
一侧;集成到 main 时需 `alembic merge` 合并 phone_rebind_log 那个头(与本迁移无关的既有问题)。
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "analytics_active_idx"
down_revision: Union[str, Sequence[str], None] = "135e79414fd0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"ix_analytics_event_active",
"analytics_event",
["event", "page", "user_id", "created_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_analytics_event_active", table_name="analytics_event")
@@ -0,0 +1,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,25 @@
"""merge inactivity(analytics_active_idx) + phone_rebind_log heads
Revision ID: merge_active_phone
Revises: analytics_active_idx, phone_rebind_log
Create Date: 2026-07-18 18:52:34.001148
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'merge_active_phone'
down_revision: Union[str, Sequence[str], None] = ('analytics_active_idx', 'phone_rebind_log')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+68
View File
@@ -0,0 +1,68 @@
"""notification table (消息通知中心 站内消息)
Revision ID: notification_table
Revises: 1a924c274fce
Create Date: 2026-07-15 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'notification_table'
down_revision: Union[str, Sequence[str], None] = '1a924c274fce'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# PG 用 JSONB,SQLite 退化为通用 JSON(与 models/notification._JSON 一致)。
_JSON = sa.JSON().with_variant(postgresql.JSONB(), 'postgresql')
def upgrade() -> None:
op.create_table(
'notification',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('type', sa.String(length=32), nullable=False),
sa.Column('coins', sa.Integer(), nullable=True),
sa.Column('cash_cents', sa.Integer(), nullable=True),
sa.Column('info_rows', _JSON, nullable=False),
sa.Column('extra', _JSON, nullable=False),
sa.Column('is_read', sa.Boolean(), nullable=False),
sa.Column('read_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('dedup_key', sa.String(length=64), nullable=True),
sa.Column('sent_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('notification', schema=None) as batch_op:
batch_op.create_index('ix_notification_type', ['type'], unique=False)
# 列表分页:按用户取 + sent_at 倒序
batch_op.create_index('ix_notification_user_sent', ['user_id', 'sent_at'], unique=False)
# 铃铛角标:count where user_id=? and is_read=false —— 部分索引只覆盖未读行
batch_op.create_index(
'ix_notification_user_unread', ['user_id'], unique=False,
sqlite_where=sa.text('is_read = 0'),
postgresql_where=sa.text('is_read = false'),
)
# 去重/合并:同一 (user, type, dedup_key) 未读期间只允许一条(已读后可再生成)
batch_op.create_index(
'uq_notification_user_type_dedup', ['user_id', 'type', 'dedup_key'], unique=True,
sqlite_where=sa.text('dedup_key IS NOT NULL AND is_read = 0'),
postgresql_where=sa.text('dedup_key IS NOT NULL AND is_read = false'),
)
def downgrade() -> None:
with op.batch_alter_table('notification', schema=None) as batch_op:
batch_op.drop_index('uq_notification_user_type_dedup')
batch_op.drop_index('ix_notification_user_unread')
batch_op.drop_index('ix_notification_user_sent')
batch_op.drop_index('ix_notification_type')
op.drop_table('notification')
+11 -62
View File
@@ -11,7 +11,6 @@ from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, case, desc, func, or_, select
from sqlalchemy.orm import Session
from app.admin.repositories.stats import COMPARE_START_EVENT, COUPON_START_EVENT
from app.core import rewards
from app.core.config import settings
from app.models.ad_feed_reward import AdFeedRewardRecord
@@ -32,10 +31,7 @@ from app.models.wallet import (
InviteCashTransaction,
WithdrawOrder,
)
from app.repositories import ad_ecpm
# 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券)
_ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT)
from app.repositories import activity, ad_ecpm
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
@@ -88,49 +84,6 @@ def offset_paginate(
return items, next_cursor, total
def _last_active_parts():
"""「最近活跃」的两个按 user_id 预聚合派生表(最近开始比价/领券事件、最近领券发起)。
活跃口径与大盘 DAU/留存一致(2026-07-05 产品定:进入 App≈登录 last_login_at +
发起比价 real_compare_start + 发起领券 real_coupon_start/claim_started)。
用 LEFT JOIN 预聚合而非相关标量子查询:后者在 PG 上对 users 每行各跑一个 SubPlan
(排序键、range 筛选、offset_paginate 的 count 三处叠加),埋点表大了会拖垮列表接口;
预聚合借 analytics_event.event 索引只扫两类 start 事件,每次查询聚合一次。
"""
ev_agg = (
select(
AnalyticsEvent.user_id.label("user_id"),
func.max(AnalyticsEvent.created_at).label("last_at"),
)
.where(
AnalyticsEvent.user_id.is_not(None),
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
)
.group_by(AnalyticsEvent.user_id)
.subquery()
)
eng_agg = (
select(
CouponPromptEngagement.user_id.label("user_id"),
func.max(CouponPromptEngagement.created_at).label("last_at"),
)
.where(
CouponPromptEngagement.user_id.is_not(None),
CouponPromptEngagement.engage_type == "claim_started",
)
.group_by(CouponPromptEngagement.user_id)
.subquery()
)
return ev_agg, eng_agg
def _norm_utc(dt: datetime | None) -> datetime | None:
"""naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。"""
if dt is None:
return None
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
def _attach_last_active(db: Session, users: list[User]) -> None:
"""给本页用户瞬态挂 last_active_at(非 DB 列,供 AdminUserListItem from_attributes 读)。
@@ -144,7 +97,7 @@ def _attach_last_active(db: Session, users: list[User]) -> None:
select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at))
.where(
AnalyticsEvent.user_id.in_(uids),
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
activity.active_event_condition(),
)
.group_by(AnalyticsEvent.user_id)
).all()
@@ -161,9 +114,9 @@ def _attach_last_active(db: Session, users: list[User]) -> None:
)
for u in users:
candidates = [
_norm_utc(u.last_login_at),
_norm_utc(ev_map.get(u.id)),
_norm_utc(eng_map.get(u.id)),
activity.norm_utc(u.created_at), # baseline 由 last_login_at 改为 created_at(登录不算活跃)
activity.norm_utc(ev_map.get(u.id)),
activity.norm_utc(eng_map.get(u.id)),
]
u.last_active_at = max((c for c in candidates if c is not None), default=None)
@@ -191,16 +144,12 @@ def list_users(
(口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
# 最近活跃 = max(最近登录, 最近行为事件, 最近领券发起)。PG 用 GREATEST;SQLite 标量 max()
# 任一参数 NULL 即返回 NULL,故 LEFT JOIN 未命中侧 coalesce 到 last_login_at 兜底
# (注册即登录,该列恒非空)。派生表 1:1(按 user_id 聚合),outerjoin 不放大行数,
# offset_paginate 的 count 不受影响。
ev_agg, eng_agg = _last_active_parts()
greatest = func.greatest if db.get_bind().dialect.name == "postgresql" else func.max
last_active = greatest(
User.last_login_at,
func.coalesce(ev_agg.c.last_at, User.last_login_at),
func.coalesce(eng_agg.c.last_at, User.last_login_at),
# 最近活跃 = max(注册时间, 最近行为事件, 最近领券发起)。baseline 由 last_login_at 改为 created_at
#(登录不代表在用 App;口径统一到 activity.py,含 home_view + 比价 + 领券,见 activity.ACTIVE_EVENTS)。
# 未命中侧 coalesce 到 created_at(恒非空基线)。派生表 1:1,outerjoin 不放大行数
ev_agg, eng_agg = activity.last_active_subqueries(db)
last_active = activity.last_active_expr(
User.created_at, ev_agg, eng_agg, db.get_bind().dialect.name
)
stmt = (
select(User)
+10 -2
View File
@@ -19,6 +19,7 @@ from app.admin.schemas.feedback import (
from app.models.admin import AdminUser
from app.models.feedback import Feedback
from app.repositories import wallet as wallet_repo
from app.services import notification_events
router = APIRouter(
prefix="/admin/api/feedbacks",
@@ -134,7 +135,11 @@ def approve_feedback(
)
db.commit()
db.refresh(fb)
return FeedbackOut.model_validate(fb)
out = FeedbackOut.model_validate(fb)
# PRD #10 反馈奖励:采纳发金币后通知用户(站内 + push,必带官方留言)。
# 业务已 commit,通知失败只 log 不影响审核结果。
notification_events.notify_feedback_reward(db, fb)
return out
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
@@ -179,4 +184,7 @@ def reject_feedback(
)
db.commit()
db.refresh(fb)
return FeedbackOut.model_validate(fb)
out = FeedbackOut.model_validate(fb)
# PRD #9 官方回复:未采纳也回复了用户(原因/留言用户端可见),通知去反馈历史页查看。
notification_events.notify_feedback_reply(db, fb)
return out
+5 -1
View File
@@ -3,7 +3,8 @@
数据由客户端 POST /api/v1/report 写入 price_report 表(提交即 pending);本路由是运营后台
对它的人工审核窗口。**通过** → 给上报用户钱包发固定金币(PRICE_REPORT_REWARD_COINS):
改状态 + 发金币(wallet.grant_coins)+ 审计同一事务一起 commit(原子,仿 users.grant_user_coins),
绝不只改状态不发钱或反之。客户端轮询 GET /api/v1/report/records 自动看到结果(无需推送)。
绝不只改状态不发钱或反之。通过后下发「爆料审核通过」通知(站内 + push,PRD #11);
客户端也可轮询 GET /api/v1/report/records 看到结果。
"""
from __future__ import annotations
@@ -24,6 +25,7 @@ from app.core.rewards import PRICE_REPORT_REWARD_COINS
from app.models.admin import AdminUser
from app.models.price_report import PriceReport
from app.repositories import wallet as wallet_repo
from app.services import notification_events
router = APIRouter(
prefix="/admin/api/price-reports",
@@ -84,6 +86,8 @@ def approve_price_report(
detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
)
db.commit()
# PRD #11 爆料审核通过:发金币后通知用户(站内 + push)。业务已 commit,通知失败只 log。
notification_events.notify_report_approved(db, rep)
return OkResponse()
+3 -1
View File
@@ -24,7 +24,9 @@ class DeviceLivenessItem(BaseModel):
device_model: str | None = None # 由 device_id 解析(device_<机型>_<hash>);非 DB 列
platform: str
app_version: str | None = None
registration_id: str | None = None # 非空 = 拿到极光 token、可推送
registration_id: str | None = None # 旧极光字段,仅兼容历史数据
push_vendor: str | None = None
push_token: str | None = None
ever_protected: bool # 是否开过无障碍(=该设备对功能有意义)
first_protected_at: datetime | None = None # 首次开无障碍时刻(老设备为 null)
+93 -4
View File
@@ -1,19 +1,22 @@
"""设备注册 / 心跳 endpoint(无障碍保护存活检测)。
路由前缀 /api/v1/device,需 Bearer 鉴权(设备绑登录用户)。
POST /register 注册设备 / 更新 registration_id(App 前台、拿到 push token 时调)
POST /register 注册设备 / 更新厂商 push token(App 前台、拿到 push token 时调)
POST /heartbeat 上报心跳(无障碍服务存活时周期调,刷新存活)
POST /push-test 开发验收:延迟发送厂商通道测试推送
后端 heartbeat_monitor_worker 据此发现心跳超时的设备并极光推送告警。
后端 heartbeat_monitor_worker 据此发现心跳超时的设备并厂商直推告警。
见 spec: spec/accessibility-liveness-push.md。
"""
from __future__ import annotations
import logging
import time
from fastapi import APIRouter
from fastapi import APIRouter, BackgroundTasks, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.integrations import vendor_push
from app.repositories import device as device_repo
from app.schemas.device import (
DeviceOut,
@@ -22,6 +25,8 @@ from app.schemas.device import (
LivenessAckRequest,
LivenessOut,
OkResponse,
PushTestOut,
PushTestRequest,
)
logger = logging.getLogger("shagua.device")
@@ -29,6 +34,37 @@ logger = logging.getLogger("shagua.device")
router = APIRouter(prefix="/api/v1/device", tags=["device"])
def _send_push_test_after_delay(
push_vendor: str,
push_token: str,
delay_seconds: int,
user_id: int,
device_id: str,
) -> None:
if delay_seconds > 0:
time.sleep(delay_seconds)
try:
vendor_push.send_accessibility_disabled(
push_vendor,
push_token,
title="测试推送",
alert="这是一条厂商通道测试推送。收到它说明 App 被划掉后仍可通过系统通知栏触达。",
)
logger.info(
"push test sent user_id=%d device_id=%s delay=%ds",
user_id,
device_id,
delay_seconds,
)
except vendor_push.VendorPushError as e:
logger.warning(
"push test failed user_id=%d device_id=%s error=%s",
user_id,
device_id,
e,
)
@router.post("/register", response_model=DeviceOut, summary="注册设备/更新推送token")
def register_device(
req: DeviceRegisterRequest,
@@ -40,13 +76,17 @@ def register_device(
user_id=user.id,
device_id=req.device_id,
registration_id=req.registration_id,
push_vendor=req.push_vendor,
push_token=req.push_token,
platform=req.platform,
app_version=req.app_version,
)
logger.info(
"device register user_id=%d device_id=%s reg=%s",
"device register user_id=%d device_id=%s vendor=%s token=%s legacy_reg=%s",
user.id,
req.device_id,
req.push_vendor,
bool(req.push_token),
bool(req.registration_id),
)
return DeviceOut.model_validate(device)
@@ -64,10 +104,59 @@ def report_heartbeat(
device_id=req.device_id,
accessibility_enabled=req.accessibility_enabled,
registration_id=req.registration_id,
push_vendor=req.push_vendor,
push_token=req.push_token,
)
return OkResponse()
@router.post("/push-test", response_model=PushTestOut, summary="延迟发送厂商通道测试推送")
def request_push_test(
req: PushTestRequest,
background_tasks: BackgroundTasks,
user: CurrentUser,
db: DbSession,
) -> PushTestOut:
"""开发验收用:App 内点一次,服务端延迟发厂商直推,验证离线通道。"""
push_vendor = req.push_vendor.strip() if req.push_vendor else None
push_token = req.push_token.strip() if req.push_token else None
if push_vendor and push_token:
device_repo.register_or_update(
db,
user_id=user.id,
device_id=req.device_id,
registration_id=req.registration_id,
push_vendor=push_vendor,
push_token=push_token,
)
else:
device = device_repo.get_device(db, user_id=user.id, device_id=req.device_id)
push_vendor = device.push_vendor if device is not None else None
push_token = device.push_token if device is not None else None
if not push_vendor or not push_token:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="push vendor token not ready",
)
background_tasks.add_task(
_send_push_test_after_delay,
push_vendor,
push_token,
req.delay_seconds,
user.id,
req.device_id,
)
logger.info(
"push test scheduled user_id=%d device_id=%s delay=%ds",
user.id,
req.device_id,
req.delay_seconds,
)
return PushTestOut(delay_seconds=req.delay_seconds, has_push_token=True)
@router.get("/liveness", response_model=LivenessOut, summary="查询本机掉线告警(后置检测)")
def get_liveness(
device_id: str,
+124
View File
@@ -0,0 +1,124 @@
"""消息通知中心 endpoint(PRD《消息通知中心》)。
路由前缀 `/api/v1/notifications`,需 Bearer 鉴权(消息按用户隔离)。
GET / 消息列表(分页;全列表时间倒序,不分组——PRD 原文的分组已取消)
GET /unread-count 未读总数(首页铃铛角标)
POST /read 标记已读({ids:[...]} 单条/多条 或 {all:true} 全量清零)
数据落库 `notification` 表(repositories/notification.py,按用户隔离)。业务事件(奖励过期、
提现回执、反馈回复……)调 `create_notification` 下发;未接入业务前列表为空,可用
`/api/v1/push/test` 的 createNotification 造联调数据。
⚠️ 字段命名:本组接口对外为 **camelCase**(sentAt / isRead / pageSize…,PRD 前端契约),
详见 schemas/notification.py 顶部说明。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Query
from app.api.deps import CurrentUser, DbSession
from app.core import notification_catalog as catalog
from app.models.notification import Notification
from app.repositories import notification as notif_repo
from app.schemas.notification import (
InfoRow,
MarkReadOut,
MarkReadRequest,
NotificationItem,
NotificationListOut,
UnreadCountOut,
)
logger = logging.getLogger("shagua.notifications")
router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"])
def _to_item(n: Notification) -> NotificationItem:
"""通知行 + 类型静态目录 → 接口出参。"""
ntype = catalog.get_type(n.type)
return NotificationItem(
id=n.id,
category=ntype.category,
category_label=catalog.category_label(ntype.category),
type=ntype.key,
card_style=ntype.card_style,
title=ntype.card_title,
coins=n.coins,
cash_cents=n.cash_cents,
cash_yuan=notif_repo.cash_yuan(n.cash_cents),
info_rows=[InfoRow(**row) for row in n.info_rows],
action_text=ntype.action_text,
extra=n.extra,
sent_at=notif_repo.as_cst(n.sent_at),
is_read=n.is_read,
)
@router.get("", response_model=NotificationListOut, summary="消息列表(分页)")
def list_notifications(
user: CurrentUser,
db: DbSession,
page: int = Query(default=1, ge=1, description="页码,1 起"),
page_size: int = Query(
default=20, ge=1, le=100, alias="pageSize", description="每页条数,默认 20,最大 100"
),
) -> NotificationListOut:
"""通知中心消息列表。
- 排序服务端已做好:**全列表按时间倒序**(最新在前,不做分类分组;PRD §1 的
"按分类分组"为笔误,已与需求方确认取消),前端按返回顺序渲染即可。
- 每条的字段构成与各版式说明见 NotificationItem schema。
- 响应同时带 unreadCount,进页面时可顺手刷新角标。
- 无消息时返回空列表(total=0);数据由业务事件下发,联调可用 /push/test 造。
"""
items, total, unread = notif_repo.list_notifications(
db, user.id, page=page, page_size=page_size
)
return NotificationListOut(
items=[_to_item(n) for n in items],
page=page,
page_size=page_size,
total=total,
has_more=page * page_size < total,
unread_count=unread,
)
@router.get("/unread-count", response_model=UnreadCountOut, summary="未读总数(铃铛角标)")
def get_unread_count(user: CurrentUser, db: DbSession) -> UnreadCountOut:
"""首页铃铛角标数据源。刷新时机(PRD §4):进入首页时、从通知中心/其他页面返回首页时。
- count:精确未读条数;
- badgeText:直接可展示的角标文案——超过 99 返回 "99+",等于 0 返回 null(隐藏整个角标)。
"""
count = notif_repo.unread_count(db, user.id)
badge = None if count == 0 else ("99+" if count > 99 else str(count))
return UnreadCountOut(count=count, badge_text=badge)
@router.post("/read", response_model=MarkReadOut, summary="标记已读(单条/多条/全量)")
def mark_read(req: MarkReadRequest, user: CurrentUser, db: DbSession) -> MarkReadOut:
"""红点消除(PRD §4),两种调用模式:
1. `{"ids": [90001]}` —— 点击某张消息卡片(无论点击后是跳转/弹窗/无动作都算已读);
用户点击 push 直达落地页时,客户端也用它把对应站内消息同步置读(push extras 里带
notificationId);
2. `{"all": true}` —— 进入通知中心自动清零(只是浏览列表就消红点,无需逐条点击)。
幂等:不存在或已读的 id 忽略;重复调用 markedCount 为 0、不报错。
响应带 unreadCount(处理后剩余未读),可直接刷新铃铛角标。
"""
if not req.all and not req.ids:
raise HTTPException(status_code=400, detail="ids 与 all 至少传一个:{ids:[...]} 或 {all:true}")
marked, unread = notif_repo.mark_read(db, user.id, ids=req.ids, mark_all=req.all)
logger.info(
"notifications read user_id=%d mode=%s marked=%d unread_left=%d",
user.id,
"all" if req.all else f"ids×{len(req.ids or [])}",
marked,
unread,
)
return MarkReadOut(ok=True, marked_count=marked, unread_count=unread)
+187
View File
@@ -0,0 +1,187 @@
"""厂商推送 测试/联调 endpoint。
路由前缀 `/api/v1/push`,需 Bearer 鉴权。围绕「消息中心 13 类通知的厂商直推」提供三件套:
GET /vendors 5 个厂商(荣耀/华为/小米/OPPO/vivo)服务端凭据配置状态,缺哪些键一目了然
GET /templates 13 种通知类型的 push 标题/正文模板 + PRD 示例渲染效果
POST /test 测试发送:默认 mock(不真调厂商 API,回显渲染结果);mock=false 真发到手机
与 `/api/v1/device/push-test`(无障碍召回通道的延迟自测)互补:本组面向消息中心 13 类
push 的文案/参数/厂商通道联调。真实业务触发统一走 services/notification_events
(提现回执/反馈审核/爆料通过/好友下单已接入),底层与本测试端点同一条
integrations.vendor_push.send_notification 发送链路。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.core import notification_catalog as catalog
from app.integrations import vendor_push
from app.repositories import device as device_repo
from app.repositories import notification as notif_repo
from app.schemas.push import (
PushTemplateOut,
PushTemplatesOut,
PushTestOut,
PushTestRequest,
PushVendorsOut,
PushVendorStatus,
)
logger = logging.getLogger("shagua.push")
router = APIRouter(prefix="/api/v1/push", tags=["push"])
# /vendors 的展示顺序(荣耀/华为/小米/OPPO/vivo)
_VENDOR_ORDER = ("honor", "huawei", "xiaomi", "oppo", "vivo")
_GENERIC_TEST_TITLE = "傻瓜比价测试推送"
_GENERIC_TEST_BODY = "这是一条{label}通道的测试推送,收到说明服务端 → {label}厂商通道已打通。"
@router.get("/vendors", response_model=PushVendorsOut, summary="厂商推送配置状态")
def vendor_status(user: CurrentUser) -> PushVendorsOut:
"""检查 5 个厂商的服务端推送凭据是否配齐(读 .env,不打厂商接口)。
missingKeys 列出的即还需要在 .env 里补的配置键;全空说明该厂商随时可真发。
mock 测试(POST /test 默认模式)不依赖任何凭据。
"""
return PushVendorsOut(
vendors=[
PushVendorStatus(
vendor=v,
label=vendor_push.VENDOR_LABELS[v],
configured=not vendor_push.missing_settings(v),
missing_keys=vendor_push.missing_settings(v),
)
for v in _VENDOR_ORDER
]
)
@router.get("/templates", response_model=PushTemplatesOut, summary="13 类通知的 push 模板预览")
def push_templates(user: CurrentUser) -> PushTemplatesOut:
"""PRD §5 的 13 条 push 文案模板 + 用示例值渲染后的效果,联调对文案用。
标题固定(≤11 字不带变量);正文里 {var} 为变量,POST /test 的 vars 字段可覆盖。
"""
templates: list[PushTemplateOut] = []
for key, ntype in catalog.TYPES.items():
title, body_sample = catalog.render_push(key)
templates.append(
PushTemplateOut(
type=key,
category=ntype.category,
category_label=catalog.category_label(ntype.category),
card_style=ntype.card_style,
push_title=title,
push_body_sample=body_sample,
push_body_template=ntype.push_body_template,
variables=catalog.push_variable_names(key),
sample_vars=ntype.sample_vars,
)
)
return PushTemplatesOut(templates=templates)
@router.post("/test", response_model=PushTestOut, summary="测试发送厂商推送(默认 mock)")
def send_test_push(req: PushTestRequest, user: CurrentUser, db: DbSession) -> PushTestOut:
"""向指定厂商 token(或本用户已注册设备)发一条测试 push。
- **mock=true(默认)**:不真调厂商 API——校验参数、渲染文案后原样返回,并在
missingKeys 里提示真发前还缺哪些配置。虚拟数据阶段随便打,不会骚扰真机。
- **mock=false**:真发。要求该厂商凭据已配置(缺则 400 报缺失键);厂商 API 报错回 502。
注意 vivo 未上架前是测试推送模式(VIVO_PUSH_MODE=1),目标手机要先在 vivo 后台加为测试设备。
- **createNotification=true**:同时往消息中心(notification 表)插一条同类型未读通知并把
notificationId 放进 push extras → 客户端点击 push 后调 POST /notifications/read
{ids:[notificationId]} 即可闭环验证 PRD §4 的 push 已读联动。
"""
# ---- 1. 解析推送目标(vendor + token):直填优先,缺则按 deviceId 反查已注册设备 ----
vendor_raw = req.vendor.strip()
push_token = req.push_token.strip()
if (not vendor_raw or not push_token) and req.device_id.strip():
device = device_repo.get_device(db, user_id=user.id, device_id=req.device_id.strip())
if device is not None:
vendor_raw = vendor_raw or (device.push_vendor or "")
push_token = push_token or (device.push_token or "")
vendor = vendor_push.normalize_vendor(vendor_raw)
if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"vendor 无效或无法从设备推断,支持: {', '.join(_VENDOR_ORDER)}",
)
if not push_token:
# mock 模式给个占位 token,让「只想看看渲染结果」的调用免造数据;真发必须给真 token。
if req.mock:
push_token = "mock-token"
else:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="push token 未知:请直传 pushToken,或先用该设备调 /api/v1/device/register 上报",
)
# ---- 2. 组装文案与 extras:直填 > type 模板 > 通用测试文案 ----
extras: dict[str, str] = {}
notification_id: int | None = None
if req.type:
try:
title, body = catalog.render_push(req.type, req.vars or None)
except catalog.UnknownNotificationType as e:
raise HTTPException(status_code=400, detail=str(e)) from e
extras["type"] = req.type
if req.create_notification:
item = notif_repo.insert_sample(db, user.id, req.type)
notification_id = item.id
extras.update({str(k): str(v) for k, v in item.extra.items()})
extras["notificationId"] = str(item.id)
else:
label = vendor_push.VENDOR_LABELS[vendor]
title = _GENERIC_TEST_TITLE
body = _GENERIC_TEST_BODY.format(label=label)
extras["type"] = "push_test"
if req.title.strip():
title = req.title.strip()
if req.content.strip():
body = req.content.strip()
# ---- 3. 发送(mock / 真发) ----
missing = vendor_push.missing_settings(vendor)
vendor_response = None
if req.mock:
vendor_push.send_notification(
vendor, push_token, title=title, body=body, extras=extras, mock=True
)
else:
if missing:
raise HTTPException(
status_code=400,
detail=f"{vendor_push.VENDOR_LABELS[vendor]}推送凭据未配置,先在 .env 补上: "
f"{', '.join(missing)}",
)
try:
vendor_response = vendor_push.send_notification(
vendor, push_token, title=title, body=body, extras=extras
)
except vendor_push.VendorPushError as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"厂商推送失败: {e}"
) from e
logger.info(
"push test user_id=%d vendor=%s type=%s mock=%s notification_id=%s",
user.id, vendor, req.type or "generic", req.mock, notification_id,
)
return PushTestOut(
ok=True,
mock=req.mock,
vendor=vendor,
title=title,
body=body,
extras=extras,
notification_id=notification_id,
missing_keys=missing,
vendor_response=vendor_response,
)
+100 -1
View File
@@ -71,7 +71,59 @@ class Settings(BaseSettings):
JG_VERIFY_ENDPOINT: str = "https://api.verification.jpush.cn/v1/web/loginTokenVerify"
JG_REQUEST_TIMEOUT_SEC: int = 15
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
# ===== 厂商直推(无障碍保护存活告警)=====
ANDROID_PACKAGE_NAME: str = "com.jishisongfu.shaguabijia"
PUSH_REQUEST_TIMEOUT_SEC: int = 15
PUSH_TIME_TO_LIVE_SEC: int = 86400
HONOR_PUSH_APP_ID: str = ""
HONOR_PUSH_CLIENT_ID: str = ""
HONOR_PUSH_CLIENT_SECRET: str = ""
HONOR_PUSH_TOKEN_ENDPOINT: str = "https://iam.developer.honor.com/auth/token"
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
"https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage"
)
# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,取 AppId + AppSecret
# (OAuth 换 token 时 client_id 即 AppId)。发送走 v1 messages:send,成功码 80000000。
HUAWEI_PUSH_APP_ID: str = ""
HUAWEI_PUSH_APP_SECRET: str = ""
HUAWEI_PUSH_TOKEN_ENDPOINT: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/token"
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
"https://push-api.cloud.huawei.com/v1/{app_id}/messages:send"
)
VIVO_PUSH_APP_ID: str = ""
VIVO_PUSH_APP_KEY: str = ""
VIVO_PUSH_APP_SECRET: str = ""
VIVO_PUSH_AUTH_ENDPOINT: str = "https://api-push.vivo.com.cn/message/auth"
VIVO_PUSH_SEND_ENDPOINT: str = "https://api-push.vivo.com.cn/message/send"
VIVO_PUSH_MODE: int = 1 # 0=正式推送,1=测试推送(未上架 vivo 时用)
VIVO_PUSH_NOTIFY_TYPE: int = 4 # 1=无,2=响铃,3=振动,4=响铃+振动
VIVO_PUSH_CATEGORY: str = "DEVICE_REMINDER"
XIAOMI_PUSH_APP_SECRET: str = ""
XIAOMI_PUSH_SEND_ENDPOINT: str = "https://api.xmpush.xiaomi.com/v3/message/regid"
XIAOMI_PUSH_CHANNEL_ID: str = ""
XIAOMI_PUSH_TEMPLATE_ID: str = ""
XIAOMI_PUSH_TEMPLATE_TITLE: str = ""
XIAOMI_PUSH_TEMPLATE_DESCRIPTION: str = ""
XIAOMI_PUSH_TEMPLATE_PARAM_JSON: str = ""
OPPO_PUSH_APP_KEY: str = ""
OPPO_PUSH_MASTER_SECRET: str = ""
OPPO_PUSH_AUTH_ENDPOINT: str = "https://api.push.oppomobile.com/server/v1/auth"
OPPO_PUSH_SEND_ENDPOINT: str = (
"https://api.push.oppomobile.com/server/v1/message/notification/unicast"
)
# OPPO 新消息分类(2024-11-20 后创建的应用必须携带,否则可能被拒/限):
# channel_id=通知栏通道(OPPO 后台「通道ID」),category=消息分类 code(如 MARKETING 内容营销)。
# notify_level=提醒方式(0=不传走 OPPO 默认;内容营销类仅支持 1 通知栏/2 通知栏+锁屏)。
OPPO_PUSH_CHANNEL_ID: str = ""
OPPO_PUSH_CATEGORY: str = ""
OPPO_PUSH_NOTIFY_LEVEL: int = 0
# 无障碍保护存活监控后台任务(推送 + pull 后置兜底)
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
@@ -178,6 +230,15 @@ class Settings(BaseSettings):
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
# === 15 天不活跃清零(app.core.inactivity_reset_worker,worker 常驻)===
# ENABLED 只决定是否**真清**:false(默认)= 只记审计名单、不动钱(dry-run,灰度看名单);
# true = 真清金币 + 折算现金(邀请金不清)。看准名单后再置 true。
INACTIVITY_RESET_ENABLED: bool = False
INACTIVITY_RESET_DAYS: int = 15 # 不活跃阈值(天),第 (N+1) 日 0 点清
INACTIVITY_WARN_DAYS_BEFORE: str = "7,2" # 清零前几天各推一次;""=不推。逗号分隔
INACTIVITY_RESET_RUN_HOUR: int = 3 # 北京时间每日执行点(0-23)
INACTIVITY_NOTIFY_CHANNEL: str = "log" # log(占位) / jpush / sms
INACTIVITY_RESET_CHECK_INTERVAL_SEC: int = 1800 # worker 唤醒间隔(秒)
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
@@ -198,6 +259,19 @@ class Settings(BaseSettings):
"""免确认收款授权可用 = 微信支付凭证齐全 + 授权回调地址已配。"""
return bool(self.wxpay_configured and self.WXPAY_AUTH_NOTIFY_URL)
@property
def inactivity_warn_stages(self) -> list[int]:
"""解析 INACTIVITY_WARN_DAYS_BEFORE → 降序去重的提前天数列表。
丢弃非数字 / <=0 / >=RESET_DAYS 的项(空串 → 空列表 = 不推)。"""
out: list[int] = []
for part in (self.INACTIVITY_WARN_DAYS_BEFORE or "").split(","):
part = part.strip()
if part.isdigit():
v = int(part)
if 0 < v < self.INACTIVITY_RESET_DAYS and v not in out:
out.append(v)
return sorted(out, reverse=True)
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。
# 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自
@@ -325,6 +399,31 @@ class Settings(BaseSettings):
return []
return [o.strip() for o in self.CORS_ALLOW_ORIGINS.split(",") if o.strip()]
# ===== 可观测(OpenObserve 接口指标)=====
# 采集每个接口的 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker)。
# 默认关(prod 安全):未开启 → 中间件透传、worker 不启动,整套 no-op。
# 开启需 ENABLED=true 且 ENDPOINT/USER/PASSWORD 齐全(见 observe_configured)。
OBSERVE_ENABLED: bool = False
OBSERVE_ENDPOINT: str = "http://localhost:5080" # OpenObserve base URL
OBSERVE_ORG: str = "default" # 组织名
OBSERVE_STREAM: str = "app_requests" # stream 名(首次上报自动建)
OBSERVE_USER: str = "" # Basic auth 邮箱
OBSERVE_PASSWORD: str = "" # Basic auth 密码/token
OBSERVE_FLUSH_INTERVAL_SEC: float = 5.0 # worker 最长攒批间隔
OBSERVE_BATCH_MAX: int = 200 # 单批最大事件数
OBSERVE_QUEUE_MAX: int = 10000 # 有界队列上限,满则丢
OBSERVE_TIMEOUT_SEC: float = 5.0 # 上报 HTTP 超时
@property
def observe_configured(self) -> bool:
"""观测上报可用 = 总开关开 且 endpoint/账号/密码齐全(缺则整套 no-op)。"""
return bool(
self.OBSERVE_ENABLED
and self.OBSERVE_ENDPOINT
and self.OBSERVE_USER
and self.OBSERVE_PASSWORD
)
@property
def is_prod(self) -> bool:
return self.APP_ENV == "prod"
+42 -7
View File
@@ -1,7 +1,7 @@
"""无障碍保护存活监控后台任务。
周期扫描「曾经保护过、当前 alive、心跳超时」的设备 = App 被彻底杀掉/无障碍已停(心跳断了),
**命中即在服务器终端打印告警**(本期先不接推送,工程量大,用终端打印代替真实通知);并把状态机
**命中即在服务器终端打印告警并尝试厂商直推**;并把状态机
推进到 notified 防每轮重复打印(心跳恢复时由 repositories.device.touch_heartbeat 重置回 alive)。
结构仿 withdraw_reconcile_worker(单实例锁 + asyncio 轮询 + 优雅退出)。
@@ -22,6 +22,7 @@ from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
from app.db.session import SessionLocal
from app.integrations import vendor_push
from app.repositories import device as device_repo
logger = logging.getLogger("shagua.heartbeat_monitor")
@@ -71,32 +72,66 @@ def _silent_seconds(last: datetime | None) -> int | None:
"""距上次心跳的秒数(兼容 sqlite 取回的 naive datetime)。"""
if last is None:
return None
ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow()
ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow() # noqa: UP017
return int((ref - last).total_seconds())
def _scan_once(timeout_minutes: int) -> dict:
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备,在**服务器终端打印**告警代替真实推送
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备并召回
本期不接推送(极光/厂商通道工程量大),只做服务端掉线检测:命中即 logger.warning 打印到终端,
并把状态机推进到 notified 防每轮重复打印(心跳恢复时 touch_heartbeat 会重置回 alive)
有 push_vendor + push_token 时先发厂商直推,无 token 或推送失败时仍置
kill_alert_pending,客户端下次进 App 继续走后置提醒兜底
"""
notified = 0
pushed = 0
push_failed = 0
with SessionLocal() as db:
overdue = device_repo.list_overdue(db, timeout_minutes=timeout_minutes)
for device in overdue:
silent = _silent_seconds(device.last_heartbeat_at)
logger.warning(
"[掉线检测] user_id=%s device_id=%s%s 秒无心跳(阈值 %d 分钟)"
" → 判定 App 已被杀/无障碍已停。【已置 kill_alert_pending: 用户下次进 App 将弹「开启自启动」引导(后置检测);推送本期未接】",
" → 判定 App 已被杀/无障碍已停。",
device.user_id,
device.device_id,
silent if silent is not None else "?",
timeout_minutes,
)
if device.push_vendor and device.push_token:
try:
vendor_push.send_accessibility_disabled(
device.push_vendor,
device.push_token,
)
pushed += 1
logger.info(
"[掉线检测] push sent user_id=%s device_id=%s vendor=%s",
device.user_id,
device.device_id,
device.push_vendor,
)
except vendor_push.VendorPushError as e:
push_failed += 1
logger.warning(
"[掉线检测] push failed user_id=%s device_id=%s error=%s",
device.user_id,
device.device_id,
e,
)
else:
logger.info(
"[掉线检测] device has no push vendor/token, skip push user_id=%s device_id=%s",
device.user_id,
device.device_id,
)
device_repo.mark_notified(db, device_id_pk=device.id)
notified += 1
return {"checked": len(overdue), "notified": notified}
return {
"checked": len(overdue),
"notified": notified,
"pushed": pushed,
"push_failed": push_failed,
}
async def _run_loop() -> None:
+145
View File
@@ -0,0 +1,145 @@
"""15 天不活跃清零的进程内每日任务。
仿 daily_exchange_worker:App 启动自带,每 `INACTIVITY_RESET_CHECK_INTERVAL_SEC` 醒一次,
跨进北京新的一天且到达 `INACTIVITY_RESET_RUN_HOUR`(默认 3 点)后跑一轮 `run_once`(预警 + 清零)。
健壮性:
- **逐用户幂等**:清完余额=0 次日不再匹配;预警按 streak 去重。启动补跑 / 多次唤醒 / 重启都安全。
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑。
- **常驻 + dry-run 默认**:worker 一直跑;INACTIVITY_RESET_ENABLED=false(默认)只记审计名单、
不动钱(dry-run 灰度看名单),=true 才真清。
⚠️ 这是不可逆批量资金操作(清空金币 + 折算现金,**邀请现金不清**)。口径见
app.repositories.inactivity / app.repositories.activity。
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from collections.abc import Iterator
from datetime import date, datetime
from pathlib import Path
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
from app.core.rewards import CN_TZ, cn_today
from app.db.session import SessionLocal
from app.integrations.notifier import get_notifier
from app.repositories import inactivity as inactivity_repo
logger = logging.getLogger("shagua.inactivity")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "inactivity_reset.lock"
def _cn_today() -> date:
return cn_today()
def _touch_lock() -> None:
with contextlib.suppress(FileNotFoundError):
os.utime(_LOCK_PATH, None)
@contextlib.contextmanager
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
"""同机多进程保护:同一时间只允许一个清零 worker 运行。"""
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
fd: int | None = None
try:
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
try:
age = time.time() - _LOCK_PATH.stat().st_mtime
except FileNotFoundError:
age = stale_after_sec + 1
if age > stale_after_sec:
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
fd = None
if fd is None:
yield False
return
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
yield True
finally:
if fd is not None:
os.close(fd)
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
def _run_once_entry() -> dict:
"""跑一轮(预警 + 清零)。独立开 Session。"""
notifier = get_notifier(settings.INACTIVITY_NOTIFY_CHANNEL)
with SessionLocal() as db:
return inactivity_repo.run_once(
db,
notifier=notifier,
reset_days=settings.INACTIVITY_RESET_DAYS,
warn_stages=settings.inactivity_warn_stages,
today=_cn_today(),
dry_run=not settings.INACTIVITY_RESET_ENABLED, # ENABLED=false → 只记审计名单、不清
)
async def _run_loop() -> None:
interval = max(60, int(settings.INACTIVITY_RESET_CHECK_INTERVAL_SEC))
lock_stale_after = max(interval * 3, 1800)
with _single_instance_lock(lock_stale_after) as lock_acquired:
if not lock_acquired:
logger.warning("inactivity reset skipped: another worker owns lock")
return
await _run_locked_loop(interval)
async def _run_locked_loop(interval: int) -> None:
logger.info(
"inactivity reset worker started interval=%ss run_hour=%s mode=%s",
interval,
settings.INACTIVITY_RESET_RUN_HOUR,
"clear" if settings.INACTIVITY_RESET_ENABLED else "dry-run(audit-only)",
)
# 本进程上次跑过的北京日;None=尚未跑过本进程(当天到点即补)。
last_run: date | None = None
try:
while True:
try:
_touch_lock()
today = _cn_today()
hour = datetime.now(CN_TZ).hour
if last_run != today and hour >= int(settings.INACTIVITY_RESET_RUN_HOUR):
result = await asyncio.to_thread(_run_once_entry)
last_run = today
logger.info("inactivity reset done date=%s result=%s", today, result)
except SQLAlchemyError:
logger.exception("inactivity reset db error")
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
logger.exception("inactivity reset unexpected error")
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("inactivity reset worker stopped")
raise
def start_inactivity_reset_worker() -> asyncio.Task | None:
# worker 常驻(不再有"完全关"档);INACTIVITY_RESET_ENABLED 只决定是否**真清**:
# false(默认)= 只记审计名单(dry-run,不动钱),true = 真清金币+现金。
return asyncio.create_task(_run_loop(), name="inactivity-reset")
async def stop_inactivity_reset_worker(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
+241
View File
@@ -0,0 +1,241 @@
"""消息通知中心:13 种通知类型的静态目录 + Push 文案模板。
对应 PRD《消息通知中心》:§1 类型清单 / §3 字段元素 / §5 Push 文案。
这里只放**静态定义**(分类、版式、标题、操作行、Push 模板),供两处消费:
- repositories/notification.py 消息中心列表按 type 派生分类/版式/标题/操作行
- api/v1/push.py 渲染 13 类 push 标题/文案(厂商推送 + 测试端点)
PRD 文案规范(§5):push 标题 ≤11 字、固定文案不带变量;变量只出现在正文里且尽量前置。
模板变量用 `{name}` 占位,渲染时缺省回退 sample_vars(PRD 示例值),保证 mock 阶段随时可发。
"""
from __future__ import annotations
from dataclasses import dataclass, field
# ---------------------------------------------------------------------------
# 分类(仅作卡片头部的分类标签展示;列表不按分类分组——PRD §1 的分组已确认取消,全表时间倒序)
# ---------------------------------------------------------------------------
CATEGORY_WITHDRAW = "withdraw_assistant"
CATEGORY_SYSTEM = "system"
CATEGORY_FEEDBACK = "feedback"
CATEGORY_REPORT = "report"
CATEGORY_INVITE = "invite"
# key → 中文标签
CATEGORIES: dict[str, str] = {
CATEGORY_WITHDRAW: "提现助手",
CATEGORY_SYSTEM: "系统通知",
CATEGORY_FEEDBACK: "我的反馈",
CATEGORY_REPORT: "我的爆料",
CATEGORY_INVITE: "好友邀请",
}
def category_label(key: str) -> str:
return CATEGORIES[key]
# ---------------------------------------------------------------------------
# 卡片版式(PRD §3「版式」列;前端按此渲染五种卡)
# ---------------------------------------------------------------------------
CARD_DUAL_AMOUNT = "dual_amount" # 双金额卡(金币数 + 现金数)
CARD_WITHDRAW = "withdraw" # 提现卡(¥金额)
CARD_PLAIN_TEXT = "plain_text" # 纯文本卡(无数值)
CARD_COIN_REWARD = "coin_reward" # 金币奖励卡(金币数 + 单位「金币」)
CARD_FRIEND_CASH = "friend_cash" # 好友现金卡(¥金额)
@dataclass(frozen=True)
class NotificationType:
"""一种通知类型的静态定义(卡片元数据 + Push 模板)。"""
key: str # 类型 key(接口 type 字段;前端按它决定点击跳转,见 PRD §2)
category: str # 分类 key(CATEGORIES 之一)
card_style: str # 卡片版式(CARD_* 之一)
card_title: str # 卡片标题(PRD §3「标题」列)
action_text: str | None # 操作行文案;None = 无操作行(如「提现成功」)
push_title: str # push 标题(≤11 字固定文案,PRD §5)
push_body_template: str # push 正文模板,`{var}` 为变量
sample_vars: dict[str, str] = field(default_factory=dict) # PRD 示例值,渲染缺省回退
# 13 种类型,编号/文案与 PRD §1/§3/§5 一一对应(插入顺序 = PRD 编号顺序)。
TYPES: dict[str, NotificationType] = {
t.key: t
for t in [
# -- 提现助手 -------------------------------------------------------
NotificationType(
key="reward_expiring",
category=CATEGORY_WITHDRAW,
card_style=CARD_DUAL_AMOUNT,
card_title="金币现金奖励即将失效",
action_text="立即激活您的收益",
push_title="您的奖励即将失效",
push_body_template="{coins}金币和{cash}元现金{days}天后失效,完成快来激活收益",
sample_vars={"coins": "86", "cash": "12.80", "days": "3"},
),
NotificationType(
key="reward_expired",
category=CATEGORY_WITHDRAW,
card_style=CARD_DUAL_AMOUNT,
card_title="金币现金奖励已失效",
action_text="立即赚取新收益",
push_title="您的奖励已失效",
push_body_template="{coins}金币和{cash}元现金已过期,完成一次一键领券或一键比价可赚取新收益",
sample_vars={"coins": "35", "cash": "0.60"},
),
NotificationType(
key="withdraw_success",
category=CATEGORY_WITHDRAW,
card_style=CARD_WITHDRAW,
card_title="提现成功",
action_text=None, # PRD §3:提现成功卡无操作行,点击也无跳转、仅消红点
push_title="提现到账提醒",
push_body_template="¥{amount}已存入您的微信钱包,点击查看到账详情",
sample_vars={"amount": "0.50"},
),
NotificationType(
key="withdraw_failed",
category=CATEGORY_WITHDRAW,
card_style=CARD_WITHDRAW,
card_title="提现失败,款项已退回",
action_text="重新提现",
push_title="提现失败,款项已退回",
push_body_template="¥{amount}{reason}退回现金余额,点击重新提现",
sample_vars={"amount": "3.50", "reason": "微信零钱未实名"},
),
# -- 系统通知(权限异常 ×4;标题里的功能名按类型写死,见 PRD §1/§3)----
NotificationType(
key="perm_accessibility",
category=CATEGORY_SYSTEM,
card_style=CARD_PLAIN_TEXT,
card_title="检测到您的比价功能已失效",
action_text="去开启",
push_title="检测到您的比价功能已失效",
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
),
NotificationType(
key="perm_battery",
category=CATEGORY_SYSTEM,
card_style=CARD_PLAIN_TEXT,
card_title="检测到您的比价续航保护已失效",
action_text="去开启",
push_title="检测到您的比价续航保护已失效",
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
),
NotificationType(
key="perm_autostart",
category=CATEGORY_SYSTEM,
card_style=CARD_PLAIN_TEXT,
card_title="检测到您的比价启动保护已失效",
action_text="去开启",
push_title="检测到您的比价启动保护已失效",
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
),
NotificationType(
key="perm_overlay",
category=CATEGORY_SYSTEM,
card_style=CARD_PLAIN_TEXT,
card_title="检测到您的比价按钮已失效",
action_text="去开启",
push_title="检测到您的比价按钮已失效",
push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启",
),
# -- 我的反馈 -------------------------------------------------------
NotificationType(
key="feedback_reply",
category=CATEGORY_FEEDBACK,
card_style=CARD_PLAIN_TEXT,
card_title="傻瓜比价官方回复了您的反馈",
action_text="查看详情",
push_title="您的反馈有回复啦",
push_body_template="您提的建议我们认真看过了,来看看我们的回复吧~",
),
NotificationType(
key="feedback_reward",
category=CATEGORY_FEEDBACK,
card_style=CARD_COIN_REWARD,
card_title="反馈奖励",
action_text="查看反馈详情",
push_title="反馈奖励已到账",
push_body_template="谢谢您帮傻瓜比价变得更好,{coins}金币已到账,还有一条给您的留言~",
sample_vars={"coins": "300"},
),
# -- 我的爆料 -------------------------------------------------------
NotificationType(
key="report_approved",
category=CATEGORY_REPORT,
card_style=CARD_COIN_REWARD,
card_title="爆料审核通过",
action_text="查看爆料详情",
push_title="爆料审核通过",
push_body_template="您爆料的「{store}」更低价审核通过,{coins}金币已到账,感谢您的分享",
sample_vars={"store": "蜀大侠火锅", "coins": "1000"},
),
# -- 好友邀请 -------------------------------------------------------
NotificationType(
key="invite_order_reward",
category=CATEGORY_INVITE,
card_style=CARD_FRIEND_CASH,
card_title="好友比价成功,现金已到账",
action_text="邀请更多好友赚现金",
push_title="您的邀请奖励已到账",
push_body_template="您的好友「{nickname}」完成首次下单,{amount}元现金已到账",
sample_vars={"nickname": "柚子", "amount": "2"},
),
NotificationType(
key="invite_remind",
category=CATEGORY_INVITE,
card_style=CARD_PLAIN_TEXT,
card_title="你邀请的好友还差一步",
action_text="去提醒 TA",
push_title="提醒好友完成比价的奖励",
push_body_template="您的好友「{nickname}」还没完成比价下单,提醒TA完成,您可得{amount}元现金",
sample_vars={"nickname": "阿泽", "amount": "2"},
),
]
}
class UnknownNotificationType(ValueError):
"""type key 不在 13 种类型之内。"""
def get_type(type_key: str) -> NotificationType:
ntype = TYPES.get(type_key)
if ntype is None:
raise UnknownNotificationType(
f"unknown notification type: {type_key!r} (可选: {', '.join(TYPES)})"
)
return ntype
def render_push(type_key: str, variables: dict[str, str] | None = None) -> tuple[str, str]:
"""渲染某类型的 push (标题, 正文)。
variables 覆盖模板变量;缺的变量回退 sample_vars(PRD 示例值)——保证虚拟数据
阶段不传变量也能发出完整文案。多余的变量忽略。
"""
ntype = get_type(type_key)
merged = {**ntype.sample_vars, **(variables or {})}
class _Fallback(dict):
def __missing__(self, key: str) -> str: # 模板变量既没传也没示例值 → 保留 {key} 原样
return "{" + key + "}"
body = ntype.push_body_template.format_map(_Fallback(merged))
return ntype.push_title, body
def push_variable_names(type_key: str) -> list[str]:
"""列出模板里出现的变量名(给 /push/templates 预览用)。"""
import string
ntype = get_type(type_key)
return [
fname
for _, fname, _, _ in string.Formatter().parse(ntype.push_body_template)
if fname
]
+110
View File
@@ -0,0 +1,110 @@
"""接口指标埋点:有界事件队列 + 纯 ASGI 中间件。
每个 HTTP 请求测总耗时、抓路由模板 + 状态码,非阻塞塞进有界队列;由 observe_worker
后台批量上报到 OpenObserve。请求路径上无任何 I/O。未配置观测时中间件直接透传。
"""
from __future__ import annotations
import asyncio
import os
import time
from starlette.routing import Match
from app.core.config import settings
# 不采集的路径(纯噪音):健康检查。
_SKIP_PATHS = frozenset({"/health"})
# 未匹配路由(404/扫描器)归一到此,防维度爆炸。
_UNMATCHED = "__unmatched__"
# service 字段:与 logging.py 同源(LOG_SERVICE_NAME),默认 app-server。
_SERVICE = os.getenv("LOG_SERVICE_NAME", "app-server")
# 有界事件队列(懒创建,见 get_queue):首次取用时在运行中的 loop 里建,避免 import 期
# 无 loop 的边角问题;put_nowait/get_nowait 不需运行中的 loop → 可在无 loop 下测试。
_queue: asyncio.Queue[dict] | None = None
# 队列满时的丢弃计数,worker 定期取出打日志。
_dropped = 0
def get_queue() -> asyncio.Queue[dict]:
"""返回全局有界事件队列(懒创建)。测试可 monkeypatch 模块级 _queue 换成小队列。"""
global _queue
if _queue is None:
_queue = asyncio.Queue(maxsize=settings.OBSERVE_QUEUE_MAX)
return _queue
def take_dropped() -> int:
"""取出并清零累计丢弃数(供 worker 打点)。"""
global _dropped
n, _dropped = _dropped, 0
return n
def record_event(event: dict) -> None:
"""非阻塞入队;队列满则丢弃当前事件并计数。永不抛异常、永不阻塞请求。"""
global _dropped
try:
get_queue().put_nowait(event)
except asyncio.QueueFull:
_dropped += 1
def _resolve_route(scope) -> str:
"""从 scope 取路由模板(如 /things/{tid})。优先 scope['route'](现代 Starlette
路由后写入);取不到则手动匹配一次(老版本兜底);仍无 → __unmatched__(404/扫描器)。"""
route = scope.get("route")
path = getattr(route, "path", None)
if path:
return path
app_ = scope.get("app")
router = getattr(app_, "router", None)
for candidate in getattr(router, "routes", []):
try:
match, _ = candidate.matches(scope)
except Exception: # noqa: BLE001 - 匹配兜底,任一路由异常不影响整体
continue
if match == Match.FULL and getattr(candidate, "path", None):
return candidate.path
return _UNMATCHED
class RequestMetricsMiddleware:
"""纯 ASGI 中间件:测每个 http 请求耗时,记 method/route/status/duration。
放在最外层(main.py 里 CORS 之后 add),测到含 CORS 的完整耗时。未配置观测 → 透传。
"""
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http" or not settings.observe_configured:
await self.app(scope, receive, send)
return
if scope.get("path") in _SKIP_PATHS:
await self.app(scope, receive, send)
return
start = time.perf_counter()
status_holder = {"status": 500} # 下游异常未产出 response 时兜底 500
async def send_wrapper(message) -> None:
if message["type"] == "http.response.start":
status_holder["status"] = message["status"]
await send(message)
try:
await self.app(scope, receive, send_wrapper)
finally:
duration_ms = (time.perf_counter() - start) * 1000.0
record_event({
"_timestamp": int(time.time() * 1_000_000), # µs,OpenObserve 时间列
"service": _SERVICE,
"env": settings.APP_ENV,
"method": scope.get("method", ""),
"route": _resolve_route(scope),
"status": status_holder["status"],
"duration_ms": round(duration_ms, 3),
})
+128
View File
@@ -0,0 +1,128 @@
"""接口指标后台上报 worker:批量 drain 事件队列 → POST 到 OpenObserve。
对齐 heartbeat_monitor_worker 等的 start_*/stop_* 形态。best-effort 遥测:catch 全部
异常,上报失败直接丢批不重试。未配置观测 → start 返回 None(不启动),整套 no-op。
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import httpx
from app.core.config import settings
from app.core.observe import get_queue, take_dropped
logger = logging.getLogger("shagua.observe")
# 上报用的 httpx client,start 时建、stop 时关。
_client: httpx.AsyncClient | None = None
async def _collect_batch() -> list[dict]:
"""等到 ≥1 条(或到 flush 间隔)后,连抽到 BATCH_MAX 条或抽空。超时且空 → 返回 []。"""
queue = get_queue()
batch: list[dict] = []
try:
first = await asyncio.wait_for(
queue.get(), timeout=settings.OBSERVE_FLUSH_INTERVAL_SEC
)
except asyncio.TimeoutError: # noqa: UP041 - 3.10 兼容:该版 wait_for 抛的 asyncio.TimeoutError ≠ 内置 TimeoutError
return batch
batch.append(first)
while len(batch) < settings.OBSERVE_BATCH_MAX:
try:
batch.append(queue.get_nowait())
except asyncio.QueueEmpty:
break
return batch
async def _post_batch(client: httpx.AsyncClient, batch: list[dict]) -> None:
"""POST 一批事件到 OpenObserve 的 _json ingest 端点。非 2xx 仅告警。"""
url = f"/api/{settings.OBSERVE_ORG}/{settings.OBSERVE_STREAM}/_json"
resp = await client.post(url, json=batch)
if resp.status_code >= 300:
logger.warning(
"observe ingest failed status=%s body=%s",
resp.status_code,
resp.text[:200],
)
async def _run_loop(client: httpx.AsyncClient) -> None:
try:
while True:
batch = await _collect_batch()
dropped = take_dropped()
if dropped:
logger.warning("observe dropped %d events (queue full)", dropped)
if not batch:
continue
try:
await _post_batch(client, batch)
except Exception: # noqa: BLE001 - best-effort 遥测,失败丢批不重试、不退出
logger.warning(
"observe post batch failed, dropped %d events",
len(batch),
exc_info=True,
)
except asyncio.CancelledError:
logger.info("observe worker stopped")
raise
def start_observe_worker() -> asyncio.Task | None:
"""启动上报 worker。未配置观测 → 返回 None(no-op)。约定每进程只调一次(lifespan)。"""
global _client
if not settings.observe_configured:
return None
if _client is not None:
# 约定 start 每进程只调一次;已启动则不重复建 client(避免泄漏旧连接池)。
logger.warning("observe worker already started; ignoring duplicate start")
return None
_client = httpx.AsyncClient(
base_url=settings.OBSERVE_ENDPOINT,
auth=(settings.OBSERVE_USER, settings.OBSERVE_PASSWORD),
timeout=settings.OBSERVE_TIMEOUT_SEC,
)
logger.info(
"observe worker started endpoint=%s org=%s stream=%s",
settings.OBSERVE_ENDPOINT,
settings.OBSERVE_ORG,
settings.OBSERVE_STREAM,
)
return asyncio.create_task(_run_loop(_client), name="observe-worker")
async def stop_observe_worker(task: asyncio.Task | None) -> None:
"""收尾:cancel worker → best-effort 发最后一批 → 关 client。"""
global _client
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
dropped = take_dropped() # 收口:补记最后一个 flush 窗口累计的丢弃数,不让账丢在关停期
if dropped:
logger.warning("observe dropped %d events (queue full) before shutdown", dropped)
if _client is not None:
# worker 已停,安全 drain 剩余并 best-effort 发最后一批(短超时,不卡关停);
# 超过一批(BATCH_MAX)的剩余直接丢,不做多轮 flush(best-effort,关停从速)。
try:
queue = get_queue()
final: list[dict] = []
while len(final) < settings.OBSERVE_BATCH_MAX:
try:
final.append(queue.get_nowait())
except asyncio.QueueEmpty:
break
if final:
await asyncio.wait_for(
_post_batch(_client, final), timeout=settings.OBSERVE_TIMEOUT_SEC
)
except Exception: # noqa: BLE001 - 关停期尽力而为,失败忽略
pass
await _client.aclose()
_client = None
+43
View File
@@ -0,0 +1,43 @@
"""不活跃预警通知器(可插拔)。
v1 仅日志占位(LogNotifier):现状无真实推送能力(极光只用于一键登录解密 + 设备心跳告警,
心跳 worker 也只打印),先把清零主流程 + 审计做扎实。后续实现同协议的 JPushNotifier /
SmsNotifier 即可替换,worker/repo 不改。
"""
from __future__ import annotations
import logging
from typing import Protocol
logger = logging.getLogger("shagua.inactivity")
class InactivityNotifier(Protocol):
channel: str
def warn(self, *, user_id: int, coin: int, cash_cents: int,
stage: int, days_until_reset: int) -> str:
"""发预警(只涉及会被清的金币 + 折算现金;邀请现金不清、不预警)。
返回状态:'sent' / 'failed' / 'placeholder'"""
...
class LogNotifier:
"""占位实现:只打印,不真推。参照 heartbeat_monitor_worker「本期先不接推送」先例。"""
channel = "log"
def warn(self, *, user_id: int, coin: int, cash_cents: int,
stage: int, days_until_reset: int) -> str:
logger.warning(
"[inactivity-warn] user=%s coin=%s cash_cents=%s stage=T-%s days_until_reset=%s",
user_id, coin, cash_cents, stage, days_until_reset,
)
return "placeholder"
def get_notifier(channel: str) -> InactivityNotifier:
"""按配置返回通知器。未实现的通道(jpush/sms)暂回退 LogNotifier 占位。"""
# 后续:if channel == "jpush": return JPushNotifier()
# if channel == "sms": return SmsNotifier()
return LogNotifier()
+584
View File
@@ -0,0 +1,584 @@
"""厂商直推集成(荣耀 / 华为 / 小米 / OPPO / vivo)。
服务端不经由 JPush Push API,而是按客户端上报的 push_vendor + push_token
分发到各手机厂商的服务端 API。
对外两个入口:
- send_notification() 通用:任意标题/正文/extras(消息中心 13 类推送走这里),
mock=True 时不真调厂商、返回渲染结果(虚拟数据联调用)
- send_accessibility_disabled() 旧:无障碍掉线召回(heartbeat_monitor_worker 在用),
已改为 send_notification 的薄封装,行为不变
各厂商鉴权方式:荣耀/华为 OAuth client_credentials 换 access_token(进程内缓存);
vivo/OPPO 签名换 authToken(缓存 24h);小米直接 AppSecret 走 Authorization 头。
"""
from __future__ import annotations
import hashlib
import json
import logging
import time
import uuid
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote
import httpx
from app.core.config import settings
logger = logging.getLogger("shagua.vendor_push")
TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled"
SUPPORTED_VENDORS = frozenset({"honor", "huawei", "vivo", "xiaomi", "oppo"})
# vendor key → 中文名(测试/配置状态接口展示用)
VENDOR_LABELS: dict[str, str] = {
"honor": "荣耀",
"huawei": "华为",
"xiaomi": "小米",
"oppo": "OPPO",
"vivo": "vivo",
}
# 各厂商真发推送所需的 settings 键(缺任一即视为未配置;/api/v1/push/vendors 据此报缺)
REQUIRED_SETTINGS: dict[str, tuple[str, ...]] = {
"honor": ("HONOR_PUSH_APP_ID", "HONOR_PUSH_CLIENT_ID", "HONOR_PUSH_CLIENT_SECRET"),
"huawei": ("HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"),
"xiaomi": ("XIAOMI_PUSH_APP_SECRET",),
"oppo": ("OPPO_PUSH_APP_KEY", "OPPO_PUSH_MASTER_SECRET"),
"vivo": ("VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET"),
}
def missing_settings(vendor: str) -> list[str]:
"""该厂商还缺哪些配置键(全配齐返回空列表)。vendor 需已 normalize。"""
return [key for key in REQUIRED_SETTINGS.get(vendor, ()) if not getattr(settings, key, "")]
class VendorPushError(Exception):
"""厂商推送调用失败。"""
@dataclass
class _CachedToken:
value: str
expires_at: float
_token_cache: dict[str, _CachedToken] = {}
def normalize_vendor(push_vendor: str | None) -> str | None:
if not push_vendor:
return None
vendor = push_vendor.strip().lower()
aliases = {
"hihonor": "honor",
"荣耀": "honor",
"hms": "huawei",
"华为": "huawei",
"harmony": "huawei",
"harmonyos": "huawei",
"mi": "xiaomi",
"小米": "xiaomi",
"oneplus": "oppo",
"realme": "oppo",
}
return aliases.get(vendor, vendor)
def send_notification(
push_vendor: str,
push_token: str,
*,
title: str,
body: str,
extras: dict[str, str] | None = None,
mock: bool = False,
) -> dict[str, Any]:
"""按厂商 token 向单台设备发送一条通知(通用入口)。
- extras:透传给客户端的自定义键值(值统一 string,兼容各厂商限制)。消息中心推送约定
至少带 {"type": <13 种类型 key>, "notificationId": <站内消息 id>},客户端据此
深链落地 + 调 /notifications/read 同步置读(PRD §4 push 联动)。
- mock=True:不真调厂商 API,校验参数后原样返回渲染结果(虚拟数据阶段联调/自动化测试用)。
"""
vendor = normalize_vendor(push_vendor)
token = push_token.strip() if push_token else ""
if not vendor or vendor not in SUPPORTED_VENDORS:
raise VendorPushError(f"unsupported push vendor: {push_vendor}")
if not token:
raise VendorPushError("push token is empty")
extras = {str(k): str(v) for k, v in (extras or {}).items()}
if mock:
logger.info(
"[mock push] vendor=%s token=%s... title=%s body=%s extras=%s",
vendor, token[:12], title, body, extras,
)
return {
"mock": True,
"vendor": vendor,
"title": title,
"body": body,
"extras": extras,
}
dispatch: dict[str, Callable[[str, str, str, dict[str, str]], dict[str, Any]]] = {
"honor": _send_honor,
"huawei": _send_huawei,
"vivo": _send_vivo,
"xiaomi": _send_xiaomi,
"oppo": _send_oppo,
}
return dispatch[vendor](token, title, body, extras)
def send_accessibility_disabled(
push_vendor: str,
push_token: str,
*,
title: str = "保护已关闭",
alert: str = "傻瓜比价的无障碍保护被关了,点此重新开启,继续帮你自动比价省钱。",
) -> dict[str, Any]:
"""按厂商 token 向单台设备发送无障碍掉线通知(heartbeat_monitor_worker 在用,行为不变)。"""
return send_notification(
push_vendor,
push_token,
title=title,
body=alert,
extras={"type": TYPE_ACCESSIBILITY_DISABLED},
)
def _require(value: str, name: str) -> str:
if not value:
raise VendorPushError(f"{name} not configured")
return value
def _request_json(
method: str,
url: str,
*,
expected_status: tuple[int, ...] = (200,),
**kwargs: Any,
) -> dict[str, Any]:
try:
resp = httpx.request(
method,
url,
timeout=settings.PUSH_REQUEST_TIMEOUT_SEC,
**kwargs,
)
except httpx.HTTPError as e:
raise VendorPushError(f"push http error: {e}") from e
if resp.status_code not in expected_status:
logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
raise VendorPushError(f"push http {resp.status_code}")
try:
return resp.json()
except ValueError as e:
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
def _request_form(
method: str,
url: str,
*,
expected_status: tuple[int, ...] = (200,),
**kwargs: Any,
) -> dict[str, Any]:
try:
resp = httpx.request(
method,
url,
timeout=settings.PUSH_REQUEST_TIMEOUT_SEC,
**kwargs,
)
except httpx.HTTPError as e:
raise VendorPushError(f"push http error: {e}") from e
if resp.status_code not in expected_status:
logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
raise VendorPushError(f"push http {resp.status_code}")
try:
return resp.json()
except ValueError as e:
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
def _cache_get(key: str) -> str | None:
cached = _token_cache.get(key)
if cached and cached.expires_at > time.time() + 60:
return cached.value
return None
def _cache_put(key: str, value: str, expires_in: int | float | None) -> str:
ttl = int(expires_in or 3600)
_token_cache[key] = _CachedToken(value=value, expires_at=time.time() + max(60, ttl - 60))
return value
def _honor_access_token() -> str:
cache_key = "honor"
cached = _cache_get(cache_key)
if cached:
return cached
client_id = _require(settings.HONOR_PUSH_CLIENT_ID, "HONOR_PUSH_CLIENT_ID")
client_secret = _require(settings.HONOR_PUSH_CLIENT_SECRET, "HONOR_PUSH_CLIENT_SECRET")
data = _request_form(
"POST",
settings.HONOR_PUSH_TOKEN_ENDPOINT,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
token = data.get("access_token")
if not token:
raise VendorPushError(f"honor auth failed: {data}")
return _cache_put(cache_key, str(token), data.get("expires_in"))
def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID")
access_token = _honor_access_token()
payload = {
# clickAction type=3(打开应用首页)时,荣耀点击会把 data JSON 的键值对注入启动 intent 的
# extras(与 HMS 同机制)→ MainActivity.consumeNavTarget 读 notif_id/notif_type 直达落地。
"data": json.dumps(_click_extras(extras), ensure_ascii=False),
"notification": {"title": title, "body": body},
"android": {
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
"targetUserType": 1,
"notification": {
"title": title,
"body": body,
"clickAction": {"type": 3},
"importance": "NORMAL",
},
},
"token": [token],
}
data = _request_json(
"POST",
settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
json=payload,
headers={
"Content-Type": "application/json; charset=UTF-8",
"Authorization": f"Bearer {access_token}",
"timestamp": str(int(time.time() * 1000)),
},
)
code = data.get("code")
if code is not None and int(code) != 200:
raise VendorPushError(f"honor push failed: {data}")
return data
def _huawei_access_token() -> str:
"""华为 OAuth2 client_credentials 换 access_token(client_id 即 AGC 应用的 AppId)。"""
cache_key = "huawei"
cached = _cache_get(cache_key)
if cached:
return cached
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
app_secret = _require(settings.HUAWEI_PUSH_APP_SECRET, "HUAWEI_PUSH_APP_SECRET")
data = _request_form(
"POST",
settings.HUAWEI_PUSH_TOKEN_ENDPOINT,
data={
"grant_type": "client_credentials",
"client_id": app_id,
"client_secret": app_secret,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
token = data.get("access_token")
if not token:
raise VendorPushError(f"huawei auth failed: {data}")
return _cache_put(cache_key, str(token), data.get("expires_in"))
def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
"""华为 Push Kit 下行消息(v1 messages:send)。成功码 '80000000';
'80100000' 为部分成功(单 token 场景仍视为失败,错误里带原始响应便于排障)。"""
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
access_token = _huawei_access_token()
payload = {
"validate_only": False,
"message": {
# click_action type=3(打开应用首页)时,HMS 点击会把 data JSON 的键值对注入启动 intent
# 的 extras → MainActivity.consumeNavTarget 读 notif_id/notif_type 直达落地。
"data": json.dumps(_click_extras(extras), ensure_ascii=False),
"android": {
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
"notification": {
"title": title,
"body": body,
"click_action": {"type": 3},
"importance": "NORMAL",
},
},
"token": [token],
},
}
data = _request_json(
"POST",
settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
json=payload,
headers={
"Content-Type": "application/json; charset=UTF-8",
"Authorization": f"Bearer {access_token}",
},
)
if str(data.get("code", "")) != "80000000":
raise VendorPushError(f"huawei push failed: {data}")
return data
def _vivo_auth_token() -> str:
cache_key = "vivo"
cached = _cache_get(cache_key)
if cached:
return cached
app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID")
app_key = _require(settings.VIVO_PUSH_APP_KEY, "VIVO_PUSH_APP_KEY")
app_secret = _require(settings.VIVO_PUSH_APP_SECRET, "VIVO_PUSH_APP_SECRET")
timestamp = str(int(time.time() * 1000))
sign = hashlib.md5(f"{app_id}{app_key}{timestamp}{app_secret}".encode()).hexdigest() # noqa: S324
data = _request_json(
"POST",
settings.VIVO_PUSH_AUTH_ENDPOINT,
json={
"appId": app_id,
"appKey": app_key,
"timestamp": timestamp,
"sign": sign,
},
headers={"Content-Type": "application/json"},
)
if int(data.get("result", -1)) != 0:
raise VendorPushError(f"vivo auth failed: {data}")
token = data.get("authToken")
if not token:
raise VendorPushError(f"vivo auth missing authToken: {data}")
return _cache_put(cache_key, str(token), 24 * 3600)
def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID")
auth_token = _vivo_auth_token()
payload: dict[str, Any] = {
"appId": app_id,
"regId": token,
"notifyType": settings.VIVO_PUSH_NOTIFY_TYPE,
"title": title,
"content": body,
"timeToLive": settings.PUSH_TIME_TO_LIVE_SEC,
"requestId": uuid.uuid4().hex,
"pushMode": settings.VIVO_PUSH_MODE,
"clientCustomMap": extras,
}
# 点击落地:消息中心推送(带 notificationId)→ skipType=4 + skipContent=intent uri,由 vivo
# 系统直启 MainActivity 并携带 S. extras(与小米 notify_effect=2 同机制)。不依赖客户端
# VivoPushReceiver.onNotificationMessageClicked 里的后台 startActivity——Android 10+ BAL
# 会静默拦掉,receiver 路径仅作兜底。无 notificationId 的召回类保持 skipType=1 仅打开首页。
if extras.get("notificationId"):
payload["skipType"] = 4
payload["skipContent"] = _click_intent_uri(extras)
else:
payload["skipType"] = 1
if settings.VIVO_PUSH_CATEGORY:
payload["category"] = settings.VIVO_PUSH_CATEGORY
data = _request_json(
"POST",
settings.VIVO_PUSH_SEND_ENDPOINT,
json=payload,
headers={
"Content-Type": "application/json",
"authToken": auth_token,
},
)
if int(data.get("result", -1)) != 0:
raise VendorPushError(f"vivo push failed: {data}")
return data
def _send_xiaomi(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
app_secret = _require(settings.XIAOMI_PUSH_APP_SECRET, "XIAOMI_PUSH_APP_SECRET")
message_title = settings.XIAOMI_PUSH_TEMPLATE_TITLE.strip() or title
message_description = settings.XIAOMI_PUSH_TEMPLATE_DESCRIPTION.strip() or body
form = {
"registration_id": token,
"restricted_package_name": settings.ANDROID_PACKAGE_NAME,
"title": message_title,
"description": message_description,
"payload": json.dumps(extras, ensure_ascii=False),
"pass_through": "0",
"notify_type": "-1",
"time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000),
}
# 点击落地:带 notificationId 的消息中心推送 → notify_effect=2 + intent_uri,MiPush 直接打开
# MainActivity 并把 extras 作为 String extra 传入(客户端 MainActivity.consumeNavTarget 读
# notif_id/notif_type,兜底 notificationId/type)→ 置读 + 刷角标 + 按 type 直达对应页(PRD §5)。
# ⚠️ 早前用 notify_effect=1(仅打开 Launcher),小米自身不会把 payload 拆成普通 extra、而是塞进
# 序列化的 MiPushMessage(key_message),客户端读不到 → 点击后停在首页「没反应」。
# 无 notificationId 的系统召回类(如无障碍掉线)保持 notify_effect=1 仅拉起 App,行为不变。
if extras.get("notificationId"):
form["extra.notify_effect"] = "2"
form["extra.intent_uri"] = _click_intent_uri(extras)
else:
form["extra.notify_effect"] = "1"
if settings.XIAOMI_PUSH_CHANNEL_ID:
form["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip()
if settings.XIAOMI_PUSH_TEMPLATE_ID:
form["extra.template_id"] = settings.XIAOMI_PUSH_TEMPLATE_ID.strip()
if settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON:
form["extra.template_param"] = _xiaomi_template_param(title, body)
data = _request_form(
"POST",
settings.XIAOMI_PUSH_SEND_ENDPOINT,
data=form,
headers={"Authorization": f"key={app_secret}"},
)
code = data.get("code")
if code not in (0, "0", None):
raise VendorPushError(f"xiaomi push failed: {data}")
if str(data.get("result", "ok")).lower() not in ("ok", "success"):
raise VendorPushError(f"xiaomi push failed: {data}")
return data
def _click_extras(extras: dict[str, str]) -> dict[str, str]:
"""点击落地参数:消息中心推送(extras 带 notificationId)补 notif_id/notif_type 别名——
客户端 MainActivity.consumeNavTarget 首选这两个键(厂商 receiver 路径的历史约定),原始键
(notificationId/type/feedbackId/reportId/…)保留作兜底与业务跳转参数。
无 notificationId(如无障碍召回)原样返回,不喂点击路由参数。"""
if not extras.get("notificationId"):
return dict(extras)
merged = dict(extras)
merged.setdefault("notif_id", extras["notificationId"])
if extras.get("type"):
merged.setdefault("notif_type", extras["type"])
return merged
def _click_intent_uri(extras: dict[str, str]) -> str:
"""构造「系统直启 MainActivity 并带 extras」的 intent uri(小米 notify_effect=2 的
extra.intent_uri、vivo skipType=4 的 skipContent 共用):点击后厂商系统用 Intent.parseUri
解析并 startActivity,extras 作为 String extra 原样送达。
- component 显式指向本包 MainActivity(exported=true、singleTask)→ 已运行则走 onNewIntent、
未运行则 onCreate,两条都会执行 consumeNavTarget。
- 参数 = _click_extras(补 notif_id/notif_type 别名 + 透传 feedbackId/reportId 等跳转参数)。
- 值按 Android Uri.encode 规则百分号编码(quote(safe="")):中文/分号/等号都不会破坏 intent uri
结构;客户端 Intent.parseUri 侧 Uri.decode 无损还原。表单/JSON 传输层的编码与本层相互独立、
各自解码,不会双重转义(2026-07-15 小米联调结论)。
"""
pkg = settings.ANDROID_PACKAGE_NAME
parts = ["intent:#Intent", f"component={pkg}/{pkg}.MainActivity"]
parts += [f"S.{key}={quote(str(value), safe='')}" for key, value in _click_extras(extras).items()]
parts.append("end")
return ";".join(parts)
def _xiaomi_template_param(title: str, alert: str) -> str:
rendered = (
settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON
.replace("{title}", title)
.replace("{alert}", alert)
)
try:
payload = json.loads(rendered)
except ValueError as e:
raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON invalid json") from e
if not isinstance(payload, dict):
raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON must be a json object")
for key, value in payload.items():
if not isinstance(key, str) or not isinstance(value, str):
raise VendorPushError("xiaomi template params must be string key-value pairs")
if not value.strip() or len(value) > 128:
raise VendorPushError("xiaomi template param value length must be 1-128")
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def _oppo_auth_token() -> str:
cache_key = "oppo"
cached = _cache_get(cache_key)
if cached:
return cached
app_key = _require(settings.OPPO_PUSH_APP_KEY, "OPPO_PUSH_APP_KEY")
master_secret = _require(settings.OPPO_PUSH_MASTER_SECRET, "OPPO_PUSH_MASTER_SECRET")
timestamp = str(int(time.time() * 1000))
sign = hashlib.sha256(f"{app_key}{timestamp}{master_secret}".encode()).hexdigest()
data = _request_form(
"POST",
settings.OPPO_PUSH_AUTH_ENDPOINT,
data={
"app_key": app_key,
"timestamp": timestamp,
"sign": sign,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if int(data.get("code", -1)) != 0:
raise VendorPushError(f"oppo auth failed: {data}")
token = (data.get("data") or {}).get("auth_token") or data.get("auth_token")
if not token:
raise VendorPushError(f"oppo auth missing auth_token: {data}")
return _cache_put(cache_key, str(token), 24 * 3600)
def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
auth_token = _oppo_auth_token()
ttl_hours = max(1, min(72, settings.PUSH_TIME_TO_LIVE_SEC // 3600))
notification: dict[str, Any] = {
"app_message_id": f"{extras.get('type', 'notify')}_{uuid.uuid4().hex}",
"title": title,
"content": body,
"off_line": True,
"off_line_ttl": ttl_hours,
"action_parameters": json.dumps(_click_extras(extras), ensure_ascii=False),
}
# 点击落地:OPPO SDK 没有点击回调,参数只能靠服务端点击动作配置送达——action_parameters 的
# 键值对仅在 click_action_type=1/4 时才会注入目标 Activity 的 intent extras(type=0「启动应用」
# 会忽略它,extras 全丢 → 点了没反应,与小米 notify_effect=1 同款坑)。
# 消息中心推送(带 notificationId)→ type=4(打开应用内页面,Activity 全路径,exported=true);
# 无 notificationId 的召回类保持 type=0 仅打开应用。
if extras.get("notificationId"):
notification["click_action_type"] = 4
notification["click_action_activity"] = f"{settings.ANDROID_PACKAGE_NAME}.MainActivity"
else:
notification["click_action_type"] = 0
# 新消息分类(2024-11-20 后创建的 OPPO 应用必须带 category,否则可能被拒收/降级)
if settings.OPPO_PUSH_CHANNEL_ID.strip():
notification["channel_id"] = settings.OPPO_PUSH_CHANNEL_ID.strip()
if settings.OPPO_PUSH_CATEGORY.strip():
notification["category"] = settings.OPPO_PUSH_CATEGORY.strip()
if settings.OPPO_PUSH_NOTIFY_LEVEL:
notification["notify_level"] = settings.OPPO_PUSH_NOTIFY_LEVEL
message = {
"target_type": 2,
"target_value": token,
"notification": notification,
}
data = _request_form(
"POST",
settings.OPPO_PUSH_SEND_ENDPOINT,
data={
"auth_token": auth_token,
"message": json.dumps(message, ensure_ascii=False),
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if int(data.get("code", -1)) != 0:
raise VendorPushError(f"oppo push failed: {data}")
return data
+22
View File
@@ -31,8 +31,10 @@ from app.api.v1.device import router as device_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.invite import router as invite_router
from app.api.v1.meituan import router as meituan_router
from app.api.v1.notifications import router as notifications_router
from app.api.v1.order import router as order_router
from app.api.v1.platform import router as platform_router
from app.api.v1.push import router as push_router
from app.api.v1.report import router as report_router
from app.api.v1.savings import router as savings_router
from app.api.v1.signin import router as signin_router
@@ -49,7 +51,16 @@ from app.core.heartbeat_monitor_worker import (
start_heartbeat_monitor,
stop_heartbeat_monitor,
)
from app.core.inactivity_reset_worker import (
start_inactivity_reset_worker,
stop_inactivity_reset_worker,
)
from app.core.logging import setup_logging
from app.core.observe import RequestMetricsMiddleware
from app.core.observe_worker import (
start_observe_worker,
stop_observe_worker,
)
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
from app.core.withdraw_reconcile_worker import (
start_withdraw_reconcile_worker,
@@ -80,12 +91,16 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
reconcile_task = start_withdraw_reconcile_worker()
heartbeat_task = start_heartbeat_monitor()
daily_exchange_task = start_daily_exchange_worker()
observe_task = start_observe_worker()
inactivity_task = start_inactivity_reset_worker()
try:
yield
finally:
await stop_heartbeat_monitor(heartbeat_task)
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
await stop_observe_worker(observe_task)
await stop_inactivity_reset_worker(inactivity_task)
await aclose_pricebot_client()
logger.info("shutting down")
@@ -107,6 +122,9 @@ if settings.cors_origins_list:
allow_headers=["*"],
)
# 接口指标埋点(放在 CORS 之后 = 最外层:测到含 CORS 的完整耗时)。未配置观测时中间件自 no-op。
app.add_middleware(RequestMetricsMiddleware)
@app.get("/health", tags=["meta"])
def health() -> dict[str, str]:
@@ -132,6 +150,10 @@ app.include_router(savings_router)
app.include_router(ad_router)
app.include_router(order_router)
app.include_router(report_router)
# 消息通知中心(PRD;数据落库 notification 表,见 repositories/notification.py)
app.include_router(notifications_router)
# 厂商推送测试三件套(配置状态/模板预览/测试发送,支持 mock 与真发)
app.include_router(push_router)
# 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。
app.include_router(internal_price_router)
app.include_router(internal_store_router)
+5
View File
@@ -27,10 +27,15 @@ from app.models.coupon_state import ( # noqa: F401
CouponSession,
)
from app.models.feedback import Feedback # noqa: F401
from app.models.inactivity import ( # noqa: F401
InactivityNotificationLog,
InactivityResetLog,
)
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.notification import Notification # noqa: F401
from app.models.onboarding import OnboardingCompletion # noqa: F401
from app.models.phone_rebind_log import PhoneRebindLog # noqa: F401
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
+7 -1
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, BigInteger, DateTime, Integer, String, func
from sqlalchemy import JSON, BigInteger, DateTime, Index, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -23,6 +23,12 @@ from app.db.base import Base
class AnalyticsEvent(Base):
__tablename__ = "analytics_event"
__table_args__ = (
# 活跃口径聚合热点(activity.active_event_condition + last_active_subqueries):
# 按 (event,page) 过滤 首页可见(show/home)∪比价∪领券,再 group by user_id 取
# max(created_at)。覆盖索引 → 该聚合走 index-only,避免高频 show 事件全表扫。
Index("ix_analytics_event_active", "event", "page", "user_id", "created_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+9 -5
View File
@@ -1,9 +1,9 @@
"""设备表(无障碍保护存活检测 + 极光推送)。
"""设备表(无障碍保护存活检测 + 厂商直推)。
每条 = 一个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)。
客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报
registration_id(极光推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、
现在心跳超时」的设备,通过极光推送提醒用户重开无障碍。
push_vendor + push_token(厂商推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、
现在心跳超时」的设备,通过厂商直推提醒用户重开无障碍。
liveness_state 状态机(防刷屏,一次掉线只推一条):
unknown → alive(收到 service 心跳)→ silent/notified(扫描发现超时并已推送)
@@ -30,7 +30,7 @@ from app.db.base import Base
class DeviceLiveness(Base):
# 表名不叫 device:device 易被当成「设备信息(品牌/型号/系统)」表;本表实为**无障碍存活监控状态**
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 推送目标 registration_id),故名 device_liveness。
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 厂商推送目标),故名 device_liveness。
__tablename__ = "device_liveness"
__table_args__ = (
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
@@ -42,8 +42,12 @@ class DeviceLiveness(Base):
)
# 客户端 DeviceId.get() 生成的 per-install id(如 device_Pixel_ab12cd34)
device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False)
# 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发)
# 极光推送 registration id,仅为兼容历史客户端/数据保留;新链路使用 push_vendor + push_token。
registration_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 厂商推送类型:honor/vivo/xiaomi/oppo 等;客户端按实际 SDK token 来源上报。
push_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 厂商 push token / regId / registration_id;不同厂商命名不同,后端统一存这里。
push_token: Mapped[str | None] = mapped_column(String(256), nullable=True)
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android")
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
+58
View File
@@ -0,0 +1,58 @@
"""15 天不活跃清零相关表。
- inactivity_reset_log:每次清零一行,记清零前三桶余额快照 + 原因 + 判定时活跃时间/不活跃天数,
供纠纷排查(需求①)。清零同时另写 2 条钱包流水(金币 + 折算现金,biz_type=inactivity_reset),
资金流可逐笔回溯。**邀请现金是产品红线、不清零**,invite_cash_balance_cents_before 仅为清零时
仍保留的邀请现金快照(便于排查、非被清金额;见 wallet.CoinAccount 注释)。
- inactivity_notification_log:每次预警一行,记推送时余额快照 + 档位 + 通道 + 状态,
兼作"预警去重"依据(created_at > last_active)与"待推送"占位 outbox(v1 通道=log)。
append-only,不更新。user_id 只索引、不设外键(同 analytics_event,避免删用户级联/历史留痕)。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class InactivityResetLog(Base):
__tablename__ = "inactivity_reset_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
coin_balance_before: Mapped[int] = mapped_column(Integer, nullable=False)
cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False)
invite_cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False)
last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
inactive_days: Mapped[int] = mapped_column(Integer, nullable=False)
reason: Mapped[str] = mapped_column(String(32), nullable=False)
reset_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<InactivityResetLog id={self.id} user_id={self.user_id} coin={self.coin_balance_before}>"
class InactivityNotificationLog(Base):
__tablename__ = "inactivity_notification_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
stage: Mapped[int] = mapped_column(Integer, nullable=False) # 提前天数档(如 7 / 2)
inactive_days: Mapped[int] = mapped_column(Integer, nullable=False)
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False)
cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False)
invite_cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False)
channel: Mapped[str] = mapped_column(String(16), nullable=False) # log / jpush / sms
status: Mapped[str] = mapped_column(String(16), nullable=False) # placeholder / sent / failed
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<InactivityNotificationLog id={self.id} user_id={self.user_id} stage={self.stage}>"
+95
View File
@@ -0,0 +1,95 @@
"""消息通知中心:站内消息表(一行 = 一条下发给某用户的站内消息)。
13 类通知的**静态定义**(分类 / 版式 / 标题 / 操作行 / push 模板)在
`app/core/notification_catalog.py`,是代码常量,**不入库**;本表只存**每条消息的动态部分**
(与接口 NotificationItem 的动态字段一一对应):type + 金额 + 信息行 + extra + 已读态 + 时间。
category / card_style / title / action_text 都由 `type` 经 catalog 派生,不冗余存库。
- 写:`repositories/notification.create_notification`(业务事件下发站内消息的统一入口)。
- 读:`api/v1/notifications.py`(列表 / 未读数 / 标记已读),均按 user 隔离、sent_at 倒序。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
JSON,
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
String,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 comparison_record.raw_payload 等)。
_JSON = JSON().with_variant(JSONB(), "postgresql")
class Notification(Base):
__tablename__ = "notification"
__table_args__ = (
# 列表分页:按用户取 + sent_at 倒序(核心查询,覆盖 user_id 前缀查找,故不再单独索引 user_id)
Index("ix_notification_user_sent", "user_id", "sent_at"),
# 铃铛角标:count where user_id=? and is_read=false —— 部分索引只覆盖未读行
Index(
"ix_notification_user_unread",
"user_id",
sqlite_where=text("is_read = 0"),
postgresql_where=text("is_read = false"),
),
# 去重/合并:同一 (user, type, dedup_key) 未读期间只允许一条(perm_* 权限异常、
# reward_expiring 同批次即用它);消息一旦已读即离开索引,之后可再生成新的未读消息。
Index(
"uq_notification_user_type_dedup",
"user_id",
"type",
"dedup_key",
unique=True,
sqlite_where=text("dedup_key IS NOT NULL AND is_read = 0"),
postgresql_where=text("dedup_key IS NOT NULL AND is_read = false"),
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"), nullable=False)
# 13 类之一(catalog.TYPES 的 key);category/card_style/title/action_text 由它派生,不入库
type: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
# 金币数(dual_amount / coin_reward 卡);其余类型 None
coins: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 现金,单位【分】(dual_amount / withdraw / friend_cash 卡);其余 None
cash_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 信息行 [{label, value}](已渲染好文案,前端逐行展示)
info_rows: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 点击跳转/联动参数(feedbackId / withdrawId / permission / inviteeNickname / batchId …)
extra: Mapped[dict] = mapped_column(_JSON, nullable=False, default=dict)
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# 置读时刻(未读时为 None;埋点/分析用)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# 去重键(可空):perm_*→permission、reward_expiring→batchId 等;配合部分唯一索引防重复未读
dedup_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 下发/业务时间;列表排序与展示都用它(带 +08:00 下发)
sent_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<Notification id={self.id} user_id={self.user_id} "
f"type={self.type} read={self.is_read}>"
)
+101
View File
@@ -0,0 +1,101 @@
"""活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。
口径 = max(User.created_at, AnalyticsEvent[首页可见 show/home + 比价 + 领券], CouponPromptEngagement[claim_started])。
**不含 last_login_at**(登录/re-login 不代表在用 App);created_at 为恒非空基线。
清零/预警按北京自然日 0 点对齐(见 reset_cutoff)。
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ, cn_today
from app.models.analytics_event import AnalyticsEvent
from app.models.coupon_state import CouponPromptEngagement
# —— 活跃口径事件(与"用户管理"口径一致)——
# 首页可见:前端埋点 event=show + page=home(组合判定,单个 event 名不足以区分,见
# active_event_condition);其余为纯 event 名。
HOME_VIEW_EVENT = "show"
HOME_VIEW_PAGE = "home"
COMPARE_START_EVENT = "real_compare_start" # 发起比价(含浮窗触发)
COUPON_START_EVENT = "real_coupon_start" # 发起领券
# 纯 event 名即可判定的活跃事件(首页可见是 event+page 组合、不在此列)
ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT)
ACTIVE_ENGAGE_TYPE = "claim_started" # coupon_prompt_engagement 一键领取
def active_event_condition():
"""analytics_event 中算"活跃"的行为过滤:首页可见(event=show & page=home)
∪ 发起比价 ∪ 发起领券。worker 子查询与 admin 展示共用,单一真源。"""
return or_(
and_(AnalyticsEvent.event == HOME_VIEW_EVENT, AnalyticsEvent.page == HOME_VIEW_PAGE),
AnalyticsEvent.event.in_(ACTIVE_EVENTS),
)
def as_utc(value: datetime) -> datetime:
"""任意 datetime → tz-aware UTC(无时区按 UTC 解释)。用于与 DateTime(timezone=True) 列比较,
比较绝对时刻、与会话时区无关(口径同 admin queries._as_utc)。"""
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def norm_utc(dt: datetime | None) -> datetime | None:
"""naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。"""
if dt is None:
return None
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
def cn_midnight_utc(d: date) -> datetime:
"""北京 d 日 00:00 → tz-aware UTC datetime。"""
return as_utc(datetime(d.year, d.month, d.day, tzinfo=CN_TZ))
def reset_cutoff(reset_days: int, today: date | None = None) -> datetime:
"""应清零边界(tz-aware UTC):last_active < 此值 ⟺ 距末次活跃已满 reset_days 天(北京 0 点对齐)。
= 北京 00:00 of (today (reset_days 1))。例:reset_days=15、today=1/20 → 北京 1/6 00:00。"""
today = today or cn_today()
return cn_midnight_utc(today - timedelta(days=reset_days - 1))
def last_active_subqueries(db: Session):
"""两个按 user_id 预聚合的派生表:最近活跃事件(见 active_event_condition)、
最近领券发起(claim_started)。返回 (ev_sub, eng_sub)。口径同 admin,LEFT JOIN 用。"""
ev_sub = (
select(
AnalyticsEvent.user_id.label("user_id"),
func.max(AnalyticsEvent.created_at).label("last_at"),
)
.where(AnalyticsEvent.user_id.is_not(None), active_event_condition())
.group_by(AnalyticsEvent.user_id)
.subquery()
)
eng_sub = (
select(
CouponPromptEngagement.user_id.label("user_id"),
func.max(CouponPromptEngagement.created_at).label("last_at"),
)
.where(
CouponPromptEngagement.user_id.is_not(None),
CouponPromptEngagement.engage_type == ACTIVE_ENGAGE_TYPE,
)
.group_by(CouponPromptEngagement.user_id)
.subquery()
)
return ev_sub, eng_sub
def last_active_expr(base_col, ev_sub, eng_sub, dialect: str):
"""max(base_col, 最近活跃事件, 最近领券) 的 SQL 表达式。PG 用 greatest、SQLite 用 max。
子聚合缺失(未命中)时 coalesce 到 base_col(= User.created_at,恒非空基线)。"""
greatest = func.greatest if dialect == "postgresql" else func.max
return greatest(
base_col,
func.coalesce(ev_sub.c.last_at, base_col),
func.coalesce(eng_sub.c.last_at, base_col),
)
+74 -4
View File
@@ -21,17 +21,23 @@ def register_or_update(
*,
user_id: int,
device_id: str,
registration_id: str | None,
registration_id: str | None = None,
push_vendor: str | None = None,
push_token: str | None = None,
platform: str = "android",
app_version: str | None = None,
) -> DeviceLiveness:
"""注册设备或更新其 registration_id / 元信息。upsert by (user_id, device_id)。"""
"""注册设备或更新其厂商 push token / 元信息。upsert by (user_id, device_id)。"""
normalized_vendor = _normalize_push_vendor(push_vendor)
normalized_token = push_token.strip() if push_token else None
device = _get(db, user_id=user_id, device_id=device_id)
if device is None:
device = DeviceLiveness(
user_id=user_id,
device_id=device_id,
registration_id=registration_id,
push_vendor=normalized_vendor,
push_token=normalized_token,
platform=platform or "android",
app_version=app_version,
)
@@ -39,6 +45,10 @@ def register_or_update(
else:
if registration_id:
device.registration_id = registration_id
if normalized_vendor:
device.push_vendor = normalized_vendor
if normalized_token:
device.push_token = normalized_token
if platform:
device.platform = platform
if app_version:
@@ -54,7 +64,9 @@ def touch_heartbeat(
user_id: int,
device_id: str,
accessibility_enabled: bool,
registration_id: str | None,
registration_id: str | None = None,
push_vendor: str | None = None,
push_token: str | None = None,
) -> DeviceLiveness:
"""处理一次心跳(心跳也能自注册)。
@@ -69,6 +81,12 @@ def touch_heartbeat(
if registration_id:
device.registration_id = registration_id
normalized_vendor = _normalize_push_vendor(push_vendor)
normalized_token = push_token.strip() if push_token else None
if normalized_vendor:
device.push_vendor = normalized_vendor
if normalized_token:
device.push_token = normalized_token
device.last_report_protection_on = accessibility_enabled
if accessibility_enabled:
@@ -87,7 +105,7 @@ def touch_heartbeat(
def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]:
"""掉线设备:曾经保护过、当前 alive、心跳超时。
本期只做终端打印检测、不推送 → 不再要求有 registration_id(没接极光 token 的设备也要检出)
即使没有厂商 token 也要检出,后续由 kill_alert_pending 走客户端进 App 后兜底提醒
"""
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
stmt = select(DeviceLiveness).where(
@@ -124,3 +142,55 @@ def ack_kill_alert(db: Session, *, user_id: int, device_id: str) -> None:
if device is not None and device.kill_alert_pending:
device.kill_alert_pending = False
db.commit()
def has_push_target(device: DeviceLiveness | None) -> bool:
"""是否已有厂商直推所需的 vendor + token。"""
return bool(device and device.push_vendor and device.push_token)
def list_push_targets(db: Session, *, user_id: int) -> list[DeviceLiveness]:
"""该用户全部可用厂商推送目标(push_vendor + push_token 双非空),最近更新在前。
同 (vendor, token) 只留最新一行:同一台手机重装 App 后 device_id 会变、
留下 token 相同的旧行,去重防一次业务事件对同一台手机重复推送。
"""
stmt = (
select(DeviceLiveness)
.where(
DeviceLiveness.user_id == user_id,
DeviceLiveness.push_vendor.is_not(None),
DeviceLiveness.push_token.is_not(None),
)
.order_by(DeviceLiveness.updated_at.desc(), DeviceLiveness.id.desc())
)
seen: set[tuple[str, str]] = set()
targets: list[DeviceLiveness] = []
for dev in db.execute(stmt).scalars():
if not dev.push_vendor or not dev.push_token: # 空串兜底(旧数据)
continue
key = (dev.push_vendor, dev.push_token)
if key in seen:
continue
seen.add(key)
targets.append(dev)
return targets
def _normalize_push_vendor(push_vendor: str | None) -> str | None:
if not push_vendor:
return None
vendor = push_vendor.strip().lower()
aliases = {
"honor": "honor",
"hihonor": "honor",
"荣耀": "honor",
"vivo": "vivo",
"xiaomi": "xiaomi",
"mi": "xiaomi",
"小米": "xiaomi",
"oppo": "oppo",
"oneplus": "oppo",
"realme": "oppo",
}
return aliases.get(vendor, vendor)
+198
View File
@@ -0,0 +1,198 @@
"""15 天不活跃清零业务逻辑(纯同步,可单测)。worker 只是它的 asyncio 外壳。
活跃口径复用 app.repositories.activity;清零走 wallet.grant_*(负数出账、写流水、不 commit)。
逐用户独立事务,一个失败不影响其余。
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from sqlalchemy import or_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.integrations.notifier import InactivityNotifier
from app.models.inactivity import InactivityNotificationLog, InactivityResetLog
from app.models.user import User
from app.models.wallet import CoinAccount
from app.repositories import activity
from app.repositories import wallet as wallet_repo
logger = logging.getLogger("shagua.inactivity")
RESET_BIZ_TYPE = "inactivity_reset"
RESET_REMARK = "15天不活跃清零"
# 清零候选口径:金币或折算现金有余额即入选。**邀请现金不算**——它是产品红线、不清零
# (见 wallet.CoinAccount 注释),只有邀请现金余额的用户没有可清项,故不入选。
_ANY_BALANCE = or_(
CoinAccount.coin_balance > 0,
CoinAccount.cash_balance_cents > 0,
)
def _base_query(db: Session):
"""select(user_id, last_active, 三桶余额),join CoinAccount + 两活跃子查询。"""
ev_sub, eng_sub = activity.last_active_subqueries(db)
dialect = db.get_bind().dialect.name
last_active = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect)
stmt = (
select(
User.id.label("user_id"),
last_active.label("last_active"),
CoinAccount.coin_balance,
CoinAccount.cash_balance_cents,
CoinAccount.invite_cash_balance_cents,
)
.join(CoinAccount, CoinAccount.user_id == User.id)
.outerjoin(ev_sub, ev_sub.c.user_id == User.id)
.outerjoin(eng_sub, eng_sub.c.user_id == User.id)
)
return stmt, last_active
def _cn_date(dt: datetime) -> date:
"""datetime → 北京自然日(naive 视为 UTC)。"""
return activity.norm_utc(dt).astimezone(CN_TZ).date()
def _inactive_days(last_active: datetime, today: date) -> int:
return (today - _cn_date(last_active)).days
def select_inactive_users(db: Session, *, cutoff: datetime):
"""应清零用户:last_active < cutoff 且金币/折算现金有余额(邀请现金不清、不计)。
返回 Row 列表(值已快照,可跨 commit)。"""
stmt, last_active = _base_query(db)
stmt = stmt.where(_ANY_BALANCE, last_active < activity.as_utc(cutoff))
return db.execute(stmt).all()
def clear_user(db: Session, *, user_id: int, last_active: datetime, inactive_days: int,
reason: str, dry_run: bool = False) -> bool:
"""单用户清零(独立事务、行锁)。金币 + 折算现金归零 + 写审计 + 2 条流水;**邀请现金不清**
(产品红线,见 wallet.CoinAccount 注释),仅作快照记入审计。返回是否真处理了(有可清余额)。
dry_run=True:**只写审计名单、不动钱不写流水**(灰度看名单)。按 streak 去重——本 streak
已记过(reset_at > last_active)就跳,避免 worker 每日重复记。"""
acc = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
coin, cash, invite = acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents
if coin == 0 and cash == 0: # 邀请现金不清,故不算"有可清余额"
return False
if dry_run and db.execute(
select(InactivityResetLog.id).where(
InactivityResetLog.user_id == user_id,
InactivityResetLog.reset_at > activity.as_utc(last_active),
).limit(1)
).first():
return False # dry-run:本 streak 已记过审计,不重复记
log = InactivityResetLog(
user_id=user_id, coin_balance_before=coin, cash_balance_cents_before=cash,
invite_cash_balance_cents_before=invite, last_active_at=activity.norm_utc(last_active),
inactive_days=inactive_days, reason=reason,
)
db.add(log)
db.flush() # 拿 log.id 作 ref_id 交叉链接审计↔流水
if not dry_run: # dry-run 只记审计名单,不真出账
ref = str(log.id)
if coin:
wallet_repo.grant_coins(db, user_id, -coin, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK)
if cash:
wallet_repo.grant_cash(db, user_id, -cash, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK)
# 邀请现金(invite_cash_balance_cents)刻意不动:两本账物理隔离、邀请金是产品红线。
db.commit()
return True
def run_reset_once(db: Session, *, reset_days: int, today: date, dry_run: bool = False) -> dict:
"""扫一轮清零。逐用户独立 commit,失败隔离。dry_run=True 只记审计名单、不动钱(见 clear_user)。"""
stats = {"scanned": 0, "cleared": 0, "failed": 0}
cutoff = activity.reset_cutoff(reset_days, today)
reason = f"inactive_{reset_days}d" + ("_dryrun" if dry_run else "")
rows = select_inactive_users(db, cutoff=cutoff) # 先物化,避免边遍历边 commit
for row in rows:
stats["scanned"] += 1
idays = _inactive_days(row.last_active, today)
try:
if clear_user(db, user_id=row.user_id, last_active=row.last_active,
inactive_days=idays, reason=reason, dry_run=dry_run):
stats["cleared"] += 1
except SQLAlchemyError:
db.rollback()
stats["failed"] += 1
return stats
def select_warn_candidates(db: Session, *, clear_cutoff: datetime, warn_hi: datetime):
"""预警候选:clear_cutoff <= last_active < warn_hi 且有可清余额(即已进预警窗、尚未到清零)。"""
stmt, last_active = _base_query(db)
stmt = stmt.where(
_ANY_BALANCE,
last_active >= activity.as_utc(clear_cutoff),
last_active < activity.as_utc(warn_hi),
)
return db.execute(stmt).all()
def run_warn_once(db: Session, notifier: InactivityNotifier, *,
reset_days: int, warn_stages: list[int], today: date) -> dict:
"""扫一轮预警。每人取"最紧急的已到达档",按 streak 去重(notification_log.created_at > last_active)。
预警只涉及会被清的金币 + 折算现金;邀请现金不清、不预警(仅在 notification_log 记快照)。
逐用户 try/except 隔离:单用户通知器抛错 / DB 错不阻断其余,也绝不能拖累后续清零。"""
stats = {"warned": 0, "warn_skipped": 0, "warn_failed": 0}
if not warn_stages:
return stats
clear_cutoff = activity.reset_cutoff(reset_days, today) # 到此即清零,不再预警
warn_hi = activity.reset_cutoff(reset_days - max(warn_stages), today) # 最早预警档边界
ascending = sorted(warn_stages) # 最紧急(最小 k)在前
for row in select_warn_candidates(db, clear_cutoff=clear_cutoff, warn_hi=warn_hi):
idays = _inactive_days(row.last_active, today)
stage = next((k for k in ascending if idays >= reset_days - k), None)
if stage is None: # 防御:候选已在预警窗内、stage 必命中,此分支实际不可达
continue
try:
already = db.execute(
select(InactivityNotificationLog.id).where(
InactivityNotificationLog.user_id == row.user_id,
InactivityNotificationLog.stage == stage,
InactivityNotificationLog.created_at > activity.as_utc(row.last_active),
).limit(1)
).first()
if already:
stats["warn_skipped"] += 1
continue
status = notifier.warn(
user_id=row.user_id, coin=row.coin_balance, cash_cents=row.cash_balance_cents,
stage=stage, days_until_reset=reset_days - idays,
)
db.add(InactivityNotificationLog(
user_id=row.user_id, stage=stage, inactive_days=idays,
coin_balance=row.coin_balance, cash_balance_cents=row.cash_balance_cents,
invite_cash_balance_cents=row.invite_cash_balance_cents, # 快照,不参与"将清"额度
channel=notifier.channel, status=status,
))
db.commit()
stats["warned"] += 1
except Exception: # noqa: BLE001 - 单用户预警失败(通知器抛错/DB 错)隔离,不阻断其余、不拖累清零
db.rollback()
stats["warn_failed"] += 1
return stats
def run_once(db: Session, *, notifier: InactivityNotifier, reset_days: int,
warn_stages: list[int], today: date, dry_run: bool = False) -> dict:
"""一轮完整任务:先预警(阶段 A)再清零(阶段 B)。返回合并统计。
预警整段异常也**绝不阻塞清零**——清零是核心、不可逆资金操作,不能被通知故障拖住。
dry_run=True(灰度默认):只记审计名单、不清、**也不预警**(不通知一个不会发生的清零)。"""
warn = {"warned": 0, "warn_skipped": 0, "warn_failed": 0}
if not dry_run:
try:
warn = run_warn_once(db, notifier, reset_days=reset_days, warn_stages=warn_stages, today=today)
except Exception: # noqa: BLE001 - 预警阶段整体失败(如候选查询失败)也要继续清零
logger.exception("inactivity warn phase failed; proceeding to reset")
db.rollback()
warn = {"warned": 0, "warn_skipped": 0, "warn_failed": 0, "warn_phase_error": 1}
reset = run_reset_once(db, reset_days=reset_days, today=today, dry_run=dry_run)
return {**warn, **reset}
+8 -2
View File
@@ -25,6 +25,7 @@ from app.models.invite import InviteRelation
from app.models.invite_fingerprint import InviteFingerprint
from app.models.user import User
from app.repositories import wallet as crud_wallet
from app.services import notification_events
# 邀请码字符集:去掉易混字符(0/O/1/I/L/B/8/S/5/Z/2),用户口述/手输不易错
_CODE_ALPHABET = "ACDEFGHJKMNPQRTUVWXY34679"
@@ -197,12 +198,13 @@ def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardRes
return CompareRewardResult("inviter_inactive", rel.inviter_user_id)
reward = rewards.INVITE_COMPARE_REWARD_CENTS
inviter_id = inviter.id
rel.compare_reward_granted = True
rel.compare_reward_cents = reward
rel.compare_rewarded_at = datetime.now(timezone.utc)
# 发邀请奖励金到邀请人的独立账户(与金币隔离),ref_id 指向被邀请人便于对账
crud_wallet.grant_invite_cash(
db, inviter.id, reward,
db, inviter_id, reward,
biz_type="invite_reward", ref_id=str(invitee_user_id), remark="好友比价奖励",
)
try:
@@ -210,7 +212,11 @@ def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardRes
except Exception:
db.rollback()
raise
return CompareRewardResult("granted", inviter.id, reward)
# PRD #12 好友下单到账:发奖已 commit,通知邀请人(站内 + push;失败只 log 不影响发奖)
notification_events.notify_invite_order_reward(
db, inviter_user_id=inviter_id, invitee_user_id=invitee_user_id, cash_cents=reward
)
return CompareRewardResult("granted", inviter_id, reward)
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
+318
View File
@@ -0,0 +1,318 @@
"""消息通知中心 数据仓库(落库版,查/写 `notification` 表)。
沿用原 notification_mock 的同名函数(list_notifications / unread_count / mark_read /
insert_sample),由内存 mock 迁到落库,**API 契约不变**。
- 读:按 user 隔离、sent_at 倒序;未读数 / 标记已读同口径。
- 写:`create_notification` 是落库统一入口。**业务事件请走 services/notification_events**
(站内消息 + 厂商 push 一起下发,已接入提现回执/反馈审核/爆料通过/好友下单);
`build_sample_card` / `insert_sample` 按类型造样例内容,供
`/api/v1/push/test` 的 createNotification 做「push → 站内已读联动」联调。
排序规则:全列表按 sent_at 倒序(最新在前;同秒再按 id 倒序稳定化),不分组。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core import notification_catalog as catalog
from app.models.notification import Notification
# 北京时间:sent_at 统一带 +08:00 下发,前端直接按本地时区渲染「今天/昨天/M月D日」。
_CST = timezone(timedelta(hours=8))
def cash_yuan(cents: int | None) -> str | None:
"""分 → 保留两位小数的元字符串(PRD §3:现金/提现金额保留两位小数)。"""
if cents is None:
return None
return f"{cents // 100}.{cents % 100:02d}"
def as_cst(dt: datetime) -> datetime:
"""把库里取出的时间归一到北京时间(+08:00)再下发,保证接口 sentAt 恒带 +08:00。
SQLite 的 DateTime 不存时区,取出为 naive(存的就是写入时的 CST 墙上时间)→ 直接贴 +08:00;
PostgreSQL 的 timestamptz 取出为 aware(通常 UTC)→ 转到 +08:00。两端下发口径一致。
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=_CST)
return dt.astimezone(_CST)
def _fmt_time(dt: datetime) -> str:
"""信息行里「到账时间」等 value 的展示格式。"""
return dt.strftime("%Y-%m-%d %H:%M")
# ---------------------------------------------------------------------------
# 读:列表 / 未读数 / 标记已读
# ---------------------------------------------------------------------------
def _unread_count(db: Session, user_id: int) -> int:
return int(
db.execute(
select(func.count())
.select_from(Notification)
.where(Notification.user_id == user_id, Notification.is_read.is_(False))
).scalar_one()
)
def list_notifications(
db: Session, user_id: int, *, page: int, page_size: int
) -> tuple[list[Notification], int, int]:
"""分页取通知列表。返回 (当前页条目, 总条数, 未读条数)。"""
total = int(
db.execute(
select(func.count())
.select_from(Notification)
.where(Notification.user_id == user_id)
).scalar_one()
)
unread = _unread_count(db, user_id)
rows = (
db.execute(
select(Notification)
.where(Notification.user_id == user_id)
.order_by(Notification.sent_at.desc(), Notification.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
.scalars()
.all()
)
return list(rows), total, unread
def unread_count(db: Session, user_id: int) -> int:
"""未读总数(首页铃铛角标)。"""
return _unread_count(db, user_id)
def mark_read(
db: Session, user_id: int, *, ids: list[int] | None = None, mark_all: bool = False
) -> tuple[int, int]:
"""标记已读。mark_all=True 全量清零,否则按 ids 逐条置读(不存在的 id 忽略,幂等)。
返回 (本次实际由未读→已读的条数, 剩余未读数)。
"""
if not mark_all:
wanted = set(ids or [])
if not wanted:
return 0, _unread_count(db, user_id)
stmt = select(Notification).where(
Notification.user_id == user_id, Notification.is_read.is_(False)
)
if not mark_all:
stmt = stmt.where(Notification.id.in_(wanted))
now = datetime.now(timezone.utc)
marked = 0
for n in db.execute(stmt).scalars().all():
n.is_read = True
n.read_at = now
marked += 1
db.commit()
return marked, _unread_count(db, user_id)
# ---------------------------------------------------------------------------
# 写:业务下发入口
# ---------------------------------------------------------------------------
def create_notification(
db: Session,
*,
user_id: int,
type_key: str,
coins: int | None = None,
cash_cents: int | None = None,
info_rows: list[dict[str, str]] | None = None,
extra: dict[str, str] | None = None,
sent_at: datetime | None = None,
dedup_key: str | None = None,
) -> Notification:
"""下发一条站内消息(业务事件统一入口)。type_key 必须是 catalog 的 13 类之一。
dedup_key 非空时受部分唯一索引约束(同 user+type+dedup_key 未读期间仅一条);
需要「同批次/同权限只保留一条未读」的调用方,应捕获 IntegrityError 或先查已存在的未读再决定
更新 sent_at,而非重复插入(见 models/notification 的 uq_notification_user_type_dedup)。
"""
catalog.get_type(type_key) # 校验类型合法(未知类型抛 UnknownNotificationType)
row = Notification(
user_id=user_id,
type=type_key,
coins=coins,
cash_cents=cash_cents,
info_rows=info_rows or [],
extra=extra or {},
sent_at=sent_at or datetime.now(_CST),
dedup_key=dedup_key,
)
db.add(row)
db.commit()
db.refresh(row)
return row
# ---------------------------------------------------------------------------
# 样例内容(供 /push/test createNotification 联调;文案对齐 PRD §3)
# ---------------------------------------------------------------------------
def _card_reward_expiring(sent_at: datetime, coins: int = 86, cash: int = 1280, days: int = 3) -> dict:
return {
"coins": coins,
"cash_cents": cash,
"info_rows": [
{
"label": "过期说明",
"value": f"您有{coins}金币和{cash_yuan(cash)}元现金即将失效,"
"完成一次一键领券或一键比价即可激活收益",
},
{"label": "过期时间", "value": f"{days}天后失效"},
],
# batchId:同一批次激活成功后不再重复推送(PRD §2 激活逻辑)
"extra": {"batchId": f"batch_{sent_at:%Y%m%d}"},
}
def _card_reward_expired(sent_at: datetime, coins: int = 35, cash: int = 60) -> dict:
return {
"coins": coins,
"cash_cents": cash,
"info_rows": [
{
"label": "过期说明",
"value": f"您的{coins}金币和{cash_yuan(cash)}元现金已失效,"
"完成一次一键领券或一键比价可赚取新收益",
},
{"label": "过期时间", "value": f"已过期 {sent_at.month}{sent_at.day}日失效"},
],
"extra": {}, # 点击跳赚钱页(tab),无需参数
}
def _card_withdraw_success(sent_at: datetime, cash: int = 50) -> dict:
return {
"cash_cents": cash,
"info_rows": [
{"label": "到账账户", "value": "微信钱包"},
{"label": "到账时间", "value": _fmt_time(sent_at)},
],
"extra": {}, # 无跳转,仅消红点
}
def _card_withdraw_failed(sent_at: datetime, cash: int = 350, reason: str = "微信零钱未实名") -> dict:
return {
"cash_cents": cash,
"info_rows": [
{"label": "失败原因", "value": reason},
{"label": "退回说明", "value": "款项已原路退回现金余额"},
],
"extra": {"withdrawId": "88001"}, # 点击跳提现页
}
def _card_permission(permission: str) -> dict:
# permission ∈ accessibility(无障碍)/ battery(省电策略)/ autostart(自启动)/ overlay(悬浮窗)
# 客户端点击时按此 key 实时检测该权限并弹对应开启弹窗(PRD §2 权限逻辑)。
return {
"info_rows": [
{"label": "说明文案", "value": "未开启将导致核心功能不可用,请尽快开启"},
],
"extra": {"permission": permission},
}
def _card_feedback_reply(feedback_id: str) -> dict:
return {
"info_rows": [
{"label": "说明文案", "value": "快去看看官方给您的回复吧~"},
],
"extra": {"feedbackId": feedback_id}, # 跳反馈历史页并滚动高亮该条(PRD §2)
}
def _card_feedback_reward(sent_at: datetime, coins: int = 300,
reply: str = "感谢反馈,您说的问题已经修复上线,送您的金币请查收~") -> dict:
return {
"coins": coins,
"info_rows": [
{"label": "奖励说明", "value": "感谢您的反馈,您的金币奖励已到账"},
{"label": "官方留言", "value": reply}, # PRD §3:官方留言必填(发奖励必带留言)
{"label": "到账时间", "value": _fmt_time(sent_at)},
],
"extra": {"feedbackId": "3002"},
}
def _card_report_approved(sent_at: datetime, coins: int = 1000, store: str = "蜀大侠火锅") -> dict:
return {
"coins": coins,
"info_rows": [
{"label": "奖励说明", "value": f"您爆料的「{store}」更低价已通过审核,金币奖励已到账"},
{"label": "到账时间", "value": _fmt_time(sent_at)},
],
"extra": {"reportId": "5001"}, # 跳爆料记录页并滚动高亮该条
}
def _card_invite_order_reward(sent_at: datetime, cash: int = 200, nickname: str = "柚子") -> dict:
return {
"cash_cents": cash,
"info_rows": [
{"label": "奖励说明", "value": f"好友「{nickname}」完成首次下单"},
{"label": "到账时间", "value": _fmt_time(sent_at)},
],
"extra": {"inviteeNickname": nickname}, # 跳邀请页(welfare/invite.html?from=notifications)
}
def _card_invite_remind(nickname: str = "阿泽") -> dict:
return {
"info_rows": [
{
"label": "说明文案",
"value": f"好友「{nickname}」已注册,还没完成比价下单,提醒TA完成后你可得2元现金",
},
],
# scrollTo=remind:跳邀请页并自动滚动到底部「提醒好友」模块(PRD §2 #13)
"extra": {"inviteeNickname": nickname, "scrollTo": "remind"},
}
def build_sample_card(type_key: str, sent_at: datetime | None = None) -> dict:
"""按类型生成一份样例卡片内容({coins?, cash_cents?, info_rows, extra}),/push/test 联调用。"""
catalog.get_type(type_key) # 校验 type 合法
now = sent_at or datetime.now(_CST)
builders = {
"reward_expiring": lambda: _card_reward_expiring(now),
"reward_expired": lambda: _card_reward_expired(now),
"withdraw_success": lambda: _card_withdraw_success(now),
"withdraw_failed": lambda: _card_withdraw_failed(now),
"perm_accessibility": lambda: _card_permission("accessibility"),
"perm_battery": lambda: _card_permission("battery"),
"perm_autostart": lambda: _card_permission("autostart"),
"perm_overlay": lambda: _card_permission("overlay"),
"feedback_reply": lambda: _card_feedback_reply("3001"),
"feedback_reward": lambda: _card_feedback_reward(now),
"report_approved": lambda: _card_report_approved(now),
"invite_order_reward": lambda: _card_invite_order_reward(now),
"invite_remind": lambda: _card_invite_remind(),
}
return builders[type_key]()
def insert_sample(db: Session, user_id: int, type_key: str) -> Notification:
"""插入一条该类型的样例未读通知并落库(/push/test createNotification 联调:push extras 带上
它的 id,客户端点击 push 后调 POST /notifications/read {ids:[id]} 即闭环验证已读联动)。"""
return create_notification(db, user_id=user_id, type_key=type_key, **build_sample_card(type_key))
+12
View File
@@ -29,6 +29,7 @@ from app.models.wallet import (
WechatTransferAuthorization,
WithdrawOrder,
)
from app.services import notification_events
# 微信转账终态:成功 / 失败(失败/取消/关闭都退款)
_WX_STATE_SUCCESS = "SUCCESS"
@@ -525,6 +526,8 @@ def _refund_withdraw(
order.status = final_status
order.fail_reason = reason[:256]
db.commit()
# 上次退款后没走完终态(如中途崩溃)的补账路径:这里补发通知(dedup 防重)
notification_events.notify_withdraw_failed(db, order)
return
bal = _add_cash(db, order.user_id, order.amount_cents, order.source)
db.add(
@@ -568,6 +571,11 @@ def _refund_withdraw(
fresh_order.status = final_status
fresh_order.fail_reason = reason[:256]
db.commit()
notification_events.notify_withdraw_failed(db, fresh_order)
return
# PRD #4 提现失败通知:所有退款终态(failed/rejected)在此收口下发;
# dedup=out_bill_no,与上面并发路径重复触发时未读期间只落一条。
notification_events.notify_withdraw_failed(db, order)
def _wx_not_found(result: dict) -> bool:
@@ -608,6 +616,7 @@ def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> N
order.status = "success"
order.transfer_bill_no = q["data"].get("transfer_bill_no")
db.commit()
notification_events.notify_withdraw_success(db, order) # PRD #3 提现到账
elif state in _WX_STATE_FAILED:
_refund_withdraw(db, order, reason=reason)
else:
@@ -981,6 +990,8 @@ def _apply_transfer_result(db: Session, order: WithdrawOrder, data: dict) -> Wit
order.status = "success"
db.commit()
db.refresh(order)
if order.status == "success": # 免确认转账直接到账 → PRD #3 提现到账
notification_events.notify_withdraw_success(db, order)
return order
@@ -1130,6 +1141,7 @@ def refresh_withdraw_status(
if state == _WX_STATE_SUCCESS:
order.status = "success"
db.commit()
notification_events.notify_withdraw_success(db, order) # PRD #3 提现到账
elif state in _WX_STATE_FAILED:
_refund_withdraw(db, order, reason=f"微信转账状态 {state}")
elif state == _WX_STATE_WAIT_CONFIRM and cancel_if_unconfirmed:
+22 -1
View File
@@ -3,12 +3,15 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
class DeviceRegisterRequest(BaseModel):
device_id: str
# registration_id 为旧极光字段,新推送链路统一使用 push_vendor + push_token。
registration_id: str | None = None
push_vendor: str | None = None
push_token: str | None = None
platform: str = "android"
app_version: str | None = None
@@ -18,6 +21,8 @@ class HeartbeatRequest(BaseModel):
source: str = "service" # service | app
accessibility_enabled: bool = True
registration_id: str | None = None
push_vendor: str | None = None
push_token: str | None = None
class DeviceOut(BaseModel):
@@ -26,6 +31,8 @@ class DeviceOut(BaseModel):
id: int
device_id: str
registration_id: str | None
push_vendor: str | None
push_token: str | None
ever_protected: bool
liveness_state: str
last_heartbeat_at: datetime | None
@@ -46,3 +53,17 @@ class LivenessOut(BaseModel):
class LivenessAckRequest(BaseModel):
device_id: str
class PushTestRequest(BaseModel):
device_id: str
delay_seconds: int = Field(default=10, ge=0, le=60)
push_vendor: str | None = None
push_token: str | None = None
registration_id: str | None = None
class PushTestOut(BaseModel):
ok: bool = True
delay_seconds: int
has_push_token: bool
+128
View File
@@ -0,0 +1,128 @@
"""消息通知中心 请求/响应契约。
⚠️ 命名约定:本组接口按 PRD 前端契约使用 **camelCase**(sentAt / isRead / pageSize …),
与库内其他 snake_case 接口不同——PRD 与前端原型(notifications.html)按 camelCase 对接,
需求方接口清单亦明确写作 sentAt / isRead,故整组遵循之。响应序列化走 pydantic alias。
字段说明都写在 Field(description=...) 里,起服务后打开 /docs 即是给前端的在线文档。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
class _CamelModel(BaseModel):
"""出参统一 camelCase(alias);populate_by_name 允许服务端代码仍用 snake_case 构造。"""
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
class InfoRow(_CamelModel):
"""卡片信息行(PRD §3「信息行」列),前端按 label: value 逐行渲染。"""
label: str = Field(description="行标签,如「过期说明」「到账账户」「失败原因」")
value: str = Field(description="行内容(已按 PRD 文案拼好变量,前端直接展示)")
class NotificationItem(_CamelModel):
"""一条通知卡片。
卡片头部三要素:categoryLabel(分类标签)+ 未读红点(isRead=false 时展示)+ 时间(sentAt)。
时间显示规则(前端处理):今天→「今天」;昨天→「昨天」;当年→「M月D日」;跨年→「YYYY年M月D日」。
"""
id: int = Field(description="通知 id(未读消除、push 联动都用它)")
category: str = Field(
description="分类 key:withdraw_assistant=提现助手 / system=系统通知 / "
"feedback=我的反馈 / report=我的爆料 / invite=好友邀请"
)
category_label: str = Field(description="分类中文标签(卡片头部直接展示)")
type: str = Field(
description="类型 key(13 种,决定点击行为,见 PRD §2):reward_expiring 即将失效 / "
"reward_expired 已失效 / withdraw_success 提现成功 / withdraw_failed 提现失败 / "
"perm_accessibility 无障碍异常 / perm_battery 省电策略异常 / "
"perm_autostart 自启动异常 / perm_overlay 悬浮窗异常 / "
"feedback_reply 官方回复 / feedback_reward 反馈奖励 / "
"report_approved 爆料审核通过 / invite_order_reward 好友下单奖励 / "
"invite_remind 好友催单提醒"
)
card_style: str = Field(
description="卡片版式:dual_amount 双金额卡 / withdraw 提现卡 / plain_text 纯文本卡 / "
"coin_reward 金币奖励卡 / friend_cash 好友现金卡"
)
title: str = Field(description="卡片标题(双金额/提现/金币奖励/好友现金卡标题居中)")
coins: int | None = Field(
default=None,
description="金币数(整数,不带小数)。dual_amount / coin_reward 卡有值,其余 null",
)
cash_cents: int | None = Field(
default=None,
description="现金金额,单位【分】。dual_amount / withdraw / friend_cash 卡有值,其余 null",
)
cash_yuan: str | None = Field(
default=None,
description="现金金额展示串(元,保留两位小数,如 \"12.80\"),与 cashCents 同源,可直接展示",
)
info_rows: list[InfoRow] = Field(
description="信息行列表(label: value),内容已按 PRD §3 拼好,前端逐行渲染即可"
)
action_text: str | None = Field(
default=None,
description="操作行文案(如「立即激活您的收益」「去开启」);null=无操作行(提现成功卡)。"
"注意:点击目标是整张卡片,不区分卡片主体和操作行",
)
extra: dict[str, Any] = Field(
description="点击跳转所需业务参数,按 type 取用:perm_* → {permission: accessibility|battery|"
"autostart|overlay}(点击时实时检测该权限);feedback_* → {feedbackId};"
"report_approved → {reportId};withdraw_failed → {withdrawId};"
"invite_order_reward → {inviteeNickname};invite_remind → "
"{inviteeNickname, scrollTo:\"remind\"};reward_expiring → {batchId}"
)
sent_at: datetime = Field(description="下发时间(ISO8601 带 +08:00 时区),前端按显示规则格式化")
is_read: bool = Field(description="是否已读;false 时分类标签右侧展示 6px 红点(#E53935)")
class NotificationListOut(_CamelModel):
"""GET /api/v1/notifications 出参。列表已按时间倒序排好(最新在前,**不分组**;
PRD §1 的"按分类分组"为笔误,已确认取消),前端无需再排。"""
items: list[NotificationItem] = Field(description="当前页通知卡片")
page: int = Field(description="当前页码(1 起)")
page_size: int = Field(description="每页条数")
total: int = Field(description="全部通知总条数(含已读)")
has_more: bool = Field(description="是否还有下一页")
unread_count: int = Field(description="当前未读总数(与 /notifications/unread-count 同口径,省一次请求)")
class UnreadCountOut(_CamelModel):
"""GET /api/v1/notifications/unread-count 出参(首页铃铛角标)。"""
count: int = Field(description="未读总条数(精确值)")
badge_text: str | None = Field(
description="角标展示文案:超过 99 返回 \"99+\";等于 0 返回 null(整个角标隐藏,不展示空红点)"
)
class MarkReadRequest(_CamelModel):
"""POST /api/v1/notifications/read 入参,两种模式二选一:
- `{"ids": [90001, 90002]}` 单条/多条置读——点击某张卡片、点击 push 落地后同步置读;
- `{"all": true}` 全量清零——进入通知中心(或退出时)自动清零(PRD §4)。
同时传时 all=true 优先;不存在/已读的 id 自动忽略(幂等,可放心重试)。
"""
ids: list[int] | None = Field(default=None, description="要置为已读的通知 id 列表")
all: bool = Field(default=False, description="true=清空该用户全部未读")
class MarkReadOut(_CamelModel):
"""POST /api/v1/notifications/read 出参。"""
ok: bool = Field(description="固定 true(参数非法时走 400,不会到这里)")
marked_count: int = Field(description="本次实际由未读变为已读的条数(重复请求会是 0)")
unread_count: int = Field(description="处理后的剩余未读总数,可直接刷新铃铛角标")
+99
View File
@@ -0,0 +1,99 @@
"""厂商推送(测试/联调)接口契约。与消息中心同族,出参统一 camelCase。"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
class _CamelModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
class PushVendorStatus(_CamelModel):
vendor: str = Field(description="厂商 key:honor / huawei / xiaomi / oppo / vivo")
label: str = Field(description="厂商中文名")
configured: bool = Field(description="服务端凭据是否齐全(齐全才能真发,mock 不受影响)")
missing_keys: list[str] = Field(description="缺失的 .env 配置键;configured=true 时为空")
class PushVendorsOut(_CamelModel):
vendors: list[PushVendorStatus] = Field(description="5 个厂商的配置状态")
class PushTemplateOut(_CamelModel):
type: str = Field(description="通知类型 key(13 种,与消息中心 type 一致)")
category: str = Field(description="分类 key")
category_label: str = Field(description="分类中文标签")
card_style: str = Field(description="站内卡片版式")
push_title: str = Field(description="push 标题(≤11 字固定文案,PRD §5)")
push_body_sample: str = Field(description="push 正文示例(模板用 PRD 示例值渲染后的效果)")
push_body_template: str = Field(description="push 正文模板原文,{var} 为变量占位")
variables: list[str] = Field(description="模板变量名列表(调 /push/test 时可在 vars 里覆盖)")
sample_vars: dict[str, str] = Field(description="各变量的 PRD 示例值(vars 未覆盖时的缺省)")
class PushTemplatesOut(_CamelModel):
templates: list[PushTemplateOut] = Field(description="13 种通知类型的 push 模板(PRD 编号顺序)")
class PushTestRequest(_CamelModel):
"""POST /api/v1/push/test 入参。三种发送内容来源(优先级从高到低):
1. 直接指定 title + content;
2. 指定 type(13 种之一)→ 按 PRD §5 模板渲染,vars 可覆盖模板变量;
3. 都不传 → 发一条通用测试文案。
推送目标:pushToken 直填,或 deviceId 反查该用户已注册设备(/api/v1/device/register 上报过的)。
"""
vendor: str = Field(
default="",
description="厂商:honor/huawei/xiaomi/oppo/vivo(中文「华为」「小米」等别名也识别)。"
"留空时用 deviceId 对应设备上报的 push_vendor",
)
push_token: str = Field(default="", description="厂商 push token / regId;留空则走 deviceId 反查")
device_id: str = Field(default="", description="设备 id(客户端 DeviceId.get());用于反查 token")
type: str = Field(
default="",
description="通知类型 key(13 种,见 GET /push/templates);留空且未直接给 title/content 时发通用测试文案",
)
vars: dict[str, str] = Field(
default_factory=dict,
description="覆盖 push 模板变量,如 {\"coins\":\"520\",\"cash\":\"6.66\"};缺省用 PRD 示例值",
)
title: str = Field(default="", description="直接指定标题(优先于 type 模板)")
content: str = Field(default="", description="直接指定正文(优先于 type 模板)")
create_notification: bool = Field(
default=False,
description="true=同时往该用户的消息中心 mock 列表插入一条同类型未读通知,push extras 带上它的"
" notificationId → 可闭环验证「点 push → 落地 → 调 /notifications/read 消红点」联动"
"(仅 type 为 13 种类型之一时生效)",
)
mock: bool = Field(
default=True,
description="true(默认)=不真调厂商 API,返回渲染结果(联调安全);false=真发,要求该厂商凭据已配置",
)
class PushTestOut(_CamelModel):
ok: bool = Field(description="发送(或 mock 渲染)成功")
mock: bool = Field(description="本次是否 mock(未真调厂商 API)")
vendor: str = Field(description="实际使用的厂商 key(已归一化)")
title: str = Field(description="实际下发的 push 标题")
body: str = Field(description="实际下发的 push 正文")
extras: dict[str, str] = Field(
description="随 push 下发的自定义键值(客户端深链用):type 必有;createNotification=true 时带"
" notificationId 及该通知的业务参数(feedbackId / permission / …)"
)
notification_id: int | None = Field(
default=None, description="createNotification=true 时新插入的站内 mock 通知 id"
)
missing_keys: list[str] = Field(
default_factory=list,
description="该厂商仍缺失的配置键(mock 发送时提示「真发前还需配什么」;真发时必为空)",
)
vendor_response: dict[str, Any] | None = Field(
default=None, description="真发时厂商 API 的原始响应(mock 时为 null)"
)
+269
View File
@@ -0,0 +1,269 @@
"""消息通知中心:业务事件 → 站内消息 + 厂商 push 的统一下发口。
PRD《消息通知中心》真实业务触发在此收口(替代 /push/test 的样例数据),已接入:
#3 withdraw_success 提现到账(repositories/wallet 各「pending→success」转换点)
#4 withdraw_failed 提现失败/退回(repositories/wallet._refund_withdraw,含审核拒绝)
#9 feedback_reply 官方回复(admin 反馈审核「拒绝」,带用户可见原因/留言)
#10 feedback_reward 反馈奖励(admin 反馈审核「采纳」发金币,必带官方留言)
#11 report_approved 爆料审核通过(admin 上报更低价「通过」发金币)
#12 invite_order_reward 好友下单到账(repositories/invite.try_reward_on_compare 发奖后)
行为约定(调用方唯一需要知道的两条):
1. **绝不抛异常**——通知只是业务的副产物,站内消息落库失败/推送失败只 log,
绝不让提现退款、审核发奖等主流程回滚或报错。
2. **必须在业务事务 commit 之后调用**——内部会再 commit(写 notification 表);
若在业务半途调用,会把调用方未提交的脏状态一并提交。
去重:各事件用业务主键做 dedup_key(提现单号/反馈 id/爆料 id/被邀请人 id),配合
notification 表的部分唯一索引,同一事件并发重复触发时未读期间只落一条、只推一次。
推送:向该用户所有已上报厂商 token 的设备直推(integrations/vendor_push);
厂商凭据未配置(本地/测试环境)时自动跳过推送、只落站内消息。extras 按
PRD §4 约定带 {type, notificationId, ...跳转参数},客户端点击 push 深链落地
并调 POST /notifications/read 同步置读。
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import notification_catalog as catalog
from app.core.rewards import CN_TZ
from app.integrations import vendor_push
from app.models.user import User
from app.repositories import device as device_repo
from app.repositories import notification as notif_repo
if TYPE_CHECKING:
from app.models.feedback import Feedback
from app.models.notification import Notification
from app.models.price_report import PriceReport
from app.models.wallet import WithdrawOrder
logger = logging.getLogger("shagua.notification_events")
def _fmt_time(dt: datetime) -> str:
"""信息行「到账时间」的展示格式(与 repositories/notification 样例卡一致)。"""
return dt.strftime("%Y-%m-%d %H:%M")
def _yuan_trim(cents: int) -> str:
"""分 → 元,去掉多余的 0(200→"2"、1280→"12.80")。push 正文用(PRD §5 示例口径:
{2}元现金已到账」);卡片数值仍走 cash_cents 由前端按两位小数渲染。"""
yuan = cents / 100
return f"{yuan:.2f}".rstrip("0").rstrip(".")
def _display_name(user: User | None) -> str:
"""好友昵称展示:昵称 → 微信昵称 → 手机尾号,全无则「好友」。"""
name = ((user.nickname if user else None) or (user.wechat_nickname if user else None) or "").strip()
if not name and user and user.phone:
name = f"用户{user.phone[-4:]}"
return name or "好友"
# ---------------------------------------------------------------------------
# 内核:落站内消息 + 厂商推送(全程吞异常)
# ---------------------------------------------------------------------------
def _dispatch(
db: Session,
*,
user_id: int,
type_key: str,
coins: int | None = None,
cash_cents: int | None = None,
info_rows: list[dict[str, str]] | None = None,
extra: dict[str, str] | None = None,
dedup_key: str | None = None,
push_vars: dict[str, str] | None = None,
) -> Notification | None:
"""落一条站内消息并向该用户设备直推。返回落库行;去重命中/失败返回 None。"""
try:
row = notif_repo.create_notification(
db,
user_id=user_id,
type_key=type_key,
coins=coins,
cash_cents=cash_cents,
info_rows=info_rows,
extra=extra,
dedup_key=dedup_key,
)
except IntegrityError:
# 同 (user, type, dedup_key) 已有未读消息 = 同一事件并发/重复触发 → 不重复落、不重复推
db.rollback()
logger.info(
"notification dedup hit user_id=%s type=%s dedup_key=%s", user_id, type_key, dedup_key
)
return None
except Exception: # noqa: BLE001 — 通知失败绝不影响业务主流程
logger.exception("create notification failed user_id=%s type=%s", user_id, type_key)
try:
db.rollback()
except Exception: # noqa: BLE001 — 回滚失败也不外抛,session 由请求生命周期兜底
logger.exception("rollback after notification failure also failed")
return None
_push_to_user_devices(db, row, push_vars)
return row
def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, str] | None) -> None:
"""向消息归属用户的全部厂商推送目标直推(best-effort,单设备失败不影响其余)。"""
try:
title, body = catalog.render_push(row.type, push_vars)
# PRD §4 push 联动:extras 至少带 type + notificationId,外加该类型的跳转参数(extra 列)
extras: dict[str, str] = {"type": row.type}
extras.update({str(k): str(v) for k, v in (row.extra or {}).items()})
extras["notificationId"] = str(row.id)
for dev in device_repo.list_push_targets(db, user_id=row.user_id):
vendor = vendor_push.normalize_vendor(dev.push_vendor)
if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS:
continue
if vendor_push.missing_settings(vendor):
# 本地/测试环境凭据不齐 → 只落站内消息,不发真推送(与 push/vendors 的报缺口径一致)
logger.info(
"skip push (vendor %s not configured) user_id=%s type=%s",
vendor, row.user_id, row.type,
)
continue
try:
vendor_push.send_notification(
vendor, dev.push_token, title=title, body=body, extras=extras
)
logger.info(
"push sent user_id=%s type=%s vendor=%s notification_id=%s",
row.user_id, row.type, vendor, row.id,
)
except vendor_push.VendorPushError as e:
logger.warning(
"push failed user_id=%s type=%s vendor=%s: %s", row.user_id, row.type, vendor, e
)
except Exception: # noqa: BLE001 — 渲染/查设备等意外失败同样不外抛
logger.exception("push notification failed user_id=%s type=%s", row.user_id, row.type)
# ---------------------------------------------------------------------------
# 六个业务事件(PRD §1/§3/§5 编号见文件头)
# ---------------------------------------------------------------------------
def notify_withdraw_success(db: Session, order: WithdrawOrder) -> None:
"""#3 提现成功:款项已存入微信零钱。点击无跳转仅消红点(extra 空)。"""
_dispatch(
db,
user_id=order.user_id,
type_key="withdraw_success",
cash_cents=order.amount_cents,
info_rows=[
{"label": "到账账户", "value": "微信钱包"},
{"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))},
],
extra={},
dedup_key=order.out_bill_no,
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents)},
)
def notify_withdraw_failed(db: Session, order: WithdrawOrder) -> None:
"""#4 提现失败/退回:含微信侧失败、审核拒绝、解绑退回。点击跳提现页重新提现。
失败原因用 order.fail_reason(与 /withdraw/status 下发的用户可读原因同源)。
"""
reason = (order.fail_reason or "").strip() or "提现未成功"
_dispatch(
db,
user_id=order.user_id,
type_key="withdraw_failed",
cash_cents=order.amount_cents,
info_rows=[
{"label": "失败原因", "value": reason},
{"label": "退回说明", "value": "款项已原路退回现金余额"},
],
extra={"withdrawId": order.out_bill_no},
dedup_key=order.out_bill_no,
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents), "reason": reason},
)
def notify_feedback_reply(db: Session, feedback: Feedback) -> None:
"""#9 官方回复:运营审核了反馈且未采纳(用户可见原因/留言落在反馈记录上)。
点击跳反馈历史页滚动高亮该条(extra.feedbackId)。"""
_dispatch(
db,
user_id=feedback.user_id,
type_key="feedback_reply",
info_rows=[{"label": "说明文案", "value": "快去看看官方给您的回复吧~"}],
extra={"feedbackId": str(feedback.id)},
dedup_key=str(feedback.id),
)
def notify_feedback_reward(db: Session, feedback: Feedback) -> None:
"""#10 反馈奖励:反馈被采纳,金币已到账。PRD 约定发奖必带官方留言(admin_reply);
运营漏填时省略该信息行,不硬造文案。"""
coins = int(feedback.reward_coins or 0)
info_rows = [{"label": "奖励说明", "value": "感谢您的反馈,您的金币奖励已到账"}]
reply = (feedback.admin_reply or "").strip()
if reply:
info_rows.append({"label": "官方留言", "value": reply})
info_rows.append({"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))})
_dispatch(
db,
user_id=feedback.user_id,
type_key="feedback_reward",
coins=coins,
info_rows=info_rows,
extra={"feedbackId": str(feedback.id)},
dedup_key=str(feedback.id),
push_vars={"coins": str(coins)},
)
def notify_report_approved(db: Session, report: PriceReport) -> None:
"""#11 爆料审核通过:上报的更低价过审,金币已到账。点击跳爆料记录页高亮该条。"""
coins = int(report.reward_coins or 0)
store = (report.store_name or "").strip() or "该店铺"
_dispatch(
db,
user_id=report.user_id,
type_key="report_approved",
coins=coins,
info_rows=[
{"label": "奖励说明", "value": f"您爆料的「{store}」更低价已通过审核,金币奖励已到账"},
{"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))},
],
extra={"reportId": str(report.id)},
dedup_key=str(report.id),
push_vars={"store": store, "coins": str(coins)},
)
def notify_invite_order_reward(
db: Session, *, inviter_user_id: int, invitee_user_id: int, cash_cents: int
) -> None:
"""#12 好友下单到账:被邀请好友完成首次下单(比价),现金奖励已入邀请人账户。
通知发给【邀请人】;每个好友只发一次奖 → dedup 按被邀请人。"""
invitee = db.get(User, invitee_user_id)
nickname = _display_name(invitee)
_dispatch(
db,
user_id=inviter_user_id,
type_key="invite_order_reward",
cash_cents=cash_cents,
info_rows=[
{"label": "奖励说明", "value": f"好友「{nickname}」完成首次下单"},
{"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))},
],
extra={"inviteeNickname": nickname},
dedup_key=str(invitee_user_id),
push_vars={"nickname": nickname, "amount": _yuan_trim(cash_cents)},
)
+5 -1
View File
@@ -19,7 +19,11 @@ server {
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
client_max_body_size 4m;
# 上传接口(反馈/上报截图、头像)业务上限 = 最多 6 张 × 每张 5MB
# (见 app _MAX_IMAGES / AVATAR_MAX_BYTES)≈ 30MB,留余量设 32m。
# 低于此值时带截图的反馈会在到达 uvicorn 前就被 nginx 413,表现为「提交经常失败」
# (纯文字反馈体积小、不受影响 → 呈现为「时好时坏」)。根治仍需客户端上传前压缩。
client_max_body_size 32m;
location / {
proxy_pass http://127.0.0.1:8770;
+57
View File
@@ -0,0 +1,57 @@
# OpenObserve 监控台反代(observe.shaguabijia.com)。证书走 Certbot/Let's Encrypt,与 admin-web 一致。
#
# 前置(一次性):
# 1) DNS: observe.shaguabijia.com A 记录 → 本服务器公网 IP
# 2) 证书: sudo certbot certonly --nginx -d observe.shaguabijia.com
# (options-ssl-nginx.conf / ssl-dhparams.pem 首次跑 certbot 时已生成,admin-web 在用即已存在)
# 3) OpenObserve 只绑 127.0.0.1:5080(见 docker-compose.prod.yml),本文件把它反代出公网
# 4) nginx -t 通过后 systemctl reload nginx
#
# 安全:OO 有自身登录。监控台不必对全网裸开——本机办公网无固定出口 IP,故在 nginx 层加 Basic Auth 兜底;
# 将来有固定 IP 可改用【IP 白名单】块(更省事,可去掉 Basic Auth)。
server {
server_name observe.shaguabijia.com;
client_max_body_size 10m;
# —— IP 白名单:办公网无固定出口 IP,暂不用;将来有固定 IP 可改用这块(比 Basic Auth 省事)——
# allow 1.2.3.4; # ← 换成你的真实出口 IP,可多行
# deny all;
# —— Basic Auth:无固定 IP 的兜底密码(生成 .htpasswd_observe 的命令见 README/下方)——
auth_basic "OpenObserve";
auth_basic_user_file /etc/nginx/conf.d/.htpasswd_observe;
location / {
proxy_pass http://127.0.0.1:5080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# OpenObserve 有实时/流式面板,需透传 WebSocket
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}
# IPv6 这行不带 ipv6only=on:该选项对 [::]:443 全局只能设一次,admin-web 那个 server 块已设(否则 nginx 报 duplicate listen options)
listen [::]:443 ssl; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/observe.shaguabijia.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/observe.shaguabijia.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = observe.shaguabijia.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
listen [::]:80;
server_name observe.shaguabijia.com;
return 404; # managed by Certbot
}
+4
View File
@@ -0,0 +1,4 @@
# OpenObserve 落盘数据(parquet/索引/元数据),运行时产生,不入库。
data/
# 生产 compose 的密码文件(OO_ROOT_PASSWORD),含机密,不入库。
.env
+126
View File
@@ -0,0 +1,126 @@
# OpenObserve 本地部署(接口 QPS / 耗时可观测)
app-server 通过中间件采集每个接口的 QPS + 耗时 + 错误率,批量上报到这里。
设计见 [../../docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md](../../docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md)。
## 启动
```bash
cd deploy/openobserve
docker compose up -d
```
- Web UIhttp://localhost:5080
- 登录:`admin@shaguabijia.local` / `Complexpass#123`(见 `docker-compose.yml`
- 数据落 `deploy/openobserve/data/`(已挂卷持久化;该目录已 gitignore)
## 让 app-server 上报
在项目根的 `.env` 打开观测(`OBSERVE_*`,账号密码与 compose 里 root 一致):
```dotenv
OBSERVE_ENABLED=true
OBSERVE_ENDPOINT=http://localhost:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=admin@shaguabijia.local
OBSERVE_PASSWORD=Complexpass#123
```
重启 app-server,随便打几个接口。stream `app_requests` **首次上报自动创建**
在 UI 的 Logs → 选 `app_requests` 就能看到逐条请求事件(字段:`method` / `route` /
`status` / `duration_ms` / `service` / `env`)。
> 未开 `OBSERVE_ENABLED` 或缺账号密码时,中间件透传、worker 不启动,整套 no-op,不影响业务。
## 查询(Logs 页 SQL,或建 Dashboard 面板)
各接口 QPS(1 分钟分桶,面板里再除 60 得每秒):
```sql
SELECT route, histogram(_timestamp, '1 minute') AS ts, count(*) AS cnt
FROM app_requests GROUP BY route, ts ORDER BY ts
```
各接口 P95 耗时(毫秒):
```sql
SELECT route, approx_percentile_cont(duration_ms, 0.95) AS p95_ms
FROM app_requests GROUP BY route ORDER BY p95_ms DESC
```
各接口错误率(5xx 占比):
```sql
SELECT route,
count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) AS err_pct
FROM app_requests GROUP BY route ORDER BY err_pct DESC
```
## 一键导入现成仪表盘(QPS / P95 / 分位 / 错误率)
备好了 [dashboard-api-metrics.json](dashboard-api-metrics.json),4 个面板:各接口每分钟请求数(QPS 源)、
P95 耗时折线、P50/P95/P99 分位表、5xx 错误率表。
- **UI 导入**Dashboards → 右上 **Import** → 选该 JSON 文件 → Import(每次导入新建,不覆盖)。
- **或 API 导入**
```bash
curl -u admin@shaguabijia.local:Complexpass#123 -H 'Content-Type: application/json' \
-X POST 'http://localhost:5080/api/default/dashboards?folder=default' \
--data-binary @deploy/openobserve/dashboard-api-metrics.json
```
导入后进仪表盘,右上角时间调到「最近 15 分钟 / 1 小时」、开自动刷新即可。低流量下 QPS 面板看「每分钟请求数」比「每秒」直观。
## 停止 / 清数据
```bash
docker compose down # 停止(保留数据)
docker compose down -v && rm -rf data # 停止并清空数据
```
## 生产部署(单机)+ UI 访问
前提:app-server 与 OpenObserve **同机**,app→OO 走 localhost(`127.0.0.1:5080`)、不出网、无需 TLS。
唯一要防的是**别把 :5080 裸暴露公网**。硬化版编排见 [docker-compose.prod.yml](docker-compose.prod.yml)。
### 部署步骤
```bash
# 1) 密码文件(本目录,已 gitignore)
echo "OO_ROOT_PASSWORD=$(python -c 'import secrets;print(secrets.token_urlsafe(24))')" > deploy/openobserve/.env
# 2) 起 OpenObserve(只绑 127.0.0.1、命名卷持久化、mem 1g)
cd deploy/openobserve && docker compose -f docker-compose.prod.yml up -d
sudo systemctl enable docker # 开机自起
```
3) app-server 的 `.env` 打开观测并**重启**(用非 root 的专用 ingest 账号):
```dotenv
OBSERVE_ENABLED=true
OBSERVE_ENDPOINT=http://127.0.0.1:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=ingest@shaguabijia.com # UI → Users 建的非 root 账号
OBSERVE_PASSWORD=<该账号密码>
```
```bash
sudo systemctl restart shaguabijia-app-server # 日志出现 "observe worker started" 即生效
```
4) 两个必做收口(磁盘/安全):
- **保留期**:UI → Streams → `app_requests` → Data Retention 设 14/30 天(一请求一行,不封顶迟早撑爆盘)。
- **专用账号**:UI → Users 建非 root 账号给 app 上报,root 只留人工登 UI。
### UI 访问(二选一)
**A. SSH 隧道(推荐,零暴露、不用域名/证书):**
```bash
ssh -L 5080:127.0.0.1:5080 用户@服务器IP
# 然后本机浏览器开 http://localhost:5080
```
**B. nginx 子域名反代(要固定 URL / 团队常看):** 见 [../nginx/observe.shaguabijia.com.conf](../nginx/observe.shaguabijia.com.conf)。
需 DNS `observe.shaguabijia.com` → 本机 + 证书放 `/etc/nginx/ssl/`;含 IP 白名单 + TLS + WebSocket 透传。
> ⚠️ prod compose 必须保持 `127.0.0.1:5080:5080`;写成 `5080:5080`(绑 0.0.0.0)= 裸暴露公网,这是唯一真正的坑。
@@ -0,0 +1,302 @@
{
"version": 8,
"dashboardId": "api-metrics",
"title": "接口监控 (QPS / 耗时 / 错误率)",
"description": "app-server 接口 QPS、P50/P95/P99 耗时、5xx 错误率。数据流 app_requests。",
"role": "",
"tabs": [
{
"tabId": "default",
"name": "Default",
"panels": [
{
"id": "panel_qps",
"type": "line",
"title": "各接口 每分钟请求数 (QPS 源)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT histogram(_timestamp, '1 minute') as ts, route, count(*) as reqs FROM app_requests GROUP BY ts, route ORDER BY ts",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "ts",
"alias": "ts",
"column": "ts",
"color": null,
"sortBy": "ASC"
}
],
"y": [
{
"label": "reqs",
"alias": "reqs",
"column": "reqs",
"color": null
}
],
"z": [],
"breakdown": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 0,
"y": 0,
"w": 24,
"h": 9,
"i": 1
}
},
{
"id": "panel_p95",
"type": "line",
"title": "各接口 P95 耗时 (ms)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT histogram(_timestamp, '1 minute') as ts, route, approx_percentile_cont(duration_ms, 0.95) as p95_ms FROM app_requests GROUP BY ts, route ORDER BY ts",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "ts",
"alias": "ts",
"column": "ts",
"color": null,
"sortBy": "ASC"
}
],
"y": [
{
"label": "p95_ms",
"alias": "p95_ms",
"column": "p95_ms",
"color": null
}
],
"z": [],
"breakdown": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 24,
"y": 0,
"w": 24,
"h": 9,
"i": 2
}
},
{
"id": "panel_pctl",
"type": "table",
"title": "各接口 耗时分位 P50/P95/P99 (ms)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT route, approx_percentile_cont(duration_ms,0.5) as p50, approx_percentile_cont(duration_ms,0.95) as p95, approx_percentile_cont(duration_ms,0.99) as p99, count(*) as cnt FROM app_requests GROUP BY route ORDER BY p95 DESC",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"y": [
{
"label": "p50",
"alias": "p50",
"column": "p50",
"color": null
},
{
"label": "p95",
"alias": "p95",
"column": "p95",
"color": null
},
{
"label": "p99",
"alias": "p99",
"column": "p99",
"color": null
},
{
"label": "cnt",
"alias": "cnt",
"column": "cnt",
"color": null
}
],
"z": [],
"breakdown": [],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 0,
"y": 9,
"w": 24,
"h": 9,
"i": 3
}
},
{
"id": "panel_err",
"type": "table",
"title": "各接口 错误率 (5xx %)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT route, count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) as err_pct, count(*) as cnt FROM app_requests GROUP BY route ORDER BY err_pct DESC",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"y": [
{
"label": "err_pct",
"alias": "err_pct",
"column": "err_pct",
"color": null
},
{
"label": "cnt",
"alias": "cnt",
"column": "cnt",
"color": null
}
],
"z": [],
"breakdown": [],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 24,
"y": 9,
"w": 24,
"h": 9,
"i": 4
}
}
]
}
],
"variables": {
"list": []
}
}
@@ -0,0 +1,33 @@
# 生产用 OpenObserve(单机)。相对本地版 docker-compose.yml 的区别:
# - 端口只绑 127.0.0.1 → 公网/外网都到不了(UI 访问走 SSH 隧道或 nginx 反代,见 README)
# - root 密码走环境变量(放同目录 .env,已 gitignore,勿提交)
# - 数据 bind-mount 到宿主 /data 分区(需预建目录 + 确认容器可写)+ CPU/内存上限(与 app/PG 共存防抢内存)
#
# 用法:
# 1) 本目录建 .env(已 gitignore):
# OO_ROOT_PASSWORD=<强随机串> # 生成: python -c "import secrets;print(secrets.token_urlsafe(24))"
# 2) docker compose -f docker-compose.prod.yml up -d
# 3) 开机自起: sudo systemctl enable docker
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:v0.91.2
container_name: openobserve
ports:
- "127.0.0.1:5080:5080" # 只绑本机,安全
environment:
ZO_ROOT_USER_EMAIL: "admin@shaguabijia.com"
ZO_ROOT_USER_PASSWORD: "${OO_ROOT_PASSWORD:?请先在 deploy/openobserve/.env 里设 OO_ROOT_PASSWORD}"
ZO_DATA_DIR: "/data"
ZO_COMPACT_DATA_RETENTION_DAYS: "30" # 超 30 天自动删,防爆盘(默认 3650 天=10年)
ZO_TELEMETRY: "false" # 关匿名遥测(内网自用);变量名是 ZO_TELEMETRY,不是 *_ENABLED
volumes:
- /data/openobserve/data:/data # 绑定挂载到宿主机的 /data/openobserve/data 目录(建议该目录所在分区有 20G+ 空间)
restart: unless-stopped
deploy:
resources:
limits: # 硬上限:防 OO 查询/ingest 抢爆 CPU/内存,拖垮同机 PG+app
cpus: '2.0'
memory: 3G
logging: # 容器 stdout 日志上限,防爆盘
driver: json-file
options: { max-size: "10m", max-file: "3" }
+16
View File
@@ -0,0 +1,16 @@
# 本地开发用 OpenObserve(单容器 = local 模式)。用于接收 app-server 的接口指标(QPS/耗时/错误率)。
# 启动: cd deploy/openobserve && docker compose up -d
# Web UI: http://localhost:5080 (账号见下方 env)
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
container_name: openobserve
ports:
- "5080:5080"
environment:
ZO_ROOT_USER_EMAIL: "admin@shaguabijia.local"
ZO_ROOT_USER_PASSWORD: "Complexpass#123"
ZO_DATA_DIR: "/data"
volumes:
- ./data:/data
restart: unless-stopped
+19 -1
View File
@@ -1,9 +1,13 @@
# 傻瓜比价 App 后端 — API 接口文档(索引)
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**(⚠️ 例外:消息通知中心 `notifications` 族与厂商推送 `push` 族按 PRD 前端契约用 **camelCase**,见各自文档)
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
<<<<<<< HEAD
> 最后更新:2026-07-14(新增 **消息通知中心** 3 端点(M1-M3,虚拟数据阶段)与 **厂商推送测试** 3 端点(P1-P3,荣耀/华为/小米/OPPO/vivo);上一次 2026-06-23 补全 device/internal/CPS 短链等整族端点)
=======
> 最后更新:2026-07-09(① 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」(#112 尾声帧 `trace/epilogue` 一并补录);② 新端点:`user/onboarding/reset`(#114)、`GET /internal/launch-confirm-samples`(#91);③ 参数更新:提现族 `source` 分账(#82/#121)、`wallet/account` 邀请奖励金余额、美团 feed/top-sales 按城市过滤(#116)、admin 调现金 `account` 目标账户(#95);④ **Admin 索引补全到当前全量**:新家族 roles(#117/#126)/coupon-data(#99)/device-liveness(#80)/event-logs(#83)/price-reports(#94)/CPS 运营台/提现审核族,及 feedbacks 采纳拒绝(#94/#105)、marquee 模式与真实条浏览(#122/#123)等。上一次 2026-07-03
>>>>>>> origin/main
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
---
@@ -103,12 +107,26 @@
| 36c | `POST /api/v1/user/onboarding/reset` | Bearer | [详情](./user/user-onboarding.md)(重置本设备引导标记,下次登录重走,#114 |
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) |
| **帮助与反馈**(前缀 `/api/v1/feedback` |||
<<<<<<< HEAD
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
| 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) |
| 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected(无单独文档) |
| **消息通知中心**(前缀 `/api/v1/notifications`;⚠️ 本族对外 **camelCase**;虚拟数据阶段:内存 mock,重启复位) |||
| M1 | `GET /api/v1/notifications` | Bearer | [详情](./notifications.md)(消息列表,分页;13 类型卡片字段 + sentAt/isRead;服务端已按时间倒序排好,不分组) |
| M2 | `GET /api/v1/notifications/unread-count` | Bearer | [详情](./notifications.md)(未读总数,首页铃铛角标;>99 → "99+",0 → null 隐藏) |
| M3 | `POST /api/v1/notifications/read` | Bearer | [详情](./notifications.md)(标记已读:`{ids:[...]}` 单条/多条 或 `{all:true}` 进通知中心全量清零;幂等) |
| **厂商推送测试**(前缀 `/api/v1/push`;荣耀/华为/小米/OPPO/vivo 五通道联调三件套,同为 camelCase |||
| P1 | `GET /api/v1/push/vendors` | Bearer | [详情](./push-vendor-test.md)(5 厂商服务端凭据配置状态,缺哪些 .env 键一目了然) |
| P2 | `GET /api/v1/push/templates` | Bearer | [详情](./push-vendor-test.md)(13 类通知的 push 标题/正文模板 + PRD 示例渲染效果) |
| P3 | `POST /api/v1/push/test` | Bearer | [详情](./push-vendor-test.md)(测试发送:默认 mock 不真发;mock=false 真发;可联动插一条站内 mock 通知闭环验证已读) |
=======
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) |
| 38a | `GET /api/v1/feedback/config` | Bearer | [详情](./other/feedback-config.md)(反馈页「加群二维码」卡配置:开关+二维码图+三行文案) |
| 38b | `GET /api/v1/feedback/records` | Bearer | [详情](./other/feedback-records.md)(我的反馈历史,pending/adopted/rejected |
| **埋点 & 订单上报**(前缀分散;全部 Bearer 除 analytics/events 不强制登录) |||
| E1 | `POST /api/v1/analytics/events` | 无 | [详情](./other/analytics-events.md)(批量上报埋点事件,不强制登录,每批最多200条) |
| E2 | `POST /api/v1/order/report` | Bearer | [详情](./other/order-report.md)(上报归因订单,比价后5分钟内点链接+支付金额与比价价相差≤1元) |
>>>>>>> origin/main
| **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) |||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform/platform-stats.md) |
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./savings/platform-savings-feed.md) |
+132
View File
@@ -0,0 +1,132 @@
# 消息通知中心(notifications 族)
> 所属:notifications 组(前缀 `/api/v1/notifications`,源 `app/api/v1/notifications.py`) | 鉴权:**全部 Bearer**(消息按用户隔离) | [← 返回 API 索引](./README.md)
>
> 对应 PRD《消息通知中心》(通知类型清单 / 点击跳转 / 字段元素 / 未读红点 / Push 文案)。
> Push 侧(厂商直推 + 测试)见 [push-vendor-test.md](./push-vendor-test.md)。
>
> **数据落库**:消息存 `notification` 表(`app/repositories/notification.py`,按用户隔离,`sentAt` 倒序)。业务事件统一走 `app/services/notification_events.py` 下发(站内消息 + 厂商 push 一条链路,业务事务 commit 后触发、失败只 log 不影响业务)。**已接入 6 类真实触发**:
>
> | type | 触发点 |
> |---|---|
> | `withdraw_success` | 提现单转账到账(免确认直达 / 查单归一化 / 对账兜底,`repositories/wallet.py`) |
> | `withdraw_failed` | 提现退款收口 `_refund_withdraw`(微信侧失败、审核拒绝、解绑退回) |
> | `feedback_reply` | admin 反馈审核「拒绝」(原因/留言用户可见,`admin/routers/feedback.py`) |
> | `feedback_reward` | admin 反馈审核「采纳」发金币(必带官方留言) |
> | `report_approved` | admin 上报更低价「通过」发金币(`admin/routers/price_report.py`) |
> | `invite_order_reward` | 被邀请好友首次成功比价 → 邀请人发 2 元(`repositories/invite.try_reward_on_compare`) |
>
> 其余类型(奖励过期 ×2、权限异常 ×4、好友催单)业务侧尚未接入。要造联调数据,用 [POST /api/v1/push/test](./push-vendor-test.md) 的 `createNotification:true` 逐条插入。
>
> ⚠️ **字段命名**:本组接口(含 push 测试组)对外为 **camelCase**(`sentAt` / `isRead` / `pageSize`…),与库内其他 snake_case 接口不同——按 PRD 前端契约对接,勿混用。
## 通知类型速查(13 种)
列表**服务端已排好序:全列表按时间倒序**(最新在前,**不做分类分组**——PRD §1 的"按分类分组"为笔误,2026-07-14 需求方确认取消),前端按返回顺序渲染即可。category 仅用于卡片头部的分类标签展示。
| category | 分类标签 | type | 类型 | cardStyle 版式 | actionText 操作行 | extra 里带什么 |
|---|---|---|---|---|---|---|
| withdraw_assistant | 提现助手 | `reward_expiring` | 金币现金奖励即将失效 | dual_amount 双金额卡 | 立即激活您的收益 | `batchId` |
| withdraw_assistant | 提现助手 | `reward_expired` | 金币现金奖励已失效 | dual_amount 双金额卡 | 立即赚取新收益 | — |
| withdraw_assistant | 提现助手 | `withdraw_success` | 提现成功 | withdraw 提现卡 | **null(无操作行,点击仅消红点)** | — |
| withdraw_assistant | 提现助手 | `withdraw_failed` | 提现失败,款项已退回 | withdraw 提现卡 | 重新提现 | `withdrawId` |
| system | 系统通知 | `perm_accessibility` | 比价功能异常(无障碍) | plain_text 纯文本卡 | 去开启 | `permission:"accessibility"` |
| system | 系统通知 | `perm_battery` | 比价续航保护异常(省电策略) | plain_text 纯文本卡 | 去开启 | `permission:"battery"` |
| system | 系统通知 | `perm_autostart` | 比价启动保护异常(自启动) | plain_text 纯文本卡 | 去开启 | `permission:"autostart"` |
| system | 系统通知 | `perm_overlay` | 比价按钮异常(悬浮窗) | plain_text 纯文本卡 | 去开启 | `permission:"overlay"` |
| feedback | 我的反馈 | `feedback_reply` | 官方回复 | plain_text 纯文本卡 | 查看详情 | `feedbackId` |
| feedback | 我的反馈 | `feedback_reward` | 反馈奖励(必带官方留言行) | coin_reward 金币奖励卡 | 查看反馈详情 | `feedbackId` |
| report | 我的爆料 | `report_approved` | 爆料审核通过 | coin_reward 金币奖励卡 | 查看爆料详情 | `reportId` |
| invite | 好友邀请 | `invite_order_reward` | 好友下单奖励到账 | friend_cash 好友现金卡 | 邀请更多好友赚现金 | `inviteeNickname` |
| invite | 好友邀请 | `invite_remind` | 好友催单提醒 | plain_text 纯文本卡 | 去提醒 TA | `inviteeNickname`, `scrollTo:"remind"` |
点击跳转逻辑按 PRD §2 由客户端按 `type` 分发;点击目标 = 整张卡片(不区分主体和操作行),任何点击都先调 `POST /read` 消该条红点。
## GET /api/v1/notifications — 消息列表(分页)
**入参(query)**
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `page` | int | ❌ | 页码,1 起,默认 1 |
| `pageSize` | int | ❌ | 每页条数,默认 20,最大 100 |
**出参**
```jsonc
{
"items": [
{
"id": 90001,
"category": "withdraw_assistant", // 分类 key(5 种,见上表)
"categoryLabel": "提现助手", // 卡片头部左上角分类标签
"type": "reward_expiring", // 类型 key(13 种,决定点击行为)
"cardStyle": "dual_amount", // 版式:dual_amount/withdraw/plain_text/coin_reward/friend_cash
"title": "金币现金奖励即将失效", // 卡片标题
"coins": 86, // 金币数,整数;无金币的版式为 null
"cashCents": 1280, // 现金金额(分);无现金的版式为 null
"cashYuan": "12.80", // 现金展示串(元,两位小数),与 cashCents 同源
"infoRows": [ // 信息行,已按 PRD 拼好文案,逐行 label: value 渲染
{ "label": "过期说明", "value": "您有86金币和12.80元现金即将失效,完成一次一键领券或一键比价即可激活收益" },
{ "label": "过期时间", "value": "3天后失效" }
],
"actionText": "立即激活您的收益", // 操作行;null = 无操作行(提现成功卡)
"extra": { "batchId": "batch_20260714" }, // 跳转/联动参数,按 type 取用(见上表)
"sentAt": "2026-07-14T14:59:58+08:00", // ISO8601 带时区
"isRead": false // false → 分类标签右侧显示 6px 红点(#E53935)
}
],
"page": 1,
"pageSize": 20,
"total": 16,
"hasMore": false,
"unreadCount": 12 // 与 /unread-count 同口径,进页面可顺手刷角标
}
```
**时间显示规则(前端处理 `sentAt`)**:今天 →「今天」;昨天 →「昨天」;当年 →「M月D日」(不补零);跨年 →「YYYY年M月D日」。`sentAt` 恒带 +08:00(服务端已归一,与库底层用 SQLite/PostgreSQL 无关)。
**数值约束(PRD §3)**:金币整数不带小数;现金/提现金额两位小数(直接用 `cashYuan`)。
## GET /api/v1/notifications/unread-count — 未读总数(首页铃铛角标)
无入参。**出参**:
```jsonc
{ "count": 12, "badgeText": "12" } // count>99 时 badgeText="99+";count=0 时 badgeText=null → 整个角标隐藏
```
刷新时机(PRD §4):进入首页时、从通知中心/其他页面返回首页时(原型监听 `pageshow`)。
## POST /api/v1/notifications/read — 标记已读
**入参(JSON),两种模式二选一(同时传时 `all` 优先)**
| 模式 | body | 使用场景 |
|---|---|---|
| 单条/多条 | `{ "ids": [90001, 90003] }` | ① 点击某张消息卡片(点击后无论跳转/弹窗/无动作都算已读);② 用户点击 push 直达落地页后,客户端拿 push extras 里的 `notificationId` 同步置读 |
| 全量清零 | `{ "all": true }` | 进入通知中心自动清零(只浏览列表就消红点,无需逐条点击;退出通知中心时也可再调一次兜底) |
**出参**
```jsonc
{ "ok": true, "markedCount": 2, "unreadCount": 10 } // unreadCount = 处理后剩余未读,可直接刷新角标
```
幂等:不存在/已读的 id 忽略,重复调用 `markedCount=0` 不报错。
**错误**:`400` ids 与 all 都没传(或 ids 为空数组);`401` 未鉴权。
## 联调小抄
```bash
# 1. 登录拿 token(SMS mock:任意手机号 + 任意 6 位验证码)
curl -X POST :8770/api/v1/auth/sms/send -d '{"phone":"13800001234"}'
curl -X POST :8770/api/v1/auth/sms/login -d '{"phone":"13800001234","code":"123456"}'
# 2. 列表 / 角标 / 置读
curl ":8770/api/v1/notifications?page=1&pageSize=20" -H "Authorization: Bearer $TOKEN"
curl ":8770/api/v1/notifications/unread-count" -H "Authorization: Bearer $TOKEN"
curl -X POST ":8770/api/v1/notifications/read" -d '{"all":true}' -H "Authorization: Bearer $TOKEN"
```
列表初始为空,登录后先用 [POST /api/v1/push/test](./push-vendor-test.md) 的 `createNotification:true` 插几条(可指定 `type` 覆盖不同版式),再验列表 / 角标 / 置读全流程;它同时把 `notificationId` 放进 push extras,可闭环验证「push → 站内已读联动」。
+103
View File
@@ -0,0 +1,103 @@
# 厂商推送测试三件套(push 族)
> 所属:push 组(前缀 `/api/v1/push`,源 `app/api/v1/push.py`) | 鉴权:**全部 Bearer** | [← 返回 API 索引](./README.md)
>
> 发送实现:`app/integrations/vendor_push.py`(荣耀 / **华为** / 小米 / OPPO / vivo 五通道,
> `send_notification()` 通用入口)。站内消息中心见 [notifications.md](./notifications.md)。
> 与 `POST /api/v1/device/push-test`(无障碍召回通道延迟自测)互补:本组面向消息中心 13 类 push 的文案/参数/通道联调。
>
> 字段命名同 notifications 族:**camelCase**。
## 链路总览
```
真实业务事件(提现回执/反馈审核/爆料通过/好友下单 已接入;奖励过期等待接)
└→ services/notification_events(先落 notification 表,再向该用户全部已注册设备直推)
└→ vendor_push.send_notification(vendor, token, title, body, extras)
extras = { type, notificationId, ...业务参数 } ← 客户端深链 + 已读联动的钥匙
客户端点击 push → 按 extras.type 直达落地页(与站内点击一致)
→ 调 POST /notifications/read {ids:[extras.notificationId]} 同步消红点(PRD §4)
```
推送目标来源:客户端集成各厂商 push SDK 拿到 regId/token 后,通过 `POST /api/v1/device/register` 上报 `push_vendor` + `push_token`,服务端存 `device_liveness` 表。
## GET /api/v1/push/vendors — 厂商配置状态
检查 5 家厂商服务端凭据是否配齐(只读 .env,不打厂商接口)。`missingKeys` 即还要补的配置键;mock 测试不依赖任何凭据。
```jsonc
{ "vendors": [
{ "vendor": "honor", "label": "荣耀", "configured": false, "missingKeys": ["HONOR_PUSH_APP_ID", "HONOR_PUSH_CLIENT_ID", "HONOR_PUSH_CLIENT_SECRET"] },
{ "vendor": "huawei", "label": "华为", "configured": false, "missingKeys": ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"] },
{ "vendor": "xiaomi", "label": "小米", "configured": true, "missingKeys": [] },
{ "vendor": "oppo", "label": "OPPO", "configured": false, "missingKeys": ["OPPO_PUSH_APP_KEY", "OPPO_PUSH_MASTER_SECRET"] },
{ "vendor": "vivo", "label": "vivo", "configured": false, "missingKeys": ["VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET"] }
] }
```
## GET /api/v1/push/templates — 13 类通知的 push 模板预览
PRD §5 的 13 条 push 文案(标题固定 ≤11 字不带变量;正文 `{var}` 为变量,示例值即 PRD 示例)。对文案、看变量名用。
```jsonc
{ "templates": [
{
"type": "withdraw_success",
"category": "withdraw_assistant", "categoryLabel": "提现助手", "cardStyle": "withdraw",
"pushTitle": "提现到账提醒",
"pushBodySample": "¥0.50已存入您的微信钱包,点击查看到账详情", // 用示例值渲染后的效果
"pushBodyTemplate": "¥{amount}已存入您的微信钱包,点击查看到账详情",
"variables": ["amount"],
"sampleVars": { "amount": "0.50" }
}
// ... 共 13 条,顺序即 PRD 编号
] }
```
## POST /api/v1/push/test — 测试发送(默认 mock)
**入参(JSON)**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `vendor` | string | ❌* | `honor/huawei/xiaomi/oppo/vivo`,中文「华为」「小米」等别名也识别;留空时用 `deviceId` 设备上报的 vendor |
| `pushToken` | string | ❌* | 厂商 push token/regId;留空则按 `deviceId` 反查已注册设备(*mock 模式两者都缺时用占位 token,只看渲染结果*) |
| `deviceId` | string | ❌ | 客户端 `DeviceId.get()` 的设备 id,用于反查 vendor+token |
| `type` | string | ❌ | 13 种类型 key 之一 → 按 PRD 模板渲染;不传且没直给文案 → 发通用测试文案 |
| `vars` | object | ❌ | 覆盖模板变量,如 `{"coins":"520","cash":"6.66"}`;缺省用 PRD 示例值 |
| `title` / `content` | string | ❌ | 直接指定标题/正文(优先于 type 模板) |
| `createNotification` | bool | ❌ | true = 同时往该用户消息中心插一条同类型未读 mock 通知,extras 带其 `notificationId` → 可闭环验证「点 push → 调 /notifications/read 消红点」(仅 type 合法时生效) |
| `mock` | bool | ❌ | **默认 true = 不真调厂商 API**,回显渲染结果;false = 真发到手机(要求该厂商凭据已配) |
**出参**
```jsonc
{
"ok": true, "mock": true, "vendor": "huawei",
"title": "反馈奖励已到账",
"body": "谢谢您帮傻瓜比价变得更好,300金币已到账,还有一条给您的留言~",
"extras": { "type": "feedback_reward", "feedbackId": "3002", "notificationId": "90017" },
"notificationId": 90017, // createNotification=true 时的站内 mock 通知 id
"missingKeys": ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"], // 真发前还缺的配置(真发成功时必为空)
"vendorResponse": null // 真发时为厂商 API 原始响应
}
```
**错误**:`400` vendor/type 非法、真发但凭据未配(detail 列缺失键);`409` 真发但拿不到 pushToken;`502` 厂商 API 返回失败(detail 带厂商原始错误)。
**真发注意**:
- 目标手机必须先装 App 且客户端已集成对应厂商 SDK、`/device/register` 上报过 token;
- vivo 未上架前走测试推送(`VIVO_PUSH_MODE=1`),目标手机需在 vivo 开放平台加入测试设备;
- 小米新设备需在开放平台把签名/包名配好,token 才有效。
## 厂商凭据怎么拿(.env 键名)
| 厂商 | 后台 | 需要的键 |
|---|---|---|
| 华为 | AGC 控制台 → 项目设置 → 常规 → 应用 | `HUAWEI_PUSH_APP_ID``HUAWEI_PUSH_APP_SECRET`(OAuth client_id 即 AppId) |
| 荣耀 | 荣耀开发者服务平台 → 推送服务 | `HONOR_PUSH_APP_ID``HONOR_PUSH_CLIENT_ID``HONOR_PUSH_CLIENT_SECRET` |
| 小米 | 开放平台 → 消息推送 → 应用秘钥 | `XIAOMI_PUSH_APP_SECRET`(服务端只要这个;AppID/AppKey 是客户端 SDK 用) |
| OPPO | 开放平台 → 推送服务 | `OPPO_PUSH_APP_KEY``OPPO_PUSH_MASTER_SECRET`(注意是**服务端 MasterSecret**) |
| vivo | 开放平台 → 推送 | `VIVO_PUSH_APP_ID``VIVO_PUSH_APP_KEY``VIVO_PUSH_APP_SECRET` |
各家发送协议差异(鉴权方式/成功码/payload 结构)封装在 `integrations/vendor_push.py`,业务侧只面对 `send_notification()`
+2
View File
@@ -42,6 +42,8 @@
| `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | [详情](./ad_ecpm_record.md) |
| `ad_feed_reward_record` | 信息流/Draw 广告结算记录(10 秒一份,client_event_id 幂等;`ad_type`+`feed_scene` 分形态/场景) | `models/ad_feed_reward.py` | [详情](./ad_feed_reward_record.md) |
| `ad_pangle_daily_revenue` | 穿山甲 GroMore 后台收益日表(定时拉取,收益报表/大盘真实收益源,#92) | `models/ad_pangle_revenue.py` | [详情](./ad_pangle_daily_revenue.md) |
| `inactivity_reset_log` | 15 天不活跃清零审计(每次清零一行;清零前三桶余额快照+原因+不活跃天数;只清金币+现金,邀请金仅快照) | `models/inactivity.py` | [详情](./inactivity_reset_log.md) |
| `inactivity_notification_log` | 不活跃清零前预警记录(余额快照+档位+通道+状态;streak 去重依据 + 占位 outbox) | `models/inactivity.py` | [详情](./inactivity_notification_log.md) |
### 比价 / 省钱
| 表 | 用途 | 模型 | 文档 |
@@ -0,0 +1,36 @@
# inactivity_notification_log — 不活跃清零前预警记录
> 模型 `app/models/inactivity.py` · 仓库 `app/repositories/inactivity.py` · 通知器 `app/integrations/notifier.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
清零前按可配置节奏(`INACTIVITY_WARN_DAYS_BEFORE`,默认清零前 7 天、2 天各一次)向用户预警"账户里的 xx 金币和 xx 现金将被清零"。每发一次预警写一行,记推送时的余额快照 + 提前天数档 + 通道 + 状态。兼作两用:**预警去重**依据(同 streak 内 `stage==k 且 created_at > last_active` 即已推过、不重推)与**占位 outbox**(v1 通道=`log`,只打日志不真推;后续接 JPush/短信同层扩展)。append-only,不更新。**预警只涉及会被清的金币 + 折算现金;邀请奖励金不清、不预警**(`invite_cash_balance_cents` 仅作账户状态快照)。
## 用在哪 / 增删改查
- **C(插入)**:`inactivity.run_warn_once` 命中预警档、且本 streak 未推过时,调 `notifier.warn` 后写一行(`status` = 通知器返回,占位实现为 `placeholder`)。
- **U / D**:无(append-only)。
- **R**:预警去重查询(`user_id + stage + created_at > last_active`);未来接真实推送时作待推送 outbox。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | **PK**, autoincrement | 主键 |
| `user_id` | Integer | NOT NULL, index | 预警对象;只索引不设外键(同 `analytics_event`) |
| `stage` | Integer | NOT NULL | 提前天数档(如 `7` / `2`,即清零前第几天推) |
| `inactive_days` | Integer | NOT NULL | 推送时的不活跃天数(北京自然日) |
| `coin_balance` | Integer | NOT NULL | 推送时金币余额快照(将被清) |
| `cash_balance_cents` | Integer | NOT NULL | 推送时折算现金余额快照(分,将被清) |
| `invite_cash_balance_cents` | Integer | NOT NULL | 推送时**邀请奖励金**余额快照(分,**不清、不在预警额度内**) |
| `channel` | String(16) | NOT NULL | 通道:`log`(占位) / `jpush` / `sms` |
| `status` | String(16) | NOT NULL | 状态:`placeholder`(占位未真推) / `sent` / `failed` |
| `created_at` | DateTime(tz) | server_default now(), index | 推送时刻;去重比 `created_at > last_active`(用户回归后 `last_active` 前移 → 旧行自然失效、开启新 streak) |
## 关系 / Join Key
- `user_id``user.id`(无外键直连,靠 `user_id` 关联)。
-`inactivity_reset_log` 无直接外键;同一 streak 内先有若干预警行,到期后有一行清零。
## 索引与约束
- PK `id`;`ix_inactivity_notification_log_user_id``ix_inactivity_notification_log_created_at`
## 注意
- **预警去重按 streak**:判据是 `created_at > last_active`;用户一有活跃(`home_view`/比价/领券),`last_active` 前移,旧预警行"失效",回归后可重新进入预警。
- **占位实现**:v1 `LogNotifier``logger.warning("[inactivity-warn] ...")`、返回 `placeholder`,不真推(参照心跳告警"本期先不接推送"先例)。
- **漏跑补发**:worker 漏跑数天后某用户可能同时满足多档,只补发**最紧急的未推档**(最小提前天数),避免刷屏。
+35
View File
@@ -0,0 +1,35 @@
# inactivity_reset_log — 15 天不活跃清零审计
> 模型 `app/models/inactivity.py` · 仓库 `app/repositories/inactivity.py` · worker `app/core/inactivity_reset_worker.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
连续 15 天不活跃(北京自然日,活跃口径见 `app/repositories/activity.py`:`home_view` + 发起比价 + 发起领券,**不含登录**)的用户,worker 每日自动清零其**金币 + 折算现金**。每清一个用户写一行,记清零前三桶余额快照 + 原因 + 判定时的活跃时间/不活跃天数,供纠纷排查。清零同时另写 2 条钱包流水(`coin_transaction` / `cash_transaction`,`biz_type=inactivity_reset`,`ref_id=` 本表 `id`),资金流可逐笔回溯、人工恢复。**邀请奖励金(`invite_cash_balance_cents`)是产品红线、不清零**,本表 `invite_cash_balance_cents_before` 仅为清零时仍保留的邀请金快照(非被清金额)。append-only,不更新。
## 用在哪 / 增删改查
- **C(插入)**:`inactivity.clear_user` 逐用户清零(独立事务、行锁)时写一行,`db.flush()``id` 作流水 `ref_id` 交叉链接。
- **U / D**:无(append-only 审计)。
- **R**:纠纷排查 / 对账(与 `coin_transaction` / `cash_transaction``ref_id` 交叉核对)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | **PK**, autoincrement | 主键;作 `ref_id` 写入两条清零流水 |
| `user_id` | Integer | NOT NULL, index | 被清零用户;只索引不设外键(同 `analytics_event`,避免删用户级联 / 留历史) |
| `coin_balance_before` | Integer | NOT NULL | 清零前金币余额(个数);= 对应 `coin_transaction.amount` 绝对值 |
| `cash_balance_cents_before` | Integer | NOT NULL | 清零前折算现金余额(分);= 对应 `cash_transaction.amount_cents` 绝对值 |
| `invite_cash_balance_cents_before` | Integer | NOT NULL | 清零时的**邀请奖励金**余额快照(分)——**不清、原封保留**,仅记录以证明"未动邀请金" |
| `last_active_at` | DateTime(tz) | nullable | 判定时的最近活跃时刻(UTC);无任何活跃信号时兜底为 `user.created_at` |
| `inactive_days` | Integer | NOT NULL | 判定时的不活跃天数(北京自然日) |
| `reason` | String(32) | NOT NULL | 清零原因,如 `inactive_15d` |
| `reset_at` | DateTime(tz) | server_default now(), index | 清零时刻 |
## 关系 / Join Key
- `user_id``user.id`(无外键直连,靠 `user_id` 关联)。
- `id``coin_transaction.ref_id` / `cash_transaction.ref_id`(`biz_type=inactivity_reset`):审计行 ↔ 资金流水交叉对账。
## 索引与约束
- PK `id`;`ix_inactivity_reset_log_user_id`(按用户查)、`ix_inactivity_reset_log_reset_at`(按时间查)。
## 注意
- **只清 2 桶**:金币 + 折算现金;**邀请现金不清**(两本账物理隔离,见 [`coin_account`](./coin_account.md) / `wallet.CoinAccount` 注释)。
- **天然幂等**:清完余额=0,次日不再匹配;worker 重启 / 多次唤醒 / 补跑都不会重复清零或重复流水。
- **总闸默认关**(`INACTIVITY_RESET_ENABLED=false`),灰度验证清零名单后再开。
@@ -0,0 +1,897 @@
# 接口 QPS + 耗时可观测(OpenObserve)实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 给 app-server 每个接口采集 QPS + 耗时 + 错误率,经轻量 ASGI 中间件 + 后台 worker 批量直采到本地 Docker 的 OpenObserve。
**Architecture:** 纯 ASGI 中间件测每请求耗时/抓路由模板+状态码 → 非阻塞入有界队列(满则丢、绝不阻塞)→ 后台 asyncio worker 批量 POST 到 OpenObserve `_json` ingest 端点。请求路径零 I/O;未配置观测则整套 no-op;上报失败丢批不重试。
**Tech Stack:** FastAPI / Starlette ASGI 中间件、`asyncio.Queue``httpx.AsyncClient`(已有依赖)、pydantic-settings、OpenObserveDocker)。
参考 spec[docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md](2026-07-06-openobserve-api-metrics-design.md)
---
## 文件结构
| 文件 | 职责 |
|---|---|
| `app/core/config.py`(改) | 新增 `OBSERVE_*` 配置 + `observe_configured` 门槛属性 |
| `app/core/observe.py`(新) | 有界事件队列 + `record_event` + 路由模板解析 + `RequestMetricsMiddleware` |
| `app/core/observe_worker.py`(新) | 后台批量上报 worker:`_collect_batch` / `_post_batch` / `start_*` / `stop_*` |
| `app/main.py`(改) | 挂中间件(最外层)+ lifespan 启停 worker |
| `.env.example`(改) | 新增 `OBSERVE_*` 注释段 |
| `deploy/openobserve/docker-compose.yml`(新) | 本地 OpenObserve 容器 |
| `deploy/openobserve/README.md`(新) | 部署步骤 + 查询/仪表盘 SQL |
| `tests/test_observe.py`(新) | 配置门槛 / 队列 / 中间件 / worker 单测 |
**关键接口契约(跨任务一致,勿改名):**
- `app.core.observe.get_queue() -> asyncio.Queue[dict]`
- `app.core.observe.record_event(event: dict) -> None`
- `app.core.observe.take_dropped() -> int`
- `app.core.observe.RequestMetricsMiddleware`ASGI class`__init__(self, app)`
- 事件字段:`_timestamp`(µs int) / `service` / `env` / `method` / `route` / `status` / `duration_ms`(float)
- `app.core.observe_worker.start_observe_worker() -> asyncio.Task | None`
- `app.core.observe_worker.stop_observe_worker(task) -> None`
- `settings.observe_configured -> bool`
---
## Task 1: 配置项 `OBSERVE_*` + `observe_configured`
**Files:**
- Modify: `app/core/config.py`(在 `cors_origins_list` property 之后、`is_prod` property 之前插入)
- Test: `tests/test_observe.py`(新建)
- [ ] **Step 1: 写失败测试**
新建 `tests/test_observe.py`
```python
"""接口指标可观测(observe)单测:配置门槛 / 队列 / 中间件 / worker。
沿用仓库约定:TestClient + monkeypatch,绝不打真网络。observe 默认关(conftest 未设
OBSERVE_*),需要开启的用例用 monkeypatch 改 settings 单例属性。
"""
from __future__ import annotations
from app.core.config import settings
def test_observe_configured_requires_switch_and_creds(monkeypatch):
# 开关开 + endpoint(默认 localhost)+ user + password 齐全 → True
monkeypatch.setattr(settings, "OBSERVE_ENABLED", True)
monkeypatch.setattr(settings, "OBSERVE_USER", "u")
monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p")
assert settings.observe_configured is True
# 缺密码 → False
monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "")
assert settings.observe_configured is False
# 开关关 → False(即便凭证齐全)
monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p")
monkeypatch.setattr(settings, "OBSERVE_ENABLED", False)
assert settings.observe_configured is False
```
- [ ] **Step 2: 运行,确认失败**
Run: `pytest tests/test_observe.py::test_observe_configured_requires_switch_and_creds -q`
Expected: FAIL —— `AttributeError``settings``OBSERVE_ENABLED` / 无 `observe_configured`
- [ ] **Step 3: 实现配置**
`app/core/config.py``cors_origins_list` property 之后、`is_prod` property 之前插入:
```python
# ===== 可观测(OpenObserve 接口指标)=====
# 采集每个接口的 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker)。
# 默认关(prod 安全):未开启 → 中间件透传、worker 不启动,整套 no-op。
# 开启需 ENABLED=true 且 ENDPOINT/USER/PASSWORD 齐全(见 observe_configured)。
OBSERVE_ENABLED: bool = False
OBSERVE_ENDPOINT: str = "http://localhost:5080" # OpenObserve base URL
OBSERVE_ORG: str = "default" # 组织名
OBSERVE_STREAM: str = "app_requests" # stream 名(首次上报自动建)
OBSERVE_USER: str = "" # Basic auth 邮箱
OBSERVE_PASSWORD: str = "" # Basic auth 密码/token
OBSERVE_FLUSH_INTERVAL_SEC: float = 5.0 # worker 最长攒批间隔
OBSERVE_BATCH_MAX: int = 200 # 单批最大事件数
OBSERVE_QUEUE_MAX: int = 10000 # 有界队列上限,满则丢
OBSERVE_TIMEOUT_SEC: float = 5.0 # 上报 HTTP 超时
@property
def observe_configured(self) -> bool:
"""观测上报可用 = 总开关开 且 endpoint/账号/密码齐全(缺则整套 no-op)。"""
return bool(
self.OBSERVE_ENABLED
and self.OBSERVE_ENDPOINT
and self.OBSERVE_USER
and self.OBSERVE_PASSWORD
)
```
- [ ] **Step 4: 运行,确认通过**
Run: `pytest tests/test_observe.py::test_observe_configured_requires_switch_and_creds -q`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add app/core/config.py tests/test_observe.py
git commit -m "feat(observe): 加 OBSERVE_* 配置与 observe_configured 门槛"
```
---
## Task 2: 事件队列 + `record_event` + `take_dropped`
**Files:**
- Create: `app/core/observe.py`
- Test: `tests/test_observe.py`(追加)
- [ ] **Step 1: 写失败测试**
`tests/test_observe.py` 顶部 import 区补 `import asyncio``from app.core import observe`,并追加:
```python
def test_record_event_enqueues(monkeypatch):
q = asyncio.Queue(maxsize=10)
monkeypatch.setattr(observe, "_queue", q)
observe.record_event({"route": "/x"})
assert q.get_nowait() == {"route": "/x"}
def test_record_event_drops_when_full(monkeypatch):
q = asyncio.Queue(maxsize=1)
monkeypatch.setattr(observe, "_queue", q)
monkeypatch.setattr(observe, "_dropped", 0)
observe.record_event({"n": 1}) # 占满
observe.record_event({"n": 2}) # 满 → 丢弃当前,不抛异常
assert observe.take_dropped() == 1
assert observe.take_dropped() == 0 # 取出后清零
assert q.get_nowait() == {"n": 1} # 保留的是先到的
```
- [ ] **Step 2: 运行,确认失败**
Run: `pytest tests/test_observe.py -q -k record_event`
Expected: FAIL —— `ModuleNotFoundError: app.core.observe` 或无 `record_event`
- [ ] **Step 3: 实现 `app/core/observe.py`(先只放队列部分)**
> 注意:本步只放队列相关代码。中间件用到的 `os`/`time`/`Match` 及 `_SKIP_PATHS`/`_UNMATCHED`/`_SERVICE` 常量放到 Task 3 一并加入——否则本步提交时 ruff 会报 F401 未用导入。
新建 `app/core/observe.py`
```python
"""接口指标埋点:有界事件队列 + 纯 ASGI 中间件。
每个 HTTP 请求测总耗时、抓路由模板 + 状态码,非阻塞塞进有界队列;由 observe_worker
后台批量上报到 OpenObserve。请求路径上无任何 I/O。未配置观测时中间件直接透传。
"""
from __future__ import annotations
import asyncio
from app.core.config import settings
# 有界事件队列(懒创建,见 get_queue):首次取用时在运行中的 loop 里建,避免 import 期
# 无 loop 的边角问题;put_nowait/get_nowait 不需运行中的 loop → 可在无 loop 下测试。
_queue: asyncio.Queue[dict] | None = None
# 队列满时的丢弃计数,worker 定期取出打日志。
_dropped = 0
def get_queue() -> asyncio.Queue[dict]:
"""返回全局有界事件队列(懒创建)。测试可 monkeypatch 模块级 _queue 换成小队列。"""
global _queue
if _queue is None:
_queue = asyncio.Queue(maxsize=settings.OBSERVE_QUEUE_MAX)
return _queue
def take_dropped() -> int:
"""取出并清零累计丢弃数(供 worker 打点)。"""
global _dropped
n, _dropped = _dropped, 0
return n
def record_event(event: dict) -> None:
"""非阻塞入队;队列满则丢弃当前事件并计数。永不抛异常、永不阻塞请求。"""
global _dropped
try:
get_queue().put_nowait(event)
except asyncio.QueueFull:
_dropped += 1
```
- [ ] **Step 4: 运行,确认通过**
Run: `pytest tests/test_observe.py -q -k record_event`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add app/core/observe.py tests/test_observe.py
git commit -m "feat(observe): 加有界事件队列与 record_event(满则丢)"
```
---
## Task 3: `RequestMetricsMiddleware`(路由模板 + 状态码 + 耗时)
**Files:**
- Modify: `app/core/observe.py`(追加 `_resolve_route` 和中间件 class
- Test: `tests/test_observe.py`(追加)
- [ ] **Step 1: 写失败测试**
`tests/test_observe.py` 顶部 import 区补:
```python
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
```
并追加:
```python
def _make_probe_app() -> FastAPI:
"""独立最小 app:只挂中间件 + 两个无鉴权路由,不碰真业务 DB/auth。"""
app = FastAPI()
app.add_middleware(observe.RequestMetricsMiddleware)
@app.get("/things/{tid}")
def get_thing(tid: str):
return {"tid": tid}
@app.get("/health")
def health():
return {"ok": True}
return app
@pytest.fixture
def observe_on(monkeypatch):
"""开启观测 + 换一个干净小队列,返回该队列供断言。"""
monkeypatch.setattr(settings, "OBSERVE_ENABLED", True)
monkeypatch.setattr(settings, "OBSERVE_USER", "u")
monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p")
q = asyncio.Queue(maxsize=100)
monkeypatch.setattr(observe, "_queue", q)
return q
def test_middleware_records_route_template(observe_on):
client = TestClient(_make_probe_app())
r = client.get("/things/42")
assert r.status_code == 200
evt = observe_on.get_nowait()
assert evt["route"] == "/things/{tid}" # 模板,不是 /things/42
assert evt["method"] == "GET"
assert evt["status"] == 200
assert evt["duration_ms"] >= 0
assert evt["service"] and "env" in evt and isinstance(evt["_timestamp"], int)
def test_middleware_skips_health(observe_on):
client = TestClient(_make_probe_app())
client.get("/health")
assert observe_on.empty()
def test_middleware_unmatched_route_is_normalized(observe_on):
client = TestClient(_make_probe_app())
r = client.get("/definitely-not-a-route")
assert r.status_code == 404
evt = observe_on.get_nowait()
assert evt["route"] == "__unmatched__"
assert evt["status"] == 404
def test_middleware_noop_when_disabled(monkeypatch):
monkeypatch.setattr(settings, "OBSERVE_ENABLED", False)
q = asyncio.Queue(maxsize=100)
monkeypatch.setattr(observe, "_queue", q)
client = TestClient(_make_probe_app())
client.get("/things/1")
assert q.empty() # 未配置观测 → 零入队
```
- [ ] **Step 2: 运行,确认失败**
Run: `pytest tests/test_observe.py -q -k middleware`
Expected: FAIL —— `AttributeError: module 'app.core.observe' has no attribute 'RequestMetricsMiddleware'`
- [ ] **Step 3a: 给 `app/core/observe.py` 补中间件用的导入与常量**
把顶部 import 段从
```python
from __future__ import annotations
import asyncio
from app.core.config import settings
```
改成
```python
from __future__ import annotations
import asyncio
import os
import time
from starlette.routing import Match
from app.core.config import settings
# 不采集的路径(纯噪音):健康检查。
_SKIP_PATHS = frozenset({"/health"})
# 未匹配路由(404/扫描器)归一到此,防维度爆炸。
_UNMATCHED = "__unmatched__"
# service 字段:与 logging.py 同源(LOG_SERVICE_NAME),默认 app-server。
_SERVICE = os.getenv("LOG_SERVICE_NAME", "app-server")
```
`_queue` / `_dropped` / `get_queue` / `take_dropped` / `record_event` 保持不动。)
- [ ] **Step 3b: 实现中间件(追加到 `app/core/observe.py` 末尾)**
```python
def _resolve_route(scope) -> str:
"""从 scope 取路由模板(如 /things/{tid})。优先 scope['route'](现代 Starlette
路由后写入);取不到则手动匹配一次(老版本兜底);仍无 → __unmatched__(404/扫描器)。"""
route = scope.get("route")
path = getattr(route, "path", None)
if path:
return path
app_ = scope.get("app")
router = getattr(app_, "router", None)
for candidate in getattr(router, "routes", []):
try:
match, _ = candidate.matches(scope)
except Exception: # noqa: BLE001 - 匹配兜底,任一路由异常不影响整体
continue
if match == Match.FULL and getattr(candidate, "path", None):
return candidate.path
return _UNMATCHED
class RequestMetricsMiddleware:
"""纯 ASGI 中间件:测每个 http 请求耗时,记 method/route/status/duration。
放在最外层(main.py 里 CORS 之后 add),测到含 CORS 的完整耗时。未配置观测 → 透传。
"""
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http" or not settings.observe_configured:
await self.app(scope, receive, send)
return
if scope.get("path") in _SKIP_PATHS:
await self.app(scope, receive, send)
return
start = time.perf_counter()
status_holder = {"status": 500} # 下游异常未产出 response 时兜底 500
async def send_wrapper(message) -> None:
if message["type"] == "http.response.start":
status_holder["status"] = message["status"]
await send(message)
try:
await self.app(scope, receive, send_wrapper)
finally:
duration_ms = (time.perf_counter() - start) * 1000.0
record_event({
"_timestamp": int(time.time() * 1_000_000), # µs,OpenObserve 时间列
"service": _SERVICE,
"env": settings.APP_ENV,
"method": scope.get("method", ""),
"route": _resolve_route(scope),
"status": status_holder["status"],
"duration_ms": round(duration_ms, 3),
})
```
- [ ] **Step 4: 运行,确认通过**
Run: `pytest tests/test_observe.py -q -k middleware`
Expected: PASS4 个中间件用例全过)
> 若 `test_middleware_records_route_template` 拿到的是 `/things/42` 而非模板,说明该 Starlette 版本未在 `scope["route"]` 写模板——此时 `_resolve_route` 的手动匹配兜底应已生效并返回模板;若仍不对,检查兜底分支是否被 import 顺序影响。
- [ ] **Step 5: 提交**
```bash
git add app/core/observe.py tests/test_observe.py
git commit -m "feat(observe): 加 RequestMetricsMiddleware(路由模板+状态码+耗时)"
```
---
## Task 4: 后台上报 worker
**Files:**
- Create: `app/core/observe_worker.py`
- Test: `tests/test_observe.py`(追加)
- [ ] **Step 1: 写失败测试**
`tests/test_observe.py` 顶部 import 区补:
```python
import httpx
from app.core import observe_worker
```
并追加:
```python
async def test_collect_batch_drains_up_to_batch_max(monkeypatch):
q = asyncio.Queue(maxsize=100)
monkeypatch.setattr(observe, "_queue", q)
monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.1)
monkeypatch.setattr(settings, "OBSERVE_BATCH_MAX", 200)
for i in range(3):
q.put_nowait({"n": i})
batch = await observe_worker._collect_batch()
assert [e["n"] for e in batch] == [0, 1, 2]
async def test_collect_batch_timeout_returns_empty(monkeypatch):
q = asyncio.Queue(maxsize=100)
monkeypatch.setattr(observe, "_queue", q)
monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.05)
batch = await observe_worker._collect_batch()
assert batch == []
async def test_post_batch_hits_json_ingest_url(monkeypatch):
monkeypatch.setattr(settings, "OBSERVE_ORG", "default")
monkeypatch.setattr(settings, "OBSERVE_STREAM", "app_requests")
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["json"] = request.content
return httpx.Response(200, json={"code": 200})
client = httpx.AsyncClient(
base_url="http://oo", transport=httpx.MockTransport(handler)
)
await observe_worker._post_batch(client, [{"route": "/x", "status": 200}])
await client.aclose()
assert captured["url"] == "http://oo/api/default/app_requests/_json"
assert b"/x" in captured["json"]
def test_start_observe_worker_noop_when_not_configured(monkeypatch):
monkeypatch.setattr(settings, "OBSERVE_ENABLED", False)
assert observe_worker.start_observe_worker() is None
```
- [ ] **Step 2: 运行,确认失败**
Run: `pytest tests/test_observe.py -q -k "collect_batch or post_batch or start_observe"`
Expected: FAIL —— `ModuleNotFoundError: app.core.observe_worker`
- [ ] **Step 3: 实现 `app/core/observe_worker.py`**
新建 `app/core/observe_worker.py`
```python
"""接口指标后台上报 worker:批量 drain 事件队列 → POST 到 OpenObserve。
对齐 heartbeat_monitor_worker 等的 start_*/stop_* 形态。best-effort 遥测:catch 全部
异常,上报失败直接丢批不重试。未配置观测 → start 返回 None(不启动),整套 no-op。
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import httpx
from app.core.config import settings
from app.core.observe import get_queue, take_dropped
logger = logging.getLogger("shagua.observe")
# 上报用的 httpx client,start 时建、stop 时关。
_client: httpx.AsyncClient | None = None
async def _collect_batch() -> list[dict]:
"""等到 ≥1 条(或到 flush 间隔)后,连抽到 BATCH_MAX 条或抽空。超时且空 → 返回 []。"""
queue = get_queue()
batch: list[dict] = []
try:
first = await asyncio.wait_for(
queue.get(), timeout=settings.OBSERVE_FLUSH_INTERVAL_SEC
)
except asyncio.TimeoutError:
return batch
batch.append(first)
while len(batch) < settings.OBSERVE_BATCH_MAX:
try:
batch.append(queue.get_nowait())
except asyncio.QueueEmpty:
break
return batch
async def _post_batch(client: httpx.AsyncClient, batch: list[dict]) -> None:
"""POST 一批事件到 OpenObserve 的 _json ingest 端点。非 2xx 仅告警。"""
url = f"/api/{settings.OBSERVE_ORG}/{settings.OBSERVE_STREAM}/_json"
resp = await client.post(url, json=batch)
if resp.status_code >= 300:
logger.warning(
"observe ingest failed status=%s body=%s",
resp.status_code,
resp.text[:200],
)
async def _run_loop(client: httpx.AsyncClient) -> None:
try:
while True:
batch = await _collect_batch()
dropped = take_dropped()
if dropped:
logger.warning("observe dropped %d events (queue full)", dropped)
if not batch:
continue
try:
await _post_batch(client, batch)
except Exception: # noqa: BLE001 - best-effort 遥测,失败丢批不重试、不退出
logger.warning(
"observe post batch failed, dropped %d events",
len(batch),
exc_info=True,
)
except asyncio.CancelledError:
logger.info("observe worker stopped")
raise
def start_observe_worker() -> asyncio.Task | None:
"""启动上报 worker。未配置观测 → 返回 None(no-op)。"""
global _client
if not settings.observe_configured:
return None
_client = httpx.AsyncClient(
base_url=settings.OBSERVE_ENDPOINT,
auth=(settings.OBSERVE_USER, settings.OBSERVE_PASSWORD),
timeout=settings.OBSERVE_TIMEOUT_SEC,
)
logger.info(
"observe worker started endpoint=%s org=%s stream=%s",
settings.OBSERVE_ENDPOINT,
settings.OBSERVE_ORG,
settings.OBSERVE_STREAM,
)
return asyncio.create_task(_run_loop(_client), name="observe-worker")
async def stop_observe_worker(task: asyncio.Task | None) -> None:
"""收尾:cancel worker → best-effort 发最后一批 → 关 client。"""
global _client
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
if _client is not None:
# worker 已停,安全 drain 剩余并 best-effort 发最后一批(短超时,不卡关停)。
try:
queue = get_queue()
final: list[dict] = []
while len(final) < settings.OBSERVE_BATCH_MAX:
try:
final.append(queue.get_nowait())
except asyncio.QueueEmpty:
break
if final:
await asyncio.wait_for(
_post_batch(_client, final), timeout=settings.OBSERVE_TIMEOUT_SEC
)
except Exception: # noqa: BLE001 - 关停期尽力而为,失败忽略
pass
await _client.aclose()
_client = None
```
- [ ] **Step 4: 运行,确认通过**
Run: `pytest tests/test_observe.py -q -k "collect_batch or post_batch or start_observe"`
Expected: PASS
- [ ] **Step 5: 提交**
```bash
git add app/core/observe_worker.py tests/test_observe.py
git commit -m "feat(observe): 加后台批量上报 worker(失败丢批不重试)"
```
---
## Task 5: 接线到 `app/main.py`(挂中间件 + lifespan 启停)
**Files:**
- Modify: `app/main.py`import 区、lifespan、CORS 之后)
- Test: `tests/test_observe.py`(追加)
- [ ] **Step 1: 写失败测试**
`tests/test_observe.py` 追加:
```python
def test_app_has_metrics_middleware():
from app.main import app
names = [m.cls.__name__ for m in app.user_middleware]
assert "RequestMetricsMiddleware" in names
```
- [ ] **Step 2: 运行,确认失败**
Run: `pytest tests/test_observe.py::test_app_has_metrics_middleware -q`
Expected: FAIL —— 断言失败(中间件尚未挂载)
- [ ] **Step 3: 实现接线**
3a. 在 `app/main.py` import 区(`withdraw_reconcile_worker` import 块之后)加:
```python
from app.core.observe import RequestMetricsMiddleware
from app.core.observe_worker import (
start_observe_worker,
stop_observe_worker,
)
```
3b. lifespan 里加启停(现有 `daily_exchange_task = start_daily_exchange_worker()` 之后、`try:` 之前加一行;`finally` 里在 `stop_daily_exchange_worker` 之后加一行):
```python
daily_exchange_task = start_daily_exchange_worker()
observe_task = start_observe_worker()
try:
yield
finally:
await stop_heartbeat_monitor(heartbeat_task)
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
await stop_observe_worker(observe_task)
await aclose_pricebot_client()
logger.info("shutting down")
```
3c. 挂中间件——在 CORS 的 `if settings.cors_origins_list:` 整块之后加(使其成为最外层,测到含 CORS 的完整耗时):
```python
# 接口指标埋点(最外层:测含 CORS 的完整耗时)。未配置观测时中间件自 no-op。
app.add_middleware(RequestMetricsMiddleware)
```
- [ ] **Step 4: 运行,确认通过**
Run: `pytest tests/test_observe.py::test_app_has_metrics_middleware -q`
Expected: PASS
- [ ] **Step 5: 跑整套 observe 测试 + 全量回归,确认无破坏**
Run: `pytest tests/test_observe.py -q && pytest -q`
Expected: 全 PASS(现有用例不受影响:conftest 未设 `OBSERVE_*` → 观测关 → worker no-op、中间件透传)
- [ ] **Step 6: 提交**
```bash
git add app/main.py tests/test_observe.py
git commit -m "feat(observe): main.py 挂中间件 + lifespan 启停上报 worker"
```
---
## Task 6: OpenObserve 本地部署(compose + README + .env.example
**Files:**
- Create: `deploy/openobserve/docker-compose.yml`
- Create: `deploy/openobserve/README.md`
- Modify: `.env.example`(追加 `OBSERVE_*` 段)
- [ ] **Step 1: 写 docker-compose**
新建 `deploy/openobserve/docker-compose.yml`
```yaml
# 本地开发用 OpenObserve(单容器 = local 模式)。用于接收 app-server 的接口指标。
# 启动: cd deploy/openobserve && docker compose up -d
# Web UI: http://localhost:5080 (账号见下方 env)
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
container_name: openobserve
ports:
- "5080:5080"
environment:
ZO_ROOT_USER_EMAIL: "admin@shaguabijia.local"
ZO_ROOT_USER_PASSWORD: "Complexpass#123"
ZO_DATA_DIR: "/data"
volumes:
- ./data:/data
restart: unless-stopped
```
- [ ] **Step 2: 写 README**
新建 `deploy/openobserve/README.md`
````markdown
# OpenObserve 本地部署(接口 QPS / 耗时可观测)
app-server 通过中间件采集每个接口的 QPS + 耗时 + 错误率,批量上报到这里。
## 启动
```bash
cd deploy/openobserve
docker compose up -d
```
- Web UIhttp://localhost:5080
- 登录:`admin@shaguabijia.local` / `Complexpass#123`(见 `docker-compose.yml`
- 数据落 `deploy/openobserve/data/`(已挂卷持久化;`data/` 建议 gitignore
## 让 app-server 上报
在项目根的 `.env` 打开观测(`OBSERVE_*`,账号密码与 compose 里 root 一致):
```dotenv
OBSERVE_ENABLED=true
OBSERVE_ENDPOINT=http://localhost:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=admin@shaguabijia.local
OBSERVE_PASSWORD=Complexpass#123
```
重启 app-server,随便打几个接口。stream `app_requests` **首次上报自动创建**
在 UI 的 Logs → 选 `app_requests` 就能看到逐条请求事件。
## 查询(Logs 页 SQL,或建 Dashboard 面板)
各接口 QPS(1 分钟分桶,面板里再除 60 得每秒):
```sql
SELECT route, histogram(_timestamp, '1 minute') AS ts, count(*) AS cnt
FROM app_requests GROUP BY route, ts ORDER BY ts
```
各接口 P95 耗时(毫秒):
```sql
SELECT route, approx_percentile_cont(duration_ms, 0.95) AS p95_ms
FROM app_requests GROUP BY route ORDER BY p95_ms DESC
```
各接口错误率(5xx 占比):
```sql
SELECT route,
count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) AS err_pct
FROM app_requests GROUP BY route ORDER BY err_pct DESC
```
## 停止 / 清数据
```bash
docker compose down # 停止(保留数据)
docker compose down -v && rm -rf data # 停止并清空数据
```
> 生产部署(持久化规格、独立 ingest 账号、鉴权收紧)见 spec 第 9 节,本期不做。
````
- [ ] **Step 3: 追加 `.env.example`**
在 `.env.example` 末尾追加:
```dotenv
# ===== 可观测(OpenObserve 接口指标)=====
# 采集每个接口 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker,见 deploy/openobserve/)。
# 默认关;开启需 ENABLED=true 且填 USER/PASSWORD(与 docker-compose 里 root 账号一致)。
OBSERVE_ENABLED=false
OBSERVE_ENDPOINT=http://localhost:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=admin@shaguabijia.local
OBSERVE_PASSWORD=Complexpass#123
# 进阶(一般不用改):攒批间隔秒 / 单批最大条数 / 有界队列上限(满则丢) / 上报超时秒
OBSERVE_FLUSH_INTERVAL_SEC=5
OBSERVE_BATCH_MAX=200
OBSERVE_QUEUE_MAX=10000
OBSERVE_TIMEOUT_SEC=5
```
- [ ] **Step 4: 校验 compose 语法(不需真拉镜像)**
Run: `docker compose -f deploy/openobserve/docker-compose.yml config`
Expected: 打印规整后的配置、无报错(若本机无 docker,可跳过,标注为手动验证项)
- [ ] **Step 5: 提交**
```bash
git add deploy/openobserve/docker-compose.yml deploy/openobserve/README.md .env.example
git commit -m "feat(observe): 加 OpenObserve 本地 compose + README + .env.example"
```
---
## Task 7: 端到端手动验证 + 全量 lint/test 收尾
**Files:** 无(验证 + 收尾)
- [ ] **Step 1: 起 OpenObserve**
Run: `cd deploy/openobserve && docker compose up -d`
Expected: 容器起来,浏览器打开 http://localhost:5080 能登录
- [ ] **Step 2: 本地开观测起 app-server**
在根 `.env` 设 `OBSERVE_ENABLED=true` + `OBSERVE_USER/PASSWORD`(同 compose),然后:
Run: `./run.sh`Windows 用 `python -m uvicorn app.main:app --port 8770 --reload --reload-dir app`
Expected: 启动日志出现 `observe worker started endpoint=http://localhost:5080 ...`
- [ ] **Step 3: 打几个接口产生数据**
Run: `curl http://localhost:8770/health && curl http://localhost:8770/things-does-not-exist -i`(或正常业务接口若干)
Expected: 稍等 ≤5sflush 间隔),OpenObserve UI 的 Logs → `app_requests` 出现事件;`/health` 不应出现;不存在的路径 route 为 `__unmatched__`
- [ ] **Step 4: 验证三条查询**
在 OpenObserve UI 分别粘贴 README 里的 QPS / P95 / 错误率 SQL,确认能出数。
- [ ] **Step 5: lint(仅本改动涉及文件)+ 全量测试**
> 说明:仓库基线有 ~558 个既有 ruff 错误、且未强制 ruff 通过。不要去清历史欠债(范围蔓延)。只要求**本次新增/改动的文件**零 ruff 错误。
Run: `ruff check app/core/observe.py app/core/observe_worker.py tests/test_observe.py && python -m pytest -q`
Expected: 上述三个新文件 ruff 无错;测试里 `tests/test_observe.py` 全 PASS,且**全量失败数不超过基线的 4 个**(test_compare_proxy ×2 / test_coupon_proxy ×1 / test_invite ×1,均与本功能无关)。
额外确认我对既有文件的改动没有引入**新的** ruff 错误:`ruff check app/core/config.py app/main.py`(数量应与基线一致,不因本改动增加)。
- [ ] **Step 6: 关观测复跑一次,确认降级**
把 `.env` 的 `OBSERVE_ENABLED` 改回 `false``ruff check .` 不涉及,直接 `pytest -q`
Expected: 全 PASS(验证 observe 关闭时零副作用)
- [ ] **Step 7: 收尾提交(如有 .env 之外的改动)**
```bash
git add -A
git commit -m "chore(observe): 端到端验证与收尾" --allow-empty
```
> `.env` 不入 git(已 gitignore);本任务只验证,不提交 `.env`。
---
## Self-Review(写完计划后自查)
- **Spec 覆盖**Docker 部署→Task 6/7;事件 schema→Task 3`record_event` 事件字段);中间件→Task 3worker→Task 4;配置→Task 1main 接线→Task 5;查询/仪表盘→Task 6 README;测试→Task 1-5;决策(a)队列满丢→Task 2(b)失败不重试→Task 4(c)跳过 /health→Task 3。全覆盖。
- **占位符**:无 TBD/TODO;每个代码步骤含完整代码。
- **类型/命名一致**`get_queue` / `record_event` / `take_dropped` / `RequestMetricsMiddleware` / `start_observe_worker` / `stop_observe_worker` / `observe_configured` / 事件字段名,跨 Task 1-5 与文件结构表一致。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,236 @@
# 接口 QPS + 耗时可观测(OpenObserve)设计
- **日期**2026-07-06
- **状态**:已评审通过,待写实现计划
- **范围**:仅 app-server8770);admin8771)暂不接入
- **方案**:A —— 轻量自研 ASGI 中间件 + 后台 worker 批量直采到 OpenObserve
## 1. 背景与目标
app-server 目前除 CORS 外无任何中间件,也无接口级可观测。需要按**每个接口**采集:
- **QPS**(每秒请求数,可按接口/时间分桶)
- **耗时**P50/P95/P99 等分位)
顺带低成本拿到**错误率**`status >= 500` 占比)。落地目标是:本地 Docker 跑一个 OpenObserve 实例接收数据,服务侧加埋点上报,在 OpenObserve 仪表盘上看各接口 QPS + 耗时。
### 非目标(YAGNI
- 不做分布式 trace / span 关联(只要接口聚合指标)。
- 不引入 OpenTelemetry / Prometheus 客户端等重依赖。
- 不采集请求体 / query / 用户身份等,任何 PII 都不进上报。
- admin(8771)本期不接(中间件写成可复用,未来一行挂载即可)。
- 上报失败不做持久化重试 / 落盘补偿(best-effort)。
## 2. 方案选型
对比过三条路(详见评审记录):
- **A 轻量自研中间件 + JSON 直采**(选中):零新依赖(`httpx` 已在依赖里),完全贴合本仓库「后台 worker + JSON 事件 + `*_configured` 优雅降级」的既有习惯,恰好满足「每接口 QPS + 耗时 + 错误率」并保留原始事件下钻能力。
- B OpenTelemetry 自动埋点 + OTLP:行业标准、顺带 trace,但多 5–6 个依赖、概念多、数据量/成本高于需求,与精简代码库风格相悖。
- C Prometheus 进程内聚合 + remote_write/抓取:数据量最小,但 remote_write 编码复杂或需额外抓取进程,丢失单请求下钻,最不贴合 OpenObserve 的 log-first 强项。
**结论:A。**
## 3. 架构与数据流
```
每个 HTTP 请求
→ RequestMetricsMiddleware(最外层:测总耗时 / 抓路由模板 + 状态码)
→ record_event() 非阻塞入队(有界队列,满则丢最旧,绝不阻塞、绝不 OOM)
→ observe_worker(后台 asyncio.Task,随 lifespan 启停)批量 drain
→ httpx POST {ENDPOINT}/api/{ORG}/{STREAM}/_json → OpenObserve
→ 仪表盘 SQL 聚合出 QPS / 分位耗时 / 错误率
```
**核心不变量**
1. 请求路径上只做「测时 + 构建一个小 dict + `put_nowait`」,**无任何网络/磁盘 I/O**。
2. 所有上报 I/O 在后台 worker;worker 捕获全部异常,绝不让埋点影响请求。
3. 未配置观测(`observe_configured=False`)→ 中间件透传、worker 不启动,整套 no-op。
4. OpenObserve 不可用 → 队列填满后丢弃事件 + 限流告警,业务零影响。
## 4. 组件设计
### 4.1 OpenObserve 本地部署 —— `deploy/openobserve/docker-compose.yml`(新增)
```yaml
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
container_name: openobserve
ports: ["5080:5080"]
environment:
ZO_ROOT_USER_EMAIL: "admin@shaguabijia.local"
ZO_ROOT_USER_PASSWORD: "Complexpass#123"
ZO_DATA_DIR: "/data"
volumes: ["./data:/data"]
restart: unless-stopped
```
- `docker compose up -d` 启动;Web UI `http://localhost:5080`,用上面邮箱/密码登录。
- 单容器 = local 模式,数据落 `./data`(已挂卷持久化)。
- **stream 首次上报自动创建**,无需预建 `app_requests`
- 上报鉴权:HTTP Basic auth`email:password`),本地直接用 root 账号;生产应另建仅具 ingest 权限的用户/服务账号(本期不涉及)。
### 4.2 事件 schema(一请求一行 JSON
```json
{
"_timestamp": 1720000000000000, // 微秒(µs)整数,请求完成时刻。OpenObserve 默认时间列 _timestamp 以微秒计
"service": "app-server", // 取 LOG_SERVICE_NAME / 固定值
"env": "dev", // settings.APP_ENV
"method": "POST",
"route": "/api/v1/coupon/step", // 路由模板(非实际 path)
"status": 200,
"duration_ms": 42.7 // float 毫秒
}
```
- **只存路由模板**(如 `/c/{code}``/media` 静态归一),避免 path 参数把维度打爆。
- 未匹配路由(404 / 扫描器)归一到常量 `__unmatched__`
- 只采 method / route / status / duration —— 无 body、无 query、无 PII。
### 4.3 埋点中间件 —— `app/core/observe.py`(新增)
**纯 ASGI 中间件**(比 `BaseHTTPMiddleware` 开销低;能可靠读到路由与最终状态码;scope 按引用透传,内层 router 的 `scope["route"]` 外层可见)。
职责:
1. 非 `http` 请求、或 `not settings.observe_configured` → 直接透传,不测。
2. `perf_counter()` 记起点;包一层 `send``http.response.start``status`(默认兜底 500,覆盖下游抛异常未产出 response 的情况)。
3. `finally` 里算 `duration_ms`,从 `scope` 取路由模板(见下),构建事件,调 `record_event()`
4. 跳过路径集合 `_SKIP_PATHS = {"/health"}`(纯噪音)。
**路由模板解析(跨 Starlette 版本稳健)**
```python
route = scope.get("route")
template = getattr(route, "path", None)
if template is None: # 未匹配 / 老版本未写 scope["route"]
template = "__unmatched__"
```
(若实测某 Starlette 版本不写 `scope["route"]`,回退用 `request.app.router.routes` 逐个 `route.matches(scope)==Match.FULL` 找模板;实现时以实际版本为准,优先 `scope["route"]`。)
**入队(`record_event`**:模块级 `asyncio.Queue(maxsize=OBSERVE_QUEUE_MAX)`。用 `put_nowait``QueueFull` 则丢弃并累加一个 `_dropped` 计数(每累计 N 条限流打一条 WARNING)。**永不 `await put()`、永不阻塞请求**。
> 决策(a):队列满 → **丢弃**(不阻塞请求)。
### 4.4 上报 worker —— `app/core/observe_worker.py`(新增)
对齐现有 `heartbeat_monitor_worker.py` / `daily_exchange_worker.py` / `withdraw_reconcile_worker.py``start_*` / `stop_*` 形态。
- `start_observe_worker() -> asyncio.Task | None`
- `not settings.observe_configured` → 返回 `None`no-op)。
- 否则建专用 `httpx.AsyncClient``base_url=ENDPOINT``auth=(USER, PASSWORD)``timeout=OBSERVE_TIMEOUT_SEC`),起 `_run_loop` task。
- `_run_loop()`:循环
1. `_collect_batch()``await asyncio.wait_for(queue.get(), timeout=FLUSH_INTERVAL)` 拿到首条(超时且空 → 返回空,continue);再 `get_nowait()` 连抽到 `BATCH_MAX` 条或抽空。
2. `POST /api/{ORG}/{STREAM}/_json`body 为事件数组。
3. **catch 所有异常**:失败限流打 WARNING,**直接丢弃该批,不重试**。
- `stop_observe_worker(task)`best-effort 收尾 flush(短超时)→ `task.cancel()``await`(吞 `CancelledError`)→ 关 client。
> 决策(b):上报失败 → **直接丢弃,不重试**best-effort 遥测)。
### 4.5 配置 —— `app/core/config.py`(改)
新增一段 `# ===== 可观测(OpenObserve 接口指标)=====`,默认全关(prod 安全):
| 配置 | 默认 | 说明 |
|---|---|---|
| `OBSERVE_ENABLED` | `False` | 总开关;默认关,opt-in |
| `OBSERVE_ENDPOINT` | `http://localhost:5080` | OpenObserve base URL |
| `OBSERVE_ORG` | `default` | 组织名 |
| `OBSERVE_STREAM` | `app_requests` | stream 名 |
| `OBSERVE_USER` | `""` | Basic auth 邮箱 |
| `OBSERVE_PASSWORD` | `""` | Basic auth 密码/token |
| `OBSERVE_FLUSH_INTERVAL_SEC` | `5.0` | worker 最长攒批间隔 |
| `OBSERVE_BATCH_MAX` | `200` | 单批最大事件数 |
| `OBSERVE_QUEUE_MAX` | `10000` | 有界队列上限,满则丢 |
| `OBSERVE_TIMEOUT_SEC` | `5.0` | 上报 HTTP 超时 |
```python
@property
def observe_configured(self) -> bool:
return bool(self.OBSERVE_ENABLED and self.OBSERVE_ENDPOINT
and self.OBSERVE_USER and self.OBSERVE_PASSWORD)
```
`.env.example` 同步补一段带注释的 `OBSERVE_*`(沿用该文件重注释风格),`OBSERVE_ENABLED=false`
### 4.6 接线 —— `app/main.py`(改)
- import `RequestMetricsMiddleware``start_observe_worker` / `stop_observe_worker`
- `app.add_middleware(RequestMetricsMiddleware)`:放在 CORS `add_middleware` **之后** → 成为最外层,测到含 CORS 的完整耗时。无条件挂载(内部自 no-op)。
- `lifespan`:启动 `observe_task = start_observe_worker()``finally``await stop_observe_worker(observe_task)`,与现有 worker 并列。
### 4.7 OpenObserve 查询 / 仪表盘 —— `deploy/openobserve/README.md`(新增)
含:compose 启停、登录、stream 自动创建说明、`.env` 接线,以及可直接粘的示例 SQL
- **各接口 QPS**1 分钟分桶):
```sql
SELECT route, histogram(_timestamp, '1 minute') AS ts, count(*) AS cnt
FROM app_requests GROUP BY route, ts ORDER BY ts
```
(面板按 `cnt/60` 展示每秒;或用 OpenObserve 图表的 rate 能力。)
- **各接口 P95 耗时**
```sql
SELECT route, approx_percentile_cont(duration_ms, 0.95) AS p95_ms
FROM app_requests GROUP BY route ORDER BY p95_ms DESC
```
- **各接口错误率**
```sql
SELECT route,
count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) AS err_pct
FROM app_requests GROUP BY route ORDER BY err_pct DESC
```
## 5. 关键设计决策汇总
- **(a) 队列满 → 丢弃**(不阻塞请求):遥测让路于业务可用性。
- **(b) 上报失败 → 不重试**best-effort;避免 poison batch 堆积与队列无限增长。
- **(c) 跳过 `/health`**:健康检查是纯噪音,硬编码在 `_SKIP_PATHS`
- **只存路由模板 + `__unmatched__`**:防维度爆炸。
- **默认 OFF、opt-in**:prod 安全默认;开启后仍全异步 + 有界。
- **纯 ASGI 中间件 + `perf_counter`**:请求路径开销微秒级,无 I/O。
## 6. 安全 / 性能保证
- 请求路径新增开销 ≈ 一次 `perf_counter` 差 + 一个小 dict + 一次 `put_nowait`(微秒级),无锁竞争的显著热点。
- 失败隔离:入队丢弃 + worker 全异常捕获;OpenObserve 宕机不影响任何请求。
- 有界内存:队列 `maxsize` 封顶,最坏丢事件不涨内存。
- 无 PII:仅 method / route / status / duration。
## 7. 测试策略 —— `tests/test_observe.py`(新增)
沿用仓库约定(`TestClient` + `monkeypatch`,绝不打真网络;`conftest` 在 import 前设 env):
1. 埋点入队字段正确:模板路由、`status``duration_ms > 0`
2. 参数化路由 → 取到**模板**而非实际 path。
3. 未匹配路径(404)→ `route == "__unmatched__"`
4. `OBSERVE_ENABLED=false` → 零入队、零 HTTP(现有测试不受影响)。
5. 队列满 → `record_event` 不抛异常(走丢弃分支)。
6. worker 批量 POST 的 URL / payload 正确(monkeypatch httpx client / `_post`,不打网络)。
7. `/health` 被跳过 → 不入队。
> `settings``lru_cache` 单例;需要开启观测的用例通过 monkeypatch `settings` 属性或直接调 `record_event` / 中间件并 patch `observe_configured` 实现,避免全局 env 改动波及他用例。
## 8. 文件清单
| 文件 | 动作 |
|---|---|
| `deploy/openobserve/docker-compose.yml` | 新增(OpenObserve 容器)|
| `deploy/openobserve/README.md` | 新增(部署步骤 + 查询/仪表盘)|
| `app/core/observe.py` | 新增(中间件 + 有界队列 + `record_event` + 路由解析)|
| `app/core/observe_worker.py` | 新增(后台批量上报 worker)|
| `app/core/config.py` | 改(`OBSERVE_*` + `observe_configured`|
| `app/main.py` | 改(挂中间件 + lifespan 启停 worker|
| `.env.example` | 改(新增 `OBSERVE_*` 注释段)|
| `tests/test_observe.py` | 新增 |
## 9. 未来工作(本期不做)
- admin(8771)接入同一套中间件(`service` 字段区分)。
- 生产部署 OpenObserve(持久化、独立 ingest 账号、资源规格、鉴权收紧)。
- 上报字段扩展(如按 user/设备维度、上游 pricebot 透传耗时拆分)。
@@ -0,0 +1,296 @@
# 15 天不活跃自动清零(金币 + 现金)设计
- **日期**2026-07-16
- **状态**Draft — 待评审
- **所属**app-server`app/`),含一处 admin 侧重构 + 一项 Android 端埋点依赖
- **一句话**:连续 15 天不活跃的用户,自动清零其金币与现金;清零前按可配置节奏预警;全过程留审计以备纠纷排查。
---
## 1. 背景与目标
运营需要对**长期不活跃**用户的钱包余额做清理。两条硬性要求:
1. **可审计**:记录清零原因与**清零前的三桶余额**,便于后续排查与处理客户纠纷。
2. **临清预警**:在临近清零前推送信息告知用户"因账号不活跃,账户里的 xx 金币和 xx 现金将被清零"。
### 非目标(本期不做)
- 不做真实推送通道(极光 JPush / 短信)的对接 —— 仅做**可插拔通知器 + 日志占位**,接口预留、后续无缝替换。
- 不改动提现(`WithdrawOrder`)流程。
- 不新增 `User.last_active_at` 列、不改鉴权热路径。
---
## 2. 需求
| # | 需求 | 落地 |
|---|---|---|
| R1 | 连续 15 天不活跃 → 清零金币 + 现金 | 每日 worker 扫描 + 逐用户事务清零(§6) |
| R2 | 记录清零原因 + 清零前余额 | `inactivity_reset_log` 审计表 + 3 条钱包流水(§5、§7) |
| R3 | 临清前预警"xx 金币 xx 现金将清零" | 阶段 A 预警 + `inactivity_notification_log`(§6、§7 |
| R4 | 活跃口径与"用户管理"一致 | 抽共享模块 `activity.py`admin 与 worker 共用(§4、§12 |
| R5 | 预警时机完全可配置 | `INACTIVITY_*` 配置项(§8 |
---
## 3. 决策记录(来自评审问答)
| 决策点 | 结论 | 理由 |
|---|---|---|
| **活跃口径** | 与"用户管理"一致:`max(首页可见 show/home, 比价, 领券)`**不含 last_login_at**;无任何信号时以 `created_at` 为非空基线 | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除 |
| **"进首页"信号落地** | **方案 A:前端上报 `home_view` 埋点**(复用 `/analytics/events`),非新接口 | 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度 |
| **清零范围** | **金币 + 折算现金**(**邀请现金不清**——产品红线,仅快照入审计) | 对应"账户里的金币和现金";邀请奖励金与金币现金物理隔离、不可累加,见 `wallet.CoinAccount` 注释 |
| **预警推送** | **可插拔通知器 + 日志占位**v1),后续接 JPush/短信 | 现状无真实推送能力;先把清零主流程 + 审计做扎实,不阻塞 |
| **预警时机** | **完全可配置**(提前天数列表 + 次数 + 执行点 + 通道) | R5 |
| **触发方式** | **进程内每日 worker**,仿 `daily_exchange_worker` | 与项目最新模式一致,无需外部 cron |
| **admin 共享口径** | 共享模块 + **重构 admin 改用它** | 单一真源,永不漂移(R4 |
### 已知取舍(可接受)
- analytics 的 `user_id` 是**端上报、未鉴权**(可伪造)。但伪造只能"保自己活跃、避免被清",无收益,且正是本功能要防的行为,风险良性。活跃时间的非空基线由服务端权威的 `User.created_at` 提供(见 §4),不再依赖 `last_login_at`。与"用户管理"口径一致。
---
## 4. 活跃口径与共享模块 `app/repositories/activity.py`(新建)
活跃口径的**唯一真源**。app 侧模块,admin 可 import`app.main` 不 import `app.admin`,反向允许)。
### 口径
```
last_active = max(
User.created_at, # 注册基线(恒非空;re-login 不推进,只有真实使用才推进)
max AnalyticsEvent.created_at WHERE event IN ACTIVE_EVENTS,
max CouponPromptEngagement.created_at WHERE engage_type == "claim_started",
)
不活跃判定:按北京自然日、0 点对齐(非从末次活跃时刻滚动 15×24h)
last_active_date = 北京(last_active).date() # 末次活跃的北京日,记为「第 1 日」
清零边界 = 北京 00:00 of (last_active_date + RESET_DAYS 天) =「第 (RESET_DAYS+1) 日 0 点」 # 15 → 第16日0点
应清零 ⟺ (cn_today() last_active_date).days ≥ RESET_DAYS
⟺ last_active < cutoff cutoff = 北京 00:00 of (cn_today() (RESET_DAYS1)) # 供 SQL 比较
inactive_days = (cn_today() last_active_date).days # 清零当日恰 = RESET_DAYS
例:末次活跃 1/1 → 1/16 00:00(第16日0点)清零,当日 inactive_days=151/15 及之前不清
```
### 模块内容
- 常量:
- **首页可见活跃信号已定名:`event=show` + `page=home`**(前端确认,原占位 `home_view`;下文出现的 `home_view` 均指此信号)。活跃行为过滤见 `activity.active_event_condition()`:首页可见 比价 `real_compare_start` 领券 `real_coupon_start``ACTIVE_EVENTS` 仅含后两个纯 event 名(首页可见是 event+page 组合、单列)。
- `ACTIVE_ENGAGE_TYPE = "claim_started"`
- `last_active_subqueries(db)` —— 复刻现 admin `queries._last_active_parts()`:两个按 `user_id``GROUP BY max(created_at)` 聚合子查询。
- `last_active_expr(base_col, ev_sub, eng_sub, dialect)` —— 生成 `greatest`/`max`PG `func.greatest`SQLite `func.max`);子聚合缺失时 `coalesce(子聚合, User.created_at)` 兜底(注册基线恒非空,**替代原 last_login_at**)。
- `_norm_utc()` —— 沿用现 admin 的 naive→UTC 归一(SQLite naive / PG aware 混算保护)。
- `reset_cutoff(reset_days)` / `warn_cutoff(reset_days, k)` —— 生成**北京 0 点对齐**的边界 datetime(见口径):`reset_cutoff = 北京 00:00 of (cn_today() (reset_days1))`,供下面查询按 `last_active < cutoff` 比较。
- `select_inactive_users(db, *, cutoff, with_balance=True)` —— **worker 专用**join `CoinAccount`,筛 `last_active < cutoff`(cutoff = 北京 0 点对齐边界,见口径)且(`coin_balance>0 OR cash_balance_cents>0`;**邀请现金不清、不计入候选**),返回 `(user, account, last_active, inactive_days)`
- `select_warn_targets(db, *, reset_days, warn_days_before)` —— **worker 专用**:返回 `(user, account, last_active, inactive_days, stage)` 元组——各"提前天数"窗口内、有余额、本 streak 未推过档 `stage` 的用户(去重结合 `notification_log`,逻辑见 §9)。
> **参考现状**:现口径散落在 `app/admin/repositories/queries.py:38,91-124,199-204``_ACTIVE_EVENTS`/`_last_active_parts`/`greatest`)与 `app/admin/repositories/stats.py:51-52,138-146``COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集)。这些改为从 `activity.py` 导入(§12)。
---
## 5. 数据模型(2 张新表,不动 `User`
两表均登记进 `app/models/__init__.py`;一个 Alembic 迁移建两表(`render_as_batch`SQLite 兼容)。
### ① `inactivity_reset_log` —— 清零审计(R2
仿 `app/models/phone_rebind_log.py` 的简单审计表风格。
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int PK autoincrement | |
| `user_id` | int, index, not null | |
| `coin_balance_before` | int, not null | 清零前金币 |
| `cash_balance_cents_before` | int, not null | 清零前折算现金(分) |
| `invite_cash_balance_cents_before` | int, not null | 清零前邀请现金(分) |
| `last_active_at` | DateTime(tz), nullable | 判定时的最近活跃时间 |
| `inactive_days` | int, not null | 判定时不活跃天数 |
| `reason` | String(32), not null | 如 `"inactive_15d"` |
| `reset_at` | DateTime(tz), server_default now(), index, not null | 清零时刻 |
### ② `inactivity_notification_log` —— 预警记录 + 去重 + 占位 outboxR3
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int PK autoincrement | |
| `user_id` | int, index, not null | |
| `stage` | int, not null | 提前天数档(如 7 / 2 |
| `inactive_days` | int, not null | 推送时不活跃天数 |
| `coin_balance` | int, not null | 推送快照:告知用户的金币数 |
| `cash_balance_cents` | int, not null | 推送快照:折算现金 |
| `invite_cash_balance_cents` | int, not null | 推送快照:邀请现金 |
| `channel` | String(16), not null | `"log"` / `"jpush"` / `"sms"` |
| `status` | String(16), not null | `"placeholder"` / `"sent"` / `"failed"` |
| `created_at` | DateTime(tz), server_default now(), index, not null | 去重锚点(见 §9 |
> 备注:不新增 `User.last_active_at` 列,不改 `get_current_user`。活跃时间由 §4 口径**实时计算**。
---
## 6. 清零 worker `app/core/inactivity_reset_worker.py`(新建)
**完全仿 [`app/core/daily_exchange_worker.py`](../../../app/core/daily_exchange_worker.py)**App 启动自带 asyncio 任务,文件锁(`data/inactivity_reset.lock`)防同机多进程并发。**worker 常驻**;`INACTIVITY_RESET_ENABLED` 只决定是否**真清**:false(默认)= 只记审计名单、不动钱(dry-run),true = 真清。
### 调度
- 每 `INACTIVITY_RESET_CHECK_INTERVAL_SEC` 秒醒一次;`last_run: date` 守卫**北京日**,保证每日只跑一轮。
- 仅当 `cn_today() != last_run` 且当前北京小时 `>= INACTIVITY_RESET_RUN_HOUR` 时执行(启动补跑同 daily_exchange 语义)。
- **清零资格边界 = 第 16 日 0 点(北京,见 §4),与 worker 执行点解耦**worker 于当日 `RUN_HOUR`(默认 3 点)跑,把已过边界者一并清;若要严格 0 点触发可置 `RUN_HOUR=0`,但注意与 `daily_auto_exchange` 的 0 点任务错峰。
- lifespan 里 `start_inactivity_reset_worker()` / `stop_...`(仿 `start_daily_exchange_worker``app/main.py` 的接线)。
### 一轮 `run_once(db)` 两阶段(同一次运行、各自逐用户独立 commit)
**阶段 A — 预警**
```
for user, acc, last_active, inactive_days, stage in activity.select_warn_targets(...):
notifier.send_inactivity_warning(user, balances=snapshot(acc), stage=stage, days_until_reset=RESET_DAYS-inactive_days)
db.add(InactivityNotificationLog(..., channel=notifier.channel, status=notifier.last_status))
db.commit() # 逐条独立
```
**阶段 B — 清零**`biz_type="inactivity_reset"`
```
for user, acc, last_active, inactive_days in activity.select_inactive_users(db, cutoff=activity.reset_cutoff(RESET_DAYS)): # 北京 00:00 of (今天−(RESET_DAYS1))
try:
acc = wallet.get_or_create_account(db, user.id, commit=False, lock=True) # 行锁
before = (acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents)
if acc.coin_balance == 0 and acc.cash_balance_cents == 0: continue # 邀请现金不清,不算可清余额
log = InactivityResetLog(user_id=user.id, coin_balance_before=before[0],
cash_balance_cents_before=before[1], invite_cash_balance_cents_before=before[2], # 邀请现金仅快照
last_active_at=last_active, inactive_days=inactive_days, reason=f"inactive_{RESET_DAYS}d")
db.add(log); db.flush() # 拿 log.id 作 ref_id 交叉链接
if acc.coin_balance: wallet.grant_coins(db, user.id, -acc.coin_balance, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零")
if acc.cash_balance_cents: wallet.grant_cash(db, user.id, -acc.cash_balance_cents, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零")
# 邀请现金(invite_cash_balance_cents)不清:产品红线、两本账物理隔离,仅快照记入审计。
db.commit()
except SQLAlchemyError:
db.rollback(); stats["failed"] += 1
```
- `grant_*` 负数出账、`balance_after=0`、写**两条**流水(金币 + 折算现金;**邀请现金不清**);`grant_coins` 负数**不**动 `total_coin_earned`(历史累计保留)。
- 逐用户独立 commit:一个失败不影响其余。返回 `stats = {warned, warn_skipped, warn_failed, scanned, cleared, failed}``logger.info`。**预警逐用户 try/except 隔离、且预警整段异常也绝不阻塞清零**(清零是不可逆资金操作,不能被通知故障拖住)。
---
## 7. 预警与可插拔通知器
`app/integrations/notifier.py` 定义协议(外部投递属 integrations 层):
```python
class InactivityNotifier(Protocol):
channel: str # "log" / "jpush" / "sms"
last_status: str # "placeholder" / "sent" / "failed"
def send_inactivity_warning(self, user, *, balances, stage, days_until_reset) -> None: ...
```
- **v1 `LogNotifier`**`channel="log"`):`logger.warning("[inactivity-warn] user=%s coin=%s cash=%s invite=%s T-%s", ...)``last_status="placeholder"`。参照 `heartbeat_monitor_worker` 先例("本期先不接推送,用终端打印代替")。
- 未来 `JPushNotifier` / `SmsNotifier`:实现同协议即可替换,worker 不改。
- 选择:`INACTIVITY_NOTIFY_CHANNEL` → 工厂返回对应实现(未配到真实实现时回退 `LogNotifier`)。
- 预警文案数据来自快照 `balances`,满足 R3"告知 xx 金币 xx 现金"。
---
## 8. 配置项(`app/core/config.py`
```
INACTIVITY_RESET_ENABLED = False # false(默认)=只记审计名单(dry-run,不动钱);true=真清
INACTIVITY_RESET_DAYS = 15 # 不活跃阈值(天)
INACTIVITY_WARN_DAYS_BEFORE = "7,2" # 清零前几天各推一次;空串=不推。逗号分隔,降序解析
INACTIVITY_RESET_RUN_HOUR = 3 # 北京时间每日执行点(0-23)
INACTIVITY_NOTIFY_CHANNEL = "log" # log(占位) / jpush / sms
INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现有间隔常量)
```
- 清零范围(三桶)固定为常量,不做配置。
- `INACTIVITY_WARN_DAYS_BEFORE` 语义(`inactive_days` 为北京自然日,见 §4):档位 `k` ⟹ 当 `inactive_days >= RESET_DAYS-k``< RESET_DAYS` 且本 streak 未推过档 `k` 时预警,即在北京日 `last_active_date + (RESET_DAYSk)` 触发(漏跑某天时补发最紧急未推档,§9)。
- `INACTIVITY_RESET_RUN_HOUR` 只决定 worker 每日执行点,**不改变**"第 16 日 0 点"这一资格边界(§4/§6)。
---
## 9. 幂等与重新活跃
- **重新活跃自动退出**`inactive_days` 由 §4 口径**实时算**。用户一有 `home_view`/比价/领券(**登录本身不算**),`last_active` 前移,自动移出预警与清零队列。**无需**显式"重置标记"。
- **预警去重**`inactivity_notification_log` 中存在 `stage==k 且 created_at > last_active` 的行 ⟹ 本 streak 已推过档 `k`,不重推。用户回归后 `last_active` 前移,旧预警行自然"失效",开启新 streak。
- **清零幂等**:阶段 B 只处理三桶非全 0 者;清完 = 0,次日不再匹配。worker 重启 / 多次唤醒 / 补跑均安全,不产生重复清零或重复流水。
- **稳健补发**:worker 漏跑数天后,某用户可能同时满足多档;只补发**最紧急的未推档**(最小 `k`),避免一次刷屏。
---
## 10. 边界与安全
| 场景 | 处理 |
|---|---|
| 新用户 | `created_at` 作活跃基线(恒非空)→ 注册即"第 1 日活跃";注册后连续 15 天无 home_view/比价/领券 才清 |
| 在途提现 | 提现申请时现金已扣入 `WithdrawOrder`,当前余额已不含在途;只清当前余额、不动提现单。提现失败退款到已清账户 = 用户的钱,正常 |
| 与 `daily_auto_exchange` 并存 | 各自逐用户幂等;金币多已日结折现金,三桶全清正好覆盖 |
| 时区/日界 | 统一北京(`rewards.cn_today()`/`CN_TZ`);**清零/预警按北京自然日 0 点对齐**(末次活跃记为第 1 日 → 第 16 日 0 点清零,见 §4),非滚动 24h;流水 `created_at` 沿用北京 wall-clock naive |
| 误清防护 | worker 常驻默认 **dry-run**`ENABLED=false` 只记审计名单、不动钱);看准名单再置 `true` 真清(§13 |
---
## 11. 前端依赖:`home_view` 埋点(跨仓 — Android
- **Android 端**`shaguabijia-app-android`)需在**首页可见**`onResume`/Tab 切入)时,向现有 `POST /api/v1/analytics/events` 批量上报里加一条 `event=<首页可见事件名>`(名称明天加埋点时定,暂记 `"home_view"` 的事件,**携带登录后的 `user_id`**。
- 客户端按会话/前台去重即可(服务端只取 `max(created_at)`,多报无害)。
- **上线顺序依赖**`home_view` 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 `created_at` 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故**开真清(`ENABLED=true`)必须待 `home_view` 铺满后再开**(§13);dry-run 只记名单不动钱、可先开着看。
---
## 12. admin 重构范围与影响(R4
- `app/admin/repositories/queries.py`:删本地 `_ACTIVE_EVENTS`/`_last_active_parts()`,改用 `activity.py` 的常量与子查询构造;`list_users``greatest(...)` 排序/筛选、`_attach_last_active` 均改走共享构造器。
- `app/admin/repositories/stats.py``COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集(`:138-146`)改用共享常量与口径。
- **行为变化(预期内、需产品知会)**admin 的"最近活跃 / DAU"口径变化——**移除 `last_login_at`(登录不再计为活跃)、以 `created_at` 为基线、纳入 `home_view`**。net`home_view` 铺满后更准(真正把"开首页"算进活跃);铺满前"只登录不操作"的用户活跃度会下降。
- **回归底线**:现有 admin 用户列表 / stats 测试按新口径**更新预期**last_login_at 移除 + created_at 基线 + home_view 纳入);非活跃口径部分行为不变。
---
## 13. 灰度与上线顺序(安全优先)
1. **后端先行**:合入共享模块 + 两表 + worker + 通知器,`INACTIVITY_RESET_ENABLED=False`;活跃口径以 `created_at` 为非空基线、**不含 last_login_at**。
2. **Android 发版**:上报 `home_view`;观察 analytics 覆盖率。
3. **dry-run 灰度(默认即是)**`INACTIVITY_RESET_ENABLED=False` 时 worker 常驻只写审计名单(`reason=inactive_Nd_dryrun`)、不动钱、不预警;核对名单准确。
4. **开真清**:确认无误后置 `INACTIVITY_RESET_ENABLED=True`(转为真清 + 预警)。
5. **收尾/监控**:持续观察 `home_view` 覆盖率与预警/清零名单;发现"活跃却被判不活跃"的漏报即回查埋点覆盖(口径已不含 last_login_at,登录不再兜底)。
---
## 14. 测试计划
- **活跃口径(共享模块)**`home_view`/比价/领券 各单独命中都算活跃;**纯登录不算**;无信号用户以 `created_at` 计;`max` 取最新;naive/aware 混算不崩。
- **admin 回归**:用户列表 / stats 按新口径更新预期(移除 last_login_at + created_at 基线 + home_view)。
- **不活跃判定**`last_active` 分别 `<15d / =15d / >15d` × 有/无余额 的命中矩阵。
- **清零**:三桶归零;`inactivity_reset_log` 清前值正确;三条流水 `biz_type=inactivity_reset``balance_after=0``ref_id=log.id``total_coin_earned` 不变。
- **预警**:命中窗口调 notifier + 写 `notification_log`;同 streak 不重推;回归后 `last_active` 前移可再次预警;漏跑补发最紧急档。
- **worker**:常驻;`ENABLED=false` 走 dry-run(只记审计名单、不清、不预警);文件锁互斥;逐用户失败隔离(一个抛错不影响其余,`failed` 计数);重复跑幂等。
- **配置**`INACTIVITY_WARN_DAYS_BEFORE` 解析(含空串=不推);`RESET_DAYS`/`RUN_HOUR` 生效。
- 沿用 `tests/conftest.py`(临时 SQLite、`RATE_LIMIT_ENABLED=false`);外部通知 monkeypatch。
---
## 15. 未来工作
- 接真实 `JPushNotifier`(需用户级 `registration_id` 覆盖 + JPush push API/ `SmsNotifier`
- 如需 admin 后台可视化:不活跃/预警/清零名单与历史查询接口。
- 如量级增长导致每日 join 扫描变慢:再考虑物化 `last_active_at`(当前每日一次可接受)。
---
## 附:涉及文件清单
**新增**
- `app/repositories/activity.py` — 活跃口径唯一真源
- `app/models/inactivity_reset_log.py` — 审计表
- `app/models/inactivity_notification_log.py` — 预警/占位表
- `app/core/inactivity_reset_worker.py` — 每日 worker(仿 daily_exchange_worker
- `app/integrations/notifier.py` — 通知器协议 + `LogNotifier`(真实 JPush/短信后续同层扩展)
- `alembic/versions/<...>_add_inactivity_tables.py` — 建两表迁移
- `docs/database/inactivity_reset_log.md` / `inactivity_notification_log.md` — 表字典(随实现补)
- 对应 `tests/test_inactivity_reset.py`
**改动**
- `app/models/__init__.py` — 注册两模型
- `app/core/config.py``INACTIVITY_*` 配置
- `app/main.py` — lifespan 接线 start/stop worker
- `app/admin/repositories/queries.py``stats.py` — 改用 `activity.py`(§12
+5 -1
View File
@@ -38,4 +38,8 @@ if errorlevel 1 (
)
REM Long-running foreground process. Ctrl+C to stop.
"%PY%" -m uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload
REM --timeout-keep-alive 120: real-device debugging over `adb reverse` — uvicorn's default 5s
REM closes idle keep-alive connections, but the adb-reverse pipe doesn't propagate the close,
REM so okhttp reuses a dead connection and the next request fails with "unexpected end of
REM stream" (esp. login / message-center calls after an idle gap). Bump to 120s to avoid it.
"%PY%" -m uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload --timeout-keep-alive 120
+4 -1
View File
@@ -23,4 +23,7 @@ mkdir -p data # sqlite 文件所在目录
# --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
# --timeout-keep-alive 120:真机经 adb reverse 联调时,uvicorn 默认 5s 就关闭空闲 keep-alive
# 连接,但 adb reverse 管道不把关闭事件透传回设备侧 → okhttp 复用"已死"的连接、下一次请求
# 报 "unexpected end of stream"(尤其登录/消息中心等间隔较久的调用)。调大到 120s 规避。
exec "$PY" -m uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload --reload-dir app --timeout-keep-alive 120
+141
View File
@@ -0,0 +1,141 @@
"""直接触发「消息通知中心」真实推送链路,给指定用户(默认 11111111111)的已注册设备发 push。
用于**后台无法驱动**的事件联调(本环境:wxpay 未配 提现成功打不通提现单唯一约束
造不了多张待审单好友下单后台无入口)本脚本直接调 services/notification_events 的真实
下发函数,走的就是生产同一条链路: notification (站内消息) + 厂商直推(honor/huawei/
xiaomi/oppo/vivo)到该用户 device_liveness 里已注册的 push token
默认只发这 3 (后台驱动不了的):
#3 withdraw_success 提现到账
#4 withdraw_failed 提现失败,款项已退回
#12 invite_order_reward 好友下单奖励到账
可用 --types 指定;--types all 追加后台能驱动的 #9/#10/#11(注意:这几类的点击跳转 id 是假的,
仅验证推送到达手机,真实跳转请走后台审核流程)
.venv\\Scripts\\python.exe scripts\\fire_push_events.py # 3 类各 10 条
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --count 1 # 各 1 条(先小量验证通道)
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --types withdraw_failed --count 3
.venv\\Scripts\\python.exe scripts\\fire_push_events.py --types all --count 2
推送成败看输出里的 `shagua.vendor_push` 日志(push sent / push failed);2 台设备则每条各推 2
凭据缺失或 token 失效时 notification_events 只记日志不抛错(站内消息仍会落库)
"""
from __future__ import annotations
import argparse
import logging
import random
import sys
import uuid
from app.core.rewards import INVITE_COMPARE_REWARD_CENTS, PRICE_REPORT_REWARD_COINS
from app.db.session import SessionLocal, engine
from app.models.feedback import Feedback
from app.models.price_report import PriceReport
from app.models.wallet import WithdrawOrder
from app.repositories import device as device_repo
from app.repositories import user as user_repo
from app.services import notification_events
# SQL 回显静音;shagua.* 开到 INFO,好看到「push sent / push failed」结果
# dev 下 APP_DEBUG=true → engine echo=True,echo 走 InstanceLogger 直写、无视 logger level,只能关 echo 本身
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
engine.echo = False
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
DEFAULT_PHONE = "11111111111"
DEFAULT_TYPES = ["withdraw_success", "withdraw_failed", "invite_order_reward"]
ADMIN_DRIVEN = ["feedback_reward", "feedback_reply", "report_approved"] # --types all 追加
ALL_TYPES = DEFAULT_TYPES + ADMIN_DRIVEN
_FAIL_REASONS = [
"微信零钱未实名,款项已退回",
"收款账户异常,款项已退回",
"超出微信零钱收款限额,款项已退回",
]
def _fire_one(db, uid: int, type_key: str, i: int) -> None:
"""构造一条该类型的瞬态业务对象(不落业务表,只为给 notify 函数读字段),触发真实推送。"""
if type_key == "withdraw_success":
order = WithdrawOrder(user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=50, source="coin_cash")
notification_events.notify_withdraw_success(db, order)
elif type_key == "withdraw_failed":
order = WithdrawOrder(
user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=350, source="coin_cash",
fail_reason=random.choice(_FAIL_REASONS),
)
notification_events.notify_withdraw_failed(db, order)
elif type_key == "invite_order_reward":
# 假被邀请人 id(> 真实用户范围,避重):昵称回退「好友」。真实昵称请走 API 流程(见文末说明)。
fake_invitee = random.randint(900000, 999999)
notification_events.notify_invite_order_reward(
db, inviter_user_id=uid, invitee_user_id=fake_invitee, cash_cents=INVITE_COMPARE_REWARD_CENTS
)
elif type_key == "feedback_reward":
fb = Feedback(user_id=uid, content="(直发)", contact="", status="adopted",
reward_coins=300, admin_reply="感谢反馈,您说的问题已修复上线,金币请查收~")
fb.id = random.randint(900000, 999999)
notification_events.notify_feedback_reward(db, fb)
elif type_key == "feedback_reply":
fb = Feedback(user_id=uid, content="(直发)", contact="", status="rejected",
admin_reply="您的建议我们记录啦,会在后续版本评估~")
fb.id = random.randint(900000, 999999)
notification_events.notify_feedback_reply(db, fb)
elif type_key == "report_approved":
rep = PriceReport(
user_id=uid, reported_platform_id="jd", reported_platform_name="京东外卖",
reported_price_cents=8800, images=[], status="approved",
reward_coins=PRICE_REPORT_REWARD_COINS, store_name=f"测试火锅店{i:02d}",
)
rep.id = random.randint(900000, 999999)
notification_events.notify_report_approved(db, rep)
else:
raise SystemExit(f"未知类型: {type_key}(可选: {', '.join(ALL_TYPES)})")
def main() -> None:
parser = argparse.ArgumentParser(description="直接触发消息通知中心真实推送(后台驱动不了的事件用)")
parser.add_argument("--phone", default=DEFAULT_PHONE, help=f"目标用户手机号(默认 {DEFAULT_PHONE})")
parser.add_argument("--count", type=int, default=10, help="每类发多少条(默认 10)")
parser.add_argument(
"--types", default=",".join(DEFAULT_TYPES),
help=f"逗号分隔的类型;'all' = {', '.join(ALL_TYPES)}。默认 {', '.join(DEFAULT_TYPES)}",
)
args = parser.parse_args()
types = ALL_TYPES if args.types.strip() == "all" else [t.strip() for t in args.types.split(",") if t.strip()]
bad = [t for t in types if t not in ALL_TYPES]
if bad:
print(f"❌ 未知类型: {', '.join(bad)}(可选: {', '.join(ALL_TYPES)})")
return
db = SessionLocal()
try:
user = user_repo.get_user_by_phone(db, args.phone)
if user is None:
print(f"❌ 用户 {args.phone} 不存在。请先用该手机号在 App 登录一次再跑本脚本。")
return
uid = user.id
targets = device_repo.list_push_targets(db, user_id=uid)
print(f"目标用户 {args.phone}(id={uid});已注册推送设备 {len(targets)} 台:"
f"{[t.push_vendor for t in targets] or '无(手机收不到!先在 App 上报 push token)'}")
print(f"即将触发:{types},每类 {args.count} 条 → 共 {len(types) * args.count}\n")
for t in types:
print(f"── {t} ×{args.count} " + "" * 30)
for i in range(1, args.count + 1):
_fire_one(db, uid, t, i)
print(f"\n✅ 已触发完。站内消息已落 notification 表(用 {args.phone} 登录 App 可在消息中心看到);"
"\n 手机推送成败见上方 `shagua.vendor_push` 日志(push sent=成功 / push failed=失败)。")
finally:
db.close()
if __name__ == "__main__":
main()
+139
View File
@@ -0,0 +1,139 @@
"""人工验证用:按「金币/现金/邀请」排列组合 + 活跃/新用户对照,造一批账号。
用法(仓库根目录,venv 解释器):
.venv/Scripts/python.exe scripts/seed_inactivity_cases.py # 造号(会先清掉上次 vcase*)
.venv/Scripts/python.exe scripts/seed_inactivity_cases.py --clean # 只清理,不造
配合默认配置 INACTIVITY_RESET_DAYS=15 / INACTIVITY_WARN_DAYS_BEFORE=7,2 验证
造完把 worker 打开( README/对话里的 .env),启动服务即会在 RUN_HOUR 后跑一轮
worker 清零针对**库里所有**符合条件的用户,不止 vcase*dev 库里若有其它"老且有余额、
无近期活跃事件"的用户,也会被一起清。要干净验证建议用一个空/副本 dev 库。
"""
from __future__ import annotations
import os
import sys
from datetime import UTC, datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import delete, select # noqa: E402
from app.db.session import SessionLocal # noqa: E402
from app.models.analytics_event import AnalyticsEvent # noqa: E402
from app.models.inactivity import ( # noqa: E402
InactivityNotificationLog,
InactivityResetLog,
)
from app.models.user import User # noqa: E402
from app.models.wallet import ( # noqa: E402
CashTransaction,
CoinAccount,
CoinTransaction,
InviteCashTransaction,
)
from app.repositories import wallet as wallet_repo # noqa: E402
MARK = "vcase" # username 前缀,用于清理
# label, 创建于N天前, coin, cash, invite, 近期事件(N天前)or None, 预期
CASES = [
("1 三桶全有", 30, 100, 200, 300, None, "清 coin+cash;invite=300 保留;审计1行+2流水"),
("2 金币+现金", 30, 100, 200, 0, None, "清 coin+cash;审计1行+2流水"),
("3 金币+邀请", 30, 100, 0, 300, None, "清 coin;invite=300 保留;审计1行+1流水"),
("4 现金+邀请", 30, 0, 200, 300, None, "清 cash;invite=300 保留;审计1行+1流水"),
("5 只有金币", 30, 100, 0, 0, None, "清 coin;审计1行+1流水"),
("6 只有现金", 30, 0, 200, 0, None, "清 cash;审计1行+1流水"),
("7 只有邀请(红线)", 30, 0, 0, 300, None, "不选中/不清/无审计/无流水;invite=300 原封"),
("8 预警窗(10天)", 10, 50, 60, 70, None, "不清;发 T-7 预警;notification_log 1行;余额不动"),
("9 活跃兜底", 30, 100, 200, 300, 1, "昨日 home_view→last_active 近→不清不警"),
("10 新用户(3天)", 3, 100, 200, 0, None, "created_at 近→不清不警"),
]
def _mark_uids(db) -> list[int]:
return list(db.execute(select(User.id).where(User.username.like(f"{MARK}%"))).scalars())
def clean(db) -> int:
uids = _mark_uids(db)
if uids:
for model in (
InactivityResetLog, InactivityNotificationLog,
CoinTransaction, CashTransaction, InviteCashTransaction,
AnalyticsEvent, CoinAccount,
):
db.execute(delete(model).where(model.user_id.in_(uids)))
db.execute(delete(User).where(User.id.in_(uids)))
db.commit()
return len(uids)
def seed(db) -> None:
now = datetime.now(UTC)
print(f"{'#':>3} {'uid':>5} {'案例':<16} {'coin/cash/invite':<18} {'创建':<7} 预期")
for i, (label, days_ago, coin, cash, invite, ev_days, expected) in enumerate(CASES, 1):
u = User(
phone=f"seed_tmp_{i}", username=f"{MARK}{i}", status="active",
created_at=now - timedelta(days=days_ago),
last_login_at=now, # 登录很新——但登录不算活跃,清零该发生照发生
)
db.add(u)
db.flush() # 拿自增 id
u.phone = f"1{u.id:010d}" # 用全局唯一 id 拼 "100…" 段手机号,dev 库里绝不撞
acc = wallet_repo.get_or_create_account(db, u.id, commit=False)
acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents = coin, cash, invite
acc.total_coin_earned = coin
if ev_days is not None:
db.add(AnalyticsEvent( # 首页可见 = event=show + page=home
event="show", page="home", device_id=MARK, user_id=u.id,
client_ts=0, created_at=now - timedelta(days=ev_days),
))
db.flush()
print(f"{i:>3} {u.id:>5} {label:<16} {f'{coin}/{cash}/{invite}':<18} {f'{days_ago}天前':<7} {expected}")
db.commit()
def check(db) -> None:
"""worker 跑完后:打印每个 vcase 账号的当前三桶余额 + 是否有审计/预警行。"""
rows = db.execute(
select(User.id, User.username).where(User.username.like(f"{MARK}%")).order_by(User.id)
).all()
if not rows:
print("没有 vcase* 账号(先跑一次不带参数造号)")
return
print(f"{'uid':>5} {'账号':<8} {'coin/cash/invite(现在)':<24} {'审计':<5} 预警")
for uid, uname in rows:
acc = db.get(CoinAccount, uid)
bal = f"{acc.coin_balance}/{acc.cash_balance_cents}/{acc.invite_cash_balance_cents}" if acc else ""
has_reset = db.execute(
select(InactivityResetLog.id).where(InactivityResetLog.user_id == uid).limit(1)
).first()
stages = db.execute(
select(InactivityNotificationLog.stage).where(InactivityNotificationLog.user_id == uid)
).scalars().all()
warn = ",".join(f"T-{s}" for s in stages) if stages else ""
print(f"{uid:>5} {uname:<8} {bal:<24} {'' if has_reset else '':<5} {warn}")
def main() -> None:
db = SessionLocal()
try:
if "--check" in sys.argv:
check(db)
return
removed = clean(db)
if removed:
print(f"已清理上次 {removed}{MARK}* 账号")
if "--clean" in sys.argv:
return
seed(db)
print("\n造号完成。打开 worker(INACTIVITY_RESET_ENABLED=true, RUN_HOUR=17)后启动服务,"
"≥17:00 首个 tick 即跑一轮。验完 `--clean` 清理。")
finally:
db.close()
if __name__ == "__main__":
main()
+420
View File
@@ -0,0 +1,420 @@
"""给指定用户(默认手机号 11111111111)造一整套「消息通知中心」联调数据。
不只是 notification 本身,还把 13 种类型**点击后要跳转的落地页数据**一起造齐,保证每条都能点开看到真实内容:
notification 类型 点击落地 需要的业务数据(本脚本一并造)
reward_expiring/expired 赚钱页(tab) (金额在通知里,无需外部记录)
withdraw_success 无跳转,仅消红点
withdraw_failed 提现页(withdrawId) withdraw_order(failed 一单)
perm_*(4 ) 客户端权限检测弹窗 (纯客户端)
feedback_reply 我的反馈(feedbackId) feedback(rejected + 官方回复)
feedback_reward 我的反馈(feedbackId) feedback(adopted + 官方留言 + 奖励金币)
report_approved 我的爆料(reportId) price_report(approved + 截图 + 奖励)
invite_order_reward 邀请页 invite_relation + 好友 user(已完成比价)
invite_remind 邀请页(scrollTo) invite_relation + 好友 user(未完成)
配套还造:钱包余额 + 金币/现金/邀请奖励金流水(让赚钱页 / 金币明细 / 现金明细 / 邀请战绩都有内容)
幂等:每次先清掉该用户上一轮由本脚本造的全部数据(通知 + 上述业务记录 + mock 好友 + mock 截图)再重建
.venv\\Scripts\\python.exe scripts\\seed_mock_notifications.py
.venv\\Scripts\\python.exe scripts\\seed_mock_notifications.py --phone 11111111111
.venv\\Scripts\\python.exe scripts\\seed_mock_notifications.py --clean-only
时间口径按各域现有约定:notification.sent_at 用东八区( +08:00 下发);feedback / withdraw /
钱包流水 / 邀请关系用 naive UTC(= func.now() SQLite 的口径,与真实数据一致);price_report
naive 北京时间( report_repo.create_report 一致)
"""
from __future__ import annotations
import argparse
import struct
import sys
import uuid
import zlib
from datetime import datetime, timedelta, timezone
from pathlib import Path
from sqlalchemy import delete, select
from app.core import rewards
from app.core.config import settings
from app.db.session import SessionLocal
from app.models.feedback import Feedback
from app.models.invite import InviteRelation
from app.models.notification import Notification
from app.models.price_report import PriceReport
from app.models.user import User
from app.models.wallet import (
CashTransaction,
CoinAccount,
CoinTransaction,
InviteCashTransaction,
WithdrawOrder,
)
from app.repositories import notification as notif_repo
from app.repositories import user as user_repo
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8") # Windows GBK 控制台也能打印中文/¥
_CST = timezone(timedelta(hours=8))
DEFAULT_PHONE = "11111111111"
# mock 好友(被邀请人):固定手机号,便于幂等清理。(phone, 昵称, 是否已完成比价)
FRIEND_SPECS = [
("12000000001", "柚子", True), # 已完成 → 驱动 invite_order_reward,计入邀请战绩
("12000000003", "小美", True), # 已完成 → 让邀请列表 / 战绩更丰满
("12000000002", "阿泽", False), # 未完成 → 驱动 invite_remind(去催单)
]
FRIEND_PHONES = [p for p, _, _ in FRIEND_SPECS]
_REPORT_DIR = Path(settings.MEDIA_ROOT) / "price_report"
_MOCK_IMG_GLOB = "mock_notif_*.png" # 本脚本生成的截图前缀,清理按此删
# ---------------------------------------------------------------------------
# 时间口径小工具
# ---------------------------------------------------------------------------
def _utc() -> datetime:
"""naive UTC now(与 func.now() 在 SQLite 一致:feedback / withdraw / 流水 / 邀请关系用)。"""
return datetime.now(timezone.utc).replace(tzinfo=None)
def _bj_naive() -> datetime:
"""naive 北京 wall-clock(price_report 用,与 report_repo.create_report 一致)。"""
return datetime.now(_CST).replace(tzinfo=None)
# ---------------------------------------------------------------------------
# mock 截图(纯色 PNG,无需 Pillow;抄 seed_mock_price_reports 的手写字节法)
# ---------------------------------------------------------------------------
def _solid_png(width: int, height: int, rgb: tuple[int, int, int]) -> bytes:
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", width, height, 8, 2, 0, 0, 0) # RGB truecolor
row = b"\x00" + bytes(rgb) * width
idat = zlib.compress(row * height, 9)
return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"")
def _write_mock_image(name: str, rgb: tuple[int, int, int]) -> str:
_REPORT_DIR.mkdir(parents=True, exist_ok=True)
(_REPORT_DIR / name).write_bytes(_solid_png(320, 320, rgb))
return f"{settings.MEDIA_URL_PREFIX}/price_report/{name}"
# ---------------------------------------------------------------------------
# 清理(幂等)
# ---------------------------------------------------------------------------
def clean(db, target: User) -> None:
uid = target.id
friend_ids = list(
db.execute(select(User.id).where(User.phone.in_(FRIEND_PHONES))).scalars()
)
# 1) 目标用户的通知 + 业务记录 + 钱包
for model in (
Notification, Feedback, PriceReport, WithdrawOrder,
CashTransaction, CoinTransaction, InviteCashTransaction, CoinAccount,
):
db.execute(delete(model).where(model.user_id == uid))
# 2) 邀请关系(目标作为邀请人 + mock 好友作为被邀请人)
db.execute(delete(InviteRelation).where(InviteRelation.inviter_user_id == uid))
if friend_ids:
db.execute(delete(InviteRelation).where(InviteRelation.invitee_user_id.in_(friend_ids)))
db.execute(delete(User).where(User.id.in_(friend_ids)))
db.commit()
# 3) mock 截图文件
if _REPORT_DIR.exists():
for f in _REPORT_DIR.glob(_MOCK_IMG_GLOB):
f.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# 造业务记录(通知的点击落地数据)
# ---------------------------------------------------------------------------
def _make_friends(db, inviter: User) -> dict[str, User]:
"""建 mock 好友 user + 邀请关系(注册即生效;完成比价的置 compare_reward_granted 并发奖励金)。"""
now = _utc()
friends: dict[str, User] = {}
for i, (phone, nickname, _completed) in enumerate(FRIEND_SPECS):
u = User(
phone=phone,
username=user_repo._gen_unique_username(db),
nickname=nickname,
register_channel="sms",
status="active",
created_at=now - timedelta(days=6 - i),
last_login_at=now - timedelta(hours=2),
)
db.add(u)
friends[nickname] = u
db.flush() # 拿 friend.id
for i, (_phone, nickname, completed) in enumerate(FRIEND_SPECS):
f = friends[nickname]
db.add(InviteRelation(
inviter_user_id=inviter.id,
invitee_user_id=f.id,
channel="clipboard",
status="effective",
compare_reward_granted=completed,
compare_reward_cents=rewards.INVITE_COMPARE_REWARD_CENTS if completed else 0,
compare_rewarded_at=(now - timedelta(days=5 - i)) if completed else None,
created_at=now - timedelta(days=6 - i),
))
return friends
def _make_feedbacks(db, uid: int) -> dict[str, Feedback]:
"""两条反馈:一条(rejected)带官方回复 → feedback_reply;一条(adopted)带留言+奖励 → feedback_reward。"""
now = _utc()
reply = Feedback(
user_id=uid,
content="比价结果页希望能一键复制到微信分享给朋友。",
contact="",
source="profile",
status="rejected",
admin_reply="您反馈的分享功能我们记录啦,会在后续版本评估上线,感谢支持~",
review_note="需求已进池",
reviewed_at=now - timedelta(hours=5),
created_at=now - timedelta(days=1, hours=2),
)
reward = Feedback(
user_id=uid,
content="点某些店铺比价偶尔会闪退,机型 Redmi K60。",
contact="",
source="comparison",
scene="compare_slow",
status="adopted",
admin_reply="感谢反馈,您说的闪退问题已修复上线,送您的金币请查收~",
review_note="已修复:比价页空指针",
reward_coins=300,
reviewed_at=now - timedelta(days=1),
created_at=now - timedelta(days=3),
)
db.add_all([reply, reward])
db.flush()
return {"reply": reply, "reward": reward}
def _make_report(db, uid: int) -> PriceReport:
"""一条 approved 上报(带真实可加载截图 + 奖励金币)→ report_approved 点击可看爆料详情。"""
now = _bj_naive()
img = _write_mock_image("mock_notif_report.png", (250, 173, 20))
rep = PriceReport(
user_id=uid,
comparison_record_id=None,
store_name="蜀大侠火锅(春熙路店)",
dish_summary="招牌牛油锅 × 1、鲜毛肚 × 2",
original_platform_id="meituan-waimai",
original_platform_name="美团外卖",
original_price_cents=13800,
reported_platform_id="jd-waimai",
reported_platform_name="京东外卖",
reported_price_cents=11800,
images=[img],
status="approved",
reward_coins=1000,
reviewed_at=now - timedelta(days=39, hours=-1),
created_at=now - timedelta(days=40),
)
db.add(rep)
db.flush()
return rep
def _make_withdraws(db, uid: int) -> dict[str, WithdrawOrder]:
"""两单提现:success(历史)+ failed(驱动 withdraw_failed 点击去提现页)。不造在审单,避活动单唯一约束。"""
now = _utc()
success = WithdrawOrder(
user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=500, source="coin_cash",
user_name="测试用户", status="success", wechat_state="SUCCESS",
transfer_bill_no="1330" + str(uuid.uuid4().int)[:26],
created_at=now - timedelta(days=5), updated_at=now - timedelta(days=5),
)
failed = WithdrawOrder(
user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=350, source="coin_cash",
user_name="测试用户", status="failed", wechat_state="FAIL",
transfer_bill_no="1330" + str(uuid.uuid4().int)[:26],
fail_reason="微信实名与提现实名不一致,款项已原路退回现金余额",
created_at=now - timedelta(days=2), updated_at=now - timedelta(days=2) + timedelta(hours=1),
)
db.add_all([success, failed])
db.flush()
return {"success": success, "failed": failed}
def _make_wallet(db, uid: int, friends: dict[str, User], withdraws: dict[str, WithdrawOrder]) -> None:
"""钱包余额 + 三本流水(金币 / 现金 / 邀请奖励金),让赚钱页与各明细页都有内容。"""
now = _utc()
# 金币流水(只增,链上 balance_after)
coin_events = [
(2000, "signin", (now - timedelta(days=6)).date().isoformat(), "每日签到"),
(160, "reward_video", uuid.uuid4().hex, "看视频奖励"),
(500, "task_enable_notification", "task_enable_notification", "开启消息提醒奖励"),
(1000, "report_reward", None, "爆料审核通过奖励"),
(300, "feedback_reward", None, "反馈采纳奖励"),
]
coin_bal = 0
for amt, biz, ref, remark in coin_events:
coin_bal += amt
db.add(CoinTransaction(
user_id=uid, amount=amt, balance_after=coin_bal, biz_type=biz,
ref_id=ref, remark=remark, created_at=now - timedelta(days=4),
))
# 现金流水:兑入 + 两单提现扣款 + 失败退款 → 期末 1500
cash_events = [
(now - timedelta(days=10), 2000, "exchange_in", None, "金币兑入"),
(withdraws["success"].created_at, -500, "withdraw", withdraws["success"].out_bill_no, "提现扣款"),
(withdraws["failed"].created_at, -350, "withdraw", withdraws["failed"].out_bill_no, "提现扣款"),
(withdraws["failed"].updated_at, 350, "withdraw_refund", withdraws["failed"].out_bill_no, "提现退款"),
]
cash_events.sort(key=lambda e: e[0])
cash_bal = 0
for t, amt, biz, ref, remark in cash_events:
cash_bal += amt
db.add(CashTransaction(
user_id=uid, amount_cents=amt, balance_after_cents=cash_bal,
biz_type=biz, ref_id=ref, remark=remark, created_at=t,
))
# 邀请奖励金流水:每个已完成好友发一笔 → 期末 = 已完成好友数 × 单笔奖励
invite_bal = 0
reward_cents = rewards.INVITE_COMPARE_REWARD_CENTS
for i, (_phone, nickname, completed) in enumerate(FRIEND_SPECS):
if not completed:
continue
invite_bal += reward_cents
db.add(InviteCashTransaction(
user_id=uid, amount_cents=reward_cents, balance_after_cents=invite_bal,
biz_type="invite_reward", ref_id=str(friends[nickname].id),
remark="好友比价奖励", created_at=now - timedelta(days=5 - i),
))
total_earned = sum(a for a, *_ in coin_events)
db.add(CoinAccount(
user_id=uid,
coin_balance=coin_bal,
cash_balance_cents=cash_bal,
invite_cash_balance_cents=invite_bal,
total_coin_earned=total_earned,
))
# ---------------------------------------------------------------------------
# 造 13 类通知(extra 指向上面真实记录的 id)
# ---------------------------------------------------------------------------
def _notif(uid: int, type_key: str, sent_at: datetime, *, read: bool = False,
extra_override: dict | None = None, dedup_key: str | None = None) -> Notification:
card = notif_repo.build_sample_card(type_key, sent_at=sent_at)
extra = dict(card.get("extra", {}))
if extra_override:
extra.update(extra_override)
return Notification(
user_id=uid, type=type_key, is_read=read,
read_at=(sent_at + timedelta(minutes=5)) if read else None,
sent_at=sent_at, dedup_key=dedup_key,
coins=card.get("coins"), cash_cents=card.get("cash_cents"),
info_rows=card.get("info_rows", []), extra=extra,
)
def _make_notifications(
db, uid: int, fb: dict[str, Feedback], rep: PriceReport,
wd: dict[str, WithdrawOrder], friends: dict[str, User],
) -> list[Notification]:
n = datetime.now(_CST)
def ago(**kw) -> datetime:
return n - timedelta(**kw)
rows = [
# —— 提现助手 ——(金额/现金卡;withdraw_failed 指向真实失败单)
_notif(uid, "reward_expiring", ago(hours=2), dedup_key=f"batch_{n:%Y%m%d}"),
_notif(uid, "reward_expired", ago(days=1, hours=3), read=True),
_notif(uid, "withdraw_success", ago(minutes=10)),
_notif(uid, "withdraw_success", ago(days=3), read=True), # 额外一条(历史,已读)
_notif(uid, "withdraw_failed", ago(days=1, hours=1),
extra_override={"withdrawId": str(wd["failed"].id)}),
# —— 系统通知(权限异常 ×4;dedup_key=权限名,未读期间只保留一条)——
_notif(uid, "perm_accessibility", ago(hours=1), dedup_key="accessibility"),
_notif(uid, "perm_battery", ago(days=3), read=True, dedup_key="battery"),
_notif(uid, "perm_autostart", ago(days=5), dedup_key="autostart"),
_notif(uid, "perm_overlay", ago(days=6), read=True, dedup_key="overlay"),
# —— 我的反馈(feedbackId 指向真实反馈)——
_notif(uid, "feedback_reply", ago(hours=4),
extra_override={"feedbackId": str(fb["reply"].id)}),
_notif(uid, "feedback_reward", ago(days=1),
extra_override={"feedbackId": str(fb["reward"].id)}),
_notif(uid, "feedback_reply", ago(days=380), read=True, # 跨年(测「YYYY年M月D日」),已读
extra_override={"feedbackId": str(fb["reply"].id)}),
# —— 我的爆料(reportId 指向真实上报)——
_notif(uid, "report_approved", ago(days=40), # 当年(测「M月D日」)
extra_override={"reportId": str(rep.id)}),
# —— 好友邀请(inviteeNickname 指向真实好友)——
_notif(uid, "invite_order_reward", ago(minutes=20),
extra_override={"inviteeNickname": "柚子"}),
_notif(uid, "invite_remind", ago(days=2),
extra_override={"inviteeNickname": "阿泽", "scrollTo": "remind"}),
]
db.add_all(rows)
return rows
def seed(db, target: User) -> list[Notification]:
uid = target.id
friends = _make_friends(db, target)
fb = _make_feedbacks(db, uid)
rep = _make_report(db, uid)
wd = _make_withdraws(db, uid)
_make_wallet(db, uid, friends, wd)
rows = _make_notifications(db, uid, fb, rep, wd, friends)
db.commit()
return rows
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="给指定用户造消息通知中心 + 点击落地页 mock 数据")
parser.add_argument("--phone", default=DEFAULT_PHONE, help=f"目标用户手机号(默认 {DEFAULT_PHONE})")
parser.add_argument("--clean-only", action="store_true", help="只清理,不重建")
args = parser.parse_args()
db = SessionLocal()
try:
target = user_repo.get_user_by_phone(db, args.phone)
if target is None:
print(f"❌ 用户 {args.phone} 不存在。请先用该手机号在 App 登录一次(SMS mock:任意 6 位验证码)再跑本脚本。")
return
clean(db, target)
print(f"🧹 已清理用户 {args.phone}(id={target.id})上一轮 mock 通知 + 业务记录 + mock 好友/截图")
if args.clean_only:
print("✅ 仅清理,已完成。")
return
rows = seed(db, target)
unread = sum(1 for r in rows if not r.is_read)
print(f"\n✅ 已为用户 {args.phone}(id={target.id})生成 {len(rows)} 条通知(未读 {unread}):")
for r in sorted(rows, key=lambda x: x.sent_at, reverse=True):
flag = " " if r.is_read else ""
print(f" {flag} {r.type:<20} {r.sent_at:%Y-%m-%d %H:%M} extra={r.extra}")
print(
"\n👉 用 11111111111 登录 App(SMS mock:任意 6 位验证码)看消息通知中心;"
"\n 逐条点击验证跳转:反馈→我的反馈、爆料→我的爆料、提现失败→提现页、邀请→邀请页、权限→检测弹窗。"
"\n 后端若没带 --reload,改了数据也无需重启(本脚本直接写库,接口实时读)。"
)
finally:
db.close()
if __name__ == "__main__":
main()
+135
View File
@@ -0,0 +1,135 @@
"""给指定用户(默认 11111111111)造「后台可驱动」的推送联调数据。
覆盖能从**管理后台点一下就触发手机推送** 3 类事件,每类 10 条待审记录:
事件(PRD #) 后台动作 造的数据
#10 反馈奖励 反馈工单 → 采纳(填回复留言 + 金币) 10 条 pending feedback(标「请采纳」)
#9 官方回复 反馈工单 → 拒绝(填未采纳原因/留言) 10 条 pending feedback(标「请拒绝」)
#11 爆料审核通过 上报更低价 → 通过 10 条 pending price_report
触发链路:admin 审核 发金币/改状态 services/notification_events 落站内消息 + 厂商直推
该用户已注册设备(device_liveness)收到 push
其余 3 (#3 提现成功 / #4 提现失败 / #12 好友下单到账)后台无法在本环境驱动
(wxpay 未配 / 提现单唯一约束 / 后台无入口), scripts/fire_push_events.py 直接触发
幂等:每次先删掉本脚本上一轮造的记录(按内容标记 [PUSH测试] 识别,不动用户真实反馈/爆料),再重建
.venv\\Scripts\\python.exe scripts\\seed_push_admin_test.py
.venv\\Scripts\\python.exe scripts\\seed_push_admin_test.py --phone 11111111111 --count 10
.venv\\Scripts\\python.exe scripts\\seed_push_admin_test.py --clean-only
"""
from __future__ import annotations
import argparse
import logging
import sys
from sqlalchemy import delete, select
from app.db.session import SessionLocal
from app.models.feedback import Feedback
from app.models.price_report import PriceReport
from app.repositories import user as user_repo
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) # 静音 SQL 回显,输出更干净
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
DEFAULT_PHONE = "11111111111"
MARK = "[PUSH测试]" # 本脚本造的数据统一带此标记,幂等清理按它识别(不误删真实数据)
def clean(db, uid: int) -> tuple[int, int]:
"""删掉本脚本上一轮造的带标记记录(任何状态都删,彻底重置)。返回 (删反馈数, 删爆料数)。"""
fb_ids = list(db.execute(
select(Feedback.id).where(Feedback.user_id == uid, Feedback.content.like(f"{MARK}%"))
).scalars())
rep_ids = list(db.execute(
select(PriceReport.id).where(
PriceReport.user_id == uid, PriceReport.store_name.like(f"{MARK}%")
)
).scalars())
if fb_ids:
db.execute(delete(Feedback).where(Feedback.id.in_(fb_ids)))
if rep_ids:
db.execute(delete(PriceReport).where(PriceReport.id.in_(rep_ids)))
db.commit()
return len(fb_ids), len(rep_ids)
def seed(db, uid: int, count: int) -> None:
# #10 反馈奖励:采纳这些 → 手机收「反馈奖励已到账」。采纳时记得在后台填「给用户的回复留言」
# (PRD 要求发奖必带官方留言),否则通知里不带留言行。
for i in range(1, count + 1):
db.add(Feedback(
user_id=uid,
content=f"{MARK} 请【采纳】我 → 触发 #10 反馈奖励推送。测试反馈内容 {i:02d}:比价页能加个历史记录就好了。",
contact="",
source="profile",
status="pending",
))
# #9 官方回复:拒绝这些 → 手机收「您的反馈有回复啦」。拒绝时填「未采纳原因」+「回复留言」。
for i in range(1, count + 1):
db.add(Feedback(
user_id=uid,
content=f"{MARK} 请【拒绝】我 → 触发 #9 官方回复推送。测试反馈内容 {i:02d}:希望支持某某小众平台比价。",
contact="",
source="comparison",
scene="other",
status="pending",
))
# #11 爆料审核通过:通过这些 → 手机收「爆料审核通过」(发固定金币)。
for i in range(1, count + 1):
db.add(PriceReport(
user_id=uid,
comparison_record_id=None,
store_name=f"{MARK}测试火锅店{i:02d}",
dish_summary="招牌套餐 × 1",
original_platform_id="meituan-waimai",
original_platform_name="美团外卖",
original_price_cents=9900,
reported_platform_id="jd-waimai",
reported_platform_name="京东外卖",
reported_price_cents=8800,
images=[],
status="pending",
))
db.commit()
def main() -> None:
parser = argparse.ArgumentParser(description="造后台可驱动的推送联调数据(反馈×2 + 爆料)")
parser.add_argument("--phone", default=DEFAULT_PHONE, help=f"目标用户手机号(默认 {DEFAULT_PHONE})")
parser.add_argument("--count", type=int, default=10, help="每类造多少条(默认 10)")
parser.add_argument("--clean-only", action="store_true", help="只清理本脚本造的数据,不重建")
args = parser.parse_args()
db = SessionLocal()
try:
user = user_repo.get_user_by_phone(db, args.phone)
if user is None:
print(f"❌ 用户 {args.phone} 不存在。请先用该手机号在 App 登录一次再跑本脚本。")
return
uid = user.id
nf, nr = clean(db, uid)
print(f"🧹 已清理上一轮 [PUSH测试] 数据:反馈 {nf} 条、爆料 {nr}")
if args.clean_only:
print("✅ 仅清理,已完成。")
return
seed(db, uid, args.count)
print(f"\n✅ 已为 {args.phone}(id={uid})造好后台联调数据(每类 {args.count} 条):")
print(f" • 反馈工单「请采纳」× {args.count} → 后台【采纳】(填回复留言+金币)→ 手机收 #10 反馈奖励")
print(f" • 反馈工单「请拒绝」× {args.count} → 后台【拒绝】(填未采纳原因/留言)→ 手机收 #9 官方回复")
print(f" • 上报更低价 × {args.count} → 后台【通过】→ 手机收 #11 爆料审核通过")
print("\n👉 打开管理后台(:8771)对应列表即可看到这些待审记录,逐条审核就会推到手机。")
print(" #3/#4/#12 本环境后台驱动不了,用:.venv\\Scripts\\python.exe scripts\\fire_push_events.py")
finally:
db.close()
if __name__ == "__main__":
main()
+13
View File
@@ -0,0 +1,13 @@
@echo off
REM Show all device_id + push regIds (push_token / registration_id) from the
REM local SQLite DB. Double-click this file to see everything, or run from a
REM console. Optional substring filter: show_device_regids.bat xiaomi
REM Works from ANY directory (locates project root + venv python by itself).
REM ASCII-only comments: cmd parses .bat in the console codepage (GBK); UTF-8
REM Chinese here gets mangled into bogus commands.
cd /d "%~dp0.."
set "PY=python"
if exist ".venv\Scripts\python.exe" set "PY=.venv\Scripts\python.exe"
"%PY%" "scripts\show_device_regids.py" %*
echo.
pause
+136
View File
@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
"""导出 device_liveness 表里所有 device_id 及其对应的推送 regId,按 push_vendor 分组(本地开发工具)。
用法:
双击 scripts/show_device_regids.bat,或命令行:
python scripts/show_device_regids.py [filter]
[filter] 可选:大小写不敏感的子串,匹配 device_id / push_vendor / push_token /
registration_id 任一列;不传则列出全部
show_device_regids.py xiaomi # 只看小米
show_device_regids.py device_Pixel # 按 device_id 片段找
说明:
- 直接以**只读**方式读 SQLite(server 在跑也不会抢写锁),所以开不开服务器都能用
- push_token = 各厂商的 regId/pushToken(新链路,当前在用);registration_id = 旧极光
regId(历史兼容)两列都打出来,方便跟 logcat 现役值逐字比对
- 输出刻意保持纯 ASCII 排版并竖排展示**完整值**:双击弹出的 cmd GBK 码页,竖排纯
ASCII 不会乱码;完整值不截断,才能直接复制去比对
- DATABASE_URL 不是 sqlite(生产 postgres),这里只提示改用 psql
"""
from __future__ import annotations
import os
import sqlite3
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def _resolve_sqlite_path() -> Path | None:
"""按 env > .env > 默认 的顺序拿 DATABASE_URL,解析出 sqlite 文件路径。"""
url = os.environ.get("DATABASE_URL", "").strip()
if not url:
env = REPO_ROOT / ".env"
if env.exists():
for line in env.read_text(encoding="utf-8", errors="ignore").splitlines():
s = line.strip()
if s.startswith("DATABASE_URL="):
url = s.split("=", 1)[1].strip().strip('"').strip("'")
break
if not url:
url = "sqlite:///./data/app.db"
if not url.startswith("sqlite"):
print(f"[!] DATABASE_URL not sqlite: {url}")
print(" This tool only reads a local SQLite dev DB. For prod use psql.")
return None
raw = url.split("///", 1)[1] if "///" in url else "./data/app.db"
p = Path(raw)
if not p.is_absolute():
p = (REPO_ROOT / raw).resolve()
return p
def main() -> int:
needle = sys.argv[1].lower() if len(sys.argv) > 1 else None
db = _resolve_sqlite_path()
if db is None:
return 2
if not db.exists():
print(f"[X] DB file not found: {db}")
return 2
# 只读打开,避免和正在运行的 server 抢写锁。URI 路径用正斜杠(as_posix)规避
# Windows 反斜杠/盘符在 file: URI 里的歧义;万一 URI 形式打不开再回退普通连接。
try:
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True)
except sqlite3.OperationalError:
con = sqlite3.connect(str(db))
con.row_factory = sqlite3.Row
rows = con.execute(
"""
SELECT id, user_id, device_id, push_vendor, push_token, registration_id,
platform, app_version, liveness_state, last_heartbeat_at, updated_at
FROM device_liveness
ORDER BY updated_at DESC
"""
).fetchall()
con.close()
if needle:
def hit(r: sqlite3.Row) -> bool:
for k in ("device_id", "push_vendor", "push_token", "registration_id"):
v = r[k]
if v and needle in str(v).lower():
return True
return False
rows = [r for r in rows if hit(r)]
with_token = sum(1 for r in rows if (r["push_token"] or "").strip())
with_reg = sum(1 for r in rows if (r["registration_id"] or "").strip())
# 按 push_vendor 分组;None/空归入 "(none)"。组顺序:设备数多的在前,(none) 垫底;
# 组内沿用 updated_at DESC(rows 查询时已如此排序,dict 保序即可)。
groups: dict[str, list] = {}
for r in rows:
groups.setdefault(r["push_vendor"] or "(none)", []).append(r)
ordered = sorted(
groups.items(), key=lambda kv: (kv[0] == "(none)", -len(kv[1]), kv[0])
)
tally = " ".join(f"{v}={len(items)}" for v, items in ordered) or "-"
print(f"DB : {db}")
line = f"device_liveness : {len(rows)} row(s)"
if needle:
line += f' filter="{sys.argv[1]}"'
print(line)
print(f"has push_token(vendor regId) : {with_token}"
f" has registration_id(jiguang) : {with_reg}")
print(f"vendors : {tally}")
print("=" * 72)
if not rows:
print("(no rows)")
return 0
for vendor, items in ordered:
print()
print(f"===== vendor={vendor} : {len(items)} device(s) =====")
for r in items:
print(f" [#{r['id']}] user_id={r['user_id']} platform={r['platform']}"
f" state={r['liveness_state']} app={r['app_version'] or '-'}")
print(f" device_id : {r['device_id']}")
print(f" push_token(regId): {r['push_token'] or '(empty)'}")
print(f" registration_id : {r['registration_id'] or '(empty)'}")
print(f" last_heartbeat : {r['last_heartbeat_at'] or '-'}"
f" updated : {r['updated_at']}")
print(" " + "-" * 68)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,9 @@
@echo off
REM Push test #12 invite_order_reward (1 notification per run, random amount).
REM Works from ANY directory (locates project root + venv python by itself):
REM scripts\test_push_invite_order_reward.bat
REM Extra args pass through, e.g.: test_push_invite_order_reward.bat --invitee-phone 12000000001
REM ASCII-only comments: cmd parses .bat in the console codepage (GBK), UTF-8
REM Chinese here gets mangled into bogus commands.
cd /d "%~dp0.."
".venv\Scripts\python.exe" "scripts\test_push_invite_order_reward.py" %*
+110
View File
@@ -0,0 +1,110 @@
"""#12 好友下单到账(invite_order_reward)推送联调脚本 —— 每次执行只发 1 条,金额随机。
后台没有驱动这个事件的入口(真实链路要好友注册 + 完成首次比价);本脚本直接调
services/notification_events.notify_invite_order_reward 与生产同一条下发链路:
notification (站内消息)+ 邀请人全部已注册厂商 token 直推(honor/huawei/xiaomi/oppo/vivo)
可重复性:
默认用随机假被邀请人 id dedup_key 永不去重,想跑多少次都行(昵称兜底显示好友)
金额默认每次随机(0.01 ~ 99.99 ) 手机上按金额认出这条通知;--cents 可固定(线上真实值 200 = 2 )
--invitee-phone 指定真实用户(如种子好友 12000000001 柚子) 通知里显示真实昵称;
dedup_key = 被邀请人 id:上一条还未读时重复发会命中去重(日志出现 dedup hit,不落库不推送),
App 里把那条读掉(或换号)即可再次触发
用法(服务端进程无需在跑,脚本自己连库 + 直调厂商接口):
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py # 随机金额发 1 条
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --cents 200 # 固定 2.00 元
.venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --invitee-phone 12000000001 # 真实昵称
结果判读(看输出日志):
push sent = 厂商接口受理成功,手机应弹好友下单奖励到账通知
push failed = 厂商拒绝(原因见日志:token 失效/凭据错误等)
skip push = 该厂商凭据未配置,只落站内消息
站内消息用 11111111111 登录 App 消息中心好友邀请可见;点击应跳邀请页
"""
from __future__ import annotations
import argparse
import logging
import random
import sys
from sqlalchemy import func, select
from app.db.session import SessionLocal, engine
from app.models.notification import Notification
from app.repositories import device as device_repo
from app.repositories import user as user_repo
from app.services import notification_events
# SQL 回显静音;shagua.* 开到 INFO 才看得到 push sent / push failed / dedup hit
# dev 下 APP_DEBUG=true → engine echo=True,echo 走 InstanceLogger 直写、无视 logger level,只能关 echo 本身
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
engine.echo = False
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
TYPE_KEY = "invite_order_reward"
def _notif_count(db, uid: int) -> int:
return int(
db.execute(
select(func.count())
.select_from(Notification)
.where(Notification.user_id == uid, Notification.type == TYPE_KEY)
).scalar_one()
)
def main() -> None:
parser = argparse.ArgumentParser(description="#12 好友下单到账 推送联调(每次 1 条,金额默认随机)")
parser.add_argument("--phone", default="11111111111", help="邀请人(收通知方)手机号,默认 11111111111")
parser.add_argument("--cents", type=int, default=None,
help="奖励金额,单位分(默认随机 1~9999;线上真实值 200)")
parser.add_argument("--invitee-phone", default="",
help="被邀请人手机号(可选):显示真实昵称;不传用随机假 id,昵称显示「好友」")
args = parser.parse_args()
cents = args.cents if args.cents is not None else random.randint(1, 9999)
db = SessionLocal()
try:
user = user_repo.get_user_by_phone(db, args.phone)
if user is None:
print(f"❌ 用户 {args.phone} 不存在。先用该手机号在 App 登录一次再跑。")
return
if args.invitee_phone.strip():
invitee = user_repo.get_user_by_phone(db, args.invitee_phone.strip())
if invitee is None:
print(f"❌ 被邀请人 {args.invitee_phone} 不存在(种子好友可用 12000000001 柚子 / 12000000003 小美)。")
return
invitee_id = invitee.id
else:
invitee_id = random.randint(900000, 999999) # 假 id,昵称兜底「好友」,永不去重
targets = device_repo.list_push_targets(db, user_id=user.id)
vendors = [t.push_vendor for t in targets]
print(f"邀请人 {args.phone}(id={user.id});推送设备 {len(targets)} 台:{vendors or '无 ← 手机收不到!先在 App 上报 push token'}")
before = _notif_count(db, user.id)
print(f"→ 本次奖励 【{cents / 100:.2f} 元】(invitee_user_id={invitee_id}),手机上按金额认领这条通知")
notification_events.notify_invite_order_reward(
db, inviter_user_id=user.id, invitee_user_id=invitee_id, cash_cents=cents
)
created = _notif_count(db, user.id) - before
if created == 1:
verdict = "✅ 已落库 1 条站内消息"
else:
verdict = "⚠️ 未落库(若日志有 dedup hit:该被邀请人上一条还未读,先在 App 里读掉再发)"
print(f"\n{verdict};推送成败见上方 shagua.notification_events 日志。")
finally:
db.close()
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
@echo off
REM Push test #4 withdraw_failed (1 notification per run, random amount + reason).
REM Works from ANY directory (locates project root + venv python by itself):
REM scripts\test_push_withdraw_failed.bat
REM Extra args pass through, e.g.: test_push_withdraw_failed.bat --cents 350
REM ASCII-only comments: cmd parses .bat in the console codepage (GBK), UTF-8
REM Chinese here gets mangled into bogus commands.
cd /d "%~dp0.."
".venv\Scripts\python.exe" "scripts\test_push_withdraw_failed.py" %*
+106
View File
@@ -0,0 +1,106 @@
"""#4 提现失败(withdraw_failed)推送联调脚本 —— 每次执行只发 1 条,金额 + 失败原因随机。
后台虽能驱动提现拒绝,但受同一用户同时只能有 1 张待审单约束,批量测试凑不齐单子;
本脚本直接调 services/notification_events.notify_withdraw_failed 与生产同一条下发链路:
notification (站内消息)+ 向该用户全部已注册厂商 token 直推(honor/huawei/xiaomi/oppo/vivo)
可重复性:每次执行用全新 uuid 单号做 dedup_key,永不命中未读去重,想跑多少次都行
金额默认每次随机(0.01 ~ 99.99 )失败原因随机 手机上按金额/原因就能认出这条通知;
--cents / --reason 可固定
用法(服务端进程无需在跑,脚本自己连库 + 直调厂商接口):
.venv\\Scripts\\python.exe scripts\\test_push_withdraw_failed.py # 随机金额+原因发 1 条
.venv\\Scripts\\python.exe scripts\\test_push_withdraw_failed.py --cents 350 # 固定 3.50 元
.venv\\Scripts\\python.exe scripts\\test_push_withdraw_failed.py --reason "自定义原因" # 固定失败原因
结果判读(看输出日志):
push sent = 厂商接口受理成功,手机应弹提现失败通知
push failed = 厂商拒绝(原因见日志:token 失效/凭据错误等)
skip push = 该厂商凭据未配置,只落站内消息
站内消息用 11111111111 登录 App 消息中心提现助手可见;点击应跳提现页(extra.withdrawId)
"""
from __future__ import annotations
import argparse
import logging
import random
import sys
import uuid
from sqlalchemy import func, select
from app.db.session import SessionLocal, engine
from app.models.notification import Notification
from app.models.wallet import WithdrawOrder
from app.repositories import device as device_repo
from app.repositories import user as user_repo
from app.services import notification_events
# SQL 回显静音;shagua.* 开到 INFO 才看得到 push sent / push failed
# dev 下 APP_DEBUG=true → engine echo=True,echo 走 InstanceLogger 直写、无视 logger level,只能关 echo 本身
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
engine.echo = False
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
TYPE_KEY = "withdraw_failed"
# 失败原因池:不带 --reason 时随机抽一条,模拟不同失败场景(文案与 /withdraw/status 用户可读口径一致)
FAIL_REASONS = [
"微信零钱未实名,款项已退回",
"收款账户异常,款项已退回",
"超出微信零钱收款限额,款项已退回",
"微信实名与提现实名不一致,款项已原路退回现金余额",
]
def _notif_count(db, uid: int) -> int:
return int(
db.execute(
select(func.count())
.select_from(Notification)
.where(Notification.user_id == uid, Notification.type == TYPE_KEY)
).scalar_one()
)
def main() -> None:
parser = argparse.ArgumentParser(description="#4 提现失败 推送联调(每次 1 条,金额/原因默认随机)")
parser.add_argument("--phone", default="11111111111", help="目标用户手机号(默认 11111111111)")
parser.add_argument("--cents", type=int, default=None, help="退回金额,单位分(默认随机 1~9999)")
parser.add_argument("--reason", default="", help="失败原因(默认从内置原因池随机抽)")
args = parser.parse_args()
cents = args.cents if args.cents is not None else random.randint(1, 9999)
reason = args.reason.strip() or random.choice(FAIL_REASONS)
db = SessionLocal()
try:
user = user_repo.get_user_by_phone(db, args.phone)
if user is None:
print(f"❌ 用户 {args.phone} 不存在。先用该手机号在 App 登录一次再跑。")
return
targets = device_repo.list_push_targets(db, user_id=user.id)
vendors = [t.push_vendor for t in targets]
print(f"目标用户 {args.phone}(id={user.id});推送设备 {len(targets)} 台:{vendors or '无 ← 手机收不到!先在 App 上报 push token'}")
before = _notif_count(db, user.id)
order = WithdrawOrder(
user_id=user.id, out_bill_no=uuid.uuid4().hex,
amount_cents=cents, source="coin_cash", fail_reason=reason,
)
print(f"→ 本次金额 【{cents / 100:.2f} 元】,原因【{reason}】(单号 {order.out_bill_no[:8]}…)")
notification_events.notify_withdraw_failed(db, order)
created = _notif_count(db, user.id) - before
verdict = "✅ 已落库 1 条站内消息" if created == 1 else "⚠️ 未落库(见上方日志)"
print(f"\n{verdict};推送成败见上方 shagua.notification_events 日志。")
finally:
db.close()
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
@echo off
REM Push test #3 withdraw_success (1 notification per run, random amount).
REM Works from ANY directory (locates project root + venv python by itself):
REM scripts\test_push_withdraw_success.bat
REM Extra args pass through, e.g.: test_push_withdraw_success.bat --cents 1280
REM ASCII-only comments: cmd parses .bat in the console codepage (GBK), UTF-8
REM Chinese here gets mangled into bogus commands.
cd /d "%~dp0.."
".venv\Scripts\python.exe" "scripts\test_push_withdraw_success.py" %*
+94
View File
@@ -0,0 +1,94 @@
"""#3 提现到账(withdraw_success)推送联调脚本 —— 每次执行只发 1 条,金额随机。
本环境 wxpay 未配置,后台审核通过发不出微信转账,打不通真实提现链路;本脚本直接调
services/notification_events.notify_withdraw_success 与生产同一条下发链路:
notification (站内消息)+ 向该用户全部已注册厂商 token 直推(honor/huawei/xiaomi/oppo/vivo)
可重复性:每次执行用全新 uuid 单号做 dedup_key,永不命中未读去重,想跑多少次都行
金额默认每次随机(0.01 ~ 99.99 ) 手机上按金额就能认出这条通知是哪次跑出来的;--cents 可固定
用法(服务端进程无需在跑,脚本自己连库 + 直调厂商接口):
.venv\\Scripts\\python.exe scripts\\test_push_withdraw_success.py # 随机金额发 1 条
.venv\\Scripts\\python.exe scripts\\test_push_withdraw_success.py --cents 1280 # 固定 12.80 元
结果判读(看输出日志):
push sent = 厂商接口受理成功,手机应弹提现到账通知
push failed = 厂商拒绝(原因见日志:token 失效/凭据错误等)
skip push = 该厂商凭据未配置,只落站内消息
站内消息用 11111111111 登录 App 消息中心提现助手可见
"""
from __future__ import annotations
import argparse
import logging
import random
import sys
import uuid
from sqlalchemy import func, select
from app.db.session import SessionLocal, engine
from app.models.notification import Notification
from app.models.wallet import WithdrawOrder
from app.repositories import device as device_repo
from app.repositories import user as user_repo
from app.services import notification_events
# SQL 回显静音;shagua.* 开到 INFO 才看得到 push sent / push failed
# dev 下 APP_DEBUG=true → engine echo=True,echo 走 InstanceLogger 直写、无视 logger level,只能关 echo 本身
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
engine.echo = False
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
TYPE_KEY = "withdraw_success"
def _notif_count(db, uid: int) -> int:
return int(
db.execute(
select(func.count())
.select_from(Notification)
.where(Notification.user_id == uid, Notification.type == TYPE_KEY)
).scalar_one()
)
def main() -> None:
parser = argparse.ArgumentParser(description="#3 提现到账 推送联调(每次 1 条,金额默认随机)")
parser.add_argument("--phone", default="11111111111", help="目标用户手机号(默认 11111111111)")
parser.add_argument("--cents", type=int, default=None, help="到账金额,单位分(默认随机 1~9999)")
args = parser.parse_args()
cents = args.cents if args.cents is not None else random.randint(1, 9999)
db = SessionLocal()
try:
user = user_repo.get_user_by_phone(db, args.phone)
if user is None:
print(f"❌ 用户 {args.phone} 不存在。先用该手机号在 App 登录一次再跑。")
return
targets = device_repo.list_push_targets(db, user_id=user.id)
vendors = [t.push_vendor for t in targets]
print(f"目标用户 {args.phone}(id={user.id});推送设备 {len(targets)} 台:{vendors or '无 ← 手机收不到!先在 App 上报 push token'}")
before = _notif_count(db, user.id)
order = WithdrawOrder(
user_id=user.id, out_bill_no=uuid.uuid4().hex,
amount_cents=cents, source="coin_cash",
)
print(f"→ 本次金额 【{cents / 100:.2f} 元】(单号 {order.out_bill_no[:8]}…),手机上按金额认领这条通知")
notification_events.notify_withdraw_success(db, order)
created = _notif_count(db, user.id) - before
verdict = "✅ 已落库 1 条站内消息" if created == 1 else "⚠️ 未落库(见上方日志)"
print(f"\n{verdict};推送成败见上方 shagua.notification_events 日志。")
finally:
db.close()
if __name__ == "__main__":
main()
+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"
+478
View File
@@ -0,0 +1,478 @@
"""15 天不活跃清零:模型 / 活跃口径 / 清零 / 预警 / 配置 / worker。"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
import pytest
from sqlalchemy import delete, select, update
from app.db.session import SessionLocal
from app.models.inactivity import InactivityNotificationLog, InactivityResetLog
from app.repositories import activity
def test_reset_and_notification_models_persist() -> None:
db = SessionLocal()
try:
db.add(InactivityResetLog(
user_id=1, coin_balance_before=10, cash_balance_cents_before=20,
invite_cash_balance_cents_before=30,
last_active_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
inactive_days=15, reason="inactive_15d",
))
db.add(InactivityNotificationLog(
user_id=1, stage=7, inactive_days=8, coin_balance=10,
cash_balance_cents=20, invite_cash_balance_cents=30,
channel="log", status="placeholder",
))
db.commit()
r = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == 1)).scalar_one()
assert r.reason == "inactive_15d" and r.reset_at is not None
n = db.execute(select(InactivityNotificationLog).where(InactivityNotificationLog.user_id == 1)).scalar_one()
assert n.stage == 7 and n.created_at is not None
finally:
db.rollback()
db.close()
def test_reset_cutoff_is_cn_midnight_of_today_minus_days_minus_1() -> None:
# RESET_DAYS=15, today=1/20 → cutoff = 北京 00:00 of 1/6 = 1/5 16:00 UTC
cutoff = activity.reset_cutoff(15, today=date(2026, 1, 20))
assert cutoff == datetime(2026, 1, 5, 16, 0, tzinfo=timezone.utc)
def test_active_event_constants() -> None:
# 首页可见 = event=show + page=home 组合,不在纯 event 名集合里
assert activity.HOME_VIEW_EVENT == "show" and activity.HOME_VIEW_PAGE == "home"
assert activity.HOME_VIEW_EVENT not in activity.ACTIVE_EVENTS
assert "real_compare_start" in activity.ACTIVE_EVENTS
assert "real_coupon_start" in activity.ACTIVE_EVENTS
assert activity.ACTIVE_ENGAGE_TYPE == "claim_started"
def test_as_utc_normalizes() -> None:
assert activity.as_utc(datetime(2026, 1, 1)) == datetime(2026, 1, 1, tzinfo=timezone.utc)
cn = datetime(2026, 1, 1, tzinfo=activity.CN_TZ) # 北京 0 点 = 前一天 16:00 UTC
assert activity.as_utc(cn) == datetime(2025, 12, 31, 16, 0, tzinfo=timezone.utc)
from app.core.rewards import CN_TZ
from app.models.analytics_event import AnalyticsEvent
from app.models.coupon_state import CouponPromptEngagement
from app.models.user import User
from app.models.wallet import CoinAccount
from app.repositories import wallet as wallet_repo
_PHONE_SEQ = [0]
@pytest.fixture(autouse=True)
def _isolate_inactivity_state():
"""本文件的测试都做全表扫描 + 全局计数,而 SQLite 测试库 session 级共享、无逐用例回滚
(commit 后的 rollback no-op),故先把可能泄漏的余额清零 + 清掉活跃事件/审计行,
保证每个用例干净起步不删 User(零余额用户不会被扫描选中,避免跨文件/外键影响)"""
db = SessionLocal()
try:
db.execute(update(CoinAccount).values(
coin_balance=0, cash_balance_cents=0, invite_cash_balance_cents=0))
for model in (AnalyticsEvent, CouponPromptEngagement,
InactivityResetLog, InactivityNotificationLog):
db.execute(delete(model))
db.commit()
finally:
db.close()
yield
def _new_user(db, *, created_at, coin=0, cash=0, invite=0) -> int:
"""直接建一个 User + CoinAccount,created_at 可控。返回 user_id。"""
_PHONE_SEQ[0] += 1
# 199 前缀 + 递增序号:共享测试库跨文件累积用户,别的文件用固定手机号(如 test_admin_write
# 的 13900000001..),这里用没人用的 199 段避免撞 user.phone / username 的 UNIQUE。
u = User(phone=f"199{_PHONE_SEQ[0]:08d}", created_at=created_at,
last_login_at=created_at, status="active",
username=f"inact{_PHONE_SEQ[0]}")
db.add(u)
db.flush()
acc = wallet_repo.get_or_create_account(db, u.id, commit=False)
acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents = coin, cash, invite
acc.total_coin_earned = coin
db.flush()
return u.id
def _add_event(db, user_id, event, when: datetime, page=None) -> None:
db.add(AnalyticsEvent(event=event, device_id="d", user_id=user_id, client_ts=0,
created_at=when, page=page))
def _add_engage(db, user_id, when: datetime, engage_type="claim_started") -> None:
db.add(CouponPromptEngagement(device_id=f"dev{user_id}", package="p", user_id=user_id,
engage_date=when.date(), engage_type=engage_type, created_at=when))
def test_last_active_expr_takes_max_of_baseline_and_events() -> None:
from sqlalchemy import select
db = SessionLocal()
try:
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
uid = _new_user(db, created_at=base, coin=5)
_add_event(db, uid, "real_compare_start", datetime(2026, 1, 10, tzinfo=timezone.utc))
db.commit()
ev_sub, eng_sub = activity.last_active_subqueries(db)
dialect = db.get_bind().dialect.name
expr = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect)
stmt = (select(expr).select_from(User)
.outerjoin(ev_sub, ev_sub.c.user_id == User.id)
.outerjoin(eng_sub, eng_sub.c.user_id == User.id)
.where(User.id == uid))
got = activity.norm_utc(db.execute(stmt).scalar_one())
assert got == datetime(2026, 1, 10, tzinfo=timezone.utc) # 事件 > 基线
finally:
db.rollback()
db.close()
def test_home_signal_uses_show_event_on_home_page() -> None:
"""首页可见活跃口径 = event=show + page=home 组合;show 但非 home 页不算活跃。"""
from sqlalchemy import select
db = SessionLocal()
try:
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
seen = _new_user(db, created_at=base) # show/home → 活跃
_add_event(db, seen, "show", datetime(2026, 1, 10, tzinfo=timezone.utc), page="home")
other = _new_user(db, created_at=base) # show/其他页 → 不算活跃
_add_event(db, other, "show", datetime(2026, 1, 10, tzinfo=timezone.utc), page="coupon")
db.commit()
ev_sub, eng_sub = activity.last_active_subqueries(db)
dialect = db.get_bind().dialect.name
expr = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect)
def last_active(uid):
stmt = (select(expr).select_from(User)
.outerjoin(ev_sub, ev_sub.c.user_id == User.id)
.outerjoin(eng_sub, eng_sub.c.user_id == User.id)
.where(User.id == uid))
return activity.norm_utc(db.execute(stmt).scalar_one())
assert last_active(seen) == datetime(2026, 1, 10, tzinfo=timezone.utc) # show/home 算
assert last_active(other) == base # show/其他页 不算
finally:
db.rollback()
db.close()
def test_inactivity_warn_stages_parsing() -> None:
from app.core.config import Settings
s = Settings(INACTIVITY_WARN_DAYS_BEFORE="7,2", INACTIVITY_RESET_DAYS=15)
assert s.inactivity_warn_stages == [7, 2] # 降序去重
s2 = Settings(INACTIVITY_WARN_DAYS_BEFORE="", INACTIVITY_RESET_DAYS=15)
assert s2.inactivity_warn_stages == [] # 空=不推
s3 = Settings(INACTIVITY_WARN_DAYS_BEFORE="2,20,7,2", INACTIVITY_RESET_DAYS=15)
assert s3.inactivity_warn_stages == [7, 2] # 去重 + 丢弃 >=RESET_DAYS(20)
def test_log_notifier_returns_placeholder(caplog) -> None:
from app.integrations.notifier import LogNotifier, get_notifier
n = get_notifier("log")
assert isinstance(n, LogNotifier) and n.channel == "log"
status = n.warn(user_id=1, coin=10, cash_cents=20, stage=7, days_until_reset=8)
assert status == "placeholder"
# 未实现通道回退 LogNotifier(占位)
assert get_notifier("jpush").channel == "log"
def test_run_reset_clears_coin_and_cash_but_preserves_invite_cash() -> None:
from sqlalchemy import select
from app.models.wallet import CoinAccount, CoinTransaction, CashTransaction, InviteCashTransaction
from app.repositories import inactivity
db = SessionLocal()
try:
today = date(2026, 2, 1)
# 末次活跃 = created_at 基线 = 1/10(距 today 22 天 → 应清)
old = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc),
coin=100, cash=200, invite=300)
# 活跃用户:昨天有 home_view → 不清
fresh = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=50)
_add_event(db, fresh, "show", datetime(2026, 1, 31, tzinfo=timezone.utc), page="home")
db.commit()
stats = inactivity.run_reset_once(db, reset_days=15, today=today)
assert stats["cleared"] == 1 and stats["failed"] == 0
acc = db.get(CoinAccount, old)
# 金币 + 折算现金清零;邀请现金是产品红线,原封不动(见 wallet.CoinAccount 注释)
assert (acc.coin_balance, acc.cash_balance_cents) == (0, 0)
assert acc.invite_cash_balance_cents == 300
assert acc.total_coin_earned == 100 # 历史累计不动
log = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == old)).scalar_one()
# 审计仍快照三桶余额(邀请现金记为"清零时仍保留"的余额,便于纠纷排查)
assert (log.coin_balance_before, log.cash_balance_cents_before,
log.invite_cash_balance_cents_before) == (100, 200, 300)
assert log.inactive_days == 22 and log.reason == "inactive_15d"
ct = db.execute(select(CoinTransaction).where(
CoinTransaction.user_id == old, CoinTransaction.biz_type == "inactivity_reset")).scalar_one()
assert ct.amount == -100 and ct.balance_after == 0 and ct.ref_id == str(log.id)
assert db.execute(select(CashTransaction).where(
CashTransaction.user_id == old, CashTransaction.biz_type == "inactivity_reset")).scalar_one().amount_cents == -200
# 关键:不写邀请现金流水(邀请现金不清)
assert db.execute(select(InviteCashTransaction).where(
InviteCashTransaction.user_id == old,
InviteCashTransaction.biz_type == "inactivity_reset")).first() is None
# 活跃用户不动;再跑一次幂等(coin+cash 已 0、邀请现金不算候选 → 不再匹配)
assert db.get(CoinAccount, fresh).coin_balance == 50
assert inactivity.run_reset_once(db, reset_days=15, today=today)["cleared"] == 0
finally:
db.rollback()
db.close()
def test_user_with_only_invite_cash_is_not_cleared() -> None:
"""只有邀请现金余额的久不活跃用户:邀请现金是产品红线,不清 → 根本不该被选中。"""
from app.models.wallet import CoinAccount
from app.repositories import inactivity
db = SessionLocal()
try:
today = date(2026, 2, 1)
uid = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc),
coin=0, cash=0, invite=500)
db.commit()
stats = inactivity.run_reset_once(db, reset_days=15, today=today)
assert stats["cleared"] == 0
assert db.get(CoinAccount, uid).invite_cash_balance_cents == 500 # 原封不动
finally:
db.rollback()
db.close()
def test_run_warn_picks_stage_and_dedups_within_streak() -> None:
from app.integrations.notifier import LogNotifier
from app.repositories import inactivity
db = SessionLocal()
try:
today = date(2026, 2, 1)
# 末次活跃 1/22(距 today 10 天)→ 档 7 命中(idays>=8),档 2 未到(需>=13)
uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=100)
db.commit()
stats = inactivity.run_warn_once(db, LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)
assert stats["warned"] == 1
from sqlalchemy import select
rows = db.execute(select(InactivityNotificationLog).where(
InactivityNotificationLog.user_id == uid)).scalars().all()
assert len(rows) == 1 and rows[0].stage == 7 and rows[0].status == "placeholder"
assert rows[0].inactive_days == 10 and rows[0].coin_balance == 100
# 同一 streak 再跑 → 不重推
assert inactivity.run_warn_once(db, LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)["warned"] == 0
# 无余额用户不预警
_new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=0)
db.commit()
assert inactivity.run_warn_once(db, LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)["warned"] == 0
finally:
db.rollback()
db.close()
def test_run_once_warns_then_resets() -> None:
from app.integrations.notifier import LogNotifier
from app.models.wallet import CoinAccount
from app.repositories import inactivity
db = SessionLocal()
try:
today = date(2026, 2, 1)
warn_uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=10) # 10天→预警
clear_uid = _new_user(db, created_at=datetime(2026, 1, 5, tzinfo=timezone.utc), coin=10) # 27天→清零
db.commit()
stats = inactivity.run_once(db, notifier=LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)
assert stats["warned"] == 1 and stats["cleared"] == 1
assert db.get(CoinAccount, clear_uid).coin_balance == 0
assert db.get(CoinAccount, warn_uid).coin_balance == 10 # 预警不动钱
finally:
db.rollback()
db.close()
def test_worker_run_once_entry_dry_run(monkeypatch) -> None:
"""ENABLED=false(默认语义)→ worker 常驻但只记审计不清(dry_run = not ENABLED)。"""
from sqlalchemy import select
from app.core import inactivity_reset_worker as w
from app.core.config import settings
from app.models.wallet import CoinAccount
monkeypatch.setattr(settings, "INACTIVITY_RESET_ENABLED", False) # false = 只记审计
monkeypatch.setattr(settings, "INACTIVITY_RESET_DAYS", 15)
monkeypatch.setattr(settings, "INACTIVITY_WARN_DAYS_BEFORE", "")
monkeypatch.setattr(w, "_cn_today", lambda: date(2026, 2, 1))
db = SessionLocal()
try:
uid = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=100)
db.commit()
finally:
db.close()
w._run_once_entry()
db = SessionLocal()
try:
assert db.get(CoinAccount, uid).coin_balance == 100 # 没清
log = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == uid)).scalar_one()
assert log.reason.endswith("dryrun") # 记了审计
finally:
db.close()
def test_worker_run_once_entry_executes(monkeypatch) -> None:
"""_run_once_entry 用真实 SessionLocal 跑一轮,总闸开时能清掉一个不活跃用户。"""
from app.core import inactivity_reset_worker as w
from app.core.config import settings
from app.models.wallet import CoinAccount
monkeypatch.setattr(settings, "INACTIVITY_RESET_ENABLED", True)
monkeypatch.setattr(settings, "INACTIVITY_RESET_DAYS", 15)
monkeypatch.setattr(settings, "INACTIVITY_WARN_DAYS_BEFORE", "") # 只测清零
# 固定"今天"避免依赖真实时钟
monkeypatch.setattr(w, "_cn_today", lambda: date(2026, 2, 1))
db = SessionLocal()
try:
uid = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=100)
db.commit()
finally:
db.close()
stats = w._run_once_entry()
assert stats["cleared"] >= 1
db = SessionLocal()
try:
assert db.get(CoinAccount, uid).coin_balance == 0
finally:
db.close()
def test_run_once_dry_run_records_audit_but_does_not_clear() -> None:
"""dry-run:只写审计(标 dryrun)、不动钱、不预警;重复跑不重复记(streak dedup)。"""
from sqlalchemy import select
from app.integrations.notifier import LogNotifier
from app.models.wallet import CoinAccount, CoinTransaction
from app.repositories import inactivity
db = SessionLocal()
try:
today = date(2026, 2, 1)
old = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc), coin=100, cash=200, invite=300)
warn_uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=50) # 预警窗
db.commit()
stats = inactivity.run_once(db, notifier=LogNotifier(), reset_days=15,
warn_stages=[7, 2], today=today, dry_run=True)
acc = db.get(CoinAccount, old)
assert (acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents) == (100, 200, 300) # 原封
log = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == old)).scalar_one()
assert log.coin_balance_before == 100 and log.reason.endswith("dryrun") # 审计标 dryrun
assert db.execute(select(CoinTransaction).where(
CoinTransaction.user_id == old, CoinTransaction.biz_type == "inactivity_reset")).first() is None # 无流水
assert stats["warned"] == 0 # dry-run 不预警
assert db.execute(select(InactivityNotificationLog).where(
InactivityNotificationLog.user_id == warn_uid)).first() is None
assert stats["cleared"] == 1 # dry-run:cleared=记了几条
# 再跑一次 → 不重复记(dedup),余额仍原封
inactivity.run_once(db, notifier=LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today, dry_run=True)
assert len(db.execute(select(InactivityResetLog).where(
InactivityResetLog.user_id == old)).scalars().all()) == 1
assert db.get(CoinAccount, old).coin_balance == 100
finally:
db.rollback()
db.close()
def test_admin_list_users_last_active_ignores_login() -> None:
"""admin 用户列表 last_active_at 改用共享口径:登录不算活跃(baseline=created_at)、只认活跃事件。"""
from app.admin.repositories import queries
db = SessionLocal()
try:
created = datetime(2026, 1, 1, tzinfo=timezone.utc)
uid = _new_user(db, created_at=created)
u = db.get(User, uid)
u.last_login_at = datetime(2026, 6, 1, tzinfo=timezone.utc) # 登录很新、但无任何活跃事件
db.commit()
phone = db.get(User, uid).phone
users, _cursor, _total = queries.list_users(db, phone=phone)
item = next(x for x in users if x.id == uid)
assert activity.norm_utc(item.last_active_at) == created # 登录不算 → last_active=created_at
finally:
db.rollback()
db.close()
def test_run_warn_isolates_notifier_failure_and_does_not_block_reset() -> None:
"""单用户通知器抛错:预警计 warn_failed、不外抛,且清零(reset)照常执行。"""
from app.models.wallet import CoinAccount
from app.repositories import inactivity
class BoomNotifier:
channel = "log"
def warn(self, *, user_id, coin, cash_cents, stage, days_until_reset) -> str:
raise RuntimeError("push service down")
db = SessionLocal()
try:
today = date(2026, 2, 1)
warn_uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=10) # 10天→预警
clear_uid = _new_user(db, created_at=datetime(2026, 1, 5, tzinfo=timezone.utc), coin=10) # 27天→清零
db.commit()
stats = inactivity.run_once(db, notifier=BoomNotifier(), reset_days=15,
warn_stages=[7, 2], today=today)
assert stats["warned"] == 0 and stats["warn_failed"] >= 1 # 预警失败被隔离
assert stats["cleared"] == 1 # 关键:清零没被阻塞
assert db.get(CoinAccount, clear_uid).coin_balance == 0
assert db.get(CoinAccount, warn_uid).coin_balance == 10 # 预警用户不动钱
# 预警失败已回滚,不留半条 notification_log
from sqlalchemy import select
assert db.execute(select(InactivityNotificationLog).where(
InactivityNotificationLog.user_id == warn_uid)).first() is None
finally:
db.rollback()
db.close()
def test_run_once_reset_runs_even_if_warn_phase_throws(monkeypatch) -> None:
"""预警整段异常(如候选查询失败)也绝不阻塞清零。"""
from app.integrations.notifier import LogNotifier
from app.models.wallet import CoinAccount
from app.repositories import inactivity
def boom(*a, **k):
raise RuntimeError("warn phase blew up")
monkeypatch.setattr(inactivity, "run_warn_once", boom)
db = SessionLocal()
try:
today = date(2026, 2, 1)
clear_uid = _new_user(db, created_at=datetime(2026, 1, 5, tzinfo=timezone.utc), coin=10)
db.commit()
stats = inactivity.run_once(db, notifier=LogNotifier(), reset_days=15,
warn_stages=[7, 2], today=today)
assert stats["cleared"] == 1
assert db.get(CoinAccount, clear_uid).coin_balance == 0
finally:
db.rollback()
db.close()
+358
View File
@@ -0,0 +1,358 @@
"""业务事件 → 站内通知 + 厂商推送 联动测试(services/notification_events)。
覆盖 PRD 六类真实触发:
#3 提现成功 / #4 提现失败(含审核拒绝) / #9 官方回复 / #10 反馈奖励 /
#11 爆料审核通过 / #12 好友下单到账。
厂商推送不真发:测试环境无凭据默认跳过;推送链路用 monkeypatch 捕获/注错验证
有设备则推推挂了业务不受影响wxpay 网络调用照旧全 monkeypatch
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo
from app.core.rewards import INVITE_COMPARE_REWARD_CENTS, PRICE_REPORT_REWARD_COINS
from app.core.security import decode_token, hash_password
from app.db.session import SessionLocal
from app.models.feedback import Feedback
from app.models.price_report import PriceReport
from app.models.wallet import CoinAccount, WithdrawOrder
from app.repositories import device as device_repo
from app.repositories import wallet as crud_wallet
from app.services import notification_events
# ===== 用户侧 helpers(同 test_withdraw / test_notifications)=====
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 _uid(token: str) -> int:
return int(decode_token(token, expected_type="access")["sub"])
def _notifications(client: TestClient, token: str) -> list[dict]:
r = client.get("/api/v1/notifications?pageSize=50", headers=_auth(token))
assert r.status_code == 200, r.text
return r.json()["items"]
def _seed_cash(client: TestClient, token: str, cents: int) -> None:
"""先访问 /account 触发建账户,再直接灌现金余额。"""
client.get("/api/v1/wallet/account", headers=_auth(token))
with SessionLocal() as db:
acc = db.get(CoinAccount, _uid(token))
acc.cash_balance_cents = cents
db.commit()
def _create_withdraw(client: TestClient, token: str, monkeypatch, cents: int = 50) -> str:
"""绑微信 + 发起提现(进入 reviewing),返回 out_bill_no。"""
monkeypatch.setattr(
"app.integrations.wxpay.code_to_userinfo",
lambda code: {"openid": f"openid_{_uid(token)}", "nickname": None, "avatar_url": None, "raw": {}},
)
_seed_cash(client, token, cents * 2)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": cents}, headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["status"] == "reviewing"
return r.json()["out_bill_no"]
# ===== admin 侧 helpers(同 test_admin_write)=====
@pytest.fixture()
def admin_client() -> TestClient:
return TestClient(admin_app)
@pytest.fixture()
def operator_token() -> str:
with SessionLocal() as db:
a = admin_repo.get_by_username(db, "ne_operator")
if a is None:
admin_repo.create_admin(db, username="ne_operator", password="pass1234", role="operator")
else:
a.password_hash = hash_password("pass1234")
a.role = "operator"
a.status = "active"
db.commit()
c = TestClient(admin_app)
return c.post(
"/admin/api/auth/login", json={"username": "ne_operator", "password": "pass1234"}
).json()["access_token"]
def _seed_feedback(uid: int) -> int:
with SessionLocal() as db:
fb = Feedback(user_id=uid, content="比价按钮找不到", contact="wx123", status="pending")
db.add(fb)
db.commit()
return fb.id
def _seed_price_report(uid: int, store: str = "蜀大侠火锅") -> int:
with SessionLocal() as db:
rep = PriceReport(
user_id=uid,
store_name=store,
reported_platform_id="mt",
reported_platform_name="美团",
reported_price_cents=990,
images=[],
status="pending",
)
db.add(rep)
db.commit()
return rep.id
# ===== #4 提现失败(审核拒绝路径,_refund_withdraw 收口)=====
def test_withdraw_reject_creates_failed_notification(client: TestClient, monkeypatch) -> None:
token = _login(client, "13800005001")
bill = _create_withdraw(client, token, monkeypatch)
with SessionLocal() as db:
crud_wallet.reject_withdraw(db, bill, "微信零钱未实名")
items = _notifications(client, token)
failed = [i for i in items if i["type"] == "withdraw_failed"]
assert len(failed) == 1
n = failed[0]
assert n["cashCents"] == 50
assert n["cashYuan"] == "0.50"
assert n["title"] == "提现失败,款项已退回"
assert n["extra"]["withdrawId"] == bill
assert n["isRead"] is False
rows = {r["label"]: r["value"] for r in n["infoRows"]}
assert rows["失败原因"] == "微信零钱未实名"
assert rows["退回说明"] == "款项已原路退回现金余额"
def test_withdraw_failed_event_dedup_single_unread(client: TestClient, monkeypatch) -> None:
"""同一提现单重复触发失败事件(并发查单等)→ 未读期间只落一条。"""
token = _login(client, "13800005002")
bill = _create_withdraw(client, token, monkeypatch)
with SessionLocal() as db:
crud_wallet.reject_withdraw(db, bill, "审核未通过")
order = db.execute(
select(WithdrawOrder).where(WithdrawOrder.out_bill_no == bill)
).scalar_one()
notification_events.notify_withdraw_failed(db, order) # 人为重复触发
items = [i for i in _notifications(client, token) if i["type"] == "withdraw_failed"]
assert len(items) == 1
# ===== #3 提现成功(查单归一化路径)=====
def test_withdraw_success_notification_on_status_query(client: TestClient, monkeypatch) -> None:
token = _login(client, "13800005003")
monkeypatch.setattr(
"app.integrations.wxpay.create_transfer",
lambda openid, amount_fen, out_bill_no, user_name=None: {
"status_code": 200,
"data": {"state": "WAIT_USER_CONFIRM", "package_info": "pkg", "transfer_bill_no": "tb"},
},
)
bill = _create_withdraw(client, token, monkeypatch)
with SessionLocal() as db:
crud_wallet.approve_withdraw(db, bill) # 审核通过 → 转账进 pending(等用户确认)
assert [i for i in _notifications(client, token) if i["type"] == "withdraw_success"] == []
# 用户确认后查单 → SUCCESS → success + 下发「提现到账」通知
monkeypatch.setattr(
"app.integrations.wxpay.query_transfer",
lambda out_bill_no: {"status_code": 200, "data": {"state": "SUCCESS"}},
)
r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token))
assert r.json()["status"] == "success"
ok = [i for i in _notifications(client, token) if i["type"] == "withdraw_success"]
assert len(ok) == 1
n = ok[0]
assert n["cashCents"] == 50
assert n["actionText"] is None # PRD:提现成功卡无操作行
rows = {r["label"]: r["value"] for r in n["infoRows"]}
assert rows["到账账户"] == "微信钱包"
assert "到账时间" in rows
# 再查一次(已终态,早退)→ 不重复下发
client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token))
assert len([i for i in _notifications(client, token) if i["type"] == "withdraw_success"]) == 1
# ===== #10 反馈奖励(admin 采纳发金币)=====
def test_feedback_approve_sends_reward_notification(
client: TestClient, admin_client: TestClient, operator_token: str
) -> None:
token = _login(client, "13800005004")
fb_id = _seed_feedback(_uid(token))
r = admin_client.post(
f"/admin/api/feedbacks/{fb_id}/approve",
json={"reward_coins": 300, "note": "好建议", "reply": "问题已修复上线,送您的金币请查收~"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
items = [i for i in _notifications(client, token) if i["type"] == "feedback_reward"]
assert len(items) == 1
n = items[0]
assert n["coins"] == 300
assert n["extra"]["feedbackId"] == str(fb_id)
rows = {r["label"]: r["value"] for r in n["infoRows"]}
assert rows["官方留言"] == "问题已修复上线,送您的金币请查收~" # PRD:发奖必带官方留言
assert "到账时间" in rows
# ===== #9 官方回复(admin 拒绝,原因/留言用户可见)=====
def test_feedback_reject_sends_reply_notification(
client: TestClient, admin_client: TestClient, operator_token: str
) -> None:
token = _login(client, "13800005005")
fb_id = _seed_feedback(_uid(token))
r = admin_client.post(
f"/admin/api/feedbacks/{fb_id}/reject",
json={"reason": "无法复现", "reply": "麻烦补个录屏,我们再看看~"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
items = [i for i in _notifications(client, token) if i["type"] == "feedback_reply"]
assert len(items) == 1
n = items[0]
assert n["title"] == "傻瓜比价官方回复了您的反馈"
assert n["extra"]["feedbackId"] == str(fb_id)
assert n["coins"] is None
# ===== #11 爆料审核通过(admin 通过发固定金币)=====
def test_price_report_approve_sends_notification(
client: TestClient, admin_client: TestClient, operator_token: str
) -> None:
token = _login(client, "13800005006")
rep_id = _seed_price_report(_uid(token), store="蜀大侠火锅")
r = admin_client.post(
f"/admin/api/price-reports/{rep_id}/approve", headers=_auth(operator_token)
)
assert r.status_code == 200, r.text
items = [i for i in _notifications(client, token) if i["type"] == "report_approved"]
assert len(items) == 1
n = items[0]
assert n["coins"] == PRICE_REPORT_REWARD_COINS
assert n["extra"]["reportId"] == str(rep_id)
rows = {r["label"]: r["value"] for r in n["infoRows"]}
assert "蜀大侠火锅" in rows["奖励说明"]
# ===== #12 好友下单到账(好友首次成功比价 → 通知邀请人)=====
def test_invite_compare_reward_sends_notification(client: TestClient) -> None:
a = _login(client, "13800005007")
b = _login(client, "13800005008")
code = client.get("/api/v1/invite/me", headers=_auth(a)).json()["invite_code"]
client.post("/api/v1/invite/bind", json={"invite_code": code}, headers=_auth(b))
r = client.post(
"/api/v1/compare/record",
json={"trace_id": "trace-notif-1", "status": "success"},
headers=_auth(b),
)
assert r.status_code == 200, r.text
items = [i for i in _notifications(client, a) if i["type"] == "invite_order_reward"]
assert len(items) == 1
n = items[0]
assert n["cashCents"] == INVITE_COMPARE_REWARD_CENTS
assert n["extra"]["inviteeNickname"] # 好友昵称兜底(昵称/尾号/「好友」)非空
# 被邀请人自己不收该通知
assert [i for i in _notifications(client, b) if i["type"] == "invite_order_reward"] == []
# 好友再比价 → 不再发奖也不再通知(发奖幂等 + 通知 dedup 双保险)
client.post(
"/api/v1/compare/record",
json={"trace_id": "trace-notif-2", "status": "success"},
headers=_auth(b),
)
assert len([i for i in _notifications(client, a) if i["type"] == "invite_order_reward"]) == 1
# ===== 厂商推送联动(有设备则推;推送失败不伤业务)=====
def _register_device(uid: int, vendor: str = "xiaomi", token: str = "regid-1") -> None:
with SessionLocal() as db:
device_repo.register_or_update(
db, user_id=uid, device_id=f"dev_{uid}", push_vendor=vendor, push_token=token
)
def test_push_sent_to_registered_device(client: TestClient, monkeypatch) -> None:
token = _login(client, "13800005009")
_register_device(_uid(token))
sent: list[dict] = []
monkeypatch.setattr(notification_events.vendor_push, "missing_settings", lambda vendor: [])
def _capture(vendor, push_token, *, title, body, extras=None, mock=False):
sent.append({"vendor": vendor, "token": push_token, "title": title, "body": body, "extras": extras})
return {"ok": True}
monkeypatch.setattr(notification_events.vendor_push, "send_notification", _capture)
bill = _create_withdraw(client, token, monkeypatch)
with SessionLocal() as db:
crud_wallet.reject_withdraw(db, bill, "微信零钱未实名")
assert len(sent) == 1
p = sent[0]
assert p["vendor"] == "xiaomi" and p["token"] == "regid-1"
assert p["title"] == "提现失败,款项已退回"
assert "0.50" in p["body"] and "微信零钱未实名" in p["body"]
# PRD §4 push 已读联动:extras 带 type + notificationId + 跳转参数
assert p["extras"]["type"] == "withdraw_failed"
assert p["extras"]["withdrawId"] == bill
nid = int(p["extras"]["notificationId"])
assert any(i["id"] == nid for i in _notifications(client, token))
def test_push_failure_does_not_break_business(client: TestClient, monkeypatch) -> None:
"""推送炸了(哪怕不是 VendorPushError)→ 提现拒绝照常退款,站内消息照常落库。"""
token = _login(client, "13800005010")
_register_device(_uid(token), token="regid-2")
monkeypatch.setattr(notification_events.vendor_push, "missing_settings", lambda vendor: [])
def _boom(*args, **kwargs):
raise RuntimeError("vendor api down")
monkeypatch.setattr(notification_events.vendor_push, "send_notification", _boom)
bill = _create_withdraw(client, token, monkeypatch)
with SessionLocal() as db:
crud_wallet.reject_withdraw(db, bill, "审核未通过")
r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token))
assert r.json()["status"] == "rejected" # 业务不受影响
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.json()["cash_balance_cents"] == 100 # 已退回(seed 100 扣 50 退 50)
assert len([i for i in _notifications(client, token) if i["type"] == "withdraw_failed"]) == 1
+251
View File
@@ -0,0 +1,251 @@
"""消息通知中心 3 接口(落库版)。
覆盖:空列表(虚拟数据已清除)列表字段/派生/排序/分页未读角标标记已读(ids / all /
幂等 / 参数校验)鉴权与用户隔离去重键部分唯一索引数据直接写 notification
(repositories/notification),不再有内存 mock
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.exc import IntegrityError
from app.core.security import decode_token
from app.db.session import SessionLocal
from app.repositories import notification as notif_repo
_CST = timezone(timedelta(hours=8))
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 _uid(token: str) -> int:
return int(decode_token(token, expected_type="access")["sub"])
def _seed_sample(token: str, type_key: str):
"""按类型插一条样例通知(复用 repo 的样例卡片内容),返回落库行。"""
with SessionLocal() as db:
return notif_repo.insert_sample(db, _uid(token), type_key)
def _seed(token: str, type_key: str, **kw):
"""按显式内容插一条通知(排序/分页用,可指定 sent_at)。"""
with SessionLocal() as db:
return notif_repo.create_notification(db, user_id=_uid(token), type_key=type_key, **kw)
def _fetch_all(client: TestClient, token: str) -> dict:
r = client.get("/api/v1/notifications?pageSize=100", headers=_auth(token))
assert r.status_code == 200, r.text
return r.json()
def test_requires_auth(client: TestClient) -> None:
assert client.get("/api/v1/notifications").status_code == 401
assert client.get("/api/v1/notifications/unread-count").status_code == 401
assert client.post("/api/v1/notifications/read", json={"all": True}).status_code == 401
def test_fresh_user_has_no_notifications(client: TestClient) -> None:
"""虚拟数据已清除:新用户初始为空列表、未读 0、角标隐藏。"""
token = _login(client, "13900010000")
data = _fetch_all(client, token)
assert data["items"] == []
assert data["total"] == 0
assert data["unreadCount"] == 0
assert data["hasMore"] is False
r = client.get("/api/v1/notifications/unread-count", headers=_auth(token))
assert r.json() == {"count": 0, "badgeText": None}
def test_list_item_fields_camel_case_and_derived(client: TestClient) -> None:
token = _login(client, "13900010001")
_seed_sample(token, "reward_expiring")
_seed_sample(token, "withdraw_success")
_seed_sample(token, "perm_accessibility")
_seed_sample(token, "feedback_reward")
items = _fetch_all(client, token)["items"]
assert len(items) == 4
# 字段按 PRD 契约 camelCase,卡片要素齐全
first = items[0]
for key in (
"id", "category", "categoryLabel", "type", "cardStyle", "title",
"coins", "cashCents", "cashYuan", "infoRows", "actionText",
"extra", "sentAt", "isRead",
):
assert key in first, f"missing field {key}"
assert "+08:00" in first["sentAt"] # 落库后仍恒带 +08:00
by_type = {i["type"]: i for i in items}
# 双金额卡:金币整数 + 现金两位小数;category/cardStyle/title/actionText 均由 catalog 派生
expiring = by_type["reward_expiring"]
assert expiring["category"] == "withdraw_assistant"
assert expiring["categoryLabel"] == "提现助手"
assert expiring["cardStyle"] == "dual_amount"
assert expiring["title"] == "金币现金奖励即将失效"
assert expiring["actionText"] == "立即激活您的收益"
assert isinstance(expiring["coins"], int)
assert expiring["cashCents"] == 1280
assert expiring["cashYuan"] == "12.80"
assert [row["label"] for row in expiring["infoRows"]] == ["过期说明", "过期时间"]
# 提现成功卡:无操作行、无金币
ws = by_type["withdraw_success"]
assert ws["actionText"] is None
assert ws["coins"] is None
# 权限异常卡带 permission 参数(客户端点击时实时检测用)
assert by_type["perm_accessibility"]["extra"] == {"permission": "accessibility"}
# 反馈奖励卡:官方留言必填(PRD §3)
reward = by_type["feedback_reward"]
assert any(row["label"] == "官方留言" and row["value"] for row in reward["infoRows"])
# 新插入默认未读
assert all(i["isRead"] is False for i in items)
def test_list_sorted_by_time_desc(client: TestClient) -> None:
"""全列表 sent_at 倒序(最新在前),不分组。"""
token = _login(client, "13900010002")
base = datetime(2026, 7, 1, 12, 0, tzinfo=_CST)
_seed(token, "withdraw_success", sent_at=base - timedelta(days=2))
newest = _seed(token, "invite_order_reward", sent_at=base)
_seed(token, "feedback_reply", sent_at=base - timedelta(days=1))
items = _fetch_all(client, token)["items"]
sent_ats = [i["sentAt"] for i in items]
assert sent_ats == sorted(sent_ats, reverse=True), "最新在前"
assert items[0]["id"] == newest.id
assert items[0]["type"] == "invite_order_reward"
def test_pagination(client: TestClient) -> None:
token = _login(client, "13900010003")
base = datetime(2026, 7, 1, 12, 0, tzinfo=_CST)
n = 12
for i in range(n):
_seed(token, "withdraw_success", sent_at=base - timedelta(minutes=i))
total = _fetch_all(client, token)["total"]
assert total == n
page_size = 5
seen_ids: list[int] = []
page = 1
while True:
r = client.get(
f"/api/v1/notifications?page={page}&pageSize={page_size}", headers=_auth(token)
)
assert r.status_code == 200
data = r.json()
assert data["page"] == page
assert data["pageSize"] == page_size
assert data["total"] == total
seen_ids.extend(i["id"] for i in data["items"])
if not data["hasMore"]:
assert len(data["items"]) <= page_size
break
assert len(data["items"]) == page_size
page += 1
assert len(seen_ids) == total
assert len(set(seen_ids)) == total, "翻页不重不漏"
# 超出末页 → 空页而非报错
r = client.get("/api/v1/notifications?page=99&pageSize=50", headers=_auth(token))
assert r.status_code == 200
assert r.json()["items"] == []
assert r.json()["hasMore"] is False
def test_unread_count_and_badge(client: TestClient) -> None:
token = _login(client, "13900010004")
for _ in range(3):
_seed_sample(token, "withdraw_success")
r = client.get("/api/v1/notifications/unread-count", headers=_auth(token))
assert r.json() == {"count": 3, "badgeText": "3"}
# 全部读完 → count=0,badgeText=null(整个角标隐藏)
client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token))
r = client.get("/api/v1/notifications/unread-count", headers=_auth(token))
assert r.json() == {"count": 0, "badgeText": None}
def test_mark_read_by_ids_idempotent(client: TestClient) -> None:
token = _login(client, "13900010005")
ids = [_seed_sample(token, "withdraw_success").id for _ in range(3)]
picked = ids[:2]
r = client.post("/api/v1/notifications/read", json={"ids": picked}, headers=_auth(token))
assert r.status_code == 200
assert r.json() == {"ok": True, "markedCount": 2, "unreadCount": 1}
# 列表状态同步翻转
items = {i["id"]: i for i in _fetch_all(client, token)["items"]}
assert all(items[i]["isRead"] for i in picked)
assert items[ids[2]]["isRead"] is False
# 重复置读 + 不存在的 id → 幂等,不报错
r = client.post(
"/api/v1/notifications/read", json={"ids": [*picked, 123456789]}, headers=_auth(token)
)
assert r.status_code == 200
assert r.json()["markedCount"] == 0
assert r.json()["unreadCount"] == 1
def test_mark_read_requires_ids_or_all(client: TestClient) -> None:
token = _login(client, "13900010006")
r = client.post("/api/v1/notifications/read", json={}, headers=_auth(token))
assert r.status_code == 400
r = client.post("/api/v1/notifications/read", json={"ids": []}, headers=_auth(token))
assert r.status_code == 400
def test_isolated_between_users(client: TestClient) -> None:
token_a = _login(client, "13900010007")
token_b = _login(client, "13900010008")
_seed_sample(token_a, "withdraw_success")
_seed_sample(token_b, "withdraw_success")
client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token_a))
assert client.get("/api/v1/notifications/unread-count", headers=_auth(token_a)).json()["count"] == 0
assert (
client.get("/api/v1/notifications/unread-count", headers=_auth(token_b)).json()["count"] == 1
), "A 清零不影响 B"
def test_dedup_key_blocks_duplicate_unread(client: TestClient) -> None:
"""同一 (user, type, dedup_key) 未读期间只允许一条(部分唯一索引拦重复未读)。"""
token = _login(client, "13900010009")
uid = _uid(token)
with SessionLocal() as db:
notif_repo.create_notification(
db, user_id=uid, type_key="perm_accessibility", dedup_key="accessibility"
)
# 同键第二条(仍未读)→ 唯一索引拦截
with pytest.raises(IntegrityError):
with SessionLocal() as db:
notif_repo.create_notification(
db, user_id=uid, type_key="perm_accessibility", dedup_key="accessibility"
)
# 只落了一条
assert _fetch_all(client, token)["total"] == 1
+234
View File
@@ -0,0 +1,234 @@
"""接口指标可观测(observe)单测:配置门槛 / 队列 / 中间件 / worker。
沿用仓库约定:TestClient + monkeypatch,绝不打真网络observe 默认关(conftest 未设
OBSERVE_*),需要开启的用例用 monkeypatch settings 单例属性
"""
from __future__ import annotations
import asyncio
import httpx
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core import observe, observe_worker
from app.core.config import settings
def test_observe_configured_requires_switch_and_creds(monkeypatch):
# 开关开 + endpoint(默认 localhost)+ user + password 齐全 → True
monkeypatch.setattr(settings, "OBSERVE_ENABLED", True)
monkeypatch.setattr(settings, "OBSERVE_USER", "u")
monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p")
assert settings.observe_configured is True
# 缺密码 → False
monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "")
assert settings.observe_configured is False
# 缺用户名 → False
monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p")
monkeypatch.setattr(settings, "OBSERVE_USER", "")
assert settings.observe_configured is False
# 开关关 → False(即便凭证齐全)
monkeypatch.setattr(settings, "OBSERVE_USER", "u")
monkeypatch.setattr(settings, "OBSERVE_ENABLED", False)
assert settings.observe_configured is False
def test_record_event_enqueues(monkeypatch):
q = asyncio.Queue(maxsize=10)
monkeypatch.setattr(observe, "_queue", q)
observe.record_event({"route": "/x"})
assert q.get_nowait() == {"route": "/x"}
def test_record_event_drops_when_full(monkeypatch):
q = asyncio.Queue(maxsize=1)
monkeypatch.setattr(observe, "_queue", q)
monkeypatch.setattr(observe, "_dropped", 0)
observe.record_event({"n": 1}) # 占满
observe.record_event({"n": 2}) # 满 → 丢弃当前,不抛异常
assert observe.take_dropped() == 1
assert observe.take_dropped() == 0 # 取出后清零
assert q.get_nowait() == {"n": 1} # 保留的是先到的
def _make_probe_app() -> FastAPI:
"""独立最小 app:只挂中间件 + 两个无鉴权路由,不碰真业务 DB/auth。"""
app = FastAPI()
app.add_middleware(observe.RequestMetricsMiddleware)
@app.get("/things/{tid}")
def get_thing(tid: str):
return {"tid": tid}
@app.get("/health")
def health():
return {"ok": True}
return app
@pytest.fixture
def observe_on(monkeypatch):
"""开启观测 + 换一个干净小队列,返回该队列供断言。"""
monkeypatch.setattr(settings, "OBSERVE_ENABLED", True)
monkeypatch.setattr(settings, "OBSERVE_USER", "u")
monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p")
q = asyncio.Queue(maxsize=100)
monkeypatch.setattr(observe, "_queue", q)
return q
def test_middleware_records_route_template(observe_on):
client = TestClient(_make_probe_app())
r = client.get("/things/42")
assert r.status_code == 200
evt = observe_on.get_nowait()
assert evt["route"] == "/things/{tid}" # 模板,不是 /things/42
assert evt["method"] == "GET"
assert evt["status"] == 200
assert evt["duration_ms"] >= 0
assert evt["service"] and "env" in evt and isinstance(evt["_timestamp"], int)
def test_middleware_skips_health(observe_on):
client = TestClient(_make_probe_app())
client.get("/health")
assert observe_on.empty()
def test_middleware_unmatched_route_is_normalized(observe_on):
client = TestClient(_make_probe_app())
r = client.get("/definitely-not-a-route")
assert r.status_code == 404
evt = observe_on.get_nowait()
assert evt["route"] == "__unmatched__"
assert evt["status"] == 404
def test_middleware_noop_when_disabled(monkeypatch):
monkeypatch.setattr(settings, "OBSERVE_ENABLED", False)
q = asyncio.Queue(maxsize=100)
monkeypatch.setattr(observe, "_queue", q)
client = TestClient(_make_probe_app())
client.get("/things/1")
assert q.empty() # 未配置观测 → 零入队
async def test_collect_batch_drains_up_to_batch_max(monkeypatch):
q = asyncio.Queue(maxsize=100)
monkeypatch.setattr(observe, "_queue", q)
monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.1)
monkeypatch.setattr(settings, "OBSERVE_BATCH_MAX", 200)
for i in range(3):
q.put_nowait({"n": i})
batch = await observe_worker._collect_batch()
assert [e["n"] for e in batch] == [0, 1, 2]
async def test_collect_batch_timeout_returns_empty(monkeypatch):
q = asyncio.Queue(maxsize=100)
monkeypatch.setattr(observe, "_queue", q)
monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.05)
batch = await observe_worker._collect_batch()
assert batch == []
async def test_post_batch_hits_json_ingest_url(monkeypatch):
monkeypatch.setattr(settings, "OBSERVE_ORG", "default")
monkeypatch.setattr(settings, "OBSERVE_STREAM", "app_requests")
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["json"] = request.content
return httpx.Response(200, json={"code": 200})
client = httpx.AsyncClient(
base_url="http://oo", transport=httpx.MockTransport(handler)
)
await observe_worker._post_batch(client, [{"route": "/x", "status": 200}])
await client.aclose()
assert captured["url"] == "http://oo/api/default/app_requests/_json"
assert b"/x" in captured["json"]
def test_start_observe_worker_noop_when_not_configured(monkeypatch):
monkeypatch.setattr(settings, "OBSERVE_ENABLED", False)
assert observe_worker.start_observe_worker() is None
async def test_run_loop_survives_post_failure(monkeypatch):
"""_post_batch 抛异常时,loop 不崩溃、继续处理后续批次(best-effort 契约)。"""
q = asyncio.Queue(maxsize=100)
monkeypatch.setattr(observe, "_queue", q)
monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.02)
monkeypatch.setattr(settings, "OBSERVE_BATCH_MAX", 200)
seen: list[list[int]] = []
async def boom(client, batch):
seen.append([e["n"] for e in batch])
raise RuntimeError("boom")
monkeypatch.setattr(observe_worker, "_post_batch", boom)
q.put_nowait({"n": 1})
task = asyncio.create_task(observe_worker._run_loop(None))
try:
for _ in range(50): # 轮询直到第 1 批被处理(失败),最多等 0.5s
await asyncio.sleep(0.01)
if seen:
break
q.put_nowait({"n": 2})
for _ in range(50): # 第 2 批被处理 → 证明失败后 loop 仍存活
await asyncio.sleep(0.01)
if len(seen) >= 2:
break
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert seen == [[1], [2]]
async def test_stop_flushes_remaining_and_closes_client(monkeypatch):
"""stop:cancel 后把剩余事件 best-effort 发出最后一批,并关闭 + 置空 client。"""
q = asyncio.Queue(maxsize=100)
monkeypatch.setattr(observe, "_queue", q)
monkeypatch.setattr(settings, "OBSERVE_ORG", "default")
monkeypatch.setattr(settings, "OBSERVE_STREAM", "app_requests")
monkeypatch.setattr(settings, "OBSERVE_BATCH_MAX", 200)
q.put_nowait({"n": 1})
q.put_nowait({"n": 2})
posted: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
posted["body"] = request.content
return httpx.Response(200, json={"code": 200})
client = httpx.AsyncClient(
base_url="http://oo", transport=httpx.MockTransport(handler)
)
monkeypatch.setattr(observe_worker, "_client", client)
async def _noop() -> None:
return None
task = asyncio.create_task(_noop())
await observe_worker.stop_observe_worker(task)
assert b'"n"' in posted["body"] # 关停时把剩余事件发了出去
assert observe_worker._client is None # client 已关闭并置空
def test_app_has_metrics_middleware():
from app.main import app
names = [m.cls.__name__ for m in app.user_middleware]
assert "RequestMetricsMiddleware" in names
+454
View File
@@ -0,0 +1,454 @@
"""厂商推送(5 家)+ 推送测试三件套。
覆盖:华为 Push Kit 发送链路(OAuth + messages:send payload)send_notification 通用入口
mock 模式/push/vendors 配置状态/push/templates 模板渲染/push/test
mock/真发/变量覆盖/站内联动/设备反查/参数校验厂商 HTTP 全部 monkeypatch,不真发
"""
from __future__ import annotations
import json
import pytest
from fastapi.testclient import TestClient
from app.integrations import vendor_push
_ALL_VENDOR_SETTINGS = [key for keys in vendor_push.REQUIRED_SETTINGS.values() for key in keys]
class _Resp:
status_code = 200
text = "{}"
def __init__(self, data: dict) -> None:
self._data = data
def json(self) -> dict:
return self._data
@pytest.fixture()
def _no_vendor_creds(monkeypatch) -> None:
"""把 5 家厂商凭据全部清空(隔离本机 .env 里已填的真实密钥,保证用例确定性)。"""
for key in _ALL_VENDOR_SETTINGS:
monkeypatch.setattr(vendor_push.settings, key, "")
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}"}
# ---------------------------------------------------------------------------
# integrations.vendor_push:华为链路 + 通用入口
# ---------------------------------------------------------------------------
def test_huawei_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.HUAWEI_PUSH_TOKEN_ENDPOINT:
return _Resp({"access_token": "hw-access", "expires_in": 3600})
return _Resp({"code": "80000000", "msg": "Success", "requestId": "req-1"})
monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_ID", "10086001")
monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_SECRET", "hw-secret")
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
data = vendor_push.send_notification(
"huawei",
"hw-token",
title="测试标题",
body="测试内容",
extras={"type": "withdraw_success", "notificationId": "90001"},
)
assert data["code"] == "80000000"
# OAuth:client_id 即 AppId
assert calls[0]["url"] == vendor_push.settings.HUAWEI_PUSH_TOKEN_ENDPOINT
assert calls[0]["data"]["grant_type"] == "client_credentials"
assert calls[0]["data"]["client_id"] == "10086001"
# 发送:v1 messages:send,Bearer 鉴权,token 数组 + data 透传 extras
# (消息中心推送带 notificationId → data 额外补 notif_id/notif_type 点击路由别名,
# 点击时 HMS 把 data 键值对注入启动 intent,客户端首选这两个键落地)
assert calls[1]["url"].endswith("/v1/10086001/messages:send")
assert calls[1]["headers"]["Authorization"] == "Bearer hw-access"
message = calls[1]["json"]["message"]
assert message["token"] == ["hw-token"]
assert message["android"]["notification"]["title"] == "测试标题"
assert message["android"]["notification"]["click_action"] == {"type": 3}
assert json.loads(message["data"]) == {
"type": "withdraw_success",
"notificationId": "90001",
"notif_id": "90001",
"notif_type": "withdraw_success",
}
def test_huawei_non_success_code_raises(monkeypatch) -> None:
vendor_push._token_cache.clear()
def _fake_request(method, url, **kwargs): # noqa: ANN001
if url == vendor_push.settings.HUAWEI_PUSH_TOKEN_ENDPOINT:
return _Resp({"access_token": "hw-access", "expires_in": 3600})
return _Resp({"code": "80300007", "msg": "all tokens are invalid"})
monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_ID", "10086001")
monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_SECRET", "hw-secret")
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
with pytest.raises(vendor_push.VendorPushError, match="huawei push failed"):
vendor_push.send_notification("huawei", "bad-token", title="t", body="b")
def test_vendor_aliases_normalize() -> None:
assert vendor_push.normalize_vendor("华为") == "huawei"
assert vendor_push.normalize_vendor("HMS") == "huawei"
assert vendor_push.normalize_vendor("荣耀") == "honor"
assert vendor_push.normalize_vendor("小米") == "xiaomi"
assert vendor_push.SUPPORTED_VENDORS == {"honor", "huawei", "xiaomi", "oppo", "vivo"}
def test_send_notification_mock_skips_http(monkeypatch) -> None:
def _boom(*args, **kwargs): # noqa: ANN001, ANN002, ANN003
raise AssertionError("mock 模式不应发起任何 HTTP 请求")
monkeypatch.setattr(vendor_push.httpx, "request", _boom)
data = vendor_push.send_notification(
"oppo", "any-token", title="标题", body="正文", extras={"type": "push_test"}, mock=True
)
assert data == {
"mock": True,
"vendor": "oppo",
"title": "标题",
"body": "正文",
"extras": {"type": "push_test"},
}
def test_send_notification_rejects_unknown_vendor() -> None:
with pytest.raises(vendor_push.VendorPushError, match="unsupported push vendor"):
vendor_push.send_notification("nokia", "t", title="a", body="b", mock=True)
with pytest.raises(vendor_push.VendorPushError, match="token is empty"):
vendor_push.send_notification("huawei", " ", title="a", body="b", mock=True)
def test_accessibility_wrapper_keeps_legacy_extras(monkeypatch) -> None:
"""旧入口 send_accessibility_disabled 仍传 {"type":"accessibility_disabled"}(worker 兼容)。"""
captured: dict = {}
def _fake_request(method, url, **kwargs): # noqa: ANN001
captured.update(method=method, url=url, **kwargs)
return _Resp({"code": 0, "result": "ok"})
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret")
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_PARAM_JSON", "")
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_TITLE", "")
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_DESCRIPTION", "")
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
vendor_push.send_accessibility_disabled("xiaomi", "regid-1")
assert json.loads(captured["data"]["payload"]) == {"type": "accessibility_disabled"}
def test_oppo_payload_includes_new_message_category(monkeypatch) -> None:
"""OPPO 新消息分类:配置了 channel_id/category 时随通知体下发(2024-11 新规必带)。"""
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.settings, "OPPO_PUSH_CHANNEL_ID", "push_oplus_category_content")
monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_CATEGORY", "MARKETING")
monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_NOTIFY_LEVEL", 0)
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
vendor_push.send_notification(
"oppo", "oppo-regid", title="标题", body="正文", extras={"type": "push_test"}
)
notification = json.loads(calls[1]["data"]["message"])["notification"]
assert notification["channel_id"] == "push_oplus_category_content"
assert notification["category"] == "MARKETING"
assert "notify_level" not in notification # 0=不传,走 OPPO 默认
def test_oppo_payload_omits_category_when_unconfigured(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.settings, "OPPO_PUSH_CHANNEL_ID", "")
monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_CATEGORY", "")
monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_NOTIFY_LEVEL", 0)
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
vendor_push.send_notification(
"oppo", "oppo-regid", title="标题", body="正文", extras={"type": "push_test"}
)
notification = json.loads(calls[1]["data"]["message"])["notification"]
assert "channel_id" not in notification
assert "category" not in notification
# ---------------------------------------------------------------------------
# /api/v1/push 三件套
# ---------------------------------------------------------------------------
def test_vendors_status_reports_missing_keys(client: TestClient, monkeypatch, _no_vendor_creds) -> None:
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret")
token = _login(client, "13900011001")
r = client.get("/api/v1/push/vendors", headers=_auth(token))
assert r.status_code == 200
vendors = {v["vendor"]: v for v in r.json()["vendors"]}
assert list(vendors) == ["honor", "huawei", "xiaomi", "oppo", "vivo"]
assert vendors["xiaomi"]["configured"] is True
assert vendors["xiaomi"]["missingKeys"] == []
assert vendors["huawei"]["configured"] is False
assert vendors["huawei"]["missingKeys"] == ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"]
assert vendors["honor"]["label"] == "荣耀"
assert vendors["vivo"]["missingKeys"] == [
"VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET",
]
def test_templates_render_all_13_types(client: TestClient) -> None:
token = _login(client, "13900011002")
r = client.get("/api/v1/push/templates", headers=_auth(token))
assert r.status_code == 200
templates = r.json()["templates"]
assert len(templates) == 13
by_type = {t["type"]: t for t in templates}
ws = by_type["withdraw_success"]
assert ws["pushTitle"] == "提现到账提醒"
assert ws["pushBodySample"] == "¥0.50已存入您的微信钱包,点击查看到账详情"
assert ws["variables"] == ["amount"]
expiring = by_type["reward_expiring"]
assert "86金币" in expiring["pushBodySample"]
assert "{coins}" in expiring["pushBodyTemplate"]
assert expiring["sampleVars"]["cash"] == "12.80"
# 权限类标题按类型写死功能名
assert by_type["perm_overlay"]["pushTitle"] == "检测到您的比价按钮已失效"
def test_push_test_mock_renders_template(client: TestClient, _no_vendor_creds) -> None:
token = _login(client, "13900011003")
r = client.post(
"/api/v1/push/test",
json={"vendor": "华为", "pushToken": "hw-token-1", "type": "withdraw_success"},
headers=_auth(token),
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["mock"] is True
assert body["vendor"] == "huawei" # 中文别名已归一化
assert body["title"] == "提现到账提醒"
assert body["body"] == "¥0.50已存入您的微信钱包,点击查看到账详情"
assert body["extras"] == {"type": "withdraw_success"}
assert body["missingKeys"] == ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"]
assert body["vendorResponse"] is None
def test_push_test_vars_override(client: TestClient, _no_vendor_creds) -> None:
token = _login(client, "13900011004")
r = client.post(
"/api/v1/push/test",
json={
"vendor": "xiaomi",
"pushToken": "xm-1",
"type": "invite_order_reward",
"vars": {"nickname": "小王", "amount": "6.66"},
},
headers=_auth(token),
)
assert r.status_code == 200
assert r.json()["body"] == "您的好友「小王」完成首次下单,6.66元现金已到账"
def test_push_test_generic_copy_without_type(client: TestClient, _no_vendor_creds) -> None:
token = _login(client, "13900011005")
r = client.post(
"/api/v1/push/test",
json={"vendor": "oppo", "pushToken": "op-1"},
headers=_auth(token),
)
assert r.status_code == 200
body = r.json()
assert body["extras"]["type"] == "push_test"
assert "OPPO" in body["body"]
def test_push_test_create_notification_links_message_center(
client: TestClient, _no_vendor_creds
) -> None:
token = _login(client, "13900011006")
before = client.get("/api/v1/notifications/unread-count", headers=_auth(token)).json()["count"]
r = client.post(
"/api/v1/push/test",
json={
"vendor": "vivo",
"pushToken": "vv-1",
"type": "feedback_reward",
"createNotification": True,
},
headers=_auth(token),
)
assert r.status_code == 200
body = r.json()
nid = body["notificationId"]
assert isinstance(nid, int)
assert body["extras"]["notificationId"] == str(nid)
assert body["extras"]["type"] == "feedback_reward"
assert body["extras"]["feedbackId"] # 业务参数一并带上,客户端可直达反馈详情
# 站内多了一条未读;按 push extras 的 id 置读 → 闭环
after = client.get("/api/v1/notifications/unread-count", headers=_auth(token)).json()["count"]
assert after == before + 1
r = client.post("/api/v1/notifications/read", json={"ids": [nid]}, headers=_auth(token))
assert r.json()["markedCount"] == 1
def test_push_test_real_send_requires_credentials(client: TestClient, _no_vendor_creds) -> None:
token = _login(client, "13900011007")
r = client.post(
"/api/v1/push/test",
json={"vendor": "huawei", "pushToken": "hw-1", "mock": False},
headers=_auth(token),
)
assert r.status_code == 400
assert "HUAWEI_PUSH_APP_ID" in r.json()["detail"]
def test_push_test_real_send_xiaomi(client: TestClient, monkeypatch, _no_vendor_creds) -> 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-real"}})
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret")
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
token = _login(client, "13900011008")
r = client.post(
"/api/v1/push/test",
json={
"vendor": "xiaomi",
"pushToken": "xm-regid-9",
"type": "report_approved",
"mock": False,
},
headers=_auth(token),
)
assert r.status_code == 200, r.text
body = r.json()
assert body["mock"] is False
assert body["missingKeys"] == []
assert body["vendorResponse"]["data"]["id"] == "xm-real"
assert captured["data"]["registration_id"] == "xm-regid-9"
assert captured["data"]["title"] == "爆料审核通过"
assert "蜀大侠火锅" in captured["data"]["description"]
assert json.loads(captured["data"]["payload"]) == {"type": "report_approved"}
def test_push_test_real_send_vendor_error_maps_502(
client: TestClient, monkeypatch, _no_vendor_creds
) -> None:
def _fake_request(method, url, **kwargs): # noqa: ANN001
return _Resp({"code": 500, "result": "error", "reason": "invalid regid"})
monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret")
monkeypatch.setattr(vendor_push.httpx, "request", _fake_request)
token = _login(client, "13900011009")
r = client.post(
"/api/v1/push/test",
json={"vendor": "xiaomi", "pushToken": "bad", "mock": False},
headers=_auth(token),
)
assert r.status_code == 502
assert "厂商推送失败" in r.json()["detail"]
def test_push_test_resolves_token_from_registered_device(
client: TestClient, _no_vendor_creds
) -> None:
token = _login(client, "13900011010")
r = client.post(
"/api/v1/device/register",
json={"device_id": "dev-push-center-1", "push_vendor": "honor", "push_token": "honor-t1"},
headers=_auth(token),
)
assert r.status_code == 200, r.text
r = client.post(
"/api/v1/push/test",
json={"deviceId": "dev-push-center-1", "type": "perm_accessibility"},
headers=_auth(token),
)
assert r.status_code == 200, r.text
body = r.json()
assert body["vendor"] == "honor"
assert body["title"] == "检测到您的比价功能已失效"
def test_push_test_validation_errors(client: TestClient, _no_vendor_creds) -> None:
token = _login(client, "13900011011")
# 未知厂商
r = client.post(
"/api/v1/push/test",
json={"vendor": "nokia", "pushToken": "t"},
headers=_auth(token),
)
assert r.status_code == 400
# 未知类型
r = client.post(
"/api/v1/push/test",
json={"vendor": "xiaomi", "pushToken": "t", "type": "bogus"},
headers=_auth(token),
)
assert r.status_code == 400
assert "unknown notification type" in r.json()["detail"]
# 真发但没有 token 可用
r = client.post(
"/api/v1/push/test",
json={"vendor": "xiaomi", "mock": False},
headers=_auth(token),
)
assert r.status_code == 409