Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2461004aa5 | |||
| 2eb36b44c8 | |||
| 510df176b3 | |||
| 73970087ff | |||
| 9286b82b6d | |||
| f9a62bffbe | |||
| beadce31ed | |||
| f39467ec08 | |||
| 1f874819fd | |||
| c53ce896f7 | |||
| ed26935b14 | |||
| 0a8cab9e7e | |||
| 48037f03fd | |||
| 130a7dff29 | |||
| a86688ccfb | |||
| 061f6baaf1 | |||
| b395648b7c | |||
| dd15c5dc97 | |||
| e9fd51d119 | |||
| d6016c12f9 | |||
| a8ac5dc0c7 | |||
| 5c6840dd71 | |||
| 824045dd19 | |||
| 930eff822c | |||
| 285e46ebaf | |||
| 3630fb7b3a | |||
| 2ddea4159d | |||
| 37fd51a498 | |||
| fa4127b9e5 | |||
| 0cf5b3816f | |||
| 2236a8b3ee |
+65
-1
@@ -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
|
||||
|
||||
@@ -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,72 @@
|
||||
"""analytics_selfstat tables
|
||||
|
||||
Revision ID: 11c44afbea58
|
||||
Revises: admin_user_plain_password
|
||||
Create Date: 2026-07-08 16:32:49.351817
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '11c44afbea58'
|
||||
down_revision: str | Sequence[str] | None = 'admin_user_plain_password'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'analytics_selfstat',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('device_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('epoch_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('app_ver', sa.String(length=32), nullable=True),
|
||||
sa.Column('oem', sa.String(length=32), nullable=True),
|
||||
sa.Column('os', sa.String(length=32), nullable=True),
|
||||
sa.Column('batches_attempted', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('batches_ok', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('batches_fail', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('retries', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('queue_depth', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('sent_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
with op.batch_alter_table('analytics_selfstat', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_analytics_selfstat_created_at'), ['created_at'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_analytics_selfstat_device_id'), ['device_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_analytics_selfstat_epoch_id'), ['epoch_id'], unique=False)
|
||||
|
||||
op.create_table(
|
||||
'analytics_selfstat_event',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('snapshot_id', sa.Integer(), nullable=False),
|
||||
sa.Column('event', sa.String(length=64), nullable=False),
|
||||
sa.Column('attempted', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('drop_capture', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('delivered', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.Column('drop_undelivered', sa.BigInteger(), nullable=False, server_default='0'),
|
||||
sa.ForeignKeyConstraint(['snapshot_id'], ['analytics_selfstat.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
with op.batch_alter_table('analytics_selfstat_event', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_analytics_selfstat_event_event'), ['event'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_analytics_selfstat_event_snapshot_id'), ['snapshot_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('analytics_selfstat_event', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_event_snapshot_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_event_event'))
|
||||
op.drop_table('analytics_selfstat_event')
|
||||
with op.batch_alter_table('analytics_selfstat', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_epoch_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_device_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_created_at'))
|
||||
op.drop_table('analytics_selfstat')
|
||||
@@ -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,44 @@
|
||||
"""ad_ecpm_record.trace_id(展示收益归属到比价/领券 trace)
|
||||
|
||||
信息流(Draw)展示 eCPM 上报时带上本场比价/领券 trace_id,落此列;领券数据 / 比价记录看板
|
||||
按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端为 NULL。
|
||||
|
||||
本迁移原以 (11c44afbea58, merge_pages_override_coupon_slot) 为双亲、顺带收敛双 head,
|
||||
但与它并行落 main 的 merge_selfstat_coupon_slot 已用同一对双亲做了纯收敛 → 同一对
|
||||
父节点出现两个收敛点、main 上又成双 head。故重挂到该 merge 之后成单链(仅改链接、
|
||||
schema 改动不变;两文件都保留,已 stamp 在 merge 上的库可直接线性升级)。
|
||||
|
||||
Revision ID: ad_ecpm_trace_id
|
||||
Revises: merge_selfstat_coupon_slot
|
||||
Create Date: 2026-07-10
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "ad_ecpm_trace_id"
|
||||
down_revision: Union[str, Sequence[str], None] = "merge_selfstat_coupon_slot"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# SQLite 下 ADD COLUMN(可空)与 CREATE INDEX 均原生支持,无需 batch_alter_table
|
||||
# (同 ad_feed_reward_trace_id 迁移)。
|
||||
op.add_column(
|
||||
"ad_ecpm_record",
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_ad_ecpm_record_trace_id"),
|
||||
"ad_ecpm_record",
|
||||
["trace_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_ad_ecpm_record_trace_id"), table_name="ad_ecpm_record")
|
||||
op.drop_column("ad_ecpm_record", "trace_id")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""ad_reward_record.boost_round_id(金币膨胀「本轮累计」标签)
|
||||
|
||||
看完一条激励视频后客户端要弹「本轮累计获得 N 金币」,N 必须等于这一轮实际到账之和(否则用户
|
||||
会认为少发了钱)。单条到账额 reward-result 已经能给,但「一轮」的边界只有客户端知道(点「放弃
|
||||
赚钱」才算结束),客户端自己累加又会在进程被杀后丢失。
|
||||
|
||||
解法:客户端把轮次 id 随 mediaExtra 透传,穿山甲 S2S 原样带回,发奖时打在记录上;
|
||||
reward-result 按 (user_id, boost_round_id) 对 granted 记录求和返回 round_coin。
|
||||
|
||||
本列是**纯标签**:不参与发奖判定,发多少/发不发完全不受影响。客户端就算一直复用同一个 id,
|
||||
也只是把展示数字滚大,不产生任何新入账(求和的是已发生的发奖记录),无资损风险。
|
||||
|
||||
Revision ID: ad_reward_boost_round_id
|
||||
Revises: comparison_llm_cost
|
||||
Create Date: 2026-07-20
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "ad_reward_boost_round_id"
|
||||
down_revision: str | Sequence[str] | None = "comparison_llm_cost"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 可空、无默认:SQLite 原生支持 ADD COLUMN,不需要 batch_alter_table(同 comparison_llm_cost)。
|
||||
# 存量行留 NULL = 「不属于任何一轮」,求和时天然不参与,老客户端行为不变。
|
||||
op.add_column(
|
||||
"ad_reward_record",
|
||||
sa.Column("boost_round_id", sa.String(length=64), nullable=True),
|
||||
)
|
||||
# 求和恒带 user_id(轮 id 是客户端生成的,不能跨用户信任),故建复合索引而非单列
|
||||
op.create_index(
|
||||
"ix_ad_reward_user_boost_round",
|
||||
"ad_reward_record",
|
||||
["user_id", "boost_round_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ad_reward_user_boost_round", table_name="ad_reward_record")
|
||||
op.drop_column("ad_reward_record", "boost_round_id")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""analytics_event 活跃口径复合索引
|
||||
|
||||
Revision ID: analytics_active_idx
|
||||
Revises: 135e79414fd0
|
||||
Create Date: 2026-07-18 17:35:00.000000
|
||||
|
||||
给 analytics_event 加活跃口径热点复合索引 (event, page, user_id, created_at):
|
||||
activity.active_event_condition 按 event IN (home_visible ∪ 比价 ∪ 领券) 过滤后
|
||||
group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频活跃事件全表扫。
|
||||
(历史:早期首页可见用 event=show+page=home 组合,故索引含 page 列;现改单一 home_visible、
|
||||
不再按 page 过滤 → page 列成冗余,索引仍靠 event 前缀生效;如需更优可后续新迁移瘦成 (event,user_id,created_at)。)
|
||||
|
||||
⚠️ 本分支迁移树有**既有多头**:135e79414fd0(不活跃两表)与 phone_rebind_log 同从
|
||||
comparison_llm_cost 分叉,`alembic upgrade head` 会多头报错。本迁移挂在 135e79414fd0
|
||||
一侧;集成到 main 时需 `alembic merge` 合并 phone_rebind_log 那个头(与本迁移无关的既有问题)。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "analytics_active_idx"
|
||||
down_revision: Union[str, Sequence[str], None] = "135e79414fd0"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index(
|
||||
"ix_analytics_event_active",
|
||||
"analytics_event",
|
||||
["event", "page", "user_id", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_analytics_event_active", table_name="analytics_event")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""comparison_record: llm_cost_yuan + llm_price_snapshot(比价 LLM 调用成本 + 当时单价快照)
|
||||
|
||||
回填 llm_calls 时按「当时的价」逐模型算出本次比价 LLM 总成本(元),连同所用单价快照一起冻结到
|
||||
记录上;admin 比价记录详情展示实际成本(旧记录 NULL → 前端回退估算)。见 services/llm_cost.py。
|
||||
|
||||
Revision ID: comparison_llm_cost
|
||||
Revises: ad_ecpm_trace_id
|
||||
Create Date: 2026-07-13
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "comparison_llm_cost"
|
||||
down_revision: str | Sequence[str] | None = "ad_ecpm_trace_id"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 均可空、无索引;SQLite 原生支持 ADD COLUMN,无需 batch_alter_table(同 comparison_debug_fields)。
|
||||
op.add_column("comparison_record", sa.Column("llm_cost_yuan", sa.Float(), nullable=True))
|
||||
op.add_column("comparison_record", sa.Column("llm_price_snapshot", _JSONB, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("comparison_record", "llm_price_snapshot")
|
||||
op.drop_column("comparison_record", "llm_cost_yuan")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""coupon_claim_record 加 app_env 列(领券所属 session 环境;每券成功率表按它过滤 prod/dev)
|
||||
|
||||
Revision ID: coupon_claim_app_env
|
||||
Revises: coupon_session_platform_success
|
||||
Create Date: 2026-07-08 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "coupon_claim_app_env"
|
||||
down_revision: str | Sequence[str] | None = "coupon_session_platform_success"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("app_env", sa.String(length=16), nullable=True))
|
||||
batch_op.create_index(
|
||||
"ix_coupon_claim_record_app_env", ["app_env"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op:
|
||||
batch_op.drop_index("ix_coupon_claim_record_app_env")
|
||||
batch_op.drop_column("app_env")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""coupon_session 加 platform_success 列(本次至少领到一张的平台 id 列表)
|
||||
|
||||
供 admin「领券数据」算 ②整单成功率 / ③点位成功率(平台粒度)。数据落点:服务端 /step 逐帧
|
||||
按 trace_id 并集写入(见 app/repositories/coupon_state.merge_session_platform_success)。旧行 NULL
|
||||
视作空集,已建表环境靠它补列、全新环境顺序应用不重复加列。设计:docs/guides/领券成功率指标-设计与埋点.md。
|
||||
|
||||
Revision ID: coupon_session_platform_success
|
||||
Revises: admin_user_plain_password
|
||||
Create Date: 2026-07-07 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "coupon_session_platform_success"
|
||||
down_revision: str | Sequence[str] | None = "admin_user_plain_password"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 model 的 _JSON variant / 建表迁移)。
|
||||
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("platform_success", _JSON, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.drop_column("platform_success")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add direct vendor push fields
|
||||
|
||||
Revision ID: direct_vendor_push_fields
|
||||
Revises: jd_cps_order_fields
|
||||
Create Date: 2026-07-01 16:30:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "direct_vendor_push_fields"
|
||||
down_revision = "jd_cps_order_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("device_liveness") as batch_op:
|
||||
batch_op.add_column(sa.Column("push_vendor", sa.String(length=32), nullable=True))
|
||||
batch_op.add_column(sa.Column("push_token", sa.String(length=256), nullable=True))
|
||||
batch_op.create_index("ix_device_liveness_push_vendor", ["push_vendor"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("device_liveness") as batch_op:
|
||||
batch_op.drop_index("ix_device_liveness_push_vendor")
|
||||
batch_op.drop_column("push_token")
|
||||
batch_op.drop_column("push_vendor")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""下线签到膨胀:drop signin_boost_record
|
||||
|
||||
产品 2026-07 确认「固定 3000 金币的签到膨胀」从来不是设计内的口径 —— 奖励只有「签到」和
|
||||
「看视频」两种。签到弹窗里的「看广告膨胀」改与福利页看视频走同一条 reward_video 路径
|
||||
(按 eCPM 公式发,记在 ad_reward_record),signin_boost 场景整体摘除。
|
||||
|
||||
⚠️ **只 drop 这张表,不动 coin_transaction**:`biz_type='signin_boost'` 的金币流水是真发过的
|
||||
钱,账必须留得住(admin 大盘的 signin_boost_coin_total / signin_boost_watch_count 改为从
|
||||
coin_transaction 统计,继续能查回历史)。本表只是「哪天膨胀过」的业务留痕,金额与去向都能
|
||||
从流水还原,drop 掉不影响对账。
|
||||
|
||||
downgrade 只重建空表结构,**不恢复数据** —— 真要回滚得先从备份捞行。
|
||||
|
||||
Revision ID: drop_signin_boost_record
|
||||
Revises: ad_reward_boost_round_id
|
||||
Create Date: 2026-07-20
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "drop_signin_boost_record"
|
||||
down_revision: str | Sequence[str] | None = "ad_reward_boost_round_id"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("signin_boost_record", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_signin_boost_record_user_id"))
|
||||
op.drop_table("signin_boost_record")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 只还结构不还数据(见模块 docstring)
|
||||
op.create_table(
|
||||
"signin_boost_record",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("signin_date", sa.Date(), nullable=False),
|
||||
sa.Column("coin_awarded", sa.Integer(), nullable=False),
|
||||
sa.Column("ad_ref_id", sa.String(length=64), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("user_id", "signin_date", name="uq_signin_boost_user_date"),
|
||||
)
|
||||
with op.batch_alter_table("signin_boost_record", schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_signin_boost_record_user_id"), ["user_id"], unique=False
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""merge inactivity(analytics_active_idx) + phone_rebind_log heads
|
||||
|
||||
Revision ID: merge_active_phone
|
||||
Revises: analytics_active_idx, phone_rebind_log
|
||||
Create Date: 2026-07-18 18:52:34.001148
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'merge_active_phone'
|
||||
down_revision: Union[str, Sequence[str], None] = ('analytics_active_idx', 'phone_rebind_log')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,28 @@
|
||||
"""合并两个 alembic head:admin_user_pages_override(#126 权限)+ coupon_claim_app_env(领券成功率)。
|
||||
|
||||
两条迁移都从 admin_user_plain_password 分叉——#126 经 pull main 进入本分支,领券成功率为本分支新增——
|
||||
于是出现两个 head。本迁移仅把二者收敛成单 head,让 `alembic upgrade head`(单数,部署/run.sh 用)
|
||||
恢复正常;**不含任何表结构 / 数据改动**(纯 merge)。
|
||||
|
||||
Revision ID: merge_pages_override_coupon_slot
|
||||
Revises: admin_user_pages_override, coupon_claim_app_env
|
||||
Create Date: 2026-07-09 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = "merge_pages_override_coupon_slot"
|
||||
down_revision: str | Sequence[str] | None = (
|
||||
"admin_user_pages_override",
|
||||
"coupon_claim_app_env",
|
||||
)
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""纯合并 head,无 schema 改动。"""
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""拆回两个 head,无 schema 改动。"""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""合并两个 alembic head:11c44afbea58(#127 埋点健康度 selfstat)+ merge_pages_override_coupon_slot(#130 自带的合并迁移)。
|
||||
|
||||
三条分支都从 admin_user_plain_password 分叉(#126 权限 / #127 selfstat / #130 领券成功率)。
|
||||
#130 自带的 merge 创建时本地 main 尚无 #127 的 11c44afbea58,只收敛了 #126 + 自身两条,
|
||||
#130 合入后 main 上仍留两个 head → `alembic upgrade head`(单数,部署/run.sh 用)直接报错、服务起不来。
|
||||
本迁移仅把二者收敛成单 head;**不含任何表结构 / 数据改动**(纯 merge)。
|
||||
|
||||
Revision ID: merge_selfstat_coupon_slot
|
||||
Revises: 11c44afbea58, merge_pages_override_coupon_slot
|
||||
Create Date: 2026-07-10 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = "merge_selfstat_coupon_slot"
|
||||
down_revision: str | Sequence[str] | None = (
|
||||
"11c44afbea58",
|
||||
"merge_pages_override_coupon_slot",
|
||||
)
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""纯合并 head,无 schema 改动。"""
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""拆回两个 head,无 schema 改动。"""
|
||||
@@ -0,0 +1,32 @@
|
||||
"""合并两个 alembic head:drop_signin_boost_record(本分支)+ merge_active_phone(main)。
|
||||
|
||||
两条线同从 comparison_llm_cost 分叉——本分支的 ad_reward_boost_round_id → drop_signin_boost_record
|
||||
走「金币膨胀本轮累计 + 下线签到膨胀」;main 侧的 phone_rebind_log / analytics_active_idx 两支已由
|
||||
merge_active_phone 收敛。88f2380 把 main 合进本分支后,两条迁移线在 git 上汇合了、在 alembic 图上
|
||||
却没有,于是 `alembic upgrade head`(单数)报 "Multiple head revisions are present"——按 CLAUDE.md
|
||||
run.sh 启动即自动迁移,app server 会直接起不来。
|
||||
|
||||
本迁移仅把二者收敛成单 head;**不含任何表结构 / 数据改动**(纯 merge)。
|
||||
|
||||
Revision ID: merge_signin_boost_main
|
||||
Revises: drop_signin_boost_record, merge_active_phone
|
||||
Create Date: 2026-07-21 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
revision: str = "merge_signin_boost_main"
|
||||
down_revision: str | Sequence[str] | None = (
|
||||
"drop_signin_boost_record",
|
||||
"merge_active_phone",
|
||||
)
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""纯合并 head,无 schema 改动。"""
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""拆回两个 head,无 schema 改动。"""
|
||||
@@ -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')
|
||||
@@ -0,0 +1,32 @@
|
||||
"""phone_rebind_log 表(M2 换绑 30 天限制台账)
|
||||
|
||||
Revision ID: phone_rebind_log
|
||||
Revises: comparison_llm_cost
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "phone_rebind_log"
|
||||
down_revision = "comparison_llm_cost"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"phone_rebind_log",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("phone", sa.String(length=20), nullable=False),
|
||||
sa.Column("old_user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("new_user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("source", sa.String(length=32), nullable=False, server_default="wechat_conflict"),
|
||||
sa.Column("rebound_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_phone_rebind_log_phone", "phone_rebind_log", ["phone"])
|
||||
op.create_index("ix_phone_rebind_log_rebound_at", "phone_rebind_log", ["rebound_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_phone_rebind_log_rebound_at", table_name="phone_rebind_log")
|
||||
op.drop_index("ix_phone_rebind_log_phone", table_name="phone_rebind_log")
|
||||
op.drop_table("phone_rebind_log")
|
||||
@@ -26,9 +26,11 @@ from app.admin.routers.cps import router as cps_router
|
||||
from app.admin.routers.dashboard import router as dashboard_router
|
||||
from app.admin.routers.device_liveness import router as device_liveness_router
|
||||
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
|
||||
from app.admin.routers.analytics_health import router as analytics_health_router
|
||||
from app.admin.routers.event_logs import router as event_logs_router
|
||||
from app.admin.routers.feedback import router as feedback_router
|
||||
from app.admin.routers.feedback_qr import router as feedback_qr_router
|
||||
from app.admin.routers.huawei_review import router as huawei_review_router
|
||||
from app.admin.routers.onboarding import router as onboarding_router
|
||||
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
|
||||
from app.admin.routers.price_report import router as price_report_router
|
||||
@@ -97,6 +99,7 @@ admin_app.include_router(withdraw_router)
|
||||
admin_app.include_router(price_report_router)
|
||||
admin_app.include_router(feedback_router)
|
||||
admin_app.include_router(event_logs_router)
|
||||
admin_app.include_router(analytics_health_router)
|
||||
admin_app.include_router(feedback_qr_router)
|
||||
admin_app.include_router(admins_router)
|
||||
admin_app.include_router(roles_router)
|
||||
@@ -107,4 +110,5 @@ admin_app.include_router(cps_router)
|
||||
admin_app.include_router(coupon_data_router)
|
||||
admin_app.include_router(ad_audit_router)
|
||||
admin_app.include_router(ad_config_router)
|
||||
admin_app.include_router(huawei_review_router)
|
||||
admin_app.include_router(ad_revenue_router)
|
||||
|
||||
@@ -31,6 +31,7 @@ PERMISSION_CATALOG: list[dict] = [
|
||||
{"group": "数据配置", "pages": [
|
||||
{"key": "config", "label": "系统配置"},
|
||||
{"key": "ad-revenue", "label": "广告配置"},
|
||||
{"key": "huawei-review", "label": "华为审核开关"},
|
||||
{"key": "users", "label": "用户管理"},
|
||||
]},
|
||||
{"group": "其他", "pages": [
|
||||
@@ -51,13 +52,14 @@ BUILTIN_ROLES: list[dict] = [
|
||||
{"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []},
|
||||
{"name": "operator", "label": "运营", "pages": [
|
||||
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
|
||||
"cps", "device-liveness", "price-reports", "feedbacks",
|
||||
"cps", "device-liveness", "price-reports", "feedbacks", "huawei-review",
|
||||
]},
|
||||
{"name": "finance", "label": "财务", "pages": [
|
||||
"dashboard", "ad-revenue-report", "cps", "withdraws",
|
||||
]},
|
||||
{"name": "tech", "label": "技术", "pages": [
|
||||
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
|
||||
"dashboard", "device-liveness", "config", "ad-revenue", "huawei-review",
|
||||
"event-logs", "audit-logs",
|
||||
]},
|
||||
]
|
||||
|
||||
|
||||
@@ -33,7 +33,29 @@ from app.admin.repositories import stats as admin_stats
|
||||
from app.core import rewards
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.models.user import User
|
||||
from app.repositories import ad_pangle_revenue
|
||||
from app.repositories import ad_pangle_revenue, app_config
|
||||
|
||||
# 已上线过的正式业务代码位要永久保留,避免运营切换当前配置后,历史报表把旧业务位误判成测试流量。
|
||||
_KNOWN_PROD_BUSINESS_CODE_IDS = frozenset({"104098712", "104099389"})
|
||||
|
||||
# 测试应用中实际承载业务链路的代码位。广告测试 demo 的插屏/半屏/信息流测试位不在这里,
|
||||
# 避免“业务口径”把开发诊断曝光混进客户端与穿山甲对账。
|
||||
_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"})
|
||||
|
||||
|
||||
def _business_code_ids(db: Session, app_env: str | None) -> set[str]:
|
||||
"""返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。"""
|
||||
prod_config = app_config.get_ad_config(db)
|
||||
prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | {
|
||||
str(prod_config.get(key) or "").strip()
|
||||
for key in ("reward_code_id", "compare_draw_code_id", "coupon_draw_code_id")
|
||||
}
|
||||
prod_ids.discard("")
|
||||
if app_env == "prod":
|
||||
return prod_ids
|
||||
if app_env == "test":
|
||||
return set(_TEST_BUSINESS_CODE_IDS)
|
||||
return prod_ids | set(_TEST_BUSINESS_CODE_IDS)
|
||||
|
||||
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
@@ -82,6 +104,7 @@ def ad_revenue_report(
|
||||
ad_type: str | None = None,
|
||||
feed_scene: str | None = None,
|
||||
app_env: str | None = None,
|
||||
revenue_scope: str = "all",
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
offset: int = 0,
|
||||
@@ -277,14 +300,18 @@ def ad_revenue_report(
|
||||
if feed_scene is not None:
|
||||
events = [e for e in events if e.get("feed_scene") == feed_scene]
|
||||
|
||||
# app_env 过滤(2026-06-29 新增能力,修隐患:测试应用上报的假 eCPM 如 ¥678 CPM 会污染正式收益合计/平均):
|
||||
# 显式传 "prod"/"test" 只看该环境;不传=全部(维持现状)。**不擅自把默认改成排除 test**——本地 dev 库多为
|
||||
# test 数据、默认排除会使本地报表空,且「正式报表是否含 test」属产品口径。建议前端报表页加 app_env 筛选器
|
||||
# (默认选 prod),或产品确认后再把默认改成排除 test。注:穿山甲后台收益列(total_pangle_*)暂未联动此过滤
|
||||
# (它是独立对照列,且 pangle 的 test 是真实小额、非客户端那种假值)。
|
||||
# app_env 过滤:显式传 "prod"/"test" 只看该环境;不传=全部。该参数也会传给下方穿山甲聚合,
|
||||
# 保证客户端预估与 GroMore 汇总使用同一应用环境口径。
|
||||
if app_env is not None:
|
||||
events = [e for e in events if e.get("app_env") == app_env]
|
||||
|
||||
# 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试
|
||||
# demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。
|
||||
business_code_ids: set[str] | None = None
|
||||
if revenue_scope == "business":
|
||||
business_code_ids = _business_code_ids(db, app_env)
|
||||
events = [e for e in events if e.get("our_code_id") in business_code_ids]
|
||||
|
||||
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
|
||||
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
|
||||
if sort == "ecpm":
|
||||
@@ -336,7 +363,13 @@ def ad_revenue_report(
|
||||
total_pangle_revenue_yuan: float | None = None
|
||||
total_pangle_api_revenue_yuan: float | None = None
|
||||
if pangle_filterable:
|
||||
pangle_aggs = ad_pangle_revenue.aggregate_by_date(db, date_from=date_from, date_to=date_to)
|
||||
pangle_aggs = ad_pangle_revenue.aggregate_by_date(
|
||||
db,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
app_env=app_env,
|
||||
our_code_ids=business_code_ids,
|
||||
)
|
||||
if pangle_aggs:
|
||||
by_date = {a["date"]: a for a in pangle_aggs}
|
||||
for d in daily:
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""埋点健康度聚合(埋点成功率 / 上报成功率)。
|
||||
|
||||
只存原始累计快照,查询时在 Python 侧差分聚合(admin 低频、量级小,跨 PG/SQLite 无方言坑;
|
||||
与 cps.py / coupon_data.py 同款约定)。差分按 (device_id, epoch_id, event) 分区、created_at
|
||||
升序,相邻做差、负值夹 0;每增量按其快照 created_at 归入北京天桶。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.analytics_selfstat import AnalyticsSelfStat as H
|
||||
from app.models.analytics_selfstat import AnalyticsSelfStatEvent as E
|
||||
|
||||
_COUNTS = ("attempted", "drop_capture", "delivered", "drop_undelivered")
|
||||
|
||||
|
||||
def diff_snapshots(rows: list[dict]) -> list[dict]:
|
||||
"""累计快照行 → 每快照增量行(纯逻辑)。
|
||||
|
||||
rows 每行含 device_id/epoch_id/event/created_at/app_ver/oem/os + 四个累计计数。
|
||||
返回每行含 dims + created_at + 四个增量 d_*(分区首行增量=累计值;负值夹 0)。
|
||||
"""
|
||||
parts: dict[tuple, list[dict]] = defaultdict(list)
|
||||
for r in rows:
|
||||
parts[(r["device_id"], r["epoch_id"], r["event"])].append(r)
|
||||
|
||||
out: list[dict] = []
|
||||
for group in parts.values():
|
||||
group.sort(key=lambda r: (r["created_at"], r.get("id", 0)))
|
||||
prev = {k: 0 for k in _COUNTS}
|
||||
for r in group:
|
||||
deltas = {f"d_{k}": max(0, int(r[k]) - prev[k]) for k in _COUNTS}
|
||||
out.append({
|
||||
"device_id": r["device_id"], "epoch_id": r["epoch_id"], "event": r["event"],
|
||||
"created_at": r["created_at"], "app_ver": r["app_ver"],
|
||||
"oem": r["oem"], "os": r["os"], **deltas,
|
||||
})
|
||||
prev = {k: int(r[k]) for k in _COUNTS}
|
||||
return out
|
||||
|
||||
|
||||
def _cn_day(dt: datetime) -> str:
|
||||
"""created_at(UTC 口径)→ 北京日期字符串 YYYY-MM-DD。naive 当 UTC,tz-aware 直接换算。"""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.astimezone(rewards.CN_TZ).date().isoformat()
|
||||
|
||||
|
||||
def _rates(sums: dict) -> dict:
|
||||
"""由四个增量和派生两段率(分母 0 → None)。"""
|
||||
persisted_denom = sums["attempted"]
|
||||
report_denom = sums["delivered"] + sums["drop_undelivered"]
|
||||
return {
|
||||
**sums,
|
||||
"track_success_rate": (
|
||||
(sums["attempted"] - sums["drop_capture"]) / persisted_denom
|
||||
if persisted_denom else None
|
||||
),
|
||||
"report_success_rate": (
|
||||
sums["delivered"] / report_denom if report_denom else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _sum_deltas(deltas: list[dict]) -> dict:
|
||||
return {k: sum(d[f"d_{k}"] for d in deltas) for k in _COUNTS}
|
||||
|
||||
|
||||
def _fetch_rows(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
|
||||
"""取 [from, to) 区间行 + 每分区在 from 左侧的最后一条基线行(供第一条区间增量做差)。"""
|
||||
cols = (
|
||||
H.id, H.device_id, H.epoch_id, E.event, H.created_at,
|
||||
H.app_ver, H.oem, H.os,
|
||||
E.attempted, E.drop_capture, E.delivered, E.drop_undelivered,
|
||||
)
|
||||
in_range = db.execute(
|
||||
select(*cols).join(E, E.snapshot_id == H.id)
|
||||
.where(H.created_at >= date_from, H.created_at < date_to)
|
||||
).mappings().all()
|
||||
|
||||
# 注:基线子查询无下界扫 from 左侧全量(spec §7 已接受的取舍;量级变大再上物化 rollup)。
|
||||
# 用 max(id) 而非 max(created_at) 选"最新一条":id 严格单调,避免 SQLite 秒级时间戳撞车时选歧义。
|
||||
sub = (
|
||||
select(H.device_id, H.epoch_id, E.event, func.max(H.id).label("max_id"))
|
||||
.join(E, E.snapshot_id == H.id)
|
||||
.where(H.created_at < date_from)
|
||||
.group_by(H.device_id, H.epoch_id, E.event)
|
||||
.subquery()
|
||||
)
|
||||
baseline = db.execute(
|
||||
select(*cols).join(E, E.snapshot_id == H.id).join(
|
||||
sub, sub.c.max_id == H.id
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
return [dict(r) for r in list(baseline) + list(in_range)]
|
||||
|
||||
|
||||
def _in_range_deltas(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
|
||||
"""差分后只保留 created_at ∈ [from, to) 的增量(基线行被差分用后丢弃)。
|
||||
|
||||
Python 侧过滤需对齐 tz 口径:SQLite 返回 naive UTC,PG 返回 aware UTC。
|
||||
统一转成 naive UTC 再比较,兼容两种后端。
|
||||
"""
|
||||
def _to_naive_utc(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is not None:
|
||||
return dt.astimezone(UTC).replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
from_naive = _to_naive_utc(date_from)
|
||||
to_naive = _to_naive_utc(date_to)
|
||||
deltas = diff_snapshots(_fetch_rows(db, date_from, date_to))
|
||||
return [d for d in deltas if from_naive <= _to_naive_utc(d["created_at"]) < to_naive]
|
||||
|
||||
|
||||
def overview(db: Session, date_from: datetime, date_to: datetime) -> dict:
|
||||
deltas = _in_range_deltas(db, date_from, date_to)
|
||||
return _rates(_sum_deltas(deltas))
|
||||
|
||||
|
||||
def trend(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
|
||||
deltas = _in_range_deltas(db, date_from, date_to)
|
||||
by_day: dict[str, list[dict]] = defaultdict(list)
|
||||
for d in deltas:
|
||||
by_day[_cn_day(d["created_at"])].append(d)
|
||||
return [
|
||||
{"day": day, **_rates(_sum_deltas(items))}
|
||||
for day, items in sorted(by_day.items())
|
||||
]
|
||||
|
||||
|
||||
def breakdown(db: Session, date_from: datetime, date_to: datetime, dim: str) -> list[dict]:
|
||||
if dim not in ("event", "app_ver", "oem"):
|
||||
raise ValueError(f"invalid dim: {dim!r}")
|
||||
deltas = _in_range_deltas(db, date_from, date_to)
|
||||
by_key: dict[str, list[dict]] = defaultdict(list)
|
||||
for d in deltas:
|
||||
by_key[d.get(dim) or "(unknown)"].append(d)
|
||||
rows = [{"key": key, **_rates(_sum_deltas(items))} for key, items in by_key.items()]
|
||||
rows.sort(key=lambda r: (r["report_success_rate"] is None, r["report_success_rate"] or 0.0))
|
||||
return rows
|
||||
@@ -5,17 +5,24 @@
|
||||
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
|
||||
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
|
||||
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
|
||||
- 另含 coupon_slot_report(数据源 coupon_claim_record):按 coupon_id「按券成功率」表,见设计 §13。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date as _date, datetime
|
||||
from datetime import UTC, datetime
|
||||
from datetime import date as _date
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.coupon_state import CouponSession
|
||||
from app.models.coupon_state import CouponClaimRecord, CouponSession
|
||||
from app.models.user import User
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform
|
||||
|
||||
_SLOT_OK = ("success", "already_claimed")
|
||||
_SLOT_TRIED = ("success", "already_claimed", "failed")
|
||||
|
||||
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
@@ -42,7 +49,53 @@ def _avg(vals: list[int]) -> int | None:
|
||||
return round(sum(vals) / len(vals)) if vals else None
|
||||
|
||||
|
||||
def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> dict:
|
||||
def _success_rates(rows: list) -> dict:
|
||||
"""平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md §3/§12):
|
||||
|
||||
- sel(s) = 勾选平台(`platforms` 空 → 全领三档 DEFAULT_PLATFORMS);
|
||||
- succ(s) = `platform_success` ∩ sel(至少领到一张的平台);
|
||||
- ② 整单成功率 = #{sel⊆succ 且 sel≠∅} / 发起数;
|
||||
- ③ 点位成功率 = Σ|succ| / Σ|sel|;per_platform[p] = 勾了 p 且成功 / 勾了 p。
|
||||
基数含全部 session(started/completed/failed/abandoned),与「发起数」同基数。
|
||||
"""
|
||||
started = len(rows)
|
||||
full_success = 0
|
||||
point_success = 0
|
||||
point_total = 0
|
||||
per_succ = {p: 0 for p in DEFAULT_PLATFORMS}
|
||||
per_total = {p: 0 for p in DEFAULT_PLATFORMS}
|
||||
for r in rows:
|
||||
sel = set(r.platforms) if r.platforms else set(DEFAULT_PLATFORMS)
|
||||
succ = set(r.platform_success or []) & sel
|
||||
point_success += len(succ)
|
||||
point_total += len(sel)
|
||||
if sel and succ == sel:
|
||||
full_success += 1
|
||||
for p in sel:
|
||||
if p in per_total: # 只统计三档已知平台;未知/非法平台 id 不进 per_platform
|
||||
per_total[p] += 1
|
||||
if p in succ:
|
||||
per_succ[p] += 1
|
||||
return {
|
||||
"full_success_count": full_success,
|
||||
"full_success_rate": round(full_success / started, 4) if started else None,
|
||||
"point_success_count": point_success,
|
||||
"point_total_count": point_total,
|
||||
"point_success_rate": round(point_success / point_total, 4) if point_total else None,
|
||||
"per_platform": {
|
||||
p: (round(per_succ[p] / per_total[p], 4) if per_total[p] else None)
|
||||
for p in DEFAULT_PLATFORMS
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _session_to_row(
|
||||
r,
|
||||
phone: str | None = None,
|
||||
nickname: str | None = None,
|
||||
ad_revenue_yuan: float = 0.0,
|
||||
point_stats: dict | None = None,
|
||||
) -> dict:
|
||||
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
|
||||
return {
|
||||
"id": r.id,
|
||||
@@ -60,10 +113,60 @@ def _session_to_row(r, phone: str | None = None, nickname: str | None = None) ->
|
||||
"app_env": r.app_env,
|
||||
"started_at": r.started_at,
|
||||
"claimed_count": r.claimed_count,
|
||||
"point_success_count": point_stats["succeeded"] if point_stats else None,
|
||||
"point_total_count": point_stats["tried"] if point_stats else None,
|
||||
"trace_url": r.trace_url,
|
||||
"ad_revenue_yuan": ad_revenue_yuan,
|
||||
}
|
||||
|
||||
|
||||
def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict[str, int]]:
|
||||
"""聚合查询批量返回逐场点位分数,不加载逐券明细。"""
|
||||
if not trace_ids:
|
||||
return {}
|
||||
succeeded = func.sum(case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0))
|
||||
rows = db.execute(
|
||||
select(
|
||||
CouponClaimRecord.trace_id,
|
||||
succeeded.label("succeeded"),
|
||||
func.count().label("tried"),
|
||||
)
|
||||
.where(
|
||||
CouponClaimRecord.trace_id.in_(trace_ids),
|
||||
CouponClaimRecord.status.in_(_SLOT_TRIED),
|
||||
)
|
||||
.group_by(CouponClaimRecord.trace_id)
|
||||
).all()
|
||||
return {
|
||||
trace_id: {"succeeded": int(success_count or 0), "tried": int(tried or 0)}
|
||||
for trace_id, success_count, tried in rows
|
||||
if trace_id is not None
|
||||
}
|
||||
|
||||
|
||||
def coupon_point_details(db: Session, *, trace_id: str) -> list[dict]:
|
||||
"""按单个 trace 查询逐券结果;仅在后台用户点击分数时调用。"""
|
||||
rows = db.execute(
|
||||
select(
|
||||
CouponClaimRecord.coupon_id,
|
||||
CouponClaimRecord.coupon_name,
|
||||
CouponClaimRecord.status,
|
||||
CouponClaimRecord.reason,
|
||||
)
|
||||
.where(CouponClaimRecord.trace_id == trace_id)
|
||||
.order_by(CouponClaimRecord.id)
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"coupon_id": coupon_id,
|
||||
"coupon_name": coupon_name,
|
||||
"status": status,
|
||||
"reason": reason,
|
||||
}
|
||||
for coupon_id, coupon_name, status, reason in rows
|
||||
]
|
||||
|
||||
|
||||
def _empty_result() -> dict:
|
||||
return {
|
||||
"summary": {
|
||||
@@ -84,6 +187,7 @@ def coupon_data_report(
|
||||
date_to: str,
|
||||
user: str | None = None,
|
||||
app_env: str | None = None,
|
||||
statuses: list[str] | None = None,
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
offset: int = 0,
|
||||
@@ -93,6 +197,8 @@ def coupon_data_report(
|
||||
|
||||
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
|
||||
- app_env:prod/dev 精确;None=全部。
|
||||
- statuses:领券状态多选(started/completed/failed/abandoned);None/空=全部。整个视图
|
||||
(汇总/成功率/趋势/明细)按选中状态算,与 app_env 同级过滤(方案 A)。
|
||||
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
|
||||
"""
|
||||
by_hour = granularity == "hour"
|
||||
@@ -115,6 +221,8 @@ def coupon_data_report(
|
||||
)
|
||||
if app_env is not None:
|
||||
stmt = stmt.where(CouponSession.app_env == app_env)
|
||||
if statuses:
|
||||
stmt = stmt.where(CouponSession.status.in_(statuses))
|
||||
if user_ids is not None:
|
||||
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
|
||||
rows = list(db.execute(stmt).scalars())
|
||||
@@ -131,6 +239,7 @@ def coupon_data_report(
|
||||
"p50_ms": _percentile(completed_elapsed, 50),
|
||||
"p95_ms": _percentile(completed_elapsed, 95),
|
||||
"p99_ms": _percentile(completed_elapsed, 99),
|
||||
**_success_rates(rows),
|
||||
}
|
||||
|
||||
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
|
||||
@@ -197,10 +306,18 @@ def coupon_data_report(
|
||||
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
|
||||
).all()
|
||||
}
|
||||
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page])
|
||||
point_stats_map = _point_scores_by_trace(db, [r.trace_id for r in page])
|
||||
items = []
|
||||
for r in page:
|
||||
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
|
||||
items.append(_session_to_row(r, phone, nickname))
|
||||
items.append(_session_to_row(
|
||||
r,
|
||||
phone,
|
||||
nickname,
|
||||
ad_revenue_yuan=rev_map.get(r.trace_id, 0.0),
|
||||
point_stats=point_stats_map.get(r.trace_id),
|
||||
))
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
@@ -222,4 +339,59 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
|
||||
total = db.execute(
|
||||
select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id)
|
||||
).scalar_one()
|
||||
return {"items": [_session_to_row(r) for r in rows], "total": int(total)}
|
||||
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows])
|
||||
return {
|
||||
"items": [
|
||||
_session_to_row(
|
||||
r,
|
||||
ad_revenue_yuan=rev_map.get(r.trace_id, 0.0),
|
||||
)
|
||||
for r in rows
|
||||
],
|
||||
"total": int(total),
|
||||
}
|
||||
|
||||
|
||||
def coupon_slot_report(
|
||||
db: Session, *, date_from: str, date_to: str, app_env: str | None = None
|
||||
) -> dict:
|
||||
"""按 coupon_id(具体券)聚合成功率(见 docs/guides/领券成功率指标-设计与埋点.md §13)。
|
||||
|
||||
数据源 coupon_claim_record(粒度=设备-天,唯一键 device+coupon+day)。
|
||||
- 尝试 = status ∈ {success, already_claimed, failed}(skipped 排除);
|
||||
- 成功 = status ∈ {success, already_claimed};成功率 = 成功/尝试;
|
||||
- claim_date 区间 + app_env(None=全部)过滤;按 tried 倒序返回。
|
||||
"""
|
||||
d_from = _date.fromisoformat(date_from)
|
||||
d_to = _date.fromisoformat(date_to)
|
||||
ok = case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0)
|
||||
stmt = (
|
||||
select(
|
||||
CouponClaimRecord.coupon_id,
|
||||
func.max(CouponClaimRecord.coupon_name).label("coupon_name"),
|
||||
func.count().label("tried"),
|
||||
func.sum(ok).label("succeeded"),
|
||||
)
|
||||
.where(
|
||||
CouponClaimRecord.claim_date >= d_from,
|
||||
CouponClaimRecord.claim_date <= d_to,
|
||||
CouponClaimRecord.status.in_(_SLOT_TRIED),
|
||||
)
|
||||
.group_by(CouponClaimRecord.coupon_id)
|
||||
)
|
||||
if app_env is not None:
|
||||
stmt = stmt.where(CouponClaimRecord.app_env == app_env)
|
||||
items = []
|
||||
for coupon_id, coupon_name, tried, succeeded in db.execute(stmt).all():
|
||||
tried = int(tried or 0)
|
||||
succeeded = int(succeeded or 0)
|
||||
items.append({
|
||||
"coupon_id": coupon_id,
|
||||
"coupon_name": coupon_name,
|
||||
"platform": coupon_id_to_platform(coupon_id),
|
||||
"tried": tried,
|
||||
"succeeded": succeeded,
|
||||
"success_rate": round(succeeded / tried, 4) if tried else None,
|
||||
})
|
||||
items.sort(key=lambda x: (-x["tried"], x["coupon_id"]))
|
||||
return {"items": items}
|
||||
|
||||
@@ -5,13 +5,12 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import Select, asc, case, desc, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.stats import COMPARE_START_EVENT, COUPON_START_EVENT
|
||||
from app.core import rewards
|
||||
from app.core.config import settings
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
@@ -32,9 +31,7 @@ from app.models.wallet import (
|
||||
InviteCashTransaction,
|
||||
WithdrawOrder,
|
||||
)
|
||||
|
||||
# 「最近活跃」计入的行为事件(与大盘 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")
|
||||
@@ -87,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 读)。
|
||||
|
||||
@@ -143,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()
|
||||
@@ -160,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)
|
||||
|
||||
@@ -190,16 +144,12 @@ def list_users(
|
||||
(口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
|
||||
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
|
||||
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
|
||||
# 最近活跃 = max(最近登录, 最近行为事件, 最近领券发起)。PG 用 GREATEST;SQLite 标量 max()
|
||||
# 任一参数 NULL 即返回 NULL,故 LEFT JOIN 未命中侧 coalesce 到 last_login_at 兜底
|
||||
# (注册即登录,该列恒非空)。派生表 1:1(按 user_id 聚合),outerjoin 不会放大行数,
|
||||
# offset_paginate 的 count 不受影响。
|
||||
ev_agg, eng_agg = _last_active_parts()
|
||||
greatest = func.greatest if db.get_bind().dialect.name == "postgresql" else func.max
|
||||
last_active = greatest(
|
||||
User.last_login_at,
|
||||
func.coalesce(ev_agg.c.last_at, User.last_login_at),
|
||||
func.coalesce(eng_agg.c.last_at, User.last_login_at),
|
||||
# 最近活跃 = max(注册时间, 最近行为事件, 最近领券发起)。baseline 由 last_login_at 改为 created_at
|
||||
#(登录不代表在用 App;口径统一到 activity.py,含 home_visible + 比价 + 领券,见 activity.ACTIVE_EVENTS)。
|
||||
# 未命中侧 coalesce 到 created_at(恒非空基线)。派生表 1:1,outerjoin 不放大行数。
|
||||
ev_agg, eng_agg = activity.last_active_subqueries(db)
|
||||
last_active = activity.last_active_expr(
|
||||
User.created_at, ev_agg, eng_agg, db.get_bind().dialect.name
|
||||
)
|
||||
stmt = (
|
||||
select(User)
|
||||
@@ -259,6 +209,45 @@ def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | P
|
||||
r.nickname = nick
|
||||
|
||||
|
||||
def _comparison_conditions(
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
phone: str | None = None,
|
||||
status: str | None = None,
|
||||
business_type: str | None = None,
|
||||
store: str | None = None,
|
||||
product: str | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
) -> list:
|
||||
"""比价列表与概览共用筛选条件;日期按北京自然日闭区间解释。"""
|
||||
conditions = []
|
||||
if user_id is not None:
|
||||
conditions.append(ComparisonRecord.user_id == user_id)
|
||||
if phone:
|
||||
conditions.append(
|
||||
ComparisonRecord.user_id.in_(
|
||||
select(User.id).where(User.phone.like(f"{phone}%"))
|
||||
)
|
||||
)
|
||||
if status:
|
||||
conditions.append(ComparisonRecord.status == status)
|
||||
if business_type:
|
||||
conditions.append(ComparisonRecord.business_type == business_type)
|
||||
if store:
|
||||
conditions.append(ComparisonRecord.store_name.like(f"%{store}%"))
|
||||
if product:
|
||||
conditions.append(ComparisonRecord.product_names.like(f"%{product}%"))
|
||||
beijing = ZoneInfo("Asia/Shanghai")
|
||||
if date_from is not None:
|
||||
start_utc = datetime.combine(date_from, time.min, tzinfo=beijing).astimezone(timezone.utc)
|
||||
conditions.append(ComparisonRecord.created_at >= start_utc)
|
||||
if date_to is not None:
|
||||
end_utc = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=beijing).astimezone(timezone.utc)
|
||||
conditions.append(ComparisonRecord.created_at < end_utc)
|
||||
return conditions
|
||||
|
||||
|
||||
def list_comparison_records(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -268,39 +257,119 @@ def list_comparison_records(
|
||||
business_type: str | None = None,
|
||||
store: str | None = None,
|
||||
product: str | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[ComparisonRecord], int | None, int]:
|
||||
"""admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛,
|
||||
store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。
|
||||
join User 取 phone/nickname 瞬态挂记录上。"""
|
||||
stmt = select(ComparisonRecord)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(ComparisonRecord.user_id == user_id)
|
||||
if phone:
|
||||
stmt = stmt.where(
|
||||
ComparisonRecord.user_id.in_(
|
||||
select(User.id).where(User.phone.like(f"{phone}%"))
|
||||
)
|
||||
)
|
||||
if status:
|
||||
stmt = stmt.where(ComparisonRecord.status == status)
|
||||
if business_type:
|
||||
stmt = stmt.where(ComparisonRecord.business_type == business_type)
|
||||
if store:
|
||||
stmt = stmt.where(ComparisonRecord.store_name.like(f"%{store}%"))
|
||||
if product:
|
||||
# 商品名搜 product_names 派生文本列(非 items JSON:SQLite 下 JSON 中文被转义无法直接 LIKE)。
|
||||
stmt = stmt.where(ComparisonRecord.product_names.like(f"%{product}%"))
|
||||
conditions = _comparison_conditions(
|
||||
user_id=user_id, phone=phone, status=status, business_type=business_type,
|
||||
store=store, product=product, date_from=date_from, date_to=date_to,
|
||||
)
|
||||
stmt = select(ComparisonRecord).where(*conditions)
|
||||
items, next_cursor, total = offset_paginate(
|
||||
db, stmt,
|
||||
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
|
||||
limit=limit, cursor=cursor,
|
||||
)
|
||||
_attach_user_info(db, items)
|
||||
# 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。
|
||||
# ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。
|
||||
rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items])
|
||||
for it in items:
|
||||
it.ad_revenue_yuan = rev.get(it.trace_id, 0.0)
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def _comparison_percentile(sorted_values: list[int], q: float) -> int | None:
|
||||
"""线性插值分位数(非负毫秒值四舍五入;单条数据返回自身)。"""
|
||||
if not sorted_values:
|
||||
return None
|
||||
if len(sorted_values) == 1:
|
||||
return sorted_values[0]
|
||||
index = (len(sorted_values) - 1) * q
|
||||
lower = int(index)
|
||||
upper = min(lower + 1, len(sorted_values) - 1)
|
||||
value = sorted_values[lower] * (upper - index) + sorted_values[upper] * (index - lower)
|
||||
return int(value + 0.5)
|
||||
|
||||
|
||||
def comparison_records_summary(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
phone: str | None = None,
|
||||
status: str | None = None,
|
||||
business_type: str | None = None,
|
||||
store: str | None = None,
|
||||
product: str | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
) -> dict:
|
||||
"""比价记录页概览聚合;主耗时均值及分位数只取成功记录。"""
|
||||
conditions = _comparison_conditions(
|
||||
user_id=user_id, phone=phone, status=status, business_type=business_type,
|
||||
store=store, product=product, date_from=date_from, date_to=date_to,
|
||||
)
|
||||
row = db.execute(
|
||||
select(
|
||||
func.count(ComparisonRecord.id),
|
||||
func.sum(case((ComparisonRecord.status.in_(("success", "failed")), 1), else_=0)),
|
||||
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
|
||||
func.avg(ComparisonRecord.llm_cost_yuan),
|
||||
func.sum(case((
|
||||
(ComparisonRecord.status == "success")
|
||||
& (ComparisonRecord.saved_amount_cents > 0), 1
|
||||
), else_=0)),
|
||||
func.sum(case((ComparisonRecord.status == "cancelled", 1), else_=0)),
|
||||
).where(*conditions)
|
||||
).one()
|
||||
started = int(row[0] or 0)
|
||||
completed = int(row[1] or 0)
|
||||
success = int(row[2] or 0)
|
||||
lower_price = int(row[4] or 0)
|
||||
cancelled = int(row[5] or 0)
|
||||
success_durations = sorted(db.execute(
|
||||
select(ComparisonRecord.total_ms).where(
|
||||
*conditions,
|
||||
ComparisonRecord.status == "success",
|
||||
ComparisonRecord.total_ms.is_not(None),
|
||||
)
|
||||
).scalars().all())
|
||||
cancelled_durations = sorted(db.execute(
|
||||
select(ComparisonRecord.total_ms).where(
|
||||
*conditions,
|
||||
ComparisonRecord.status == "cancelled",
|
||||
ComparisonRecord.total_ms.is_not(None),
|
||||
)
|
||||
).scalars().all())
|
||||
success_rate_denominator = started - cancelled
|
||||
return {
|
||||
"started": started,
|
||||
"completed": completed,
|
||||
"success": success,
|
||||
"success_rate": success / success_rate_denominator if success_rate_denominator else None,
|
||||
"avg_token_cost": float(row[3]) if row[3] is not None else None,
|
||||
"lower_price_rate": lower_price / success if success else None,
|
||||
"avg_duration_ms": (
|
||||
int(sum(success_durations) / len(success_durations) + 0.5)
|
||||
if success_durations else None
|
||||
),
|
||||
"p5_duration_ms": _comparison_percentile(success_durations, 0.05),
|
||||
"p50_duration_ms": _comparison_percentile(success_durations, 0.5),
|
||||
"p95_duration_ms": _comparison_percentile(success_durations, 0.95),
|
||||
"p99_duration_ms": _comparison_percentile(success_durations, 0.99),
|
||||
"cancelled": cancelled,
|
||||
"cancelled_rate": cancelled / started if started else None,
|
||||
"cancelled_p5_ms": _comparison_percentile(cancelled_durations, 0.05),
|
||||
"cancelled_p50_ms": _comparison_percentile(cancelled_durations, 0.5),
|
||||
"cancelled_p95_ms": _comparison_percentile(cancelled_durations, 0.95),
|
||||
}
|
||||
|
||||
|
||||
def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
|
||||
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。"""
|
||||
rec = db.get(ComparisonRecord, record_id)
|
||||
|
||||
+127
-25
@@ -6,10 +6,10 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from datetime import UTC, date, datetime, time, timedelta, timezone
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.coupon_data import _percentile
|
||||
@@ -25,7 +25,7 @@ from app.models.coupon_state import (
|
||||
from app.models.cps_order import CpsOrder
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.savings import SavingsRecord
|
||||
from app.models.signin import SigninBoostRecord, SigninRecord
|
||||
from app.models.signin import SigninRecord
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinTransaction, WithdrawOrder
|
||||
|
||||
@@ -37,14 +37,13 @@ REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward")
|
||||
# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。
|
||||
COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward")
|
||||
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
|
||||
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
|
||||
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
|
||||
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
|
||||
*REWARD_VIDEO_BIZ_TYPES,
|
||||
*COUPON_REWARD_BIZ_TYPES,
|
||||
*COMPARISON_REWARD_BIZ_TYPES,
|
||||
*EXCLUDED_REWARD_BIZ_TYPES,
|
||||
*UNCLASSIFIED_FEED_BIZ_TYPES,
|
||||
# 常规任务必须按明确来源相加;不能从全部正向流水反减排除项,否则新增广告/运营
|
||||
# biz_type 时会在排除清单更新前自动混入该桶。task_ 前缀在查询处单独覆盖现有及未来任务。
|
||||
REGULAR_TASK_EXACT_BIZ_TYPES = (
|
||||
"signin",
|
||||
"signin_boost",
|
||||
"price_report_reward",
|
||||
"feedback_reward",
|
||||
)
|
||||
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
|
||||
MEITUAN_CPS_SETTLED_STATUS = "6"
|
||||
@@ -61,7 +60,7 @@ def _beijing_today_start_utc() -> datetime:
|
||||
"""北京时间今天 0 点对应的 UTC 时刻(DAU / 今日新增按北京时区切天)。"""
|
||||
now_bj = datetime.now(_BEIJING)
|
||||
start_bj = now_bj.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return start_bj.astimezone(timezone.utc)
|
||||
return start_bj.astimezone(UTC)
|
||||
|
||||
|
||||
def today_dau(db: Session) -> int:
|
||||
@@ -94,8 +93,8 @@ def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime,
|
||||
"""
|
||||
start_bj = datetime.combine(date_from, time.min, tzinfo=_BEIJING)
|
||||
end_bj = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=_BEIJING)
|
||||
start_utc = start_bj.astimezone(timezone.utc)
|
||||
end_utc = end_bj.astimezone(timezone.utc)
|
||||
start_utc = start_bj.astimezone(UTC)
|
||||
end_utc = end_bj.astimezone(UTC)
|
||||
return (
|
||||
start_utc,
|
||||
end_utc,
|
||||
@@ -109,6 +108,23 @@ def _date_range(date_from: date, date_to: date) -> list[date]:
|
||||
return [date_from + timedelta(days=i) for i in range(days + 1)]
|
||||
|
||||
|
||||
def _duration_percentile(sorted_values: list[int], q: float) -> int | None:
|
||||
"""Linear-interpolated percentile with the same half-up rounding as Math.round."""
|
||||
if not sorted_values:
|
||||
return None
|
||||
if len(sorted_values) == 1:
|
||||
return sorted_values[0]
|
||||
index = (len(sorted_values) - 1) * q
|
||||
lower = int(index)
|
||||
upper = min(lower + 1, len(sorted_values) - 1)
|
||||
fraction = Decimal(str(index - lower))
|
||||
value = (
|
||||
Decimal(sorted_values[lower]) * (Decimal(1) - fraction)
|
||||
+ Decimal(sorted_values[upper]) * fraction
|
||||
)
|
||||
return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _id_set(db: Session, stmt) -> set[int]:
|
||||
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
|
||||
|
||||
@@ -242,16 +258,46 @@ def dashboard_overview(
|
||||
ComparisonRecord.created_at >= start_local,
|
||||
ComparisonRecord.created_at < end_local,
|
||||
)
|
||||
period_comparison_total = _count(ComparisonRecord, *period_comparison_conds)
|
||||
period_comparison_success = _count(
|
||||
ComparisonRecord,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status == "success",
|
||||
period_comparison_stats = db.execute(
|
||||
select(
|
||||
func.count(ComparisonRecord.id),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(ComparisonRecord.status.in_(("success", "failed")), 1),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case((ComparisonRecord.status == "cancelled", 1), else_=0)
|
||||
),
|
||||
0,
|
||||
),
|
||||
func.coalesce(
|
||||
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0),
|
||||
).where(*period_comparison_conds)
|
||||
).one()
|
||||
period_comparison_total = int(period_comparison_stats[0])
|
||||
period_comparison_completed = int(period_comparison_stats[1])
|
||||
period_comparison_cancelled = int(period_comparison_stats[2])
|
||||
period_comparison_success = int(period_comparison_stats[3])
|
||||
period_comparison_token_cost_yuan = float(period_comparison_stats[4])
|
||||
period_comparison_success_denominator = (
|
||||
period_comparison_total - period_comparison_cancelled
|
||||
)
|
||||
period_comparison_success_rate = (
|
||||
round(period_comparison_success / period_comparison_total, 4)
|
||||
if period_comparison_total
|
||||
else 0.0
|
||||
round(
|
||||
period_comparison_success / period_comparison_success_denominator,
|
||||
4,
|
||||
)
|
||||
if period_comparison_success_denominator > 0
|
||||
else None
|
||||
)
|
||||
period_saved_positive_count = _count(
|
||||
ComparisonRecord,
|
||||
@@ -282,6 +328,47 @@ def dashboard_overview(
|
||||
if period_avg_duration_ms is not None
|
||||
else None
|
||||
)
|
||||
completed_duration_conds = (
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status.in_(("success", "failed")),
|
||||
ComparisonRecord.total_ms.is_not(None),
|
||||
)
|
||||
if db.bind is not None and db.bind.dialect.name == "postgresql":
|
||||
period_median_duration_ms, period_p95_duration_ms = db.execute(
|
||||
select(
|
||||
func.percentile_cont(0.5).within_group(ComparisonRecord.total_ms),
|
||||
func.percentile_cont(0.95).within_group(ComparisonRecord.total_ms),
|
||||
).where(*completed_duration_conds)
|
||||
).one()
|
||||
period_median_duration_ms = (
|
||||
int(
|
||||
Decimal(str(period_median_duration_ms)).quantize(
|
||||
Decimal("1"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
if period_median_duration_ms is not None
|
||||
else None
|
||||
)
|
||||
period_p95_duration_ms = (
|
||||
int(
|
||||
Decimal(str(period_p95_duration_ms)).quantize(
|
||||
Decimal("1"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
if period_p95_duration_ms is not None
|
||||
else None
|
||||
)
|
||||
else:
|
||||
# SQLite 测试环境没有 percentile_cont;仅回退读取耗时单列,不加载完整记录。
|
||||
completed_durations = list(
|
||||
db.execute(
|
||||
select(ComparisonRecord.total_ms)
|
||||
.where(*completed_duration_conds)
|
||||
.order_by(ComparisonRecord.total_ms)
|
||||
).scalars()
|
||||
)
|
||||
period_median_duration_ms = _duration_percentile(completed_durations, 0.5)
|
||||
period_p95_duration_ms = _duration_percentile(completed_durations, 0.95)
|
||||
|
||||
ordered_exists = (
|
||||
select(SavingsRecord.id)
|
||||
@@ -417,7 +504,10 @@ def dashboard_overview(
|
||||
period_regular_task_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
|
||||
or_(
|
||||
CoinTransaction.biz_type.in_(REGULAR_TASK_EXACT_BIZ_TYPES),
|
||||
CoinTransaction.biz_type.like(r"task\_%", escape="\\"),
|
||||
),
|
||||
)
|
||||
period_cps_orders = list(
|
||||
db.execute(
|
||||
@@ -590,7 +680,14 @@ def dashboard_overview(
|
||||
CoinTransaction.amount > 0,
|
||||
CoinTransaction.biz_type == "signin_boost",
|
||||
),
|
||||
"signin_boost_watch_count": _count(SigninBoostRecord),
|
||||
# 签到膨胀 2026-07 已下线,signin_boost_record 表随之 drop。这两项保留为**历史口径**
|
||||
# (钱是真发过的,账要能查回)。次数改数金币流水:一次膨胀 = 一笔 signin_boost 流水,
|
||||
# 与原来数 signin_boost_record 行数等价。
|
||||
"signin_boost_watch_count": _count(
|
||||
CoinTransaction,
|
||||
CoinTransaction.biz_type == "signin_boost",
|
||||
CoinTransaction.amount > 0,
|
||||
),
|
||||
},
|
||||
"cash": {
|
||||
"withdraw_success_cents": _sum(
|
||||
@@ -622,11 +719,16 @@ def dashboard_overview(
|
||||
},
|
||||
"comparison": {
|
||||
"total": period_comparison_total,
|
||||
"completed": period_comparison_completed,
|
||||
"cancelled": period_comparison_cancelled,
|
||||
"success": period_comparison_success,
|
||||
"success_rate": period_comparison_success_rate,
|
||||
"ordered": period_ordered_count,
|
||||
"average_duration_ms": period_avg_duration_ms,
|
||||
"median_duration_ms": period_median_duration_ms,
|
||||
"p95_duration_ms": period_p95_duration_ms,
|
||||
"average_saved_cents": period_avg_saved_cents,
|
||||
"token_cost_total_yuan": period_comparison_token_cost_yuan,
|
||||
},
|
||||
"coupon": {
|
||||
"started": coupon_started,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as _date
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
@@ -63,6 +63,13 @@ def get_ad_revenue_report(
|
||||
"建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均"
|
||||
),
|
||||
] = None,
|
||||
revenue_scope: Annotated[
|
||||
Literal["business", "all"],
|
||||
Query(
|
||||
description="business=仅业务代码位(用于客户端与穿山甲同口径对账)/ "
|
||||
"all=穿山甲应用全部代码位(包含广告测试等非业务曝光)"
|
||||
),
|
||||
] = "all",
|
||||
granularity: Annotated[
|
||||
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
|
||||
] = "day",
|
||||
@@ -83,6 +90,7 @@ def get_ad_revenue_report(
|
||||
result = ad_revenue.ad_revenue_report(
|
||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
|
||||
user_id=user_id, ad_type=ad_type, feed_scene=feed_scene, app_env=app_env,
|
||||
revenue_scope=revenue_scope,
|
||||
granularity=granularity, limit=limit, offset=offset, sort=sort,
|
||||
)
|
||||
return AdRevenueReportOut(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""admin 埋点健康度:埋点成功率 / 上报成功率 总览 + 趋势 + 下钻(只读)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import analytics_health as repo
|
||||
from app.admin.schemas.analytics_health import (
|
||||
HealthBreakdownRow,
|
||||
HealthMetrics,
|
||||
HealthTrendPoint,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/analytics-health",
|
||||
tags=["admin-analytics-health"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/overview", response_model=HealthMetrics, summary="两段成功率总览")
|
||||
def overview(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[datetime, Query()],
|
||||
date_to: Annotated[datetime, Query()],
|
||||
) -> HealthMetrics:
|
||||
return HealthMetrics(**repo.overview(db, date_from, date_to))
|
||||
|
||||
|
||||
@router.get("/trend", response_model=list[HealthTrendPoint], summary="按北京天趋势")
|
||||
def trend(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[datetime, Query()],
|
||||
date_to: Annotated[datetime, Query()],
|
||||
) -> list[HealthTrendPoint]:
|
||||
return [HealthTrendPoint(**p) for p in repo.trend(db, date_from, date_to)]
|
||||
|
||||
|
||||
@router.get("/breakdown", response_model=list[HealthBreakdownRow], summary="按维度下钻")
|
||||
def breakdown(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[datetime, Query()],
|
||||
date_to: Annotated[datetime, Query()],
|
||||
dim: Annotated[str, Query(pattern="^(event|app_ver|oem)$")] = "event",
|
||||
) -> list[HealthBreakdownRow]:
|
||||
return [HealthBreakdownRow(**r) for r in repo.breakdown(db, date_from, date_to, dim)]
|
||||
@@ -5,6 +5,7 @@ trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.de
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
@@ -12,7 +13,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import queries
|
||||
from app.admin.schemas.common import CursorPage
|
||||
from app.admin.schemas.comparison import AdminComparisonDetail, AdminComparisonListItem
|
||||
from app.admin.schemas.comparison import (
|
||||
AdminComparisonDetail,
|
||||
AdminComparisonListItem,
|
||||
AdminComparisonSummary,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/comparison-records",
|
||||
@@ -34,12 +39,15 @@ def list_comparison_records(
|
||||
business_type: Annotated[str | None, Query()] = None,
|
||||
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
|
||||
product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None,
|
||||
date_from: Annotated[date | None, Query(description="北京自然日起始日")] = None,
|
||||
date_to: Annotated[date | None, Query(description="北京自然日结束日")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[AdminComparisonListItem]:
|
||||
items, next_cursor, total = queries.list_comparison_records(
|
||||
db, user_id=user_id, phone=phone, status=status,
|
||||
business_type=business_type, store=store, product=product,
|
||||
date_from=date_from, date_to=date_to,
|
||||
limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
@@ -49,6 +57,29 @@ def list_comparison_records(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/summary",
|
||||
response_model=AdminComparisonSummary,
|
||||
summary="比价记录概览聚合",
|
||||
)
|
||||
def comparison_records_summary(
|
||||
db: AdminDb,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
|
||||
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
|
||||
business_type: Annotated[str | None, Query()] = None,
|
||||
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
|
||||
product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None,
|
||||
date_from: Annotated[date | None, Query(description="北京自然日起始日")] = None,
|
||||
date_to: Annotated[date | None, Query(description="北京自然日结束日")] = None,
|
||||
) -> AdminComparisonSummary:
|
||||
return AdminComparisonSummary(**queries.comparison_records_summary(
|
||||
db, user_id=user_id, phone=phone, status=status,
|
||||
business_type=business_type, store=store, product=product,
|
||||
date_from=date_from, date_to=date_to,
|
||||
))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{record_id}",
|
||||
response_model=AdminComparisonDetail,
|
||||
|
||||
@@ -18,6 +18,10 @@ from app.admin.schemas.coupon_data import (
|
||||
CouponDataOut,
|
||||
CouponDataRow,
|
||||
CouponDataSummary,
|
||||
CouponPointDetail,
|
||||
CouponPointDetailsOut,
|
||||
CouponSlotRow,
|
||||
CouponSlotsOut,
|
||||
CouponUserRecordsOut,
|
||||
)
|
||||
from app.core.rewards import cn_today
|
||||
@@ -52,6 +56,10 @@ def get_coupon_data(
|
||||
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
|
||||
user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None,
|
||||
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
|
||||
status: Annotated[
|
||||
list[str] | None,
|
||||
Query(description="领券状态多选 started/completed/failed/abandoned;不传=全部"),
|
||||
] = None,
|
||||
granularity: Annotated[
|
||||
str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day")
|
||||
] = "day",
|
||||
@@ -73,7 +81,7 @@ def get_coupon_data(
|
||||
env = None if app_env == "all" else app_env
|
||||
result = coupon_data.coupon_data_report(
|
||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
|
||||
user=user, app_env=env, granularity=granularity,
|
||||
user=user, app_env=env, statuses=status, granularity=granularity,
|
||||
limit=limit, offset=offset, sort=sort,
|
||||
)
|
||||
return CouponDataOut(
|
||||
@@ -87,6 +95,51 @@ def get_coupon_data(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/coupons",
|
||||
response_model=CouponSlotsOut,
|
||||
summary="按券成功率(coupon_id 粒度;成功/(成功+失败),skipped 排除,设备-天口径)",
|
||||
)
|
||||
def get_coupon_slots(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[str | None, Query(description="起始日 北京 YYYY-MM-DD,默认今天")] = None,
|
||||
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
|
||||
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
|
||||
) -> CouponSlotsOut:
|
||||
today = cn_today()
|
||||
d_from = _parse_day(date_from, field="date_from", default=today)
|
||||
d_to = _parse_day(date_to, field="date_to", default=d_from)
|
||||
if d_to < d_from:
|
||||
raise HTTPException(status_code=422, detail="date_to 不能早于 date_from")
|
||||
if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS:
|
||||
raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS} 天")
|
||||
env = None if app_env == "all" else app_env
|
||||
result = coupon_data.coupon_slot_report(
|
||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), app_env=env
|
||||
)
|
||||
return CouponSlotsOut(
|
||||
date_from=d_from.isoformat(),
|
||||
date_to=d_to.isoformat(),
|
||||
items=[CouponSlotRow(**r) for r in result["items"]],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/point-details",
|
||||
response_model=CouponPointDetailsOut,
|
||||
summary="按 trace 查询单次领券任务的逐券点位明细",
|
||||
)
|
||||
def get_coupon_point_details(
|
||||
db: AdminDb,
|
||||
trace_id: Annotated[str, Query(min_length=1, max_length=64, description="领券 trace_id")],
|
||||
) -> CouponPointDetailsOut:
|
||||
items = coupon_data.coupon_point_details(db, trace_id=trace_id)
|
||||
return CouponPointDetailsOut(
|
||||
trace_id=trace_id,
|
||||
items=[CouponPointDetail(**item) for item in items],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/user-records",
|
||||
response_model=CouponUserRecordsOut,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""admin 华为审核开关:控制新手引导页(快速设置)在华为 ROM 客户端能否被用户关闭。
|
||||
|
||||
存在 app_config 表的 huawei_review dict(见 repositories/app_config.get_huawei_review/set_huawei_review)。
|
||||
客户端经 /api/v1/platform/huawei-review 拉取(且只有华为 ROM 机型会去拉)。权限 operator/tech + 审计。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.schemas.huawei_review import HuaweiReviewOut, HuaweiReviewUpdate
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.app_config import AppConfig
|
||||
from app.repositories import app_config
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/huawei-review",
|
||||
tags=["admin-huawei-review"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
def _out(db: Session) -> HuaweiReviewOut:
|
||||
row = db.get(AppConfig, app_config.HUAWEI_REVIEW_KEY)
|
||||
return HuaweiReviewOut(
|
||||
mode=app_config.get_huawei_review(db)["mode"],
|
||||
updated_at=row.updated_at.isoformat() if row is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=HuaweiReviewOut, summary="华为审核开关当前状态")
|
||||
def get_huawei_review(db: AdminDb) -> HuaweiReviewOut:
|
||||
return _out(db)
|
||||
|
||||
|
||||
@router.patch("", response_model=HuaweiReviewOut, summary="切换华为审核开关(带审计)")
|
||||
def update_huawei_review(
|
||||
body: HuaweiReviewUpdate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator", "tech"))],
|
||||
db: AdminDb,
|
||||
) -> HuaweiReviewOut:
|
||||
before = app_config.get_huawei_review(db)["mode"]
|
||||
try:
|
||||
app_config.set_huawei_review(db, body.mode, admin_id=admin.id, commit=False)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
write_audit(
|
||||
db, admin, action="huawei_review.set", target_type="huawei_review",
|
||||
target_id=app_config.HUAWEI_REVIEW_KEY,
|
||||
detail={"before": before, "after": body.mode}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return _out(db)
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""埋点健康度 admin 响应 schema。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class HealthMetrics(BaseModel):
|
||||
attempted: int
|
||||
drop_capture: int
|
||||
delivered: int
|
||||
drop_undelivered: int
|
||||
track_success_rate: float | None
|
||||
report_success_rate: float | None
|
||||
|
||||
|
||||
class HealthTrendPoint(HealthMetrics):
|
||||
day: str
|
||||
|
||||
|
||||
class HealthBreakdownRow(HealthMetrics):
|
||||
key: str
|
||||
@@ -36,14 +36,38 @@ class AdminComparisonListItem(BaseModel):
|
||||
retry_count: int | None = None
|
||||
input_tokens: int | None = None # Σ usage.prompt_tokens(server 派生)
|
||||
output_tokens: int | None = None # Σ usage.completion_tokens(server 派生)
|
||||
# 本次比价 LLM 总成本(元,按当时价冻结);旧记录/未回填为 None → 前端「成本」列回退估算。见 services/llm_cost.py。
|
||||
llm_cost_yuan: float | None = None
|
||||
device_model: str | None = None
|
||||
rom_vendor: str | None = None
|
||||
rom_name: str | None = None
|
||||
android_version: str | None = None
|
||||
app_version: str | None = None
|
||||
ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminComparisonSummary(BaseModel):
|
||||
"""比价记录页概览;主耗时指标仅统计 status=success。"""
|
||||
|
||||
started: int
|
||||
completed: int
|
||||
success: int
|
||||
success_rate: float | None = None
|
||||
avg_token_cost: float | None = None
|
||||
lower_price_rate: float | None = None
|
||||
avg_duration_ms: int | None = None
|
||||
p5_duration_ms: int | None = None
|
||||
p50_duration_ms: int | None = None
|
||||
p95_duration_ms: int | None = None
|
||||
p99_duration_ms: int | None = None
|
||||
cancelled: int
|
||||
cancelled_rate: float | None = None
|
||||
cancelled_p5_ms: int | None = None
|
||||
cancelled_p50_ms: int | None = None
|
||||
cancelled_p95_ms: int | None = None
|
||||
|
||||
|
||||
class AdminComparisonDetail(AdminComparisonListItem):
|
||||
"""详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。"""
|
||||
|
||||
@@ -71,3 +95,5 @@ class AdminComparisonDetail(AdminComparisonListItem):
|
||||
# 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读
|
||||
# (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。
|
||||
raw_payload: dict | None = None
|
||||
# 算成本所用单价快照 {mode, prices:{model:{...}}}(llm_cost_yuan 继承自列表项)。见 services/llm_cost.py。
|
||||
llm_price_snapshot: dict | None = None
|
||||
|
||||
@@ -19,6 +19,16 @@ class CouponDataSummary(BaseModel):
|
||||
p50_ms: int | None = Field(None, description="耗时 50 分位(ms,中位数)")
|
||||
p95_ms: int | None = Field(None, description="耗时 95 分位(ms)")
|
||||
p99_ms: int | None = Field(None, description="耗时 99 分位(ms)")
|
||||
# 平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md):基数含全部 session。
|
||||
full_success_count: int = Field(0, description="整单成功数(勾选平台全部领到的 session 数)")
|
||||
full_success_rate: float | None = Field(None, description="整单成功率②=整单成功数/发起数;无数据为空")
|
||||
point_success_count: int = Field(0, description="成功平台点位数(Σ 每次成功的平台数)")
|
||||
point_total_count: int = Field(0, description="总平台点位数(Σ 每次勾选平台数;空勾选=全领三档)")
|
||||
point_success_rate: float | None = Field(None, description="点位成功率③=成功点位/总点位;无数据为空")
|
||||
per_platform: dict[str, float | None] = Field(
|
||||
default_factory=dict,
|
||||
description="分平台点位成功率 {平台id: rate|None};恒含美团/淘宝/京东三档,区间内无人勾选的平台为 None",
|
||||
)
|
||||
|
||||
|
||||
class CouponDataDaily(BaseModel):
|
||||
@@ -39,6 +49,15 @@ class CouponDataHourly(BaseModel):
|
||||
avg_elapsed_ms: int | None = None
|
||||
|
||||
|
||||
class CouponPointDetail(BaseModel):
|
||||
"""一次领券任务中的单券点位结果。"""
|
||||
|
||||
coupon_id: str
|
||||
coupon_name: str | None = None
|
||||
status: str = Field(..., description="success / already_claimed / failed / skipped")
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class CouponDataRow(BaseModel):
|
||||
"""一条领券明细(一次领券任务)。"""
|
||||
|
||||
@@ -59,7 +78,16 @@ class CouponDataRow(BaseModel):
|
||||
app_env: str | None = None
|
||||
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
|
||||
claimed_count: int | None = None
|
||||
point_success_count: int | None = Field(
|
||||
None, description="本次成功券点位数(success+already_claimed);无逐券埋点为空"
|
||||
)
|
||||
point_total_count: int | None = Field(
|
||||
None, description="本次尝试券点位数(success+already_claimed+failed,不含 skipped);无逐券埋点为空"
|
||||
)
|
||||
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
|
||||
ad_revenue_yuan: float = Field(
|
||||
0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record"
|
||||
)
|
||||
|
||||
|
||||
class CouponDataOut(BaseModel):
|
||||
@@ -76,8 +104,34 @@ class CouponDataOut(BaseModel):
|
||||
items: list[CouponDataRow] = Field(..., description="逐条领券明细(当前页)")
|
||||
|
||||
|
||||
class CouponPointDetailsOut(BaseModel):
|
||||
"""单次领券任务的逐券点位结果,供点击分数时按需加载。"""
|
||||
|
||||
trace_id: str
|
||||
items: list[CouponPointDetail] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CouponUserRecordsOut(BaseModel):
|
||||
"""某用户全部领券记录(点手机号抽屉用):total=该用户领券总次数,items=记录列表(UserRecordsDrawer 渲染)。"""
|
||||
|
||||
items: list[CouponDataRow]
|
||||
total: int
|
||||
|
||||
|
||||
class CouponSlotRow(BaseModel):
|
||||
"""按券成功率一行(§13):粒度=设备-天;成功率=成功/(成功+失败),skipped 排除。"""
|
||||
|
||||
coupon_id: str
|
||||
coupon_name: str | None = None
|
||||
platform: str | None = Field(None, description="美团/淘宝/京东 平台 id;无法识别为空")
|
||||
tried: int = Field(..., description="尝试数(success+already_claimed+failed 的设备-天数)")
|
||||
succeeded: int = Field(..., description="成功数(success+already_claimed)")
|
||||
success_rate: float | None = Field(None, description="成功率=成功/尝试")
|
||||
|
||||
|
||||
class CouponSlotsOut(BaseModel):
|
||||
"""按券成功率表响应(§13)。"""
|
||||
|
||||
date_from: str
|
||||
date_to: str
|
||||
items: list[CouponSlotRow]
|
||||
|
||||
@@ -53,11 +53,16 @@ class DashboardPeriodUsers(BaseModel):
|
||||
|
||||
class DashboardPeriodComparison(BaseModel):
|
||||
total: int
|
||||
completed: int
|
||||
cancelled: int
|
||||
success: int
|
||||
success_rate: float
|
||||
success_rate: float | None = None
|
||||
ordered: int
|
||||
average_duration_ms: int | None = None
|
||||
median_duration_ms: int | None = None
|
||||
p95_duration_ms: int | None = None
|
||||
average_saved_cents: int | None = None
|
||||
token_cost_total_yuan: float = 0.0
|
||||
|
||||
|
||||
class DashboardPeriodCoupon(BaseModel):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""admin 华为审核开关 schemas(两态:default / review)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class HuaweiReviewOut(BaseModel):
|
||||
"""当前开关状态。updated_at 给后台展示「谁什么时候切的」提供时间锚点。"""
|
||||
|
||||
mode: Literal["default", "review"]
|
||||
updated_at: str | None = None # ISO 字符串;从未切过为 None
|
||||
|
||||
|
||||
class HuaweiReviewUpdate(BaseModel):
|
||||
"""切换开关。整值覆盖,不做部分更新(就一个字段)。"""
|
||||
|
||||
mode: Literal["default", "review"]
|
||||
+94
-98
@@ -3,6 +3,8 @@
|
||||
路由前缀 `/api/v1/ad`:
|
||||
GET /pangle-callback 穿山甲 S2S 发奖回调(**无 JWT,靠验签**),穿山甲服务器调
|
||||
GET /reward-status 客户端查今日看广告发奖进度(Bearer)
|
||||
GET /reward-result/{ad_session_id}
|
||||
客户端按会话查本次广告实发金币(Bearer,只读,弹窗金额用)
|
||||
|
||||
发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币。客户端只负责
|
||||
看完后刷新余额,不参与发奖,被破解也刷不到钱。
|
||||
@@ -13,7 +15,7 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import rewards
|
||||
@@ -25,8 +27,8 @@ from app.repositories import ad_feed_reward as crud_feed
|
||||
from app.repositories import ad_reward as crud_ad
|
||||
from app.repositories import ad_watch as crud_watch
|
||||
from app.repositories import app_config
|
||||
from app.repositories import signin as crud_signin
|
||||
from app.schemas.ad import (
|
||||
AdRewardResultOut,
|
||||
AdRewardStatusOut,
|
||||
EcpmReportIn,
|
||||
EcpmReportOut,
|
||||
@@ -52,11 +54,14 @@ REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字
|
||||
REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
|
||||
|
||||
REWARD_SCENE_REWARD_VIDEO = "reward_video"
|
||||
REWARD_SCENE_SIGNIN_BOOST = "signin_boost"
|
||||
# 提现看视频:看完才能提现的「硬门槛」广告,**不发金币**,只记一条幂等记录(收益由 eCPM 上报口径
|
||||
# ad_type="withdrawal_video" 单独统计)。故意不放进 SUPPORTED_REWARD_SCENES——它不走发币分支。
|
||||
REWARD_SCENE_WITHDRAWAL_AD = "withdrawal_ad"
|
||||
SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO, REWARD_SCENE_SIGNIN_BOOST}
|
||||
# 2026-07 下线 signin_boost(签到膨胀):它按固定 3000 金币发,与广告实际收益脱钩,产品确认
|
||||
# 从来不是设计内的口径。签到弹窗里的「看广告膨胀」现在与福利页看视频走同一条 reward_video
|
||||
# 路径(按 eCPM 公式发),奖励只剩「签到」+「看视频」两种。历史发币流水(coin_transaction
|
||||
# .biz_type='signin_boost')保留不动——钱是真发过的,账必须留。
|
||||
SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO}
|
||||
|
||||
|
||||
def _parse_extra(raw_extra: str | None) -> dict[str, str]:
|
||||
@@ -118,6 +123,11 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
extra.update(_parse_extra(params.get(extra_key)))
|
||||
reward_scene = extra.get("reward_scene") or REWARD_SCENE_REWARD_VIDEO
|
||||
ad_session_id = extra.get("ad_session_id")
|
||||
# 「这条广告属于哪一轮膨胀」。纯标签:不参与发奖判定,只让 reward-result 能把同一轮求和成
|
||||
# 弹窗要显示的累计值(见 crud_ad.round_coin_total)。老客户端不带 → NULL → 累计值返 null。
|
||||
boost_round_id = (extra.get("boost_round_id") or None)
|
||||
if boost_round_id is not None:
|
||||
boost_round_id = boost_round_id[:64]
|
||||
ecpm = params.get("ecpm")
|
||||
|
||||
# 环境隔离:激励视频 mediaExtra 里带「这次观看属于哪个后端环境」(srv_env=dev/prod,客户端按
|
||||
@@ -169,50 +179,11 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
user_id, trans_id, reward_scene,
|
||||
)
|
||||
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
|
||||
if reward_scene == REWARD_SCENE_SIGNIN_BOOST:
|
||||
try:
|
||||
boost, _balance = crud_signin.boost_today_signin(
|
||||
db, user_id, ad_ref_id=trans_id, commit=False
|
||||
)
|
||||
except crud_signin.NotSignedTodayError:
|
||||
db.rollback()
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
|
||||
ad_session_id=ad_session_id, ecpm=ecpm,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
status="not_signed",
|
||||
)
|
||||
except crud_signin.AlreadyBoostedError:
|
||||
db.rollback()
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
|
||||
ad_session_id=ad_session_id, ecpm=ecpm,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
status="already_boosted",
|
||||
)
|
||||
except crud_signin.LastCycleDayBoostBlockedError:
|
||||
db.rollback()
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
|
||||
ad_session_id=ad_session_id, ecpm=ecpm,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
status="last_day",
|
||||
)
|
||||
else:
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user_id, trans_id, coin=boost.coin_awarded,
|
||||
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm=ecpm,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
else:
|
||||
rec = crud_ad.grant_ad_reward(
|
||||
db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id,
|
||||
reward_scene=REWARD_SCENE_REWARD_VIDEO,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
)
|
||||
rec = crud_ad.grant_ad_reward(
|
||||
db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id,
|
||||
reward_scene=REWARD_SCENE_REWARD_VIDEO, boost_round_id=boost_round_id,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
)
|
||||
except crud_ad.UnknownUserError:
|
||||
logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id)
|
||||
return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER)
|
||||
@@ -242,6 +213,46 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/reward-result/{ad_session_id}",
|
||||
response_model=AdRewardResultOut,
|
||||
summary="按 ad_session_id 查本次广告的权威发奖结果",
|
||||
dependencies=[Depends(rate_limit(120, 60, "ad-reward-result"))],
|
||||
)
|
||||
def reward_result(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
ad_session_id: str = Path(..., min_length=8, max_length=64, description="本次广告会话 id"),
|
||||
) -> AdRewardResultOut:
|
||||
"""客户端看完激励视频后轮询本接口拿**本次实发金币 + 本轮累计**用于弹窗,不再用余额差 /
|
||||
coin_per_ad 估算(修「弹窗数值与真实金币对不上」)。
|
||||
|
||||
round_coin 是「恭喜累计获得奖励」弹窗真正显示的数:本轮(= 客户端的 boost_round_id)所有
|
||||
granted 记录之和。由服务端求和而不是客户端自己累加——客户端进程被杀/重建后本地累计会丢,
|
||||
发奖记录不会。取不到轮 id(pending / 老客户端 / extra 丢失)时为 null,客户端退回显示单条。
|
||||
|
||||
S2S 回调异步:查不到记录 = 回调还没到 → 返 200 + status='pending' 让客户端继续重试,
|
||||
**不返 404**(404 只表示路由不存在)。纯只读:发奖仍只由验签过的 S2S 回调完成,
|
||||
这里不写库、不产生任何奖励,被刷也只是查自己的记录。
|
||||
"""
|
||||
rec = crud_ad.find_by_session(db, user.id, ad_session_id)
|
||||
if rec is None:
|
||||
# 连记录都没有 → 不知道属于哪一轮,round_coin 一并为 null(不是 0,0 会被当成"本轮没赚到")
|
||||
return AdRewardResultOut(
|
||||
ad_session_id=ad_session_id, status="pending", coin=None, round_coin=None,
|
||||
)
|
||||
# 本条不是 granted 时**仍返本轮累计**(这条按 0 计):第 3 条撞每日上限那下,客户端的限额
|
||||
# toast 要显示的是前两条已到账的总额,不是空。
|
||||
round_coin = (
|
||||
crud_ad.round_coin_total(db, user.id, rec.boost_round_id)
|
||||
if rec.boost_round_id
|
||||
else None
|
||||
)
|
||||
return AdRewardResultOut(
|
||||
ad_session_id=ad_session_id, status=rec.status, coin=rec.coin, round_coin=round_coin,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/watch-report",
|
||||
response_model=WatchReportOut,
|
||||
@@ -280,18 +291,31 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
|
||||
Bearer 鉴权,user_id 取自 JWT(不信 body)。best-effort:落库即 ok,客户端 fire-and-forget,
|
||||
丢一两条不影响业务(穿山甲后台报表是结算权威)。eCPM 与发奖(S2S)是两条独立流,不逐条关联。
|
||||
"""
|
||||
attributed_trace_id = crud_ecpm.attributable_trace_id(
|
||||
db,
|
||||
feed_scene=payload.feed_scene,
|
||||
trace_id=payload.trace_id,
|
||||
exposure_ms=payload.exposure_ms,
|
||||
)
|
||||
if payload.trace_id and attributed_trace_id is None:
|
||||
logger.info(
|
||||
"detach late coupon ad impression from failed trace user_id=%d trace=%s session=%s",
|
||||
user.id, payload.trace_id, payload.ad_session_id,
|
||||
)
|
||||
crud_ecpm.create_ecpm_record(
|
||||
db, user.id,
|
||||
ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
|
||||
ad_session_id=payload.ad_session_id,
|
||||
adn=payload.adn, slot_id=payload.slot_id,
|
||||
feed_scene=payload.feed_scene,
|
||||
trace_id=attributed_trace_id,
|
||||
app_env=payload.app_env, our_code_id=payload.our_code_id,
|
||||
exposure_ms=payload.exposure_ms,
|
||||
)
|
||||
logger.info(
|
||||
"ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s",
|
||||
"ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s exposure_ms=%s adn=%s slot=%s app=%s code=%s",
|
||||
user.id, payload.ad_type, payload.feed_scene, payload.ad_session_id, payload.ecpm,
|
||||
payload.adn, payload.slot_id, payload.app_env, payload.our_code_id,
|
||||
payload.exposure_ms, payload.adn, payload.slot_id, payload.app_env, payload.our_code_id,
|
||||
)
|
||||
return EcpmReportOut(ok=True)
|
||||
|
||||
@@ -316,55 +340,27 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
|
||||
if reward_scene not in SUPPORTED_REWARD_SCENES:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="bad reward_scene")
|
||||
|
||||
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限/今日膨胀一次)
|
||||
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限)
|
||||
trans_id = f"test-{user.id}-{uuid.uuid4().hex}"
|
||||
if reward_scene == REWARD_SCENE_SIGNIN_BOOST:
|
||||
try:
|
||||
boost, _balance = crud_signin.boost_today_signin(
|
||||
db, user.id, ad_ref_id=trans_id, commit=False
|
||||
)
|
||||
except crud_signin.NotSignedTodayError:
|
||||
db.rollback()
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
|
||||
raw="client debug test-grant signin_boost", status="not_signed",
|
||||
)
|
||||
except crud_signin.AlreadyBoostedError:
|
||||
db.rollback()
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
|
||||
raw="client debug test-grant signin_boost", status="already_boosted",
|
||||
)
|
||||
except crud_signin.LastCycleDayBoostBlockedError:
|
||||
db.rollback()
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
|
||||
raw="client debug test-grant signin_boost", status="last_day",
|
||||
)
|
||||
else:
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user.id, trans_id, coin=boost.coin_awarded,
|
||||
reward_scene=reward_scene, reward_name="测试签到膨胀",
|
||||
raw="client debug test-grant signin_boost", commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
else:
|
||||
# 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
|
||||
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
|
||||
ad_session_id = payload.ad_session_id if payload is not None else None
|
||||
ecpm_val = "200"
|
||||
if ad_session_id:
|
||||
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id)
|
||||
if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0:
|
||||
ecpm_val = ecpm_rec.ecpm_raw
|
||||
try:
|
||||
rec = crud_ad.grant_ad_reward(
|
||||
db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
|
||||
reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
|
||||
)
|
||||
except crud_ad.UnknownUserError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
|
||||
# 正式链路的轮次 id 走 S2S 的 mediaExtra;本接口不经 S2S,只能由 body 补,否则 debug 包
|
||||
# 的 reward-result 恒返 round_coin=null,「弹窗 40 → 60」那套累计验收在本地跑不起来。
|
||||
boost_round_id = (payload.boost_round_id if payload is not None else None) or None
|
||||
# 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
|
||||
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
|
||||
ad_session_id = payload.ad_session_id if payload is not None else None
|
||||
ecpm_val = "200"
|
||||
if ad_session_id:
|
||||
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id)
|
||||
if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0:
|
||||
ecpm_val = ecpm_rec.ecpm_raw
|
||||
try:
|
||||
rec = crud_ad.grant_ad_reward(
|
||||
db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
|
||||
boost_round_id=boost_round_id,
|
||||
reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
|
||||
)
|
||||
except crud_ad.UnknownUserError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
|
||||
|
||||
(used, limit, coin_per, round_count, cooldown_until,
|
||||
_watched, _watch_limit) = crud_ad.today_status(db, user.id)
|
||||
|
||||
+16
-1
@@ -6,13 +6,18 @@ POST /api/v1/analytics/events — 批量接收新手引导(及后续)埋点,appe
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.repositories import analytics as analytics_repo
|
||||
from app.repositories import analytics_selfstat as selfstat_repo
|
||||
from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut
|
||||
from app.schemas.analytics_selfstat import SelfStatBatchIn, SelfStatIngestOut
|
||||
|
||||
router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
|
||||
logger = logging.getLogger("shagua.analytics")
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
@@ -29,3 +34,13 @@ def ingest_events(
|
||||
) -> AnalyticsIngestOut:
|
||||
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
|
||||
return AnalyticsIngestOut(received=n)
|
||||
|
||||
|
||||
@router.post("/selfstat", response_model=SelfStatIngestOut, summary="上报自报计数快照")
|
||||
def ingest_selfstat(batch: SelfStatBatchIn, db: DbSession) -> SelfStatIngestOut:
|
||||
try:
|
||||
snap_id = selfstat_repo.record_selfstat(db, batch)
|
||||
except Exception: # noqa: BLE001 — 计数链路要稳,落库失败不裸奔 500,记日志回明确错误
|
||||
logger.exception("selfstat ingest failed device=%s epoch=%s", batch.device_id, batch.epoch_id)
|
||||
raise HTTPException(status_code=503, detail="selfstat ingest failed") from None
|
||||
return SelfStatIngestOut(snapshot_id=snap_id)
|
||||
|
||||
+299
-16
@@ -12,19 +12,36 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import test_account
|
||||
from app.core.ratelimit import enforce_rate_limit
|
||||
from app.core.security import TokenError, decode_token, issue_token_pair
|
||||
from app.core.ratelimit import (
|
||||
RateLimitRule,
|
||||
check_rate_limits,
|
||||
enforce_rate_limit,
|
||||
record_rate_limits,
|
||||
)
|
||||
from app.core.security import (
|
||||
TokenError,
|
||||
create_bind_ticket,
|
||||
create_conflict_ticket,
|
||||
decode_bind_ticket,
|
||||
decode_conflict_ticket,
|
||||
decode_token,
|
||||
issue_token_pair,
|
||||
)
|
||||
from app.integrations import wxpay
|
||||
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
|
||||
from app.integrations.sms import SmsError, send_code, verify_code
|
||||
from app.repositories import onboarding as onboarding_repo
|
||||
from app.repositories import phone_rebind as rebind_repo
|
||||
from app.repositories import user as user_repo
|
||||
from app.schemas.auth import (
|
||||
JverifyLoginRequest,
|
||||
LogoutResponse,
|
||||
OccupiedAccountInfo,
|
||||
RefreshRequest,
|
||||
SmsLoginRequest,
|
||||
SmsSendRequest,
|
||||
@@ -32,6 +49,13 @@ from app.schemas.auth import (
|
||||
TokenPair,
|
||||
TokenWithUser,
|
||||
UserOut,
|
||||
WechatBindPhoneJverifyRequest,
|
||||
WechatBindPhoneSmsRequest,
|
||||
WechatBindResultResponse,
|
||||
WechatConflictContinueRequest,
|
||||
WechatConflictRebindRequest,
|
||||
WechatLoginRequest,
|
||||
WechatLoginResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.auth")
|
||||
@@ -40,9 +64,10 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
|
||||
SMS_LOGIN_MAX_PER_HOUR = 5
|
||||
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
|
||||
# 发码防刷(同一设备 device_id + 同一 IP,**只按成功发码计数**;被单号 60s 冷却挡下的重发不占额度):
|
||||
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
|
||||
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
|
||||
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限
|
||||
SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸)
|
||||
|
||||
|
||||
def _login_response(
|
||||
@@ -99,23 +124,26 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
|
||||
logger.info("test_account sms_send short-circuit (不真发)")
|
||||
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
|
||||
|
||||
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。
|
||||
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,
|
||||
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。
|
||||
enforce_rate_limit(
|
||||
request,
|
||||
scope="sms-send-device",
|
||||
subject=req.device_id,
|
||||
limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE,
|
||||
window_sec=3600,
|
||||
detail="操作过于频繁,请稍后再试",
|
||||
)
|
||||
# 发码防刷:同一设备(device_id) + 同一 IP,每小时 / 每天两道闸,**均只按成功发码计数**。
|
||||
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。
|
||||
# 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前
|
||||
# (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。
|
||||
send_rules = [
|
||||
RateLimitRule("sms-send-device", SMS_SEND_MAX_PER_HOUR_PER_DEVICE, 3600,
|
||||
"操作过于频繁,请稍后再试"),
|
||||
RateLimitRule("sms-send-device-daily", SMS_SEND_MAX_PER_DAY_PER_DEVICE, 86400,
|
||||
"今日验证码发送次数过多,请明天再试"),
|
||||
]
|
||||
check_rate_limits(request, subject=req.device_id, rules=send_rules)
|
||||
|
||||
try:
|
||||
cooldown = send_code(req.phone)
|
||||
except SmsError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||
|
||||
# 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度)
|
||||
record_rate_limits(request, subject=req.device_id, rules=send_rules)
|
||||
|
||||
from app.core.config import settings # 局部 import 避免循环
|
||||
|
||||
return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown)
|
||||
@@ -166,6 +194,261 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
|
||||
return _login_response(user, onboarding_completed=completed)
|
||||
|
||||
|
||||
# ===================== 微信登录 =====================
|
||||
|
||||
@router.post(
|
||||
"/wechat-login",
|
||||
response_model=WechatLoginResponse,
|
||||
summary="微信登录(openid 命中即登入,否则发绑号令牌)",
|
||||
)
|
||||
def wechat_login(req: WechatLoginRequest, db: DbSession) -> WechatLoginResponse:
|
||||
from app.core.config import settings # 局部 import,避免循环
|
||||
|
||||
# 微信登录只需 code→openid(sns/oauth2),不需要商户转账证书;故只校验 APP_ID/SECRET。
|
||||
if not (settings.WECHAT_APP_ID and settings.WECHAT_APP_SECRET):
|
||||
raise HTTPException(status_code=503, detail="wechat login not configured")
|
||||
|
||||
try:
|
||||
info = wxpay.code_to_userinfo(req.code) # {openid, nickname, avatar_url, raw};失败抛 ValueError
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
openid = info["openid"]
|
||||
user = user_repo.get_user_by_wechat_openid(db, openid)
|
||||
if user is not None:
|
||||
# openid 命中 → 直接登入(绝不套用提现 bind-wechat 的"撞号即 409"逻辑)
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
user_repo.touch_last_login(db, user)
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("wechat_login hit user_id=%d openid=%s*** onboarded=%s", user.id, openid[:6], completed)
|
||||
return WechatLoginResponse(
|
||||
status="logged_in",
|
||||
token=_login_response(user, onboarding_completed=completed),
|
||||
)
|
||||
|
||||
# 未命中 → 签发短时 bind_ticket,进手机号绑定流程(账号此刻还不建)
|
||||
ticket = create_bind_ticket(
|
||||
openid=openid,
|
||||
wechat_nickname=info["nickname"],
|
||||
wechat_avatar_url=info["avatar_url"],
|
||||
)
|
||||
logger.info("wechat_login new openid=%s*** issue bind_ticket", openid[:6])
|
||||
return WechatLoginResponse(
|
||||
status="need_bind_phone",
|
||||
bind_ticket=ticket,
|
||||
wechat_nickname=info["nickname"],
|
||||
wechat_avatar_url=info["avatar_url"],
|
||||
)
|
||||
|
||||
|
||||
def _finish_wechat_bind(
|
||||
db,
|
||||
*,
|
||||
openid: str,
|
||||
wechat_nickname: str | None,
|
||||
wechat_avatar_url: str | None,
|
||||
phone: str,
|
||||
device_id: str,
|
||||
) -> WechatBindResultResponse:
|
||||
"""绑手机建号的公共尾段:手机号被占用 → 返回 phone_occupied(M2 处理 3 选 1);
|
||||
未占用 → 新建微信账号(channel=wechat,昵称头像取微信)→ 签 token 登入。"""
|
||||
existing = user_repo.get_user_by_phone(db, phone)
|
||||
if existing is not None:
|
||||
from app.core.config import settings # 局部 import,避免循环
|
||||
|
||||
ticket = create_conflict_ticket(
|
||||
openid=openid,
|
||||
wechat_nickname=wechat_nickname,
|
||||
wechat_avatar_url=wechat_avatar_url,
|
||||
phone=phone,
|
||||
)
|
||||
blocked = rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
|
||||
logger.info(
|
||||
"wechat bind phone occupied phone=%s by user_id=%d has_wechat=%s",
|
||||
mask_phone(phone), existing.id, bool(existing.wechat_openid),
|
||||
)
|
||||
return WechatBindResultResponse(
|
||||
status="phone_occupied",
|
||||
occupied_account=OccupiedAccountInfo(
|
||||
nickname=existing.nickname,
|
||||
avatar_url=existing.avatar_url,
|
||||
created_at=existing.created_at,
|
||||
has_wechat=bool(existing.wechat_openid),
|
||||
),
|
||||
conflict_ticket=ticket,
|
||||
rebind_available=not blocked,
|
||||
rebind_blocked_days=(
|
||||
rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
|
||||
if blocked else 0
|
||||
),
|
||||
)
|
||||
user = user_repo.create_wechat_user(
|
||||
db,
|
||||
phone=phone,
|
||||
openid=openid,
|
||||
wechat_nickname=wechat_nickname,
|
||||
wechat_avatar_url=wechat_avatar_url,
|
||||
)
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=device_id)
|
||||
logger.info("wechat bind ok user_id=%d phone=%s openid=%s*** onboarded=%s",
|
||||
user.id, mask_phone(phone), openid[:6], completed)
|
||||
return WechatBindResultResponse(
|
||||
status="logged_in",
|
||||
token=_login_response(user, onboarding_completed=completed),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/wechat/bind-phone/sms",
|
||||
response_model=WechatBindResultResponse,
|
||||
summary="微信登录·其他手机号(短信)绑定",
|
||||
)
|
||||
def wechat_bind_phone_sms(
|
||||
req: WechatBindPhoneSmsRequest, request: Request, db: DbSession
|
||||
) -> WechatBindResultResponse:
|
||||
try:
|
||||
claims = decode_bind_ticket(req.bind_ticket)
|
||||
except TokenError as e:
|
||||
raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e
|
||||
|
||||
# 防刷:同 sms/login,按 设备+IP 每小时限流(放在验证码校验之前,失败也计数)
|
||||
enforce_rate_limit(
|
||||
request,
|
||||
scope="wechat-bind-sms-device",
|
||||
subject=req.device_id,
|
||||
limit=SMS_LOGIN_MAX_PER_HOUR,
|
||||
window_sec=3600,
|
||||
detail="登录尝试过于频繁,请稍后再试",
|
||||
)
|
||||
|
||||
if not verify_code(req.phone, req.code):
|
||||
raise HTTPException(status_code=400, detail="invalid sms code")
|
||||
|
||||
return _finish_wechat_bind(
|
||||
db,
|
||||
openid=claims["openid"],
|
||||
wechat_nickname=claims["wnk"],
|
||||
wechat_avatar_url=claims["wav"],
|
||||
phone=req.phone,
|
||||
device_id=req.device_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/wechat/bind-phone/jverify",
|
||||
response_model=WechatBindResultResponse,
|
||||
summary="微信登录·本机号(极光)绑定",
|
||||
)
|
||||
def wechat_bind_phone_jverify(
|
||||
req: WechatBindPhoneJverifyRequest, db: DbSession
|
||||
) -> WechatBindResultResponse:
|
||||
try:
|
||||
claims = decode_bind_ticket(req.bind_ticket)
|
||||
except TokenError as e:
|
||||
raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e
|
||||
|
||||
try:
|
||||
phone = verify_and_get_phone(req.login_token)
|
||||
except JiguangError as e:
|
||||
logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e
|
||||
|
||||
return _finish_wechat_bind(
|
||||
db,
|
||||
openid=claims["openid"],
|
||||
wechat_nickname=claims["wnk"],
|
||||
wechat_avatar_url=claims["wav"],
|
||||
phone=phone,
|
||||
device_id=req.device_id,
|
||||
)
|
||||
|
||||
|
||||
# ===================== 微信占用冲突(M2) =====================
|
||||
|
||||
@router.post(
|
||||
"/wechat/conflict/continue",
|
||||
response_model=WechatBindResultResponse,
|
||||
summary="微信占用冲突·继续绑定(登录老账号,能绑就绑)",
|
||||
)
|
||||
def wechat_conflict_continue(
|
||||
req: WechatConflictContinueRequest, request: Request, db: DbSession
|
||||
) -> WechatBindResultResponse:
|
||||
try:
|
||||
claims = decode_conflict_ticket(req.conflict_ticket)
|
||||
except TokenError as e:
|
||||
raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e
|
||||
|
||||
enforce_rate_limit(
|
||||
request, scope="wechat-conflict-device", subject=req.device_id,
|
||||
limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
|
||||
)
|
||||
|
||||
user = user_repo.get_user_by_phone(db, claims["phone"])
|
||||
if user is None:
|
||||
# P 期间被腾空(老账号改号/注销)→ 前提已变,让前端重走
|
||||
raise HTTPException(status_code=409, detail="账号状态已变化,请重新登录")
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
|
||||
if user.wechat_openid is None:
|
||||
try:
|
||||
user_repo.attach_wechat_to_user(
|
||||
db, user, openid=claims["openid"],
|
||||
wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"],
|
||||
)
|
||||
except IntegrityError:
|
||||
db.rollback() # openid 被别处绑走 → 只登入不绑
|
||||
user_repo.touch_last_login(db, user)
|
||||
else:
|
||||
user_repo.touch_last_login(db, user) # X 已绑别的微信 → 只登入,丢弃本次 openid
|
||||
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("wechat conflict continue user_id=%d openid=%s***", user.id, claims["openid"][:6])
|
||||
return WechatBindResultResponse(
|
||||
status="logged_in",
|
||||
token=_login_response(user, onboarding_completed=completed),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/wechat/conflict/rebind",
|
||||
response_model=WechatBindResultResponse,
|
||||
summary="微信占用冲突·换绑(注销老账号+用该号重建全新账号)",
|
||||
)
|
||||
def wechat_conflict_rebind(
|
||||
req: WechatConflictRebindRequest, request: Request, db: DbSession
|
||||
) -> WechatBindResultResponse:
|
||||
from app.core.config import settings # 局部 import,避免循环
|
||||
|
||||
try:
|
||||
claims = decode_conflict_ticket(req.conflict_ticket)
|
||||
except TokenError as e:
|
||||
raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e
|
||||
|
||||
enforce_rate_limit(
|
||||
request, scope="wechat-conflict-device", subject=req.device_id,
|
||||
limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
|
||||
)
|
||||
|
||||
phone = claims["phone"]
|
||||
if rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS):
|
||||
days = rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
|
||||
raise HTTPException(status_code=409, detail=f"该手机号 {days} 天内已换绑过,暂不能再次换绑")
|
||||
|
||||
user = user_repo.rebind_account(
|
||||
db, phone=phone, openid=claims["openid"],
|
||||
wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"],
|
||||
)
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("wechat conflict rebind new_user_id=%d phone=%s openid=%s***",
|
||||
user.id, mask_phone(phone), claims["openid"][:6])
|
||||
return WechatBindResultResponse(
|
||||
status="logged_in",
|
||||
token=_login_response(user, onboarding_completed=completed),
|
||||
)
|
||||
|
||||
|
||||
# ===================== Refresh =====================
|
||||
|
||||
@router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对")
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.schemas.compare_record import (
|
||||
ComparisonRecordOut,
|
||||
ComparisonRecordPage,
|
||||
)
|
||||
from app.services.llm_cost import compute_llm_cost, get_llm_prices
|
||||
from app.services.pricebot_llm_calls import fetch_llm_calls
|
||||
|
||||
logger = logging.getLogger("shagua.compare_record")
|
||||
@@ -81,6 +82,8 @@ def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
|
||||
# error 的调用 usage 可能为 None,or {} 兜底)
|
||||
rec.input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls)
|
||||
rec.output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls)
|
||||
# 本次比价 LLM 成本(元)+ 当时单价快照:按 app_config 现价逐模型算好冻结(services/llm_cost.py)。
|
||||
rec.llm_cost_yuan, rec.llm_price_snapshot = compute_llm_cost(calls, get_llm_prices(db))
|
||||
db.commit()
|
||||
logger.info(
|
||||
"backfill llm_calls trace=%s n=%d in_tok=%d out_tok=%d",
|
||||
|
||||
+10
-1
@@ -81,7 +81,16 @@ def _record_claims_blocking(
|
||||
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
|
||||
) -> None:
|
||||
with SessionLocal() as db:
|
||||
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
|
||||
# 取本次 session 环境,给 coupon_claim_record 打 app_env 标(每券成功率表按它过滤;设计 §13)。
|
||||
app_env = coupon_repo.session_app_env(db, trace_id)
|
||||
coupon_repo.record_claims(db, device_id, user_id, trace_id, results, app_env=app_env)
|
||||
# 顺带把本帧「成功平台」并入 coupon_session.platform_success(admin 领券数据 ②整单/③点位成功率;
|
||||
# 设计 route B,见 docs/guides/领券成功率指标-设计与埋点.md)。复用同一 SessionLocal、紧接 record_claims,
|
||||
# 不新增连接;并集幂等(无新平台不写),trace_id 缺失或 session 行未落库则跳过。
|
||||
if trace_id:
|
||||
coupon_repo.merge_session_platform_success(
|
||||
db, trace_id, coupon_repo.succeeded_platforms(results)
|
||||
)
|
||||
|
||||
|
||||
def _mark_completed_blocking(
|
||||
|
||||
+93
-4
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -20,6 +20,7 @@ from app.schemas.platform import (
|
||||
AdConfigPublicOut,
|
||||
AppFlagsOut,
|
||||
AppVersionOut,
|
||||
HuaweiReviewOut,
|
||||
PlatformStatsOut,
|
||||
SavingsFeedItem,
|
||||
SavingsFeedOut,
|
||||
@@ -72,6 +73,15 @@ def ad_config(db: DbSession) -> AdConfigPublicOut:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/huawei-review", response_model=HuaweiReviewOut, summary="华为审核开关(不鉴权)")
|
||||
def huawei_review(db: DbSession) -> HuaweiReviewOut:
|
||||
"""客户端进新手引导前拉一次,决定「快速设置」权限步左上角要不要给退出按钮。
|
||||
不鉴权:引导页在登录之前就展示,此时必然没有 token。空库回退 default(=不给退出按钮,维持现状)。
|
||||
只有华为 ROM 客户端会来拉(荣耀 MagicOS 不拉),故这里不做机型判断,由客户端自己 gate。"""
|
||||
mode = app_config.get_huawei_review(db)["mode"]
|
||||
return HuaweiReviewOut(mode=mode, onboarding_closable=(mode == "review"))
|
||||
|
||||
|
||||
@router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)")
|
||||
def app_version(db: DbSession) -> AppVersionOut:
|
||||
"""客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""厂商推送 测试/联调 endpoint。
|
||||
|
||||
路由前缀 `/api/v1/push`,需 Bearer 鉴权。围绕「消息中心 13 类通知的厂商直推」提供三件套:
|
||||
GET /vendors 5 个厂商(荣耀/华为/小米/OPPO/vivo)服务端凭据配置状态,缺哪些键一目了然
|
||||
GET /templates 13 种通知类型的 push 标题/正文模板 + PRD 示例渲染效果
|
||||
POST /test 测试发送:默认 mock(不真调厂商 API,回显渲染结果);mock=false 真发到手机
|
||||
|
||||
与 `/api/v1/device/push-test`(无障碍召回通道的延迟自测)互补:本组面向消息中心 13 类
|
||||
push 的文案/参数/厂商通道联调。真实业务触发统一走 services/notification_events
|
||||
(提现回执/反馈审核/爆料通过/好友下单已接入),底层与本测试端点同一条
|
||||
integrations.vendor_push.send_notification 发送链路。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import notification_catalog as catalog
|
||||
from app.integrations import vendor_push
|
||||
from app.repositories import device as device_repo
|
||||
from app.repositories import notification as notif_repo
|
||||
from app.schemas.push import (
|
||||
PushTemplateOut,
|
||||
PushTemplatesOut,
|
||||
PushTestOut,
|
||||
PushTestRequest,
|
||||
PushVendorsOut,
|
||||
PushVendorStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.push")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/push", tags=["push"])
|
||||
|
||||
# /vendors 的展示顺序(荣耀/华为/小米/OPPO/vivo)
|
||||
_VENDOR_ORDER = ("honor", "huawei", "xiaomi", "oppo", "vivo")
|
||||
|
||||
_GENERIC_TEST_TITLE = "傻瓜比价测试推送"
|
||||
_GENERIC_TEST_BODY = "这是一条{label}通道的测试推送,收到说明服务端 → {label}厂商通道已打通。"
|
||||
|
||||
|
||||
@router.get("/vendors", response_model=PushVendorsOut, summary="厂商推送配置状态")
|
||||
def vendor_status(user: CurrentUser) -> PushVendorsOut:
|
||||
"""检查 5 个厂商的服务端推送凭据是否配齐(读 .env,不打厂商接口)。
|
||||
|
||||
missingKeys 列出的即还需要在 .env 里补的配置键;全空说明该厂商随时可真发。
|
||||
mock 测试(POST /test 默认模式)不依赖任何凭据。
|
||||
"""
|
||||
return PushVendorsOut(
|
||||
vendors=[
|
||||
PushVendorStatus(
|
||||
vendor=v,
|
||||
label=vendor_push.VENDOR_LABELS[v],
|
||||
configured=not vendor_push.missing_settings(v),
|
||||
missing_keys=vendor_push.missing_settings(v),
|
||||
)
|
||||
for v in _VENDOR_ORDER
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/templates", response_model=PushTemplatesOut, summary="13 类通知的 push 模板预览")
|
||||
def push_templates(user: CurrentUser) -> PushTemplatesOut:
|
||||
"""PRD §5 的 13 条 push 文案模板 + 用示例值渲染后的效果,联调对文案用。
|
||||
|
||||
标题固定(≤11 字不带变量);正文里 {var} 为变量,POST /test 的 vars 字段可覆盖。
|
||||
"""
|
||||
templates: list[PushTemplateOut] = []
|
||||
for key, ntype in catalog.TYPES.items():
|
||||
title, body_sample = catalog.render_push(key)
|
||||
templates.append(
|
||||
PushTemplateOut(
|
||||
type=key,
|
||||
category=ntype.category,
|
||||
category_label=catalog.category_label(ntype.category),
|
||||
card_style=ntype.card_style,
|
||||
push_title=title,
|
||||
push_body_sample=body_sample,
|
||||
push_body_template=ntype.push_body_template,
|
||||
variables=catalog.push_variable_names(key),
|
||||
sample_vars=ntype.sample_vars,
|
||||
)
|
||||
)
|
||||
return PushTemplatesOut(templates=templates)
|
||||
|
||||
|
||||
@router.post("/test", response_model=PushTestOut, summary="测试发送厂商推送(默认 mock)")
|
||||
def send_test_push(req: PushTestRequest, user: CurrentUser, db: DbSession) -> PushTestOut:
|
||||
"""向指定厂商 token(或本用户已注册设备)发一条测试 push。
|
||||
|
||||
- **mock=true(默认)**:不真调厂商 API——校验参数、渲染文案后原样返回,并在
|
||||
missingKeys 里提示真发前还缺哪些配置。虚拟数据阶段随便打,不会骚扰真机。
|
||||
- **mock=false**:真发。要求该厂商凭据已配置(缺则 400 报缺失键);厂商 API 报错回 502。
|
||||
注意 vivo 未上架前是测试推送模式(VIVO_PUSH_MODE=1),目标手机要先在 vivo 后台加为测试设备。
|
||||
- **createNotification=true**:同时往消息中心(notification 表)插一条同类型未读通知并把
|
||||
notificationId 放进 push extras → 客户端点击 push 后调 POST /notifications/read
|
||||
{ids:[notificationId]} 即可闭环验证 PRD §4 的 push 已读联动。
|
||||
"""
|
||||
# ---- 1. 解析推送目标(vendor + token):直填优先,缺则按 deviceId 反查已注册设备 ----
|
||||
vendor_raw = req.vendor.strip()
|
||||
push_token = req.push_token.strip()
|
||||
if (not vendor_raw or not push_token) and req.device_id.strip():
|
||||
device = device_repo.get_device(db, user_id=user.id, device_id=req.device_id.strip())
|
||||
if device is not None:
|
||||
vendor_raw = vendor_raw or (device.push_vendor or "")
|
||||
push_token = push_token or (device.push_token or "")
|
||||
|
||||
vendor = vendor_push.normalize_vendor(vendor_raw)
|
||||
if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"vendor 无效或无法从设备推断,支持: {', '.join(_VENDOR_ORDER)}",
|
||||
)
|
||||
if not push_token:
|
||||
# mock 模式给个占位 token,让「只想看看渲染结果」的调用免造数据;真发必须给真 token。
|
||||
if req.mock:
|
||||
push_token = "mock-token"
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="push token 未知:请直传 pushToken,或先用该设备调 /api/v1/device/register 上报",
|
||||
)
|
||||
|
||||
# ---- 2. 组装文案与 extras:直填 > type 模板 > 通用测试文案 ----
|
||||
extras: dict[str, str] = {}
|
||||
notification_id: int | None = None
|
||||
if req.type:
|
||||
try:
|
||||
title, body = catalog.render_push(req.type, req.vars or None)
|
||||
except catalog.UnknownNotificationType as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
extras["type"] = req.type
|
||||
if req.create_notification:
|
||||
item = notif_repo.insert_sample(db, user.id, req.type)
|
||||
notification_id = item.id
|
||||
extras.update({str(k): str(v) for k, v in item.extra.items()})
|
||||
extras["notificationId"] = str(item.id)
|
||||
else:
|
||||
label = vendor_push.VENDOR_LABELS[vendor]
|
||||
title = _GENERIC_TEST_TITLE
|
||||
body = _GENERIC_TEST_BODY.format(label=label)
|
||||
extras["type"] = "push_test"
|
||||
if req.title.strip():
|
||||
title = req.title.strip()
|
||||
if req.content.strip():
|
||||
body = req.content.strip()
|
||||
|
||||
# ---- 3. 发送(mock / 真发) ----
|
||||
missing = vendor_push.missing_settings(vendor)
|
||||
vendor_response = None
|
||||
if req.mock:
|
||||
vendor_push.send_notification(
|
||||
vendor, push_token, title=title, body=body, extras=extras, mock=True
|
||||
)
|
||||
else:
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{vendor_push.VENDOR_LABELS[vendor]}推送凭据未配置,先在 .env 补上: "
|
||||
f"{', '.join(missing)}",
|
||||
)
|
||||
try:
|
||||
vendor_response = vendor_push.send_notification(
|
||||
vendor, push_token, title=title, body=body, extras=extras
|
||||
)
|
||||
except vendor_push.VendorPushError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"厂商推送失败: {e}"
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"push test user_id=%d vendor=%s type=%s mock=%s notification_id=%s",
|
||||
user.id, vendor, req.type or "generic", req.mock, notification_id,
|
||||
)
|
||||
return PushTestOut(
|
||||
ok=True,
|
||||
mock=req.mock,
|
||||
vendor=vendor,
|
||||
title=title,
|
||||
body=body,
|
||||
extras=extras,
|
||||
notification_id=notification_id,
|
||||
missing_keys=missing,
|
||||
vendor_response=vendor_response,
|
||||
)
|
||||
+6
-41
@@ -1,9 +1,12 @@
|
||||
"""签到 endpoint。
|
||||
|
||||
路由前缀 `/api/v1/signin`:
|
||||
GET /status 今日签到状态 + 14 天档位
|
||||
GET /status 今日签到状态 + 7 天档位
|
||||
POST / 执行今日签到
|
||||
POST /boost 签到后看广告膨胀金币
|
||||
|
||||
2026-07 下线 `POST /boost`(签到膨胀):它按固定 3000 金币补发、与广告实际收益脱钩。
|
||||
签到弹窗里的「看广告膨胀」改与福利页看视频走同一条 reward_video 路径(按 eCPM 发,
|
||||
`/ad/pangle-callback` → `/ad/reward-result` 取金额),奖励只剩「签到」+「看视频」两种。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,15 +15,8 @@ import logging
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.repositories import ad_reward as crud_ad
|
||||
from app.repositories import signin as crud_signin
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.schemas.welfare import (
|
||||
SigninBoostRequest,
|
||||
SigninBoostResultOut,
|
||||
SigninResultOut,
|
||||
SigninStatusOut,
|
||||
)
|
||||
from app.schemas.welfare import SigninResultOut, SigninStatusOut
|
||||
|
||||
logger = logging.getLogger("shagua.signin")
|
||||
|
||||
@@ -50,34 +46,3 @@ def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut:
|
||||
streak=record.streak,
|
||||
coin_balance=balance,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/boost", response_model=SigninBoostResultOut, summary="签到后看广告膨胀金币")
|
||||
def boost_signin(
|
||||
payload: SigninBoostRequest, user: CurrentUser, db: DbSession
|
||||
) -> SigninBoostResultOut:
|
||||
if not payload.ad_ref_id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward required")
|
||||
ad_rec = crud_ad.find_by_trans(db, payload.ad_ref_id)
|
||||
if (
|
||||
ad_rec is None
|
||||
or ad_rec.user_id != user.id
|
||||
or ad_rec.reward_scene != "signin_boost"
|
||||
or ad_rec.status != "granted"
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward not verified")
|
||||
record = crud_signin.boost_by_ad_ref(db, user.id, payload.ad_ref_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="signin boost not granted")
|
||||
acc = crud_wallet.get_or_create_account(db, user.id)
|
||||
balance = acc.coin_balance
|
||||
|
||||
logger.info(
|
||||
"signin boost ok user_id=%d date=%s coin=%d",
|
||||
user.id, record.signin_date, record.coin_awarded,
|
||||
)
|
||||
return SigninBoostResultOut(
|
||||
coin_awarded=record.coin_awarded,
|
||||
coin_balance=balance,
|
||||
signin_date=record.signin_date.isoformat(),
|
||||
)
|
||||
|
||||
+14
-2
@@ -43,6 +43,7 @@ from app.schemas.welfare import (
|
||||
WithdrawRequest,
|
||||
WithdrawResultOut,
|
||||
WithdrawStatusOut,
|
||||
WithdrawTierOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.wallet")
|
||||
@@ -173,8 +174,15 @@ def unbind_wechat(
|
||||
return UnbindWechatResultOut(bound=False)
|
||||
|
||||
|
||||
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关")
|
||||
def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
||||
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关/档位")
|
||||
def withdraw_info(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
source: str = Query(
|
||||
"coin_cash",
|
||||
description="提现账户:coin_cash(福利页,下发 tiers 档位) / invite_cash(邀请页,tiers 为空走旧逻辑)",
|
||||
),
|
||||
) -> WithdrawInfoOut:
|
||||
u = db.get(User, user.id)
|
||||
# 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时
|
||||
auth = crud_wallet.sync_transfer_auth(db, user.id)
|
||||
@@ -185,6 +193,7 @@ def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
||||
wechat_nickname=u.wechat_nickname if u else None,
|
||||
wechat_avatar_url=u.wechat_avatar_url if u else None,
|
||||
transfer_auth_enabled=bool(auth and auth.state == "active"),
|
||||
tiers=[WithdrawTierOut(**t) for t in crud_wallet.withdraw_tier_states(db, user.id, source)],
|
||||
)
|
||||
|
||||
|
||||
@@ -218,6 +227,9 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="已有提现申请正在审核或打款中,请处理完成后再申请",
|
||||
) from e
|
||||
except crud_wallet.WithdrawTierUnavailableError as e:
|
||||
# 福利页档位闸(7-9):次数满/已选其他额度。正常客户端已按 tiers 预拦,此处兜底防绕过。
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="今日额度已达上限") from e
|
||||
except crud_wallet.InsufficientCashError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e
|
||||
|
||||
|
||||
+109
-1
@@ -44,6 +44,11 @@ class Settings(BaseSettings):
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 120
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||
# 微信登录未命中 openid 时签发的"待绑手机"令牌有效期(JWT_SECRET_KEY 签名,typ=wechat_bind;
|
||||
# 见 security.create_bind_ticket)。需覆盖"授权→输手机号→收短信→输验证码"整个绑定流程。
|
||||
WECHAT_BIND_TICKET_EXPIRE_MINUTES: int = 10
|
||||
# 一个手机号 30 天内最多换绑一次(微信占用冲突页的"换绑"动作)。见 phone_rebind_log。
|
||||
PHONE_REBIND_LIMIT_DAYS: int = 30
|
||||
|
||||
# ===== Admin 后台 =====
|
||||
# admin 用独立 JWT secret(≠ JWT_SECRET_KEY),App 用户 token 无法越权访问后台。
|
||||
@@ -66,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 # 扫描周期
|
||||
@@ -81,6 +138,7 @@ class Settings(BaseSettings):
|
||||
SMS_SIGN_ID: int = 31729 # 极光短信签名 ID(非机密,可被 .env 覆盖)
|
||||
SMS_TEMPLATE_ID: int = 1 # 极光短信模板 ID(变量名 code,有效期 5 分钟)
|
||||
SMS_CODE_LENGTH: int = 6 # 验证码位数(本服务生成;前端 code 字段 4-8 位兼容)
|
||||
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
|
||||
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
|
||||
|
||||
# ===== 测试账号(release 包全流程联调用)=====
|
||||
@@ -106,6 +164,9 @@ class Settings(BaseSettings):
|
||||
# 美团调用走的代理。本机开发直连美团会 SSL EOF,需填 http://127.0.0.1:7897;
|
||||
# 线上国内服务器留空(=直连)。见 .env.example 与 integrations/meituan.py。
|
||||
MT_CPS_PROXY: str = ""
|
||||
# 本地开发:开启后 /feed 接口直接返回 mock 数据,不调美团 API、不查离线库,
|
||||
# 方便前端联调 feed 卡片样式、分页、距离排序等 UI。生产必须 false。
|
||||
MT_CPS_MOCK_FEED: bool = True
|
||||
|
||||
@property
|
||||
def mt_cps_configured(self) -> bool:
|
||||
@@ -169,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 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
|
||||
@@ -189,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 不同(后台各自
|
||||
@@ -316,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"
|
||||
|
||||
@@ -66,11 +66,6 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
|
||||
"group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。",
|
||||
},
|
||||
"signin_boost_coin": {
|
||||
"default": r.SIGNIN_BOOST_COIN, "label": "签到膨胀固定金币",
|
||||
"group": "签到", "type": "int",
|
||||
"help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。",
|
||||
},
|
||||
"comparing_ad_enabled": {
|
||||
"default": True, "label": "比价/领券期信息流广告",
|
||||
"group": "看广告", "type": "bool", "hidden": True,
|
||||
@@ -96,4 +91,19 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"group": "首页轮播", "type": "enum", "hidden": True,
|
||||
"help": "mixed=真实优先+种子补位(默认);real=只用真实比价记录;seed=只用种子/合成(演示)。",
|
||||
},
|
||||
# 比价 LLM 调用成本计价。值是嵌套 JSON(非 str→int),借 dict_str_int 类型在配置页走原始 JSON
|
||||
# 编辑框;set_value 不校验类型,嵌套 JSON 照存。
|
||||
"llm_token_price": {
|
||||
"default": {
|
||||
"per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}},
|
||||
"default": {"input_per_1m": 3.0, "output_per_1m": 15.0},
|
||||
"currency": "CNY", "unit": "per_1m_tokens",
|
||||
},
|
||||
"label": "LLM 模型单价(元/百万 token)",
|
||||
"group": "LLM 成本", "type": "dict_str_int",
|
||||
"help": (
|
||||
"比价 LLM 调用成本计价。JSON:per_model 按模型配 input/output 单价(元/1M token),"
|
||||
"default 兜底未登记的模型。改价只影响之后回填的新记录,历史记录用当时价格快照。"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
@@ -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),
|
||||
})
|
||||
@@ -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
|
||||
+93
-8
@@ -9,29 +9,41 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# key -> (window_start_ts, count)
|
||||
_buckets: dict[str, tuple[float, int]] = {}
|
||||
# key -> (window_start_ts, count, window_sec)
|
||||
# 存每个 key 自己的 window_sec:_buckets 混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key,
|
||||
# GC 必须按各 key 自己的窗口判过期(见 [_purge_expired]),否则短窗口调用触发的 GC 会误删长窗口 key。
|
||||
_buckets: dict[str, tuple[float, int, float]] = {}
|
||||
_lock = threading.Lock()
|
||||
_GC_THRESHOLD = 10000 # _buckets 超此阈值才顺手清过期 key(仿 sms.py;测试可 monkeypatch 调小强制每次扫)
|
||||
|
||||
|
||||
def _purge_expired(now: float) -> None:
|
||||
"""清过期 key(**仅在持有 _lock 时调用**)。按每个 key 自己存的 window_sec 判过期,而非调用方的窗口
|
||||
—— _buckets 是全局共享、混着 60s(广告)/3600s(登录)/86400s(日闸)不同窗口的 key;若用调用方窗口,
|
||||
高频的 60s 广告端点触发 GC 时会把本该活 3600s/86400s 的登录/日闸计数一并删掉,使其在规模上(超阈值才
|
||||
触发本清理)被反复清零而失效。仅在超阈值时扫,低频、开销可忽略。"""
|
||||
if len(_buckets) <= _GC_THRESHOLD:
|
||||
return
|
||||
for k in [k for k, (s, _, w) in _buckets.items() if now - s >= w]:
|
||||
_buckets.pop(k, None)
|
||||
|
||||
|
||||
def _hit(key: str, limit: int, window_sec: float) -> bool:
|
||||
"""记一次访问。返回 True=放行,False=超限。"""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
start, count = _buckets.get(key, (now, 0))
|
||||
start, count, _ = _buckets.get(key, (now, 0, window_sec))
|
||||
if now - start >= window_sec: # 窗口过期,重置
|
||||
start, count = now, 0
|
||||
count += 1
|
||||
_buckets[key] = (start, count)
|
||||
# 顺手清理过期 key,防内存无限涨(低频访问足够)
|
||||
if len(_buckets) > 10000:
|
||||
for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]:
|
||||
_buckets.pop(k, None)
|
||||
_buckets[key] = (start, count, window_sec)
|
||||
_purge_expired(now) # 顺手清过期 key(按各自窗口),防内存无限涨
|
||||
return count <= limit
|
||||
|
||||
|
||||
@@ -83,3 +95,76 @@ def enforce_rate_limit(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
# ===================== 先判 / 后记(只按「成功」计数)=====================
|
||||
# _hit 是原子「判+记」:一调用就 +1,适合登录爆破(失败尝试也要计)。但对「短信发码」这类
|
||||
# **只想给成功动作计数**的场景不合适 —— 被单号冷却挡下的重发没真发、没烧钱,不该占额度。
|
||||
# 故拆成 _peek(只判不记)+ _commit(只记):check_rate_limits 先判 → 动作 → 成功后 record。
|
||||
|
||||
|
||||
class RateLimitRule(NamedTuple):
|
||||
"""一条限流规则。scope 区分不同闸(不同 key 前缀);同一 (subject, IP) 在 window_sec
|
||||
内最多 limit 次,超限抛 429 用 detail 文案。
|
||||
|
||||
(scope, window_sec) 成对绑在一条规则里 —— check(先判)与 record(计数)复用同一条,
|
||||
避免两处把窗口/scope 写歪导致 key 对不上。
|
||||
"""
|
||||
|
||||
scope: str
|
||||
limit: int
|
||||
window_sec: float
|
||||
detail: str = "操作过于频繁,请稍后再试"
|
||||
|
||||
|
||||
def _peek(key: str, limit: int, window_sec: float) -> bool:
|
||||
"""只读:当前窗口内是否还没到上限(count < limit)。**不改计数**。
|
||||
与 [_commit] 配对实现「先判后记」——只在动作成功后才 _commit。"""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
start, count, _ = _buckets.get(key, (now, 0, window_sec))
|
||||
if now - start >= window_sec: # 窗口已过期 → 视作已重置(count 归零)
|
||||
count = 0
|
||||
return count < limit
|
||||
|
||||
|
||||
def _commit(key: str, window_sec: float) -> None:
|
||||
"""记一次访问(+1)。窗口过期则以本次为起点重置。仅在动作成功后调用。"""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
start, count, _ = _buckets.get(key, (now, 0, window_sec))
|
||||
if now - start >= window_sec: # 窗口过期,重置
|
||||
start, count = now, 0
|
||||
_buckets[key] = (start, count + 1, window_sec)
|
||||
_purge_expired(now) # 顺手清过期 key(按各自窗口,同 [_hit])
|
||||
|
||||
|
||||
def check_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None:
|
||||
"""【先判】一组限流:任一规则已达上限即抛 429,且**不改计数**。
|
||||
|
||||
配合 [record_rate_limits] 实现「只按成功计数」:先 check 所有闸(全未超才继续)→ 执行动作
|
||||
→ 动作**成功后**再 record。动作被下游挡下(如短信单号冷却)、没真正发生时不 record → 不占额度。
|
||||
key = `scope:subject:client_ip`(与 [enforce_rate_limit] 同款)。
|
||||
"""
|
||||
if not settings.RATE_LIMIT_ENABLED:
|
||||
return
|
||||
ip = _client_ip(request)
|
||||
for rule in rules:
|
||||
if not _peek(f"{rule.scope}:{subject}:{ip}", rule.limit, rule.window_sec):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=rule.detail,
|
||||
)
|
||||
|
||||
|
||||
def record_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None:
|
||||
"""【记一次】一组限流(每条规则 +1)。仅在动作成功后调用,与 [check_rate_limits] 配对。
|
||||
|
||||
⚠️ check→动作→record 非原子:并发突发下计数可能略超 limit(每个在途请求各 +1)。对
|
||||
「防脚本/防轰炸」的安全网定位可接受;要精确配额需迁 Redis(见模块 docstring)。
|
||||
"""
|
||||
if not settings.RATE_LIMIT_ENABLED:
|
||||
return
|
||||
ip = _client_ip(request)
|
||||
for rule in rules:
|
||||
_commit(f"{rule.scope}:{subject}:{ip}", rule.window_sec)
|
||||
|
||||
+27
-4
@@ -6,6 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import NamedTuple
|
||||
|
||||
# 业务时区:签到的"今天"按北京时间算,不能用 UTC。
|
||||
# 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。
|
||||
@@ -52,6 +53,30 @@ WITHDRAW_MIN_CENTS: int = 10
|
||||
WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
|
||||
|
||||
|
||||
# ===== 提现档位(福利页 coin_cash;7-9 对齐原型 withdrawal.html)=====
|
||||
# 后端是档位唯一真相源:withdraw-info 按此下发,create_withdraw 按此校验(防绕过客户端刷)。
|
||||
# 规则(2026-07-09 拍板):
|
||||
# - 新人档(is_newbie):账号历史一次性,"发起就算用过"(任意状态含被拒),用过即不再下发;
|
||||
# 0.1 与 0.3 各自独立同天可各提一次,且不参与常规档"每日选一个额度"互斥。
|
||||
# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1),三档每天只能选一个。
|
||||
# invite_cash(邀请页)本轮无档位概念,不在此表。改档位=改这里发版。
|
||||
class WithdrawTier(NamedTuple):
|
||||
amount_cents: int
|
||||
label: str # 客户端档位方块展示文案
|
||||
badge: str | None # 角标文案;None=无角标
|
||||
daily_limit: int # 每日次数上限(新人档的"历史一次性"另由 is_newbie 判定)
|
||||
is_newbie: bool
|
||||
|
||||
|
||||
WITHDRAW_TIERS_COIN_CASH: tuple[WithdrawTier, ...] = (
|
||||
WithdrawTier(10, "0.1", "新人福利", 1, True),
|
||||
WithdrawTier(30, "0.3", "新人福利", 1, True),
|
||||
WithdrawTier(50, "0.5", None, 3, False),
|
||||
WithdrawTier(1000, "10", None, 1, False),
|
||||
WithdrawTier(2000, "20", None, 1, False),
|
||||
)
|
||||
|
||||
|
||||
# ===== 一次性任务(领一次,user_task 去重)=====
|
||||
TASK_ENABLE_NOTIFICATION = "enable_notification"
|
||||
|
||||
@@ -214,8 +239,8 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: i
|
||||
return max(0, round(yuan * COIN_PER_YUAN))
|
||||
|
||||
|
||||
# 签到看广告膨胀:S2S 固定补发(原型 2026-06 由 2000 提到 3000,对应 CTA「看广告最高膨胀至3000金币」)。
|
||||
SIGNIN_BOOST_COIN: int = 3000
|
||||
# 签到膨胀(SIGNIN_BOOST_COIN,固定 3000)已于 2026-07 下线:它与广告实际收益脱钩,产品确认
|
||||
# 非设计内口径。签到弹窗的「看广告膨胀」现与福利页看视频同走 calculate_ad_reward_coin。
|
||||
|
||||
|
||||
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
|
||||
@@ -309,5 +334,3 @@ def get_ad_cooldown_sec(db) -> int: # noqa: ANN001
|
||||
return int(_cfg(db, "ad_cooldown_sec"))
|
||||
|
||||
|
||||
def get_signin_boost_coin(db) -> int: # noqa: ANN001
|
||||
return int(_cfg(db, "signin_boost_coin"))
|
||||
|
||||
@@ -87,6 +87,91 @@ def issue_token_pair(user_id: int) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def create_bind_ticket(
|
||||
*, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None
|
||||
) -> str:
|
||||
"""微信登录未命中 openid 时,签发短时"待绑手机"令牌,承载 openid + 微信昵称头像。
|
||||
|
||||
typ='wechat_bind'、sub=openid;有效期 settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES 分钟。
|
||||
与 access/refresh 用同一 JWT_SECRET_KEY 签名,靠 typ 区分,decode_bind_ticket 校验 typ。
|
||||
"""
|
||||
now = _now()
|
||||
expire = now + timedelta(minutes=settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES)
|
||||
payload: dict[str, Any] = {
|
||||
"sub": openid,
|
||||
"typ": "wechat_bind",
|
||||
"wnk": wechat_nickname,
|
||||
"wav": wechat_avatar_url,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expire.timestamp()),
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_bind_ticket(token: str) -> dict[str, Any]:
|
||||
"""解析"待绑手机"令牌,校验签名/过期/类型。失败抛 TokenError。
|
||||
|
||||
返回 {'openid': str, 'wnk': str|None, 'wav': str|None}。
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||
except jwt.ExpiredSignatureError as e:
|
||||
raise TokenError("bind ticket expired") from e
|
||||
except jwt.InvalidTokenError as e:
|
||||
raise TokenError(f"invalid bind ticket: {e}") from e
|
||||
if payload.get("typ") != "wechat_bind":
|
||||
raise TokenError(f"wrong token type: want=wechat_bind got={payload.get('typ')}")
|
||||
if "sub" not in payload:
|
||||
raise TokenError("bind ticket missing sub")
|
||||
return {"openid": payload["sub"], "wnk": payload.get("wnk"), "wav": payload.get("wav")}
|
||||
|
||||
|
||||
def create_conflict_ticket(
|
||||
*, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None, phone: str
|
||||
) -> str:
|
||||
"""手机号占用时签发的短时"冲突处理"令牌。
|
||||
|
||||
比 bind_ticket 多编码 **已验证的手机号 phone** —— 换绑/继续绑定只认它,证明"这对
|
||||
openid/手机号刚在绑号时验证通过",免用户重输验证码,又堵住"拿自己 openid + 任意手机号
|
||||
去夺号"的接管漏洞。typ='wechat_conflict';有效期复用 WECHAT_BIND_TICKET_EXPIRE_MINUTES。
|
||||
"""
|
||||
now = _now()
|
||||
expire = now + timedelta(minutes=settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES)
|
||||
payload: dict[str, Any] = {
|
||||
"sub": openid,
|
||||
"typ": "wechat_conflict",
|
||||
"wnk": wechat_nickname,
|
||||
"wav": wechat_avatar_url,
|
||||
"phn": phone,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expire.timestamp()),
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_conflict_ticket(token: str) -> dict[str, Any]:
|
||||
"""解析"冲突处理"令牌,校验签名/过期/类型。失败抛 TokenError。
|
||||
|
||||
返回 {'openid': str, 'wnk': str|None, 'wav': str|None, 'phone': str}。
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||
except jwt.ExpiredSignatureError as e:
|
||||
raise TokenError("conflict ticket expired") from e
|
||||
except jwt.InvalidTokenError as e:
|
||||
raise TokenError(f"invalid conflict ticket: {e}") from e
|
||||
if payload.get("typ") != "wechat_conflict":
|
||||
raise TokenError(f"wrong token type: want=wechat_conflict got={payload.get('typ')}")
|
||||
if "sub" not in payload or "phn" not in payload:
|
||||
raise TokenError("conflict ticket missing sub/phn")
|
||||
return {
|
||||
"openid": payload["sub"],
|
||||
"wnk": payload.get("wnk"),
|
||||
"wav": payload.get("wav"),
|
||||
"phone": payload["phn"],
|
||||
}
|
||||
|
||||
|
||||
# ===================== 密码 hash(admin 后台账号用)=====================
|
||||
# 用户侧是手机号+验证码登录,不存密码;仅 admin 账号用 username+password 登录。
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -13,7 +13,8 @@ worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移
|
||||
|
||||
防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
|
||||
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
|
||||
2. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
|
||||
2. 单设备(device_id)+ IP 每小时 / 每天频控(api 层 auth.sms_send 的 check/record_rate_limits,
|
||||
**只按成功发码计数** —— 被本文件单号冷却挡下的重发不占额度)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
|
||||
⚠️ 原「单 IP 频控(rate_limit 依赖)」2026-06-26 按产品要求删除、改设备维度;但 device_id 客户端可伪造/轮换,
|
||||
脚本轮换 id 能绕过本层 → 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)。
|
||||
⚠️ 原「单号每日上限」2026-07-03 按精简要求删除(mentor 定:登录风控只留单号冷却 + 单设备频控);
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+11
-1
@@ -7,6 +7,10 @@ from app.models.ad_watch_log import AdWatchLog # noqa: F401
|
||||
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
|
||||
from app.models.admin_role import AdminRole # noqa: F401
|
||||
from app.models.analytics_event import AnalyticsEvent # noqa: F401
|
||||
from app.models.analytics_selfstat import ( # noqa: F401
|
||||
AnalyticsSelfStat,
|
||||
AnalyticsSelfStatEvent,
|
||||
)
|
||||
from app.models.app_config import AppConfig # noqa: F401
|
||||
from app.models.comparison import ComparisonRecord # noqa: F401
|
||||
from app.models.cps_activity import CpsActivity # noqa: F401
|
||||
@@ -23,17 +27,23 @@ 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
|
||||
from app.models.ops_stat_config import OpsStatConfig # noqa: F401
|
||||
from app.models.price_observation import PriceObservation # noqa: F401
|
||||
from app.models.price_report import PriceReport # noqa: F401
|
||||
from app.models.savings import SavingsRecord # noqa: F401
|
||||
from app.models.signin import SigninBoostRecord, SigninRecord # noqa: F401
|
||||
from app.models.signin import SigninRecord # noqa: F401
|
||||
from app.models.store_mapping import StoreMapping # noqa: F401
|
||||
from app.models.task import UserTask # noqa: F401
|
||||
from app.models.user import User # noqa: F401
|
||||
|
||||
@@ -32,6 +32,9 @@ class AdEcpmRecord(Base):
|
||||
# 点位场景:comparison(比价) / coupon(领券) / welfare(福利),供收益报表区分比价/领券 Draw 收益;
|
||||
# 仅信息流/Draw 上报(比价与领券共用同一代码位,只能客户端各调用点显式打标),激励视频为 NULL。
|
||||
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
# 本次比价/领券 trace_id(信息流场景客户端带上):把这条展示收益归属到对应比价/领券记录。
|
||||
# 领券数据 / 比价记录看板按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端 = NULL。
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
# 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值
|
||||
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
# 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt)
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -16,6 +16,10 @@ from app.db.base import Base
|
||||
|
||||
class AdRewardRecord(Base):
|
||||
__tablename__ = "ad_reward_record"
|
||||
__table_args__ = (
|
||||
# 「本轮膨胀累计发了多少」= SUM(coin) WHERE user_id=? AND boost_round_id=? AND status='granted'
|
||||
Index("ix_ad_reward_user_boost_round", "user_id", "boost_round_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 穿山甲交易号,幂等键(同号回调不重复发奖)
|
||||
@@ -31,6 +35,10 @@ class AdRewardRecord(Base):
|
||||
reward_scene: Mapped[str] = mapped_column(String(32), nullable=False, default="reward_video")
|
||||
# 客户端生成并通过 extra 透传的广告会话 id
|
||||
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
# 客户端生成并通过 extra 透传的「膨胀轮」id:一轮 = 用户点「去膨胀」到点「放弃赚钱」之间连看的
|
||||
# 若干条广告。纯标签,不影响发多少/发不发,只用于把同一轮的发奖记录求和成弹窗要显示的累计值。
|
||||
# 轮次边界完全由客户端定(它才知道用户点了放弃);老客户端/extra 丢失时为 NULL → 累计值返 null。
|
||||
boost_round_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报)
|
||||
ecpm_raw: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx。
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, BigInteger, DateTime, Integer, String, func
|
||||
from sqlalchemy import JSON, BigInteger, DateTime, Index, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -23,6 +23,13 @@ from app.db.base import Base
|
||||
|
||||
class AnalyticsEvent(Base):
|
||||
__tablename__ = "analytics_event"
|
||||
__table_args__ = (
|
||||
# 活跃口径聚合热点(activity.active_event_condition + last_active_subqueries):
|
||||
# 按 event IN (home_visible∪比价∪领券) 过滤,再 group by user_id 取 max(created_at)。
|
||||
# 覆盖索引 → 该聚合走 index-only。注:page 列是早期 show+home 组合的遗留,现不再按 page
|
||||
# 过滤(索引靠 event 前缀仍生效);后续可新迁移瘦成 (event,user_id,created_at)。
|
||||
Index("ix_analytics_event_active", "event", "page", "user_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""埋点/上报成功率自报计数快照表(append-only)。
|
||||
|
||||
客户端周期上报「自 epoch 起算的累计计数」;服务端只存原始快照,查询时在 Python 侧差分聚合
|
||||
(见 app/admin/repositories/analytics_health.py)。与既有 analytics_event 表完全独立。
|
||||
|
||||
- analytics_selfstat :一快照一行(快照头 + 设备维度 + 设备级诊断量)
|
||||
- analytics_selfstat_event :一 event 一行(四类累计计数),外键指向快照头
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AnalyticsSelfStat(Base):
|
||||
__tablename__ = "analytics_selfstat"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
epoch_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
# 设备维度(每设备固定,下钻用)
|
||||
app_ver: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
oem: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
os: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 设备级诊断量(累计;queue_depth 是瞬时 gauge)
|
||||
batches_attempted: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
batches_ok: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
batches_fail: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
retries: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
queue_depth: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
sent_at: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 端上报时刻 epoch ms
|
||||
# 服务端接收时间(权威,用于时间分桶与分区排序)
|
||||
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"<AnalyticsSelfStat id={self.id} device={self.device_id} epoch={self.epoch_id}>"
|
||||
|
||||
|
||||
class AnalyticsSelfStatEvent(Base):
|
||||
__tablename__ = "analytics_selfstat_event"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
snapshot_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("analytics_selfstat.id"), index=True, nullable=False
|
||||
)
|
||||
event: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
attempted: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
drop_capture: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
delivered: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
drop_undelivered: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<AnalyticsSelfStatEvent snap={self.snapshot_id} event={self.event}>"
|
||||
@@ -137,6 +137,12 @@ class ComparisonRecord(Base):
|
||||
# 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}];
|
||||
# server 收上报后按 trace_id 同机拉 pricebot 落库(见 compare_record 端点)。旧记录/未采集为 None。
|
||||
llm_calls: Mapped[list | None] = mapped_column(_JSON, nullable=True)
|
||||
# 本次比价 LLM 总成本(元):回填时按「当时的价」逐模型算好冻结(见 services/llm_cost.py)。
|
||||
# 单次亚分级 → float「元」(不用 *_cents)。旧记录/未回填为 None,前端回退「估算成本」。
|
||||
llm_cost_yuan: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
# 算成本所用单价快照 {mode, prices:{model:{input_per_1m,output_per_1m,_source}}}:app_config 只存
|
||||
# 当前价、不留历史,故把当时价冻结进来供审计/复算。
|
||||
llm_price_snapshot: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
|
||||
@@ -66,6 +66,9 @@ class CouponClaimRecord(Base):
|
||||
|
||||
# success / already_claimed / failed / skipped(原样取 pricebot coupon 结果)
|
||||
status: Mapped[str] = mapped_column(String(24), nullable=False)
|
||||
# 领券所属 session 的环境 prod/dev(/step 按 trace_id 查 coupon_session.app_env 打标)。
|
||||
# 旧行 NULL(不回填)。admin「按券成功率」表据此过滤环境。见设计 §13。
|
||||
app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
|
||||
vendor: Mapped[str | None] = mapped_column(String(48), nullable=True)
|
||||
coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 这张领到几张(pricebot display_count;给不出时为 None)
|
||||
@@ -240,6 +243,10 @@ class CouponSession(Base):
|
||||
platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
# 领到总张数(收尾帧带)。
|
||||
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 本次 session 至少领到一张(status∈{success,already_claimed})的平台 id 列表,如 ["meituan-waimai","jd-waimai"]。
|
||||
# admin「领券数据」据此算整单成功率(②)/点位成功率(③);服务端 /step 逐帧按 trace_id 并集写入
|
||||
# (见 coupon_state.merge_session_platform_success)。旧行=NULL → 视作空集。
|
||||
platform_success: Mapped[list | None] = mapped_column(_JSON, nullable=True)
|
||||
# pricebot done 帧回传的公网调试链接(price.shaguabijia.com/traces/{dir});含落盘时分秒、拼不出,只能存
|
||||
# (同 ComparisonRecord.trace_url)。admin「领券数据」明细据此渲染可点 trace 链接;未到 done(failed/abandoned)为空。
|
||||
trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""手机号换绑台账。
|
||||
|
||||
记录"手机号从老账号被夺走、重建为新账号(X 注销 → Y)"这一破坏性事件,支撑"一个手机号
|
||||
30 天内最多换绑一次"的限制。手机号级、渠道无关(source 标来源);普通微信绑定不写此表。
|
||||
见 M2 spec §4.1。
|
||||
"""
|
||||
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 PhoneRebindLog(Base):
|
||||
__tablename__ = "phone_rebind_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 被换绑的真实手机号(注意:存真实号,不是老账号被腾号后的 deleted_<id>)
|
||||
phone: Mapped[str] = mapped_column(String(20), index=True, nullable=False)
|
||||
# 被注销的老账号 X;P 换绑时已被腾空(极边界)则为空
|
||||
old_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 换绑后新建的账号 Y
|
||||
new_user_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 换绑来源。手机号级配额、渠道无关,留字段给未来其他换绑路径共用同一份 30 天限制。
|
||||
source: Mapped[str] = mapped_column(String(32), nullable=False, default="wechat_conflict")
|
||||
rebound_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
+5
-27
@@ -1,6 +1,10 @@
|
||||
"""签到记录表。
|
||||
|
||||
每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。
|
||||
|
||||
2026-07 下线 `signin_boost_record`(签到膨胀):膨胀按固定 3000 金币补发、与广告实际收益
|
||||
脱钩,产品确认非设计内口径。签到弹窗的「看广告膨胀」改走 reward_video(按 eCPM 发,记在
|
||||
`ad_reward_record`)。历史发币流水 `coin_transaction.biz_type='signin_boost'` 保留不动。
|
||||
- cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1
|
||||
(周期长度 = rewards.SIGNIN_CYCLE_LEN,2026-06 由 14 天改 7 天一轮)。
|
||||
- streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。
|
||||
@@ -9,7 +13,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -42,29 +46,3 @@ class SigninRecord(Base):
|
||||
)
|
||||
|
||||
|
||||
class SigninBoostRecord(Base):
|
||||
"""签到后看广告膨胀记录。
|
||||
|
||||
一天最多膨胀一次,补发金额等于当天签到原始奖励。独立表用于防并发重复补发,
|
||||
后续接入真实 S2S 广告 session 时可把 ad_ref_id 回填为广告会话/交易号。
|
||||
"""
|
||||
|
||||
__tablename__ = "signin_boost_record"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "signin_date", name="uq_signin_boost_user_date"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
signin_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
ad_ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<SigninBoostRecord user_id={self.user_id} date={self.signin_date} coin={self.coin_awarded}>"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。
|
||||
|
||||
口径 = max(User.created_at, AnalyticsEvent[首页可见 home_visible + 比价 + 领券], CouponPromptEngagement[claim_started])。
|
||||
**不含 last_login_at**(登录/re-login 不代表在用 App);created_at 为恒非空基线。
|
||||
清零/预警按北京自然日 0 点对齐(见 reset_cutoff)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ, cn_today
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
|
||||
# —— 活跃口径事件(与"用户管理"口径一致)——
|
||||
# 首页可见:前端埋点确认 event=home_visible(首页进入可视区时触发,单一 event 名即可判定)。
|
||||
HOME_VISIBLE_EVENT = "home_visible"
|
||||
COMPARE_START_EVENT = "real_compare_start" # 发起比价(含浮窗触发)
|
||||
COUPON_START_EVENT = "real_coupon_start" # 发起领券
|
||||
ACTIVE_EVENTS = (HOME_VISIBLE_EVENT, COMPARE_START_EVENT, COUPON_START_EVENT)
|
||||
ACTIVE_ENGAGE_TYPE = "claim_started" # coupon_prompt_engagement 一键领取
|
||||
|
||||
|
||||
def active_event_condition():
|
||||
"""analytics_event 中算"活跃"的行为过滤:首页可见(event=home_visible)
|
||||
∪ 发起比价 ∪ 发起领券。worker 子查询与 admin 展示共用,单一真源。"""
|
||||
return AnalyticsEvent.event.in_(ACTIVE_EVENTS)
|
||||
|
||||
|
||||
def as_utc(value: datetime) -> datetime:
|
||||
"""任意 datetime → tz-aware UTC(无时区按 UTC 解释)。用于与 DateTime(timezone=True) 列比较,
|
||||
比较绝对时刻、与会话时区无关(口径同 admin queries._as_utc)。"""
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def norm_utc(dt: datetime | None) -> datetime | None:
|
||||
"""naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def cn_midnight_utc(d: date) -> datetime:
|
||||
"""北京 d 日 00:00 → tz-aware UTC datetime。"""
|
||||
return as_utc(datetime(d.year, d.month, d.day, tzinfo=CN_TZ))
|
||||
|
||||
|
||||
def reset_cutoff(reset_days: int, today: date | None = None) -> datetime:
|
||||
"""应清零边界(tz-aware UTC):last_active < 此值 ⟺ 距末次活跃已满 reset_days 天(北京 0 点对齐)。
|
||||
= 北京 00:00 of (today − (reset_days − 1))。例:reset_days=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),
|
||||
)
|
||||
@@ -10,8 +10,46 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.core.rewards import cn_today
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.models.coupon_state import CouponSession
|
||||
|
||||
MIN_REVENUE_EXPOSURE_MS = 1000
|
||||
|
||||
|
||||
def effective_ecpm_raw(ecpm_raw: str, exposure_ms: int | None) -> str:
|
||||
"""曝光不足一秒时保留展示记录,但把该条有效 eCPM 归零。"""
|
||||
if exposure_ms is not None and exposure_ms < MIN_REVENUE_EXPOSURE_MS:
|
||||
return "0"
|
||||
return ecpm_raw
|
||||
|
||||
|
||||
def attributable_trace_id(
|
||||
db: Session,
|
||||
*,
|
||||
feed_scene: str | None,
|
||||
trace_id: str | None,
|
||||
exposure_ms: int | None = None,
|
||||
) -> str | None:
|
||||
"""返回广告展示允许归属的业务 trace。
|
||||
|
||||
领券任务可能在 Draw 广告异步加载完成前已经失败或被用户放弃。非完成终态先落库、
|
||||
广告回调后到时,收益仍需保留在总广告报表中,但不能再挂到该死亡领券明细,
|
||||
因此清空关联 trace。其它场景、找不到 session、进行中或已完成状态保持原值,
|
||||
由客户端生命周期修复负责主防线。
|
||||
"""
|
||||
if feed_scene != "coupon" or not trace_id:
|
||||
return trace_id
|
||||
session_status = db.execute(
|
||||
select(CouponSession.status).where(CouponSession.trace_id == trace_id)
|
||||
).scalar_one_or_none()
|
||||
if session_status not in {"failed", "abandoned"}:
|
||||
return trace_id
|
||||
# 已真实上墙但不足一秒的曝光要在终态明细中明确显示 0,而不是被误判成“未填充”。
|
||||
if exposure_ms is not None and exposure_ms < MIN_REVENUE_EXPOSURE_MS:
|
||||
return trace_id
|
||||
return None
|
||||
|
||||
|
||||
def create_ecpm_record(
|
||||
@@ -24,8 +62,10 @@ def create_ecpm_record(
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
feed_scene: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
app_env: str | None = None,
|
||||
our_code_id: str | None = None,
|
||||
exposure_ms: int | None = None,
|
||||
) -> AdEcpmRecord:
|
||||
"""落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。
|
||||
|
||||
@@ -43,9 +83,10 @@ def create_ecpm_record(
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
trace_id=trace_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
ecpm_raw=ecpm_raw,
|
||||
ecpm_raw=effective_ecpm_raw(ecpm_raw, exposure_ms),
|
||||
report_date=cn_today().isoformat(),
|
||||
)
|
||||
db.add(rec)
|
||||
@@ -105,3 +146,27 @@ def count_today(db: Session, user_id: int) -> int:
|
||||
AdEcpmRecord.report_date == cn_today().isoformat(),
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def revenue_yuan_by_trace(db: Session, trace_ids: list[str]) -> dict[str, float]:
|
||||
"""各 trace_id 的广告预估收益(元):按 trace_id 聚合 ad_ecpm_record 的展示收益。
|
||||
|
||||
单条展示收益 = min(eCPM元, AD_ECPM_MAX_FEN/100) / 1000(与 admin 广告收益报表同口径)。
|
||||
ecpm_raw 是字符串且需逐条钳顶,故取回后 Python 求和(行数=本页各 trace 的展示条数,很小)。
|
||||
trace_id 仅信息流(比价/领券)场景客户端带,激励视频/旧数据为 NULL,按 trace_id 过滤天然只算对应场景。
|
||||
只喂**当前页**的 trace_id(≤ 一页条数);空集合直接返回(避免 IN () 非法)。
|
||||
"""
|
||||
if not trace_ids:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
select(AdEcpmRecord.trace_id, AdEcpmRecord.ecpm_raw).where(
|
||||
AdEcpmRecord.trace_id.in_(trace_ids),
|
||||
)
|
||||
).all()
|
||||
cap_yuan = rewards.AD_ECPM_MAX_FEN / 100.0
|
||||
out: dict[str, float] = {}
|
||||
for tid, ecpm_raw in rows:
|
||||
if not tid:
|
||||
continue
|
||||
out[tid] = out.get(tid, 0.0) + min(rewards.parse_ecpm_yuan(ecpm_raw), cap_yuan) / 1000.0
|
||||
return {tid: round(v, 6) for tid, v in out.items()}
|
||||
|
||||
@@ -176,10 +176,19 @@ def grant_feed_reward(
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
# 按点位场景拆流水文案(2026-07):比价等候期看的广告 vs 领券时看的广告,在收益明细里分开显示。
|
||||
# feed_scene=comparison→比价奖励 / coupon→领券奖励;其它(welfare/空/旧端不带)维持通用「信息流广告奖励」。
|
||||
# 客户端按此 biz_type 直显固定文案(见 CoinHistoryViewModel.coinTitle),故 remark 只作后台留痕/兜底。
|
||||
if feed_scene == "comparison":
|
||||
reward_biz, reward_remark = "feed_ad_reward_comparison", "比价奖励"
|
||||
elif feed_scene == "coupon":
|
||||
reward_biz, reward_remark = "feed_ad_reward_coupon", "领券奖励"
|
||||
else:
|
||||
reward_biz, reward_remark = "feed_ad_reward", "信息流广告奖励"
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type="feed_ad_reward", ref_id=client_event_id,
|
||||
remark="信息流广告奖励",
|
||||
biz_type=reward_biz, ref_id=client_event_id,
|
||||
remark=reward_remark,
|
||||
)
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Collection
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from sqlalchemy import func, select
|
||||
@@ -73,6 +74,7 @@ def aggregate_by_date(
|
||||
date_to: str,
|
||||
app_env: str | None = None,
|
||||
our_code_id: str | None = None,
|
||||
our_code_ids: Collection[str] | None = None,
|
||||
) -> list[PangleDateAgg]:
|
||||
"""按日期汇总穿山甲收益(闭区间,北京时间),供报表趋势 + 合计。
|
||||
|
||||
@@ -97,6 +99,8 @@ def aggregate_by_date(
|
||||
stmt = stmt.where(AdPangleDailyRevenue.app_env == app_env)
|
||||
if our_code_id is not None:
|
||||
stmt = stmt.where(AdPangleDailyRevenue.our_code_id == our_code_id)
|
||||
if our_code_ids is not None:
|
||||
stmt = stmt.where(AdPangleDailyRevenue.our_code_id.in_(our_code_ids))
|
||||
|
||||
out: list[PangleDateAgg] = []
|
||||
for report_date, rev, api_rev, imp in db.execute(stmt).all():
|
||||
|
||||
@@ -41,6 +41,61 @@ def find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None:
|
||||
return _find_by_trans(db, trans_id)
|
||||
|
||||
|
||||
def find_by_session(db: Session, user_id: int, ad_session_id: str) -> AdRewardRecord | None:
|
||||
"""按广告会话 id 查该用户本次广告的发奖记录,供客户端轮询弹窗金额(reward-result)。
|
||||
|
||||
同一 ad_session_id 可能命中多条,**必须显式优先 granted**,不能只取最近一条:
|
||||
- 客户端先上报 closed_early、S2S 随后才姗姗来迟 → 两条,granted 反而是后写的;
|
||||
- record_reward_noshow 只在写入前查 granted,挡不住这种后到的竞态;
|
||||
- 本地联调重复调 test-grant → 同 session 多条 granted(trans_id 各不相同)。
|
||||
granted 是唯一「真发了钱」的状态,取它才是权威金额;都没有再取最近一条,让客户端
|
||||
知道没发的原因(capped/closed_early…)。按 user_id 收窄,防止拿别人的 session 探测结果。
|
||||
"""
|
||||
granted = db.execute(
|
||||
select(AdRewardRecord)
|
||||
.where(
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.ad_session_id == ad_session_id,
|
||||
AdRewardRecord.status == "granted",
|
||||
)
|
||||
.order_by(AdRewardRecord.created_at.desc())
|
||||
.limit(1)
|
||||
).scalars().first()
|
||||
if granted is not None:
|
||||
return granted
|
||||
return db.execute(
|
||||
select(AdRewardRecord)
|
||||
.where(
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.ad_session_id == ad_session_id,
|
||||
)
|
||||
.order_by(AdRewardRecord.created_at.desc())
|
||||
.limit(1)
|
||||
).scalars().first()
|
||||
|
||||
|
||||
def round_coin_total(db: Session, user_id: int, boost_round_id: str) -> int:
|
||||
"""本轮膨胀累计已发金币 = 该轮所有 granted 记录的 coin 之和(含刚发的这条)。
|
||||
|
||||
客户端弹窗要显示的就是它:第 1 条弹 40、第 2 条弹 60(=40+20),点「放弃赚钱」后余额涨 60,
|
||||
三个数必须相等。之所以由服务端求和而不是客户端自己累加——客户端进程被杀/低内存重建后
|
||||
本地累计就丢了,而发奖记录不会丢。
|
||||
|
||||
**必须带 user_id**:boost_round_id 是客户端生成的,不带 user_id 就等于让任何人拿别人的
|
||||
轮 id 查别人发了多少。未发奖的状态(capped/closed_early/ecpm_missing)coin 本就是 0,
|
||||
这里按 status 过滤只是让意图显式。
|
||||
"""
|
||||
return int(
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(AdRewardRecord.coin), 0)).where(
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.boost_round_id == boost_round_id,
|
||||
AdRewardRecord.status == "granted",
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
|
||||
return db.execute(
|
||||
select(func.count())
|
||||
@@ -78,8 +133,12 @@ def grant_ad_reward(
|
||||
reward_scene: str = "reward_video",
|
||||
reward_name: str | None = None,
|
||||
raw: str | None = None,
|
||||
boost_round_id: str | None = None,
|
||||
) -> AdRewardRecord:
|
||||
"""福利页激励视频发奖(幂等 + 每日限额 + eCPM 公式)。"""
|
||||
"""福利页激励视频发奖(幂等 + 每日限额 + eCPM 公式)。
|
||||
|
||||
boost_round_id 只是随记录存下的标签(见 round_coin_total),**不参与任何发奖判定**。
|
||||
"""
|
||||
# #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发
|
||||
existing = _find_by_trans(db, trans_id)
|
||||
if existing is not None:
|
||||
@@ -112,7 +171,7 @@ def grant_ad_reward(
|
||||
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
|
||||
reward_date=today, reward_name=reward_name, raw=raw,
|
||||
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm,
|
||||
app_env=src_app_env, our_code_id=src_code_id,
|
||||
app_env=src_app_env, our_code_id=src_code_id, boost_round_id=boost_round_id,
|
||||
)
|
||||
return _commit_record(db, rec, trans_id)
|
||||
|
||||
@@ -123,7 +182,7 @@ def grant_ad_reward(
|
||||
trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing",
|
||||
reward_date=today, reward_name=reward_name, raw=raw,
|
||||
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=None,
|
||||
app_env=src_app_env, our_code_id=src_code_id,
|
||||
app_env=src_app_env, our_code_id=src_code_id, boost_round_id=boost_round_id,
|
||||
)
|
||||
return _commit_record(db, rec, trans_id)
|
||||
|
||||
@@ -140,7 +199,7 @@ def grant_ad_reward(
|
||||
trans_id=trans_id, user_id=user_id, coin=coin, status="granted",
|
||||
reward_date=today, reward_name=reward_name, raw=raw,
|
||||
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_raw,
|
||||
app_env=src_app_env, our_code_id=src_code_id,
|
||||
app_env=src_app_env, our_code_id=src_code_id, boost_round_id=boost_round_id,
|
||||
)
|
||||
return _commit_record(db, rec, trans_id)
|
||||
|
||||
@@ -206,8 +265,13 @@ def record_external_reward(
|
||||
raw: str | None = None,
|
||||
status: str = "granted",
|
||||
commit: bool = True,
|
||||
boost_round_id: str | None = None,
|
||||
) -> AdRewardRecord:
|
||||
"""记录非普通看视频场景的 S2S 回调幂等,发币由调用方业务仓储完成。"""
|
||||
"""记录非普通看视频场景的 S2S 回调幂等,发币由调用方业务仓储完成。
|
||||
|
||||
boost_round_id 同 grant_ad_reward:纯标签。签到膨胀场景的 coin 也会计入本轮累计
|
||||
(它的 coin 就是实发额),所以这里也要存,否则一轮里混了膨胀就会漏算。
|
||||
"""
|
||||
existing = _find_by_trans(db, trans_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
@@ -224,6 +288,7 @@ def record_external_reward(
|
||||
reward_scene=reward_scene,
|
||||
ad_session_id=ad_session_id,
|
||||
ecpm_raw=ecpm,
|
||||
boost_round_id=boost_round_id,
|
||||
)
|
||||
db.add(rec)
|
||||
if commit:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""自报计数快照落库。一次事务:插 1 条快照头 + N 条 event 行,返回快照 id。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.analytics_selfstat import AnalyticsSelfStat, AnalyticsSelfStatEvent
|
||||
from app.schemas.analytics_selfstat import SelfStatBatchIn
|
||||
|
||||
|
||||
def record_selfstat(db: Session, batch: SelfStatBatchIn) -> int:
|
||||
snap = AnalyticsSelfStat(
|
||||
device_id=batch.device_id,
|
||||
epoch_id=batch.epoch_id,
|
||||
app_ver=batch.app_ver,
|
||||
oem=batch.oem,
|
||||
os=batch.os,
|
||||
batches_attempted=batch.batches_attempted,
|
||||
batches_ok=batch.batches_ok,
|
||||
batches_fail=batch.batches_fail,
|
||||
retries=batch.retries,
|
||||
queue_depth=batch.queue_depth,
|
||||
sent_at=batch.sent_at,
|
||||
)
|
||||
db.add(snap)
|
||||
db.flush() # 拿到 snap.id
|
||||
db.add_all([
|
||||
AnalyticsSelfStatEvent(
|
||||
snapshot_id=snap.id,
|
||||
event=e.event,
|
||||
attempted=e.attempted,
|
||||
drop_capture=e.drop_capture,
|
||||
delivered=e.delivered,
|
||||
drop_undelivered=e.drop_undelivered,
|
||||
)
|
||||
for e in batch.events
|
||||
])
|
||||
db.commit()
|
||||
return snap.id
|
||||
@@ -142,3 +142,47 @@ def set_ad_config(db: Session, data: dict, *, admin_id: int, commit: bool = True
|
||||
else:
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
|
||||
# ── 华为审核开关(admin 可切,仅华为 ROM 客户端拉)────────────────────────────────
|
||||
# 同 ad_config:复用 AppConfig 表但不进 CONFIG_DEFS——它由「华为审核开关」专用页管理,
|
||||
# 有自己的两态语义,混进通用系统配置页只会显示成一个没头没尾的 on/off。
|
||||
# default → 新手引导页(快速设置)强制展示,用户无法关闭(= 上线至今的现状)
|
||||
# review → 快速设置的权限步左上角出现退出按钮,可直接进首页(过华为应用市场审核用)
|
||||
# 空库 = default = 行为完全不变。客户端经 /api/v1/platform/huawei-review 拉取。
|
||||
HUAWEI_REVIEW_KEY = "huawei_review"
|
||||
HUAWEI_REVIEW_MODES = ("default", "review")
|
||||
_HUAWEI_REVIEW_DEFAULTS: dict[str, Any] = {
|
||||
"mode": "default",
|
||||
}
|
||||
|
||||
|
||||
def get_huawei_review(db: Session) -> dict:
|
||||
"""读华为审核开关。DB 无 / 脏值一律回退 default(宁可不给退出按钮,也不误放开)。"""
|
||||
row = db.get(AppConfig, HUAWEI_REVIEW_KEY)
|
||||
merged = dict(_HUAWEI_REVIEW_DEFAULTS)
|
||||
if row is not None and isinstance(row.value, dict):
|
||||
merged.update(row.value)
|
||||
if merged.get("mode") not in HUAWEI_REVIEW_MODES:
|
||||
merged["mode"] = _HUAWEI_REVIEW_DEFAULTS["mode"]
|
||||
return merged
|
||||
|
||||
|
||||
def set_huawei_review(db: Session, mode: str, *, admin_id: int, commit: bool = True) -> AppConfig:
|
||||
"""admin 切换华为审核开关。非法 mode 抛 ValueError(路由转 400)。"""
|
||||
if mode not in HUAWEI_REVIEW_MODES:
|
||||
raise ValueError(f"invalid mode: {mode} (expected one of {list(HUAWEI_REVIEW_MODES)})")
|
||||
row = db.get(AppConfig, HUAWEI_REVIEW_KEY)
|
||||
value = {"mode": mode}
|
||||
if row is None:
|
||||
row = AppConfig(key=HUAWEI_REVIEW_KEY, value=value, updated_by_admin_id=admin_id)
|
||||
db.add(row)
|
||||
else:
|
||||
row.value = value
|
||||
row.updated_by_admin_id = admin_id
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
else:
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
@@ -147,12 +147,22 @@ def reset_today_completion(db: Session, device_id: str) -> int:
|
||||
|
||||
# ===== 领券记录(coupon_claim_record)=====
|
||||
|
||||
def session_app_env(db: Session, trace_id: str | None) -> str | None:
|
||||
"""按 trace_id 取 coupon_session.app_env(每券成功率表打环境标用);无 trace_id / 查不到 → None。"""
|
||||
if not trace_id:
|
||||
return None
|
||||
return db.execute(
|
||||
select(CouponSession.app_env).where(CouponSession.trace_id == trace_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def record_claims(
|
||||
db: Session,
|
||||
device_id: str,
|
||||
user_id: int | None,
|
||||
trace_id: str | None,
|
||||
results: list[dict],
|
||||
app_env: str | None = None,
|
||||
) -> int:
|
||||
"""一批券领取结果幂等写入,返回写入(新增 + 更新)条数。
|
||||
|
||||
@@ -186,12 +196,15 @@ def record_claims(
|
||||
row.user_id = user_id
|
||||
if count is not None:
|
||||
row.claimed_count = count
|
||||
if app_env is not None:
|
||||
row.app_env = app_env
|
||||
row.extra = r
|
||||
else:
|
||||
db.add(CouponClaimRecord(
|
||||
device_id=device_id, user_id=user_id,
|
||||
coupon_id=coupon_id, claim_date=today,
|
||||
status=status, vendor=r.get("vendor"), coupon_name=r.get("name"),
|
||||
status=status, app_env=app_env,
|
||||
vendor=r.get("vendor"), coupon_name=r.get("name"),
|
||||
claimed_count=count, trace_id=trace_id, reason=r.get("reason"),
|
||||
extra=r,
|
||||
))
|
||||
@@ -235,6 +248,47 @@ def sum_claimed_count(db: Session, user_id: int) -> int:
|
||||
return int(total or 0)
|
||||
|
||||
|
||||
# ===== 领券平台推导(coupon_id → 平台;成功平台集)=====
|
||||
|
||||
# 成功语义:success + already_claimed 算成功(pricebot 代码 emit already_claimed,协议 enum 漏了);
|
||||
# failed / skipped 不算。与 sum_claimed_count 同口径。
|
||||
_SUCCESS_STATUSES = frozenset({"success", "already_claimed"})
|
||||
|
||||
# 三档平台 id 及固定序(美团→淘宝→京东),与客户端 DEFAULT_PLATFORM_ORDER 对齐。
|
||||
DEFAULT_PLATFORMS: tuple[str, ...] = ("meituan-waimai", "taobao-shanguang", "jd-waimai")
|
||||
|
||||
|
||||
def coupon_id_to_platform(coupon_id: str | None) -> str | None:
|
||||
"""coupon_id 前缀 → 平台 id;无法识别 / 空 → None。
|
||||
|
||||
与客户端 `CouponForegroundService.couponIdToPlatform` 同词表:
|
||||
mt_→美团外卖 / tb_·ele_·elm_→淘宝闪购 / jd_→京东外卖。
|
||||
"""
|
||||
if not coupon_id:
|
||||
return None
|
||||
if coupon_id.startswith("mt_"):
|
||||
return "meituan-waimai"
|
||||
if coupon_id.startswith(("tb_", "ele_", "elm_")):
|
||||
return "taobao-shanguang"
|
||||
if coupon_id.startswith("jd_"):
|
||||
return "jd-waimai"
|
||||
return None
|
||||
|
||||
|
||||
def succeeded_platforms(results: list[dict]) -> list[str]:
|
||||
"""一批券结果 → 至少领到一张的平台集(按 DEFAULT_PLATFORMS 去重保序)。
|
||||
|
||||
只取 status∈{success, already_claimed} 的券;失败/跳过、无法识别平台的券跳过。
|
||||
"""
|
||||
ok: set[str] = set()
|
||||
for r in results:
|
||||
if r.get("status") in _SUCCESS_STATUSES:
|
||||
platform = coupon_id_to_platform(r.get("coupon_id"))
|
||||
if platform is not None:
|
||||
ok.add(platform)
|
||||
return [p for p in DEFAULT_PLATFORMS if p in ok]
|
||||
|
||||
|
||||
# ===== 领券任务流水(coupon_session,admin「领券数据」看板数据源)=====
|
||||
|
||||
def upsert_coupon_session(
|
||||
@@ -321,3 +375,31 @@ def upsert_coupon_session(
|
||||
except IntegrityError:
|
||||
# 并发下另一请求刚插了同 trace_id → 唯一约束撞,回滚忽略(本就幂等)。
|
||||
db.rollback()
|
||||
|
||||
|
||||
def merge_session_platform_success(
|
||||
db: Session, trace_id: str, platforms: list[str]
|
||||
) -> None:
|
||||
"""把本帧「成功平台」并入 coupon_session.platform_success(按 trace_id,并集幂等,按 DEFAULT_PLATFORMS 保序)。
|
||||
|
||||
- 领券 /step 每逢带券结果的帧调一次(平台成败布尔,跨帧取并集天然幂等,不重复计)。
|
||||
- 读不到该 trace_id 的行 → **静默跳过**(不建兜底行;设计 §5:started 帧几乎必先落库)。
|
||||
- 并集无变化(该平台已记过)→ 不写库,省一次 UPDATE。
|
||||
- fire-and-forget:调用方已吞异常;并发唯一冲突回滚忽略。
|
||||
"""
|
||||
if not platforms:
|
||||
return
|
||||
row = db.execute(
|
||||
select(CouponSession).where(CouponSession.trace_id == trace_id)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return
|
||||
merged = set(row.platform_success or []) | set(platforms)
|
||||
new_list = [p for p in DEFAULT_PLATFORMS if p in merged]
|
||||
if new_list == (row.platform_success or []):
|
||||
return # 幂等:无新平台,不写
|
||||
row.platform_success = new_list
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
@@ -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]:
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1,39 @@
|
||||
"""手机号换绑台账(phone_rebind_log)的查询与写入。见 M2 spec §4.1。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.phone_rebind_log import PhoneRebindLog
|
||||
|
||||
|
||||
def rebound_within_days(db: Session, phone: str, days: int) -> bool:
|
||||
"""该手机号在最近 days 天内是否换绑过(命中 → 禁止再次换绑)。"""
|
||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
stmt = (
|
||||
select(PhoneRebindLog.id)
|
||||
.where(PhoneRebindLog.phone == phone, PhoneRebindLog.rebound_at >= since)
|
||||
.limit(1)
|
||||
)
|
||||
return db.execute(stmt).first() is not None
|
||||
|
||||
|
||||
def remaining_block_days(db: Session, phone: str, days: int) -> int:
|
||||
"""距离该手机号可再次换绑还剩几天(向上取整;无记录返回 0)。"""
|
||||
last = db.execute(
|
||||
select(func.max(PhoneRebindLog.rebound_at)).where(PhoneRebindLog.phone == phone)
|
||||
).scalar_one_or_none()
|
||||
if last is None:
|
||||
return 0
|
||||
if last.tzinfo is None: # SQLite 取回 naive datetime,按 UTC 归一
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
remaining = (last + timedelta(days=days) - datetime.now(timezone.utc)).total_seconds()
|
||||
return max(0, math.ceil(remaining / 86400))
|
||||
|
||||
|
||||
def add_rebind_log(db: Session, *, phone: str, old_user_id: int | None, new_user_id: int, source: str) -> None:
|
||||
"""写一条换绑台账(**不 commit**,交给调用方 rebind_account 的单事务)。"""
|
||||
db.add(PhoneRebindLog(phone=phone, old_user_id=old_user_id, new_user_id=new_user_id, source=source))
|
||||
@@ -11,12 +11,11 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.core.rewards import SIGNIN_CYCLE_LEN, cn_today
|
||||
from app.models.signin import SigninBoostRecord, SigninRecord
|
||||
from app.models.signin import SigninRecord
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
@@ -24,18 +23,6 @@ class AlreadySignedError(Exception):
|
||||
"""今天已经签过了。"""
|
||||
|
||||
|
||||
class NotSignedTodayError(Exception):
|
||||
"""今天尚未签到,不能膨胀。"""
|
||||
|
||||
|
||||
class AlreadyBoostedError(Exception):
|
||||
"""今天签到奖励已经膨胀过。"""
|
||||
|
||||
|
||||
class LastCycleDayBoostBlockedError(Exception):
|
||||
"""循环最后一天(第 SIGNIN_CYCLE_LEN 天)不允许签到膨胀。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SigninStep:
|
||||
day: int # 1..14
|
||||
@@ -141,69 +128,3 @@ def do_signin(db: Session, user_id: int) -> tuple[SigninRecord, int]:
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record, acc.coin_balance
|
||||
|
||||
|
||||
def _today_record(db: Session, user_id: int) -> SigninRecord | None:
|
||||
today = cn_today()
|
||||
return db.execute(
|
||||
select(SigninRecord).where(
|
||||
SigninRecord.user_id == user_id,
|
||||
SigninRecord.signin_date == today,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def boost_by_ad_ref(
|
||||
db: Session, user_id: int, ad_ref_id: str
|
||||
) -> SigninBoostRecord | None:
|
||||
"""按广告交易号查签到膨胀记录。S2S 发奖后客户端确认用。"""
|
||||
return db.execute(
|
||||
select(SigninBoostRecord).where(
|
||||
SigninBoostRecord.user_id == user_id,
|
||||
SigninBoostRecord.ad_ref_id == ad_ref_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def boost_today_signin(
|
||||
db: Session, user_id: int, *, ad_ref_id: str | None = None, commit: bool = True
|
||||
) -> tuple[SigninBoostRecord, int]:
|
||||
"""签到后看广告膨胀:固定补发配置金币。返回 (膨胀记录, 补发后余额)。"""
|
||||
record = _today_record(db, user_id)
|
||||
if record is None:
|
||||
raise NotSignedTodayError
|
||||
if record.cycle_day == SIGNIN_CYCLE_LEN:
|
||||
raise LastCycleDayBoostBlockedError
|
||||
|
||||
today = record.signin_date
|
||||
existing = db.execute(
|
||||
select(SigninBoostRecord).where(
|
||||
SigninBoostRecord.user_id == user_id,
|
||||
SigninBoostRecord.signin_date == today,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise AlreadyBoostedError
|
||||
|
||||
boost = SigninBoostRecord(
|
||||
user_id=user_id,
|
||||
signin_date=today,
|
||||
coin_awarded=rewards.get_signin_boost_coin(db),
|
||||
ad_ref_id=ad_ref_id,
|
||||
)
|
||||
db.add(boost)
|
||||
try:
|
||||
acc, _ = crud_wallet.grant_coins(
|
||||
db, user_id, boost.coin_awarded,
|
||||
biz_type="signin_boost", ref_id=ad_ref_id or today.isoformat(),
|
||||
remark=f"签到膨胀 第{record.cycle_day}天",
|
||||
)
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
raise AlreadyBoostedError from e
|
||||
db.refresh(boost)
|
||||
return boost, acc.coin_balance
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.user import User
|
||||
from app.repositories import phone_rebind
|
||||
|
||||
|
||||
# ===== 创建时分配的标识:用户名(对外展示账号 ID)+ 默认昵称 =====
|
||||
@@ -58,6 +59,17 @@ def is_default_nickname(nickname: str | None) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def apply_wechat_display_identity(
|
||||
user: User, *, wechat_nickname: str | None, wechat_avatar_url: str | None
|
||||
) -> None:
|
||||
"""§10:用已有账号绑微信时,仅当展示字段仍为默认才用微信昵称/头像替换(两规则独立);
|
||||
自定义(改过昵称/传过头像)则保留。只改内存对象,由调用方 commit。"""
|
||||
if is_default_nickname(user.nickname) and wechat_nickname:
|
||||
user.nickname = wechat_nickname
|
||||
if user.avatar_url is None and wechat_avatar_url:
|
||||
user.avatar_url = wechat_avatar_url
|
||||
|
||||
|
||||
def get_user_by_username(db: Session, username: str) -> User | None:
|
||||
return db.execute(
|
||||
select(User).where(User.username == username)
|
||||
@@ -86,6 +98,85 @@ def get_user_by_phone(db: Session, phone: str) -> User | None:
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_user_by_wechat_openid(db: Session, openid: str) -> User | None:
|
||||
stmt = select(User).where(User.wechat_openid == openid)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def touch_last_login(db: Session, user: User) -> User:
|
||||
"""openid 命中登录时更新 last_login_at(手机号登录在 upsert_user_for_login 里已更新)。"""
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def attach_wechat_to_user(
|
||||
db: Session, user: User, *, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None
|
||||
) -> User:
|
||||
"""继续绑定:把微信 openid + 微信源字段并入已存在账号(调用方保证 user.wechat_openid 为空)。
|
||||
|
||||
写 wechat_openid / wechat_nickname / wechat_avatar_url,并按 §10 规则回填展示字段:
|
||||
仅当昵称仍为默认值(is_default_nickname)时用微信昵称替换,仅当头像为 null 时用微信头像替换;
|
||||
用户已自定义的展示昵称/头像始终保留,两规则相互独立。
|
||||
撞 openid 唯一约束(O 期间被别处绑走,极罕见)时由调用方捕获 IntegrityError 兜底降级为"只登入不绑"。
|
||||
"""
|
||||
user.wechat_openid = openid
|
||||
user.wechat_nickname = wechat_nickname
|
||||
user.wechat_avatar_url = wechat_avatar_url
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
apply_wechat_display_identity(user, wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def _build_wechat_user(
|
||||
db: Session,
|
||||
*,
|
||||
phone: str,
|
||||
openid: str,
|
||||
wechat_nickname: str | None,
|
||||
wechat_avatar_url: str | None,
|
||||
) -> User:
|
||||
"""构造并 db.add 一个微信账号行(register_channel='wechat',展示昵称头像取微信,缺则默认),
|
||||
**不 commit**。create_wechat_user 与 rebind_account 共用,保证建号逻辑单一来源。"""
|
||||
user = User(
|
||||
phone=phone,
|
||||
username=_gen_unique_username(db),
|
||||
nickname=wechat_nickname or _gen_nickname(),
|
||||
avatar_url=wechat_avatar_url,
|
||||
register_channel="wechat",
|
||||
wechat_openid=openid,
|
||||
wechat_nickname=wechat_nickname,
|
||||
wechat_avatar_url=wechat_avatar_url,
|
||||
last_login_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
return user
|
||||
|
||||
|
||||
def create_wechat_user(
|
||||
db: Session,
|
||||
*,
|
||||
phone: str,
|
||||
openid: str,
|
||||
wechat_nickname: str | None,
|
||||
wechat_avatar_url: str | None,
|
||||
) -> User:
|
||||
"""微信登录新建账号(未占用分支)。见 _build_wechat_user。
|
||||
|
||||
openid 唯一约束是并发/重复绑定的最终防线(极罕见,openid 在 wechat-login 刚查过为空)。
|
||||
"""
|
||||
user = _build_wechat_user(
|
||||
db, phone=phone, openid=openid,
|
||||
wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def upsert_user_for_login(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -154,3 +245,44 @@ def soft_delete_account(db: Session, user: User) -> None:
|
||||
# 释放邀请码唯一槽
|
||||
user.invite_code = None
|
||||
db.commit()
|
||||
|
||||
|
||||
def rebind_account(
|
||||
db: Session,
|
||||
*,
|
||||
phone: str,
|
||||
openid: str,
|
||||
wechat_nickname: str | None,
|
||||
wechat_avatar_url: str | None,
|
||||
source: str = "wechat_conflict",
|
||||
) -> User:
|
||||
"""换绑:**单事务内**注销老账号 X(腾出手机号)+ 用该号建全新微信账号 Y + 写换绑台账。
|
||||
|
||||
- 老账号可能已不存在(P 被腾空)→ old_user_id=None,直接建 Y(幂等更稳)。
|
||||
- 手机号唯一约束靠时序:先把 X.phone 改名并 flush 腾号,再插 Y。
|
||||
- 全程不中途 commit,任一步失败整体回滚,绝不出现"X 删了 Y 没建"。
|
||||
X 的字段变更等价 soft_delete_account(软删 + 匿名化 + 释放 openid/邀请码唯一槽),但不在此 commit。
|
||||
"""
|
||||
old = get_user_by_phone(db, phone)
|
||||
old_id = old.id if old is not None else None
|
||||
if old is not None:
|
||||
old.status = "deleted"
|
||||
old.phone = f"deleted_{old.id}"
|
||||
old.nickname = None
|
||||
old.avatar_url = None
|
||||
old.wechat_openid = None
|
||||
old.wechat_nickname = None
|
||||
old.wechat_avatar_url = None
|
||||
old.invite_code = None
|
||||
db.flush() # 先落 phone 改名,腾出手机号唯一约束,才能给 Y 用
|
||||
new_user = _build_wechat_user(
|
||||
db, phone=phone, openid=openid,
|
||||
wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url,
|
||||
)
|
||||
db.flush() # 拿 new_user.id
|
||||
phone_rebind.add_rebind_log(
|
||||
db, phone=phone, old_user_id=old_id, new_user_id=new_user.id, source=source
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
return new_user
|
||||
|
||||
+121
-1
@@ -11,7 +11,7 @@ import unicodedata
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.core.config import settings
|
||||
from app.core.rewards import COIN_PER_CENT, coins_to_cents
|
||||
from app.integrations import wxpay
|
||||
from app.models.user import User
|
||||
from app.repositories.user import apply_wechat_display_identity
|
||||
from app.models.wallet import (
|
||||
CashTransaction,
|
||||
CoinAccount,
|
||||
@@ -28,12 +29,17 @@ from app.models.wallet import (
|
||||
WechatTransferAuthorization,
|
||||
WithdrawOrder,
|
||||
)
|
||||
from app.services import notification_events
|
||||
|
||||
# 微信转账终态:成功 / 失败(失败/取消/关闭都退款)
|
||||
_WX_STATE_SUCCESS = "SUCCESS"
|
||||
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
|
||||
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
|
||||
_WITHDRAW_ACTIVE_STATUSES = {"reviewing", "pending"}
|
||||
# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。
|
||||
# 被拒/转账失败/解绑退回(rejected/failed,均已退款、钱没到手)不在此列 → 新人档恢复可提
|
||||
# (2026-07-16 修正:此前判定不看状态,解绑微信退回后 0.1 被误判已用、资格永久锁死)。
|
||||
_NEWBIE_TIER_HELD_STATUSES = {"reviewing", "pending", "success"}
|
||||
# 免确认收款授权状态
|
||||
_WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账
|
||||
_WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启
|
||||
@@ -67,6 +73,10 @@ class WithdrawTooFrequentError(Exception):
|
||||
"""提现申请过于频繁,或已有未完成提现单。"""
|
||||
|
||||
|
||||
class WithdrawTierUnavailableError(Exception):
|
||||
"""该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。"""
|
||||
|
||||
|
||||
class WithdrawTransferError(Exception):
|
||||
"""调用微信转账失败(已退回余额)。"""
|
||||
|
||||
@@ -370,6 +380,7 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict:
|
||||
user.wechat_openid = info["openid"]
|
||||
user.wechat_nickname = info["nickname"]
|
||||
user.wechat_avatar_url = info["avatar_url"]
|
||||
apply_wechat_display_identity(user, wechat_nickname=info["nickname"], wechat_avatar_url=info["avatar_url"])
|
||||
db.commit()
|
||||
return info
|
||||
|
||||
@@ -515,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(
|
||||
@@ -558,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:
|
||||
@@ -598,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:
|
||||
@@ -605,6 +624,91 @@ def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> N
|
||||
db.commit()
|
||||
|
||||
|
||||
def _beijing_today_start_utc() -> datetime:
|
||||
"""北京时今日 0 点(转 UTC)。WithdrawOrder.created_at 是 func.now()(UTC)存储,
|
||||
比较时统一转 UTC,与 admin 看板 today_start 同口径(admin/repositories/queries.py)。"""
|
||||
return (
|
||||
datetime.now(rewards.CN_TZ)
|
||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
.astimezone(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -> list[dict]:
|
||||
"""福利页(coin_cash)提现档位的可提现状态。withdraw-info 下发与 create_withdraw 校验共用此口径。
|
||||
|
||||
规则(2026-07-09 拍板,7-9提现ui对齐;新人档判定 2026-07-16 修正):
|
||||
- 新人档(0.1/0.3):账号历史一次性——进行中(reviewing/pending)或成功打款(success)即视为
|
||||
已用,直接**从返回列表消失**;被拒/转账失败/解绑退回(均已退款、钱没到手)则恢复可提,不永久
|
||||
占用资格。两档各自独立互不影响,不参与"每日选一个额度"互斥。
|
||||
- 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
|
||||
都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected。
|
||||
- invite_cash 本轮无档位概念 → 返回空列表(邀请页客户端仍用本地写死档位,行为不变)。
|
||||
余额是否足够由客户端本地判断(余额随兑换实时变化,不在此快照)。
|
||||
"""
|
||||
if source != "coin_cash":
|
||||
return []
|
||||
tiers = rewards.WITHDRAW_TIERS_COIN_CASH
|
||||
amounts = [t.amount_cents for t in tiers]
|
||||
newbie_amounts = [t.amount_cents for t in tiers if t.is_newbie]
|
||||
# 新人档历史是否用过:进行中或已成功打款的单占用资格;被拒/失败/解绑退回(已退款)不算(恢复可提)
|
||||
used_newbie: set[int] = set(
|
||||
db.execute(
|
||||
select(WithdrawOrder.amount_cents)
|
||||
.distinct()
|
||||
.where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.source == "coin_cash",
|
||||
WithdrawOrder.amount_cents.in_(newbie_amounts),
|
||||
WithdrawOrder.status.in_(_NEWBIE_TIER_HELD_STATUSES),
|
||||
)
|
||||
).scalars()
|
||||
) if newbie_amounts else set()
|
||||
# 今日(北京日)每档已发起次数(任意状态)
|
||||
today_counts: dict[int, int] = {
|
||||
int(amount): int(cnt)
|
||||
for amount, cnt in db.execute(
|
||||
select(WithdrawOrder.amount_cents, func.count(WithdrawOrder.id))
|
||||
.where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.source == "coin_cash",
|
||||
WithdrawOrder.amount_cents.in_(amounts),
|
||||
WithdrawOrder.created_at >= _beijing_today_start_utc(),
|
||||
)
|
||||
.group_by(WithdrawOrder.amount_cents)
|
||||
)
|
||||
}
|
||||
# "每日选一个额度":今天发起过的常规档(新人档不算)
|
||||
selected_regular = next(
|
||||
(t.amount_cents for t in tiers if not t.is_newbie and today_counts.get(t.amount_cents, 0) > 0),
|
||||
None,
|
||||
)
|
||||
out: list[dict] = []
|
||||
for t in tiers:
|
||||
if t.is_newbie:
|
||||
if t.amount_cents in used_newbie:
|
||||
continue # 用过即消失,不再下发
|
||||
out.append({
|
||||
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
|
||||
"is_newbie": True, "available": True, "disabled_reason": None,
|
||||
"remaining_today": 1,
|
||||
})
|
||||
continue
|
||||
used = today_counts.get(t.amount_cents, 0)
|
||||
if selected_regular is not None and selected_regular != t.amount_cents:
|
||||
available, reason, remaining = False, "other_tier_selected", 0
|
||||
elif used >= t.daily_limit:
|
||||
available, reason, remaining = False, "quota_exhausted", 0
|
||||
else:
|
||||
available, reason, remaining = True, None, t.daily_limit - used
|
||||
out.append({
|
||||
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
|
||||
"is_newbie": False, "available": available, "disabled_reason": reason,
|
||||
"remaining_today": remaining,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def create_withdraw(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
@@ -660,6 +764,19 @@ def create_withdraw(
|
||||
if active_order_id is not None:
|
||||
raise WithdrawTooFrequentError
|
||||
|
||||
# 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过
|
||||
# 客户端刷)。放在幂等返回/在途互斥之后:同号重试仍原样返回旧单,不被档位闸误杀。
|
||||
# allow_sub_min(0.01 调试直发)保持原样放行,不受档位约束;invite_cash 本轮无档位概念不校验。
|
||||
if source == "coin_cash" and not allow_sub_min:
|
||||
tier_state = next(
|
||||
(t for t in withdraw_tier_states(db, user_id, source) if t["amount_cents"] == amount_cents),
|
||||
None,
|
||||
)
|
||||
if tier_state is None: # 非预设档位金额,或新人档已用过(已从列表消失)
|
||||
raise InvalidWithdrawAmountError
|
||||
if not tier_state["available"]:
|
||||
raise WithdrawTierUnavailableError
|
||||
|
||||
# 账户须存在(原子扣款的 UPDATE 不会建账户)
|
||||
get_or_create_account(db, user_id, commit=True)
|
||||
|
||||
@@ -873,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
|
||||
|
||||
|
||||
@@ -1022,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:
|
||||
|
||||
+50
-2
@@ -43,6 +43,35 @@ class AdRewardStatusOut(BaseModel):
|
||||
watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数;limit=0 时客户端不据此拦截")
|
||||
|
||||
|
||||
class AdRewardResultOut(BaseModel):
|
||||
"""按 ad_session_id 查本次广告的**权威发奖结果**(福利页看完视频的弹窗金额只认它)。
|
||||
|
||||
S2S 回调是异步的,客户端看完广告立刻轮询多半还查不到记录 —— 这种「还没到账」返回
|
||||
200 + status='pending' 让客户端重试,**不返 404**:404 只应表示路由不存在,两者混在
|
||||
一起客户端没法区分「后端没部署」和「再等等」。纯只读,不产生任何奖励。
|
||||
"""
|
||||
|
||||
ad_session_id: str = Field(..., description="回显请求的广告会话 id")
|
||||
status: str = Field(
|
||||
...,
|
||||
description="pending(S2S 未到账,客户端应继续轮询) / granted(已发奖) / capped(当日超限未发) / "
|
||||
"ecpm_missing(缺 eCPM 未发) / closed_early(提前关闭未发);其余同 AdRewardRecord.status。"
|
||||
"客户端只在 granted 且 coin>0 时弹窗,其它一律不弹(不显示假数字)",
|
||||
)
|
||||
coin: int | None = Field(
|
||||
None,
|
||||
description="本次实发金币:granted 为真实到账额;未发奖的状态为 0;pending 为 null",
|
||||
)
|
||||
round_coin: int | None = Field(
|
||||
None,
|
||||
description="**本轮膨胀累计已发金币**(含本条)——客户端「恭喜累计获得奖励」弹窗显示的就是它。"
|
||||
"轮 = 用户点「去膨胀」到点「放弃赚钱」之间连看的若干条广告,边界由客户端的 boost_round_id 定。"
|
||||
"本条不是 granted(capped/closed_early/…)时**仍返本轮累计**,只是这条按 0 计。"
|
||||
"pending(没记录,取不到轮 id)、或该记录没有 boost_round_id(老客户端 / extra 丢失)时为 null,"
|
||||
"客户端见 null 退回只显示单条 coin",
|
||||
)
|
||||
|
||||
|
||||
class EcpmReportIn(BaseModel):
|
||||
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
|
||||
|
||||
@@ -63,6 +92,18 @@ class EcpmReportIn(BaseModel):
|
||||
description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);"
|
||||
"比价与领券共用同一 Draw 代码位,需客户端在各调用点显式标注,供收益报表区分比价/领券;激励视频为空",
|
||||
)
|
||||
trace_id: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券,"
|
||||
"供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空",
|
||||
)
|
||||
exposure_ms: int | None = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=86_400_000,
|
||||
description="本条广告真实在屏曝光毫秒数;小于 1000ms 时收益强制按 0 计算。旧客户端不传则保持原口径",
|
||||
)
|
||||
app_env: str | None = Field(
|
||||
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
|
||||
)
|
||||
@@ -99,13 +140,20 @@ class TestGrantIn(BaseModel):
|
||||
|
||||
reward_scene: str = Field(
|
||||
"reward_video",
|
||||
description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)",
|
||||
description="模拟发奖场景。当前只支持 reward_video(普通激励视频);signin_boost(签到膨胀)"
|
||||
"已于 2026-07 下线,传它会 422",
|
||||
)
|
||||
ad_session_id: str | None = Field(
|
||||
None, min_length=8, max_length=64,
|
||||
description="本次广告会话 id(与 ecpm-report 同值)。reward_video 场景下据此查回客户端"
|
||||
"已上报的真实 eCPM 来按公式发奖;查不到或 eCPM≤0 时兜底 200,保证本地联调仍出非零金币",
|
||||
)
|
||||
boost_round_id: str | None = Field(
|
||||
None, max_length=64,
|
||||
description="本次广告属于哪一轮膨胀。正式链路走穿山甲 S2S 的 mediaExtra,test-grant 不经 S2S、"
|
||||
"拿不到 extra,故在 body 里补一个——不传的话 debug 包 reward-result 的 round_coin 恒为 null,"
|
||||
"「弹窗 40 → 60 → toast +60」那套验收在本地跑不起来",
|
||||
)
|
||||
|
||||
|
||||
class TestGrantOut(BaseModel):
|
||||
@@ -113,7 +161,7 @@ class TestGrantOut(BaseModel):
|
||||
|
||||
granted: bool = Field(..., description="本次是否真的发了金币(达每日上限则 False)")
|
||||
status: str = Field(
|
||||
..., description="granted / capped / not_signed / already_boosted / last_day / unknown_scene"
|
||||
..., description="granted / capped / ecpm_missing / unknown_scene"
|
||||
)
|
||||
coin: int = Field(..., description="本次发放金币(capped 时为 0)")
|
||||
used_today: int = Field(..., description="今日已成功发奖次数")
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""自报计数上报 schema。字段名对齐客户端 payload(snake_case),累计值语义。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SelfStatEventIn(BaseModel):
|
||||
event: str = Field(max_length=64)
|
||||
attempted: int = 0
|
||||
drop_capture: int = 0
|
||||
delivered: int = 0
|
||||
drop_undelivered: int = 0
|
||||
|
||||
|
||||
class SelfStatBatchIn(BaseModel):
|
||||
device_id: str = Field(max_length=64)
|
||||
epoch_id: str = Field(max_length=64)
|
||||
sent_at: int | None = None
|
||||
app_ver: str | None = Field(default=None, max_length=32)
|
||||
oem: str | None = Field(default=None, max_length=32)
|
||||
os: str | None = Field(default=None, max_length=32)
|
||||
batches_attempted: int = 0
|
||||
batches_ok: int = 0
|
||||
batches_fail: int = 0
|
||||
retries: int = 0
|
||||
queue_depth: int = 0
|
||||
# 允许空列表:某次快照只上报设备级计数(batches_*/retries/queue_depth)、无 event 细分时也合法
|
||||
# (与 AnalyticsBatchIn 的 min_length=1 有意不同——那是行为事件、必须至少一条)。
|
||||
events: list[SelfStatEventIn] = Field(default_factory=list, max_length=200)
|
||||
|
||||
|
||||
class SelfStatIngestOut(BaseModel):
|
||||
ok: bool = True
|
||||
snapshot_id: int
|
||||
@@ -102,3 +102,64 @@ class RefreshRequest(BaseModel):
|
||||
|
||||
class LogoutResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
# ===== 微信登录 =====
|
||||
|
||||
class WechatLoginRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1, description="微信 App 授权拿到的 code(单次有效)")
|
||||
device_id: str = Field(
|
||||
"", max_length=64,
|
||||
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
|
||||
)
|
||||
|
||||
|
||||
class WechatLoginResponse(BaseModel):
|
||||
# status="logged_in" → openid 命中,token 有值;"need_bind_phone" → 未命中,bind_ticket 有值
|
||||
status: str
|
||||
token: TokenWithUser | None = None
|
||||
bind_ticket: str | None = None
|
||||
wechat_nickname: str | None = None
|
||||
wechat_avatar_url: str | None = None
|
||||
|
||||
|
||||
class OccupiedAccountInfo(BaseModel):
|
||||
"""手机号被占用时返回的原账号脱敏展示信息(供冲突页)。"""
|
||||
nickname: str | None = None
|
||||
avatar_url: str | None = None
|
||||
created_at: datetime
|
||||
has_wechat: bool = False
|
||||
|
||||
|
||||
class WechatBindResultResponse(BaseModel):
|
||||
# status="logged_in" → 未占用,已建号登入,token 有值;
|
||||
# "phone_occupied" → 手机号被占用,occupied_account + conflict_ticket 有值,token 为 None
|
||||
status: str
|
||||
token: TokenWithUser | None = None
|
||||
occupied_account: OccupiedAccountInfo | None = None
|
||||
conflict_ticket: str | None = None # 占用时签发,换绑/继续绑定只认它
|
||||
rebind_available: bool | None = None # 该手机号 30 天内是否还能换绑(给换绑按钮预置禁用态)
|
||||
rebind_blocked_days: int | None = None # 被限时剩余天数(rebind_available=False 时>0)
|
||||
|
||||
|
||||
class WechatBindPhoneSmsRequest(BaseModel):
|
||||
bind_ticket: str = Field(..., min_length=1)
|
||||
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
|
||||
code: str = Field(..., min_length=4, max_length=8)
|
||||
device_id: str = Field("", max_length=64)
|
||||
|
||||
|
||||
class WechatBindPhoneJverifyRequest(BaseModel):
|
||||
bind_ticket: str = Field(..., min_length=1)
|
||||
login_token: str = Field(..., min_length=1, description="客户端 loginAuth 拿到的 loginToken")
|
||||
device_id: str = Field("", max_length=64)
|
||||
|
||||
|
||||
class WechatConflictContinueRequest(BaseModel):
|
||||
conflict_ticket: str = Field(..., min_length=1)
|
||||
device_id: str = Field("", max_length=64)
|
||||
|
||||
|
||||
class WechatConflictRebindRequest(BaseModel):
|
||||
conflict_ticket: str = Field(..., min_length=1)
|
||||
device_id: str = Field("", max_length=64)
|
||||
|
||||
+22
-1
@@ -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
|
||||
|
||||
@@ -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="处理后的剩余未读总数,可直接刷新铃铛角标")
|
||||
@@ -46,6 +46,17 @@ class AdConfigPublicOut(BaseModel):
|
||||
withdrawal_ad_enabled: bool # 提现激励视频开关(关=客户端直接放行提现)
|
||||
|
||||
|
||||
class HuaweiReviewOut(BaseModel):
|
||||
"""华为审核开关下发给客户端(不鉴权,引导页在登录前就要展示)。
|
||||
|
||||
客户端只需读 onboarding_closable 决策;mode 仅供排查问题时看后台切成了哪态。
|
||||
只有华为 ROM(HarmonyOS/EMUI,不含荣耀 MagicOS)的客户端才会来拉这个端点。
|
||||
"""
|
||||
|
||||
mode: str = "default" # default | review
|
||||
onboarding_closable: bool = False # 快速设置权限步是否允许用户退出(mode == review)
|
||||
|
||||
|
||||
class AppVersionOut(BaseModel):
|
||||
"""最新 App 版本信息(OTA 检查更新,不鉴权)。
|
||||
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
+19
-10
@@ -75,6 +75,21 @@ class ExchangeResultOut(BaseModel):
|
||||
|
||||
# ===== 提现(现金 → 微信零钱) =====
|
||||
|
||||
class WithdrawTierOut(BaseModel):
|
||||
"""提现档位(福利页 coin_cash;7-9 对齐原型)。served by rewards.WITHDRAW_TIERS_COIN_CASH。"""
|
||||
|
||||
amount_cents: int = Field(..., description="档位金额(分)")
|
||||
label: str = Field(..., description="档位方块展示文案,如 0.1 / 10")
|
||||
badge: str | None = Field(None, description="角标文案(如 新人福利);无则空")
|
||||
is_newbie: bool = Field(False, description="新人档:历史一次性,用过后不再下发;免广告直提")
|
||||
available: bool = Field(True, description="当前是否可提(次数/选一额度口径;余额由客户端自判)")
|
||||
disabled_reason: str | None = Field(
|
||||
None,
|
||||
description="不可提原因:quota_exhausted(今日次数满) / other_tier_selected(今日已选其他额度)",
|
||||
)
|
||||
remaining_today: int = Field(0, description="今日剩余可提次数")
|
||||
|
||||
|
||||
class WithdrawInfoOut(BaseModel):
|
||||
min_cents: int = Field(..., description="单次最低提现(分)")
|
||||
max_cents: int = Field(..., description="单次最高提现(分)")
|
||||
@@ -84,6 +99,10 @@ class WithdrawInfoOut(BaseModel):
|
||||
transfer_auth_enabled: bool = Field(
|
||||
False, description="是否已开启免确认到账(开启后提现免跳微信确认,直接到账)"
|
||||
)
|
||||
tiers: list[WithdrawTierOut] = Field(
|
||||
default_factory=list,
|
||||
description="提现档位(source=coin_cash 下发;invite_cash 为空,客户端走旧逻辑)",
|
||||
)
|
||||
|
||||
|
||||
# ===== 免确认收款授权(用户授权免确认模式)=====
|
||||
@@ -204,16 +223,6 @@ class SigninResultOut(BaseModel):
|
||||
coin_balance: int = Field(..., description="签到后金币余额")
|
||||
|
||||
|
||||
class SigninBoostRequest(BaseModel):
|
||||
ad_ref_id: str | None = Field(None, description="广告会话/交易号。当前开发期可空,后续接 S2S 时回填")
|
||||
|
||||
|
||||
class SigninBoostResultOut(BaseModel):
|
||||
coin_awarded: int = Field(..., description="本次膨胀补发金币")
|
||||
coin_balance: int = Field(..., description="膨胀补发后金币余额")
|
||||
signin_date: str = Field(..., description="被膨胀的签到日期 YYYY-MM-DD")
|
||||
|
||||
|
||||
# ===== 任务 =====
|
||||
|
||||
class TaskOut(BaseModel):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user