Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bf04f2655 | |||
| 5f6593eaf2 | |||
| e11f506e1e | |||
| addc30817f | |||
| e165fcfc5e | |||
| 3f7b5167fa | |||
| 9e88ca72d3 | |||
| 21a4d0af5b | |||
| f7a7a49281 | |||
| 71aef455f4 | |||
| 66527f6cdc | |||
| b4a2a8c31d | |||
| ceceeb3458 | |||
| 31f61f6aad | |||
| b7cfcf7495 | |||
| 77f772f47c | |||
| cb8e8ccc1d | |||
| fda82fe313 | |||
| 28a86c3b2c | |||
| 0717c09721 | |||
| 2eb36b44c8 | |||
| 510df176b3 | |||
| 73970087ff | |||
| 9286b82b6d | |||
| f9a62bffbe | |||
| beadce31ed | |||
| f39467ec08 | |||
| 1f874819fd | |||
| c53ce896f7 | |||
| ed26935b14 | |||
| 48037f03fd | |||
| 130a7dff29 | |||
| a86688ccfb | |||
| 061f6baaf1 | |||
| b395648b7c | |||
| e9fd51d119 | |||
| 5c6840dd71 | |||
| 824045dd19 | |||
| 930eff822c | |||
| 285e46ebaf | |||
| 3630fb7b3a | |||
| 2ddea4159d | |||
| 37fd51a498 | |||
| fa4127b9e5 | |||
| 0cf5b3816f | |||
| 9e082e6376 |
+72
-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_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify
|
||||||
JG_REQUEST_TIMEOUT_SEC=15
|
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_MONITOR_ENABLED=true
|
||||||
HEARTBEAT_TIMEOUT_MINUTES=60
|
HEARTBEAT_TIMEOUT_MINUTES=60
|
||||||
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
||||||
@@ -65,6 +113,13 @@ JD_UNION_APP_SECRET=
|
|||||||
JD_UNION_SITE_ID=
|
JD_UNION_SITE_ID=
|
||||||
JD_UNION_AUTH_KEY=
|
JD_UNION_AUTH_KEY=
|
||||||
|
|
||||||
|
# 美团 + 京东订单每天北京时间 05:00 自动对账;按更新时间回拉近 3 天,重叠防漏单并刷新状态。
|
||||||
|
# 手动对账按钮不受该开关影响。通常保持开启;临时停自动任务时设为 false。
|
||||||
|
CPS_AUTO_RECONCILE_ENABLED=true
|
||||||
|
CPS_AUTO_RECONCILE_RUN_HOUR=5
|
||||||
|
CPS_AUTO_RECONCILE_LOOKBACK_DAYS=3
|
||||||
|
CPS_AUTO_RECONCILE_CHECK_INTERVAL_SEC=60
|
||||||
|
|
||||||
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
||||||
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
|
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
|
||||||
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
|
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
|
||||||
@@ -137,3 +192,19 @@ PANGLE_REPORT_SECURITY_KEY=
|
|||||||
# GroMore AppId(报表 site_id 维度)→ 应用环境;默认取现网两个应用,按需覆盖。
|
# GroMore AppId(报表 site_id 维度)→ 应用环境;默认取现网两个应用,按需覆盖。
|
||||||
PANGLE_REPORT_SITE_ID_PROD=5830519
|
PANGLE_REPORT_SITE_ID_PROD=5830519
|
||||||
PANGLE_REPORT_SITE_ID_TEST=5832303
|
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,26 @@
|
|||||||
|
"""merge notification/comparison_user_idx/monitoring_audit heads
|
||||||
|
|
||||||
|
Revision ID: 8e04cc13a211
|
||||||
|
Revises: comparison_user_created_idx, monitoring_audit_rbac, notification_table
|
||||||
|
Create Date: 2026-07-23 15:37:26.967540
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '8e04cc13a211'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ('comparison_user_created_idx', 'monitoring_audit_rbac', 'notification_table')
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,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,35 @@
|
|||||||
|
"""admin_user 加 pages_override 列(「自定义」权限:按人存专属可见页 key 列表)
|
||||||
|
|
||||||
|
权限管理页新增「自定义」角色:选它时该成员的可见页不跟随任何共享角色,而由逐页勾选决定,
|
||||||
|
存这个人专属的一份页 key 列表。仅 role == "custom" 时有效;普通角色为 None(可见页跟随角色)。
|
||||||
|
PG 用 JSONB,SQLite 退化为通用 JSON(同 admin_audit_log.detail / comparison_record.raw_payload)。
|
||||||
|
|
||||||
|
Revision ID: admin_user_pages_override
|
||||||
|
Revises: admin_user_plain_password
|
||||||
|
Create Date: 2026-07-08 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "admin_user_pages_override"
|
||||||
|
down_revision: str | Sequence[str] | None = "admin_user_plain_password"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
# 与 app/models/admin.py 的 _JSON 一致:PG JSONB / 其它 JSON
|
||||||
|
_JSON = sa.JSON().with_variant(JSONB(), "postgresql")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("admin_user", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("pages_override", _JSON, nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("admin_user", schema=None) as batch_op:
|
||||||
|
batch_op.drop_column("pages_override")
|
||||||
@@ -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,52 @@
|
|||||||
|
"""add composite index (user_id, created_at, id) on comparison_record
|
||||||
|
|
||||||
|
C 端「我的比价记录」列表(GET /api/v1/compare/records)是
|
||||||
|
`WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT n` —— 原来只有单列 user_id 索引,
|
||||||
|
过滤完还要把该用户的**全部**记录取出来排序才能拿前 n 条,重度用户随记录数线性变慢。
|
||||||
|
|
||||||
|
本复合索引的反向扫恰好等于 (created_at DESC, id DESC),规划器直接取前 n 条、免排序。
|
||||||
|
列序 (user_id, created_at, id) 与查询一一对应,不要调整。
|
||||||
|
|
||||||
|
Revision ID: comparison_user_created_idx
|
||||||
|
Revises: merge_active_phone
|
||||||
|
Create Date: 2026-07-21
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "comparison_user_created_idx"
|
||||||
|
down_revision = "merge_active_phone"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
INDEX_NAME = "ix_comparison_user_created"
|
||||||
|
COLUMNS = ["user_id", "created_at", "id"]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if bind.dialect.name == "postgresql":
|
||||||
|
# 线上 comparison_record 已有数据量,普通 CREATE INDEX 持表写锁会阻塞比价 harvest 写入;
|
||||||
|
# 用 CONCURRENTLY 不锁表(须脱离事务,autocommit_block 切到自动提交)。
|
||||||
|
# 同 comparison_status_created_idx 的做法。
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
op.create_index(
|
||||||
|
INDEX_NAME, "comparison_record", COLUMNS,
|
||||||
|
unique=False, postgresql_concurrently=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
op.create_index(INDEX_NAME, "comparison_record", COLUMNS, unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if bind.dialect.name == "postgresql":
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
op.drop_index(
|
||||||
|
INDEX_NAME, table_name="comparison_record",
|
||||||
|
postgresql_concurrently=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
op.drop_index(INDEX_NAME, table_name="comparison_record")
|
||||||
@@ -0,0 +1,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,103 @@
|
|||||||
|
"""add per-session coupon claim event table
|
||||||
|
|
||||||
|
Revision ID: coupon_claim_event
|
||||||
|
Revises: 8e04cc13a211
|
||||||
|
Create Date: 2026-07-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "coupon_claim_event"
|
||||||
|
down_revision: str | Sequence[str] | None = "8e04cc13a211"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"coupon_claim_event",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("trace_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("device_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("coupon_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("claim_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=24), nullable=False),
|
||||||
|
sa.Column("app_env", sa.String(length=16), nullable=True),
|
||||||
|
sa.Column("vendor", sa.String(length=48), nullable=True),
|
||||||
|
sa.Column("coupon_name", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("claimed_count", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("reason", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("extra", _JSON, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"trace_id", "coupon_id",
|
||||||
|
name="uq_coupon_claim_event_trace_coupon",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_coupon_claim_event_date_env",
|
||||||
|
"coupon_claim_event",
|
||||||
|
["claim_date", "app_env"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_coupon_claim_event_app_env"),
|
||||||
|
"coupon_claim_event",
|
||||||
|
["app_env"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_coupon_claim_event_trace_id"),
|
||||||
|
"coupon_claim_event",
|
||||||
|
["trace_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_coupon_claim_event_user_id"),
|
||||||
|
"coupon_claim_event",
|
||||||
|
["user_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 旧表按 (device, coupon, 自然日) 去重,trace_id 可空且不在唯一键里:同一
|
||||||
|
# (trace_id, coupon_id) 可能散落在多行(如一次会话的 /step 帧跨零点,把同一张券
|
||||||
|
# 写进相邻两天)。新表按 (trace_id, coupon_id) 唯一,整表 1:1 复制会撞
|
||||||
|
# uq_coupon_claim_event_trace_coupon。回填时按 (trace_id, coupon_id) 只取 id 最大
|
||||||
|
# (最近写入)的一行。历史上已被每日去重覆盖的关联仍无法恢复。
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO coupon_claim_event (
|
||||||
|
trace_id, device_id, user_id, coupon_id, claim_date, status, app_env,
|
||||||
|
vendor, coupon_name, claimed_count, reason, extra, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
trace_id, device_id, user_id, coupon_id, claim_date, status, app_env,
|
||||||
|
vendor, coupon_name, claimed_count, reason, extra, created_at, updated_at
|
||||||
|
FROM coupon_claim_record
|
||||||
|
WHERE trace_id IS NOT NULL
|
||||||
|
AND id IN (
|
||||||
|
SELECT MAX(id)
|
||||||
|
FROM coupon_claim_record
|
||||||
|
WHERE trace_id IS NOT NULL
|
||||||
|
GROUP BY trace_id, coupon_id
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_coupon_claim_event_user_id"), table_name="coupon_claim_event")
|
||||||
|
op.drop_index(op.f("ix_coupon_claim_event_trace_id"), table_name="coupon_claim_event")
|
||||||
|
op.drop_index(op.f("ix_coupon_claim_event_app_env"), table_name="coupon_claim_event")
|
||||||
|
op.drop_index("ix_coupon_claim_event_date_env", table_name="coupon_claim_event")
|
||||||
|
op.drop_table("coupon_claim_event")
|
||||||
@@ -0,0 +1,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,26 @@
|
|||||||
|
"""merge guide_video seq_uq and coupon_claim_event heads
|
||||||
|
|
||||||
|
Revision ID: d8dd2106e438
|
||||||
|
Revises: coupon_claim_event, guide_video_user_seq_uq
|
||||||
|
Create Date: 2026-07-24 11:52:18.290731
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'd8dd2106e438'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ('coupon_claim_event', 'guide_video_user_seq_uq')
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""merge guide_video and main alembic heads
|
||||||
|
|
||||||
|
Revision ID: d9c03cc3ea07
|
||||||
|
Revises: 8e04cc13a211, guide_video_play_table
|
||||||
|
Create Date: 2026-07-23 22:57:40.998161
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'd9c03cc3ea07'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ('8e04cc13a211', 'guide_video_play_table')
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add direct vendor push fields
|
||||||
|
|
||||||
|
Revision ID: direct_vendor_push_fields
|
||||||
|
Revises: jd_cps_order_fields
|
||||||
|
Create Date: 2026-07-01 16:30:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "direct_vendor_push_fields"
|
||||||
|
down_revision = "jd_cps_order_fields"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("device_liveness") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("push_vendor", sa.String(length=32), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column("push_token", sa.String(length=256), nullable=True))
|
||||||
|
batch_op.create_index("ix_device_liveness_push_vendor", ["push_vendor"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("device_liveness") as batch_op:
|
||||||
|
batch_op.drop_index("ix_device_liveness_push_vendor")
|
||||||
|
batch_op.drop_column("push_token")
|
||||||
|
batch_op.drop_column("push_vendor")
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""下线签到膨胀:drop signin_boost_record
|
||||||
|
|
||||||
|
产品 2026-07 确认「固定 3000 金币的签到膨胀」从来不是设计内的口径 —— 奖励只有「签到」和
|
||||||
|
「看视频」两种。签到弹窗里的「看广告膨胀」改与福利页看视频走同一条 reward_video 路径
|
||||||
|
(按 eCPM 公式发,记在 ad_reward_record),signin_boost 场景整体摘除。
|
||||||
|
|
||||||
|
⚠️ **只 drop 这张表,不动 coin_transaction**:`biz_type='signin_boost'` 的金币流水是真发过的
|
||||||
|
钱,账必须留得住(admin 大盘的 signin_boost_coin_total / signin_boost_watch_count 改为从
|
||||||
|
coin_transaction 统计,继续能查回历史)。本表只是「哪天膨胀过」的业务留痕,金额与去向都能
|
||||||
|
从流水还原,drop 掉不影响对账。
|
||||||
|
|
||||||
|
downgrade 只重建空表结构,**不恢复数据** —— 真要回滚得先从备份捞行。
|
||||||
|
|
||||||
|
Revision ID: drop_signin_boost_record
|
||||||
|
Revises: ad_reward_boost_round_id
|
||||||
|
Create Date: 2026-07-20
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "drop_signin_boost_record"
|
||||||
|
down_revision: str | Sequence[str] | None = "ad_reward_boost_round_id"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("signin_boost_record", schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f("ix_signin_boost_record_user_id"))
|
||||||
|
op.drop_table("signin_boost_record")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 只还结构不还数据(见模块 docstring)
|
||||||
|
op.create_table(
|
||||||
|
"signin_boost_record",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("signin_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("coin_awarded", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("ad_ref_id", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("user_id", "signin_date", name="uq_signin_boost_user_date"),
|
||||||
|
)
|
||||||
|
with op.batch_alter_table("signin_boost_record", schema=None) as batch_op:
|
||||||
|
batch_op.create_index(
|
||||||
|
batch_op.f("ix_signin_boost_record_user_id"), ["user_id"], unique=False
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""drop withdraw active-order partial unique index (allow multiple in-flight withdrawals)
|
||||||
|
|
||||||
|
Revision ID: drop_withdraw_active_uniq_idx
|
||||||
|
Revises: d8dd2106e438
|
||||||
|
Create Date: 2026-07-24 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "drop_withdraw_active_uniq_idx"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "d8dd2106e438"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 取消「同一用户同一时刻仅一笔在途提现」:允许 reviewing/pending 并存。
|
||||||
|
# 仅删本索引;姊妹索引 ux_cash_transaction_withdraw_refund_ref(退款幂等)保持不动。
|
||||||
|
op.drop_index("ux_withdraw_order_user_active", table_name="withdraw_order")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 回滚重建分区唯一索引。注意:若届时某用户已有 ≥2 张在途单,重建会因唯一冲突失败——
|
||||||
|
# 属预期的回滚代价(取消限制后本就允许多单),需先人工收敛在途单再回滚。
|
||||||
|
op.create_index(
|
||||||
|
"ux_withdraw_order_user_active",
|
||||||
|
"withdraw_order",
|
||||||
|
["user_id"],
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=sa.text("status IN ('reviewing', 'pending')"),
|
||||||
|
postgresql_where=sa.text("status IN ('reviewing', 'pending')"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""新手引导视频播放记录表(领券浮层前 N 次替代广告)
|
||||||
|
|
||||||
|
见 app/models/guide_video.py:按账号计次(开播即计数)、play_token 幂等发币。
|
||||||
|
配置(开关 / 视频地址 / 次数 / 金币)复用既有 app_config 表,无需建表。
|
||||||
|
|
||||||
|
Revision ID: guide_video_play_table
|
||||||
|
Revises: meituan_coupon_feed_indexes
|
||||||
|
Create Date: 2026-07-23 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "guide_video_play_table"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "meituan_coupon_feed_indexes"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"guide_video_play",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("play_token", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("scene", sa.String(length=16), nullable=False, server_default="coupon"),
|
||||||
|
sa.Column("seq", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("video_url", sa.String(length=512), nullable=True),
|
||||||
|
sa.Column("coin", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("status", sa.String(length=16), nullable=False, server_default="playing"),
|
||||||
|
sa.Column("completed", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column(
|
||||||
|
"started_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("granted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("play_token", name="uq_guide_video_play_token"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_guide_video_play_user_id", "guide_video_play", ["user_id"])
|
||||||
|
op.create_index("ix_guide_video_play_started_at", "guide_video_play", ["started_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_guide_video_play_started_at", table_name="guide_video_play")
|
||||||
|
op.drop_index("ix_guide_video_play_user_id", table_name="guide_video_play")
|
||||||
|
op.drop_table("guide_video_play")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""guide_video_play 加 (user_id, seq) 唯一约束:堵住并发 /start 绕过次数上限
|
||||||
|
|
||||||
|
start_play 是无锁 check-then-insert(读 COUNT(*) 算 seq=used+1 再插一行),N 个并发
|
||||||
|
/start 会都读到同一个 used、算出同一个 seq、各插一行拿到各自的 play_token,于是 3 次
|
||||||
|
上限被绕过、每个 token 都能换 120 金币。加唯一键后并发同 seq 必撞,start_play 捕获
|
||||||
|
IntegrityError 降级为 should_play=false(客户端照旧放广告)。
|
||||||
|
|
||||||
|
用 unique index 而不是 batch_alter_table 加 UniqueConstraint:SQLite 加约束要整表重建,
|
||||||
|
而 CREATE UNIQUE INDEX 两边都原生支持,回滚也干净。
|
||||||
|
|
||||||
|
注:若库里已有并发产生的重复 (user_id, seq),建索引会失败 —— 本功能尚未上线,表通常是空的;
|
||||||
|
真撞上了先按 seq 去重(留 id 最小的一行,多发的金币按 scripts/reset_guide_video.py 的口径退)。
|
||||||
|
|
||||||
|
Revision ID: guide_video_user_seq_uq
|
||||||
|
Revises: d9c03cc3ea07
|
||||||
|
Create Date: 2026-07-24 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "guide_video_user_seq_uq"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "d9c03cc3ea07"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_index(
|
||||||
|
"uq_guide_video_play_user_seq",
|
||||||
|
"guide_video_play",
|
||||||
|
["user_id", "seq"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("uq_guide_video_play_user_seq", table_name="guide_video_play")
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""meituan_coupon 首页 feed 分页复合索引(销量最高 / 智能推荐)
|
||||||
|
|
||||||
|
「销量最高」「智能推荐」两个 tab 都是
|
||||||
|
WHERE city_id = ? [+ 过滤] → DISTINCT ON (dedup_key) ORDER BY dedup_key, <排序键> DESC
|
||||||
|
的形状。列顺序对齐后 Postgres 可以顺着索引流式去重,免掉「每翻一页就把该城全部券重排一遍」,
|
||||||
|
这是首页下滑到底越来越慢的根因之一(另一半在 app 层:见 api/v1/meituan.py 的 _paged_dedup_ids)。
|
||||||
|
|
||||||
|
⚠️ 本文件同时是一个 **merge 迁移**:主干此前有 3 个并行 head
|
||||||
|
(comparison_user_created_idx / monitoring_audit_rbac / notification_table),
|
||||||
|
`alembic upgrade head` 会因 multiple heads 报错。这里一并收敛回单 head。
|
||||||
|
|
||||||
|
Revision ID: meituan_coupon_feed_indexes
|
||||||
|
Revises: comparison_user_created_idx, monitoring_audit_rbac, notification_table
|
||||||
|
Create Date: 2026-07-23 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "meituan_coupon_feed_indexes"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = (
|
||||||
|
"comparison_user_created_idx",
|
||||||
|
"monitoring_audit_rbac",
|
||||||
|
"notification_table",
|
||||||
|
)
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 销量最高:WHERE city_id=? AND sale_volume_num IS NOT NULL
|
||||||
|
# ORDER BY dedup_key, sale_volume_num DESC, commission_percent DESC
|
||||||
|
op.create_index(
|
||||||
|
"ix_meituan_coupon_city_dedup_sales",
|
||||||
|
"meituan_coupon",
|
||||||
|
["city_id", "dedup_key", sa.text("sale_volume_num DESC"), sa.text("commission_percent DESC")],
|
||||||
|
)
|
||||||
|
# 智能推荐:WHERE city_id=? AND commission_percent>=3.0
|
||||||
|
# ORDER BY dedup_key, commission_percent DESC
|
||||||
|
op.create_index(
|
||||||
|
"ix_meituan_coupon_city_dedup_comm",
|
||||||
|
"meituan_coupon",
|
||||||
|
["city_id", "dedup_key", sa.text("commission_percent DESC")],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_meituan_coupon_city_dedup_comm", table_name="meituan_coupon")
|
||||||
|
op.drop_index("ix_meituan_coupon_city_dedup_sales", table_name="meituan_coupon")
|
||||||
@@ -0,0 +1,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,56 @@
|
|||||||
|
"""补齐监控审计页面权限。
|
||||||
|
|
||||||
|
Revision ID: monitoring_audit_rbac
|
||||||
|
Revises: merge_signin_boost_main
|
||||||
|
Create Date: 2026-07-22 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "monitoring_audit_rbac"
|
||||||
|
down_revision: str | Sequence[str] | None = "merge_signin_boost_main"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||||
|
_PAGE = "analytics-health"
|
||||||
|
|
||||||
|
|
||||||
|
def _role_table() -> sa.TableClause:
|
||||||
|
return sa.table(
|
||||||
|
"admin_role",
|
||||||
|
sa.column("name", sa.String),
|
||||||
|
sa.column("pages", _JSON),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
role = _role_table()
|
||||||
|
conn = op.get_bind()
|
||||||
|
pages = conn.execute(
|
||||||
|
sa.select(role.c.pages).where(role.c.name == "tech")
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if pages is not None and _PAGE not in pages:
|
||||||
|
conn.execute(
|
||||||
|
role.update()
|
||||||
|
.where(role.c.name == "tech")
|
||||||
|
.values(pages=[*pages, _PAGE])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
role = _role_table()
|
||||||
|
conn = op.get_bind()
|
||||||
|
pages = conn.execute(
|
||||||
|
sa.select(role.c.pages).where(role.c.name == "tech")
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if pages is not None and _PAGE in pages:
|
||||||
|
conn.execute(
|
||||||
|
role.update()
|
||||||
|
.where(role.c.name == "tech")
|
||||||
|
.values(pages=[page for page in pages if page != _PAGE])
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""add generic behavior, risk incident and subject restriction tables
|
||||||
|
|
||||||
|
Revision ID: risk_monitor_generic
|
||||||
|
Revises: drop_withdraw_active_uniq_idx
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "risk_monitor_generic"
|
||||||
|
down_revision: str | None = "drop_withdraw_active_uniq_idx"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||||
|
_RISK_MONITOR_PAGE = "risk-monitor"
|
||||||
|
_DEFAULT_RISK_MONITOR_ROLES = ("operator", "tech")
|
||||||
|
|
||||||
|
|
||||||
|
def _role_table() -> sa.TableClause:
|
||||||
|
return sa.table(
|
||||||
|
"admin_role",
|
||||||
|
sa.column("name", sa.String),
|
||||||
|
sa.column("pages", _JSON),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_default_role_permissions() -> None:
|
||||||
|
"""Grant the new page without replacing any existing role customisation."""
|
||||||
|
role = _role_table()
|
||||||
|
conn = op.get_bind()
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.select(role.c.name, role.c.pages).where(
|
||||||
|
role.c.name.in_(_DEFAULT_RISK_MONITOR_ROLES)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for name, pages in rows:
|
||||||
|
current_pages = list(pages or [])
|
||||||
|
if _RISK_MONITOR_PAGE not in current_pages:
|
||||||
|
conn.execute(
|
||||||
|
role.update()
|
||||||
|
.where(role.c.name == name)
|
||||||
|
.values(pages=[*current_pages, _RISK_MONITOR_PAGE])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_default_role_permissions() -> None:
|
||||||
|
role = _role_table()
|
||||||
|
conn = op.get_bind()
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.select(role.c.name, role.c.pages).where(
|
||||||
|
role.c.name.in_(_DEFAULT_RISK_MONITOR_ROLES)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for name, pages in rows:
|
||||||
|
current_pages = list(pages or [])
|
||||||
|
if _RISK_MONITOR_PAGE in current_pages:
|
||||||
|
conn.execute(
|
||||||
|
role.update()
|
||||||
|
.where(role.c.name == name)
|
||||||
|
.values(
|
||||||
|
pages=[
|
||||||
|
page
|
||||||
|
for page in current_pages
|
||||||
|
if page != _RISK_MONITOR_PAGE
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"behavior_event",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("event_type", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("subject_type", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("subject_id", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("device_id", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("device_model", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("phone", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("client_ip", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("outcome", sa.String(length=24), nullable=False),
|
||||||
|
sa.Column("reason", sa.String(length=256), nullable=True),
|
||||||
|
sa.Column("details", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_behavior_event_occurred_at", "behavior_event", ["occurred_at"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_behavior_event_type_time", "behavior_event", ["event_type", "occurred_at"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_behavior_event_subject_time",
|
||||||
|
"behavior_event",
|
||||||
|
["subject_type", "subject_id", "occurred_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_behavior_event_user_time", "behavior_event", ["user_id", "occurred_at"]
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"risk_incident",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("rule_code", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("event_type", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("subject_type", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("subject_id", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("window_key", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("window_start", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("window_end", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("first_event_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("triggered_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("last_event_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("event_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=24), nullable=False),
|
||||||
|
sa.Column("action_reason", sa.String(length=256), nullable=True),
|
||||||
|
sa.Column("handled_by", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("handled_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("details", sa.JSON(), nullable=True),
|
||||||
|
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.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"rule_code",
|
||||||
|
"subject_type",
|
||||||
|
"subject_id",
|
||||||
|
"window_key",
|
||||||
|
name="uq_risk_incident_rule_subject_window",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_risk_incident_rule_status",
|
||||||
|
"risk_incident",
|
||||||
|
["rule_code", "status", "triggered_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_risk_incident_subject",
|
||||||
|
"risk_incident",
|
||||||
|
["subject_type", "subject_id", "triggered_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"subject_restriction",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("subject_type", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("subject_id", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("scope", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("active", sa.Boolean(), server_default=sa.true(), nullable=False),
|
||||||
|
sa.Column("reason", sa.String(length=256), nullable=True),
|
||||||
|
sa.Column("incident_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_by", sa.Integer(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("(CURRENT_TIMESTAMP)"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("revoked_by", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("details", sa.JSON(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"subject_type",
|
||||||
|
"subject_id",
|
||||||
|
"scope",
|
||||||
|
name="uq_subject_restriction_subject_scope",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_subject_restriction_lookup",
|
||||||
|
"subject_restriction",
|
||||||
|
["subject_type", "subject_id", "scope", "active"],
|
||||||
|
)
|
||||||
|
_add_default_role_permissions()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_remove_default_role_permissions()
|
||||||
|
op.drop_index("ix_subject_restriction_lookup", table_name="subject_restriction")
|
||||||
|
op.drop_table("subject_restriction")
|
||||||
|
op.drop_index("ix_risk_incident_subject", table_name="risk_incident")
|
||||||
|
op.drop_index("ix_risk_incident_rule_status", table_name="risk_incident")
|
||||||
|
op.drop_table("risk_incident")
|
||||||
|
op.drop_index("ix_behavior_event_user_time", table_name="behavior_event")
|
||||||
|
op.drop_index("ix_behavior_event_subject_time", table_name="behavior_event")
|
||||||
|
op.drop_index("ix_behavior_event_type_time", table_name="behavior_event")
|
||||||
|
op.drop_index("ix_behavior_event_occurred_at", table_name="behavior_event")
|
||||||
|
op.drop_table("behavior_event")
|
||||||
@@ -10,6 +10,8 @@ from fastapi import Depends, HTTPException, Request, status
|
|||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.admin.permissions import ALL_PAGE_KEYS, CUSTOM_ROLE, SUPER_ADMIN_ROLE, sanitize_pages
|
||||||
|
from app.admin.repositories import admin_role as role_repo
|
||||||
from app.admin.repositories import admin_user as admin_repo
|
from app.admin.repositories import admin_user as admin_repo
|
||||||
from app.admin.security import AdminTokenError, decode_admin_token
|
from app.admin.security import AdminTokenError, decode_admin_token
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
@@ -72,6 +74,33 @@ def require_role(*roles: str):
|
|||||||
return _checker
|
return _checker
|
||||||
|
|
||||||
|
|
||||||
|
def require_page(page: str):
|
||||||
|
"""页面权限守卫依赖工厂。
|
||||||
|
|
||||||
|
左侧导航隐藏只是 UI,这个守卫确保直接调用 API 也必须持有对应页面权限。
|
||||||
|
super_admin 恒通过;custom 读个人 pages_override;其余角色读 admin_role.pages。
|
||||||
|
"""
|
||||||
|
if page not in ALL_PAGE_KEYS:
|
||||||
|
raise ValueError(f"unknown admin page permission: {page}")
|
||||||
|
|
||||||
|
def _checker(admin: CurrentAdmin, db: AdminDb) -> AdminUser:
|
||||||
|
if admin.role == SUPER_ADMIN_ROLE:
|
||||||
|
return admin
|
||||||
|
pages = (
|
||||||
|
sanitize_pages(admin.pages_override)
|
||||||
|
if admin.role == CUSTOM_ROLE
|
||||||
|
else role_repo.effective_pages_of(db, admin.role)
|
||||||
|
)
|
||||||
|
if page not in pages:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"page '{page}' not allowed",
|
||||||
|
)
|
||||||
|
return admin
|
||||||
|
|
||||||
|
return _checker
|
||||||
|
|
||||||
|
|
||||||
def get_client_ip(request: Request) -> str:
|
def get_client_ip(request: Request) -> str:
|
||||||
"""取客户端 IP(审计日志用)。生产经 nginx 反代,优先 X-Forwarded-For 第一段;否则直连 IP。
|
"""取客户端 IP(审计日志用)。生产经 nginx 反代,优先 X-Forwarded-For 第一段;否则直连 IP。
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -17,6 +17,7 @@ from app.admin.routers.ad_audit import router as ad_audit_router
|
|||||||
from app.admin.routers.ad_config import router as ad_config_router
|
from app.admin.routers.ad_config import router as ad_config_router
|
||||||
from app.admin.routers.ad_revenue import router as ad_revenue_router
|
from app.admin.routers.ad_revenue import router as ad_revenue_router
|
||||||
from app.admin.routers.admins import router as admins_router
|
from app.admin.routers.admins import router as admins_router
|
||||||
|
from app.admin.routers.analytics_health import router as analytics_health_router
|
||||||
from app.admin.routers.audit import router as audit_router
|
from app.admin.routers.audit import router as audit_router
|
||||||
from app.admin.routers.auth import router as auth_router
|
from app.admin.routers.auth import router as auth_router
|
||||||
from app.admin.routers.comparison import router as comparison_router
|
from app.admin.routers.comparison import router as comparison_router
|
||||||
@@ -25,19 +26,23 @@ from app.admin.routers.coupon_data import router as coupon_data_router
|
|||||||
from app.admin.routers.cps import router as cps_router
|
from app.admin.routers.cps import router as cps_router
|
||||||
from app.admin.routers.dashboard import router as dashboard_router
|
from app.admin.routers.dashboard import router as dashboard_router
|
||||||
from app.admin.routers.device_liveness import router as device_liveness_router
|
from app.admin.routers.device_liveness import router as device_liveness_router
|
||||||
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
|
|
||||||
from app.admin.routers.event_logs import router as event_logs_router
|
from app.admin.routers.event_logs import router as event_logs_router
|
||||||
from app.admin.routers.feedback import router as feedback_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.feedback_qr import router as feedback_qr_router
|
||||||
|
from app.admin.routers.guide_video import router as guide_video_router
|
||||||
|
from app.admin.routers.huawei_review import router as huawei_review_router
|
||||||
from app.admin.routers.onboarding import router as onboarding_router
|
from app.admin.routers.onboarding import router as onboarding_router
|
||||||
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
|
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
|
||||||
|
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
|
||||||
from app.admin.routers.price_report import router as price_report_router
|
from app.admin.routers.price_report import router as price_report_router
|
||||||
|
from app.admin.routers.risk_monitor import router as risk_monitor_router
|
||||||
from app.admin.routers.roles import router as roles_router
|
from app.admin.routers.roles import router as roles_router
|
||||||
from app.admin.routers.users import router as users_router
|
from app.admin.routers.users import router as users_router
|
||||||
from app.admin.routers.wallet import router as wallet_router
|
from app.admin.routers.wallet import router as wallet_router
|
||||||
from app.admin.routers.withdraw import router as withdraw_router
|
from app.admin.routers.withdraw import router as withdraw_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.logging import setup_logging
|
from app.core.logging import setup_logging
|
||||||
|
from app.integrations import meituan as mt_meituan
|
||||||
|
|
||||||
setup_logging(debug=settings.APP_DEBUG)
|
setup_logging(debug=settings.APP_DEBUG)
|
||||||
logger = logging.getLogger("shagua.admin")
|
logger = logging.getLogger("shagua.admin")
|
||||||
@@ -51,6 +56,8 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|||||||
settings.DATABASE_URL.split("://", 1)[0],
|
settings.DATABASE_URL.split("://", 1)[0],
|
||||||
)
|
)
|
||||||
yield
|
yield
|
||||||
|
# CPS 后台页会打美团(routers/cps.py),那条共享 client 若被建过要在这里关掉连接池
|
||||||
|
mt_meituan.close_client()
|
||||||
logger.info("admin app shutting down")
|
logger.info("admin app shutting down")
|
||||||
|
|
||||||
|
|
||||||
@@ -95,9 +102,12 @@ admin_app.include_router(onboarding_router)
|
|||||||
admin_app.include_router(wallet_router)
|
admin_app.include_router(wallet_router)
|
||||||
admin_app.include_router(withdraw_router)
|
admin_app.include_router(withdraw_router)
|
||||||
admin_app.include_router(price_report_router)
|
admin_app.include_router(price_report_router)
|
||||||
|
admin_app.include_router(risk_monitor_router)
|
||||||
admin_app.include_router(feedback_router)
|
admin_app.include_router(feedback_router)
|
||||||
admin_app.include_router(event_logs_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(feedback_qr_router)
|
||||||
|
admin_app.include_router(guide_video_router)
|
||||||
admin_app.include_router(admins_router)
|
admin_app.include_router(admins_router)
|
||||||
admin_app.include_router(roles_router)
|
admin_app.include_router(roles_router)
|
||||||
admin_app.include_router(audit_router)
|
admin_app.include_router(audit_router)
|
||||||
@@ -107,4 +117,5 @@ admin_app.include_router(cps_router)
|
|||||||
admin_app.include_router(coupon_data_router)
|
admin_app.include_router(coupon_data_router)
|
||||||
admin_app.include_router(ad_audit_router)
|
admin_app.include_router(ad_audit_router)
|
||||||
admin_app.include_router(ad_config_router)
|
admin_app.include_router(ad_config_router)
|
||||||
|
admin_app.include_router(huawei_review_router)
|
||||||
admin_app.include_router(ad_revenue_router)
|
admin_app.include_router(ad_revenue_router)
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ super_admin 为内建全权角色,恒可见全部页(effective_pages 特判)。
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
SUPER_ADMIN_ROLE = "super_admin"
|
SUPER_ADMIN_ROLE = "super_admin"
|
||||||
|
# 「自定义」哨兵角色:不是 admin_role 表里的行,而是标记「这个人的可见页由 pages_override 决定」。
|
||||||
|
# admin_user.role == CUSTOM_ROLE 时,有效可见页取 admin_user.pages_override(见 auth._admin_out_with_pages)。
|
||||||
|
CUSTOM_ROLE = "custom"
|
||||||
|
|
||||||
# 分组镜像前端导航(app/(main)/layout.tsx 的 NAV_GROUPS);key = 路由一级
|
# 分组镜像前端导航(app/(main)/layout.tsx 的 NAV_GROUPS);key = 路由一级
|
||||||
PERMISSION_CATALOG: list[dict] = [
|
PERMISSION_CATALOG: list[dict] = [
|
||||||
@@ -18,7 +21,6 @@ PERMISSION_CATALOG: list[dict] = [
|
|||||||
{"key": "ad-revenue-report", "label": "广告收益"},
|
{"key": "ad-revenue-report", "label": "广告收益"},
|
||||||
{"key": "comparison-records", "label": "比价记录"},
|
{"key": "comparison-records", "label": "比价记录"},
|
||||||
{"key": "cps", "label": "CPS收益"},
|
{"key": "cps", "label": "CPS收益"},
|
||||||
{"key": "device-liveness", "label": "设备存活"},
|
|
||||||
]},
|
]},
|
||||||
{"group": "奖励审核", "pages": [
|
{"group": "奖励审核", "pages": [
|
||||||
{"key": "withdraws", "label": "提现审核"},
|
{"key": "withdraws", "label": "提现审核"},
|
||||||
@@ -28,12 +30,18 @@ PERMISSION_CATALOG: list[dict] = [
|
|||||||
{"group": "数据配置", "pages": [
|
{"group": "数据配置", "pages": [
|
||||||
{"key": "config", "label": "系统配置"},
|
{"key": "config", "label": "系统配置"},
|
||||||
{"key": "ad-revenue", "label": "广告配置"},
|
{"key": "ad-revenue", "label": "广告配置"},
|
||||||
|
{"key": "huawei-review", "label": "华为审核开关"},
|
||||||
{"key": "users", "label": "用户管理"},
|
{"key": "users", "label": "用户管理"},
|
||||||
]},
|
]},
|
||||||
|
{"group": "监控审计", "pages": [
|
||||||
|
{"key": "risk-monitor", "label": "风控监控"},
|
||||||
|
{"key": "device-liveness", "label": "设备存活"},
|
||||||
|
{"key": "analytics-health", "label": "埋点成功率"},
|
||||||
|
{"key": "event-logs", "label": "埋点日志"},
|
||||||
|
{"key": "audit-logs", "label": "审计日志"},
|
||||||
|
]},
|
||||||
{"group": "其他", "pages": [
|
{"group": "其他", "pages": [
|
||||||
{"key": "admins", "label": "权限管理"},
|
{"key": "admins", "label": "权限管理"},
|
||||||
{"key": "event-logs", "label": "埋点日志"},
|
|
||||||
{"key": "audit-logs", "label": "审计日志"},
|
|
||||||
]},
|
]},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -48,13 +56,14 @@ BUILTIN_ROLES: list[dict] = [
|
|||||||
{"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []},
|
{"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []},
|
||||||
{"name": "operator", "label": "运营", "pages": [
|
{"name": "operator", "label": "运营", "pages": [
|
||||||
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
|
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
|
||||||
"cps", "device-liveness", "price-reports", "feedbacks",
|
"cps", "risk-monitor", "device-liveness", "price-reports", "feedbacks", "huawei-review",
|
||||||
]},
|
]},
|
||||||
{"name": "finance", "label": "财务", "pages": [
|
{"name": "finance", "label": "财务", "pages": [
|
||||||
"dashboard", "ad-revenue-report", "cps", "withdraws",
|
"dashboard", "ad-revenue-report", "cps", "withdraws",
|
||||||
]},
|
]},
|
||||||
{"name": "tech", "label": "技术", "pages": [
|
{"name": "tech", "label": "技术", "pages": [
|
||||||
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
|
"dashboard", "risk-monitor", "device-liveness", "analytics-health", "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.core import rewards
|
||||||
from app.models.ad_ecpm import AdEcpmRecord
|
from app.models.ad_ecpm import AdEcpmRecord
|
||||||
from app.models.user import User
|
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:
|
def _cn_hour(dt: datetime) -> int:
|
||||||
@@ -59,6 +81,10 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
|
|||||||
# ad_feed_reward_record,由 audit 内部按 ad_type 区分(feed 含历史 NULL,draw 仅 ad_type=="draw")。
|
# ad_feed_reward_record,由 audit 内部按 ad_type 区分(feed 含历史 NULL,draw 仅 ad_type=="draw")。
|
||||||
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
|
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
|
||||||
|
|
||||||
|
# 激励视频未满足有效播放条件时不计客户端预估收益。客户端仍会在 onAdShow
|
||||||
|
# 上报 eCPM,随后才在关闭时补报以下终态,因此必须在展示/发奖合并后修正收益。
|
||||||
|
_ZERO_REVENUE_REWARD_VIDEO_STATUSES = frozenset({"closed_early", "too_short"})
|
||||||
|
|
||||||
|
|
||||||
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
|
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
|
||||||
_REWARD_DETAIL_KEYS = (
|
_REWARD_DETAIL_KEYS = (
|
||||||
@@ -82,6 +108,7 @@ def ad_revenue_report(
|
|||||||
ad_type: str | None = None,
|
ad_type: str | None = None,
|
||||||
feed_scene: str | None = None,
|
feed_scene: str | None = None,
|
||||||
app_env: str | None = None,
|
app_env: str | None = None,
|
||||||
|
revenue_scope: str = "all",
|
||||||
granularity: str = "day",
|
granularity: str = "day",
|
||||||
limit: int = 500,
|
limit: int = 500,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
@@ -179,6 +206,11 @@ def ad_revenue_report(
|
|||||||
"matched": bool(rwd["matched"]),
|
"matched": bool(rwd["matched"]),
|
||||||
"reward_detail": _reward_detail(rwd),
|
"reward_detail": _reward_detail(rwd),
|
||||||
})
|
})
|
||||||
|
if (
|
||||||
|
rec.ad_type == "reward_video"
|
||||||
|
and rwd["status"] in _ZERO_REVENUE_REWARD_VIDEO_STATUSES
|
||||||
|
):
|
||||||
|
ev["revenue_yuan"] = 0.0
|
||||||
else:
|
else:
|
||||||
# 纯展示(信息流逐条展示、激励视频缺发奖记录):不计对账,matched=True。
|
# 纯展示(信息流逐条展示、激励视频缺发奖记录):不计对账,matched=True。
|
||||||
ev.update({
|
ev.update({
|
||||||
@@ -277,14 +309,18 @@ def ad_revenue_report(
|
|||||||
if feed_scene is not None:
|
if feed_scene is not None:
|
||||||
events = [e for e in events if e.get("feed_scene") == feed_scene]
|
events = [e for e in events if e.get("feed_scene") == feed_scene]
|
||||||
|
|
||||||
# app_env 过滤(2026-06-29 新增能力,修隐患:测试应用上报的假 eCPM 如 ¥678 CPM 会污染正式收益合计/平均):
|
# app_env 过滤:显式传 "prod"/"test" 只看该环境;不传=全部。该参数也会传给下方穿山甲聚合,
|
||||||
# 显式传 "prod"/"test" 只看该环境;不传=全部(维持现状)。**不擅自把默认改成排除 test**——本地 dev 库多为
|
# 保证客户端预估与 GroMore 汇总使用同一应用环境口径。
|
||||||
# test 数据、默认排除会使本地报表空,且「正式报表是否含 test」属产品口径。建议前端报表页加 app_env 筛选器
|
|
||||||
# (默认选 prod),或产品确认后再把默认改成排除 test。注:穿山甲后台收益列(total_pangle_*)暂未联动此过滤
|
|
||||||
# (它是独立对照列,且 pangle 的 test 是真实小额、非客户端那种假值)。
|
|
||||||
if app_env is not None:
|
if app_env is not None:
|
||||||
events = [e for e in events if e.get("app_env") == app_env]
|
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 原值是字符串「分」,转数值排;
|
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
|
||||||
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
|
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
|
||||||
if sort == "ecpm":
|
if sort == "ecpm":
|
||||||
@@ -336,7 +372,13 @@ def ad_revenue_report(
|
|||||||
total_pangle_revenue_yuan: float | None = None
|
total_pangle_revenue_yuan: float | None = None
|
||||||
total_pangle_api_revenue_yuan: float | None = None
|
total_pangle_api_revenue_yuan: float | None = None
|
||||||
if pangle_filterable:
|
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:
|
if pangle_aggs:
|
||||||
by_date = {a["date"]: a for a in pangle_aggs}
|
by_date = {a["date"]: a for a in pangle_aggs}
|
||||||
for d in daily:
|
for d in daily:
|
||||||
|
|||||||
@@ -26,14 +26,17 @@ def create_admin(
|
|||||||
password: str,
|
password: str,
|
||||||
role: str = "operator",
|
role: str = "operator",
|
||||||
plain_password: str | None = None,
|
plain_password: str | None = None,
|
||||||
|
pages_override: list[str] | None = None,
|
||||||
) -> AdminUser:
|
) -> AdminUser:
|
||||||
"""建管理员。plain_password 非空则额外留存明文(后台 UI 建的账号传,供权限管理页复看);
|
"""建管理员。plain_password 非空则额外留存明文(后台 UI 建的账号传,供权限管理页复看);
|
||||||
脚本/起后台建账号不传(留 None → 前端不显示密码)。"""
|
脚本/起后台建账号不传(留 None → 前端不显示密码)。
|
||||||
|
pages_override:role=="custom" 时传专属可见页 key 列表;普通角色为 None。"""
|
||||||
admin = AdminUser(
|
admin = AdminUser(
|
||||||
username=username,
|
username=username,
|
||||||
password_hash=hash_password(password),
|
password_hash=hash_password(password),
|
||||||
role=role,
|
role=role,
|
||||||
plain_password=plain_password,
|
plain_password=plain_password,
|
||||||
|
pages_override=pages_override,
|
||||||
)
|
)
|
||||||
db.add(admin)
|
db.add(admin)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,21 +1,28 @@
|
|||||||
"""admin「领券数据」看板聚合:发起/完成数、耗时均值与分位、按天/小时趋势、逐条明细。
|
"""admin「领券数据」看板聚合:发起/完成数、耗时均值与分位、按天/小时趋势、逐条明细。
|
||||||
|
|
||||||
数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。量级不大,全量拉
|
数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。生产 PostgreSQL
|
||||||
区间数据后 Python 聚合(分位 SQLite 无 percentile,统一 Python 算,PG 上也一致)。
|
使用 percentile_cont 聚合耗时分位;SQLite 本地/测试环境回退读取耗时单列计算。
|
||||||
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
|
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
|
||||||
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
|
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
|
||||||
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
|
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
|
||||||
|
- 另含 coupon_slot_report(数据源 coupon_claim_record):按 coupon_id「按券成功率」表,见设计 §13。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core import rewards
|
from app.core import rewards
|
||||||
from app.models.coupon_state import CouponSession
|
from app.models.coupon_state import CouponClaimEvent, CouponClaimRecord, CouponSession
|
||||||
from app.models.user import User
|
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:
|
def _cn_hour(dt: datetime) -> int:
|
||||||
@@ -38,11 +45,126 @@ def _percentile(sorted_vals: list[int], q: float) -> int | None:
|
|||||||
return round(sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac)
|
return round(sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac)
|
||||||
|
|
||||||
|
|
||||||
|
def _round_duration_ms(value) -> int | None:
|
||||||
|
"""将数据库聚合结果按既有 Python round 口径转为整数毫秒。"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return int(round(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _coupon_summary_aggregate_stmt(conditions: list):
|
||||||
|
"""PostgreSQL 汇总卡聚合语句;计数、均值与四个分位一次返回。"""
|
||||||
|
completed = CouponSession.status == "completed"
|
||||||
|
completed_elapsed = completed & CouponSession.elapsed_ms.is_not(None)
|
||||||
|
return select(
|
||||||
|
func.count(CouponSession.id),
|
||||||
|
func.sum(case((completed, 1), else_=0)),
|
||||||
|
func.avg(CouponSession.elapsed_ms).filter(completed_elapsed),
|
||||||
|
*(
|
||||||
|
func.percentile_cont(q)
|
||||||
|
.within_group(CouponSession.elapsed_ms)
|
||||||
|
.filter(completed_elapsed)
|
||||||
|
for q in (0.05, 0.5, 0.95, 0.99)
|
||||||
|
),
|
||||||
|
).where(*conditions)
|
||||||
|
|
||||||
|
|
||||||
|
def _coupon_summary_aggregates(db: Session, conditions: list) -> dict:
|
||||||
|
"""汇总卡基础指标;生产 PG 全部在数据库内完成,SQLite 仅作测试回退。"""
|
||||||
|
if db.bind is not None and db.bind.dialect.name == "postgresql":
|
||||||
|
row = db.execute(_coupon_summary_aggregate_stmt(conditions)).one()
|
||||||
|
return {
|
||||||
|
"started_count": int(row[0] or 0),
|
||||||
|
"completed_count": int(row[1] or 0),
|
||||||
|
"avg_elapsed_ms": _round_duration_ms(row[2]),
|
||||||
|
"p5_ms": _round_duration_ms(row[3]),
|
||||||
|
"p50_ms": _round_duration_ms(row[4]),
|
||||||
|
"p95_ms": _round_duration_ms(row[5]),
|
||||||
|
"p99_ms": _round_duration_ms(row[6]),
|
||||||
|
}
|
||||||
|
|
||||||
|
counts = db.execute(
|
||||||
|
select(
|
||||||
|
func.count(CouponSession.id),
|
||||||
|
func.sum(case((CouponSession.status == "completed", 1), else_=0)),
|
||||||
|
).where(*conditions)
|
||||||
|
).one()
|
||||||
|
# SQLite 没有 percentile_cont;本地/测试只回退读取耗时单列,不加载完整记录。
|
||||||
|
completed_elapsed = list(
|
||||||
|
db.execute(
|
||||||
|
select(CouponSession.elapsed_ms)
|
||||||
|
.where(
|
||||||
|
*conditions,
|
||||||
|
CouponSession.status == "completed",
|
||||||
|
CouponSession.elapsed_ms.is_not(None),
|
||||||
|
)
|
||||||
|
.order_by(CouponSession.elapsed_ms)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"started_count": int(counts[0] or 0),
|
||||||
|
"completed_count": int(counts[1] or 0),
|
||||||
|
"avg_elapsed_ms": _round_duration_ms(
|
||||||
|
sum(completed_elapsed) / len(completed_elapsed)
|
||||||
|
) if completed_elapsed else None,
|
||||||
|
"p5_ms": _percentile(completed_elapsed, 5),
|
||||||
|
"p50_ms": _percentile(completed_elapsed, 50),
|
||||||
|
"p95_ms": _percentile(completed_elapsed, 95),
|
||||||
|
"p99_ms": _percentile(completed_elapsed, 99),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _avg(vals: list[int]) -> int | None:
|
def _avg(vals: list[int]) -> int | None:
|
||||||
return round(sum(vals) / len(vals)) if vals else 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(主表「领券数据」与「用户全部领券」抽屉共用)。"""
|
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
|
||||||
return {
|
return {
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
@@ -60,10 +182,60 @@ def _session_to_row(r, phone: str | None = None, nickname: str | None = None) ->
|
|||||||
"app_env": r.app_env,
|
"app_env": r.app_env,
|
||||||
"started_at": r.started_at,
|
"started_at": r.started_at,
|
||||||
"claimed_count": r.claimed_count,
|
"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,
|
"trace_url": r.trace_url,
|
||||||
|
"ad_revenue_yuan": ad_revenue_yuan,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict[str, int]]:
|
||||||
|
"""聚合查询批量返回逐场点位分数,不加载逐券明细。"""
|
||||||
|
if not trace_ids:
|
||||||
|
return {}
|
||||||
|
succeeded = func.sum(case((CouponClaimEvent.status.in_(_SLOT_OK), 1), else_=0))
|
||||||
|
rows = db.execute(
|
||||||
|
select(
|
||||||
|
CouponClaimEvent.trace_id,
|
||||||
|
succeeded.label("succeeded"),
|
||||||
|
func.count().label("tried"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
CouponClaimEvent.trace_id.in_(trace_ids),
|
||||||
|
CouponClaimEvent.status.in_(_SLOT_TRIED),
|
||||||
|
)
|
||||||
|
.group_by(CouponClaimEvent.trace_id)
|
||||||
|
).all()
|
||||||
|
return {
|
||||||
|
trace_id: {"succeeded": int(success_count or 0), "tried": int(tried or 0)}
|
||||||
|
for trace_id, success_count, tried in rows
|
||||||
|
if trace_id is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def coupon_point_details(db: Session, *, trace_id: str) -> list[dict]:
|
||||||
|
"""按单个 trace 查询逐券结果;仅在后台用户点击分数时调用。"""
|
||||||
|
rows = db.execute(
|
||||||
|
select(
|
||||||
|
CouponClaimEvent.coupon_id,
|
||||||
|
CouponClaimEvent.coupon_name,
|
||||||
|
CouponClaimEvent.status,
|
||||||
|
CouponClaimEvent.reason,
|
||||||
|
)
|
||||||
|
.where(CouponClaimEvent.trace_id == trace_id)
|
||||||
|
.order_by(CouponClaimEvent.id)
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"coupon_id": coupon_id,
|
||||||
|
"coupon_name": coupon_name,
|
||||||
|
"status": status,
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
for coupon_id, coupon_name, status, reason in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _empty_result() -> dict:
|
def _empty_result() -> dict:
|
||||||
return {
|
return {
|
||||||
"summary": {
|
"summary": {
|
||||||
@@ -84,6 +256,7 @@ def coupon_data_report(
|
|||||||
date_to: str,
|
date_to: str,
|
||||||
user: str | None = None,
|
user: str | None = None,
|
||||||
app_env: str | None = None,
|
app_env: str | None = None,
|
||||||
|
statuses: list[str] | None = None,
|
||||||
granularity: str = "day",
|
granularity: str = "day",
|
||||||
limit: int = 500,
|
limit: int = 500,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
@@ -93,6 +266,8 @@ def coupon_data_report(
|
|||||||
|
|
||||||
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
|
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
|
||||||
- app_env:prod/dev 精确;None=全部。
|
- app_env:prod/dev 精确;None=全部。
|
||||||
|
- statuses:领券状态多选(started/completed/failed/abandoned);None/空=全部。整个视图
|
||||||
|
(汇总/成功率/趋势/明细)按选中状态算,与 app_env 同级过滤(方案 A)。
|
||||||
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
|
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
|
||||||
"""
|
"""
|
||||||
by_hour = granularity == "hour"
|
by_hour = granularity == "hour"
|
||||||
@@ -109,28 +284,23 @@ def coupon_data_report(
|
|||||||
if not user_ids:
|
if not user_ids:
|
||||||
return _empty_result()
|
return _empty_result()
|
||||||
|
|
||||||
stmt = select(CouponSession).where(
|
conditions = [
|
||||||
CouponSession.started_date >= d_from,
|
CouponSession.started_date >= d_from,
|
||||||
CouponSession.started_date <= d_to,
|
CouponSession.started_date <= d_to,
|
||||||
)
|
]
|
||||||
if app_env is not None:
|
if app_env is not None:
|
||||||
stmt = stmt.where(CouponSession.app_env == app_env)
|
conditions.append(CouponSession.app_env == app_env)
|
||||||
|
if statuses:
|
||||||
|
conditions.append(CouponSession.status.in_(statuses))
|
||||||
if user_ids is not None:
|
if user_ids is not None:
|
||||||
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
|
conditions.append(CouponSession.user_id.in_(user_ids))
|
||||||
|
stmt = select(CouponSession).where(*conditions)
|
||||||
rows = list(db.execute(stmt).scalars())
|
rows = list(db.execute(stmt).scalars())
|
||||||
|
|
||||||
# ── 汇总卡 ──
|
# ── 汇总卡 ──
|
||||||
completed_elapsed = sorted(
|
|
||||||
r.elapsed_ms for r in rows if r.status == "completed" and r.elapsed_ms is not None
|
|
||||||
)
|
|
||||||
summary = {
|
summary = {
|
||||||
"started_count": len(rows),
|
**_coupon_summary_aggregates(db, conditions),
|
||||||
"completed_count": sum(1 for r in rows if r.status == "completed"),
|
**_success_rates(rows),
|
||||||
"avg_elapsed_ms": _avg(completed_elapsed),
|
|
||||||
"p5_ms": _percentile(completed_elapsed, 5),
|
|
||||||
"p50_ms": _percentile(completed_elapsed, 50),
|
|
||||||
"p95_ms": _percentile(completed_elapsed, 95),
|
|
||||||
"p99_ms": _percentile(completed_elapsed, 99),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
|
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
|
||||||
@@ -197,16 +367,24 @@ def coupon_data_report(
|
|||||||
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
|
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
|
||||||
).all()
|
).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 = []
|
items = []
|
||||||
for r in page:
|
for r in page:
|
||||||
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
|
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 {
|
return {
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"daily": daily,
|
"daily": daily,
|
||||||
"hourly": hourly,
|
"hourly": hourly,
|
||||||
"total": len(rows),
|
"total": summary["started_count"],
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,4 +400,59 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
|
|||||||
total = db.execute(
|
total = db.execute(
|
||||||
select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id)
|
select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id)
|
||||||
).scalar_one()
|
).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,6 +5,7 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -15,12 +16,14 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.admin.repositories.queries import _as_utc, offset_paginate
|
from app.admin.repositories.queries import _as_utc, offset_paginate
|
||||||
from app.integrations import jd_union, meituan
|
from app.integrations import jd_union, meituan
|
||||||
from app.repositories import cps_link as cps_link_repo
|
|
||||||
from app.models.cps_activity import CpsActivity
|
from app.models.cps_activity import CpsActivity
|
||||||
from app.models.cps_group import CpsGroup
|
from app.models.cps_group import CpsGroup
|
||||||
from app.models.cps_link import CpsClick, CpsLink
|
from app.models.cps_link import CpsClick, CpsLink
|
||||||
from app.models.cps_order import CpsOrder
|
from app.models.cps_order import CpsOrder
|
||||||
from app.models.cps_wx_user import CpsWxUser
|
from app.models.cps_wx_user import CpsWxUser
|
||||||
|
from app.repositories import cps_link as cps_link_repo
|
||||||
|
|
||||||
|
logger = logging.getLogger("shagua.cps_reconcile")
|
||||||
|
|
||||||
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
|
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
|
||||||
_INVALID_STATUS = {"4", "5"}
|
_INVALID_STATUS = {"4", "5"}
|
||||||
@@ -378,18 +381,35 @@ def effective_commission_cents(order: CpsOrder) -> int:
|
|||||||
def reconcile_orders(
|
def reconcile_orders(
|
||||||
db: Session, *, start_time: int, end_time: int,
|
db: Session, *, start_time: int, end_time: int,
|
||||||
query_time_type: int = 1, sid: str | None = None, max_pages: int = 200,
|
query_time_type: int = 1, sid: str | None = None, max_pages: int = 200,
|
||||||
|
audit_context: dict[str, Any] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""调美团 query_order 分页拉单 → 按 order_id upsert。返回 {fetched, inserted, updated, pages}。
|
"""调美团 query_order 分页拉单 → 按 order_id upsert。返回 {fetched, inserted, updated, pages}。
|
||||||
|
|
||||||
订单状态会随时间变(付款→完成→结算/退款),重复拉同一单则更新。max_pages 防异常死循环。
|
订单状态会随时间变(付款→完成→结算/退款),重复拉同一单则更新。max_pages 防异常死循环。
|
||||||
"""
|
"""
|
||||||
fetched = inserted = updated = pages = 0
|
fetched = inserted = updated = pages = api_requests = 0
|
||||||
page = 1
|
page = 1
|
||||||
while page <= max_pages:
|
while page <= max_pages:
|
||||||
|
api_requests += 1
|
||||||
|
try:
|
||||||
resp = meituan.query_order(
|
resp = meituan.query_order(
|
||||||
sid=sid, start_time=start_time, end_time=end_time,
|
sid=sid, start_time=start_time, end_time=end_time,
|
||||||
query_time_type=query_time_type, page=page, limit=100,
|
query_time_type=query_time_type, page=page, limit=100,
|
||||||
)
|
)
|
||||||
|
except Exception: # noqa: BLE001 - 记录失败页后保持原异常类型继续抛出
|
||||||
|
if audit_context is not None:
|
||||||
|
logger.exception(
|
||||||
|
"CPS reconcile upstream request failed platform=meituan page=%s",
|
||||||
|
page,
|
||||||
|
extra={
|
||||||
|
**audit_context,
|
||||||
|
"event": "cps_reconcile.request_failed",
|
||||||
|
"platform": "meituan",
|
||||||
|
"failed_page": page,
|
||||||
|
"api_request_number": api_requests,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise
|
||||||
rows = ((resp.get("data") or {}).get("dataList")) or []
|
rows = ((resp.get("data") or {}).get("dataList")) or []
|
||||||
if not rows:
|
if not rows:
|
||||||
break
|
break
|
||||||
@@ -419,23 +439,33 @@ def reconcile_orders(
|
|||||||
break
|
break
|
||||||
page += 1
|
page += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
|
return {
|
||||||
|
"fetched": fetched,
|
||||||
|
"inserted": inserted,
|
||||||
|
"updated": updated,
|
||||||
|
"pages": pages,
|
||||||
|
"api_requests": api_requests,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def reconcile_jd_orders(
|
def reconcile_jd_orders(
|
||||||
db: Session, *, start_time: datetime, end_time: datetime,
|
db: Session, *, start_time: datetime, end_time: datetime,
|
||||||
query_time_type: int = 3, max_pages: int = 100,
|
query_time_type: int = 3, max_pages: int = 100,
|
||||||
|
audit_context: dict[str, Any] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""调京东 order.row.query 拉单 → 按订单行 upsert。
|
"""调京东 order.row.query 拉单 → 按订单行 upsert。
|
||||||
|
|
||||||
京东单次查询窗口最多 1 小时,这里按北京自然时间切窗并逐页拉取。
|
京东单次查询窗口最多 1 小时,这里按北京自然时间切窗并逐页拉取。
|
||||||
"""
|
"""
|
||||||
fetched = inserted = updated = pages = 0
|
fetched = inserted = updated = pages = api_requests = windows = 0
|
||||||
cur = start_time
|
cur = start_time
|
||||||
while cur < end_time:
|
while cur < end_time:
|
||||||
win_end = min(cur + timedelta(hours=1), end_time)
|
win_end = min(cur + timedelta(hours=1), end_time)
|
||||||
|
windows += 1
|
||||||
page = 1
|
page = 1
|
||||||
while page <= max_pages:
|
while page <= max_pages:
|
||||||
|
api_requests += 1
|
||||||
|
try:
|
||||||
resp = jd_union.query_order_rows(
|
resp = jd_union.query_order_rows(
|
||||||
start_time=cur,
|
start_time=cur,
|
||||||
end_time=win_end,
|
end_time=win_end,
|
||||||
@@ -443,6 +473,24 @@ def reconcile_jd_orders(
|
|||||||
page_index=page,
|
page_index=page,
|
||||||
page_size=200,
|
page_size=200,
|
||||||
)
|
)
|
||||||
|
except Exception: # noqa: BLE001 - 记录失败窗口后保持原异常类型继续抛出
|
||||||
|
if audit_context is not None:
|
||||||
|
logger.exception(
|
||||||
|
"CPS reconcile upstream request failed platform=jd window=%s..%s page=%s",
|
||||||
|
cur.isoformat(),
|
||||||
|
win_end.isoformat(),
|
||||||
|
page,
|
||||||
|
extra={
|
||||||
|
**audit_context,
|
||||||
|
"event": "cps_reconcile.request_failed",
|
||||||
|
"platform": "jd",
|
||||||
|
"failed_window_start": cur.isoformat(),
|
||||||
|
"failed_window_end": win_end.isoformat(),
|
||||||
|
"failed_page": page,
|
||||||
|
"api_request_number": api_requests,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise
|
||||||
rows = resp.get("rows") or []
|
rows = resp.get("rows") or []
|
||||||
has_more = bool(resp.get("has_more"))
|
has_more = bool(resp.get("has_more"))
|
||||||
if not rows:
|
if not rows:
|
||||||
@@ -469,7 +517,14 @@ def reconcile_jd_orders(
|
|||||||
page += 1
|
page += 1
|
||||||
cur = win_end
|
cur = win_end
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
|
return {
|
||||||
|
"fetched": fetched,
|
||||||
|
"inserted": inserted,
|
||||||
|
"updated": updated,
|
||||||
|
"pages": pages,
|
||||||
|
"api_requests": api_requests,
|
||||||
|
"windows": windows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_orders(
|
def list_orders(
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from sqlalchemy import Select, asc, case, desc, func, or_, select
|
from sqlalchemy import Select, asc, case, desc, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
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 import rewards
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||||
@@ -32,9 +32,7 @@ from app.models.wallet import (
|
|||||||
InviteCashTransaction,
|
InviteCashTransaction,
|
||||||
WithdrawOrder,
|
WithdrawOrder,
|
||||||
)
|
)
|
||||||
|
from app.repositories import activity, ad_ecpm
|
||||||
# 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券)
|
|
||||||
_ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT)
|
|
||||||
|
|
||||||
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
|
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
|
||||||
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
|
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
|
||||||
@@ -87,49 +85,6 @@ def offset_paginate(
|
|||||||
return items, next_cursor, total
|
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:
|
def _attach_last_active(db: Session, users: list[User]) -> None:
|
||||||
"""给本页用户瞬态挂 last_active_at(非 DB 列,供 AdminUserListItem from_attributes 读)。
|
"""给本页用户瞬态挂 last_active_at(非 DB 列,供 AdminUserListItem from_attributes 读)。
|
||||||
|
|
||||||
@@ -143,7 +98,7 @@ def _attach_last_active(db: Session, users: list[User]) -> None:
|
|||||||
select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at))
|
select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at))
|
||||||
.where(
|
.where(
|
||||||
AnalyticsEvent.user_id.in_(uids),
|
AnalyticsEvent.user_id.in_(uids),
|
||||||
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
|
activity.active_event_condition(),
|
||||||
)
|
)
|
||||||
.group_by(AnalyticsEvent.user_id)
|
.group_by(AnalyticsEvent.user_id)
|
||||||
).all()
|
).all()
|
||||||
@@ -160,9 +115,9 @@ def _attach_last_active(db: Session, users: list[User]) -> None:
|
|||||||
)
|
)
|
||||||
for u in users:
|
for u in users:
|
||||||
candidates = [
|
candidates = [
|
||||||
_norm_utc(u.last_login_at),
|
activity.norm_utc(u.created_at), # baseline 由 last_login_at 改为 created_at(登录不算活跃)
|
||||||
_norm_utc(ev_map.get(u.id)),
|
activity.norm_utc(ev_map.get(u.id)),
|
||||||
_norm_utc(eng_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)
|
u.last_active_at = max((c for c in candidates if c is not None), default=None)
|
||||||
|
|
||||||
@@ -190,16 +145,12 @@ def list_users(
|
|||||||
(口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
|
(口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
|
||||||
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
|
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
|
||||||
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
|
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
|
||||||
# 最近活跃 = max(最近登录, 最近行为事件, 最近领券发起)。PG 用 GREATEST;SQLite 标量 max()
|
# 最近活跃 = max(注册时间, 最近行为事件, 最近领券发起)。baseline 由 last_login_at 改为 created_at
|
||||||
# 任一参数 NULL 即返回 NULL,故 LEFT JOIN 未命中侧 coalesce 到 last_login_at 兜底
|
#(登录不代表在用 App;口径统一到 activity.py,含 home_visible + 比价 + 领券,见 activity.ACTIVE_EVENTS)。
|
||||||
# (注册即登录,该列恒非空)。派生表 1:1(按 user_id 聚合),outerjoin 不会放大行数,
|
# 未命中侧 coalesce 到 created_at(恒非空基线)。派生表 1:1,outerjoin 不放大行数。
|
||||||
# offset_paginate 的 count 不受影响。
|
ev_agg, eng_agg = activity.last_active_subqueries(db)
|
||||||
ev_agg, eng_agg = _last_active_parts()
|
last_active = activity.last_active_expr(
|
||||||
greatest = func.greatest if db.get_bind().dialect.name == "postgresql" else func.max
|
User.created_at, ev_agg, eng_agg, db.get_bind().dialect.name
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(User)
|
select(User)
|
||||||
@@ -259,6 +210,45 @@ def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | P
|
|||||||
r.nickname = nick
|
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(
|
def list_comparison_records(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
@@ -268,39 +258,166 @@ def list_comparison_records(
|
|||||||
business_type: str | None = None,
|
business_type: str | None = None,
|
||||||
store: str | None = None,
|
store: str | None = None,
|
||||||
product: str | None = None,
|
product: str | None = None,
|
||||||
|
date_from: date | None = None,
|
||||||
|
date_to: date | None = None,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
cursor: int | None = None,
|
cursor: int | None = None,
|
||||||
) -> tuple[list[ComparisonRecord], int | None, int]:
|
) -> tuple[list[ComparisonRecord], int | None, int]:
|
||||||
"""admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛,
|
"""admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛,
|
||||||
store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。
|
store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。
|
||||||
join User 取 phone/nickname 瞬态挂记录上。"""
|
join User 取 phone/nickname 瞬态挂记录上。"""
|
||||||
stmt = select(ComparisonRecord)
|
conditions = _comparison_conditions(
|
||||||
if user_id is not None:
|
user_id=user_id, phone=phone, status=status, business_type=business_type,
|
||||||
stmt = stmt.where(ComparisonRecord.user_id == user_id)
|
store=store, product=product, date_from=date_from, date_to=date_to,
|
||||||
if phone:
|
|
||||||
stmt = stmt.where(
|
|
||||||
ComparisonRecord.user_id.in_(
|
|
||||||
select(User.id).where(User.phone.like(f"{phone}%"))
|
|
||||||
)
|
)
|
||||||
)
|
stmt = select(ComparisonRecord).where(*conditions)
|
||||||
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}%"))
|
|
||||||
items, next_cursor, total = offset_paginate(
|
items, next_cursor, total = offset_paginate(
|
||||||
db, stmt,
|
db, stmt,
|
||||||
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
|
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
|
||||||
limit=limit, cursor=cursor,
|
limit=limit, cursor=cursor,
|
||||||
)
|
)
|
||||||
_attach_user_info(db, items)
|
_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
|
return items, next_cursor, total
|
||||||
|
|
||||||
|
|
||||||
|
def _comparison_percentile(sorted_values: list[int], q: float) -> int | None:
|
||||||
|
"""线性插值分位数(非负毫秒值四舍五入;单条数据返回自身)。"""
|
||||||
|
if not sorted_values:
|
||||||
|
return None
|
||||||
|
if len(sorted_values) == 1:
|
||||||
|
return sorted_values[0]
|
||||||
|
index = (len(sorted_values) - 1) * q
|
||||||
|
lower = int(index)
|
||||||
|
upper = min(lower + 1, len(sorted_values) - 1)
|
||||||
|
value = sorted_values[lower] * (upper - index) + sorted_values[upper] * (index - lower)
|
||||||
|
return int(value + 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def _round_duration_ms(value) -> int | None:
|
||||||
|
"""将数据库聚合结果按既有口径四舍五入为整数毫秒。"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||||
|
|
||||||
|
|
||||||
|
def _comparison_duration_aggregate_stmt(conditions: list, status: str, quantiles: tuple[float, ...]):
|
||||||
|
"""PostgreSQL 耗时聚合语句;每种状态只返回一行。"""
|
||||||
|
return select(
|
||||||
|
func.avg(ComparisonRecord.total_ms),
|
||||||
|
*(
|
||||||
|
func.percentile_cont(q).within_group(ComparisonRecord.total_ms)
|
||||||
|
for q in quantiles
|
||||||
|
),
|
||||||
|
).where(
|
||||||
|
*conditions,
|
||||||
|
ComparisonRecord.status == status,
|
||||||
|
ComparisonRecord.total_ms.is_not(None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _comparison_duration_aggregates(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
conditions: list,
|
||||||
|
status: str,
|
||||||
|
quantiles: tuple[float, ...],
|
||||||
|
) -> list[int | None]:
|
||||||
|
"""返回平均值和各分位数;生产 PG 在数据库内聚合,SQLite 仅作测试回退。"""
|
||||||
|
if db.bind is not None and db.bind.dialect.name == "postgresql":
|
||||||
|
row = db.execute(
|
||||||
|
_comparison_duration_aggregate_stmt(conditions, status, quantiles)
|
||||||
|
).one()
|
||||||
|
return [_round_duration_ms(value) for value in row]
|
||||||
|
|
||||||
|
# SQLite 没有 percentile_cont;本地/测试只回退读取耗时单列,不加载完整记录。
|
||||||
|
values = list(
|
||||||
|
db.execute(
|
||||||
|
select(ComparisonRecord.total_ms)
|
||||||
|
.where(
|
||||||
|
*conditions,
|
||||||
|
ComparisonRecord.status == status,
|
||||||
|
ComparisonRecord.total_ms.is_not(None),
|
||||||
|
)
|
||||||
|
.order_by(ComparisonRecord.total_ms)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
average = _round_duration_ms(sum(values) / len(values)) if values else None
|
||||||
|
return [average, *(_comparison_percentile(values, q) for q in quantiles)]
|
||||||
|
|
||||||
|
|
||||||
|
def comparison_records_summary(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
user_id: int | None = None,
|
||||||
|
phone: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
business_type: str | None = None,
|
||||||
|
store: str | None = None,
|
||||||
|
product: str | None = None,
|
||||||
|
date_from: date | None = None,
|
||||||
|
date_to: date | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""比价记录页概览聚合;主耗时均值及分位数只取成功记录。"""
|
||||||
|
conditions = _comparison_conditions(
|
||||||
|
user_id=user_id, phone=phone, status=status, business_type=business_type,
|
||||||
|
store=store, product=product, date_from=date_from, date_to=date_to,
|
||||||
|
)
|
||||||
|
row = db.execute(
|
||||||
|
select(
|
||||||
|
func.count(ComparisonRecord.id),
|
||||||
|
func.sum(case((ComparisonRecord.status.in_(("success", "failed")), 1), else_=0)),
|
||||||
|
func.sum(case((ComparisonRecord.status == "success", 1), else_=0)),
|
||||||
|
func.avg(ComparisonRecord.llm_cost_yuan),
|
||||||
|
func.sum(case((
|
||||||
|
(ComparisonRecord.status == "success")
|
||||||
|
& (ComparisonRecord.saved_amount_cents > 0), 1
|
||||||
|
), else_=0)),
|
||||||
|
func.sum(case((ComparisonRecord.status == "cancelled", 1), else_=0)),
|
||||||
|
).where(*conditions)
|
||||||
|
).one()
|
||||||
|
started = int(row[0] or 0)
|
||||||
|
completed = int(row[1] or 0)
|
||||||
|
success = int(row[2] or 0)
|
||||||
|
lower_price = int(row[4] or 0)
|
||||||
|
cancelled = int(row[5] or 0)
|
||||||
|
success_duration_stats = _comparison_duration_aggregates(
|
||||||
|
db,
|
||||||
|
conditions=conditions,
|
||||||
|
status="success",
|
||||||
|
quantiles=(0.05, 0.5, 0.95, 0.99),
|
||||||
|
)
|
||||||
|
cancelled_duration_stats = _comparison_duration_aggregates(
|
||||||
|
db,
|
||||||
|
conditions=conditions,
|
||||||
|
status="cancelled",
|
||||||
|
quantiles=(0.05, 0.5, 0.95),
|
||||||
|
)
|
||||||
|
success_rate_denominator = started - cancelled
|
||||||
|
return {
|
||||||
|
"started": started,
|
||||||
|
"completed": completed,
|
||||||
|
"success": success,
|
||||||
|
"success_rate": success / success_rate_denominator if success_rate_denominator else None,
|
||||||
|
"avg_token_cost": float(row[3]) if row[3] is not None else None,
|
||||||
|
"lower_price_rate": lower_price / success if success else None,
|
||||||
|
"avg_duration_ms": success_duration_stats[0],
|
||||||
|
"p5_duration_ms": success_duration_stats[1],
|
||||||
|
"p50_duration_ms": success_duration_stats[2],
|
||||||
|
"p95_duration_ms": success_duration_stats[3],
|
||||||
|
"p99_duration_ms": success_duration_stats[4],
|
||||||
|
"cancelled": cancelled,
|
||||||
|
"cancelled_rate": cancelled / started if started else None,
|
||||||
|
"cancelled_p5_ms": cancelled_duration_stats[1],
|
||||||
|
"cancelled_p50_ms": cancelled_duration_stats[2],
|
||||||
|
"cancelled_p95_ms": cancelled_duration_stats[3],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
|
def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
|
||||||
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。"""
|
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。"""
|
||||||
rec = db.get(ComparisonRecord, record_id)
|
rec = db.get(ComparisonRecord, record_id)
|
||||||
@@ -1054,24 +1171,30 @@ def user_reward_stats(
|
|||||||
acc = db.get(CoinAccount, user_id) # 现金余额:当前快照,不随窗口
|
acc = db.get(CoinAccount, user_id) # 现金余额:当前快照,不随窗口
|
||||||
cash_balance = acc.cash_balance_cents if acc else 0
|
cash_balance = acc.cash_balance_cents if acc else 0
|
||||||
|
|
||||||
rv = list(db.execute(
|
# 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时,
|
||||||
select(AdRewardRecord).where(
|
# SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。
|
||||||
|
rv = db.execute(
|
||||||
|
select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where(
|
||||||
AdRewardRecord.user_id == user_id,
|
AdRewardRecord.user_id == user_id,
|
||||||
AdRewardRecord.reward_scene == "reward_video",
|
AdRewardRecord.reward_scene == "reward_video",
|
||||||
AdRewardRecord.status == "granted",
|
AdRewardRecord.status == "granted",
|
||||||
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
||||||
)
|
)
|
||||||
).scalars())
|
).all()
|
||||||
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
|
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
|
||||||
rv_coins = sum(r.coin for r in rv)
|
rv_coins = sum(r.coin for r in rv)
|
||||||
|
|
||||||
feed = list(db.execute(
|
feed = db.execute(
|
||||||
select(AdFeedRewardRecord).where(
|
select(
|
||||||
|
AdFeedRewardRecord.unit_count,
|
||||||
|
AdFeedRewardRecord.ecpm_raw,
|
||||||
|
AdFeedRewardRecord.coin,
|
||||||
|
).where(
|
||||||
AdFeedRewardRecord.user_id == user_id,
|
AdFeedRewardRecord.user_id == user_id,
|
||||||
AdFeedRewardRecord.status == "granted",
|
AdFeedRewardRecord.status == "granted",
|
||||||
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
||||||
)
|
)
|
||||||
).scalars())
|
).all()
|
||||||
feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw]
|
feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw]
|
||||||
feed_coins = sum(f.coin for f in feed)
|
feed_coins = sum(f.coin for f in feed)
|
||||||
|
|
||||||
@@ -1129,8 +1252,14 @@ def user_coin_records(
|
|||||||
signin_from = date_from + timedelta(hours=8) if date_from is not None else None
|
signin_from = date_from + timedelta(hours=8) if date_from is not None else None
|
||||||
signin_to = date_to + timedelta(hours=8) if date_to is not None else None
|
signin_to = date_to + timedelta(hours=8) if date_to is not None else None
|
||||||
|
|
||||||
|
# 三类来源都只取页面需要的列,避免无关 ORM 新列造成旧库查询失败。
|
||||||
for rec in db.execute(
|
for rec in db.execute(
|
||||||
select(AdRewardRecord)
|
select(
|
||||||
|
AdRewardRecord.reward_scene,
|
||||||
|
AdRewardRecord.created_at,
|
||||||
|
AdRewardRecord.ecpm_raw,
|
||||||
|
AdRewardRecord.coin,
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
AdRewardRecord.user_id == user_id,
|
AdRewardRecord.user_id == user_id,
|
||||||
AdRewardRecord.status == "granted",
|
AdRewardRecord.status == "granted",
|
||||||
@@ -1138,7 +1267,7 @@ def user_coin_records(
|
|||||||
)
|
)
|
||||||
.order_by(AdRewardRecord.created_at.desc())
|
.order_by(AdRewardRecord.created_at.desc())
|
||||||
.limit(fetch)
|
.limit(fetch)
|
||||||
).scalars():
|
).all():
|
||||||
is_video = rec.reward_scene == "reward_video"
|
is_video = rec.reward_scene == "reward_video"
|
||||||
rows.append({
|
rows.append({
|
||||||
"source": rec.reward_scene,
|
"source": rec.reward_scene,
|
||||||
@@ -1149,7 +1278,12 @@ def user_coin_records(
|
|||||||
})
|
})
|
||||||
|
|
||||||
for rec in db.execute(
|
for rec in db.execute(
|
||||||
select(AdFeedRewardRecord)
|
select(
|
||||||
|
AdFeedRewardRecord.feed_scene,
|
||||||
|
AdFeedRewardRecord.created_at,
|
||||||
|
AdFeedRewardRecord.ecpm_raw,
|
||||||
|
AdFeedRewardRecord.coin,
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
AdFeedRewardRecord.user_id == user_id,
|
AdFeedRewardRecord.user_id == user_id,
|
||||||
AdFeedRewardRecord.status == "granted",
|
AdFeedRewardRecord.status == "granted",
|
||||||
@@ -1157,7 +1291,7 @@ def user_coin_records(
|
|||||||
)
|
)
|
||||||
.order_by(AdFeedRewardRecord.created_at.desc())
|
.order_by(AdFeedRewardRecord.created_at.desc())
|
||||||
.limit(fetch)
|
.limit(fetch)
|
||||||
).scalars():
|
).all():
|
||||||
rows.append({
|
rows.append({
|
||||||
"source": "feed",
|
"source": "feed",
|
||||||
"source_label": _FEED_SCENE_LABEL.get(rec.feed_scene, "信息流广告"),
|
"source_label": _FEED_SCENE_LABEL.get(rec.feed_scene, "信息流广告"),
|
||||||
@@ -1167,7 +1301,7 @@ def user_coin_records(
|
|||||||
})
|
})
|
||||||
|
|
||||||
for rec in db.execute(
|
for rec in db.execute(
|
||||||
select(CoinTransaction)
|
select(CoinTransaction.created_at, CoinTransaction.amount)
|
||||||
.where(
|
.where(
|
||||||
CoinTransaction.user_id == user_id,
|
CoinTransaction.user_id == user_id,
|
||||||
CoinTransaction.biz_type == "signin",
|
CoinTransaction.biz_type == "signin",
|
||||||
@@ -1175,7 +1309,7 @@ def user_coin_records(
|
|||||||
)
|
)
|
||||||
.order_by(CoinTransaction.created_at.desc())
|
.order_by(CoinTransaction.created_at.desc())
|
||||||
.limit(fetch)
|
.limit(fetch)
|
||||||
).scalars():
|
).all():
|
||||||
rows.append({
|
rows.append({
|
||||||
"source": "signin",
|
"source": "signin",
|
||||||
"source_label": "签到",
|
"source_label": "签到",
|
||||||
|
|||||||
@@ -0,0 +1,545 @@
|
|||||||
|
"""风控监控读模型:聚合通用行为流水、风险事件与比价记录。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import desc, func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.comparison import ComparisonRecord
|
||||||
|
from app.models.device import DeviceLiveness
|
||||||
|
from app.models.risk import BehaviorEvent, RiskIncident
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories import risk as risk_repo
|
||||||
|
|
||||||
|
KIND_TO_RULE = {
|
||||||
|
"sms": risk_repo.RULE_SMS_HOURLY,
|
||||||
|
"oneclick": risk_repo.RULE_ONECLICK_DAILY,
|
||||||
|
"compare": risk_repo.RULE_COMPARE_DAILY,
|
||||||
|
}
|
||||||
|
RULE_TO_KIND = {rule: kind for kind, rule in KIND_TO_RULE.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _day_bounds(now: datetime | None = None) -> tuple[str, datetime, datetime]:
|
||||||
|
current = (now or datetime.now(UTC)).astimezone(risk_repo.CN_TZ)
|
||||||
|
start = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
end = start + timedelta(days=1)
|
||||||
|
return (
|
||||||
|
start.strftime("%Y-%m-%d"),
|
||||||
|
start.astimezone(UTC),
|
||||||
|
end.astimezone(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_day_bounds(now: datetime | None = None) -> tuple[datetime, datetime]:
|
||||||
|
current = (now or datetime.now(UTC)).astimezone(risk_repo.CN_TZ).replace(
|
||||||
|
tzinfo=None
|
||||||
|
)
|
||||||
|
start = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
return start, start + timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_today_compare_incidents(db: Session, now: datetime | None = None) -> None:
|
||||||
|
risk_repo.reconcile_compare_rule(db, at=now)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_open_alerts(
|
||||||
|
db: Session, *, admin_id: int, reset_at: datetime
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""将三类待处理报警归零,并从 reset_at 开始重新累计。"""
|
||||||
|
rule_codes = tuple(KIND_TO_RULE.values())
|
||||||
|
incidents = list(
|
||||||
|
db.scalars(
|
||||||
|
select(RiskIncident).where(
|
||||||
|
RiskIncident.rule_code.in_(rule_codes),
|
||||||
|
RiskIncident.status == "open",
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
counts = {kind: 0 for kind in KIND_TO_RULE}
|
||||||
|
for incident in incidents:
|
||||||
|
incident.status = "resolved"
|
||||||
|
incident.action_reason = risk_repo.MANUAL_RESET_REASON
|
||||||
|
incident.handled_by = admin_id
|
||||||
|
incident.handled_at = reset_at
|
||||||
|
counts[RULE_TO_KIND[incident.rule_code]] += 1
|
||||||
|
risk_repo.reset_rule_baselines(
|
||||||
|
db,
|
||||||
|
rule_codes=rule_codes,
|
||||||
|
reset_at=reset_at,
|
||||||
|
admin_id=admin_id,
|
||||||
|
)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def summary(db: Session, now: datetime | None = None) -> dict:
|
||||||
|
date_key, start, end = _day_bounds(now)
|
||||||
|
compare_start, compare_end = _compare_day_bounds(now)
|
||||||
|
current = now or datetime.now(UTC)
|
||||||
|
risk_repo.reconcile_behavior_rule(
|
||||||
|
db, rule_code=risk_repo.RULE_SMS_HOURLY, at=current, commit=False
|
||||||
|
)
|
||||||
|
risk_repo.reconcile_behavior_rule(
|
||||||
|
db, rule_code=risk_repo.RULE_ONECLICK_DAILY, at=current, commit=False
|
||||||
|
)
|
||||||
|
risk_repo.reconcile_compare_rule(db, at=current, commit=False)
|
||||||
|
db.commit()
|
||||||
|
sms_threshold = risk_repo.get_rule_threshold(db, risk_repo.RULE_SMS_HOURLY)
|
||||||
|
oneclick_threshold = risk_repo.get_rule_threshold(
|
||||||
|
db, risk_repo.RULE_ONECLICK_DAILY
|
||||||
|
)
|
||||||
|
compare_threshold = risk_repo.get_rule_threshold(db, risk_repo.RULE_COMPARE_DAILY)
|
||||||
|
|
||||||
|
def _behavior_total(event_type: str, outcomes: tuple[str, ...]) -> int:
|
||||||
|
return int(
|
||||||
|
db.scalar(
|
||||||
|
select(func.count(BehaviorEvent.id)).where(
|
||||||
|
BehaviorEvent.event_type == event_type,
|
||||||
|
BehaviorEvent.outcome.in_(outcomes),
|
||||||
|
BehaviorEvent.occurred_at >= start,
|
||||||
|
BehaviorEvent.occurred_at < end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
compare_total = int(
|
||||||
|
db.scalar(
|
||||||
|
select(func.count(ComparisonRecord.id)).where(
|
||||||
|
ComparisonRecord.created_at >= compare_start,
|
||||||
|
ComparisonRecord.created_at < compare_end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
def _open_count(rule_code: str) -> int:
|
||||||
|
return int(
|
||||||
|
db.scalar(
|
||||||
|
select(func.count(RiskIncident.id)).where(
|
||||||
|
RiskIncident.rule_code == rule_code,
|
||||||
|
RiskIncident.status == "open",
|
||||||
|
RiskIncident.window_key.like(f"{date_key}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": date_key,
|
||||||
|
"updated_at": now or datetime.now(UTC),
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"kind": "sms",
|
||||||
|
"alert_subject_count": _open_count(risk_repo.RULE_SMS_HOURLY),
|
||||||
|
"today_total": _behavior_total(risk_repo.EVENT_SMS_SEND, ("success",)),
|
||||||
|
"threshold": sms_threshold,
|
||||||
|
"rule_text": f"单设备 1 小时 ≥ {sms_threshold} 次",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "oneclick",
|
||||||
|
"alert_subject_count": _open_count(risk_repo.RULE_ONECLICK_DAILY),
|
||||||
|
"today_total": _behavior_total(
|
||||||
|
risk_repo.EVENT_ONECLICK_LOGIN, ("success", "failed")
|
||||||
|
),
|
||||||
|
"threshold": oneclick_threshold,
|
||||||
|
"rule_text": f"单设备当日 ≥ {oneclick_threshold} 次",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "compare",
|
||||||
|
"alert_subject_count": _open_count(risk_repo.RULE_COMPARE_DAILY),
|
||||||
|
"today_total": compare_total,
|
||||||
|
"threshold": compare_threshold,
|
||||||
|
"rule_text": f"单账户当日 ≥ {compare_threshold} 次",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_device_model(db: Session, subject_id: str) -> str | None:
|
||||||
|
return db.execute(
|
||||||
|
select(BehaviorEvent.device_model)
|
||||||
|
.where(
|
||||||
|
BehaviorEvent.subject_type == "device",
|
||||||
|
BehaviorEvent.subject_id == subject_id,
|
||||||
|
BehaviorEvent.device_model.is_not(None),
|
||||||
|
BehaviorEvent.device_model != "",
|
||||||
|
)
|
||||||
|
.order_by(BehaviorEvent.occurred_at.desc(), BehaviorEvent.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def _first_used_at(db: Session, subject_id: str) -> datetime | None:
|
||||||
|
behavior_first = db.scalar(
|
||||||
|
select(func.min(BehaviorEvent.occurred_at)).where(
|
||||||
|
BehaviorEvent.subject_type == "device",
|
||||||
|
BehaviorEvent.subject_id == subject_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registered_first = db.scalar(
|
||||||
|
select(func.min(DeviceLiveness.created_at)).where(
|
||||||
|
DeviceLiveness.device_id == subject_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
observed = [value for value in (behavior_first, registered_first) if value is not None]
|
||||||
|
if not observed:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _utc(value: datetime) -> datetime:
|
||||||
|
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
||||||
|
|
||||||
|
return min(observed, key=_utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _common_device(db: Session, user_id: int, start: datetime, end: datetime) -> str | None:
|
||||||
|
return db.execute(
|
||||||
|
select(ComparisonRecord.device_id)
|
||||||
|
.where(
|
||||||
|
ComparisonRecord.user_id == user_id,
|
||||||
|
ComparisonRecord.device_id.is_not(None),
|
||||||
|
ComparisonRecord.created_at >= start,
|
||||||
|
ComparisonRecord.created_at < end,
|
||||||
|
)
|
||||||
|
.group_by(ComparisonRecord.device_id)
|
||||||
|
.order_by(func.count(ComparisonRecord.id).desc(), ComparisonRecord.device_id.asc())
|
||||||
|
.limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def list_incidents(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
status: str = "open",
|
||||||
|
limit: int = 20,
|
||||||
|
cursor: int = 0,
|
||||||
|
) -> tuple[list[dict], int | None, int]:
|
||||||
|
rule_code = KIND_TO_RULE[kind]
|
||||||
|
stmt = select(RiskIncident).where(RiskIncident.rule_code == rule_code)
|
||||||
|
count_stmt = select(func.count(RiskIncident.id)).where(
|
||||||
|
RiskIncident.rule_code == rule_code
|
||||||
|
)
|
||||||
|
if status != "all":
|
||||||
|
stmt = stmt.where(RiskIncident.status == status)
|
||||||
|
count_stmt = count_stmt.where(RiskIncident.status == status)
|
||||||
|
total = int(db.scalar(count_stmt) or 0)
|
||||||
|
incidents = list(
|
||||||
|
db.scalars(
|
||||||
|
stmt.order_by(desc(RiskIncident.triggered_at), desc(RiskIncident.id))
|
||||||
|
.offset(cursor)
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
user_ids = [int(i.subject_id) for i in incidents if i.subject_type == "user"]
|
||||||
|
users = {
|
||||||
|
u.id: u
|
||||||
|
for u in db.scalars(select(User).where(User.id.in_(user_ids))).all()
|
||||||
|
} if user_ids else {}
|
||||||
|
|
||||||
|
items: list[dict] = []
|
||||||
|
for incident in incidents:
|
||||||
|
item = {
|
||||||
|
"incident_id": incident.id,
|
||||||
|
"kind": kind,
|
||||||
|
"subject_type": incident.subject_type,
|
||||||
|
"subject_id": incident.subject_id,
|
||||||
|
"window_start": incident.window_start,
|
||||||
|
"window_end": incident.window_end,
|
||||||
|
"first_event_at": incident.first_event_at,
|
||||||
|
"triggered_at": incident.triggered_at,
|
||||||
|
"last_event_at": incident.last_event_at,
|
||||||
|
"event_count": incident.event_count,
|
||||||
|
"status": incident.status,
|
||||||
|
}
|
||||||
|
if incident.subject_type == "device":
|
||||||
|
restriction = risk_repo.get_active_restriction(
|
||||||
|
db,
|
||||||
|
subject_type="device",
|
||||||
|
subject_id=incident.subject_id,
|
||||||
|
scope=risk_repo.SCOPE_AUTH_DEVICE,
|
||||||
|
)
|
||||||
|
item["device_model"] = _latest_device_model(db, incident.subject_id)
|
||||||
|
item["first_used_at"] = _first_used_at(db, incident.subject_id)
|
||||||
|
item["restricted"] = restriction is not None
|
||||||
|
item["restriction_id"] = restriction.id if restriction else None
|
||||||
|
else:
|
||||||
|
uid = int(incident.subject_id)
|
||||||
|
user = users.get(uid)
|
||||||
|
restriction = risk_repo.get_active_restriction(
|
||||||
|
db,
|
||||||
|
subject_type="user",
|
||||||
|
subject_id=incident.subject_id,
|
||||||
|
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
|
||||||
|
)
|
||||||
|
item.update(
|
||||||
|
{
|
||||||
|
"user_id": uid,
|
||||||
|
"phone": user.phone if user else None,
|
||||||
|
"registered_at": user.created_at if user else None,
|
||||||
|
"common_device_id": _common_device(
|
||||||
|
db, uid, incident.window_start, incident.window_end
|
||||||
|
),
|
||||||
|
"restricted": restriction is not None,
|
||||||
|
"restriction_id": restriction.id if restriction else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
items.append(item)
|
||||||
|
next_cursor = cursor + len(items) if cursor + len(items) < total else None
|
||||||
|
return items, next_cursor, total
|
||||||
|
|
||||||
|
|
||||||
|
def _page_events(
|
||||||
|
db: Session,
|
||||||
|
incident: RiskIncident,
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
outcomes: tuple[str, ...] | None,
|
||||||
|
limit: int,
|
||||||
|
cursor: int,
|
||||||
|
) -> tuple[list[BehaviorEvent], int]:
|
||||||
|
filters = [
|
||||||
|
BehaviorEvent.event_type == event_type,
|
||||||
|
BehaviorEvent.subject_type == incident.subject_type,
|
||||||
|
BehaviorEvent.subject_id == incident.subject_id,
|
||||||
|
BehaviorEvent.occurred_at >= incident.window_start,
|
||||||
|
BehaviorEvent.occurred_at < incident.window_end,
|
||||||
|
]
|
||||||
|
if outcomes:
|
||||||
|
filters.append(BehaviorEvent.outcome.in_(outcomes))
|
||||||
|
total = int(db.scalar(select(func.count(BehaviorEvent.id)).where(*filters)) or 0)
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(BehaviorEvent)
|
||||||
|
.where(*filters)
|
||||||
|
.order_by(BehaviorEvent.occurred_at.desc(), BehaviorEvent.id.desc())
|
||||||
|
.offset(cursor)
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
return rows, total
|
||||||
|
|
||||||
|
|
||||||
|
def _user_map(db: Session, user_ids: set[int]) -> dict[int, User]:
|
||||||
|
if not user_ids:
|
||||||
|
return {}
|
||||||
|
return {u.id: u for u in db.scalars(select(User).where(User.id.in_(user_ids))).all()}
|
||||||
|
|
||||||
|
|
||||||
|
def _distinct_event_users(
|
||||||
|
db: Session,
|
||||||
|
incident: RiskIncident,
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
outcomes: tuple[str, ...],
|
||||||
|
) -> int:
|
||||||
|
return int(
|
||||||
|
db.scalar(
|
||||||
|
select(func.count(func.distinct(BehaviorEvent.user_id))).where(
|
||||||
|
BehaviorEvent.event_type == event_type,
|
||||||
|
BehaviorEvent.subject_type == incident.subject_type,
|
||||||
|
BehaviorEvent.subject_id == incident.subject_id,
|
||||||
|
BehaviorEvent.outcome.in_(outcomes),
|
||||||
|
BehaviorEvent.user_id.is_not(None),
|
||||||
|
BehaviorEvent.occurred_at >= incident.window_start,
|
||||||
|
BehaviorEvent.occurred_at < incident.window_end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sms_distinct_accounts(db: Session, incident: RiskIncident) -> int:
|
||||||
|
"""统计整个告警窗口内,确实完成本设备短信验证的不同账号数。"""
|
||||||
|
send_rows = db.execute(
|
||||||
|
select(BehaviorEvent.phone, BehaviorEvent.occurred_at).where(
|
||||||
|
BehaviorEvent.event_type == risk_repo.EVENT_SMS_SEND,
|
||||||
|
BehaviorEvent.subject_type == incident.subject_type,
|
||||||
|
BehaviorEvent.subject_id == incident.subject_id,
|
||||||
|
BehaviorEvent.outcome == "success",
|
||||||
|
BehaviorEvent.phone.is_not(None),
|
||||||
|
BehaviorEvent.occurred_at >= incident.window_start,
|
||||||
|
BehaviorEvent.occurred_at < incident.window_end,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
phones = {phone for phone, _ in send_rows}
|
||||||
|
if not phones:
|
||||||
|
return 0
|
||||||
|
login_rows = db.execute(
|
||||||
|
select(
|
||||||
|
BehaviorEvent.user_id,
|
||||||
|
BehaviorEvent.phone,
|
||||||
|
BehaviorEvent.occurred_at,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
BehaviorEvent.event_type == risk_repo.EVENT_SMS_LOGIN,
|
||||||
|
BehaviorEvent.subject_id == incident.subject_id,
|
||||||
|
BehaviorEvent.phone.in_(phones),
|
||||||
|
BehaviorEvent.user_id.is_not(None),
|
||||||
|
BehaviorEvent.outcome == "success",
|
||||||
|
BehaviorEvent.occurred_at >= incident.window_start,
|
||||||
|
BehaviorEvent.occurred_at
|
||||||
|
< incident.window_end + timedelta(seconds=settings.SMS_CODE_TTL_SEC),
|
||||||
|
)
|
||||||
|
.order_by(BehaviorEvent.occurred_at.asc(), BehaviorEvent.id.asc())
|
||||||
|
).all()
|
||||||
|
matched_users = {
|
||||||
|
user_id
|
||||||
|
for user_id, phone, login_at in login_rows
|
||||||
|
if any(
|
||||||
|
sent_phone == phone
|
||||||
|
and sent_at <= login_at
|
||||||
|
and login_at
|
||||||
|
<= sent_at + timedelta(seconds=settings.SMS_CODE_TTL_SEC)
|
||||||
|
for sent_phone, sent_at in send_rows
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return len(matched_users)
|
||||||
|
|
||||||
|
|
||||||
|
def incident_details(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
incident: RiskIncident,
|
||||||
|
kind: str,
|
||||||
|
limit: int,
|
||||||
|
cursor: int,
|
||||||
|
) -> dict:
|
||||||
|
if kind == "sms":
|
||||||
|
rows, total = _page_events(
|
||||||
|
db,
|
||||||
|
incident,
|
||||||
|
event_type=risk_repo.EVENT_SMS_SEND,
|
||||||
|
outcomes=("success",),
|
||||||
|
limit=limit,
|
||||||
|
cursor=cursor,
|
||||||
|
)
|
||||||
|
phone_values = {r.phone for r in rows if r.phone}
|
||||||
|
login_rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(BehaviorEvent)
|
||||||
|
.where(
|
||||||
|
BehaviorEvent.event_type == risk_repo.EVENT_SMS_LOGIN,
|
||||||
|
BehaviorEvent.subject_id == incident.subject_id,
|
||||||
|
BehaviorEvent.phone.in_(phone_values),
|
||||||
|
BehaviorEvent.outcome == "success",
|
||||||
|
BehaviorEvent.occurred_at >= incident.window_start,
|
||||||
|
BehaviorEvent.occurred_at
|
||||||
|
< incident.window_end + timedelta(seconds=settings.SMS_CODE_TTL_SEC),
|
||||||
|
)
|
||||||
|
.order_by(BehaviorEvent.occurred_at.asc(), BehaviorEvent.id.asc())
|
||||||
|
).all()
|
||||||
|
) if phone_values else []
|
||||||
|
users = _user_map(db, {r.user_id for r in login_rows if r.user_id is not None})
|
||||||
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
match = next(
|
||||||
|
(
|
||||||
|
login
|
||||||
|
for login in login_rows
|
||||||
|
if login.phone == row.phone
|
||||||
|
and login.occurred_at >= row.occurred_at
|
||||||
|
and login.occurred_at
|
||||||
|
<= row.occurred_at + timedelta(seconds=settings.SMS_CODE_TTL_SEC)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
user = users.get(match.user_id) if match and match.user_id else None
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"occurred_at": row.occurred_at,
|
||||||
|
"outcome": row.outcome,
|
||||||
|
"phone": row.phone,
|
||||||
|
"user_id": user.id if user else None,
|
||||||
|
"username": user.username if user else None,
|
||||||
|
"account_registered_at": user.created_at if user else None,
|
||||||
|
"verified": match is not None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
distinct_accounts = _sms_distinct_accounts(db, incident)
|
||||||
|
elif kind == "oneclick":
|
||||||
|
rows, total = _page_events(
|
||||||
|
db,
|
||||||
|
incident,
|
||||||
|
event_type=risk_repo.EVENT_ONECLICK_LOGIN,
|
||||||
|
outcomes=("success", "failed"),
|
||||||
|
limit=limit,
|
||||||
|
cursor=cursor,
|
||||||
|
)
|
||||||
|
users = _user_map(db, {r.user_id for r in rows if r.user_id is not None})
|
||||||
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
user = users.get(row.user_id) if row.user_id else None
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"occurred_at": row.occurred_at,
|
||||||
|
"outcome": row.outcome,
|
||||||
|
"phone": row.phone,
|
||||||
|
"user_id": row.user_id,
|
||||||
|
"username": user.username if user else None,
|
||||||
|
"account_registered_at": user.created_at if user else None,
|
||||||
|
"reason": row.reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
distinct_accounts = _distinct_event_users(
|
||||||
|
db,
|
||||||
|
incident,
|
||||||
|
event_type=risk_repo.EVENT_ONECLICK_LOGIN,
|
||||||
|
outcomes=("success", "failed"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
user_id = int(incident.subject_id)
|
||||||
|
filters = (
|
||||||
|
ComparisonRecord.user_id == user_id,
|
||||||
|
ComparisonRecord.created_at >= incident.window_start,
|
||||||
|
ComparisonRecord.created_at < incident.window_end,
|
||||||
|
)
|
||||||
|
total = int(db.scalar(select(func.count(ComparisonRecord.id)).where(*filters)) or 0)
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ComparisonRecord)
|
||||||
|
.where(*filters)
|
||||||
|
.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc())
|
||||||
|
.offset(cursor)
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
prices = {
|
||||||
|
str(result.get("platform_name") or result.get("platform_id") or "未知平台"): (
|
||||||
|
float(result["price"]) if result.get("price") is not None else None
|
||||||
|
)
|
||||||
|
for result in (row.comparison_results or [])
|
||||||
|
}
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"occurred_at": row.created_at,
|
||||||
|
"outcome": row.status,
|
||||||
|
"store_name": row.store_name,
|
||||||
|
"product_names": row.product_names,
|
||||||
|
"prices": prices,
|
||||||
|
"saved_amount_yuan": (
|
||||||
|
row.saved_amount_cents / 100
|
||||||
|
if row.saved_amount_cents is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"status": row.status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
distinct_accounts = None
|
||||||
|
next_cursor = cursor + len(items) if cursor + len(items) < total else None
|
||||||
|
return {
|
||||||
|
"kind": kind,
|
||||||
|
"incident_id": incident.id,
|
||||||
|
"event_count": incident.event_count,
|
||||||
|
"distinct_accounts": distinct_accounts,
|
||||||
|
"items": items,
|
||||||
|
"next_cursor": next_cursor,
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
+129
-28
@@ -6,10 +6,10 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import date, datetime, time, timedelta, timezone
|
from datetime import UTC, date, datetime, time, timedelta, timezone
|
||||||
from decimal import Decimal, InvalidOperation
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.admin.repositories.coupon_data import _percentile
|
from app.admin.repositories.coupon_data import _percentile
|
||||||
@@ -25,26 +25,24 @@ from app.models.coupon_state import (
|
|||||||
from app.models.cps_order import CpsOrder
|
from app.models.cps_order import CpsOrder
|
||||||
from app.models.feedback import Feedback
|
from app.models.feedback import Feedback
|
||||||
from app.models.savings import SavingsRecord
|
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.user import User
|
||||||
from app.models.wallet import CoinTransaction, WithdrawOrder
|
from app.models.wallet import CoinTransaction, WithdrawOrder
|
||||||
|
|
||||||
_BEIJING = timezone(timedelta(hours=8))
|
_BEIJING = timezone(timedelta(hours=8))
|
||||||
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward")
|
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward", "signin_boost")
|
||||||
# 领券/比价奖励金币的真实来源是信息流广告发奖(ad_feed_reward_record,按 feed_scene 分场景);
|
# 领券/比价奖励金币的真实来源是信息流广告发奖(ad_feed_reward_record,按 feed_scene 分场景);
|
||||||
# coin_transaction 里只有扁平的 feed_ad_reward、biz_type 不分 coupon/comparison,故这俩桶历史从未
|
# coin_transaction 里只有扁平的 feed_ad_reward、biz_type 不分 coupon/comparison,故这俩桶历史从未
|
||||||
# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。reward_video/
|
# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。
|
||||||
# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。
|
# reward_video/ad_reward 及历史 signin_boost 均归看视频桶,不再混进领券奖励或常规任务。
|
||||||
COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward")
|
COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward")
|
||||||
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_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",)
|
# biz_type 时会在排除清单更新前自动混入该桶。task_ 前缀在查询处单独覆盖现有及未来任务。
|
||||||
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
|
REGULAR_TASK_EXACT_BIZ_TYPES = (
|
||||||
*REWARD_VIDEO_BIZ_TYPES,
|
"signin",
|
||||||
*COUPON_REWARD_BIZ_TYPES,
|
"price_report_reward",
|
||||||
*COMPARISON_REWARD_BIZ_TYPES,
|
"feedback_reward",
|
||||||
*EXCLUDED_REWARD_BIZ_TYPES,
|
|
||||||
*UNCLASSIFIED_FEED_BIZ_TYPES,
|
|
||||||
)
|
)
|
||||||
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
|
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
|
||||||
MEITUAN_CPS_SETTLED_STATUS = "6"
|
MEITUAN_CPS_SETTLED_STATUS = "6"
|
||||||
@@ -61,7 +59,7 @@ def _beijing_today_start_utc() -> datetime:
|
|||||||
"""北京时间今天 0 点对应的 UTC 时刻(DAU / 今日新增按北京时区切天)。"""
|
"""北京时间今天 0 点对应的 UTC 时刻(DAU / 今日新增按北京时区切天)。"""
|
||||||
now_bj = datetime.now(_BEIJING)
|
now_bj = datetime.now(_BEIJING)
|
||||||
start_bj = now_bj.replace(hour=0, minute=0, second=0, microsecond=0)
|
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:
|
def today_dau(db: Session) -> int:
|
||||||
@@ -94,8 +92,8 @@ def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime,
|
|||||||
"""
|
"""
|
||||||
start_bj = datetime.combine(date_from, time.min, tzinfo=_BEIJING)
|
start_bj = datetime.combine(date_from, time.min, tzinfo=_BEIJING)
|
||||||
end_bj = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=_BEIJING)
|
end_bj = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=_BEIJING)
|
||||||
start_utc = start_bj.astimezone(timezone.utc)
|
start_utc = start_bj.astimezone(UTC)
|
||||||
end_utc = end_bj.astimezone(timezone.utc)
|
end_utc = end_bj.astimezone(UTC)
|
||||||
return (
|
return (
|
||||||
start_utc,
|
start_utc,
|
||||||
end_utc,
|
end_utc,
|
||||||
@@ -109,6 +107,23 @@ def _date_range(date_from: date, date_to: date) -> list[date]:
|
|||||||
return [date_from + timedelta(days=i) for i in range(days + 1)]
|
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]:
|
def _id_set(db: Session, stmt) -> set[int]:
|
||||||
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
|
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
|
||||||
|
|
||||||
@@ -242,16 +257,46 @@ def dashboard_overview(
|
|||||||
ComparisonRecord.created_at >= start_local,
|
ComparisonRecord.created_at >= start_local,
|
||||||
ComparisonRecord.created_at < end_local,
|
ComparisonRecord.created_at < end_local,
|
||||||
)
|
)
|
||||||
period_comparison_total = _count(ComparisonRecord, *period_comparison_conds)
|
period_comparison_stats = db.execute(
|
||||||
period_comparison_success = _count(
|
select(
|
||||||
ComparisonRecord,
|
func.count(ComparisonRecord.id),
|
||||||
*period_comparison_conds,
|
func.coalesce(
|
||||||
ComparisonRecord.status == "success",
|
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 = (
|
period_comparison_success_rate = (
|
||||||
round(period_comparison_success / period_comparison_total, 4)
|
round(
|
||||||
if period_comparison_total
|
period_comparison_success / period_comparison_success_denominator,
|
||||||
else 0.0
|
4,
|
||||||
|
)
|
||||||
|
if period_comparison_success_denominator > 0
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
period_saved_positive_count = _count(
|
period_saved_positive_count = _count(
|
||||||
ComparisonRecord,
|
ComparisonRecord,
|
||||||
@@ -282,6 +327,47 @@ def dashboard_overview(
|
|||||||
if period_avg_duration_ms is not None
|
if period_avg_duration_ms is not None
|
||||||
else 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 = (
|
ordered_exists = (
|
||||||
select(SavingsRecord.id)
|
select(SavingsRecord.id)
|
||||||
@@ -417,7 +503,10 @@ def dashboard_overview(
|
|||||||
period_regular_task_coin_total = _sum(
|
period_regular_task_coin_total = _sum(
|
||||||
CoinTransaction.amount,
|
CoinTransaction.amount,
|
||||||
*period_coin_conds,
|
*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(
|
period_cps_orders = list(
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -590,7 +679,14 @@ def dashboard_overview(
|
|||||||
CoinTransaction.amount > 0,
|
CoinTransaction.amount > 0,
|
||||||
CoinTransaction.biz_type == "signin_boost",
|
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": {
|
"cash": {
|
||||||
"withdraw_success_cents": _sum(
|
"withdraw_success_cents": _sum(
|
||||||
@@ -622,11 +718,16 @@ def dashboard_overview(
|
|||||||
},
|
},
|
||||||
"comparison": {
|
"comparison": {
|
||||||
"total": period_comparison_total,
|
"total": period_comparison_total,
|
||||||
|
"completed": period_comparison_completed,
|
||||||
|
"cancelled": period_comparison_cancelled,
|
||||||
"success": period_comparison_success,
|
"success": period_comparison_success,
|
||||||
"success_rate": period_comparison_success_rate,
|
"success_rate": period_comparison_success_rate,
|
||||||
"ordered": period_ordered_count,
|
"ordered": period_ordered_count,
|
||||||
"average_duration_ms": period_avg_duration_ms,
|
"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,
|
"average_saved_cents": period_avg_saved_cents,
|
||||||
|
"token_cost_total_yuan": period_comparison_token_cost_yuan,
|
||||||
},
|
},
|
||||||
"coupon": {
|
"coupon": {
|
||||||
"started": coupon_started,
|
"started": coupon_started,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date as _date
|
from datetime import date as _date
|
||||||
from typing import Annotated
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
@@ -63,6 +63,13 @@ def get_ad_revenue_report(
|
|||||||
"建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均"
|
"建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均"
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
|
revenue_scope: Annotated[
|
||||||
|
Literal["business", "all"],
|
||||||
|
Query(
|
||||||
|
description="business=仅业务代码位(用于客户端与穿山甲同口径对账)/ "
|
||||||
|
"all=穿山甲应用全部代码位(包含广告测试等非业务曝光)"
|
||||||
|
),
|
||||||
|
] = "all",
|
||||||
granularity: Annotated[
|
granularity: Annotated[
|
||||||
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
|
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
|
||||||
] = "day",
|
] = "day",
|
||||||
@@ -83,6 +90,7 @@ def get_ad_revenue_report(
|
|||||||
result = ad_revenue.ad_revenue_report(
|
result = ad_revenue.ad_revenue_report(
|
||||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
|
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,
|
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,
|
granularity=granularity, limit=limit, offset=offset, sort=sort,
|
||||||
)
|
)
|
||||||
return AdRevenueReportOut(
|
return AdRevenueReportOut(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||||||
|
|
||||||
from app.admin.audit import write_audit
|
from app.admin.audit import write_audit
|
||||||
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
|
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
|
||||||
from app.admin.permissions import SUPER_ADMIN_ROLE
|
from app.admin.permissions import CUSTOM_ROLE, SUPER_ADMIN_ROLE, sanitize_pages
|
||||||
from app.admin.repositories import admin_role as role_repo
|
from app.admin.repositories import admin_role as role_repo
|
||||||
from app.admin.repositories import admin_user as admin_repo
|
from app.admin.repositories import admin_user as admin_repo
|
||||||
from app.admin.schemas.admin import AdminCreateRequest, AdminUpdateRequest
|
from app.admin.schemas.admin import AdminCreateRequest, AdminUpdateRequest
|
||||||
@@ -26,14 +26,22 @@ def _active_super_count(db: AdminDb) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _validate_role(db: AdminDb, role: str) -> None:
|
def _validate_role(db: AdminDb, role: str) -> None:
|
||||||
"""角色必须是 super_admin 或 admin_role 表里已存在的角色,否则 400。"""
|
"""角色必须是 super_admin / custom(自定义) / admin_role 表里已存在的角色,否则 400。"""
|
||||||
if role == SUPER_ADMIN_ROLE:
|
if role in (SUPER_ADMIN_ROLE, CUSTOM_ROLE):
|
||||||
return
|
return
|
||||||
role_repo.ensure_builtin_roles(db) # 空表(测试/全新库)兜底播种,再校验
|
role_repo.ensure_builtin_roles(db) # 空表(测试/全新库)兜底播种,再校验
|
||||||
if role_repo.get_role(db, role) is None:
|
if role_repo.get_role(db, role) is None:
|
||||||
raise HTTPException(status_code=400, detail=f"角色不存在: {role}")
|
raise HTTPException(status_code=400, detail=f"角色不存在: {role}")
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_override(role: str, pages_override: list[str] | None) -> list[str] | None:
|
||||||
|
"""按最终角色算出该存的 pages_override:custom → 勾选集(过滤非法 key,可空列表);
|
||||||
|
非 custom → None(切回普通角色即清空自定义页)。"""
|
||||||
|
if role == CUSTOM_ROLE:
|
||||||
|
return sanitize_pages(pages_override)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[AdminOut], summary="管理员列表(含明文密码,super_admin 专属)")
|
@router.get("", response_model=list[AdminOut], summary="管理员列表(含明文密码,super_admin 专属)")
|
||||||
def list_admins(db: AdminDb) -> list[AdminOut]:
|
def list_admins(db: AdminDb) -> list[AdminOut]:
|
||||||
out: list[AdminOut] = []
|
out: list[AdminOut] = []
|
||||||
@@ -51,13 +59,16 @@ def create_admin(
|
|||||||
if admin_repo.get_by_username(db, body.username) is not None:
|
if admin_repo.get_by_username(db, body.username) is not None:
|
||||||
raise HTTPException(status_code=409, detail="用户名已存在")
|
raise HTTPException(status_code=409, detail="用户名已存在")
|
||||||
_validate_role(db, body.role)
|
_validate_role(db, body.role)
|
||||||
|
override = _clean_override(body.role, body.pages_override)
|
||||||
new = admin_repo.create_admin(
|
new = admin_repo.create_admin(
|
||||||
db, username=body.username, password=body.password, role=body.role,
|
db, username=body.username, password=body.password, role=body.role,
|
||||||
plain_password=body.password, # UI 建的账号留存明文,供权限管理页复看
|
plain_password=body.password, # UI 建的账号留存明文,供权限管理页复看
|
||||||
|
pages_override=override,
|
||||||
)
|
)
|
||||||
write_audit(
|
write_audit(
|
||||||
db, admin, action="admin.create", target_type="admin", target_id=new.id,
|
db, admin, action="admin.create", target_type="admin", target_id=new.id,
|
||||||
detail={"username": new.username, "role": new.role}, ip=get_client_ip(request), commit=True,
|
detail={"username": new.username, "role": new.role, "pages_override": override},
|
||||||
|
ip=get_client_ip(request), commit=True,
|
||||||
)
|
)
|
||||||
return AdminOut.model_validate(new)
|
return AdminOut.model_validate(new)
|
||||||
|
|
||||||
@@ -89,7 +100,8 @@ def update_admin(
|
|||||||
_validate_role(db, body.role)
|
_validate_role(db, body.role)
|
||||||
|
|
||||||
changes: dict = {}
|
changes: dict = {}
|
||||||
if body.role is not None and body.role != target.role:
|
role_changed = body.role is not None and body.role != target.role
|
||||||
|
if role_changed:
|
||||||
changes["role"] = {"before": target.role, "after": body.role}
|
changes["role"] = {"before": target.role, "after": body.role}
|
||||||
target.role = body.role
|
target.role = body.role
|
||||||
if body.status is not None and body.status != target.status:
|
if body.status is not None and body.status != target.status:
|
||||||
@@ -99,6 +111,17 @@ def update_admin(
|
|||||||
changes["password"] = "reset"
|
changes["password"] = "reset"
|
||||||
target.password_hash = hash_password(body.password)
|
target.password_hash = hash_password(body.password)
|
||||||
target.plain_password = body.password # 同步留存明文,权限管理页复看保持一致
|
target.plain_password = body.password # 同步留存明文,权限管理页复看保持一致
|
||||||
|
|
||||||
|
# 自定义可见页:按「最终角色」(target.role,已应用完角色变更)决定该存什么。
|
||||||
|
# - 切到/维持 custom 且传了 pages_override → 用勾选集;切到 custom 没传 → 清成空列表。
|
||||||
|
# - 切回普通角色 → 清空 override(_clean_override 返回 None)。
|
||||||
|
# - 角色没变、只传 pages_override(编辑现有 custom 用户的勾选)→ 也更新。
|
||||||
|
if role_changed or body.pages_override is not None:
|
||||||
|
new_override = _clean_override(target.role, body.pages_override)
|
||||||
|
if new_override != target.pages_override:
|
||||||
|
changes["pages_override"] = {"before": target.pages_override, "after": new_override}
|
||||||
|
target.pages_override = new_override
|
||||||
|
|
||||||
if not changes:
|
if not changes:
|
||||||
raise HTTPException(status_code=400, detail="无任何变更字段")
|
raise HTTPException(status_code=400, detail="无任何变更字段")
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -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, require_page
|
||||||
|
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(require_page("analytics-health"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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)]
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"""admin 操作审计日志查询(所有 admin 可看:谁在何时对什么做了什么)。"""
|
"""admin 操作审计日志查询(需要 audit-logs 页面权限)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
from app.admin.deps import AdminDb, get_current_admin
|
from app.admin.deps import AdminDb, require_page
|
||||||
from app.admin.repositories import audit_log as audit_repo
|
from app.admin.repositories import audit_log as audit_repo
|
||||||
from app.admin.schemas.admin import AdminAuditLogOut
|
from app.admin.schemas.admin import AdminAuditLogOut
|
||||||
from app.admin.schemas.common import CursorPage
|
from app.admin.schemas.common import CursorPage
|
||||||
@@ -13,7 +13,7 @@ from app.admin.schemas.common import CursorPage
|
|||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/admin/api/audit-logs",
|
prefix="/admin/api/audit-logs",
|
||||||
tags=["admin-audit"],
|
tags=["admin-audit"],
|
||||||
dependencies=[Depends(get_current_admin)],
|
dependencies=[Depends(require_page("audit-logs"))],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import logging
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from app.admin.deps import AdminDb, CurrentAdmin
|
from app.admin.deps import AdminDb, CurrentAdmin
|
||||||
|
from app.admin.permissions import CUSTOM_ROLE, sanitize_pages
|
||||||
from app.admin.repositories import admin_role as role_repo
|
from app.admin.repositories import admin_role as role_repo
|
||||||
from app.admin.repositories import admin_user as admin_repo
|
from app.admin.repositories import admin_user as admin_repo
|
||||||
from app.admin.schemas.auth import AdminLoginRequest, AdminLoginResponse, AdminOut
|
from app.admin.schemas.auth import AdminLoginRequest, AdminLoginResponse, AdminOut
|
||||||
@@ -19,8 +20,12 @@ router = APIRouter(prefix="/admin/api/auth", tags=["admin-auth"])
|
|||||||
|
|
||||||
|
|
||||||
def _admin_out_with_pages(admin, db: AdminDb) -> AdminOut: # noqa: ANN001
|
def _admin_out_with_pages(admin, db: AdminDb) -> AdminOut: # noqa: ANN001
|
||||||
"""AdminOut + 当前角色有效可见页(前端左侧导航按此过滤)。"""
|
"""AdminOut + 有效可见页(前端左侧导航按此过滤)。
|
||||||
|
role=="custom" → 用这个人的 pages_override(按人自定义,过滤悬空 key);其余走角色解析。"""
|
||||||
out = AdminOut.model_validate(admin)
|
out = AdminOut.model_validate(admin)
|
||||||
|
if admin.role == CUSTOM_ROLE:
|
||||||
|
out.pages = sanitize_pages(admin.pages_override)
|
||||||
|
else:
|
||||||
out.pages = role_repo.effective_pages_of(db, admin.role)
|
out.pages = role_repo.effective_pages_of(db, admin.role)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.de
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
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.deps import AdminDb, get_current_admin
|
||||||
from app.admin.repositories import queries
|
from app.admin.repositories import queries
|
||||||
from app.admin.schemas.common import CursorPage
|
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(
|
router = APIRouter(
|
||||||
prefix="/admin/api/comparison-records",
|
prefix="/admin/api/comparison-records",
|
||||||
@@ -34,12 +39,15 @@ def list_comparison_records(
|
|||||||
business_type: Annotated[str | None, Query()] = None,
|
business_type: Annotated[str | None, Query()] = None,
|
||||||
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
|
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
|
||||||
product: 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,
|
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
cursor: Annotated[int | None, Query()] = None,
|
cursor: Annotated[int | None, Query()] = None,
|
||||||
) -> CursorPage[AdminComparisonListItem]:
|
) -> CursorPage[AdminComparisonListItem]:
|
||||||
items, next_cursor, total = queries.list_comparison_records(
|
items, next_cursor, total = queries.list_comparison_records(
|
||||||
db, user_id=user_id, phone=phone, status=status,
|
db, user_id=user_id, phone=phone, status=status,
|
||||||
business_type=business_type, store=store, product=product,
|
business_type=business_type, store=store, product=product,
|
||||||
|
date_from=date_from, date_to=date_to,
|
||||||
limit=limit, cursor=cursor,
|
limit=limit, cursor=cursor,
|
||||||
)
|
)
|
||||||
return CursorPage(
|
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(
|
@router.get(
|
||||||
"/{record_id}",
|
"/{record_id}",
|
||||||
response_model=AdminComparisonDetail,
|
response_model=AdminComparisonDetail,
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ def _validate(key: str, value: Any) -> None:
|
|||||||
if t == "int":
|
if t == "int":
|
||||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||||
raise ValueError("需为非负整数")
|
raise ValueError("需为非负整数")
|
||||||
|
minimum = CONFIG_DEFS[key].get("min")
|
||||||
|
maximum = CONFIG_DEFS[key].get("max")
|
||||||
|
if minimum is not None and value < minimum:
|
||||||
|
raise ValueError(f"不能小于 {minimum}")
|
||||||
|
if maximum is not None and value > maximum:
|
||||||
|
raise ValueError(f"不能大于 {maximum}")
|
||||||
elif t == "int_list":
|
elif t == "int_list":
|
||||||
if not isinstance(value, list) or not value:
|
if not isinstance(value, list) or not value:
|
||||||
raise ValueError("需为非空整数列表")
|
raise ValueError("需为非空整数列表")
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ from app.admin.schemas.coupon_data import (
|
|||||||
CouponDataOut,
|
CouponDataOut,
|
||||||
CouponDataRow,
|
CouponDataRow,
|
||||||
CouponDataSummary,
|
CouponDataSummary,
|
||||||
|
CouponPointDetail,
|
||||||
|
CouponPointDetailsOut,
|
||||||
|
CouponSlotRow,
|
||||||
|
CouponSlotsOut,
|
||||||
CouponUserRecordsOut,
|
CouponUserRecordsOut,
|
||||||
)
|
)
|
||||||
from app.core.rewards import cn_today
|
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,
|
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
|
||||||
user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None,
|
user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None,
|
||||||
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
|
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
|
||||||
|
status: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
Query(description="领券状态多选 started/completed/failed/abandoned;不传=全部"),
|
||||||
|
] = None,
|
||||||
granularity: Annotated[
|
granularity: Annotated[
|
||||||
str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day")
|
str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day")
|
||||||
] = "day",
|
] = "day",
|
||||||
@@ -73,7 +81,7 @@ def get_coupon_data(
|
|||||||
env = None if app_env == "all" else app_env
|
env = None if app_env == "all" else app_env
|
||||||
result = coupon_data.coupon_data_report(
|
result = coupon_data.coupon_data_report(
|
||||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
|
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,
|
limit=limit, offset=offset, sort=sort,
|
||||||
)
|
)
|
||||||
return CouponDataOut(
|
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(
|
@router.get(
|
||||||
"/user-records",
|
"/user-records",
|
||||||
response_model=CouponUserRecordsOut,
|
response_model=CouponUserRecordsOut,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
数据源 device_liveness 表(心跳 last_heartbeat_at + liveness_state + kill_alert_pending,
|
数据源 device_liveness 表(心跳 last_heartbeat_at + liveness_state + kill_alert_pending,
|
||||||
见 app/models/device.py)。在线/掉线、掉线时长由 repo 按 HEARTBEAT_TIMEOUT_MINUTES 阈值派生。
|
见 app/models/device.py)。在线/掉线、掉线时长由 repo 按 HEARTBEAT_TIMEOUT_MINUTES 阈值派生。
|
||||||
纯读:无写、无审计。任意登录管理员可看(同大盘/设备管理,无角色门)。
|
纯读:无写、无审计。需要 device-liveness 页面权限。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
from app.admin.deps import AdminDb, get_current_admin
|
from app.admin.deps import AdminDb, require_page
|
||||||
from app.admin.repositories import queries
|
from app.admin.repositories import queries
|
||||||
from app.admin.schemas.common import CursorPage
|
from app.admin.schemas.common import CursorPage
|
||||||
from app.admin.schemas.device import DeviceLivenessItem, DeviceLivenessStats
|
from app.admin.schemas.device import DeviceLivenessItem, DeviceLivenessStats
|
||||||
@@ -18,7 +18,7 @@ from app.admin.schemas.device import DeviceLivenessItem, DeviceLivenessStats
|
|||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/admin/api/device-liveness",
|
prefix="/admin/api/device-liveness",
|
||||||
tags=["admin-device-liveness"],
|
tags=["admin-device-liveness"],
|
||||||
dependencies=[Depends(get_current_admin)],
|
dependencies=[Depends(require_page("device-liveness"))],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
from app.admin.deps import AdminDb, get_current_admin
|
from app.admin.deps import AdminDb, require_page
|
||||||
from app.admin.repositories import queries
|
from app.admin.repositories import queries
|
||||||
from app.admin.schemas.analytics import AnalyticsEventOut
|
from app.admin.schemas.analytics import AnalyticsEventOut
|
||||||
from app.admin.schemas.common import CursorPage
|
from app.admin.schemas.common import CursorPage
|
||||||
@@ -14,7 +14,7 @@ from app.admin.schemas.common import CursorPage
|
|||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/admin/api/event-logs",
|
prefix="/admin/api/event-logs",
|
||||||
tags=["admin-event-logs"],
|
tags=["admin-event-logs"],
|
||||||
dependencies=[Depends(get_current_admin)],
|
dependencies=[Depends(require_page("event-logs"))],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+168
-78
@@ -12,6 +12,10 @@ from app.admin.repositories import mutations, queries
|
|||||||
from app.admin.schemas.common import CursorPage, OkResponse
|
from app.admin.schemas.common import CursorPage, OkResponse
|
||||||
from app.admin.schemas.feedback import (
|
from app.admin.schemas.feedback import (
|
||||||
FeedbackApproveRequest,
|
FeedbackApproveRequest,
|
||||||
|
FeedbackBulkApproveRequest,
|
||||||
|
FeedbackBulkItemResult,
|
||||||
|
FeedbackBulkRejectRequest,
|
||||||
|
FeedbackBulkResult,
|
||||||
FeedbackOut,
|
FeedbackOut,
|
||||||
FeedbackRejectRequest,
|
FeedbackRejectRequest,
|
||||||
FeedbackSummary,
|
FeedbackSummary,
|
||||||
@@ -19,6 +23,7 @@ from app.admin.schemas.feedback import (
|
|||||||
from app.models.admin import AdminUser
|
from app.models.admin import AdminUser
|
||||||
from app.models.feedback import Feedback
|
from app.models.feedback import Feedback
|
||||||
from app.repositories import wallet as wallet_repo
|
from app.repositories import wallet as wallet_repo
|
||||||
|
from app.services import notification_events
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/admin/api/feedbacks",
|
prefix="/admin/api/feedbacks",
|
||||||
@@ -32,6 +37,123 @@ def _ensure_pending(fb: Feedback) -> None:
|
|||||||
raise HTTPException(status_code=400, detail="反馈已审核")
|
raise HTTPException(status_code=400, detail="反馈已审核")
|
||||||
|
|
||||||
|
|
||||||
|
def _approve_feedback(
|
||||||
|
db: AdminDb,
|
||||||
|
admin: AdminUser,
|
||||||
|
feedback_id: int,
|
||||||
|
payload: FeedbackApproveRequest | FeedbackBulkApproveRequest,
|
||||||
|
ip: str,
|
||||||
|
*,
|
||||||
|
bulk: bool = False,
|
||||||
|
) -> FeedbackOut:
|
||||||
|
fb = db.get(Feedback, feedback_id, with_for_update=True)
|
||||||
|
if fb is None:
|
||||||
|
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||||
|
_ensure_pending(fb)
|
||||||
|
|
||||||
|
before = fb.status
|
||||||
|
mutations.review_feedback(
|
||||||
|
db,
|
||||||
|
fb,
|
||||||
|
status="adopted",
|
||||||
|
reward_coins=payload.reward_coins,
|
||||||
|
review_note=payload.note,
|
||||||
|
admin_reply=payload.reply,
|
||||||
|
reviewed_by_admin_id=admin.id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
wallet_repo.grant_coins(
|
||||||
|
db,
|
||||||
|
fb.user_id,
|
||||||
|
payload.reward_coins,
|
||||||
|
biz_type="feedback_reward",
|
||||||
|
ref_id=str(fb.id),
|
||||||
|
remark="意见反馈被采纳",
|
||||||
|
)
|
||||||
|
detail = {
|
||||||
|
"before": before,
|
||||||
|
"after": "adopted",
|
||||||
|
"reward_coins": payload.reward_coins,
|
||||||
|
"note": payload.note,
|
||||||
|
"reply": payload.reply,
|
||||||
|
}
|
||||||
|
if bulk:
|
||||||
|
detail["bulk"] = True
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="feedback.approve",
|
||||||
|
target_type="feedback",
|
||||||
|
target_id=feedback_id,
|
||||||
|
detail=detail,
|
||||||
|
ip=ip,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(fb)
|
||||||
|
out = FeedbackOut.model_validate(fb)
|
||||||
|
notification_events.notify_feedback_reward(db, fb)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_feedback(
|
||||||
|
db: AdminDb,
|
||||||
|
admin: AdminUser,
|
||||||
|
feedback_id: int,
|
||||||
|
payload: FeedbackRejectRequest | FeedbackBulkRejectRequest,
|
||||||
|
ip: str,
|
||||||
|
*,
|
||||||
|
bulk: bool = False,
|
||||||
|
) -> FeedbackOut:
|
||||||
|
fb = db.get(Feedback, feedback_id, with_for_update=True)
|
||||||
|
if fb is None:
|
||||||
|
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||||
|
_ensure_pending(fb)
|
||||||
|
|
||||||
|
before = fb.status
|
||||||
|
mutations.review_feedback(
|
||||||
|
db,
|
||||||
|
fb,
|
||||||
|
status="rejected",
|
||||||
|
reject_reason=payload.reason,
|
||||||
|
review_note=payload.note,
|
||||||
|
admin_reply=payload.reply,
|
||||||
|
reviewed_by_admin_id=admin.id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
detail = {
|
||||||
|
"before": before,
|
||||||
|
"after": "rejected",
|
||||||
|
"reason": payload.reason,
|
||||||
|
"note": payload.note,
|
||||||
|
"reply": payload.reply,
|
||||||
|
}
|
||||||
|
if bulk:
|
||||||
|
detail["bulk"] = True
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="feedback.reject",
|
||||||
|
target_type="feedback",
|
||||||
|
target_id=feedback_id,
|
||||||
|
detail=detail,
|
||||||
|
ip=ip,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(fb)
|
||||||
|
out = FeedbackOut.model_validate(fb)
|
||||||
|
notification_events.notify_feedback_reply(db, fb)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _bulk_result(items: list[FeedbackBulkItemResult]) -> FeedbackBulkResult:
|
||||||
|
success = sum(1 for item in items if item.ok)
|
||||||
|
return FeedbackBulkResult(
|
||||||
|
total=len(items), success=success, failed=len(items) - success, items=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=CursorPage[FeedbackOut], summary="反馈工单列表")
|
@router.get("", response_model=CursorPage[FeedbackOut], summary="反馈工单列表")
|
||||||
def list_feedbacks(
|
def list_feedbacks(
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
@@ -72,6 +194,50 @@ def feedback_summary(db: AdminDb) -> FeedbackSummary:
|
|||||||
return FeedbackSummary.model_validate(queries.feedback_summary(db))
|
return FeedbackSummary.model_validate(queries.feedback_summary(db))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk/approve", response_model=FeedbackBulkResult, summary="批量采纳反馈并发金币")
|
||||||
|
def bulk_approve_feedbacks(
|
||||||
|
body: FeedbackBulkApproveRequest,
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> FeedbackBulkResult:
|
||||||
|
results: list[FeedbackBulkItemResult] = []
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
for feedback_id in body.ids:
|
||||||
|
try:
|
||||||
|
out = _approve_feedback(db, admin, feedback_id, body, ip, bulk=True)
|
||||||
|
results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status))
|
||||||
|
except HTTPException as exc:
|
||||||
|
db.rollback()
|
||||||
|
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail)))
|
||||||
|
except Exception: # noqa: BLE001 - 单笔失败不打断整批
|
||||||
|
db.rollback()
|
||||||
|
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常"))
|
||||||
|
return _bulk_result(results)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk/reject", response_model=FeedbackBulkResult, summary="批量拒绝采纳反馈")
|
||||||
|
def bulk_reject_feedbacks(
|
||||||
|
body: FeedbackBulkRejectRequest,
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> FeedbackBulkResult:
|
||||||
|
results: list[FeedbackBulkItemResult] = []
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
for feedback_id in body.ids:
|
||||||
|
try:
|
||||||
|
out = _reject_feedback(db, admin, feedback_id, body, ip, bulk=True)
|
||||||
|
results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status))
|
||||||
|
except HTTPException as exc:
|
||||||
|
db.rollback()
|
||||||
|
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail)))
|
||||||
|
except Exception: # noqa: BLE001 - 单笔失败不打断整批
|
||||||
|
db.rollback()
|
||||||
|
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常"))
|
||||||
|
return _bulk_result(results)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
|
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
|
||||||
def handle_feedback(
|
def handle_feedback(
|
||||||
feedback_id: int,
|
feedback_id: int,
|
||||||
@@ -92,49 +258,7 @@ def approve_feedback(
|
|||||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
) -> FeedbackOut:
|
) -> FeedbackOut:
|
||||||
fb = db.get(Feedback, feedback_id)
|
return _approve_feedback(db, admin, feedback_id, payload, get_client_ip(request))
|
||||||
if fb is None:
|
|
||||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
|
||||||
_ensure_pending(fb)
|
|
||||||
|
|
||||||
before = fb.status
|
|
||||||
mutations.review_feedback(
|
|
||||||
db,
|
|
||||||
fb,
|
|
||||||
status="adopted",
|
|
||||||
reward_coins=payload.reward_coins,
|
|
||||||
review_note=payload.note,
|
|
||||||
admin_reply=payload.reply,
|
|
||||||
reviewed_by_admin_id=admin.id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
wallet_repo.grant_coins(
|
|
||||||
db,
|
|
||||||
fb.user_id,
|
|
||||||
payload.reward_coins,
|
|
||||||
biz_type="feedback_reward",
|
|
||||||
ref_id=str(fb.id),
|
|
||||||
remark="意见反馈被采纳",
|
|
||||||
)
|
|
||||||
write_audit(
|
|
||||||
db,
|
|
||||||
admin,
|
|
||||||
action="feedback.approve",
|
|
||||||
target_type="feedback",
|
|
||||||
target_id=feedback_id,
|
|
||||||
detail={
|
|
||||||
"before": before,
|
|
||||||
"after": "adopted",
|
|
||||||
"reward_coins": payload.reward_coins,
|
|
||||||
"note": payload.note,
|
|
||||||
"reply": payload.reply,
|
|
||||||
},
|
|
||||||
ip=get_client_ip(request),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(fb)
|
|
||||||
return FeedbackOut.model_validate(fb)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
|
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
|
||||||
@@ -145,38 +269,4 @@ def reject_feedback(
|
|||||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
) -> FeedbackOut:
|
) -> FeedbackOut:
|
||||||
fb = db.get(Feedback, feedback_id)
|
return _reject_feedback(db, admin, feedback_id, payload, get_client_ip(request))
|
||||||
if fb is None:
|
|
||||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
|
||||||
_ensure_pending(fb)
|
|
||||||
|
|
||||||
before = fb.status
|
|
||||||
mutations.review_feedback(
|
|
||||||
db,
|
|
||||||
fb,
|
|
||||||
status="rejected",
|
|
||||||
reject_reason=payload.reason,
|
|
||||||
review_note=payload.note,
|
|
||||||
admin_reply=payload.reply,
|
|
||||||
reviewed_by_admin_id=admin.id,
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
write_audit(
|
|
||||||
db,
|
|
||||||
admin,
|
|
||||||
action="feedback.reject",
|
|
||||||
target_type="feedback",
|
|
||||||
target_id=feedback_id,
|
|
||||||
detail={
|
|
||||||
"before": before,
|
|
||||||
"after": "rejected",
|
|
||||||
"reason": payload.reason,
|
|
||||||
"note": payload.note,
|
|
||||||
"reply": payload.reply,
|
|
||||||
},
|
|
||||||
ip=get_client_ip(request),
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(fb)
|
|
||||||
return FeedbackOut.model_validate(fb)
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""admin 新手引导视频配置:读 / 改开关次数金币 / 上传视频 / 删视频(带审计)。
|
||||||
|
|
||||||
|
整份配置存通用 app_config 表(见 app/repositories/guide_video.py),App 领券等候浮层
|
||||||
|
每次展示前调 POST /api/v1/guide-video/start 同步。权限:operator 可改(运营维护),
|
||||||
|
super 恒可;读为只读(任意已登录 admin)。
|
||||||
|
|
||||||
|
⚠️ 视频上限 100MB(settings.GUIDE_VIDEO_MAX_BYTES),已在 admin nginx 为本接口单独放宽
|
||||||
|
client_max_body_size,见 shaguabijia-admin-web/deploy/nginx/admin.shaguabijia.com.conf。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||||
|
|
||||||
|
from app.admin.audit import write_audit
|
||||||
|
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||||
|
from app.admin.schemas.guide_video import GuideVideoConfigOut, GuideVideoConfigUpdate
|
||||||
|
from app.core import media
|
||||||
|
from app.models.admin import AdminUser
|
||||||
|
from app.repositories import guide_video
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/admin/api/guide-video",
|
||||||
|
tags=["admin-guide-video"],
|
||||||
|
dependencies=[Depends(get_current_admin)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _out(db: AdminDb) -> GuideVideoConfigOut:
|
||||||
|
"""配置 + 播放统计合成响应(四个写接口都以最新状态返回,前端一次同步到位)。"""
|
||||||
|
return GuideVideoConfigOut(**guide_video.get_config(db), **guide_video.play_stats(db))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=GuideVideoConfigOut, summary="新手引导视频配置(领券浮层)")
|
||||||
|
def get_config(db: AdminDb) -> GuideVideoConfigOut:
|
||||||
|
return _out(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("", response_model=GuideVideoConfigOut, summary="改开关/次数/金币(带审计)")
|
||||||
|
def update_config(
|
||||||
|
body: GuideVideoConfigUpdate,
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> GuideVideoConfigOut:
|
||||||
|
before, after = guide_video.update_config(
|
||||||
|
db,
|
||||||
|
enabled=body.enabled,
|
||||||
|
max_plays=body.max_plays,
|
||||||
|
reward_coin=body.reward_coin,
|
||||||
|
admin_id=admin.id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
write_audit(
|
||||||
|
db, admin, action="guide_video.update", target_type="guide_video", target_id=None,
|
||||||
|
detail={"before": before, "after": after}, ip=get_client_ip(request), commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _out(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/video", response_model=GuideVideoConfigOut, summary="上传新手引导视频(MP4,带审计)")
|
||||||
|
async def upload_video(
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
|
db: AdminDb,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> GuideVideoConfigOut:
|
||||||
|
data = await file.read()
|
||||||
|
try:
|
||||||
|
url = media.save_guide_video(data)
|
||||||
|
except media.MediaError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
before, after = guide_video.set_video(db, url, admin_id=admin.id, commit=False)
|
||||||
|
write_audit(
|
||||||
|
db, admin, action="guide_video.set_video", target_type="guide_video", target_id=None,
|
||||||
|
detail={"before": before.get("video_url"), "after": url, "bytes": len(data)},
|
||||||
|
ip=get_client_ip(request), commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
# 提交成功后再删旧片,避免新片没落库就把旧片丢了
|
||||||
|
media.delete_guide_video(before.get("video_url"))
|
||||||
|
return _out(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/video", response_model=GuideVideoConfigOut, summary="移除新手引导视频(带审计)")
|
||||||
|
def delete_video(
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> GuideVideoConfigOut:
|
||||||
|
"""移除后 /guide-video/start 一律返回 should_play=false,领券浮层回到「只放广告」。"""
|
||||||
|
before, after = guide_video.set_video(db, None, admin_id=admin.id, commit=False)
|
||||||
|
write_audit(
|
||||||
|
db, admin, action="guide_video.delete_video", target_type="guide_video", target_id=None,
|
||||||
|
detail={"before": before.get("video_url")}, ip=get_client_ip(request), commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
media.delete_guide_video(before.get("video_url"))
|
||||||
|
return _out(db)
|
||||||
@@ -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);本路由是运营后台
|
数据由客户端 POST /api/v1/report 写入 price_report 表(提交即 pending);本路由是运营后台
|
||||||
对它的人工审核窗口。**通过** → 给上报用户钱包发固定金币(PRICE_REPORT_REWARD_COINS):
|
对它的人工审核窗口。**通过** → 给上报用户钱包发固定金币(PRICE_REPORT_REWARD_COINS):
|
||||||
改状态 + 发金币(wallet.grant_coins)+ 审计同一事务一起 commit(原子,仿 users.grant_user_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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -16,6 +17,10 @@ from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_ro
|
|||||||
from app.admin.repositories import mutations, queries
|
from app.admin.repositories import mutations, queries
|
||||||
from app.admin.schemas.common import CursorPage, OkResponse
|
from app.admin.schemas.common import CursorPage, OkResponse
|
||||||
from app.admin.schemas.price_report import (
|
from app.admin.schemas.price_report import (
|
||||||
|
PriceReportBulkItemResult,
|
||||||
|
PriceReportBulkRejectRequest,
|
||||||
|
PriceReportBulkRequest,
|
||||||
|
PriceReportBulkResult,
|
||||||
PriceReportOut,
|
PriceReportOut,
|
||||||
PriceReportRejectRequest,
|
PriceReportRejectRequest,
|
||||||
PriceReportSummary,
|
PriceReportSummary,
|
||||||
@@ -24,6 +29,7 @@ from app.core.rewards import PRICE_REPORT_REWARD_COINS
|
|||||||
from app.models.admin import AdminUser
|
from app.models.admin import AdminUser
|
||||||
from app.models.price_report import PriceReport
|
from app.models.price_report import PriceReport
|
||||||
from app.repositories import wallet as wallet_repo
|
from app.repositories import wallet as wallet_repo
|
||||||
|
from app.services import notification_events
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/admin/api/price-reports",
|
prefix="/admin/api/price-reports",
|
||||||
@@ -32,6 +38,59 @@ router = APIRouter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _approve_price_report(
|
||||||
|
db: AdminDb, admin: AdminUser, report_id: int, ip: str, *, bulk: bool = False
|
||||||
|
) -> PriceReport:
|
||||||
|
rep = db.get(PriceReport, report_id, with_for_update=True)
|
||||||
|
if rep is None:
|
||||||
|
raise HTTPException(status_code=404, detail="上报记录不存在")
|
||||||
|
if rep.status != "pending":
|
||||||
|
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
|
||||||
|
coins = PRICE_REPORT_REWARD_COINS
|
||||||
|
mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False)
|
||||||
|
wallet_repo.grant_coins(
|
||||||
|
db, rep.user_id, coins,
|
||||||
|
biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过",
|
||||||
|
)
|
||||||
|
detail = {"reward_coins": coins, "user_id": rep.user_id}
|
||||||
|
if bulk:
|
||||||
|
detail["bulk"] = True
|
||||||
|
write_audit(
|
||||||
|
db, admin, action="price_report.approve", target_type="price_report", target_id=report_id,
|
||||||
|
detail=detail, ip=ip, commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
notification_events.notify_report_approved(db, rep)
|
||||||
|
return rep
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_price_report(
|
||||||
|
db: AdminDb, admin: AdminUser, report_id: int, reason: str, ip: str, *, bulk: bool = False
|
||||||
|
) -> PriceReport:
|
||||||
|
rep = db.get(PriceReport, report_id, with_for_update=True)
|
||||||
|
if rep is None:
|
||||||
|
raise HTTPException(status_code=404, detail="上报记录不存在")
|
||||||
|
if rep.status != "pending":
|
||||||
|
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
|
||||||
|
mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False)
|
||||||
|
detail = {"reason": reason, "user_id": rep.user_id}
|
||||||
|
if bulk:
|
||||||
|
detail["bulk"] = True
|
||||||
|
write_audit(
|
||||||
|
db, admin, action="price_report.reject", target_type="price_report", target_id=report_id,
|
||||||
|
detail=detail, ip=ip, commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return rep
|
||||||
|
|
||||||
|
|
||||||
|
def _bulk_result(items: list[PriceReportBulkItemResult]) -> PriceReportBulkResult:
|
||||||
|
success = sum(1 for item in items if item.ok)
|
||||||
|
return PriceReportBulkResult(
|
||||||
|
total=len(items), success=success, failed=len(items) - success, items=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)")
|
@router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)")
|
||||||
def list_price_reports(
|
def list_price_reports(
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
@@ -58,6 +117,50 @@ def price_report_summary(db: AdminDb) -> PriceReportSummary:
|
|||||||
return PriceReportSummary.model_validate(queries.price_report_summary(db))
|
return PriceReportSummary.model_validate(queries.price_report_summary(db))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk/approve", response_model=PriceReportBulkResult, summary="批量通过上报(发固定金币)")
|
||||||
|
def bulk_approve_price_reports(
|
||||||
|
body: PriceReportBulkRequest,
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> PriceReportBulkResult:
|
||||||
|
results: list[PriceReportBulkItemResult] = []
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
for report_id in body.ids:
|
||||||
|
try:
|
||||||
|
rep = _approve_price_report(db, admin, report_id, ip, bulk=True)
|
||||||
|
results.append(PriceReportBulkItemResult(id=report_id, ok=True, status=rep.status))
|
||||||
|
except HTTPException as exc:
|
||||||
|
db.rollback()
|
||||||
|
results.append(PriceReportBulkItemResult(id=report_id, ok=False, error=str(exc.detail)))
|
||||||
|
except Exception: # noqa: BLE001 - 单笔失败不打断整批
|
||||||
|
db.rollback()
|
||||||
|
results.append(PriceReportBulkItemResult(id=report_id, ok=False, error="系统异常"))
|
||||||
|
return _bulk_result(results)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk/reject", response_model=PriceReportBulkResult, summary="批量拒绝上报")
|
||||||
|
def bulk_reject_price_reports(
|
||||||
|
body: PriceReportBulkRejectRequest,
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> PriceReportBulkResult:
|
||||||
|
results: list[PriceReportBulkItemResult] = []
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
for report_id in body.ids:
|
||||||
|
try:
|
||||||
|
rep = _reject_price_report(db, admin, report_id, body.reason, ip, bulk=True)
|
||||||
|
results.append(PriceReportBulkItemResult(id=report_id, ok=True, status=rep.status))
|
||||||
|
except HTTPException as exc:
|
||||||
|
db.rollback()
|
||||||
|
results.append(PriceReportBulkItemResult(id=report_id, ok=False, error=str(exc.detail)))
|
||||||
|
except Exception: # noqa: BLE001 - 单笔失败不打断整批
|
||||||
|
db.rollback()
|
||||||
|
results.append(PriceReportBulkItemResult(id=report_id, ok=False, error="系统异常"))
|
||||||
|
return _bulk_result(results)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)")
|
@router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)")
|
||||||
def approve_price_report(
|
def approve_price_report(
|
||||||
report_id: int,
|
report_id: int,
|
||||||
@@ -65,25 +168,7 @@ def approve_price_report(
|
|||||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
) -> OkResponse:
|
) -> OkResponse:
|
||||||
# 行锁(SELECT FOR UPDATE):并发/连点双请求会都读到 pending → 各发一次金币双倍发奖,
|
_approve_price_report(db, admin, report_id, get_client_ip(request))
|
||||||
# 锁住该行串行化,第二个请求拿锁后看到 approved → 走 400。SQLite 下 FOR UPDATE 为 no-op。
|
|
||||||
rep = db.get(PriceReport, report_id, with_for_update=True)
|
|
||||||
if rep is None:
|
|
||||||
raise HTTPException(status_code=404, detail="上报记录不存在")
|
|
||||||
if rep.status != "pending":
|
|
||||||
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
|
|
||||||
coins = PRICE_REPORT_REWARD_COINS
|
|
||||||
# 改状态 + 发金币 + 审计同一事务(commit=False),最后一起 commit:改了就有痕、发了就留账
|
|
||||||
mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False)
|
|
||||||
wallet_repo.grant_coins(
|
|
||||||
db, rep.user_id, coins,
|
|
||||||
biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过",
|
|
||||||
)
|
|
||||||
write_audit(
|
|
||||||
db, admin, action="price_report.approve", target_type="price_report", target_id=report_id,
|
|
||||||
detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
return OkResponse()
|
return OkResponse()
|
||||||
|
|
||||||
|
|
||||||
@@ -95,16 +180,5 @@ def reject_price_report(
|
|||||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
) -> OkResponse:
|
) -> OkResponse:
|
||||||
rep = db.get(PriceReport, report_id, with_for_update=True) # 行锁,同 approve(防并发重复审核)
|
_reject_price_report(db, admin, report_id, body.reason, get_client_ip(request))
|
||||||
if rep is None:
|
|
||||||
raise HTTPException(status_code=404, detail="上报记录不存在")
|
|
||||||
if rep.status != "pending":
|
|
||||||
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
|
|
||||||
reason = body.reason.strip()
|
|
||||||
mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False)
|
|
||||||
write_audit(
|
|
||||||
db, admin, action="price_report.reject", target_type="price_report", target_id=report_id,
|
|
||||||
detail={"reason": reason, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
return OkResponse()
|
return OkResponse()
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
"""风控监控:统计、风险事件列表/详情及忽略、封禁、解封。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
|
|
||||||
|
from app.admin.audit import write_audit
|
||||||
|
from app.admin.deps import (
|
||||||
|
AdminDb,
|
||||||
|
get_client_ip,
|
||||||
|
require_page,
|
||||||
|
require_role,
|
||||||
|
)
|
||||||
|
from app.admin.repositories import risk_monitor as repo
|
||||||
|
from app.admin.schemas.risk_monitor import (
|
||||||
|
RestrictionRevokeResponse,
|
||||||
|
RiskActionRequest,
|
||||||
|
RiskActionResponse,
|
||||||
|
RiskDetailPage,
|
||||||
|
RiskIncidentPage,
|
||||||
|
RiskMonitorSummary,
|
||||||
|
RiskResetResponse,
|
||||||
|
RiskRuleConfig,
|
||||||
|
)
|
||||||
|
from app.core.config_schema import (
|
||||||
|
RISK_COMPARE_DAILY_THRESHOLD_KEY,
|
||||||
|
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
|
||||||
|
RISK_SMS_HOURLY_THRESHOLD_KEY,
|
||||||
|
)
|
||||||
|
from app.models.risk import RiskIncident, SubjectRestriction
|
||||||
|
from app.repositories import app_config
|
||||||
|
from app.repositories import risk as risk_repo
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/admin/api/risk-monitor",
|
||||||
|
tags=["admin-risk-monitor"],
|
||||||
|
dependencies=[Depends(require_page("risk-monitor"))],
|
||||||
|
)
|
||||||
|
|
||||||
|
RiskKind = Literal["sms", "oneclick", "compare"]
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_config(db) -> RiskRuleConfig:
|
||||||
|
return RiskRuleConfig(
|
||||||
|
sms_hourly_threshold=risk_repo.get_rule_threshold(
|
||||||
|
db, risk_repo.RULE_SMS_HOURLY
|
||||||
|
),
|
||||||
|
oneclick_daily_threshold=risk_repo.get_rule_threshold(
|
||||||
|
db, risk_repo.RULE_ONECLICK_DAILY
|
||||||
|
),
|
||||||
|
compare_daily_threshold=risk_repo.get_rule_threshold(
|
||||||
|
db, risk_repo.RULE_COMPARE_DAILY
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _incident_or_404(db, incident_id: int, kind: RiskKind | None = None) -> RiskIncident:
|
||||||
|
incident = db.get(RiskIncident, incident_id)
|
||||||
|
if incident is None:
|
||||||
|
raise HTTPException(status_code=404, detail="风险事件不存在")
|
||||||
|
if kind and incident.rule_code != repo.KIND_TO_RULE[kind]:
|
||||||
|
raise HTTPException(status_code=404, detail="风险事件类型不匹配")
|
||||||
|
return incident
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=RiskMonitorSummary, summary="风控监控顶部卡片")
|
||||||
|
def get_summary(db: AdminDb) -> RiskMonitorSummary:
|
||||||
|
return RiskMonitorSummary(**repo.summary(db))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rules", response_model=RiskRuleConfig, summary="读取风控规则阈值")
|
||||||
|
def get_rules(db: AdminDb) -> RiskRuleConfig:
|
||||||
|
return _rule_config(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/rules", response_model=RiskRuleConfig, summary="修改风控规则并即时重算")
|
||||||
|
def update_rules(
|
||||||
|
body: RiskRuleConfig,
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[object, Depends(require_role("operator", "tech"))],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> RiskRuleConfig:
|
||||||
|
before = _rule_config(db).model_dump()
|
||||||
|
after = body.model_dump()
|
||||||
|
values = (
|
||||||
|
(RISK_SMS_HOURLY_THRESHOLD_KEY, body.sms_hourly_threshold),
|
||||||
|
(RISK_ONECLICK_DAILY_THRESHOLD_KEY, body.oneclick_daily_threshold),
|
||||||
|
(RISK_COMPARE_DAILY_THRESHOLD_KEY, body.compare_daily_threshold),
|
||||||
|
)
|
||||||
|
for key, value in values:
|
||||||
|
app_config.set_value(
|
||||||
|
db, key, value, admin_id=admin.id, commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
now = risk_repo.utcnow()
|
||||||
|
risk_repo.reconcile_behavior_rule(
|
||||||
|
db, rule_code=risk_repo.RULE_SMS_HOURLY, at=now, commit=False
|
||||||
|
)
|
||||||
|
risk_repo.reconcile_behavior_rule(
|
||||||
|
db, rule_code=risk_repo.RULE_ONECLICK_DAILY, at=now, commit=False
|
||||||
|
)
|
||||||
|
risk_repo.reconcile_compare_rule(db, at=now, commit=False)
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="risk.rules.update",
|
||||||
|
target_type="risk_rule",
|
||||||
|
target_id="all",
|
||||||
|
detail={"before": before, "after": after},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _rule_config(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/reset",
|
||||||
|
response_model=RiskResetResponse,
|
||||||
|
summary="重置三类待处理报警并从零重新累计",
|
||||||
|
)
|
||||||
|
def reset_alerts(
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[object, Depends(require_role("operator", "tech"))],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> RiskResetResponse:
|
||||||
|
reset_at = risk_repo.utcnow()
|
||||||
|
counts = repo.reset_open_alerts(db, admin_id=admin.id, reset_at=reset_at)
|
||||||
|
total = sum(counts.values())
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="risk.alerts.reset",
|
||||||
|
target_type="risk_monitor",
|
||||||
|
target_id="all",
|
||||||
|
detail={
|
||||||
|
"reset_at": reset_at.isoformat(),
|
||||||
|
"reset_incident_count": total,
|
||||||
|
"reset_counts": counts,
|
||||||
|
"restrictions_changed": False,
|
||||||
|
},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return RiskResetResponse(
|
||||||
|
reset_at=reset_at,
|
||||||
|
reset_incident_count=total,
|
||||||
|
reset_counts=counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/incidents/{kind}",
|
||||||
|
response_model=RiskIncidentPage,
|
||||||
|
summary="按短信/一键登录/比价读取风险事件",
|
||||||
|
)
|
||||||
|
def get_incidents(
|
||||||
|
kind: RiskKind,
|
||||||
|
db: AdminDb,
|
||||||
|
incident_status: Annotated[
|
||||||
|
Literal["open", "ignored", "blocked", "resolved", "all"], Query(alias="status")
|
||||||
|
] = "open",
|
||||||
|
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
|
cursor: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
) -> RiskIncidentPage:
|
||||||
|
if kind == "compare":
|
||||||
|
repo.sync_today_compare_incidents(db)
|
||||||
|
items, next_cursor, total = repo.list_incidents(
|
||||||
|
db, kind=kind, status=incident_status, limit=limit, cursor=cursor
|
||||||
|
)
|
||||||
|
return RiskIncidentPage(items=items, next_cursor=next_cursor, total=total)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/incidents/{kind}/{incident_id}/details",
|
||||||
|
response_model=RiskDetailPage,
|
||||||
|
summary="风险事件当期事实明细",
|
||||||
|
)
|
||||||
|
def get_incident_details(
|
||||||
|
kind: RiskKind,
|
||||||
|
incident_id: int,
|
||||||
|
db: AdminDb,
|
||||||
|
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
|
cursor: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
) -> RiskDetailPage:
|
||||||
|
incident = _incident_or_404(db, incident_id, kind)
|
||||||
|
return RiskDetailPage(
|
||||||
|
**repo.incident_details(
|
||||||
|
db, incident=incident, kind=kind, limit=limit, cursor=cursor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/incidents/{incident_id}/ignore",
|
||||||
|
response_model=RiskActionResponse,
|
||||||
|
summary="忽略风险事件",
|
||||||
|
)
|
||||||
|
def ignore_incident(
|
||||||
|
incident_id: int,
|
||||||
|
body: RiskActionRequest,
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[object, Depends(require_role("operator", "tech"))],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> RiskActionResponse:
|
||||||
|
incident = _incident_or_404(db, incident_id)
|
||||||
|
if incident.status != "open":
|
||||||
|
raise HTTPException(status_code=409, detail="该风险事件已处理")
|
||||||
|
incident.status = "ignored"
|
||||||
|
incident.action_reason = body.reason.strip() or "管理员确认忽略"
|
||||||
|
incident.handled_by = admin.id
|
||||||
|
incident.handled_at = risk_repo.utcnow()
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="risk.incident.ignore",
|
||||||
|
target_type="risk_incident",
|
||||||
|
target_id=incident.id,
|
||||||
|
detail={
|
||||||
|
"rule_code": incident.rule_code,
|
||||||
|
"subject_type": incident.subject_type,
|
||||||
|
"subject_id": incident.subject_id,
|
||||||
|
"reason": incident.action_reason,
|
||||||
|
},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return RiskActionResponse(incident_id=incident.id, status=incident.status)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/incidents/{incident_id}/block",
|
||||||
|
response_model=RiskActionResponse,
|
||||||
|
summary="封禁风险主体",
|
||||||
|
)
|
||||||
|
def block_incident(
|
||||||
|
incident_id: int,
|
||||||
|
body: RiskActionRequest,
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[object, Depends(require_role("operator", "tech"))],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> RiskActionResponse:
|
||||||
|
incident = _incident_or_404(db, incident_id)
|
||||||
|
if incident.status not in ("open", "blocked"):
|
||||||
|
raise HTTPException(status_code=409, detail="该风险事件已处理")
|
||||||
|
if incident.subject_type == "device":
|
||||||
|
if incident.subject_id.startswith("legacy-ip:"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="旧客户端未上报设备ID,不能执行设备封禁",
|
||||||
|
)
|
||||||
|
scope = risk_repo.SCOPE_AUTH_DEVICE
|
||||||
|
elif incident.subject_type == "user":
|
||||||
|
scope = risk_repo.SCOPE_ECONOMIC_ACCOUNT
|
||||||
|
else: # pragma: no cover - 当前三条规则只会生成 device/user
|
||||||
|
raise HTTPException(status_code=400, detail="该主体类型暂不支持封禁")
|
||||||
|
|
||||||
|
reason = body.reason.strip() or "风控规则命中"
|
||||||
|
restriction = risk_repo.activate_restriction(
|
||||||
|
db,
|
||||||
|
subject_type=incident.subject_type,
|
||||||
|
subject_id=incident.subject_id,
|
||||||
|
scope=scope,
|
||||||
|
reason=reason,
|
||||||
|
incident_id=incident.id,
|
||||||
|
admin_id=admin.id,
|
||||||
|
)
|
||||||
|
incident.status = "blocked"
|
||||||
|
incident.action_reason = reason
|
||||||
|
incident.handled_by = admin.id
|
||||||
|
incident.handled_at = risk_repo.utcnow()
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="risk.subject.block",
|
||||||
|
target_type=incident.subject_type,
|
||||||
|
target_id=incident.subject_id,
|
||||||
|
detail={
|
||||||
|
"incident_id": incident.id,
|
||||||
|
"rule_code": incident.rule_code,
|
||||||
|
"scope": scope,
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return RiskActionResponse(
|
||||||
|
incident_id=incident.id,
|
||||||
|
status=incident.status,
|
||||||
|
restriction_id=restriction.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/restrictions/{restriction_id}/revoke",
|
||||||
|
response_model=RestrictionRevokeResponse,
|
||||||
|
summary="解除主体限制",
|
||||||
|
)
|
||||||
|
def revoke_restriction(
|
||||||
|
restriction_id: int,
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[object, Depends(require_role("operator", "tech"))],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> RestrictionRevokeResponse:
|
||||||
|
restriction = db.get(SubjectRestriction, restriction_id)
|
||||||
|
if restriction is None:
|
||||||
|
raise HTTPException(status_code=404, detail="限制记录不存在")
|
||||||
|
if restriction.active:
|
||||||
|
risk_repo.revoke_restriction(db, restriction=restriction, admin_id=admin.id)
|
||||||
|
if restriction.incident_id:
|
||||||
|
incident = db.get(RiskIncident, restriction.incident_id)
|
||||||
|
if incident and incident.status == "blocked":
|
||||||
|
incident.status = "resolved"
|
||||||
|
incident.handled_by = admin.id
|
||||||
|
incident.handled_at = risk_repo.utcnow()
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="risk.subject.unblock",
|
||||||
|
target_type=restriction.subject_type,
|
||||||
|
target_id=restriction.subject_id,
|
||||||
|
detail={"restriction_id": restriction.id, "scope": restriction.scope},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return RestrictionRevokeResponse(restriction_id=restriction.id)
|
||||||
@@ -129,13 +129,11 @@ def withdraw_health_check(db: AdminDb) -> WxpayHealthCheckOut:
|
|||||||
issues.append("免确认授权回调地址未配置")
|
issues.append("免确认授权回调地址未配置")
|
||||||
|
|
||||||
# 实际是否自动对账 = env 部署总闸(worker 起没起)AND 运营后台 DB 开关(本轮跑不跑)。
|
# 实际是否自动对账 = env 部署总闸(worker 起没起)AND 运营后台 DB 开关(本轮跑不跑)。
|
||||||
|
# 自动查单属于非阻断运维能力:状态继续返回给调用方,但关闭时不计入微信提现配置 issues,
|
||||||
|
# 避免把“没有自动扫单”误报成“无法打款”。
|
||||||
worker_running = settings.WITHDRAW_AUTO_RECONCILE_ENABLED
|
worker_running = settings.WITHDRAW_AUTO_RECONCILE_ENABLED
|
||||||
daily_on = bool(app_config.get_value(db, "withdraw_auto_reconcile_enabled"))
|
daily_on = bool(app_config.get_value(db, "withdraw_auto_reconcile_enabled"))
|
||||||
auto_reconcile_enabled = worker_running and daily_on
|
auto_reconcile_enabled = worker_running and daily_on
|
||||||
if not worker_running:
|
|
||||||
issues.append("自动对账 worker 未启动(部署侧 env WITHDRAW_AUTO_RECONCILE_ENABLED=false)")
|
|
||||||
elif not daily_on:
|
|
||||||
issues.append("自动对账运营开关已关闭(系统配置页可开)")
|
|
||||||
|
|
||||||
return WxpayHealthCheckOut(
|
return WxpayHealthCheckOut(
|
||||||
ok=not issues,
|
ok=not issues,
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ class AdRevenueRecord(BaseModel):
|
|||||||
|
|
||||||
record_id: int
|
record_id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
status: str = Field(..., description="granted / capped / ecpm_missing")
|
status: str = Field(
|
||||||
|
...,
|
||||||
|
description="granted / capped / ecpm_missing / closed_early / too_short",
|
||||||
|
)
|
||||||
ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示)")
|
ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示)")
|
||||||
ecpm_factor: float | None = Field(None, description="因子1(eCPM 档);非 granted 为空")
|
ecpm_factor: float | None = Field(None, description="因子1(eCPM 档);非 granted 为空")
|
||||||
units: int = Field(..., description="折算份数:激励视频恒 1;信息流 = 满 10 秒份数")
|
units: int = Field(..., description="折算份数:激励视频恒 1;信息流 = 满 10 秒份数")
|
||||||
@@ -44,7 +47,7 @@ class AdRevenueDaily(BaseModel):
|
|||||||
|
|
||||||
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
||||||
impressions: int = Field(..., description="当天展示条数合计")
|
impressions: int = Field(..., description="当天展示条数合计")
|
||||||
revenue_yuan: float = Field(..., description="当天客户端预估收益合计(元;eCPM 折算)")
|
revenue_yuan: float = Field(..., description="当天客户端有效预估收益合计(元;eCPM 折算)")
|
||||||
pangle_revenue_yuan: float | None = Field(
|
pangle_revenue_yuan: float | None = Field(
|
||||||
None, description="当天穿山甲后台预估收益(元;GroMore revenue);非全量视图/无数据为空"
|
None, description="当天穿山甲后台预估收益(元;GroMore revenue);非全量视图/无数据为空"
|
||||||
)
|
)
|
||||||
@@ -93,7 +96,10 @@ class AdRevenueRow(BaseModel):
|
|||||||
has_impression: bool = Field(..., description="是否有广告展示(信息流逐条展示=True,纯发奖行=False)")
|
has_impression: bool = Field(..., description="是否有广告展示(信息流逐条展示=True,纯发奖行=False)")
|
||||||
impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)")
|
impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)")
|
||||||
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
|
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
|
||||||
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000;纯发奖行=0")
|
revenue_yuan: float = Field(
|
||||||
|
...,
|
||||||
|
description="本次有效展示预估收益(元)= eCPM元 ÷ 1000;纯发奖、激励视频提前关闭/时长不足=0",
|
||||||
|
)
|
||||||
row_revenue_yuan: float | None = Field(
|
row_revenue_yuan: float | None = Field(
|
||||||
None,
|
None,
|
||||||
description="主表逐行展示用的预估收益(元):一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;"
|
description="主表逐行展示用的预估收益(元):一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;"
|
||||||
@@ -150,7 +156,7 @@ class AdRevenueReportOut(BaseModel):
|
|||||||
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
|
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
|
||||||
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
|
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
|
||||||
total_impressions: int = Field(..., description="全量展示条数合计")
|
total_impressions: int = Field(..., description="全量展示条数合计")
|
||||||
total_revenue_yuan: float = Field(..., description="全量客户端预估收益合计(元;eCPM 折算)")
|
total_revenue_yuan: float = Field(..., description="全量客户端有效预估收益合计(元;eCPM 折算)")
|
||||||
total_pangle_revenue_yuan: float | None = Field(
|
total_pangle_revenue_yuan: float | None = Field(
|
||||||
None,
|
None,
|
||||||
description="全量穿山甲后台预估收益合计(元;GroMore revenue)。穿山甲无用户/类型/场景维度,"
|
description="全量穿山甲后台预估收益合计(元;GroMore revenue)。穿山甲无用户/类型/场景维度,"
|
||||||
|
|||||||
@@ -13,14 +13,18 @@ class AdminCreateRequest(BaseModel):
|
|||||||
username: str = Field(..., min_length=3, max_length=64)
|
username: str = Field(..., min_length=3, max_length=64)
|
||||||
password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节
|
password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节
|
||||||
role: str = Field("operator", min_length=1, max_length=32)
|
role: str = Field("operator", min_length=1, max_length=32)
|
||||||
|
# 仅当 role == "custom":这个人专属可见页 key 列表(逐页勾选结果)。其余角色不传/忽略。
|
||||||
|
pages_override: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class AdminUpdateRequest(BaseModel):
|
class AdminUpdateRequest(BaseModel):
|
||||||
"""改角色 / 启用禁用 / 重置密码,字段都可选(只改传了的)。"""
|
"""改角色 / 启用禁用 / 重置密码 / 自定义可见页,字段都可选(只改传了的)。"""
|
||||||
|
|
||||||
role: str | None = Field(None, min_length=1, max_length=32)
|
role: str | None = Field(None, min_length=1, max_length=32)
|
||||||
status: Literal["active", "disabled"] | None = None
|
status: Literal["active", "disabled"] | None = None
|
||||||
password: str | None = Field(None, min_length=8, max_length=72)
|
password: str | None = Field(None, min_length=8, max_length=72)
|
||||||
|
# 改成/更新「自定义」可见页;role 切回普通角色时后端会清空 override(见路由)。
|
||||||
|
pages_override: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class AdminAuditLogOut(BaseModel):
|
class AdminAuditLogOut(BaseModel):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -21,8 +21,11 @@ class AdminOut(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
last_login_at: datetime | None = None
|
last_login_at: datetime | None = None
|
||||||
# 该管理员当前角色的有效可见页(= 左侧导航项 key);仅登录 / /me 填充,列表接口默认空。
|
# 该管理员当前角色的有效可见页(= 左侧导航项 key);仅登录 / /me 填充,列表接口默认空。
|
||||||
# 前端据此过滤左侧导航(super_admin = 全部页)。见 app/admin/permissions.py。
|
# 前端据此过滤左侧导航(super_admin = 全部页;role=="custom" = pages_override)。见 permissions.py。
|
||||||
pages: list[str] = []
|
pages: list[str] = []
|
||||||
|
# 「自定义」可见页原始勾选集(role=="custom" 时非空);列表接口下发,供权限管理页编辑回填勾选。
|
||||||
|
# 普通角色为 None。from_attributes 自动从 ORM 列取。
|
||||||
|
pages_override: list[str] | None = None
|
||||||
# 明文登录密码:仅「管理员账号列表」(super_admin 专属路由)填充,供权限管理页编辑时复看;
|
# 明文登录密码:仅「管理员账号列表」(super_admin 专属路由)填充,供权限管理页编辑时复看;
|
||||||
# 无留存(脚本建的超管 / 旧账号)为 None → 前端不显示。登录 / /me 不下发(保持 None)。
|
# 无留存(脚本建的超管 / 旧账号)为 None → 前端不显示。登录 / /me 不下发(保持 None)。
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
|
|||||||
@@ -36,14 +36,38 @@ class AdminComparisonListItem(BaseModel):
|
|||||||
retry_count: int | None = None
|
retry_count: int | None = None
|
||||||
input_tokens: int | None = None # Σ usage.prompt_tokens(server 派生)
|
input_tokens: int | None = None # Σ usage.prompt_tokens(server 派生)
|
||||||
output_tokens: int | None = None # Σ usage.completion_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
|
device_model: str | None = None
|
||||||
rom_vendor: str | None = None
|
rom_vendor: str | None = None
|
||||||
rom_name: str | None = None
|
rom_name: str | None = None
|
||||||
android_version: str | None = None
|
android_version: str | None = None
|
||||||
app_version: str | None = None
|
app_version: str | None = None
|
||||||
|
ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上
|
||||||
created_at: datetime
|
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):
|
class AdminComparisonDetail(AdminComparisonListItem):
|
||||||
"""详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。"""
|
"""详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。"""
|
||||||
|
|
||||||
@@ -71,3 +95,5 @@ class AdminComparisonDetail(AdminComparisonListItem):
|
|||||||
# 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读
|
# 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读
|
||||||
# (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。
|
# (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。
|
||||||
raw_payload: dict | None = None
|
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,中位数)")
|
p50_ms: int | None = Field(None, description="耗时 50 分位(ms,中位数)")
|
||||||
p95_ms: int | None = Field(None, description="耗时 95 分位(ms)")
|
p95_ms: int | None = Field(None, description="耗时 95 分位(ms)")
|
||||||
p99_ms: int | None = Field(None, description="耗时 99 分位(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):
|
class CouponDataDaily(BaseModel):
|
||||||
@@ -39,6 +49,15 @@ class CouponDataHourly(BaseModel):
|
|||||||
avg_elapsed_ms: int | None = None
|
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):
|
class CouponDataRow(BaseModel):
|
||||||
"""一条领券明细(一次领券任务)。"""
|
"""一条领券明细(一次领券任务)。"""
|
||||||
|
|
||||||
@@ -59,7 +78,16 @@ class CouponDataRow(BaseModel):
|
|||||||
app_env: str | None = None
|
app_env: str | None = None
|
||||||
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
|
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
|
||||||
claimed_count: int | None = None
|
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")
|
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):
|
class CouponDataOut(BaseModel):
|
||||||
@@ -76,8 +104,34 @@ class CouponDataOut(BaseModel):
|
|||||||
items: list[CouponDataRow] = Field(..., description="逐条领券明细(当前页)")
|
items: list[CouponDataRow] = Field(..., description="逐条领券明细(当前页)")
|
||||||
|
|
||||||
|
|
||||||
|
class CouponPointDetailsOut(BaseModel):
|
||||||
|
"""单次领券任务的逐券点位结果,供点击分数时按需加载。"""
|
||||||
|
|
||||||
|
trace_id: str
|
||||||
|
items: list[CouponPointDetail] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CouponUserRecordsOut(BaseModel):
|
class CouponUserRecordsOut(BaseModel):
|
||||||
"""某用户全部领券记录(点手机号抽屉用):total=该用户领券总次数,items=记录列表(UserRecordsDrawer 渲染)。"""
|
"""某用户全部领券记录(点手机号抽屉用):total=该用户领券总次数,items=记录列表(UserRecordsDrawer 渲染)。"""
|
||||||
|
|
||||||
items: list[CouponDataRow]
|
items: list[CouponDataRow]
|
||||||
total: int
|
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):
|
class DashboardPeriodComparison(BaseModel):
|
||||||
total: int
|
total: int
|
||||||
|
completed: int
|
||||||
|
cancelled: int
|
||||||
success: int
|
success: int
|
||||||
success_rate: float
|
success_rate: float | None = None
|
||||||
ordered: int
|
ordered: int
|
||||||
average_duration_ms: int | None = None
|
average_duration_ms: int | None = None
|
||||||
|
median_duration_ms: int | None = None
|
||||||
|
p95_duration_ms: int | None = None
|
||||||
average_saved_cents: int | None = None
|
average_saved_cents: int | None = None
|
||||||
|
token_cost_total_yuan: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class DashboardPeriodCoupon(BaseModel):
|
class DashboardPeriodCoupon(BaseModel):
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ class DeviceLivenessItem(BaseModel):
|
|||||||
device_model: str | None = None # 由 device_id 解析(device_<机型>_<hash>);非 DB 列
|
device_model: str | None = None # 由 device_id 解析(device_<机型>_<hash>);非 DB 列
|
||||||
platform: str
|
platform: str
|
||||||
app_version: str | None = None
|
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 # 是否开过无障碍(=该设备对功能有意义)
|
ever_protected: bool # 是否开过无障碍(=该设备对功能有意义)
|
||||||
first_protected_at: datetime | None = None # 首次开无障碍时刻(老设备为 null)
|
first_protected_at: datetime | None = None # 首次开无障碍时刻(老设备为 null)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
from app.core.rewards import FEEDBACK_REWARD_MAX_COINS
|
from app.core.rewards import FEEDBACK_REWARD_MAX_COINS
|
||||||
|
|
||||||
@@ -55,6 +55,54 @@ class FeedbackRejectRequest(BaseModel):
|
|||||||
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
|
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackBulkRequest(BaseModel):
|
||||||
|
ids: list[int] = Field(min_length=1, max_length=50, description="待审核反馈 ID 列表")
|
||||||
|
|
||||||
|
@field_validator("ids")
|
||||||
|
@classmethod
|
||||||
|
def _ids_must_be_unique(cls, ids: list[int]) -> list[int]:
|
||||||
|
if len(ids) != len(set(ids)):
|
||||||
|
raise ValueError("反馈 ID 不能重复")
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackBulkApproveRequest(FeedbackBulkRequest):
|
||||||
|
reward_coins: int = Field(
|
||||||
|
ge=1,
|
||||||
|
le=FEEDBACK_REWARD_MAX_COINS,
|
||||||
|
description="每条采纳反馈发放的金币数",
|
||||||
|
)
|
||||||
|
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注(内部)")
|
||||||
|
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackBulkRejectRequest(FeedbackBulkRequest):
|
||||||
|
reason: str = Field(min_length=1, max_length=256, description="批量未采纳原因,用户端可见")
|
||||||
|
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
|
||||||
|
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
|
||||||
|
|
||||||
|
@field_validator("reason")
|
||||||
|
@classmethod
|
||||||
|
def _reason_not_blank(cls, value: str) -> str:
|
||||||
|
if not value.strip():
|
||||||
|
raise ValueError("未采纳原因不能为空")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackBulkItemResult(BaseModel):
|
||||||
|
id: int
|
||||||
|
ok: bool
|
||||||
|
status: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackBulkResult(BaseModel):
|
||||||
|
total: int
|
||||||
|
success: int
|
||||||
|
failed: int
|
||||||
|
items: list[FeedbackBulkItemResult]
|
||||||
|
|
||||||
|
|
||||||
class FeedbackSummary(BaseModel):
|
class FeedbackSummary(BaseModel):
|
||||||
"""审核台顶部各状态计数(pending 含历史 new 态)。"""
|
"""审核台顶部各状态计数(pending 含历史 new 态)。"""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""admin 新手引导视频配置 schemas(开关 / 视频地址 / 前几次 / 每次金币)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.repositories.guide_video import MAX_PLAYS_LIMIT, REWARD_COIN_LIMIT
|
||||||
|
|
||||||
|
|
||||||
|
class GuideVideoConfigOut(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
video_url: str | None = None # 相对地址 /media/guide_video/xxx.mp4;未配片 = None
|
||||||
|
max_plays: int
|
||||||
|
reward_coin: int
|
||||||
|
updated_at: str | None = None
|
||||||
|
# 只读统计,后台展示用:已有多少次播放、其中已发币多少次。
|
||||||
|
total_plays: int = 0
|
||||||
|
granted_plays: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class GuideVideoConfigUpdate(BaseModel):
|
||||||
|
"""部分更新:只改传入(非 None)字段。视频文件走 /video 上传接口。"""
|
||||||
|
|
||||||
|
enabled: bool | None = None
|
||||||
|
max_plays: int | None = Field(default=None, ge=0, le=MAX_PLAYS_LIMIT)
|
||||||
|
reward_coin: int | None = Field(default=None, ge=0, le=REWARD_COIN_LIMIT)
|
||||||
@@ -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"]
|
||||||
@@ -56,6 +56,42 @@ class PriceReportRejectRequest(BaseModel):
|
|||||||
return v.strip()
|
return v.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class PriceReportBulkRequest(BaseModel):
|
||||||
|
ids: list[int] = Field(min_length=1, max_length=50, description="待审核上报 ID 列表")
|
||||||
|
|
||||||
|
@field_validator("ids")
|
||||||
|
@classmethod
|
||||||
|
def _ids_must_be_unique(cls, ids: list[int]) -> list[int]:
|
||||||
|
if len(ids) != len(set(ids)):
|
||||||
|
raise ValueError("上报 ID 不能重复")
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
class PriceReportBulkRejectRequest(PriceReportBulkRequest):
|
||||||
|
reason: str = Field(min_length=1, max_length=256, description="批量拒绝理由,用户端记录页会看到")
|
||||||
|
|
||||||
|
@field_validator("reason")
|
||||||
|
@classmethod
|
||||||
|
def _reason_not_blank(cls, value: str) -> str:
|
||||||
|
if not value.strip():
|
||||||
|
raise ValueError("拒绝理由不能为空")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class PriceReportBulkItemResult(BaseModel):
|
||||||
|
id: int
|
||||||
|
ok: bool
|
||||||
|
status: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PriceReportBulkResult(BaseModel):
|
||||||
|
total: int
|
||||||
|
success: int
|
||||||
|
failed: int
|
||||||
|
items: list[PriceReportBulkItemResult]
|
||||||
|
|
||||||
|
|
||||||
class PriceReportSummary(BaseModel):
|
class PriceReportSummary(BaseModel):
|
||||||
"""审核台顶部各状态计数。"""
|
"""审核台顶部各状态计数。"""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""风控监控后台接口 schema。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSummaryCard(BaseModel):
|
||||||
|
kind: str
|
||||||
|
alert_subject_count: int
|
||||||
|
today_total: int
|
||||||
|
threshold: int
|
||||||
|
rule_text: str
|
||||||
|
|
||||||
|
|
||||||
|
class RiskMonitorSummary(BaseModel):
|
||||||
|
date: str
|
||||||
|
updated_at: datetime
|
||||||
|
cards: list[RiskSummaryCard]
|
||||||
|
|
||||||
|
|
||||||
|
class RiskRuleConfig(BaseModel):
|
||||||
|
sms_hourly_threshold: int = Field(ge=1, le=5)
|
||||||
|
oneclick_daily_threshold: int = Field(ge=1, le=100_000)
|
||||||
|
compare_daily_threshold: int = Field(ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class RiskIncidentItem(BaseModel):
|
||||||
|
incident_id: int
|
||||||
|
kind: str
|
||||||
|
subject_type: str
|
||||||
|
subject_id: str
|
||||||
|
device_model: str | None = None
|
||||||
|
first_used_at: datetime | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
user_id: int | None = None
|
||||||
|
registered_at: datetime | None = None
|
||||||
|
common_device_id: str | None = None
|
||||||
|
window_start: datetime
|
||||||
|
window_end: datetime
|
||||||
|
first_event_at: datetime
|
||||||
|
triggered_at: datetime
|
||||||
|
last_event_at: datetime
|
||||||
|
event_count: int
|
||||||
|
status: str
|
||||||
|
restricted: bool = False
|
||||||
|
restriction_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RiskIncidentPage(BaseModel):
|
||||||
|
items: list[RiskIncidentItem]
|
||||||
|
next_cursor: int | None = None
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class RiskDetailItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
occurred_at: datetime
|
||||||
|
outcome: str
|
||||||
|
phone: str | None = None
|
||||||
|
user_id: int | None = None
|
||||||
|
username: str | None = None
|
||||||
|
account_registered_at: datetime | None = None
|
||||||
|
verified: bool | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
store_name: str | None = None
|
||||||
|
product_names: str | None = None
|
||||||
|
prices: dict[str, float | None] | None = None
|
||||||
|
saved_amount_yuan: float | None = None
|
||||||
|
status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RiskDetailPage(BaseModel):
|
||||||
|
kind: str
|
||||||
|
incident_id: int
|
||||||
|
event_count: int
|
||||||
|
distinct_accounts: int | None = None
|
||||||
|
items: list[RiskDetailItem]
|
||||||
|
next_cursor: int | None = None
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class RiskActionRequest(BaseModel):
|
||||||
|
reason: str = Field("", max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class RiskActionResponse(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
|
incident_id: int
|
||||||
|
status: str
|
||||||
|
restriction_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RiskResetResponse(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
|
reset_at: datetime
|
||||||
|
reset_incident_count: int
|
||||||
|
reset_counts: dict[str, int]
|
||||||
|
|
||||||
|
|
||||||
|
class RestrictionRevokeResponse(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
|
restriction_id: int
|
||||||
+75
-79
@@ -3,6 +3,8 @@
|
|||||||
路由前缀 `/api/v1/ad`:
|
路由前缀 `/api/v1/ad`:
|
||||||
GET /pangle-callback 穿山甲 S2S 发奖回调(**无 JWT,靠验签**),穿山甲服务器调
|
GET /pangle-callback 穿山甲 S2S 发奖回调(**无 JWT,靠验签**),穿山甲服务器调
|
||||||
GET /reward-status 客户端查今日看广告发奖进度(Bearer)
|
GET /reward-status 客户端查今日看广告发奖进度(Bearer)
|
||||||
|
GET /reward-result/{ad_session_id}
|
||||||
|
客户端按会话查本次广告实发金币(Bearer,只读,弹窗金额用)
|
||||||
|
|
||||||
发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币。客户端只负责
|
发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币。客户端只负责
|
||||||
看完后刷新余额,不参与发奖,被破解也刷不到钱。
|
看完后刷新余额,不参与发奖,被破解也刷不到钱。
|
||||||
@@ -13,7 +15,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
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.api.deps import CurrentUser, DbSession
|
||||||
from app.core import rewards
|
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_reward as crud_ad
|
||||||
from app.repositories import ad_watch as crud_watch
|
from app.repositories import ad_watch as crud_watch
|
||||||
from app.repositories import app_config
|
from app.repositories import app_config
|
||||||
from app.repositories import signin as crud_signin
|
|
||||||
from app.schemas.ad import (
|
from app.schemas.ad import (
|
||||||
|
AdRewardResultOut,
|
||||||
AdRewardStatusOut,
|
AdRewardStatusOut,
|
||||||
EcpmReportIn,
|
EcpmReportIn,
|
||||||
EcpmReportOut,
|
EcpmReportOut,
|
||||||
@@ -52,11 +54,14 @@ REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字
|
|||||||
REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
|
REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
|
||||||
|
|
||||||
REWARD_SCENE_REWARD_VIDEO = "reward_video"
|
REWARD_SCENE_REWARD_VIDEO = "reward_video"
|
||||||
REWARD_SCENE_SIGNIN_BOOST = "signin_boost"
|
|
||||||
# 提现看视频:看完才能提现的「硬门槛」广告,**不发金币**,只记一条幂等记录(收益由 eCPM 上报口径
|
# 提现看视频:看完才能提现的「硬门槛」广告,**不发金币**,只记一条幂等记录(收益由 eCPM 上报口径
|
||||||
# ad_type="withdrawal_video" 单独统计)。故意不放进 SUPPORTED_REWARD_SCENES——它不走发币分支。
|
# ad_type="withdrawal_video" 单独统计)。故意不放进 SUPPORTED_REWARD_SCENES——它不走发币分支。
|
||||||
REWARD_SCENE_WITHDRAWAL_AD = "withdrawal_ad"
|
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]:
|
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)))
|
extra.update(_parse_extra(params.get(extra_key)))
|
||||||
reward_scene = extra.get("reward_scene") or REWARD_SCENE_REWARD_VIDEO
|
reward_scene = extra.get("reward_scene") or REWARD_SCENE_REWARD_VIDEO
|
||||||
ad_session_id = extra.get("ad_session_id")
|
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")
|
ecpm = params.get("ecpm")
|
||||||
|
|
||||||
# 环境隔离:激励视频 mediaExtra 里带「这次观看属于哪个后端环境」(srv_env=dev/prod,客户端按
|
# 环境隔离:激励视频 mediaExtra 里带「这次观看属于哪个后端环境」(srv_env=dev/prod,客户端按
|
||||||
@@ -169,48 +179,9 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
|||||||
user_id, trans_id, reward_scene,
|
user_id, trans_id, reward_scene,
|
||||||
)
|
)
|
||||||
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
|
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(
|
rec = crud_ad.grant_ad_reward(
|
||||||
db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id,
|
db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id,
|
||||||
reward_scene=REWARD_SCENE_REWARD_VIDEO,
|
reward_scene=REWARD_SCENE_REWARD_VIDEO, boost_round_id=boost_round_id,
|
||||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||||
)
|
)
|
||||||
except crud_ad.UnknownUserError:
|
except crud_ad.UnknownUserError:
|
||||||
@@ -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(
|
@router.post(
|
||||||
"/watch-report",
|
"/watch-report",
|
||||||
response_model=WatchReportOut,
|
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,
|
Bearer 鉴权,user_id 取自 JWT(不信 body)。best-effort:落库即 ok,客户端 fire-and-forget,
|
||||||
丢一两条不影响业务(穿山甲后台报表是结算权威)。eCPM 与发奖(S2S)是两条独立流,不逐条关联。
|
丢一两条不影响业务(穿山甲后台报表是结算权威)。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(
|
crud_ecpm.create_ecpm_record(
|
||||||
db, user.id,
|
db, user.id,
|
||||||
ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
|
ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
|
||||||
ad_session_id=payload.ad_session_id,
|
ad_session_id=payload.ad_session_id,
|
||||||
adn=payload.adn, slot_id=payload.slot_id,
|
adn=payload.adn, slot_id=payload.slot_id,
|
||||||
feed_scene=payload.feed_scene,
|
feed_scene=payload.feed_scene,
|
||||||
|
trace_id=attributed_trace_id,
|
||||||
app_env=payload.app_env, our_code_id=payload.our_code_id,
|
app_env=payload.app_env, our_code_id=payload.our_code_id,
|
||||||
|
exposure_ms=payload.exposure_ms,
|
||||||
)
|
)
|
||||||
logger.info(
|
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,
|
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)
|
return EcpmReportOut(ok=True)
|
||||||
|
|
||||||
@@ -316,40 +340,11 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
|
|||||||
if reward_scene not in SUPPORTED_REWARD_SCENES:
|
if reward_scene not in SUPPORTED_REWARD_SCENES:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="bad reward_scene")
|
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}"
|
trans_id = f"test-{user.id}-{uuid.uuid4().hex}"
|
||||||
if reward_scene == REWARD_SCENE_SIGNIN_BOOST:
|
# 正式链路的轮次 id 走 S2S 的 mediaExtra;本接口不经 S2S,只能由 body 补,否则 debug 包
|
||||||
try:
|
# 的 reward-result 恒返 round_coin=null,「弹窗 40 → 60」那套累计验收在本地跑不起来。
|
||||||
boost, _balance = crud_signin.boost_today_signin(
|
boost_round_id = (payload.boost_round_id if payload is not None else None) or None
|
||||||
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(走与正式发奖相同的公式);
|
# 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
|
||||||
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
|
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
|
||||||
ad_session_id = payload.ad_session_id if payload is not None else None
|
ad_session_id = payload.ad_session_id if payload is not None else None
|
||||||
@@ -361,6 +356,7 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
|
|||||||
try:
|
try:
|
||||||
rec = crud_ad.grant_ad_reward(
|
rec = crud_ad.grant_ad_reward(
|
||||||
db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
|
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}",
|
reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
|
||||||
)
|
)
|
||||||
except crud_ad.UnknownUserError as e:
|
except crud_ad.UnknownUserError as e:
|
||||||
|
|||||||
+16
-1
@@ -6,13 +6,18 @@ POST /api/v1/analytics/events — 批量接收新手引导(及后续)埋点,appe
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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.api.deps import DbSession
|
||||||
from app.repositories import analytics as analytics_repo
|
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 import AnalyticsBatchIn, AnalyticsIngestOut
|
||||||
|
from app.schemas.analytics_selfstat import SelfStatBatchIn, SelfStatIngestOut
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
|
router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
|
||||||
|
logger = logging.getLogger("shagua.analytics")
|
||||||
|
|
||||||
|
|
||||||
def _client_ip(request: Request) -> str:
|
def _client_ip(request: Request) -> str:
|
||||||
@@ -29,3 +34,13 @@ def ingest_events(
|
|||||||
) -> AnalyticsIngestOut:
|
) -> AnalyticsIngestOut:
|
||||||
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
|
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
|
||||||
return AnalyticsIngestOut(received=n)
|
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)
|
||||||
|
|||||||
+435
-18
@@ -12,19 +12,37 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
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.api.deps import CurrentUser, DbSession
|
||||||
from app.core import test_account
|
from app.core import test_account
|
||||||
from app.core.ratelimit import enforce_rate_limit
|
from app.core.ratelimit import (
|
||||||
from app.core.security import TokenError, decode_token, issue_token_pair
|
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.jiguang import JiguangError, mask_phone, verify_and_get_phone
|
||||||
from app.integrations.sms import SmsError, send_code, verify_code
|
from app.integrations.sms import SmsError, send_code, verify_code
|
||||||
from app.repositories import onboarding as onboarding_repo
|
from app.repositories import onboarding as onboarding_repo
|
||||||
|
from app.repositories import phone_rebind as rebind_repo
|
||||||
|
from app.repositories import risk as risk_repo
|
||||||
from app.repositories import user as user_repo
|
from app.repositories import user as user_repo
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
JverifyLoginRequest,
|
JverifyLoginRequest,
|
||||||
LogoutResponse,
|
LogoutResponse,
|
||||||
|
OccupiedAccountInfo,
|
||||||
RefreshRequest,
|
RefreshRequest,
|
||||||
SmsLoginRequest,
|
SmsLoginRequest,
|
||||||
SmsSendRequest,
|
SmsSendRequest,
|
||||||
@@ -32,6 +50,13 @@ from app.schemas.auth import (
|
|||||||
TokenPair,
|
TokenPair,
|
||||||
TokenWithUser,
|
TokenWithUser,
|
||||||
UserOut,
|
UserOut,
|
||||||
|
WechatBindPhoneJverifyRequest,
|
||||||
|
WechatBindPhoneSmsRequest,
|
||||||
|
WechatBindResultResponse,
|
||||||
|
WechatConflictContinueRequest,
|
||||||
|
WechatConflictRebindRequest,
|
||||||
|
WechatLoginRequest,
|
||||||
|
WechatLoginResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.auth")
|
logger = logging.getLogger("shagua.auth")
|
||||||
@@ -40,9 +65,22 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
|||||||
|
|
||||||
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
|
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
|
||||||
SMS_LOGIN_MAX_PER_HOUR = 5
|
SMS_LOGIN_MAX_PER_HOUR = 5
|
||||||
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
|
# 发码防刷(同一设备 device_id + 同一 IP,**只按成功发码计数**;被单号 60s 冷却挡下的重发不占额度):
|
||||||
# 堵「换手机号绕开单号 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 _client_ip(request: Request) -> str:
|
||||||
|
xff = request.headers.get("x-forwarded-for")
|
||||||
|
if xff:
|
||||||
|
return xff.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _device_subject(device_id: str, request: Request) -> str:
|
||||||
|
# 兼容旧客户端空 device_id;新客户端均上传稳定硬件标识。
|
||||||
|
return device_id or f"legacy-ip:{_client_ip(request)}"
|
||||||
|
|
||||||
|
|
||||||
def _login_response(
|
def _login_response(
|
||||||
@@ -62,7 +100,15 @@ def _login_response(
|
|||||||
# ===================== 极光一键登录 =====================
|
# ===================== 极光一键登录 =====================
|
||||||
|
|
||||||
@router.post("/jverify-login", response_model=TokenWithUser, summary="极光一键登录")
|
@router.post("/jverify-login", response_model=TokenWithUser, summary="极光一键登录")
|
||||||
def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
|
def jverify_login(req: JverifyLoginRequest, request: Request, db: DbSession) -> TokenWithUser:
|
||||||
|
subject_id = _device_subject(req.device_id, request)
|
||||||
|
if req.device_id and risk_repo.is_restricted(
|
||||||
|
db,
|
||||||
|
subject_type="device",
|
||||||
|
subject_id=req.device_id,
|
||||||
|
scope=risk_repo.SCOPE_AUTH_DEVICE,
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="当前设备环境异常,暂无法登录")
|
||||||
logger.info(
|
logger.info(
|
||||||
"jverify_login operator=%s token_len=%d",
|
"jverify_login operator=%s token_len=%d",
|
||||||
req.operator or "-",
|
req.operator or "-",
|
||||||
@@ -72,10 +118,37 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
|
|||||||
try:
|
try:
|
||||||
phone = verify_and_get_phone(req.login_token)
|
phone = verify_and_get_phone(req.login_token)
|
||||||
except JiguangError as e:
|
except JiguangError as e:
|
||||||
|
risk_repo.record_behavior_event(
|
||||||
|
db,
|
||||||
|
event_type=risk_repo.EVENT_ONECLICK_LOGIN,
|
||||||
|
subject_type="device",
|
||||||
|
subject_id=subject_id,
|
||||||
|
device_id=req.device_id or None,
|
||||||
|
device_model=req.device_model or None,
|
||||||
|
client_ip=_client_ip(request),
|
||||||
|
outcome="failed",
|
||||||
|
reason=str(e),
|
||||||
|
details={"operator": req.operator or None},
|
||||||
|
evaluate_rule=risk_repo.RULE_ONECLICK_DAILY,
|
||||||
|
)
|
||||||
logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True)
|
logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e
|
raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e
|
||||||
|
|
||||||
user = user_repo.upsert_user_for_login(db, phone=phone, register_channel="jverify")
|
user = user_repo.upsert_user_for_login(db, phone=phone, register_channel="jverify")
|
||||||
|
risk_repo.record_behavior_event(
|
||||||
|
db,
|
||||||
|
event_type=risk_repo.EVENT_ONECLICK_LOGIN,
|
||||||
|
subject_type="device",
|
||||||
|
subject_id=subject_id,
|
||||||
|
user_id=user.id,
|
||||||
|
device_id=req.device_id or None,
|
||||||
|
device_model=req.device_model or None,
|
||||||
|
phone=phone,
|
||||||
|
client_ip=_client_ip(request),
|
||||||
|
outcome="success",
|
||||||
|
details={"operator": req.operator or None},
|
||||||
|
evaluate_rule=risk_repo.RULE_ONECLICK_DAILY,
|
||||||
|
)
|
||||||
if user.status != "active":
|
if user.status != "active":
|
||||||
raise HTTPException(status_code=403, detail="account disabled")
|
raise HTTPException(status_code=403, detail="account disabled")
|
||||||
|
|
||||||
@@ -91,33 +164,84 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
|
|||||||
response_model=SmsSendResponse,
|
response_model=SmsSendResponse,
|
||||||
summary="发送短信验证码",
|
summary="发送短信验证码",
|
||||||
)
|
)
|
||||||
def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
|
def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendResponse:
|
||||||
|
subject_id = _device_subject(req.device_id, request)
|
||||||
|
if req.device_id and risk_repo.is_restricted(
|
||||||
|
db,
|
||||||
|
subject_type="device",
|
||||||
|
subject_id=req.device_id,
|
||||||
|
scope=risk_repo.SCOPE_AUTH_DEVICE,
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="当前设备环境异常,暂无法发送验证码")
|
||||||
# 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。
|
# 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。
|
||||||
# 真正的"免验证码"在 /sms/login 跳过校验;每日上限也在 login 处算(send 不耗额度)。
|
# 真正的"免验证码"在 /sms/login 跳过校验;每日上限也在 login 处算(send 不耗额度)。
|
||||||
# (也不受下面设备发码限流约束:QA 联调要反复发码。)
|
# (也不受下面设备发码限流约束:QA 联调要反复发码。)
|
||||||
if test_account.is_test_account(req.phone):
|
if test_account.is_test_account(req.phone):
|
||||||
logger.info("test_account sms_send short-circuit (不真发)")
|
logger.info("test_account sms_send short-circuit (不真发)")
|
||||||
|
risk_repo.record_behavior_event(
|
||||||
|
db,
|
||||||
|
event_type=risk_repo.EVENT_SMS_SEND,
|
||||||
|
subject_type="device",
|
||||||
|
subject_id=subject_id,
|
||||||
|
device_id=req.device_id or None,
|
||||||
|
device_model=req.device_model or None,
|
||||||
|
phone=req.phone,
|
||||||
|
client_ip=_client_ip(request),
|
||||||
|
# 测试号不真发短信,也豁免正式频控;保留流水供排查,但不能污染
|
||||||
|
# “今日短信下发”与短信风控报警。
|
||||||
|
outcome="test",
|
||||||
|
details={"mock": True, "test_account": True},
|
||||||
|
)
|
||||||
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
|
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
|
||||||
|
|
||||||
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。
|
# 发码防刷:同一设备(device_id) + 同一 IP,每小时 / 每天两道闸,**均只按成功发码计数**。
|
||||||
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,
|
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。
|
||||||
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。
|
# 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前
|
||||||
enforce_rate_limit(
|
# (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。
|
||||||
request,
|
send_rules = [
|
||||||
scope="sms-send-device",
|
RateLimitRule("sms-send-device", SMS_SEND_MAX_PER_HOUR_PER_DEVICE, 3600,
|
||||||
subject=req.device_id,
|
"操作过于频繁,请稍后再试"),
|
||||||
limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE,
|
RateLimitRule("sms-send-device-daily", SMS_SEND_MAX_PER_DAY_PER_DEVICE, 86400,
|
||||||
window_sec=3600,
|
"今日验证码发送次数过多,请明天再试"),
|
||||||
detail="操作过于频繁,请稍后再试",
|
]
|
||||||
)
|
check_rate_limits(request, subject=req.device_id, rules=send_rules)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cooldown = send_code(req.phone)
|
cooldown = send_code(req.phone)
|
||||||
except SmsError as e:
|
except SmsError as e:
|
||||||
|
risk_repo.record_behavior_event(
|
||||||
|
db,
|
||||||
|
event_type=risk_repo.EVENT_SMS_SEND,
|
||||||
|
subject_type="device",
|
||||||
|
subject_id=subject_id,
|
||||||
|
device_id=req.device_id or None,
|
||||||
|
device_model=req.device_model or None,
|
||||||
|
phone=req.phone,
|
||||||
|
client_ip=_client_ip(request),
|
||||||
|
outcome="failed",
|
||||||
|
reason=str(e),
|
||||||
|
)
|
||||||
raise HTTPException(status_code=e.status_code, detail=str(e)) from 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 避免循环
|
from app.core.config import settings # 局部 import 避免循环
|
||||||
|
|
||||||
|
risk_repo.record_behavior_event(
|
||||||
|
db,
|
||||||
|
event_type=risk_repo.EVENT_SMS_SEND,
|
||||||
|
subject_type="device",
|
||||||
|
subject_id=subject_id,
|
||||||
|
device_id=req.device_id or None,
|
||||||
|
device_model=req.device_model or None,
|
||||||
|
phone=req.phone,
|
||||||
|
client_ip=_client_ip(request),
|
||||||
|
outcome="success",
|
||||||
|
details={"mock": settings.SMS_MOCK},
|
||||||
|
evaluate_rule=risk_repo.RULE_SMS_HOURLY,
|
||||||
|
)
|
||||||
|
|
||||||
return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown)
|
return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown)
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +251,7 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
|
|||||||
summary="手机号+验证码登录",
|
summary="手机号+验证码登录",
|
||||||
)
|
)
|
||||||
def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser:
|
def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser:
|
||||||
|
subject_id = _device_subject(req.device_id, request)
|
||||||
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
|
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
|
||||||
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
|
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
|
||||||
# 测试账号走自己的每日额度、不受下面 (设备+IP) 每小时限流约束(QA 需在一小时内反复登录联调)。
|
# 测试账号走自己的每日额度、不受下面 (设备+IP) 每小时限流约束(QA 需在一小时内反复登录联调)。
|
||||||
@@ -134,6 +259,19 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
|
|||||||
if not test_account.try_consume_quota():
|
if not test_account.try_consume_quota():
|
||||||
raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
|
raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
|
||||||
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
|
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
|
||||||
|
risk_repo.record_behavior_event(
|
||||||
|
db,
|
||||||
|
event_type=risk_repo.EVENT_SMS_LOGIN,
|
||||||
|
subject_type="device",
|
||||||
|
subject_id=subject_id,
|
||||||
|
user_id=user.id,
|
||||||
|
device_id=req.device_id or None,
|
||||||
|
device_model=req.device_model or None,
|
||||||
|
phone=req.phone,
|
||||||
|
client_ip=_client_ip(request),
|
||||||
|
outcome="success",
|
||||||
|
details={"test_account": True},
|
||||||
|
)
|
||||||
if user.status != "active":
|
if user.status != "active":
|
||||||
raise HTTPException(status_code=403, detail="account disabled")
|
raise HTTPException(status_code=403, detail="account disabled")
|
||||||
logger.info("sms_login test_account ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
|
logger.info("sms_login test_account ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
|
||||||
@@ -155,9 +293,33 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not verify_code(req.phone, req.code):
|
if not verify_code(req.phone, req.code):
|
||||||
|
risk_repo.record_behavior_event(
|
||||||
|
db,
|
||||||
|
event_type=risk_repo.EVENT_SMS_LOGIN,
|
||||||
|
subject_type="device",
|
||||||
|
subject_id=subject_id,
|
||||||
|
device_id=req.device_id or None,
|
||||||
|
device_model=req.device_model or None,
|
||||||
|
phone=req.phone,
|
||||||
|
client_ip=_client_ip(request),
|
||||||
|
outcome="failed",
|
||||||
|
reason="invalid sms code",
|
||||||
|
)
|
||||||
raise HTTPException(status_code=400, detail="invalid sms code")
|
raise HTTPException(status_code=400, detail="invalid sms code")
|
||||||
|
|
||||||
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
|
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
|
||||||
|
risk_repo.record_behavior_event(
|
||||||
|
db,
|
||||||
|
event_type=risk_repo.EVENT_SMS_LOGIN,
|
||||||
|
subject_type="device",
|
||||||
|
subject_id=subject_id,
|
||||||
|
user_id=user.id,
|
||||||
|
device_id=req.device_id or None,
|
||||||
|
device_model=req.device_model or None,
|
||||||
|
phone=req.phone,
|
||||||
|
client_ip=_client_ip(request),
|
||||||
|
outcome="success",
|
||||||
|
)
|
||||||
if user.status != "active":
|
if user.status != "active":
|
||||||
raise HTTPException(status_code=403, detail="account disabled")
|
raise HTTPException(status_code=403, detail="account disabled")
|
||||||
|
|
||||||
@@ -166,6 +328,261 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
|
|||||||
return _login_response(user, onboarding_completed=completed)
|
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 =====================
|
# ===================== Refresh =====================
|
||||||
|
|
||||||
@router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对")
|
@router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对")
|
||||||
|
|||||||
+41
-8
@@ -28,19 +28,30 @@ import httpx
|
|||||||
from fastapi import APIRouter, HTTPException, Request, status
|
from fastapi import APIRouter, HTTPException, Request, status
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from app.api.deps import OptionalUser
|
from app.api.deps import DbSession, OptionalUser
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.logging import trace_id_ctx
|
from app.core.logging import trace_id_ctx
|
||||||
from app.core.pricebot_client import get_pricebot_client
|
from app.core.pricebot_client import get_pricebot_client
|
||||||
from app.core.pricebot_router import pick_pricebot
|
from app.core.pricebot_router import pick_pricebot
|
||||||
from app.db.session import SessionLocal
|
from app.db.session import SessionLocal
|
||||||
from app.repositories import comparison as crud_compare
|
from app.repositories import comparison as crud_compare
|
||||||
|
from app.repositories import risk as risk_repo
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.compare")
|
logger = logging.getLogger("shagua.compare")
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1", tags=["compare"])
|
router = APIRouter(prefix="/api/v1", tags=["compare"])
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_compare_allowed(user, db) -> None:
|
||||||
|
if user and risk_repo.is_restricted(
|
||||||
|
db,
|
||||||
|
subject_type="user",
|
||||||
|
subject_id=str(user.id),
|
||||||
|
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# harvest 落库(阻塞 SQLAlchemy → run_in_threadpool,独立 SessionLocal,不阻塞事件循环;
|
# harvest 落库(阻塞 SQLAlchemy → run_in_threadpool,独立 SessionLocal,不阻塞事件循环;
|
||||||
# 任何写库异常都吞掉、绝不连累比价透传返回 —— 同 coupon.py 现有 best-effort 写法)。
|
# 任何写库异常都吞掉、绝不连累比价透传返回 —— 同 coupon.py 现有 best-effort 写法)。
|
||||||
@@ -52,10 +63,14 @@ def _harvest_running_blocking(
|
|||||||
device_id: str | None, device_info: dict | None, trace_url: str | None,
|
device_id: str | None, device_info: dict | None, trace_url: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
crud_compare.harvest_running(
|
rec = crud_compare.harvest_running(
|
||||||
db, trace_id=trace_id, user_id=user_id, business_type=business_type,
|
db, trace_id=trace_id, user_id=user_id, business_type=business_type,
|
||||||
device_id=device_id, device_info=device_info, trace_url=trace_url,
|
device_id=device_id, device_info=device_info, trace_url=trace_url,
|
||||||
)
|
)
|
||||||
|
if rec.user_id is not None:
|
||||||
|
risk_repo.sync_compare_incident(
|
||||||
|
db, user_id=rec.user_id, at=rec.created_at
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"harvest running row (user=%s)", user_id,
|
"harvest running row (user=%s)", user_id,
|
||||||
extra={"phase": "harvest_running", "status": "running", "user_id": user_id},
|
extra={"phase": "harvest_running", "status": "running", "user_id": user_id},
|
||||||
@@ -72,6 +87,10 @@ def _harvest_done_blocking(
|
|||||||
business_type=business_type, device_id=device_id,
|
business_type=business_type, device_id=device_id,
|
||||||
device_info=device_info, trace_url=trace_url,
|
device_info=device_info, trace_url=trace_url,
|
||||||
)
|
)
|
||||||
|
if rec.user_id is not None:
|
||||||
|
risk_repo.sync_compare_incident(
|
||||||
|
db, user_id=rec.user_id, at=rec.created_at
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"harvest done → %s saved=%s newly=%s", rec.status,
|
"harvest done → %s saved=%s newly=%s", rec.status,
|
||||||
rec.saved_amount_cents, newly_success,
|
rec.saved_amount_cents, newly_success,
|
||||||
@@ -199,25 +218,33 @@ async def _forward(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传 + 建 running 行)")
|
@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传 + 建 running 行)")
|
||||||
async def intent_recognize(request: Request, user: OptionalUser) -> dict[str, Any]:
|
async def intent_recognize(
|
||||||
|
request: Request, user: OptionalUser, db: DbSession
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_ensure_compare_allowed(user, db)
|
||||||
resp, _, _ = await _forward(request, "/api/intent/recognize", user)
|
resp, _, _ = await _forward(request, "/api/intent/recognize", user)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传, 仅淘宝源)")
|
@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传, 仅淘宝源)")
|
||||||
async def intent_step(request: Request, user: OptionalUser) -> dict[str, Any]:
|
async def intent_step(request: Request, user: OptionalUser, db: DbSession) -> dict[str, Any]:
|
||||||
|
_ensure_compare_allowed(user, db)
|
||||||
resp, _, _ = await _forward(request, "/api/intent/step", user)
|
resp, _, _ = await _forward(request, "/api/intent/step", user)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@router.post("/intent/precoupon/step", summary="外卖比价 Phase 0 识别前先用券 (透传, 仅美团源)")
|
@router.post("/intent/precoupon/step", summary="外卖比价 Phase 0 识别前先用券 (透传, 仅美团源)")
|
||||||
async def intent_precoupon_step(request: Request, user: OptionalUser) -> dict[str, Any]:
|
async def intent_precoupon_step(
|
||||||
|
request: Request, user: OptionalUser, db: DbSession
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_ensure_compare_allowed(user, db)
|
||||||
resp, _, _ = await _forward(request, "/api/intent/precoupon/step", user)
|
resp, _, _ = await _forward(request, "/api/intent/precoupon/step", user)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传 + done 落库)")
|
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传 + done 落库)")
|
||||||
async def price_step(request: Request, user: OptionalUser) -> dict[str, Any]:
|
async def price_step(request: Request, user: OptionalUser, db: DbSession) -> dict[str, Any]:
|
||||||
|
_ensure_compare_allowed(user, db)
|
||||||
resp, trace_id, meta = await _forward(request, "/api/price/step", user)
|
resp, trace_id, meta = await _forward(request, "/api/price/step", user)
|
||||||
# 最终 done 帧(command=done 且 continue=false)→ harvest 更新成终态。
|
# 最终 done 帧(command=done 且 continue=false)→ harvest 更新成终态。
|
||||||
# (单平台中途 done 被 pricebot 改写成 wait+continue=true,不会命中这里,同 coupon 语义。)
|
# (单平台中途 done 被 pricebot 改写成 wait+continue=true,不会命中这里,同 coupon 语义。)
|
||||||
@@ -237,7 +264,10 @@ async def price_step(request: Request, user: OptionalUser) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/trace/epilogue", summary="比价结果页尾声帧 (透传到 pricebot, 用户视角截图入 trace)")
|
@router.post("/trace/epilogue", summary="比价结果页尾声帧 (透传到 pricebot, 用户视角截图入 trace)")
|
||||||
async def trace_epilogue(request: Request, user: OptionalUser) -> dict[str, Any]:
|
async def trace_epilogue(
|
||||||
|
request: Request, user: OptionalUser, db: DbSession
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_ensure_compare_allowed(user, db)
|
||||||
# App 收到 done、渲染完结果页后,把自己页面的截图(base64)传给 pricebot 存进 trace
|
# App 收到 done、渲染完结果页后,把自己页面的截图(base64)传给 pricebot 存进 trace
|
||||||
# 目录并触发重传 —— trace 里补上"用户实际看到的汇总页"(步骤帧只有目标 App 画面)。
|
# 目录并触发重传 —— trace 里补上"用户实际看到的汇总页"(步骤帧只有目标 App 画面)。
|
||||||
# body ~几百 KB(截图 base64), 纯透传壳(harvest_first_frame=False:不建行/不落库,
|
# body ~几百 KB(截图 base64), 纯透传壳(harvest_first_frame=False:不建行/不落库,
|
||||||
@@ -250,7 +280,10 @@ async def trace_epilogue(request: Request, user: OptionalUser) -> dict[str, Any]
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)")
|
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)")
|
||||||
async def trace_finalize(request: Request, user: OptionalUser) -> dict[str, Any]:
|
async def trace_finalize(
|
||||||
|
request: Request, user: OptionalUser, db: DbSession
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_ensure_compare_allowed(user, db)
|
||||||
# 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url};
|
# 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url};
|
||||||
# app-server 顺手把该 trace 的 running 行更新成夭折终态(**不降级 success**)。
|
# app-server 顺手把该 trace 的 running 行更新成夭折终态(**不降级 success**)。
|
||||||
# 客户端 finalize body 带 status(cancelled/failed)+ reason;老客户端只带 trace_id →
|
# 客户端 finalize body 带 status(cancelled/failed)+ reason;老客户端只带 trace_id →
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ from app.api.deps import CurrentUser, DbSession
|
|||||||
from app.db.session import SessionLocal
|
from app.db.session import SessionLocal
|
||||||
from app.models.comparison import ComparisonRecord
|
from app.models.comparison import ComparisonRecord
|
||||||
from app.repositories import comparison as crud_compare
|
from app.repositories import comparison as crud_compare
|
||||||
|
from app.repositories import risk as risk_repo
|
||||||
from app.schemas.compare_record import (
|
from app.schemas.compare_record import (
|
||||||
|
CompareStartReserveIn,
|
||||||
|
CompareStartReserveOut,
|
||||||
CompareStatsOut,
|
CompareStatsOut,
|
||||||
ComparisonRecordCreatedOut,
|
ComparisonRecordCreatedOut,
|
||||||
ComparisonRecordDetailOut,
|
ComparisonRecordDetailOut,
|
||||||
@@ -27,6 +30,7 @@ from app.schemas.compare_record import (
|
|||||||
ComparisonRecordOut,
|
ComparisonRecordOut,
|
||||||
ComparisonRecordPage,
|
ComparisonRecordPage,
|
||||||
)
|
)
|
||||||
|
from app.services.llm_cost import compute_llm_cost, get_llm_prices
|
||||||
from app.services.pricebot_llm_calls import fetch_llm_calls
|
from app.services.pricebot_llm_calls import fetch_llm_calls
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.compare_record")
|
logger = logging.getLogger("shagua.compare_record")
|
||||||
@@ -34,6 +38,50 @@ logger = logging.getLogger("shagua.compare_record")
|
|||||||
router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
|
router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/start",
|
||||||
|
response_model=CompareStartReserveOut,
|
||||||
|
summary="预占一次当日比价发起次数(每人每天最多100次)",
|
||||||
|
)
|
||||||
|
def reserve_compare_start(
|
||||||
|
payload: CompareStartReserveIn,
|
||||||
|
user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
) -> CompareStartReserveOut:
|
||||||
|
if risk_repo.is_restricted(
|
||||||
|
db,
|
||||||
|
subject_type="user",
|
||||||
|
subject_id=str(user.id),
|
||||||
|
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用")
|
||||||
|
try:
|
||||||
|
rec, used = crud_compare.reserve_daily_start(
|
||||||
|
db,
|
||||||
|
user_id=user.id,
|
||||||
|
trace_id=payload.trace_id,
|
||||||
|
business_type=payload.business_type,
|
||||||
|
device_id=payload.device_id,
|
||||||
|
)
|
||||||
|
except crud_compare.DailyCompareStartLimitExceeded:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="今日已比价超过100次,请明天再试",
|
||||||
|
) from None
|
||||||
|
except crud_compare.ComparisonTraceOwnershipError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="比价任务标识冲突,请重新发起",
|
||||||
|
) from None
|
||||||
|
# 风控阈值由后台动态配置,不能再只在固定的 100 次业务上限处同步。
|
||||||
|
risk_repo.sync_compare_incident(db, user_id=user.id, at=rec.created_at)
|
||||||
|
return CompareStartReserveOut(
|
||||||
|
limit=crud_compare.DAILY_COMPARE_START_LIMIT,
|
||||||
|
used=used,
|
||||||
|
remaining=max(crud_compare.DAILY_COMPARE_START_LIMIT - used, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/record",
|
"/record",
|
||||||
response_model=ComparisonRecordCreatedOut,
|
response_model=ComparisonRecordCreatedOut,
|
||||||
@@ -45,6 +93,13 @@ def report_record(
|
|||||||
db: DbSession,
|
db: DbSession,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
) -> ComparisonRecordCreatedOut:
|
) -> ComparisonRecordCreatedOut:
|
||||||
|
if risk_repo.is_restricted(
|
||||||
|
db,
|
||||||
|
subject_type="user",
|
||||||
|
subject_id=str(user.id),
|
||||||
|
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用")
|
||||||
rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload)
|
rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload)
|
||||||
# LLM 调用明细异步回填:同机拉 pricebot llm_calls 最长 5s 且是 best-effort,放后台
|
# LLM 调用明细异步回填:同机拉 pricebot llm_calls 最长 5s 且是 best-effort,放后台
|
||||||
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
|
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
|
||||||
@@ -81,6 +136,8 @@ def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
|
|||||||
# error 的调用 usage 可能为 None,or {} 兜底)
|
# error 的调用 usage 可能为 None,or {} 兜底)
|
||||||
rec.input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls)
|
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)
|
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()
|
db.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"backfill llm_calls trace=%s n=%d in_tok=%d out_tok=%d",
|
"backfill llm_calls trace=%s n=%d in_tok=%d out_tok=%d",
|
||||||
@@ -112,13 +169,22 @@ def list_records(
|
|||||||
db: DbSession,
|
db: DbSession,
|
||||||
limit: int = Query(20, ge=1, le=100),
|
limit: int = Query(20, ge=1, le=100),
|
||||||
cursor: int | None = Query(None, description="上一页末条 id"),
|
cursor: int | None = Query(None, description="上一页末条 id"),
|
||||||
|
ordered: bool | None = Query(
|
||||||
|
None,
|
||||||
|
description="true=只看「已下单」(店名命中本人真实下单)的记录;不传=全部",
|
||||||
|
),
|
||||||
|
keyword: str | None = Query(
|
||||||
|
None,
|
||||||
|
max_length=64,
|
||||||
|
description="按店名 / 菜名模糊搜索,忽略大小写;空白串等同不传",
|
||||||
|
),
|
||||||
include_trace: bool = Query(
|
include_trace: bool = Query(
|
||||||
False,
|
False,
|
||||||
description="客户端开了本机 agent 调试模式时带 true,放行本人记录的 trace_url",
|
description="客户端开了本机 agent 调试模式时带 true,放行本人记录的 trace_url",
|
||||||
),
|
),
|
||||||
) -> ComparisonRecordPage:
|
) -> ComparisonRecordPage:
|
||||||
items, next_cursor = crud_compare.list_records(
|
items, next_cursor = crud_compare.list_records(
|
||||||
db, user.id, limit=limit, cursor=cursor
|
db, user.id, limit=limit, cursor=cursor, ordered=ordered, keyword=keyword
|
||||||
)
|
)
|
||||||
outs = [ComparisonRecordOut.model_validate(it) for it in items]
|
outs = [ComparisonRecordOut.model_validate(it) for it in items]
|
||||||
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)。
|
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)。
|
||||||
|
|||||||
+11
-2
@@ -81,7 +81,16 @@ def _record_claims_blocking(
|
|||||||
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
|
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
|
||||||
) -> None:
|
) -> None:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
|
# 取本次 session 环境,给每日资产和逐次事件同时打环境标。
|
||||||
|
app_env = coupon_repo.session_app_env(db, trace_id)
|
||||||
|
coupon_repo.record_claims(db, device_id, user_id, trace_id, results, app_env=app_env)
|
||||||
|
# 顺带把本帧「成功平台」并入 coupon_session.platform_success(admin 领券数据 ②整单/③点位成功率;
|
||||||
|
# 设计 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(
|
def _mark_completed_blocking(
|
||||||
@@ -167,7 +176,7 @@ async def coupon_step(
|
|||||||
|
|
||||||
resp_json = resp.json()
|
resp_json = resp.json()
|
||||||
|
|
||||||
# 领券结果沉淀(资产):中间帧 last_coupon_result + done 帧 coupon_results 幂等写库。
|
# 领券结果沉淀:每日资产 + 逐次事件;中间帧和 done 全量帧均幂等写库。
|
||||||
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
|
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
|
||||||
if device_id:
|
if device_id:
|
||||||
results = _extract_coupon_results(resp_json)
|
results = _extract_coupon_results(resp_json)
|
||||||
|
|||||||
+93
-4
@@ -1,19 +1,22 @@
|
|||||||
"""设备注册 / 心跳 endpoint(无障碍保护存活检测)。
|
"""设备注册 / 心跳 endpoint(无障碍保护存活检测)。
|
||||||
|
|
||||||
路由前缀 /api/v1/device,需 Bearer 鉴权(设备绑登录用户)。
|
路由前缀 /api/v1/device,需 Bearer 鉴权(设备绑登录用户)。
|
||||||
POST /register 注册设备 / 更新 registration_id(App 前台、拿到 push token 时调)
|
POST /register 注册设备 / 更新厂商 push token(App 前台、拿到 push token 时调)
|
||||||
POST /heartbeat 上报心跳(无障碍服务存活时周期调,刷新存活)
|
POST /heartbeat 上报心跳(无障碍服务存活时周期调,刷新存活)
|
||||||
|
POST /push-test 开发验收:延迟发送厂商通道测试推送
|
||||||
|
|
||||||
后端 heartbeat_monitor_worker 据此发现心跳超时的设备并极光推送告警。
|
后端 heartbeat_monitor_worker 据此发现心跳超时的设备并厂商直推告警。
|
||||||
见 spec: spec/accessibility-liveness-push.md。
|
见 spec: spec/accessibility-liveness-push.md。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, DbSession
|
from app.api.deps import CurrentUser, DbSession
|
||||||
|
from app.integrations import vendor_push
|
||||||
from app.repositories import device as device_repo
|
from app.repositories import device as device_repo
|
||||||
from app.schemas.device import (
|
from app.schemas.device import (
|
||||||
DeviceOut,
|
DeviceOut,
|
||||||
@@ -22,6 +25,8 @@ from app.schemas.device import (
|
|||||||
LivenessAckRequest,
|
LivenessAckRequest,
|
||||||
LivenessOut,
|
LivenessOut,
|
||||||
OkResponse,
|
OkResponse,
|
||||||
|
PushTestOut,
|
||||||
|
PushTestRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.device")
|
logger = logging.getLogger("shagua.device")
|
||||||
@@ -29,6 +34,37 @@ logger = logging.getLogger("shagua.device")
|
|||||||
router = APIRouter(prefix="/api/v1/device", tags=["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")
|
@router.post("/register", response_model=DeviceOut, summary="注册设备/更新推送token")
|
||||||
def register_device(
|
def register_device(
|
||||||
req: DeviceRegisterRequest,
|
req: DeviceRegisterRequest,
|
||||||
@@ -40,13 +76,17 @@ def register_device(
|
|||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
device_id=req.device_id,
|
device_id=req.device_id,
|
||||||
registration_id=req.registration_id,
|
registration_id=req.registration_id,
|
||||||
|
push_vendor=req.push_vendor,
|
||||||
|
push_token=req.push_token,
|
||||||
platform=req.platform,
|
platform=req.platform,
|
||||||
app_version=req.app_version,
|
app_version=req.app_version,
|
||||||
)
|
)
|
||||||
logger.info(
|
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,
|
user.id,
|
||||||
req.device_id,
|
req.device_id,
|
||||||
|
req.push_vendor,
|
||||||
|
bool(req.push_token),
|
||||||
bool(req.registration_id),
|
bool(req.registration_id),
|
||||||
)
|
)
|
||||||
return DeviceOut.model_validate(device)
|
return DeviceOut.model_validate(device)
|
||||||
@@ -64,10 +104,59 @@ def report_heartbeat(
|
|||||||
device_id=req.device_id,
|
device_id=req.device_id,
|
||||||
accessibility_enabled=req.accessibility_enabled,
|
accessibility_enabled=req.accessibility_enabled,
|
||||||
registration_id=req.registration_id,
|
registration_id=req.registration_id,
|
||||||
|
push_vendor=req.push_vendor,
|
||||||
|
push_token=req.push_token,
|
||||||
)
|
)
|
||||||
return OkResponse()
|
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="查询本机掉线告警(后置检测)")
|
@router.get("/liveness", response_model=LivenessOut, summary="查询本机掉线告警(后置检测)")
|
||||||
def get_liveness(
|
def get_liveness(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""新手引导视频(领券等候浮层前 N 次替代广告)。
|
||||||
|
|
||||||
|
路由前缀 `/api/v1/guide-video`(均需 Bearer):
|
||||||
|
POST /start 这次浮层放引导视频还是放广告?命中则**当场计次**并下发 play_token
|
||||||
|
POST /reward 播完 / 中途关闭都调,按 play_token 幂等发固定金币
|
||||||
|
|
||||||
|
发币额度以**服务端配置**为准(运营后台可改),客户端只报"播完/关闭",报不了金额,
|
||||||
|
所以被破解也刷不到超额金币;次数上限由 guide_video_play 行数(按账号)硬卡。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, DbSession
|
||||||
|
from app.core.ratelimit import rate_limit
|
||||||
|
from app.repositories import guide_video as crud_guide
|
||||||
|
from app.schemas.guide_video import (
|
||||||
|
GuideVideoRewardIn,
|
||||||
|
GuideVideoRewardOut,
|
||||||
|
GuideVideoStartIn,
|
||||||
|
GuideVideoStartOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("shagua.guide_video")
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/guide-video", tags=["guide-video"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/start",
|
||||||
|
response_model=GuideVideoStartOut,
|
||||||
|
summary="领券浮层是否放新手引导视频(命中即计次)",
|
||||||
|
dependencies=[Depends(rate_limit(60, 60, "guide-video-start"))],
|
||||||
|
)
|
||||||
|
def start(payload: GuideVideoStartIn, user: CurrentUser, db: DbSession) -> GuideVideoStartOut:
|
||||||
|
"""开播即计数:返回 should_play=True 时服务端已写下这一次,客户端必须真的播。
|
||||||
|
|
||||||
|
没配视频 / 开关关 / 次数用完 → should_play=False,客户端照旧走广告链路(行为不变)。
|
||||||
|
"""
|
||||||
|
result = crud_guide.start_play(db, user.id, scene=payload.scene or "coupon")
|
||||||
|
logger.info(
|
||||||
|
"guide video start user_id=%d scene=%s should_play=%s seq=%d remaining=%d",
|
||||||
|
user.id, payload.scene, result["should_play"], result["seq"], result["remaining"],
|
||||||
|
)
|
||||||
|
return GuideVideoStartOut(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/reward",
|
||||||
|
response_model=GuideVideoRewardOut,
|
||||||
|
summary="引导视频发金币(播完/中途关闭都发,play_token 幂等)",
|
||||||
|
dependencies=[Depends(rate_limit(60, 60, "guide-video-reward"))],
|
||||||
|
)
|
||||||
|
def reward(payload: GuideVideoRewardIn, user: CurrentUser, db: DbSession) -> GuideVideoRewardOut:
|
||||||
|
result = crud_guide.grant_play(
|
||||||
|
db, user.id, play_token=payload.play_token, completed=payload.completed
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"guide video reward user_id=%d token=%s completed=%s granted=%s coin=%d",
|
||||||
|
user.id, payload.play_token[:12], payload.completed, result["granted"], result["coin"],
|
||||||
|
)
|
||||||
|
return GuideVideoRewardOut(**result)
|
||||||
+148
-69
@@ -5,11 +5,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import nullslast, select
|
from sqlalchemy import nullslast, select
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
@@ -25,8 +26,14 @@ from app.schemas.meituan import (
|
|||||||
ReferralLinkResponse,
|
ReferralLinkResponse,
|
||||||
TopSalesRequest,
|
TopSalesRequest,
|
||||||
)
|
)
|
||||||
|
from app.utils import mt_search_cursor
|
||||||
from app.utils.meituan_city import get_meituan_city
|
from app.utils.meituan_city import get_meituan_city
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # 仅供类型标注(本模块已开 from __future__ import annotations)
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from sqlalchemy import ColumnElement
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.meituan")
|
logger = logging.getLogger("shagua.meituan")
|
||||||
|
|
||||||
|
|
||||||
@@ -109,6 +116,86 @@ def _commission_pct(card: CouponCard) -> float:
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────── 离线库分页(智能推荐 / 销量最高 共用) ──────────────
|
||||||
|
# 去重+排序阶段**只投影这几列**:够 DISTINCT ON 分组、够排序、够回表定位,且全是定长小字段。
|
||||||
|
# ⚠️ 关键性能点:`raw` 是整条美团原始返回(JSONB,每行数 KB)。原实现用 select(MeituanCoupon)
|
||||||
|
# 做子查询,等于把整城几千行连 raw 一起塞进两次排序(DISTINCT ON 一次 + 分页一次),
|
||||||
|
# 体量轻松超过 work_mem → Postgres 落盘做外部归并排序,而且**每翻一页都要重来一遍**。
|
||||||
|
# 拆成「先在小列上排出本页 id,再按 id 回表取 raw」后,排序数据量降到原来的百分之几,
|
||||||
|
# JSONB 只解析当前页 ~20 行。
|
||||||
|
_DEDUP_COLS = (
|
||||||
|
MeituanCoupon.id,
|
||||||
|
MeituanCoupon.dedup_key,
|
||||||
|
MeituanCoupon.sale_volume_num,
|
||||||
|
MeituanCoupon.commission_percent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _paged_dedup_ids(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
conds: list[ColumnElement[bool]],
|
||||||
|
dedup_order: list[ColumnElement],
|
||||||
|
page_order: Callable[[Any], list[ColumnElement]],
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
) -> tuple[list[int], bool]:
|
||||||
|
"""DISTINCT ON(dedup_key) 跨源去重 → 整体排序 → 分页,返回 (本页 id 列表, 是否还有下一页)。
|
||||||
|
|
||||||
|
- `dedup_order`:同一个 dedup_key 的多条里留哪条(如销量最高/佣金最高)。
|
||||||
|
- `page_order`:接收去重子查询的列集合(`sub.c`),返回去重后的整体排序。
|
||||||
|
多取 1 条用于判断 has_next。
|
||||||
|
"""
|
||||||
|
deduped = (
|
||||||
|
select(*_DEDUP_COLS)
|
||||||
|
.where(*conds)
|
||||||
|
.distinct(MeituanCoupon.dedup_key)
|
||||||
|
.order_by(MeituanCoupon.dedup_key, *dedup_order)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
ids = db.execute(
|
||||||
|
select(deduped.c.id)
|
||||||
|
.order_by(*page_order(deduped.c))
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size + 1)
|
||||||
|
).scalars().all()
|
||||||
|
return list(ids[:page_size]), len(ids) > page_size
|
||||||
|
|
||||||
|
|
||||||
|
def _load_raws(db: Session, ids: list[int]) -> list[dict]:
|
||||||
|
"""按给定 id 顺序取 raw(只回表本页 ~20 行)。缺行(被 ETL 清掉)静默跳过。"""
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
raw_by_id = {
|
||||||
|
row_id: raw
|
||||||
|
for row_id, raw in db.execute(
|
||||||
|
select(MeituanCoupon.id, MeituanCoupon.raw).where(MeituanCoupon.id.in_(ids))
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
return [raw_by_id[i] for i in ids if i in raw_by_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _cards_from_raws(raws: list[dict], *, hide_distance: bool) -> list[CouponCard]:
|
||||||
|
"""raw → CouponCard;解析失败的单条跳过,不整页失败。
|
||||||
|
|
||||||
|
hide_distance:离线库里的距离是相对「城市默认点」算的,对用户无意义且误导 —— 智能推荐 /
|
||||||
|
销量最高两个 tab 一律置空,前端「距离 店名」那行只剩店名、自动顶到最左。
|
||||||
|
"""
|
||||||
|
cards: list[CouponCard] = []
|
||||||
|
for raw in raws:
|
||||||
|
try:
|
||||||
|
card = CouponCard.from_raw(raw or {})
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
continue
|
||||||
|
if not card.product_view_sign:
|
||||||
|
continue
|
||||||
|
if hide_distance:
|
||||||
|
card.distance_text = None
|
||||||
|
card.distance_meters = None
|
||||||
|
cards.append(card)
|
||||||
|
return cards
|
||||||
|
|
||||||
|
|
||||||
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近")
|
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近")
|
||||||
def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
||||||
lon, lat = req.longitude, req.latitude
|
lon, lat = req.longitude, req.latitude
|
||||||
@@ -133,17 +220,21 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
|||||||
return [], True
|
return [], True
|
||||||
|
|
||||||
# 距离最近:搜索召回(外卖搜"外卖" + 到店搜"美食",都 sortField=6 离我最近)一页页拉。
|
# 距离最近:搜索召回(外卖搜"外卖" + 到店搜"美食",都 sortField=6 离我最近)一页页拉。
|
||||||
# 搜索翻页必须用 searchId(pageNo 翻不动),所以每个 feed 页顺序翻到第 N 页;两路并行、page 1 最快。
|
# 搜索翻页必须用 searchId(pageNo 翻不动),而接口是无状态的(客户端只传页码)—— 原实现因此
|
||||||
# 无状态、不改 APP(传页码即可);按你位置实时算距离(库里没存 POI 经纬度,只能实时)。
|
# 每次都从第 1 页顺序重放到第 N 页,取第 N 页要向美团发 N 次请求,越往下滑越慢。
|
||||||
|
# 现在把沿途 searchId 记进 [mt_search_cursor],稳态下每翻一页恒定 1 次请求;两路仍并行。
|
||||||
|
# 按你位置实时算距离(库里没存 POI 经纬度,只能实时)。
|
||||||
if tab == "distance":
|
if tab == "distance":
|
||||||
lon_i, lat_i = int(lon * 1_000_000), int(lat * 1_000_000)
|
lon_i, lat_i = int(lon * 1_000_000), int(lat * 1_000_000)
|
||||||
|
|
||||||
def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool, bool]:
|
def _replay(
|
||||||
"""顺序翻到第 n 页(搜索须 searchId 续页),返回(第 n 页 items, 是否还有下一页, 是否调用失败)。"""
|
platform: int, biz_line: int | None, keyword: str,
|
||||||
sid: str | None = None
|
key: mt_search_cursor.RouteKey, start: int, sid: str | None, n: int,
|
||||||
|
) -> tuple[list[dict], bool, bool]:
|
||||||
|
"""从第 start 页(用 sid 取)顺序翻到第 n 页。start==1 时 sid 应为 None(走 pageNo=1)。"""
|
||||||
data: list[dict] = []
|
data: list[dict] = []
|
||||||
has_next = False
|
has_next = False
|
||||||
for pg in range(1, n + 1):
|
for pg in range(start, n + 1):
|
||||||
body: dict = {
|
body: dict = {
|
||||||
"platform": platform, "searchText": keyword, "sortField": 6,
|
"platform": platform, "searchText": keyword, "sortField": 6,
|
||||||
"longitude": lon_i, "latitude": lat_i, "pageSize": 20,
|
"longitude": lon_i, "latitude": lat_i, "pageSize": 20,
|
||||||
@@ -161,10 +252,27 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
|||||||
data = r.get("data") or []
|
data = r.get("data") or []
|
||||||
sid = r.get("searchId")
|
sid = r.get("searchId")
|
||||||
has_next = bool(r.get("hasNext")) and bool(data)
|
has_next = bool(r.get("hasNext")) and bool(data)
|
||||||
|
# 记下「下一页要用哪个 searchId」;没有下一页就别记,免得存进死游标。
|
||||||
|
if sid and has_next:
|
||||||
|
mt_search_cursor.remember(key, pg + 1, sid)
|
||||||
if not data or (not has_next and pg < n):
|
if not data or (not has_next and pg < n):
|
||||||
return [], False, False # 没那么多页了(非错误)
|
return [], False, False # 没那么多页了(非错误)
|
||||||
return data, has_next, False
|
return data, has_next, False
|
||||||
|
|
||||||
|
def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool, bool]:
|
||||||
|
"""取第 n 页,返回(第 n 页 items, 是否还有下一页, 是否调用失败)。
|
||||||
|
|
||||||
|
优先用缓存游标一发直达;缓存未命中/过期才从最近的已知页往后重放,并把沿途游标补进缓存。
|
||||||
|
"""
|
||||||
|
key = mt_search_cursor.route_key(lat, lon, platform, keyword)
|
||||||
|
start, sid = mt_search_cursor.lookup(key, n)
|
||||||
|
data, has_next, failed = _replay(platform, biz_line, keyword, key, start, sid, n)
|
||||||
|
# 用缓存游标却打不通,多半是上游 searchId 过期:作废整条路线,回到第 1 页重放一次。
|
||||||
|
if failed and start > 1:
|
||||||
|
mt_search_cursor.drop(key)
|
||||||
|
data, has_next, failed = _replay(platform, biz_line, keyword, key, 1, None, n)
|
||||||
|
return data, has_next, failed
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
f_wm = pool.submit(_search_page_n, 1, None, "外卖", req.page)
|
f_wm = pool.submit(_search_page_n, 1, None, "外卖", req.page)
|
||||||
f_dd = pool.submit(_search_page_n, 2, 1, "美食", req.page)
|
f_dd = pool.submit(_search_page_n, 2, 1, "美食", req.page)
|
||||||
@@ -194,39 +302,25 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
|||||||
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
|
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
|
||||||
PAGE = 20
|
PAGE = 20
|
||||||
try:
|
try:
|
||||||
base = select(MeituanCoupon).where(
|
ids, has_next = _paged_dedup_ids(
|
||||||
|
db,
|
||||||
|
conds=[
|
||||||
MeituanCoupon.commission_percent >= 3.0,
|
MeituanCoupon.commission_percent >= 3.0,
|
||||||
MeituanCoupon.city_id == city_id,
|
MeituanCoupon.city_id == city_id,
|
||||||
)
|
],
|
||||||
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
|
# 同一去重键留佣金最高那条
|
||||||
MeituanCoupon.dedup_key,
|
dedup_order=[MeituanCoupon.commission_percent.desc()],
|
||||||
MeituanCoupon.commission_percent.desc(),
|
|
||||||
).subquery()
|
|
||||||
m = aliased(MeituanCoupon, deduped)
|
|
||||||
start = (req.page - 1) * PAGE
|
|
||||||
rows = db.execute(
|
|
||||||
select(m)
|
|
||||||
# 销量高的优先(无销量档排后),同档佣金高优先,id 兜底稳定分页
|
# 销量高的优先(无销量档排后),同档佣金高优先,id 兜底稳定分页
|
||||||
.order_by(nullslast(m.sale_volume_num.desc()), m.commission_percent.desc(), m.id)
|
page_order=lambda c: [
|
||||||
.offset(start)
|
nullslast(c.sale_volume_num.desc()), c.commission_percent.desc(), c.id,
|
||||||
.limit(PAGE + 1)
|
],
|
||||||
).scalars().all()
|
page=req.page, page_size=PAGE,
|
||||||
|
)
|
||||||
|
raws = _load_raws(db, ids)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.exception("[feed] rec 库查询失败,降级返空")
|
logger.exception("[feed] rec 库查询失败,降级返空")
|
||||||
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
|
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
|
||||||
has_next = len(rows) > PAGE
|
cards = _cards_from_raws(raws, hide_distance=True)
|
||||||
cards: list[CouponCard] = []
|
|
||||||
for row in rows[:PAGE]:
|
|
||||||
try:
|
|
||||||
card = CouponCard.from_raw(row.raw or {})
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
continue
|
|
||||||
if card.product_view_sign:
|
|
||||||
# 智能推荐不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。
|
|
||||||
# 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。
|
|
||||||
card.distance_text = None
|
|
||||||
card.distance_meters = None
|
|
||||||
cards.append(card)
|
|
||||||
if not cards and req.page == 1:
|
if not cards and req.page == 1:
|
||||||
# 命中城市却 0 券:该城确无 ≥3% 券,或 ETL 灌的 city_id 与 city_dict 口径不一致。
|
# 命中城市却 0 券:该城确无 ≥3% 券,或 ETL 灌的 city_id 与 city_dict 口径不一致。
|
||||||
logger.info("[feed] rec city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
|
logger.info("[feed] rec city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
|
||||||
@@ -282,51 +376,36 @@ def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponList
|
|||||||
if not city_id:
|
if not city_id:
|
||||||
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
||||||
|
|
||||||
# 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。
|
# 去重 + 排序 + 分页全在 SQL 做,每页只回表并解析当前页 ~20 条(见 _paged_dedup_ids 的性能说明)。
|
||||||
# (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。)
|
|
||||||
# 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。
|
# 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。
|
||||||
try:
|
conds = [
|
||||||
# 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
|
|
||||||
base = select(MeituanCoupon).where(
|
|
||||||
MeituanCoupon.sale_volume_num.isnot(None),
|
MeituanCoupon.sale_volume_num.isnot(None),
|
||||||
MeituanCoupon.city_id == city_id,
|
MeituanCoupon.city_id == city_id,
|
||||||
)
|
]
|
||||||
if req.platform is not None:
|
if req.platform is not None:
|
||||||
base = base.where(MeituanCoupon.platform == req.platform)
|
conds.append(MeituanCoupon.platform == req.platform)
|
||||||
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
|
try:
|
||||||
MeituanCoupon.dedup_key,
|
ids, has_next = _paged_dedup_ids(
|
||||||
|
db,
|
||||||
|
conds=conds,
|
||||||
|
# 每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
|
||||||
|
dedup_order=[
|
||||||
MeituanCoupon.sale_volume_num.desc(),
|
MeituanCoupon.sale_volume_num.desc(),
|
||||||
MeituanCoupon.commission_percent.desc(),
|
MeituanCoupon.commission_percent.desc(),
|
||||||
).subquery()
|
],
|
||||||
|
|
||||||
# 2) 对去重结果按销量降序分页;多取 1 条判断 has_next,只对本页做 from_raw
|
|
||||||
m = aliased(MeituanCoupon, deduped)
|
|
||||||
start = (req.page - 1) * req.page_size
|
|
||||||
rows = db.execute(
|
|
||||||
select(m)
|
|
||||||
# 加 id 作稳定 tiebreaker:同销量同佣金的并列项排序确定,避免跨页重复/漏项
|
# 加 id 作稳定 tiebreaker:同销量同佣金的并列项排序确定,避免跨页重复/漏项
|
||||||
.order_by(m.sale_volume_num.desc(), m.commission_percent.desc(), m.id)
|
page_order=lambda c: [
|
||||||
.offset(start)
|
c.sale_volume_num.desc(), c.commission_percent.desc(), c.id,
|
||||||
.limit(req.page_size + 1)
|
],
|
||||||
).scalars().all()
|
page=req.page, page_size=req.page_size,
|
||||||
|
)
|
||||||
|
raws = _load_raws(db, ids)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.exception("[top-sales] 库查询失败,降级返空")
|
logger.exception("[top-sales] 库查询失败,降级返空")
|
||||||
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
||||||
|
|
||||||
has_next = len(rows) > req.page_size
|
# 不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导),与推荐流口径一致。
|
||||||
cards: list[CouponCard] = []
|
cards = _cards_from_raws(raws, hide_distance=True)
|
||||||
for row in rows[:req.page_size]:
|
|
||||||
try:
|
|
||||||
card = CouponCard.from_raw(row.raw or {})
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
continue
|
|
||||||
if card.product_view_sign:
|
|
||||||
# 不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。
|
|
||||||
# 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。
|
|
||||||
# 逻辑与推荐流保持一致
|
|
||||||
card.distance_text = None
|
|
||||||
card.distance_meters = None
|
|
||||||
cards.append(card)
|
|
||||||
if not cards and req.page == 1:
|
if not cards and req.page == 1:
|
||||||
# 命中城市却 0 券:可能该城确无券,也可能 ETL 灌的 city_id 与 city_dict 口径不一致(静默降级的隐患)。
|
# 命中城市却 0 券:可能该城确无券,也可能 ETL 灌的 city_id 与 city_dict 口径不一致(静默降级的隐患)。
|
||||||
logger.info("[top-sales] city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
|
logger.info("[top-sales] city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
|
||||||
|
|||||||
@@ -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,
|
AdConfigPublicOut,
|
||||||
AppFlagsOut,
|
AppFlagsOut,
|
||||||
AppVersionOut,
|
AppVersionOut,
|
||||||
|
HuaweiReviewOut,
|
||||||
PlatformStatsOut,
|
PlatformStatsOut,
|
||||||
SavingsFeedItem,
|
SavingsFeedItem,
|
||||||
SavingsFeedOut,
|
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 检查更新,不鉴权)")
|
@router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)")
|
||||||
def app_version(db: DbSession) -> AppVersionOut:
|
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。
|
"""签到 endpoint。
|
||||||
|
|
||||||
路由前缀 `/api/v1/signin`:
|
路由前缀 `/api/v1/signin`:
|
||||||
GET /status 今日签到状态 + 14 天档位
|
GET /status 今日签到状态 + 7 天档位
|
||||||
POST / 执行今日签到
|
POST / 执行今日签到
|
||||||
POST /boost 签到后看广告膨胀金币
|
|
||||||
|
2026-07 下线 `POST /boost`(签到膨胀):它按固定 3000 金币补发、与广告实际收益脱钩。
|
||||||
|
签到弹窗里的「看广告膨胀」改与福利页看视频走同一条 reward_video 路径(按 eCPM 发,
|
||||||
|
`/ad/pangle-callback` → `/ad/reward-result` 取金额),奖励只剩「签到」+「看视频」两种。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,15 +15,8 @@ import logging
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, DbSession
|
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 signin as crud_signin
|
||||||
from app.repositories import wallet as crud_wallet
|
from app.schemas.welfare import SigninResultOut, SigninStatusOut
|
||||||
from app.schemas.welfare import (
|
|
||||||
SigninBoostRequest,
|
|
||||||
SigninBoostResultOut,
|
|
||||||
SigninResultOut,
|
|
||||||
SigninStatusOut,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.signin")
|
logger = logging.getLogger("shagua.signin")
|
||||||
|
|
||||||
@@ -50,34 +46,3 @@ def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut:
|
|||||||
streak=record.streak,
|
streak=record.streak,
|
||||||
coin_balance=balance,
|
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(),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import logging
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, DbSession
|
from app.api.deps import CurrentUser, DbSession
|
||||||
|
from app.repositories import risk as risk_repo
|
||||||
from app.repositories import task as crud_task
|
from app.repositories import task as crud_task
|
||||||
from app.schemas.welfare import TaskClaimResultOut, TaskListOut, TaskOut
|
from app.schemas.welfare import TaskClaimResultOut, TaskListOut, TaskOut
|
||||||
|
|
||||||
@@ -36,6 +37,13 @@ def list_tasks(user: CurrentUser, db: DbSession) -> TaskListOut:
|
|||||||
summary="领取任务奖励",
|
summary="领取任务奖励",
|
||||||
)
|
)
|
||||||
def claim_task(task_key: str, user: CurrentUser, db: DbSession) -> TaskClaimResultOut:
|
def claim_task(task_key: str, user: CurrentUser, db: DbSession) -> TaskClaimResultOut:
|
||||||
|
if risk_repo.is_restricted(
|
||||||
|
db,
|
||||||
|
subject_type="user",
|
||||||
|
subject_id=str(user.id),
|
||||||
|
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用")
|
||||||
try:
|
try:
|
||||||
coin, balance = crud_task.claim_task(db, user.id, task_key)
|
coin, balance = crud_task.claim_task(db, user.id, task_key)
|
||||||
except crud_task.UnknownTaskError as e:
|
except crud_task.UnknownTaskError as e:
|
||||||
|
|||||||
+26
-10
@@ -20,8 +20,9 @@ from app.core.rewards import (
|
|||||||
WITHDRAW_MAX_CENTS,
|
WITHDRAW_MAX_CENTS,
|
||||||
WITHDRAW_MIN_CENTS,
|
WITHDRAW_MIN_CENTS,
|
||||||
)
|
)
|
||||||
from app.repositories import wallet as crud_wallet
|
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.repositories import risk as risk_repo
|
||||||
|
from app.repositories import wallet as crud_wallet
|
||||||
from app.schemas.welfare import (
|
from app.schemas.welfare import (
|
||||||
BindWechatRequest,
|
BindWechatRequest,
|
||||||
BindWechatResultOut,
|
BindWechatResultOut,
|
||||||
@@ -43,6 +44,7 @@ from app.schemas.welfare import (
|
|||||||
WithdrawRequest,
|
WithdrawRequest,
|
||||||
WithdrawResultOut,
|
WithdrawResultOut,
|
||||||
WithdrawStatusOut,
|
WithdrawStatusOut,
|
||||||
|
WithdrawTierOut,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.wallet")
|
logger = logging.getLogger("shagua.wallet")
|
||||||
@@ -173,8 +175,15 @@ def unbind_wechat(
|
|||||||
return UnbindWechatResultOut(bound=False)
|
return UnbindWechatResultOut(bound=False)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关")
|
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关/档位")
|
||||||
def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
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)
|
u = db.get(User, user.id)
|
||||||
# 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时
|
# 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时
|
||||||
auth = crud_wallet.sync_transfer_auth(db, user.id)
|
auth = crud_wallet.sync_transfer_auth(db, user.id)
|
||||||
@@ -184,7 +193,8 @@ def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
|||||||
wechat_bound=bool(u and u.wechat_openid),
|
wechat_bound=bool(u and u.wechat_openid),
|
||||||
wechat_nickname=u.wechat_nickname if u else None,
|
wechat_nickname=u.wechat_nickname if u else None,
|
||||||
wechat_avatar_url=u.wechat_avatar_url if u else None,
|
wechat_avatar_url=u.wechat_avatar_url if u else None,
|
||||||
transfer_auth_enabled=bool(auth and auth.state == "active"),
|
transfer_auth_enabled=bool(auth and auth.state == "active" and auth.authorization_id),
|
||||||
|
tiers=[WithdrawTierOut(**t) for t in crud_wallet.withdraw_tier_states(db, user.id, source)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -195,6 +205,13 @@ def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
|||||||
dependencies=[Depends(rate_limit(5, 60, "withdraw"))], # IP 级粗限流;用户级未完成单限制在仓库层
|
dependencies=[Depends(rate_limit(5, 60, "withdraw"))], # IP 级粗限流;用户级未完成单限制在仓库层
|
||||||
)
|
)
|
||||||
def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> WithdrawResultOut:
|
def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> WithdrawResultOut:
|
||||||
|
if risk_repo.is_restricted(
|
||||||
|
db,
|
||||||
|
subject_type="user",
|
||||||
|
subject_id=str(user.id),
|
||||||
|
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用")
|
||||||
# 提现发起本身不调微信(打款在审核通过后),但仍要求微信支付已配置——否则审核通过也打不了款,提前拦
|
# 提现发起本身不调微信(打款在审核通过后),但仍要求微信支付已配置——否则审核通过也打不了款,提前拦
|
||||||
if not settings.wxpay_configured:
|
if not settings.wxpay_configured:
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
|
||||||
@@ -213,11 +230,9 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
|
|||||||
) from e
|
) from e
|
||||||
except crud_wallet.WechatNotBoundError as e:
|
except crud_wallet.WechatNotBoundError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先绑定微信") from e
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先绑定微信") from e
|
||||||
except crud_wallet.WithdrawTooFrequentError as e:
|
except crud_wallet.WithdrawTierUnavailableError as e:
|
||||||
raise HTTPException(
|
# 福利页档位闸(7-9):次数满/已选其他额度。正常客户端已按 tiers 预拦,此处兜底防绕过。
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="今日额度已达上限") from e
|
||||||
detail="已有提现申请正在审核或打款中,请处理完成后再申请",
|
|
||||||
) from e
|
|
||||||
except crud_wallet.InsufficientCashError as e:
|
except crud_wallet.InsufficientCashError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e
|
||||||
|
|
||||||
@@ -323,7 +338,8 @@ def open_transfer_auth(user: CurrentUser, db: DbSession) -> TransferAuthResultOu
|
|||||||
def transfer_auth_status(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut:
|
def transfer_auth_status(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut:
|
||||||
auth = crud_wallet.sync_transfer_auth(db, user.id)
|
auth = crud_wallet.sync_transfer_auth(db, user.id)
|
||||||
state = auth.state if auth else "none"
|
state = auth.state if auth else "none"
|
||||||
return TransferAuthStatusOut(state=state, enabled=(state == "active"))
|
enabled = bool(auth and auth.state == "active" and auth.authorization_id)
|
||||||
|
return TransferAuthStatusOut(state=state, enabled=enabled)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|||||||
+119
-1
@@ -44,6 +44,11 @@ class Settings(BaseSettings):
|
|||||||
JWT_ALGORITHM: str = "HS256"
|
JWT_ALGORITHM: str = "HS256"
|
||||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 120
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 120
|
||||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
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 后台 =====
|
||||||
# admin 用独立 JWT secret(≠ JWT_SECRET_KEY),App 用户 token 无法越权访问后台。
|
# 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_VERIFY_ENDPOINT: str = "https://api.verification.jpush.cn/v1/web/loginTokenVerify"
|
||||||
JG_REQUEST_TIMEOUT_SEC: int = 15
|
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_MONITOR_ENABLED: bool = True # 总开关
|
||||||
HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
|
HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
|
||||||
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
|
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
|
||||||
@@ -81,6 +138,7 @@ class Settings(BaseSettings):
|
|||||||
SMS_SIGN_ID: int = 31729 # 极光短信签名 ID(非机密,可被 .env 覆盖)
|
SMS_SIGN_ID: int = 31729 # 极光短信签名 ID(非机密,可被 .env 覆盖)
|
||||||
SMS_TEMPLATE_ID: int = 1 # 极光短信模板 ID(变量名 code,有效期 5 分钟)
|
SMS_TEMPLATE_ID: int = 1 # 极光短信模板 ID(变量名 code,有效期 5 分钟)
|
||||||
SMS_CODE_LENGTH: int = 6 # 验证码位数(本服务生成;前端 code 字段 4-8 位兼容)
|
SMS_CODE_LENGTH: int = 6 # 验证码位数(本服务生成;前端 code 字段 4-8 位兼容)
|
||||||
|
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
|
||||||
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
|
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
|
||||||
|
|
||||||
# ===== 测试账号(release 包全流程联调用)=====
|
# ===== 测试账号(release 包全流程联调用)=====
|
||||||
@@ -106,6 +164,9 @@ class Settings(BaseSettings):
|
|||||||
# 美团调用走的代理。本机开发直连美团会 SSL EOF,需填 http://127.0.0.1:7897;
|
# 美团调用走的代理。本机开发直连美团会 SSL EOF,需填 http://127.0.0.1:7897;
|
||||||
# 线上国内服务器留空(=直连)。见 .env.example 与 integrations/meituan.py。
|
# 线上国内服务器留空(=直连)。见 .env.example 与 integrations/meituan.py。
|
||||||
MT_CPS_PROXY: str = ""
|
MT_CPS_PROXY: str = ""
|
||||||
|
# 本地开发:开启后 /feed 接口直接返回 mock 数据,不调美团 API、不查离线库,
|
||||||
|
# 方便前端联调 feed 卡片样式、分页、距离排序等 UI。生产必须 false。
|
||||||
|
MT_CPS_MOCK_FEED: bool = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mt_cps_configured(self) -> bool:
|
def mt_cps_configured(self) -> bool:
|
||||||
@@ -127,6 +188,13 @@ class Settings(BaseSettings):
|
|||||||
"""京东联盟订单查询凭证齐全。"""
|
"""京东联盟订单查询凭证齐全。"""
|
||||||
return bool(self.JD_UNION_APP_KEY and self.JD_UNION_APP_SECRET)
|
return bool(self.JD_UNION_APP_KEY and self.JD_UNION_APP_SECRET)
|
||||||
|
|
||||||
|
# 美团 + 京东 CPS 订单自动对账:进程内 worker 每天北京时间 05:00 后跑一轮。
|
||||||
|
# 按更新时间回拉近 N 天(重叠窗口防漏单并刷新状态),order_id 幂等更新;手动接口不受影响。
|
||||||
|
CPS_AUTO_RECONCILE_ENABLED: bool = True
|
||||||
|
CPS_AUTO_RECONCILE_RUN_HOUR: int = 5
|
||||||
|
CPS_AUTO_RECONCILE_LOOKBACK_DAYS: int = 3
|
||||||
|
CPS_AUTO_RECONCILE_CHECK_INTERVAL_SEC: int = 60
|
||||||
|
|
||||||
# ===== 微信服务号(网页授权) =====
|
# ===== 微信服务号(网页授权) =====
|
||||||
# CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。
|
# CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。
|
||||||
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
|
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
|
||||||
@@ -169,6 +237,15 @@ class Settings(BaseSettings):
|
|||||||
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
|
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
|
||||||
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
|
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
|
||||||
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
|
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、不带参数。
|
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
|
||||||
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
|
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
|
||||||
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
|
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
|
||||||
@@ -189,6 +266,19 @@ class Settings(BaseSettings):
|
|||||||
"""免确认收款授权可用 = 微信支付凭证齐全 + 授权回调地址已配。"""
|
"""免确认收款授权可用 = 微信支付凭证齐全 + 授权回调地址已配。"""
|
||||||
return bool(self.wxpay_configured and self.WXPAY_AUTH_NOTIFY_URL)
|
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,客户端被破解也刷不到)。
|
# 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。
|
||||||
# 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自
|
# 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自
|
||||||
@@ -294,6 +384,9 @@ class Settings(BaseSettings):
|
|||||||
MEDIA_ROOT: str = "./data/media"
|
MEDIA_ROOT: str = "./data/media"
|
||||||
MEDIA_URL_PREFIX: str = "/media"
|
MEDIA_URL_PREFIX: str = "/media"
|
||||||
AVATAR_MAX_BYTES: int = 5 * 1024 * 1024 # 头像最大 5MB
|
AVATAR_MAX_BYTES: int = 5 * 1024 * 1024 # 头像最大 5MB
|
||||||
|
# 运营后台上传的新手引导视频上限。视频比图片大一个量级,单独一档;
|
||||||
|
# ⚠️ 改大时同步放宽网关 client_max_body_size(实测 QA 4MiB / prod 32MiB),否则 nginx 先挡下。
|
||||||
|
GUIDE_VIDEO_MAX_BYTES: int = 100 * 1024 * 1024 # 引导视频最大 100MB
|
||||||
|
|
||||||
# ===== 邀请好友 =====
|
# ===== 邀请好友 =====
|
||||||
# 分享落地页(二维码 / 分享链接指向这里;扫码 → 落地页 → 引导浏览器下载 APK)。
|
# 分享落地页(二维码 / 分享链接指向这里;扫码 → 落地页 → 引导浏览器下载 APK)。
|
||||||
@@ -316,6 +409,31 @@ class Settings(BaseSettings):
|
|||||||
return []
|
return []
|
||||||
return [o.strip() for o in self.CORS_ALLOW_ORIGINS.split(",") if o.strip()]
|
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
|
@property
|
||||||
def is_prod(self) -> bool:
|
def is_prod(self) -> bool:
|
||||||
return self.APP_ENV == "prod"
|
return self.APP_ENV == "prod"
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ from typing import Any
|
|||||||
|
|
||||||
from app.core import rewards as r
|
from app.core import rewards as r
|
||||||
|
|
||||||
|
RISK_SMS_HOURLY_THRESHOLD_KEY = "risk_sms_hourly_threshold"
|
||||||
|
RISK_ONECLICK_DAILY_THRESHOLD_KEY = "risk_oneclick_daily_threshold"
|
||||||
|
RISK_COMPARE_DAILY_THRESHOLD_KEY = "risk_compare_daily_threshold"
|
||||||
|
|
||||||
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool / enum
|
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool / enum
|
||||||
# hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用
|
# hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用
|
||||||
# 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由
|
# 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由
|
||||||
@@ -66,11 +70,6 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
|||||||
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
|
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
|
||||||
"group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。",
|
"group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。",
|
||||||
},
|
},
|
||||||
"signin_boost_coin": {
|
|
||||||
"default": r.SIGNIN_BOOST_COIN, "label": "签到膨胀固定金币",
|
|
||||||
"group": "签到", "type": "int",
|
|
||||||
"help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。",
|
|
||||||
},
|
|
||||||
"comparing_ad_enabled": {
|
"comparing_ad_enabled": {
|
||||||
"default": True, "label": "比价/领券期信息流广告",
|
"default": True, "label": "比价/领券期信息流广告",
|
||||||
"group": "看广告", "type": "bool", "hidden": True,
|
"group": "看广告", "type": "bool", "hidden": True,
|
||||||
@@ -96,4 +95,51 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
|||||||
"group": "首页轮播", "type": "enum", "hidden": True,
|
"group": "首页轮播", "type": "enum", "hidden": True,
|
||||||
"help": "mixed=真实优先+种子补位(默认);real=只用真实比价记录;seed=只用种子/合成(演示)。",
|
"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 兜底未登记的模型。改价只影响之后回填的新记录,历史记录用当时价格快照。"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
# 风控监控页专用配置。复用 app_config 持久化,但不在通用「系统配置」页重复展示;
|
||||||
|
# 由 /admin/api/risk-monitor/rules 专用接口整组校验、审计和即时重算当前窗口。
|
||||||
|
RISK_SMS_HOURLY_THRESHOLD_KEY: {
|
||||||
|
"default": 5,
|
||||||
|
"label": "短信设备每小时告警阈值",
|
||||||
|
"group": "风控",
|
||||||
|
"type": "int",
|
||||||
|
"min": 1,
|
||||||
|
"max": 5,
|
||||||
|
"hidden": True,
|
||||||
|
"help": "同一设备在北京时间同一自然小时内成功下发短信达到该次数时告警;不得高于现有每小时 5 次的发送上限。",
|
||||||
|
},
|
||||||
|
RISK_ONECLICK_DAILY_THRESHOLD_KEY: {
|
||||||
|
"default": 20,
|
||||||
|
"label": "一键登录设备每日告警阈值",
|
||||||
|
"group": "风控",
|
||||||
|
"type": "int",
|
||||||
|
"min": 1,
|
||||||
|
"max": 100_000,
|
||||||
|
"hidden": True,
|
||||||
|
"help": "同一设备在北京时间同一自然日内一键登录成功和失败合计达到该次数时告警。",
|
||||||
|
},
|
||||||
|
RISK_COMPARE_DAILY_THRESHOLD_KEY: {
|
||||||
|
"default": 100,
|
||||||
|
"label": "比价账户每日告警阈值",
|
||||||
|
"group": "风控",
|
||||||
|
"type": "int",
|
||||||
|
"min": 1,
|
||||||
|
"max": 100,
|
||||||
|
"hidden": True,
|
||||||
|
"help": "同一账户在北京时间同一自然日内发起比价达到该次数时告警。",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
"""美团、京东 CPS 订单每日自动对账任务。
|
||||||
|
|
||||||
|
每天北京时间 `CPS_AUTO_RECONCILE_RUN_HOUR`(默认 05:00)后执行一次,按更新时间
|
||||||
|
回拉最近若干天订单并复用 admin CPS 仓储层的幂等 upsert。手动对账接口保持独立、不受影响。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.admin.repositories import cps as cps_repo
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.rewards import CN_TZ
|
||||||
|
from app.db.session import SessionLocal, engine
|
||||||
|
|
||||||
|
logger = logging.getLogger("shagua.cps_reconcile")
|
||||||
|
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "cps_reconcile.lock"
|
||||||
|
|
||||||
|
|
||||||
|
def _cn_now() -> datetime:
|
||||||
|
return datetime.now(CN_TZ)
|
||||||
|
|
||||||
|
|
||||||
|
def _new_run_id(now: datetime) -> str:
|
||||||
|
return f"{now:%Y%m%d-%H%M%S}-{uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _next_run_at(now: datetime, run_hour: int) -> datetime:
|
||||||
|
scheduled = now.replace(hour=run_hour, minute=0, second=0, microsecond=0)
|
||||||
|
return scheduled if now < scheduled else scheduled + timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _trigger_for_run(worker_started_at: datetime, now: datetime, run_hour: int) -> str:
|
||||||
|
if worker_started_at.date() == now.date() and worker_started_at.hour >= run_hour:
|
||||||
|
return "startup_catchup"
|
||||||
|
return "scheduled"
|
||||||
|
|
||||||
|
|
||||||
|
def _touch_lock() -> None:
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
os.utime(_LOCK_PATH, None)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
|
||||||
|
"""同机多进程保护:同一时间只允许一个 CPS 自动对账 worker 运行。"""
|
||||||
|
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd: int | None = None
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
|
except FileExistsError:
|
||||||
|
try:
|
||||||
|
age = time.time() - _LOCK_PATH.stat().st_mtime
|
||||||
|
except FileNotFoundError:
|
||||||
|
age = stale_after_sec + 1
|
||||||
|
if age > stale_after_sec:
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
_LOCK_PATH.unlink()
|
||||||
|
try:
|
||||||
|
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
|
except FileExistsError:
|
||||||
|
fd = None
|
||||||
|
|
||||||
|
if fd is None:
|
||||||
|
yield False
|
||||||
|
return
|
||||||
|
|
||||||
|
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
|
||||||
|
yield True
|
||||||
|
finally:
|
||||||
|
if fd is not None:
|
||||||
|
os.close(fd)
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
_LOCK_PATH.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_result() -> dict:
|
||||||
|
return {
|
||||||
|
"fetched": 0,
|
||||||
|
"inserted": 0,
|
||||||
|
"updated": 0,
|
||||||
|
"pages": 0,
|
||||||
|
"api_requests": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_log_fields(platform: str, result: dict) -> dict:
|
||||||
|
fields = {
|
||||||
|
"platform": platform,
|
||||||
|
"platform_status": result["status"],
|
||||||
|
"duration_ms": result["duration_ms"],
|
||||||
|
}
|
||||||
|
for key in ("fetched", "inserted", "updated", "pages", "api_requests", "windows"):
|
||||||
|
if key in result:
|
||||||
|
fields[key] = result[key]
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def _run_platform(
|
||||||
|
*,
|
||||||
|
platform: str,
|
||||||
|
query_time_type: int,
|
||||||
|
common: dict,
|
||||||
|
reconcile: Callable[[], dict],
|
||||||
|
) -> tuple[dict, str | None]:
|
||||||
|
started = time.perf_counter()
|
||||||
|
logger.info(
|
||||||
|
"CPS auto reconcile platform started run_id=%s platform=%s",
|
||||||
|
common["run_id"],
|
||||||
|
platform,
|
||||||
|
extra={
|
||||||
|
**common,
|
||||||
|
"event": "cps_reconcile.platform_started",
|
||||||
|
"platform": platform,
|
||||||
|
"query_time_type": query_time_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
raw_result = reconcile()
|
||||||
|
except Exception as exc: # noqa: BLE001 - 单平台失败不能阻断另一平台
|
||||||
|
result = {
|
||||||
|
"status": "failed",
|
||||||
|
"duration_ms": round((time.perf_counter() - started) * 1000, 3),
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
"error_summary": str(exc)[:500],
|
||||||
|
}
|
||||||
|
logger.exception(
|
||||||
|
"CPS auto reconcile platform failed run_id=%s platform=%s error_type=%s",
|
||||||
|
common["run_id"],
|
||||||
|
platform,
|
||||||
|
type(exc).__name__,
|
||||||
|
extra={
|
||||||
|
**common,
|
||||||
|
"event": "cps_reconcile.platform_failed",
|
||||||
|
**_platform_log_fields(platform, result),
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
"error_summary": str(exc)[:500],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result, str(exc)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
**raw_result,
|
||||||
|
"status": "success",
|
||||||
|
"duration_ms": round((time.perf_counter() - started) * 1000, 3),
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"CPS auto reconcile platform completed run_id=%s platform=%s fetched=%s inserted=%s updated=%s",
|
||||||
|
common["run_id"],
|
||||||
|
platform,
|
||||||
|
result.get("fetched", 0),
|
||||||
|
result.get("inserted", 0),
|
||||||
|
result.get("updated", 0),
|
||||||
|
extra={
|
||||||
|
**common,
|
||||||
|
"event": "cps_reconcile.platform_completed",
|
||||||
|
**_platform_log_fields(platform, result),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result, None
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_platform(platform: str, common: dict) -> dict:
|
||||||
|
result = {
|
||||||
|
**_empty_result(),
|
||||||
|
"status": "skipped",
|
||||||
|
"skipped": "not_configured",
|
||||||
|
"duration_ms": 0.0,
|
||||||
|
}
|
||||||
|
logger.warning(
|
||||||
|
"CPS auto reconcile platform skipped run_id=%s platform=%s reason=not_configured",
|
||||||
|
common["run_id"],
|
||||||
|
platform,
|
||||||
|
extra={
|
||||||
|
**common,
|
||||||
|
"event": "cps_reconcile.platform_skipped",
|
||||||
|
**_platform_log_fields(platform, result),
|
||||||
|
"skip_reason": "not_configured",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_once(
|
||||||
|
now: datetime | None = None,
|
||||||
|
*,
|
||||||
|
run_id: str | None = None,
|
||||||
|
trigger: str = "scheduled",
|
||||||
|
) -> dict:
|
||||||
|
"""独立拉取美团和京东;单平台异常只记日志,不影响另一平台。"""
|
||||||
|
end = (now or _cn_now()).astimezone(CN_TZ)
|
||||||
|
run_id = run_id or _new_run_id(end)
|
||||||
|
lookback_days = max(1, int(settings.CPS_AUTO_RECONCILE_LOOKBACK_DAYS))
|
||||||
|
start = end - timedelta(days=lookback_days)
|
||||||
|
task_started = time.perf_counter()
|
||||||
|
common = {
|
||||||
|
"run_id": run_id,
|
||||||
|
"trigger": trigger,
|
||||||
|
"scheduled_date": end.date().isoformat(),
|
||||||
|
"app_env": settings.APP_ENV,
|
||||||
|
"db_dialect": engine.dialect.name,
|
||||||
|
"hostname": socket.gethostname(),
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"window_start": start.isoformat(),
|
||||||
|
"window_end": end.isoformat(),
|
||||||
|
"lookback_days": lookback_days,
|
||||||
|
}
|
||||||
|
result = {
|
||||||
|
"run_id": run_id,
|
||||||
|
"trigger": trigger,
|
||||||
|
"window_start": start.isoformat(),
|
||||||
|
"window_end": end.isoformat(),
|
||||||
|
"meituan": None,
|
||||||
|
"jd": None,
|
||||||
|
"errors": {},
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"CPS auto reconcile started run_id=%s trigger=%s window=%s..%s",
|
||||||
|
run_id,
|
||||||
|
trigger,
|
||||||
|
result["window_start"],
|
||||||
|
result["window_end"],
|
||||||
|
extra={**common, "event": "cps_reconcile.started"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.mt_cps_configured:
|
||||||
|
def reconcile_meituan() -> dict:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
return cps_repo.reconcile_orders(
|
||||||
|
db,
|
||||||
|
start_time=int(start.timestamp()),
|
||||||
|
end_time=int(end.timestamp()),
|
||||||
|
query_time_type=2,
|
||||||
|
audit_context=common,
|
||||||
|
)
|
||||||
|
|
||||||
|
result["meituan"], error = _run_platform(
|
||||||
|
platform="meituan",
|
||||||
|
query_time_type=2,
|
||||||
|
common=common,
|
||||||
|
reconcile=reconcile_meituan,
|
||||||
|
)
|
||||||
|
if error is not None:
|
||||||
|
result["errors"]["meituan"] = error
|
||||||
|
else:
|
||||||
|
result["meituan"] = _skip_platform("meituan", common)
|
||||||
|
|
||||||
|
_touch_lock()
|
||||||
|
if settings.jd_union_configured:
|
||||||
|
def reconcile_jd() -> dict:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
return cps_repo.reconcile_jd_orders(
|
||||||
|
db,
|
||||||
|
start_time=start,
|
||||||
|
end_time=end,
|
||||||
|
query_time_type=3,
|
||||||
|
audit_context=common,
|
||||||
|
)
|
||||||
|
|
||||||
|
result["jd"], error = _run_platform(
|
||||||
|
platform="jd",
|
||||||
|
query_time_type=3,
|
||||||
|
common=common,
|
||||||
|
reconcile=reconcile_jd,
|
||||||
|
)
|
||||||
|
if error is not None:
|
||||||
|
result["errors"]["jd"] = error
|
||||||
|
else:
|
||||||
|
result["jd"] = _skip_platform("jd", common)
|
||||||
|
|
||||||
|
successes = sum(
|
||||||
|
platform_result.get("status") == "success"
|
||||||
|
for platform_result in (result["meituan"], result["jd"])
|
||||||
|
)
|
||||||
|
if result["errors"]:
|
||||||
|
task_status = "partial_success" if successes else "failed"
|
||||||
|
else:
|
||||||
|
task_status = "success"
|
||||||
|
completed_at = _cn_now()
|
||||||
|
duration_ms = round((time.perf_counter() - task_started) * 1000, 3)
|
||||||
|
next_run_at = _next_run_at(
|
||||||
|
completed_at,
|
||||||
|
min(max(int(settings.CPS_AUTO_RECONCILE_RUN_HOUR), 0), 23),
|
||||||
|
).isoformat()
|
||||||
|
result.update({
|
||||||
|
"status": task_status,
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
"manual_retry_required": bool(result["errors"]),
|
||||||
|
"next_run_at": next_run_at,
|
||||||
|
})
|
||||||
|
completion_fields = {
|
||||||
|
**common,
|
||||||
|
"event": "cps_reconcile.completed",
|
||||||
|
"task_status": task_status,
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
"manual_retry_required": result["manual_retry_required"],
|
||||||
|
"failed_platforms": sorted(result["errors"]),
|
||||||
|
"next_run_at": next_run_at,
|
||||||
|
"meituan_result": result["meituan"],
|
||||||
|
"jd_result": result["jd"],
|
||||||
|
}
|
||||||
|
log_method = logger.info if task_status == "success" else logger.warning
|
||||||
|
log_method(
|
||||||
|
"CPS auto reconcile completed run_id=%s status=%s duration_ms=%s failed_platforms=%s next_run_at=%s",
|
||||||
|
run_id,
|
||||||
|
task_status,
|
||||||
|
duration_ms,
|
||||||
|
",".join(sorted(result["errors"])) or "-",
|
||||||
|
next_run_at,
|
||||||
|
extra=completion_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _should_run(last_run: date | None, now: datetime, run_hour: int) -> bool:
|
||||||
|
return last_run != now.date() and now.hour >= run_hour
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_loop() -> None:
|
||||||
|
interval = max(30, int(settings.CPS_AUTO_RECONCILE_CHECK_INTERVAL_SEC))
|
||||||
|
run_hour = min(max(int(settings.CPS_AUTO_RECONCILE_RUN_HOUR), 0), 23)
|
||||||
|
lock_stale_after = max(interval * 3, 1800)
|
||||||
|
with _single_instance_lock(lock_stale_after) as lock_acquired:
|
||||||
|
if not lock_acquired:
|
||||||
|
logger.warning(
|
||||||
|
"CPS auto reconcile worker skipped: another worker owns lock",
|
||||||
|
extra={
|
||||||
|
"event": "cps_reconcile.worker_skipped",
|
||||||
|
"skip_reason": "lock_not_acquired",
|
||||||
|
"pid": os.getpid(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await _run_locked_loop(interval, run_hour)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_locked_loop(interval: int, run_hour: int) -> None:
|
||||||
|
worker_started_at = _cn_now()
|
||||||
|
logger.info(
|
||||||
|
"CPS auto reconcile worker started run_hour=%s interval=%ss lookback_days=%s",
|
||||||
|
run_hour,
|
||||||
|
interval,
|
||||||
|
settings.CPS_AUTO_RECONCILE_LOOKBACK_DAYS,
|
||||||
|
extra={
|
||||||
|
"event": "cps_reconcile.worker_started",
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"hostname": socket.gethostname(),
|
||||||
|
"app_env": settings.APP_ENV,
|
||||||
|
"db_dialect": engine.dialect.name,
|
||||||
|
"run_hour": run_hour,
|
||||||
|
"interval_sec": interval,
|
||||||
|
"lookback_days": settings.CPS_AUTO_RECONCILE_LOOKBACK_DAYS,
|
||||||
|
"next_run_at": _next_run_at(worker_started_at, run_hour).isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
last_run: date | None = None
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
run_id: str | None = None
|
||||||
|
try:
|
||||||
|
_touch_lock()
|
||||||
|
now = _cn_now()
|
||||||
|
if _should_run(last_run, now, run_hour):
|
||||||
|
run_id = _new_run_id(now)
|
||||||
|
trigger = _trigger_for_run(worker_started_at, now, run_hour)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_reconcile_once,
|
||||||
|
now,
|
||||||
|
run_id=run_id,
|
||||||
|
trigger=trigger,
|
||||||
|
)
|
||||||
|
last_run = now.date()
|
||||||
|
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
|
||||||
|
logger.exception(
|
||||||
|
"CPS auto reconcile unexpected worker error",
|
||||||
|
extra={
|
||||||
|
"event": "cps_reconcile.unexpected_failed",
|
||||||
|
"run_id": run_id or "unassigned",
|
||||||
|
"pid": os.getpid(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info(
|
||||||
|
"CPS auto reconcile worker stopped",
|
||||||
|
extra={"event": "cps_reconcile.worker_stopped", "pid": os.getpid()},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def start_cps_reconcile_worker() -> asyncio.Task | None:
|
||||||
|
if not settings.CPS_AUTO_RECONCILE_ENABLED:
|
||||||
|
logger.info(
|
||||||
|
"CPS auto reconcile disabled",
|
||||||
|
extra={
|
||||||
|
"event": "cps_reconcile.worker_skipped",
|
||||||
|
"skip_reason": "disabled",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if not settings.mt_cps_configured and not settings.jd_union_configured:
|
||||||
|
logger.warning(
|
||||||
|
"CPS auto reconcile not started: Meituan and JD credentials are missing",
|
||||||
|
extra={
|
||||||
|
"event": "cps_reconcile.worker_skipped",
|
||||||
|
"skip_reason": "all_platform_credentials_missing",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return asyncio.create_task(_run_loop(), name="cps-auto-reconcile")
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_cps_reconcile_worker(task: asyncio.Task | None) -> None:
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""无障碍保护存活监控后台任务。
|
"""无障碍保护存活监控后台任务。
|
||||||
|
|
||||||
周期扫描「曾经保护过、当前 alive、心跳超时」的设备 = App 被彻底杀掉/无障碍已停(心跳断了),
|
周期扫描「曾经保护过、当前 alive、心跳超时」的设备 = App 被彻底杀掉/无障碍已停(心跳断了),
|
||||||
**命中即在服务器终端打印告警**(本期先不接推送,工程量大,用终端打印代替真实通知);并把状态机
|
**命中即在服务器终端打印告警并尝试厂商直推**;并把状态机
|
||||||
推进到 notified 防每轮重复打印(心跳恢复时由 repositories.device.touch_heartbeat 重置回 alive)。
|
推进到 notified 防每轮重复打印(心跳恢复时由 repositories.device.touch_heartbeat 重置回 alive)。
|
||||||
结构仿 withdraw_reconcile_worker(单实例锁 + asyncio 轮询 + 优雅退出)。
|
结构仿 withdraw_reconcile_worker(单实例锁 + asyncio 轮询 + 优雅退出)。
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.session import SessionLocal
|
from app.db.session import SessionLocal
|
||||||
|
from app.integrations import vendor_push
|
||||||
from app.repositories import device as device_repo
|
from app.repositories import device as device_repo
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.heartbeat_monitor")
|
logger = logging.getLogger("shagua.heartbeat_monitor")
|
||||||
@@ -71,32 +72,66 @@ def _silent_seconds(last: datetime | None) -> int | None:
|
|||||||
"""距上次心跳的秒数(兼容 sqlite 取回的 naive datetime)。"""
|
"""距上次心跳的秒数(兼容 sqlite 取回的 naive datetime)。"""
|
||||||
if last is None:
|
if last is None:
|
||||||
return 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())
|
return int((ref - last).total_seconds())
|
||||||
|
|
||||||
|
|
||||||
def _scan_once(timeout_minutes: int) -> dict:
|
def _scan_once(timeout_minutes: int) -> dict:
|
||||||
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备,在**服务器终端打印**告警代替真实推送。
|
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备并召回。
|
||||||
|
|
||||||
本期不接推送(极光/厂商通道工程量大),只做服务端掉线检测:命中即 logger.warning 打印到终端,
|
有 push_vendor + push_token 时先发厂商直推,无 token 或推送失败时仍置
|
||||||
并把状态机推进到 notified 防每轮重复打印(心跳恢复时 touch_heartbeat 会重置回 alive)。
|
kill_alert_pending,客户端下次进 App 继续走后置提醒兜底。
|
||||||
"""
|
"""
|
||||||
notified = 0
|
notified = 0
|
||||||
|
pushed = 0
|
||||||
|
push_failed = 0
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
overdue = device_repo.list_overdue(db, timeout_minutes=timeout_minutes)
|
overdue = device_repo.list_overdue(db, timeout_minutes=timeout_minutes)
|
||||||
for device in overdue:
|
for device in overdue:
|
||||||
silent = _silent_seconds(device.last_heartbeat_at)
|
silent = _silent_seconds(device.last_heartbeat_at)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"[掉线检测] user_id=%s device_id=%s 已 %s 秒无心跳(阈值 %d 分钟)"
|
"[掉线检测] user_id=%s device_id=%s 已 %s 秒无心跳(阈值 %d 分钟)"
|
||||||
" → 判定 App 已被杀/无障碍已停。【已置 kill_alert_pending: 用户下次进 App 将弹「开启自启动」引导(后置检测);推送本期未接】",
|
" → 判定 App 已被杀/无障碍已停。",
|
||||||
device.user_id,
|
device.user_id,
|
||||||
device.device_id,
|
device.device_id,
|
||||||
silent if silent is not None else "?",
|
silent if silent is not None else "?",
|
||||||
timeout_minutes,
|
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)
|
device_repo.mark_notified(db, device_id_pk=device.id)
|
||||||
notified += 1
|
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:
|
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
|
||||||
+51
-2
@@ -22,13 +22,13 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
# 请求级 trace_id:入口(如 compare.py 透传壳)set 之后, 本请求上下文(含 run_in_threadpool
|
# 请求级 trace_id:入口(如 compare.py 透传壳)set 之后, 本请求上下文(含 run_in_threadpool
|
||||||
# 拷贝出去的线程)内所有日志自动带上。默认空串 = 非请求上下文(启动/后台 worker)。
|
# 拷贝出去的线程)内所有日志自动带上。默认空串 = 非请求上下文(启动/后台 worker)。
|
||||||
trace_id_ctx: ContextVar[str] = ContextVar("trace_id", default="")
|
trace_id_ctx: ContextVar[str] = ContextVar("trace_id", default="")
|
||||||
@@ -91,6 +91,55 @@ class TextFormatter(logging.Formatter):
|
|||||||
return f"{base} trace={tid}" if tid else base
|
return f"{base} trace={tid}" if tid else base
|
||||||
|
|
||||||
|
|
||||||
|
class SafeRotatingFileHandler(RotatingFileHandler):
|
||||||
|
"""Windows 下不会被外部句柄卡死的 RotatingFileHandler。
|
||||||
|
|
||||||
|
stdlib 轮转靠 rename 活动文件(app-server.log → .1);Windows 只要有别的句柄(IDE 索引、
|
||||||
|
app.admin.main 第二进程、残留 --reload worker、杀软扫描)开着它, rename 就 WinError 32,
|
||||||
|
轮转永久卡死——文件停在 maxBytes、之后每条日志被丢。这里 Windows 改用 copytruncate:把活动
|
||||||
|
文件拷进备份、再通过自己的句柄原地清空, 从不 rename 活动文件, 故外部句柄开着也能转。
|
||||||
|
POSIX(生产 Linux)rename 打开中的文件本就合法, 保留 stdlib 的原子轮转不变。
|
||||||
|
|
||||||
|
代价:copytruncate 在“拷贝→清空”极窄窗口内并发写可能丢几行(仅跨进程;同进程 emit 有
|
||||||
|
handler 锁串行, 无此问题)。对本地开发日志可接受。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def doRollover(self) -> None:
|
||||||
|
if os.name != "nt":
|
||||||
|
super().doRollover()
|
||||||
|
return
|
||||||
|
if self.stream is None:
|
||||||
|
self.stream = self._open()
|
||||||
|
else:
|
||||||
|
self.stream.flush()
|
||||||
|
try:
|
||||||
|
self._copytruncate_backups()
|
||||||
|
except OSError:
|
||||||
|
# 备份腾挪是尽力而为:任一备份被占用也绝不能挡住下面的清空, 否则活动文件继续涨、
|
||||||
|
# 轮转又卡死——那就白改了。
|
||||||
|
pass
|
||||||
|
# 通过自己独占的句柄原地清空:不涉及 rename, 外部只读句柄不受影响。
|
||||||
|
self.stream.seek(0)
|
||||||
|
self.stream.truncate()
|
||||||
|
self.stream.flush()
|
||||||
|
|
||||||
|
def _copytruncate_backups(self) -> None:
|
||||||
|
"""把 .N-1→.N 逐级腾挪, 再把活动文件拷到 .1(不动活动文件本身)。"""
|
||||||
|
if self.backupCount <= 0:
|
||||||
|
return
|
||||||
|
for i in range(self.backupCount - 1, 0, -1):
|
||||||
|
sfn = self.rotation_filename(f"{self.baseFilename}.{i}")
|
||||||
|
dfn = self.rotation_filename(f"{self.baseFilename}.{i + 1}")
|
||||||
|
if os.path.exists(sfn):
|
||||||
|
if os.path.exists(dfn):
|
||||||
|
os.remove(dfn)
|
||||||
|
os.replace(sfn, dfn)
|
||||||
|
dfn = self.rotation_filename(f"{self.baseFilename}.1")
|
||||||
|
if os.path.exists(dfn):
|
||||||
|
os.remove(dfn)
|
||||||
|
shutil.copyfile(self.baseFilename, dfn)
|
||||||
|
|
||||||
|
|
||||||
_CONFIGURED = False
|
_CONFIGURED = False
|
||||||
|
|
||||||
|
|
||||||
@@ -126,7 +175,7 @@ def setup_logging(debug: bool = False) -> None:
|
|||||||
Path(os.getenv("LOG_DIR", "logs")) / "app-server.log"
|
Path(os.getenv("LOG_DIR", "logs")) / "app-server.log"
|
||||||
)
|
)
|
||||||
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
||||||
file_handler = RotatingFileHandler(
|
file_handler = SafeRotatingFileHandler(
|
||||||
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8",
|
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8",
|
||||||
)
|
)
|
||||||
file_handler.setFormatter(JsonFormatter(service))
|
file_handler.setFormatter(JsonFormatter(service))
|
||||||
|
|||||||
@@ -77,6 +77,35 @@ def save_feedback_qr(data: bytes) -> str:
|
|||||||
return _save_named("feedback_qr", "qr", data)
|
return _save_named("feedback_qr", "qr", data)
|
||||||
|
|
||||||
|
|
||||||
|
def _sniff_video_ext(data: bytes) -> str | None:
|
||||||
|
"""按魔数判定视频类型,返回扩展名;非支持类型返回 None。
|
||||||
|
|
||||||
|
只认 MP4 家族(ISO BMFF):`....ftyp` 在偏移 4。Android ExoPlayer 与浏览器 <video>
|
||||||
|
都稳吃 H.264/AAC 的 mp4;放开 mkv/avi 只会让端上放不出来,不如在入口就挡掉。
|
||||||
|
"""
|
||||||
|
if len(data) >= 12 and data[4:8] == b"ftyp":
|
||||||
|
return ".mp4"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_guide_video(data: bytes) -> str:
|
||||||
|
"""保存新手引导视频(运营后台上传的运营素材),返回相对 URL(`/media/guide_video/<file>`)。
|
||||||
|
|
||||||
|
与图片分开一套校验:体积上限走 [settings.GUIDE_VIDEO_MAX_BYTES],类型只认 MP4。
|
||||||
|
"""
|
||||||
|
if not data:
|
||||||
|
raise MediaError("空文件")
|
||||||
|
limit = settings.GUIDE_VIDEO_MAX_BYTES
|
||||||
|
if len(data) > limit:
|
||||||
|
raise MediaError(f"视频过大(上限 {limit // (1024 * 1024)}MB)")
|
||||||
|
if _sniff_video_ext(data) is None:
|
||||||
|
raise MediaError("仅支持 MP4 视频(H.264 编码)")
|
||||||
|
|
||||||
|
fname = f"guide_{secrets.token_hex(8)}.mp4"
|
||||||
|
(_media_dir("guide_video") / fname).write_bytes(data)
|
||||||
|
return f"{settings.MEDIA_URL_PREFIX}/guide_video/{fname}"
|
||||||
|
|
||||||
|
|
||||||
def save_cps_image(admin_id: int, data: bytes) -> str:
|
def save_cps_image(admin_id: int, data: bytes) -> str:
|
||||||
"""保存 CPS 活动落地页图,返回相对 URL(`/media/cps/<file>`)。admin_id 入文件名便于追溯。"""
|
"""保存 CPS 活动落地页图,返回相对 URL(`/media/cps/<file>`)。admin_id 入文件名便于追溯。"""
|
||||||
return _save_image("cps", admin_id, data)
|
return _save_image("cps", admin_id, data)
|
||||||
@@ -116,3 +145,8 @@ def delete_avatar(url: str | None) -> None:
|
|||||||
def delete_feedback_qr(url: str | None) -> None:
|
def delete_feedback_qr(url: str | None) -> None:
|
||||||
"""删除本服务托管的旧反馈页二维码文件;外部 URL 或空值不处理。"""
|
"""删除本服务托管的旧反馈页二维码文件;外部 URL 或空值不处理。"""
|
||||||
_delete_managed("feedback_qr", url)
|
_delete_managed("feedback_qr", url)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_guide_video(url: str | None) -> None:
|
||||||
|
"""删除本服务托管的旧新手引导视频文件;外部 URL 或空值不处理。"""
|
||||||
|
_delete_managed("guide_video", url)
|
||||||
|
|||||||
@@ -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 threading
|
||||||
import time
|
import time
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
from fastapi import HTTPException, Request, status
|
from fastapi import HTTPException, Request, status
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
# key -> (window_start_ts, count)
|
# key -> (window_start_ts, count, window_sec)
|
||||||
_buckets: dict[str, tuple[float, int]] = {}
|
# 存每个 key 自己的 window_sec:_buckets 混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key,
|
||||||
|
# GC 必须按各 key 自己的窗口判过期(见 [_purge_expired]),否则短窗口调用触发的 GC 会误删长窗口 key。
|
||||||
|
_buckets: dict[str, tuple[float, int, float]] = {}
|
||||||
_lock = threading.Lock()
|
_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:
|
def _hit(key: str, limit: int, window_sec: float) -> bool:
|
||||||
"""记一次访问。返回 True=放行,False=超限。"""
|
"""记一次访问。返回 True=放行,False=超限。"""
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
with _lock:
|
with _lock:
|
||||||
start, count = _buckets.get(key, (now, 0))
|
start, count, _ = _buckets.get(key, (now, 0, window_sec))
|
||||||
if now - start >= window_sec: # 窗口过期,重置
|
if now - start >= window_sec: # 窗口过期,重置
|
||||||
start, count = now, 0
|
start, count = now, 0
|
||||||
count += 1
|
count += 1
|
||||||
_buckets[key] = (start, count)
|
_buckets[key] = (start, count, window_sec)
|
||||||
# 顺手清理过期 key,防内存无限涨(低频访问足够)
|
_purge_expired(now) # 顺手清过期 key(按各自窗口),防内存无限涨
|
||||||
if len(_buckets) > 10000:
|
|
||||||
for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]:
|
|
||||||
_buckets.pop(k, None)
|
|
||||||
return count <= limit
|
return count <= limit
|
||||||
|
|
||||||
|
|
||||||
@@ -83,3 +95,76 @@ def enforce_rate_limit(
|
|||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
detail=detail,
|
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 __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
# 业务时区:签到的"今天"按北京时间算,不能用 UTC。
|
# 业务时区:签到的"今天"按北京时间算,不能用 UTC。
|
||||||
# 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。
|
# 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。
|
||||||
@@ -52,6 +53,30 @@ WITHDRAW_MIN_CENTS: int = 10
|
|||||||
WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
|
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 去重)=====
|
# ===== 一次性任务(领一次,user_task 去重)=====
|
||||||
TASK_ENABLE_NOTIFICATION = "enable_notification"
|
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))
|
return max(0, round(yuan * COIN_PER_YUAN))
|
||||||
|
|
||||||
|
|
||||||
# 签到看广告膨胀:S2S 固定补发(原型 2026-06 由 2000 提到 3000,对应 CTA「看广告最高膨胀至3000金币」)。
|
# 签到膨胀(SIGNIN_BOOST_COIN,固定 3000)已于 2026-07 下线:它与广告实际收益脱钩,产品确认
|
||||||
SIGNIN_BOOST_COIN: int = 3000
|
# 非设计内口径。签到弹窗的「看广告膨胀」现与福利页看视频同走 calculate_ad_reward_coin。
|
||||||
|
|
||||||
|
|
||||||
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
|
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
|
||||||
@@ -309,5 +334,3 @@ def get_ad_cooldown_sec(db) -> int: # noqa: ANN001
|
|||||||
return int(_cfg(db, "ad_cooldown_sec"))
|
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 后台账号用)=====================
|
# ===================== 密码 hash(admin 后台账号用)=====================
|
||||||
# 用户侧是手机号+验证码登录,不存密码;仅 admin 账号用 username+password 登录。
|
# 用户侧是手机号+验证码登录,不存密码;仅 admin 账号用 username+password 登录。
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -25,6 +26,44 @@ class MeituanCpsError(Exception):
|
|||||||
"""美团 CPS 接口调用失败。"""
|
"""美团 CPS 接口调用失败。"""
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────── 共享 httpx.Client(连接池 + keep-alive) ──────────────────────
|
||||||
|
# 原实现每次 _call 都 `with httpx.Client(...)` 新建再关掉,等于每发一次请求就:
|
||||||
|
# ① 重建一套 SSL 上下文(加载 certifi CA,实测几百 ms) ② 重做一次 TLS 握手 ③ 用完即弃连接。
|
||||||
|
# 首页 feed 一次翻页要向美团发好几次请求(外卖/到店两路,「距离最近」还要续页),这份固定开销被成倍放大,
|
||||||
|
# 是「滑到底部加载很慢」的一大块。改成进程内单例:握手一次、后续走 keep-alive 复用。
|
||||||
|
# httpx.Client 本身线程安全,可被 feed 的 ThreadPoolExecutor 两条抓取线程共用。
|
||||||
|
_client: httpx.Client | None = None
|
||||||
|
_client_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_client() -> httpx.Client:
|
||||||
|
"""取美团 CPS 共享 client(幂等,懒建)。lifespan 启动时预热,把建 SSL 的成本摊到启动。"""
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
with _client_lock:
|
||||||
|
if _client is None:
|
||||||
|
# 走 MT_CPS_PROXY(本机开发直连会 SSL EOF,必须走代理;线上留空=直连)。
|
||||||
|
# trust_env=False:不读进程环境的 HTTP_PROXY,只认配置,避免被错误/失效代理误导。
|
||||||
|
_client = httpx.Client(
|
||||||
|
proxy=settings.MT_CPS_PROXY or None,
|
||||||
|
trust_env=False,
|
||||||
|
timeout=settings.MT_CPS_TIMEOUT_SEC,
|
||||||
|
# 池子留足:feed 每个请求会起 2 条抓取线程,并发用户多时别在池上排队
|
||||||
|
# (排满会等到 MT_CPS_TIMEOUT_SEC 抛 PoolTimeout,表现成"又慢又降级")。
|
||||||
|
limits=httpx.Limits(max_keepalive_connections=16, max_connections=32),
|
||||||
|
)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def close_client() -> None:
|
||||||
|
"""lifespan 关停时调,优雅关连接池。"""
|
||||||
|
global _client
|
||||||
|
with _client_lock:
|
||||||
|
if _client is not None:
|
||||||
|
_client.close()
|
||||||
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
def _content_md5(body: bytes) -> str:
|
def _content_md5(body: bytes) -> str:
|
||||||
return base64.b64encode(hashlib.md5(body).digest()).decode()
|
return base64.b64encode(hashlib.md5(body).digest()).decode()
|
||||||
|
|
||||||
@@ -62,12 +101,8 @@ def _call(path: str, body_obj: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
url = f"{settings.MT_CPS_HOST}{path}"
|
url = f"{settings.MT_CPS_HOST}{path}"
|
||||||
# 美团调用走 MT_CPS_PROXY(本机开发直连会 SSL EOF,必须走代理;线上留空=直连)。
|
|
||||||
# trust_env=False:不读进程环境的 HTTP_PROXY,只认配置,避免被错误/失效代理误导。
|
|
||||||
proxy = settings.MT_CPS_PROXY or None
|
|
||||||
try:
|
try:
|
||||||
with httpx.Client(proxy=proxy, trust_env=False, timeout=settings.MT_CPS_TIMEOUT_SEC) as client:
|
resp = get_client().post(url, content=body, headers=headers)
|
||||||
resp = client.post(url, content=body, headers=headers)
|
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
logger.exception("[MT] http error calling %s", url)
|
logger.exception("[MT] http error calling %s", url)
|
||||||
raise MeituanCpsError(f"meituan http error: {e}") from e
|
raise MeituanCpsError(f"meituan http error: {e}") from e
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user